diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 2938affba..e5a641cca 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -177,7 +177,7 @@ class LoopAPNSService { finalPayload["encrypted_return_notification"] = encryptedReturnInfo } LogManager.shared.log(category: .apns, message: "Sending carbs: \(String(format: "%.1f", payload.carbsAmount ?? 0.0))g, absorption: \(String(format: "%.1f", payload.absorptionTime ?? 3.0))h") - sendRemoteCommand(finalPayload, completion: completion) + sendRemoteCommand(finalPayload, completion: RemoteCommandRefresh.following(.meal(grams: payload.carbsAmount ?? 0, bolus: 0), completion)) } /// Sends bolus via APNS push notification @@ -190,7 +190,7 @@ class LoopAPNSService { finalPayload["encrypted_return_notification"] = encryptedReturnInfo } LogManager.shared.log(category: .apns, message: "Sending bolus: \(String(format: "%.2f", payload.bolusAmount ?? 0.0))U") - sendRemoteCommand(finalPayload, completion: completion) + sendRemoteCommand(finalPayload, completion: RemoteCommandRefresh.following(.bolus(units: payload.bolusAmount ?? 0), completion)) } /// Validates APNS credentials similar to PushNotificationManager @@ -479,7 +479,7 @@ class LoopAPNSService { apnsKey: creds.apnsKey, teamId: creds.teamId, payload: payload, - completion: completion + completion: RemoteCommandRefresh.following(.override, completion) ) } @@ -511,7 +511,7 @@ class LoopAPNSService { apnsKey: creds.apnsKey, teamId: creds.teamId, payload: payload, - completion: completion + completion: RemoteCommandRefresh.following(.override, completion) ) } } diff --git a/LoopFollow/Remote/RemoteCommandRefresh.swift b/LoopFollow/Remote/RemoteCommandRefresh.swift new file mode 100644 index 000000000..f1773f86d --- /dev/null +++ b/LoopFollow/Remote/RemoteCommandRefresh.swift @@ -0,0 +1,136 @@ +// LoopFollow +// RemoteCommandRefresh.swift + +import Foundation +import HealthKit + +/// Loop and Trio apply a remote command within seconds, but LoopFollow only sees +/// the result on its next regular Nightscout fetch, which can be minutes away. +/// After a command is sent, treatments and device status are fetched a few extra +/// times, stopping as soon as the change the command makes shows up, so the new +/// override, temp target, bolus or carbs reach the phone, the watch and the Live +/// Activity sooner. +final class RemoteCommandRefresh { + /// What a command changes, and so what ends the follow-up fetches. + enum Change { + case override + case tempTarget + case bolus(units: Double) + /// A bolus sent with the meal (Trio) also has to show up; 0 g of carbs + /// (fat and protein only) matches any new carb entry. + case meal(grams: Double, bolus: Double) + + var name: String { + switch self { + case .override: "override" + case .tempTarget: "temp target" + case .bolus: "bolus" + case .meal: "meal" + } + } + } + + static let shared = RemoteCommandRefresh() + + /// Seconds after the send. Most uploads land in the first half minute; the + /// later fetches cover a pump that is slow to deliver or an upload that is retried. + static let delays: [TimeInterval] = [10, 20, 30, 60, 90, 120] + + private var pending: DispatchWorkItem? + + private init() {} + + /// Wraps a send's completion so that a successful send starts the follow-up fetches. + static func following(_ change: Change, _ completion: @escaping (Bool, String?) -> Void) -> (Bool, String?) -> Void { + { success, error in + if success { shared.commandSent(change) } + completion(success, error) + } + } + + /// Restarts the follow-up fetches; a newer command replaces the remaining ones. + func commandSent(_ change: Change) { + DispatchQueue.main.async { + self.cancel() + LogManager.shared.log(category: .apns, message: "Fetching Nightscout early until the \(change.name) shows up") + self.schedule(step: 0, after: Self.delays[0], arrived: Self.arrivalCheck(for: change), change: change) + } + } + + /// Each fetch schedules the next one, so when the app was suspended past + /// several of them (Bluetooth heartbeat modes), it fetches once on resuming + /// instead of all the overdue ones at once. + private func schedule(step: Int, after delay: TimeInterval, arrived: @escaping () -> Bool, change: Change) { + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + if arrived() { + LogManager.shared.log(category: .apns, message: "The \(change.name) showed up; \(Self.delays.count - step) early fetches skipped") + self.pending = nil + return + } + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date()) + let next = step + 1 + guard next < Self.delays.count else { + self.pending = nil + return + } + self.schedule(step: next, after: Self.delays[next] - Self.delays[step], arrived: arrived, change: change) + } + pending = work + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + } + + private func cancel() { + pending?.cancel() + pending = nil + } + + /// Captures the phone's data now and returns a check that is true once the + /// command's change is in it. A change that never matches (a partial bolus, + /// a renamed preset) only means the fetches run their full two minutes. + private static func arrivalCheck(for change: Change) -> () -> Bool { + switch change { + case .override: + let before = (Observable.shared.override.value, Observable.shared.overrideEndAt.value) + return { (Observable.shared.override.value, Observable.shared.overrideEndAt.value) != before } + case .tempTarget: + // Compared as numbers: each treatments fetch creates a new HKQuantity. + func current() -> (Double?, TimeInterval?) { + (Observable.shared.tempTarget.value?.doubleValue(for: .milligramsPerDeciliter), Observable.shared.tempTargetEndAt.value) + } + let before = current() + return { current() != before } + case let .bolus(units): + return newEntry(in: boluses, matching: units, tolerance: 0.01) + case let .meal(grams, bolus): + let carbsArrived = newEntry(in: carbs, matching: grams > 0 ? grams : nil, tolerance: 0.5) + guard bolus > 0 else { return carbsArrived } + let bolusArrived = newEntry(in: boluses, matching: bolus, tolerance: 0.01) + return { carbsArrived() && bolusArrived() } + } + } + + /// True once an entry that was not there at the send appears, with `value` when given. + private static func newEntry( + in entries: @escaping () -> [(date: TimeInterval, value: Double)], + matching value: Double?, + tolerance: Double + ) -> () -> Bool { + let known = Set(entries().map { $0.date }) + return { + entries().contains { entry in + !known.contains(entry.date) && value.map { abs(entry.value - $0) < tolerance } ?? true + } + } + } + + private static func boluses() -> [(date: TimeInterval, value: Double)] { + let vc = MainViewController.shared + return ((vc?.bolusData ?? []) + (vc?.smbData ?? [])).map { (date: $0.date, value: $0.value) } + } + + private static func carbs() -> [(date: TimeInterval, value: Double)] { + (MainViewController.shared?.carbData ?? []).map { (date: $0.date, value: $0.value) } + } +} diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index 7a6513a15..532d9bf27 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -40,7 +40,7 @@ class PushNotificationManager { overrideName: override.name, returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following(.override, completion)) } func sendCancelOverridePushNotification(completion: @escaping (Bool, String?) -> Void) { @@ -51,7 +51,7 @@ class PushNotificationManager { overrideName: nil, returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following(.override, completion)) } func sendBolusPushNotification(bolusAmount: HKQuantity, completion: @escaping (Bool, String?) -> Void) { @@ -63,7 +63,7 @@ class PushNotificationManager { bolusAmount: bolusAmountDecimal, returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following(.bolus(units: bolusAmount.doubleValue(for: .internationalUnit())), completion)) } func sendTempTargetPushNotification(target: HKQuantity, duration: HKQuantity, completion: @escaping (Bool, String?) -> Void) { @@ -77,7 +77,7 @@ class PushNotificationManager { duration: durationValue, returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following(.tempTarget, completion)) } func sendCancelTempTargetPushNotification(completion: @escaping (Bool, String?) -> Void) { @@ -87,7 +87,7 @@ class PushNotificationManager { timestamp: Date().timeIntervalSince1970, returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following(.tempTarget, completion)) } func sendMealPushNotification( @@ -127,7 +127,10 @@ class PushNotificationManager { scheduledTime: scheduledTimeInterval, returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following( + .meal(grams: Double(carbsValue ?? 0), bolus: bolusAmount.doubleValue(for: .internationalUnit())), + completion + )) } /// Trio confirms edit/delete through a return notification, so LoopFollow's own APNs credentials are required. diff --git a/LoopFollow/Watch/WatchSessionManager.swift b/LoopFollow/Watch/WatchSessionManager.swift index 08a9aacb1..041169524 100644 --- a/LoopFollow/Watch/WatchSessionManager.swift +++ b/LoopFollow/Watch/WatchSessionManager.swift @@ -15,7 +15,12 @@ final class WatchSessionManager: NSObject { /// are only used when a new reading arrives and this long has passed. private let complicationTransferInterval: TimeInterval = 15 * 60 + /// A state equal to the last one is still sent this often, well inside the + /// watch's 10 minutes before it treats the phone's data as stale. + private let unchangedResendInterval: TimeInterval = 5 * 60 + private var lastSentState: WatchState? + private var lastContextUpdate: Date = .distantPast private var lastSentCredentials: WatchCredentials? private var lastComplicationTransfer: Date = .distantPast private var cancellables = Set() @@ -121,11 +126,21 @@ final class WatchSessionManager: NSObject { private func publish(reason: String) { guard let session, session.activationState == .activated else { return } let state = WatchStateBuilder.build() + // Most refreshes rebuild the chart without changing anything the watch shows; + // sending those would only wake the watch. + if let lastSentState, Self.sameContent(state, lastSentState), + Date().timeIntervalSince(lastContextUpdate) < unchangedResendInterval + { + LogManager.shared.log(category: .general, message: "[Watch] state unchanged, not sent reason=\(reason)", isDebug: true) + return + } guard let data = state.encoded() else { return } do { try session.updateApplicationContext([WatchState.contextKey: data]) + lastContextUpdate = Date() } catch { + lastContextUpdate = .distantPast LogManager.shared.log(category: .general, message: "[Watch] context update failed: \(error.localizedDescription)") } @@ -142,6 +157,14 @@ final class WatchSessionManager: NSObject { lastSentState = state LogManager.shared.log(category: .general, message: "[Watch] state sent (\(data.count) bytes) reason=\(reason)", isDebug: true) } + + /// Equal apart from when the two states were built. + private static func sameContent(_ lhs: WatchState, _ rhs: WatchState) -> Bool { + var lhs = lhs + lhs.builtAt = rhs.builtAt + lhs.phoneBuiltAt = rhs.phoneBuiltAt + return lhs == rhs + } } // MARK: - WCSessionDelegate @@ -153,7 +176,7 @@ extension WatchSessionManager: WCSessionDelegate { return } if state == .activated { - requestUpdate(reason: "activated") + resendFullState(reason: "activated") DispatchQueue.main.async { self.sendCredentialsIfChanged(force: false) } } } @@ -166,7 +189,16 @@ extension WatchSessionManager: WCSessionDelegate { } func sessionWatchStateDidChange(_: WCSession) { - requestUpdate(reason: "watchStateChanged") + resendFullState(reason: "watchStateChanged") + } + + /// A newly activated session or a newly installed or paired watch may not have + /// the last state, so the next one is sent even if it has not changed. + private func resendFullState(reason: String) { + DispatchQueue.main.async { + self.lastContextUpdate = .distantPast + self.requestUpdate(reason: reason) + } } func session(_: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { diff --git a/LoopFollowWatch/StandaloneFetcher.swift b/LoopFollowWatch/StandaloneFetcher.swift index ea13ad588..0617371c0 100644 --- a/LoopFollowWatch/StandaloneFetcher.swift +++ b/LoopFollowWatch/StandaloneFetcher.swift @@ -98,6 +98,15 @@ enum StandaloneFetcher { let latest = glucose.last let previous = glucose.dropLast().last + // As on the phone (RecommendedBolus), a recommendation from a loop cycle + // that ran before the latest manual bolus leaves that bolus out, and + // offering it could double it. Insulin sent from the watch is left out + // on the bolus screen. + var recommendedBolus = status?.recommendedBolus + if let loopAt = status?.loopAt, let lastBolus = lastManualBolus(treatments), lastBolus >= loopAt { + recommendedBolus = nil + } + let active = activeAdjustments(treatments, now: now) var snapshot: GlucoseSnapshot? if let latest { @@ -112,7 +121,7 @@ enum StandaloneFetcher { projected: nil, override: active.override, tempTargetMgdl: active.tempTargetMgdl, - recBolus: status?.recommendedBolus, + recBolus: recommendedBolus, unit: base?.snapshot?.unit ?? .mgdl, isNotLooping: loopAge.map { $0 >= 15 * 60 } ?? false ) @@ -120,7 +129,7 @@ enum StandaloneFetcher { var remote = base?.remote if remote != nil { - remote?.recommendedBolus = status?.recommendedBolus + remote?.recommendedBolus = recommendedBolus remote?.recommendedBolusAt = status?.loopAt } @@ -153,6 +162,24 @@ enum StandaloneFetcher { return WatchReading(t: at.timeIntervalSince1970, v: value) } + /// The latest manual bolus, classified as the phone does (Treatments.swift): + /// automatic boluses and SMBs are left out, since the cycle that recommends + /// a bolus can also give one. + private static func lastManualBolus(_ treatments: [[String: Any]]) -> Date? { + treatments.compactMap { entry -> Date? in + guard let insulin = entry["insulin"] as? Double, insulin > 0 else { return nil } + switch entry["eventType"] as? String { + case "Correction Bolus", "Bolus", "External Insulin": + if entry["automatic"] as? Bool == true { return nil } + case "Meal Bolus": + break + default: + return nil + } + return DeviceStatusSummary.date(entry["timestamp"] ?? entry["created_at"]) + }.max() + } + /// The override and temp target that are running now, from the latest of each in the treatments. private static func activeAdjustments(_ treatments: [[String: Any]], now: Date) -> (override: String?, tempTargetMgdl: Double?) { func latest(_ eventType: String) -> (start: Date, entry: [String: Any])? { @@ -167,7 +194,8 @@ enum StandaloneFetcher { let minutes = entry["duration"] as? Double ?? 0 let indefinite = minutes == 0 || (entry["durationType"] as? String) == "indefinite" if start <= now, indefinite || now < start.addingTimeInterval(minutes * 60) { - override = entry["reason"] as? String ?? entry["notes"] as? String ?? "Override" + // Same order as the phone: Trio keeps the name in "notes", Loop in "reason". + override = entry["notes"] as? String ?? entry["reason"] as? String ?? "Override" } } diff --git a/LoopFollowWatch/WatchSession.swift b/LoopFollowWatch/WatchSession.swift index 2a0c8892c..1c7d97eac 100644 --- a/LoopFollowWatch/WatchSession.swift +++ b/LoopFollowWatch/WatchSession.swift @@ -18,6 +18,9 @@ final class WatchSession: NSObject, ObservableObject { static let phoneStaleAfter: TimeInterval = 10 * 60 /// Minimum time between two fetches made by the watch itself. private static let standaloneInterval: TimeInterval = 60 + /// After a command sent without the phone, the watch fetches this many seconds + /// after the send instead of waiting for its regular minute, until the change shows up. + private static let directFollowUpDelays: [TimeInterval] = [10, 20, 40, 60] @Published private(set) var state: WatchState? @Published private(set) var isReachable = false @@ -26,6 +29,7 @@ final class WatchSession: NSObject, ObservableObject { private var credentials: WatchCredentials? private var lastPhoneBuiltAt: Date private var lastStandaloneAttempt: Date = .distantPast + private var directFollowUp: Task? override private init() { let stored = WatchStateStore.load() @@ -74,14 +78,19 @@ final class WatchSession: NSObject, ObservableObject { return } guard Date().timeIntervalSince(lastStandaloneAttempt) >= Self.standaloneInterval else { return } + guard let fetched = await fetchStandalone(credentials), !isPhoneDataFresh else { return } + store(fetched) + } + + private func fetchStandalone(_ credentials: WatchCredentials) async -> WatchState? { lastStandaloneAttempt = Date() do { let fetched = try await StandaloneFetcher.fetch(credentials: credentials, base: state) - guard !isPhoneDataFresh else { return } - store(fetched) LogManager.shared.log(category: .nightscout, message: "Standalone fetch: \(fetched.readings.count) readings") + return fetched } catch { LogManager.shared.log(category: .nightscout, message: "Standalone fetch failed: \(error.localizedDescription)") + return nil } } @@ -114,7 +123,73 @@ final class WatchSession: NSObject, ObservableObject { guard let credentials, let config = state?.remote else { return WatchCommandReply(success: false, message: "iPhone not reachable. Open LoopFollow on the iPhone and keep it near the watch.") } - return await DirectCommandSender.send(action, credentials: credentials, config: config) + let reply = await DirectCommandSender.send(action, credentials: credentials, config: config) + if reply.success { + followUpDirectCommand(action, credentials: credentials, config: config) + } + return reply + } + + /// The phone did not send this command, so it does not fetch early for it. + /// The watch fetches a few times itself, even while the phone's last state is + /// still fresh, and stops at the first fetch that shows the change. + private func followUpDirectCommand(_ action: WatchCommand.Action, credentials: WatchCredentials, config: WatchRemoteConfig) { + guard credentials.hasDataSource, + let arrived = Self.arrivalCheck(for: action, config: config, sentAt: Date()) + else { return } + let phoneBuiltAtSend = lastPhoneBuiltAt + directFollowUp?.cancel() + directFollowUp = Task { + var elapsed: TimeInterval = 0 + for delay in Self.directFollowUpDelays { + try? await Task.sleep(nanoseconds: UInt64((delay - elapsed) * 1_000_000_000)) + elapsed = delay + guard !Task.isCancelled else { return } + guard var fetched = await fetchStandalone(credentials) else { continue } + // Once the phone is back, its states take over again; a fetch started + // before one arrived would replace it with the watch's own. + guard !Task.isCancelled, lastPhoneBuiltAt == phoneBuiltAtSend else { return } + // The watch's own fetch has no alarms. While the phone's last state is + // still fresh, its alarm stays shown, as it would without this fetch. + if isPhoneDataFresh { + fetched.alarm = state?.alarm + } + store(fetched) + if arrived(fetched) { + LogManager.shared.log(category: .nightscout, message: "Direct \(action.name) showed up after \(Int(delay)) s") + return + } + } + } + } + + /// True once a fetched state shows what the command changes. A check that never + /// matches (a partial bolus, a renamed preset) only means every follow-up fetch runs. + private static func arrivalCheck(for action: WatchCommand.Action, config: WatchRemoteConfig, sentAt: Date) -> ((WatchState) -> Bool)? { + let sent = sentAt.timeIntervalSince1970 + switch action { + case let .startOverride(presetID): + let name = config.overridePresets.first { $0.id == presetID }?.name ?? presetID + return { $0.snapshot?.override?.contains(name) == true } + case .cancelOverride: + return { $0.snapshot.map { $0.override == nil } ?? false } + case let .startTempTarget(presetID): + let target = config.tempTargetPresets.first { $0.id == presetID }?.targetMgdl + return { state in + guard let current = state.snapshot?.tempTargetMgdl else { return false } + return target.map { abs(current - $0) < 1 } ?? true + } + case .cancelTempTarget: + return { $0.snapshot.map { $0.tempTargetMgdl == nil } ?? false } + case let .bolus(units): + return { $0.boluses.contains { $0.t >= sent - 60 && abs($0.v - units) < 0.01 } } + case let .meal(carbs, _, _, _, consumedAt): + guard carbs > 0 else { return { _ in false } } + let at = consumedAt.timeIntervalSince1970 + return { $0.carbs.contains { abs($0.t - at) < 5 * 60 && abs($0.v - carbs) < 0.5 } } + case .snoozeAlarm: + return nil + } } private enum PhoneSendResult {