Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -479,7 +479,7 @@ class LoopAPNSService {
apnsKey: creds.apnsKey,
teamId: creds.teamId,
payload: payload,
completion: completion
completion: RemoteCommandRefresh.following(.override, completion)
)
}

Expand Down Expand Up @@ -511,7 +511,7 @@ class LoopAPNSService {
apnsKey: creds.apnsKey,
teamId: creds.teamId,
payload: payload,
completion: completion
completion: RemoteCommandRefresh.following(.override, completion)
)
}
}
136 changes: 136 additions & 0 deletions LoopFollow/Remote/RemoteCommandRefresh.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
15 changes: 9 additions & 6 deletions LoopFollow/Remote/TRC/PushNotificationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
36 changes: 34 additions & 2 deletions LoopFollow/Watch/WatchSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()
Expand Down Expand Up @@ -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)")
}

Expand All @@ -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
Expand All @@ -153,7 +176,7 @@ extension WatchSessionManager: WCSessionDelegate {
return
}
if state == .activated {
requestUpdate(reason: "activated")
resendFullState(reason: "activated")
DispatchQueue.main.async { self.sendCredentialsIfChanged(force: false) }
}
}
Expand All @@ -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) {
Expand Down
34 changes: 31 additions & 3 deletions LoopFollowWatch/StandaloneFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -112,15 +121,15 @@ 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
)
}

var remote = base?.remote
if remote != nil {
remote?.recommendedBolus = status?.recommendedBolus
remote?.recommendedBolus = recommendedBolus
remote?.recommendedBolusAt = status?.loopAt
}

Expand Down Expand Up @@ -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])? {
Expand All @@ -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"
}
}

Expand Down
Loading