AppleWatchManager.swift 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021
  1. import Combine
  2. import CoreData
  3. import Foundation
  4. import Swinject
  5. import UIKit
  6. import WatchConnectivity
  7. /// Protocol defining the base functionality for Watch communication
  8. protocol WatchManager {
  9. func setupWatchState() async -> WatchState
  10. }
  11. /// Main implementation of the Watch communication manager
  12. /// Handles bidirectional communication between iPhone and Apple Watch
  13. final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchManager {
  14. private var session: WCSession?
  15. @Injected() var broadcaster: Broadcaster!
  16. @Injected() private var apsManager: APSManager!
  17. @Injected() private var settingsManager: SettingsManager!
  18. @Injected() private var fileStorage: FileStorage!
  19. @Injected() private var glucoseStorage: GlucoseStorage!
  20. @Injected() private var determinationStorage: DeterminationStorage!
  21. @Injected() private var overrideStorage: OverrideStorage!
  22. @Injected() private var tempTargetStorage: TempTargetsStorage!
  23. @Injected() private var bolusCalculationManager: BolusCalculationManager!
  24. private var units: GlucoseUnits = .mgdL
  25. private var glucoseColorScheme: GlucoseColorScheme = .staticColor
  26. private var lowGlucose: Decimal = 70.0
  27. private var highGlucose: Decimal = 180.0
  28. private var currentGlucoseTarget: Decimal = 100.0
  29. private var activeBolusAmount: Double = 0.0
  30. // Queue for handling Core Data change notifications
  31. private let queue = DispatchQueue(label: "BaseWatchManagerManager.queue", qos: .utility)
  32. private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
  33. private var subscriptions = Set<AnyCancellable>()
  34. typealias PumpEvent = PumpEventStored.EventType
  35. let backgroundContext = CoreDataStack.shared.newTaskContext()
  36. let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  37. init(resolver: Resolver) {
  38. super.init()
  39. injectServices(resolver)
  40. setupWatchSession()
  41. units = settingsManager.settings.units
  42. glucoseColorScheme = settingsManager.settings.glucoseColorScheme
  43. lowGlucose = settingsManager.settings.low
  44. highGlucose = settingsManager.settings.high
  45. Task {
  46. currentGlucoseTarget = await getCurrentGlucoseTarget() ?? Decimal(100)
  47. }
  48. broadcaster.register(SettingsObserver.self, observer: self)
  49. broadcaster.register(PumpSettingsObserver.self, observer: self)
  50. // Observer for OrefDetermination and adjustments
  51. coreDataPublisher =
  52. changedObjectsOnManagedObjectContextDidSavePublisher()
  53. .receive(on: DispatchQueue.global(qos: .background))
  54. .share()
  55. .eraseToAnyPublisher()
  56. // Observer for glucose and manual glucose
  57. glucoseStorage.updatePublisher
  58. .receive(on: DispatchQueue.global(qos: .background))
  59. .sink { [weak self] _ in
  60. guard let self = self else { return }
  61. Task {
  62. let state = await self.setupWatchState()
  63. await self.sendDataToWatch(state)
  64. }
  65. }
  66. .store(in: &subscriptions)
  67. registerHandlers()
  68. subscribeToBolusProgress()
  69. }
  70. private func registerHandlers() {
  71. coreDataPublisher?.filterByEntityName("OrefDetermination").sink { [weak self] _ in
  72. guard let self = self else { return }
  73. Task {
  74. let state = await self.setupWatchState()
  75. await self.sendDataToWatch(state)
  76. }
  77. }.store(in: &subscriptions)
  78. // Due to the Batch insert this only is used for observing Deletion of Glucose entries
  79. coreDataPublisher?.filterByEntityName("GlucoseStored").sink { [weak self] _ in
  80. guard let self = self else { return }
  81. Task {
  82. let state = await self.setupWatchState()
  83. await self.sendDataToWatch(state)
  84. }
  85. }.store(in: &subscriptions)
  86. coreDataPublisher?.filterByEntityName("PumpEventStored").sink { [weak self] _ in
  87. guard let self = self else { return }
  88. Task {
  89. await self.getActiveBolusAmount()
  90. }
  91. }.store(in: &subscriptions)
  92. coreDataPublisher?.filterByEntityName("OverrideStored").sink { [weak self] _ in
  93. guard let self = self else { return }
  94. Task {
  95. let state = await self.setupWatchState()
  96. await self.sendDataToWatch(state)
  97. }
  98. }.store(in: &subscriptions)
  99. coreDataPublisher?.filterByEntityName("TempTargetStored").sink { [weak self] _ in
  100. guard let self = self else { return }
  101. Task {
  102. let state = await self.setupWatchState()
  103. await self.sendDataToWatch(state)
  104. }
  105. }.store(in: &subscriptions)
  106. }
  107. /// Sets up the WatchConnectivity session if the device supports it
  108. private func setupWatchSession() {
  109. if WCSession.isSupported() {
  110. let session = WCSession.default
  111. session.delegate = self
  112. session.activate()
  113. self.session = session
  114. debug(.watchManager, "📱 Phone session setup - isPaired: \(session.isPaired)")
  115. } else {
  116. debug(.watchManager, "📱 WCSession is not supported on this device")
  117. }
  118. }
  119. /// Attempts to reestablish the Watch connection if it becomes unreachable
  120. private func retryConnection() {
  121. guard let session = session else { return }
  122. if !session.isReachable {
  123. debug(.watchManager, "📱 Attempting to reactivate session...")
  124. session.activate()
  125. }
  126. }
  127. /// Prepares the current state data to be sent to the Watch
  128. /// - Returns: WatchState containing current glucose readings and trends and determination infos for displaying cob and iob in the view
  129. func setupWatchState() async -> WatchState {
  130. // Get NSManagedObjectIDs
  131. let glucoseIds = await fetchGlucose()
  132. // TODO: - if we want that the watch immediately displays updated cob and iob values when entered via treatment view from phone, we would need to use a predicate here that also filters for NON-ENACTED Determinations
  133. let determinationIds = await determinationStorage.fetchLastDeterminationObjectID(
  134. predicate: NSPredicate.predicateFor30MinAgoForDetermination
  135. )
  136. let overridePresetIds = await overrideStorage.fetchForOverridePresets()
  137. let tempTargetPresetIds = await tempTargetStorage.fetchForTempTargetPresets()
  138. // Get NSManagedObjects
  139. let glucoseObjects: [GlucoseStored] = await CoreDataStack.shared
  140. .getNSManagedObject(with: glucoseIds, context: backgroundContext)
  141. let determinationObjects: [OrefDetermination] = await CoreDataStack.shared
  142. .getNSManagedObject(with: determinationIds, context: backgroundContext)
  143. let overridePresetObjects: [OverrideStored] = await CoreDataStack.shared
  144. .getNSManagedObject(with: overridePresetIds, context: backgroundContext)
  145. let tempTargetPresetObjects: [TempTargetStored] = await CoreDataStack.shared
  146. .getNSManagedObject(with: tempTargetPresetIds, context: backgroundContext)
  147. return await backgroundContext.perform {
  148. var watchState = WatchState(date: Date())
  149. // Set lastLoopDate
  150. let lastLoopMinutes = Int((Date().timeIntervalSince(self.apsManager.lastLoopDate) - 30) / 60) + 1
  151. if lastLoopMinutes > 1440 {
  152. watchState.lastLoopTime = "--"
  153. } else {
  154. watchState.lastLoopTime = "\(lastLoopMinutes) min"
  155. }
  156. // Set IOB and COB from latest determination
  157. if let latestDetermination = determinationObjects.first {
  158. let iob = latestDetermination.iob ?? 0
  159. watchState.iob = Formatter.decimalFormatterWithTwoFractionDigits.string(from: iob)
  160. let cob = NSNumber(value: latestDetermination.cob)
  161. watchState.cob = Formatter.integerFormatter.string(from: cob)
  162. }
  163. // Set override presets with their enabled status
  164. watchState.overridePresets = overridePresetObjects.map { override in
  165. OverridePresetWatch(
  166. name: override.name ?? "",
  167. isEnabled: override.enabled
  168. )
  169. }
  170. guard let latestGlucose = glucoseObjects.first else {
  171. return watchState
  172. }
  173. // Assign currentGlucose and its color
  174. /// Set current glucose with proper formatting
  175. if self.units == .mgdL {
  176. watchState.currentGlucose = "\(latestGlucose.glucose)"
  177. } else {
  178. let mgdlValue = Decimal(latestGlucose.glucose)
  179. let latestGlucoseValue = mgdlValue.formattedAsMmolL
  180. watchState.currentGlucose = "\(latestGlucoseValue)"
  181. }
  182. /// Calculate latest color
  183. let hardCodedLow = Decimal(55)
  184. let hardCodedHigh = Decimal(220)
  185. let isDynamicColorScheme = self.glucoseColorScheme == .dynamicColor
  186. let highGlucoseValue = isDynamicColorScheme ? hardCodedHigh : self.highGlucose
  187. let lowGlucoseValue = isDynamicColorScheme ? hardCodedLow : self.lowGlucose
  188. let highGlucoseColorValue = highGlucoseValue
  189. let lowGlucoseColorValue = lowGlucoseValue
  190. let targetGlucose = self.currentGlucoseTarget
  191. let currentGlucoseColor = Trio.getDynamicGlucoseColor(
  192. glucoseValue: Decimal(latestGlucose.glucose),
  193. highGlucoseColorValue: highGlucoseColorValue,
  194. lowGlucoseColorValue: lowGlucoseColorValue,
  195. targetGlucose: targetGlucose,
  196. glucoseColorScheme: self.glucoseColorScheme
  197. )
  198. if Decimal(latestGlucose.glucose) <= self.lowGlucose || Decimal(latestGlucose.glucose) >= self.highGlucose {
  199. watchState.currentGlucoseColorString = currentGlucoseColor.toHexString()
  200. } else {
  201. watchState.currentGlucoseColorString = "#ffffff" // white when in range; colored when out of range
  202. }
  203. // Map glucose values
  204. watchState.glucoseValues = glucoseObjects.compactMap { glucose in
  205. let glucoseValue = self.units == .mgdL
  206. ? Double(glucose.glucose)
  207. : Double(truncating: Decimal(glucose.glucose).asMmolL as NSNumber)
  208. let glucoseColor = Trio.getDynamicGlucoseColor(
  209. glucoseValue: Decimal(glucose.glucose),
  210. highGlucoseColorValue: highGlucoseColorValue,
  211. lowGlucoseColorValue: lowGlucoseColorValue,
  212. targetGlucose: targetGlucose,
  213. glucoseColorScheme: self.glucoseColorScheme
  214. )
  215. return WatchGlucoseObject(date: glucose.date ?? Date(), glucose: glucoseValue, color: glucoseColor.toHexString())
  216. }
  217. .sorted { $0.date < $1.date }
  218. // Convert direction to trend string
  219. watchState.trend = latestGlucose.direction
  220. // Calculate delta if we have at least 2 readings
  221. if glucoseObjects.count >= 2 {
  222. var deltaValue = Decimal(glucoseObjects[0].glucose - glucoseObjects[1].glucose)
  223. if self.units == .mmolL {
  224. deltaValue = Double(truncating: deltaValue as NSNumber).asMmolL
  225. }
  226. let formattedDelta = Formatter.glucoseFormatter(for: self.units)
  227. .string(from: deltaValue as NSNumber) ?? "0"
  228. watchState.delta = deltaValue < 0 ? "\(formattedDelta)" : "+\(formattedDelta)"
  229. }
  230. // Set temp target presets with their enabled status
  231. watchState.tempTargetPresets = tempTargetPresetObjects.map { tempTarget in
  232. TempTargetPresetWatch(
  233. name: tempTarget.name ?? "",
  234. isEnabled: tempTarget.enabled
  235. )
  236. }
  237. // Set units
  238. watchState.units = self.units
  239. // Add limits and pump specific dosing increment settings values
  240. watchState.maxBolus = self.settingsManager.pumpSettings.maxBolus
  241. watchState.maxCarbs = self.settingsManager.settings.maxCarbs
  242. watchState.maxFat = self.settingsManager.settings.maxFat
  243. watchState.maxProtein = self.settingsManager.settings.maxProtein
  244. watchState.bolusIncrement = self.settingsManager.preferences.bolusIncrement
  245. watchState.confirmBolusFaster = self.settingsManager.settings.confirmBolusFaster
  246. debug(
  247. .watchManager,
  248. "📱 Setup WatchState - currentGlucose: \(watchState.currentGlucose ?? "nil"), trend: \(watchState.trend ?? "nil"), delta: \(watchState.delta ?? "nil"), values: \(watchState.glucoseValues.count)"
  249. )
  250. return watchState
  251. }
  252. }
  253. /// Fetches recent glucose readings from CoreData
  254. /// - Returns: Array of NSManagedObjectIDs for glucose readings
  255. private func fetchGlucose() async -> [NSManagedObjectID] {
  256. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  257. ofType: GlucoseStored.self,
  258. onContext: backgroundContext,
  259. predicate: NSPredicate.glucose,
  260. key: "date",
  261. ascending: false,
  262. fetchLimit: 288
  263. )
  264. return await backgroundContext.perform {
  265. guard let fetchedResults = results as? [GlucoseStored] else { return [] }
  266. return fetchedResults.map(\.objectID)
  267. }
  268. }
  269. /// Fetches last pump event that is a non-external bolus from CoreData
  270. /// - Returns: NSManagedObjectIDs for last bolus
  271. func fetchLastBolus() async -> NSManagedObjectID? {
  272. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  273. ofType: PumpEventStored.self,
  274. onContext: backgroundContext,
  275. predicate: NSPredicate.lastPumpBolus,
  276. key: "timestamp",
  277. ascending: false,
  278. fetchLimit: 1
  279. )
  280. return await backgroundContext.perform {
  281. guard let fetchedResults = results as? [PumpEventStored] else { return [].first }
  282. return fetchedResults.map(\.objectID).first
  283. }
  284. }
  285. /// Gets the active bolus amount by fetching last (active) bolus.
  286. @MainActor func getActiveBolusAmount() async {
  287. if let lastBolusObjectId = await fetchLastBolus() {
  288. let lastBolusObject: [PumpEventStored] = await CoreDataStack.shared
  289. .getNSManagedObject(with: [lastBolusObjectId], context: viewContext)
  290. activeBolusAmount = lastBolusObject.first?.bolus?.amount?.doubleValue ?? 0.0
  291. }
  292. }
  293. // MARK: - Send to Watch
  294. /// Sends the state of type WatchState to the connected Watch
  295. /// - Parameter state: Current WatchState containing glucose data to be sent
  296. @MainActor func sendDataToWatch(_ state: WatchState) async {
  297. guard let session = session else { return }
  298. guard session.isPaired else {
  299. debug(.watchManager, "⌚️❌ No Watch is paired")
  300. return
  301. }
  302. guard session.isWatchAppInstalled else {
  303. debug(.watchManager, "⌚️❌ Trio Watch app is")
  304. return
  305. }
  306. guard session.activationState == .activated else {
  307. let activationStateString = "\(session.activationState)"
  308. debug(.watchManager, "⌚️ Watch session activationState = \(activationStateString). Reactivating...")
  309. session.activate()
  310. return
  311. }
  312. let message: [String: Any] = [
  313. WatchMessageKeys.date: Date().timeIntervalSince1970,
  314. WatchMessageKeys.currentGlucose: state.currentGlucose ?? "--",
  315. WatchMessageKeys.currentGlucoseColorString: state.currentGlucoseColorString ?? "#ffffff",
  316. WatchMessageKeys.trend: state.trend ?? "",
  317. WatchMessageKeys.delta: state.delta ?? "",
  318. WatchMessageKeys.iob: state.iob ?? "",
  319. WatchMessageKeys.cob: state.cob ?? "",
  320. WatchMessageKeys.lastLoopTime: state.lastLoopTime ?? "",
  321. WatchMessageKeys.glucoseValues: state.glucoseValues.map { value in
  322. [
  323. "glucose": value.glucose,
  324. "date": value.date.timeIntervalSince1970,
  325. "color": value.color
  326. ]
  327. },
  328. WatchMessageKeys.overridePresets: state.overridePresets.map { preset in
  329. [
  330. "name": preset.name,
  331. "isEnabled": preset.isEnabled
  332. ]
  333. },
  334. WatchMessageKeys.tempTargetPresets: state.tempTargetPresets.map { preset in
  335. [
  336. "name": preset.name,
  337. "isEnabled": preset.isEnabled
  338. ]
  339. },
  340. WatchMessageKeys.maxBolus: state.maxBolus,
  341. WatchMessageKeys.maxCarbs: state.maxCarbs,
  342. WatchMessageKeys.maxFat: state.maxFat,
  343. WatchMessageKeys.maxProtein: state.maxProtein,
  344. WatchMessageKeys.bolusIncrement: state.bolusIncrement,
  345. WatchMessageKeys.confirmBolusFaster: state.confirmBolusFaster
  346. ]
  347. // if session is reachable, it means watch App is in the foreground -> send watchState as message
  348. // if session is not reachable, it means it's in background -> send watchState as userInfo
  349. if session.isReachable {
  350. session.sendMessage([WatchMessageKeys.watchState: message], replyHandler: nil) { error in
  351. debug(.watchManager, "❌ Error sending watch state: \(error.localizedDescription)")
  352. }
  353. } else {
  354. session.transferUserInfo([WatchMessageKeys.watchState: message])
  355. }
  356. }
  357. func sendAcknowledgment(toWatch success: Bool, message: String = "") {
  358. guard let session = session, session.isReachable else {
  359. debug(.watchManager, "⌚️ Watch not reachable for acknowledgment")
  360. return
  361. }
  362. let ackMessage: [String: Any] = [
  363. WatchMessageKeys.acknowledged: success,
  364. WatchMessageKeys.message: message
  365. ]
  366. session.sendMessage(ackMessage, replyHandler: nil) { error in
  367. debug(.watchManager, "❌ Error sending acknowledgment: \(error.localizedDescription)")
  368. }
  369. }
  370. // MARK: - WCSessionDelegate
  371. func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
  372. if let error = error {
  373. debug(.watchManager, "📱 Phone session activation failed: \(error.localizedDescription)")
  374. return
  375. }
  376. debug(.watchManager, "📱 Phone session activated with state: \(activationState.rawValue)")
  377. debug(.watchManager, "📱 Phone isReachable after activation: \(session.isReachable)")
  378. // Try to send initial data after activation
  379. Task {
  380. let state = await self.setupWatchState()
  381. await self.sendDataToWatch(state)
  382. }
  383. }
  384. func session(_: WCSession, didReceiveMessage message: [String: Any]) {
  385. DispatchQueue.main.async { [weak self] in
  386. // Check Watch State Update Request first
  387. if let requestWatchUpdate = message[WatchMessageKeys.requestWatchUpdate] as? String,
  388. requestWatchUpdate == WatchMessageKeys.watchState
  389. {
  390. debug(.watchManager, "📱 Watch requested watch state data update.")
  391. guard let self = self else { return }
  392. Task {
  393. let state = await self.setupWatchState()
  394. await self.sendDataToWatch(state)
  395. }
  396. return
  397. }
  398. if let bolusAmount = message[WatchMessageKeys.bolus] as? Double,
  399. message[WatchMessageKeys.carbs] == nil,
  400. message[WatchMessageKeys.date] == nil
  401. {
  402. debug(.watchManager, "📱 Received bolus request from watch: \(bolusAmount)U")
  403. self?.handleBolusRequest(Decimal(bolusAmount))
  404. } else if let carbsAmount = message[WatchMessageKeys.carbs] as? Int,
  405. let timestamp = message[WatchMessageKeys.date] as? TimeInterval,
  406. message[WatchMessageKeys.bolus] == nil
  407. {
  408. let date = Date(timeIntervalSince1970: timestamp)
  409. debug(.watchManager, "📱 Received carbs request from watch: \(carbsAmount)g at \(date)")
  410. self?.handleCarbsRequest(carbsAmount, date)
  411. } else if let bolusAmount = message[WatchMessageKeys.bolus] as? Double,
  412. let carbsAmount = message[WatchMessageKeys.carbs] as? Int,
  413. let timestamp = message[WatchMessageKeys.date] as? TimeInterval
  414. {
  415. let date = Date(timeIntervalSince1970: timestamp)
  416. debug(
  417. .watchManager,
  418. "📱 Received meal bolus combo request from watch: \(bolusAmount)U, \(carbsAmount)g at \(date)"
  419. )
  420. self?.handleCombinedRequest(bolusAmount: Decimal(bolusAmount), carbsAmount: Decimal(carbsAmount), date: date)
  421. } else {
  422. debug(.watchManager, "📱 Invalid or incomplete data received from watch. Received: \(message)")
  423. }
  424. if message[WatchMessageKeys.cancelOverride] as? Bool == true {
  425. debug(.watchManager, "📱 Received cancel override request from watch")
  426. self?.handleCancelOverride()
  427. }
  428. if let presetName = message[WatchMessageKeys.activateOverride] as? String {
  429. debug(.watchManager, "📱 Received activate override request from watch for preset: \(presetName)")
  430. self?.handleActivateOverride(presetName)
  431. }
  432. if let presetName = message[WatchMessageKeys.activateTempTarget] as? String {
  433. debug(.watchManager, "📱 Received activate temp target request from watch for preset: \(presetName)")
  434. self?.handleActivateTempTarget(presetName)
  435. }
  436. if message[WatchMessageKeys.cancelTempTarget] as? Bool == true {
  437. debug(.watchManager, "📱 Received cancel temp target request from watch")
  438. self?.handleCancelTempTarget()
  439. }
  440. // Handle bolus cancellation
  441. if message[WatchMessageKeys.cancelBolus] as? Bool == true {
  442. Task {
  443. await self?.apsManager.cancelBolus { [self] success, message in
  444. // Acknowledge success or error of bolus
  445. self?.sendAcknowledgment(toWatch: success, message: message)
  446. }
  447. debug(.watchManager, "📱 Bolus cancelled from watch")
  448. // perform determine basal sync, otherwise you could end up with too much IOB when opening the calculator again
  449. await self?.apsManager.determineBasalSync()
  450. }
  451. }
  452. if message[WatchMessageKeys.requestBolusRecommendation] as? Bool == true {
  453. let carbs = message[WatchMessageKeys.carbs] as? Int ?? 0
  454. Task { [weak self] in
  455. guard let self = self else { return }
  456. // Get recommendation from BolusCalculationManager
  457. let result = await bolusCalculationManager.handleBolusCalculation(
  458. carbs: Decimal(carbs),
  459. useFattyMealCorrection: false,
  460. useSuperBolus: false
  461. )
  462. // Send recommendation back to watch
  463. let recommendationMessage: [String: Any] = [
  464. WatchMessageKeys.recommendedBolus: NSDecimalNumber(decimal: result.insulinCalculated)
  465. ]
  466. if let session = self.session, session.isReachable {
  467. print("📱 Sending recommendedBolus: \(result.insulinCalculated)")
  468. session.sendMessage(recommendationMessage, replyHandler: nil)
  469. }
  470. }
  471. return
  472. }
  473. }
  474. }
  475. #if os(iOS)
  476. func sessionDidBecomeInactive(_: WCSession) {}
  477. func sessionDidDeactivate(_ session: WCSession) {
  478. session.activate()
  479. }
  480. #endif
  481. func sessionReachabilityDidChange(_ session: WCSession) {
  482. debug(.watchManager, "📱 Phone reachability changed: \(session.isReachable)")
  483. if session.isReachable {
  484. // Try to send data when connection is established
  485. Task {
  486. let state = await self.setupWatchState()
  487. await self.sendDataToWatch(state)
  488. }
  489. } else {
  490. // Try to reconnect after a short delay
  491. DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
  492. self?.retryConnection()
  493. }
  494. }
  495. }
  496. /// Processes bolus requests received from the Watch
  497. /// - Parameter amount: The requested bolus amount in units
  498. private func handleBolusRequest(_ amount: Decimal) {
  499. Task {
  500. await apsManager.enactBolus(amount: Double(amount), isSMB: false) { success, message in
  501. // Acknowledge success or error of bolus
  502. self.sendAcknowledgment(toWatch: success, message: message)
  503. }
  504. debug(.watchManager, "📱 Enacted bolus via APS Manager: \(amount)U")
  505. }
  506. }
  507. /// Handles carbs entry requests received from the Watch
  508. /// - Parameters:
  509. /// - amount: The carbs amount in grams
  510. /// - date: Timestamp for the carbs entry
  511. private func handleCarbsRequest(_ amount: Int, _ date: Date) {
  512. Task {
  513. let context = CoreDataStack.shared.newTaskContext()
  514. await context.perform {
  515. let carbEntry = CarbEntryStored(context: context)
  516. carbEntry.id = UUID()
  517. carbEntry.carbs = Double(truncating: amount as NSNumber)
  518. carbEntry.date = date
  519. carbEntry.note = "Via Watch"
  520. carbEntry.isFPU = false // set this to false to ensure watch-entered carbs are displayed in main chart
  521. carbEntry.isUploadedToNS = false
  522. do {
  523. guard context.hasChanges else { return }
  524. try context.save()
  525. debug(.watchManager, "📱 Saved carbs from watch: \(amount)g at \(date)")
  526. // Acknowledge success
  527. self.sendAcknowledgment(toWatch: true, message: "Carbs logged successfully.")
  528. } catch {
  529. debug(.watchManager, "❌ Error saving carbs: \(error.localizedDescription)")
  530. // Acknowledge failure
  531. self.sendAcknowledgment(toWatch: false, message: "Error logging carbs")
  532. }
  533. }
  534. }
  535. }
  536. /// Handles combined bolus and carbs entry requests received from the Watch.
  537. /// - Parameters:
  538. /// - bolusAmount: The bolus amount in units
  539. /// - carbsAmount: The carbs amount in grams
  540. /// - date: Timestamp for the carbs entry
  541. private func handleCombinedRequest(bolusAmount: Decimal, carbsAmount: Decimal, date: Date) {
  542. Task {
  543. let context = CoreDataStack.shared.newTaskContext()
  544. do {
  545. // Notify Watch: "Saving carbs..."
  546. self.sendAcknowledgment(toWatch: true, message: "Saving Carbs...")
  547. // Save carbs entry in Core Data
  548. try await context.perform {
  549. let carbEntry = CarbEntryStored(context: context)
  550. carbEntry.id = UUID()
  551. carbEntry.carbs = NSDecimalNumber(decimal: carbsAmount).doubleValue
  552. carbEntry.date = date
  553. carbEntry.note = "Via Watch"
  554. carbEntry.isFPU = false // set this to false to ensure watch-entered carbs are displayed in main chart
  555. carbEntry.isUploadedToNS = false
  556. guard context.hasChanges else { return }
  557. try context.save()
  558. debug(.watchManager, "📱 Saved carbs from watch: \(carbsAmount) g at \(date)")
  559. }
  560. // Notify Watch: "Enacting bolus..."
  561. sendAcknowledgment(toWatch: true, message: "Enacting bolus...")
  562. // Enact bolus via APS Manager
  563. let bolusDouble = NSDecimalNumber(decimal: bolusAmount).doubleValue
  564. await apsManager.enactBolus(amount: bolusDouble, isSMB: false) { success, message in
  565. // Acknowledge success or error of bolus
  566. self.sendAcknowledgment(toWatch: success, message: message)
  567. }
  568. debug(.watchManager, "📱 Enacted bolus from watch via APS Manager: \(bolusDouble) U")
  569. // Notify Watch: "Carbs and bolus logged successfully"
  570. sendAcknowledgment(toWatch: true, message: "Carbs and Bolus logged successfully.")
  571. } catch {
  572. debug(.watchManager, "❌ Error processing combined request: \(error.localizedDescription)")
  573. sendAcknowledgment(toWatch: false, message: "Failed to log carbs and bolus")
  574. }
  575. }
  576. }
  577. private func handleCancelOverride() {
  578. Task {
  579. let context = CoreDataStack.shared.newTaskContext()
  580. if let overrideId = await overrideStorage.fetchLatestActiveOverride() {
  581. let override = await context.perform {
  582. context.object(with: overrideId) as? OverrideStored
  583. }
  584. await context.perform {
  585. if let activeOverride = override {
  586. activeOverride.enabled = false
  587. do {
  588. guard context.hasChanges else { return }
  589. try context.save()
  590. debug(.watchManager, "📱 Successfully stopped override")
  591. // Send notification to update Adjustments UI
  592. Foundation.NotificationCenter.default.post(
  593. name: .didUpdateOverrideConfiguration,
  594. object: nil
  595. )
  596. // Acknowledge cancellation success
  597. self.sendAcknowledgment(toWatch: true, message: "Stopped Override successfully.")
  598. } catch {
  599. debug(.watchManager, "❌ Error cancelling override: \(error.localizedDescription)")
  600. // Acknowledge cancellation error
  601. self.sendAcknowledgment(toWatch: false, message: "Error stopping Override.")
  602. }
  603. }
  604. }
  605. }
  606. }
  607. }
  608. private func handleActivateOverride(_ presetName: String) {
  609. Task {
  610. let context = CoreDataStack.shared.newTaskContext()
  611. // Fetch all presets to find the one to activate
  612. let presetIds = await overrideStorage.fetchForOverridePresets()
  613. let presets: [OverrideStored] = await CoreDataStack.shared
  614. .getNSManagedObject(with: presetIds, context: context)
  615. // Check for active override
  616. if let activeOverrideId = await overrideStorage.fetchLatestActiveOverride() {
  617. let activeOverride = await context.perform {
  618. context.object(with: activeOverrideId) as? OverrideStored
  619. }
  620. // Deactivate if exists
  621. if let override = activeOverride {
  622. await context.perform {
  623. override.enabled = false
  624. }
  625. }
  626. }
  627. // Activate the selected preset
  628. await context.perform {
  629. if let presetToActivate = presets.first(where: { $0.name == presetName }) {
  630. presetToActivate.enabled = true
  631. presetToActivate.date = Date()
  632. do {
  633. guard context.hasChanges else { return }
  634. try context.save()
  635. debug(.watchManager, "📱 Successfully activated override: \(presetName)")
  636. // Send notification to update Adjustments UI
  637. Foundation.NotificationCenter.default.post(
  638. name: .didUpdateOverrideConfiguration,
  639. object: nil
  640. )
  641. // Acknowledge activation success
  642. self.sendAcknowledgment(toWatch: true, message: "Started Override \"\(presetName)\" successfully.")
  643. } catch {
  644. debug(.watchManager, "❌ Error activating override: \(error.localizedDescription)")
  645. // Acknowledge activation error
  646. self.sendAcknowledgment(toWatch: false, message: "Error activating Override \"\(presetName)\".")
  647. }
  648. }
  649. }
  650. }
  651. }
  652. private func handleActivateTempTarget(_ presetName: String) {
  653. Task {
  654. let context = CoreDataStack.shared.newTaskContext()
  655. // Fetch all presets to find the one to activate
  656. let presetIds = await tempTargetStorage.fetchForTempTargetPresets()
  657. let presets: [TempTargetStored] = await CoreDataStack.shared
  658. .getNSManagedObject(with: presetIds, context: context)
  659. // Check for active temp target
  660. if let activeTempTargetId = await tempTargetStorage.loadLatestTempTargetConfigurations(fetchLimit: 1).first {
  661. let activeTempTarget = await context.perform {
  662. context.object(with: activeTempTargetId) as? TempTargetStored
  663. }
  664. // Deactivate if exists
  665. if let tempTarget = activeTempTarget {
  666. await context.perform {
  667. tempTarget.enabled = false
  668. }
  669. }
  670. }
  671. // Activate the selected preset
  672. await context.perform {
  673. if let presetToActivate = presets.first(where: { $0.name == presetName }) {
  674. presetToActivate.enabled = true
  675. presetToActivate.date = Date()
  676. do {
  677. guard context.hasChanges else { return }
  678. try context.save()
  679. debug(.watchManager, "📱 Successfully activated temp target: \(presetName)")
  680. let settingsHalfBasalTarget = self.settingsManager.preferences
  681. .halfBasalExerciseTarget
  682. let halfBasalTarget = presetToActivate.halfBasalTarget?.decimalValue
  683. // To activate the temp target also in oref
  684. let tempTarget = TempTarget(
  685. name: presetToActivate.name,
  686. createdAt: Date(),
  687. targetTop: presetToActivate.target?.decimalValue,
  688. targetBottom: presetToActivate.target?.decimalValue,
  689. duration: presetToActivate.duration?.decimalValue ?? 0,
  690. enteredBy: TempTarget.local,
  691. reason: TempTarget.custom,
  692. isPreset: true,
  693. enabled: true,
  694. halfBasalTarget: halfBasalTarget ?? settingsHalfBasalTarget
  695. )
  696. self.tempTargetStorage.saveTempTargetsToStorage([tempTarget])
  697. // Send notification to update Adjustments UI
  698. Foundation.NotificationCenter.default.post(
  699. name: .didUpdateTempTargetConfiguration,
  700. object: nil
  701. )
  702. // Acknowledge activation success
  703. self.sendAcknowledgment(toWatch: true, message: "Started Temp Target \"\(presetName)\" successfully.")
  704. } catch {
  705. debug(.watchManager, "❌ Error activating temp target: \(error.localizedDescription)")
  706. // Acknowledge activation error
  707. self.sendAcknowledgment(toWatch: false, message: "Error activating Temp Target \"\(presetName)\".")
  708. }
  709. }
  710. }
  711. }
  712. }
  713. private func handleCancelTempTarget() {
  714. Task {
  715. let context = CoreDataStack.shared.newTaskContext()
  716. if let tempTargetId = await tempTargetStorage.loadLatestTempTargetConfigurations(fetchLimit: 1).first {
  717. let tempTarget = await context.perform {
  718. context.object(with: tempTargetId) as? TempTargetStored
  719. }
  720. await context.perform {
  721. if let activeTempTarget = tempTarget {
  722. activeTempTarget.enabled = false
  723. do {
  724. guard context.hasChanges else { return }
  725. try context.save()
  726. debug(.watchManager, "📱 Successfully cancelled temp target")
  727. // To cancel the temp target also for oref
  728. self.tempTargetStorage.saveTempTargetsToStorage([TempTarget.cancel(at: Date())])
  729. // Send notification to update Adjustments UI
  730. Foundation.NotificationCenter.default.post(
  731. name: .didUpdateTempTargetConfiguration,
  732. object: nil
  733. )
  734. // Acknowledge cancellation success
  735. self.sendAcknowledgment(toWatch: true, message: "Stopped Temp Target successfully.")
  736. } catch {
  737. debug(.watchManager, "❌ Error stopping temp target: \(error.localizedDescription)")
  738. // Acknowledge cancellation error
  739. self.sendAcknowledgment(toWatch: false, message: "Error stopping Temp Target.")
  740. }
  741. }
  742. }
  743. }
  744. }
  745. }
  746. /// Subscribes to bolus progress updates and sends progress or cancellation messages to the Watch
  747. private func subscribeToBolusProgress() {
  748. var wasBolusActive = false
  749. apsManager.bolusProgress
  750. .receive(on: DispatchQueue.main)
  751. .sink { [weak self] progress in
  752. if let progress = progress {
  753. wasBolusActive = true
  754. Task {
  755. await self?.sendBolusProgressToWatch(progress: progress)
  756. }
  757. } else if wasBolusActive {
  758. // Only if a bolus was previously active and now nil is received,
  759. // the bolus was cancelled
  760. wasBolusActive = false
  761. self?.activeBolusAmount = 0.0
  762. debug(.watchManager, "📱 Bolus cancelled from phone")
  763. self?.sendBolusCanceledMessageToWatch()
  764. }
  765. }
  766. .store(in: &subscriptions)
  767. }
  768. /// Sends bolus progress updates to the Watch
  769. /// - Parameter progress: The current bolus progress as a Decimal
  770. private func sendBolusProgressToWatch(progress: Decimal?) async {
  771. guard let session = session, let progress = progress, let pumpManager = apsManager.pumpManager else { return }
  772. let message: [String: Any] = [
  773. WatchMessageKeys.bolusProgressTimestamp: Date().timeIntervalSince1970,
  774. WatchMessageKeys.bolusProgress: Double(truncating: progress as NSNumber),
  775. WatchMessageKeys.activeBolusAmount: activeBolusAmount,
  776. WatchMessageKeys.deliveredAmount: pumpManager
  777. .roundToSupportedBolusVolume(units: activeBolusAmount * Double(truncating: progress as NSNumber))
  778. ]
  779. // If the session is not yet activated, try to activate
  780. if session.activationState != .activated {
  781. session.activate()
  782. // Then, queue data for eventual delivery in the background
  783. session.transferUserInfo(message)
  784. return
  785. }
  786. // If we reach here, session should be .activated
  787. if session.isReachable {
  788. // Real-time ephemeral
  789. session.sendMessage(message, replyHandler: nil) { error in
  790. debug(.watchManager, "❌ Error sending bolus progress: \(error.localizedDescription)")
  791. }
  792. } else {
  793. // Fallback to be double safe: queue userInfo for eventual delivery
  794. session.transferUserInfo(message)
  795. }
  796. }
  797. private func sendBolusCanceledMessageToWatch() {
  798. if let session = session, session.isReachable {
  799. let message: [String: Any] = [WatchMessageKeys.bolusCanceled: true]
  800. session.sendMessage(message, replyHandler: nil) { error in
  801. debug(.watchManager, "❌ Error sending bolus cancellation to watch: \(error.localizedDescription)")
  802. }
  803. }
  804. }
  805. }
  806. // TODO: - is there a better approach than setting up the watch state every time a setting has changed?
  807. extension BaseWatchManager: SettingsObserver, PumpSettingsObserver {
  808. // to update maxBolus
  809. func pumpSettingsDidChange(_: PumpSettings) {
  810. Task {
  811. let state = await self.setupWatchState()
  812. await self.sendDataToWatch(state)
  813. }
  814. }
  815. // to update the rest
  816. func settingsDidChange(_: TrioSettings) {
  817. units = settingsManager.settings.units
  818. glucoseColorScheme = settingsManager.settings.glucoseColorScheme
  819. lowGlucose = settingsManager.settings.low
  820. highGlucose = settingsManager.settings.high
  821. Task {
  822. let state = await self.setupWatchState()
  823. await self.sendDataToWatch(state)
  824. }
  825. }
  826. }
  827. extension BaseWatchManager {
  828. /// Retrieves the current glucose target based on the time of day.
  829. private func getCurrentGlucoseTarget() async -> Decimal? {
  830. let now = Date()
  831. let calendar = Calendar.current
  832. let dateFormatter = DateFormatter()
  833. dateFormatter.dateFormat = "HH:mm"
  834. dateFormatter.timeZone = TimeZone.current
  835. let bgTargets = await fileStorage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self)
  836. ?? BGTargets(from: OpenAPS.defaults(for: OpenAPS.Settings.bgTargets))
  837. ?? BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: [])
  838. let entries: [(start: String, value: Decimal)] = bgTargets.targets.map { ($0.start, $0.low) }
  839. for (index, entry) in entries.enumerated() {
  840. guard let entryTime = dateFormatter.date(from: entry.start) else {
  841. print("Invalid entry start time: \(entry.start)")
  842. continue
  843. }
  844. let entryComponents = calendar.dateComponents([.hour, .minute, .second], from: entryTime)
  845. let entryStartTime = calendar.date(
  846. bySettingHour: entryComponents.hour!,
  847. minute: entryComponents.minute!,
  848. second: entryComponents.second!,
  849. of: now
  850. )!
  851. let entryEndTime: Date
  852. if index < entries.count - 1,
  853. let nextEntryTime = dateFormatter.date(from: entries[index + 1].start)
  854. {
  855. let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
  856. entryEndTime = calendar.date(
  857. bySettingHour: nextEntryComponents.hour!,
  858. minute: nextEntryComponents.minute!,
  859. second: nextEntryComponents.second!,
  860. of: now
  861. )!
  862. } else {
  863. entryEndTime = calendar.date(byAdding: .day, value: 1, to: entryStartTime)!
  864. }
  865. if now >= entryStartTime, now < entryEndTime {
  866. return entry.value
  867. }
  868. }
  869. return nil
  870. }
  871. }