HomeStateModel.swift 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991
  1. import CGMBLEKit
  2. import CGMBLEKitUI
  3. import Combine
  4. import CoreData
  5. import Foundation
  6. import G7SensorKit
  7. import LibreTransmitter
  8. import LoopKit
  9. import LoopKitUI
  10. import Observation
  11. import SwiftDate
  12. import SwiftUI
  13. extension Home {
  14. @Observable final class StateModel: BaseStateModel<Provider> {
  15. @ObservationIgnored @Injected() var broadcaster: Broadcaster!
  16. @ObservationIgnored @Injected() var apsManager: APSManager!
  17. @ObservationIgnored @Injected() var pluginCGMManager: PluginManager!
  18. @ObservationIgnored @Injected() var fetchGlucoseManager: FetchGlucoseManager!
  19. @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager!
  20. @ObservationIgnored @Injected() var determinationStorage: DeterminationStorage!
  21. @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage!
  22. @ObservationIgnored @Injected() var carbsStorage: CarbsStorage!
  23. @ObservationIgnored @Injected() var tempTargetStorage: TempTargetsStorage!
  24. @ObservationIgnored @Injected() var overrideStorage: OverrideStorage!
  25. @ObservationIgnored @Injected() var bluetoothManager: BluetoothStateManager!
  26. @ObservationIgnored @Injected() var iobService: IOBService!
  27. @ObservationIgnored @Injected() var unlockmanager: UnlockManager!
  28. var cgmStateModel: CGMSettings.StateModel {
  29. CGMSettings.StateModel.shared
  30. }
  31. private let timer = DispatchTimer(timeInterval: 30)
  32. private(set) var filteredHours = 24
  33. var startMarker = Date(timeIntervalSinceNow: TimeInterval(hours: -24))
  34. var endMarker = Date(timeIntervalSinceNow: TimeInterval(hours: 3))
  35. var manualGlucose: [BloodGlucose] = []
  36. var uploadStats = false
  37. var recentGlucose: BloodGlucose?
  38. var maxBasal: Decimal = 2
  39. var basalProfile: [BasalProfileEntry] = []
  40. var bgTargets = BGTargets(from: OpenAPS.defaults(for: OpenAPS.Settings.bgTargets))
  41. ?? BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: [])
  42. var targetProfiles: [TargetProfile] = []
  43. var timerDate = Date()
  44. var closedLoop = false
  45. var isLooping = false
  46. var statusTitle = ""
  47. var lastLoopDate: Date = .distantPast
  48. var battery: Battery?
  49. var reservoir: Decimal?
  50. var pumpName = ""
  51. var pumpExpiresAtDate: Date?
  52. var pumpActivatedAtDate: Date?
  53. var highTTraisesSens: Bool = false
  54. var lowTTlowersSens: Bool = false
  55. var isExerciseModeActive: Bool = false
  56. var settingHalfBasalTarget: Decimal = 160
  57. var percentage: Int = 100
  58. var shouldDisplayPumpSetupSheet = false
  59. var shouldDisplayCGMSetupSheet = false
  60. var errorMessage: String?
  61. var errorDate: Date?
  62. var bolusProgress: Decimal?
  63. var eventualBG: Int?
  64. var allowManualTemp = false
  65. var units: GlucoseUnits = .mgdL
  66. var pumpDisplayState: PumpDisplayState?
  67. var alarm: GlucoseAlarm?
  68. var manualTempBasal = false
  69. var isSmoothingEnabled = false
  70. var maxIOB: Decimal = 0.0
  71. var currentIOB: Decimal = 0.0
  72. var autosensMax: Decimal = 1.2
  73. var lowGlucose: Decimal = 70
  74. var highGlucose: Decimal = 180
  75. var currentGlucoseTarget: Decimal = 100
  76. var glucoseColorScheme: GlucoseColorScheme = .staticColor
  77. var eA1cDisplayUnit: EstimatedA1cDisplayUnit = .percent
  78. var displayXgridLines: Bool = false
  79. var displayYgridLines: Bool = false
  80. var thresholdLines: Bool = false
  81. var bolusDisplayThreshold: BolusDisplayThreshold = .allUnits
  82. var hours: Int16 = 6
  83. var totalBolus: Decimal = 0
  84. var isLoopStatusPresented: Bool = false
  85. var isLegendPresented: Bool = false
  86. var roundedTotalBolus: String = ""
  87. var selectedTab: Int = 0
  88. var waitForSuggestion: Bool = false
  89. var glucoseFromPersistence: [GlucoseStored] = []
  90. var latestTwoGlucoseValues: [GlucoseStored] = []
  91. var carbsFromPersistence: [CarbEntryStored] = []
  92. var fpusFromPersistence: [CarbEntryStored] = []
  93. var determinationsFromPersistence: [OrefDetermination] = []
  94. var enactedAndNonEnactedDeterminations: [OrefDetermination] = []
  95. var fetchedTDDs: [TDD] = []
  96. var insulinFromPersistence: [PumpEventStored] = []
  97. var tempBasals: [PumpEventStored] = []
  98. var suspendAndResumeEvents: [PumpEventStored] = []
  99. var batteryFromPersistence: [OpenAPS_Battery] = []
  100. var bolusStatus: BolusStatus = .noBolus
  101. var lastPumpBolus: PumpEventStored?
  102. var overrides: [OverrideStored] = []
  103. var overrideRunStored: [OverrideRunStored] = []
  104. var tempTargetStored: [TempTargetStored] = []
  105. var tempTargetRunStored: [TempTargetRunStored] = []
  106. var isOverrideCancelled: Bool = false
  107. var preprocessedData: [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)] = []
  108. var pumpStatusHighlightMessage: String?
  109. var pumpStatusBadgeImage: UIImage?
  110. var pumpStatusBadgeColor: Color?
  111. var cgmAvailable: Bool = false
  112. var cgmDisplayState: CgmDisplayState?
  113. var cgmProgressHighlight: DeviceLifecycleProgress?
  114. var cgmSensorExpiresAt: Date?
  115. var cgmWarmupEndsAt: Date?
  116. var listOfCGM: [CGMModel] = []
  117. var cgmCurrent = cgmDefaultModel
  118. var pumpInitialSettings = PumpConfig.PumpInitialSettings.default
  119. var shouldRunDeleteOnSettingsChange = true
  120. var showCarbsRequiredBadge: Bool = true
  121. var enableQuickBolus: Bool = false
  122. var quickBolusHistory: [Decimal] = []
  123. private(set) var setupPumpType: PumpConfig.PumpType = .minimed
  124. var minForecast: [Int] = []
  125. var maxForecast: [Int] = []
  126. var minCount: Int = 12 // count of Forecasts drawn in 5 min distances, i.e. 12 means a min of 1 hour
  127. var forecastDisplayType: ForecastDisplayType = .cone
  128. var minYAxisValue: Decimal = 39
  129. var maxYAxisValue: Decimal = 200
  130. var minValueCobChart: Decimal = 0
  131. var maxValueCobChart: Decimal = 20
  132. var minValueIobChart: Decimal = 0
  133. var maxValueIobChart: Decimal = 5
  134. let taskContext = CoreDataStack.shared.newTaskContext()
  135. let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
  136. let carbsFetchContext = CoreDataStack.shared.newTaskContext()
  137. let fpuFetchContext = CoreDataStack.shared.newTaskContext()
  138. let determinationFetchContext = CoreDataStack.shared.newTaskContext()
  139. let tddFetchContext = CoreDataStack.shared.newTaskContext()
  140. let pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
  141. let overrideFetchContext = CoreDataStack.shared.newTaskContext()
  142. let tempTargetFetchContext = CoreDataStack.shared.newTaskContext()
  143. let batteryFetchContext = CoreDataStack.shared.newTaskContext()
  144. let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  145. // Queue for handling Core Data change notifications
  146. private let queue = DispatchQueue(label: "HomeStateModel.queue", qos: .userInitiated)
  147. private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
  148. private var subscriptions = Set<AnyCancellable>()
  149. typealias PumpEvent = PumpEventStored.EventType
  150. override init() {
  151. super.init()
  152. }
  153. override func subscribe() {
  154. coreDataPublisher =
  155. changedObjectsOnManagedObjectContextDidSavePublisher()
  156. .receive(on: queue)
  157. .share()
  158. .eraseToAnyPublisher()
  159. registerSubscribers()
  160. registerHandlers()
  161. // Parallelize Setup functions
  162. setupHomeViewConcurrently()
  163. }
  164. private func setupHomeViewConcurrently() {
  165. Task {
  166. // We need to initialize settings and observers first
  167. await self.setupSettings()
  168. await self.setupPumpSettings()
  169. await self.setupCGMSettings()
  170. self.registerObservers()
  171. // The rest can be initialized concurrently
  172. await withTaskGroup(of: Void.self) { group in
  173. group.addTask {
  174. self.setupGlucoseArray()
  175. }
  176. group.addTask {
  177. self.setupCarbsArray()
  178. }
  179. group.addTask {
  180. self.setupFPUsArray()
  181. }
  182. group.addTask {
  183. self.setupDeterminationsArray()
  184. }
  185. group.addTask {
  186. self.setupTDDArray()
  187. }
  188. group.addTask {
  189. self.setupInsulinArray()
  190. }
  191. group.addTask {
  192. self.setupLastBolus()
  193. }
  194. group.addTask {
  195. self.setupBatteryArray()
  196. }
  197. group.addTask {
  198. await self.setupBasalProfile()
  199. }
  200. group.addTask {
  201. await self.setupGlucoseTargets()
  202. }
  203. group.addTask {
  204. self.setupReservoir()
  205. }
  206. group.addTask {
  207. self.setupOverrides()
  208. }
  209. group.addTask {
  210. self.setupOverrideRunStored()
  211. }
  212. group.addTask {
  213. self.setupTempTargetsStored()
  214. }
  215. group.addTask {
  216. self.setupTempTargetsRunStored()
  217. }
  218. group.addTask {
  219. self.iobService.updateIOB()
  220. }
  221. }
  222. }
  223. }
  224. // These combine subscribers are only necessary due to the batch inserts of glucose/FPUs which do not trigger a ManagedObjectContext change notification
  225. private func registerSubscribers() {
  226. iobService.iobPublisher
  227. .receive(on: DispatchQueue.main)
  228. .sink { [weak self] _ in
  229. guard let self = self else { return }
  230. self.currentIOB = self.iobService.currentIOB ?? 0
  231. }
  232. .store(in: &subscriptions)
  233. glucoseStorage.updatePublisher
  234. .receive(on: queue)
  235. .sink { [weak self] _ in
  236. guard let self = self else { return }
  237. self.setupGlucoseArray()
  238. }
  239. .store(in: &subscriptions)
  240. carbsStorage.updatePublisher
  241. .receive(on: queue)
  242. .sink { [weak self] _ in
  243. guard let self = self else { return }
  244. self.setupFPUsArray()
  245. }
  246. .store(in: &subscriptions)
  247. provider.deviceManager.bolusTrigger
  248. .receive(on: queue)
  249. .sink { [weak self] state in
  250. guard let self = self else { return }
  251. self.bolusStatus = state
  252. }
  253. .store(in: &subscriptions)
  254. }
  255. private func registerHandlers() {
  256. coreDataPublisher?.filteredByEntityName("OrefDetermination").sink { [weak self] _ in
  257. guard let self = self else { return }
  258. self.setupDeterminationsArray()
  259. }.store(in: &subscriptions)
  260. coreDataPublisher?.filteredByEntityName("TDDStored").sink { [weak self] _ in
  261. guard let self = self else { return }
  262. self.setupTDDArray()
  263. }.store(in: &subscriptions)
  264. coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
  265. guard let self = self else { return }
  266. self.setupGlucoseArray()
  267. }.store(in: &subscriptions)
  268. coreDataPublisher?.filteredByEntityName("CarbEntryStored").sink { [weak self] _ in
  269. guard let self = self else { return }
  270. self.setupCarbsArray()
  271. }.store(in: &subscriptions)
  272. coreDataPublisher?.filteredByEntityName("PumpEventStored").sink { [weak self] _ in
  273. guard let self = self else { return }
  274. self.setupInsulinArray()
  275. self.setupLastBolus()
  276. self.displayPumpStatusHighlightMessage()
  277. self.displayPumpStatusBadge()
  278. }.store(in: &subscriptions)
  279. coreDataPublisher?.filteredByEntityName("OpenAPS_Battery").sink { [weak self] _ in
  280. guard let self = self else { return }
  281. self.setupBatteryArray()
  282. }.store(in: &subscriptions)
  283. coreDataPublisher?.filteredByEntityName("OverrideStored").sink { [weak self] _ in
  284. guard let self = self else { return }
  285. self.setupOverrides()
  286. }.store(in: &subscriptions)
  287. coreDataPublisher?.filteredByEntityName("OverrideRunStored").sink { [weak self] _ in
  288. guard let self = self else { return }
  289. self.setupOverrideRunStored()
  290. }.store(in: &subscriptions)
  291. coreDataPublisher?.filteredByEntityName("TempTargetStored").sink { [weak self] _ in
  292. guard let self = self else { return }
  293. self.setupTempTargetsStored()
  294. }.store(in: &subscriptions)
  295. coreDataPublisher?.filteredByEntityName("TempTargetRunStored").sink { [weak self] _ in
  296. guard let self = self else { return }
  297. self.setupTempTargetsRunStored()
  298. }.store(in: &subscriptions)
  299. }
  300. private func registerObservers() {
  301. broadcaster.register(DeterminationObserver.self, observer: self)
  302. broadcaster.register(SettingsObserver.self, observer: self)
  303. broadcaster.register(PreferencesObserver.self, observer: self)
  304. broadcaster.register(PumpSettingsObserver.self, observer: self)
  305. broadcaster.register(BasalProfileObserver.self, observer: self)
  306. broadcaster.register(BGTargetsObserver.self, observer: self)
  307. broadcaster.register(PumpReservoirObserver.self, observer: self)
  308. broadcaster.register(PumpDeactivatedObserver.self, observer: self)
  309. timer.eventHandler = {
  310. DispatchQueue.main.async { [weak self] in
  311. guard let self else { return }
  312. self.timerDate = Date()
  313. // The publisher only re-emits on state changes; re-pull
  314. // so the arc + countdowns + status text advance during
  315. // warmup / stabilizing / expiry. Simulator has no
  316. // CGMManager, so fall back to reading its synthetic
  317. // lifecycle / highlight so the bobble sees the same
  318. // data shape a real CGM would deliver.
  319. let manager = self.fetchGlucoseManager.cgmManager
  320. let source = self.fetchGlucoseManager.glucoseSource
  321. let progress: DeviceLifecycleProgress?
  322. let highlight: DeviceStatusHighlight?
  323. if let manager {
  324. progress = manager.cgmLifecycleProgress
  325. highlight = manager.cgmStatusHighlight
  326. } else if let sim = source as? GlucoseSimulatorSource {
  327. progress = sim.cgmLifecycleProgress
  328. highlight = sim.cgmStatusHighlight
  329. } else {
  330. progress = nil
  331. highlight = nil
  332. }
  333. self.cgmProgressHighlight = progress
  334. if let highlight {
  335. self.cgmDisplayState = CgmDisplayState(
  336. localizedMessage: highlight.localizedMessage,
  337. imageName: highlight.imageName,
  338. status: CgmDisplayStatus.from(highlight.state)
  339. )
  340. } else {
  341. self.cgmDisplayState = nil
  342. }
  343. self.cgmSensorExpiresAt = Self.resolveSensorExpiresAt(
  344. manager: manager,
  345. glucoseSource: source,
  346. lifecycle: progress
  347. )
  348. self.cgmWarmupEndsAt = Self.resolveWarmupEndsAt(manager: manager)
  349. }
  350. }
  351. timer.resume()
  352. fetchGlucoseManager.cgmDisplayState
  353. .receive(on: DispatchQueue.main)
  354. .sink { [weak self] state in self?.cgmDisplayState = state }
  355. .store(in: &lifetime)
  356. fetchGlucoseManager.cgmProgressHighlight
  357. .receive(on: DispatchQueue.main)
  358. .sink { [weak self] progress in
  359. guard let self else { return }
  360. self.cgmProgressHighlight = progress
  361. self.cgmSensorExpiresAt = Self.resolveSensorExpiresAt(
  362. manager: self.fetchGlucoseManager.cgmManager,
  363. glucoseSource: self.fetchGlucoseManager.glucoseSource,
  364. lifecycle: progress
  365. )
  366. self.cgmWarmupEndsAt = Self.resolveWarmupEndsAt(
  367. manager: self.fetchGlucoseManager.cgmManager
  368. )
  369. }
  370. .store(in: &lifetime)
  371. apsManager.isLooping
  372. .receive(on: DispatchQueue.main)
  373. .weakAssign(to: \.isLooping, on: self)
  374. .store(in: &lifetime)
  375. apsManager.lastLoopDateSubject
  376. .receive(on: DispatchQueue.main)
  377. .weakAssign(to: \.lastLoopDate, on: self)
  378. .store(in: &lifetime)
  379. apsManager.pumpName
  380. .receive(on: DispatchQueue.main)
  381. .weakAssign(to: \.pumpName, on: self)
  382. .store(in: &lifetime)
  383. apsManager.pumpExpiresAtDate
  384. .receive(on: DispatchQueue.main)
  385. .weakAssign(to: \.pumpExpiresAtDate, on: self)
  386. .store(in: &lifetime)
  387. apsManager.pumpActivatedAtDate
  388. .receive(on: DispatchQueue.main)
  389. .weakAssign(to: \.pumpActivatedAtDate, on: self)
  390. .store(in: &lifetime)
  391. apsManager.lastError
  392. .receive(on: DispatchQueue.main)
  393. .map { [weak self] error in
  394. self?.errorDate = error == nil ? nil : Date()
  395. if let error = error {
  396. debug(.default, "APSManager lastError: \(String(describing: error))")
  397. }
  398. return error?.localizedDescription
  399. }
  400. .weakAssign(to: \.errorMessage, on: self)
  401. .store(in: &lifetime)
  402. apsManager.bolusProgress
  403. .receive(on: DispatchQueue.main)
  404. .weakAssign(to: \.bolusProgress, on: self)
  405. .store(in: &lifetime)
  406. apsManager.pumpDisplayState
  407. .receive(on: DispatchQueue.main)
  408. .sink { [weak self] state in
  409. guard let self = self else { return }
  410. self.pumpDisplayState = state
  411. if state == nil {
  412. self.reservoir = nil
  413. self.battery = nil
  414. self.pumpName = ""
  415. self.pumpExpiresAtDate = nil
  416. self.shouldDisplayPumpSetupSheet = false
  417. } else {
  418. self.setupReservoir()
  419. self.displayPumpStatusHighlightMessage()
  420. self.displayPumpStatusBadge()
  421. self.setupBatteryArray()
  422. }
  423. }
  424. .store(in: &lifetime)
  425. }
  426. private enum SettingType {
  427. case basal
  428. case carbRatio
  429. case bgTarget
  430. case isf
  431. }
  432. @MainActor private func setupSettings() async {
  433. units = settingsManager.settings.units
  434. allowManualTemp = !settingsManager.settings.closedLoop
  435. closedLoop = settingsManager.settings.closedLoop
  436. lastLoopDate = apsManager.lastLoopDate
  437. alarm = provider.glucoseStorage.alarm
  438. manualTempBasal = apsManager.isManualTempBasal
  439. isSmoothingEnabled = settingsManager.settings.smoothGlucose
  440. glucoseColorScheme = settingsManager.settings.glucoseColorScheme
  441. autosensMax = settingsManager.preferences.autosensMax
  442. lowGlucose = settingsManager.settings.low
  443. highGlucose = settingsManager.settings.high
  444. eA1cDisplayUnit = settingsManager.settings.eA1cDisplayUnit
  445. displayXgridLines = settingsManager.settings.xGridLines
  446. displayYgridLines = settingsManager.settings.yGridLines
  447. bolusDisplayThreshold = settingsManager.settings.bolusDisplayThreshold
  448. thresholdLines = settingsManager.settings.rulerMarks
  449. showCarbsRequiredBadge = settingsManager.settings.showCarbsRequiredBadge
  450. enableQuickBolus = settingsManager.settings.enableQuickBolus
  451. forecastDisplayType = settingsManager.settings.forecastDisplayType
  452. isExerciseModeActive = settingsManager.preferences.exerciseMode
  453. highTTraisesSens = settingsManager.preferences.highTemptargetRaisesSensitivity
  454. lowTTlowersSens = settingsManager.preferences.lowTemptargetLowersSensitivity
  455. settingHalfBasalTarget = settingsManager.preferences.halfBasalExerciseTarget
  456. maxIOB = settingsManager.preferences.maxIOB
  457. }
  458. @MainActor private func setupCGMSettings() async {
  459. cgmAvailable = fetchGlucoseManager.cgmGlucoseSourceType != CGMType.none
  460. listOfCGM = (
  461. CGMType.allCases.filter { $0 != CGMType.plugin }.map {
  462. CGMModel(id: $0.id, type: $0, displayName: $0.displayName, subtitle: $0.subtitle)
  463. } +
  464. pluginCGMManager.availableCGMManagers.map {
  465. CGMModel(
  466. id: $0.identifier,
  467. type: CGMType.plugin,
  468. displayName: $0.localizedTitle,
  469. subtitle: $0.localizedTitle
  470. )
  471. }
  472. ).sorted(by: { lhs, rhs in
  473. if lhs.displayName == "None" {
  474. return true
  475. } else if rhs.displayName == "None" {
  476. return false
  477. } else {
  478. return lhs.displayName < rhs.displayName
  479. }
  480. })
  481. switch settingsManager.settings.cgm {
  482. case .plugin:
  483. if let cgmPluginInfo = listOfCGM.first(where: { $0.id == settingsManager.settings.cgmPluginIdentifier }) {
  484. cgmCurrent = CGMModel(
  485. id: settingsManager.settings.cgmPluginIdentifier,
  486. type: .plugin,
  487. displayName: cgmPluginInfo.displayName,
  488. subtitle: cgmPluginInfo.subtitle
  489. )
  490. } else {
  491. // no more type of plugin available - fallback to default
  492. cgmCurrent = cgmDefaultModel
  493. }
  494. default:
  495. cgmCurrent = CGMModel(
  496. id: settingsManager.settings.cgm.id,
  497. type: settingsManager.settings.cgm,
  498. displayName: settingsManager.settings.cgm.displayName,
  499. subtitle: settingsManager.settings.cgm.subtitle
  500. )
  501. }
  502. }
  503. func loadQuickBolusSuggestions() async {
  504. guard enableQuickBolus else { return }
  505. let cutoff = Calendar.current.date(byAdding: .day, value: -90, to: Date()) ?? Date()
  506. let predicate = NSPredicate(
  507. format: "isSMB == false AND isExternal == false AND pumpEvent.timestamp >= %@",
  508. cutoff as NSDate
  509. )
  510. do {
  511. let results: Any = try await CoreDataStack.shared.fetchEntitiesAsync(
  512. ofType: BolusStored.self,
  513. onContext: pumpHistoryFetchContext,
  514. predicate: predicate,
  515. key: "pumpEvent.timestamp",
  516. ascending: false,
  517. batchSize: 100
  518. )
  519. let suggestions: [Decimal] = await pumpHistoryFetchContext.perform {
  520. guard let boluses = results as? [BolusStored] else { return [] }
  521. let now = Date()
  522. let cal = Calendar.current
  523. let nowMinute = cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now)
  524. let nowDOW = cal.component(.weekday, from: now)
  525. let sigma: Double = 60.0
  526. let halfLife: Double = 10.0
  527. var groups: [Decimal: Double] = [:]
  528. for bolus in boluses {
  529. guard let nsAmount = bolus.amount, nsAmount.doubleValue > 0,
  530. let timestamp = bolus.pumpEvent?.timestamp else { continue }
  531. var roundedKey = Decimal()
  532. var tempAmount = nsAmount as Decimal
  533. NSDecimalRound(&roundedKey, &tempAmount, 2, .plain)
  534. let entryMinute = cal.component(.hour, from: timestamp) * 60 + cal.component(.minute, from: timestamp)
  535. let entryDOW = cal.component(.weekday, from: timestamp)
  536. let diff = abs(entryMinute - nowMinute)
  537. let circularDiff = Double(min(diff, 1440 - diff))
  538. let t = exp(-(circularDiff * circularDiff) / (2.0 * sigma * sigma))
  539. let d: Double
  540. if entryDOW == nowDOW {
  541. d = 1.0
  542. } else {
  543. let nowWeekend = nowDOW == 1 || nowDOW == 7
  544. let entryWeekend = entryDOW == 1 || entryDOW == 7
  545. d = nowWeekend == entryWeekend ? 0.7 : 0.15
  546. }
  547. let daysAgo = now.timeIntervalSince(timestamp) / 86400.0
  548. let r = pow(0.5, daysAgo / halfLife)
  549. groups[roundedKey, default: 0] += t * d * r
  550. }
  551. return groups
  552. .filter { $0.value >= 0.1 }
  553. .sorted { $0.value > $1.value }
  554. .prefix(5)
  555. .map(\.key)
  556. }
  557. await MainActor.run {
  558. quickBolusHistory = suggestions
  559. }
  560. } catch {
  561. debug(.default, "\(DebuggingIdentifiers.failed) failed to fetch quick bolus history: \(error)")
  562. }
  563. }
  564. func enactQuickBolus(amount: Decimal) async -> Bool {
  565. guard amount > 0 else { return false }
  566. let delivery = min(
  567. Double(truncating: amount as NSDecimalNumber),
  568. pumpInitialSettings.maxBolusUnits
  569. )
  570. do {
  571. let authenticated = try await unlockmanager.unlock()
  572. if authenticated {
  573. await apsManager.enactBolus(amount: delivery, isSMB: false, callback: nil)
  574. return true
  575. }
  576. return false
  577. } catch {
  578. debug(.bolusState, "Quick bolus authentication error: \(error)")
  579. return false
  580. }
  581. }
  582. func addPump(_ type: PumpConfig.PumpType) {
  583. setupPumpType = type
  584. shouldDisplayPumpSetupSheet = true
  585. }
  586. func addCGM(cgm: CGMModel) {
  587. cgmCurrent = cgm
  588. switch cgmCurrent.type {
  589. case .plugin:
  590. shouldDisplayCGMSetupSheet = true
  591. default:
  592. shouldDisplayCGMSetupSheet = true
  593. settingsManager.settings.cgm = cgmCurrent.type
  594. settingsManager.settings.cgmPluginIdentifier = ""
  595. fetchGlucoseManager.updateGlucoseSource(cgmGlucoseSourceType: cgmCurrent.type, cgmGlucosePluginId: cgmCurrent.id)
  596. broadcaster.notify(GlucoseObserver.self, on: .main) {
  597. $0.glucoseDidUpdate([])
  598. }
  599. }
  600. }
  601. func deleteCGM() {
  602. fetchGlucoseManager.performOnCGMManagerQueue {
  603. // Call plugin functionality on the manager queue (or at least attempt to)
  604. Task {
  605. await self.fetchGlucoseManager?.deleteGlucoseSource()
  606. // UI updates go back to Main
  607. await MainActor.run {
  608. self.shouldDisplayCGMSetupSheet = false
  609. self.broadcaster.notify(GlucoseObserver.self, on: .main) {
  610. $0.glucoseDidUpdate([])
  611. }
  612. }
  613. }
  614. }
  615. }
  616. /// Display the eventual status message provided by the manager of the pump
  617. /// Only display if state is warning or critical message else return nil
  618. private func displayPumpStatusHighlightMessage(_ didDeactivate: Bool = false) {
  619. DispatchQueue.main.async { [weak self] in
  620. guard let self = self else { return }
  621. if let statusHighlight = self.provider.deviceManager.pumpManager?.pumpStatusHighlight,
  622. statusHighlight.state == .warning || statusHighlight.state == .critical, !didDeactivate
  623. {
  624. pumpStatusHighlightMessage = (statusHighlight.state == .warning ? "⚠️\n" : "‼️\n") + statusHighlight
  625. .localizedMessage
  626. } else {
  627. pumpStatusHighlightMessage = nil
  628. }
  629. }
  630. }
  631. private func displayPumpStatusBadge(_ didDeactivate: Bool = false) {
  632. DispatchQueue.main.async { [weak self] in
  633. guard let self = self else { return }
  634. if let statusBadge = self.provider.deviceManager.pumpManager?.pumpStatusBadge,
  635. let image = statusBadge.image, !didDeactivate
  636. {
  637. pumpStatusBadgeImage = image
  638. pumpStatusBadgeColor = statusBadge.state == .critical ? .critical : .warning
  639. } else {
  640. pumpStatusBadgeImage = nil
  641. pumpStatusBadgeColor = nil
  642. }
  643. }
  644. }
  645. func runLoop() {
  646. provider.heartbeatNow()
  647. }
  648. func showProgressView() {
  649. glucoseStorage
  650. .isGlucoseDataFresh(glucoseFromPersistence.first?.date) ? (waitForSuggestion = true) : (waitForSuggestion = false)
  651. }
  652. func cancelBolus() {
  653. Task {
  654. await apsManager.cancelBolus(nil)
  655. // perform determine basal sync, otherwise you have could end up with too much iob when opening the calculator again
  656. try await apsManager.determineBasalSync()
  657. }
  658. }
  659. private func setupPumpSettings() async {
  660. let settings = await provider.pumpSettings()
  661. await MainActor.run {
  662. self.maxBasal = settings.maxBasal
  663. self.pumpInitialSettings.maxBasalRateUnitsPerHour = Double(settings.maxBasal)
  664. self.pumpInitialSettings.maxBolusUnits = Double(settings.maxBolus)
  665. }
  666. }
  667. private func setupBasalProfile() async {
  668. let basalProfile = await provider.getBasalProfile()
  669. await MainActor.run {
  670. self.basalProfile = basalProfile
  671. if let schedule = BasalRateSchedule(
  672. dailyItems: basalProfile
  673. .map { RepeatingScheduleValue(startTime: TimeInterval($0.minutes * 60), value: Double($0.rate)) }
  674. ) {
  675. self.pumpInitialSettings.basalSchedule = schedule
  676. }
  677. }
  678. }
  679. private func setupGlucoseTargets() async {
  680. let bgTargets = await provider.getBGTargets()
  681. let targetProfiles = processFetchedTargets(bgTargets, startMarker: startMarker)
  682. await MainActor.run {
  683. self.bgTargets = bgTargets
  684. self.targetProfiles = targetProfiles
  685. }
  686. }
  687. private func setupReservoir() {
  688. Task {
  689. let reservoir = await provider.pumpReservoir()
  690. await MainActor.run {
  691. self.reservoir = reservoir
  692. }
  693. }
  694. }
  695. private func getCurrentGlucoseTarget() async {
  696. let now = Date()
  697. let calendar = Calendar.current
  698. let entries: [(start: String, value: Decimal)] = bgTargets.targets.map { ($0.start, $0.low) }
  699. for (index, entry) in entries.enumerated() {
  700. guard let entryTime = TherapySettingsUtil.parseTime(entry.start) else {
  701. debug(.default, "Invalid entry start time: \(entry.start)")
  702. continue
  703. }
  704. let entryComponents = calendar.dateComponents([.hour, .minute, .second], from: entryTime)
  705. let entryStartTime = calendar.date(
  706. bySettingHour: entryComponents.hour!,
  707. minute: entryComponents.minute!,
  708. second: entryComponents.second!,
  709. of: now
  710. )!
  711. let entryEndTime: Date
  712. if index < entries.count - 1,
  713. let nextEntryTime = TherapySettingsUtil.parseTime(entries[index + 1].start)
  714. {
  715. let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
  716. entryEndTime = calendar.date(
  717. bySettingHour: nextEntryComponents.hour!,
  718. minute: nextEntryComponents.minute!,
  719. second: nextEntryComponents.second!,
  720. of: now
  721. )!
  722. } else {
  723. entryEndTime = calendar.date(byAdding: .day, value: 1, to: entryStartTime)!
  724. }
  725. if now >= entryStartTime, now < entryEndTime {
  726. await MainActor.run {
  727. currentGlucoseTarget = entry.value
  728. }
  729. return
  730. }
  731. }
  732. }
  733. /// Sensor expiration for the home label. Prefers manager-reported
  734. /// dates; reverse-derives from `lifecycle.percentComplete` when not.
  735. /// `activatedAt` must be session start, not transmitter activation.
  736. private static func resolveSensorExpiresAt(
  737. manager: CGMManagerUI?,
  738. glucoseSource: GlucoseSource?,
  739. lifecycle: DeviceLifecycleProgress?
  740. ) -> Date? {
  741. if let sim = glucoseSource as? GlucoseSimulatorSource {
  742. return sim.simulatedSensorExpiresAt
  743. }
  744. guard let manager else { return nil }
  745. // Once a G7 enters grace period, `sensorExpiresAt` is in the past
  746. // and would collapse the bobble countdown to "<1m" while the arc
  747. // (driven by lifecycle.percentComplete against `sensorEndsAt`) is
  748. // still mid-progress. Fall back to `sensorEndsAt` so bobble and
  749. // arc agree, and the user sees grace-period time remaining.
  750. if let g7 = manager as? G7CGMManager {
  751. let now = Date()
  752. if let exp = g7.sensorExpiresAt, exp > now { return exp }
  753. return g7.sensorEndsAt ?? g7.sensorExpiresAt
  754. }
  755. if let g6 = manager as? G6CGMManager, let exp = g6.latestReading?.sessionExpDate { return exp }
  756. if let g5 = manager as? G5CGMManager, let exp = g5.latestReading?.sessionExpDate { return exp }
  757. let activatedAt: Date?
  758. if let g7 = manager as? G7CGMManager {
  759. activatedAt = g7.sensorActivatedAt
  760. } else if let libre = manager as? LibreTransmitterManagerV3 {
  761. activatedAt = libre.sensorInfoObservable.activatedAt
  762. } else {
  763. activatedAt = nil
  764. }
  765. guard let activatedAt,
  766. let lifecycle,
  767. lifecycle.percentComplete > 0.001
  768. else { return nil }
  769. let elapsed = Date().timeIntervalSince(activatedAt)
  770. guard elapsed > 0 else { return nil }
  771. return activatedAt.addingTimeInterval(elapsed / lifecycle.percentComplete)
  772. }
  773. /// Wall-clock end of the sensor's warmup window; `nil` when not warming up.
  774. private static func resolveWarmupEndsAt(manager: CGMManagerUI?) -> Date? {
  775. guard let manager else { return nil }
  776. if let g7 = manager as? G7CGMManager {
  777. guard let ends = g7.sensorFinishesWarmupAt, ends > Date() else { return nil }
  778. return ends
  779. }
  780. if let g6 = manager as? G6CGMManager, let start = g6.latestReading?.sessionStartDate {
  781. let window: TimeInterval = g6.isAnubis ? 50 * 60 : 2 * 60 * 60
  782. let ends = start.addingTimeInterval(window)
  783. return ends > Date() ? ends : nil
  784. }
  785. if let g5 = manager as? G5CGMManager, let start = g5.latestReading?.sessionStartDate {
  786. let ends = start.addingTimeInterval(2 * 60 * 60)
  787. return ends > Date() ? ends : nil
  788. }
  789. return nil
  790. }
  791. }
  792. }
  793. extension Home.StateModel:
  794. DeterminationObserver,
  795. SettingsObserver,
  796. PreferencesObserver,
  797. PumpSettingsObserver,
  798. BasalProfileObserver,
  799. BGTargetsObserver,
  800. PumpReservoirObserver,
  801. PumpDeactivatedObserver
  802. {
  803. func determinationDidUpdate(_: Determination) {
  804. waitForSuggestion = false
  805. }
  806. func settingsDidChange(_ settings: TrioSettings) {
  807. allowManualTemp = !settings.closedLoop
  808. closedLoop = settingsManager.settings.closedLoop
  809. units = settingsManager.settings.units
  810. manualTempBasal = apsManager.isManualTempBasal
  811. isSmoothingEnabled = settingsManager.settings.smoothGlucose
  812. lowGlucose = settingsManager.settings.low
  813. highGlucose = settingsManager.settings.high
  814. Task {
  815. await getCurrentGlucoseTarget()
  816. await setupGlucoseTargets()
  817. }
  818. eA1cDisplayUnit = settingsManager.settings.eA1cDisplayUnit
  819. glucoseColorScheme = settingsManager.settings.glucoseColorScheme
  820. displayXgridLines = settingsManager.settings.xGridLines
  821. displayYgridLines = settingsManager.settings.yGridLines
  822. thresholdLines = settingsManager.settings.rulerMarks
  823. bolusDisplayThreshold = settingsManager.settings.bolusDisplayThreshold
  824. showCarbsRequiredBadge = settingsManager.settings.showCarbsRequiredBadge
  825. enableQuickBolus = settingsManager.settings.enableQuickBolus
  826. forecastDisplayType = settingsManager.settings.forecastDisplayType
  827. cgmAvailable = (fetchGlucoseManager.cgmGlucoseSourceType != CGMType.none)
  828. displayPumpStatusHighlightMessage()
  829. displayPumpStatusBadge()
  830. setupBatteryArray()
  831. Task {
  832. await setupCGMSettings()
  833. }
  834. if settingsManager.settings.cgm == .none, shouldRunDeleteOnSettingsChange {
  835. shouldRunDeleteOnSettingsChange = false
  836. cgmCurrent = cgmDefaultModel
  837. DispatchQueue.main.async {
  838. self.broadcaster.notify(GlucoseObserver.self, on: .main) {
  839. $0.glucoseDidUpdate([])
  840. }
  841. }
  842. } else {
  843. shouldRunDeleteOnSettingsChange = true
  844. }
  845. }
  846. func preferencesDidChange(_: Preferences) {
  847. autosensMax = settingsManager.preferences.autosensMax
  848. settingHalfBasalTarget = settingsManager.preferences.halfBasalExerciseTarget
  849. highTTraisesSens = settingsManager.preferences.highTemptargetRaisesSensitivity
  850. isExerciseModeActive = settingsManager.preferences.exerciseMode
  851. lowTTlowersSens = settingsManager.preferences.lowTemptargetLowersSensitivity
  852. maxIOB = settingsManager.preferences.maxIOB
  853. }
  854. func pumpSettingsDidChange(_: PumpSettings) {
  855. Task {
  856. await setupPumpSettings()
  857. setupBatteryArray()
  858. }
  859. }
  860. func basalProfileDidChange(_: [BasalProfileEntry]) {
  861. Task {
  862. await setupBasalProfile()
  863. }
  864. }
  865. func bgTargetsDidChange(_: BGTargets) {
  866. Task {
  867. await setupGlucoseTargets()
  868. }
  869. }
  870. func pumpReservoirDidChange(_: Decimal) {
  871. setupReservoir()
  872. displayPumpStatusHighlightMessage()
  873. displayPumpStatusBadge()
  874. }
  875. func pumpDeactivatedDidChange() {
  876. displayPumpStatusHighlightMessage(true)
  877. displayPumpStatusBadge(true)
  878. batteryFromPersistence = []
  879. }
  880. }
  881. extension Home.StateModel: PumpManagerOnboardingDelegate {
  882. func pumpManagerOnboarding(didCreatePumpManager pumpManager: PumpManagerUI) {
  883. provider.apsManager.pumpManager = pumpManager
  884. if let insulinType = pumpManager.status.insulinType {
  885. settingsManager.updateInsulinCurve(insulinType)
  886. }
  887. }
  888. func pumpManagerOnboarding(didOnboardPumpManager _: PumpManagerUI) {
  889. // nothing to do
  890. }
  891. func pumpManagerOnboarding(didPauseOnboarding _: PumpManagerUI) {
  892. // nothing to do
  893. }
  894. }