| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566 |
- import Combine
- import ConnectIQ
- import CoreData
- import Foundation
- import Swinject
- // MARK: - GarminManager Protocol
- /// Manages Garmin devices, allowing the app to select devices, update a known device list,
- /// and send watch-state data to connected Garmin watch apps.
- protocol GarminManager {
- /// Prompts the user to select Garmin devices, returning the chosen devices in a publisher.
- /// - Returns: A publisher that eventually outputs an array of selected `IQDevice` objects.
- func selectDevices() -> AnyPublisher<[IQDevice], Never>
- /// Updates the currently tracked device list. This typically persists the device list and
- /// triggers re-registration for any relevant ConnectIQ events.
- /// - Parameter devices: The new array of `IQDevice` objects to track.
- func updateDeviceList(_ devices: [IQDevice])
- /// Takes raw JSON-encoded watch-state data and dispatches it to any connected watch apps.
- /// - Parameter data: The JSON-encoded data representing the watch state.
- func sendWatchStateData(_ data: Data)
- /// The devices currently known to the app. May be loaded from disk or user selection.
- var devices: [IQDevice] { get }
- }
- // MARK: - BaseGarminManager
- /// Concrete implementation of `GarminManager` that handles:
- /// - Device registration/unregistration with Garmin ConnectIQ
- /// - Data persistence for selected devices
- /// - Generating & sending watch-state updates (glucose, IOB, COB, etc.) to Garmin watch apps.
- final class BaseGarminManager: NSObject, GarminManager, Injectable {
- // MARK: - Dependencies & Properties
- /// Observes system-wide notifications, including `.openFromGarminConnect`.
- @Injected() private var notificationCenter: NotificationCenter!
- /// Broadcaster used for publishing or subscribing to global events (e.g., unit changes).
- @Injected() private var broadcaster: Broadcaster!
- /// APSManager containing insulin pump logic, e.g., for making bolus requests, reading basal info, etc.
- @Injected() private var apsManager: APSManager!
- /// Manages local user settings, such as glucose units (mg/dL or mmol/L).
- @Injected() private var settingsManager: SettingsManager!
- /// Stores, retrieves, and updates glucose data in CoreData.
- @Injected() private var glucoseStorage: GlucoseStorage!
- /// Stores, retrieves, and updates insulin dose determinations in CoreData.
- @Injected() private var determinationStorage: DeterminationStorage!
- /// Persists the user’s device list between app launches.
- @Persisted(key: "BaseGarminManager.persistedDevices") private var persistedDevices: [GarminDevice] = []
- /// Router for presenting alerts or navigation flows (injected via Swinject).
- private let router: Router
- /// Garmin ConnectIQ shared instance for watch interactions.
- private let connectIQ = ConnectIQ.sharedInstance()
- /// Keeps references to watch apps (both watchface & data field) for each registered device.
- private var watchApps: [IQApp] = []
- /// A subject that publishes watch-state dictionaries; watchers can throttle or debounce.
- private let watchStateSubject = PassthroughSubject<NSDictionary, Never>()
- /// A set of Combine cancellables for managing the lifecycle of various subscriptions.
- private var cancellables = Set<AnyCancellable>()
- /// Holds a promise used when the user is selecting devices (via `showDeviceSelection()`).
- private var deviceSelectionPromise: Future<[IQDevice], Never>.Promise?
- /// Array of Garmin `IQDevice` objects currently tracked.
- /// Changing this property triggers re-registration and updates persisted devices.
- private(set) var devices: [IQDevice] = [] {
- didSet {
- // Persist newly updated device list
- persistedDevices = devices.map(GarminDevice.init)
- // Re-register for events, app messages, etc.
- registerDevices(devices)
- }
- }
- /// Current glucose units, either mg/dL or mmol/L, read from user settings.
- private var units: GlucoseUnits = .mgdL
- /// Publishes any changed CoreData objects that match our filters (e.g., OrefDetermination, GlucoseStored).
- private var coreDataPublisher: AnyPublisher<Set<NSManagedObject>, Never>?
- /// Additional local subscriptions (separate from `cancellables`) for CoreData events.
- private var subscriptions = Set<AnyCancellable>()
- /// Represents the context for background tasks in CoreData.
- let backgroundContext = CoreDataStack.shared.newTaskContext()
- /// Represents the main (view) context for CoreData, typically used on the main thread.
- let viewContext = CoreDataStack.shared.persistentContainer.viewContext
- // MARK: - Initialization
- /// Creates a new `BaseGarminManager`, injecting required services, restoring any persisted devices,
- /// and setting up watchers for data changes (e.g., glucose updates).
- /// - Parameter resolver: Swinject resolver for injecting dependencies like the Router.
- init(resolver: Resolver) {
- router = resolver.resolve(Router.self)!
- super.init()
- injectServices(resolver)
- connectIQ?.initialize(withUrlScheme: "Trio", uiOverrideDelegate: self)
- restoreDevices()
- subscribeToOpenFromGarminConnect()
- subscribeToWatchState()
- units = settingsManager.settings.units
- broadcaster.register(SettingsObserver.self, observer: self)
- coreDataPublisher =
- changedObjectsOnManagedObjectContextDidSavePublisher()
- .receive(on: DispatchQueue.global(qos: .background))
- .share()
- .eraseToAnyPublisher()
- glucoseStorage.updatePublisher
- .receive(on: DispatchQueue.global(qos: .background))
- .sink { [weak self] _ in
- guard let self = self else { return }
- Task {
- let watchState = await self.setupGarminWatchState()
- let watchStateData = try JSONEncoder().encode(watchState)
- self.sendWatchStateData(watchStateData)
- }
- }
- .store(in: &subscriptions)
- registerHandlers()
- }
- // MARK: - Internal Setup / Handlers
- /// Sets up handlers for OrefDetermination and GlucoseStored entity changes in CoreData.
- /// When these change, we re-compute the Garmin watch state and send updates to the watch.
- private func registerHandlers() {
- coreDataPublisher?
- .filterByEntityName("OrefDetermination")
- .sink { [weak self] _ in
- guard let self = self else { return }
- Task {
- let watchState = await self.setupGarminWatchState()
- let watchStateData = try JSONEncoder().encode(watchState)
- self.sendWatchStateData(watchStateData)
- }
- }
- .store(in: &subscriptions)
- // Due to the batch insert, this only observes deletion of Glucose entries
- coreDataPublisher?
- .filterByEntityName("GlucoseStored")
- .sink { [weak self] _ in
- guard let self = self else { return }
- Task {
- let watchState = await self.setupGarminWatchState()
- let watchStateData = try JSONEncoder().encode(watchState)
- self.sendWatchStateData(watchStateData)
- }
- }
- .store(in: &subscriptions)
- }
- /// Fetches recent glucose readings from CoreData, up to 288 results.
- /// - Returns: An array of `NSManagedObjectID`s for glucose readings.
- private func fetchGlucose() async -> [NSManagedObjectID] {
- let results = await CoreDataStack.shared.fetchEntitiesAsync(
- ofType: GlucoseStored.self,
- onContext: backgroundContext,
- predicate: NSPredicate.glucose,
- key: "date",
- ascending: false,
- fetchLimit: 288
- )
- return await backgroundContext.perform {
- guard let fetchedResults = results as? [GlucoseStored] else { return [] }
- return fetchedResults.map(\.objectID)
- }
- }
- /// Builds a `GarminWatchState` reflecting the latest glucose, trend, delta, eventual BG, ISF, IOB, and COB.
- /// - Returns: A `GarminWatchState` containing the most recent device- and therapy-related info.
- func setupGarminWatchState() async -> GarminWatchState {
- // Get Glucose IDs
- let glucoseIds = await fetchGlucose()
- // Fetch the latest OrefDetermination object if available
- let determinationIds = await determinationStorage.fetchLastDeterminationObjectID(
- predicate: NSPredicate.predicateFor30MinAgoForDetermination
- )
- // Turn those IDs into live NSManagedObjects
- let glucoseObjects: [GlucoseStored] = await CoreDataStack.shared
- .getNSManagedObject(with: glucoseIds, context: backgroundContext)
- let determinationObjects: [OrefDetermination] = await CoreDataStack.shared
- .getNSManagedObject(with: determinationIds, context: backgroundContext)
- // Perform logic on the background context
- return await backgroundContext.perform {
- var watchState = GarminWatchState()
- /// Pull `glucose`, `trendRaw`, `delta`, `lastLoopDateInterval`, `iob`, `cob`, `isf`, and `eventualBGRaw` from the latest determination.
- if let latestDetermination = determinationObjects.first {
- watchState.lastLoopDateInterval = latestDetermination.timestamp.map {
- guard $0.timeIntervalSince1970 > 0 else { return 0 }
- return UInt64($0.timeIntervalSince1970)
- }
- let iobValue = latestDetermination.iob ?? 0
- watchState.iob = Formatter.decimalFormatterWithTwoFractionDigits.string(from: iobValue)
- let cobNumber = NSNumber(value: latestDetermination.cob)
- watchState.cob = Formatter.integerFormatter.string(from: cobNumber)
- let insulinSensitivity = latestDetermination.insulinSensitivity ?? 0
- let eventualBG = latestDetermination.eventualBG ?? 0
- if self.units == .mgdL {
- watchState.isf = insulinSensitivity.description
- watchState.eventualBGRaw = Formatter.glucoseFormatter(for: self.units)
- .string(from: eventualBG) ?? "0"
- } else {
- let parsedIsf = Double(truncating: insulinSensitivity).asMmolL
- let parsedEventualBG = Double(truncating: eventualBG).asMmolL
- watchState.isf = parsedIsf.description
- watchState.eventualBGRaw = Formatter.glucoseFormatter(for: self.units)
- .string(from: parsedEventualBG as NSNumber) ?? "0"
- }
- }
- // If no glucose data is present, just return partial watch state
- guard let latestGlucose = glucoseObjects.first else {
- return watchState
- }
- // Format the current glucose reading
- if self.units == .mgdL {
- watchState.glucose = "\(latestGlucose.glucose)"
- } else {
- let mgdlValue = Decimal(latestGlucose.glucose)
- let latestGlucoseValue = Double(truncating: mgdlValue.asMmolL as NSNumber)
- watchState.glucose = "\(latestGlucoseValue)"
- }
- // Convert direction to a textual trend
- watchState.trendRaw = latestGlucose.direction ?? "--"
- // Calculate a glucose delta if we have at least two readings
- if glucoseObjects.count >= 2 {
- var deltaValue = Decimal(glucoseObjects[0].glucose - glucoseObjects[1].glucose)
- if self.units == .mmolL {
- deltaValue = Double(truncating: deltaValue as NSNumber).asMmolL
- }
- let formattedDelta = Formatter.glucoseFormatter(for: self.units)
- .string(from: deltaValue as NSNumber) ?? "0"
- watchState.delta = deltaValue < 0 ? "\(formattedDelta)" : "+\(formattedDelta)"
- }
- debug(
- .watchManager,
- """
- 📱 Setup GarminWatchState - \
- glucose: \(watchState.glucose ?? "nil"), \
- trendRaw: \(watchState.trendRaw ?? "nil"), \
- delta: \(watchState.delta ?? "nil"), \
- eventualBGRaw: \(watchState.eventualBGRaw ?? "nil"), \
- isf: \(watchState.isf ?? "nil"), \
- cob: \(watchState.cob ?? "nil"), \
- iob: \(watchState.iob ?? "nil"), \
- lastLoopDateInterval: \(watchState.lastLoopDateInterval?.description ?? "nil")
- """
- )
- return watchState
- }
- }
- // MARK: - Device & App Registration
- /// Registers the given devices for ConnectIQ events (device status changes) and watch app messages.
- /// It also creates and registers watch apps (watchface + data field) for each device.
- /// - Parameter devices: The devices to register.
- private func registerDevices(_ devices: [IQDevice]) {
- // Clear out old references
- watchApps.removeAll()
- for device in devices {
- // Listen for device-level status changes
- connectIQ?.register(forDeviceEvents: device, delegate: self)
- // Create a watchface app
- guard
- let watchfaceUUID = Config.watchfaceUUID,
- let watchfaceApp = IQApp(uuid: watchfaceUUID, store: UUID(), device: device)
- else {
- debug(.watchManager, "Garmin: Could not create watchface app for device \(device.uuid!))")
- continue
- }
- // Create a watch data field app
- guard
- let watchdataUUID = Config.watchdataUUID,
- let watchDataFieldApp = IQApp(uuid: watchdataUUID, store: UUID(), device: device)
- else {
- debug(.watchManager, "Garmin: Could not create data-field app for device \(device.uuid!)")
- continue
- }
- // Track both apps for potential messages
- watchApps.append(watchfaceApp)
- watchApps.append(watchDataFieldApp)
- // Register to receive app-messages from the watchface
- connectIQ?.register(forAppMessages: watchfaceApp, delegate: self)
- }
- }
- /// Restores previously persisted devices from local storage into `devices`.
- private func restoreDevices() {
- devices = persistedDevices.map(\.iqDevice)
- }
- // MARK: - Combine Subscriptions
- /// Subscribes to the `.openFromGarminConnect` notification, parsing devices from the given URL
- /// and updating the device list accordingly.
- private func subscribeToOpenFromGarminConnect() {
- notificationCenter
- .publisher(for: .openFromGarminConnect)
- .sink { [weak self] notification in
- guard
- let self = self,
- let url = notification.object as? URL
- else { return }
- self.parseDevices(for: url)
- }
- .store(in: &cancellables)
- }
- /// Subscribes to any watch-state dictionaries published via `watchStateSubject`, and throttles them
- /// so updates aren’t sent too frequently. Each update triggers a broadcast to all watch apps.
- private func subscribeToWatchState() {
- watchStateSubject
- .throttle(for: .seconds(10), scheduler: DispatchQueue.main, latest: true)
- .sink { [weak self] state in
- self?.broadcastStateToWatchApps(state)
- }
- .store(in: &cancellables)
- }
- // MARK: - Parsing & Broadcasting
- /// Parses devices from a Garmin Connect URL and updates our `devices` property.
- /// - Parameter url: The URL provided by Garmin Connect containing device selection info.
- private func parseDevices(for url: URL) {
- let parsed = connectIQ?.parseDeviceSelectionResponse(from: url) as? [IQDevice]
- devices = parsed ?? []
- // Fulfill any pending promise in case this is in response to `selectDevices()`.
- deviceSelectionPromise?(.success(devices))
- deviceSelectionPromise = nil
- }
- /// Sends the given state dictionary to all known watch apps (watchface & data field) by checking
- /// if each app is installed and then sending messages asynchronously.
- /// - Parameter state: The dictionary representing the watch state to be broadcast.
- private func broadcastStateToWatchApps(_ state: NSDictionary) {
- watchApps.forEach { app in
- connectIQ?.getAppStatus(app) { [weak self] status in
- guard status?.isInstalled == true else {
- debug(.watchManager, "Garmin: App not installed on device: \(app.uuid!)")
- return
- }
- debug(.watchManager, "Garmin: Sending watch-state to app \(app.uuid!)")
- self?.sendMessage(state, to: app)
- }
- }
- }
- // MARK: - GarminManager Conformance
- /// Prompts the user to select one or more Garmin devices, returning a publisher that emits
- /// the final array of selected devices once the user finishes selection.
- /// - Returns: An `AnyPublisher` emitting `[IQDevice]` on success, or empty array on error/timeout.
- func selectDevices() -> AnyPublisher<[IQDevice], Never> {
- Future { [weak self] promise in
- guard let self = self else {
- // If self is gone, just resolve with an empty array
- promise(.success([]))
- return
- }
- // Store the promise so we can fulfill it when the user selects devices
- self.deviceSelectionPromise = promise
- // Show Garmin's default device selection UI
- self.connectIQ?.showDeviceSelection()
- }
- .timeout(.seconds(120), scheduler: DispatchQueue.main)
- .replaceEmpty(with: [])
- .eraseToAnyPublisher()
- }
- /// Updates the manager’s list of devices, typically after user selection or manual changes.
- /// - Parameter devices: The new array of `IQDevice` objects to track.
- func updateDeviceList(_ devices: [IQDevice]) {
- self.devices = devices
- }
- /// Converts the given JSON data into an NSDictionary and sends it to all known watch apps.
- /// - Parameter data: JSON-encoded data representing the latest watch state. If decoding fails,
- /// the method logs an error and does nothing else.
- func sendWatchStateData(_ data: Data) {
- guard
- let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []),
- let dict = jsonObject as? NSDictionary
- else {
- debug(.watchManager, "Garmin: Invalid JSON for watch-state data")
- return
- }
- watchStateSubject.send(dict)
- }
- // MARK: - Helper: Sending Messages
- /// Sends a message to a given IQApp with optional progress and completion callbacks.
- /// - Parameters:
- /// - msg: The dictionary to send to the watch app.
- /// - app: The `IQApp` instance representing the watchface or data field.
- private func sendMessage(_ msg: NSDictionary, to app: IQApp) {
- connectIQ?.sendMessage(
- msg,
- to: app,
- progress: { _, _ in
- // Optionally track progress here
- },
- completion: { result in
- switch result {
- case .success:
- debug(.watchManager, "Garmin: Successfully sent message to \(app.uuid!)")
- default:
- debug(.watchManager, "Garmin: Unknown result or failed to send message to \(app.uuid!)")
- }
- }
- )
- }
- }
- // MARK: - Extensions
- extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppMessageDelegate {
- // MARK: - IQUIOverrideDelegate
- /// Called if the Garmin Connect Mobile app is not installed or otherwise not available.
- /// Typically, you would show an alert or prompt the user to install the app from the store.
- func needsToInstallConnectMobile() {
- debug(.apsManager, "Garmin is not available")
- let messageCont = MessageContent(
- content: "The app Garmin Connect must be installed to use Trio.\nGo to the App Store to download it.",
- type: .warning,
- subtype: .misc,
- title: "Garmin is not available"
- )
- router.alertMessage.send(messageCont)
- }
- // MARK: - IQDeviceEventDelegate
- /// Called whenever the status of a registered Garmin device changes (e.g., connected, not found, etc.).
- /// - Parameters:
- /// - device: The device whose status has changed.
- /// - status: The new status for the device.
- func deviceStatusChanged(_ device: IQDevice, status: IQDeviceStatus) {
- switch status {
- case .invalidDevice:
- debug(.watchManager, "Garmin: invalidDevice (\(device.uuid!))")
- case .bluetoothNotReady:
- debug(.watchManager, "Garmin: bluetoothNotReady (\(device.uuid!))")
- case .notFound:
- debug(.watchManager, "Garmin: notFound (\(device.uuid!))")
- case .notConnected:
- debug(.watchManager, "Garmin: notConnected (\(device.uuid!))")
- case .connected:
- debug(.watchManager, "Garmin: connected (\(device.uuid!))")
- @unknown default:
- debug(.watchManager, "Garmin: unknown state (\(device.uuid!))")
- }
- }
- // MARK: - IQAppMessageDelegate
- /// Called when a message arrives from a Garmin watch app (watchface or data field).
- /// If the watch requests a "status" update, we call `setupGarminWatchState()` asynchronously
- /// and re-send the watch state data.
- /// - Parameters:
- /// - message: The message content from the watch app.
- /// - app: The watch app sending the message.
- func receivedMessage(_ message: Any, from app: IQApp) {
- debug(.watchManager, "Garmin: Received message \(message) from app \(app.uuid!)")
- Task {
- // Check if the message is literally the string "status"
- guard
- let statusString = message as? String,
- statusString == "status"
- else {
- return
- }
- do {
- // Fetch the latest watch state (async) and encode it to JSON data
- let watchState = await self.setupGarminWatchState()
- let watchStateData = try JSONEncoder().encode(watchState)
- // Now send that JSON data to the watch
- sendWatchStateData(watchStateData)
- } catch {
- debug(.watchManager, "Garmin: Cannot encode watch state: \(error)")
- }
- }
- }
- }
- extension BaseGarminManager {
- // MARK: - Config
- /// Configuration struct containing watch app UUIDs for the Garmin watchface and data field.
- private enum Config {
- /// Example watchface UUID
- static let watchfaceUUID = UUID(uuidString: "EC3420F6-027D-49B3-B45F-D81D6D3ED90A")
- /// Example data field UUID
- static let watchdataUUID = UUID(uuidString: "71CF0982-CA41-42A5-8441-EA81D36056C3")
- }
- }
- extension BaseGarminManager: SettingsObserver {
- /// Called whenever TrioSettings changes (e.g., user toggles mg/dL vs. mmol/L).
- /// - Parameter _: The updated TrioSettings instance.
- func settingsDidChange(_: TrioSettings) {
- // Update local units and re-send watch state
- units = settingsManager.settings.units
- Task {
- let watchState = await setupGarminWatchState()
- let watchStateData = try JSONEncoder().encode(watchState)
- sendWatchStateData(watchStateData)
- }
- }
- }
|