diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 0095528757..d46a599ad1 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -231,6 +231,11 @@ 4F7E8AC520E2AB9600AEA65E /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC420E2AB9600AEA65E /* Date.swift */; }; 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */; }; 4FDDD23720DC51DF00D04B16 /* LoopDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */; }; + D7A3000100000000000A0002 /* WatchCGMHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A3000100000000000A0001 /* WatchCGMHost.swift */; }; + D7A3000100000000000A0004 /* WatchDeviceManagers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A3000100000000000A0003 /* WatchDeviceManagers.swift */; }; + 101AA7370B0E499FBD88BB9F /* G7SensorKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = F8760AF31E3C43F6AFBA1BE7 /* G7SensorKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 921B7FAE002E40EFAB3F50C6 /* G7SensorKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8760AF31E3C43F6AFBA1BE7 /* G7SensorKit.framework */; }; + 8B5CF0144D79477AACBE4A82 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4344628320A7A3BE00C4BE6F /* LoopKit.framework */; }; 4FF4D0F81E1725B000846527 /* NibLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434F54561D287FDB002A9274 /* NibLoadable.swift */; }; 5021CA8C264146C9DFF33E9D /* PluginPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE102E23D8C1C1291EF66387 /* PluginPickerView.swift */; }; 7D23667D21250C7E0028B67D /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D23667C21250C7E0028B67D /* LocalizedString.swift */; }; @@ -467,6 +472,7 @@ C129BF4A2B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */; }; C129D3BF2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C129D3BE2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift */; }; C12D72402E4FBC5F00BD628A /* WatchActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12D723F2E4FBC5D00BD628A /* WatchActionsView.swift */; }; + D7A3000100000000000A0006 /* WatchSensorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A3000100000000000A0005 /* WatchSensorView.swift */; }; C13072BA2A76AF31009A7C58 /* live_capture_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = C13072B92A76AF31009A7C58 /* live_capture_predicted_glucose.json */; }; C13255D6223E7BE2008AF50C /* BolusProgressTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */; }; C138C6F82C1B8A2C00F08F1A /* GlucoseCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = C138C6F72C1B8A2C00F08F1A /* GlucoseCondition.swift */; }; @@ -759,6 +765,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + 101AA7370B0E499FBD88BB9F /* G7SensorKit.framework in Embed Frameworks */, 43C05CB221EBD88A006FB252 /* LoopCore.framework in Embed Frameworks */, C1275E1C2E82269A0013B99D /* LoopKit.framework in Embed Frameworks */, ); @@ -1025,6 +1032,9 @@ 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchPredictedGlucose.swift; sourceTree = ""; }; 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSUserDefaults+StatusExtension.swift"; sourceTree = ""; }; 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDataManager.swift; sourceTree = ""; }; + D7A3000100000000000A0001 /* WatchCGMHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCGMHost.swift; sourceTree = ""; }; + D7A3000100000000000A0003 /* WatchDeviceManagers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchDeviceManagers.swift; sourceTree = ""; }; + F8760AF31E3C43F6AFBA1BE7 /* G7SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = G7SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4FF4D0FF1E18374700846527 /* WatchContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WatchContext.swift; sourceTree = ""; }; 5181B60CBF3CB2DA537F0475 /* Loop/Resources/Sounds/simple.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/simple.caf; sourceTree = SOURCE_ROOT; }; 62F7CB5D5DC337E705032AC7 /* Loop/Resources/Sounds/dings.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/dings.caf; sourceTree = SOURCE_ROOT; }; @@ -1300,6 +1310,7 @@ C12CB9B623106A6200F84978 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Intents.strings; sourceTree = ""; }; C12CB9B823106A6300F84978 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Intents.strings; sourceTree = ""; }; C12D723F2E4FBC5D00BD628A /* WatchActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchActionsView.swift; sourceTree = ""; }; + D7A3000100000000000A0005 /* WatchSensorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSensorView.swift; sourceTree = ""; }; C13072B92A76AF31009A7C58 /* live_capture_predicted_glucose.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = live_capture_predicted_glucose.json; sourceTree = ""; }; C138C6F72C1B8A2C00F08F1A /* GlucoseCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseCondition.swift; sourceTree = ""; }; C13DA2AF24F6C7690098BB29 /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; @@ -1479,6 +1490,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 921B7FAE002E40EFAB3F50C6 /* G7SensorKit.framework in Frameworks */, + 8B5CF0144D79477AACBE4A82 /* LoopKit.framework in Frameworks */, C1E2773E224177C000354103 /* ClockKit.framework in Frameworks */, 4344628220A7A37F00C4BE6F /* CoreBluetooth.framework in Frameworks */, 43D9002F21EB234400AF44BF /* LoopCore.framework in Frameworks */, @@ -2213,6 +2226,8 @@ isa = PBXGroup; children = ( 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */, + D7A3000100000000000A0001 /* WatchCGMHost.swift */, + D7A3000100000000000A0003 /* WatchDeviceManagers.swift */, 898ECA62218ABD21001E9D35 /* ComplicationChartManager.swift */, ); path = Managers; @@ -2462,6 +2477,7 @@ 894F6DD8243C060600CCE676 /* ScalablePositionedText.swift */, 89A605EA243288E4009C1096 /* TopDownTriangle.swift */, C12D723F2E4FBC5D00BD628A /* WatchActionsView.swift */, + D7A3000100000000000A0005 /* WatchSensorView.swift */, ); path = Views; sourceTree = ""; @@ -2583,6 +2599,7 @@ 43D9002C21EB225D00AF44BF /* HealthKit.framework */, 43F5C2C81B929C09003EB13D /* HealthKit.framework */, 4344628320A7A3BE00C4BE6F /* LoopKit.framework */, + F8760AF31E3C43F6AFBA1BE7 /* G7SensorKit.framework */, 437AFEE6203688CF008C4892 /* LoopKitUI.framework */, 892A5D5A222F0D7C008961AB /* LoopTestingKit.framework */, C1E2774722433D7A00354103 /* MKRingProgressView.framework */, @@ -3701,6 +3718,8 @@ 895788AF242E69A2002CB114 /* BolusInput.swift in Sources */, 894F6DDB243C07CF00CCE676 /* GramLabel.swift in Sources */, 4FDDD23720DC51DF00D04B16 /* LoopDataManager.swift in Sources */, + D7A3000100000000000A0002 /* WatchCGMHost.swift in Sources */, + D7A3000100000000000A0004 /* WatchDeviceManagers.swift in Sources */, 89E267FD2292456700A3F2AF /* FeatureFlags.swift in Sources */, 898ECA60218ABD17001E9D35 /* GlucoseChartScaler.swift in Sources */, 894F6DD9243C060600CCE676 /* ScalablePositionedText.swift in Sources */, @@ -3710,6 +3729,7 @@ C10C57FC2E70B8B900A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift in Sources */, 4372E492213D956C0068E043 /* GlucoseRangeSchedule.swift in Sources */, C12D72402E4FBC5F00BD628A /* WatchActionsView.swift in Sources */, + D7A3000100000000000A0006 /* WatchSensorView.swift in Sources */, A9347F3324E7522900C99C34 /* WatchHistoricalCarbs.swift in Sources */, 895788AD242E69A2002CB114 /* AbsorptionTimeSelection.swift in Sources */, 89A605EF2432925D009C1096 /* CompletionCheckmark.swift in Sources */, diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 138fa5126a..bdeccf10e4 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -296,6 +296,9 @@ final class WatchDataManager: NSObject { dosingDecision.carbsOnBoard = carbsOnBoard context.cgmManagerState = self.deviceManager.cgmManager?.rawValue + if let sharing = self.deviceManager.cgmManager as? DeviceConfigurationSharing, sharing.isConfigurationSharingEnabled { + context.cgmConfiguration = sharing.exportConfiguration().rawValue + } let settings = self.settingsManager.loopSettings diff --git a/LoopCore/Models/WatchContext.swift b/LoopCore/Models/WatchContext.swift index adbfb92778..9a0e37ad3b 100644 --- a/LoopCore/Models/WatchContext.swift +++ b/LoopCore/Models/WatchContext.swift @@ -61,6 +61,9 @@ public final class WatchContext: RawRepresentable { public var cgmManagerState: CGMManager.RawStateValue? + /// The CGM's `SharedDeviceConfiguration`, for a watch that reads the sensor itself. + public var cgmConfiguration: SharedDeviceConfiguration.RawValue? + public var isClosedLoop: Bool? public var deviceIssue: Bool? public var isOnboardingCompleted: Bool? @@ -178,6 +181,7 @@ public final class WatchContext: RawRepresentable { cob = rawValue["cob"] as? Double cgmManagerState = rawValue["cgmManagerState"] as? CGMManager.RawStateValue + cgmConfiguration = rawValue["cgmConfiguration"] as? SharedDeviceConfiguration.RawValue if let rawValue = rawValue["pg"] as? WatchPredictedGlucose.RawValue { predictedGlucose = WatchPredictedGlucose(rawValue: rawValue) @@ -198,6 +202,7 @@ public final class WatchContext: RawRepresentable { raw["oc"] = isOnboardingCompleted raw["cgmManagerState"] = cgmManagerState + raw["cgmConfiguration"] = cgmConfiguration raw["cob"] = cob diff --git a/WatchApp Extension/ComplicationController.swift b/WatchApp Extension/ComplicationController.swift index 3bacfd933e..f0a5c19744 100644 --- a/WatchApp Extension/ComplicationController.swift +++ b/WatchApp Extension/ComplicationController.swift @@ -19,7 +19,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { // MARK: - Timeline Configuration func getTimelineStartDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) { - if let date = LoopDataManager.shared.activeContext?.glucoseDate { + if let date = LoopDataManager.shared.glucoseDisplayContext?.glucoseDate { handler(date) } else { handler(nil) @@ -27,7 +27,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { } func getTimelineEndDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) { - if let date = LoopDataManager.shared.activeContext?.glucoseDate { + if let date = LoopDataManager.shared.glucoseDisplayContext?.glucoseDate { handler(date) } else { handler(nil) @@ -97,7 +97,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { self.log.default("Updating current complication timeline entry") - if let context = LoopDataManager.shared.activeContext, + if let context = LoopDataManager.shared.glucoseDisplayContext, let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context, at: timelineDate, @@ -123,7 +123,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { updateChartManagerIfNeeded { let entries: [CLKComplicationTimelineEntry]? - guard let context = LoopDataManager.shared.activeContext, + guard let context = LoopDataManager.shared.glucoseDisplayContext, let glucoseDate = context.glucoseDate else { handler(nil) diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 5d913b5996..01c253e99f 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -65,6 +65,9 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate { } }) + // At launch, so a relaunch for the CGM's Bluetooth link finds its central. + _ = WatchCGMHost.shared + session.activate() } @@ -93,20 +96,31 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate { // glucose we're missing. loopManager.requestContextUpdate() loopManager.requestGlucoseBackfillIfNecessary() + WatchCGMHost.shared.recheckAcquisition() } func applicationWillResignActive() { } - // Presumably the main thread? func handle(_ backgroundTasks: Set) { + // watchOS can deliver a Bluetooth wake on CoreBluetooth's queue; everything below is main-only. + guard Thread.isMainThread else { + DispatchQueue.main.async { self.handle(backgroundTasks) } + return + } loopManager.requestGlucoseBackfillIfNecessary() for task in backgroundTasks { switch task { case is WKApplicationRefreshBackgroundTask: log.default("Processing WKApplicationRefreshBackgroundTask") - break + WatchCGMHost.shared.recheckAcquisition() + case let task as WKBluetoothAlertRefreshBackgroundTask: + // A connect the system held for the CGM relaunched or woke the app: keep it running + // through the reading rather than completing the task at once. + log.default("Processing WKBluetoothAlertRefreshBackgroundTask") + holdBluetoothTask(task) + continue case let task as WKSnapshotRefreshBackgroundTask: log.default("Processing WKSnapshotRefreshBackgroundTask") task.setTaskCompleted(restoredDefaultState: false, estimatedSnapshotExpiration: Date(timeIntervalSinceNow: TimeInterval(minutes: 5)), userInfo: nil) @@ -138,6 +152,42 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate { private var pendingConnectivityTasks: [WKWatchConnectivityRefreshBackgroundTask] = [] + /// The one Bluetooth wake task held at a time, and every task completed, kept so a task handed over + /// again (WatchKit does, even in a later wake) is never completed twice, which throws. + private var heldBluetoothTask: WKBluetoothAlertRefreshBackgroundTask? + private var completedBluetoothTasks: [WKBluetoothAlertRefreshBackgroundTask] = [] + + /// Holds a Bluetooth wake 25 s, through the CGM's reading, or until the system expires it. Later + /// deliveries while one is held are completed on arrival. + private func holdBluetoothTask(_ task: WKBluetoothAlertRefreshBackgroundTask) { + dispatchPrecondition(condition: .onQueue(.main)) + guard task !== heldBluetoothTask else { return } + guard heldBluetoothTask == nil else { + completeBluetoothTask(task) + return + } + heldBluetoothTask = task + task.expirationHandler = { [weak self] in + DispatchQueue.main.async { self?.completeBluetoothTask(task) } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 25) { [weak self] in + self?.completeBluetoothTask(task) + } + } + + private func completeBluetoothTask(_ task: WKBluetoothAlertRefreshBackgroundTask) { + dispatchPrecondition(condition: .onQueue(.main)) + if task === heldBluetoothTask { + heldBluetoothTask = nil + } + guard !completedBluetoothTasks.contains(where: { $0 === task }) else { return } + completedBluetoothTasks.append(task) + if completedBluetoothTasks.count > 256 { + completedBluetoothTasks.removeFirst(64) + } + task.setTaskCompletedWithSnapshot(false) + } + private func completePendingConnectivityTasksIfNeeded() { if WCSession.default.activationState == .activated && !WCSession.default.hasContentPending { pendingConnectivityTasks.forEach { (task) in diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index 1746cf8ce8..ddedcf1a91 100644 --- a/WatchApp Extension/Managers/LoopDataManager.swift +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -90,6 +90,12 @@ class LoopDataManager { private var needsDidUpdateContextNotification: Bool = false + /// The newest reading from the watch's own connection to the CGM. Main queue only. + private(set) var latestDirectGlucose: NewGlucoseSample? + + /// Bumped once the watch's own readings are in the store, for the chart. + private(set) var directGlucoseRevision = 0 + /// The last attempt to backfill glucose. We use a date because the message timeout is longer /// than our desired retry interval, so we allow multiple messages in-flight /// Main queue only @@ -152,6 +158,50 @@ extension LoopDataManager { } activeContext = context } + WatchCGMHost.shared.update(from: context) + } + + /// Readings from the watch's own connection to the CGM. Stored beside the phone's (the same + /// reading from both carries the same sync identifier, so the store keeps one) and shown while + /// newer than the phone's. + func addDirectGlucose(_ samples: [NewGlucoseSample]) { + dispatchPrecondition(condition: .onQueue(.main)) + guard let latest = samples.max(by: { $0.date < $1.date }) else { return } + if latestDirectGlucose.map({ latest.date > $0.date }) ?? true { + latestDirectGlucose = latest + } + Task { + do { + _ = try await self.glucoseStore?.addGlucoseSamples(samples) + } catch { + self.log.error("Failure adding direct glucose samples: %{public}@", String(describing: error)) + } + self.directGlucoseRevision += 1 + self.needsDidUpdateContextNotification = true + self.sendDidUpdateContextNotificationIfNecessary() + } + } + + func directGlucoseDidEnd() { + latestDirectGlucose = nil + } + + /// The phone's context, with the watch's own reading in place of the phone's when it is newer. + var glucoseDisplayContext: WatchContext? { + guard let activeContext else { return nil } + guard let direct = latestDirectGlucose, direct.date > activeContext.glucoseDate ?? .distantPast, + let context = WatchContext(rawValue: activeContext.rawValue) else { + return activeContext + } + context.glucose = direct.quantity + context.glucoseDate = direct.date + context.glucoseTrend = direct.trend + context.glucoseTrendRate = direct.trendRate + context.glucoseCondition = direct.condition + context.glucoseIsDisplayOnly = direct.isDisplayOnly + context.glucoseWasUserEntered = direct.wasUserEntered + context.glucoseSyncIdentifier = direct.syncIdentifier + return context } func sendDidUpdateContextNotificationIfNecessary() { @@ -205,7 +255,12 @@ extension LoopDataManager { // to power the chart. Backfill from just after our most recent stored sample so the phone // returns exactly what we're missing (no overlap); if we have nothing recent, fall back to // the earliest cutoff to repopulate the whole chart window. - let latestSampleDate = glucoseStore?.latestGlucose?.startDate ?? .distantPast + // The watch's own readings don't mean the phone's are here: start no later than the phone's + // last reading, and the store drops what it already has. + var latestSampleDate = glucoseStore?.latestGlucose?.startDate ?? .distantPast + if latestDirectGlucose != nil, let phoneGlucoseDate = activeContext?.glucoseDate { + latestSampleDate = min(latestSampleDate, phoneGlucoseDate) + } let latestDate = max(latestSampleDate, .earliestGlucoseCutoff) guard latestDate < .staleGlucoseCutoff else { self.log.default("Skipping glucose backfill request because our latest sample date is %{public}@", String(describing: latestDate)) @@ -309,7 +364,7 @@ extension LoopDataManager { } var glucoseValue: String { - guard let activeContext = activeContext, + guard let activeContext = glucoseDisplayContext, let glucose = activeContext.glucose, let unit = activeContext.displayGlucoseUnit else { diff --git a/WatchApp Extension/Managers/WatchCGMHost.swift b/WatchApp Extension/Managers/WatchCGMHost.swift new file mode 100644 index 0000000000..bf1f3915a9 --- /dev/null +++ b/WatchApp Extension/Managers/WatchCGMHost.swift @@ -0,0 +1,192 @@ +// +// WatchCGMHost.swift +// WatchApp Extension +// +// Reads the phone's CGM directly, when the phone's CGM manager shares its configuration: the +// watch builds its own manager from that export, keeps it across launches and stores its +// readings beside the phone's. +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopCore +import os.log +import WatchKit + +@MainActor +@Observable +final class WatchCGMHost { + static let shared = WatchCGMHost() + + @ObservationIgnored + private let log = OSLog(category: "WatchCGMHost") + + /// The manager's `managerIdentifier`, `state`, and the export it was built from. + @ObservationIgnored + @PersistedProperty(key: "WatchCGMManager") + private var rawManager: [String: Any]? + + @ObservationIgnored + private(set) var cgmManager: CGMManager? + @ObservationIgnored + private var builtFrom: [String: Any]? + + /// For the sensor page. + private(set) var isReadingDirectly = false + private(set) var sensorName: String? + private(set) var statusNote: String? + private(set) var lastConnection: Date? + + /// A background refresh this long after each reading re-checks acquisition, in case watchOS froze + /// the app before it lodged the next connect. Past the G7's 35 s tail clearance, so a connect it + /// lodges never lands in the sensor's advertising tail. + static let acquisitionBackstopDelay: TimeInterval = 60 + + private init() { + if let raw = rawManager, let manager = watchCGMManager(rawValue: raw) { + builtFrom = raw["builtFrom"] as? [String: Any] + install(manager) + } + NotificationCenter.default.addObserver(forName: watchCGMStatusDidChange, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.updateStatusNote() } + } + } + + private func updateStatusNote() { + isReadingDirectly = cgmManager != nil + sensorName = watchCGMSensorName(cgmManager) + statusNote = watchCGMStatusNote(cgmManager) + lastConnection = watchCGMLastConnection(cgmManager) + } + + /// The phone's context arrived. Builds a manager when its export names a different sensor or code, + /// lets go of the sensor when the phone stopped sharing, and otherwise treats it as a wake. + func update(from context: WatchContext) { + guard let raw = context.cgmConfiguration, let configuration = SharedDeviceConfiguration(rawValue: raw) else { + if cgmManager != nil { + log.default("The phone stopped sharing its CGM; letting go of the sensor") + remove() + } + return + } + if cgmManager != nil, let builtFrom, (builtFrom as NSDictionary).isEqual(to: configuration.state) { + recheckAcquisition() + return + } + guard let manager = watchCGMManager(adopting: configuration, localState: nil) else { + log.default("The phone's CGM (%{public}@) can't be read from the watch", configuration.managerIdentifier) + return + } + log.default("CGM manager built from the phone's configuration (%{public}@)", configuration.managerIdentifier) + remove() + builtFrom = configuration.state + install(manager) + save(manager) + } + + /// Any wake that is not the sensor's own. + func recheckAcquisition() { + watchCGMRecheckAcquisition(cgmManager) + } + + private func install(_ manager: CGMManager) { + manager.delegateQueue = .main + manager.cgmManagerDelegate = self + cgmManager = manager + updateStatusNote() + } + + private func remove() { + guard let previous = cgmManager else { return } + previous.cgmManagerDelegate = nil + previous.delete {} + cgmManager = nil + builtFrom = nil + rawManager = nil + updateStatusNote() + LoopDataManager.shared.directGlucoseDidEnd() + } + + private func save(_ manager: CGMManager) { + var raw = manager.watchRawValue + raw["builtFrom"] = builtFrom + rawManager = raw + } + + private func scheduleAcquisitionBackstop() { + WKApplication.shared().scheduleBackgroundRefresh(withPreferredDate: Date(timeIntervalSinceNow: Self.acquisitionBackstopDelay), userInfo: nil) { error in + if let error { + self.log.error("Could not schedule the acquisition backstop: %{public}@", String(describing: error)) + } + } + } +} + +extension WatchCGMHost: CGMManagerDelegate { + nonisolated func startDateToFilterNewData(for manager: CGMManager) -> Date? { + MainActor.assumeIsolated { LoopDataManager.shared.glucoseStore?.latestGlucose?.startDate } + } + + nonisolated func cgmManager(_ manager: CGMManager, hasNew readingResult: CGMReadingResult) { + MainActor.assumeIsolated { + switch readingResult { + case .newData(let samples): + log.default("Direct reading: %d sample(s)", samples.count) + LoopDataManager.shared.addDirectGlucose(samples) + scheduleAcquisitionBackstop() + case .error(let error): + log.error("CGM reading error: %{public}@", String(describing: error)) + case .noData, .unreliableData: + break + } + } + } + + nonisolated func cgmManager(_ manager: CGMManager, hasNew events: [PersistedCgmEvent]) {} + + /// The phone owns the sensor; a manager passed its configuration lets go when the phone stops sharing. + nonisolated func cgmManagerWantsDeletion(_ manager: CGMManager) async {} + + nonisolated func cgmManagerDidUpdateState(_ manager: CGMManager) { + MainActor.assumeIsolated { + guard manager === cgmManager else { return } + save(manager) + updateStatusNote() + } + } + + nonisolated func credentialStoragePrefix(for manager: CGMManager) -> String { + "com.loopkit.Loop.WatchCGMHost" + } + + nonisolated func cgmManager(_ manager: CGMManager, didUpdate status: CGMManagerStatus) { + MainActor.assumeIsolated { + guard manager === cgmManager else { return } + updateStatusNote() + } + } + + nonisolated func deviceManager(_ manager: DeviceManager, logEventForDeviceIdentifier deviceIdentifier: String?, type: DeviceLogEntryType, message: String, completion: ((Error?) -> Void)?) { + os_log("%{public}@ %{public}@", log: OSLog(category: "WatchCGMDevice"), type: .default, String(describing: type), message) + completion?(nil) + } + + nonisolated func deviceManager(_ manager: DeviceManager, recordAnalyticsEvent name: String, properties: [AnyHashable: Any]?) {} + + // The phone issues the sensor's alerts; the watch only logs them. + nonisolated func issueAlert(_ alert: Alert) async { + os_log("Alert from the watch's CGM, not shown: %{public}@", log: OSLog(category: "WatchCGMHost"), type: .default, alert.identifier.value) + } + + nonisolated func retractAlert(identifier: Alert.Identifier) async {} + + nonisolated func doesIssuedAlertExist(identifier: Alert.Identifier) async throws -> Bool { false } + + nonisolated func lookupAllUnretracted(managerIdentifier: String) async throws -> [PersistedAlert] { [] } + + nonisolated func lookupAllUnacknowledgedUnretracted(managerIdentifier: String) async throws -> [PersistedAlert] { [] } + + nonisolated func recordRetractedAlert(_ alert: Alert, at date: Date) async throws {} +} diff --git a/WatchApp Extension/Managers/WatchDeviceManagers.swift b/WatchApp Extension/Managers/WatchDeviceManagers.swift new file mode 100644 index 0000000000..4551ae88ae --- /dev/null +++ b/WatchApp Extension/Managers/WatchDeviceManagers.swift @@ -0,0 +1,60 @@ +// +// WatchDeviceManagers.swift +// WatchApp Extension +// +// The device managers the watch can be passed, by identifier, for managers built from another +// controller's exported configuration. The one place the watch names a device kit. +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import LoopKit +import G7SensorKit + +/// Keyed by each manager's `pluginIdentifier`, which is what its export carries. +let watchCGMManagersByIdentifier: [String: (CGMManager & DeviceConfigurationSharing).Type] = [ + "G7CGMManager": G7CGMManager.self, +] + +/// A CGM manager built from another controller's export and this watch's saved `localState`. +func watchCGMManager(adopting configuration: SharedDeviceConfiguration, localState: [String: Any]?) -> CGMManager? { + watchCGMManagersByIdentifier[configuration.managerIdentifier]?.init(adopting: configuration, localState: localState) +} + +/// A CGM manager restored from its saved `managerIdentifier` and `state`. +func watchCGMManager(rawValue: [String: Any]) -> CGMManager? { + guard let identifier = rawValue["managerIdentifier"] as? String, + let rawState = rawValue["state"] as? CGMManager.RawStateValue else { return nil } + return watchCGMManagersByIdentifier[identifier]?.init(rawState: rawState) +} + +/// On a wake that is not the CGM's own, its acquisition re-checks itself (G7: a lost Bluetooth +/// callback can leave no connect standing and nothing to notice). +func watchCGMRecheckAcquisition(_ manager: CGMManager?) { + (manager as? G7CGMManager)?.recheckAcquisition() +} + +/// What the watch's CGM needs from the user (a code, or the app kept open while it searches), and the +/// notification posted when that changes. +func watchCGMStatusNote(_ manager: CGMManager?) -> String? { + guard let g7 = manager as? G7CGMManager else { return nil } + return G7WatchDirectRead.needsCodeNote(for: g7.needsCodeForSensor) ?? G7WatchDirectRead.searchingNote(g7.isSearchingForSensor) +} + +let watchCGMStatusDidChange = G7CGMManager.statusDidChange + +func watchCGMSensorName(_ manager: CGMManager?) -> String? { + (manager as? G7CGMManager)?.sensorName +} + +/// When the watch last had a link to the sensor, readings or not (none during warmup). +func watchCGMLastConnection(_ manager: CGMManager?) -> Date? { + (manager as? G7CGMManager)?.lastConnect ?? manager?.cgmManagerStatus.lastCommunicationDate +} + +extension DeviceManager { + /// What the watch saves, as stock saves a manager: `managerIdentifier` and `state`. + var watchRawValue: [String: Any] { + ["managerIdentifier": pluginIdentifier, "state": rawState] + } +} diff --git a/WatchApp Extension/Views/ChartPageView.swift b/WatchApp Extension/Views/ChartPageView.swift index 188cedec38..01c7dad590 100644 --- a/WatchApp Extension/Views/ChartPageView.swift +++ b/WatchApp Extension/Views/ChartPageView.swift @@ -223,6 +223,9 @@ struct ChartPageView: View { .onChange(of: loopManager.activeContext?.predictedGlucose) { oldValue, newValue in updateGlucoseChart() } + .onChange(of: loopManager.directGlucoseRevision) { oldValue, newValue in + updateGlucoseChart() + } .sheet(isPresented: $isShowingCarbList) { CarbList() } diff --git a/WatchApp Extension/Views/WatchSensorView.swift b/WatchApp Extension/Views/WatchSensorView.swift new file mode 100644 index 0000000000..d8754aefed --- /dev/null +++ b/WatchApp Extension/Views/WatchSensorView.swift @@ -0,0 +1,79 @@ +// +// WatchSensorView.swift +// WatchApp Extension +// +// The CGM the watch reads itself: what it needs from the user, and its last reading and connection. +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct WatchSensorView: View { + @Environment(LoopDataManager.self) var loopManager + + private var host: WatchCGMHost { WatchCGMHost.shared } + + var body: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 10) { + Text(host.sensorName ?? String(localized: "Sensor", comment: "Title of the watch's sensor page when the sensor has no name yet")) + .font(.headline) + + if let note = host.statusNote { + Text(note) + .font(.footnote) + } + + LabelValueRow("Last Reading") { + if let reading = loopManager.latestDirectGlucose { + Text(glucoseText(reading)) + Text(" ") + Text(reading.date, style: .time).font(.footnote) + } else { + Text("—") + } + } + + LabelValueRow("Next Reading") { + if let lastConnection = host.lastConnection { + TimelineView(.periodic(from: .now, by: 1)) { context in + Text(Self.countdown(to: Self.nextReading(after: lastConnection, now: context.date), now: context.date)) + .monospacedDigit() + } + } else { + Text("—") + } + } + + LabelValueRow("Last Connection") { + if let date = host.lastConnection { + Text(date, style: .time) + } else { + Text("—") + } + } + } + .padding(.horizontal) + } + } + + /// The sensor's 5-minute cadence, counted from the last link-up, which comes a few seconds into a window. + static let readingInterval: TimeInterval = 5 * 60 + + static func nextReading(after lastConnection: Date, now: Date) -> Date { + let elapsed = max(0, now.timeIntervalSince(lastConnection)) + return lastConnection.addingTimeInterval((floor(elapsed / readingInterval) + 1) * readingInterval) + } + + static func countdown(to date: Date, now: Date) -> String { + let seconds = max(0, Int(date.timeIntervalSince(now).rounded(.up))) + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } + + private func glucoseText(_ reading: NewGlucoseSample) -> String { + let unit = loopManager.displayGlucoseUnit + let value = NumberFormatter.glucoseFormatter(for: unit).string(from: reading.quantity.doubleValue(for: unit)) ?? "" + return value + (reading.trend?.symbol ?? "") + } +} diff --git a/WatchApp/ContentView.swift b/WatchApp/ContentView.swift index 0600410a77..114ba9490d 100644 --- a/WatchApp/ContentView.swift +++ b/WatchApp/ContentView.swift @@ -10,6 +10,9 @@ import LoopKit import SwiftUI struct ContentView: View { + /// Clear of the pages Sport Mode adds after the chart. + static let sensorPage = 10 + @Environment(LoopDataManager.self) var loopManager @State private var presetToConfirm: SelectablePreset? = nil @@ -30,6 +33,12 @@ struct ContentView: View { .task { loopManager.requestContextUpdate {} } + + // While the watch reads the CGM itself. + if WatchCGMHost.shared.isReadingDirectly { + WatchSensorView() + .tag(Self.sensorPage) + } } .tabViewStyle(.page) .indexViewStyle(.page(backgroundDisplayMode: .automatic)) diff --git a/WatchApp/Info.plist b/WatchApp/Info.plist index 3e7b8187eb..08ccae82d9 100644 --- a/WatchApp/Info.plist +++ b/WatchApp/Info.plist @@ -26,12 +26,18 @@ $(PRODUCT_MODULE_NAME).ComplicationController NSHealthShareUsageDescription Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to optimize delivery of Apple Watch complication updates during the time you are awake. + NSBluetoothAlwaysUsageDescription + The watch uses Bluetooth to read your CGM directly when your phone isn't there. NSHealthUpdateUsageDescription Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit. NSUserActivityTypes com.loopkit.Loop.AddCarbEntryOnWatch + UIBackgroundModes + + bluetooth-central + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait