GarminManager.swift 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902
  1. import Combine
  2. import ConnectIQ
  3. import CoreData
  4. import Foundation
  5. import Swinject
  6. // MARK: - GarminManager Protocol
  7. /// Manages Garmin devices, allowing the app to select devices, update a known device list,
  8. /// and send watch-state data to connected Garmin watch apps.
  9. protocol GarminManager {
  10. /// Prompts the user to select Garmin devices, returning the chosen devices in a publisher.
  11. /// - Returns: A publisher that eventually outputs an array of selected `IQDevice` objects.
  12. func selectDevices() -> AnyPublisher<[IQDevice], Never>
  13. /// Updates the currently tracked device list. This typically persists the device list and
  14. /// triggers re-registration for any relevant ConnectIQ events.
  15. /// - Parameter devices: The new array of `IQDevice` objects to track.
  16. func updateDeviceList(_ devices: [IQDevice])
  17. /// Takes raw JSON-encoded watch-state data and dispatches it to any connected watch apps.
  18. /// - Parameter data: The JSON-encoded data representing the watch state.
  19. func sendWatchStateData(_ data: Data)
  20. /// The devices currently known to the app. May be loaded from disk or user selection.
  21. var devices: [IQDevice] { get }
  22. }
  23. // MARK: - BaseGarminManager
  24. /// Concrete implementation of `GarminManager` that handles:
  25. /// - Device registration/unregistration with Garmin ConnectIQ
  26. /// - Data persistence for selected devices
  27. /// - Generating & sending watch-state updates (glucose, IOB, COB, etc.) to Garmin watch apps.
  28. final class BaseGarminManager: NSObject, GarminManager, Injectable {
  29. // MARK: - Dependencies & Properties
  30. /// Observes system-wide notifications, including `.openFromGarminConnect`.
  31. @Injected() private var notificationCenter: NotificationCenter!
  32. /// Broadcaster used for publishing or subscribing to global events (e.g., unit changes).
  33. @Injected() private var broadcaster: Broadcaster!
  34. /// APSManager containing insulin pump logic, e.g., for making bolus requests, reading basal info, etc.
  35. @Injected() private var apsManager: APSManager!
  36. /// Manages local user settings, such as glucose units (mg/dL or mmol/L).
  37. @Injected() private var settingsManager: SettingsManager!
  38. /// Stores, retrieves, and updates glucose data in CoreData.
  39. @Injected() private var glucoseStorage: GlucoseStorage!
  40. /// Stores, retrieves, and updates insulin dose determinations in CoreData.
  41. @Injected() private var determinationStorage: DeterminationStorage!
  42. @Injected() private var iobService: IOBService!
  43. /// Persists the user's device list between app launches.
  44. @Persisted(key: "BaseGarminManager.persistedDevices") private var persistedDevices: [GarminDevice] = []
  45. /// Router for presenting alerts or navigation flows (injected via Swinject).
  46. private let router: Router
  47. /// Garmin ConnectIQ shared instance for watch interactions.
  48. private let connectIQ = ConnectIQ.sharedInstance()
  49. /// Keeps references to watch apps (both watchface & data field) for each registered device.
  50. private var watchApps: [IQApp] = []
  51. /// A set of Combine cancellables for managing the lifecycle of various subscriptions.
  52. private var cancellables = Set<AnyCancellable>()
  53. /// Holds a promise used when the user is selecting devices (via `showDeviceSelection()`).
  54. private var deviceSelectionPromise: Future<[IQDevice], Never>.Promise?
  55. /// Subject for debouncing watch state updates
  56. private let watchStateSubject = PassthroughSubject<Data, Never>()
  57. /// Current glucose units, either mg/dL or mmol/L, read from user settings.
  58. private var units: GlucoseUnits = .mgdL
  59. // MARK: - Debug Logging
  60. /// Enable/disable verbose debug logging for watch state preparation
  61. private let debugWatchState = true
  62. /// Enable/disable general Garmin debug logging (connections, sends, etc.)
  63. private let debugGarminEnabled = true
  64. /// Helper method for conditional Garmin debug logging.
  65. /// Logs messages only if debugGarminEnabled is true.
  66. /// - Parameter message: The debug message to log.
  67. private func debugGarmin(_ message: String) {
  68. guard debugGarminEnabled else { return }
  69. debug(.watchManager, message)
  70. }
  71. // MARK: - Deduplication
  72. /// Hash of last sent data to prevent duplicate broadcasts
  73. private var lastSentDataHash: Int?
  74. /// Hash of last prepared data to skip redundant preparation
  75. private var lastPreparedDataHash: Int?
  76. private var lastPreparedWatchState: [GarminWatchState]?
  77. // MARK: - Glucose/Determination Coordination
  78. /// Delay before sending glucose if determination hasn't arrived (seconds)
  79. /// Based on log analysis: avg delay ~5s, max ~24s, >15s occurs <1% of time
  80. private let glucoseFallbackDelay: TimeInterval = 20
  81. /// Pending glucose fallback task - cancelled if determination arrives first
  82. private var pendingGlucoseFallback: DispatchWorkItem?
  83. /// Queue for glucose fallback timer
  84. private let timerQueue = DispatchQueue(label: "BaseGarminManager.timerQueue", qos: .utility)
  85. /// Queue for handling Core Data change notifications
  86. private let queue = DispatchQueue(label: "BaseGarminManager.queue", qos: .utility)
  87. /// Publishes any changed CoreData objects that match our filters (e.g., OrefDetermination, GlucoseStored).
  88. private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
  89. /// Additional local subscriptions (separate from `cancellables`) for CoreData events.
  90. private var subscriptions = Set<AnyCancellable>()
  91. /// Represents the context for background tasks in CoreData.
  92. let backgroundContext = CoreDataStack.shared.newTaskContext()
  93. /// Represents the main (view) context for CoreData, typically used on the main thread.
  94. let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  95. /// Array of Garmin `IQDevice` objects currently tracked.
  96. /// Changing this property triggers re-registration and updates persisted devices.
  97. private(set) var devices: [IQDevice] = [] {
  98. didSet {
  99. // Persist newly updated device list
  100. persistedDevices = devices.map(GarminDevice.init)
  101. // Re-register for events, app messages, etc.
  102. registerDevices(devices)
  103. }
  104. }
  105. // MARK: - Initialization
  106. /// Creates a new `BaseGarminManager`, injecting required services, restoring any persisted devices,
  107. /// and setting up watchers for data changes (e.g., glucose updates).
  108. /// - Parameter resolver: Swinject resolver for injecting dependencies like the Router.
  109. init(resolver: Resolver) {
  110. router = resolver.resolve(Router.self)!
  111. super.init()
  112. injectServices(resolver)
  113. connectIQ?.initialize(withUrlScheme: "Trio", uiOverrideDelegate: self)
  114. restoreDevices()
  115. subscribeToOpenFromGarminConnect()
  116. subscribeToWatchState()
  117. units = settingsManager.settings.units
  118. broadcaster.register(SettingsObserver.self, observer: self)
  119. coreDataPublisher =
  120. changedObjectsOnManagedObjectContextDidSavePublisher()
  121. .receive(on: queue)
  122. .share()
  123. .eraseToAnyPublisher()
  124. // Glucose updates - start 20s fallback timer
  125. // When loop is working: determination arrives within ~5s, cancels timer, sends complete data
  126. // When loop is slow/failing: timer fires after 20s, sends glucose with stale loop data
  127. // This ensures watch gets fresh glucose even if loop doesn't complete
  128. glucoseStorage.updatePublisher
  129. .receive(on: DispatchQueue.global(qos: .background))
  130. .sink { [weak self] _ in
  131. self?.handleGlucoseUpdate()
  132. }
  133. .store(in: &subscriptions)
  134. // IOB updates - also wait for determination like glucose does
  135. iobService.iobPublisher
  136. .receive(on: DispatchQueue.global(qos: .background))
  137. .sink { [weak self] _ in
  138. self?.handleIOBUpdate()
  139. }
  140. .store(in: &subscriptions)
  141. registerHandlers()
  142. }
  143. // MARK: - Settings Helpers
  144. /// Returns the currently configured Garmin watchface from settings
  145. private var currentWatchface: GarminWatchface {
  146. settingsManager.settings.garminSettings.watchface
  147. }
  148. /// Returns the currently configured Garmin datafield from settings
  149. private var currentDatafield: GarminDatafield {
  150. settingsManager.settings.garminSettings.datafield
  151. }
  152. /// Returns whether watchface data transmission is enabled in settings
  153. private var isWatchfaceDataEnabled: Bool {
  154. settingsManager.settings.garminSettings.isWatchfaceDataEnabled
  155. }
  156. /// SwissAlpine watchface uses historical glucose data (24 entries)
  157. /// Trio watchface only uses current reading
  158. private var needsHistoricalGlucoseData: Bool {
  159. currentWatchface == .swissalpine
  160. }
  161. /// Returns the display name for an app UUID (watchface or datafield).
  162. /// Use this for routine log messages where UUID adds noise.
  163. private func appDisplayName(for uuid: UUID) -> String {
  164. if uuid == currentWatchface.watchfaceUUID {
  165. return "watchface:\(currentWatchface.displayName)"
  166. } else if uuid == currentDatafield.datafieldUUID {
  167. return "datafield:\(currentDatafield.displayName)"
  168. } else {
  169. return "unknown app"
  170. }
  171. }
  172. /// Returns the detailed display name including UUID for an app.
  173. /// Use this for registration/connection messages and error scenarios where UUID identification is valuable.
  174. /// This helps with debugging when multiple versions/distributions exist (local, test, live builds).
  175. private func appDetailedName(for uuid: UUID) -> String {
  176. if uuid == currentWatchface.watchfaceUUID {
  177. return "watchface:\(currentWatchface.displayName) (\(uuid.uuidString))"
  178. } else if uuid == currentDatafield.datafieldUUID {
  179. return "datafield:\(currentDatafield.displayName) (\(uuid.uuidString))"
  180. } else {
  181. return "unknown app (\(uuid.uuidString))"
  182. }
  183. }
  184. // MARK: - Internal Setup / Handlers
  185. /// Sets up handlers for OrefDetermination and GlucoseStored entity changes in CoreData.
  186. /// When these change, we re-compute the Garmin watch state and send updates to the watch.
  187. private func registerHandlers() {
  188. // OrefDetermination changes - debounce at CoreData level
  189. coreDataPublisher?
  190. .filteredByEntityName("OrefDetermination")
  191. .debounce(for: .seconds(2), scheduler: DispatchQueue.main)
  192. .sink { [weak self] _ in
  193. self?.triggerWatchStateUpdate(triggeredBy: "Determination")
  194. }
  195. .store(in: &subscriptions)
  196. }
  197. /// Handles glucose updates with delayed fallback
  198. /// Waits up to 20 seconds for determination to arrive before sending glucose-only update
  199. /// This ensures we send complete data when loop is working, but still update watch if loop is slow/failing
  200. private func handleGlucoseUpdate() {
  201. guard !devices.isEmpty else { return }
  202. // Cancel any existing fallback timer
  203. pendingGlucoseFallback?.cancel()
  204. // Create new fallback task
  205. let fallback = DispatchWorkItem { [weak self] in
  206. guard let self = self else { return }
  207. Task {
  208. do {
  209. self
  210. .debugGarmin(
  211. "Garmin: Glucose fallback timer fired (no determination in \(Int(self.glucoseFallbackDelay))s)"
  212. )
  213. let watchState = try await self.setupGarminWatchState(triggeredBy: "Glucose (fallback)")
  214. let watchStateData = try JSONEncoder().encode(watchState)
  215. self.watchStateSubject.send(watchStateData)
  216. } catch {
  217. debug(.watchManager, "Garmin: Error in glucose fallback: \(error)")
  218. }
  219. }
  220. }
  221. pendingGlucoseFallback = fallback
  222. timerQueue.asyncAfter(deadline: .now() + glucoseFallbackDelay, execute: fallback)
  223. debugGarmin("Garmin: Glucose received - waiting \(Int(glucoseFallbackDelay))s for determination")
  224. }
  225. /// Handles IOB updates with delayed fallback
  226. /// Also waits up to 20 seconds for determination to arrive, restarting the shared timer
  227. /// This prevents IOB changes from triggering premature watch updates before determination arrives
  228. private func handleIOBUpdate() {
  229. guard !devices.isEmpty else { return }
  230. // Cancel any existing fallback timer (restart the 20s window)
  231. pendingGlucoseFallback?.cancel()
  232. // Create new fallback task
  233. let fallback = DispatchWorkItem { [weak self] in
  234. guard let self = self else { return }
  235. Task {
  236. do {
  237. self
  238. .debugGarmin(
  239. "Garmin: IOB fallback timer fired (no determination in \(Int(self.glucoseFallbackDelay))s)"
  240. )
  241. let watchState = try await self.setupGarminWatchState(triggeredBy: "IOB (fallback)")
  242. let watchStateData = try JSONEncoder().encode(watchState)
  243. self.watchStateSubject.send(watchStateData)
  244. } catch {
  245. debug(.watchManager, "Garmin: Error in IOB fallback: \(error)")
  246. }
  247. }
  248. }
  249. pendingGlucoseFallback = fallback
  250. timerQueue.asyncAfter(deadline: .now() + glucoseFallbackDelay, execute: fallback)
  251. debugGarmin("Garmin: IOB received - waiting \(Int(glucoseFallbackDelay))s for determination")
  252. }
  253. /// Triggers watch state preparation and sends to debounce subject
  254. /// If triggered by Determination, cancels pending glucose fallback timer
  255. private func triggerWatchStateUpdate(triggeredBy trigger: String) {
  256. guard !devices.isEmpty else { return }
  257. // If determination arrived, cancel the glucose fallback timer
  258. // Determination includes both fresh glucose and loop data
  259. if trigger == "Determination" {
  260. if pendingGlucoseFallback != nil {
  261. pendingGlucoseFallback?.cancel()
  262. pendingGlucoseFallback = nil
  263. debugGarmin("Garmin: Determination arrived - cancelled glucose fallback timer")
  264. }
  265. }
  266. Task {
  267. do {
  268. let watchState = try await setupGarminWatchState(triggeredBy: trigger)
  269. let watchStateData = try JSONEncoder().encode(watchState)
  270. watchStateSubject.send(watchStateData)
  271. } catch {
  272. debug(.watchManager, "Garmin: Error preparing watch state (\(trigger)): \(error)")
  273. }
  274. }
  275. }
  276. // MARK: - CoreData Fetch Methods
  277. /// Fetches recent glucose readings from CoreData, up to specified limit.
  278. /// - Parameter limit: Maximum number of glucose entries to fetch (default: 2)
  279. /// - Returns: An array of `NSManagedObjectID`s for glucose readings.
  280. private func fetchGlucose(limit: Int = 2) async throws -> [NSManagedObjectID] {
  281. let results = try await CoreDataStack.shared.fetchEntitiesAsync(
  282. ofType: GlucoseStored.self,
  283. onContext: backgroundContext,
  284. predicate: NSPredicate.glucose,
  285. key: "date",
  286. ascending: false,
  287. fetchLimit: limit
  288. )
  289. return try await backgroundContext.perform {
  290. guard let fetchedResults = results as? [GlucoseStored] else {
  291. throw CoreDataError.fetchError(function: #function, file: #file)
  292. }
  293. return fetchedResults.map(\.objectID)
  294. }
  295. }
  296. /// Fetches the most recent temporary basal rate from CoreData pump history.
  297. /// - Returns: An array containing the NSManagedObjectID of the latest temp basal event, if any.
  298. private func fetchTempBasals() async throws -> [NSManagedObjectID] {
  299. let tempBasalPredicate = NSPredicate(format: "tempBasal != nil")
  300. let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
  301. NSPredicate.pumpHistoryLast24h,
  302. tempBasalPredicate
  303. ])
  304. let results = try await CoreDataStack.shared.fetchEntitiesAsync(
  305. ofType: PumpEventStored.self,
  306. onContext: backgroundContext,
  307. predicate: compoundPredicate,
  308. key: "timestamp",
  309. ascending: false,
  310. fetchLimit: 1
  311. )
  312. return try await backgroundContext.perform {
  313. guard let pumpEvents = results as? [PumpEventStored] else {
  314. throw CoreDataError.fetchError(function: #function, file: #file)
  315. }
  316. return pumpEvents.map(\.objectID)
  317. }
  318. }
  319. /// Fetches all determinations from the last 30 minutes (no fetch limit).
  320. /// Returns them sorted newest first, allowing us to find both enacted and suggested determinations.
  321. /// - Returns: An array of `NSManagedObjectID`s for all determinations in the 30-minute window.
  322. private func fetchDeterminations30Min() async throws -> [NSManagedObjectID] {
  323. let results = try await CoreDataStack.shared.fetchEntitiesAsync(
  324. ofType: OrefDetermination.self,
  325. onContext: backgroundContext,
  326. predicate: NSPredicate.predicateFor30MinAgoForDetermination,
  327. key: "deliverAt",
  328. ascending: false,
  329. fetchLimit: 0 // No limit - get all determinations in 30min window
  330. )
  331. return try await backgroundContext.perform {
  332. guard let fetchedResults = results as? [OrefDetermination] else {
  333. throw CoreDataError.fetchError(function: #function, file: #file)
  334. }
  335. return fetchedResults.map(\.objectID)
  336. }
  337. }
  338. // MARK: - Watch State Setup
  339. /// Builds an array of GarminWatchState objects containing current glucose, trend, loop data, and historical readings.
  340. /// Historical data is included for watchfaces that support it (e.g., SwissAlpine).
  341. /// - Parameter triggeredBy: A string describing what triggered this update (for debugging/logging).
  342. /// - Returns: An array of `GarminWatchState` objects with the latest watch data.
  343. func setupGarminWatchState(triggeredBy: String = #function) async throws -> [GarminWatchState] {
  344. // Skip if no devices connected
  345. guard !devices.isEmpty else {
  346. return []
  347. }
  348. if debugWatchState {
  349. debug(.watchManager, "Garmin: Preparing watch state [Trigger: \(triggeredBy)]")
  350. }
  351. // Fetch glucose - SwissAlpine needs 24, Trio needs 2 (for delta calculation)
  352. let glucoseLimit = needsHistoricalGlucoseData ? 24 : 2
  353. let glucoseIds = try await fetchGlucose(limit: glucoseLimit)
  354. // Fetch all determinations from last 30 minutes (no limit)
  355. // This ensures we get both enacted and suggested determinations
  356. let allDeterminationIds = try await fetchDeterminations30Min()
  357. let tempBasalIds = try await fetchTempBasals()
  358. let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared
  359. .getNSManagedObject(with: glucoseIds, context: backgroundContext)
  360. let allDeterminationObjects: [OrefDetermination] = try await CoreDataStack.shared
  361. .getNSManagedObject(with: allDeterminationIds, context: backgroundContext)
  362. let tempBasalObjects: [PumpEventStored] = try await CoreDataStack.shared
  363. .getNSManagedObject(with: tempBasalIds, context: backgroundContext)
  364. return await backgroundContext.perform {
  365. var watchStates: [GarminWatchState] = []
  366. let unitsHint = self.units == .mgdL ? "mgdl" : "mmol"
  367. // IOB with 1 decimal precision
  368. let iobValue = self.formatIOB(self.iobService.currentIOB ?? Decimal(0))
  369. // Find enacted determination for timestamp (when loop actually ran)
  370. // If no enacted determination exists in last 30 min, use a synthetic timestamp
  371. // of "31 minutes ago" so watchface can distinguish between:
  372. // - nil = no data received yet (watch startup)
  373. // - 31+ min old = loop is stale
  374. let enactedDetermination = allDeterminationObjects.first(where: { $0.enacted })
  375. let enactedTimestamp: Date = enactedDetermination?.timestamp ?? Date().addingTimeInterval(-31 * 60)
  376. // Extract data values from most recent determination (enacted or suggested)
  377. // Suggested sets provide latest calculations even if loop hasn't run yet
  378. var cobValue: Double?
  379. var sensRatioValue: Double?
  380. var isfValue: Int16?
  381. var eventualBGValue: Int16?
  382. if let latestDetermination = allDeterminationObjects.first {
  383. cobValue = Double(latestDetermination.cob)
  384. if let ratio = latestDetermination.sensitivityRatio {
  385. sensRatioValue = Double(truncating: ratio)
  386. }
  387. if let isf = latestDetermination.insulinSensitivity {
  388. isfValue = Int16(truncating: isf)
  389. }
  390. if let eventualBG = latestDetermination.eventualBG {
  391. eventualBGValue = Int16(truncating: eventualBG)
  392. }
  393. }
  394. // TBR from temp basal or profile
  395. var tbrValue: Double?
  396. if let firstTempBasal = tempBasalObjects.first,
  397. let tempBasalData = firstTempBasal.tempBasal,
  398. let tempRate = tempBasalData.rate
  399. {
  400. tbrValue = Double(truncating: tempRate)
  401. } else {
  402. // Fall back to scheduled basal from profile
  403. let basalProfile = self.settingsManager.preferences.basalProfile as? [BasalProfileEntry] ?? []
  404. if !basalProfile.isEmpty {
  405. let now = Date()
  406. let calendar = Calendar.current
  407. let currentTimeMinutes = calendar.component(.hour, from: now) * 60 + calendar.component(.minute, from: now)
  408. for entry in basalProfile.reversed() {
  409. if entry.minutes <= currentTimeMinutes {
  410. tbrValue = Double(entry.rate)
  411. break
  412. }
  413. }
  414. }
  415. }
  416. // Display configuration from settings
  417. let displayPrimaryChoice = self.settingsManager.settings.garminSettings.primaryAttributeChoice.rawValue
  418. let displaySecondaryChoice = self.settingsManager.settings.garminSettings.secondaryAttributeChoice.rawValue
  419. // Process glucose readings
  420. let entriesToSend = self.needsHistoricalGlucoseData ? glucoseObjects.count : 1
  421. for (index, glucose) in glucoseObjects.enumerated() {
  422. guard index < entriesToSend else { break }
  423. let glucoseValue = glucose.glucose
  424. var watchState = GarminWatchState()
  425. // Loop timestamp: Only use enacted determination timestamp (never glucose timestamp)
  426. // This shows when the loop actually executed, not when glucose was received
  427. if index == 0 {
  428. watchState.date = UInt64(enactedTimestamp.timeIntervalSince1970 * 1000)
  429. } else {
  430. watchState.date = glucose.date.map { UInt64($0.timeIntervalSince1970 * 1000) }
  431. }
  432. watchState.sgv = glucoseValue
  433. // Only add extended data for first entry
  434. if index == 0 {
  435. watchState.direction = glucose.direction ?? "--"
  436. // Delta calculation
  437. if glucoseObjects.count > 1 {
  438. watchState.delta = glucose.glucose - glucoseObjects[1].glucose
  439. } else {
  440. watchState.delta = 0
  441. }
  442. // Glucose timestamp: Used by watchface to determine if glucose is fresh
  443. // Enables green coloring when: enacted loop is 6+ min old but glucose is <10 min old
  444. watchState.glucoseDate = glucose.date.map { UInt64($0.timeIntervalSince1970 * 1000) }
  445. watchState.units_hint = unitsHint
  446. watchState.iob = iobValue
  447. watchState.cob = cobValue
  448. watchState.tbr = tbrValue
  449. watchState.isf = isfValue
  450. watchState.eventualBG = eventualBGValue
  451. watchState.sensRatio = sensRatioValue
  452. watchState.displayPrimaryAttributeChoice = displayPrimaryChoice
  453. watchState.displaySecondaryAttributeChoice = displaySecondaryChoice
  454. }
  455. watchStates.append(watchState)
  456. }
  457. // Deduplicate: Check if data is unchanged from last preparation
  458. let currentHash = watchStates.hashValue
  459. if currentHash == self.lastPreparedDataHash {
  460. if self.debugWatchState {
  461. debug(.watchManager, "Garmin: Skipping - data unchanged (hash: \(currentHash))")
  462. }
  463. return self.lastPreparedWatchState ?? watchStates
  464. }
  465. if self.debugWatchState {
  466. debug(
  467. .watchManager,
  468. "Garmin: Prepared \(watchStates.count) entries - sgv: \(watchStates.first?.sgv ?? 0), iob: \(watchStates.first?.iob ?? 0), cob: \(watchStates.first?.cob ?? 0), tbr: \(watchStates.first?.tbr ?? 0), eventualBG: \(watchStates.first?.eventualBG ?? 0), sensRatio: \(watchStates.first?.sensRatio ?? 0)"
  469. )
  470. }
  471. // Cache for deduplication
  472. self.lastPreparedDataHash = currentHash
  473. self.lastPreparedWatchState = watchStates
  474. return watchStates
  475. }
  476. }
  477. /// Formats IOB (Insulin On Board) value with 1 decimal precision for display.
  478. /// Prevents small values from rounding to zero by enforcing a minimum magnitude of 0.1.
  479. /// - Parameter value: The IOB value to format.
  480. /// - Returns: The formatted IOB value as a Double with 1 decimal place.
  481. private func formatIOB(_ value: Decimal) -> Double {
  482. let doubleValue = NSDecimalNumber(decimal: value).doubleValue
  483. if doubleValue.magnitude < 0.1, doubleValue != 0 {
  484. return doubleValue > 0 ? 0.1 : -0.1
  485. }
  486. return (doubleValue * 10).rounded() / 10
  487. }
  488. // MARK: - Device & App Registration
  489. /// Registers the given devices for ConnectIQ events (device status changes) and watch app messages.
  490. /// It also creates and registers watch apps (watchface + data field) for each device.
  491. /// - Parameter devices: The devices to register.
  492. private func registerDevices(_ devices: [IQDevice]) {
  493. watchApps.removeAll()
  494. for device in devices {
  495. connectIQ?.register(forDeviceEvents: device, delegate: self)
  496. // Register watchface if enabled
  497. if isWatchfaceDataEnabled,
  498. let watchfaceUUID = currentWatchface.watchfaceUUID,
  499. let watchfaceApp = IQApp(uuid: watchfaceUUID, store: UUID(), device: device)
  500. {
  501. debugGarmin("Garmin: Registered \(appDetailedName(for: watchfaceUUID))")
  502. watchApps.append(watchfaceApp)
  503. connectIQ?.register(forAppMessages: watchfaceApp, delegate: self)
  504. } else if !isWatchfaceDataEnabled {
  505. debugGarmin("Garmin: Watchface data disabled - skipping watchface registration")
  506. }
  507. // Always register datafield (if configured)
  508. if let datafieldUUID = currentDatafield.datafieldUUID,
  509. let datafieldApp = IQApp(uuid: datafieldUUID, store: UUID(), device: device)
  510. {
  511. debugGarmin("Garmin: Registered \(appDetailedName(for: datafieldUUID))")
  512. watchApps.append(datafieldApp)
  513. connectIQ?.register(forAppMessages: datafieldApp, delegate: self)
  514. }
  515. }
  516. }
  517. /// Restores previously persisted devices from local storage into `devices`.
  518. private func restoreDevices() {
  519. devices = persistedDevices.map(\.iqDevice)
  520. }
  521. // MARK: - Simulator Support
  522. #if targetEnvironment(simulator)
  523. /// Mock IQDevice class for simulator testing
  524. /// Minimal implementation just for testing - no actual Garmin functionality
  525. class MockIQDevice: IQDevice {
  526. private let _uuid: UUID
  527. private let _friendlyName: String
  528. private let _modelName: String
  529. override var uuid: UUID { _uuid }
  530. override var friendlyName: String { _friendlyName }
  531. override var modelName: String { _modelName }
  532. var status: IQDeviceStatus { .connected }
  533. init(uuid: UUID, friendlyName: String, modelName: String) {
  534. _uuid = uuid
  535. _friendlyName = friendlyName
  536. _modelName = modelName
  537. super.init()
  538. }
  539. @available(*, unavailable) required init?(coder _: NSCoder) {
  540. fatalError("init(coder:) not implemented for mock device")
  541. }
  542. /// Shared simulated device UUID for consistency across the app
  543. static let simulatedUUID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!
  544. /// Creates the standard simulated Enduro 3 device
  545. static func createSimulated() -> MockIQDevice {
  546. MockIQDevice(
  547. uuid: simulatedUUID,
  548. friendlyName: "Enduro 3 Sim",
  549. modelName: "Enduro 3"
  550. )
  551. }
  552. }
  553. #endif
  554. // MARK: - Combine Subscriptions
  555. /// Subscribes to the `.openFromGarminConnect` notification, parsing devices from the given URL
  556. /// and updating the device list accordingly.
  557. private func subscribeToOpenFromGarminConnect() {
  558. notificationCenter
  559. .publisher(for: .openFromGarminConnect)
  560. .sink { [weak self] notification in
  561. guard let self = self, let url = notification.object as? URL else { return }
  562. self.parseDevices(for: url)
  563. }
  564. .store(in: &cancellables)
  565. }
  566. /// Subscribes to watch state updates with debouncing
  567. private func subscribeToWatchState() {
  568. watchStateSubject
  569. .debounce(for: .seconds(2), scheduler: DispatchQueue.main)
  570. .sink { [weak self] data in
  571. self?.broadcastWatchStateData(data)
  572. }
  573. .store(in: &cancellables)
  574. }
  575. // MARK: - Parsing & Broadcasting
  576. /// Parses devices from a Garmin Connect URL and updates our `devices` property.
  577. /// - Parameter url: The URL provided by Garmin Connect containing device selection info.
  578. private func parseDevices(for url: URL) {
  579. let parsed = connectIQ?.parseDeviceSelectionResponse(from: url) as? [IQDevice]
  580. devices = parsed ?? []
  581. deviceSelectionPromise?(.success(devices))
  582. deviceSelectionPromise = nil
  583. }
  584. /// Broadcasts watch state data to all registered apps
  585. private func broadcastWatchStateData(_ data: Data) {
  586. // Deduplicate: Use stable content-based hash (sorted JSON bytes)
  587. let currentHash: Int
  588. if let sortedData = try? JSONSerialization.data(
  589. withJSONObject: JSONSerialization.jsonObject(with: data, options: []),
  590. options: [.sortedKeys]
  591. ) {
  592. currentHash = sortedData.base64EncodedString().hashValue
  593. } else {
  594. currentHash = data.count // Fallback
  595. }
  596. if currentHash == lastSentDataHash {
  597. debugGarmin("Garmin: Skipping broadcast - data unchanged")
  598. return
  599. }
  600. guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) else {
  601. debug(.watchManager, "Garmin: Invalid JSON for watch-state data")
  602. return
  603. }
  604. watchApps.forEach { app in
  605. let appName = self.appDisplayName(for: app.uuid!)
  606. connectIQ?.getAppStatus(app) { [weak self] status in
  607. guard status?.isInstalled == true else {
  608. debug(.watchManager, "Garmin: App not installed: \(appName)")
  609. return
  610. }
  611. self?.debugGarmin("Garmin: Sending to \(appName)")
  612. self?.sendMessage(jsonObject as Any, to: app, appName: appName)
  613. }
  614. }
  615. // Update last sent hash after initiating send
  616. lastSentDataHash = currentHash
  617. }
  618. // MARK: - GarminManager Conformance
  619. /// Prompts the user to select one or more Garmin devices, returning a publisher that emits
  620. /// the final array of selected devices once the user finishes selection.
  621. /// - Returns: An `AnyPublisher` emitting `[IQDevice]` on success, or empty array on error/timeout.
  622. func selectDevices() -> AnyPublisher<[IQDevice], Never> {
  623. Future { [weak self] promise in
  624. guard let self = self else {
  625. promise(.success([]))
  626. return
  627. }
  628. self.deviceSelectionPromise = promise
  629. self.connectIQ?.showDeviceSelection()
  630. }
  631. .timeout(.seconds(120), scheduler: DispatchQueue.main)
  632. .replaceEmpty(with: [])
  633. .eraseToAnyPublisher()
  634. }
  635. /// Updates the manager's list of devices, typically after user selection or manual changes.
  636. /// - Parameter devices: The new array of `IQDevice` objects to track.
  637. func updateDeviceList(_ devices: [IQDevice]) {
  638. self.devices = devices
  639. }
  640. /// Sends the given watch state data to the debounce subject for eventual broadcast.
  641. /// - Parameter data: JSON-encoded data representing the latest watch state.
  642. func sendWatchStateData(_ data: Data) {
  643. watchStateSubject.send(data)
  644. }
  645. // MARK: - Helper: Sending Messages
  646. /// Sends a message to a given IQApp with optional progress and completion callbacks.
  647. /// - Parameters:
  648. /// - msg: The data to send to the watch app.
  649. /// - app: The `IQApp` instance representing the watchface or data field.
  650. /// - appName: The display name of the app for logging.
  651. private func sendMessage(_ msg: Any, to app: IQApp, appName: String) {
  652. connectIQ?.sendMessage(
  653. msg,
  654. to: app,
  655. progress: { _, _ in },
  656. completion: { result in
  657. switch result {
  658. case .success:
  659. debug(.watchManager, "Garmin: Successfully sent to \(appName)")
  660. default:
  661. debug(.watchManager, "Garmin: FAILED to send to \(appName)")
  662. }
  663. }
  664. )
  665. }
  666. }
  667. // MARK: - Extensions
  668. extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppMessageDelegate {
  669. // MARK: - IQUIOverrideDelegate
  670. /// Called if the Garmin Connect Mobile app is not installed or otherwise not available.
  671. /// Typically, you would show an alert or prompt the user to install the app from the store.
  672. func needsToInstallConnectMobile() {
  673. debug(.apsManager, "Garmin is not available")
  674. let messageCont = MessageContent(
  675. content: "The app Garmin Connect must be installed to use Trio.\nGo to the App Store to download it.",
  676. type: .warning,
  677. subtype: .misc,
  678. title: "Garmin is not available"
  679. )
  680. router.alertMessage.send(messageCont)
  681. }
  682. // MARK: - IQDeviceEventDelegate
  683. /// Called whenever the status of a registered Garmin device changes (e.g., connected, not found, etc.).
  684. /// - Parameters:
  685. /// - device: The device whose status has changed.
  686. /// - status: The new status for the device.
  687. func deviceStatusChanged(_: IQDevice, status: IQDeviceStatus) {
  688. // Always log connection state changes - critical for diagnosing SDK issues
  689. switch status {
  690. case .invalidDevice:
  691. debug(.watchManager, "Garmin: Device status -> invalidDevice")
  692. case .bluetoothNotReady:
  693. debug(.watchManager, "Garmin: Device status -> bluetoothNotReady")
  694. case .notFound:
  695. debug(.watchManager, "Garmin: Device status -> notFound")
  696. case .notConnected:
  697. debug(.watchManager, "Garmin: Device status -> notConnected")
  698. case .connected:
  699. debug(.watchManager, "Garmin: Device status -> connected")
  700. @unknown default:
  701. debug(.watchManager, "Garmin: Device status -> unknown(\(status.rawValue))")
  702. }
  703. }
  704. // MARK: - IQAppMessageDelegate
  705. /// Called when a message arrives from a Garmin watch app (watchface or data field).
  706. /// If the watch requests a "status" update, we call `setupGarminWatchState()` asynchronously
  707. /// and re-send the watch state data.
  708. /// - Parameters:
  709. /// - message: The message content from the watch app.
  710. /// - app: The watch app sending the message.
  711. func receivedMessage(_ message: Any, from app: IQApp) {
  712. let appName = appDisplayName(for: app.uuid!)
  713. debugGarmin("Garmin: Received message '\(message)' from \(appName)")
  714. // If watch requests status update, send current data
  715. guard let statusString = message as? String, statusString == "status" else {
  716. return
  717. }
  718. Task {
  719. do {
  720. let watchState = try await setupGarminWatchState(triggeredBy: "WatchRequest")
  721. let watchStateData = try JSONEncoder().encode(watchState)
  722. sendWatchStateData(watchStateData)
  723. } catch {
  724. debug(.watchManager, "Garmin: Cannot encode watch state: \(error)")
  725. }
  726. }
  727. }
  728. }
  729. extension BaseGarminManager: SettingsObserver {
  730. /// Called whenever TrioSettings changes (e.g., user toggles mg/dL vs. mmol/L).
  731. /// - Parameter _: The updated TrioSettings instance.
  732. func settingsDidChange(_: TrioSettings) {
  733. units = settingsManager.settings.units
  734. // Re-register devices to pick up watchface/datafield changes
  735. if !devices.isEmpty {
  736. registerDevices(devices)
  737. }
  738. // Send updated state
  739. triggerWatchStateUpdate(triggeredBy: "Settings")
  740. }
  741. }