diff --git a/Loop.xcconfig b/Loop.xcconfig index ffe6062add..4ecf2d09d7 100644 --- a/Loop.xcconfig +++ b/Loop.xcconfig @@ -25,6 +25,14 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) // General [DEFAULT] LOOP_LOCAL_CACHE_DURATION_DAYS = 90 +// Background modes [DEFAULT] +// LOOP_BLUETOOTH_PERIPHERAL adds the bluetooth-peripheral background mode to +// Info.plist, which watch plugins that publish a Bluetooth service need to keep +// the watch updated while Loop is in the background. Set +// INFOPLIST_PREPROCESSOR_DEFINITIONS to nothing to leave it out. +INFOPLIST_PREPROCESS = YES +INFOPLIST_PREPROCESSOR_DEFINITIONS = LOOP_BLUETOOTH_PERIPHERAL=1 + // Entitlements [DEFAULT] LOOP_ENTITLEMENTS = Loop/Loop.entitlements diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index e02e07538d..98011a1743 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -234,6 +234,10 @@ 439A7944211FE22F0041B75F /* NSUserActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439A7943211FE22F0041B75F /* NSUserActivity.swift */; }; 439A7945211FE23A0041B75F /* NSUserActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439A7943211FE22F0041B75F /* NSUserActivity.swift */; }; 439BED2A1E76093C00B0AED5 /* CGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439BED291E76093C00B0AED5 /* CGMManager.swift */; }; + 0AB9F65B073904D4F9E5FCB9 /* SmartwatchManagerHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35441020C3D0037E9AEB302 /* SmartwatchManagerHost.swift */; }; + 1ADD1EF77E2083AC7690BD85 /* SmartwatchManagerHost+AlertForwarder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8772495BA2855FC99DDC3EB0 /* SmartwatchManagerHost+AlertForwarder.swift */; }; + A018C4AF5E040B0DAC379735 /* SmartwatchManagerHost+RemoteActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EE5E1D046A80EAC16C5514F /* SmartwatchManagerHost+RemoteActions.swift */; }; + D23CD09C2E6905F436987257 /* SmartwatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3621E1DB280412962D20BE6C /* SmartwatchManager.swift */; }; 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */; }; 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A51E201EB6DBDD000736CC /* LoopChartsTableViewController.swift */; }; 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A567681C94880B00334FAC /* LoopDataManager.swift */; }; @@ -595,6 +599,7 @@ C17DDC9C28AC339E005FBF4C /* PersistedProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */; }; C17DDC9D28AC33A1005FBF4C /* PersistedProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */; }; C188599B2AF15E1B0010F21F /* DeviceDataManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */; }; + 22A2B3D7A696722C213DDFCD /* SmartwatchManagerHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E4F9D180117EFDC2A339E1 /* SmartwatchManagerHostTests.swift */; }; C188599E2AF15FAB0010F21F /* AlertMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599D2AF15FAB0010F21F /* AlertMocks.swift */; }; C18859A02AF1612B0010F21F /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599F2AF1612B0010F21F /* PersistenceController.swift */; }; C18859A22AF165130010F21F /* MockPumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18859A12AF165130010F21F /* MockPumpManager.swift */; }; @@ -1039,6 +1044,10 @@ 439A7941211F631C0041B75F /* RootNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootNavigationController.swift; sourceTree = ""; }; 439A7943211FE22F0041B75F /* NSUserActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSUserActivity.swift; sourceTree = ""; }; 439BED291E76093C00B0AED5 /* CGMManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMManager.swift; sourceTree = ""; }; + A35441020C3D0037E9AEB302 /* SmartwatchManagerHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartwatchManagerHost.swift; sourceTree = ""; }; + 8772495BA2855FC99DDC3EB0 /* SmartwatchManagerHost+AlertForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SmartwatchManagerHost+AlertForwarder.swift"; sourceTree = ""; }; + 3EE5E1D046A80EAC16C5514F /* SmartwatchManagerHost+RemoteActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SmartwatchManagerHost+RemoteActions.swift"; sourceTree = ""; }; + 3621E1DB280412962D20BE6C /* SmartwatchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartwatchManager.swift; sourceTree = ""; }; 43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAbsorptionViewController.swift; sourceTree = ""; }; 43A51E201EB6DBDD000736CC /* LoopChartsTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopChartsTableViewController.swift; sourceTree = ""; }; 43A567681C94880B00334FAC /* LoopDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = LoopDataManager.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; @@ -1459,6 +1468,7 @@ C17D52072E7F0E18001D2AD2 /* CarbList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbList.swift; sourceTree = ""; }; C1814B85225E507C008D2D8E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDataManagerTests.swift; sourceTree = ""; }; + 13E4F9D180117EFDC2A339E1 /* SmartwatchManagerHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartwatchManagerHostTests.swift; sourceTree = ""; }; C188599D2AF15FAB0010F21F /* AlertMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMocks.swift; sourceTree = ""; }; C188599F2AF1612B0010F21F /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; C18859A12AF165130010F21F /* MockPumpManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPumpManager.swift; sourceTree = ""; }; @@ -1824,6 +1834,7 @@ C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */, A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */, C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */, + 13E4F9D180117EFDC2A339E1 /* SmartwatchManagerHostTests.swift */, C16B983F26B4898800256B05 /* DoseEnactorTests.swift */, E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */, A1B2C3D40000000000000001 /* GlucoseAlertManagerTests.swift */, @@ -2287,6 +2298,10 @@ C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */, 439BED291E76093C00B0AED5 /* CGMManager.swift */, + A35441020C3D0037E9AEB302 /* SmartwatchManagerHost.swift */, + 8772495BA2855FC99DDC3EB0 /* SmartwatchManagerHost+AlertForwarder.swift */, + 3EE5E1D046A80EAC16C5514F /* SmartwatchManagerHost+RemoteActions.swift */, + 3621E1DB280412962D20BE6C /* SmartwatchManager.swift */, C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */, A977A2F324ACFECF0059C207 /* CriticalEventLogExportManager.swift */, C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */, @@ -3821,6 +3836,10 @@ 434FF1EE1CF27EEF000DB779 /* UITableViewCell.swift in Sources */, 84DF48C32F6A0AF600BEDB40 /* Image+Crop.swift in Sources */, 439BED2A1E76093C00B0AED5 /* CGMManager.swift in Sources */, + 0AB9F65B073904D4F9E5FCB9 /* SmartwatchManagerHost.swift in Sources */, + 1ADD1EF77E2083AC7690BD85 /* SmartwatchManagerHost+AlertForwarder.swift in Sources */, + A018C4AF5E040B0DAC379735 /* SmartwatchManagerHost+RemoteActions.swift in Sources */, + D23CD09C2E6905F436987257 /* SmartwatchManager.swift in Sources */, C16B983E26B4893300256B05 /* DoseEnactor.swift in Sources */, 3ED319962EB65A5C00820BCF /* LiveActivityManagementViewModel.swift in Sources */, B4001CEE28CBBC82002FB414 /* AlertManagementView.swift in Sources */, @@ -4225,6 +4244,7 @@ 1DA7A84224476EAD008257F0 /* AlertManagerTests.swift in Sources */, A91E4C2324F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift in Sources */, C188599B2AF15E1B0010F21F /* DeviceDataManagerTests.swift in Sources */, + 22A2B3D7A696722C213DDFCD /* SmartwatchManagerHostTests.swift in Sources */, C18859A42AF165330010F21F /* MockCGMManager.swift in Sources */, E9C58A7324DB4A2700487A17 /* LoopDataManagerTests.swift in Sources */, A1B2C3D40000000000000002 /* GlucoseAlertManagerTests.swift in Sources */, diff --git a/Loop/Info.plist b/Loop/Info.plist index ee01b7fbfb..5fcedef013 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -109,6 +109,9 @@ UIBackgroundModes bluetooth-central +#if LOOP_BLUETOOTH_PERIPHERAL + bluetooth-peripheral +#endif processing remote-notification audio diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 92601ae0e8..b0c2e65697 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -6105,6 +6105,9 @@ } } }, + "Add Watch" : { + "comment" : "The title of the watch chooser in settings\nTitle text for button to add a watch device" + }, "Adjust Preset Duration" : { }, @@ -7932,6 +7935,9 @@ "Apple Health" : { "comment" : "Apple Health settings button text\nTitle of the Apple Health access screen" }, + "Apple Watch isn't added here. Loop's Apple Watch app is installed from the Watch app on your iPhone." : { + "comment" : "Footer of the watch chooser in settings: Apple Watch is not set up there" + }, "Are you sure you want to delete all history entries?" : { "comment" : "Action sheet confirmation message for pump history deletion", "localizations" : { @@ -35704,6 +35710,9 @@ } } }, + "Tap here to set up a watch other than Apple Watch" : { + "comment" : "Descriptive text for button to add a watch device: Apple Watch uses Loop's own watch app" + }, "Tap to Add" : { "comment" : "The subtitle of the cell displaying an action to add a manually measurement glucose value", "extractionState" : "stale", @@ -40337,6 +40346,9 @@ } } }, + "Watch" : { + "comment" : "Descriptive text for a watch plugin in Settings → Devices" + }, "What are examples of Critical Alerts and Time Sensitive Notifications?" : { }, diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index fc70c5b126..52ec2029f3 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -21,6 +21,17 @@ public enum AlertUserNotificationUserInfoKey: String { case alert, alertTimestamp } +/// A sink that renders Loop's alerts somewhere beyond the phone — such as a +/// watch plugin that buzzes and shows them. Registered with `addAlertForwarder` +/// and called from the same fan-out as the modal and notification schedulers, so +/// it receives the `muted` determination Loop uses for notification sound. That +/// determination covers critical alerts too: Loop's mute delivers them at volume 0. +@MainActor +protocol AlertForwarder: AnyObject { + func alertManager(_ manager: AlertManager, didIssueAlert alert: Alert, muted: Bool) + func alertManager(_ manager: AlertManager, didRetractAlertWithIdentifier identifier: Alert.Identifier) +} + /// Main (singleton-ish) class that is responsible for: /// - managing the different targets (handlers) that will post alerts /// - managing the different responders that might acknowledge the alert @@ -38,6 +49,9 @@ public final class AlertManager { private var responders: [String: Weak] = [:] private var soundVendors: [String: Weak] = [:] + private struct WeakAlertForwarder { weak var value: AlertForwarder? } + private var alertForwarders: [WeakAlertForwarder] = [] + // Defer issuance of new alerts until playback is done private var deferredAlerts: [Alert] = [] private var deferredRetractions: [Alert.Identifier] = [] @@ -143,6 +157,11 @@ public final class AlertManager { soundVendors.removeValue(forKey: managerIdentifier) } + func addAlertForwarder(_ forwarder: AlertForwarder) { + alertForwarders.removeAll { $0.value == nil || $0.value === forwarder } + alertForwarders.append(WeakAlertForwarder(value: forwarder)) + } + // MARK: - Bluetooth alerts private func onBluetoothPermissionDenied() { @@ -446,6 +465,9 @@ extension AlertManager: AlertIssuer { criticalAlertAudioPlayer.play(soundNamed: inAppSound, boostVolume: false, for: alert.identifier) } playCriticalAlertAudioFallbackIfNeeded(for: alert, muted: muted, issuedDate: issuedDate) + for forwarder in alertForwarders.compactMap(\.value) { + forwarder.alertManager(self, didIssueAlert: alert, muted: muted) + } } private func inAppSoundThroughSilent(for alert: Alert) -> String? { @@ -463,6 +485,9 @@ extension AlertManager: AlertIssuer { userNotificationAlertScheduler.unscheduleAlert(identifier: identifier) criticalAlertAlarmScheduler.cancelAlarm(for: identifier) criticalAlertAudioPlayer.stop(for: identifier) + for forwarder in alertForwarders.compactMap(\.value) { + forwarder.alertManager(self, didRetractAlertWithIdentifier: identifier) + } } /// When the system can't deliver a true Critical Alert (no diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 19db48210d..9e15656a95 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -166,6 +166,23 @@ final class DeviceDataManager { @PersistedProperty(key: "PumpManagerState") var rawPumpManager: PumpManager.RawValue? + // MARK: - Watch + + var smartwatchManager: SmartwatchManagerUI? { + didSet { + // Restoring at launch starts from nil, so this only fires on removal or replacement. + if let oldValue, smartwatchManager.map({ ($0 as AnyObject) !== (oldValue as AnyObject) }) ?? true { + tearDownWatch(oldValue) + } + setupWatch() + NotificationCenter.default.post(name: .SmartwatchManagerChanged, object: self, userInfo: nil) + rawSmartwatchManager = smartwatchManager?.rawValue + } + } + + @PersistedProperty(key: "SmartwatchManagerState") + var rawSmartwatchManager: SmartwatchManager.RawValue? + var doseEnactor = DoseEnactor() @@ -242,6 +259,15 @@ final class DeviceDataManager { private weak var displayGlucoseUnitBroadcaster: DisplayGlucoseUnitBroadcaster? + /// `smartwatchManager`'s delegate. Created after a watch manager may have been + /// restored at launch, so the manager is handed to it once it's set. + weak var smartwatchManagerHost: SmartwatchManagerHost? { + didSet { + smartwatchManager?.smartwatchManagerDelegate = smartwatchManagerHost + smartwatchManagerHost?.refresh() + } + } + init(pluginManager: PluginManager, deviceLog: PersistentDeviceLog, alertManager: AlertManager, @@ -353,6 +379,15 @@ final class DeviceDataManager { log.error("Saved CGM manager plugin unavailable; preserving persisted CGMManagerState for a future launch") } } + + if let smartwatchManagerRawValue = rawSmartwatchManager { + if let restoredSmartwatchManager = smartwatchManagerFromRawValue(smartwatchManagerRawValue) { + smartwatchManager = restoredSmartwatchManager + } else { + // As for the pump and CGM: keep the saved state for a build that includes the plugin. + log.error("Saved watch manager plugin unavailable; preserving persisted SmartwatchManagerState for a future launch") + } + } } var availablePumpManagers: [PumpManagerDescriptor] { @@ -562,6 +597,37 @@ final class DeviceDataManager { return .success(result) } + var availableSmartwatchManagers: [SmartwatchManagerDescriptor] { + pluginManager.availableSmartwatchManagers + } + + struct UnknownSmartwatchManagerIdentifierError: Error {} + + func setupSmartwatchManager(withIdentifier identifier: String) -> Swift.Result, Error> { + guard let smartwatchManagerUIType = pluginManager.getSmartwatchManagerTypeByIdentifier(identifier) else { + return .failure(UnknownSmartwatchManagerIdentifierError()) + } + + let result = smartwatchManagerUIType.setupViewController(bluetoothProvider: bluetoothProvider, displayGlucosePreference: displayGlucosePreference, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures) + if case .createdAndOnboarded(let smartwatchManagerUI) = result { + smartwatchManagerOnboarding(didCreateSmartwatchManager: smartwatchManagerUI) + smartwatchManagerOnboarding(didOnboardSmartwatchManager: smartwatchManagerUI) + } + + return .success(result) + } + + func smartwatchManagerFromRawValue(_ rawValue: [String: Any]) -> SmartwatchManagerUI? { + guard let managerIdentifier = rawValue["managerIdentifier"] as? String, + let rawState = rawValue["state"] as? SmartwatchManager.RawStateValue, + let Manager = pluginManager.getSmartwatchManagerTypeByIdentifier(managerIdentifier) + else { + return nil + } + + return Manager.init(rawState: rawState) + } + public func cgmManagerTypeByIdentifier(_ identifier: String) -> CGMManagerUI.Type? { return pluginManager.getCGMManagerTypeByIdentifier(identifier) ?? staticCGMManagersByIdentifier[identifier] as? CGMManagerUI.Type } @@ -704,6 +770,25 @@ private extension DeviceDataManager { } } + func setupWatch() { + smartwatchManager?.smartwatchManagerDelegate = smartwatchManagerHost + if let smartwatchManager { + alertManager?.addAlertResponder(managerIdentifier: smartwatchManager.pluginIdentifier, + alertResponder: smartwatchManager) + alertManager?.addAlertSoundVendor(managerIdentifier: smartwatchManager.pluginIdentifier, + soundVendor: smartwatchManager) + } + reportPluginInitializationComplete() + smartwatchManagerHost?.refresh() + } + + /// A removed or replaced watch manager hears nothing more from Loop. + func tearDownWatch(_ smartwatchManager: SmartwatchManagerUI) { + smartwatchManager.smartwatchManagerDelegate = nil + alertManager?.removeAlertResponder(managerIdentifier: smartwatchManager.pluginIdentifier) + alertManager?.removeAlertSoundVendor(managerIdentifier: smartwatchManager.pluginIdentifier) + } + func setupPump() { dispatchPrecondition(condition: .onQueue(.main)) @@ -786,6 +871,12 @@ extension DeviceDataManager { allActivePlugins.append(pumpManager) } } + + if let smartwatchManager = smartwatchManager { + if !allActivePlugins.contains(where: { $0.pluginIdentifier == smartwatchManager.pluginIdentifier }) { + allActivePlugins.append(smartwatchManager) + } + } return allActivePlugins } @@ -794,6 +885,13 @@ extension DeviceDataManager { // MARK: - Client API extension DeviceDataManager { func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws { + try await enactBolus(units: units, decisionId: decisionId, activationType: activationType, throwingPumpErrors: false) + } + + /// With `throwingPumpErrors`, the pump's error (uncertain delivery included) is + /// thrown after its notification, for callers that report the outcome elsewhere, + /// such as a smartwatch, and must not report a failed bolus as delivered. + func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType, throwingPumpErrors: Bool) async throws { guard let pumpManager = pumpManager else { throw LoopError.configurationError(.pumpManager) } @@ -811,6 +909,9 @@ extension DeviceDataManager { try await pumpManager.enactBolus(decisionId: decisionId, units: units, activationType: activationType) } catch PumpManagerError.uncertainDelivery { // Do not generate notification on uncertain delivery error + if throwingPumpErrors { + throw PumpManagerError.uncertainDelivery + } } catch { if !activationType.isAutomatic, let error = error as? PumpManagerError { do { @@ -819,6 +920,9 @@ extension DeviceDataManager { log.error("Error sending bolus failure notification %{public}@", String(describing: error)) } } + if throwingPumpErrors { + throw error + } } } @@ -1008,6 +1112,19 @@ extension DeviceDataManager: CGMManagerDelegate { } } +// MARK: - SmartwatchManagerOnboardingDelegate +extension DeviceDataManager: SmartwatchManagerOnboardingDelegate { + func smartwatchManagerOnboarding(didCreateSmartwatchManager smartwatchManager: SmartwatchManagerUI) { + log.default("Watch manager with identifier '%{public}@' created", smartwatchManager.pluginIdentifier) + self.smartwatchManager = smartwatchManager + } + + func smartwatchManagerOnboarding(didOnboardSmartwatchManager smartwatchManager: SmartwatchManagerUI) { + log.default("Watch manager with identifier '%{public}@' onboarded", smartwatchManager.pluginIdentifier) + smartwatchManagerHost?.refresh() + } +} + // MARK: - CGMManagerOnboardingDelegate extension DeviceDataManager: CGMManagerOnboardingDelegate { @@ -1335,6 +1452,7 @@ extension DeviceDataManager: BolusDurationEstimator { extension Notification.Name { static let PumpManagerChanged = Notification.Name(rawValue: "com.loopKit.notification.PumpManagerChanged") static let CGMManagerChanged = Notification.Name(rawValue: "com.loopKit.notification.CGMManagerChanged") + static let SmartwatchManagerChanged = Notification.Name(rawValue: "com.loopKit.notification.SmartwatchManagerChanged") static let PumpEventsAdded = Notification.Name(rawValue: "com.loopKit.notification.PumpEventsAdded") } @@ -1436,6 +1554,8 @@ extension DeviceDataManager: DeviceSupportDelegate { "", self.pumpManager != nil ? String(reflecting: self.pumpManager!) : "pumpManager: nil", "", + self.smartwatchManager != nil ? String(reflecting: self.smartwatchManager!) : "smartwatchManager: nil", + "", await deviceLog.generateDiagnosticReport() ] return report.joined(separator: "\n") diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 2e9761470e..50af424b73 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -93,6 +93,7 @@ class LoopAppManager: NSObject { private var mealDetectionManager: MealDetectionManager! private var statusExtensionManager: ExtensionDataManager! private var watchManager: WatchDataManager! + private var smartwatchManagerHost: SmartwatchManagerHost! private var crashRecoveryManager: CrashRecoveryManager! private var cgmEventStore: CgmEventStore! private var servicesManager: ServicesManager! @@ -427,6 +428,46 @@ class LoopAppManager: NSObject { healthStore: healthStore ) + smartwatchManagerHost = SmartwatchManagerHost( + deviceManager: deviceDataManager, + loopDataManager: loopDataManager, + settingsManager: settingsManager, + glucoseStore: glucoseStore, + healthStore: healthStore, + alertMuter: alertManager.alertMuter, + // The same path as remote boluses: maximum bolus check, notifications, upload. + // A pump error is thrown, so the watch isn't told a failed bolus was delivered. + deliverBolus: { [weak servicesManager] units in + guard let servicesManager else { + throw SmartwatchBolusError.notAllowed + } + try await servicesManager.deliverRemoteBolus(amountInUnits: units, decisionId: nil, throwingPumpErrors: true) + }, + // The same path as remote carbs: validation, notification, upload. + deliverCarbs: { [weak servicesManager] grams, date, absorptionTime in + guard let servicesManager else { + throw SmartwatchCarbEntryError.notAllowed + } + try await servicesManager.deliverRemoteCarbs(amountInGrams: grams, absorptionTime: absorptionTime, foodType: nil, startDate: date) + }, + // The same path as remote overrides: the preset's own duration, upload. + startOverride: { [weak servicesManager] name in + guard let servicesManager else { + throw SmartwatchPresetError.notAllowed + } + try await servicesManager.enactRemoteOverride(name: name, durationTime: nil, remoteAddress: "Watch") + }, + cancelOverride: { [weak servicesManager] in + guard let servicesManager else { + throw SmartwatchPresetError.notAllowed + } + try await servicesManager.cancelRemoteOverride() + } + ) + deviceDataManager.smartwatchManagerHost = smartwatchManagerHost + // Forward Loop's alerts to the watch plugin so it buzzes in step with the phone. + alertManager.addAlertForwarder(smartwatchManagerHost) + self.mealDetectionManager = MealDetectionManager( algorithmStateProvider: loopDataManager, settingsProvider: temporaryPresetsManager, @@ -607,6 +648,13 @@ class LoopAppManager: NSObject { ) viewModel.favoriteFoodInsightsDelegate = loopDataManager + viewModel.smartwatchManagerSettingsViewModel = SmartwatchManagerViewModel( + image: {[weak self] in self?.deviceDataManager.smartwatchManager?.smallImage }, + name: {[weak self] in self?.deviceDataManager.smartwatchManager?.localizedTitle ?? "" }, + isSetUp: {[weak self] in self?.deviceDataManager.smartwatchManager?.isOnboarded == true }, + availableDevices: deviceDataManager.availableSmartwatchManagers + ) + viewModel.insulinDeliveryHistory = { [weak loopDataManager] days in guard let loopDataManager else { throw CancellationError() } return try await loopDataManager.insulinDeliveryHistory(days: days) @@ -753,7 +801,9 @@ class LoopAppManager: NSObject { func handle(_ url: URL) -> Bool { guard let deeplink = Deeplink(url: url) else { - return false + // Not one of Loop's own links: offer it to the watch plugin, for a + // companion app's callback such as a watch picker. + return deviceDataManager?.smartwatchManager?.watchHostDidReceiveURL(url) ?? false } analyticsServicesManager.didOpenFromLink() @@ -1091,8 +1141,8 @@ extension LoopAppManager: ResetLoopManagerDelegate { // MARK: - ServicesManagerDosingDelegate extension LoopAppManager: ServicesManagerDosingDelegate { - func deliverBolus(amountInUnits: Double, decisionId: UUID?) async throws { - try await deviceDataManager.enactBolus(units: amountInUnits, decisionId: decisionId, activationType: .manualNoRecommendation) + func deliverBolus(amountInUnits: Double, decisionId: UUID?, throwingPumpErrors: Bool) async throws { + try await deviceDataManager.enactBolus(units: amountInUnits, decisionId: decisionId, activationType: .manualNoRecommendation, throwingPumpErrors: throwingPumpErrors) } } diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index b557f1840a..f3d15e8027 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -244,7 +244,7 @@ class ServicesManager { } public protocol ServicesManagerDosingDelegate: AnyObject { - func deliverBolus(amountInUnits: Double, decisionId: UUID?) async throws + func deliverBolus(amountInUnits: Double, decisionId: UUID?, throwingPumpErrors: Bool) async throws } public protocol ServicesManagerDelegate: AnyObject { @@ -337,6 +337,12 @@ extension ServicesManager: ServiceDelegate { } func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?) async throws { + try await deliverRemoteBolus(amountInUnits: amountInUnits, decisionId: decisionId, throwingPumpErrors: false) + } + + /// `throwingPumpErrors`: see `DeviceDataManager.enactBolus`. Off for remote + /// commands, which keep their behaviour; on for the smartwatch host. + func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?, throwingPumpErrors: Bool) async throws { do { guard amountInUnits > 0 else { @@ -351,7 +357,7 @@ extension ServicesManager: ServiceDelegate { throw BolusActionError.exceedsMaxBolus } - try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits, decisionId: decisionId) + try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits, decisionId: decisionId, throwingPumpErrors: throwingPumpErrors) NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) await remoteDataServicesManager.performUpload(for: .dose) analyticsServicesManager.didBolus(source: "Remote") diff --git a/Loop/Managers/SmartwatchManager.swift b/Loop/Managers/SmartwatchManager.swift new file mode 100644 index 0000000000..6b75b22c40 --- /dev/null +++ b/Loop/Managers/SmartwatchManager.swift @@ -0,0 +1,20 @@ +// +// SmartwatchManager.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import LoopKit + +extension SmartwatchManager { + + typealias RawValue = [String: Any] + + var rawValue: [String: Any] { + return [ + "managerIdentifier": pluginIdentifier, + "state": self.rawState + ] + } +} diff --git a/Loop/Managers/SmartwatchManagerHost+AlertForwarder.swift b/Loop/Managers/SmartwatchManagerHost+AlertForwarder.swift new file mode 100644 index 0000000000..2416f39c69 --- /dev/null +++ b/Loop/Managers/SmartwatchManagerHost+AlertForwarder.swift @@ -0,0 +1,129 @@ +// +// SmartwatchManagerHost+AlertForwarder.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopAlgorithm +import LoopKit + +extension SmartwatchManagerHost: AlertForwarder { + /// Forwards Loop's immediate, unmuted alerts to the watch so it buzzes in step + /// with the phone. Delayed/repeating alerts are left to the phone's notification + /// schedule. Muted alerts are not sent: Loop's mute covers critical alerts too + /// (they are delivered at volume 0), so the watch follows the mute for every + /// alert. Each alert that isn't sent is logged with the reason. + func alertManager(_ manager: AlertManager, didIssueAlert alert: Alert, muted: Bool) { + guard let smartwatchManager = deviceManager.smartwatchManager else { + return + } + let content = alert.foregroundContent ?? alert.backgroundContent + // Log why an alert doesn't reach the watch, so a watch alert level of 0 + // can be explained from the device log. + // The watch is a glucose display, so only forward glucose alerts (from + // GlucoseAlertManager). Pump/pod reminders, CGM sensor-expiry and other + // system alerts stay on the phone. This also avoids a stale watch level: + // a pod reminder is acknowledged but never retracted, so it would otherwise + // linger in the plugin's active set forever. + guard alert.identifier.managerIdentifier == GlucoseAlertManager.managerIdentifier else { + // Device log only: override reminders, pod alerts and the like never + // reach the watch, so they would only clutter the watch's own log. + logAlert(smartwatchManager, "\(content.title) not sent to the watch: only glucose alerts are forwarded", showInPluginLog: false) + return + } + guard case .immediate = alert.trigger else { + logAlert(smartwatchManager, "\(content.title) not sent to the watch: it is scheduled for later, and only immediate alerts are forwarded") + return + } + guard !muted else { + logAlert(smartwatchManager, "\(content.title) not sent to the watch: Loop's alerts are muted") + return + } + smartwatchManager.watchAlertDidIssue(alert, glucoseAlertType: Self.glucoseAlertType(of: alert.identifier), issuedAt: Date()) + // Loop never retracts a test alert: clear it on the watch once it has shown. + if Self.isTestAlert(alert.identifier) { + lastWatchTestAlertAt = Date() + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(Self.watchTestAlertDuration * 1_000_000_000)) + guard let current = self.deviceManager.smartwatchManager else { return } + self.retractWatchAlertsNoLongerShown(current) + } + } + // Queued after the plugin's own "issued" line, which also hops to the main + // actor. Uses the unit the watch reads (HealthKit's stored one). + let firedAt = Date() + Task { @MainActor in + let unit = await self.preferredGlucoseUnit() + if let note = self.nextAlertNote(for: alert, unit: unit, now: firedAt) { + self.logAlert(smartwatchManager, note) + } + } + } + + /// When Loop will alert again for a glucose alert it just fired, and what + /// clears it, so the device log explains the next phone alert and watch buzz. + /// Reads GlucoseAlertManager's active settings; the alert fired just now, so a + /// repeat is due one repeat interval from now, on the first reading from then. + private func nextAlertNote(for alert: Alert, unit: LoopUnit, now: Date) -> String? { + let config = deviceManager.glucoseAlertManager.activeConfiguration(at: now) + let title = (alert.foregroundContent ?? alert.backgroundContent).title + let rule: (above: Bool, threshold: Double, repeatInterval: TimeInterval?) + switch alert.identifier.alertIdentifier { + case GlucoseAlertManager.highAlertIdentifier: + rule = (true, config.highThresholdMgDL, config.highSnoozeEnabled ? config.highRepeatInterval : nil) + case GlucoseAlertManager.lowAlertIdentifier: + rule = (false, config.lowThresholdMgDL, config.lowSnoozeEnabled ? config.lowRepeatInterval : nil) + case GlucoseAlertManager.urgentLowAlertIdentifier: + rule = (false, config.urgentLowThresholdMgDL, config.urgentLowRepeatInterval) + case GlucoseAlertManager.predictedLowAlertIdentifier: + return "Next alert: \(title) doesn't repeat; it fires once per predicted low." + default: + return nil + } + let margin = config.recoveryMarginMgDL + let level = glucoseText(rule.threshold, unit: unit) + let clearLevel = glucoseText(rule.above ? rule.threshold - margin : rule.threshold + margin, unit: unit) + let side = rule.above ? "above" : "below" + let recovers = rule.above ? "falls below" : "rises above" + guard let repeatInterval = rule.repeatInterval else { + return "Next alert: \(title) doesn't repeat (repeat is off). It fires again only after glucose \(recovers) \(clearLevel) and then goes \(side) \(level) again." + } + let next = now.addingTimeInterval(repeatInterval) + return "Next alert: if glucose is still \(side) \(level), Loop repeats \(title) and forwards it to the watch at the first reading from \(timeText(next)) (every \(intervalText(repeatInterval))). It clears once glucose \(recovers) \(clearLevel)." + } + + private func glucoseText(_ milligramsPerDeciliter: Double, unit: LoopUnit) -> String { + let quantity = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: milligramsPerDeciliter) + return QuantityFormatter(for: unit).string(from: quantity) ?? "\(Int(milligramsPerDeciliter)) mg/dL" + } + + private func timeText(_ date: Date) -> String { + let calendar = Calendar.current + if calendar.isDateInToday(date) { + return DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .short) + } + if calendar.isDateInTomorrow(date) { + return DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .short) + " tomorrow" + } + return DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .short) + } + + private func intervalText(_ interval: TimeInterval) -> String { + let minutes = Int((interval / 60).rounded()) + guard minutes >= 60 else { return "\(minutes) min" } + return minutes % 60 == 0 ? "\(minutes / 60) h" : "\(minutes / 60) h \(minutes % 60) min" + } + + /// Retractions of glucose alerts, the only ones forwarded, so an alert the + /// watch was given doesn't stay active there. + func alertManager(_ manager: AlertManager, didRetractAlertWithIdentifier identifier: Alert.Identifier) { + guard let smartwatchManager = deviceManager.smartwatchManager, + identifier.managerIdentifier == GlucoseAlertManager.managerIdentifier + else { + return + } + smartwatchManager.watchAlertDidRetract(identifier) + } +} diff --git a/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift new file mode 100644 index 0000000000..07ba954746 --- /dev/null +++ b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift @@ -0,0 +1,234 @@ +// +// SmartwatchManagerHost+RemoteActions.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopAlgorithm +import LoopCore +import LoopKit + +/// Requests from the watch plugin: presets, boluses, alert mute and carbs. Each +/// gets the checks Loop applies to any request of that kind, such as its maximum +/// bolus, and only the current watch manager's requests are acted on. Whether the +/// watch may ask at all, and how fresh or frequent its requests must be, is the +/// plugin's own setting and policy. Every request and its outcome goes to the +/// device log. +extension SmartwatchManagerHost { + /// Starts or ends a preset for a request from the watch, through the same path + /// as remote overrides. + func smartwatchManager(_ manager: SmartwatchManager, enactOverride action: SmartwatchPresetAction) async throws { + let preset: TemporaryPreset? + switch action { + case .activate(let presetID): + preset = settingsManager.settings.overridePresets.first { $0.id == presetID } + logWatchRequest(manager, type: .receive, "Preset request: start \(preset.map { "\"\($0.name)\"" } ?? presetID)") + case .cancel: + preset = nil + logWatchRequest(manager, type: .receive, "Preset request: end the running preset") + } + guard isCurrentWatch(manager) else { + logWatchRequest(manager, type: .error, "Preset request rejected: notAllowed (the request isn't from the current watch)") + throw SmartwatchPresetError.notAllowed + } + do { + switch action { + case .activate: + guard let preset else { + logWatchRequest(manager, type: .error, "Preset request rejected: unknownPreset") + throw SmartwatchPresetError.unknownPreset + } + // Loop starts a preset by name; with another one of the same name it + // could start that one instead of the one the watch chose. + guard settingsManager.settings.overridePresets.filter({ $0.name == preset.name }).count == 1 else { + logWatchRequest(manager, type: .error, "Preset request rejected: another preset is also named \"\(preset.name)\"") + throw SmartwatchPresetError.failed("another preset has the same name") + } + try await startOverride(preset.name) + logWatchRequest(manager, type: .delegateResponse, "Preset \"\(preset.name)\" started from the watch") + case .cancel: + try await cancelOverride() + logWatchRequest(manager, type: .delegateResponse, "Preset ended from the watch") + } + } catch let error as SmartwatchPresetError { + throw error + } catch { + logWatchRequest(manager, type: .error, "Preset request from the watch failed: \(error)") + throw SmartwatchPresetError.failed(String(describing: error)) + } + signal(.presets) + } + + // MARK: - Watch bolus + + /// Delivers a bolus the plugin passed on, through the same path as remote + /// boluses. Only for the current watch manager, at most Loop's maximum bolus, + /// rounded to a volume the pump supports, and one at a time. Returns once the + /// pump accepted the bolus (it may still be cancelled while it runs): a pump + /// error, uncertain delivery included, is thrown as `.deliveryFailed`. + func smartwatchManager(_ manager: SmartwatchManager, deliverBolus units: Double) async throws -> Double { + logWatchRequest(manager, type: .receive, String(format: "Bolus request: %.3f U", units)) + let maximumBolus = settingsManager.loopSettings.maximumBolus + let maximumText = maximumBolus.map { String(format: "maximum bolus %.2f U", $0) } ?? "no maximum bolus" + guard isCurrentWatch(manager) else { + logWatchRequest(manager, type: .error, "Bolus request rejected: notAllowed (the request isn't from the current watch)") + throw SmartwatchBolusError.notAllowed + } + // Checked and set with no suspension in between, so two requests can't + // both pass. + guard !isDeliveringWatchBolus else { + logWatchRequest(manager, type: .error, "Bolus request answered with inProgress: a watch bolus is still being delivered") + throw SmartwatchBolusError.inProgress + } + guard units.isFinite, units > 0 else { + logWatchRequest(manager, type: .error, "Bolus request rejected: invalidAmount") + throw SmartwatchBolusError.invalidAmount + } + guard let maximumBolus else { + logWatchRequest(manager, type: .error, "Bolus request rejected: no maximum bolus is set") + throw SmartwatchBolusError.refusedByHost("no maximum bolus is set") + } + guard let pumpManager = deviceManager.pumpManager else { + logWatchRequest(manager, type: .error, "Bolus request rejected: no pump") + throw SmartwatchBolusError.refusedByHost("no pump") + } + let rounded = pumpManager.roundToSupportedBolusVolume(units: units) + // Both the amount asked for and the one delivered, whichever way the pump rounds. + guard units <= maximumBolus, rounded <= maximumBolus else { + logWatchRequest(manager, type: .error, String(format: "Bolus request rejected: exceedsMaximumBolus (%.3f U, rounded %.3f U; %@)", units, rounded, maximumText)) + throw SmartwatchBolusError.exceedsMaximumBolus + } + guard rounded > 0 else { + logWatchRequest(manager, type: .error, String(format: "Bolus request rejected: invalidAmount (%.3f U rounds to 0 U)", units)) + throw SmartwatchBolusError.invalidAmount + } + + isDeliveringWatchBolus = true + defer { isDeliveringWatchBolus = false } + logWatchRequest(manager, type: .delegate, String(format: "Delivering watch bolus of %.3f U (%@)", rounded, maximumText)) + do { + try await deliverBolus(rounded) + } catch { + logWatchRequest(manager, type: .error, "Watch bolus delivery failed: \(error)") + signal(.loop) + throw SmartwatchBolusError.deliveryFailed(String(describing: error)) + } + logWatchRequest(manager, type: .delegateResponse, String(format: "Watch bolus of %.3f U delivered", rounded)) + signal(.loop) + return rounded + } + + /// What Loop would recommend with the carbs the user is about to add on the + /// watch, without adding them: like the Apple Watch's recommendation for a + /// potential carb entry, with the entry the watch's carbs become (dated now). + /// The bolus itself is checked again when it's requested. + func smartwatchManager(_ manager: SmartwatchManager, recommendBolusForCarbs grams: Double, absorptionTime: TimeInterval?) async throws -> Double { + let absorptionTime = absorptionTime ?? LoopCoreConstants.defaultCarbAbsorptionTimes.medium + // With 0 g it's the plugin's read for its bolus context, made after every + // change: logged only when the answer changes. The watch's own questions + // come with carbs and are always logged. + let isContextRead = grams == 0 + if !isContextRead { + logWatchRequest(manager, type: .receive, String(format: "Bolus recommendation request with %.0f g of carbs, %.0f min absorption", grams, absorptionTime / 60)) + } + guard isCurrentWatch(manager) else { + logWatchRequest(manager, type: .error, "Bolus recommendation refused: the request isn't from the current watch") + throw SmartwatchBolusError.notAllowed + } + let maximumGrams = LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram) + guard grams.isFinite, grams >= 0, grams <= maximumGrams, + absorptionTime >= LoopConstants.minCarbAbsorptionTime, absorptionTime <= LoopConstants.maxCarbAbsorptionTime + else { + logWatchRequest(manager, type: .error, String(format: "Bolus recommendation refused: %.0f g over %.0f min isn't a valid carb entry (maximum %.0f g)", grams, absorptionTime / 60, maximumGrams)) + throw SmartwatchBolusError.invalidAmount + } + let potentialCarbEntry = grams > 0 ? NewCarbEntry( + syncIdentifier: UUID().uuidString, + quantity: LoopQuantity(unit: .gram, doubleValue: grams), + startDate: Date(), + foodType: nil, + absorptionTime: absorptionTime + ) : nil + let recommendation: ManualBolusRecommendation? + do { + recommendation = try await loopDataManager.recommendManualBolus(carbEntryChange: potentialCarbEntry.map { .add($0) }) + } catch { + logWatchRequest(manager, type: .error, "Bolus recommendation failed: \(error)") + throw SmartwatchBolusError.refusedByHost(String(describing: error)) + } + let recommended = max(0, recommendation?.amount ?? 0) + let units = deviceManager.pumpManager?.roundToSupportedBolusVolume(units: recommended) ?? recommended + let maximumBolus = settingsManager.loopSettings.maximumBolus + if isContextRead, !Self.isNewContextRecommendation(units, last: &lastContextRecommendation) { + return units + } + logWatchRequest(manager, type: .delegateResponse, String(format: "Bolus recommendation with %.0f g of carbs: %.2f U%@", grams, units, maximumBolus.map { String(format: " (maximum bolus %.2f U)", $0) } ?? "")) + return units + } + + /// Mutes Loop's alerts for a request from the watch, like Mute All Alerts in + /// Settings: the duration is one Loop offers (the shortest that covers the + /// request), and the mute shows on the phone with its Unmute button. + func smartwatchManager(_ manager: SmartwatchManager, muteAlertsFor duration: TimeInterval) async throws -> Date { + guard isCurrentWatch(manager) else { + logWatchRequest(manager, type: .error, "Mute request from the watch refused: the request isn't from the current watch") + throw SmartwatchAlertMuteError.notAllowed + } + guard duration > 0 else { + alertMuter.unmuteAlerts() + logWatchRequest(manager, type: .receive, "Alerts unmuted from the watch") + return Date() + } + let allowed = AlertMuter.allowedDurations + let muteDuration = allowed.first { $0 >= duration } ?? allowed[allowed.count - 1] + alertMuter.configuration = AlertMuter.Configuration(startTime: Date(), duration: muteDuration) + let end = alertMuter.configuration.mutingEndTime ?? Date(timeIntervalSinceNow: muteDuration) + logWatchRequest(manager, type: .receive, String(format: "Alerts muted from the watch for %.0f min, until %@", muteDuration / 60, String(describing: end))) + return end + } + + /// Adds a carb entry the watch asked for, dated as given, through the same + /// path as remote carbs (validation, notification, upload). + func smartwatchManager(_ manager: SmartwatchManager, addCarbEntry grams: Double, absorptionTime: TimeInterval?, date: Date) async throws -> Double { + let absorptionTime = absorptionTime ?? LoopCoreConstants.defaultCarbAbsorptionTimes.medium + logWatchRequest(manager, type: .receive, String(format: "Carb entry request: %.0f g, %.0f min absorption, dated %@", grams, absorptionTime / 60, String(describing: date))) + guard isCurrentWatch(manager) else { + logWatchRequest(manager, type: .error, "Carb entry request rejected: notAllowed (the request isn't from the current watch)") + throw SmartwatchCarbEntryError.notAllowed + } + guard grams.isFinite, grams > 0 else { + logWatchRequest(manager, type: .error, "Carb entry request rejected: invalidAmount") + throw SmartwatchCarbEntryError.invalidAmount + } + let maximumGrams = LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram) + guard grams <= maximumGrams else { + logWatchRequest(manager, type: .error, String(format: "Carb entry request rejected: exceedsMaximum (maximum %.0f g)", maximumGrams)) + throw SmartwatchCarbEntryError.exceedsMaximum + } + do { + try await deliverCarbs(grams, date, absorptionTime) + } catch { + logWatchRequest(manager, type: .error, "Watch carb entry failed: \(error)") + throw SmartwatchCarbEntryError.failed(String(describing: error)) + } + logWatchRequest(manager, type: .delegateResponse, String(format: "Watch carb entry of %.0f g added", grams)) + signal(.loop) + return grams + } + + /// Whether the plugin's 0 g context read answered differently from last time, + /// so it is logged; records the answer in `last` when it did. + static func isNewContextRecommendation(_ units: Double, last: inout Double?) -> Bool { + guard units != last else { + return false + } + last = units + return true + } + + private func logWatchRequest(_ manager: SmartwatchManager, type: DeviceLogEntryType, _ message: String) { + deviceManager.deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: nil, type: type, message: message, completion: nil) + } +} diff --git a/Loop/Managers/SmartwatchManagerHost.swift b/Loop/Managers/SmartwatchManagerHost.swift new file mode 100644 index 0000000000..e0c438fdda --- /dev/null +++ b/Loop/Managers/SmartwatchManagerHost.swift @@ -0,0 +1,477 @@ +// +// SmartwatchManagerHost.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Combine +import Foundation +import HealthKit +import LoopAlgorithm +import LoopCore +import LoopKit + +/// The watch plugin's host in Settings → Devices: its `SmartwatchManagerDelegate`. +/// +/// The plugin reads Loop's state from here after `hostDataDidChange`, which says +/// what changed. A new glucose reading is signalled as soon as the glucose store +/// saves it, before the loop runs; the loop's output when it changes. +/// +/// The plugin's log lines go to the device log, and its alerts to Loop's alert +/// manager, at most time-sensitive. Its remote actions get the checks Loop applies +/// to any request of that kind (`SmartwatchManagerHost+RemoteActions.swift`). +@MainActor +final class SmartwatchManagerHost { + unowned let deviceManager: DeviceDataManager + unowned let loopDataManager: LoopDataManager + unowned let settingsManager: SettingsManager + let glucoseStore: GlucoseStore + private let healthStore: HKHealthStore + let deliverBolus: (Double) async throws -> Void + let alertMuter: AlertMuter + /// Adds grams of carbs dated when the user confirmed them on the watch. + let deliverCarbs: (Double, Date, TimeInterval) async throws -> Void + let startOverride: (String) async throws -> Void + let cancelOverride: () async throws -> Void + + let log = DiagnosticLog(category: "SmartwatchManagerHost") + + /// Whether a watch bolus is being delivered, so a second one waits for its result. + var isDeliveringWatchBolus = false + /// The last answer to the plugin's 0 g recommendation read, which it makes + /// after every change for its bolus context: logged only when it changes. + var lastContextRecommendation: Double? + /// What changed since the watch manager was last told, sent on the next main + /// run loop turn so changes that arrive together go out as one call. + private var pendingChanges: SmartwatchHostDataChange = [] + private var notificationObservers: [Any] = [] + private var cancellables: Set = [] + /// The Closed Loop setting the watch was last told about. + private var observedDosingEnabled = false + /// When a test alert last went to the watch. Loop never retracts test alerts, + /// so the host clears them once they have shown for `watchTestAlertDuration`. + var lastWatchTestAlertAt: Date? + /// How long a test alert colours the watch's ring. + static let watchTestAlertDuration: TimeInterval = 30 + + init( + deviceManager: DeviceDataManager, + loopDataManager: LoopDataManager, + settingsManager: SettingsManager, + glucoseStore: GlucoseStore, + healthStore: HKHealthStore, + alertMuter: AlertMuter, + deliverBolus: @escaping (Double) async throws -> Void, + deliverCarbs: @escaping (Double, Date, TimeInterval) async throws -> Void, + startOverride: @escaping (String) async throws -> Void, + cancelOverride: @escaping () async throws -> Void + ) { + self.deviceManager = deviceManager + self.loopDataManager = loopDataManager + self.settingsManager = settingsManager + self.glucoseStore = glucoseStore + self.healthStore = healthStore + self.deliverBolus = deliverBolus + self.alertMuter = alertMuter + self.deliverCarbs = deliverCarbs + self.startOverride = startOverride + self.cancelOverride = cancelOverride + + notificationObservers = [ + // Posted as soon as new samples are saved, before LoopDataManager + // recomputes its display state for them. + NotificationCenter.default.addObserver(forName: GlucoseStore.glucoseSamplesDidChange, object: glucoseStore, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.signal(.glucose) } + }, + NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: .main) { [weak self] note in + let context = (note.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopUpdateContext.RawValue).flatMap(LoopUpdateContext.init(rawValue:)) + MainActor.assumeIsolated { + // Preferences cover settings, presets and the active override, + // and closed loop, which the loop state reports. + self?.signal(context == .preferences ? [.loop, .presets, .settings] : .loop) + } + }, + NotificationCenter.default.addObserver(forName: .HealthStorePreferredGlucoseUnitDidChange, object: healthStore, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.signal(.settings) } + }, + NotificationCenter.default.addObserver(forName: .LoopCycleCompleted, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.signal(.loop) } + }, + // The pump's bolus volumes. + NotificationCenter.default.addObserver(forName: .PumpManagerChanged, object: deviceManager, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.signal(.settings) } + }, + ] + + alertMuter.$configuration + .dropFirst() + .removeDuplicates() + .sink { [weak self] _ in self?.signal(.alertMute) } + .store(in: &cancellables) + + // A manual temp basal turns automatic dosing off, and its end turns it back on. + deviceManager.$pumpIsAllowingAutomation + .dropFirst() + .removeDuplicates() + .sink { [weak self] _ in self?.signal(.loop) } + .store(in: &cancellables) + + // The Closed Loop switch sets the setting directly and posts no + // LoopDataUpdated, so it is observed here, as Loop's status screen does. + observedDosingEnabled = settingsManager.dosingEnabled + withObservationTracking(of: settingsManager.dosingEnabled) { [weak self] enabled in + Task { @MainActor in + guard let self, Self.isNewDosingEnabled(enabled, last: &self.observedDosingEnabled) else { + return + } + self.signal(.loop) + } + } + + // The glucose display limits: a changed alert profile, or another one active. + let glucoseAlertManager = deviceManager.glucoseAlertManager + glucoseAlertManager.$profiles + .dropFirst() + .map { _ in () } + .merge(with: glucoseAlertManager.$activeProfileID.dropFirst().removeDuplicates().map { _ in () }) + .sink { [weak self] in self?.signal(.settings) } + .store(in: &cancellables) + } + + /// Whether the Closed Loop setting differs from the one the watch was last + /// told about; records it when it does. + static func isNewDosingEnabled(_ enabled: Bool, last: inout Bool) -> Bool { + guard enabled != last else { + return false + } + last = enabled + return true + } + + /// Tells the watch manager that everything changed: for a new watch manager, + /// right after its delegate is set, or one that finished onboarding. + func refresh() { + pendingChanges = [] + guard let smartwatchManager = deviceManager.smartwatchManager else { + return + } + retractWatchAlertsNoLongerShown(smartwatchManager) + smartwatchManager.hostDataDidChange(.all) + } + + /// Tells the watch manager what changed, on the next main run loop turn + /// together with whatever else changes until then. + func signal(_ changes: SmartwatchHostDataChange) { + let isScheduled = !pendingChanges.isEmpty + pendingChanges.formUnion(changes) + guard !isScheduled else { + return + } + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { self?.sendPendingChanges() } + } + } + + private func sendPendingChanges() { + let changes = pendingChanges + pendingChanges = [] + guard !changes.isEmpty, let smartwatchManager = deviceManager.smartwatchManager else { + return + } + retractWatchAlertsNoLongerShown(smartwatchManager) + smartwatchManager.hostDataDidChange(changes) + } + + /// Whether `manager` is the watch manager in Settings → Devices. Only its + /// requests are acted on; a removed one may still hold this host. + func isCurrentWatch(_ manager: SmartwatchManager) -> Bool { + deviceManager.smartwatchManager.map { ($0 as AnyObject) === (manager as AnyObject) } ?? false + } + + /// The glucose unit from HealthKit's stored preference, which is what Loop's own + /// display settles on. Loop's in-memory preference starts at mg/dL until + /// HealthKit's first answer arrives, so reading it at launch briefly sent mg/dL + /// to the watch of a mmol/L user. Falls back to it when HealthKit can't answer, + /// for example while protected data is unavailable. + func preferredGlucoseUnit() async -> LoopUnit { + if let hkUnit = await healthStore.cachedPreferredUnits(for: .bloodGlucose) { + return LoopUnit(from: hkUnit) + } + return deviceManager.displayGlucosePreference.unit + } + + /// A watch plugin's alert as Loop issues it: from the plugin, whatever manager + /// it names, so it can't retract or impersonate another device's alert, and at + /// most time-sensitive. A watch plugin's alerts are about the watch (a missing + /// companion app); they never break through Focus or the silent switch. + static func watchPluginAlert(_ alert: Alert, from pluginIdentifier: String) -> Alert { + Alert( + identifier: watchPluginAlertIdentifier(alert.identifier, from: pluginIdentifier), + foregroundContent: alert.foregroundContent, + backgroundContent: alert.backgroundContent, + trigger: alert.trigger, + interruptionLevel: alert.interruptionLevel == .critical ? .timeSensitive : alert.interruptionLevel, + sound: alert.sound, + metadata: alert.metadata, + categoryIdentifier: alert.categoryIdentifier + ) + } + + static func watchPluginAlertIdentifier(_ identifier: Alert.Identifier, from pluginIdentifier: String) -> Alert.Identifier { + Alert.Identifier(managerIdentifier: pluginIdentifier, alertIdentifier: identifier.alertIdentifier) + } + + /// Which glucose alert this is, so plugins needn't know Loop's alert identifiers. + /// A test alert counts as its alarm's, so the watch shows and buzzes it the same way. + static func glucoseAlertType(of identifier: Alert.Identifier) -> SmartwatchGlucoseAlertType? { + guard identifier.managerIdentifier == GlucoseAlertManager.managerIdentifier else { return nil } + switch GlucoseAlarmKind.kind(forAlertIdentifier: identifier.alertIdentifier) { + case .urgentLow: return .urgentLow + case .low: return .low + case .high: return .high + case .predictedLow: return .predictedLow + case nil: return nil + } + } + + static func isTestAlert(_ identifier: Alert.Identifier) -> Bool { + identifier.managerIdentifier == GlucoseAlertManager.managerIdentifier + && GlucoseAlarmKind.allCases.contains { $0.testAlertIdentifier == identifier.alertIdentifier } + } + + /// Glucose alerts the watch must stop showing although Loop won't retract them: + /// Loop clears an alert only on a reading back over its recovery line, and stops + /// checking one that is switched off. So every alarm that is off in the active + /// profile, or all of them while Loop's alerts are off (the CGM raises its own), + /// and test alerts once they have shown long enough. Based on the settings, not + /// the reading: recovery while an alarm is on is Loop's own. + static func watchAlertsToRetract(configuration: GlucoseAlertConfiguration, loopAlertsEnabled: Bool, lastTestAlertAt: Date?, now: Date) -> [Alert.AlertIdentifier] { + var identifiers = GlucoseAlarmKind.allCases + .filter { !loopAlertsEnabled || !isEnabled($0, in: configuration) } + .map(\.alertIdentifier) + if lastTestAlertAt.map({ now.timeIntervalSince($0) >= watchTestAlertDuration }) ?? true { + identifiers += GlucoseAlarmKind.allCases.map(\.testAlertIdentifier) + } + return identifiers + } + + private static func isEnabled(_ kind: GlucoseAlarmKind, in configuration: GlucoseAlertConfiguration) -> Bool { + switch kind { + case .urgentLow: return configuration.urgentLowEnabled + case .low: return configuration.lowEnabled + case .high: return configuration.highEnabled + case .predictedLow: return configuration.predictedLowEnabled + } + } + + /// Retracts what `watchAlertsToRetract` lists, on every change signal (each + /// reading, loop cycle and new manager). The plugin ignores alerts it doesn't + /// have, so the host keeps no record of what it sent, and this also works after + /// a relaunch. + func retractWatchAlertsNoLongerShown(_ smartwatchManager: SmartwatchManager, now: Date = Date()) { + let alertManager = deviceManager.glucoseAlertManager + let identifiers = Self.watchAlertsToRetract( + configuration: alertManager.activeConfiguration(at: now), + loopAlertsEnabled: alertManager.effectiveLoopAlertsEnabled, + lastTestAlertAt: lastWatchTestAlertAt, + now: now + ) + for identifier in identifiers { + smartwatchManager.watchAlertDidRetract(Alert.Identifier(managerIdentifier: GlucoseAlertManager.managerIdentifier, alertIdentifier: identifier)) + } + } + + /// Writes an alert note to the device log and, unless `showInPluginLog` is + /// false, hands it to the plugin so its own log shows it next to the watch traffic. + func logAlert(_ manager: SmartwatchManager, _ message: String, showInPluginLog: Bool = true) { + deviceManager.deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: nil, type: .delegate, message: message, completion: nil) + if showInPluginLog { + manager.watchHostDidLog(message) + } + } +} + +// MARK: - SmartwatchManagerDelegate: state and reads + +extension SmartwatchManagerHost: SmartwatchManagerDelegate { + nonisolated + func smartwatchManagerDidUpdateState(_ manager: SmartwatchManager) { + Task { @MainActor in + // A removed manager must not bring its saved state back. + guard isCurrentWatch(manager) else { return } + deviceManager.rawSmartwatchManager = manager.rawValue + } + } + + func smartwatchManagerWantsDeletion(_ manager: SmartwatchManager) async { + log.default("Watch manager with identifier '%{public}@' wants deletion", manager.pluginIdentifier) + guard isCurrentWatch(manager) else { return } + deviceManager.smartwatchManager = nil + } + + func smartwatchManagerGlucoseDisplayUnit(_ manager: SmartwatchManager) async -> LoopUnit { + await preferredGlucoseUnit() + } + + func smartwatchManager(_ manager: SmartwatchManager, glucoseSamplesSince date: Date) async throws -> [StoredGlucoseSample] { + try await glucoseStore.getGlucoseSamples(start: date) + } + + /// Read together from LoopDataManager's display state, so they come from one + /// loop run. Loop has no sensitivity ratio (an oref/autosens value), so that + /// stays nil; eventual glucose is the end of the forecast. + func smartwatchManagerLoopState(_ manager: SmartwatchManager) async -> SmartwatchLoopState { + let predictedGlucose = loopDataManager.predictedGlucose ?? [] + return SmartwatchLoopState( + lastLoopCompleted: loopDataManager.lastLoopCompleted, + // As Loop's loop icon, Apple Watch app and widget show it: closed only while + // Loop doses automatically, so not during a manual temp basal. + isClosedLoop: deviceManager.isAutomaticDosingActive, + insulinOnBoard: loopDataManager.activeInsulin, + carbsOnBoard: loopDataManager.activeCarbs, + predictedGlucose: predictedGlucose, + eventualGlucose: predictedGlucose.last?.quantity + ) + } + + func smartwatchManagerScheduledBasalRate(_ manager: SmartwatchManager) async -> LoopQuantity? { + guard let schedule = loopDataManager.temporaryPresetsManager.basalRateScheduleApplyingOverrideHistory else { + return nil + } + return LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: schedule.value(at: Date())) + } + + func smartwatchManagerTempBasal(_ manager: SmartwatchManager) async -> DoseEntry? { + guard case .tempBasal(let dose) = deviceManager.pumpManager?.status.basalDeliveryState else { + return nil + } + return dose + } + + func smartwatchManagerInsulinSensitivity(_ manager: SmartwatchManager) async -> LoopQuantity? { + loopDataManager.temporaryPresetsManager.insulinSensitivityScheduleApplyingOverrideHistory?.quantity(at: Date()) + } + + func smartwatchManagerCorrectionRange(_ manager: SmartwatchManager) async -> ClosedRange? { + loopDataManager.temporaryPresetsManager.effectiveCorrectionRangeSchedule()?.quantityRange(at: Date()) + } + + /// Loop has no display limits of its own: the low and high of the active + /// glucose alert profile, whether or not those alerts are on. + func smartwatchManagerGlucoseDisplayLimits(_ manager: SmartwatchManager) async -> SmartwatchGlucoseDisplayLimits? { + Self.displayLimits(from: deviceManager.glucoseAlertManager.activeConfiguration()) + } + + static func displayLimits(from configuration: GlucoseAlertConfiguration) -> SmartwatchGlucoseDisplayLimits? { + SmartwatchGlucoseDisplayLimits( + low: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: configuration.lowThresholdMgDL), + high: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: configuration.highThresholdMgDL) + ) + } + + func smartwatchManagerOverridePresets(_ manager: SmartwatchManager) async -> [TemporaryPreset] { + settingsManager.settings.overridePresets + } + + /// Any running override: a preset, pre-meal, an activity or a custom one. + func smartwatchManagerActiveOverride(_ manager: SmartwatchManager) async -> TemporaryScheduleOverride? { + loopDataManager.temporaryPresetsManager.activeOverride + } + + func smartwatchManagerAlertMuteEnd(_ manager: SmartwatchManager) async -> Date? { + alertMuter.configuration.shouldMute ? alertMuter.configuration.mutingEndTime : nil + } + + func smartwatchManagerMaximumBolus(_ manager: SmartwatchManager) async -> Double? { + settingsManager.loopSettings.maximumBolus + } + + func smartwatchManagerMaximumCarbEntry(_ manager: SmartwatchManager) async -> Double? { + LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram) + } + + func smartwatchManagerSupportedBolusVolumes(_ manager: SmartwatchManager) async -> [Double] { + deviceManager.pumpManager?.supportedBolusVolumes ?? [] + } + + /// Loop's fast, medium and slow, as in carb entry. + func smartwatchManagerDefaultCarbAbsorptionTimes(_ manager: SmartwatchManager) async -> (fast: TimeInterval, medium: TimeInterval, slow: TimeInterval)? { + LoopCoreConstants.defaultCarbAbsorptionTimes + } +} + +// MARK: - DeviceManagerDelegate: the plugin's log and alerts + +extension SmartwatchManagerHost { + nonisolated + func deviceManager(_ manager: DeviceManager, logEventForDeviceIdentifier deviceIdentifier: String?, type: DeviceLogEntryType, message: String, completion: ((Error?) -> Void)?) { + Task { @MainActor in + deviceManager.deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: deviceIdentifier, type: type, message: message, completion: completion) + } + } + + nonisolated + func deviceManager(_ manager: DeviceManager, recordAnalyticsEvent name: String, properties: [AnyHashable: Any]?) { + Task { @MainActor in + deviceManager.deviceManager(manager, recordAnalyticsEvent: name, properties: properties) + } + } + + /// Issues the plugin's alert about the watch as Loop's own alert from this + /// plugin, capped by `watchPluginAlert`. + func issueAlert(_ alert: Alert) async { + guard let manager = deviceManager.smartwatchManager else { + return + } + let watchAlert = Self.watchPluginAlert(alert, from: manager.pluginIdentifier) + let content = watchAlert.foregroundContent ?? watchAlert.backgroundContent + var note = "Watch plugin alert: \(content.title): \(content.body)" + if alert.interruptionLevel != watchAlert.interruptionLevel { + note += " (critical lowered to time-sensitive)" + } + if alert.identifier != watchAlert.identifier { + note += " (issued as \(watchAlert.identifier.value), not \(alert.identifier.value))" + } + deviceManager.deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: nil, type: .error, message: note, completion: nil) + await deviceManager.alertManager?.issueAlert(watchAlert) + } + + func retractAlert(identifier: Alert.Identifier) async { + guard let manager = deviceManager.smartwatchManager else { + return + } + await deviceManager.alertManager?.retractAlert(identifier: Self.watchPluginAlertIdentifier(identifier, from: manager.pluginIdentifier)) + } + + // The lookups find the plugin's alerts where `issueAlert` stored them: under + // the plugin's identifier, whatever manager the plugin names. + + func doesIssuedAlertExist(identifier: Alert.Identifier) async throws -> Bool { + guard let manager = deviceManager.smartwatchManager else { + return false + } + return try await deviceManager.doesIssuedAlertExist(identifier: Self.watchPluginAlertIdentifier(identifier, from: manager.pluginIdentifier)) + } + + func lookupAllUnretracted(managerIdentifier: String) async throws -> [PersistedAlert] { + guard let manager = deviceManager.smartwatchManager else { + return [] + } + return try await deviceManager.lookupAllUnretracted(managerIdentifier: manager.pluginIdentifier) + } + + func lookupAllUnacknowledgedUnretracted(managerIdentifier: String) async throws -> [PersistedAlert] { + guard let manager = deviceManager.smartwatchManager else { + return [] + } + return try await deviceManager.lookupAllUnacknowledgedUnretracted(managerIdentifier: manager.pluginIdentifier) + } + + func recordRetractedAlert(_ alert: Alert, at date: Date) async throws { + guard let manager = deviceManager.smartwatchManager else { + return + } + try await deviceManager.recordRetractedAlert(Self.watchPluginAlert(alert, from: manager.pluginIdentifier), at: date) + } +} diff --git a/Loop/Plugins/PluginManager.swift b/Loop/Plugins/PluginManager.swift index 21ceb43c1d..535c7e41d3 100644 --- a/Loop/Plugins/PluginManager.swift +++ b/Loop/Plugins/PluginManager.swift @@ -129,6 +129,48 @@ class PluginManager { }) } + func getSmartwatchManagerTypeByIdentifier(_ identifier: String) -> SmartwatchManagerUI.Type? { + for bundle in pluginBundles { + if let name = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.smartwatchManagerIdentifier.rawValue) as? String, name == identifier { + do { + try bundle.loadAndReturnError() + + if let principalClass = bundle.principalClass as? NSObject.Type { + + if let plugin = principalClass.init() as? SmartwatchManagerUIPlugin { + return plugin.smartwatchManagerType + } else { + fatalError("PrincipalClass does not conform to SmartwatchManagerUIPlugin") + } + + } else { + fatalError("PrincipalClass not found") + } + } catch let error { + log.error("Error loading plugin: %{public}@", String(describing: error)) + } + } + } + return nil + } + + var availableSmartwatchManagers: [SmartwatchManagerDescriptor] { + pluginBundles.compactMap({ (bundle) -> SmartwatchManagerDescriptor? in + guard let title = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.smartwatchManagerDisplayName.rawValue) as? String, + let identifier = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.smartwatchManagerIdentifier.rawValue) as? String else { + return nil + } + + return SmartwatchManagerDescriptor( + identifier: identifier, + localizedTitle: title, + subtitle: bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.pluginSubtitle.rawValue) as? String, + manufacturer: bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.deviceManufacturer.rawValue) as? String, + image: getSmartwatchManagerTypeByIdentifier(identifier)?.pickerImage + ) + }) + } + func getServiceTypeByIdentifier(_ identifier: String) -> ServiceUI.Type? { for bundle in pluginBundles { if let name = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.serviceIdentifier.rawValue) as? String, name == identifier { @@ -275,9 +317,10 @@ extension Bundle { var isStatefulPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.statefulPluginIdentifier.rawValue) as? String != nil } var isServicePlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.serviceIdentifier.rawValue) as? String != nil } var isOnboardingPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.onboardingIdentifier.rawValue) as? String != nil } + var isSmartwatchManagerPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.smartwatchManagerIdentifier.rawValue) as? String != nil } var isSupportPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.supportIdentifier.rawValue) as? String != nil } - var isLoopPlugin: Bool { isPumpManagerPlugin || isCGMManagerPlugin || isStatefulPlugin || isServicePlugin || isOnboardingPlugin || isSupportPlugin } + var isLoopPlugin: Bool { isPumpManagerPlugin || isCGMManagerPlugin || isSmartwatchManagerPlugin || isStatefulPlugin || isServicePlugin || isOnboardingPlugin || isSupportPlugin } var isLoopExtension: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.extensionIdentifier.rawValue) as? String != nil } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 9991db642c..0e7448209c 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -90,6 +90,12 @@ final class StatusTableViewController: LoopChartsTableViewController { statusTableViewModel.settingsViewModel.cgmManagerSettingsViewModel.didTapAdd = { [weak self] in self?.addCGMManager(withIdentifier: $0.identifier) } + statusTableViewModel.settingsViewModel.smartwatchManagerSettingsViewModel.didTap = { [weak self] in + self?.onWatchTapped() + } + statusTableViewModel.settingsViewModel.smartwatchManagerSettingsViewModel.didTapAdd = { [weak self] in + self?.addSmartwatchManager(withIdentifier: $0.identifier) + } tableView.register(BolusProgressTableViewCell.nib(), forCellReuseIdentifier: BolusProgressTableViewCell.className) tableView.register(InsulinSuspendedTableViewCell.nib(), forCellReuseIdentifier: InsulinSuspendedTableViewCell.className) @@ -1730,6 +1736,17 @@ final class StatusTableViewController: LoopChartsTableViewController { show(settings, sender: self) } + private func onWatchTapped() { + guard let smartwatchManager = deviceManager.smartwatchManager else { + return + } + + var settings = smartwatchManager.settingsViewController(bluetoothProvider: deviceManager.bluetoothProvider, displayGlucosePreference: deviceManager.displayGlucosePreference, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures) + settings.smartwatchManagerOnboardingDelegate = deviceManager + settings.completionDelegate = self + show(settings, sender: self) + } + private func automaticDosingStatusChanged(_ automaticDosingEnabled: Bool) { log.debug("automaticDosingStatusChanged -> %{public}@", String(describing: automaticDosingEnabled)) hudView?.loopCompletionHUD.loopIconClosed = deviceManager.isAutomaticDosingActive @@ -1932,6 +1949,8 @@ final class StatusTableViewController: LoopChartsTableViewController { deviceManager.availableCGMManagers.map { PluginPickerView.Item($0) } case .pump: deviceManager.availablePumpManagers.map { PluginPickerView.Item($0) } + case .watch: + deviceManager.availableSmartwatchManagers.map { PluginPickerView.Item($0) } case .service: servicesManager.availableServices.map { PluginPickerView.Item($0) } } @@ -1943,6 +1962,8 @@ final class StatusTableViewController: LoopChartsTableViewController { self?.addCGMManager(withIdentifier: identifier) case .pump: self?.addPumpManager(withIdentifier: identifier) + case .watch: + self?.addSmartwatchManager(withIdentifier: identifier) case .service: self?.addService(withIdentifier: identifier) } @@ -2256,6 +2277,24 @@ extension StatusTableViewController { } } +extension StatusTableViewController { + fileprivate func addSmartwatchManager(withIdentifier identifier: String) { + switch deviceManager.setupSmartwatchManager(withIdentifier: identifier) { + case .failure(let error): + log.error("Failure to setup watch manager with identifier '%{public}@': %{public}@", identifier, String(describing: error)) + case .success(let success): + switch success { + case .userInteractionRequired(var setupViewController): + setupViewController.smartwatchManagerOnboardingDelegate = deviceManager + setupViewController.completionDelegate = self + show(setupViewController, sender: self) + case .createdAndOnboarded: + log.default("Watch manager with identifier '%{public}@' created and onboarded", identifier) + } + } + } +} + extension StatusTableViewController { fileprivate func addPumpManager(withIdentifier identifier: String) { guard let maximumBasalRate = settingsManager.settings.maximumBasalRatePerHour, diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 626ae21e28..3432d083f8 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -47,6 +47,7 @@ public class DeviceViewModel: ObservableObject { public typealias CGMManagerViewModel = DeviceViewModel public typealias PumpManagerViewModel = DeviceViewModel +public typealias SmartwatchManagerViewModel = DeviceViewModel public protocol SettingsViewModelDelegate: AnyObject { func dosingEnabledChanged(_: Bool) @@ -72,6 +73,11 @@ class SettingsViewModel { var availableSupports: [SupportUI] let pumpManagerSettingsViewModel: PumpManagerViewModel let cgmManagerSettingsViewModel: CGMManagerViewModel + /// Shown in Settings → Devices only when a watch plugin is installed. + var smartwatchManagerSettingsViewModel = SmartwatchManagerViewModel() + /// Changes whenever a watch manager is added or removed, so the Devices row + /// re-renders; the view model's closures are not observable. + private(set) var smartwatchManagerRevision = 0 let servicesViewModel: ServicesViewModel let therapySettings: () -> TherapySettings var isOnboardingComplete: Bool @@ -180,6 +186,11 @@ class SettingsViewModel { self.deliveryDelegate = deliveryDelegate self.deviceManager = deviceManager + NotificationCenter.default.publisher(for: .SmartwatchManagerChanged) + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.smartwatchManagerRevision += 1 } + .store(in: &cancellables) + // This strangeness ensures the composed ViewModels' (ObservableObjects') changes get reported to this ViewModel (ObservableObject) lastLoopCompletion .assign(to: \.lastLoopCompletion, on: self) diff --git a/Loop/Views/PluginPickerView.swift b/Loop/Views/PluginPickerView.swift index 915c39e374..8551d8a54e 100644 --- a/Loop/Views/PluginPickerView.swift +++ b/Loop/Views/PluginPickerView.swift @@ -18,6 +18,7 @@ struct PluginPickerView: View { enum PluginType: Hashable { case cgm case pump + case watch case service var localizedTitle: String { @@ -26,17 +27,31 @@ struct PluginPickerView: View { NSLocalizedString("Add CGM", comment: "The title of the CGM chooser in settings") case .pump: NSLocalizedString("Add Pump", comment: "The title of the pump chooser in settings") + case .watch: + NSLocalizedString("Add Watch", comment: "The title of the watch chooser in settings") case .service: NSLocalizedString("Add Service", comment: "The title of the add service picker in settings") } } + /// Shown below the list, for what the list doesn't cover. + var footer: String? { + switch self { + case .watch: + NSLocalizedString("Apple Watch isn't added here. Loop's Apple Watch app is installed from the Watch app on your iPhone.", comment: "Footer of the watch chooser in settings: Apple Watch is not set up there") + case .cgm, .pump, .service: + nil + } + } + var fallbackSystemImage: String { switch self { case .cgm: "sensor.tag.radiowaves.forward" case .pump: "ivfluid.bag" + case .watch: + "watch.analog" case .service: "network" } @@ -102,6 +117,11 @@ struct PluginPickerView: View { rows(for: group) } } + if let footer = pluginType.footer { + Section(footer: Text(footer)) { + EmptyView() + } + } } .insetGroupedListStyle() .navigationBarTitle(Text(pluginType.localizedTitle)) @@ -187,6 +207,10 @@ extension PluginPickerView.Item { self.init(id: descriptor.identifier, title: descriptor.localizedTitle, subtitle: descriptor.subtitle, group: descriptor.manufacturer, image: descriptor.image) } + init(_ descriptor: SmartwatchManagerDescriptor) { + self.init(id: descriptor.identifier, title: descriptor.localizedTitle, subtitle: descriptor.subtitle, group: descriptor.manufacturer, image: descriptor.image) + } + init(_ descriptor: ServiceDescriptor) { self.init(id: descriptor.identifier, title: descriptor.localizedTitle, subtitle: descriptor.subtitle, group: descriptor.category, image: descriptor.image) } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 70f6d26b4e..68a664bd24 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -45,6 +45,7 @@ struct SettingsView: View { case cgmPicker case pumpPicker + case watchPicker case servicePicker } @@ -501,6 +502,9 @@ extension SettingsView { cgmSection .accessibilityIdentifier("settingsViewCGM") + + watchSection + .accessibilityIdentifier("settingsViewWatch") } } @@ -538,6 +542,25 @@ extension SettingsView { } } + /// Only shown when a watch plugin is installed or set up. + @ViewBuilder + private var watchSection: some View { + let _ = viewModel.smartwatchManagerRevision + if viewModel.smartwatchManagerSettingsViewModel.isSetUp() { + LargeButton(action: self.viewModel.smartwatchManagerSettingsViewModel.didTap, + includeArrow: true, + imageView: deviceImage(uiImage: viewModel.smartwatchManagerSettingsViewModel.image()), + label: viewModel.smartwatchManagerSettingsViewModel.name(), + descriptiveText: NSLocalizedString("Watch", comment: "Descriptive text for a watch plugin in Settings → Devices")) + } else if !viewModel.smartwatchManagerSettingsViewModel.availableDevices.isEmpty { + LargeButton(action: { pluginPicker = .watchPicker }, + includeArrow: false, + imageView: plusImage, + label: NSLocalizedString("Add Watch", comment: "Title text for button to add a watch device"), + descriptiveText: NSLocalizedString("Tap here to set up a watch other than Apple Watch", comment: "Descriptive text for button to add a watch device: Apple Watch uses Loop's own watch app")) + } + } + private var favoriteFoodsSection: some View { Section { LargeButton(action: { sheet = .favoriteFoods }, @@ -575,6 +598,18 @@ extension SettingsView { }, onCancel: { pluginPicker = nil } ) + case .watchPicker: + PluginPickerView( + pluginType: .watch, + items: viewModel.smartwatchManagerSettingsViewModel.availableDevices.map { PluginPickerView.Item($0) }, + onSelect: { item in + if let device = viewModel.smartwatchManagerSettingsViewModel.availableDevices.first(where: { $0.identifier == item.id }) { + pluginSelection = { viewModel.smartwatchManagerSettingsViewModel.didTapAdd(device) } + } + pluginPicker = nil + }, + onCancel: { pluginPicker = nil } + ) case .servicePicker: PluginPickerView( pluginType: .service, diff --git a/LoopTests/Managers/DeviceDataManagerTests.swift b/LoopTests/Managers/DeviceDataManagerTests.swift index bf12ef5ede..9b47d632e0 100644 --- a/LoopTests/Managers/DeviceDataManagerTests.swift +++ b/LoopTests/Managers/DeviceDataManagerTests.swift @@ -150,6 +150,35 @@ final class DeviceDataManagerTests: XCTestCase { XCTAssertEqual(limits.maximumBasalRate, newLimits.maximumBasalRate) } + func testAPumpErrorIsThrownForCallersThatReportTheOutcome() async { + pumpManager.enactBolusError = .communication(nil) + do { + try await deviceDataManager.enactBolus(units: 1, decisionId: nil, activationType: .manualNoRecommendation, throwingPumpErrors: true) + XCTFail("A failed bolus must not be reported as delivered") + } catch PumpManagerError.communication { + } catch { + XCTFail("Unexpected error \(error)") + } + } + + func testAnUncertainDeliveryIsThrownForCallersThatReportTheOutcome() async { + pumpManager.enactBolusError = .uncertainDelivery + do { + try await deviceDataManager.enactBolus(units: 1, decisionId: nil, activationType: .manualNoRecommendation, throwingPumpErrors: true) + XCTFail("An uncertain bolus must not be reported as delivered") + } catch PumpManagerError.uncertainDelivery { + } catch { + XCTFail("Unexpected error \(error)") + } + } + + func testPumpErrorsAreNotThrownByDefault() async throws { + pumpManager.enactBolusError = .communication(nil) + try await deviceDataManager.enactBolus(units: 1, decisionId: nil, activationType: .manualNoRecommendation) + pumpManager.enactBolusError = .uncertainDelivery + try await deviceDataManager.enactBolus(units: 1, decisionId: nil, activationType: .manualNoRecommendation) + } + func testReceivedUnreliableCGMReadingCancelsTempBasal() { let dose = DoseEntry( type: .tempBasal, @@ -177,6 +206,46 @@ final class DeviceDataManagerTests: XCTestCase { XCTAssertEqual(loopControlMock.lastCancelActiveTempBasalReason, .unreliableCGMData) } + private func manualTempBasal(automatic: Bool?, endDate: Date) -> DoseEntry { + DoseEntry(type: .tempBasal, startDate: Date().addingTimeInterval(-.minutes(10)), endDate: endDate, value: 2.0, unit: .unitsPerHour, decisionId: nil, automatic: automatic) + } + + /// What the watch plugin's loop state reports as closed loop (the host forwards + /// `isAutomaticDosingActive`): closed with Closed Loop on and no manual temp + /// basal, open during a manual temp basal, as Loop's loop icon shows it. + func testAutomaticDosingIsActiveOnlyWithClosedLoopOnAndNoManualTempBasal() { + settingsManager.dosingEnabled = true + pumpManager.status.basalDeliveryState = .active(Date()) + XCTAssertTrue(deviceDataManager.isAutomaticDosingActive) + + pumpManager.status.basalDeliveryState = .tempBasal(manualTempBasal(automatic: true, endDate: Date().addingTimeInterval(.minutes(20)))) + XCTAssertTrue(deviceDataManager.isAutomaticDosingActive) + + pumpManager.status.basalDeliveryState = .tempBasal(manualTempBasal(automatic: false, endDate: Date().addingTimeInterval(.minutes(20)))) + XCTAssertFalse(deviceDataManager.isAutomaticDosingActive) + + // A manual temp basal that has ended no longer holds the loop open. + pumpManager.status.basalDeliveryState = .tempBasal(manualTempBasal(automatic: false, endDate: Date().addingTimeInterval(-.minutes(1)))) + XCTAssertTrue(deviceDataManager.isAutomaticDosingActive) + + settingsManager.dosingEnabled = false + pumpManager.status.basalDeliveryState = .active(Date()) + XCTAssertFalse(deviceDataManager.isAutomaticDosingActive) + } + + /// The watch host signals a loop change when this flips: off for a manual temp + /// basal, back on when it ends. + func testPumpIsAllowingAutomationFollowsAManualTempBasal() { + var status = pumpManager.status + status.basalDeliveryState = .tempBasal(manualTempBasal(automatic: false, endDate: Date().addingTimeInterval(.minutes(20)))) + deviceDataManager.updatePumpIsAllowingAutomation(status: status) + XCTAssertFalse(deviceDataManager.pumpIsAllowingAutomation) + + status.basalDeliveryState = .active(Date()) + deviceDataManager.updatePumpIsAllowingAutomation(status: status) + XCTAssertTrue(deviceDataManager.pumpIsAllowingAutomation) + } + func testUploadEventListener() { let alertStore = AlertStore() deviceDataManager.alertStoreHasUpdatedAlertData(alertStore) diff --git a/LoopTests/Managers/SmartwatchManagerHostTests.swift b/LoopTests/Managers/SmartwatchManagerHostTests.swift new file mode 100644 index 0000000000..1bd12390ae --- /dev/null +++ b/LoopTests/Managers/SmartwatchManagerHostTests.swift @@ -0,0 +1,178 @@ +// +// SmartwatchManagerHostTests.swift +// LoopTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopKit +@testable import Loop + +@MainActor +final class SmartwatchManagerHostTests: XCTestCase { + private func identifier(_ alertIdentifier: Alert.AlertIdentifier) -> Alert.Identifier { + Alert.Identifier(managerIdentifier: GlucoseAlertManager.managerIdentifier, alertIdentifier: alertIdentifier) + } + + private var testAlertIdentifiers: [Alert.AlertIdentifier] { + GlucoseAlarmKind.allCases.map(\.testAlertIdentifier) + } + + private func alert(_ identifier: Alert.Identifier, level: Alert.InterruptionLevel) -> Alert { + let content = Alert.Content(title: "Title", body: "Body", acknowledgeActionButtonLabel: "OK") + return Alert(identifier: identifier, foregroundContent: content, backgroundContent: content, trigger: .immediate, interruptionLevel: level, sound: .vibrate) + } + + /// A watch plugin can't raise a critical alert: Loop issues it time-sensitive. + /// Lower levels and everything else about the alert are kept. + func testAWatchPluginAlertIsAtMostTimeSensitive() { + let identifier = Alert.Identifier(managerIdentifier: "Watch", alertIdentifier: "appMissing") + let critical = SmartwatchManagerHost.watchPluginAlert(alert(identifier, level: .critical), from: "Watch") + XCTAssertEqual(critical.interruptionLevel, .timeSensitive) + XCTAssertEqual(critical.identifier, identifier) + XCTAssertEqual(critical.foregroundContent, alert(identifier, level: .critical).foregroundContent) + XCTAssertEqual(critical.trigger, .immediate) + XCTAssertEqual(critical.sound, .vibrate) + + XCTAssertEqual(SmartwatchManagerHost.watchPluginAlert(alert(identifier, level: .timeSensitive), from: "Watch").interruptionLevel, .timeSensitive) + XCTAssertEqual(SmartwatchManagerHost.watchPluginAlert(alert(identifier, level: .active), from: "Watch").interruptionLevel, .active) + } + + /// A watch plugin's alert is issued, and retracted, as the plugin's own, so it + /// can't stand in for or retract another device's alert. + func testAWatchPluginAlertIsThePluginsOwn() { + let pumpAlert = Alert.Identifier(managerIdentifier: "Omnipod", alertIdentifier: "lowReservoir") + let issued = SmartwatchManagerHost.watchPluginAlert(alert(pumpAlert, level: .timeSensitive), from: "Watch") + XCTAssertEqual(issued.identifier, Alert.Identifier(managerIdentifier: "Watch", alertIdentifier: "lowReservoir")) + XCTAssertEqual(SmartwatchManagerHost.watchPluginAlertIdentifier(pumpAlert, from: "Watch"), issued.identifier) + } + + /// The host's lookups find the plugin's alert where it issued it: under the + /// plugin's identifier, whatever manager the plugin named. Issued, looked up and + /// retracted through Loop's alert store with the identifiers the host uses. + func testAWatchPluginAlertIsLookedUpUnderThePluginsIdentifier() async throws { + let alertStore = AlertStore() + let named = Alert.Identifier(managerIdentifier: "Omnipod", alertIdentifier: "appMissing") + await alertStore.recordIssued(alert: SmartwatchManagerHost.watchPluginAlert(alert(named, level: .timeSensitive), from: "Watch")) + + // doesIssuedAlertExist and retractAlert + let lookedUp = SmartwatchManagerHost.watchPluginAlertIdentifier(named, from: "Watch") + let matching = try await alertStore.lookupAllMatching(identifier: lookedUp) + XCTAssertEqual(matching.map(\.identifier), [lookedUp]) + let underTheNamedManager = try await alertStore.lookupAllMatching(identifier: named) + XCTAssertTrue(underTheNamedManager.isEmpty) + + // lookupAllUnretracted and lookupAllUnacknowledgedUnretracted + let unretracted = try await alertStore.lookupAllUnretracted(managerIdentifier: "Watch") + XCTAssertEqual(unretracted.map(\.identifier), [lookedUp]) + let unacknowledged = try await alertStore.lookupAllUnacknowledgedUnretracted(managerIdentifier: "Watch") + XCTAssertEqual(unacknowledged.map(\.identifier), [lookedUp]) + let unretractedForTheNamedManager = try await alertStore.lookupAllUnretracted(managerIdentifier: "Omnipod") + XCTAssertTrue(unretractedForTheNamedManager.isEmpty) + + try await alertStore.recordRetraction(of: lookedUp) + let unretractedAfterRetraction = try await alertStore.lookupAllUnretracted(managerIdentifier: "Watch") + XCTAssertTrue(unretractedAfterRetraction.isEmpty) + } + + /// The plugin's 0 g recommendation read, made after every change, is logged + /// only when its answer changes. + func testTheContextRecommendationIsLoggedOnlyWhenItChanges() { + var last: Double? + XCTAssertTrue(SmartwatchManagerHost.isNewContextRecommendation(0, last: &last)) + XCTAssertEqual(last, 0) + XCTAssertFalse(SmartwatchManagerHost.isNewContextRecommendation(0, last: &last)) + XCTAssertFalse(SmartwatchManagerHost.isNewContextRecommendation(0, last: &last)) + XCTAssertTrue(SmartwatchManagerHost.isNewContextRecommendation(1.25, last: &last)) + XCTAssertEqual(last, 1.25) + XCTAssertFalse(SmartwatchManagerHost.isNewContextRecommendation(1.25, last: &last)) + XCTAssertTrue(SmartwatchManagerHost.isNewContextRecommendation(0, last: &last)) + XCTAssertEqual(last, 0) + } + + /// A test alert counts as its alarm's, so the watch colours its ring and buzzes for it. + func testTestAlertsHaveTheirAlarmsGlucoseType() { + XCTAssertEqual(SmartwatchManagerHost.glucoseAlertType(of: identifier(GlucoseAlarmKind.urgentLow.testAlertIdentifier)), .urgentLow) + XCTAssertEqual(SmartwatchManagerHost.glucoseAlertType(of: identifier(GlucoseAlarmKind.low.testAlertIdentifier)), .low) + XCTAssertEqual(SmartwatchManagerHost.glucoseAlertType(of: identifier(GlucoseAlarmKind.high.testAlertIdentifier)), .high) + XCTAssertEqual(SmartwatchManagerHost.glucoseAlertType(of: identifier(GlucoseAlarmKind.predictedLow.testAlertIdentifier)), .predictedLow) + XCTAssertEqual(SmartwatchManagerHost.glucoseAlertType(of: identifier(GlucoseAlertManager.highAlertIdentifier)), .high) + XCTAssertNil(SmartwatchManagerHost.glucoseAlertType(of: Alert.Identifier(managerIdentifier: "Omni", alertIdentifier: "glucose.low"))) + + XCTAssertTrue(SmartwatchManagerHost.isTestAlert(identifier(GlucoseAlarmKind.low.testAlertIdentifier))) + XCTAssertFalse(SmartwatchManagerHost.isTestAlert(identifier(GlucoseAlertManager.lowAlertIdentifier))) + } + + /// With every alarm on, nothing is retracted while a test alert is still showing. + func testNothingIsRetractedWhileEveryAlarmIsOnAndATestIsShowing() { + let now = Date() + XCTAssertEqual(SmartwatchManagerHost.watchAlertsToRetract(configuration: .default, loopAlertsEnabled: true, lastTestAlertAt: now.addingTimeInterval(-5), now: now), []) + } + + /// A test alert is cleared once it has shown long enough, and after a relaunch + /// (no test sent since), as Loop never retracts it. + func testTestAlertsAreRetractedOnceShown() { + let now = Date() + let shownLongEnough = now.addingTimeInterval(-SmartwatchManagerHost.watchTestAlertDuration) + XCTAssertEqual(SmartwatchManagerHost.watchAlertsToRetract(configuration: .default, loopAlertsEnabled: true, lastTestAlertAt: shownLongEnough, now: now), testAlertIdentifiers) + XCTAssertEqual(SmartwatchManagerHost.watchAlertsToRetract(configuration: .default, loopAlertsEnabled: true, lastTestAlertAt: nil, now: now), testAlertIdentifiers) + } + + /// An alarm switched off (or off in the active profile) is retracted: Loop stops + /// checking it, so it would never clear otherwise. + func testAlarmsThatAreOffAreRetracted() { + let now = Date() + var configuration = GlucoseAlertConfiguration.default + configuration.lowEnabled = false + configuration.highEnabled = false + XCTAssertEqual( + Set(SmartwatchManagerHost.watchAlertsToRetract(configuration: configuration, loopAlertsEnabled: true, lastTestAlertAt: now, now: now)), + [GlucoseAlertManager.lowAlertIdentifier, GlucoseAlertManager.highAlertIdentifier] + ) + configuration.urgentLowEnabled = false + configuration.predictedLowEnabled = false + XCTAssertEqual( + Set(SmartwatchManagerHost.watchAlertsToRetract(configuration: configuration, loopAlertsEnabled: true, lastTestAlertAt: now, now: now)), + Set(GlucoseAlarmKind.allCases.map(\.alertIdentifier)) + ) + } + + /// While Loop's alerts are off (the CGM raises its own), every alarm is retracted. + func testEveryAlarmIsRetractedWhileLoopsAlertsAreOff() { + let now = Date() + XCTAssertEqual( + Set(SmartwatchManagerHost.watchAlertsToRetract(configuration: .default, loopAlertsEnabled: false, lastTestAlertAt: now, now: now)), + Set(GlucoseAlarmKind.allCases.map(\.alertIdentifier)) + ) + } + + func testTheDisplayLimitsAreTheActiveAlertProfilesLowAndHigh() { + let limits = SmartwatchManagerHost.displayLimits(from: .default) + XCTAssertEqual(limits?.low, LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 70)) + XCTAssertEqual(limits?.high, LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180)) + + var changed = GlucoseAlertConfiguration.default + changed.lowThresholdMgDL = 80 + changed.highThresholdMgDL = 250 + changed.lowEnabled = false + changed.highEnabled = false + let changedLimits = SmartwatchManagerHost.displayLimits(from: changed) + XCTAssertEqual(changedLimits?.low, LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 80), "even with the alert off") + XCTAssertEqual(changedLimits?.high, LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 250)) + + var reversed = GlucoseAlertConfiguration.default + reversed.lowThresholdMgDL = 200 + XCTAssertNil(SmartwatchManagerHost.displayLimits(from: reversed)) + } + + func testTheWatchIsToldOnlyWhenClosedLoopReallyChanges() { + var last = true + XCTAssertFalse(SmartwatchManagerHost.isNewDosingEnabled(true, last: &last)) + XCTAssertTrue(SmartwatchManagerHost.isNewDosingEnabled(false, last: &last)) + XCTAssertFalse(last) + XCTAssertFalse(SmartwatchManagerHost.isNewDosingEnabled(false, last: &last)) + XCTAssertTrue(SmartwatchManagerHost.isNewDosingEnabled(true, last: &last)) + XCTAssertTrue(last) + } +} diff --git a/LoopTests/Mocks/MockPumpManager.swift b/LoopTests/Mocks/MockPumpManager.swift index 2010d141cb..910684c7e6 100644 --- a/LoopTests/Mocks/MockPumpManager.swift +++ b/LoopTests/Mocks/MockPumpManager.swift @@ -22,6 +22,7 @@ class MockPumpManager: PumpManager { var enactTempBasalCalled: ((Double, TimeInterval) -> Void)? var enactTempBasalError: PumpManagerError? + var enactBolusError: PumpManagerError? init() { @@ -84,7 +85,7 @@ class MockPumpManager: PumpManager { func enactBolus(decisionId: UUID?, units: Double, activationType: BolusActivationType, completion: @escaping (PumpManagerError?) -> Void) { enactBolusCalled?(units, activationType) - completion(nil) + completion(enactBolusError) } func cancelBolus(completion: @escaping (PumpManagerResult) -> Void) {