From 768106b374fcfd803d37049aa404135efc5850db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge?= Date: Mon, 2 Nov 2026 11:27:26 +0100 Subject: [PATCH 01/21] Declare bluetooth-peripheral through Info.plist preprocessing Watch plugins that publish a Bluetooth service, such as a Pebble plugin, need the bluetooth-peripheral background mode to keep the watch updated while Loop is in the background, and a plugin bundle can't declare background modes itself. Info.plist lists it under #if LOOP_BLUETOOTH_PERIPHERAL, defined in Loop.xcconfig; clearing INFOPLIST_PREPROCESSOR_DEFINITIONS (for example in LoopConfigOverride.xcconfig) leaves it out. The built Info.plist is otherwise unchanged. --- Loop.xcconfig | 8 ++++++++ Loop/Info.plist | 3 +++ 2 files changed, 11 insertions(+) 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/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 From 48bc6d4ed852f62ad34866bb94b6a3dcc38acb17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge?= Date: Mon, 2 Nov 2026 11:28:54 +0100 Subject: [PATCH 02/21] Host SmartwatchManager plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One watch plugin, under Settings → Devices → Add Watch, saved and restored like the pump and CGM. - `SmartwatchManagerHost` sends snapshots when a reading is stored and when the loop's output changes, with only what the plugin declares and requests: glucose, history, prediction, loop status and therapy context. - Glucose alerts reach the watch through a new `AlertForwarder` hook on `AlertManager`, following Loop's mute. Test alerts show on the watch and clear after 30 s; alarms that are off in the active profile, or all while the CGM raises its own, are cleared on the watch. - The plugin's log lines go to the device log, and its device alerts become Loop alerts, at most time-sensitive. - URLs Loop doesn't handle are offered to the plugin. --- Loop.xcodeproj/project.pbxproj | 16 + Loop/Managers/Alerts/AlertManager.swift | 25 ++ Loop/Managers/DeviceDataManager.swift | 137 +++++++++ Loop/Managers/LoopAppManager.swift | 23 +- Loop/Managers/SmartwatchManager.swift | 20 ++ ...SmartwatchManagerHost+AlertForwarder.swift | 152 ++++++++++ Loop/Managers/SmartwatchManagerHost.swift | 279 ++++++++++++++++++ Loop/Plugins/PluginManager.swift | 45 ++- .../StatusTableViewController.swift | 39 +++ Loop/View Models/SettingsViewModel.swift | 11 + Loop/Views/PluginPickerView.swift | 9 + Loop/Views/SettingsView.swift | 35 +++ .../Managers/SmartwatchManagerHostTests.swift | 77 +++++ 13 files changed, 866 insertions(+), 2 deletions(-) create mode 100644 Loop/Managers/SmartwatchManager.swift create mode 100644 Loop/Managers/SmartwatchManagerHost+AlertForwarder.swift create mode 100644 Loop/Managers/SmartwatchManagerHost.swift create mode 100644 LoopTests/Managers/SmartwatchManagerHostTests.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 299c19910d..68cd995064 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -197,6 +197,9 @@ 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 */; }; + 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 */; }; @@ -562,6 +565,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 */; }; @@ -1008,6 +1012,9 @@ 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 = ""; }; + 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; }; @@ -1426,6 +1433,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 = ""; }; @@ -1760,6 +1768,7 @@ C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */, A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */, C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */, + 13E4F9D180117EFDC2A339E1 /* SmartwatchManagerHostTests.swift */, C16B983F26B4898800256B05 /* DoseEnactorTests.swift */, E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */, A1B2C3D40000000000000001 /* GlucoseAlertManagerTests.swift */, @@ -2232,6 +2241,9 @@ C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */, 439BED291E76093C00B0AED5 /* CGMManager.swift */, + A35441020C3D0037E9AEB302 /* SmartwatchManagerHost.swift */, + 8772495BA2855FC99DDC3EB0 /* SmartwatchManagerHost+AlertForwarder.swift */, + 3621E1DB280412962D20BE6C /* SmartwatchManager.swift */, C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */, A977A2F324ACFECF0059C207 /* CriticalEventLogExportManager.swift */, C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */, @@ -3761,6 +3773,9 @@ 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 */, + D23CD09C2E6905F436987257 /* SmartwatchManager.swift in Sources */, C16B983E26B4893300256B05 /* DoseEnactor.swift in Sources */, 3ED319962EB65A5C00820BCF /* LiveActivityManagementViewModel.swift in Sources */, E98A55EF24EDD6E60008715D /* DosingDecisionStoreProtocol.swift in Sources */, @@ -4138,6 +4153,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/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 99c4cf9501..f0e9aed103 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] = [] @@ -142,6 +156,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() { @@ -445,6 +464,9 @@ extension AlertManager: AlertIssuer { criticalAlertAudioPlayer.play(soundNamed: inAppSound, boostVolume: false) } 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? { @@ -462,6 +484,9 @@ extension AlertManager: AlertIssuer { userNotificationAlertScheduler.unscheduleAlert(identifier: identifier) criticalAlertAlarmScheduler.cancelAlarm(for: identifier) criticalAlertAudioPlayer.stop() + 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 8605580571..b234b12292 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -166,6 +166,19 @@ final class DeviceDataManager { @PersistedProperty(key: "PumpManagerState") var rawPumpManager: PumpManager.RawValue? + // MARK: - Watch + + var smartwatchManager: SmartwatchManagerUI? { + didSet { + 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 +255,9 @@ final class DeviceDataManager { private weak var displayGlucoseUnitBroadcaster: DisplayGlucoseUnitBroadcaster? + /// Sends Loop's state to `smartwatchManager`. + weak var smartwatchManagerHost: SmartwatchManagerHost? + init(pluginManager: PluginManager, deviceLog: PersistentDeviceLog, alertManager: AlertManager, @@ -353,6 +369,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 +587,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 +760,11 @@ private extension DeviceDataManager { } } + func setupWatch() { + smartwatchManager?.smartwatchManagerDelegate = self + reportPluginInitializationComplete() + } + func setupPump() { dispatchPrecondition(condition: .onQueue(.main)) @@ -786,6 +847,12 @@ extension DeviceDataManager { allActivePlugins.append(pumpManager) } } + + if let smartwatchManager = smartwatchManager { + if !allActivePlugins.contains(where: { $0.pluginIdentifier == smartwatchManager.pluginIdentifier }) { + allActivePlugins.append(smartwatchManager) + } + } return allActivePlugins } @@ -1016,6 +1083,73 @@ extension DeviceDataManager: CGMManagerDelegate { } } +// MARK: - SmartwatchManagerDelegate +extension DeviceDataManager: SmartwatchManagerDelegate { + nonisolated + func smartwatchManagerDidUpdateState(_ manager: SmartwatchManager) { + Task { @MainActor in + rawSmartwatchManager = manager.rawValue + } + } + + nonisolated + func smartwatchManagerWantsDeletion(_ manager: SmartwatchManager) async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + self.log.default("Watch manager with identifier '%{public}@' wants deletion", manager.pluginIdentifier) + self.smartwatchManager = nil + continuation.resume() + } + } + } + + nonisolated + func smartwatchManagerRequestsRefresh(_ manager: SmartwatchManager) { + Task { @MainActor in + smartwatchManagerHost?.refresh() + } + } + + nonisolated + func smartwatchManager(_ manager: SmartwatchManager, didLogEvent event: SmartwatchDeviceLogEntry) { + Task { @MainActor in + deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: event.deviceIdentifier, type: DeviceLogEntryType(event.type), message: event.message, completion: nil) + } + } + + /// Shows the plugin's alert about the watch as Loop's own alert from this + /// plugin. A plugin can't raise a critical alert: at most time-sensitive. + nonisolated + func smartwatchManager(_ manager: SmartwatchManager, issueDeviceAlert alert: SmartwatchDeviceAlert) { + let loopAlert = Alert(alert, from: manager, acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Alert acknowledgment OK button")) + Task { @MainActor in + deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: nil, type: .error, message: "Watch plugin alert: \(alert.title): \(alert.body)", completion: nil) + await alertManager?.issueAlert(loopAlert) + } + } + + nonisolated + func smartwatchManager(_ manager: SmartwatchManager, retractDeviceAlert identifier: SmartwatchAlertIdentifier) { + let loopIdentifier = Alert.Identifier(identifier, from: manager) + Task { @MainActor in + await alertManager?.retractAlert(identifier: loopIdentifier) + } + } +} + +// 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 { @@ -1343,6 +1477,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") } @@ -1444,6 +1579,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 47f1b67d7a..8bf0c08b50 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! @@ -425,6 +426,17 @@ class LoopAppManager: NSObject { healthStore: healthStore ) + smartwatchManagerHost = SmartwatchManagerHost( + deviceManager: deviceDataManager, + loopDataManager: loopDataManager, + settingsManager: settingsManager, + glucoseStore: glucoseStore, + healthStore: healthStore + ) + 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, @@ -605,6 +617,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) @@ -751,7 +770,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() 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..d08e2248ff --- /dev/null +++ b/Loop/Managers/SmartwatchManagerHost+AlertForwarder.swift @@ -0,0 +1,152 @@ +// +// 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. Only plugins + /// that declare and request `.alerts` are fed. + func alertManager(_ manager: AlertManager, didIssueAlert alert: Alert, muted: Bool) { + guard let smartwatchManager = deviceManager.smartwatchManager, + smartwatchManager.effectiveCapabilities.contains(.alerts) + 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( + SmartwatchAlert( + identifier: SmartwatchAlertIdentifier(alert.identifier.value), + level: alert.interruptionLevel.smartwatchAlertLevel, + title: content.title, + body: content.body, + issuedAt: Date(), + glucoseAlertType: Self.glucoseAlertType(of: alert.identifier) + ) + ) + // 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 same unit as the watch snapshots (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 go to every plugin that declares `.alerts`, even one that has + /// stopped requesting it, so an alert it was given doesn't stay active there. + func alertManager(_ manager: AlertManager, didRetractAlertWithIdentifier identifier: Alert.Identifier) { + guard let smartwatchManager = deviceManager.smartwatchManager, + type(of: smartwatchManager).capabilities.contains(.alerts), + identifier.managerIdentifier == GlucoseAlertManager.managerIdentifier + else { + return + } + smartwatchManager.watchAlertDidRetract(SmartwatchAlertIdentifier(identifier.value)) + } +} + +private extension Alert.InterruptionLevel { + var smartwatchAlertLevel: SmartwatchAlertLevel { + switch self { + case .active: return .informational + case .timeSensitive: return .warning + case .critical: return .critical + } + } +} diff --git a/Loop/Managers/SmartwatchManagerHost.swift b/Loop/Managers/SmartwatchManagerHost.swift new file mode 100644 index 0000000000..f7804bb8e4 --- /dev/null +++ b/Loop/Managers/SmartwatchManagerHost.swift @@ -0,0 +1,279 @@ +// +// SmartwatchManagerHost.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopAlgorithm +import LoopCore +import LoopKit + +/// Sends Loop's state to the watch plugin in Settings → Devices. +/// +/// A new glucose reading is sent as soon as the glucose store saves it, before +/// the loop runs; the snapshot is sent again when the loop's output changes. +/// Unchanged snapshots are not resent. +@MainActor +final class SmartwatchManagerHost { + unowned let deviceManager: DeviceDataManager + unowned let loopDataManager: LoopDataManager + unowned let settingsManager: SettingsManager + let glucoseStore: GlucoseStore + private let healthStore: HKHealthStore + + let log = DiagnosticLog(category: "SmartwatchManagerHost") + + private var lastSentSnapshot: SmartwatchStatusSnapshot? + /// Incremented per update so a slower glucose fetch cannot overwrite a newer snapshot. + private var updateGeneration = 0 + private var notificationObservers: [Any] = [] + /// 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 + ) { + self.deviceManager = deviceManager + self.loopDataManager = loopDataManager + self.settingsManager = settingsManager + self.glucoseStore = glucoseStore + self.healthStore = healthStore + + 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?.update() } + }, + NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.update() } + }, + NotificationCenter.default.addObserver(forName: .HealthStorePreferredGlucoseUnitDidChange, object: healthStore, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.update() } + }, + NotificationCenter.default.addObserver(forName: .LoopCycleCompleted, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.update() } + }, + NotificationCenter.default.addObserver(forName: .SmartwatchManagerChanged, object: deviceManager, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.refresh() } + }, + ] + } + + /// Sends the current snapshot even if unchanged, for a new watch manager or + /// one that reconnected. + func refresh() { + lastSentSnapshot = nil + loggedUndeclaredRequest = [] + update() + } + + private func update() { + guard let smartwatchManager = deviceManager.smartwatchManager else { + return + } + retractWatchAlertsNoLongerShown(smartwatchManager) + updateGeneration += 1 + let generation = updateGeneration + // Fill only what the watch asks for now (the user may have turned a display + // element off), within what the plugin declares. + let capabilities = smartwatchManager.effectiveCapabilities + logUndeclaredRequest(smartwatchManager) + + Task { + let snapshot: SmartwatchStatusSnapshot + do { + snapshot = try await makeSnapshot(capabilities: capabilities) + } catch { + log.error("Unable to read glucose for the watch: %{public}@", String(describing: error)) + return + } + guard generation == updateGeneration, + let current = deviceManager.smartwatchManager, (current as AnyObject) === (smartwatchManager as AnyObject), + snapshot != lastSentSnapshot + else { + return + } + lastSentSnapshot = snapshot + current.watchDataDidUpdate(snapshot) + } + } + + /// 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. + private func displayUnit() async -> SmartwatchGlucoseUnit { + await preferredGlucoseUnit() == .millimolesPerLiter ? .millimolesPerLiter : .milligramsPerDeciliter + } + + func preferredGlucoseUnit() async -> LoopUnit { + if let hkUnit = await healthStore.cachedPreferredUnits(for: .bloodGlucose) { + return LoopUnit(from: hkUnit) + } + return deviceManager.displayGlucosePreference.unit + } + + private func makeSnapshot(capabilities: SmartwatchCapabilities) async throws -> SmartwatchStatusSnapshot { + let historyStart = Date().addingTimeInterval(-SmartwatchStatusSnapshot.historyInterval) + let samples = try await glucoseStore.getGlucoseSamples(start: historyStart) + + let readings = samples.map(SmartwatchGlucoseReading.init) + var snapshot = SmartwatchStatusSnapshot( + displayUnit: await displayUnit(), + glucose: readings.last, + glucoseHistory: capabilities.contains(.history) ? readings : [] + ) + if capabilities.contains(.prediction) { + snapshot.predictedGlucose = loopDataManager.predictedGlucose?.map { + SmartwatchPredictedGlucose(date: $0.startDate, milligramsPerDeciliter: $0.quantity.doubleValue(for: .milligramsPerDeciliter)) + } + } + if capabilities.contains(.loopStatus) { + snapshot.insulinOnBoard = loopDataManager.activeInsulin?.value + snapshot.carbsOnBoard = loopDataManager.activeCarbs?.quantity.doubleValue(for: .gram) + snapshot.lastLoopCompleted = loopDataManager.lastLoopCompleted + snapshot.isClosedLoop = settingsManager.loopSettings.dosingEnabled + } + if capabilities.contains(.therapy) { + snapshot.therapy = currentTherapyContext() + } + return snapshot + } + + /// What the algorithm is working with now, in mg/dL, with any active preset + /// applied. Loop has no sensitivity ratio (an oref/autosens value), so that + /// stays nil. + private func currentTherapyContext(now: Date = Date()) -> SmartwatchTherapyContext { + let presets = loopDataManager.temporaryPresetsManager + var tempBasalRate: Double? + if case .tempBasal(let dose) = deviceManager.pumpManager?.status.basalDeliveryState { + tempBasalRate = dose.unitsPerHour + } + let range = presets.effectiveCorrectionRangeSchedule()?.quantityRange(at: now) + let override = presets.scheduleOverride.flatMap { $0.isActive(at: now) ? $0 : nil } + let presetName: String? = switch override?.context { + case .preMeal: NSLocalizedString("Pre-Meal", comment: "Name of the pre-meal preset sent to a watch") + case .preset(let preset): preset.name + case .activity(let activity): activity.activityType.name + case .custom: NSLocalizedString("Custom Preset", comment: "Name of a custom preset sent to a watch") + case nil: nil + } + return SmartwatchTherapyContext( + tempBasalRate: tempBasalRate, + scheduledBasalRate: presets.basalRateScheduleApplyingOverrideHistory?.value(at: now), + insulinSensitivity: presets.insulinSensitivityScheduleApplyingOverrideHistory?.quantity(at: now).doubleValue(for: .milligramsPerDeciliter), + eventualGlucose: loopDataManager.predictedGlucose?.last?.quantity.doubleValue(for: .milligramsPerDeciliter), + correctionRange: range.map { $0.lowerBound.doubleValue(for: .milligramsPerDeciliter)...$0.upperBound.doubleValue(for: .milligramsPerDeciliter) }, + activePresetName: presetName, + activePresetEnd: override?.scheduledEndDate + ) + } + + /// The undeclared capabilities a plugin last requested, so each set is logged once. + private var loggedUndeclaredRequest: SmartwatchCapabilities = [] + + /// A plugin can't obtain a capability it doesn't declare: hosts act only on + /// `effectiveCapabilities`. Log when it asks for more, once per distinct set. + private func logUndeclaredRequest(_ manager: SmartwatchManager) { + let undeclared = manager.undeclaredRequestedCapabilities + guard undeclared != loggedUndeclaredRequest else { return } + loggedUndeclaredRequest = undeclared + guard !undeclared.isEmpty else { return } + deviceManager.deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: nil, type: .error, message: "The watch plugin requested capabilities it doesn't declare (\(undeclared)); they are ignored", completion: nil) + } + + /// 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 update (each reading, + /// loop cycle and reconnect). 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()) { + guard type(of: smartwatchManager).capabilities.contains(.alerts) else { return } + let alertManager = deviceManager.glucoseAlertManager + let identifiers = Self.watchAlertsToRetract( + configuration: alertManager.activeConfiguration(at: now), + loopAlertsEnabled: alertManager.effectiveLoopAlertsEnabled, + lastTestAlertAt: lastWatchTestAlertAt, + now: now + ) + for identifier in identifiers { + let alertIdentifier = Alert.Identifier(managerIdentifier: GlucoseAlertManager.managerIdentifier, alertIdentifier: identifier) + smartwatchManager.watchAlertDidRetract(SmartwatchAlertIdentifier(alertIdentifier.value)) + } + } + + /// 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) + } + } +} + +extension SmartwatchGlucoseReading { + init(_ sample: StoredGlucoseSample) { + self.init( + date: sample.startDate, + milligramsPerDeciliter: sample.quantity.doubleValue(for: .milligramsPerDeciliter), + trend: sample.trend.flatMap { SmartwatchGlucoseTrend(rawValue: $0.rawValue) }, + trendRate: sample.trendRate?.doubleValue(for: .milligramsPerDeciliterPerMinute), + wasUserEntered: sample.wasUserEntered, + isDisplayOnly: sample.isDisplayOnly + ) + } +} 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 3551774a9b..b26392944b 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) @@ -1729,6 +1735,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 = automaticDosingEnabled @@ -1927,6 +1944,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) } } @@ -1938,6 +1957,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) } @@ -2251,6 +2272,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..d7c072ce68 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,6 +27,8 @@ 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") } @@ -37,6 +40,8 @@ struct PluginPickerView: View { "sensor.tag.radiowaves.forward" case .pump: "ivfluid.bag" + case .watch: + "applewatch" case .service: "network" } @@ -187,6 +192,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..cbf935a95f 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", comment: "Descriptive text for button to add a watch device")) + } + } + 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/SmartwatchManagerHostTests.swift b/LoopTests/Managers/SmartwatchManagerHostTests.swift new file mode 100644 index 0000000000..be22ea618a --- /dev/null +++ b/LoopTests/Managers/SmartwatchManagerHostTests.swift @@ -0,0 +1,77 @@ +// +// 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) + } + + /// 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)) + ) + } +} From 5adc79cc55fbded002e231f82105a06b836536df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge?= Date: Mon, 2 Nov 2026 11:28:54 +0100 Subject: [PATCH 03/21] Remote actions from watch plugins Bolus, carbs, alert mute and override presets requested from the watch, each behind its own Watch Permissions toggle, off by default, and checked with LoopKit's `SmartwatchRemoteActionPolicy`. The screen shows only when the plugin declares a remote action; the Pebble plugin declares none. Revert this commit to leave remote actions out. - Bolus: through the remote bolus path (maximum bolus, notifications, upload), with the bolus context in the snapshot, recent glucose required, and the plugin's `remoteActionLimits`. - Carbs: through the remote carbs path, dated when the user confirmed them on the watch. - Mute: Loop's alert mute, capped at its own durations. - Presets: Loop's override presets in the snapshot; start one or end the running one through the remote override path. Temp targets are refused (LoopKit's default). - Deleting or replacing the watch turns every permission off. --- Loop.xcodeproj/project.pbxproj | 12 + Loop/Managers/DeviceDataManager.swift | 39 ++++ Loop/Managers/LoopAppManager.swift | 30 ++- .../SmartwatchManagerHost+RemoteActions.swift | 211 ++++++++++++++++++ Loop/Managers/SmartwatchManagerHost.swift | 50 ++++- Loop/Managers/SmartwatchPermissions.swift | 90 ++++++++ Loop/View Models/SettingsViewModel.swift | 1 + Loop/Views/SettingsView.swift | 69 ++++++ .../Managers/SmartwatchPermissionsTests.swift | 31 +++ 9 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 Loop/Managers/SmartwatchManagerHost+RemoteActions.swift create mode 100644 Loop/Managers/SmartwatchPermissions.swift create mode 100644 LoopTests/Managers/SmartwatchPermissionsTests.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 68cd995064..2ccc638ba5 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -199,6 +199,8 @@ 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 */; }; + F89DD32969109D74C5AF9BCA /* SmartwatchPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3FF5A18F9D81237E2855A7D /* SmartwatchPermissions.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 */; }; @@ -565,6 +567,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 */; }; + 893CADEC0F22143E8D0DE0DF /* SmartwatchPermissionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2D6390D50BED3BB63A16AEB /* SmartwatchPermissionsTests.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 */; }; @@ -1014,6 +1017,8 @@ 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 = ""; }; + D3FF5A18F9D81237E2855A7D /* SmartwatchPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartwatchPermissions.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 = ""; }; @@ -1433,6 +1438,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 = ""; }; + F2D6390D50BED3BB63A16AEB /* SmartwatchPermissionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartwatchPermissionsTests.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 = ""; }; @@ -1768,6 +1774,7 @@ C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */, A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */, C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */, + F2D6390D50BED3BB63A16AEB /* SmartwatchPermissionsTests.swift */, 13E4F9D180117EFDC2A339E1 /* SmartwatchManagerHostTests.swift */, C16B983F26B4898800256B05 /* DoseEnactorTests.swift */, E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */, @@ -2243,6 +2250,8 @@ 439BED291E76093C00B0AED5 /* CGMManager.swift */, A35441020C3D0037E9AEB302 /* SmartwatchManagerHost.swift */, 8772495BA2855FC99DDC3EB0 /* SmartwatchManagerHost+AlertForwarder.swift */, + 3EE5E1D046A80EAC16C5514F /* SmartwatchManagerHost+RemoteActions.swift */, + D3FF5A18F9D81237E2855A7D /* SmartwatchPermissions.swift */, 3621E1DB280412962D20BE6C /* SmartwatchManager.swift */, C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */, A977A2F324ACFECF0059C207 /* CriticalEventLogExportManager.swift */, @@ -3775,6 +3784,8 @@ 439BED2A1E76093C00B0AED5 /* CGMManager.swift in Sources */, 0AB9F65B073904D4F9E5FCB9 /* SmartwatchManagerHost.swift in Sources */, 1ADD1EF77E2083AC7690BD85 /* SmartwatchManagerHost+AlertForwarder.swift in Sources */, + A018C4AF5E040B0DAC379735 /* SmartwatchManagerHost+RemoteActions.swift in Sources */, + F89DD32969109D74C5AF9BCA /* SmartwatchPermissions.swift in Sources */, D23CD09C2E6905F436987257 /* SmartwatchManager.swift in Sources */, C16B983E26B4893300256B05 /* DoseEnactor.swift in Sources */, 3ED319962EB65A5C00820BCF /* LiveActivityManagementViewModel.swift in Sources */, @@ -4153,6 +4164,7 @@ 1DA7A84224476EAD008257F0 /* AlertManagerTests.swift in Sources */, A91E4C2324F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift in Sources */, C188599B2AF15E1B0010F21F /* DeviceDataManagerTests.swift in Sources */, + 893CADEC0F22143E8D0DE0DF /* SmartwatchPermissionsTests.swift in Sources */, 22A2B3D7A696722C213DDFCD /* SmartwatchManagerHostTests.swift in Sources */, C18859A42AF165330010F21F /* MockCGMManager.swift in Sources */, E9C58A7324DB4A2700487A17 /* LoopDataManagerTests.swift in Sources */, diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index b234b12292..544c871d9e 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -170,6 +170,10 @@ final class DeviceDataManager { 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 { + UserDefaults.standard.resetWatchPermissions() + } setupWatch() NotificationCenter.default.post(name: .SmartwatchManagerChanged, object: self, userInfo: nil) rawSmartwatchManager = smartwatchManager?.rawValue @@ -1106,6 +1110,8 @@ extension DeviceDataManager: SmartwatchManagerDelegate { nonisolated func smartwatchManagerRequestsRefresh(_ manager: SmartwatchManager) { Task { @MainActor in + // Requested capabilities may have changed, which shows or hides Watch Permissions. + NotificationCenter.default.post(name: .SmartwatchManagerCapabilitiesChanged, object: self, userInfo: nil) smartwatchManagerHost?.refresh() } } @@ -1135,6 +1141,38 @@ extension DeviceDataManager: SmartwatchManagerDelegate { await alertManager?.retractAlert(identifier: loopIdentifier) } } + + nonisolated + func smartwatchManager(_ manager: SmartwatchManager, requestBolus request: SmartwatchBolusRequest) async throws -> Double { + guard let host = await smartwatchManagerHost else { + throw SmartwatchBolusError.notAllowed + } + return try await host.handleBolusRequest(request, from: manager) + } + + nonisolated + func smartwatchManager(_ manager: SmartwatchManager, requestAlertMute request: SmartwatchAlertMuteRequest) async throws -> Date { + guard let host = await smartwatchManagerHost else { + throw SmartwatchAlertMuteError.notAllowed + } + return try await host.handleAlertMuteRequest(request, from: manager) + } + + nonisolated + func smartwatchManager(_ manager: SmartwatchManager, requestCarbEntry request: SmartwatchCarbEntryRequest) async throws -> Double { + guard let host = await smartwatchManagerHost else { + throw SmartwatchCarbEntryError.notAllowed + } + return try await host.handleCarbEntryRequest(request, from: manager) + } + + nonisolated + func smartwatchManager(_ manager: SmartwatchManager, requestOverride request: SmartwatchOverrideRequest) async throws { + guard let host = await smartwatchManagerHost else { + throw SmartwatchPresetError.notAllowed + } + try await host.handleOverrideRequest(request, from: manager) + } } // MARK: - SmartwatchManagerOnboardingDelegate @@ -1478,6 +1516,7 @@ 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 SmartwatchManagerCapabilitiesChanged = Notification.Name(rawValue: "com.loopKit.notification.SmartwatchManagerCapabilitiesChanged") static let PumpEventsAdded = Notification.Name(rawValue: "com.loopKit.notification.PumpEventsAdded") } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 8bf0c08b50..14b8e09d6a 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -431,7 +431,35 @@ class LoopAppManager: NSObject { loopDataManager: loopDataManager, settingsManager: settingsManager, glucoseStore: glucoseStore, - healthStore: healthStore + healthStore: healthStore, + alertMuter: alertManager.alertMuter, + // The same path as remote boluses: maximum bolus check, notifications, upload. + deliverBolus: { [weak servicesManager] units in + guard let servicesManager else { + throw SmartwatchBolusError.notAllowed + } + try await servicesManager.deliverRemoteBolus(amountInUnits: units, decisionId: nil) + }, + // The same path as remote carbs: validation, notification, upload. + deliverCarbs: { [weak servicesManager] grams, date in + guard let servicesManager else { + throw SmartwatchCarbEntryError.notAllowed + } + try await servicesManager.deliverRemoteCarbs(amountInGrams: grams, absorptionTime: nil, 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. diff --git a/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift new file mode 100644 index 0000000000..daa1159f33 --- /dev/null +++ b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift @@ -0,0 +1,211 @@ +// +// 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 +/// is checked against the plugin's capabilities, the user's Watch Permissions +/// and LoopKit's remote action rules, and 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. Only while Allow Presets from Watch is on, and for + /// requests confirmed in the last 30 seconds. + func handleOverrideRequest(_ request: SmartwatchOverrideRequest, from manager: SmartwatchManager) async throws { + let isCurrentWatch = deviceManager.smartwatchManager.map { ($0 as AnyObject) === (manager as AnyObject) } ?? false + let preset: TemporaryPreset? + switch request.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), confirmed at \(request.requestedAt)") + case .cancel: + preset = nil + logWatchRequest(manager, type: .receive, "Preset request: end the running preset, confirmed at \(request.requestedAt)") + } + let refusal: String? = if !isCurrentWatch { + "the request isn't from the current watch" + } else if !manager.effectiveCapabilities.contains(.remoteOverride) { + "this watch can't start presets (remoteOverride not declared or not requested)" + } else if !UserDefaults.standard.allowWatchOverrides { + "Allow Presets from Watch is off" + } else { + nil + } + if let refusal { + logWatchRequest(manager, type: .error, "Preset request rejected: notAllowed (\(refusal))") + throw SmartwatchPresetError.notAllowed + } + let age = Date().timeIntervalSince(request.requestedAt) + guard manager.remoteActionLimits.requestWindow.contains(request.requestedAt, now: Date()) else { + logWatchRequest(manager, type: .error, String(format: "Preset request rejected: expiredRequest (confirmed %.0f s ago)", age)) + throw SmartwatchPresetError.expiredRequest + } + do { + switch request.action { + case .activate: + guard let preset else { + logWatchRequest(manager, type: .error, "Preset request rejected: unknownPreset") + throw SmartwatchPresetError.unknownPreset + } + 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)) + } + refresh() + } + + // MARK: - Watch bolus + + func currentBolusContext(latestGlucoseDate: Date?) async -> SmartwatchBolusContext { + let recommendation = try? await loopDataManager.recommendManualBolus() + let inputs = SmartwatchBolusContextIssuer.Inputs( + recommendedBolus: recommendation?.amount, + maximumBolus: settingsManager.loopSettings.maximumBolus, + supportedBolusVolumes: deviceManager.pumpManager?.supportedBolusVolumes ?? [], + latestGlucoseDate: latestGlucoseDate + ) + return bolusContextIssuer.context(for: inputs, now: Date()) + } + + /// Delivers a bolus the plugin authenticated, if `SmartwatchBolusPolicy` allows it. + /// Every request and its outcome goes to the device log. + func handleBolusRequest(_ request: SmartwatchBolusRequest, from manager: SmartwatchManager) async throws -> Double { + let isCurrentWatch = deviceManager.smartwatchManager.map { ($0 as AnyObject) === (manager as AnyObject) } ?? false + logWatchRequest(manager, type: .receive, String(format: "%@ request: %.3f U, confirmed at %@, context %@", request.isForced ? "Forced bolus (recommendation not checked)" : "Bolus", request.units, String(describing: request.requestedAt), request.contextToken.uuidString)) + if bolusContextIssuer.isDelivering(request) { + logWatchRequest(manager, type: .error, "Bolus request answered with inProgress: the same bolus is still being delivered") + throw SmartwatchBolusError.inProgress + } + + let policy = SmartwatchBolusPolicy( + limits: manager.remoteActionLimits.bolusPolicyLimits, + isAllowed: UserDefaults.standard.allowWatchBolus && isCurrentWatch && manager.effectiveCapabilities.contains(.remoteBolus), + currentContext: bolusContextIssuer.current, + latestGlucoseDate: glucoseStore.latestGlucose?.startDate, + recentDeliveries: UserDefaults.standard.watchBolusDeliveries, + roundToSupportedVolume: { [deviceManager] units in + deviceManager.pumpManager?.roundToSupportedBolusVolume(units: units) ?? units + } + ) + let units: Double + switch policy.evaluate(request, now: Date()) { + case .failure(let error): + let units = { (value: Double?) in value.map { String(format: "%.2f U", $0) } ?? "none" } + let notAllowedReason: String = if !isCurrentWatch { + "the request isn't from the current watch; " + } else if !manager.effectiveCapabilities.contains(.remoteBolus) { + "this watch can't request boluses (remoteBolus not declared or not requested); " + } else if !UserDefaults.standard.allowWatchBolus { + "Allow Boluses from Watch is off; " + } else { + "" + } + let glucoseAge = glucoseStore.latestGlucose.map { String(format: "newest glucose %.0f min ago; ", Date().timeIntervalSince($0.startDate) / 60) } ?? "no glucose; " + logWatchRequest(manager, type: .error, "Bolus request rejected: \(error) (\(error == .notAllowed ? notAllowedReason : "")\(error == .noRecentGlucose ? glucoseAge : "")requested \(units(request.units)), Loop recommends \(units(bolusContextIssuer.current?.recommendedBolus ?? 0)), maximum bolus \(units(bolusContextIssuer.current?.maximumBolus)))") + throw error + case .success(let allowed): + units = allowed + } + + bolusContextIssuer.beginDelivery(of: request) + defer { bolusContextIssuer.endDelivery() } + logWatchRequest(manager, type: .delegate, String(format: "Delivering watch bolus of %.3f U", units)) + do { + try await deliverBolus(units) + } catch { + logWatchRequest(manager, type: .error, "Watch bolus delivery failed: \(error)") + refresh() + throw SmartwatchBolusError.deliveryFailed(String(describing: error)) + } + UserDefaults.standard.watchBolusDeliveries += [SmartwatchBolusPolicy.Delivery(date: Date(), units: units)] + logWatchRequest(manager, type: .delegateResponse, String(format: "Watch bolus of %.3f U delivered", units)) + refresh() + 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. Only + /// while Allow Mute from Watch is on. + func handleAlertMuteRequest(_ request: SmartwatchAlertMuteRequest, from manager: SmartwatchManager) throws -> Date { + let duration = request.duration + let isCurrentWatch = deviceManager.smartwatchManager.map { ($0 as AnyObject) === (manager as AnyObject) } ?? false + guard isCurrentWatch, manager.effectiveCapabilities.contains(.remoteMute) else { + logWatchRequest(manager, type: .error, "Mute request from the watch refused: this watch can't mute alerts (remoteMute not declared or not requested)") + throw SmartwatchAlertMuteError.notAllowed + } + guard UserDefaults.standard.allowWatchMute else { + logWatchRequest(manager, type: .error, "Mute request from the watch refused: Allow Mute from Watch is off") + throw SmartwatchAlertMuteError.notAllowed + } + guard manager.remoteActionLimits.requestWindow.contains(request.requestedAt, now: Date()) else { + logWatchRequest(manager, type: .error, String(format: "Mute request from the watch refused: expiredRequest (confirmed %.0f s ago)", Date().timeIntervalSince(request.requestedAt))) + throw SmartwatchAlertMuteError.expiredRequest + } + 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, if LoopKit's `SmartwatchCarbEntryPolicy` + /// allows it, through the same path as remote carbs (notification, upload). + func handleCarbEntryRequest(_ request: SmartwatchCarbEntryRequest, from manager: SmartwatchManager) async throws -> Double { + let grams = request.grams + let isCurrentWatch = deviceManager.smartwatchManager.map { ($0 as AnyObject) === (manager as AnyObject) } ?? false + logWatchRequest(manager, type: .receive, String(format: "Carb entry request: %.0f g, confirmed at %@", grams, String(describing: request.requestedAt))) + let refusal: String? = if !isCurrentWatch { + "the request isn't from the current watch" + } else if !manager.effectiveCapabilities.contains(.remoteCarbs) { + "this watch can't add carbs (remoteCarbs not declared or not requested)" + } else if !UserDefaults.standard.allowWatchCarbs { + "Allow Carbs from Watch is off" + } else { + nil + } + let previousEntry = UserDefaults.standard.lastWatchCarbEntry + let policy = SmartwatchCarbEntryPolicy( + limits: manager.remoteActionLimits.carbEntryPolicyLimits, + isAllowed: refusal == nil, + maximumGrams: LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram), + lastEntry: previousEntry + ) + if case .failure(let error) = policy.evaluate(request, now: Date()) { + logWatchRequest(manager, type: .error, "Carb entry request rejected: \(error)\(refusal.map { " (\($0))" } ?? "")") + throw error + } + // Take the slot before storing, so a resend that arrives meanwhile is + // rate limited instead of adding the carbs a second time. + UserDefaults.standard.lastWatchCarbEntry = Date() + do { + try await deliverCarbs(grams, request.requestedAt) + } catch { + UserDefaults.standard.lastWatchCarbEntry = previousEntry + 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)) + refresh() + return grams + } + + 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 index f7804bb8e4..b2144f2acd 100644 --- a/Loop/Managers/SmartwatchManagerHost.swift +++ b/Loop/Managers/SmartwatchManagerHost.swift @@ -16,6 +16,10 @@ import LoopKit /// A new glucose reading is sent as soon as the glucose store saves it, before /// the loop runs; the snapshot is sent again when the loop's output changes. /// Unchanged snapshots are not resent. +/// +/// For plugins that declare `.remoteBolus`, it also issues the bolus context and +/// decides on bolus requests with `SmartwatchBolusPolicy`. Watch boluses stay off +/// unless the user allows them in Watch Permissions. @MainActor final class SmartwatchManagerHost { unowned let deviceManager: DeviceDataManager @@ -23,10 +27,18 @@ final class SmartwatchManagerHost { 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) async throws -> Void + let startOverride: (String) async throws -> Void + let cancelOverride: () async throws -> Void let log = DiagnosticLog(category: "SmartwatchManagerHost") private var lastSentSnapshot: SmartwatchStatusSnapshot? + /// Hands out the bolus context and tracks the watch bolus being delivered. + var bolusContextIssuer = SmartwatchBolusContextIssuer() /// Incremented per update so a slower glucose fetch cannot overwrite a newer snapshot. private var updateGeneration = 0 private var notificationObservers: [Any] = [] @@ -41,13 +53,23 @@ final class SmartwatchManagerHost { loopDataManager: LoopDataManager, settingsManager: SettingsManager, glucoseStore: GlucoseStore, - healthStore: HKHealthStore + healthStore: HKHealthStore, + alertMuter: AlertMuter, + deliverBolus: @escaping (Double) async throws -> Void, + deliverCarbs: @escaping (Double, Date) 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 @@ -67,6 +89,9 @@ final class SmartwatchManagerHost { NotificationCenter.default.addObserver(forName: .SmartwatchManagerChanged, object: deviceManager, queue: .main) { [weak self] _ in MainActor.assumeIsolated { self?.refresh() } }, + NotificationCenter.default.addObserver(forName: .WatchPermissionsChanged, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.update() } + }, ] } @@ -149,6 +174,19 @@ final class SmartwatchManagerHost { if capabilities.contains(.therapy) { snapshot.therapy = currentTherapyContext() } + if capabilities.contains(.remoteBolus) { + snapshot.bolusContext = await currentBolusContext(latestGlucoseDate: samples.last?.startDate) + } + if capabilities.contains(.remoteOverride) { + snapshot.overridePresets = currentOverridePresets() + } + snapshot.permittedActions = UserDefaults.standard.permittedWatchActions(within: capabilities) + if capabilities.contains(.remoteCarbs) { + snapshot.maximumCarbEntry = LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram) + } + if capabilities.contains(.remoteBolus) { + snapshot.bolusIncrement = SmartwatchStatusSnapshot.bolusIncrement(of: deviceManager.pumpManager?.supportedBolusVolumes ?? []) + } return snapshot } @@ -181,6 +219,16 @@ final class SmartwatchManagerHost { ) } + /// Loop's presets, with the running one marked. + private func currentOverridePresets(now: Date = Date()) -> [SmartwatchPreset] { + let running = loopDataManager.temporaryPresetsManager.scheduleOverride.flatMap { $0.isActive(at: now) ? $0 : nil } + let runningID: String? = if case .preset(let preset) = running?.context { preset.id } else { nil } + return settingsManager.settings.overridePresets.map { preset in + let duration: TimeInterval? = if case .finite(let interval) = preset.duration { interval } else { nil } + return SmartwatchPreset(id: preset.id, name: preset.name, isActive: preset.id == runningID, duration: duration) + } + } + /// The undeclared capabilities a plugin last requested, so each set is logged once. private var loggedUndeclaredRequest: SmartwatchCapabilities = [] diff --git a/Loop/Managers/SmartwatchPermissions.swift b/Loop/Managers/SmartwatchPermissions.swift new file mode 100644 index 0000000000..f77eca9117 --- /dev/null +++ b/Loop/Managers/SmartwatchPermissions.swift @@ -0,0 +1,90 @@ +// +// SmartwatchPermissions.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +/// What the user allowed a watch plugin to do, and the history the rate limits +/// need. The bolus checks themselves are LoopKit's `SmartwatchBolusPolicy`. +extension Notification.Name { + /// A Watch Permissions toggle changed, so the snapshot's `permittedActions` did. + static let WatchPermissionsChanged = Notification.Name("com.loopkit.Loop.WatchPermissionsChanged") +} + +extension UserDefaults { + /// Whether boluses requested from a watch plugin may be delivered. Off by default. + static let allowWatchBolusKey = "com.loopkit.Loop.allowWatchBolus" + private static let watchBolusDeliveriesKey = "com.loopkit.Loop.watchBolusDeliveries" + + var allowWatchBolus: Bool { + get { bool(forKey: Self.allowWatchBolusKey) } + set { set(newValue, forKey: Self.allowWatchBolusKey) } + } + + /// Whether a watch plugin with `.remoteCarbs` may add carb entries. Off by default. + static let allowWatchCarbsKey = "com.loopkit.Loop.allowWatchCarbs" + private static let lastWatchCarbEntryKey = "com.loopkit.Loop.lastWatchCarbEntry" + + var allowWatchCarbs: Bool { + get { bool(forKey: Self.allowWatchCarbsKey) } + set { set(newValue, forKey: Self.allowWatchCarbsKey) } + } + + /// When the last carb entry from the watch was added, for its rate limit. + var lastWatchCarbEntry: Date? { + get { object(forKey: Self.lastWatchCarbEntryKey) as? Date } + set { set(newValue, forKey: Self.lastWatchCarbEntryKey) } + } + + /// Whether a watch plugin with `.remoteMute` may mute Loop's alerts. Off by default. + static let allowWatchMuteKey = "com.loopkit.Loop.allowWatchMute" + + var allowWatchMute: Bool { + get { bool(forKey: Self.allowWatchMuteKey) } + set { set(newValue, forKey: Self.allowWatchMuteKey) } + } + + /// Whether a watch plugin with `.remoteOverride` may start and end override presets. Off by default. + static let allowWatchOverridesKey = "com.loopkit.Loop.allowWatchOverrides" + + var allowWatchOverrides: Bool { + get { bool(forKey: Self.allowWatchOverridesKey) } + set { set(newValue, forKey: Self.allowWatchOverridesKey) } + } + + /// The remote actions among `capabilities` whose toggle is on, for the + /// snapshot's `permittedActions`. + func permittedWatchActions(within capabilities: SmartwatchCapabilities) -> SmartwatchCapabilities { + var permitted: SmartwatchCapabilities = [] + if allowWatchBolus { permitted.insert(.remoteBolus) } + if allowWatchCarbs { permitted.insert(.remoteCarbs) } + if allowWatchMute { permitted.insert(.remoteMute) } + if allowWatchOverrides { permitted.insert(.remoteOverride) } + return permitted.intersection(capabilities) + } + + /// Turns off every Watch Permissions toggle. Called when the watch plugin is + /// removed or replaced, so a new plugin never inherits what the user granted + /// another one. The rate-limit history is kept. + func resetWatchPermissions() { + allowWatchBolus = false + allowWatchCarbs = false + allowWatchMute = false + allowWatchOverrides = false + } + + /// Watch boluses delivered in the last 24 hours, kept across launches for the rate limits. + var watchBolusDeliveries: [SmartwatchBolusPolicy.Delivery] { + get { + data(forKey: Self.watchBolusDeliveriesKey).flatMap { try? JSONDecoder().decode([SmartwatchBolusPolicy.Delivery].self, from: $0) } ?? [] + } + set { + let dayStart = Date().addingTimeInterval(-24 * 60 * 60) + set(try? JSONEncoder().encode(newValue.filter { $0.date > dayStart }), forKey: Self.watchBolusDeliveriesKey) + } + } +} diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 3432d083f8..9988ad8f16 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -187,6 +187,7 @@ class SettingsViewModel { self.deviceManager = deviceManager NotificationCenter.default.publisher(for: .SmartwatchManagerChanged) + .merge(with: NotificationCenter.default.publisher(for: .SmartwatchManagerCapabilitiesChanged)) .receive(on: RunLoop.main) .sink { [weak self] _ in self?.smartwatchManagerRevision += 1 } .store(in: &cancellables) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index cbf935a95f..40f551cf62 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -91,6 +91,9 @@ struct SettingsView: View { } presetsSection deviceSettingsSection + if offersWatchPermissions { + watchPermissionsSection + } healthAccessSection if FeatureFlags.allowExperimentalFeatures { favoriteFoodsSection @@ -508,6 +511,36 @@ extension SettingsView { } } + /// Watch Permissions appear only when the watch plugin offers a remote action + /// (`.remoteMute`, `.remoteBolus`, `.remoteCarbs`, `.remoteOverride`). No plugin does yet, so the + /// screen stays hidden; it is kept compiled for when one does. + private var offersWatchPermissions: Bool { + let _ = viewModel.smartwatchManagerRevision + guard let manager = viewModel.deviceManager?.smartwatchManager else { + return false + } + return !manager.effectiveCapabilities.isDisjoint(with: [.remoteMute, .remoteBolus, .remoteCarbs, .remoteOverride]) + } + + /// Opens what the watch may ask Loop to do. + private var watchPermissionsSection: some View { + Section { + NavigationLink(destination: WatchPermissionsView()) { + LargeButton( + action: {}, + includeArrow: false, + imageView: Image(systemName: "hand.raised.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30), + label: NSLocalizedString("Watch Permissions", comment: "Watch permissions button text"), + descriptiveText: NSLocalizedString("What the watch may ask Loop to do", comment: "Watch permissions descriptive text") + ) + .accessibilityIdentifier("settingsViewWatchPermissions") + } + } + } + @ViewBuilder private var pumpSection: some View { if viewModel.pumpManagerSettingsViewModel.isSetUp() { @@ -956,3 +989,39 @@ struct DisplaySettingsView: View { } } } + +/// What the watch may ask Loop to do. Each request is refused unless its +/// toggle is on, and the watch plugin must declare the matching capability. +private struct WatchPermissionsView: View { + @Environment(\.appName) private var appName + @AppStorage(UserDefaults.allowWatchMuteKey) private var allowWatchMute = false + @AppStorage(UserDefaults.allowWatchCarbsKey) private var allowWatchCarbs = false + @AppStorage(UserDefaults.allowWatchBolusKey) private var allowWatchBolus = false + @AppStorage(UserDefaults.allowWatchOverridesKey) private var allowWatchOverrides = false + + var body: some View { + List { + Section(footer: Text(String(format: NSLocalizedString("Mutes all %1$@ sounds for as long as the watch asks, rounded up to one of the app's mute durations, the same as Mute All App Sounds, including critical alerts.", comment: "Footer for Allow Mute from Watch (1: app name)"), appName))) { + Toggle(NSLocalizedString("Allow Mute from Watch", comment: "Toggle allowing a watch to mute the app's alerts"), isOn: $allowWatchMute) + } + + Section(footer: Text(String(format: NSLocalizedString("%1$@ starts the preset picked on the watch, for the preset's own duration, or ends the running one, like a remote override.", comment: "Footer for Allow Presets from Watch (1: app name)"), appName))) { + Toggle(NSLocalizedString("Allow Presets from Watch", comment: "Toggle allowing a watch to start and end override presets"), isOn: $allowWatchOverrides) + } + + Section(footer: Text(String(format: NSLocalizedString("%1$@ adds the watch's carb entry like a remote carb entry, at most one every 15 minutes. In closed loop, %1$@ may dose for it.", comment: "Footer for Allow Carbs from Watch (1: app name)"), appName))) { + Toggle(NSLocalizedString("Allow Carbs from Watch", comment: "Toggle allowing carb entries requested from a watch plugin"), isOn: $allowWatchCarbs) + } + + // SmartwatchBolusPolicy checks every request. + Section(footer: Text(String(format: NSLocalizedString("%1$@ delivers a bolus requested on the watch only within your maximum bolus and at most one every 15 minutes, and never more than the current recommendation unless the watch marks the request as forced.", comment: "Footer for Allow Boluses from Watch (1: app name)"), appName))) { + Toggle(NSLocalizedString("Allow Boluses from Watch", comment: "Toggle allowing boluses requested from a watch plugin"), isOn: $allowWatchBolus) + } + } + .insetGroupedListStyle() + .navigationTitle(NSLocalizedString("Watch Permissions", comment: "Watch permissions screen title")) + .onChange(of: [allowWatchMute, allowWatchOverrides, allowWatchCarbs, allowWatchBolus]) { + NotificationCenter.default.post(name: .WatchPermissionsChanged, object: nil) + } + } +} diff --git a/LoopTests/Managers/SmartwatchPermissionsTests.swift b/LoopTests/Managers/SmartwatchPermissionsTests.swift new file mode 100644 index 0000000000..b7bef34dee --- /dev/null +++ b/LoopTests/Managers/SmartwatchPermissionsTests.swift @@ -0,0 +1,31 @@ +// +// SmartwatchPermissionsTests.swift +// LoopTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +@testable import Loop + +final class SmartwatchPermissionsTests: XCTestCase { + func testResettingWatchPermissionsTurnsEveryToggleOffAndKeepsTheRateLimitHistory() throws { + let suiteName = "SmartwatchPermissionsTests.\(UUID())" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.allowWatchBolus = true + defaults.allowWatchCarbs = true + defaults.allowWatchMute = true + defaults.allowWatchOverrides = true + let lastCarbEntry = Date() + defaults.lastWatchCarbEntry = lastCarbEntry + + defaults.resetWatchPermissions() + + XCTAssertFalse(defaults.allowWatchBolus) + XCTAssertFalse(defaults.allowWatchCarbs) + XCTAssertFalse(defaults.allowWatchMute) + XCTAssertFalse(defaults.allowWatchOverrides) + XCTAssertEqual(defaults.lastWatchCarbEntry, lastCarbEntry) + } +} From 265952ccb13c93c0c73625933a41d9bfa17ba249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge?= Date: Mon, 2 Nov 2026 13:38:38 +0100 Subject: [PATCH 04/21] Host SmartwatchManager plugins: their strings in the string catalog The Watch row, the watch picker, the device alert's OK button and the preset names sent to a watch, added to Loop/Localizable.xcstrings as Xcode extracts them, so they reach translation. Custom Preset is in use again, with its translations. --- Loop/Localizable.xcstrings | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index e8be2e8501..d48e591ec4 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" : { }, @@ -15303,8 +15306,7 @@ } }, "Custom Preset" : { - "comment" : "The title of the cell indicating a generic custom preset is enabled", - "extractionState" : "stale", + "comment" : "Name of a custom preset sent to a watch", "localizations" : { "da" : { "stringUnit" : { @@ -31459,6 +31461,9 @@ }, "Power lifting" : { + }, + "Pre-Meal" : { + "comment" : "Name of the pre-meal preset sent to a watch" }, "Pre-meal Preset" : { "comment" : "Status row title for premeal override enabled" @@ -38080,6 +38085,9 @@ } } }, + "Tap here to set up a watch" : { + "comment" : "Descriptive text for button to add a watch device" + }, "Tap to Add" : { "comment" : "The subtitle of the cell displaying an action to add a manually measurement glucose value", "extractionState" : "stale", @@ -42808,6 +42816,9 @@ } } }, + "Watch" : { + "comment" : "Descriptive text for a watch plugin in Settings → Devices" + }, "What are examples of Critical Alerts and Time Sensitive Notifications?" : { }, From 7c2999c5a6629d9b865420a53e12e5c30be548d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge?= Date: Mon, 2 Nov 2026 13:38:39 +0100 Subject: [PATCH 05/21] Remote actions from watch plugins: their strings in the string catalog Watch Permissions, its toggles and their footers, added to Loop/Localizable.xcstrings as Xcode extracts them. Revert this commit together with "Remote actions from watch plugins" to leave remote actions out. --- Loop/Localizable.xcstrings | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index d48e591ec4..5c135f7c60 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -1939,6 +1939,9 @@ } } }, + "%1$@ adds the watch's carb entry like a remote carb entry, at most one every 15 minutes. In closed loop, %1$@ may dose for it." : { + "comment" : "Footer for Allow Carbs from Watch (1: app name)" + }, "%1$@ also reads insulin data from other apps in Apple Health. Apple Health does not report whether an app may read data, so read access can't be shown here." : { "comment" : "Health access reading explanation (1: app name)" }, @@ -1981,6 +1984,9 @@ "%1$@ cannot turn your iPhone's volume up for you. Make sure your volume is turned up — especially overnight — or you may not hear urgent alarms. The Mute All App Sounds feature also silences these alarms while it is active." : { "comment" : "Critical alert audio fallback callout message (1: app name)" }, + "%1$@ delivers a bolus requested on the watch only within your maximum bolus and at most one every 15 minutes, and never more than the current recommendation unless the watch marks the request as forced." : { + "comment" : "Footer for Allow Boluses from Watch (1: app name)" + }, "%1$@ has been active for more than 24 hours. Make sure you still want it enabled, or turn it off in the app." : { "comment" : "Active preset reminder alert background body. (1: preset name)" }, @@ -2239,6 +2245,9 @@ } } }, + "%1$@ starts the preset picked on the watch, for the preset's own duration, or ends the running one, like a remote override." : { + "comment" : "Footer for Allow Presets from Watch (1: app name)" + }, "%1$@ Still Active" : { "comment" : "The format title for the preset still active alert. (1: preset name)" }, @@ -6895,6 +6904,18 @@ }, "All Presets" : { + }, + "Allow Boluses from Watch" : { + "comment" : "Toggle allowing boluses requested from a watch plugin" + }, + "Allow Carbs from Watch" : { + "comment" : "Toggle allowing carb entries requested from a watch plugin" + }, + "Allow Mute from Watch" : { + "comment" : "Toggle allowing a watch to mute the app's alerts" + }, + "Allow Presets from Watch" : { + "comment" : "Toggle allowing a watch to start and end override presets" }, "Allowed" : { "comment" : "Health sharing status: authorized" @@ -28726,6 +28747,9 @@ "Muted until" : { "comment" : "Label for when mute alert will end" }, + "Mutes all %1$@ sounds for as long as the watch asks, rounded up to one of the app's mute durations, the same as Mute All App Sounds, including critical alerts." : { + "comment" : "Footer for Allow Mute from Watch (1: app name)" + }, "Name" : { "comment" : "Label for name row on add favorite food screen\nPreset sorting option description for sorting by name", "localizations" : { @@ -42819,6 +42843,9 @@ "Watch" : { "comment" : "Descriptive text for a watch plugin in Settings → Devices" }, + "Watch Permissions" : { + "comment" : "Watch permissions button text\nWatch permissions screen title" + }, "What are examples of Critical Alerts and Time Sensitive Notifications?" : { }, @@ -42890,6 +42917,9 @@ "What if Critical Alerts aren't available in %1$@?" : { "comment" : "Title text for critical alerts unavailable fallback (1: app name)" }, + "What the watch may ask Loop to do" : { + "comment" : "Watch permissions descriptive text" + }, "When current or forecasted glucose is below the glucose safety limit, Loop will not recommend a bolus, and will always recommend a temporary basal rate of 0 units per hour." : { "comment" : "Explanation of glucose safety limit", "localizations" : { From f532d3d29b5147ec092e66508086b12dbd9b3dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge?= Date: Mon, 2 Nov 2026 14:40:04 +0100 Subject: [PATCH 06/21] Settings: make clear that Add Watch is for watches other than Apple Watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop's Apple Watch app needs no setup in Settings → Devices, so people looking for it there would find only other watches. The Devices row now reads "Tap here to set up a watch other than Apple Watch", and the watch picker says that Apple Watch isn't added there and where Loop's Apple Watch app comes from. The picker's fallback icon for a watch plugin is a generic watch instead of the Apple Watch symbol. The string catalog follows. --- Loop/Localizable.xcstrings | 7 +++++-- Loop/Views/PluginPickerView.swift | 17 ++++++++++++++++- Loop/Views/SettingsView.swift | 2 +- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 5c135f7c60..04e1d307c5 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -7956,6 +7956,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" : { @@ -38109,8 +38112,8 @@ } } }, - "Tap here to set up a watch" : { - "comment" : "Descriptive text for button to add a watch device" + "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", diff --git a/Loop/Views/PluginPickerView.swift b/Loop/Views/PluginPickerView.swift index d7c072ce68..8551d8a54e 100644 --- a/Loop/Views/PluginPickerView.swift +++ b/Loop/Views/PluginPickerView.swift @@ -34,6 +34,16 @@ struct PluginPickerView: View { } } + /// 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: @@ -41,7 +51,7 @@ struct PluginPickerView: View { case .pump: "ivfluid.bag" case .watch: - "applewatch" + "watch.analog" case .service: "network" } @@ -107,6 +117,11 @@ struct PluginPickerView: View { rows(for: group) } } + if let footer = pluginType.footer { + Section(footer: Text(footer)) { + EmptyView() + } + } } .insetGroupedListStyle() .navigationBarTitle(Text(pluginType.localizedTitle)) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 40f551cf62..f39a1ce058 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -590,7 +590,7 @@ extension SettingsView { 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", comment: "Descriptive 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")) } } From 021f6e40f4713528a517c9223d8421493513e72e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge=20Berg?= Date: Thu, 5 Nov 2026 11:06:31 +0100 Subject: [PATCH 07/21] Watch plugins: Loop's bolus recommendation for carbs about to be added Answers a watch's recommendation request like the Apple Watch's: recommendManualBolus with the carbs as the entry the watch's carbs become (medium absorption, dated now), rounded as the bolus check rounds the recommendation it caps at, plus the maximum bolus. Only while Allow Boluses from Watch is on, for the current watch with remoteBolus, with glucose from the last 15 minutes and a valid carb amount; every request and answer goes to the device log. --- Loop/Managers/DeviceDataManager.swift | 8 +++ .../SmartwatchManagerHost+RemoteActions.swift | 54 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 544c871d9e..ffe24c94b5 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1150,6 +1150,14 @@ extension DeviceDataManager: SmartwatchManagerDelegate { return try await host.handleBolusRequest(request, from: manager) } + nonisolated + func smartwatchManager(_ manager: SmartwatchManager, recommendBolusFor request: SmartwatchBolusRecommendationRequest) async throws -> SmartwatchBolusRecommendation { + guard let host = await smartwatchManagerHost else { + throw SmartwatchBolusError.notAllowed + } + return try await host.handleBolusRecommendationRequest(request, from: manager) + } + nonisolated func smartwatchManager(_ manager: SmartwatchManager, requestAlertMute request: SmartwatchAlertMuteRequest) async throws -> Date { guard let host = await smartwatchManagerHost else { diff --git a/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift index daa1159f33..7225999825 100644 --- a/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift +++ b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift @@ -137,6 +137,60 @@ extension SmartwatchManagerHost { return units } + /// 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 (medium + /// absorption, dated now). Only while the watch may request boluses and + /// glucose is recent; the bolus itself is checked again after the carbs. + func handleBolusRecommendationRequest(_ request: SmartwatchBolusRecommendationRequest, from manager: SmartwatchManager) async throws -> SmartwatchBolusRecommendation { + let grams = request.grams + let isCurrentWatch = deviceManager.smartwatchManager.map { ($0 as AnyObject) === (manager as AnyObject) } ?? false + logWatchRequest(manager, type: .receive, String(format: "Bolus recommendation request with %.0f g of carbs", grams)) + let refusal: String? = if !isCurrentWatch { + "the request isn't from the current watch" + } else if !manager.effectiveCapabilities.contains(.remoteBolus) { + "this watch can't request boluses (remoteBolus not declared or not requested)" + } else if !UserDefaults.standard.allowWatchBolus { + "Allow Boluses from Watch is off" + } else { + nil + } + if let refusal { + logWatchRequest(manager, type: .error, "Bolus recommendation refused: \(refusal)") + throw SmartwatchBolusError.notAllowed + } + let maximumGlucoseAge = manager.remoteActionLimits.bolusPolicyLimits.maximumGlucoseAge + guard let latestGlucoseDate = glucoseStore.latestGlucose?.startDate, Date().timeIntervalSince(latestGlucoseDate) <= maximumGlucoseAge else { + logWatchRequest(manager, type: .error, "Bolus recommendation refused: no glucose in the last \(Int(maximumGlucoseAge / 60)) minutes") + throw SmartwatchBolusError.noRecentGlucose + } + let maximumGrams = LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram) + guard grams.isFinite, grams >= 0, grams <= maximumGrams else { + logWatchRequest(manager, type: .error, String(format: "Bolus recommendation refused: %.0f g isn't a valid carb entry (maximum %.0f g)", grams, maximumGrams)) + throw SmartwatchBolusError.invalidAmount + } + let potentialCarbEntry = grams > 0 ? NewCarbEntry( + syncIdentifier: UUID().uuidString, + quantity: LoopQuantity(unit: .gram, doubleValue: grams), + startDate: Date(), + foodType: nil, + absorptionTime: LoopCoreConstants.defaultCarbAbsorptionTimes.medium + ) : 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)) + } + // Rounded as the bolus check rounds the recommendation it caps at. + let recommended = max(0, recommendation?.amount ?? 0) + let units = deviceManager.pumpManager?.roundToSupportedBolusVolume(units: recommended) ?? recommended + let maximumBolus = settingsManager.loopSettings.maximumBolus + 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 SmartwatchBolusRecommendation(units: units, grams: grams, maximumBolus: maximumBolus, issuedAt: Date()) + } + /// 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. Only From 73e89c6b936f52fb2929dbab498528f1111261d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge=20Berg?= Date: Thu, 5 Nov 2026 12:54:19 +0100 Subject: [PATCH 08/21] Watch plugins: carb absorption from the watch, 5 minutes between requests The watch's fast, medium or slow carbs become carb entries (and recommendations) with Loop's carb-entry absorption times (30 min, 3 h, 5 h), medium when it doesn't say. Allow Carbs and Allow Boluses from Watch say at most one every 5 minutes, LoopKit's new interval. --- Loop/Localizable.xcstrings | 4 ++-- Loop/Managers/LoopAppManager.swift | 4 ++-- .../SmartwatchManagerHost+RemoteActions.swift | 19 +++++++++++++++---- Loop/Managers/SmartwatchManagerHost.swift | 4 ++-- Loop/Views/SettingsView.swift | 4 ++-- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 04e1d307c5..aaa943f6b1 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -1939,7 +1939,7 @@ } } }, - "%1$@ adds the watch's carb entry like a remote carb entry, at most one every 15 minutes. In closed loop, %1$@ may dose for it." : { + "%1$@ adds the watch's carb entry like a remote carb entry, at most one every 5 minutes. In closed loop, %1$@ may dose for it." : { "comment" : "Footer for Allow Carbs from Watch (1: app name)" }, "%1$@ also reads insulin data from other apps in Apple Health. Apple Health does not report whether an app may read data, so read access can't be shown here." : { @@ -1984,7 +1984,7 @@ "%1$@ cannot turn your iPhone's volume up for you. Make sure your volume is turned up — especially overnight — or you may not hear urgent alarms. The Mute All App Sounds feature also silences these alarms while it is active." : { "comment" : "Critical alert audio fallback callout message (1: app name)" }, - "%1$@ delivers a bolus requested on the watch only within your maximum bolus and at most one every 15 minutes, and never more than the current recommendation unless the watch marks the request as forced." : { + "%1$@ delivers a bolus requested on the watch only within your maximum bolus and at most one every 5 minutes, and never more than the current recommendation unless the watch marks the request as forced." : { "comment" : "Footer for Allow Boluses from Watch (1: app name)" }, "%1$@ has been active for more than 24 hours. Make sure you still want it enabled, or turn it off in the app." : { diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 14b8e09d6a..27b218a7b0 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -441,11 +441,11 @@ class LoopAppManager: NSObject { try await servicesManager.deliverRemoteBolus(amountInUnits: units, decisionId: nil) }, // The same path as remote carbs: validation, notification, upload. - deliverCarbs: { [weak servicesManager] grams, date in + deliverCarbs: { [weak servicesManager] grams, date, absorptionTime in guard let servicesManager else { throw SmartwatchCarbEntryError.notAllowed } - try await servicesManager.deliverRemoteCarbs(amountInGrams: grams, absorptionTime: nil, foodType: nil, startDate: date) + 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 diff --git a/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift index 7225999825..efe7cb7a4e 100644 --- a/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift +++ b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift @@ -145,7 +145,7 @@ extension SmartwatchManagerHost { func handleBolusRecommendationRequest(_ request: SmartwatchBolusRecommendationRequest, from manager: SmartwatchManager) async throws -> SmartwatchBolusRecommendation { let grams = request.grams let isCurrentWatch = deviceManager.smartwatchManager.map { ($0 as AnyObject) === (manager as AnyObject) } ?? false - logWatchRequest(manager, type: .receive, String(format: "Bolus recommendation request with %.0f g of carbs", grams)) + logWatchRequest(manager, type: .receive, String(format: "Bolus recommendation request with %.0f g of %@ carbs", grams, (request.absorption ?? .medium).rawValue)) let refusal: String? = if !isCurrentWatch { "the request isn't from the current watch" } else if !manager.effectiveCapabilities.contains(.remoteBolus) { @@ -169,12 +169,13 @@ extension SmartwatchManagerHost { logWatchRequest(manager, type: .error, String(format: "Bolus recommendation refused: %.0f g isn't a valid carb entry (maximum %.0f g)", grams, maximumGrams)) throw SmartwatchBolusError.invalidAmount } + let absorptionTime = Self.absorptionTime(request.absorption) let potentialCarbEntry = grams > 0 ? NewCarbEntry( syncIdentifier: UUID().uuidString, quantity: LoopQuantity(unit: .gram, doubleValue: grams), startDate: Date(), foodType: nil, - absorptionTime: LoopCoreConstants.defaultCarbAbsorptionTimes.medium + absorptionTime: absorptionTime ) : nil let recommendation: ManualBolusRecommendation? do { @@ -223,7 +224,7 @@ extension SmartwatchManagerHost { func handleCarbEntryRequest(_ request: SmartwatchCarbEntryRequest, from manager: SmartwatchManager) async throws -> Double { let grams = request.grams let isCurrentWatch = deviceManager.smartwatchManager.map { ($0 as AnyObject) === (manager as AnyObject) } ?? false - logWatchRequest(manager, type: .receive, String(format: "Carb entry request: %.0f g, confirmed at %@", grams, String(describing: request.requestedAt))) + logWatchRequest(manager, type: .receive, String(format: "Carb entry request: %.0f g, %@ absorption, confirmed at %@", grams, (request.absorption ?? .medium).rawValue, String(describing: request.requestedAt))) let refusal: String? = if !isCurrentWatch { "the request isn't from the current watch" } else if !manager.effectiveCapabilities.contains(.remoteCarbs) { @@ -248,7 +249,7 @@ extension SmartwatchManagerHost { // rate limited instead of adding the carbs a second time. UserDefaults.standard.lastWatchCarbEntry = Date() do { - try await deliverCarbs(grams, request.requestedAt) + try await deliverCarbs(grams, request.requestedAt, Self.absorptionTime(request.absorption)) } catch { UserDefaults.standard.lastWatchCarbEntry = previousEntry logWatchRequest(manager, type: .error, "Watch carb entry failed: \(error)") @@ -259,6 +260,16 @@ extension SmartwatchManagerHost { return grams } + /// Loop's absorption time for the watch's choice: fast, medium or slow as + /// in carb entry; medium when the watch didn't say (older watches). + static func absorptionTime(_ absorption: SmartwatchCarbAbsorption?) -> TimeInterval { + switch absorption { + case .fast: LoopCoreConstants.defaultCarbAbsorptionTimes.fast + case .slow: LoopCoreConstants.defaultCarbAbsorptionTimes.slow + case .medium, nil: LoopCoreConstants.defaultCarbAbsorptionTimes.medium + } + } + 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 index b2144f2acd..b53d91b7b2 100644 --- a/Loop/Managers/SmartwatchManagerHost.swift +++ b/Loop/Managers/SmartwatchManagerHost.swift @@ -30,7 +30,7 @@ final class SmartwatchManagerHost { 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) async throws -> Void + let deliverCarbs: (Double, Date, TimeInterval) async throws -> Void let startOverride: (String) async throws -> Void let cancelOverride: () async throws -> Void @@ -56,7 +56,7 @@ final class SmartwatchManagerHost { healthStore: HKHealthStore, alertMuter: AlertMuter, deliverBolus: @escaping (Double) async throws -> Void, - deliverCarbs: @escaping (Double, Date) async throws -> Void, + deliverCarbs: @escaping (Double, Date, TimeInterval) async throws -> Void, startOverride: @escaping (String) async throws -> Void, cancelOverride: @escaping () async throws -> Void ) { diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index f39a1ce058..993512e451 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -1009,12 +1009,12 @@ private struct WatchPermissionsView: View { Toggle(NSLocalizedString("Allow Presets from Watch", comment: "Toggle allowing a watch to start and end override presets"), isOn: $allowWatchOverrides) } - Section(footer: Text(String(format: NSLocalizedString("%1$@ adds the watch's carb entry like a remote carb entry, at most one every 15 minutes. In closed loop, %1$@ may dose for it.", comment: "Footer for Allow Carbs from Watch (1: app name)"), appName))) { + Section(footer: Text(String(format: NSLocalizedString("%1$@ adds the watch's carb entry like a remote carb entry, at most one every 5 minutes. In closed loop, %1$@ may dose for it.", comment: "Footer for Allow Carbs from Watch (1: app name)"), appName))) { Toggle(NSLocalizedString("Allow Carbs from Watch", comment: "Toggle allowing carb entries requested from a watch plugin"), isOn: $allowWatchCarbs) } // SmartwatchBolusPolicy checks every request. - Section(footer: Text(String(format: NSLocalizedString("%1$@ delivers a bolus requested on the watch only within your maximum bolus and at most one every 15 minutes, and never more than the current recommendation unless the watch marks the request as forced.", comment: "Footer for Allow Boluses from Watch (1: app name)"), appName))) { + Section(footer: Text(String(format: NSLocalizedString("%1$@ delivers a bolus requested on the watch only within your maximum bolus and at most one every 5 minutes, and never more than the current recommendation unless the watch marks the request as forced.", comment: "Footer for Allow Boluses from Watch (1: app name)"), appName))) { Toggle(NSLocalizedString("Allow Boluses from Watch", comment: "Toggle allowing boluses requested from a watch plugin"), isOn: $allowWatchBolus) } } From be08347065d89a436b74b33f06072111a49901f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge=20Berg?= Date: Thu, 5 Nov 2026 15:12:34 +0100 Subject: [PATCH 09/21] Watch plugins: end the mute of alerts from the watch --- Loop/Managers/SmartwatchManagerHost+RemoteActions.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift index efe7cb7a4e..ab613a251e 100644 --- a/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift +++ b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift @@ -211,6 +211,11 @@ extension SmartwatchManagerHost { logWatchRequest(manager, type: .error, String(format: "Mute request from the watch refused: expiredRequest (confirmed %.0f s ago)", Date().timeIntervalSince(request.requestedAt))) throw SmartwatchAlertMuteError.expiredRequest } + 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) From e539d3805a4d4664c7f57742cc9db30a9857705b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge=20Berg?= Date: Thu, 5 Nov 2026 15:16:38 +0100 Subject: [PATCH 10/21] Settings: say what a third-party smartwatch can do, above its permissions The watch permissions are under a Third-Party Smartwatch header in Settings. Their screen starts with what a watch other than Apple Watch can do, and the toggles are under Third-Party Smartwatch Permissions. The mute footer says that the watch can also end the mute. The string catalog follows. --- Loop/Localizable.xcstrings | 18 +++++++++++++++--- Loop/Views/SettingsView.swift | 12 +++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index aaa943f6b1..55a288777e 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -4503,6 +4503,9 @@ } } }, + "A watch set up in Settings → Devices, other than Apple Watch, works through a watch plugin. It shows what %1$@ sends it, such as your glucose and insulin on board. With the permissions below, it can also ask %1$@ to act: each is off until you turn it on, and %1$@ checks every request. Apple Watch uses %1$@'s own watch app and isn't affected by these settings." : { + "comment" : "Explanation at the top of the watch permissions screen (1: app name)" + }, "Above range (>180): %d%% · goal <25%%" : { "comment" : "TIR above-range summary" }, @@ -28750,7 +28753,7 @@ "Muted until" : { "comment" : "Label for when mute alert will end" }, - "Mutes all %1$@ sounds for as long as the watch asks, rounded up to one of the app's mute durations, the same as Mute All App Sounds, including critical alerts." : { + "Mutes all %1$@ sounds for as long as the watch asks, rounded up to one of the app's mute durations, the same as Mute All App Sounds, including critical alerts. The watch can also end the mute." : { "comment" : "Footer for Allow Mute from Watch (1: app name)" }, "Name" : { @@ -39820,6 +39823,12 @@ }, "These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you." : { + }, + "Third-Party Smartwatch" : { + "comment" : "The title of the section in settings for what a watch other than Apple Watch may do" + }, + "Third-Party Smartwatch Permissions" : { + "comment" : "Header above the watch permission toggles" }, "This device keeps %d days of glucose. Increase the cache duration and rebuild to review the full period." : { "comment" : "Notice when the local cache limits the statistics history" @@ -42849,6 +42858,9 @@ "Watch Permissions" : { "comment" : "Watch permissions button text\nWatch permissions screen title" }, + "What a watch other than Apple Watch may ask Loop to do" : { + "comment" : "Watch permissions descriptive text" + }, "What are examples of Critical Alerts and Time Sensitive Notifications?" : { }, @@ -42920,8 +42932,8 @@ "What if Critical Alerts aren't available in %1$@?" : { "comment" : "Title text for critical alerts unavailable fallback (1: app name)" }, - "What the watch may ask Loop to do" : { - "comment" : "Watch permissions descriptive text" + "What Non-Apple Watches Can Do" : { + "comment" : "Header of the explanation at the top of the watch permissions screen" }, "When current or forecasted glucose is below the glucose safety limit, Loop will not recommend a bolus, and will always recommend a temporary basal rate of 0 units per hour." : { "comment" : "Explanation of glucose safety limit", diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 993512e451..59c2ccac22 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -524,7 +524,7 @@ extension SettingsView { /// Opens what the watch may ask Loop to do. private var watchPermissionsSection: some View { - Section { + Section(header: SectionHeader(label: NSLocalizedString("Third-Party Smartwatch", comment: "The title of the section in settings for what a watch other than Apple Watch may do"))) { NavigationLink(destination: WatchPermissionsView()) { LargeButton( action: {}, @@ -534,7 +534,7 @@ extension SettingsView { .aspectRatio(contentMode: .fit) .frame(width: 30), label: NSLocalizedString("Watch Permissions", comment: "Watch permissions button text"), - descriptiveText: NSLocalizedString("What the watch may ask Loop to do", comment: "Watch permissions descriptive text") + descriptiveText: NSLocalizedString("What a watch other than Apple Watch may ask Loop to do", comment: "Watch permissions descriptive text") ) .accessibilityIdentifier("settingsViewWatchPermissions") } @@ -1001,7 +1001,13 @@ private struct WatchPermissionsView: View { var body: some View { List { - Section(footer: Text(String(format: NSLocalizedString("Mutes all %1$@ sounds for as long as the watch asks, rounded up to one of the app's mute durations, the same as Mute All App Sounds, including critical alerts.", comment: "Footer for Allow Mute from Watch (1: app name)"), appName))) { + Section(header: SectionHeader(label: NSLocalizedString("What Non-Apple Watches Can Do", comment: "Header of the explanation at the top of the watch permissions screen"))) { + Text(String(format: NSLocalizedString("A watch set up in Settings → Devices, other than Apple Watch, works through a watch plugin. It shows what %1$@ sends it, such as your glucose and insulin on board. With the permissions below, it can also ask %1$@ to act: each is off until you turn it on, and %1$@ checks every request. Apple Watch uses %1$@'s own watch app and isn't affected by these settings.", comment: "Explanation at the top of the watch permissions screen (1: app name)"), appName)) + .font(.footnote) + .foregroundColor(.secondary) + } + + Section(header: SectionHeader(label: NSLocalizedString("Third-Party Smartwatch Permissions", comment: "Header above the watch permission toggles")), footer: Text(String(format: NSLocalizedString("Mutes all %1$@ sounds for as long as the watch asks, rounded up to one of the app's mute durations, the same as Mute All App Sounds, including critical alerts. The watch can also end the mute.", comment: "Footer for Allow Mute from Watch (1: app name)"), appName))) { Toggle(NSLocalizedString("Allow Mute from Watch", comment: "Toggle allowing a watch to mute the app's alerts"), isOn: $allowWatchMute) } From 289287278d51cbfba1543c266b7b39f6537d7f8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge=20Berg?= Date: Thu, 5 Nov 2026 23:28:23 +0100 Subject: [PATCH 11/21] Watch Permissions: shorter texts, and the bolus limits in full The intro and the mute footer say only what the user needs. The bolus footer lists every limit (the recommendation and maximum bolus, one every 5 minutes, 3 maximum boluses a day, glucose from the last 15 minutes) and no longer mentions forced requests, which no watch app offers. --- Loop/Localizable.xcstrings | 6 +++--- Loop/Views/SettingsView.swift | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 55a288777e..7540b91855 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -1984,7 +1984,7 @@ "%1$@ cannot turn your iPhone's volume up for you. Make sure your volume is turned up — especially overnight — or you may not hear urgent alarms. The Mute All App Sounds feature also silences these alarms while it is active." : { "comment" : "Critical alert audio fallback callout message (1: app name)" }, - "%1$@ delivers a bolus requested on the watch only within your maximum bolus and at most one every 5 minutes, and never more than the current recommendation unless the watch marks the request as forced." : { + "Up to %1$@'s recommendation and your maximum bolus. At most one every 5 minutes, and 3 maximum boluses a day. Needs glucose from the last 15 minutes." : { "comment" : "Footer for Allow Boluses from Watch (1: app name)" }, "%1$@ has been active for more than 24 hours. Make sure you still want it enabled, or turn it off in the app." : { @@ -4503,7 +4503,7 @@ } } }, - "A watch set up in Settings → Devices, other than Apple Watch, works through a watch plugin. It shows what %1$@ sends it, such as your glucose and insulin on board. With the permissions below, it can also ask %1$@ to act: each is off until you turn it on, and %1$@ checks every request. Apple Watch uses %1$@'s own watch app and isn't affected by these settings." : { + "Watches added in Settings → Devices (not Apple Watch) show your glucose and, if you allow it below, can ask %1$@ to act. %1$@ checks every request." : { "comment" : "Explanation at the top of the watch permissions screen (1: app name)" }, "Above range (>180): %d%% · goal <25%%" : { @@ -28753,7 +28753,7 @@ "Muted until" : { "comment" : "Label for when mute alert will end" }, - "Mutes all %1$@ sounds for as long as the watch asks, rounded up to one of the app's mute durations, the same as Mute All App Sounds, including critical alerts. The watch can also end the mute." : { + "Like Mute All App Sounds, critical alerts included, for the time picked on the watch. The watch can also end the mute." : { "comment" : "Footer for Allow Mute from Watch (1: app name)" }, "Name" : { diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 59c2ccac22..58e4b1f16d 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -1002,12 +1002,12 @@ private struct WatchPermissionsView: View { var body: some View { List { Section(header: SectionHeader(label: NSLocalizedString("What Non-Apple Watches Can Do", comment: "Header of the explanation at the top of the watch permissions screen"))) { - Text(String(format: NSLocalizedString("A watch set up in Settings → Devices, other than Apple Watch, works through a watch plugin. It shows what %1$@ sends it, such as your glucose and insulin on board. With the permissions below, it can also ask %1$@ to act: each is off until you turn it on, and %1$@ checks every request. Apple Watch uses %1$@'s own watch app and isn't affected by these settings.", comment: "Explanation at the top of the watch permissions screen (1: app name)"), appName)) + Text(String(format: NSLocalizedString("Watches added in Settings → Devices (not Apple Watch) show your glucose and, if you allow it below, can ask %1$@ to act. %1$@ checks every request.", comment: "Explanation at the top of the watch permissions screen (1: app name)"), appName)) .font(.footnote) .foregroundColor(.secondary) } - Section(header: SectionHeader(label: NSLocalizedString("Third-Party Smartwatch Permissions", comment: "Header above the watch permission toggles")), footer: Text(String(format: NSLocalizedString("Mutes all %1$@ sounds for as long as the watch asks, rounded up to one of the app's mute durations, the same as Mute All App Sounds, including critical alerts. The watch can also end the mute.", comment: "Footer for Allow Mute from Watch (1: app name)"), appName))) { + Section(header: SectionHeader(label: NSLocalizedString("Third-Party Smartwatch Permissions", comment: "Header above the watch permission toggles")), footer: Text(String(format: NSLocalizedString("Like Mute All App Sounds, critical alerts included, for the time picked on the watch. The watch can also end the mute.", comment: "Footer for Allow Mute from Watch (1: app name)"), appName))) { Toggle(NSLocalizedString("Allow Mute from Watch", comment: "Toggle allowing a watch to mute the app's alerts"), isOn: $allowWatchMute) } @@ -1020,7 +1020,7 @@ private struct WatchPermissionsView: View { } // SmartwatchBolusPolicy checks every request. - Section(footer: Text(String(format: NSLocalizedString("%1$@ delivers a bolus requested on the watch only within your maximum bolus and at most one every 5 minutes, and never more than the current recommendation unless the watch marks the request as forced.", comment: "Footer for Allow Boluses from Watch (1: app name)"), appName))) { + Section(footer: Text(String(format: NSLocalizedString("Up to %1$@'s recommendation and your maximum bolus. At most one every 5 minutes, and 3 maximum boluses a day. Needs glucose from the last 15 minutes.", comment: "Footer for Allow Boluses from Watch (1: app name)"), appName))) { Toggle(NSLocalizedString("Allow Boluses from Watch", comment: "Toggle allowing boluses requested from a watch plugin"), isOn: $allowWatchBolus) } } From 16eabfc0f60f7ec63506ffb96c779750f5ca4457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge=20Berg?= Date: Thu, 5 Nov 2026 23:51:31 +0100 Subject: [PATCH 12/21] Watch Permissions: the daily bolus limit is 3 times the maximum bolus in 24 hours '3 maximum boluses a day' read as three boluses. The limit is a total: watch boluses in any 24 hours add up to at most 3 times the maximum bolus. --- Loop/Localizable.xcstrings | 2 +- Loop/Views/SettingsView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 3e0677ecf8..c7e2725cbb 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -1984,7 +1984,7 @@ "%1$@ cannot turn your iPhone's volume up for you. Make sure your volume is turned up — especially overnight — or you may not hear urgent alarms. The Mute All App Sounds feature also silences these alarms while it is active." : { "comment" : "Critical alert audio fallback callout message (1: app name)" }, - "Up to %1$@'s recommendation and your maximum bolus. At most one every 5 minutes, and 3 maximum boluses a day. Needs glucose from the last 15 minutes." : { + "Up to %1$@'s recommendation and your maximum bolus. At most one every 5 minutes, and no more than 3 times your maximum bolus in 24 hours. Needs glucose from the last 15 minutes." : { "comment" : "Footer for Allow Boluses from Watch (1: app name)" }, "%1$@ has been active for more than 24 hours. Make sure you still want it enabled, or turn it off in the app." : { diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 58e4b1f16d..3f88e72b81 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -1020,7 +1020,7 @@ private struct WatchPermissionsView: View { } // SmartwatchBolusPolicy checks every request. - Section(footer: Text(String(format: NSLocalizedString("Up to %1$@'s recommendation and your maximum bolus. At most one every 5 minutes, and 3 maximum boluses a day. Needs glucose from the last 15 minutes.", comment: "Footer for Allow Boluses from Watch (1: app name)"), appName))) { + Section(footer: Text(String(format: NSLocalizedString("Up to %1$@'s recommendation and your maximum bolus. At most one every 5 minutes, and no more than 3 times your maximum bolus in 24 hours. Needs glucose from the last 15 minutes.", comment: "Footer for Allow Boluses from Watch (1: app name)"), appName))) { Toggle(NSLocalizedString("Allow Boluses from Watch", comment: "Toggle allowing boluses requested from a watch plugin"), isOn: $allowWatchBolus) } } From 818c02f8a51e36a28397292a26bb11c78e6ed5af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge=20Berg?= Date: Fri, 6 Nov 2026 22:20:52 +0100 Subject: [PATCH 13/21] Watch boluses: a failed one still counts for the time between watch boluses A watch bolus was recorded for SmartwatchBolusPolicy (5 minutes between watch boluses, the daily total) only once the pump reported it delivered. A failure can be uncertain (the pump lost contact mid-bolus), and the watch could then send the same bolus again at once. The bolus is now recorded as a 0 U attempt before the pump call, which counts for the time between boluses, and gets its units, for the daily total, only once delivered. --- .../SmartwatchManagerHost+RemoteActions.swift | 20 ++++++++++++++++- .../Managers/SmartwatchManagerHostTests.swift | 22 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift index ab613a251e..895cc1e112 100644 --- a/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift +++ b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift @@ -124,6 +124,12 @@ extension SmartwatchManagerHost { bolusContextIssuer.beginDelivery(of: request) defer { bolusContextIssuer.endDelivery() } logWatchRequest(manager, type: .delegate, String(format: "Delivering watch bolus of %.3f U", units)) + // Counted for the time between watch boluses from here, whether or not the + // pump reports it delivered: a failure can be uncertain (the pump lost + // contact mid-bolus), and a resend at once could give it twice. Only a + // delivered bolus counts toward the daily total. + let attemptedAt = Date() + UserDefaults.standard.watchBolusDeliveries = Self.bolusAttempted(at: attemptedAt, in: UserDefaults.standard.watchBolusDeliveries) do { try await deliverBolus(units) } catch { @@ -131,7 +137,7 @@ extension SmartwatchManagerHost { refresh() throw SmartwatchBolusError.deliveryFailed(String(describing: error)) } - UserDefaults.standard.watchBolusDeliveries += [SmartwatchBolusPolicy.Delivery(date: Date(), units: units)] + UserDefaults.standard.watchBolusDeliveries = Self.bolusDelivered(units, attemptedAt: attemptedAt, in: UserDefaults.standard.watchBolusDeliveries) logWatchRequest(manager, type: .delegateResponse, String(format: "Watch bolus of %.3f U delivered", units)) refresh() return units @@ -267,6 +273,18 @@ extension SmartwatchManagerHost { /// Loop's absorption time for the watch's choice: fast, medium or slow as /// in carb entry; medium when the watch didn't say (older watches). + /// The watch boluses `SmartwatchBolusPolicy` counts, with an attempt at `date`: + /// 0 U, so it counts for the time between boluses but not the daily total. + static func bolusAttempted(at date: Date, in deliveries: [SmartwatchBolusPolicy.Delivery]) -> [SmartwatchBolusPolicy.Delivery] { + deliveries + [SmartwatchBolusPolicy.Delivery(date: date, units: 0)] + } + + /// The attempt at `attemptedAt` delivered `units`: they count toward the daily total too. + static func bolusDelivered(_ units: Double, attemptedAt: Date, in deliveries: [SmartwatchBolusPolicy.Delivery]) -> [SmartwatchBolusPolicy.Delivery] { + deliveries.filter { $0 != SmartwatchBolusPolicy.Delivery(date: attemptedAt, units: 0) } + + [SmartwatchBolusPolicy.Delivery(date: attemptedAt, units: units)] + } + static func absorptionTime(_ absorption: SmartwatchCarbAbsorption?) -> TimeInterval { switch absorption { case .fast: LoopCoreConstants.defaultCarbAbsorptionTimes.fast diff --git a/LoopTests/Managers/SmartwatchManagerHostTests.swift b/LoopTests/Managers/SmartwatchManagerHostTests.swift index be22ea618a..13910fe12f 100644 --- a/LoopTests/Managers/SmartwatchManagerHostTests.swift +++ b/LoopTests/Managers/SmartwatchManagerHostTests.swift @@ -19,6 +19,28 @@ final class SmartwatchManagerHostTests: XCTestCase { GlucoseAlarmKind.allCases.map(\.testAlertIdentifier) } + /// A watch bolus that reached the pump counts for the time between boluses, + /// delivered or not: a failure can be uncertain. Only a delivered one counts + /// toward the daily total. + func testABolusThatReachedThePumpCountsForTheTimeBetweenBoluses() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let token = UUID() + let context = SmartwatchBolusContext(token: token, issuedAt: now.addingTimeInterval(-60), recommendedBolus: 2, maximumBolus: 5, supportedBolusVolumes: []) + func evaluate(_ deliveries: [SmartwatchBolusPolicy.Delivery], at date: Date) -> Result { + SmartwatchBolusPolicy(isAllowed: true, currentContext: context, latestGlucoseDate: date.addingTimeInterval(-60), recentDeliveries: deliveries, roundToSupportedVolume: { $0 }) + .evaluate(SmartwatchBolusRequest(units: 1, contextToken: token, requestedAt: date), now: date) + } + + let failed = SmartwatchManagerHost.bolusAttempted(at: now, in: []) + XCTAssertEqual(evaluate(failed, at: now.addingTimeInterval(60)), .failure(.rateLimited)) + XCTAssertEqual(evaluate(failed, at: now.addingTimeInterval(5 * 60 + 1)), .success(1)) + XCTAssertEqual(failed.reduce(0) { $0 + $1.units }, 0) + + let delivered = SmartwatchManagerHost.bolusDelivered(1.5, attemptedAt: now, in: failed) + XCTAssertEqual(delivered, [SmartwatchBolusPolicy.Delivery(date: now, units: 1.5)]) + XCTAssertEqual(evaluate(delivered, at: now.addingTimeInterval(60)), .failure(.rateLimited)) + } + /// 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) From 3ac6514524a85a2365a254946eed006ac34d425f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge=20Berg?= Date: Mon, 9 Nov 2026 22:50:42 +0100 Subject: [PATCH 14/21] Watch host: a failed pump bolus isn't reported as delivered; context issued only for the sent snapshot; no preset start with a duplicate name - DeviceDataManager.enactBolus swallowed pump errors, so the watch was told a bolus the pump failed (or couldn't confirm) was delivered, and its units counted. The watch path now passes throwingPumpErrors and gets the error; remote commands and the app keep their behaviour. - A slower snapshot task that was dropped still issued a bolus context, replacing the one the watch was sent. The context is issued after the generation check. - Loop starts a preset by name; with two presets of the same name a watch request could start the other one, so it is refused. --- Loop/Managers/DeviceDataManager.swift | 13 +++++++++ Loop/Managers/LoopAppManager.swift | 7 +++-- Loop/Managers/ServicesManager.swift | 10 +++++-- .../SmartwatchManagerHost+RemoteActions.swift | 11 +++++-- Loop/Managers/SmartwatchManagerHost.swift | 24 ++++++++++----- .../Managers/DeviceDataManagerTests.swift | 29 +++++++++++++++++++ LoopTests/Mocks/MockPumpManager.swift | 3 +- 7 files changed, 81 insertions(+), 16 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index f7a9b5e80f..ba2698abca 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -865,6 +865,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) } @@ -882,6 +889,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 { @@ -890,6 +900,9 @@ extension DeviceDataManager { log.error("Error sending bolus failure notification %{public}@", String(describing: error)) } } + if throwingPumpErrors { + throw error + } } } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 9bf9e0105b..50af424b73 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -436,11 +436,12 @@ class LoopAppManager: NSObject { 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) + 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 @@ -1140,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/SmartwatchManagerHost+RemoteActions.swift b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift index 895cc1e112..7541e399a4 100644 --- a/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift +++ b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift @@ -53,6 +53,12 @@ extension SmartwatchManagerHost { 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: @@ -70,15 +76,14 @@ extension SmartwatchManagerHost { // MARK: - Watch bolus - func currentBolusContext(latestGlucoseDate: Date?) async -> SmartwatchBolusContext { + func bolusContextInputs(latestGlucoseDate: Date?) async -> SmartwatchBolusContextIssuer.Inputs { let recommendation = try? await loopDataManager.recommendManualBolus() - let inputs = SmartwatchBolusContextIssuer.Inputs( + return SmartwatchBolusContextIssuer.Inputs( recommendedBolus: recommendation?.amount, maximumBolus: settingsManager.loopSettings.maximumBolus, supportedBolusVolumes: deviceManager.pumpManager?.supportedBolusVolumes ?? [], latestGlucoseDate: latestGlucoseDate ) - return bolusContextIssuer.context(for: inputs, now: Date()) } /// Delivers a bolus the plugin authenticated, if `SmartwatchBolusPolicy` allows it. diff --git a/Loop/Managers/SmartwatchManagerHost.swift b/Loop/Managers/SmartwatchManagerHost.swift index b53d91b7b2..d8928e0223 100644 --- a/Loop/Managers/SmartwatchManagerHost.swift +++ b/Loop/Managers/SmartwatchManagerHost.swift @@ -116,19 +116,27 @@ final class SmartwatchManagerHost { logUndeclaredRequest(smartwatchManager) Task { - let snapshot: SmartwatchStatusSnapshot + var snapshot: SmartwatchStatusSnapshot + let bolusInputs: SmartwatchBolusContextIssuer.Inputs? do { - snapshot = try await makeSnapshot(capabilities: capabilities) + (snapshot, bolusInputs) = try await makeSnapshot(capabilities: capabilities) } catch { log.error("Unable to read glucose for the watch: %{public}@", String(describing: error)) return } guard generation == updateGeneration, - let current = deviceManager.smartwatchManager, (current as AnyObject) === (smartwatchManager as AnyObject), - snapshot != lastSentSnapshot + let current = deviceManager.smartwatchManager, (current as AnyObject) === (smartwatchManager as AnyObject) else { return } + // Issued only for the snapshot that goes out, so a slower, dropped one + // can't replace the context the watch was sent. + if let bolusInputs { + snapshot.bolusContext = bolusContextIssuer.context(for: bolusInputs, now: Date()) + } + guard snapshot != lastSentSnapshot else { + return + } lastSentSnapshot = snapshot current.watchDataDidUpdate(snapshot) } @@ -150,7 +158,8 @@ final class SmartwatchManagerHost { return deviceManager.displayGlucosePreference.unit } - private func makeSnapshot(capabilities: SmartwatchCapabilities) async throws -> SmartwatchStatusSnapshot { + /// The snapshot without its bolus context, and what that context is to be issued from. + private func makeSnapshot(capabilities: SmartwatchCapabilities) async throws -> (SmartwatchStatusSnapshot, SmartwatchBolusContextIssuer.Inputs?) { let historyStart = Date().addingTimeInterval(-SmartwatchStatusSnapshot.historyInterval) let samples = try await glucoseStore.getGlucoseSamples(start: historyStart) @@ -174,8 +183,9 @@ final class SmartwatchManagerHost { if capabilities.contains(.therapy) { snapshot.therapy = currentTherapyContext() } + var bolusInputs: SmartwatchBolusContextIssuer.Inputs? if capabilities.contains(.remoteBolus) { - snapshot.bolusContext = await currentBolusContext(latestGlucoseDate: samples.last?.startDate) + bolusInputs = await bolusContextInputs(latestGlucoseDate: samples.last?.startDate) } if capabilities.contains(.remoteOverride) { snapshot.overridePresets = currentOverridePresets() @@ -187,7 +197,7 @@ final class SmartwatchManagerHost { if capabilities.contains(.remoteBolus) { snapshot.bolusIncrement = SmartwatchStatusSnapshot.bolusIncrement(of: deviceManager.pumpManager?.supportedBolusVolumes ?? []) } - return snapshot + return (snapshot, bolusInputs) } /// What the algorithm is working with now, in mg/dL, with any active preset diff --git a/LoopTests/Managers/DeviceDataManagerTests.swift b/LoopTests/Managers/DeviceDataManagerTests.swift index bf12ef5ede..9a2e3ba0b8 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, 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) { From b83a3dad4ff9b968537117a1ff87abb4e677c6a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge?= Date: Tue, 10 Nov 2026 10:02:01 +0100 Subject: [PATCH 15/21] Watch host: the watch plugin's device manager delegate; the plugin reads Loop's state For LoopKit's reworked SmartwatchManager (#684): a DeviceManager whose delegate is a DeviceManagerDelegate, and which reads from its host. - SmartwatchManagerHost is the watch manager's delegate. DeviceDataManager hands the manager to it when a watch is set or restored, or once the host exists, and registers the manager with the alert manager for alert acknowledgements and sounds, as for the pump and CGM. A removed or replaced manager is unregistered and loses its delegate. - The plugin's log lines go to the device log. Its alerts go to Loop's alert manager as the plugin's own alerts, whatever manager they name, and at most time-sensitive: a watch plugin can't raise a critical alert. Lookups of stored alerts work as for pump and CGM managers. - Reads instead of the snapshot: glucose since a date, display unit, the loop state from one loop run (eventual glucose is the end of the forecast; no sensitivity ratio), scheduled and temp basal, ISF and correction range now, override presets and the running override, the alert mute end, maximum bolus and carb entry, the pump's bolus volumes, and Loop's default absorption times. - hostDataDidChange replaces sending snapshots: .glucose when the glucose store saves samples, before the loop runs; .loop on loop updates and completions; .presets and .settings on preference changes; .settings for the display unit and the pump; .alertMute; .all right after the delegate is set. Changes in the same run loop turn go out as one call. - Loop's glucose alerts are forwarded as Alert with their glucose alert type and retracted by Alert.Identifier, with the same mute behaviour and host notes. No capability gating any more. - A removed manager can't save its state or delete the current one. --- Loop/Managers/DeviceDataManager.swift | 125 +----- ...SmartwatchManagerHost+AlertForwarder.swift | 37 +- Loop/Managers/SmartwatchManagerHost.swift | 420 +++++++++++------- 3 files changed, 282 insertions(+), 300 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index ba2698abca..9e15656a95 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -172,7 +172,7 @@ final class DeviceDataManager { 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 { - UserDefaults.standard.resetWatchPermissions() + tearDownWatch(oldValue) } setupWatch() NotificationCenter.default.post(name: .SmartwatchManagerChanged, object: self, userInfo: nil) @@ -259,8 +259,14 @@ final class DeviceDataManager { private weak var displayGlucoseUnitBroadcaster: DisplayGlucoseUnitBroadcaster? - /// Sends Loop's state to `smartwatchManager`. - weak var smartwatchManagerHost: SmartwatchManagerHost? + /// `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, @@ -765,8 +771,22 @@ private extension DeviceDataManager { } func setupWatch() { - smartwatchManager?.smartwatchManagerDelegate = self + 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() { @@ -1092,102 +1112,6 @@ extension DeviceDataManager: CGMManagerDelegate { } } -// MARK: - SmartwatchManagerDelegate -extension DeviceDataManager: SmartwatchManagerDelegate { - nonisolated - func smartwatchManagerDidUpdateState(_ manager: SmartwatchManager) { - Task { @MainActor in - rawSmartwatchManager = manager.rawValue - } - } - - nonisolated - func smartwatchManagerWantsDeletion(_ manager: SmartwatchManager) async { - await withCheckedContinuation { continuation in - DispatchQueue.main.async { - self.log.default("Watch manager with identifier '%{public}@' wants deletion", manager.pluginIdentifier) - self.smartwatchManager = nil - continuation.resume() - } - } - } - - nonisolated - func smartwatchManagerRequestsRefresh(_ manager: SmartwatchManager) { - Task { @MainActor in - // Requested capabilities may have changed, which shows or hides Watch Permissions. - NotificationCenter.default.post(name: .SmartwatchManagerCapabilitiesChanged, object: self, userInfo: nil) - smartwatchManagerHost?.refresh() - } - } - - nonisolated - func smartwatchManager(_ manager: SmartwatchManager, didLogEvent event: SmartwatchDeviceLogEntry) { - Task { @MainActor in - deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: event.deviceIdentifier, type: DeviceLogEntryType(event.type), message: event.message, completion: nil) - } - } - - /// Shows the plugin's alert about the watch as Loop's own alert from this - /// plugin. A plugin can't raise a critical alert: at most time-sensitive. - nonisolated - func smartwatchManager(_ manager: SmartwatchManager, issueDeviceAlert alert: SmartwatchDeviceAlert) { - let loopAlert = Alert(alert, from: manager, acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Alert acknowledgment OK button")) - Task { @MainActor in - deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: nil, type: .error, message: "Watch plugin alert: \(alert.title): \(alert.body)", completion: nil) - await alertManager?.issueAlert(loopAlert) - } - } - - nonisolated - func smartwatchManager(_ manager: SmartwatchManager, retractDeviceAlert identifier: SmartwatchAlertIdentifier) { - let loopIdentifier = Alert.Identifier(identifier, from: manager) - Task { @MainActor in - await alertManager?.retractAlert(identifier: loopIdentifier) - } - } - - nonisolated - func smartwatchManager(_ manager: SmartwatchManager, requestBolus request: SmartwatchBolusRequest) async throws -> Double { - guard let host = await smartwatchManagerHost else { - throw SmartwatchBolusError.notAllowed - } - return try await host.handleBolusRequest(request, from: manager) - } - - nonisolated - func smartwatchManager(_ manager: SmartwatchManager, recommendBolusFor request: SmartwatchBolusRecommendationRequest) async throws -> SmartwatchBolusRecommendation { - guard let host = await smartwatchManagerHost else { - throw SmartwatchBolusError.notAllowed - } - return try await host.handleBolusRecommendationRequest(request, from: manager) - } - - nonisolated - func smartwatchManager(_ manager: SmartwatchManager, requestAlertMute request: SmartwatchAlertMuteRequest) async throws -> Date { - guard let host = await smartwatchManagerHost else { - throw SmartwatchAlertMuteError.notAllowed - } - return try await host.handleAlertMuteRequest(request, from: manager) - } - - nonisolated - func smartwatchManager(_ manager: SmartwatchManager, requestCarbEntry request: SmartwatchCarbEntryRequest) async throws -> Double { - guard let host = await smartwatchManagerHost else { - throw SmartwatchCarbEntryError.notAllowed - } - return try await host.handleCarbEntryRequest(request, from: manager) - } - - nonisolated - func smartwatchManager(_ manager: SmartwatchManager, requestOverride request: SmartwatchOverrideRequest) async throws { - guard let host = await smartwatchManagerHost else { - throw SmartwatchPresetError.notAllowed - } - try await host.handleOverrideRequest(request, from: manager) - } -} - // MARK: - SmartwatchManagerOnboardingDelegate extension DeviceDataManager: SmartwatchManagerOnboardingDelegate { func smartwatchManagerOnboarding(didCreateSmartwatchManager smartwatchManager: SmartwatchManagerUI) { @@ -1529,7 +1453,6 @@ 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 SmartwatchManagerCapabilitiesChanged = Notification.Name(rawValue: "com.loopKit.notification.SmartwatchManagerCapabilitiesChanged") static let PumpEventsAdded = Notification.Name(rawValue: "com.loopKit.notification.PumpEventsAdded") } diff --git a/Loop/Managers/SmartwatchManagerHost+AlertForwarder.swift b/Loop/Managers/SmartwatchManagerHost+AlertForwarder.swift index d08e2248ff..2416f39c69 100644 --- a/Loop/Managers/SmartwatchManagerHost+AlertForwarder.swift +++ b/Loop/Managers/SmartwatchManagerHost+AlertForwarder.swift @@ -14,12 +14,9 @@ extension SmartwatchManagerHost: AlertForwarder { /// 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. Only plugins - /// that declare and request `.alerts` are fed. + /// 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, - smartwatchManager.effectiveCapabilities.contains(.alerts) - else { + guard let smartwatchManager = deviceManager.smartwatchManager else { return } let content = alert.foregroundContent ?? alert.backgroundContent @@ -44,16 +41,7 @@ extension SmartwatchManagerHost: AlertForwarder { logAlert(smartwatchManager, "\(content.title) not sent to the watch: Loop's alerts are muted") return } - smartwatchManager.watchAlertDidIssue( - SmartwatchAlert( - identifier: SmartwatchAlertIdentifier(alert.identifier.value), - level: alert.interruptionLevel.smartwatchAlertLevel, - title: content.title, - body: content.body, - issuedAt: Date(), - glucoseAlertType: Self.glucoseAlertType(of: alert.identifier) - ) - ) + 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() @@ -64,7 +52,7 @@ extension SmartwatchManagerHost: AlertForwarder { } } // Queued after the plugin's own "issued" line, which also hops to the main - // actor. Uses the same unit as the watch snapshots (HealthKit's stored one). + // actor. Uses the unit the watch reads (HealthKit's stored one). let firedAt = Date() Task { @MainActor in let unit = await self.preferredGlucoseUnit() @@ -128,25 +116,14 @@ extension SmartwatchManagerHost: AlertForwarder { return minutes % 60 == 0 ? "\(minutes / 60) h" : "\(minutes / 60) h \(minutes % 60) min" } - /// Retractions go to every plugin that declares `.alerts`, even one that has - /// stopped requesting it, so an alert it was given doesn't stay active there. + /// 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, - type(of: smartwatchManager).capabilities.contains(.alerts), identifier.managerIdentifier == GlucoseAlertManager.managerIdentifier else { return } - smartwatchManager.watchAlertDidRetract(SmartwatchAlertIdentifier(identifier.value)) - } -} - -private extension Alert.InterruptionLevel { - var smartwatchAlertLevel: SmartwatchAlertLevel { - switch self { - case .active: return .informational - case .timeSensitive: return .warning - case .critical: return .critical - } + smartwatchManager.watchAlertDidRetract(identifier) } } diff --git a/Loop/Managers/SmartwatchManagerHost.swift b/Loop/Managers/SmartwatchManagerHost.swift index d8928e0223..f5c70a6cff 100644 --- a/Loop/Managers/SmartwatchManagerHost.swift +++ b/Loop/Managers/SmartwatchManagerHost.swift @@ -5,21 +5,22 @@ // Copyright © 2026 LoopKit Authors. All rights reserved. // +import Combine import Foundation import HealthKit import LoopAlgorithm import LoopCore import LoopKit -/// Sends Loop's state to the watch plugin in Settings → Devices. +/// The watch plugin's host in Settings → Devices: its `SmartwatchManagerDelegate`. /// -/// A new glucose reading is sent as soon as the glucose store saves it, before -/// the loop runs; the snapshot is sent again when the loop's output changes. -/// Unchanged snapshots are not resent. +/// 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. /// -/// For plugins that declare `.remoteBolus`, it also issues the bolus context and -/// decides on bolus requests with `SmartwatchBolusPolicy`. Watch boluses stay off -/// unless the user allows them in Watch Permissions. +/// 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 @@ -36,12 +37,13 @@ final class SmartwatchManagerHost { let log = DiagnosticLog(category: "SmartwatchManagerHost") - private var lastSentSnapshot: SmartwatchStatusSnapshot? - /// Hands out the bolus context and tracks the watch bolus being delivered. - var bolusContextIssuer = SmartwatchBolusContextIssuer() - /// Incremented per update so a slower glucose fetch cannot overwrite a newer snapshot. - private var updateGeneration = 0 + /// Whether a watch bolus is being delivered, so a second one waits for its result. + var isDeliveringWatchBolus = false + /// 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 = [] /// 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? @@ -75,71 +77,73 @@ final class SmartwatchManagerHost { // 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?.update() } + MainActor.assumeIsolated { self?.signal(.glucose) } }, - NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { self?.update() } + 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?.update() } + MainActor.assumeIsolated { self?.signal(.settings) } }, NotificationCenter.default.addObserver(forName: .LoopCycleCompleted, object: nil, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { self?.update() } + MainActor.assumeIsolated { self?.signal(.loop) } }, - NotificationCenter.default.addObserver(forName: .SmartwatchManagerChanged, object: deviceManager, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { self?.refresh() } - }, - NotificationCenter.default.addObserver(forName: .WatchPermissionsChanged, object: nil, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { self?.update() } + // The pump's bolus volumes. + NotificationCenter.default.addObserver(forName: .PumpManagerChanged, object: deviceManager, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.signal(.settings) } }, ] - } - /// Sends the current snapshot even if unchanged, for a new watch manager or - /// one that reconnected. - func refresh() { - lastSentSnapshot = nil - loggedUndeclaredRequest = [] - update() + alertMuter.$configuration + .dropFirst() + .removeDuplicates() + .sink { [weak self] _ in self?.signal(.alertMute) } + .store(in: &cancellables) } - private func update() { + /// 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) - updateGeneration += 1 - let generation = updateGeneration - // Fill only what the watch asks for now (the user may have turned a display - // element off), within what the plugin declares. - let capabilities = smartwatchManager.effectiveCapabilities - logUndeclaredRequest(smartwatchManager) - - Task { - var snapshot: SmartwatchStatusSnapshot - let bolusInputs: SmartwatchBolusContextIssuer.Inputs? - do { - (snapshot, bolusInputs) = try await makeSnapshot(capabilities: capabilities) - } catch { - log.error("Unable to read glucose for the watch: %{public}@", String(describing: error)) - return - } - guard generation == updateGeneration, - let current = deviceManager.smartwatchManager, (current as AnyObject) === (smartwatchManager as AnyObject) - else { - return - } - // Issued only for the snapshot that goes out, so a slower, dropped one - // can't replace the context the watch was sent. - if let bolusInputs { - snapshot.bolusContext = bolusContextIssuer.context(for: bolusInputs, now: Date()) - } - guard snapshot != lastSentSnapshot else { - return - } - lastSentSnapshot = snapshot - current.watchDataDidUpdate(snapshot) + 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 @@ -147,10 +151,6 @@ final class SmartwatchManagerHost { /// 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. - private func displayUnit() async -> SmartwatchGlucoseUnit { - await preferredGlucoseUnit() == .millimolesPerLiter ? .millimolesPerLiter : .milligramsPerDeciliter - } - func preferredGlucoseUnit() async -> LoopUnit { if let hkUnit = await healthStore.cachedPreferredUnits(for: .bloodGlucose) { return LoopUnit(from: hkUnit) @@ -158,98 +158,25 @@ final class SmartwatchManagerHost { return deviceManager.displayGlucosePreference.unit } - /// The snapshot without its bolus context, and what that context is to be issued from. - private func makeSnapshot(capabilities: SmartwatchCapabilities) async throws -> (SmartwatchStatusSnapshot, SmartwatchBolusContextIssuer.Inputs?) { - let historyStart = Date().addingTimeInterval(-SmartwatchStatusSnapshot.historyInterval) - let samples = try await glucoseStore.getGlucoseSamples(start: historyStart) - - let readings = samples.map(SmartwatchGlucoseReading.init) - var snapshot = SmartwatchStatusSnapshot( - displayUnit: await displayUnit(), - glucose: readings.last, - glucoseHistory: capabilities.contains(.history) ? readings : [] - ) - if capabilities.contains(.prediction) { - snapshot.predictedGlucose = loopDataManager.predictedGlucose?.map { - SmartwatchPredictedGlucose(date: $0.startDate, milligramsPerDeciliter: $0.quantity.doubleValue(for: .milligramsPerDeciliter)) - } - } - if capabilities.contains(.loopStatus) { - snapshot.insulinOnBoard = loopDataManager.activeInsulin?.value - snapshot.carbsOnBoard = loopDataManager.activeCarbs?.quantity.doubleValue(for: .gram) - snapshot.lastLoopCompleted = loopDataManager.lastLoopCompleted - snapshot.isClosedLoop = settingsManager.loopSettings.dosingEnabled - } - if capabilities.contains(.therapy) { - snapshot.therapy = currentTherapyContext() - } - var bolusInputs: SmartwatchBolusContextIssuer.Inputs? - if capabilities.contains(.remoteBolus) { - bolusInputs = await bolusContextInputs(latestGlucoseDate: samples.last?.startDate) - } - if capabilities.contains(.remoteOverride) { - snapshot.overridePresets = currentOverridePresets() - } - snapshot.permittedActions = UserDefaults.standard.permittedWatchActions(within: capabilities) - if capabilities.contains(.remoteCarbs) { - snapshot.maximumCarbEntry = LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram) - } - if capabilities.contains(.remoteBolus) { - snapshot.bolusIncrement = SmartwatchStatusSnapshot.bolusIncrement(of: deviceManager.pumpManager?.supportedBolusVolumes ?? []) - } - return (snapshot, bolusInputs) - } - - /// What the algorithm is working with now, in mg/dL, with any active preset - /// applied. Loop has no sensitivity ratio (an oref/autosens value), so that - /// stays nil. - private func currentTherapyContext(now: Date = Date()) -> SmartwatchTherapyContext { - let presets = loopDataManager.temporaryPresetsManager - var tempBasalRate: Double? - if case .tempBasal(let dose) = deviceManager.pumpManager?.status.basalDeliveryState { - tempBasalRate = dose.unitsPerHour - } - let range = presets.effectiveCorrectionRangeSchedule()?.quantityRange(at: now) - let override = presets.scheduleOverride.flatMap { $0.isActive(at: now) ? $0 : nil } - let presetName: String? = switch override?.context { - case .preMeal: NSLocalizedString("Pre-Meal", comment: "Name of the pre-meal preset sent to a watch") - case .preset(let preset): preset.name - case .activity(let activity): activity.activityType.name - case .custom: NSLocalizedString("Custom Preset", comment: "Name of a custom preset sent to a watch") - case nil: nil - } - return SmartwatchTherapyContext( - tempBasalRate: tempBasalRate, - scheduledBasalRate: presets.basalRateScheduleApplyingOverrideHistory?.value(at: now), - insulinSensitivity: presets.insulinSensitivityScheduleApplyingOverrideHistory?.quantity(at: now).doubleValue(for: .milligramsPerDeciliter), - eventualGlucose: loopDataManager.predictedGlucose?.last?.quantity.doubleValue(for: .milligramsPerDeciliter), - correctionRange: range.map { $0.lowerBound.doubleValue(for: .milligramsPerDeciliter)...$0.upperBound.doubleValue(for: .milligramsPerDeciliter) }, - activePresetName: presetName, - activePresetEnd: override?.scheduledEndDate + /// 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 ) } - /// Loop's presets, with the running one marked. - private func currentOverridePresets(now: Date = Date()) -> [SmartwatchPreset] { - let running = loopDataManager.temporaryPresetsManager.scheduleOverride.flatMap { $0.isActive(at: now) ? $0 : nil } - let runningID: String? = if case .preset(let preset) = running?.context { preset.id } else { nil } - return settingsManager.settings.overridePresets.map { preset in - let duration: TimeInterval? = if case .finite(let interval) = preset.duration { interval } else { nil } - return SmartwatchPreset(id: preset.id, name: preset.name, isActive: preset.id == runningID, duration: duration) - } - } - - /// The undeclared capabilities a plugin last requested, so each set is logged once. - private var loggedUndeclaredRequest: SmartwatchCapabilities = [] - - /// A plugin can't obtain a capability it doesn't declare: hosts act only on - /// `effectiveCapabilities`. Log when it asks for more, once per distinct set. - private func logUndeclaredRequest(_ manager: SmartwatchManager) { - let undeclared = manager.undeclaredRequestedCapabilities - guard undeclared != loggedUndeclaredRequest else { return } - loggedUndeclaredRequest = undeclared - guard !undeclared.isEmpty else { return } - deviceManager.deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: nil, type: .error, message: "The watch plugin requested capabilities it doesn't declare (\(undeclared)); they are ignored", completion: nil) + 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. @@ -295,11 +222,11 @@ final class SmartwatchManagerHost { } } - /// Retracts what `watchAlertsToRetract` lists, on every update (each reading, - /// loop cycle and reconnect). 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. + /// 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()) { - guard type(of: smartwatchManager).capabilities.contains(.alerts) else { return } let alertManager = deviceManager.glucoseAlertManager let identifiers = Self.watchAlertsToRetract( configuration: alertManager.activeConfiguration(at: now), @@ -308,8 +235,7 @@ final class SmartwatchManagerHost { now: now ) for identifier in identifiers { - let alertIdentifier = Alert.Identifier(managerIdentifier: GlucoseAlertManager.managerIdentifier, alertIdentifier: identifier) - smartwatchManager.watchAlertDidRetract(SmartwatchAlertIdentifier(alertIdentifier.value)) + smartwatchManager.watchAlertDidRetract(Alert.Identifier(managerIdentifier: GlucoseAlertManager.managerIdentifier, alertIdentifier: identifier)) } } @@ -323,15 +249,171 @@ final class SmartwatchManagerHost { } } -extension SmartwatchGlucoseReading { - init(_ sample: StoredGlucoseSample) { - self.init( - date: sample.startDate, - milligramsPerDeciliter: sample.quantity.doubleValue(for: .milligramsPerDeciliter), - trend: sample.trend.flatMap { SmartwatchGlucoseTrend(rawValue: $0.rawValue) }, - trendRate: sample.trendRate?.doubleValue(for: .milligramsPerDeciliterPerMinute), - wasUserEntered: sample.wasUserEntered, - isDisplayOnly: sample.isDisplayOnly +// 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, + isClosedLoop: settingsManager.loopSettings.dosingEnabled, + 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()) + } + + 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) + } } From fb392a2a3e70a0e6dd65dae0fee9a6532692be08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge?= Date: Tue, 10 Nov 2026 10:02:01 +0100 Subject: [PATCH 16/21] Watch host: remote actions get Loop's own checks only Whether a watch may ask, how fresh and how frequent its requests must be, the bolus context and the recommendation cap are the plugin's now (SmartwatchRemoteActionPolicy in LoopKit). Loop checks what it checks for any request of the kind, and acts only on the current watch manager's requests. - deliverBolus: refused above Loop's maximum bolus (the amount asked for and the rounded one), rounded to a volume the pump supports, and refused with inProgress while a watch bolus is being delivered. Delivered through the remote bolus path with throwingPumpErrors, so it throws unless the pump confirmed delivery, uncertain delivery included. Returns the delivered units. - addCarbEntry: refused above Loop's maximum carb entry, then stored through the remote carbs path, dated as given. - recommendBolusForCarbs: Loop's recommendation with the potential carb entry, rounded to a supported volume. - enactOverride and muteAlertsFor: as before, without the request window and permission checks. - No more watch bolus history, last carb entry or bolus context issuer in the host. Every request and its outcome still goes to the device log. --- .../SmartwatchManagerHost+RemoteActions.swift | 289 +++++++----------- Loop/Managers/SmartwatchManagerHost.swift | 3 + 2 files changed, 109 insertions(+), 183 deletions(-) diff --git a/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift index 7541e399a4..7b4aafdc7e 100644 --- a/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift +++ b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift @@ -11,43 +11,30 @@ import LoopCore import LoopKit /// Requests from the watch plugin: presets, boluses, alert mute and carbs. Each -/// is checked against the plugin's capabilities, the user's Watch Permissions -/// and LoopKit's remote action rules, and goes to the device log. +/// 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. Only while Allow Presets from Watch is on, and for - /// requests confirmed in the last 30 seconds. - func handleOverrideRequest(_ request: SmartwatchOverrideRequest, from manager: SmartwatchManager) async throws { - let isCurrentWatch = deviceManager.smartwatchManager.map { ($0 as AnyObject) === (manager as AnyObject) } ?? false + /// as remote overrides. + func smartwatchManager(_ manager: SmartwatchManager, enactOverride action: SmartwatchPresetAction) async throws { let preset: TemporaryPreset? - switch request.action { + 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), confirmed at \(request.requestedAt)") + 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, confirmed at \(request.requestedAt)") + logWatchRequest(manager, type: .receive, "Preset request: end the running preset") } - let refusal: String? = if !isCurrentWatch { - "the request isn't from the current watch" - } else if !manager.effectiveCapabilities.contains(.remoteOverride) { - "this watch can't start presets (remoteOverride not declared or not requested)" - } else if !UserDefaults.standard.allowWatchOverrides { - "Allow Presets from Watch is off" - } else { - nil - } - if let refusal { - logWatchRequest(manager, type: .error, "Preset request rejected: notAllowed (\(refusal))") + guard isCurrentWatch(manager) else { + logWatchRequest(manager, type: .error, "Preset request rejected: notAllowed (the request isn't from the current watch)") throw SmartwatchPresetError.notAllowed } - let age = Date().timeIntervalSince(request.requestedAt) - guard manager.remoteActionLimits.requestWindow.contains(request.requestedAt, now: Date()) else { - logWatchRequest(manager, type: .error, String(format: "Preset request rejected: expiredRequest (confirmed %.0f s ago)", age)) - throw SmartwatchPresetError.expiredRequest - } do { - switch request.action { + switch action { case .activate: guard let preset else { logWatchRequest(manager, type: .error, "Preset request rejected: unknownPreset") @@ -71,116 +58,92 @@ extension SmartwatchManagerHost { logWatchRequest(manager, type: .error, "Preset request from the watch failed: \(error)") throw SmartwatchPresetError.failed(String(describing: error)) } - refresh() + signal(.presets) } // MARK: - Watch bolus - func bolusContextInputs(latestGlucoseDate: Date?) async -> SmartwatchBolusContextIssuer.Inputs { - let recommendation = try? await loopDataManager.recommendManualBolus() - return SmartwatchBolusContextIssuer.Inputs( - recommendedBolus: recommendation?.amount, - maximumBolus: settingsManager.loopSettings.maximumBolus, - supportedBolusVolumes: deviceManager.pumpManager?.supportedBolusVolumes ?? [], - latestGlucoseDate: latestGlucoseDate - ) - } - - /// Delivers a bolus the plugin authenticated, if `SmartwatchBolusPolicy` allows it. - /// Every request and its outcome goes to the device log. - func handleBolusRequest(_ request: SmartwatchBolusRequest, from manager: SmartwatchManager) async throws -> Double { - let isCurrentWatch = deviceManager.smartwatchManager.map { ($0 as AnyObject) === (manager as AnyObject) } ?? false - logWatchRequest(manager, type: .receive, String(format: "%@ request: %.3f U, confirmed at %@, context %@", request.isForced ? "Forced bolus (recommendation not checked)" : "Bolus", request.units, String(describing: request.requestedAt), request.contextToken.uuidString)) - if bolusContextIssuer.isDelivering(request) { - logWatchRequest(manager, type: .error, "Bolus request answered with inProgress: the same bolus is still being delivered") + /// 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 } - - let policy = SmartwatchBolusPolicy( - limits: manager.remoteActionLimits.bolusPolicyLimits, - isAllowed: UserDefaults.standard.allowWatchBolus && isCurrentWatch && manager.effectiveCapabilities.contains(.remoteBolus), - currentContext: bolusContextIssuer.current, - latestGlucoseDate: glucoseStore.latestGlucose?.startDate, - recentDeliveries: UserDefaults.standard.watchBolusDeliveries, - roundToSupportedVolume: { [deviceManager] units in - deviceManager.pumpManager?.roundToSupportedBolusVolume(units: units) ?? units - } - ) - let units: Double - switch policy.evaluate(request, now: Date()) { - case .failure(let error): - let units = { (value: Double?) in value.map { String(format: "%.2f U", $0) } ?? "none" } - let notAllowedReason: String = if !isCurrentWatch { - "the request isn't from the current watch; " - } else if !manager.effectiveCapabilities.contains(.remoteBolus) { - "this watch can't request boluses (remoteBolus not declared or not requested); " - } else if !UserDefaults.standard.allowWatchBolus { - "Allow Boluses from Watch is off; " - } else { - "" - } - let glucoseAge = glucoseStore.latestGlucose.map { String(format: "newest glucose %.0f min ago; ", Date().timeIntervalSince($0.startDate) / 60) } ?? "no glucose; " - logWatchRequest(manager, type: .error, "Bolus request rejected: \(error) (\(error == .notAllowed ? notAllowedReason : "")\(error == .noRecentGlucose ? glucoseAge : "")requested \(units(request.units)), Loop recommends \(units(bolusContextIssuer.current?.recommendedBolus ?? 0)), maximum bolus \(units(bolusContextIssuer.current?.maximumBolus)))") - throw error - case .success(let allowed): - units = allowed + 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 } - bolusContextIssuer.beginDelivery(of: request) - defer { bolusContextIssuer.endDelivery() } - logWatchRequest(manager, type: .delegate, String(format: "Delivering watch bolus of %.3f U", units)) - // Counted for the time between watch boluses from here, whether or not the - // pump reports it delivered: a failure can be uncertain (the pump lost - // contact mid-bolus), and a resend at once could give it twice. Only a - // delivered bolus counts toward the daily total. - let attemptedAt = Date() - UserDefaults.standard.watchBolusDeliveries = Self.bolusAttempted(at: attemptedAt, in: UserDefaults.standard.watchBolusDeliveries) + isDeliveringWatchBolus = true + defer { isDeliveringWatchBolus = false } + logWatchRequest(manager, type: .delegate, String(format: "Delivering watch bolus of %.3f U (%@)", rounded, maximumText)) do { - try await deliverBolus(units) + try await deliverBolus(rounded) } catch { logWatchRequest(manager, type: .error, "Watch bolus delivery failed: \(error)") - refresh() + signal(.loop) throw SmartwatchBolusError.deliveryFailed(String(describing: error)) } - UserDefaults.standard.watchBolusDeliveries = Self.bolusDelivered(units, attemptedAt: attemptedAt, in: UserDefaults.standard.watchBolusDeliveries) - logWatchRequest(manager, type: .delegateResponse, String(format: "Watch bolus of %.3f U delivered", units)) - refresh() - return units + 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 (medium - /// absorption, dated now). Only while the watch may request boluses and - /// glucose is recent; the bolus itself is checked again after the carbs. - func handleBolusRecommendationRequest(_ request: SmartwatchBolusRecommendationRequest, from manager: SmartwatchManager) async throws -> SmartwatchBolusRecommendation { - let grams = request.grams - let isCurrentWatch = deviceManager.smartwatchManager.map { ($0 as AnyObject) === (manager as AnyObject) } ?? false - logWatchRequest(manager, type: .receive, String(format: "Bolus recommendation request with %.0f g of %@ carbs", grams, (request.absorption ?? .medium).rawValue)) - let refusal: String? = if !isCurrentWatch { - "the request isn't from the current watch" - } else if !manager.effectiveCapabilities.contains(.remoteBolus) { - "this watch can't request boluses (remoteBolus not declared or not requested)" - } else if !UserDefaults.standard.allowWatchBolus { - "Allow Boluses from Watch is off" - } else { - nil - } - if let refusal { - logWatchRequest(manager, type: .error, "Bolus recommendation refused: \(refusal)") + /// 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 maximumGlucoseAge = manager.remoteActionLimits.bolusPolicyLimits.maximumGlucoseAge - guard let latestGlucoseDate = glucoseStore.latestGlucose?.startDate, Date().timeIntervalSince(latestGlucoseDate) <= maximumGlucoseAge else { - logWatchRequest(manager, type: .error, "Bolus recommendation refused: no glucose in the last \(Int(maximumGlucoseAge / 60)) minutes") - throw SmartwatchBolusError.noRecentGlucose - } let maximumGrams = LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram) - guard grams.isFinite, grams >= 0, grams <= maximumGrams else { - logWatchRequest(manager, type: .error, String(format: "Bolus recommendation refused: %.0f g isn't a valid carb entry (maximum %.0f g)", grams, maximumGrams)) + 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 absorptionTime = Self.absorptionTime(request.absorption) let potentialCarbEntry = grams > 0 ? NewCarbEntry( syncIdentifier: UUID().uuidString, quantity: LoopQuantity(unit: .gram, doubleValue: grams), @@ -195,33 +158,27 @@ extension SmartwatchManagerHost { logWatchRequest(manager, type: .error, "Bolus recommendation failed: \(error)") throw SmartwatchBolusError.refusedByHost(String(describing: error)) } - // Rounded as the bolus check rounds the recommendation it caps at. let recommended = max(0, recommendation?.amount ?? 0) let units = deviceManager.pumpManager?.roundToSupportedBolusVolume(units: recommended) ?? recommended let maximumBolus = settingsManager.loopSettings.maximumBolus + if isContextRead { + guard units != lastContextRecommendation else { + return units + } + lastContextRecommendation = 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 SmartwatchBolusRecommendation(units: units, grams: grams, maximumBolus: maximumBolus, issuedAt: Date()) + 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. Only - /// while Allow Mute from Watch is on. - func handleAlertMuteRequest(_ request: SmartwatchAlertMuteRequest, from manager: SmartwatchManager) throws -> Date { - let duration = request.duration - let isCurrentWatch = deviceManager.smartwatchManager.map { ($0 as AnyObject) === (manager as AnyObject) } ?? false - guard isCurrentWatch, manager.effectiveCapabilities.contains(.remoteMute) else { - logWatchRequest(manager, type: .error, "Mute request from the watch refused: this watch can't mute alerts (remoteMute not declared or not requested)") + /// 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 UserDefaults.standard.allowWatchMute else { - logWatchRequest(manager, type: .error, "Mute request from the watch refused: Allow Mute from Watch is off") - throw SmartwatchAlertMuteError.notAllowed - } - guard manager.remoteActionLimits.requestWindow.contains(request.requestedAt, now: Date()) else { - logWatchRequest(manager, type: .error, String(format: "Mute request from the watch refused: expiredRequest (confirmed %.0f s ago)", Date().timeIntervalSince(request.requestedAt))) - throw SmartwatchAlertMuteError.expiredRequest - } guard duration > 0 else { alertMuter.unmuteAlerts() logWatchRequest(manager, type: .receive, "Alerts unmuted from the watch") @@ -235,69 +192,35 @@ extension SmartwatchManagerHost { return end } - /// Adds a carb entry the watch asked for, if LoopKit's `SmartwatchCarbEntryPolicy` - /// allows it, through the same path as remote carbs (notification, upload). - func handleCarbEntryRequest(_ request: SmartwatchCarbEntryRequest, from manager: SmartwatchManager) async throws -> Double { - let grams = request.grams - let isCurrentWatch = deviceManager.smartwatchManager.map { ($0 as AnyObject) === (manager as AnyObject) } ?? false - logWatchRequest(manager, type: .receive, String(format: "Carb entry request: %.0f g, %@ absorption, confirmed at %@", grams, (request.absorption ?? .medium).rawValue, String(describing: request.requestedAt))) - let refusal: String? = if !isCurrentWatch { - "the request isn't from the current watch" - } else if !manager.effectiveCapabilities.contains(.remoteCarbs) { - "this watch can't add carbs (remoteCarbs not declared or not requested)" - } else if !UserDefaults.standard.allowWatchCarbs { - "Allow Carbs from Watch is off" - } else { - nil + /// 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 previousEntry = UserDefaults.standard.lastWatchCarbEntry - let policy = SmartwatchCarbEntryPolicy( - limits: manager.remoteActionLimits.carbEntryPolicyLimits, - isAllowed: refusal == nil, - maximumGrams: LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram), - lastEntry: previousEntry - ) - if case .failure(let error) = policy.evaluate(request, now: Date()) { - logWatchRequest(manager, type: .error, "Carb entry request rejected: \(error)\(refusal.map { " (\($0))" } ?? "")") - throw error + 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 } - // Take the slot before storing, so a resend that arrives meanwhile is - // rate limited instead of adding the carbs a second time. - UserDefaults.standard.lastWatchCarbEntry = Date() do { - try await deliverCarbs(grams, request.requestedAt, Self.absorptionTime(request.absorption)) + try await deliverCarbs(grams, date, absorptionTime) } catch { - UserDefaults.standard.lastWatchCarbEntry = previousEntry 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)) - refresh() + signal(.loop) return grams } - /// Loop's absorption time for the watch's choice: fast, medium or slow as - /// in carb entry; medium when the watch didn't say (older watches). - /// The watch boluses `SmartwatchBolusPolicy` counts, with an attempt at `date`: - /// 0 U, so it counts for the time between boluses but not the daily total. - static func bolusAttempted(at date: Date, in deliveries: [SmartwatchBolusPolicy.Delivery]) -> [SmartwatchBolusPolicy.Delivery] { - deliveries + [SmartwatchBolusPolicy.Delivery(date: date, units: 0)] - } - - /// The attempt at `attemptedAt` delivered `units`: they count toward the daily total too. - static func bolusDelivered(_ units: Double, attemptedAt: Date, in deliveries: [SmartwatchBolusPolicy.Delivery]) -> [SmartwatchBolusPolicy.Delivery] { - deliveries.filter { $0 != SmartwatchBolusPolicy.Delivery(date: attemptedAt, units: 0) } - + [SmartwatchBolusPolicy.Delivery(date: attemptedAt, units: units)] - } - - static func absorptionTime(_ absorption: SmartwatchCarbAbsorption?) -> TimeInterval { - switch absorption { - case .fast: LoopCoreConstants.defaultCarbAbsorptionTimes.fast - case .slow: LoopCoreConstants.defaultCarbAbsorptionTimes.slow - case .medium, nil: LoopCoreConstants.defaultCarbAbsorptionTimes.medium - } - } - 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 index f5c70a6cff..d0233e7fed 100644 --- a/Loop/Managers/SmartwatchManagerHost.swift +++ b/Loop/Managers/SmartwatchManagerHost.swift @@ -39,6 +39,9 @@ final class 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 = [] From fa16d558ead4f0563ada79c69485d76a79149820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge?= Date: Tue, 10 Nov 2026 10:02:01 +0100 Subject: [PATCH 17/21] Settings: remove Watch Permissions; the watch plugin has its own What a watch may ask Loop to do is now a setting in the watch plugin (LoopKit #684), so Loop's Watch Permissions row and screen, their toggles and strings, and their reset when the watch is removed go. Not released, so no migration. --- Loop.xcodeproj/project.pbxproj | 8 -- Loop/Localizable.xcstrings | 48 +--------- Loop/Managers/SmartwatchPermissions.swift | 90 ------------------- Loop/View Models/SettingsViewModel.swift | 1 - Loop/Views/SettingsView.swift | 75 ---------------- .../Managers/SmartwatchPermissionsTests.swift | 31 ------- 6 files changed, 2 insertions(+), 251 deletions(-) delete mode 100644 Loop/Managers/SmartwatchPermissions.swift delete mode 100644 LoopTests/Managers/SmartwatchPermissionsTests.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index f18dc5e14f..98011a1743 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -237,7 +237,6 @@ 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 */; }; - F89DD32969109D74C5AF9BCA /* SmartwatchPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3FF5A18F9D81237E2855A7D /* SmartwatchPermissions.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 */; }; @@ -600,7 +599,6 @@ 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 */; }; - 893CADEC0F22143E8D0DE0DF /* SmartwatchPermissionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2D6390D50BED3BB63A16AEB /* SmartwatchPermissionsTests.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 */; }; @@ -1049,7 +1047,6 @@ 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 = ""; }; - D3FF5A18F9D81237E2855A7D /* SmartwatchPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartwatchPermissions.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 = ""; }; @@ -1471,7 +1468,6 @@ 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 = ""; }; - F2D6390D50BED3BB63A16AEB /* SmartwatchPermissionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartwatchPermissionsTests.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 = ""; }; @@ -1838,7 +1834,6 @@ C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */, A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */, C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */, - F2D6390D50BED3BB63A16AEB /* SmartwatchPermissionsTests.swift */, 13E4F9D180117EFDC2A339E1 /* SmartwatchManagerHostTests.swift */, C16B983F26B4898800256B05 /* DoseEnactorTests.swift */, E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */, @@ -2306,7 +2301,6 @@ A35441020C3D0037E9AEB302 /* SmartwatchManagerHost.swift */, 8772495BA2855FC99DDC3EB0 /* SmartwatchManagerHost+AlertForwarder.swift */, 3EE5E1D046A80EAC16C5514F /* SmartwatchManagerHost+RemoteActions.swift */, - D3FF5A18F9D81237E2855A7D /* SmartwatchPermissions.swift */, 3621E1DB280412962D20BE6C /* SmartwatchManager.swift */, C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */, A977A2F324ACFECF0059C207 /* CriticalEventLogExportManager.swift */, @@ -3845,7 +3839,6 @@ 0AB9F65B073904D4F9E5FCB9 /* SmartwatchManagerHost.swift in Sources */, 1ADD1EF77E2083AC7690BD85 /* SmartwatchManagerHost+AlertForwarder.swift in Sources */, A018C4AF5E040B0DAC379735 /* SmartwatchManagerHost+RemoteActions.swift in Sources */, - F89DD32969109D74C5AF9BCA /* SmartwatchPermissions.swift in Sources */, D23CD09C2E6905F436987257 /* SmartwatchManager.swift in Sources */, C16B983E26B4893300256B05 /* DoseEnactor.swift in Sources */, 3ED319962EB65A5C00820BCF /* LiveActivityManagementViewModel.swift in Sources */, @@ -4251,7 +4244,6 @@ 1DA7A84224476EAD008257F0 /* AlertManagerTests.swift in Sources */, A91E4C2324F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift in Sources */, C188599B2AF15E1B0010F21F /* DeviceDataManagerTests.swift in Sources */, - 893CADEC0F22143E8D0DE0DF /* SmartwatchPermissionsTests.swift in Sources */, 22A2B3D7A696722C213DDFCD /* SmartwatchManagerHostTests.swift in Sources */, C18859A42AF165330010F21F /* MockCGMManager.swift in Sources */, E9C58A7324DB4A2700487A17 /* LoopDataManagerTests.swift in Sources */, diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index c7e2725cbb..b0c2e65697 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -1939,9 +1939,6 @@ } } }, - "%1$@ adds the watch's carb entry like a remote carb entry, at most one every 5 minutes. In closed loop, %1$@ may dose for it." : { - "comment" : "Footer for Allow Carbs from Watch (1: app name)" - }, "%1$@ also reads insulin data from other apps in Apple Health. Apple Health does not report whether an app may read data, so read access can't be shown here." : { "comment" : "Health access reading explanation (1: app name)" }, @@ -1984,9 +1981,6 @@ "%1$@ cannot turn your iPhone's volume up for you. Make sure your volume is turned up — especially overnight — or you may not hear urgent alarms. The Mute All App Sounds feature also silences these alarms while it is active." : { "comment" : "Critical alert audio fallback callout message (1: app name)" }, - "Up to %1$@'s recommendation and your maximum bolus. At most one every 5 minutes, and no more than 3 times your maximum bolus in 24 hours. Needs glucose from the last 15 minutes." : { - "comment" : "Footer for Allow Boluses from Watch (1: app name)" - }, "%1$@ has been active for more than 24 hours. Make sure you still want it enabled, or turn it off in the app." : { "comment" : "Active preset reminder alert background body. (1: preset name)" }, @@ -2245,9 +2239,6 @@ } } }, - "%1$@ starts the preset picked on the watch, for the preset's own duration, or ends the running one, like a remote override." : { - "comment" : "Footer for Allow Presets from Watch (1: app name)" - }, "%1$@ Still Active" : { "comment" : "The format title for the preset still active alert. (1: preset name)" }, @@ -4503,9 +4494,6 @@ } } }, - "Watches added in Settings → Devices (not Apple Watch) show your glucose and, if you allow it below, can ask %1$@ to act. %1$@ checks every request." : { - "comment" : "Explanation at the top of the watch permissions screen (1: app name)" - }, "Above range (>180): %d%% · goal <25%%" : { "comment" : "TIR above-range summary" }, @@ -6907,18 +6895,6 @@ }, "All Presets" : { - }, - "Allow Boluses from Watch" : { - "comment" : "Toggle allowing boluses requested from a watch plugin" - }, - "Allow Carbs from Watch" : { - "comment" : "Toggle allowing carb entries requested from a watch plugin" - }, - "Allow Mute from Watch" : { - "comment" : "Toggle allowing a watch to mute the app's alerts" - }, - "Allow Presets from Watch" : { - "comment" : "Toggle allowing a watch to start and end override presets" }, "Allowed" : { "comment" : "Health sharing status: authorized" @@ -14768,7 +14744,8 @@ } }, "Custom Preset" : { - "comment" : "Name of a custom preset sent to a watch", + "comment" : "The title of the cell indicating a generic custom preset is enabled", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -27160,9 +27137,6 @@ "Muted until" : { "comment" : "Label for when mute alert will end" }, - "Like Mute All App Sounds, critical alerts included, for the time picked on the watch. The watch can also end the mute." : { - "comment" : "Footer for Allow Mute from Watch (1: app name)" - }, "Name" : { "comment" : "Label for name row on add favorite food screen\nPreset sorting option description for sorting by name", "localizations" : { @@ -29779,9 +29753,6 @@ }, "Power lifting" : { - }, - "Pre-Meal" : { - "comment" : "Name of the pre-meal preset sent to a watch" }, "Pre-meal Preset" : { "comment" : "Status row title for premeal override enabled" @@ -37447,12 +37418,6 @@ }, "These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you." : { - }, - "Third-Party Smartwatch" : { - "comment" : "The title of the section in settings for what a watch other than Apple Watch may do" - }, - "Third-Party Smartwatch Permissions" : { - "comment" : "Header above the watch permission toggles" }, "This device keeps %d days of glucose. Increase the cache duration and rebuild to review the full period." : { "comment" : "Notice when the local cache limits the statistics history" @@ -40384,12 +40349,6 @@ "Watch" : { "comment" : "Descriptive text for a watch plugin in Settings → Devices" }, - "Watch Permissions" : { - "comment" : "Watch permissions button text\nWatch permissions screen title" - }, - "What a watch other than Apple Watch may ask Loop to do" : { - "comment" : "Watch permissions descriptive text" - }, "What are examples of Critical Alerts and Time Sensitive Notifications?" : { }, @@ -40461,9 +40420,6 @@ "What if Critical Alerts aren't available in %1$@?" : { "comment" : "Title text for critical alerts unavailable fallback (1: app name)" }, - "What Non-Apple Watches Can Do" : { - "comment" : "Header of the explanation at the top of the watch permissions screen" - }, "When current or forecasted glucose is below the glucose safety limit, Loop will not recommend a bolus, and will always recommend a temporary basal rate of 0 units per hour." : { "comment" : "Explanation of glucose safety limit", "localizations" : { diff --git a/Loop/Managers/SmartwatchPermissions.swift b/Loop/Managers/SmartwatchPermissions.swift deleted file mode 100644 index f77eca9117..0000000000 --- a/Loop/Managers/SmartwatchPermissions.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// SmartwatchPermissions.swift -// Loop -// -// Copyright © 2026 LoopKit Authors. All rights reserved. -// - -import Foundation -import LoopKit - -/// What the user allowed a watch plugin to do, and the history the rate limits -/// need. The bolus checks themselves are LoopKit's `SmartwatchBolusPolicy`. -extension Notification.Name { - /// A Watch Permissions toggle changed, so the snapshot's `permittedActions` did. - static let WatchPermissionsChanged = Notification.Name("com.loopkit.Loop.WatchPermissionsChanged") -} - -extension UserDefaults { - /// Whether boluses requested from a watch plugin may be delivered. Off by default. - static let allowWatchBolusKey = "com.loopkit.Loop.allowWatchBolus" - private static let watchBolusDeliveriesKey = "com.loopkit.Loop.watchBolusDeliveries" - - var allowWatchBolus: Bool { - get { bool(forKey: Self.allowWatchBolusKey) } - set { set(newValue, forKey: Self.allowWatchBolusKey) } - } - - /// Whether a watch plugin with `.remoteCarbs` may add carb entries. Off by default. - static let allowWatchCarbsKey = "com.loopkit.Loop.allowWatchCarbs" - private static let lastWatchCarbEntryKey = "com.loopkit.Loop.lastWatchCarbEntry" - - var allowWatchCarbs: Bool { - get { bool(forKey: Self.allowWatchCarbsKey) } - set { set(newValue, forKey: Self.allowWatchCarbsKey) } - } - - /// When the last carb entry from the watch was added, for its rate limit. - var lastWatchCarbEntry: Date? { - get { object(forKey: Self.lastWatchCarbEntryKey) as? Date } - set { set(newValue, forKey: Self.lastWatchCarbEntryKey) } - } - - /// Whether a watch plugin with `.remoteMute` may mute Loop's alerts. Off by default. - static let allowWatchMuteKey = "com.loopkit.Loop.allowWatchMute" - - var allowWatchMute: Bool { - get { bool(forKey: Self.allowWatchMuteKey) } - set { set(newValue, forKey: Self.allowWatchMuteKey) } - } - - /// Whether a watch plugin with `.remoteOverride` may start and end override presets. Off by default. - static let allowWatchOverridesKey = "com.loopkit.Loop.allowWatchOverrides" - - var allowWatchOverrides: Bool { - get { bool(forKey: Self.allowWatchOverridesKey) } - set { set(newValue, forKey: Self.allowWatchOverridesKey) } - } - - /// The remote actions among `capabilities` whose toggle is on, for the - /// snapshot's `permittedActions`. - func permittedWatchActions(within capabilities: SmartwatchCapabilities) -> SmartwatchCapabilities { - var permitted: SmartwatchCapabilities = [] - if allowWatchBolus { permitted.insert(.remoteBolus) } - if allowWatchCarbs { permitted.insert(.remoteCarbs) } - if allowWatchMute { permitted.insert(.remoteMute) } - if allowWatchOverrides { permitted.insert(.remoteOverride) } - return permitted.intersection(capabilities) - } - - /// Turns off every Watch Permissions toggle. Called when the watch plugin is - /// removed or replaced, so a new plugin never inherits what the user granted - /// another one. The rate-limit history is kept. - func resetWatchPermissions() { - allowWatchBolus = false - allowWatchCarbs = false - allowWatchMute = false - allowWatchOverrides = false - } - - /// Watch boluses delivered in the last 24 hours, kept across launches for the rate limits. - var watchBolusDeliveries: [SmartwatchBolusPolicy.Delivery] { - get { - data(forKey: Self.watchBolusDeliveriesKey).flatMap { try? JSONDecoder().decode([SmartwatchBolusPolicy.Delivery].self, from: $0) } ?? [] - } - set { - let dayStart = Date().addingTimeInterval(-24 * 60 * 60) - set(try? JSONEncoder().encode(newValue.filter { $0.date > dayStart }), forKey: Self.watchBolusDeliveriesKey) - } - } -} diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 9988ad8f16..3432d083f8 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -187,7 +187,6 @@ class SettingsViewModel { self.deviceManager = deviceManager NotificationCenter.default.publisher(for: .SmartwatchManagerChanged) - .merge(with: NotificationCenter.default.publisher(for: .SmartwatchManagerCapabilitiesChanged)) .receive(on: RunLoop.main) .sink { [weak self] _ in self?.smartwatchManagerRevision += 1 } .store(in: &cancellables) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 3f88e72b81..68a664bd24 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -91,9 +91,6 @@ struct SettingsView: View { } presetsSection deviceSettingsSection - if offersWatchPermissions { - watchPermissionsSection - } healthAccessSection if FeatureFlags.allowExperimentalFeatures { favoriteFoodsSection @@ -511,36 +508,6 @@ extension SettingsView { } } - /// Watch Permissions appear only when the watch plugin offers a remote action - /// (`.remoteMute`, `.remoteBolus`, `.remoteCarbs`, `.remoteOverride`). No plugin does yet, so the - /// screen stays hidden; it is kept compiled for when one does. - private var offersWatchPermissions: Bool { - let _ = viewModel.smartwatchManagerRevision - guard let manager = viewModel.deviceManager?.smartwatchManager else { - return false - } - return !manager.effectiveCapabilities.isDisjoint(with: [.remoteMute, .remoteBolus, .remoteCarbs, .remoteOverride]) - } - - /// Opens what the watch may ask Loop to do. - private var watchPermissionsSection: some View { - Section(header: SectionHeader(label: NSLocalizedString("Third-Party Smartwatch", comment: "The title of the section in settings for what a watch other than Apple Watch may do"))) { - NavigationLink(destination: WatchPermissionsView()) { - LargeButton( - action: {}, - includeArrow: false, - imageView: Image(systemName: "hand.raised.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 30), - label: NSLocalizedString("Watch Permissions", comment: "Watch permissions button text"), - descriptiveText: NSLocalizedString("What a watch other than Apple Watch may ask Loop to do", comment: "Watch permissions descriptive text") - ) - .accessibilityIdentifier("settingsViewWatchPermissions") - } - } - } - @ViewBuilder private var pumpSection: some View { if viewModel.pumpManagerSettingsViewModel.isSetUp() { @@ -989,45 +956,3 @@ struct DisplaySettingsView: View { } } } - -/// What the watch may ask Loop to do. Each request is refused unless its -/// toggle is on, and the watch plugin must declare the matching capability. -private struct WatchPermissionsView: View { - @Environment(\.appName) private var appName - @AppStorage(UserDefaults.allowWatchMuteKey) private var allowWatchMute = false - @AppStorage(UserDefaults.allowWatchCarbsKey) private var allowWatchCarbs = false - @AppStorage(UserDefaults.allowWatchBolusKey) private var allowWatchBolus = false - @AppStorage(UserDefaults.allowWatchOverridesKey) private var allowWatchOverrides = false - - var body: some View { - List { - Section(header: SectionHeader(label: NSLocalizedString("What Non-Apple Watches Can Do", comment: "Header of the explanation at the top of the watch permissions screen"))) { - Text(String(format: NSLocalizedString("Watches added in Settings → Devices (not Apple Watch) show your glucose and, if you allow it below, can ask %1$@ to act. %1$@ checks every request.", comment: "Explanation at the top of the watch permissions screen (1: app name)"), appName)) - .font(.footnote) - .foregroundColor(.secondary) - } - - Section(header: SectionHeader(label: NSLocalizedString("Third-Party Smartwatch Permissions", comment: "Header above the watch permission toggles")), footer: Text(String(format: NSLocalizedString("Like Mute All App Sounds, critical alerts included, for the time picked on the watch. The watch can also end the mute.", comment: "Footer for Allow Mute from Watch (1: app name)"), appName))) { - Toggle(NSLocalizedString("Allow Mute from Watch", comment: "Toggle allowing a watch to mute the app's alerts"), isOn: $allowWatchMute) - } - - Section(footer: Text(String(format: NSLocalizedString("%1$@ starts the preset picked on the watch, for the preset's own duration, or ends the running one, like a remote override.", comment: "Footer for Allow Presets from Watch (1: app name)"), appName))) { - Toggle(NSLocalizedString("Allow Presets from Watch", comment: "Toggle allowing a watch to start and end override presets"), isOn: $allowWatchOverrides) - } - - Section(footer: Text(String(format: NSLocalizedString("%1$@ adds the watch's carb entry like a remote carb entry, at most one every 5 minutes. In closed loop, %1$@ may dose for it.", comment: "Footer for Allow Carbs from Watch (1: app name)"), appName))) { - Toggle(NSLocalizedString("Allow Carbs from Watch", comment: "Toggle allowing carb entries requested from a watch plugin"), isOn: $allowWatchCarbs) - } - - // SmartwatchBolusPolicy checks every request. - Section(footer: Text(String(format: NSLocalizedString("Up to %1$@'s recommendation and your maximum bolus. At most one every 5 minutes, and no more than 3 times your maximum bolus in 24 hours. Needs glucose from the last 15 minutes.", comment: "Footer for Allow Boluses from Watch (1: app name)"), appName))) { - Toggle(NSLocalizedString("Allow Boluses from Watch", comment: "Toggle allowing boluses requested from a watch plugin"), isOn: $allowWatchBolus) - } - } - .insetGroupedListStyle() - .navigationTitle(NSLocalizedString("Watch Permissions", comment: "Watch permissions screen title")) - .onChange(of: [allowWatchMute, allowWatchOverrides, allowWatchCarbs, allowWatchBolus]) { - NotificationCenter.default.post(name: .WatchPermissionsChanged, object: nil) - } - } -} diff --git a/LoopTests/Managers/SmartwatchPermissionsTests.swift b/LoopTests/Managers/SmartwatchPermissionsTests.swift deleted file mode 100644 index b7bef34dee..0000000000 --- a/LoopTests/Managers/SmartwatchPermissionsTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// SmartwatchPermissionsTests.swift -// LoopTests -// -// Copyright © 2026 LoopKit Authors. All rights reserved. -// - -import XCTest -@testable import Loop - -final class SmartwatchPermissionsTests: XCTestCase { - func testResettingWatchPermissionsTurnsEveryToggleOffAndKeepsTheRateLimitHistory() throws { - let suiteName = "SmartwatchPermissionsTests.\(UUID())" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - defaults.allowWatchBolus = true - defaults.allowWatchCarbs = true - defaults.allowWatchMute = true - defaults.allowWatchOverrides = true - let lastCarbEntry = Date() - defaults.lastWatchCarbEntry = lastCarbEntry - - defaults.resetWatchPermissions() - - XCTAssertFalse(defaults.allowWatchBolus) - XCTAssertFalse(defaults.allowWatchCarbs) - XCTAssertFalse(defaults.allowWatchMute) - XCTAssertFalse(defaults.allowWatchOverrides) - XCTAssertEqual(defaults.lastWatchCarbEntry, lastCarbEntry) - } -} From 9f9369e0b96d6fea3ba2ae78fa1b2839ef951be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge?= Date: Tue, 10 Nov 2026 10:02:01 +0100 Subject: [PATCH 18/21] Tests: a watch plugin's alert is its own and at most time-sensitive The watch bolus history test goes with the history, which the plugin keeps now. --- .../Managers/SmartwatchManagerHostTests.swift | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/LoopTests/Managers/SmartwatchManagerHostTests.swift b/LoopTests/Managers/SmartwatchManagerHostTests.swift index 13910fe12f..b5bda3f3ee 100644 --- a/LoopTests/Managers/SmartwatchManagerHostTests.swift +++ b/LoopTests/Managers/SmartwatchManagerHostTests.swift @@ -19,26 +19,33 @@ final class SmartwatchManagerHostTests: XCTestCase { GlucoseAlarmKind.allCases.map(\.testAlertIdentifier) } - /// A watch bolus that reached the pump counts for the time between boluses, - /// delivered or not: a failure can be uncertain. Only a delivered one counts - /// toward the daily total. - func testABolusThatReachedThePumpCountsForTheTimeBetweenBoluses() { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let token = UUID() - let context = SmartwatchBolusContext(token: token, issuedAt: now.addingTimeInterval(-60), recommendedBolus: 2, maximumBolus: 5, supportedBolusVolumes: []) - func evaluate(_ deliveries: [SmartwatchBolusPolicy.Delivery], at date: Date) -> Result { - SmartwatchBolusPolicy(isAllowed: true, currentContext: context, latestGlucoseDate: date.addingTimeInterval(-60), recentDeliveries: deliveries, roundToSupportedVolume: { $0 }) - .evaluate(SmartwatchBolusRequest(units: 1, contextToken: token, requestedAt: date), now: date) - } + 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) - let failed = SmartwatchManagerHost.bolusAttempted(at: now, in: []) - XCTAssertEqual(evaluate(failed, at: now.addingTimeInterval(60)), .failure(.rateLimited)) - XCTAssertEqual(evaluate(failed, at: now.addingTimeInterval(5 * 60 + 1)), .success(1)) - XCTAssertEqual(failed.reduce(0) { $0 + $1.units }, 0) + XCTAssertEqual(SmartwatchManagerHost.watchPluginAlert(alert(identifier, level: .timeSensitive), from: "Watch").interruptionLevel, .timeSensitive) + XCTAssertEqual(SmartwatchManagerHost.watchPluginAlert(alert(identifier, level: .active), from: "Watch").interruptionLevel, .active) + } - let delivered = SmartwatchManagerHost.bolusDelivered(1.5, attemptedAt: now, in: failed) - XCTAssertEqual(delivered, [SmartwatchBolusPolicy.Delivery(date: now, units: 1.5)]) - XCTAssertEqual(evaluate(delivered, at: now.addingTimeInterval(60)), .failure(.rateLimited)) + /// 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) } /// A test alert counts as its alarm's, so the watch colours its ring and buzzes for it. From cc37541bea6bf5271b04d1d157566f147da3a87e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge=20Berg?= Date: Tue, 10 Nov 2026 15:24:47 +0100 Subject: [PATCH 19/21] Watch plugins: closed loop only while Loop doses automatically The loop state gave a watch plugin the Closed Loop setting, so during a manual temp basal the watch showed a closed loop while Loop enacted nothing. It now gets `isAutomaticDosingActive`, like Loop's loop icon, Apple Watch app and widget, and the host signals `.loop` when a manual temp basal starts or ends. Tests: DeviceDataManagerTests. --- Loop/Managers/SmartwatchManagerHost.swift | 11 ++++- .../Managers/DeviceDataManagerTests.swift | 40 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/SmartwatchManagerHost.swift b/Loop/Managers/SmartwatchManagerHost.swift index d0233e7fed..6d3af43b1b 100644 --- a/Loop/Managers/SmartwatchManagerHost.swift +++ b/Loop/Managers/SmartwatchManagerHost.swift @@ -107,6 +107,13 @@ final class SmartwatchManagerHost { .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) } /// Tells the watch manager that everything changed: for a new watch manager, @@ -285,7 +292,9 @@ extension SmartwatchManagerHost: SmartwatchManagerDelegate { let predictedGlucose = loopDataManager.predictedGlucose ?? [] return SmartwatchLoopState( lastLoopCompleted: loopDataManager.lastLoopCompleted, - isClosedLoop: settingsManager.loopSettings.dosingEnabled, + // 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, diff --git a/LoopTests/Managers/DeviceDataManagerTests.swift b/LoopTests/Managers/DeviceDataManagerTests.swift index 9a2e3ba0b8..9b47d632e0 100644 --- a/LoopTests/Managers/DeviceDataManagerTests.swift +++ b/LoopTests/Managers/DeviceDataManagerTests.swift @@ -206,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) From cd40da89f1d2fadb5f87b6f348e897e11237ae35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge=20Berg?= Date: Tue, 10 Nov 2026 15:24:47 +0100 Subject: [PATCH 20/21] Watch host tests: plugin alert lookups and the quieter context logging A watch plugin's alert is found, listed and retracted under the plugin's identifier, not the named manager's. The plugin's 0 g context read is logged only when its answer changes (the comparison moved into a static function, no change in behaviour). --- .../SmartwatchManagerHost+RemoteActions.swift | 17 +++++--- .../Managers/SmartwatchManagerHostTests.swift | 43 +++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift index 7b4aafdc7e..07ba954746 100644 --- a/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift +++ b/Loop/Managers/SmartwatchManagerHost+RemoteActions.swift @@ -161,11 +161,8 @@ extension SmartwatchManagerHost { let recommended = max(0, recommendation?.amount ?? 0) let units = deviceManager.pumpManager?.roundToSupportedBolusVolume(units: recommended) ?? recommended let maximumBolus = settingsManager.loopSettings.maximumBolus - if isContextRead { - guard units != lastContextRecommendation else { - return units - } - lastContextRecommendation = units + 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 @@ -221,6 +218,16 @@ extension SmartwatchManagerHost { 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/LoopTests/Managers/SmartwatchManagerHostTests.swift b/LoopTests/Managers/SmartwatchManagerHostTests.swift index b5bda3f3ee..236ba08bde 100644 --- a/LoopTests/Managers/SmartwatchManagerHostTests.swift +++ b/LoopTests/Managers/SmartwatchManagerHostTests.swift @@ -48,6 +48,49 @@ final class SmartwatchManagerHostTests: XCTestCase { 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) From 1722ff364048c045ca8a07b6df0fd26675e0ed91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Inge=20Berg?= Date: Wed, 11 Nov 2026 12:25:51 +0100 Subject: [PATCH 21/21] Watch plugins: the Closed Loop switch reaches the watch at once; glucose display limits The Closed Loop switch sets the setting directly and posts no LoopDataUpdated, so a watch learned of it only after the next loop. The host now observes the setting, as Loop's status screen does, and signals `.loop` when it really changes. `smartwatchManagerGlucoseDisplayLimits` (LoopKit): Loop has no display limits of its own, so it returns the low and high of the active glucose alert profile, whether or not those alerts are on, and signals `.settings` when the profiles or the active profile change. Tests: SmartwatchManagerHostTests. --- Loop/Managers/SmartwatchManagerHost.swift | 46 +++++++++++++++++++ .../Managers/SmartwatchManagerHostTests.swift | 29 ++++++++++++ 2 files changed, 75 insertions(+) diff --git a/Loop/Managers/SmartwatchManagerHost.swift b/Loop/Managers/SmartwatchManagerHost.swift index 6d3af43b1b..e0c438fdda 100644 --- a/Loop/Managers/SmartwatchManagerHost.swift +++ b/Loop/Managers/SmartwatchManagerHost.swift @@ -47,6 +47,8 @@ final class SmartwatchManagerHost { 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? @@ -114,6 +116,37 @@ final class SmartwatchManagerHost { .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, @@ -324,6 +357,19 @@ extension SmartwatchManagerHost: SmartwatchManagerDelegate { 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 } diff --git a/LoopTests/Managers/SmartwatchManagerHostTests.swift b/LoopTests/Managers/SmartwatchManagerHostTests.swift index 236ba08bde..1bd12390ae 100644 --- a/LoopTests/Managers/SmartwatchManagerHostTests.swift +++ b/LoopTests/Managers/SmartwatchManagerHostTests.swift @@ -146,4 +146,33 @@ final class SmartwatchManagerHostTests: XCTestCase { 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) + } }