GarminManager.swift 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  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. /// Persists the user’s device list between app launches.
  43. @Persisted(key: "BaseGarminManager.persistedDevices") private var persistedDevices: [GarminDevice] = []
  44. /// Router for presenting alerts or navigation flows (injected via Swinject).
  45. private let router: Router
  46. /// Garmin ConnectIQ shared instance for watch interactions.
  47. private let connectIQ = ConnectIQ.sharedInstance()
  48. /// Keeps references to watch apps (both watchface & data field) for each registered device.
  49. private var watchApps: [IQApp] = []
  50. /// A subject that publishes watch-state dictionaries; watchers can throttle or debounce.
  51. private let watchStateSubject = PassthroughSubject<NSDictionary, Never>()
  52. /// A set of Combine cancellables for managing the lifecycle of various subscriptions.
  53. private var cancellables = Set<AnyCancellable>()
  54. /// Holds a promise used when the user is selecting devices (via `showDeviceSelection()`).
  55. private var deviceSelectionPromise: Future<[IQDevice], Never>.Promise?
  56. /// Array of Garmin `IQDevice` objects currently tracked.
  57. /// Changing this property triggers re-registration and updates persisted devices.
  58. private(set) var devices: [IQDevice] = [] {
  59. didSet {
  60. // Persist newly updated device list
  61. persistedDevices = devices.map(GarminDevice.init)
  62. // Re-register for events, app messages, etc.
  63. registerDevices(devices)
  64. }
  65. }
  66. /// Current glucose units, either mg/dL or mmol/L, read from user settings.
  67. private var units: GlucoseUnits = .mgdL
  68. /// Publishes any changed CoreData objects that match our filters (e.g., OrefDetermination, GlucoseStored).
  69. private var coreDataPublisher: AnyPublisher<Set<NSManagedObject>, Never>?
  70. /// Additional local subscriptions (separate from `cancellables`) for CoreData events.
  71. private var subscriptions = Set<AnyCancellable>()
  72. /// Represents the context for background tasks in CoreData.
  73. let backgroundContext = CoreDataStack.shared.newTaskContext()
  74. /// Represents the main (view) context for CoreData, typically used on the main thread.
  75. let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  76. // MARK: - Initialization
  77. /// Creates a new `BaseGarminManager`, injecting required services, restoring any persisted devices,
  78. /// and setting up watchers for data changes (e.g., glucose updates).
  79. /// - Parameter resolver: Swinject resolver for injecting dependencies like the Router.
  80. init(resolver: Resolver) {
  81. router = resolver.resolve(Router.self)!
  82. super.init()
  83. injectServices(resolver)
  84. connectIQ?.initialize(withUrlScheme: "Trio", uiOverrideDelegate: self)
  85. restoreDevices()
  86. subscribeToOpenFromGarminConnect()
  87. subscribeToWatchState()
  88. units = settingsManager.settings.units
  89. broadcaster.register(SettingsObserver.self, observer: self)
  90. coreDataPublisher =
  91. changedObjectsOnManagedObjectContextDidSavePublisher()
  92. .receive(on: DispatchQueue.global(qos: .background))
  93. .share()
  94. .eraseToAnyPublisher()
  95. glucoseStorage.updatePublisher
  96. .receive(on: DispatchQueue.global(qos: .background))
  97. .sink { [weak self] _ in
  98. guard let self = self else { return }
  99. Task {
  100. let watchState = await self.setupGarminWatchState()
  101. let watchStateData = try JSONEncoder().encode(watchState)
  102. self.sendWatchStateData(watchStateData)
  103. }
  104. }
  105. .store(in: &subscriptions)
  106. registerHandlers()
  107. }
  108. // MARK: - Internal Setup / Handlers
  109. /// Sets up handlers for OrefDetermination and GlucoseStored entity changes in CoreData.
  110. /// When these change, we re-compute the Garmin watch state and send updates to the watch.
  111. private func registerHandlers() {
  112. coreDataPublisher?
  113. .filterByEntityName("OrefDetermination")
  114. .sink { [weak self] _ in
  115. guard let self = self else { return }
  116. Task {
  117. let watchState = await self.setupGarminWatchState()
  118. let watchStateData = try JSONEncoder().encode(watchState)
  119. self.sendWatchStateData(watchStateData)
  120. }
  121. }
  122. .store(in: &subscriptions)
  123. // Due to the batch insert, this only observes deletion of Glucose entries
  124. coreDataPublisher?
  125. .filterByEntityName("GlucoseStored")
  126. .sink { [weak self] _ in
  127. guard let self = self else { return }
  128. Task {
  129. let watchState = await self.setupGarminWatchState()
  130. let watchStateData = try JSONEncoder().encode(watchState)
  131. self.sendWatchStateData(watchStateData)
  132. }
  133. }
  134. .store(in: &subscriptions)
  135. }
  136. /// Fetches recent glucose readings from CoreData, up to 288 results.
  137. /// - Returns: An array of `NSManagedObjectID`s for glucose readings.
  138. private func fetchGlucose() async -> [NSManagedObjectID] {
  139. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  140. ofType: GlucoseStored.self,
  141. onContext: backgroundContext,
  142. predicate: NSPredicate.glucose,
  143. key: "date",
  144. ascending: false,
  145. fetchLimit: 288
  146. )
  147. return await backgroundContext.perform {
  148. guard let fetchedResults = results as? [GlucoseStored] else { return [] }
  149. return fetchedResults.map(\.objectID)
  150. }
  151. }
  152. /// Builds a `GarminWatchState` reflecting the latest glucose, trend, delta, eventual BG, ISF, IOB, and COB.
  153. /// - Returns: A `GarminWatchState` containing the most recent device- and therapy-related info.
  154. func setupGarminWatchState() async -> GarminWatchState {
  155. // Get Glucose IDs
  156. let glucoseIds = await fetchGlucose()
  157. // Fetch the latest OrefDetermination object if available
  158. let determinationIds = await determinationStorage.fetchLastDeterminationObjectID(
  159. predicate: NSPredicate.predicateFor30MinAgoForDetermination
  160. )
  161. // Turn those IDs into live NSManagedObjects
  162. let glucoseObjects: [GlucoseStored] = await CoreDataStack.shared
  163. .getNSManagedObject(with: glucoseIds, context: backgroundContext)
  164. let determinationObjects: [OrefDetermination] = await CoreDataStack.shared
  165. .getNSManagedObject(with: determinationIds, context: backgroundContext)
  166. // Perform logic on the background context
  167. return await backgroundContext.perform {
  168. var watchState = GarminWatchState()
  169. /// Pull `glucose`, `trendRaw`, `delta`, `lastLoopDateInterval`, `iob`, `cob`, `isf`, and `eventualBGRaw` from the latest determination.
  170. if let latestDetermination = determinationObjects.first {
  171. watchState.lastLoopDateInterval = latestDetermination.timestamp.map {
  172. guard $0.timeIntervalSince1970 > 0 else { return 0 }
  173. return UInt64($0.timeIntervalSince1970)
  174. }
  175. let iobValue = latestDetermination.iob ?? 0
  176. watchState.iob = Formatter.decimalFormatterWithTwoFractionDigits.string(from: iobValue)
  177. let cobNumber = NSNumber(value: latestDetermination.cob)
  178. watchState.cob = Formatter.integerFormatter.string(from: cobNumber)
  179. let insulinSensitivity = latestDetermination.insulinSensitivity ?? 0
  180. let eventualBG = latestDetermination.eventualBG ?? 0
  181. if self.units == .mgdL {
  182. watchState.isf = insulinSensitivity.description
  183. watchState.eventualBGRaw = Formatter.glucoseFormatter(for: self.units)
  184. .string(from: eventualBG) ?? "0"
  185. } else {
  186. let parsedIsf = Double(truncating: insulinSensitivity).asMmolL
  187. let parsedEventualBG = Double(truncating: eventualBG).asMmolL
  188. watchState.isf = parsedIsf.description
  189. watchState.eventualBGRaw = Formatter.glucoseFormatter(for: self.units)
  190. .string(from: parsedEventualBG as NSNumber) ?? "0"
  191. }
  192. }
  193. // If no glucose data is present, just return partial watch state
  194. guard let latestGlucose = glucoseObjects.first else {
  195. return watchState
  196. }
  197. // Format the current glucose reading
  198. if self.units == .mgdL {
  199. watchState.glucose = "\(latestGlucose.glucose)"
  200. } else {
  201. let mgdlValue = Decimal(latestGlucose.glucose)
  202. let latestGlucoseValue = Double(truncating: mgdlValue.asMmolL as NSNumber)
  203. watchState.glucose = "\(latestGlucoseValue)"
  204. }
  205. // Convert direction to a textual trend
  206. watchState.trendRaw = latestGlucose.direction ?? "--"
  207. // Calculate a glucose delta if we have at least two readings
  208. if glucoseObjects.count >= 2 {
  209. var deltaValue = Decimal(glucoseObjects[0].glucose - glucoseObjects[1].glucose)
  210. if self.units == .mmolL {
  211. deltaValue = Double(truncating: deltaValue as NSNumber).asMmolL
  212. }
  213. let formattedDelta = Formatter.glucoseFormatter(for: self.units)
  214. .string(from: deltaValue as NSNumber) ?? "0"
  215. watchState.delta = deltaValue < 0 ? "\(formattedDelta)" : "+\(formattedDelta)"
  216. }
  217. debug(
  218. .watchManager,
  219. """
  220. 📱 Setup GarminWatchState - \
  221. glucose: \(watchState.glucose ?? "nil"), \
  222. trendRaw: \(watchState.trendRaw ?? "nil"), \
  223. delta: \(watchState.delta ?? "nil"), \
  224. eventualBGRaw: \(watchState.eventualBGRaw ?? "nil"), \
  225. isf: \(watchState.isf ?? "nil"), \
  226. cob: \(watchState.cob ?? "nil"), \
  227. iob: \(watchState.iob ?? "nil"), \
  228. lastLoopDateInterval: \(watchState.lastLoopDateInterval?.description ?? "nil")
  229. """
  230. )
  231. return watchState
  232. }
  233. }
  234. // MARK: - Device & App Registration
  235. /// Registers the given devices for ConnectIQ events (device status changes) and watch app messages.
  236. /// It also creates and registers watch apps (watchface + data field) for each device.
  237. /// - Parameter devices: The devices to register.
  238. private func registerDevices(_ devices: [IQDevice]) {
  239. // Clear out old references
  240. watchApps.removeAll()
  241. for device in devices {
  242. // Listen for device-level status changes
  243. connectIQ?.register(forDeviceEvents: device, delegate: self)
  244. // Create a watchface app
  245. guard
  246. let watchfaceUUID = Config.watchfaceUUID,
  247. let watchfaceApp = IQApp(uuid: watchfaceUUID, store: UUID(), device: device)
  248. else {
  249. debug(.watchManager, "Garmin: Could not create watchface app for device \(device.uuid!))")
  250. continue
  251. }
  252. // Create a watch data field app
  253. guard
  254. let watchdataUUID = Config.watchdataUUID,
  255. let watchDataFieldApp = IQApp(uuid: watchdataUUID, store: UUID(), device: device)
  256. else {
  257. debug(.watchManager, "Garmin: Could not create data-field app for device \(device.uuid!)")
  258. continue
  259. }
  260. // Track both apps for potential messages
  261. watchApps.append(watchfaceApp)
  262. watchApps.append(watchDataFieldApp)
  263. // Register to receive app-messages from the watchface
  264. connectIQ?.register(forAppMessages: watchfaceApp, delegate: self)
  265. }
  266. }
  267. /// Restores previously persisted devices from local storage into `devices`.
  268. private func restoreDevices() {
  269. devices = persistedDevices.map(\.iqDevice)
  270. }
  271. // MARK: - Combine Subscriptions
  272. /// Subscribes to the `.openFromGarminConnect` notification, parsing devices from the given URL
  273. /// and updating the device list accordingly.
  274. private func subscribeToOpenFromGarminConnect() {
  275. notificationCenter
  276. .publisher(for: .openFromGarminConnect)
  277. .sink { [weak self] notification in
  278. guard
  279. let self = self,
  280. let url = notification.object as? URL
  281. else { return }
  282. self.parseDevices(for: url)
  283. }
  284. .store(in: &cancellables)
  285. }
  286. /// Subscribes to any watch-state dictionaries published via `watchStateSubject`, and throttles them
  287. /// so updates aren’t sent too frequently. Each update triggers a broadcast to all watch apps.
  288. private func subscribeToWatchState() {
  289. watchStateSubject
  290. .throttle(for: .seconds(10), scheduler: DispatchQueue.main, latest: true)
  291. .sink { [weak self] state in
  292. self?.broadcastStateToWatchApps(state)
  293. }
  294. .store(in: &cancellables)
  295. }
  296. // MARK: - Parsing & Broadcasting
  297. /// Parses devices from a Garmin Connect URL and updates our `devices` property.
  298. /// - Parameter url: The URL provided by Garmin Connect containing device selection info.
  299. private func parseDevices(for url: URL) {
  300. let parsed = connectIQ?.parseDeviceSelectionResponse(from: url) as? [IQDevice]
  301. devices = parsed ?? []
  302. // Fulfill any pending promise in case this is in response to `selectDevices()`.
  303. deviceSelectionPromise?(.success(devices))
  304. deviceSelectionPromise = nil
  305. }
  306. /// Sends the given state dictionary to all known watch apps (watchface & data field) by checking
  307. /// if each app is installed and then sending messages asynchronously.
  308. /// - Parameter state: The dictionary representing the watch state to be broadcast.
  309. private func broadcastStateToWatchApps(_ state: NSDictionary) {
  310. watchApps.forEach { app in
  311. connectIQ?.getAppStatus(app) { [weak self] status in
  312. guard status?.isInstalled == true else {
  313. debug(.watchManager, "Garmin: App not installed on device: \(app.uuid!)")
  314. return
  315. }
  316. debug(.watchManager, "Garmin: Sending watch-state to app \(app.uuid!)")
  317. self?.sendMessage(state, to: app)
  318. }
  319. }
  320. }
  321. // MARK: - GarminManager Conformance
  322. /// Prompts the user to select one or more Garmin devices, returning a publisher that emits
  323. /// the final array of selected devices once the user finishes selection.
  324. /// - Returns: An `AnyPublisher` emitting `[IQDevice]` on success, or empty array on error/timeout.
  325. func selectDevices() -> AnyPublisher<[IQDevice], Never> {
  326. Future { [weak self] promise in
  327. guard let self = self else {
  328. // If self is gone, just resolve with an empty array
  329. promise(.success([]))
  330. return
  331. }
  332. // Store the promise so we can fulfill it when the user selects devices
  333. self.deviceSelectionPromise = promise
  334. // Show Garmin's default device selection UI
  335. self.connectIQ?.showDeviceSelection()
  336. }
  337. .timeout(.seconds(120), scheduler: DispatchQueue.main)
  338. .replaceEmpty(with: [])
  339. .eraseToAnyPublisher()
  340. }
  341. /// Updates the manager’s list of devices, typically after user selection or manual changes.
  342. /// - Parameter devices: The new array of `IQDevice` objects to track.
  343. func updateDeviceList(_ devices: [IQDevice]) {
  344. self.devices = devices
  345. }
  346. /// Converts the given JSON data into an NSDictionary and sends it to all known watch apps.
  347. /// - Parameter data: JSON-encoded data representing the latest watch state. If decoding fails,
  348. /// the method logs an error and does nothing else.
  349. func sendWatchStateData(_ data: Data) {
  350. guard
  351. let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []),
  352. let dict = jsonObject as? NSDictionary
  353. else {
  354. debug(.watchManager, "Garmin: Invalid JSON for watch-state data")
  355. return
  356. }
  357. watchStateSubject.send(dict)
  358. }
  359. // MARK: - Helper: Sending Messages
  360. /// Sends a message to a given IQApp with optional progress and completion callbacks.
  361. /// - Parameters:
  362. /// - msg: The dictionary to send to the watch app.
  363. /// - app: The `IQApp` instance representing the watchface or data field.
  364. private func sendMessage(_ msg: NSDictionary, to app: IQApp) {
  365. connectIQ?.sendMessage(
  366. msg,
  367. to: app,
  368. progress: { _, _ in
  369. // Optionally track progress here
  370. },
  371. completion: { result in
  372. switch result {
  373. case .success:
  374. debug(.watchManager, "Garmin: Successfully sent message to \(app.uuid!)")
  375. default:
  376. debug(.watchManager, "Garmin: Unknown result or failed to send message to \(app.uuid!)")
  377. }
  378. }
  379. )
  380. }
  381. }
  382. // MARK: - Extensions
  383. extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppMessageDelegate {
  384. // MARK: - IQUIOverrideDelegate
  385. /// Called if the Garmin Connect Mobile app is not installed or otherwise not available.
  386. /// Typically, you would show an alert or prompt the user to install the app from the store.
  387. func needsToInstallConnectMobile() {
  388. debug(.apsManager, "Garmin is not available")
  389. let messageCont = MessageContent(
  390. content: "The app Garmin Connect must be installed to use Trio.\nGo to the App Store to download it.",
  391. type: .warning,
  392. subtype: .misc,
  393. title: "Garmin is not available"
  394. )
  395. router.alertMessage.send(messageCont)
  396. }
  397. // MARK: - IQDeviceEventDelegate
  398. /// Called whenever the status of a registered Garmin device changes (e.g., connected, not found, etc.).
  399. /// - Parameters:
  400. /// - device: The device whose status has changed.
  401. /// - status: The new status for the device.
  402. func deviceStatusChanged(_ device: IQDevice, status: IQDeviceStatus) {
  403. switch status {
  404. case .invalidDevice:
  405. debug(.watchManager, "Garmin: invalidDevice (\(device.uuid!))")
  406. case .bluetoothNotReady:
  407. debug(.watchManager, "Garmin: bluetoothNotReady (\(device.uuid!))")
  408. case .notFound:
  409. debug(.watchManager, "Garmin: notFound (\(device.uuid!))")
  410. case .notConnected:
  411. debug(.watchManager, "Garmin: notConnected (\(device.uuid!))")
  412. case .connected:
  413. debug(.watchManager, "Garmin: connected (\(device.uuid!))")
  414. @unknown default:
  415. debug(.watchManager, "Garmin: unknown state (\(device.uuid!))")
  416. }
  417. }
  418. // MARK: - IQAppMessageDelegate
  419. /// Called when a message arrives from a Garmin watch app (watchface or data field).
  420. /// If the watch requests a "status" update, we call `setupGarminWatchState()` asynchronously
  421. /// and re-send the watch state data.
  422. /// - Parameters:
  423. /// - message: The message content from the watch app.
  424. /// - app: The watch app sending the message.
  425. func receivedMessage(_ message: Any, from app: IQApp) {
  426. debug(.watchManager, "Garmin: Received message \(message) from app \(app.uuid!)")
  427. Task {
  428. // Check if the message is literally the string "status"
  429. guard
  430. let statusString = message as? String,
  431. statusString == "status"
  432. else {
  433. return
  434. }
  435. do {
  436. // Fetch the latest watch state (async) and encode it to JSON data
  437. let watchState = await self.setupGarminWatchState()
  438. let watchStateData = try JSONEncoder().encode(watchState)
  439. // Now send that JSON data to the watch
  440. sendWatchStateData(watchStateData)
  441. } catch {
  442. debug(.watchManager, "Garmin: Cannot encode watch state: \(error)")
  443. }
  444. }
  445. }
  446. }
  447. extension BaseGarminManager {
  448. // MARK: - Config
  449. /// Configuration struct containing watch app UUIDs for the Garmin watchface and data field.
  450. private enum Config {
  451. /// Example watchface UUID
  452. static let watchfaceUUID = UUID(uuidString: "EC3420F6-027D-49B3-B45F-D81D6D3ED90A")
  453. /// Example data field UUID
  454. static let watchdataUUID = UUID(uuidString: "71CF0982-CA41-42A5-8441-EA81D36056C3")
  455. }
  456. }
  457. extension BaseGarminManager: SettingsObserver {
  458. /// Called whenever TrioSettings changes (e.g., user toggles mg/dL vs. mmol/L).
  459. /// - Parameter _: The updated TrioSettings instance.
  460. func settingsDidChange(_: TrioSettings) {
  461. // Update local units and re-send watch state
  462. units = settingsManager.settings.units
  463. Task {
  464. let watchState = await setupGarminWatchState()
  465. let watchStateData = try JSONEncoder().encode(watchState)
  466. sendWatchStateData(watchStateData)
  467. }
  468. }
  469. }