From b9a122b2d18cf61ec8d44d3bf4634c9065c26c95 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:18:15 -0400 Subject: [PATCH 1/4] Don't send the watch a state that hasn't changed Every Nightscout fetch rebuilds the chart, and every rebuild sent the full 24-hour state to the watch, even when nothing in it had changed. Each send can wake the watch app. The phone now skips a state equal to the last one sent, apart from its build time. It still sends at least every 5 minutes, well inside the 10 minutes after which the watch treats the phone's data as stale and fetches on its own. It always sends after the session activates or the watch changes (new install or pairing), and retries after a failed send. --- LoopFollow/Watch/WatchSessionManager.swift | 36 ++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) 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) { From 98967121280135107545cc1e4bc0676ca4fe7a69 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:32:36 -0400 Subject: [PATCH 2/4] Fetch from the watch after a command it sends without the phone When the phone is out of reach, the watch sends commands directly to Loop or Trio. Nothing then fetched the result: the watch only fetches on its own once the phone's last state is more than 10 minutes old, so a command sent soon after losing the phone could take up to 10 minutes to show. After a successful direct command, the watch now fetches 10, 20, 40 and 60 seconds after the send, even while the phone's last state is still fresh, and stops at the first fetch that shows the change: the preset's override or temp target, its cancellation, or a new bolus or carb entry with the sent amount. A check that never matches only means all four fetches run. The follow-up stops as soon as a newer state arrives from the phone, so a fetch that was already under way does not replace it. While the phone's last state is still fresh, its alarm stays shown, since the watch's own fetch has no alarms. The watch's own fetch now reads the override name from "notes" before "reason", as the phone does, so Trio overrides show the same name on both. --- LoopFollowWatch/StandaloneFetcher.swift | 3 +- LoopFollowWatch/WatchSession.swift | 81 ++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/LoopFollowWatch/StandaloneFetcher.swift b/LoopFollowWatch/StandaloneFetcher.swift index ea13ad588..8f695feca 100644 --- a/LoopFollowWatch/StandaloneFetcher.swift +++ b/LoopFollowWatch/StandaloneFetcher.swift @@ -167,7 +167,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 { From 3c0c49f81eb0d24c60bc635fda44cc37a695ecd9 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:32:36 -0400 Subject: [PATCH 3/4] Fetch Nightscout early on the phone after a remote command Loop and Trio apply a command within seconds, but the phone only saw the result on its next regular fetch: up to 2 minutes for treatments (overrides, temp targets, boluses, carbs) and about 5 minutes for device status (IOB, recommended bolus). The phone, the watch and the Live Activity showed the old state until then. After a command is sent from the phone or the watch, the phone now fetches treatments and device status 10, 20, 30, 60, 90 and 120 seconds later. It stops as soon as the command's change is visible: the active override or temp target differs from before the send, or a new bolus or carb entry with the sent amount appears. A Trio meal sent with a bolus waits for both. A newer command restarts the schedule. Each fetch schedules the next, so when the app was suspended past several of them, as in the Bluetooth heartbeat modes, it fetches once on resuming instead of all the overdue ones at once. --- .../Remote/LoopAPNS/LoopAPNSService.swift | 8 +- LoopFollow/Remote/RemoteCommandRefresh.swift | 136 ++++++++++++++++++ .../Remote/TRC/PushNotificationManager.swift | 15 +- 3 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 LoopFollow/Remote/RemoteCommandRefresh.swift 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. From 2c5c70799f4e2a018daae52a62a2ac3f16d6350f Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:32:36 -0400 Subject: [PATCH 4/4] Don't offer the watch a recommendation from before a manual bolus When the watch fetches on its own, it took the recommended bolus from the latest device status as is. If a manual bolus came after that loop cycle, the recommendation leaves that bolus out, and the watch could start the bolus screen at it. The phone already drops such a recommendation, and the watch only dropped one from before insulin it sent itself. The watch's own fetch now drops it the same way as the phone: when a manual bolus (not automatic, not an SMB) is at or after the loop cycle. This matters more now that the watch also fetches right after a command it sends without the phone, while the phone's last state is still fresh. --- LoopFollowWatch/StandaloneFetcher.swift | 31 +++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/LoopFollowWatch/StandaloneFetcher.swift b/LoopFollowWatch/StandaloneFetcher.swift index 8f695feca..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])? {