DeviceStatus.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. // LoopFollow
  2. // DeviceStatus.swift
  3. import Foundation
  4. import HealthKit
  5. import SwiftUI
  6. extension MainViewController {
  7. func webLoadNSDeviceStatus() {
  8. let parameters = ["count": "1"]
  9. NightscoutUtils.executeDynamicRequest(eventType: .deviceStatus, parameters: parameters) { result in
  10. switch result {
  11. case let .success(json):
  12. if let jsonDeviceStatus = json as? [[String: AnyObject]] {
  13. DispatchQueue.main.async {
  14. self.updateDeviceStatusDisplay(jsonDeviceStatus: jsonDeviceStatus)
  15. Storage.shared.lastLoopingChecked.value = Date()
  16. }
  17. } else {
  18. self.handleDeviceStatusError()
  19. }
  20. case .failure:
  21. self.handleDeviceStatusError()
  22. }
  23. }
  24. }
  25. private func handleDeviceStatusError() {
  26. LogManager.shared.log(category: .deviceStatus, message: "Device status fetch failed!", limitIdentifier: "Device status fetch failed!")
  27. DispatchQueue.main.async {
  28. Storage.shared.lastLoopingChecked.value = Date()
  29. TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date().addingTimeInterval(10))
  30. self.evaluateNotLooping()
  31. }
  32. }
  33. func evaluateNotLooping() {
  34. guard let lastLoopTime = Observable.shared.alertLastLoopTime.value, lastLoopTime > 0 else {
  35. return
  36. }
  37. let now = TimeInterval(Date().timeIntervalSince1970)
  38. let nonLoopingTimeThreshold: TimeInterval = 15 * 60
  39. if IsNightscoutEnabled(), (now - lastLoopTime) >= nonLoopingTimeThreshold, lastLoopTime > 0 {
  40. IsNotLooping = true
  41. Observable.shared.isNotLooping.value = true
  42. Observable.shared.loopStatusText.value = "⚠️ Not Looping!"
  43. Observable.shared.loopStatusColor.value = .yellow
  44. #if !targetEnvironment(macCatalyst)
  45. LiveActivityManager.shared.refreshFromCurrentState(reason: "notLooping")
  46. #endif
  47. } else {
  48. IsNotLooping = false
  49. Observable.shared.isNotLooping.value = false
  50. Observable.shared.loopStatusColor.value = .primary
  51. #if !targetEnvironment(macCatalyst)
  52. LiveActivityManager.shared.refreshFromCurrentState(reason: "loopingResumed")
  53. #endif
  54. }
  55. }
  56. // NS Device Status Response Processor
  57. func updateDeviceStatusDisplay(jsonDeviceStatus: [[String: AnyObject]]) {
  58. let previousIOBText = Observable.shared.iobText.value
  59. let previousDeviceWasLoop = Storage.shared.device.value == "Loop"
  60. infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus, .tdd])
  61. // For Loop, clear the current override here - For Trio, it is handled using treatments
  62. if Storage.shared.device.value == "Loop" {
  63. infoManager.clearInfoData(types: [.override])
  64. }
  65. if jsonDeviceStatus.count == 0 {
  66. LogManager.shared.log(category: .deviceStatus, message: "Device status is empty")
  67. TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date().addingTimeInterval(5 * 60))
  68. return
  69. }
  70. // Process the current data first
  71. let lastDeviceStatus = jsonDeviceStatus[0] as [String: AnyObject]?
  72. // pump and uploader
  73. let formatter = ISO8601DateFormatter()
  74. formatter.formatOptions = [.withFullDate,
  75. .withTime,
  76. .withDashSeparatorInDate,
  77. .withColonSeparatorInTime]
  78. Observable.shared.previousAlertLastLoopTime.value = Observable.shared.alertLastLoopTime.value
  79. if let lastPumpRecord = lastDeviceStatus?["pump"] as! [String: AnyObject]? {
  80. if let bolusIncrement = lastPumpRecord["bolusIncrement"] as? Double, bolusIncrement > 0 {
  81. Storage.shared.bolusIncrement.value = HKQuantity(unit: .internationalUnit(), doubleValue: bolusIncrement)
  82. Storage.shared.bolusIncrementDetected.value = true
  83. } else if let model = lastPumpRecord["model"] as? String, model == "Dash" {
  84. Storage.shared.bolusIncrement.value = HKQuantity(unit: .internationalUnit(), doubleValue: 0.05)
  85. Storage.shared.bolusIncrementDetected.value = true
  86. } else {
  87. Storage.shared.bolusIncrementDetected.value = false
  88. }
  89. if let clockString = lastPumpRecord["clock"] as? String,
  90. let lastPumpTime = formatter.date(from: clockString)?.timeIntervalSince1970
  91. {
  92. let storedTime = Observable.shared.alertLastLoopTime.value ?? 0
  93. if lastPumpTime > storedTime {
  94. Observable.shared.alertLastLoopTime.value = lastPumpTime
  95. Storage.shared.lastLoopTime.value = lastPumpTime
  96. }
  97. if let reservoirData = lastPumpRecord["reservoir"] as? Double {
  98. latestPumpVolume = reservoirData
  99. infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U", numericValue: reservoirData)
  100. Storage.shared.lastPumpReservoirU.value = reservoirData
  101. } else {
  102. // Pumps that only report "50+" get treated as exactly 50, both
  103. // for the volume alarm and for the info row's coloring.
  104. latestPumpVolume = 50.0
  105. infoManager.updateInfoData(type: .pump, value: "50+U", numericValue: 50.0)
  106. Storage.shared.lastPumpReservoirU.value = nil
  107. }
  108. }
  109. // Parse pump battery percentage
  110. if let pumpBatteryRecord = lastPumpRecord["battery"] as? [String: AnyObject],
  111. let pumpBatteryPercent = pumpBatteryRecord["percent"] as? Double
  112. {
  113. infoManager.updateInfoData(type: .pumpBattery, value: String(format: "%.0f", pumpBatteryPercent) + "%", numericValue: pumpBatteryPercent)
  114. Observable.shared.pumpBatteryLevel.value = pumpBatteryPercent
  115. }
  116. if let uploader = lastDeviceStatus?["uploader"] as? [String: AnyObject],
  117. let upbat = uploader["battery"] as? Double
  118. {
  119. let isCharging = uploader["isCharging"] as? Bool
  120. let batteryText: String
  121. if isCharging == true {
  122. batteryText = "⚡️ " + String(format: "%.0f", upbat) + "%"
  123. } else {
  124. batteryText = String(format: "%.0f", upbat) + "%"
  125. }
  126. infoManager.updateInfoData(type: .battery, value: batteryText, numericValue: upbat)
  127. Observable.shared.deviceBatteryLevel.value = upbat
  128. Observable.shared.deviceBatteryIsCharging.value = isCharging
  129. let timestamp = uploader["timestamp"] as? Date ?? Date()
  130. let currentBattery = DataStructs.batteryStruct(batteryLevel: upbat, timestamp: timestamp)
  131. deviceBatteryData.append(currentBattery)
  132. // store only the last 30 battery readings
  133. if deviceBatteryData.count > 30 {
  134. deviceBatteryData.removeFirst()
  135. }
  136. }
  137. }
  138. // Loop - handle new data
  139. if let lastLoopRecord = lastDeviceStatus?["loop"] as! [String: AnyObject]? {
  140. // Some pumps report no `pump.clock`; without it alertLastLoopTime stays 0
  141. // and the forecast anchors to epoch 0. Fall back to the loop cycle timestamp.
  142. if (lastDeviceStatus?["pump"] as? [String: AnyObject])?["clock"] == nil,
  143. let loopTimestampString = lastLoopRecord["timestamp"] as? String,
  144. let loopTimestamp = formatter.date(from: loopTimestampString)?.timeIntervalSince1970,
  145. loopTimestamp > (Observable.shared.alertLastLoopTime.value ?? 0)
  146. {
  147. Observable.shared.alertLastLoopTime.value = loopTimestamp
  148. Storage.shared.lastLoopTime.value = loopTimestamp
  149. }
  150. DeviceStatusLoop(formatter: formatter, lastLoopRecord: lastLoopRecord)
  151. var oText = ""
  152. currentOverride = 1.0
  153. if let lastOverride = lastDeviceStatus?["override"] as? [String: AnyObject],
  154. let isActive = lastOverride["active"] as? Bool, isActive
  155. {
  156. if let lastCorrection = lastOverride["currentCorrectionRange"] as? [String: AnyObject],
  157. let minValue = lastCorrection["minValue"] as? Double,
  158. let maxValue = lastCorrection["maxValue"] as? Double
  159. {
  160. if let multiplier = lastOverride["multiplier"] as? Double {
  161. currentOverride = multiplier
  162. oText += String(format: "%.0f%%", multiplier * 100)
  163. } else {
  164. oText += "100%"
  165. }
  166. oText += " ("
  167. oText += Localizer.toDisplayUnits(String(minValue)) + "-" + Localizer.toDisplayUnits(String(maxValue)) + ")"
  168. }
  169. infoManager.updateInfoData(type: .override, value: oText)
  170. } else {
  171. infoManager.clearInfoData(type: .override)
  172. }
  173. }
  174. // OpenAPS - handle new data
  175. if let lastLoopRecord = lastDeviceStatus?["openaps"] as! [String: AnyObject]? {
  176. DeviceStatusOpenAPS(formatter: formatter, lastDeviceStatus: lastDeviceStatus, lastLoopRecord: lastLoopRecord)
  177. }
  178. // If the active looping system flipped (Loop ⇄ Trio/OpenAPS), drop the previous
  179. // system's forecast so it doesn't linger next to the one just drawn above.
  180. let currentDeviceIsLoop = Storage.shared.device.value == "Loop"
  181. if currentDeviceIsLoop != previousDeviceWasLoop {
  182. if currentDeviceIsLoop {
  183. clearOpenAPSPredictionGraph()
  184. } else {
  185. clearLoopPredictionGraph()
  186. }
  187. }
  188. // Start the timer based on the timestamp
  189. let now = dateTimeUtils.getNowTimeIntervalUTC()
  190. let secondsAgo = now - (Observable.shared.alertLastLoopTime.value ?? 0)
  191. DispatchQueue.main.async {
  192. var interval: Double
  193. if secondsAgo >= (20 * 60) {
  194. interval = 5 * 60
  195. } else if secondsAgo >= (10 * 60) {
  196. interval = 60
  197. } else if secondsAgo >= (7 * 60) {
  198. interval = 30
  199. } else if secondsAgo >= (5 * 60) {
  200. interval = 10
  201. } else {
  202. interval = 310 - secondsAgo
  203. TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(3))
  204. }
  205. if NightscoutSocketManager.shared.connectionState == .authenticated {
  206. interval = max(interval * 3, 60)
  207. }
  208. TaskScheduler.shared.rescheduleTask(
  209. id: .deviceStatus,
  210. to: Date().addingTimeInterval(interval)
  211. )
  212. }
  213. evaluateNotLooping()
  214. // Mark device status as loaded for initial loading state
  215. markDataLoaded("deviceStatus")
  216. if Storage.shared.contactEnabled.value, Storage.shared.contactIOB.value != .off,
  217. Observable.shared.iobText.value != previousIOBText
  218. {
  219. contactImageUpdater.updateContactImage(
  220. bgValue: Observable.shared.bgText.value,
  221. trend: Observable.shared.directionText.value,
  222. delta: Observable.shared.deltaText.value,
  223. iob: Observable.shared.iobText.value,
  224. stale: Observable.shared.bgStale.value
  225. )
  226. }
  227. LogManager.shared.log(category: .deviceStatus, message: "Update Device Status done", isDebug: true)
  228. }
  229. }