diff --git a/LoopFollow/Controllers/Nightscout/NSProfile.swift b/LoopFollow/Controllers/Nightscout/NSProfile.swift index e4f5146cf..1cf19c561 100644 --- a/LoopFollow/Controllers/Nightscout/NSProfile.swift +++ b/LoopFollow/Controllers/Nightscout/NSProfile.swift @@ -36,7 +36,8 @@ struct NSProfile: Decodable { let target_low: [TargetEntry]? let timezone: String - let units: String + /// Missing when the Nightscout profile editor saved the document; the document's `units` apply then. + let units: String? } let store: [String: Store] @@ -88,6 +89,16 @@ struct NSProfile: Decodable { let trioOverrides: [TrioOverrideEntry]? let loopSettings: LoopSettings? + /// Bounds the uploader accepts for remotely edited profiles; nil when missing or malformed. + let profileEditLimits: LenientDecoded? + /// The weekly profile starts; an entry that can not be read is nil, and so is a value that is not a list. + let scheduledSwitches: LenientDecoded<[LenientDecoded]>? + let scheduledSwitchesFingerprint: String? + + /// The glucose units of a store entry: its own, or the document's. + func units(of store: Store) -> String { + store.units ?? units + } enum CodingKeys: String, CodingKey { case store @@ -103,5 +114,32 @@ struct NSProfile: Decodable { case startDate case createdAt = "created_at" case remoteCommands + case profileEditLimits + case scheduledSwitches + case scheduledSwitchesFingerprint + } +} + +extension NSProfile { + /// A store entry that can not be read is left out, so one malformed profile does not hide the others. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let entries = try container.decode([String: LenientDecoded].self, forKey: .store) + store = entries.compactMapValues(\.value) + defaultProfile = try container.decode(String.self, forKey: .defaultProfile) + units = try container.decodeIfPresent(String.self, forKey: .units) ?? TherapyProfileValues.mgdl + bundleIdentifier = try? container.decodeIfPresent(String.self, forKey: .bundleIdentifier) + isAPNSProduction = try? container.decodeIfPresent(Bool.self, forKey: .isAPNSProduction) + deviceToken = try? container.decodeIfPresent(String.self, forKey: .deviceToken) + teamID = try? container.decodeIfPresent(String.self, forKey: .teamID) + expirationDate = try? container.decodeIfPresent(String.self, forKey: .expirationDate) + startDate = try? container.decodeIfPresent(String.self, forKey: .startDate) + createdAt = try? container.decodeIfPresent(String.self, forKey: .createdAt) + remoteCommands = try? container.decodeIfPresent([String].self, forKey: .remoteCommands) + trioOverrides = try? container.decodeIfPresent([TrioOverrideEntry].self, forKey: .trioOverrides) + loopSettings = try? container.decodeIfPresent(LoopSettings.self, forKey: .loopSettings) + profileEditLimits = try container.decodeIfPresent(LenientDecoded.self, forKey: .profileEditLimits) + scheduledSwitches = try container.decodeIfPresent(LenientDecoded<[LenientDecoded]>.self, forKey: .scheduledSwitches) + scheduledSwitchesFingerprint = try? container.decodeIfPresent(String.self, forKey: .scheduledSwitchesFingerprint) } } diff --git a/LoopFollow/Controllers/Nightscout/Profile.swift b/LoopFollow/Controllers/Nightscout/Profile.swift index d88c453fa..2daaa9f2c 100644 --- a/LoopFollow/Controllers/Nightscout/Profile.swift +++ b/LoopFollow/Controllers/Nightscout/Profile.swift @@ -29,6 +29,7 @@ extension MainViewController { // NS Profile Response Processor func updateProfile(profileData: NSProfile) { guard let store = profileData.store["default"] ?? profileData.store["Default"] else { + LogManager.shared.log(category: .nightscout, message: "Profile document not loaded, its Default entry could not be read") return } profileManager.loadProfile(from: profileData) diff --git a/LoopFollow/Controllers/Nightscout/ProfileManager.swift b/LoopFollow/Controllers/Nightscout/ProfileManager.swift index 5f53d9abb..b9bed1888 100644 --- a/LoopFollow/Controllers/Nightscout/ProfileManager.swift +++ b/LoopFollow/Controllers/Nightscout/ProfileManager.swift @@ -64,10 +64,11 @@ final class ProfileManager { func loadProfile(from profileData: NSProfile) { guard let store = profileData.store[profileData.defaultProfile] else { + LogManager.shared.log(category: .nightscout, message: "Profile document not loaded, its active entry could not be read") return } - units = store.units.lowercased() == "mg/dl" ? .milligramsPerDeciliter : .millimolesPerLiter + units = profileData.units(of: store).lowercased() == "mg/dl" ? .milligramsPerDeciliter : .millimolesPerLiter defaultProfile = profileData.defaultProfile timezone = getTimeZone(from: store.timezone) @@ -126,7 +127,7 @@ final class ProfileManager { Storage.shared.teamId.value = profileData.teamID ?? Storage.shared.teamId.value ?? "" Storage.shared.remoteCommands.value = profileData.remoteCommands ?? [] - Observable.shared.loopProfiles.set(LoopProfileSet(profileData)) + Observable.shared.therapyProfiles.set(TherapyProfileSet(profileData)) } func currentISF() -> HKQuantity? { @@ -268,6 +269,6 @@ final class ProfileManager { units = .millimolesPerLiter timezone = TimeZone.current defaultProfile = "" - Observable.shared.loopProfiles.set(nil) + Observable.shared.therapyProfiles.set(nil) } } diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift index aedeb8e3e..e64f7d256 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift @@ -7,6 +7,7 @@ struct LoopAPNSRemoteView: View { @Environment(\.presentationMode) var presentationMode @StateObject private var viewModel = RemoteSettingsViewModel() @ObservedObject private var activeOverrideNote = Observable.shared.override + @ObservedObject private var remoteCommands = Storage.shared.remoteCommands @ObservedObject private var loopRemoteCommands = Storage.shared.loopRemoteCommands var body: some View { @@ -23,14 +24,14 @@ struct LoopAPNSRemoteView: View { CommandButtonView(command: "Meal", iconName: "fork.knife", destination: LoopAPNSCarbsView()) CommandButtonView(command: "Bolus", iconName: "syringe", destination: LoopAPNSBolusView()) CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: OverridePresetsView(), isActive: activeOverrideNote.value != nil) - if loopRemoteCommands.value.contains(LoopProfile.switchCommand) { - CommandButtonView(command: "Profiles", iconName: "arrow.triangle.2.circlepath", destination: LoopProfilesView()) + if ProfileRemote.loop.supportsAny(in: ProfileRemote.loop.acceptedCommands(profileDocument: remoteCommands.value, loopDeviceStatus: loopRemoteCommands.value)) { + CommandButtonView(command: "Profiles", iconName: "arrow.triangle.2.circlepath", destination: TherapyProfilesView(remote: .loop)) } else { UnavailableCommandButtonView( command: "Profiles", iconName: "arrow.triangle.2.circlepath", title: "Profiles Not Available", - message: "Switching Loop profiles from LoopFollow needs Loop built with the Profile Save & Load customization, added with the Loop and Learn Customization Select script." + message: "Loop has not said it accepts profile commands. It does once it has a pump and a maximum basal rate set up and can receive remote commands. An older Loop also needs the Profile Save & Load customization, added with the Loop and Learn Customization Select script." ) } } @@ -80,3 +81,36 @@ struct LoopAPNSRemoteView: View { } } } + +/// A remote command button for a command the connected Loop build does not offer. +struct UnavailableCommandButtonView: View { + let command: String + let iconName: String + let title: String + let message: String + + @State private var showInfo = false + + var body: some View { + Button { + showInfo = true + } label: { + VStack { + Image(systemName: iconName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 50, height: 50) + Text(command) + } + .frame(maxWidth: .infinity, minHeight: 100) + .padding() + .background(Color.gray.opacity(0.35)) + .foregroundColor(.white) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + .alert(isPresented: $showInfo) { + Alert(title: Text(title), message: Text(message), dismissButton: .default(Text("OK"))) + } + } +} diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index e5a641cca..d586d165a 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -123,14 +123,30 @@ class LoopAPNSService { } /// Sends a command whose only confirmation is Loop's return push, so the encrypted return block is mandatory. - private func sendConfirmedCommand(_ payload: [String: Any], otp: String, completion: @escaping (Bool, String?) -> Void) { - guard let encryptedReturnInfo = encryptedReturnNotification(otp: otp) else { + /// `tooLargeMessage` explains a push over the APNs size limit; such a push is never sent. + private func sendConfirmedCommand( + _ payload: [String: Any], + otp: String, + tooLargeMessage: ((Int) -> String)? = nil, + completion: @escaping (Bool, String?) -> Void + ) { + guard let payload = confirmedPayload(payload, otp: otp) else { completion(false, Self.returnNotificationRequiredMessage) return } + if let tooLargeMessage, let size = try? LoopAPNSCommands.apnsBody(payload).count, size > APNSDelivery.maxPayloadBytes { + LogManager.shared.log(category: .apns, message: "Command not sent: \(size) bytes") + completion(false, tooLargeMessage(size)) + return + } + sendRemoteCommand(payload, completion: completion) + } + + private func confirmedPayload(_ payload: [String: Any], otp: String) -> [String: Any]? { + guard let encryptedReturnInfo = encryptedReturnNotification(otp: otp) else { return nil } var payload = payload payload["encrypted_return_notification"] = encryptedReturnInfo - sendRemoteCommand(payload, completion: completion) + return payload } /// Deletes the Loop carb entry with this `syncIdentifier`. Requires the Loop remote carb edit patch. @@ -163,10 +179,61 @@ class LoopAPNSService { /// Switches Loop to the saved profile `name`. Requires the Loop remote profile patch. func sendProfileSwitch(name: String, fingerprint: String, otp: String, completion: @escaping (Bool, String?) -> Void) { - LogManager.shared.log(category: .apns, message: "Sending profile switch to \(name) fingerprint=\(fingerprint)") + LogManager.shared.log(category: .apns, message: "Sending profile switch fingerprint=\(fingerprint)") sendConfirmedCommand(LoopAPNSCommands.profileSwitchCommandPayload(name: name, fingerprint: fingerprint, otp: otp), otp: otp, completion: completion) } + /// Creates, updates or renames a Loop profile. Requires a Loop that lists `profile-save` in its remote commands. + func sendProfileSave( + name: String, + originalName: String?, + baseFingerprint: String?, + values: TherapyProfileValues, + otp: String, + completion: @escaping (Bool, String?) -> Void + ) { + LogManager.shared.log(category: .apns, message: "Sending profile save base=\(baseFingerprint ?? "-") new=\(values.fingerprint)") + sendConfirmedCommand( + LoopAPNSCommands.profileSaveCommandPayload(name: name, originalName: originalName, baseFingerprint: baseFingerprint, values: values, otp: otp), + otp: otp, + tooLargeMessage: ProfilePushSize.tooLargeMessage, + completion: completion + ) + } + + func sendProfileDelete(name: String, fingerprint: String, otp: String, completion: @escaping (Bool, String?) -> Void) { + LogManager.shared.log(category: .apns, message: "Sending profile delete fingerprint=\(fingerprint)") + sendConfirmedCommand(LoopAPNSCommands.profileDeleteCommandPayload(name: name, fingerprint: fingerprint, otp: otp), otp: otp, completion: completion) + } + + /// Replaces Loop's scheduled switches. Requires a Loop that lists `profile-scheduled-switches-save` in its remote commands. + func sendScheduledSwitchesSave(entries: [ScheduledProfileSwitch], baseFingerprint: String, otp: String, completion: @escaping (Bool, String?) -> Void) { + LogManager.shared.log(category: .apns, message: "Sending scheduled switches save base=\(baseFingerprint) new=\(ScheduledProfileSwitch.fingerprint(entries))") + sendConfirmedCommand( + LoopAPNSCommands.scheduledSwitchesSaveCommandPayload(entries: entries, baseFingerprint: baseFingerprint, otp: otp), + otp: otp, + tooLargeMessage: ScheduledSwitchesPushSize.tooLargeMessage, + completion: completion + ) + } + + /// The size of the push a scheduled switches save would send. Nil when the push can not be built. + func scheduledSwitchesSavePushSize(entries: [ScheduledProfileSwitch], baseFingerprint: String) -> Int? { + let otp = "000000" + let payload = LoopAPNSCommands.scheduledSwitchesSaveCommandPayload(entries: entries, baseFingerprint: baseFingerprint, otp: otp) + guard let confirmed = confirmedPayload(payload, otp: otp) else { return nil } + return try? LoopAPNSCommands.apnsBody(confirmed).count + } + + /// The size of the push a profile save would send, with a stand-in one-time code of the same length. + /// Nil when the push can not be built (LoopFollow's own APNs credentials are missing). + func profileSavePushSize(name: String, originalName: String?, baseFingerprint: String?, values: TherapyProfileValues) -> Int? { + let otp = "000000" + let payload = LoopAPNSCommands.profileSaveCommandPayload(name: name, originalName: originalName, baseFingerprint: baseFingerprint, values: values, otp: otp) + guard let confirmed = confirmedPayload(payload, otp: otp) else { return nil } + return try? LoopAPNSCommands.apnsBody(confirmed).count + } + /// Sends carbs via APNS push notification /// - Parameters: /// - payload: The carbs payload to send diff --git a/LoopFollow/Remote/LoopAPNS/LoopProfile.swift b/LoopFollow/Remote/LoopAPNS/LoopProfile.swift deleted file mode 100644 index 99b9c7a5b..000000000 --- a/LoopFollow/Remote/LoopAPNS/LoopProfile.swift +++ /dev/null @@ -1,108 +0,0 @@ -// LoopFollow -// LoopProfile.swift - -import CryptoKit -import Foundation -import HealthKit - -/// A saved Loop profile from the Nightscout profile store, offered for a remote switch. -struct LoopProfile: Identifiable, Equatable { - struct Item: Equatable { - let seconds: Int - let value: Double - } - - struct RangeItem: Equatable { - let seconds: Int - let low: Double - let high: Double - } - - /// Loop lists this in `remoteCommands` when it accepts remote profile switches. - static let switchCommand = "profile-switch" - - let name: String - let units: HKUnit - let basal: [Item] - let carbRatio: [Item] - let sensitivity: [Item] - let correctionRange: [RangeItem] - /// Sent with the switch command. Loop only switches when its saved profile has the same fingerprint. - let fingerprint: String - - var id: String { name } - - init(name: String, store: NSProfile.Store) { - func items(_ entries: [(Double, Double)]) -> [Item] { - entries.map { Item(seconds: Int($0.0), value: $0.1) } - } - let basal = items(store.basal.map { ($0.timeAsSeconds, $0.value) }) - let carbRatio = items(store.carbratio.map { ($0.timeAsSeconds, $0.value) }) - let sensitivity = items(store.sens.map { ($0.timeAsSeconds, $0.value) }) - let targetLow = items((store.target_low ?? []).map { ($0.timeAsSeconds, $0.value) }) - let targetHigh = items((store.target_high ?? []).map { ($0.timeAsSeconds, $0.value) }) - - self.name = name - units = store.units.lowercased() == "mg/dl" ? .milligramsPerDeciliter : .millimolesPerLiter - self.basal = basal - self.carbRatio = carbRatio - self.sensitivity = sensitivity - correctionRange = zip(targetLow, targetHigh).map { RangeItem(seconds: $0.seconds, low: $0.value, high: $1.value) } - fingerprint = Self.fingerprint(basal: basal, carbRatio: carbRatio, sensitivity: sensitivity, targetLow: targetLow, targetHigh: targetHigh) - } - - /// Scheduled basal over a full day, in units. - var basalTotal: Double { - basal.enumerated().reduce(0) { total, entry in - let end = entry.offset + 1 < basal.count ? basal[entry.offset + 1].seconds : 24 * 3600 - return total + entry.element.value * Double(end - entry.element.seconds) / 3600 - } - } - - /// Saved profiles in a Loop profile document, by name. `Default` mirrors the active settings and is left out. - static func profiles(from profile: NSProfile) -> [LoopProfile] { - profile.store - .filter { $0.key != "Default" } - .map { LoopProfile(name: $0.key, store: $0.value) } - .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } - } - - /// Matches Loop's fingerprint of a profile store entry: - /// `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` with values to three - /// decimals, hashed with SHA-256, first 16 hex characters. - static func fingerprint(basal: [Item], carbRatio: [Item], sensitivity: [Item], targetLow: [Item], targetHigh: [Item]) -> String { - func encode(_ items: [Item]) -> String { - items.map { "\($0.seconds):" + String(format: "%.3f", $0.value) }.joined(separator: ",") - } - let canonical = [ - "basal=" + encode(basal), - "carbratio=" + encode(carbRatio), - "sens=" + encode(sensitivity), - "target_low=" + encode(targetLow), - "target_high=" + encode(targetHigh), - ].joined(separator: ";") - let digest = SHA256.hash(data: Data(canonical.utf8)) - return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) - } -} - -/// The saved profiles from the latest Loop profile upload. -struct LoopProfileSet: Equatable { - let profiles: [LoopProfile] - let activeName: String - let uploadedAt: Date? - - init(profiles: [LoopProfile], activeName: String, uploadedAt: Date?) { - self.profiles = profiles - self.activeName = activeName - self.uploadedAt = uploadedAt - } - - init(_ profile: NSProfile) { - self.init( - profiles: LoopProfile.profiles(from: profile), - activeName: profile.defaultProfile, - uploadedAt: profile.startDate.flatMap { NightscoutUtils.parseDate($0) } - ) - } -} diff --git a/LoopFollow/Remote/LoopAPNS/LoopProfilesView.swift b/LoopFollow/Remote/LoopAPNS/LoopProfilesView.swift deleted file mode 100644 index 05733b530..000000000 --- a/LoopFollow/Remote/LoopAPNS/LoopProfilesView.swift +++ /dev/null @@ -1,272 +0,0 @@ -// LoopFollow -// LoopProfilesView.swift - -import HealthKit -import SwiftUI - -/// Lists the profiles saved in Loop. Selecting one shows its values before it can be activated. -struct LoopProfilesView: View { - @ObservedObject private var loopProfiles = Observable.shared.loopProfiles - - var body: some View { - List { - if let profileSet = loopProfiles.value, !profileSet.profiles.isEmpty { - Section( - header: Text("Saved profiles"), - footer: uploadedFooter(profileSet.uploadedAt) - ) { - ForEach(profileSet.profiles) { profile in - NavigationLink(destination: LoopProfilePreviewView(profile: profile)) { - HStack { - Text(profile.name) - Spacer() - if profile.name == profileSet.activeName { - Text("Active") - .font(.caption) - .foregroundColor(.green) - } - } - } - } - } - } else { - Text("No saved profiles found. Profiles are saved in Loop under Settings → Profiles.") - .foregroundColor(.secondary) - .italic() - } - } - .navigationBarTitle("Remote Profiles", displayMode: .inline) - } - - private func uploadedFooter(_ date: Date?) -> Text { - guard let date else { return Text("") } - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .short - return Text("Uploaded by Loop \(formatter.string(from: date)).") - } -} - -/// Shows the values a profile switch would activate, the same sections Loop shows before loading a profile. -struct LoopProfilePreviewView: View { - let profile: LoopProfile - - @Environment(\.presentationMode) private var presentationMode - @ObservedObject private var loopProfiles = Observable.shared.loopProfiles - @ObservedObject private var tracker = RemoteCommandTracker.shared - @State private var alertType: AlertType? - - private enum AlertType: Identifiable { - case confirm - case result(success: Bool, message: String) - - var id: String { - switch self { - case .confirm: return "confirm" - case let .result(success, message): return "result-\(success)-\(message)" - } - } - } - - private var commandKey: String { - LoopProfileAckAdapter.key(for: profile.name) - } - - private var isActive: Bool { - loopProfiles.value?.activeName == profile.name - } - - private var isSending: Bool { - tracker.isBusy(key: commandKey) - } - - /// Only one profile switch is in flight at a time. - private var otherSwitchPending: Bool { - !isSending && tracker.isBusy(keyPrefix: LoopProfileAckAdapter.keyPrefix) - } - - private var commandState: RemoteCommandTracker.State? { - tracker.states[commandKey] - } - - var body: some View { - List { - Section(footer: Text("Loop writes the basal rates to the pump when the profile is activated.")) { - Text("Review the profile before activating it in Loop.") - } - - Section(header: Text("Correction Range")) { - ForEach(profile.correctionRange, id: \.seconds) { item in - LabeledValueRow( - label: Self.timeText(item.seconds), - value: "\(glucoseText(item.low)) – \(glucoseText(item.high)) \(glucoseUnitText)" - ) - } - } - - Section(header: Text("Carb Ratios")) { - ForEach(profile.carbRatio, id: \.seconds) { item in - LabeledValueRow( - label: Self.timeText(item.seconds), - value: "\(Localizer.formatToLocalizedString(item.value, maxFractionDigits: 1)) g/U" - ) - } - } - - Section(header: Text("Basal Rates")) { - ForEach(profile.basal, id: \.seconds) { item in - LabeledValueRow( - label: Self.timeText(item.seconds), - value: "\(Localizer.formatToLocalizedString(item.value, maxFractionDigits: 3)) U/hr" - ) - } - HStack { - Text("Total").bold() - Spacer() - Text("\(Localizer.formatToLocalizedString(profile.basalTotal, maxFractionDigits: 2, minFractionDigits: 2)) U/day") - .foregroundColor(.secondary) - } - } - - Section(header: Text("Insulin Sensitivities")) { - ForEach(profile.sensitivity, id: \.seconds) { item in - LabeledValueRow( - label: Self.timeText(item.seconds), - value: "\(glucoseText(item.value)) \(glucoseUnitText)/U" - ) - } - } - } - .safeAreaInset(edge: .bottom) { - Button { - alertType = .confirm - } label: { - if isSending { - HStack { - ProgressView().scaleEffect(0.8) - Text("Waiting for Loop...") - } - .frame(maxWidth: .infinity) - } else if otherSwitchPending { - Text("Another Switch Is Pending").frame(maxWidth: .infinity) - } else { - Text(isActive ? "Active Profile" : "Activate Profile").frame(maxWidth: .infinity) - } - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(isActive || isSending || otherSwitchPending) - .padding(.horizontal) - .padding(.vertical, 8) - .background(.bar) - } - .navigationBarTitle(profile.name, displayMode: .inline) - .onAppear { showResult(commandState) } - .onChange(of: commandState) { _, state in showResult(state) } - .alert(item: $alertType) { type in - switch type { - case .confirm: - return Alert( - title: Text("Activate \(profile.name) in Loop?"), - message: Text("Loop replaces its correction range, carb ratios, basal rates and insulin sensitivities with these values and writes the basal rates to the pump."), - primaryButton: .default(Text("Activate"), action: authenticateAndSend), - secondaryButton: .cancel() - ) - case let .result(success, message): - return Alert( - title: Text(success ? "Profile Activated" : message == LoopProfileAckAdapter.timeoutMessage ? "No Reply From Loop" : "Profile Not Activated"), - message: Text(message), - dismissButton: .default(Text("OK")) { - if success { presentationMode.wrappedValue.dismiss() } - } - ) - } - } - } - - /// Shows a finished command once, also when the view was left while it was pending. - private func showResult(_ state: RemoteCommandTracker.State?) { - guard case let .done(success, message) = state else { return } - tracker.consume(key: commandKey) - presentResult(success: success, message: message) - } - - /// A result that arrives at once comes while the confirmation alert is still closing. - private func presentResult(success: Bool, message: String) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - alertType = .result(success: success, message: message) - } - } - - private func authenticateAndSend() { - AuthService.authenticate(reason: "Confirm your identity to activate \(profile.name) in Loop.") { result in - switch result { - case .success: - send() - case let .unavailable(message): - presentResult(success: false, message: message) - case .failed: - presentResult(success: false, message: "Authentication failed") - case .canceled: - break - } - } - } - - private func send() { - RemoteCommandTracker.shared.sendLoopProfileSwitch(profile: profile) { success, error in - if !success { - LogManager.shared.log(category: .apns, message: "Profile switch not sent: \(error ?? "unknown error")") - } - } - } - - private var glucoseUnitText: String { - Localizer.getPreferredUnit() == .milligramsPerDeciliter ? "mg/dL" : "mmol/L" - } - - private func glucoseText(_ value: Double) -> String { - Localizer.formatQuantity(HKQuantity(unit: profile.units, doubleValue: value)) - } - - /// Schedule start times are offsets from midnight in the profile's time zone. - static func timeText(_ seconds: Int) -> String { - let formatter = DateFormatter() - formatter.timeStyle = .short - formatter.timeZone = TimeZone(identifier: "UTC") - return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(seconds))) - } -} - -/// A remote command button for a command the connected Loop build does not offer. -struct UnavailableCommandButtonView: View { - let command: String - let iconName: String - let title: String - let message: String - - @State private var showInfo = false - - var body: some View { - Button { - showInfo = true - } label: { - VStack { - Image(systemName: iconName) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 50, height: 50) - Text(command) - } - .frame(maxWidth: .infinity, minHeight: 100) - .padding() - .background(Color.gray.opacity(0.35)) - .foregroundColor(.white) - .cornerRadius(8) - } - .buttonStyle(PlainButtonStyle()) - .alert(isPresented: $showInfo) { - Alert(title: Text(title), message: Text(message), dismissButton: .default(Text("OK"))) - } - } -} diff --git a/LoopFollow/Remote/Profiles/ProfileCommandFlow.swift b/LoopFollow/Remote/Profiles/ProfileCommandFlow.swift new file mode 100644 index 000000000..2fb89a707 --- /dev/null +++ b/LoopFollow/Remote/Profiles/ProfileCommandFlow.swift @@ -0,0 +1,205 @@ +// LoopFollow +// ProfileCommandFlow.swift + +import SwiftUI + +/// Device authentication and sending for profile commands. The result arrives through the tracker state. +enum ProfileCommandFlow { + static func authenticateAndSend( + _ command: ProfileCommand, + to remote: ProfileRemote, + reason: String, + onFailure: @escaping (String) -> Void + ) { + AuthService.authenticate(reason: reason) { result in + switch result { + case .success: + RemoteCommandTracker.shared.sendProfileCommand(command, to: remote) { success, error in + if !success { + LogManager.shared.log(category: .apns, message: "Profile command not sent: \(error ?? "unknown error")") + } + } + case let .unavailable(message): + onFailure(message) + case .failed: + onFailure("Authentication failed") + case .canceled: + break + } + } + } + + static func resultTitle(kind: ProfileCommandKind, success: Bool, message: String, remote: ProfileRemote) -> String { + if message == remote.timeoutMessage { return "No Reply From \(remote.appName)" } + switch (kind, success) { + case (.activate, true): return "Profile Activated" + case (.activate, false): return "Profile Not Activated" + case (.save, true): return "Profile Saved" + case (.save, false): return "Profile Not Saved" + case (.delete, true): return "Profile Deleted" + case (.delete, false): return "Profile Not Deleted" + case (.saveScheduledSwitches, true): return "Scheduled Switches Saved" + case (.saveScheduledSwitches, false): return "Scheduled Switches Not Saved" + } + } + + /// True when the screen that sent the command closes after the result: on success; when no reply came, + /// because the change may have been applied; and when the profile changed or is gone on the phone, because + /// the screen's values are out of date. The profile list then shows the uploaded profiles. Any other refusal + /// keeps the screen open so the values can be corrected. + static func leavesAfterResult(_ result: ProfileCommandResult, remote: ProfileRemote) -> Bool { + result.success || result.outdated || result.message == remote.timeoutMessage + } + + /// The reply text, with a note on what happens next when the profile was out of date. + static func resultMessage(_ result: ProfileCommandResult, remote: ProfileRemote) -> String { + guard result.outdated else { return result.message } + return result.message + "\n\nLoopFollow is loading the profiles \(remote.appName) uploaded. Open the profile again to see its current values." + } + + /// Loads the newest profile document from Nightscout. Completes on the main queue, with nil when it can not be loaded. + static func fetchProfileSet(completion: @escaping (TherapyProfileSet?) -> Void) { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let parameters = ["count": "1", "find[startDate][$lte]": formatter.string(from: Date().addingTimeInterval(60))] + NightscoutUtils.executeRequest(eventType: .profile, parameters: parameters) { (result: Result<[NSProfile], Error>) in + let profileSet = (try? result.get())?.first.map(TherapyProfileSet.init) + DispatchQueue.main.async { completion(profileSet) } + } + } + + /// Why a save of `original`, reviewed while it was `reviewedActive`, must not be sent now that `current` is the + /// newest profile document, or nil when it can be sent. + static func saveStaleReason(original: TherapyProfile, reviewedActive: Bool, current: TherapyProfileSet?, remote: ProfileRemote) -> String? { + guard let current else { + return "LoopFollow could not load the profiles from Nightscout to check that “\(original.name)” is unchanged, so nothing was sent. Try again." + } + guard let profile = current.profile(named: original.name) else { + return "“\(original.name)” is no longer in the list uploaded by \(remote.appName), so nothing was sent." + } + if profile.fingerprint != original.fingerprint { + return "“\(original.name)” changed in \(remote.appName) while you were editing, so nothing was sent. Review it again." + } + if current.isActive(profile) != reviewedActive { + return reviewedActive + ? "“\(original.name)” is no longer the active profile in \(remote.appName), so nothing was sent. Review it again." + : "“\(original.name)” became active in \(remote.appName) while you were editing, so nothing was sent. Review it again." + } + return nil + } + + /// The tracker keys of all profile commands for this app. + static func isAnyPending(_ tracker: RemoteCommandTracker, remote: ProfileRemote) -> Bool { + tracker.isBusy(keyPrefixes: remote.keyPrefixes) + } +} + +/// The outcome of a profile command as the tracker reports it. +struct ProfileCommandResult: Equatable { + let success: Bool + let message: String + /// The phone refused because the profile changed or no longer exists there. + var outdated = false +} + +/// Reports a finished profile command once, also when the view was left while it was pending. +struct ProfileCommandResultModifier: ViewModifier { + let keys: [String] + /// When set, the result of another command sent under the same key is dropped without a report. + let command: ProfileCommand? + let onResult: (String, ProfileCommandResult) -> Void + + @ObservedObject private var tracker = RemoteCommandTracker.shared + + func body(content: Content) -> some View { + content + .onAppear { keys.forEach(check) } + .onChange(of: keys.map { tracker.states[$0] }) { _, _ in keys.forEach(check) } + } + + private func check(_ key: String) { + guard case let .done(success, message, outdated) = tracker.states[key] else { return } + tracker.consume(key: key) + if let command, tracker.profileCommands[key] != command { return } + // A result that arrives at once comes while the confirmation alert is still closing. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + onResult(key, ProfileCommandResult(success: success, message: message, outdated: outdated)) + } + } +} + +extension View { + func onProfileCommandResult( + keys: [String], + command: ProfileCommand? = nil, + perform: @escaping (String, ProfileCommandResult) -> Void + ) -> some View { + modifier(ProfileCommandResultModifier(keys: keys, command: command, onResult: perform)) + } +} + +/// The note shown where profiles are introduced and before a change is sent. +struct ProfileDosingNote: View { + var body: some View { + Text("Profiles hold the settings used to dose insulin.") + } +} + +/// Lists where a profile gives more insulin than the one it is compared with. +struct MoreInsulinWarning: View { + let increases: [ProfileInsulinIncrease] + let basalTotal: (old: Double, new: Double)? + /// The profile the values are compared with. + let comparedWith: String + /// True when dosing changes as soon as the command arrives. + let appliesNow: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Label { + Text("More insulin than “\(comparedWith)”") + .font(.callout.weight(.semibold)) + } icon: { + Image(systemName: "arrow.up.circle.fill") + } + .foregroundColor(.red) + ForEach(increases) { increase in + Text("• \(increase.text)") + } + if let basalTotal { + Text("• Basal per day \(Self.units(basalTotal.old)) → \(Self.units(basalTotal.new)) U") + } + Text(appliesNow + ? "More insulin raises the risk of lows. Check that these values are what was agreed, and watch glucose closely after sending." + : "More insulin raises the risk of lows once this profile is active. Check that these values are what was agreed.") + .font(.footnote) + .foregroundColor(.secondary) + } + .accessibilityElement(children: .combine) + } + + static func units(_ value: Double) -> String { + Localizer.formatToLocalizedString(value, maxFractionDigits: 2, minFractionDigits: 2) + } + + /// One sentence for a confirmation alert, empty when nothing gives more insulin. + static func sentence(_ increases: [ProfileInsulinIncrease], comparedWith: String) -> String { + guard !increases.isEmpty else { return "" } + return "\n\nThis gives more insulin than “\(comparedWith)”: \(increases.map(\.text).joined(separator: "; "))." + } +} + +/// The warning shown when a change applies to the profile that is in use. +struct ActiveProfileWarning: View { + let remote: ProfileRemote + + var body: some View { + Label { + Text("This is the active profile. Saving changes insulin dosing right away: \(remote.appName) sends the new basal rates to the pump and starts using all new values as soon as it gets the command.") + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .foregroundColor(.red) + .font(.callout.weight(.semibold)) + } +} diff --git a/LoopFollow/Remote/Profiles/ProfileDraft.swift b/LoopFollow/Remote/Profiles/ProfileDraft.swift new file mode 100644 index 000000000..3dbd16e9e --- /dev/null +++ b/LoopFollow/Remote/Profiles/ProfileDraft.swift @@ -0,0 +1,124 @@ +// LoopFollow +// ProfileDraft.swift + +import Foundation + +enum ProfilePushSize { + /// The largest measured size the review accepts. Trio's push is encrypted with a fresh nonce and timestamp at + /// send time, which changes its size by a few bytes, so it keeps a margin; Loop's measured size is the size sent. + static func reviewLimit(for remote: ProfileRemote) -> Int { + APNSDelivery.maxPayloadBytes - (remote == .trio ? 64 : 0) + } + + static func tooLargeMessage(_ size: Int) -> String { + "This profile is too large to send remotely (\(size) of \(APNSDelivery.maxPayloadBytes) bytes). Use fewer time segments." + } +} + +enum ScheduledSwitchesPushSize { + static func tooLargeMessage(_ size: Int) -> String { + "The scheduled switches are too large to send remotely (\(size) of \(APNSDelivery.maxPayloadBytes) bytes). Use shorter profile names." + } +} + +/// One editable schedule entry. Single-value schedules use `value`; the correction range uses `value` as low and `high`. +struct EditableSegment: Identifiable, Equatable { + let id: UUID + var seconds: Int + var value: Double + var high: Double + + init(id: UUID = UUID(), seconds: Int, value: Double, high: Double? = nil) { + self.id = id + self.seconds = seconds + self.value = value + self.high = high ?? value + } +} + +/// The schedules of a profile while they are edited. +struct ProfileDraft: Equatable { + let units: String + var basal: [EditableSegment] + var carbRatio: [EditableSegment] + var sensitivity: [EditableSegment] + var target: [EditableSegment] + + /// With `singleTarget`, each target's high value is its low value. + init(_ values: TherapyProfileValues, singleTarget: Bool = false) { + units = values.units + basal = values.basal.map { EditableSegment(seconds: $0.seconds, value: $0.value) } + carbRatio = values.carbRatio.map { EditableSegment(seconds: $0.seconds, value: $0.value) } + sensitivity = values.sensitivity.map { EditableSegment(seconds: $0.seconds, value: $0.value) } + target = values.target.map { EditableSegment(seconds: $0.seconds, value: $0.low, high: singleTarget ? $0.low : $0.high) } + } + + var values: TherapyProfileValues { + func items(_ segments: [EditableSegment]) -> [ProfileScheduleItem] { + segments.map { ProfileScheduleItem(seconds: $0.seconds, value: $0.value) } + } + return TherapyProfileValues( + units: units, + basal: items(basal), + carbRatio: items(carbRatio), + sensitivity: items(sensitivity), + target: target.map { ProfileRangeItem(seconds: $0.seconds, low: $0.value, high: $0.high) } + ) + } + + subscript(kind: ProfileScheduleKind) -> [EditableSegment] { + get { + switch kind { + case .basal: return basal + case .carbRatio: return carbRatio + case .sensitivity: return sensitivity + case .correctionRange: return target + } + } + set { + switch kind { + case .basal: basal = newValue + case .carbRatio: carbRatio = newValue + case .sensitivity: sensitivity = newValue + case .correctionRange: target = newValue + } + } + } +} + +/// Segment operations on the time grid of the published limits. +enum ProfileSegmentGrid { + /// The start time a new segment gets: one step after the last segment, or the first free slot when the + /// last segment is at the end of the day. Nil when the schedule is full. + static func newSegmentStart(_ segments: [EditableSegment], segmentSeconds: Int, maxSegments: Int) -> Int? { + guard segments.count < maxSegments, segmentSeconds > 0 else { return nil } + let starts = segments.map(\.seconds).sorted() + if let last = starts.last, last + segmentSeconds < 86400 { + return (last / segmentSeconds + 1) * segmentSeconds + } + var slot = 0 + while slot < 86400 { + if !starts.contains(slot) { return slot } + slot += segmentSeconds + } + return nil + } + + /// Adds a segment that starts with the value in effect at its start time. + static func addSegment(to segments: [EditableSegment], segmentSeconds: Int, maxSegments: Int) -> [EditableSegment] { + guard let start = newSegmentStart(segments, segmentSeconds: segmentSeconds, maxSegments: maxSegments) else { return segments } + let current = segments.last { $0.seconds <= start } ?? segments.first + var result = segments + result.append(EditableSegment(seconds: start, value: current?.value ?? 0, high: current?.high)) + return result.sorted { $0.seconds < $1.seconds } + } + + /// Start times a segment can move to: between its neighbours on the grid. The first segment stays at midnight. + static func allowedStarts(for index: Int, in segments: [EditableSegment], segmentSeconds: Int) -> [Int] { + guard index > 0, index < segments.count, segmentSeconds > 0 else { return [0] } + let lower = segments[index - 1].seconds + segmentSeconds + let upper = index + 1 < segments.count ? segments[index + 1].seconds : 86400 + let first = (lower + segmentSeconds - 1) / segmentSeconds * segmentSeconds + return Array(stride(from: first, to: upper, by: segmentSeconds)) + } +} diff --git a/LoopFollow/Remote/Profiles/ProfileEditLimits.swift b/LoopFollow/Remote/Profiles/ProfileEditLimits.swift new file mode 100644 index 000000000..b440a19b7 --- /dev/null +++ b/LoopFollow/Remote/Profiles/ProfileEditLimits.swift @@ -0,0 +1,120 @@ +// LoopFollow +// ProfileEditLimits.swift + +import Foundation + +/// Decodes `T` when it can and leaves `value` nil otherwise, so a malformed optional block +/// does not fail the whole document. +struct LenientDecoded: Decodable { + let value: T? + + init(_ value: T?) { + self.value = value + } + + init(from decoder: Decoder) throws { + value = try? T(from: decoder) + } +} + +/// The bounds Loop or Trio publishes in its profile document (`profileEditLimits`) for remotely edited profiles. +/// Glucose values and steps are in `units`. LoopFollow offers only values inside these bounds. +struct ProfileEditLimits: Decodable, Equatable { + struct Range: Decodable, Equatable { + let min: Double + let max: Double + let step: Double + let recommendedMin: Double? + let recommendedMax: Double? + + var isUsable: Bool { + min.isFinite && max.isFinite && step.isFinite && step > 0 && min <= max && (max - min) / step <= 20000 + } + + /// Every value from `min` to `max` in steps of `step`, rounded to the step's decimals. + var values: [Double] { + guard isUsable else { return [] } + let decimals = ProfileEditLimits.decimals(of: step) + let count = Int(((max - min) / step + 1e-9).rounded(.down)) + return (0 ... count).map { ProfileEditLimits.round(min + Double($0) * step, decimals: decimals) } + } + + func contains(_ value: Double) -> Bool { + value >= min - ProfileEditLimits.tolerance && value <= max + ProfileEditLimits.tolerance + } + + /// True when the value is inside the hard bounds but outside the recommended ones. + func isOutsideRecommended(_ value: Double) -> Bool { + if let recommendedMin, value < recommendedMin - ProfileEditLimits.tolerance { return true } + if let recommendedMax, value > recommendedMax + ProfileEditLimits.tolerance { return true } + return false + } + } + + static let tolerance = 1e-9 + + let units: String + let maxBasal: Double + let basalRates: [Double] + let basalMinimum: Double? + let carbRatio: Range + let sensitivity: Range + let target: Range + let maxSegments: Int + /// The pump's basal schedule entry limit, when the app publishes one. + let maxBasalSegments: Int? + let segmentSeconds: Int + + /// Basal rates the editor offers: the supported rates up to max basal, from `basalMinimum` when set. + var allowedBasalRates: [Double] { + basalRates + .filter { $0 <= maxBasal + Self.tolerance && $0 >= (basalMinimum ?? -.infinity) - Self.tolerance } + .sorted() + } + + func isAllowedBasalRate(_ rate: Double) -> Bool { + allowedBasalRates.contains { abs($0 - rate) < Self.tolerance } + } + + /// The most segments a schedule may have: the published maximum, never more than one per segment slot. + var segmentLimit: Int { + min(maxSegments, 86400 / segmentSeconds) + } + + /// The most segments a schedule of `kind` may have; basal also stays within the pump's entry limit. + func segmentLimit(for kind: ProfileScheduleKind) -> Int { + guard kind == .basal, let maxBasalSegments else { return segmentLimit } + return min(segmentLimit, maxBasalSegments) + } + + /// False when the published limits are inconsistent; the editor is then disabled. + var isUsable: Bool { + segmentSeconds > 0 && 86400 % segmentSeconds == 0 && maxSegments > 0 && (maxBasalSegments ?? 1) > 0 + && !allowedBasalRates.isEmpty + && carbRatio.isUsable && sensitivity.isUsable && target.isUsable + } + + var normalizedUnits: String { + TherapyProfileValues.normalizedUnits(units) + } + + /// The number of decimals `step` needs, at most 4. + static func decimals(of step: Double) -> Int { + for decimals in 0 ... 4 { + let scaled = step * pow(10, Double(decimals)) + if abs(scaled - scaled.rounded()) < 1e-6 { return decimals } + } + return 4 + } + + static func round(_ value: Double, decimals: Int) -> Double { + let factor = pow(10, Double(decimals)) + return (value * factor).rounded() / factor + } +} + +extension NSProfile { + var editLimits: ProfileEditLimits? { + profileEditLimits?.value + } +} diff --git a/LoopFollow/Remote/Profiles/ProfileEditorSheet.swift b/LoopFollow/Remote/Profiles/ProfileEditorSheet.swift new file mode 100644 index 000000000..b4deffa01 --- /dev/null +++ b/LoopFollow/Remote/Profiles/ProfileEditorSheet.swift @@ -0,0 +1,197 @@ +// LoopFollow +// ProfileEditorSheet.swift + +import SwiftUI + +enum ProfileEditing { + /// Why profiles of this set can not be edited remotely, or nil when they can. + static func blocker(limits: ProfileEditLimits?, remote: ProfileRemote) -> String? { + guard let limits else { + let missing = "\(remote.appName) has not shared the limits LoopFollow needs to change profiles safely, so profiles can only be viewed here." + switch remote { + case .loop: + return missing + " Loop shares them once a pump and a maximum basal rate are set up in it; an older version of Loop may need an update first." + case .trio: + return missing + " An older version of Trio may need an update first." + } + } + guard limits.isUsable else { + return "The limits \(remote.appName) shared are incomplete, so profiles can only be viewed here." + } + return nil + } + + /// Why this profile can not be edited remotely, or nil when it can. + static func blocker(for profile: TherapyProfile, limits: ProfileEditLimits?, remote: ProfileRemote) -> String? { + if let blocker = blocker(limits: limits, remote: remote) { return blocker } + if let limits, profile.values.units != limits.normalizedUnits { + return "This profile is stored in \(profile.values.units), but \(remote.appName) shared its limits in \(limits.normalizedUnits). Change it in \(remote.appName) instead." + } + if !profile.isWellFormed { + return "The correction range of this profile could not be read completely. Change it in \(remote.appName) instead." + } + return nil + } +} + +/// Creates a profile from an existing one, or edits one, and leads to the review before sending. +struct ProfileEditorSheet: View { + enum Mode: Equatable { + case new(source: TherapyProfile) + case edit(TherapyProfile) + } + + let remote: ProfileRemote + let mode: Mode + let profileSet: TherapyProfileSet + let onFinished: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var name: String + @State private var source: TherapyProfile + @State private var draft: ProfileDraft + + init(remote: ProfileRemote, mode: Mode, profileSet: TherapyProfileSet, onFinished: @escaping () -> Void) { + self.remote = remote + self.mode = mode + self.profileSet = profileSet + self.onFinished = onFinished + let source: TherapyProfile + switch mode { + case let .new(start): source = start + case let .edit(profile): source = profile + } + _source = State(initialValue: source) + _name = State(initialValue: "") + _draft = State(initialValue: ProfileDraft(source.values, singleTarget: remote.hasSingleTarget)) + } + + private var isNew: Bool { + if case .new = mode { return true } + return false + } + + private var limits: ProfileEditLimits? { profileSet.limits } + + private var blocker: String? { + ProfileEditing.blocker(for: source, limits: limits, remote: remote) + } + + private var editableLimits: ProfileEditLimits? { + blocker == nil ? limits : nil + } + + private var targetName: String { + isNew ? ProfileValidation.normalizedName(name) : source.name + } + + private var nameProblem: String? { + guard isNew else { return nil } + return ProfileValidation.countProblem(profileCount: profileSet.profiles.count) + ?? ProfileValidation.nameProblem(name, existingNames: profileSet.profiles.map(\.name)) + } + + private var problems: [String] { + guard let limits = editableLimits else { return [] } + return ProfileValidation.problems(draft.values, limits: limits) + } + + private var hasChanges: Bool { + draft.values != ProfileDraft(source.values, singleTarget: remote.hasSingleTarget).values + } + + private var isEditingActive: Bool { + !isNew && profileSet.isActive(source) + } + + private var canReview: Bool { + editableLimits != nil && nameProblem == nil && problems.isEmpty && (isNew || hasChanges) + } + + private var command: ProfileCommand { + .save(name: targetName, original: isNew ? nil : source, values: draft.values) + } + + var body: some View { + NavigationStack { + Form { + if let blocker { + Section { + Label(blocker, systemImage: "lock") + .foregroundColor(.secondary) + } + } + + if isEditingActive { + Section { + ActiveProfileWarning(remote: remote) + } + } + + if isNew { + Section(header: Text("Name"), footer: nameFooter) { + TextField("Profile name", text: $name) + .autocorrectionDisabled() + } + Section(header: Text("Start From"), footer: Text("The new profile starts with a copy of these values. Choosing another profile discards changes made below.")) { + Picker("Profile", selection: Binding( + get: { source.name }, + set: { newName in + guard let profile = profileSet.profile(named: newName) else { return } + source = profile + draft = ProfileDraft(profile.values, singleTarget: remote.hasSingleTarget) + } + )) { + ForEach(profileSet.profiles) { profile in + Text(profile.name).tag(profile.name) + } + } + } + } + + ForEach(ProfileScheduleKind.allCases) { kind in + ProfileValuesEditor(kind: kind, segments: Binding(get: { draft[kind] }, set: { draft[kind] = $0 }), units: draft.units, limits: editableLimits, singleTarget: remote.hasSingleTarget) + } + + if !problems.isEmpty { + Section(header: Text("Fix Before Sending")) { + ForEach(problems, id: \.self) { problem in + Text(problem).foregroundColor(.red) + } + } + } + } + .navigationTitle(isNew ? "New Profile" : "Edit \(source.name)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + NavigationLink("Review") { + ProfileReviewView( + remote: remote, + command: command, + baseline: source, + isActive: isEditingActive, + limits: editableLimits, + onFinished: onFinished + ) + } + .disabled(!canReview) + } + } + } + .interactiveDismissDisabled(hasChanges || !name.isEmpty) + } + + private var nameFooter: some View { + Group { + if let nameProblem, !name.isEmpty { + Text(nameProblem).foregroundColor(.red) + } else { + Text("Up to \(ProfileValidation.maxNameLength) characters. Not “Default”, no period (.), and not starting with $.") + } + } + } +} diff --git a/LoopFollow/Remote/Profiles/ProfileInsulinComparison.swift b/LoopFollow/Remote/Profiles/ProfileInsulinComparison.swift new file mode 100644 index 000000000..d909b5957 --- /dev/null +++ b/LoopFollow/Remote/Profiles/ProfileInsulinComparison.swift @@ -0,0 +1,106 @@ +// LoopFollow +// ProfileInsulinComparison.swift + +import Foundation + +/// A schedule in which one set of profile values gives more insulin than another, and the times of day it does. +struct ProfileInsulinIncrease: Equatable, Identifiable { + let kind: ProfileScheduleKind + /// Start and end of each period, in seconds from midnight, end exclusive. + let periods: [ClosedRange] + + var id: String { kind.rawValue } + + /// For example "Basal rates higher 06:00–09:00". + var text: String { + let times = periods.map { period -> String in + if period.lowerBound == 0, period.upperBound == 86400 { return "all day" } + let end = period.upperBound == 86400 ? "midnight" : ProfileValueFormat.timeText(period.upperBound) + return "\(ProfileValueFormat.timeText(period.lowerBound))–\(end)" + } + return "\(kind.title) \(kind.moreInsulinWord) \(times.joined(separator: ", "))" + } +} + +/// Compares two sets of profile values by the insulin they lead to. Higher basal rates, lower carb ratios, +/// lower insulin sensitivities and a lower correction range all give more insulin. +enum ProfileInsulinComparison { + static func increases(from old: TherapyProfileValues, to new: TherapyProfileValues) -> [ProfileInsulinIncrease] { + ProfileScheduleKind.allCases.compactMap { kind in + let periods = increasePeriods(kind, old: old, new: new) + return periods.isEmpty ? nil : ProfileInsulinIncrease(kind: kind, periods: periods) + } + } + + /// Scheduled basal over a day before and after, when the day total rises. + static func basalTotalIncrease(from old: TherapyProfileValues, to new: TherapyProfileValues) -> (old: Double, new: Double)? { + let before = TherapyProfile.basalTotal(old.basal) + let after = TherapyProfile.basalTotal(new.basal) + return after > before + tolerance ? (before, after) : nil + } + + private static let tolerance = 1e-6 + + private static func increasePeriods(_ kind: ProfileScheduleKind, old: TherapyProfileValues, new: TherapyProfileValues) -> [ClosedRange] { + let before = points(kind, in: old) + let after = points(kind, in: new) + guard !before.isEmpty, !after.isEmpty else { return [] } + let times = Set(before.map(\.seconds) + after.map(\.seconds)).sorted() + + func value(_ points: [Point], at seconds: Int) -> Point { + points.last { $0.seconds <= seconds } ?? points[0] + } + + var periods: [ClosedRange] = [] + for (index, start) in times.enumerated() { + let end = index + 1 < times.count ? times[index + 1] : 86400 + guard givesMoreInsulin(kind, old: value(before, at: start), new: value(after, at: start)) else { continue } + if let last = periods.last, last.upperBound == start { + periods[periods.count - 1] = last.lowerBound ... end + } else { + periods.append(start ... end) + } + } + return periods + } + + private struct Point { + let seconds: Int + let value: Double + let high: Double + } + + /// The schedule with glucose values in mg/dL, so profiles in different units compare correctly. + private static func points(_ kind: ProfileScheduleKind, in values: TherapyProfileValues) -> [Point] { + let glucoseFactor = TherapyProfileValues.normalizedUnits(values.units) == TherapyProfileValues.mgdl ? 1 : 18.01559 + switch kind { + case .basal: + return values.basal.map { Point(seconds: $0.seconds, value: $0.value, high: $0.value) } + case .carbRatio: + return values.carbRatio.map { Point(seconds: $0.seconds, value: $0.value, high: $0.value) } + case .sensitivity: + return values.sensitivity.map { Point(seconds: $0.seconds, value: $0.value * glucoseFactor, high: $0.value * glucoseFactor) } + case .correctionRange: + return values.target.map { Point(seconds: $0.seconds, value: $0.low * glucoseFactor, high: $0.high * glucoseFactor) } + } + } + + private static func givesMoreInsulin(_ kind: ProfileScheduleKind, old: Point, new: Point) -> Bool { + func lower(_ a: Double, than b: Double) -> Bool { a < b - tolerance * max(1, abs(b)) } + switch kind { + case .basal: + return lower(old.value, than: new.value) + case .carbRatio, .sensitivity: + return lower(new.value, than: old.value) + case .correctionRange: + return lower(new.value, than: old.value) || lower(new.high, than: old.high) + } + } +} + +extension ProfileScheduleKind { + /// The direction of this schedule's values that gives more insulin. + var moreInsulinWord: String { + self == .basal ? "higher" : "lower" + } +} diff --git a/LoopFollow/Remote/Profiles/ProfileRemote.swift b/LoopFollow/Remote/Profiles/ProfileRemote.swift new file mode 100644 index 000000000..ede87db35 --- /dev/null +++ b/LoopFollow/Remote/Profiles/ProfileRemote.swift @@ -0,0 +1,154 @@ +// LoopFollow +// ProfileRemote.swift + +import Foundation + +/// The profile commands LoopFollow can send. +enum ProfileCommandKind: String, CaseIterable { + case activate + case save + case delete + case saveScheduledSwitches +} + +/// A profile command and what it acts on. +enum ProfileCommand: Equatable { + case activate(TherapyProfile) + /// Creates `name` when `original` is nil; otherwise updates `original`, renaming it when `name` differs. + case save(name: String, original: TherapyProfile?, values: TherapyProfileValues) + case delete(TherapyProfile) + /// Replaces the scheduled switches; the phone only does so while its list has `baseFingerprint`. + case saveScheduledSwitches([ScheduledProfileSwitch], baseFingerprint: String) + + /// The tracker key name of a scheduled switches command, which names no profile. + static let scheduledSwitchesTarget = "scheduled-switches" + + var kind: ProfileCommandKind { + switch self { + case .activate: return .activate + case .save: return .save + case .delete: return .delete + case .saveScheduledSwitches: return .saveScheduledSwitches + } + } + + /// The profile named in the phone's reply. For a rename it is the new name. + var targetName: String { + switch self { + case let .activate(profile), let .delete(profile): return profile.name + case let .save(name, _, _): return name + case .saveScheduledSwitches: return Self.scheduledSwitchesTarget + } + } + + /// True for a save that gives an existing profile a new name. + var isRename: Bool { + guard case let .save(name, original?, _) = self else { return false } + return original.name != name + } +} + +/// The AID app the profile commands go to, with its command names and tracker keys. +enum ProfileRemote: String { + case loop + case trio + + var appName: String { + switch self { + case .loop: return "Loop" + case .trio: return "Trio" + } + } + + /// Trio doses to one glucose target per time, so its profiles carry a target whose high equals its low. + var hasSingleTarget: Bool { self == .trio } + + /// The name the app lists in its supported remote commands. + func commandName(_ kind: ProfileCommandKind) -> String { + switch (self, kind) { + case (.loop, .activate): return "profile-switch" + case (.loop, .save): return "profile-save" + case (.loop, .delete): return "profile-delete" + case (.loop, .saveScheduledSwitches): return "profile-scheduled-switches-save" + case (.trio, .activate): return TRCCommandType.switchProfile.rawValue + case (.trio, .save): return TRCCommandType.saveProfile.rawValue + case (.trio, .delete): return TRCCommandType.deleteProfile.rawValue + case (.trio, .saveScheduledSwitches): return TRCCommandType.saveScheduledSwitches.rawValue + } + } + + func supports(_ kind: ProfileCommandKind, in commands: [String]) -> Bool { + commands.contains(commandName(kind)) + } + + func supportsAny(in commands: [String]) -> Bool { + ProfileCommandKind.allCases.contains { supports($0, in: commands) } + } + + /// The commands the app accepts: those in its profile document. Loop builds with the Profile Save & Load + /// customization list only `profile-switch`, in their device status; they get switches, never saves or deletes. + /// A Loop that lists commands in its profile document is used through those alone. + func acceptedCommands(profileDocument: [String], loopDeviceStatus: [String]) -> [String] { + let activate = commandName(.activate) + guard self == .loop, loopDeviceStatus.contains(activate), !profileDocument.contains(activate) else { + return profileDocument + } + return profileDocument + [activate] + } + + func keyPrefix(_ kind: ProfileCommandKind) -> String { + switch kind { + case .activate: return "\(rawValue)-profile-switch:" + case .save: return "\(rawValue)-profile-save:" + case .delete: return "\(rawValue)-profile-delete:" + case .saveScheduledSwitches: return "\(rawValue)-profile-scheduled-switches-save:" + } + } + + func key(_ kind: ProfileCommandKind, name: String) -> String { + keyPrefix(kind) + name + } + + func key(for command: ProfileCommand) -> String { + key(command.kind, name: command.targetName) + } + + var keyPrefixes: [String] { + ProfileCommandKind.allCases.map(keyPrefix) + } + + /// True for tracker keys of profile commands of either app. + static func isProfileCommandKey(_ key: String) -> Bool { + [ProfileRemote.loop, .trio].contains { remote in remote.keyPrefixes.contains { key.hasPrefix($0) } } + } + + /// Pump programming and both pushes can take a while; a missing reply does not mean the command failed. + static let timeout: TimeInterval = 180 + + var timeoutMessage: String { + "\(appName) has not replied. The change may still be applied; the list shows it once \(appName) uploads its profiles. Check the list before sending again." + } + + func successMessage(_ command: ProfileCommand) -> String { + switch command.kind { + case .activate: return "Profile '\(command.targetName)' activated" + case .save: return "Profile '\(command.targetName)' saved" + case .delete: return "Profile '\(command.targetName)' deleted" + case .saveScheduledSwitches: return "Scheduled switches saved" + } + } + + /// The result of a command whose reply did not arrive, but whose change shows in the uploaded profiles. + func seenMessage(_ command: ProfileCommand) -> String { + "\(successMessage(command)). The reply from \(appName) did not arrive, but the profiles \(appName) uploaded to Nightscout show the change." + } + + func failureMessage(_ command: ProfileCommand) -> String { + switch command.kind { + case .activate: return "Profile '\(command.targetName)' not activated" + case .save: return "Profile '\(command.targetName)' not saved" + case .delete: return "Profile '\(command.targetName)' not deleted" + case .saveScheduledSwitches: return "Scheduled switches not saved" + } + } +} diff --git a/LoopFollow/Remote/Profiles/ProfileReviewView.swift b/LoopFollow/Remote/Profiles/ProfileReviewView.swift new file mode 100644 index 000000000..6114e5d15 --- /dev/null +++ b/LoopFollow/Remote/Profiles/ProfileReviewView.swift @@ -0,0 +1,335 @@ +// LoopFollow +// ProfileReviewView.swift + +import SwiftUI + +/// One row of a schedule comparison: the value in effect from `seconds` before and after the change. +struct ProfileDiffRow: Equatable, Identifiable { + let seconds: Int + let old: String + let new: String + + var id: Int { seconds } + var changed: Bool { old != new } +} + +enum ProfileDiff { + /// The value in effect at each time either schedule changes value, before and after. + static func rows(old: [EditableSegment], new: [EditableSegment], text: (EditableSegment) -> String) -> [ProfileDiffRow] { + let times = Set(old.map(\.seconds) + new.map(\.seconds)).sorted() + func effective(_ segments: [EditableSegment], at seconds: Int) -> String { + segments.last { $0.seconds <= seconds }.map(text) ?? "—" + } + return times.map { ProfileDiffRow(seconds: $0, old: effective(old, at: $0), new: effective(new, at: $0)) } + } +} + +/// Shows exactly what a profile save sends, compared with the profile it starts from, and sends it after +/// confirmation and device authentication. +struct ProfileReviewView: View { + let remote: ProfileRemote + let command: ProfileCommand + /// The profile being changed, or the one a new profile is copied from. + let baseline: TherapyProfile + let isActive: Bool + let limits: ProfileEditLimits? + let onFinished: () -> Void + + @ObservedObject private var tracker = RemoteCommandTracker.shared + @State private var alert: AlertType? + @State private var showJSON = false + /// The serialized push size, computed once per command; nil when it can not be computed. + @State private var pushSize: Int? + @State private var pushSizeComputed = false + /// True while the newest profile document is loaded before a send. + @State private var checking = false + + private enum AlertType: Identifiable { + case confirm + case result(ProfileCommandResult) + + var id: String { + switch self { + case .confirm: return "confirm" + case let .result(result): return "result-\(result.success)-\(result.message)" + } + } + } + + private var values: TherapyProfileValues { + if case let .save(_, _, values) = command { return values } + return baseline.values + } + + private var original: TherapyProfile? { + if case let .save(_, original, _) = command { return original } + return nil + } + + private var key: String { remote.key(for: command) } + + private var increases: [ProfileInsulinIncrease] { + ProfileInsulinComparison.increases(from: baseline.values, to: values) + } + + private var isSending: Bool { tracker.isBusy(key: key) } + + private var otherPending: Bool { + !isSending && ProfileCommandFlow.isAnyPending(tracker, remote: remote) + } + + private var problems: [String] { + guard let limits else { return ["Profiles can not be changed without the limits from \(remote.appName)."] } + return ProfileValidation.problems(for: command, limits: limits) + } + + private static func computePushSize(_ command: ProfileCommand, remote: ProfileRemote) -> Int? { + guard case let .save(name, original, values) = command else { return nil } + switch remote { + case .loop: + return LoopAPNSService().profileSavePushSize(name: name, originalName: original?.name, baseFingerprint: original?.fingerprint, values: values) + case .trio: + return PushNotificationManager().profileSavePushSize(name: name, originalName: original?.name, baseFingerprint: original?.fingerprint, values: values) + } + } + + private var isTooLarge: Bool { + (pushSize ?? 0) > ProfilePushSize.reviewLimit(for: remote) + } + + private var canSend: Bool { + pushSizeComputed && problems.isEmpty && !isTooLarge && !isSending && !otherPending && !checking + } + + var body: some View { + List { + Section { + Text(summary) + if appliesNow { + ActiveProfileWarning(remote: remote) + } + if !increases.isEmpty { + MoreInsulinWarning( + increases: increases, + basalTotal: ProfileInsulinComparison.basalTotalIncrease(from: baseline.values, to: values), + comparedWith: original?.name ?? baseline.name, + appliesNow: appliesNow + ) + } + } + + ForEach(ProfileScheduleKind.allCases) { kind in + diffSection(kind) + } + + Section(header: Text("Sent to \(remote.appName)"), footer: sizeFooter(pushSize)) { + LabeledValueRow(label: "Name", value: command.targetName) + if let original { + if original.name != command.targetName { + LabeledValueRow(label: "Renames", value: original.name) + } + LabeledValueRow(label: "Based on version", value: original.fingerprint) + } + LabeledValueRow(label: "Units", value: values.units) + DisclosureGroup("Exact values", isExpanded: $showJSON) { + Text(Self.jsonText(values)) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + } + + if !problems.isEmpty { + Section(header: Text("Can't Send")) { + ForEach(problems, id: \.self) { Text($0).foregroundColor(.red) } + } + } + + Section { + ProfileDosingNote() + .font(.footnote) + .foregroundColor(.secondary) + } + } + .safeAreaInset(edge: .bottom) { + Button { + alert = .confirm + } label: { + Group { + if isSending { + HStack { + ProgressView().scaleEffect(0.8) + Text("Waiting for \(remote.appName)...") + } + } else if checking { + HStack { + ProgressView().scaleEffect(0.8) + Text("Checking the profile...") + } + } else if otherPending { + Text("Another Profile Command Is Pending") + } else { + Text(appliesNow ? "Send and Apply Now" : "Send to \(remote.appName)") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(appliesNow ? .red : .accentColor) + .controlSize(.large) + .disabled(!canSend) + .padding(.horizontal) + .padding(.vertical, 8) + .background(.bar) + } + .navigationTitle("Review") + .navigationBarTitleDisplayMode(.inline) + .task(id: command) { + pushSize = Self.computePushSize(command, remote: remote) + pushSizeComputed = true + } + .onProfileCommandResult(keys: [key], command: command) { _, result in + alert = .result(result) + } + .alert(item: $alert) { type in + switch type { + case .confirm: + return Alert( + title: Text(confirmTitle), + message: Text(confirmMessage), + primaryButton: appliesNow ? .destructive(Text("Send"), action: send) : .default(Text("Send"), action: send), + secondaryButton: .cancel() + ) + case let .result(result): + return Alert( + title: Text(ProfileCommandFlow.resultTitle(kind: .save, success: result.success, message: result.message, remote: remote)), + message: Text(ProfileCommandFlow.resultMessage(result, remote: remote)), + dismissButton: .default(Text("OK")) { + if ProfileCommandFlow.leavesAfterResult(result, remote: remote) { onFinished() } + } + ) + } + } + } + + private var summary: String { + if let original { + if original.name != command.targetName { + return "Rename “\(original.name)” to “\(command.targetName)” in \(remote.appName)." + } + return "Save these changes to “\(original.name)” in \(remote.appName). Changed values are marked." + } + return "Create “\(command.targetName)” in \(remote.appName), starting from “\(baseline.name)”. Values that differ from “\(baseline.name)” are marked." + } + + private var isRename: Bool { + command.isRename + } + + /// True when the command changes the values the phone doses with as soon as it arrives. A rename keeps the values. + private var appliesNow: Bool { + isActive && !isRename + } + + private var confirmTitle: String { + if isRename { return "Rename \(original?.name ?? "")?" } + if isActive { return "Change the Active Profile?" } + return original == nil ? "Create \(command.targetName)?" : "Save \(command.targetName)?" + } + + private var confirmMessage: String { + if isRename { + return "Only the name changes. The values stay the same, so dosing does not change." + } + if isActive { + return "\(remote.appName) starts dosing with these values as soon as it gets the command, and sends the new basal rates to the pump." + + MoreInsulinWarning.sentence(increases, comparedWith: original?.name ?? baseline.name) + } + return "\(remote.appName) saves the profile. Dosing does not change unless this profile is the active one in \(remote.appName) when the command arrives." + + MoreInsulinWarning.sentence(increases, comparedWith: original?.name ?? baseline.name) + } + + private func diffSection(_ kind: ProfileScheduleKind) -> some View { + let unitText = kind.unitText(glucoseUnits: values.units) + let singleTarget = remote.hasSingleTarget + let old = ProfileDraft(baseline.values, singleTarget: singleTarget)[kind] + let new = ProfileDraft(values, singleTarget: singleTarget)[kind] + let rows = ProfileDiff.rows(old: old, new: new) { ProfileValueFormat.segmentText($0, kind: kind, limits: limits, glucoseUnits: values.units, singleTarget: singleTarget) } + let changed = rows.contains(where: \.changed) || old.map(\.seconds) != new.map(\.seconds) + let moreInsulin = increases.first { $0.kind == kind }?.periods ?? [] + let footer = changed + ? "Before → after, in \(unitText)." + (moreInsulin.isEmpty ? "" : " Red values give more insulin.") + : "Unchanged, in \(unitText)." + return Section(header: Text(singleTarget && kind.isRange ? "Glucose Target" : kind.title), footer: Text(footer)) { + ForEach(rows) { row in + ProfileRowLayout { + Text(ProfileValueFormat.timeText(row.seconds)) + } value: { + if row.changed { + HStack { + Text(row.old) + .foregroundColor(.secondary) + .strikethrough() + Image(systemName: "arrow.right") + .font(.caption) + .foregroundColor(.secondary) + Text(row.new) + .bold() + .foregroundColor(moreInsulin.contains { $0.lowerBound <= row.seconds && row.seconds < $0.upperBound } ? .red : .orange) + } + } else { + Text(row.new).foregroundColor(.secondary) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(row.changed + ? "\(ProfileValueFormat.timeText(row.seconds)), changed from \(row.old) to \(row.new)" + : "\(ProfileValueFormat.timeText(row.seconds)), \(row.new)") + } + } + } + + private func sizeFooter(_ size: Int?) -> Text { + guard pushSizeComputed else { return Text("") } + guard let size else { + return Text("The size of the command can not be computed until LoopFollow's own APNS credentials are set up in App Settings → APN.") + } + if size > ProfilePushSize.reviewLimit(for: remote) { + return Text(ProfilePushSize.tooLargeMessage(size)).foregroundColor(.red) + } + return Text("Command size: about \(size) of \(APNSDelivery.maxPayloadBytes) bytes.") + } + + /// Checks against the newest profile document that the profile is unchanged and has the active state the review + /// showed, then sends after device authentication. + private func send() { + guard let original else { + authenticateAndSend() + return + } + checking = true + ProfileCommandFlow.fetchProfileSet { current in + checking = false + guard let reason = ProfileCommandFlow.saveStaleReason(original: original, reviewedActive: isActive, current: current, remote: remote) else { + authenticateAndSend() + return + } + if let current { Observable.shared.therapyProfiles.set(current) } + alert = .result(ProfileCommandResult(success: false, message: reason, outdated: current != nil)) + } + } + + private func authenticateAndSend() { + ProfileCommandFlow.authenticateAndSend(command, to: remote, reason: "Confirm your identity to change \(command.targetName) in \(remote.appName).") { message in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + alert = .result(ProfileCommandResult(success: false, message: message)) + } + } + } + + static func jsonText(_ values: TherapyProfileValues) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(values), let text = String(data: data, encoding: .utf8) else { return "" } + return text + } +} diff --git a/LoopFollow/Remote/Profiles/ProfileValidation.swift b/LoopFollow/Remote/Profiles/ProfileValidation.swift new file mode 100644 index 000000000..a5b2630b0 --- /dev/null +++ b/LoopFollow/Remote/Profiles/ProfileValidation.swift @@ -0,0 +1,185 @@ +// LoopFollow +// ProfileValidation.swift + +import Foundation + +/// The four schedules of a therapy profile. +enum ProfileScheduleKind: String, CaseIterable, Identifiable { + case correctionRange + case carbRatio + case basal + case sensitivity + + var id: String { rawValue } + + var title: String { + switch self { + case .correctionRange: return "Correction Range" + case .carbRatio: return "Carb Ratios" + case .basal: return "Basal Rates" + case .sensitivity: return "Insulin Sensitivities" + } + } + + var isRange: Bool { self == .correctionRange } + + /// Correction ranges and insulin sensitivities are in the profile's glucose units. + var isGlucose: Bool { self == .correctionRange || self == .sensitivity } + + func unitText(glucoseUnits: String) -> String { + switch self { + case .correctionRange: return glucoseUnits + case .carbRatio: return "g/U" + case .basal: return "U/hr" + case .sensitivity: return "\(glucoseUnits)/U" + } + } + + /// The bounds for this schedule; nil for basal, which uses the list of supported rates. + func range(in limits: ProfileEditLimits) -> ProfileEditLimits.Range? { + switch self { + case .correctionRange: return limits.target + case .carbRatio: return limits.carbRatio + case .sensitivity: return limits.sensitivity + case .basal: return nil + } + } + + /// The values the editor offers, in ascending order. + func options(in limits: ProfileEditLimits) -> [Double] { + range(in: limits)?.values ?? limits.allowedBasalRates + } + + func decimals(in limits: ProfileEditLimits) -> Int { + if let range = range(in: limits) { return ProfileEditLimits.decimals(of: range.step) } + return limits.allowedBasalRates.map(ProfileEditLimits.decimals(of:)).max() ?? 2 + } +} + +/// How a value compares with the published limits. +enum ProfileValueStatus: Equatable { + case ok + /// Allowed, but outside the recommended range: shown as a warning. + case outsideRecommended + /// Outside the hard limits (or not a supported basal rate): the profile can not be sent. + case notAllowed +} + +extension ProfileEditLimits { + func status(of value: Double, in kind: ProfileScheduleKind) -> ProfileValueStatus { + guard value.isFinite else { return .notAllowed } + guard let range = kind.range(in: self) else { + return isAllowedBasalRate(value) ? .ok : .notAllowed + } + guard range.contains(value) else { return .notAllowed } + return range.isOutsideRecommended(value) ? .outsideRecommended : .ok + } + + /// Plain-language bounds for a schedule, for footers and messages. + func boundsText(for kind: ProfileScheduleKind) -> String { + let units = kind.unitText(glucoseUnits: normalizedUnits) + let decimals = kind.decimals(in: self) + func text(_ value: Double) -> String { + Localizer.formatToLocalizedString(value, maxFractionDigits: max(decimals, 3), minFractionDigits: decimals) + } + guard let range = kind.range(in: self) else { + let rates = allowedBasalRates + return "Allowed: \(text(rates.first ?? 0))–\(text(rates.last ?? 0)) \(units), in the steps the pump supports." + } + var result = "Allowed: \(text(range.min))–\(text(range.max)) \(units)." + if range.recommendedMin != nil || range.recommendedMax != nil { + let low = range.recommendedMin.map(text) ?? text(range.min) + let high = range.recommendedMax.map(text) ?? text(range.max) + result += " Values outside \(low)–\(high) are unusual and shown in orange." + } + return result + } +} + +enum ProfileValidation { + static let maxNameLength = 32 + /// The most profiles Loop and Trio keep, Default included. + static let maxProfiles = 20 + + /// Why another profile can not be created, or nil when it can. + static func countProblem(profileCount: Int) -> String? { + profileCount >= maxProfiles ? "There are already \(maxProfiles) profiles, the most the phone keeps. Delete a profile before creating another." : nil + } + + /// The name as it is sent: surrounding spaces removed. + static func normalizedName(_ name: String) -> String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Why `name` can not be used for a new or renamed profile, or nil when it can. + /// `originalName` is the profile being renamed, which may keep its own name in another letter case. + static func nameProblem(_ name: String, existingNames: [String], originalName: String? = nil) -> String? { + let name = normalizedName(name) + if name.isEmpty { return "Enter a name." } + if name.count > maxNameLength { return "Use at most \(maxNameLength) characters." } + if name.contains(".") { return "A name can't contain a period (.)." } + if name.hasPrefix("$") { return "A name can't start with $." } + if TherapyProfile.isDefaultName(name) { return "“Default” is the name of the Default profile. Choose another name." } + let taken = existingNames.contains { existing in + existing.caseInsensitiveCompare(name) == .orderedSame + && existing != originalName + } + if taken { return "A profile with this name already exists." } + return nil + } + + /// Everything that keeps `values` from being sent within `limits`. Empty when the profile can be sent. + static func problems(_ values: TherapyProfileValues, limits: ProfileEditLimits) -> [String] { + var problems: [String] = [] + if values.units != limits.normalizedUnits { + problems.append("The profile is in \(values.units), but the limits are in \(limits.normalizedUnits).") + } + let schedules: [(ProfileScheduleKind, [Int])] = [ + (.correctionRange, values.target.map(\.seconds)), + (.carbRatio, values.carbRatio.map(\.seconds)), + (.basal, values.basal.map(\.seconds)), + (.sensitivity, values.sensitivity.map(\.seconds)), + ] + for (kind, starts) in schedules { + problems += scheduleProblems(starts, kind: kind, limits: limits) + } + for (kind, items) in [(ProfileScheduleKind.carbRatio, values.carbRatio), (.basal, values.basal), (.sensitivity, values.sensitivity)] { + if items.contains(where: { limits.status(of: $0.value, in: kind) == .notAllowed }) { + problems.append("\(kind.title): a value is outside what is allowed. \(limits.boundsText(for: kind))") + } + } + if values.target.contains(where: { limits.status(of: $0.low, in: .correctionRange) == .notAllowed || limits.status(of: $0.high, in: .correctionRange) == .notAllowed }) { + problems.append("Correction Range: a value is outside what is allowed. \(limits.boundsText(for: .correctionRange))") + } + if values.target.contains(where: { $0.low > $0.high + ProfileEditLimits.tolerance }) { + problems.append("Correction Range: the low value must not be above the high value.") + } + return problems + } + + /// Everything that keeps `command` from being sent within `limits`. A save that keeps the values of the + /// profile it updates (a pure rename) is not checked against the limits, which may have changed since the + /// values were stored. + static func problems(for command: ProfileCommand, limits: ProfileEditLimits) -> [String] { + guard case let .save(_, original, values) = command else { return [] } + if let original, original.values == values { return [] } + return problems(values, limits: limits) + } + + static func scheduleProblems(_ starts: [Int], kind: ProfileScheduleKind, limits: ProfileEditLimits) -> [String] { + guard let first = starts.first else { return ["\(kind.title): add at least one time."] } + var problems: [String] = [] + if first != 0 { problems.append("\(kind.title): the first time must be midnight.") } + if zip(starts, starts.dropFirst()).contains(where: { $0 >= $1 }) { + problems.append("\(kind.title): each time must be later than the one before.") + } + if starts.contains(where: { $0 < 0 || $0 >= 86400 || $0 % limits.segmentSeconds != 0 }) { + problems.append("\(kind.title): times must be on the \(limits.segmentSeconds / 60)-minute grid.") + } + let segmentLimit = limits.segmentLimit(for: kind) + if starts.count > segmentLimit { + problems.append("\(kind.title): at most \(segmentLimit) times are allowed.") + } + return problems + } +} diff --git a/LoopFollow/Remote/Profiles/ProfileValuesEditor.swift b/LoopFollow/Remote/Profiles/ProfileValuesEditor.swift new file mode 100644 index 000000000..727b0e1f7 --- /dev/null +++ b/LoopFollow/Remote/Profiles/ProfileValuesEditor.swift @@ -0,0 +1,248 @@ +// LoopFollow +// ProfileValuesEditor.swift + +import SwiftUI + +/// Formats profile values in the profile's own units. Glucose values in mmol/L have one decimal; other values +/// show as many decimals as they have (up to three), so they are never shown rounded. +enum ProfileValueFormat { + static func text(_ value: Double, decimals: Int) -> String { + Localizer.formatToLocalizedString(value, maxFractionDigits: max(decimals, 3), minFractionDigits: decimals) + } + + static func text(_ value: Double, kind: ProfileScheduleKind, limits: ProfileEditLimits?, glucoseUnits: String) -> String { + if kind.isGlucose, TherapyProfileValues.normalizedUnits(glucoseUnits) == TherapyProfileValues.mmol { + return Localizer.formatToLocalizedString(value, maxFractionDigits: 1, minFractionDigits: 1) + } + return text(value, decimals: limits.map { kind.decimals(in: $0) } ?? defaultDecimals(kind)) + } + + static func defaultDecimals(_ kind: ProfileScheduleKind) -> Int { + kind == .basal ? 2 : 0 + } + + static func segmentText(_ segment: EditableSegment, kind: ProfileScheduleKind, limits: ProfileEditLimits?, glucoseUnits: String, singleTarget: Bool = false) -> String { + if kind.isRange, !singleTarget { + return "\(text(segment.value, kind: kind, limits: limits, glucoseUnits: glucoseUnits)) – \(text(segment.high, kind: kind, limits: limits, glucoseUnits: glucoseUnits))" + } + return text(segment.value, kind: kind, limits: limits, glucoseUnits: glucoseUnits) + } + + /// Schedule start times are offsets from midnight in the profile's time zone. + static func timeText(_ seconds: Int) -> String { + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(seconds))) + } +} + +/// A start time and its value side by side, stacked when they do not fit, as with the largest text sizes. +struct ProfileRowLayout: View { + @ViewBuilder let time: Time + @ViewBuilder let value: Value + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack { + time + Spacer() + value + } + VStack(alignment: .leading, spacing: 2) { + time + value + } + } + } +} + +/// Edits one schedule of a profile: a list of start times with values, on the time grid and inside the bounds +/// of the published limits. Without limits the schedule is shown read-only. +struct ProfileValuesEditor: View { + let kind: ProfileScheduleKind + @Binding var segments: [EditableSegment] + let units: String + /// Nil makes the editor read-only. + let limits: ProfileEditLimits? + /// One target value per time, for Trio. + var singleTarget = false + + private var isRange: Bool { kind.isRange && !singleTarget } + + @State private var expandedID: UUID? + + private var unitText: String { kind.unitText(glucoseUnits: units) } + + var body: some View { + Section(header: Text(singleTarget && kind.isRange ? "Glucose Target" : kind.title), footer: footer) { + ForEach(Array(segments.enumerated()), id: \.element.id) { index, segment in + VStack(alignment: .leading, spacing: 4) { + row(index: index, segment: segment) + if let limits, expandedID == segment.id { + pickers(index: index, limits: limits) + } + } + .deleteDisabled(limits == nil || index == 0) + } + .onDelete { offsets in + guard limits != nil else { return } + let removable = offsets.filter { $0 > 0 } + segments.remove(atOffsets: IndexSet(removable)) + } + + if let limits { + Button { + let added = ProfileSegmentGrid.addSegment(to: segments, segmentSeconds: limits.segmentSeconds, maxSegments: limits.segmentLimit(for: kind)) + let newSegment = added.first { segment in !segments.contains { $0.id == segment.id } } + segments = added + expandedID = newSegment?.id + } label: { + Label("Add Time", systemImage: "plus.circle") + } + .disabled(ProfileSegmentGrid.newSegmentStart(segments, segmentSeconds: limits.segmentSeconds, maxSegments: limits.segmentLimit(for: kind)) == nil) + } + } + } + + private func row(index _: Int, segment: EditableSegment) -> some View { + let status = valueStatus(segment) + return Button { + guard limits != nil else { return } + withAnimation { expandedID = expandedID == segment.id ? nil : segment.id } + } label: { + VStack(alignment: .leading, spacing: 2) { + ProfileRowLayout { + Text(ProfileValueFormat.timeText(segment.seconds)) + .foregroundColor(.primary) + } value: { + Text("\(ProfileValueFormat.segmentText(segment, kind: kind, limits: limits, glucoseUnits: units, singleTarget: singleTarget)) \(unitText)") + .foregroundColor(color(for: status)) + } + switch status { + case .notAllowed: + Text("Outside what is allowed. Change this value before sending.") + .font(.caption) + .foregroundColor(.red) + case .outsideRecommended: + Text("Unusual value. Double-check it.") + .font(.caption) + .foregroundColor(.orange) + case .ok: + EmptyView() + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func pickers(index: Int, limits: ProfileEditLimits) -> some View { + HStack(spacing: 0) { + if index > 0 { + Picker("Start Time", selection: startBinding(index)) { + ForEach(ProfileSegmentGrid.allowedStarts(for: index, in: segments, segmentSeconds: limits.segmentSeconds), id: \.self) { seconds in + Text(ProfileValueFormat.timeText(seconds)).tag(seconds) + } + } + .pickerStyle(.wheel) + .frame(maxWidth: .infinity) + } + if isRange { + valuePicker(label: "Low", selection: valueBinding(index, high: false), options: options(limits, current: segments[index].value, upTo: segments[index].high), limits: limits) + valuePicker(label: "High", selection: valueBinding(index, high: true), options: options(limits, current: segments[index].high, from: segments[index].value), limits: limits) + } else { + valuePicker(label: kind.title, selection: valueBinding(index, high: false), options: options(limits, current: segments[index].value), limits: limits) + } + } + .frame(height: 150) + } + + private func valuePicker(label: String, selection: Binding, options: [Double], limits: ProfileEditLimits) -> some View { + Picker(label, selection: selection) { + ForEach(options, id: \.self) { value in + Text(ProfileValueFormat.text(value, kind: kind, limits: limits, glucoseUnits: units)) + .foregroundColor(color(for: limits.status(of: value, in: kind))) + .tag(value) + } + } + .pickerStyle(.wheel) + .frame(maxWidth: .infinity) + } + + /// The allowed values, with the current value kept as it is when it is not on the grid (it is then + /// shown, and flagged when it is outside the bounds, but never replaced without the user choosing another value). + private func options(_ limits: ProfileEditLimits, current: Double, from lower: Double? = nil, upTo upper: Double? = nil) -> [Double] { + var values = kind.options(in: limits).filter { value in + (lower.map { value >= $0 - ProfileEditLimits.tolerance } ?? true) + && (upper.map { value <= $0 + ProfileEditLimits.tolerance } ?? true) + } + if let index = values.firstIndex(where: { abs($0 - current) < ProfileEditLimits.tolerance }) { + values[index] = current + } else { + values.append(current) + values.sort() + } + return values + } + + private func startBinding(_ index: Int) -> Binding { + Binding( + get: { segments.indices.contains(index) ? segments[index].seconds : 0 }, + set: { newValue in + guard segments.indices.contains(index), index > 0 else { return } + segments[index].seconds = newValue + } + ) + } + + private func valueBinding(_ index: Int, high: Bool) -> Binding { + Binding( + get: { + guard segments.indices.contains(index) else { return 0 } + return high ? segments[index].high : segments[index].value + }, + set: { newValue in + guard segments.indices.contains(index) else { return } + if high { + segments[index].high = newValue + } else { + segments[index].value = newValue + if singleTarget, kind.isRange { segments[index].high = newValue } + } + } + ) + } + + private func valueStatus(_ segment: EditableSegment) -> ProfileValueStatus { + guard let limits else { return .ok } + let statuses = kind.isRange + ? [limits.status(of: segment.value, in: kind), limits.status(of: segment.high, in: kind)] + : [limits.status(of: segment.value, in: kind)] + if statuses.contains(.notAllowed) || (kind.isRange && segment.value > segment.high + ProfileEditLimits.tolerance) { + return .notAllowed + } + return statuses.contains(.outsideRecommended) ? .outsideRecommended : .ok + } + + private func color(for status: ProfileValueStatus) -> Color { + switch status { + case .ok: return limits == nil ? .secondary : .primary + case .outsideRecommended: return .orange + case .notAllowed: return .red + } + } + + private var footer: some View { + VStack(alignment: .leading, spacing: 4) { + if kind == .basal { + Text("Total: \(Localizer.formatToLocalizedString(TherapyProfile.basalTotal(segments.map { ProfileScheduleItem(seconds: $0.seconds, value: $0.value) }), maxFractionDigits: 2, minFractionDigits: 2)) U/day") + } + if let limits { + Text(limits.boundsText(for: kind)) + Text("\(segments.count) of \(limits.segmentLimit(for: kind)) times. Tap a row to change it, swipe left to remove it.") + } + } + } +} diff --git a/LoopFollow/Remote/Profiles/ScheduledSwitches.swift b/LoopFollow/Remote/Profiles/ScheduledSwitches.swift new file mode 100644 index 000000000..0d488468f --- /dev/null +++ b/LoopFollow/Remote/Profiles/ScheduledSwitches.swift @@ -0,0 +1,332 @@ +// LoopFollow +// ScheduledSwitches.swift + +import Foundation + +/// The scheduled switches in the latest profile document of Loop or Trio. +struct ScheduledSwitchList: Equatable { + /// The entries, with Trio's `default` named Default as in the profile list. + let entries: [ScheduledProfileSwitch] + /// Fingerprint of the list as uploaded. A replace command sends it so the phone only replaces this list. + let fingerprint: String + /// False when an entry could not be read or the list does not match the fingerprint the phone uploaded, so the + /// list can not be replaced faithfully. + let isComplete: Bool + /// The time zone of the therapy schedules, in which the start times apply. + let timeZone: TimeZone + + static let empty = ScheduledSwitchList(entries: [], fingerprint: ScheduledProfileSwitch.fingerprint([]), isComplete: true, timeZone: .current) + + init(entries: [ScheduledProfileSwitch], fingerprint: String, isComplete: Bool, timeZone: TimeZone) { + self.entries = entries + self.fingerprint = fingerprint + self.isComplete = isComplete + self.timeZone = timeZone + } + + init(_ profile: NSProfile) { + let readable = profile.scheduledSwitches.map { $0.value != nil } ?? true + let decoded = profile.scheduledSwitches?.value ?? [] + let raw = decoded.compactMap(\.value) + let fingerprint = ScheduledProfileSwitch.fingerprint(raw) + let uploaded = profile.scheduledSwitchesFingerprint + self.init( + entries: raw.map { entry in + var entry = entry + if TherapyProfile.isDefaultName(entry.profile) { entry.profile = TherapyProfile.defaultName } + return entry + }, + fingerprint: fingerprint, + isComplete: readable && raw.count == decoded.count && (uploaded == nil || uploaded == fingerprint), + timeZone: profile.store[profile.defaultProfile].map { ScheduledSwitches.timeZone(identifier: $0.timezone) } ?? .current + ) + } + + var pausedCount: Int { entries.filter(\.paused).count } +} + +/// Week order, occurrences, validation and wording for scheduled switches. +enum ScheduledSwitches { + static let secondsPerDay = 86400 + + /// A Nightscout `timezone` value, also in the `ETC/GMT+1` spelling some uploaders use. + static func timeZone(identifier: String) -> TimeZone { + if let zone = TimeZone(identifier: identifier) ?? TimeZone(identifier: identifier.replacingOccurrences(of: "ETC/", with: "Etc/")) { + return zone + } + let parts = identifier.uppercased().components(separatedBy: "GMT") + if parts.count > 1, let hours = Int(parts[1]), let zone = TimeZone(secondsFromGMT: -hours * 3600) { + return zone + } + return .current + } + + /// Monday first, Sunday last. + static func weekPosition(_ weekday: Int) -> Int { + (weekday + 5) % 7 + } + + /// Calendar weekdays (Sunday = 1) from Monday. + static let weekdaysFromMonday = [2, 3, 4, 5, 6, 7, 1] + + /// Seconds from Monday 00:00. + static func weekSeconds(_ entry: ScheduledProfileSwitch) -> Int { + weekPosition(entry.weekday) * secondsPerDay + entry.time + } + + /// The entries as a week from Monday. + static func sortedForWeek(_ entries: [ScheduledProfileSwitch]) -> [ScheduledProfileSwitch] { + entries.sorted { lhs, rhs in + if weekSeconds(lhs) != weekSeconds(rhs) { return weekSeconds(lhs) < weekSeconds(rhs) } + return lhs.profile.localizedStandardCompare(rhs.profile) == .orderedAscending + } + } + + /// The unpaused start that ends the span of `entry`: the next unpaused start after it in the week, counting + /// round to the following week. Nil when `entry` is paused or no other unpaused start exists. + static func following(_ entry: ScheduledProfileSwitch, in entries: [ScheduledProfileSwitch]) -> ScheduledProfileSwitch? { + guard !entry.paused else { return nil } + let week = 7 * secondsPerDay + let start = weekSeconds(entry) + return entries + .filter { !$0.paused && $0.id != entry.id } + .min { lhs, rhs in + let left = (weekSeconds(lhs) - start + week) % week + let right = (weekSeconds(rhs) - start + week) % week + return (left == 0 ? week : left) < (right == 0 ? week : right) + } + } + + private static func calendar(_ timeZone: TimeZone) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + return calendar + } + + /// The next start of `entry` after `date`, in `timeZone`. + static func nextOccurrence(of entry: ScheduledProfileSwitch, after date: Date, timeZone: TimeZone) -> Date? { + let components = DateComponents(hour: entry.time / 3600, minute: entry.time % 3600 / 60, weekday: entry.weekday) + return calendar(timeZone).nextDate(after: date, matching: components, matchingPolicy: .nextTime) + } + + /// The next unpaused start after `date`. + static func next(in entries: [ScheduledProfileSwitch], after date: Date, timeZone: TimeZone) -> (entry: ScheduledProfileSwitch, date: Date)? { + entries + .filter { !$0.paused } + .compactMap { entry in nextOccurrence(of: entry, after: date, timeZone: timeZone).map { (entry, $0) } } + .min { $0.1 < $1.1 } + } + + /// The list with the start `id` paused, or resumed when it is paused. Nothing else changes. + static func togglingPause(of id: String, in entries: [ScheduledProfileSwitch]) -> [ScheduledProfileSwitch] { + entries.map { entry in + guard entry.id == id else { return entry } + var changed = entry + changed.paused.toggle() + return changed + } + } + + // MARK: Validation + + /// The phone's rules for a list, as sentences; empty when the list can be sent. + static func problems(_ entries: [ScheduledProfileSwitch], profileNames: [String], appName: String) -> [String] { + var problems: [String] = [] + if entries.count > ScheduledProfileSwitch.maxCount { + problems.append("There can be at most \(ScheduledProfileSwitch.maxCount) scheduled switches. Delete one before adding another.") + } + for entry in sortedForWeek(entries) { + if let problem = entryProblem(entry, in: entries, profileNames: profileNames, appName: appName), !problems.contains(problem) { + problems.append(problem) + } + } + return problems + } + + /// Why one entry can not be sent, or nil. + static func entryProblem(_ entry: ScheduledProfileSwitch, in entries: [ScheduledProfileSwitch], profileNames: [String], appName: String) -> String? { + if !profileNames.contains(where: { $0.caseInsensitiveCompare(entry.profile) == .orderedSame }) { + return "“\(entry.profile)” is not a profile in \(appName). Choose another profile or delete this start." + } + if !(1 ... 7).contains(entry.weekday) { + return "A start has no valid weekday." + } + if entry.time < 0 || entry.time >= secondsPerDay || entry.time % 60 != 0 { + return "A start time must be a whole minute of the day." + } + if let other = entries.first(where: { $0.id != entry.id && $0.weekday == entry.weekday && $0.time == entry.time }) { + if other.profile.caseInsensitiveCompare(entry.profile) == .orderedSame { + return "\(entry.profile) starts twice on \(slotText(entry)). Delete one of them." + } + let first = [entry, other].min { $0.profile.localizedStandardCompare($1.profile) == .orderedAscending }! + let second = first.id == entry.id ? other : entry + return "\(first.profile) and \(second.profile) both start on \(slotText(entry)). Only one profile can start at a time." + } + return nil + } + + // MARK: Wording + + static func weekdayName(_ weekday: Int, short: Bool = false) -> String { + let symbols = short ? Calendar.current.shortWeekdaySymbols : Calendar.current.weekdaySymbols + guard (1 ... 7).contains(weekday) else { return "?" } + return symbols[weekday - 1] + } + + /// The start time, as the profile values show their times. + static func timeText(_ seconds: Int) -> String { + ProfileValueFormat.timeText(seconds) + } + + /// "Saturday 13:00". + static func slotText(_ entry: ScheduledProfileSwitch, short: Bool = false) -> String { + "\(weekdayName(entry.weekday, short: short)) \(timeText(entry.time))" + } + + /// "Weekend starts Saturday 13:00". + static func startText(_ entry: ScheduledProfileSwitch, short: Bool = false) -> String { + "\(entry.profile) starts \(slotText(entry, short: short))" + } + + /// The second line of a row: "Paused", or how long the profile stays active. + static func spanText(_ entry: ScheduledProfileSwitch, in entries: [ScheduledProfileSwitch]) -> String { + if entry.paused { return "Paused" } + if let following = following(entry, in: entries) { + return "until \(slotText(following, short: true))" + } + return "until it starts again next week" + } + + /// The footer of the list: what runs next, and which starts are paused. + static func footerText(_ list: ScheduledSwitchList, appName: String, now: Date = Date()) -> String { + guard !list.entries.isEmpty else { + return "No scheduled switches. \(appName) keeps the active profile until someone switches." + } + var sentences: [String] = [] + if let next = next(in: list.entries, after: now, timeZone: list.timeZone) { + sentences.append("Next: \(startText(next.entry)).") + } + let paused = list.pausedCount + if paused == list.entries.count { + sentences.append(paused == 1 ? "The start is paused." : "All starts are paused.") + } else if paused > 0 { + sentences.append(paused == 1 ? "1 start paused." : "\(paused) starts paused.") + } + let unpaused = list.entries.filter { !$0.paused } + if unpaused.count == 1, let only = unpaused.first { + sentences.append("\(only.profile) starts again every \(slotText(only)) after a manual switch.") + } + sentences.append("A profile stays active until the next start, also after a manual switch. Paused starts do not run, and resuming one switches nothing until its next time.") + return sentences.joined(separator: " ") + } + + /// The sentence for a profile switch: how long the switched-to profile stays active. + static func holdText(_ list: ScheduledSwitchList, now: Date = Date()) -> String? { + guard let next = next(in: list.entries, after: now, timeZone: list.timeZone) else { return nil } + return "It stays active until \(startText(next.entry))." + } + + /// The sentences for deleting a profile: the scheduled switches that go with it, and what that does to the week. + static func deletionText(_ list: ScheduledSwitchList, profileName: String) -> String? { + let count = list.entries.filter { $0.profile.caseInsensitiveCompare(profileName) == .orderedSame }.count + guard count > 0 else { return nil } + let removed = count == 1 + ? "\(profileName) is used by 1 scheduled switch, which will be removed." + : "\(profileName) is used by \(count) scheduled switches, which will be removed." + return removed + " At those times the profile active before stays active until the next scheduled start." + } + + /// A note when the profile's time zone is not this phone's. + static func timeZoneNote(_ timeZone: TimeZone, now: Date = Date()) -> String? { + guard timeZone.secondsFromGMT(for: now) != TimeZone.current.secondsFromGMT(for: now) else { return nil } + let name = timeZone.localizedName(for: .generic, locale: .current) ?? timeZone.identifier + return "Times are in the profile's time zone (\(name)), not this phone's." + } +} + +/// One difference between the list before and after an edit. +struct ScheduledSwitchChange: Identifiable, Equatable { + enum Kind: Equatable { + case added + case removed + case changed + case paused + case resumed + } + + let kind: Kind + let old: ScheduledProfileSwitch? + let new: ScheduledProfileSwitch? + + var id: String { (new ?? old)?.id ?? "" } + + var text: String { + switch kind { + case .added: + return "Added: \(new.map { ScheduledSwitches.startText($0) } ?? "")" + (new?.paused == true ? " (paused)" : "") + case .removed: + return "Removed: \(old.map { ScheduledSwitches.startText($0) } ?? "")" + case .changed: + let paused = new?.paused == true ? " (paused)" : "" + return "Changed: \(old.map { ScheduledSwitches.startText($0) } ?? "") → \(new.map { ScheduledSwitches.startText($0) } ?? "")\(paused)" + case .paused: + return "Paused: \(new.map { ScheduledSwitches.startText($0) } ?? "")" + case .resumed: + return "Resumed: \(new.map { ScheduledSwitches.startText($0) } ?? "")" + } + } +} + +enum ScheduledSwitchDiff { + /// Entries are matched by `id`. A change of only `paused` is a pause or resume; any other change, with or + /// without `paused`, is a change. + static func changes(from old: [ScheduledProfileSwitch], to new: [ScheduledProfileSwitch]) -> [ScheduledSwitchChange] { + var changes: [ScheduledSwitchChange] = [] + let oldByID = Dictionary(old.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let newIDs = Set(new.map(\.id)) + for entry in ScheduledSwitches.sortedForWeek(new) { + guard let before = oldByID[entry.id] else { + changes.append(ScheduledSwitchChange(kind: .added, old: nil, new: entry)) + continue + } + let sameStart = before.profile == entry.profile && before.weekday == entry.weekday && before.time == entry.time + if !sameStart { + changes.append(ScheduledSwitchChange(kind: .changed, old: before, new: entry)) + } else if before.paused != entry.paused { + changes.append(ScheduledSwitchChange(kind: entry.paused ? .paused : .resumed, old: before, new: entry)) + } + } + for entry in ScheduledSwitches.sortedForWeek(old) where !newIDs.contains(entry.id) { + changes.append(ScheduledSwitchChange(kind: .removed, old: entry, new: nil)) + } + return changes + } + + /// The target profiles that start in the new list where they did not before, unpaused: added, changed or + /// resumed entries. Each name once, in week order. + static func newlyStartingProfiles(from old: [ScheduledProfileSwitch], to new: [ScheduledProfileSwitch]) -> [String] { + var names: [String] = [] + for change in changes(from: old, to: new) { + guard let entry = change.new, !entry.paused, change.kind != .removed, change.kind != .paused else { continue } + if !names.contains(entry.profile) { names.append(entry.profile) } + } + return names + } + + /// The profiles that start in the new list where they did not before and give more insulin than Default + /// somewhere, compared in mg/dL whatever units each profile has. + static func moreInsulinProfiles( + from old: [ScheduledProfileSwitch], + to new: [ScheduledProfileSwitch], + profiles: [TherapyProfile] + ) -> [(profile: TherapyProfile, increases: [ProfileInsulinIncrease])] { + guard let base = profiles.first(where: \.isDefault) else { return [] } + return newlyStartingProfiles(from: old, to: new).compactMap { name in + guard let profile = profiles.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }), !profile.isDefault else { + return nil + } + let increases = ProfileInsulinComparison.increases(from: base.values, to: profile.values) + return increases.isEmpty ? nil : (profile, increases) + } + } +} diff --git a/LoopFollow/Remote/Profiles/ScheduledSwitchesView.swift b/LoopFollow/Remote/Profiles/ScheduledSwitchesView.swift new file mode 100644 index 000000000..e606aa81b --- /dev/null +++ b/LoopFollow/Remote/Profiles/ScheduledSwitchesView.swift @@ -0,0 +1,527 @@ +// LoopFollow +// ScheduledSwitchesView.swift + +import SwiftUI + +enum ScheduledSwitchEditing { + /// Why the scheduled switches can not be changed from LoopFollow, or nil when they can. + static func blocker(_ list: ScheduledSwitchList, supported: Bool, remote: ProfileRemote) -> String? { + guard supported else { + return "\(remote.appName) has not said it accepts changes to its scheduled switches from LoopFollow, so they can only be viewed here. An older version of \(remote.appName) may need an update first." + } + guard list.isComplete else { + return "The scheduled switches \(remote.appName) uploaded could not be read completely, so they can only be viewed here. Change them in \(remote.appName) instead." + } + return nil + } +} + +/// The note shown with the scheduled switches and before a change to them is sent. +struct ScheduledSwitchDosingNote: View { + var body: some View { + Text("A scheduled switch changes the profile that doses insulin at the set time, also when nobody is looking at the phone.") + } +} + +/// One start in the week, with how long its profile stays active. +struct ScheduledSwitchRow: View { + let entry: ScheduledProfileSwitch + let entries: [ScheduledProfileSwitch] + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text("\(ScheduledSwitches.slotText(entry, short: true)) · \(entry.profile) starts") + .foregroundColor(entry.paused ? .secondary : .primary) + Text(ScheduledSwitches.spanText(entry, in: entries)) + .font(.caption) + .foregroundColor(.secondary) + } + .accessibilityElement(children: .combine) + } +} + +/// The scheduled switches section of the profile list: the week with pause and resume per start, and the button +/// for the editor. The list presents the editor and the review of a pause or resume. +struct ScheduledSwitchesSection: View { + let remote: ProfileRemote + let profileSet: TherapyProfileSet + let commands: [String] + let onEdit: () -> Void + /// Called with the whole list after pausing or resuming one start, for review before sending. + let onTogglePause: ([ScheduledProfileSwitch]) -> Void + + @ObservedObject private var tracker = RemoteCommandTracker.shared + + private var list: ScheduledSwitchList { profileSet.scheduledSwitches } + private var supported: Bool { remote.supports(.saveScheduledSwitches, in: commands) } + private var blocker: String? { ScheduledSwitchEditing.blocker(list, supported: supported, remote: remote) } + private var anyPending: Bool { ProfileCommandFlow.isAnyPending(tracker, remote: remote) } + private var canChange: Bool { blocker == nil && !anyPending } + + var body: some View { + if supported || !list.entries.isEmpty { + Section(header: Text("Scheduled Switches"), footer: footer) { + ForEach(ScheduledSwitches.sortedForWeek(list.entries)) { entry in + ScheduledSwitchRow(entry: entry, entries: list.entries) + .swipeActions(edge: .trailing) { + if canChange { + Button(entry.paused ? "Resume" : "Pause") { togglePause(entry) } + .tint(entry.paused ? .green : .orange) + } + } + } + if supported { + Button { + onEdit() + } label: { + Label("Edit Scheduled Switches", systemImage: "calendar") + } + .disabled(!canChange) + } + } + } + } + + private var footer: some View { + VStack(alignment: .leading, spacing: 4) { + Text(ScheduledSwitches.footerText(list, appName: remote.appName)) + if let note = ScheduledSwitches.timeZoneNote(list.timeZone) { + Text(note) + } + if let blocker { + Text(blocker) + } else if !list.entries.isEmpty { + Text("Swipe left on a start to pause or resume it.") + } + } + } + + private func togglePause(_ entry: ScheduledProfileSwitch) { + onTogglePause(ScheduledSwitches.togglingPause(of: entry.id, in: list.entries)) + } +} + +/// Edits the whole list of scheduled switches and leads to the review before sending. +struct ScheduledSwitchesEditorSheet: View { + let remote: ProfileRemote + let profileSet: TherapyProfileSet + let onFinished: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var draft: [ScheduledProfileSwitch] + @State private var editingID: String? + + init(remote: ProfileRemote, profileSet: TherapyProfileSet, onFinished: @escaping () -> Void) { + self.remote = remote + self.profileSet = profileSet + self.onFinished = onFinished + _draft = State(initialValue: profileSet.scheduledSwitches.entries) + } + + private var list: ScheduledSwitchList { profileSet.scheduledSwitches } + private var profileNames: [String] { profileSet.profiles.map(\.name) } + private var problems: [String] { + ScheduledSwitches.problems(draft, profileNames: profileNames, appName: remote.appName) + } + + private var hasChanges: Bool { draft != list.entries } + private var canAdd: Bool { draft.count < ScheduledProfileSwitch.maxCount && !profileNames.isEmpty } + + var body: some View { + NavigationStack { + Form { + Section( + header: Text("Starts"), + footer: Text(draft.isEmpty + ? "No starts. \(remote.appName) keeps the active profile until someone switches." + : "Each profile stays active until the next start. Tap a start to change, pause or delete it.") + ) { + ForEach(ScheduledSwitches.sortedForWeek(draft)) { entry in + Button { + editingID = entry.id + } label: { + HStack { + ScheduledSwitchRow(entry: entry, entries: draft) + Spacer() + if ScheduledSwitches.entryProblem(entry, in: draft, profileNames: profileNames, appName: remote.appName) != nil { + Image(systemName: "exclamationmark.circle.fill").foregroundColor(.red) + } + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .onDelete { offsets in + let sorted = ScheduledSwitches.sortedForWeek(draft) + let ids = Set(offsets.map { sorted[$0].id }) + draft.removeAll { ids.contains($0.id) } + } + + Button { + let entry = ScheduledProfileSwitch(profile: profileSet.profiles.first?.name ?? TherapyProfile.defaultName, weekday: 2, time: 7 * 3600) + draft.append(entry) + editingID = entry.id + } label: { + Label("Add Start", systemImage: "plus.circle") + } + .disabled(!canAdd) + } + + if let note = ScheduledSwitches.timeZoneNote(list.timeZone) { + Section { Text(note).foregroundColor(.secondary) } + } + + if !problems.isEmpty { + Section(header: Text("Fix Before Sending")) { + ForEach(problems, id: \.self) { Text($0).foregroundColor(.red) } + } + } + + Section { + ScheduledSwitchDosingNote() + .font(.footnote) + .foregroundColor(.secondary) + } + } + .navigationTitle("Scheduled Switches") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(item: $editingID) { id in + ScheduledSwitchEntryEditor(remote: remote, profileSet: profileSet, draft: $draft, id: id) + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + NavigationLink("Review") { + ScheduledSwitchesReviewView(remote: remote, profileSet: profileSet, entries: draft, onFinished: onFinished) + } + .disabled(!hasChanges || !problems.isEmpty) + } + } + } + .interactiveDismissDisabled(hasChanges) + } +} + +/// Edits one start: profile, weekday, time and pause. +struct ScheduledSwitchEntryEditor: View { + let remote: ProfileRemote + let profileSet: TherapyProfileSet + @Binding var draft: [ScheduledProfileSwitch] + let id: String + + @Environment(\.dismiss) private var dismiss + + private static let utc = TimeZone(identifier: "UTC")! + + private var index: Int? { draft.firstIndex { $0.id == id } } + + private var profileNames: [String] { + var names = profileSet.profiles.map(\.name) + if let index, !names.contains(draft[index].profile) { names.append(draft[index].profile) } + return names + } + + var body: some View { + Form { + if let index { + let entry = draft[index] + Section(footer: Text("\(entry.paused ? "Paused. When resumed, " : "")\(entry.profile) starts every \(ScheduledSwitches.weekdayName(entry.weekday)) at \(ScheduledSwitches.timeText(entry.time)) and stays active until the next scheduled start.")) { + Picker("Profile", selection: field(\.profile, of: entry)) { + ForEach(profileNames, id: \.self) { Text($0).tag($0) } + } + Picker("Day", selection: field(\.weekday, of: entry)) { + ForEach(ScheduledSwitches.weekdaysFromMonday, id: \.self) { weekday in + Text(ScheduledSwitches.weekdayName(weekday)).tag(weekday) + } + } + DatePicker("Time", selection: timeBinding(entry), displayedComponents: .hourAndMinute) + .environment(\.timeZone, Self.utc) + } + + if let problem = ScheduledSwitches.entryProblem(entry, in: draft, profileNames: profileSet.profiles.map(\.name), appName: remote.appName) { + Section { Text(problem).foregroundColor(.red) } + } + + Section(footer: Text("A paused start does not run until it is resumed. Nothing switches when it is resumed; the start runs again at its next time.")) { + Toggle("Paused", isOn: field(\.paused, of: entry)) + } + + Section { + Button("Delete Start", role: .destructive) { + draft.removeAll { $0.id == id } + dismiss() + } + } + } + } + .navigationTitle("Start") + .navigationBarTitleDisplayMode(.inline) + } + + /// A field of the start, found by its id on each access. Controls that are still on screen while the start is + /// deleted read `entry`'s value and change nothing. + private func field(_ keyPath: WritableKeyPath, of entry: ScheduledProfileSwitch) -> Binding { + Binding( + get: { draft.first { $0.id == id }?[keyPath: keyPath] ?? entry[keyPath: keyPath] }, + set: { value in + guard let index = draft.firstIndex(where: { $0.id == id }) else { return } + draft[index][keyPath: keyPath] = value + } + ) + } + + /// The time of day as a date on 1 January 1970 in UTC, as the profile times are shown. + private func timeBinding(_ entry: ScheduledProfileSwitch) -> Binding { + let time = field(\.time, of: entry) + return Binding( + get: { Date(timeIntervalSince1970: TimeInterval(time.wrappedValue)) }, + set: { date in + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = Self.utc + let parts = calendar.dateComponents([.hour, .minute], from: date) + time.wrappedValue = (parts.hour ?? 0) * 3600 + (parts.minute ?? 0) * 60 + } + ) + } +} + +/// Shows what a scheduled switches save changes, and sends it after confirmation and device authentication. +struct ScheduledSwitchesReviewView: View { + let remote: ProfileRemote + let profileSet: TherapyProfileSet + let entries: [ScheduledProfileSwitch] + let onFinished: () -> Void + + @ObservedObject private var tracker = RemoteCommandTracker.shared + @State private var alert: AlertType? + @State private var showJSON = false + @State private var pushSize: Int? + @State private var pushSizeComputed = false + @State private var checking = false + + private enum AlertType: Identifiable { + case confirm + case result(ProfileCommandResult) + + var id: String { + switch self { + case .confirm: return "confirm" + case let .result(result): return "result-\(result.success)-\(result.message)" + } + } + } + + private var list: ScheduledSwitchList { profileSet.scheduledSwitches } + private var command: ProfileCommand { .saveScheduledSwitches(entries, baseFingerprint: list.fingerprint) } + private var key: String { remote.key(for: command) } + private var changes: [ScheduledSwitchChange] { ScheduledSwitchDiff.changes(from: list.entries, to: entries) } + private var isSending: Bool { tracker.isBusy(key: key) } + private var otherPending: Bool { !isSending && ProfileCommandFlow.isAnyPending(tracker, remote: remote) } + + private var problems: [String] { + var problems = ScheduledSwitches.problems(entries, profileNames: profileSet.profiles.map(\.name), appName: remote.appName) + if changes.isEmpty { problems.append("Nothing changed.") } + return problems + } + + /// Profiles that start in the new list and give more insulin than Default somewhere. + private var increases: [(profile: TherapyProfile, increases: [ProfileInsulinIncrease])] { + ScheduledSwitchDiff.moreInsulinProfiles(from: list.entries, to: entries, profiles: profileSet.profiles) + } + + private var isTooLarge: Bool { (pushSize ?? 0) > ProfilePushSize.reviewLimit(for: remote) } + + private var canSend: Bool { + pushSizeComputed && problems.isEmpty && !isTooLarge && !isSending && !otherPending && !checking + } + + var body: some View { + List { + Section { + Text("Replace the scheduled switches in \(remote.appName) with the list below. \(remote.appName) then switches profiles at these times by itself, without asking anyone, and each switch changes insulin dosing. Nothing switches when the list is saved.") + } + + Section(header: Text("Changes")) { + ForEach(changes) { change in + Text(change.text) + .foregroundColor(change.kind == .removed ? .secondary : .primary) + } + } + + ForEach(increases, id: \.profile.name) { item in + Section(header: Text("When \(item.profile.name) starts")) { + MoreInsulinWarning( + increases: item.increases, + basalTotal: ProfileInsulinComparison.basalTotalIncrease(from: defaultValues, to: item.profile.values), + comparedWith: TherapyProfile.defaultName, + appliesNow: false + ) + } + } + + Section(header: Text("The Week After Saving"), footer: Text(ScheduledSwitches.footerText(ScheduledSwitchList(entries: entries, fingerprint: "", isComplete: true, timeZone: list.timeZone), appName: remote.appName))) { + if entries.isEmpty { + Text("No starts").foregroundColor(.secondary) + } + ForEach(ScheduledSwitches.sortedForWeek(entries)) { entry in + ScheduledSwitchRow(entry: entry, entries: entries) + } + } + + Section(header: Text("Sent to \(remote.appName)"), footer: sizeFooter) { + LabeledValueRow(label: "Starts", value: "\(entries.count) of \(ScheduledProfileSwitch.maxCount)") + LabeledValueRow(label: "Based on version", value: list.fingerprint) + DisclosureGroup("Exact list", isExpanded: $showJSON) { + Text(Self.jsonText(entries)) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + } + + if !problems.isEmpty { + Section(header: Text("Can't Send")) { + ForEach(problems, id: \.self) { Text($0).foregroundColor(.red) } + } + } + + Section { + ScheduledSwitchDosingNote() + .font(.footnote) + .foregroundColor(.secondary) + } + } + .safeAreaInset(edge: .bottom) { + Button { + alert = .confirm + } label: { + Group { + if isSending { + HStack { + ProgressView().scaleEffect(0.8) + Text("Waiting for \(remote.appName)...") + } + } else if checking { + HStack { + ProgressView().scaleEffect(0.8) + Text("Checking the scheduled switches...") + } + } else if otherPending { + Text("Another Profile Command Is Pending") + } else { + Text("Send to \(remote.appName)") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(!canSend) + .padding(.horizontal) + .padding(.vertical, 8) + .background(.bar) + } + .navigationTitle("Review") + .navigationBarTitleDisplayMode(.inline) + .task(id: entries) { + pushSize = Self.computePushSize(entries, baseFingerprint: list.fingerprint, remote: remote) + pushSizeComputed = true + } + .onProfileCommandResult(keys: [key], command: command) { _, result in + alert = .result(result) + } + .alert(item: $alert) { type in + switch type { + case .confirm: + return Alert( + title: Text("Save Scheduled Switches?"), + message: Text("\(remote.appName) replaces its scheduled switches with this list and switches profiles at these times without asking. Nothing switches right now." + + (increases.isEmpty ? "" : "\n\n" + increases.map { "\($0.profile.name) gives more insulin than Default." }.joined(separator: " "))), + primaryButton: .default(Text("Send"), action: send), + secondaryButton: .cancel() + ) + case let .result(result): + return Alert( + title: Text(ProfileCommandFlow.resultTitle(kind: .saveScheduledSwitches, success: result.success, message: result.message, remote: remote)), + message: Text(Self.resultMessage(result, remote: remote)), + dismissButton: .default(Text("OK")) { + if ProfileCommandFlow.leavesAfterResult(result, remote: remote) { onFinished() } + } + ) + } + } + } + + private var defaultValues: TherapyProfileValues { + profileSet.profiles.first(where: \.isDefault)?.values ?? TherapyProfileValues(units: TherapyProfileValues.mgdl, basal: [], carbRatio: [], sensitivity: [], target: []) + } + + private var sizeFooter: Text { + guard pushSizeComputed else { return Text("") } + guard let pushSize else { + return Text("The size of the command can not be computed until LoopFollow's own APNS credentials are set up in App Settings → APN.") + } + if isTooLarge { + return Text(ScheduledSwitchesPushSize.tooLargeMessage(pushSize)).foregroundColor(.red) + } + return Text("Command size: about \(pushSize) of \(APNSDelivery.maxPayloadBytes) bytes.") + } + + static func computePushSize(_ entries: [ScheduledProfileSwitch], baseFingerprint: String, remote: ProfileRemote) -> Int? { + switch remote { + case .loop: + return LoopAPNSService().scheduledSwitchesSavePushSize(entries: entries, baseFingerprint: baseFingerprint) + case .trio: + return PushNotificationManager().scheduledSwitchesSavePushSize(entries: entries, baseFingerprint: baseFingerprint) + } + } + + static func resultMessage(_ result: ProfileCommandResult, remote: ProfileRemote) -> String { + guard result.outdated else { return result.message } + return result.message + "\n\nLoopFollow is loading the scheduled switches \(remote.appName) uploaded. Open them again to review the current list." + } + + /// Why the reviewed list must not be sent now that `current` is the newest profile document, or nil. + static func staleReason(baseFingerprint: String, entries: [ScheduledProfileSwitch], current: TherapyProfileSet?, remote: ProfileRemote) -> String? { + guard let current else { + return "LoopFollow could not load the scheduled switches from Nightscout to check that they are unchanged, so nothing was sent. Try again." + } + if current.scheduledSwitches.fingerprint != baseFingerprint || !current.scheduledSwitches.isComplete { + return "The scheduled switches changed in \(remote.appName) while you were editing, so nothing was sent. Review them again." + } + let names = current.profiles.map(\.name) + if let missing = entries.first(where: { entry in !names.contains { $0.caseInsensitiveCompare(entry.profile) == .orderedSame } }) { + return "“\(missing.profile)” is no longer in the list uploaded by \(remote.appName), so nothing was sent. Review the scheduled switches again." + } + return nil + } + + private func send() { + checking = true + let base = list.fingerprint + ProfileCommandFlow.fetchProfileSet { current in + checking = false + if let reason = Self.staleReason(baseFingerprint: base, entries: entries, current: current, remote: remote) { + if let current { Observable.shared.therapyProfiles.set(current) } + alert = .result(ProfileCommandResult(success: false, message: reason, outdated: current != nil)) + return + } + ProfileCommandFlow.authenticateAndSend(command, to: remote, reason: "Confirm your identity to change the scheduled switches in \(remote.appName).") { message in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + alert = .result(ProfileCommandResult(success: false, message: message)) + } + } + } + } + + static func jsonText(_ entries: [ScheduledProfileSwitch]) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(entries), let text = String(data: data, encoding: .utf8) else { return "" } + return text + } +} diff --git a/LoopFollow/Remote/Profiles/TherapyProfile.swift b/LoopFollow/Remote/Profiles/TherapyProfile.swift new file mode 100644 index 000000000..0551a10fd --- /dev/null +++ b/LoopFollow/Remote/Profiles/TherapyProfile.swift @@ -0,0 +1,122 @@ +// LoopFollow +// TherapyProfile.swift + +import Foundation +import HealthKit + +/// A therapy profile from the Nightscout profile store of Loop or Trio. +struct TherapyProfile: Identifiable, Equatable { + /// The store key of the profile that always exists (Loop `Default`, Trio `default`), matched case-insensitively. + static let defaultName = "Default" + + let name: String + let units: HKUnit + let values: TherapyProfileValues + /// Fingerprint of the store entry as uploaded. Commands that act on an existing profile send it so the + /// phone only acts when its profile still has these values. + let fingerprint: String + /// False when the correction range is missing or its lows and highs do not line up, so the values can not be + /// edited faithfully. + let isWellFormed: Bool + + var id: String { name } + + var isDefault: Bool { Self.isDefaultName(name) } + + var basal: [ProfileScheduleItem] { values.basal } + var carbRatio: [ProfileScheduleItem] { values.carbRatio } + var sensitivity: [ProfileScheduleItem] { values.sensitivity } + var correctionRange: [ProfileRangeItem] { values.target } + + static func isDefaultName(_ name: String) -> Bool { + name.caseInsensitiveCompare(defaultName) == .orderedSame + } + + /// `documentUnits` applies when the store entry has no `units` of its own. + init(name: String, store: NSProfile.Store, documentUnits: String = TherapyProfileValues.mgdl) { + func items(_ entries: [(Double, Double)]) -> [ProfileScheduleItem] { + entries.map { ProfileScheduleItem(seconds: Int($0.0), value: $0.1) } + } + let basal = items(store.basal.map { ($0.timeAsSeconds, $0.value) }) + let carbRatio = items(store.carbratio.map { ($0.timeAsSeconds, $0.value) }) + let sensitivity = items(store.sens.map { ($0.timeAsSeconds, $0.value) }) + let targetLow = items((store.target_low ?? []).map { ($0.timeAsSeconds, $0.value) }) + let targetHigh = items((store.target_high ?? []).map { ($0.timeAsSeconds, $0.value) }) + + self.name = name + let unitsText = TherapyProfileValues.normalizedUnits(store.units ?? documentUnits) + units = unitsText == TherapyProfileValues.mgdl ? .milligramsPerDeciliter : .millimolesPerLiter + values = TherapyProfileValues( + units: unitsText, + basal: basal, + carbRatio: carbRatio, + sensitivity: sensitivity, + target: zip(targetLow, targetHigh).map { ProfileRangeItem(seconds: $0.seconds, low: $0.value, high: $1.value) } + ) + isWellFormed = !targetLow.isEmpty && targetLow.count == targetHigh.count && zip(targetLow, targetHigh).allSatisfy { $0.seconds == $1.seconds } + fingerprint = TherapyProfileValues.fingerprint(basal: basal, carbRatio: carbRatio, sensitivity: sensitivity, targetLow: targetLow, targetHigh: targetHigh) + } + + /// Scheduled basal over a full day, in units. + var basalTotal: Double { + Self.basalTotal(basal) + } + + static func basalTotal(_ basal: [ProfileScheduleItem]) -> Double { + basal.enumerated().reduce(0) { total, entry in + let end = entry.offset + 1 < basal.count ? basal[entry.offset + 1].seconds : 24 * 3600 + return total + entry.element.value * Double(end - entry.element.seconds) / 3600 + } + } + + /// Every profile in a profile document, Default first, then by name. Trio's `default` entry is named Default, which + /// Trio's commands also accept. + static func profiles(from profile: NSProfile) -> [TherapyProfile] { + profile.store + .map { TherapyProfile(name: isDefaultName($0.key) ? defaultName : $0.key, store: $0.value, documentUnits: profile.units) } + .sorted { lhs, rhs in + if lhs.isDefault != rhs.isDefault { return lhs.isDefault } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } +} + +/// The profiles from the latest profile upload of Loop or Trio. +struct TherapyProfileSet: Equatable { + let profiles: [TherapyProfile] + /// `defaultProfile` of the document: the active profile. + let activeName: String + let uploadedAt: Date? + let limits: ProfileEditLimits? + let scheduledSwitches: ScheduledSwitchList + + init(profiles: [TherapyProfile], activeName: String, uploadedAt: Date?, limits: ProfileEditLimits? = nil, scheduledSwitches: ScheduledSwitchList = .empty) { + self.profiles = profiles + self.activeName = activeName + self.uploadedAt = uploadedAt + self.limits = limits + self.scheduledSwitches = scheduledSwitches + } + + init(_ profile: NSProfile) { + self.init( + profiles: TherapyProfile.profiles(from: profile), + activeName: profile.defaultProfile, + uploadedAt: profile.startDate.flatMap { NightscoutUtils.parseDate($0) }, + limits: profile.editLimits, + scheduledSwitches: ScheduledSwitchList(profile) + ) + } + + func isActive(_ profile: TherapyProfile) -> Bool { + profile.name.caseInsensitiveCompare(activeName) == .orderedSame + } + + var activeProfile: TherapyProfile? { + profiles.first(where: isActive) + } + + func profile(named name: String) -> TherapyProfile? { + profiles.first { $0.name == name } + } +} diff --git a/LoopFollow/Remote/Profiles/TherapyProfilesView.swift b/LoopFollow/Remote/Profiles/TherapyProfilesView.swift new file mode 100644 index 000000000..892dcee0d --- /dev/null +++ b/LoopFollow/Remote/Profiles/TherapyProfilesView.swift @@ -0,0 +1,443 @@ +// LoopFollow +// TherapyProfilesView.swift + +import SwiftUI + +/// Lists the profiles of Loop or Trio. Selecting one shows its values, where it can be activated, edited, +/// renamed or deleted, as far as the app supports those commands. +struct TherapyProfilesView: View { + let remote: ProfileRemote + + @ObservedObject private var therapyProfiles = Observable.shared.therapyProfiles + @ObservedObject private var remoteCommands = Storage.shared.remoteCommands + @ObservedObject private var loopRemoteCommands = Storage.shared.loopRemoteCommands + @State private var sheet: ListSheet? + + /// A sheet opened from the list. It keeps the profiles it was opened with, and is attached to the list itself, + /// so it stays open with its result alert while the profiles are reloaded. + private struct ListSheet: Identifiable { + enum Kind { + case newProfile(source: TherapyProfile) + case scheduledSwitches + case pause(entries: [ScheduledProfileSwitch]) + } + + let kind: Kind + let profileSet: TherapyProfileSet + let id = UUID() + } + + private var commands: [String] { + remote.acceptedCommands(profileDocument: remoteCommands.value, loopDeviceStatus: loopRemoteCommands.value) + } + + private var canSave: Bool { remote.supports(.save, in: commands) } + + /// Default is a real profile once the app can save profiles. Older Loop builds upload a Default entry that + /// only mirrors the active settings, so it is left out for them. + private func visibleProfiles(_ set: TherapyProfileSet) -> [TherapyProfile] { + canSave ? set.profiles : set.profiles.filter { !$0.isDefault } + } + + var body: some View { + List { + Section { + ProfileDosingNote() + .font(.footnote) + .foregroundColor(.secondary) + } + + if let profileSet = therapyProfiles.value, !visibleProfiles(profileSet).isEmpty { + Section( + header: Text("Profiles"), + footer: uploadedFooter(profileSet.uploadedAt) + ) { + ForEach(visibleProfiles(profileSet)) { profile in + NavigationLink(destination: TherapyProfilePreviewView(remote: remote, name: profile.name)) { + HStack { + Text(profile.name) + Spacer() + if profileSet.isActive(profile) { + Text("Active") + .font(.caption) + .foregroundColor(.green) + } + } + } + } + } + + ScheduledSwitchesSection( + remote: remote, + profileSet: profileSet, + commands: commands, + onEdit: { sheet = ListSheet(kind: .scheduledSwitches, profileSet: profileSet) }, + onTogglePause: { sheet = ListSheet(kind: .pause(entries: $0), profileSet: profileSet) } + ) + + if canSave { + let createBlocker = ProfileEditing.blocker(limits: profileSet.limits, remote: remote) ?? ProfileValidation.countProblem(profileCount: profileSet.profiles.count) + Section(footer: Text(createBlocker ?? "A new profile starts as a copy of the active profile. Dosing does not change until it is activated.")) { + Button { + if let source = profileSet.activeProfile ?? profileSet.profiles.first { + sheet = ListSheet(kind: .newProfile(source: source), profileSet: profileSet) + } + } label: { + Label("New Profile", systemImage: "plus") + } + .disabled(createBlocker != nil || profileSet.profiles.isEmpty) + } + } + } else { + Text(remote == .loop + ? "No saved profiles found. Profiles are saved in Loop under Settings → Profiles." + : "No profiles found. Profiles are saved in Trio under Settings → Therapy Settings → Therapy Profiles.") + .foregroundColor(.secondary) + .italic() + } + } + .navigationBarTitle("Remote Profiles", displayMode: .inline) + .sheet(item: $sheet) { sheet in + switch sheet.kind { + case let .newProfile(source): + ProfileEditorSheet(remote: remote, mode: .new(source: source), profileSet: sheet.profileSet) { + self.sheet = nil + } + case .scheduledSwitches: + ScheduledSwitchesEditorSheet(remote: remote, profileSet: sheet.profileSet) { self.sheet = nil } + case let .pause(entries): + NavigationStack { + ScheduledSwitchesReviewView(remote: remote, profileSet: sheet.profileSet, entries: entries) { self.sheet = nil } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { self.sheet = nil } + } + } + } + } + } + } + + private func uploadedFooter(_ date: Date?) -> Text { + guard let date else { return Text("") } + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return Text("Uploaded by \(remote.appName) \(formatter.string(from: date)).") + } +} + +/// Shows a profile's values. From here it can be activated, edited, renamed or deleted. +struct TherapyProfilePreviewView: View { + let remote: ProfileRemote + let name: String + + @Environment(\.presentationMode) private var presentationMode + @ObservedObject private var therapyProfiles = Observable.shared.therapyProfiles + @ObservedObject private var remoteCommands = Storage.shared.remoteCommands + @ObservedObject private var loopRemoteCommands = Storage.shared.loopRemoteCommands + @ObservedObject private var tracker = RemoteCommandTracker.shared + @State private var alertType: AlertType? + @State private var sheet: ProfileSheet? + + /// An edit or rename of the profile. It keeps the profile and profiles it was opened with, and is attached + /// outside the profile's values, so it stays open with its result alert while the profiles are reloaded, + /// also when the profile is renamed. + private struct ProfileSheet: Identifiable { + enum Kind { case edit, rename } + + let kind: Kind + let profile: TherapyProfile + let profileSet: TherapyProfileSet + let id = UUID() + } + + private enum AlertType: Identifiable { + /// The confirmations carry the fingerprint of the profile they show. + case confirmActivate(fingerprint: String) + case confirmDelete(fingerprint: String) + case result(kind: ProfileCommandKind, ProfileCommandResult) + + var id: String { + switch self { + case let .confirmActivate(fingerprint): return "activate-\(fingerprint)" + case let .confirmDelete(fingerprint): return "delete-\(fingerprint)" + case let .result(kind, result): return "result-\(kind)-\(result.success)-\(result.message)" + } + } + } + + private var commands: [String] { + remote.acceptedCommands(profileDocument: remoteCommands.value, loopDeviceStatus: loopRemoteCommands.value) + } + + private var profileSet: TherapyProfileSet? { therapyProfiles.value } + + private var profile: TherapyProfile? { profileSet?.profile(named: name) } + + private var isActive: Bool { + guard let profile, let profileSet else { return false } + return profileSet.isActive(profile) + } + + /// The active profile, when this profile gives more insulin than it somewhere. + private var activationIncreases: (active: TherapyProfile, increases: [ProfileInsulinIncrease])? { + guard !isActive, let profile, let active = profileSet?.activeProfile else { return nil } + let increases = ProfileInsulinComparison.increases(from: active.values, to: profile.values) + return increases.isEmpty ? nil : (active, increases) + } + + private var activateKey: String { remote.key(.activate, name: name) } + private var deleteKey: String { remote.key(.delete, name: name) } + + private var resultKeys: [String] { + [activateKey, deleteKey] + } + + private var anyPending: Bool { ProfileCommandFlow.isAnyPending(tracker, remote: remote) } + + private var editBlocker: String? { + guard let profile else { return nil } + return ProfileEditing.blocker(for: profile, limits: profileSet?.limits, remote: remote) + } + + var body: some View { + Group { + if let profile { + content(profile) + } else { + Text("This profile is no longer in the list uploaded by \(remote.appName).") + .foregroundColor(.secondary) + .padding() + } + } + .navigationBarTitle(name, displayMode: .inline) + .onProfileCommandResult(keys: resultKeys) { key, result in + let kind: ProfileCommandKind = key == activateKey ? .activate : .delete + alertType = .result(kind: kind, result) + } + .alert(item: $alertType) { type in alert(type) } + .sheet(item: $sheet) { sheet in + switch sheet.kind { + case .edit: + ProfileEditorSheet(remote: remote, mode: .edit(sheet.profile), profileSet: sheet.profileSet) { + self.sheet = nil + } + case .rename: + ProfileRenameSheet(remote: remote, profile: sheet.profile, profileSet: sheet.profileSet, isActive: sheet.profileSet.isActive(sheet.profile)) { + self.sheet = nil + presentationMode.wrappedValue.dismiss() + } + } + } + } + + private func open(_ kind: ProfileSheet.Kind, _ profile: TherapyProfile) { + guard let profileSet else { return } + sheet = ProfileSheet(kind: kind, profile: profile, profileSet: profileSet) + } + + private func content(_ profile: TherapyProfile) -> some View { + List { + Section(footer: Text("\(remote.appName) writes the basal rates to the pump when the profile is activated.")) { + Text(isActive ? "This is the active profile. \(remote.appName) doses insulin with these values." : "Review the profile before activating it in \(remote.appName).") + if let activationIncreases { + MoreInsulinWarning( + increases: activationIncreases.increases, + basalTotal: ProfileInsulinComparison.basalTotalIncrease(from: activationIncreases.active.values, to: profile.values), + comparedWith: activationIncreases.active.name, + appliesNow: true + ) + } + } + + ForEach(ProfileScheduleKind.allCases) { kind in + ProfileValuesEditor( + kind: kind, + segments: .constant(ProfileDraft(profile.values, singleTarget: remote.hasSingleTarget)[kind]), + units: profile.values.units, + limits: nil, + singleTarget: remote.hasSingleTarget + ) + } + + if remote.supports(.save, in: commands) || remote.supports(.delete, in: commands) { + actionsSection(profile) + } + } + .toolbar { + if remote.supports(.save, in: commands) { + ToolbarItem(placement: .primaryAction) { + Button("Edit") { open(.edit, profile) } + .disabled(editBlocker != nil || anyPending) + } + } + } + .safeAreaInset(edge: .bottom) { + if remote.supports(.activate, in: commands) { + activateButton + } + } + } + + @ViewBuilder + private func actionsSection(_ profile: TherapyProfile) -> some View { + let canDelete = remote.supports(.delete, in: commands) && !isActive && !profile.isDefault + Section(footer: actionsFooter(profile)) { + if remote.supports(.save, in: commands), !profile.isDefault { + Button("Rename Profile") { open(.rename, profile) } + .disabled(editBlocker != nil || anyPending) + } + if remote.supports(.delete, in: commands), !profile.isDefault, !isActive { + Button("Delete Profile", role: .destructive) { alertType = .confirmDelete(fingerprint: profile.fingerprint) } + .disabled(!canDelete || anyPending) + } + } + } + + private func actionsFooter(_ profile: TherapyProfile) -> Text { + if let editBlocker, remote.supports(.save, in: commands) { return Text(editBlocker) } + if profile.isDefault { return Text("The Default profile always exists. It can be edited, but not renamed or deleted.") } + if isActive { return Text("The active profile can't be deleted. Activate another profile first.") } + return Text("") + } + + private var activateButton: some View { + let isSending = tracker.isBusy(key: activateKey) + return Button { + if let profile { alertType = .confirmActivate(fingerprint: profile.fingerprint) } + } label: { + Group { + if isSending { + HStack { + ProgressView().scaleEffect(0.8) + Text("Waiting for \(remote.appName)...") + } + } else if anyPending { + Text("Another Profile Command Is Pending") + } else { + Text(isActive ? "Active Profile" : "Activate Profile") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isActive || anyPending) + .padding(.horizontal) + .padding(.vertical, 8) + .background(.bar) + } + + private func alert(_ type: AlertType) -> Alert { + switch type { + case let .confirmActivate(fingerprint): + return Alert( + title: Text("Activate \(name) in \(remote.appName)?"), + message: Text("\(remote.appName) replaces its correction range, carb ratios, basal rates and insulin sensitivities with these values and writes the basal rates to the pump. Dosing changes right away." + + (profileSet.flatMap { ScheduledSwitches.holdText($0.scheduledSwitches) }.map { " " + $0 } ?? "") + + MoreInsulinWarning.sentence(activationIncreases?.increases ?? [], comparedWith: activationIncreases?.active.name ?? "")), + primaryButton: .default(Text("Activate")) { send(.activate, fingerprint: fingerprint) }, + secondaryButton: .cancel() + ) + case let .confirmDelete(fingerprint): + return Alert( + title: Text("Delete \(name)?"), + message: Text("\(remote.appName) removes this profile. This can't be undone from LoopFollow." + + (profileSet.flatMap { ScheduledSwitches.deletionText($0.scheduledSwitches, profileName: name) }.map { " " + $0 } ?? "")), + primaryButton: .destructive(Text("Delete")) { send(.delete, fingerprint: fingerprint) }, + secondaryButton: .cancel() + ) + case let .result(kind, result): + return Alert( + title: Text(ProfileCommandFlow.resultTitle(kind: kind, success: result.success, message: result.message, remote: remote)), + message: Text(ProfileCommandFlow.resultMessage(result, remote: remote)), + dismissButton: .default(Text("OK")) { + if ProfileCommandFlow.leavesAfterResult(result, remote: remote) { + presentationMode.wrappedValue.dismiss() + } + } + ) + } + } + + private func send(_ kind: ProfileCommandKind, fingerprint: String) { + guard let profile else { return } + guard profile.fingerprint == fingerprint else { + let message = "\(name) changed while the confirmation was open, so nothing was sent. Review it again." + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + alertType = .result(kind: kind, ProfileCommandResult(success: false, message: message)) + } + return + } + let command: ProfileCommand = kind == .delete ? .delete(profile) : .activate(profile) + let verb = kind == .delete ? "delete" : "activate" + ProfileCommandFlow.authenticateAndSend(command, to: remote, reason: "Confirm your identity to \(verb) \(profile.name) in \(remote.appName).") { message in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + alertType = .result(kind: kind, ProfileCommandResult(success: false, message: message)) + } + } + } +} + +/// Renames a profile: a save of its unchanged values under the new name, reviewed before sending. +struct ProfileRenameSheet: View { + let remote: ProfileRemote + let profile: TherapyProfile + let profileSet: TherapyProfileSet + let isActive: Bool + let onFinished: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var name = "" + + private var problem: String? { + if ProfileValidation.normalizedName(name) == profile.name { return "Enter a new name." } + return ProfileValidation.nameProblem(name, existingNames: profileSet.profiles.map(\.name), originalName: profile.name) + } + + private var command: ProfileCommand { + .save(name: ProfileValidation.normalizedName(name), original: profile, values: profile.values) + } + + var body: some View { + NavigationStack { + Form { + Section(header: Text("New Name"), footer: footer) { + TextField(profile.name, text: $name) + .autocorrectionDisabled() + } + } + .navigationTitle("Rename \(profile.name)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + NavigationLink("Review") { + ProfileReviewView( + remote: remote, + command: command, + baseline: profile, + isActive: isActive, + limits: profileSet.limits, + onFinished: onFinished + ) + } + .disabled(problem != nil) + } + } + } + } + + private var footer: some View { + Group { + if let problem, !name.isEmpty { + Text(problem).foregroundColor(.red) + } else { + Text("The values stay the same\(isActive ? " and the profile stays active" : ""). Up to \(ProfileValidation.maxNameLength) characters, not “Default”, no period (.), and not starting with $.") + } + } + } +} diff --git a/LoopFollow/Remote/RemoteCommandRefresh.swift b/LoopFollow/Remote/RemoteCommandRefresh.swift index f1773f86d..74fef8fe9 100644 --- a/LoopFollow/Remote/RemoteCommandRefresh.swift +++ b/LoopFollow/Remote/RemoteCommandRefresh.swift @@ -6,10 +6,10 @@ 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. +/// After a command is sent, treatments and device status (the profile document for +/// a profile command) are fetched a few extra times, stopping as soon as the change +/// the command makes shows up, so the new override, temp target, bolus, carbs or +/// profile 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 { @@ -19,6 +19,7 @@ final class RemoteCommandRefresh { /// 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) + case profile(ProfileCommand) var name: String { switch self { @@ -26,6 +27,14 @@ final class RemoteCommandRefresh { case .tempTarget: "temp target" case .bolus: "bolus" case .meal: "meal" + case .profile: "profile change" + } + } + + var tasks: [TaskID] { + switch self { + case .profile: [.profile] + default: [.treatments, .deviceStatus] } } } @@ -37,6 +46,7 @@ final class RemoteCommandRefresh { static let delays: [TimeInterval] = [10, 20, 30, 60, 90, 120] private var pending: DispatchWorkItem? + private var pendingIsProfile = false private init() {} @@ -57,6 +67,15 @@ final class RemoteCommandRefresh { } } + /// A profile command's reply: the phone uploads its profiles before replying, so the profile + /// document is fetched now, and a refused command ends the follow-up fetches. + func profileCommandAnswered(success: Bool) { + DispatchQueue.main.async { + if !success, self.pendingIsProfile { self.cancel() } + TaskScheduler.shared.rescheduleTask(id: .profile, to: Date()) + } + } + /// 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. @@ -65,25 +84,31 @@ final class RemoteCommandRefresh { 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 + self.cancel() + if case let .profile(command) = change { + RemoteCommandTracker.shared.resolveProfileCommandSeen(command) + } return } - TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) - TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date()) + for task in change.tasks { + TaskScheduler.shared.rescheduleTask(id: task, to: Date()) + } let next = step + 1 guard next < Self.delays.count else { - self.pending = nil + self.cancel() return } self.schedule(step: next, after: Self.delays[next] - Self.delays[step], arrived: arrived, change: change) } pending = work + if case .profile = change { pendingIsProfile = true } DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) } private func cancel() { pending?.cancel() pending = nil + pendingIsProfile = false } /// Captures the phone's data now and returns a check that is true once the @@ -108,6 +133,27 @@ final class RemoteCommandRefresh { guard bolus > 0 else { return carbsArrived } let bolusArrived = newEntry(in: boluses, matching: bolus, tolerance: 0.01) return { carbsArrived() && bolusArrived() } + case let .profile(command): + let before = Observable.shared.therapyProfiles.value + return { profileArrived(command, before: before, now: Observable.shared.therapyProfiles.value) } + } + } + + /// True once `now` shows the command's result: the profile is active, deleted, or saved with + /// values other than those in `before` (a new or renamed profile has none there), or the scheduled switches + /// are no longer the list the command replaced. + static func profileArrived(_ command: ProfileCommand, before: TherapyProfileSet?, now: TherapyProfileSet?) -> Bool { + guard let now else { return false } + switch command { + case let .activate(profile): + return now.activeName.caseInsensitiveCompare(profile.name) == .orderedSame + case let .delete(profile): + return now.profile(named: profile.name) == nil + case let .save(name, _, _): + guard let saved = now.profile(named: name) else { return false } + return saved.fingerprint != before?.profile(named: name)?.fingerprint + case let .saveScheduledSwitches(_, baseFingerprint): + return now.scheduledSwitches.fingerprint != baseFingerprint } } diff --git a/LoopFollow/Remote/RemoteCommandTracker.swift b/LoopFollow/Remote/RemoteCommandTracker.swift index be772414b..6a8ca0511 100644 --- a/LoopFollow/Remote/RemoteCommandTracker.swift +++ b/LoopFollow/Remote/RemoteCommandTracker.swift @@ -4,7 +4,7 @@ import Foundation extension Notification.Name { - /// Posted when a tracked remote command reaches a terminal state, with its key in `userInfo["key"]`, so lists can refresh. + /// Posted when a tracked remote command reaches a terminal state, with its key in `userInfo["key"]` and its outcome in `userInfo["success"]`, so lists can refresh. static let remoteMealCommandDidComplete = Notification.Name("LoopFollow.remoteMealCommandDidComplete") } @@ -16,16 +16,20 @@ final class RemoteCommandTracker: ObservableObject { enum State: Equatable { case pending(since: Date) - case done(success: Bool, message: String) + /// `outdated` marks a refusal because the target changed or no longer exists on the phone. + case done(success: Bool, message: String, outdated: Bool = false) } struct Resolution: Equatable { let key: String let success: Bool let message: String + var outdated = false } @Published private(set) var states: [String: State] = [:] + /// The last profile command sent under each key. + private(set) var profileCommands: [String: ProfileCommand] = [:] let trioAcks = TRCMealAckAdapter() private let timeout: TimeInterval @@ -54,23 +58,34 @@ final class RemoteCommandTracker: ObservableObject { /// True while any command whose key starts with `prefix` waits for its reply. func isBusy(keyPrefix prefix: String) -> Bool { + isBusy(keyPrefixes: [prefix]) + } + + func isBusy(keyPrefixes prefixes: [String]) -> Bool { states.contains { key, state in - if case .pending = state { return key.hasPrefix(prefix) } + if case .pending = state { return prefixes.contains { key.hasPrefix($0) } } return false } } - func resolve(key: String, success: Bool, message: String) { + func resolve(key: String, success: Bool, message: String, outdated: Bool = false) { onMain { guard case .pending = self.states[key] else { return } self.timeouts[key]?.cancel() self.timeouts[key] = nil - self.states[key] = .done(success: success, message: message) - LogManager.shared.log(category: .apns, message: "Remote command for \(LogRedactor.tail(key)) finished: success=\(success) \(message)") - NotificationCenter.default.post(name: .remoteMealCommandDidComplete, object: nil, userInfo: ["key": key]) + self.states[key] = .done(success: success, message: message, outdated: outdated) + let outcome = ProfileRemote.isProfileCommandKey(key) ? "outdated=\(outdated)" : message + LogManager.shared.log(category: .apns, message: "Remote command for \(Self.logKey(key)) finished: success=\(success) \(outcome)") + NotificationCenter.default.post(name: .remoteMealCommandDidComplete, object: nil, userInfo: ["key": key, "success": success]) } } + /// The key as it may be logged: the command kind for a profile command, whose key holds the profile name. + static func logKey(_ key: String) -> String { + guard ProfileRemote.isProfileCommandKey(key), let separator = key.firstIndex(of: ":") else { return LogRedactor.tail(key) } + return String(key[.. Bool { guard let resolution = trioAcks.resolution(for: userInfo) ?? LoopCarbAckAdapter.resolution(for: userInfo) - ?? LoopProfileAckAdapter.resolution(for: userInfo) + ?? LoopProfileAckAdapter.resolution(for: userInfo, pendingKeys: pendingKeys) + ?? LoopScheduledSwitchesAckAdapter.resolution(for: userInfo) else { return false } guard isBusy(key: resolution.key) else { - LogManager.shared.log(category: .apns, message: "Ack for \(LogRedactor.tail(resolution.key)) matched no pending command") + LogManager.shared.log(category: .apns, message: "Ack for \(Self.logKey(resolution.key)) matched no pending command") return false } - resolve(key: resolution.key, success: resolution.success, message: resolution.message) + resolve(key: resolution.key, success: resolution.success, message: resolution.message, outdated: resolution.outdated) return true } + /// Resolves the pending profile command `command` as applied once the profiles uploaded to Nightscout show its + /// change, for when the app's reply push does not arrive. + func resolveProfileCommandSeen(_ command: ProfileCommand) { + onMain { + for remote in [ProfileRemote.loop, .trio] { + let key = remote.key(for: command) + guard self.profileCommands[key] == command, self.isBusy(key: key) else { continue } + LogManager.shared.log(category: .apns, message: "No reply for \(Self.logKey(key)); the change shows in the uploaded profiles") + self.resolve(key: key, success: true, message: remote.seenMessage(command)) + } + } + } + private func onMain(_ work: @escaping () -> Void) { if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) } } } -/// Correlates Trio's return push with the meal a command was sent for via the per-send command id. +/// Correlates Trio's return push with the meal or profile a command was sent for via the per-send command id. final class TRCMealAckAdapter { - private var keys: [String: String] = [:] + private struct Entry { + let key: String + let successMessage: String? + let failureMessage: String? + } - func register(commandID: String, key: String) { - keys[commandID] = key + private var entries: [String: Entry] = [:] + + /// The messages are used when the reply has no alert body. + func register(commandID: String, key: String, successMessage: String? = nil, failureMessage: String? = nil) { + entries[commandID] = Entry(key: key, successMessage: successMessage, failureMessage: failureMessage) } func unregister(commandID: String) { - keys[commandID] = nil + entries[commandID] = nil } func resolution(for userInfo: [AnyHashable: Any]) -> RemoteCommandTracker.Resolution? { guard let status = userInfo["command_status"] as? String, let commandID = userInfo["command_id"] as? String, - let key = keys[commandID] + let entry = entries[commandID] else { return nil } - keys[commandID] = nil + entries[commandID] = nil let success = status == "success" let result = userInfo["result"] as? String LogManager.shared.log(category: .apns, message: "TRC ack: status=\(status) result=\(result ?? "-") command_id=\(commandID)") + let registered = success ? entry.successMessage : entry.failureMessage return RemoteCommandTracker.Resolution( - key: key, + key: entry.key, success: success, - message: alertBody(userInfo) ?? Self.defaultMessage(result: result, success: success) + message: alertBody(userInfo) ?? registered ?? Self.defaultMessage(result: result, success: success), + outdated: !success && ProfileRemote.isProfileCommandKey(entry.key) && (result == "conflict" || result == "not_found") ) } @@ -159,31 +204,123 @@ enum LoopCarbAckAdapter { } } -/// Matches Loop's return push for a profile switch by the profile name in its message -/// ("Profile '' activated" or "... Profile '' not activated: "). +/// Matches Loop's return push for a profile command by the profile name in its message +/// ("Profile '' activated|saved|deleted" or "... Profile '' not activated|saved|deleted: "). +/// Names may contain apostrophes, so a name of a pending command that fits the message is preferred over parsing. enum LoopProfileAckAdapter { - static let keyPrefix = "loop-profile-switch:" - /// Pump programming and both pushes can take a while; a missing reply does not mean the switch failed. - static let timeout: TimeInterval = 180 - static let timeoutMessage = "Loop has not replied. The switch may still complete; the list shows the active profile once Loop uploads it. Check it before sending again." - - static func key(for name: String) -> String { - keyPrefix + name - } + static let commandTypes: [String: ProfileCommandKind] = [ + "profile_switch": .activate, + "profile_save": .save, + "profile_delete": .delete, + ] - static func resolution(for userInfo: [AnyHashable: Any]) -> RemoteCommandTracker.Resolution? { - guard userInfo["command_type"] as? String == "profile_switch", + static func resolution(for userInfo: [AnyHashable: Any], pendingKeys: [String] = []) -> RemoteCommandTracker.Resolution? { + guard let commandType = userInfo["command_type"] as? String, + let kind = commandTypes[commandType], let status = userInfo["command_status"] as? String, - let body = alertBody(userInfo), - let name = profileName(in: body) + let body = alertBody(userInfo) else { return nil } - LogManager.shared.log(category: .apns, message: "Loop ack: type=profile_switch status=\(status)") - return RemoteCommandTracker.Resolution(key: key(for: name), success: status == "success", message: body) + let prefix = ProfileRemote.loop.keyPrefix(kind) + let pendingNames = pendingKeys.filter { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) } + // Loop builds the "Profile '' …" frame of the body in English and localizes only the reason. A body + // without any such frame resolves the only pending command of its type. + let framed = ProfileCommandKind.allCases.contains { profileName(in: body, kind: $0) != nil } + let named = profileName(in: body, kind: kind, candidates: pendingNames) + guard let name = named ?? (!framed && pendingNames.count == 1 ? pendingNames[0] : nil) else { return nil } + let result = userInfo["result"] as? String + LogManager.shared.log(category: .apns, message: "Loop ack: type=\(commandType) status=\(status) result=\(result ?? "-")") + let outcome = outcome(status: status, result: result, body: body) + return RemoteCommandTracker.Resolution( + key: ProfileRemote.loop.key(kind, name: name), + success: outcome.success, + message: body, + outdated: outcome.outdated + ) + } + + static let successResults: Set = ["activated", "saved", "deleted"] + static let outdatedResults: Set = ["conflict", "not_found"] + + /// Success and outdated from the reply's `result` code; replies without one (older Loop builds) fall back to + /// `command_status` and the English reason in the alert text. + static func outcome(status: String, result: String?, body: String) -> (success: Bool, outdated: Bool) { + if let result, !result.isEmpty { + let success = successResults.contains(result) + return (success, !success && outdatedResults.contains(result)) + } + let success = status == "success" + return (success, !success && isOutdatedRefusal(body)) + } + + /// Loop's reasons (English) for refusing a command whose profile changed or no longer exists on the phone, + /// for replies without a `result` code. + static let outdatedReasons = [ + "changed on the phone after it was uploaded", + "has changed since it was loaded", + "No profile named", + ] + + static func isOutdatedRefusal(_ message: String) -> Bool { + outdatedReasons.contains { message.localizedCaseInsensitiveContains($0) } + } + + static func verb(_ kind: ProfileCommandKind) -> String { + switch kind { + case .activate: return "activated" + case .save: return "saved" + case .delete: return "deleted" + case .saveScheduledSwitches: return "saved" + } + } + + /// The profile named in `message`: the longest candidate the message names, otherwise the name parsed up to + /// the first "' not : " or up to a final "' ". + static func profileName(in message: String, kind: ProfileCommandKind, candidates: [String] = []) -> String? { + guard kind != .saveScheduledSwitches else { return nil } + let verb = verb(kind) + let named = candidates + .filter { name in + message.hasSuffix("Profile '\(name)' \(verb)") || message.contains("Profile '\(name)' not \(verb): ") + } + .max { $0.count < $1.count } + if let named { return named } + let pattern: Regex<(Substring, Substring)> + switch kind { + case .activate: pattern = /Profile '(.+?)'(?: not activated: | activated$)/ + case .save: pattern = /Profile '(.+?)'(?: not saved: | saved$)/ + case .delete: pattern = /Profile '(.+?)'(?: not deleted: | deleted$)/ + case .saveScheduledSwitches: return nil + } + return message.firstMatch(of: pattern).map { String($0.1) } } +} + +/// Matches Loop's return push for a scheduled switches save by its command type; only one can be pending. +enum LoopScheduledSwitchesAckAdapter { + static let commandType = "profile_scheduled_switches_save" - static func profileName(in message: String) -> String? { - guard let match = message.firstMatch(of: /Profile '(.+)' (?:not )?activated/) else { return nil } - return String(match.1) + static func resolution(for userInfo: [AnyHashable: Any]) -> RemoteCommandTracker.Resolution? { + guard userInfo["command_type"] as? String == commandType, + let status = userInfo["command_status"] as? String + else { return nil } + let result = userInfo["result"] as? String + LogManager.shared.log(category: .apns, message: "Loop ack: type=\(commandType) status=\(status) result=\(result ?? "-")") + let success: Bool + let outdated: Bool + if let result, !result.isEmpty { + success = result == "saved" + outdated = !success && (result == "conflict" || result == "not_found") + } else { + success = status == "success" + outdated = false + } + let fallback = success ? "Scheduled switches saved" : "Scheduled switches not saved" + return RemoteCommandTracker.Resolution( + key: ProfileRemote.loop.key(.saveScheduledSwitches, name: ProfileCommand.scheduledSwitchesTarget), + success: success, + message: alertBody(userInfo) ?? fallback, + outdated: outdated + ) } } @@ -224,7 +361,7 @@ extension RemoteCommandTracker { private func finishTrioSend(key: String, commandID: String, success: Bool, error: String?, completion: @escaping (Bool, String?) -> Void) { DispatchQueue.main.async { if success { - LogManager.shared.log(category: .apns, message: "Meal command sent command_id=\(commandID) meal_id=\(key)") + LogManager.shared.log(category: .apns, message: "TRC command sent command_id=\(commandID) key=\(Self.logKey(key))") } else { self.trioAcks.unregister(commandID: commandID) self.resolve(key: key, success: false, message: error ?? "The command could not be sent.") @@ -262,15 +399,55 @@ extension RemoteCommandTracker { } } - func sendLoopProfileSwitch(profile: LoopProfile, completion: @escaping (Bool, String?) -> Void) { - let key = LoopProfileAckAdapter.key(for: profile.name) - begin(key: key, timeout: LoopProfileAckAdapter.timeout, timeoutMessage: LoopProfileAckAdapter.timeoutMessage) - guard let otp = loopOTP(key: key, completion: completion) else { return } - LoopAPNSService().sendProfileSwitch(name: profile.name, fingerprint: profile.fingerprint, otp: otp) { [weak self] success, error in - self?.finishLoopSend(key: key, success: success, error: error, completion: completion) + /// Sends a profile command and tracks it under `ProfileRemote.key(for:)` until the reply or the timeout. + func sendProfileCommand(_ command: ProfileCommand, to remote: ProfileRemote, completion sent: @escaping (Bool, String?) -> Void) { + let key = beginProfileCommand(command, to: remote) + let completion = RemoteCommandRefresh.following(.profile(command), sent) + switch remote { + case .loop: + guard let otp = loopOTP(key: key, completion: completion) else { return } + let finish: (Bool, String?) -> Void = { [weak self] success, error in + self?.finishLoopSend(key: key, success: success, error: error, completion: completion) + } + let service = LoopAPNSService() + switch command { + case let .activate(profile): + service.sendProfileSwitch(name: profile.name, fingerprint: profile.fingerprint, otp: otp, completion: finish) + case let .save(name, original, values): + service.sendProfileSave(name: name, originalName: original?.name, baseFingerprint: original?.fingerprint, values: values, otp: otp, completion: finish) + case let .delete(profile): + service.sendProfileDelete(name: profile.name, fingerprint: profile.fingerprint, otp: otp, completion: finish) + case let .saveScheduledSwitches(entries, baseFingerprint): + service.sendScheduledSwitchesSave(entries: entries, baseFingerprint: baseFingerprint, otp: otp, completion: finish) + } + case .trio: + let commandID = UUID().uuidString + trioAcks.register(commandID: commandID, key: key, successMessage: remote.successMessage(command), failureMessage: remote.failureMessage(command)) + let finish: (Bool, String?) -> Void = { [weak self] success, error in + self?.finishTrioSend(key: key, commandID: commandID, success: success, error: error, completion: completion) + } + let manager = PushNotificationManager() + switch command { + case let .activate(profile): + manager.sendProfileSwitchPushNotification(name: profile.name, fingerprint: profile.fingerprint, commandID: commandID, completion: finish) + case let .save(name, original, values): + manager.sendProfileSavePushNotification(name: name, originalName: original?.name, baseFingerprint: original?.fingerprint, values: values, commandID: commandID, completion: finish) + case let .delete(profile): + manager.sendProfileDeletePushNotification(name: profile.name, fingerprint: profile.fingerprint, commandID: commandID, completion: finish) + case let .saveScheduledSwitches(entries, baseFingerprint): + manager.sendScheduledSwitchesSavePushNotification(entries: entries, baseFingerprint: baseFingerprint, commandID: commandID, completion: finish) + } } } + /// Marks `command` as pending under its key and returns the key. + func beginProfileCommand(_ command: ProfileCommand, to remote: ProfileRemote) -> String { + let key = remote.key(for: command) + onMain { self.profileCommands[key] = command } + begin(key: key, timeout: ProfileRemote.timeout, timeoutMessage: remote.timeoutMessage) + return key + } + private func loopOTP(key: String, completion: (Bool, String?) -> Void) -> String? { let qrCodeURL = Storage.shared.loopAPNSQrCodeURL.value let otp = TOTPGenerator.extractOTPFromURL(qrCodeURL) @@ -292,7 +469,7 @@ extension RemoteCommandTracker { DispatchQueue.main.async { if success { TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) - LogManager.shared.log(category: .apns, message: "Loop command sent for \(LogRedactor.tail(key))") + LogManager.shared.log(category: .apns, message: "Loop command sent for \(Self.logKey(key))") } else { self.resolve(key: key, success: false, message: error ?? "The command could not be sent.") } diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index 532d9bf27..027c9fbab 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -183,6 +183,139 @@ class PushNotificationManager { } private static let returnNotificationRequiredMessage = "Editing or deleting a meal needs LoopFollow's own APNS credentials so Trio can confirm the result. Configure them in App Settings → APN." + private static let profileReturnNotificationRequiredMessage = "Profile commands need LoopFollow's own APNS credentials so Trio can confirm the result. Configure them in App Settings → APN." + + func sendProfileSwitchPushNotification(name: String, fingerprint: String, commandID: String, completion: @escaping (Bool, String?) -> Void) { + sendProfileCommand(commandType: .switchProfile, name: name, baseFingerprint: fingerprint, commandID: commandID, completion: completion) + } + + /// Creates `name` when `originalName` is nil; otherwise updates `originalName`, which Trio only changes while + /// its values still have `baseFingerprint`. + func sendProfileSavePushNotification( + name: String, + originalName: String?, + baseFingerprint: String?, + values: TherapyProfileValues, + commandID: String, + completion: @escaping (Bool, String?) -> Void + ) { + sendProfileCommand( + commandType: .saveProfile, + name: name, + originalName: originalName, + baseFingerprint: baseFingerprint, + values: values, + commandID: commandID, + completion: completion + ) + } + + func sendProfileDeletePushNotification(name: String, fingerprint: String, commandID: String, completion: @escaping (Bool, String?) -> Void) { + sendProfileCommand(commandType: .deleteProfile, name: name, baseFingerprint: fingerprint, commandID: commandID, completion: completion) + } + + /// Replaces Trio's scheduled switches, which Trio only does while its list has `baseFingerprint`. + func sendScheduledSwitchesSavePushNotification( + entries: [ScheduledProfileSwitch], + baseFingerprint: String, + commandID: String, + completion: @escaping (Bool, String?) -> Void + ) { + guard let returnNotification = ReturnNotificationInfo.loopFollow() else { + completion(false, Self.profileReturnNotificationRequiredMessage) + return + } + LogManager.shared.log(category: .apns, message: "Sending \(TRCCommandType.saveScheduledSwitches.rawValue) base=\(baseFingerprint) new=\(ScheduledProfileSwitch.fingerprint(entries))") + let payload = scheduledSwitchesPayload(entries: entries, baseFingerprint: baseFingerprint, commandID: commandID, returnNotification: returnNotification) + sendEncryptedCommand(payload: payload, tooLargeMessage: ScheduledSwitchesPushSize.tooLargeMessage, completion: completion) + } + + /// The size of the push a scheduled switches save would send. Nil when it can not be built. + func scheduledSwitchesSavePushSize(entries: [ScheduledProfileSwitch], baseFingerprint: String) -> Int? { + guard let returnNotification = ReturnNotificationInfo.loopFollow() else { return nil } + let payload = scheduledSwitchesPayload(entries: entries, baseFingerprint: baseFingerprint, commandID: UUID().uuidString, returnNotification: returnNotification) + return try? encryptedBody(payload).count + } + + private func scheduledSwitchesPayload( + entries: [ScheduledProfileSwitch], + baseFingerprint: String, + commandID: String, + returnNotification: ReturnNotificationInfo + ) -> CommandPayload { + CommandPayload( + user: user, + commandType: .saveScheduledSwitches, + timestamp: Date().timeIntervalSince1970, + commandID: commandID, + baseFingerprint: baseFingerprint, + scheduledSwitches: entries, + returnNotification: returnNotification + ) + } + + /// The size of the push a profile save would send. Nil when it can not be built. + func profileSavePushSize(name: String, originalName: String?, baseFingerprint: String?, values: TherapyProfileValues) -> Int? { + guard let returnNotification = ReturnNotificationInfo.loopFollow() else { return nil } + let payload = profilePayload( + commandType: .saveProfile, + name: name, + originalName: originalName, + baseFingerprint: baseFingerprint, + values: values, + commandID: UUID().uuidString, + returnNotification: returnNotification + ) + return try? encryptedBody(payload).count + } + + private func profilePayload( + commandType: TRCCommandType, + name: String, + originalName: String? = nil, + baseFingerprint: String?, + values: TherapyProfileValues? = nil, + commandID: String, + returnNotification: ReturnNotificationInfo + ) -> CommandPayload { + CommandPayload( + user: user, + commandType: commandType, + timestamp: Date().timeIntervalSince1970, + commandID: commandID, + profileName: name, + originalProfileName: originalName, + baseFingerprint: baseFingerprint, + profile: values, + returnNotification: returnNotification + ) + } + + private func sendProfileCommand( + commandType: TRCCommandType, + name: String, + originalName: String? = nil, + baseFingerprint: String?, + values: TherapyProfileValues? = nil, + commandID: String, + completion: @escaping (Bool, String?) -> Void + ) { + guard let returnNotification = ReturnNotificationInfo.loopFollow() else { + completion(false, Self.profileReturnNotificationRequiredMessage) + return + } + LogManager.shared.log(category: .apns, message: "Sending \(commandType.rawValue) base=\(baseFingerprint ?? "-")\(values.map { " new=\($0.fingerprint)" } ?? "")") + let payload = profilePayload( + commandType: commandType, + name: name, + originalName: originalName, + baseFingerprint: baseFingerprint, + values: values, + commandID: commandID, + returnNotification: returnNotification + ) + sendEncryptedCommand(payload: payload, tooLargeMessage: values == nil ? nil : ProfilePushSize.tooLargeMessage, completion: completion) + } private func validateCredentials() -> [String]? { var errors = [String]() @@ -226,7 +359,25 @@ class PushNotificationManager { return keyLines.joined() } - private func sendEncryptedCommand(payload: CommandPayload, completion: @escaping (Bool, String?) -> Void) { + /// The push body: the payload encrypted with the shared secret. + private func encryptedBody(_ payload: CommandPayload) throws -> Data { + guard let messenger = SecureMessenger(sharedSecret: sharedSecret) else { + throw EncryptionError.invalidSharedSecret + } + let encryptedDataString = try messenger.encrypt(payload) + return try JSONEncoder().encode(EncryptedPushMessage(encryptedData: encryptedDataString, commandType: payload.commandType)) + } + + private enum EncryptionError: LocalizedError { + case invalidSharedSecret + + var errorDescription: String? { + "Failed to initialize security module. Check shared secret." + } + } + + /// `tooLargeMessage` explains a push over the APNs size limit; such a push is never sent. + private func sendEncryptedCommand(payload: CommandPayload, tooLargeMessage: ((Int) -> String)? = nil, completion: @escaping (Bool, String?) -> Void) { var missingFields = [String]() if sharedSecret.isEmpty { missingFields.append("sharedSecret") } if apnsKey.isEmpty { missingFields.append("apnsKey") } @@ -251,18 +402,15 @@ class PushNotificationManager { } do { - guard let messenger = SecureMessenger(sharedSecret: sharedSecret) else { - let errorMessage = "Failed to initialize security module. Check shared secret." - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) + let body = try encryptedBody(payload) + if let tooLargeMessage, body.count > APNSDelivery.maxPayloadBytes { + LogManager.shared.log(category: .apns, message: "Command not sent: \(body.count) bytes") + completion(false, tooLargeMessage(body.count)) return } - let encryptedDataString = try messenger.encrypt(payload) - let finalMessage = EncryptedPushMessage(encryptedData: encryptedDataString, commandType: payload.commandType) - - try APNSDelivery.send( - body: JSONEncoder().encode(finalMessage), + APNSDelivery.send( + body: body, deviceToken: deviceToken, topic: bundleId, production: productionEnvironment, diff --git a/LoopFollow/Remote/TRC/TrioRemoteControlView.swift b/LoopFollow/Remote/TRC/TrioRemoteControlView.swift index 0ad641a8b..285cd19e8 100644 --- a/LoopFollow/Remote/TRC/TrioRemoteControlView.swift +++ b/LoopFollow/Remote/TRC/TrioRemoteControlView.swift @@ -7,6 +7,7 @@ struct TrioRemoteControlView: View { @ObservedObject var viewModel: TrioRemoteControlViewModel @ObservedObject private var activeOverrideNote = Observable.shared.override @ObservedObject private var activeTempTarget = Observable.shared.tempTarget + @ObservedObject private var remoteCommands = Storage.shared.remoteCommands @Environment(\.presentationMode) var presentationMode var body: some View { @@ -22,6 +23,9 @@ struct TrioRemoteControlView: View { CommandButtonView(command: "Bolus", iconName: "syringe", destination: BolusView()) CommandButtonView(command: "Temp Target", iconName: "scope", destination: TempTargetView(), isActive: activeTempTarget.value != nil) CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: OverrideView(), isActive: activeOverrideNote.value != nil) + if ProfileRemote.trio.supportsAny(in: remoteCommands.value) { + CommandButtonView(command: "Profiles", iconName: "arrow.triangle.2.circlepath", destination: TherapyProfilesView(remote: .trio)) + } } .padding(.horizontal) diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 229ee6c65..8d2de84b0 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -61,8 +61,8 @@ class Observable { var loopFollowDeviceToken = ObservableValue(default: "") - /// Saved profiles from the latest Loop profile upload; nil until one is loaded. - var loopProfiles = ObservableValue(default: nil) + /// Saved profiles from the latest Loop or Trio profile upload; nil until one is loaded. + var therapyProfiles = ObservableValue(default: nil) var isNotLooping = ObservableValue(default: false) diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index f6c3042cb..0155aa5d6 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -616,7 +616,7 @@ struct TreatmentDetailView: View { Text(deleteDialogMessage) } .onChange(of: commandState) { _, state in - guard let commandKey, case .done(success: true, message: _) = state else { return } + guard let commandKey, case .done(success: true, message: _, outdated: _) = state else { return } commandTracker.consume(key: commandKey) presentationMode.wrappedValue.dismiss() } @@ -649,7 +649,7 @@ struct TreatmentDetailView: View { ProgressView().scaleEffect(0.8) Text(pendingText).foregroundColor(.secondary) } - case let .done(success, message) where !success: + case let .done(success, message, _) where !success: Text(message) .font(.footnote) .foregroundColor(.red) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 501736488..914fd6055 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -700,9 +700,8 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { } @objc func handleRemoteMealCommandDidComplete(_ notification: Notification) { - if (notification.userInfo?["key"] as? String)?.hasPrefix(LoopProfileAckAdapter.keyPrefix) == true { - // Loop uploads the switched profile right after it confirms the command. - TaskScheduler.shared.rescheduleTask(id: .profile, to: Date().addingTimeInterval(10)) + if let key = notification.userInfo?["key"] as? String, ProfileRemote.isProfileCommandKey(key) { + RemoteCommandRefresh.shared.profileCommandAnswered(success: notification.userInfo?["success"] as? Bool ?? false) } else { TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) } diff --git a/RemoteShared/APNSDelivery.swift b/RemoteShared/APNSDelivery.swift index 9ec7d44c9..0ffb64099 100644 --- a/RemoteShared/APNSDelivery.swift +++ b/RemoteShared/APNSDelivery.swift @@ -6,6 +6,13 @@ import Foundation /// Sends one remote command push to the Loop or Trio app. Used by the phone and /// by the watch when it sends without the phone. enum APNSDelivery { + /// The largest payload APNs accepts. + static let maxPayloadBytes = 4096 + + static func tooLargeMessage(size: Int) -> String { + "The command is too large to send remotely (\(size) of \(maxPayloadBytes) bytes)." + } + /// Calls `completion` on an arbitrary queue with success, or the reason for failure. static func send( body: Data, @@ -19,6 +26,12 @@ enum APNSDelivery { collapseID: String? = nil, completion: @escaping (Bool, String?) -> Void ) { + guard body.count <= maxPayloadBytes else { + let errorMessage = tooLargeMessage(size: body.count) + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return + } guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else { let errorMessage = "Failed to generate JWT, please check that the APNS Key ID, APNS Key and Team ID are correct." LogManager.shared.log(category: .apns, message: errorMessage) diff --git a/RemoteShared/LoopAPNSCommands.swift b/RemoteShared/LoopAPNSCommands.swift index e709e0989..d5fe4e20d 100644 --- a/RemoteShared/LoopAPNSCommands.swift +++ b/RemoteShared/LoopAPNSCommands.swift @@ -78,6 +78,48 @@ enum LoopAPNSCommands { return fields } + /// Creates the profile `name` when `originalName` is nil. Otherwise updates (and, when the names differ, renames) + /// `originalName`, which Loop only changes while its values still have `baseFingerprint`. + static func profileSaveCommandPayload( + name: String, + originalName: String?, + baseFingerprint: String?, + values: TherapyProfileValues, + otp: String, + now: Date = Date() + ) -> [String: Any] { + var fields = commandHeader(otp: otp, alert: "Remote Profile Save: \(name)", now: now) + fields["profile-save"] = name + if let originalName { + fields["profile-save-original-name"] = originalName + } + if let baseFingerprint { + fields["profile-save-base-fingerprint"] = baseFingerprint + } + fields["profile-save-values"] = values.jsonObject + return fields + } + + static func profileDeleteCommandPayload(name: String, fingerprint: String, otp: String, now: Date = Date()) -> [String: Any] { + var fields = commandHeader(otp: otp, alert: "Remote Profile Delete: \(name)", now: now) + fields["profile-delete"] = name + fields["profile-delete-fingerprint"] = fingerprint + return fields + } + + /// Replaces the scheduled switches, which Loop only does while its list has `baseFingerprint`. + static func scheduledSwitchesSaveCommandPayload( + entries: [ScheduledProfileSwitch], + baseFingerprint: String, + otp: String, + now: Date = Date() + ) -> [String: Any] { + var fields = commandHeader(otp: otp, alert: "Remote Scheduled Switches Save", now: now) + fields["profile-scheduled-switches-save"] = entries.map(\.jsonObject) + fields["profile-scheduled-switches-base-fingerprint"] = baseFingerprint + return fields + } + /// Creates a properly formatted ISO8601 date string with milliseconds (matching Nightscout's format) /// - Parameter date: The date to format /// - Returns: Formatted date string like "2022-12-24T21:34:02.090Z" @@ -137,7 +179,7 @@ enum LoopAPNSCommands { for (key, value) in payload where key != "alert" && key != "content-available" && key != "interruption-level" { body[key] = value } - return try JSONSerialization.data(withJSONObject: body) + return try JSONSerialization.data(withJSONObject: body, options: .withoutEscapingSlashes) } static func formatDateForAPNS(_ date: Date) -> String { diff --git a/RemoteShared/PushMessage.swift b/RemoteShared/PushMessage.swift index 1d6267ae6..3025f3caf 100644 --- a/RemoteShared/PushMessage.swift +++ b/RemoteShared/PushMessage.swift @@ -45,6 +45,11 @@ struct CommandPayload: Encodable { var scheduledTime: TimeInterval? var commandID: String? var mealID: String? + var profileName: String? + var originalProfileName: String? + var baseFingerprint: String? + var profile: TherapyProfileValues? + var scheduledSwitches: [ScheduledProfileSwitch]? var returnNotification: ReturnNotificationInfo? var apnsCollapseID: String { @@ -68,6 +73,11 @@ struct CommandPayload: Encodable { case scheduledTime = "scheduled_time" case commandID = "command_id" case mealID = "meal_id" + case profileName = "profile_name" + case originalProfileName = "original_profile_name" + case baseFingerprint = "base_fingerprint" + case profile + case scheduledSwitches = "scheduled_switches" case returnNotification = "return_notification" } } diff --git a/RemoteShared/ScheduledProfileSwitch.swift b/RemoteShared/ScheduledProfileSwitch.swift new file mode 100644 index 000000000..2f2aa873e --- /dev/null +++ b/RemoteShared/ScheduledProfileSwitch.swift @@ -0,0 +1,89 @@ +// LoopFollow +// ScheduledProfileSwitch.swift + +import CryptoKit +import Foundation + +/// A weekly start of a profile: from `time` on `weekday` the profile is active until the next start. +/// The compact form the Nightscout profile document and the remote commands carry: +/// `{"id", "profile", "weekday", "time", "paused"}`, with `paused` only when true. +struct ScheduledProfileSwitch: Equatable, Hashable, Identifiable, Codable { + /// Two starts per weekday. + static let maxCount = 14 + + let id: String + var profile: String + /// 1–7, Sunday = 1 (`Calendar`'s weekday index). + var weekday: Int + /// Seconds from midnight in the profile time zone, on a whole minute. + var time: Int + /// A paused start does not run until it is resumed. + var paused: Bool + + init(id: String = UUID().uuidString, profile: String, weekday: Int, time: Int, paused: Bool = false) { + self.id = id + self.profile = profile + self.weekday = weekday + self.time = time + self.paused = paused + } + + private enum CodingKeys: String, CodingKey { + case id + case profile + case weekday + case date + case time + case paused + } + + private struct UnsupportedEntry: Error {} + + /// A one-off entry (`date`) or a time that is not whole seconds does not decode. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard !container.contains(.date) else { throw UnsupportedEntry() } + id = try container.decode(String.self, forKey: .id) + profile = try container.decode(String.self, forKey: .profile) + weekday = try container.decode(Int.self, forKey: .weekday) + let seconds = try container.decode(Double.self, forKey: .time) + guard seconds.rounded() == seconds, abs(seconds) < 1e9 else { throw UnsupportedEntry() } + time = Int(seconds) + paused = try container.decodeIfPresent(Bool.self, forKey: .paused) ?? false + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(profile, forKey: .profile) + try container.encode(weekday, forKey: .weekday) + try container.encode(time, forKey: .time) + if paused { + try container.encode(true, forKey: .paused) + } + } + + /// The compact object for `JSONSerialization` (Loop commands). + var jsonObject: [String: Any] { + var object: [String: Any] = ["id": id, "profile": profile, "weekday": weekday, "time": time] + if paused { object["paused"] = true } + return object + } + + /// `profile=;weekday=<1–7>;date=-;time=;paused=`. `date` is always `-`: every entry is + /// weekly. + var canonicalString: String { + "profile=\(profile);weekday=\(weekday);date=-;time=\(time);paused=\(paused)" + } + + /// The canonical strings of all entries, sorted and joined with `|`. `id` is identity, not content, and is left out. + static func canonicalString(_ entries: [ScheduledProfileSwitch]) -> String { + entries.map(\.canonicalString).sorted().joined(separator: "|") + } + + /// The fingerprint Loop and Trio compute for the list: SHA-256 of the canonical string, first 16 hex characters. + static func fingerprint(_ entries: [ScheduledProfileSwitch]) -> String { + let digest = SHA256.hash(data: Data(canonicalString(entries).utf8)) + return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) + } +} diff --git a/RemoteShared/TRCCommandType.swift b/RemoteShared/TRCCommandType.swift index b6a6bfe0d..d28671054 100644 --- a/RemoteShared/TRCCommandType.swift +++ b/RemoteShared/TRCCommandType.swift @@ -12,10 +12,17 @@ enum TRCCommandType: String, Encodable { case cancelOverride = "cancel_override" case editMeal = "edit_meal" case deleteMeal = "delete_meal" + case switchProfile = "switch_profile" + case saveProfile = "save_profile" + case deleteProfile = "delete_profile" + case saveScheduledSwitches = "save_scheduled_switches" /// One collapse id per command. var usesCommandIDAsCollapseID: Bool { - self == .editMeal || self == .deleteMeal + switch self { + case .editMeal, .deleteMeal, .switchProfile, .saveProfile, .deleteProfile, .saveScheduledSwitches: return true + default: return false + } } var displayName: String { @@ -28,6 +35,10 @@ enum TRCCommandType: String, Encodable { case .cancelOverride: return "Cancel Override" case .editMeal: return "Edit Meal" case .deleteMeal: return "Delete Meal" + case .switchProfile: return "Switch Profile" + case .saveProfile: return "Save Profile" + case .deleteProfile: return "Delete Profile" + case .saveScheduledSwitches: return "Save Scheduled Switches" } } } diff --git a/RemoteShared/TherapyProfileValues.swift b/RemoteShared/TherapyProfileValues.swift new file mode 100644 index 000000000..f162361ff --- /dev/null +++ b/RemoteShared/TherapyProfileValues.swift @@ -0,0 +1,147 @@ +// LoopFollow +// TherapyProfileValues.swift + +import CryptoKit +import Foundation + +/// One entry of a therapy schedule: seconds from midnight and a value. +struct ProfileScheduleItem: Equatable, Hashable { + let seconds: Int + let value: Double +} + +/// One correction range entry: seconds from midnight, low and high. +struct ProfileRangeItem: Equatable, Hashable { + let seconds: Int + let low: Double + let high: Double +} + +/// The four schedules of a therapy profile in the compact form remote profile commands carry: +/// `{"units", "basal": [[s, v]], "carbratio": [[s, v]], "sens": [[s, v]], "target": [[s, low, high]]}`. +/// Glucose values are in `units`. +struct TherapyProfileValues: Equatable, Encodable { + static let mgdl = "mg/dL" + static let mmol = "mmol/L" + + let units: String + let basal: [ProfileScheduleItem] + let carbRatio: [ProfileScheduleItem] + let sensitivity: [ProfileScheduleItem] + let target: [ProfileRangeItem] + + /// `mg/dL` or `mmol/L` for any spelling Nightscout uploaders use. + static func normalizedUnits(_ units: String) -> String { + units.lowercased().hasPrefix("mmol") ? mmol : mgdl + } + + var fingerprint: String { + Self.fingerprint( + basal: basal, + carbRatio: carbRatio, + sensitivity: sensitivity, + targetLow: target.map { ProfileScheduleItem(seconds: $0.seconds, value: $0.low) }, + targetHigh: target.map { ProfileScheduleItem(seconds: $0.seconds, value: $0.high) } + ) + } + + /// The fingerprint Loop and Trio compute for a profile store entry: + /// `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` with values to three + /// decimals in the entry's units, hashed with SHA-256, first 16 hex characters. + static func fingerprint( + basal: [ProfileScheduleItem], + carbRatio: [ProfileScheduleItem], + sensitivity: [ProfileScheduleItem], + targetLow: [ProfileScheduleItem], + targetHigh: [ProfileScheduleItem] + ) -> String { + let digest = SHA256.hash(data: Data(canonicalString( + basal: basal, + carbRatio: carbRatio, + sensitivity: sensitivity, + targetLow: targetLow, + targetHigh: targetHigh + ).utf8)) + return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) + } + + static func canonicalString( + basal: [ProfileScheduleItem], + carbRatio: [ProfileScheduleItem], + sensitivity: [ProfileScheduleItem], + targetLow: [ProfileScheduleItem], + targetHigh: [ProfileScheduleItem] + ) -> String { + func encode(_ items: [ProfileScheduleItem]) -> String { + items.map { "\($0.seconds):" + String(format: "%.3f", $0.value) }.joined(separator: ",") + } + return [ + "basal=" + encode(basal), + "carbratio=" + encode(carbRatio), + "sens=" + encode(sensitivity), + "target_low=" + encode(targetLow), + "target_high=" + encode(targetHigh), + ].joined(separator: ";") + } + + /// The decimals a value carries in a command, the same precision the fingerprint uses. + static let encodedDecimals = 3 + + /// `value` as the decimal number a command carries: rounded to `encodedDecimals`, so JSON holds `0.7` + /// for 0.7 and never its binary approximation. + static func encodedNumber(_ value: Double) -> Decimal { + Decimal(string: String(format: "%.\(encodedDecimals)f", value), locale: Locale(identifier: "en_US_POSIX")) ?? Decimal(value) + } + + /// The compact object for `JSONSerialization` (Loop commands). + var jsonObject: [String: Any] { + func number(_ value: Double) -> NSDecimalNumber { + NSDecimalNumber(decimal: Self.encodedNumber(value)) + } + return [ + "units": units, + "basal": basal.map { [$0.seconds, number($0.value)] as [Any] }, + "carbratio": carbRatio.map { [$0.seconds, number($0.value)] as [Any] }, + "sens": sensitivity.map { [$0.seconds, number($0.value)] as [Any] }, + "target": target.map { [$0.seconds, number($0.low), number($0.high)] as [Any] }, + ] + } + + private enum CodingKeys: String, CodingKey { + case units + case basal + case carbRatio = "carbratio" + case sensitivity = "sens" + case target + } + + private struct Pair: Encodable { + let item: ProfileScheduleItem + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(item.seconds) + try container.encode(TherapyProfileValues.encodedNumber(item.value)) + } + } + + private struct Triple: Encodable { + let item: ProfileRangeItem + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(item.seconds) + try container.encode(TherapyProfileValues.encodedNumber(item.low)) + try container.encode(TherapyProfileValues.encodedNumber(item.high)) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(units, forKey: .units) + try container.encode(basal.map(Pair.init), forKey: .basal) + try container.encode(carbRatio.map(Pair.init), forKey: .carbRatio) + try container.encode(sensitivity.map(Pair.init), forKey: .sensitivity) + try container.encode(target.map(Triple.init), forKey: .target) + } +} diff --git a/Tests/LoopProfileTests.swift b/Tests/LoopProfileTests.swift deleted file mode 100644 index 5a70f0941..000000000 --- a/Tests/LoopProfileTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -// LoopFollow -// LoopProfileTests.swift - -import Foundation -@testable import LoopFollow -import Testing - -struct LoopProfileTests { - private func store(units: String, sens: Double, low: Double, high: Double) throws -> NSProfile.Store { - let json = """ - {"timezone": "ETC/GMT-1", "units": "\(units)", - "basal": [{"time": "00:00", "value": 0.85, "timeAsSeconds": 0}, {"time": "06:00", "value": 1.1, "timeAsSeconds": 21600}], - "carbratio": [{"time": "00:00", "value": 10, "timeAsSeconds": 0}], - "sens": [{"time": "00:00", "value": \(sens), "timeAsSeconds": 0}], - "target_low": [{"time": "00:00", "value": \(low), "timeAsSeconds": 0}], - "target_high": [{"time": "00:00", "value": \(high), "timeAsSeconds": 0}]} - """ - return try JSONDecoder().decode(NSProfile.Store.self, from: Data(json.utf8)) - } - - @Test("fingerprint matches the value Loop computes for the same store entry") - func fingerprint() throws { - let mgdl = try LoopProfile(name: "Sport", store: store(units: "mg/dL", sens: 45, low: 100, high: 110)) - #expect(mgdl.fingerprint == "fec4f066b5a54ecd") - let mmol = try LoopProfile(name: "Sport", store: store(units: "mmol/L", sens: 2.497_646_904_752_9, low: 5.550_747_991_045_533, high: 6.105_822_790_150_087)) - #expect(mmol.fingerprint == "f7d9520ff02af97c") - } - - @Test("basal total weights each rate by how long it runs") - func basalTotal() throws { - let profile = try LoopProfile(name: "Sport", store: store(units: "mg/dL", sens: 45, low: 100, high: 110)) - #expect(abs(profile.basalTotal - (0.85 * 6 + 1.1 * 18)) < 0.000_1) - #expect(profile.correctionRange == [LoopProfile.RangeItem(seconds: 0, low: 100, high: 110)]) - } - - @Test("the Default entry is left out and names are sorted") - func savedProfiles() throws { - let entry = try store(units: "mg/dL", sens: 45, low: 100, high: 110) - let profile = NSProfile(store: ["Default": entry, "Work": entry, "Sport": entry], defaultProfile: "Work", units: "mg/dL", - bundleIdentifier: nil, isAPNSProduction: nil, deviceToken: nil, teamID: nil, expirationDate: nil, - startDate: nil, createdAt: nil, remoteCommands: nil, trioOverrides: nil, loopSettings: nil) - let set = LoopProfileSet(profile) - #expect(set.profiles.map(\.name) == ["Sport", "Work"]) - #expect(set.activeName == "Work") - } -} diff --git a/Tests/ProfileCommandPayloadTests.swift b/Tests/ProfileCommandPayloadTests.swift new file mode 100644 index 000000000..5b3055fbf --- /dev/null +++ b/Tests/ProfileCommandPayloadTests.swift @@ -0,0 +1,273 @@ +// LoopFollow +// ProfileCommandPayloadTests.swift + +import CryptoKit +import Foundation +@testable import LoopFollow +import Testing + +struct ProfileCommandPayloadTests { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + private let headerKeys: Set = ["otp", "remote-address", "notes", "entered-by", "sent-at", "expiration", "alert"] + + /// The contract example: basal [[0,0.85],[21600,1.0]], carbratio [[0,10]], sens [[0,50]], target [[0,100,110]]. + static let vector = TherapyProfileValues( + units: "mg/dL", + basal: [ProfileScheduleItem(seconds: 0, value: 0.85), ProfileScheduleItem(seconds: 21600, value: 1.0)], + carbRatio: [ProfileScheduleItem(seconds: 0, value: 10)], + sensitivity: [ProfileScheduleItem(seconds: 0, value: 50)], + target: [ProfileRangeItem(seconds: 0, low: 100, high: 110)] + ) + + private static func assertCompact(_ object: Any?) { + let compact = object as? [String: Any] + #expect(Set(compact?.keys.map { $0 } ?? []) == ["units", "basal", "carbratio", "sens", "target"]) + #expect(compact?["units"] as? String == "mg/dL") + let basal = compact?["basal"] as? [[Double]] + #expect(basal == [[0, 0.85], [21600, 1.0]]) + #expect(compact?["carbratio"] as? [[Double]] == [[0, 10]]) + #expect(compact?["sens"] as? [[Double]] == [[0, 50]]) + #expect(compact?["target"] as? [[Double]] == [[0, 100, 110]]) + } + + @Test("compact values encode as the contract's pairs and triples") + func compactEncoding() throws { + let data = try JSONEncoder().encode(Self.vector) + let text = String(decoding: data, as: UTF8.self) + #expect(text.contains("\"basal\":[[0,0.85],[21600,1]]")) + #expect(text.contains("\"target\":[[0,100,110]]")) + try Self.assertCompact(JSONSerialization.jsonObject(with: data)) + let viaSerialization = try JSONSerialization.data(withJSONObject: Self.vector.jsonObject) + try Self.assertCompact(JSONSerialization.jsonObject(with: viaSerialization)) + } + + @Test("values are sent as the decimal numbers shown, in Loop and Trio commands") + func decimalEncoding() throws { + let values = TherapyProfileValues( + units: "mmol/L", + basal: [ProfileScheduleItem(seconds: 0, value: 0.7), ProfileScheduleItem(seconds: 43200, value: 0.1 + 0.2), ProfileScheduleItem(seconds: 64800, value: 1.15)], + carbRatio: [ProfileScheduleItem(seconds: 0, value: 12.3)], + sensitivity: [ProfileScheduleItem(seconds: 0, value: 2.9)], + target: [ProfileRangeItem(seconds: 0, low: 5.6, high: 6.1)] + ) + let expected = [ + "\"basal\":[[0,0.7],[43200,0.3],[64800,1.15]]", + "\"carbratio\":[[0,12.3]]", + "\"sens\":[[0,2.9]]", + "\"target\":[[0,5.6,6.1]]", + ] + + let trio = try JSONEncoder().encode(CommandPayload(user: "u", commandType: .saveProfile, timestamp: 0, commandID: "C", profileName: "P", profile: values)) + let trioText = String(decoding: trio, as: UTF8.self) + for fragment in expected { + #expect(trioText.contains(fragment), "Trio: \(fragment) in \(trioText)") + } + + let loop = LoopAPNSCommands.profileSaveCommandPayload(name: "P", originalName: nil, baseFingerprint: nil, values: values, otp: "123456", now: now) + let loopText = try String(decoding: LoopAPNSCommands.apnsBody(loop), as: UTF8.self).replacingOccurrences(of: " ", with: "") + for fragment in expected { + #expect(loopText.contains(fragment), "Loop: \(fragment) in \(loopText)") + } + #expect(!trioText.contains("0000000") && !loopText.contains("0000000")) + + // What the phone decodes has the fingerprint LoopFollow shows for the values. + let decoded = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(values)) as? [String: Any]) + let basal = try #require(decoded["basal"] as? [[Double]]).map { ProfileScheduleItem(seconds: Int($0[0]), value: $0[1]) } + #expect(basal.map(\.value) == [0.7, 0.3, 1.15]) + let roundTrip = TherapyProfileValues(units: values.units, basal: basal, carbRatio: values.carbRatio, sensitivity: values.sensitivity, target: values.target) + #expect(roundTrip.fingerprint == values.fingerprint) + } + + @Test("Loop profile save creates with name and values only") + func loopSaveCreate() throws { + let json = LoopAPNSCommands.profileSaveCommandPayload(name: "Weekend", originalName: nil, baseFingerprint: nil, values: Self.vector, otp: "123456", now: now) + #expect(Set(json.keys) == headerKeys.union(["profile-save", "profile-save-values"])) + #expect(json["profile-save"] as? String == "Weekend") + #expect(json["alert"] as? String == "Remote Profile Save: Weekend") + #expect(json["expiration"] as? String == "2023-11-14T22:18:20.000Z") + let values = try JSONSerialization.data(withJSONObject: #require(json["profile-save-values"])) + try Self.assertCompact(JSONSerialization.jsonObject(with: values)) + } + + @Test("Loop profile save updates or renames with the original name and base fingerprint") + func loopSaveUpdate() throws { + let json = LoopAPNSCommands.profileSaveCommandPayload(name: "Weekend 2", originalName: "Weekend", baseFingerprint: "3e603478d512da04", values: Self.vector, otp: "123456", now: now) + #expect(Set(json.keys) == headerKeys.union(["profile-save", "profile-save-original-name", "profile-save-base-fingerprint", "profile-save-values"])) + #expect(json["profile-save-original-name"] as? String == "Weekend") + #expect(json["profile-save-base-fingerprint"] as? String == "3e603478d512da04") + let body = try JSONSerialization.jsonObject(with: LoopAPNSCommands.apnsBody(json)) as? [String: Any] + #expect((body?["aps"] as? [String: Any])?["alert"] as? String == "Remote Profile Save: Weekend 2") + Self.assertCompact(body?["profile-save-values"]) + } + + @Test("Loop profile delete names the profile and its fingerprint") + func loopDelete() { + let json = LoopAPNSCommands.profileDeleteCommandPayload(name: "Weekend", fingerprint: "abc", otp: "123456", now: now) + #expect(Set(json.keys) == headerKeys.union(["profile-delete", "profile-delete-fingerprint"])) + #expect(json["profile-delete"] as? String == "Weekend") + #expect(json["profile-delete-fingerprint"] as? String == "abc") + #expect(json["alert"] as? String == "Remote Profile Delete: Weekend") + } + + @Test("Trio profile commands use snake_case fields and per-command collapse ids") + func trioPayloads() throws { + func encode(_ payload: CommandPayload) throws -> [String: Any] { + try JSONSerialization.jsonObject(with: JSONEncoder().encode(payload)) as? [String: Any] ?? [:] + } + let save = CommandPayload(user: "u", commandType: .saveProfile, timestamp: 1_700_000_000, commandID: "CMD", + profileName: "Weekend 2", originalProfileName: "Weekend", baseFingerprint: "3e603478d512da04", profile: Self.vector) + let saveJSON = try encode(save) + #expect(saveJSON["command_type"] as? String == "save_profile") + #expect(saveJSON["profile_name"] as? String == "Weekend 2") + #expect(saveJSON["original_profile_name"] as? String == "Weekend") + #expect(saveJSON["base_fingerprint"] as? String == "3e603478d512da04") + #expect(saveJSON["command_id"] as? String == "CMD") + Self.assertCompact(saveJSON["profile"]) + #expect(save.apnsCollapseID == "CMD") + + let create = try encode(CommandPayload(user: "u", commandType: .saveProfile, timestamp: 0, commandID: "C2", profileName: "New", profile: Self.vector)) + #expect(create["original_profile_name"] == nil) + #expect(create["base_fingerprint"] == nil) + + let switchJSON = try encode(CommandPayload(user: "u", commandType: .switchProfile, timestamp: 0, commandID: "C3", profileName: "Weekend", baseFingerprint: "f1")) + #expect(switchJSON["command_type"] as? String == "switch_profile") + #expect(switchJSON["base_fingerprint"] as? String == "f1") + #expect(switchJSON["profile"] == nil) + + let deleteJSON = try encode(CommandPayload(user: "u", commandType: .deleteProfile, timestamp: 0, commandID: "C4", profileName: "Weekend", baseFingerprint: "f2")) + #expect(deleteJSON["command_type"] as? String == "delete_profile") + #expect(deleteJSON["profile_name"] as? String == "Weekend") + #expect(TRCCommandType.deleteProfile.displayName == "Delete Profile") + } + + @Test("an encrypted Trio save decrypts to the same payload with the shared secret") + func trioEncryptionRoundTrip() throws { + let payload = CommandPayload(user: "u", commandType: .saveProfile, timestamp: 1_700_000_000, commandID: "CMD", + profileName: "Weekend", profile: Self.vector) + let messenger = try #require(SecureMessenger(sharedSecret: "test-secret")) + let combined = try #require(Data(base64Encoded: messenger.encrypt(payload))) + let key = SymmetricKey(data: SHA256.hash(data: Data("test-secret".utf8))) + let box = try AES.GCM.SealedBox(combined: combined) + let plain = try AES.GCM.open(box, using: key) + let json = try JSONSerialization.jsonObject(with: plain) as? [String: Any] + #expect(json?["command_type"] as? String == "save_profile") + #expect(json?["profile_name"] as? String == "Weekend") + Self.assertCompact(json?["profile"]) + + let push = try JSONSerialization.jsonObject(with: JSONEncoder().encode(EncryptedPushMessage(encryptedData: "x", commandType: .saveProfile))) as? [String: Any] + #expect((push?["aps"] as? [String: Any])?["alert"] as? String == "Remote Command: Save Profile") + } + + @Test("pushes over the APNs limit are refused before sending") + func sizeLimit() async { + let result: (Bool, String?) = await withCheckedContinuation { continuation in + APNSDelivery.send(body: Data(count: 4097), deviceToken: "t", topic: "b", production: false, keyId: "k", teamId: "t", apnsKey: "", completion: { success, message in + continuation.resume(returning: (success, message)) + }) + } + #expect(result.0 == false) + #expect(result.1 == APNSDelivery.tooLargeMessage(size: 4097)) + #expect(ProfilePushSize.tooLargeMessage(5000).contains("fewer time segments")) + } + + @Test("the size checked with a stand-in one-time code is the size of the push sent") + func pushSizeMatchesSentBody() throws { + let returnInfo = ReturnNotificationInfo( + productionEnvironment: false, + deviceToken: String(repeating: "a", count: 64), + bundleId: "com.TEAMID1234.LoopFollow", + teamId: "TEAMID1234", + keyId: "KEYID12345", + apnsKey: "-----BEGIN PRIVATE KEY-----\n" + String(repeating: "A", count: 200) + "\n-----END PRIVATE KEY-----" + ) + func bodySize(otp: String) throws -> Int { + var payload = LoopAPNSCommands.profileSaveCommandPayload(name: "Sport", originalName: "Sport", baseFingerprint: "3e603478d512da04", values: Self.vector, otp: otp, now: now) + payload["encrypted_return_notification"] = try #require(OTPSecureMessenger(otpCode: otp)).encrypt(returnInfo) + return try LoopAPNSCommands.apnsBody(payload).count + } + let checked = try bodySize(otp: "000000") + for _ in 0 ..< 20 { + #expect(try bodySize(otp: String(format: "%06d", Int.random(in: 0 ... 999_999))) == checked) + } + } + + @Test("a full day of half-hour segments in every schedule does not fit in one Trio push") + func fullDayIsTooLarge() throws { + let starts = Array(stride(from: 0, to: 86400, by: 1800)) + let values = TherapyProfileValues( + units: "mg/dL", + basal: starts.map { ProfileScheduleItem(seconds: $0, value: 1.15) }, + carbRatio: starts.map { ProfileScheduleItem(seconds: $0, value: 12.5) }, + sensitivity: starts.map { ProfileScheduleItem(seconds: $0, value: 55) }, + target: starts.map { ProfileRangeItem(seconds: $0, low: 100, high: 115) } + ) + let returnInfo = ReturnNotificationInfo( + productionEnvironment: false, + deviceToken: String(repeating: "a", count: 64), + bundleId: "com.TEAMID1234.LoopFollow", + teamId: "TEAMID1234", + keyId: "KEYID12345", + apnsKey: "-----BEGIN PRIVATE KEY-----\n" + String(repeating: "A", count: 200) + "\n-----END PRIVATE KEY-----" + ) + let payload = CommandPayload(user: "Caregiver", commandType: .saveProfile, timestamp: 1_700_000_000.123, commandID: UUID().uuidString, + profileName: "Full", originalProfileName: "Full", baseFingerprint: "3e603478d512da04", profile: values, + returnNotification: returnInfo) + let messenger = try #require(SecureMessenger(sharedSecret: "secret")) + let body = try JSONEncoder().encode(EncryptedPushMessage(encryptedData: messenger.encrypt(payload), commandType: .saveProfile)) + #expect(body.count > APNSDelivery.maxPayloadBytes) + + let small = LoopAPNSCommands.profileSaveCommandPayload(name: "Small", originalName: nil, baseFingerprint: nil, values: Self.vector, otp: "123456", now: now) + #expect(try LoopAPNSCommands.apnsBody(small).count < 1000) + } + + /// Every schedule at `count` half-hour entries, in mmol/L with three-decimal values. + private static func mmolValues(entries count: Int) -> TherapyProfileValues { + let starts = (0 ..< count).map { $0 * 1800 } + return TherapyProfileValues( + units: "mmol/L", + basal: starts.map { ProfileScheduleItem(seconds: $0, value: 12.345) }, + carbRatio: starts.map { ProfileScheduleItem(seconds: $0, value: 12.345) }, + sensitivity: starts.map { ProfileScheduleItem(seconds: $0, value: 12.345) }, + target: starts.map { ProfileRangeItem(seconds: $0, low: 10.005, high: 10.005) } + ) + } + + private static let realisticReturnInfo = ReturnNotificationInfo( + productionEnvironment: true, + deviceToken: String(repeating: "a", count: 64), + bundleId: "com.TEAMID1234.LoopFollow", + teamId: "TEAMID1234", + keyId: "KEYID12345", + apnsKey: "-----BEGIN PRIVATE KEY-----\n" + String(repeating: "A", count: 200) + "\n-----END PRIVATE KEY-----" + ) + + private static func loopSaveSize(name: String, values: TherapyProfileValues) throws -> Int { + var payload = LoopAPNSCommands.profileSaveCommandPayload(name: name, originalName: name, baseFingerprint: "3e603478d512da04", values: values, otp: "123456") + payload["encrypted_return_notification"] = try #require(OTPSecureMessenger(otpCode: "123456")).encrypt(realisticReturnInfo) + return try LoopAPNSCommands.apnsBody(payload).count + } + + private static func trioSaveSize(name: String, values: TherapyProfileValues) throws -> Int { + let payload = CommandPayload(user: "Caregiver", commandType: .saveProfile, timestamp: Date().timeIntervalSince1970, commandID: UUID().uuidString, + profileName: name, originalProfileName: name, baseFingerprint: "3e603478d512da04", profile: values, + returnNotification: realisticReturnInfo) + let messenger = try #require(SecureMessenger(sharedSecret: "secret")) + return try JSONEncoder().encode(EncryptedPushMessage(encryptedData: messenger.encrypt(payload), commandType: .saveProfile)).count + } + + @Test("the largest profile the phones accept, in mmol/L with a long unicode name, is over the APNs limit for Loop and Trio") + func largestProfileIsTooLarge() throws { + let longName = String(repeating: "👨‍👩‍👧‍👦", count: ProfileValidation.maxNameLength) + #expect(ProfileValidation.nameProblem(longName, existingNames: []) == nil) + let largest = Self.mmolValues(entries: 48) + #expect(try Self.loopSaveSize(name: longName, values: largest) > APNSDelivery.maxPayloadBytes) + #expect(try Self.trioSaveSize(name: longName, values: largest) > APNSDelivery.maxPayloadBytes) + // 48 entries in every schedule are too large even with a short name. + #expect(try Self.loopSaveSize(name: "Sport", values: largest) > APNSDelivery.maxPayloadBytes) + #expect(try Self.trioSaveSize(name: "Sport", values: largest) > APNSDelivery.maxPayloadBytes) + // A day of hourly entries in every schedule fits both. + let hourly = Self.mmolValues(entries: 24) + #expect(try Self.loopSaveSize(name: "Sport", values: hourly) <= APNSDelivery.maxPayloadBytes) + #expect(try Self.trioSaveSize(name: "Sport", values: hourly) <= APNSDelivery.maxPayloadBytes) + } +} diff --git a/Tests/ProfileEditLimitsTests.swift b/Tests/ProfileEditLimitsTests.swift new file mode 100644 index 000000000..c92dc901f --- /dev/null +++ b/Tests/ProfileEditLimitsTests.swift @@ -0,0 +1,128 @@ +// LoopFollow +// ProfileEditLimitsTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct ProfileEditLimitsTests { + static let contractLimitsJSON = """ + {"units": "mg/dL", "maxBasal": 3.0, "basalRates": [0, 0.05, 0.1, 0.15, 3.0, 3.05], + "carbRatio": {"min": 2, "max": 150, "step": 0.1, "recommendedMin": 4, "recommendedMax": 28}, + "sensitivity": {"min": 10, "max": 500, "step": 1, "recommendedMin": 16, "recommendedMax": 399}, + "target": {"min": 87, "max": 180, "step": 1, "recommendedMin": 100, "recommendedMax": 115}, + "maxSegments": 48, "segmentSeconds": 1800} + """ + + static func limits(_ json: String = contractLimitsJSON) throws -> ProfileEditLimits { + try JSONDecoder().decode(ProfileEditLimits.self, from: Data(json.utf8)) + } + + private func profileDocument(limits: String?) throws -> NSProfile { + let limitsField = limits.map { ", \"profileEditLimits\": \($0)" } ?? "" + let json = """ + {"defaultProfile": "Default", "units": "mg/dL", "store": {}\(limitsField)} + """ + return try JSONDecoder().decode(NSProfile.self, from: Data(json.utf8)) + } + + @Test("profileEditLimits is read from the profile document") + func parsesFromDocument() throws { + let document = try profileDocument(limits: Self.contractLimitsJSON) + let limits = try #require(document.editLimits) + #expect(limits.units == "mg/dL") + #expect(limits.maxBasal == 3.0) + #expect(limits.carbRatio == .init(min: 2, max: 150, step: 0.1, recommendedMin: 4, recommendedMax: 28)) + #expect(limits.target.recommendedMax == 115) + #expect(limits.basalMinimum == nil) + #expect(limits.segmentLimit == 48) + #expect(limits.isUsable) + } + + @Test("a missing or malformed block leaves the limits out and keeps the document") + func missingOrMalformed() throws { + #expect(try profileDocument(limits: nil).editLimits == nil) + let malformed = try profileDocument(limits: "{\"units\": \"mg/dL\", \"maxBasal\": \"lots\"}") + #expect(malformed.editLimits == nil) + #expect(malformed.defaultProfile == "Default") + } + + @Test("recommended bounds and the basal minimum are optional") + func optionalKeys() throws { + let limits = try Self.limits(""" + {"units": "mmol/L", "maxBasal": 2, "basalRates": [0, 0.05, 0.1, 2, 2.5], "basalMinimum": 0.05, + "carbRatio": {"min": 1, "max": 99, "step": 1}, "sensitivity": {"min": 0.5, "max": 20, "step": 0.1}, + "target": {"min": 4.5, "max": 10, "step": 0.1}, "maxSegments": 48, "segmentSeconds": 1800} + """) + #expect(limits.allowedBasalRates == [0.05, 0.1, 2]) + #expect(!limits.isAllowedBasalRate(0)) + #expect(!limits.isAllowedBasalRate(2.5)) + #expect(limits.status(of: 30, in: .carbRatio) == .ok) + #expect(limits.normalizedUnits == "mmol/L") + } + + @Test("allowed basal rates stop at max basal") + func basalRates() throws { + let limits = try Self.limits() + #expect(limits.allowedBasalRates == [0, 0.05, 0.1, 0.15, 3.0]) + #expect(limits.status(of: 0.1, in: .basal) == .ok) + #expect(limits.status(of: 0.12, in: .basal) == .notAllowed) + #expect(limits.status(of: 3.05, in: .basal) == .notAllowed) + } + + @Test("range values follow the step without floating point noise") + func rangeValues() throws { + let limits = try Self.limits() + let ratios = limits.carbRatio.values + #expect(ratios.first == 2) + #expect(ratios.last == 150) + #expect(ratios.count == 1481) + #expect(ratios[1] == 2.1) + #expect(ratios[3] == 2.3) + #expect(limits.target.values.count == 94) + #expect(ProfileEditLimits.decimals(of: 0.05) == 2) + #expect(ProfileEditLimits.decimals(of: 1) == 0) + } + + @Test("status separates hard bounds from recommended bounds") + func status() throws { + let limits = try Self.limits() + #expect(limits.status(of: 100, in: .correctionRange) == .ok) + #expect(limits.status(of: 90, in: .correctionRange) == .outsideRecommended) + #expect(limits.status(of: 86, in: .correctionRange) == .notAllowed) + #expect(limits.status(of: 181, in: .correctionRange) == .notAllowed) + #expect(limits.status(of: 3, in: .carbRatio) == .outsideRecommended) + #expect(limits.status(of: 1.9, in: .carbRatio) == .notAllowed) + #expect(limits.status(of: .nan, in: .sensitivity) == .notAllowed) + } + + @Test("the pump's basal entry limit caps basal schedules only") + func maxBasalSegments() throws { + let plain = try Self.limits() + #expect(plain.maxBasalSegments == nil) + #expect(plain.segmentLimit(for: .basal) == 48) + let omnipod = try Self.limits(Self.contractLimitsJSON.replacingOccurrences(of: "\"maxSegments\": 48", with: "\"maxSegments\": 48, \"maxBasalSegments\": 24")) + #expect(omnipod.maxBasalSegments == 24) + #expect(omnipod.isUsable) + #expect(omnipod.segmentLimit(for: .basal) == 24) + #expect(omnipod.segmentLimit(for: .carbRatio) == 48) + #expect(omnipod.segmentLimit(for: .correctionRange) == 48) + let generous = try Self.limits(Self.contractLimitsJSON.replacingOccurrences(of: "\"maxSegments\": 48", with: "\"maxSegments\": 12, \"maxBasalSegments\": 48")) + #expect(generous.segmentLimit(for: .basal) == 12) + let zero = try Self.limits(Self.contractLimitsJSON.replacingOccurrences(of: "\"maxSegments\": 48", with: "\"maxSegments\": 48, \"maxBasalSegments\": 0")) + #expect(!zero.isUsable) + } + + @Test("inconsistent limits are not usable") + func unusable() throws { + let badGrid = try Self.limits(Self.contractLimitsJSON.replacingOccurrences(of: "\"segmentSeconds\": 1800", with: "\"segmentSeconds\": 1700")) + #expect(!badGrid.isUsable) + let badStep = try Self.limits(Self.contractLimitsJSON.replacingOccurrences(of: "\"step\": 0.1", with: "\"step\": 0")) + #expect(!badStep.isUsable) + #expect(ProfileEditing.blocker(limits: badStep, remote: .trio) != nil) + let missing = try #require(ProfileEditing.blocker(limits: nil, remote: .loop)) + #expect(missing.contains("pump")) + #expect(try !#require(ProfileEditing.blocker(limits: nil, remote: .trio)).contains("pump")) + #expect(try ProfileEditing.blocker(limits: Self.limits(), remote: .loop) == nil) + } +} diff --git a/Tests/ProfileInsulinComparisonTests.swift b/Tests/ProfileInsulinComparisonTests.swift new file mode 100644 index 000000000..24ae3d93b --- /dev/null +++ b/Tests/ProfileInsulinComparisonTests.swift @@ -0,0 +1,73 @@ +// LoopFollow +// ProfileInsulinComparisonTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct ProfileInsulinComparisonTests { + static func values( + units: String = "mg/dL", + basal: [(Int, Double)] = [(0, 0.8)], + carbRatio: [(Int, Double)] = [(0, 10)], + sensitivity: [(Int, Double)] = [(0, 50)], + target: [(Int, Double, Double)] = [(0, 100, 110)] + ) -> TherapyProfileValues { + TherapyProfileValues( + units: units, + basal: basal.map { ProfileScheduleItem(seconds: $0.0, value: $0.1) }, + carbRatio: carbRatio.map { ProfileScheduleItem(seconds: $0.0, value: $0.1) }, + sensitivity: sensitivity.map { ProfileScheduleItem(seconds: $0.0, value: $0.1) }, + target: target.map { ProfileRangeItem(seconds: $0.0, low: $0.1, high: $0.2) } + ) + } + + @Test("identical values give no increase") + func identical() { + #expect(ProfileInsulinComparison.increases(from: Self.values(), to: Self.values()).isEmpty) + } + + @Test("less insulin everywhere gives no increase") + func lessInsulin() { + let new = Self.values(basal: [(0, 0.7)], carbRatio: [(0, 12)], sensitivity: [(0, 60)], target: [(0, 110, 120)]) + #expect(ProfileInsulinComparison.increases(from: Self.values(), to: new).isEmpty) + #expect(ProfileInsulinComparison.basalTotalIncrease(from: Self.values(), to: new) == nil) + } + + @Test("a higher basal segment is reported with its times and the day total") + func higherBasalSegment() throws { + let new = Self.values(basal: [(0, 0.8), (21600, 1.0), (32400, 0.8)]) + let increases = ProfileInsulinComparison.increases(from: Self.values(), to: new) + #expect(increases == [ProfileInsulinIncrease(kind: .basal, periods: [21600 ... 32400])]) + let total = try #require(ProfileInsulinComparison.basalTotalIncrease(from: Self.values(), to: new)) + #expect(abs(total.old - 19.2) < 1e-9) + #expect(abs(total.new - 19.8) < 1e-9) + } + + @Test("lower carb ratio, sensitivity and target each count as more insulin") + func lowerRatios() { + let new = Self.values(carbRatio: [(0, 9)], sensitivity: [(0, 50), (43200, 45)], target: [(0, 100, 105)]) + let kinds = ProfileInsulinComparison.increases(from: Self.values(), to: new).map(\.kind) + #expect(Set(kinds) == [.carbRatio, .sensitivity, .correctionRange]) + let sensitivity = ProfileInsulinComparison.increases(from: Self.values(), to: new).first { $0.kind == .sensitivity } + #expect(sensitivity?.periods == [43200 ... 86400]) + } + + @Test("adjacent periods merge, and a whole day reads as all day") + func mergeAndAllDay() throws { + let old = Self.values(basal: [(0, 0.5), (3600, 0.6)]) + let new = Self.values(basal: [(0, 0.7), (3600, 0.8)]) + let increase = try #require(ProfileInsulinComparison.increases(from: old, to: new).first) + #expect(increase.periods == [0 ... 86400]) + #expect(increase.text == "Basal Rates higher all day") + } + + @Test("glucose values compare across units") + func acrossUnits() { + let old = Self.values(units: "mmol/L", sensitivity: [(0, 2.8)], target: [(0, 5.5, 6.1)]) + let sameInMgdl = Self.values(units: "mg/dL", sensitivity: [(0, 50.5)], target: [(0, 99.5, 110)]) + #expect(ProfileInsulinComparison.increases(from: old, to: sameInMgdl).isEmpty) + let lower = Self.values(units: "mg/dL", sensitivity: [(0, 45)], target: [(0, 99.5, 110)]) + #expect(ProfileInsulinComparison.increases(from: old, to: lower).map(\.kind) == [.sensitivity]) + } +} diff --git a/Tests/ProfileValidationTests.swift b/Tests/ProfileValidationTests.swift new file mode 100644 index 000000000..ea9858bc4 --- /dev/null +++ b/Tests/ProfileValidationTests.swift @@ -0,0 +1,160 @@ +// LoopFollow +// ProfileValidationTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct ProfileValidationTests { + private let existing = ["Default", "Sport", "School"] + + private func values( + units: String = "mg/dL", + basal: [(Int, Double)] = [(0, 0.85), (21600, 1.0)], + carbRatio: [(Int, Double)] = [(0, 10)], + sens: [(Int, Double)] = [(0, 50)], + target: [(Int, Double, Double)] = [(0, 100, 110)] + ) -> TherapyProfileValues { + TherapyProfileValues( + units: units, + basal: basal.map { ProfileScheduleItem(seconds: $0.0, value: $0.1) }, + carbRatio: carbRatio.map { ProfileScheduleItem(seconds: $0.0, value: $0.1) }, + sensitivity: sens.map { ProfileScheduleItem(seconds: $0.0, value: $0.1) }, + target: target.map { ProfileRangeItem(seconds: $0.0, low: $0.1, high: $0.2) } + ) + } + + @Test("names are trimmed and follow the contract rules") + func names() { + #expect(ProfileValidation.nameProblem(" Weekend ", existingNames: existing) == nil) + #expect(ProfileValidation.normalizedName(" Weekend ") == "Weekend") + #expect(ProfileValidation.nameProblem(" ", existingNames: existing) != nil) + #expect(ProfileValidation.nameProblem(String(repeating: "a", count: 32), existingNames: existing) == nil) + #expect(ProfileValidation.nameProblem(String(repeating: "a", count: 33), existingNames: existing) != nil) + #expect(ProfileValidation.nameProblem("Sick.Day", existingNames: existing) != nil) + #expect(ProfileValidation.nameProblem("$set", existingNames: existing) != nil) + #expect(ProfileValidation.nameProblem("Pay$", existingNames: existing) == nil) + #expect(ProfileValidation.nameProblem("default", existingNames: existing) != nil) + #expect(ProfileValidation.nameProblem("sport", existingNames: existing) != nil) + #expect(ProfileValidation.nameProblem("SPORT", existingNames: existing, originalName: "Sport") == nil) + #expect(ProfileValidation.nameProblem("School", existingNames: existing, originalName: "Sport") != nil) + #expect(ProfileValidation.nameProblem("Sick day 🤒", existingNames: existing) == nil) + #expect(ProfileValidation.nameProblem(String(repeating: "👨‍👩‍👧‍👦", count: 32), existingNames: existing) == nil) + #expect(ProfileValidation.nameProblem(String(repeating: "👨‍👩‍👧‍👦", count: 33), existingNames: existing) != nil) + #expect(ProfileValidation.nameProblem("DEFAULT", existingNames: existing) != nil) + #expect(ProfileValidation.nameProblem("Cafe\u{301}", existingNames: ["Caf\u{E9}"]) != nil) + #expect(ProfileValidation.countProblem(profileCount: 19) == nil) + #expect(ProfileValidation.countProblem(profileCount: 20) != nil) + } + + @Test("values inside the limits pass") + func valid() throws { + let limits = try ProfileEditLimitsTests.limits(ProfileEditLimitsTests.contractLimitsJSON.replacingOccurrences(of: "\"basalRates\": [0, 0.05, 0.1, 0.15, 3.0, 3.05]", with: "\"basalRates\": [0.05, 0.85, 1.0]")) + #expect(ProfileValidation.problems(values(), limits: limits).isEmpty) + } + + @Test("schedule shape is checked") + func scheduleShape() throws { + let limits = try ProfileEditLimitsTests.limits() + #expect(!ProfileValidation.scheduleProblems([], kind: .basal, limits: limits).isEmpty) + #expect(!ProfileValidation.scheduleProblems([1800], kind: .basal, limits: limits).isEmpty) + #expect(!ProfileValidation.scheduleProblems([0, 3600, 3600], kind: .basal, limits: limits).isEmpty) + #expect(!ProfileValidation.scheduleProblems([0, 7200, 3600], kind: .basal, limits: limits).isEmpty) + #expect(!ProfileValidation.scheduleProblems([0, 900], kind: .basal, limits: limits).isEmpty) + #expect(!ProfileValidation.scheduleProblems([0, 86400], kind: .basal, limits: limits).isEmpty) + #expect(ProfileValidation.scheduleProblems(Array(stride(from: 0, to: 86400, by: 1800)), kind: .basal, limits: limits).isEmpty) + let twelve = try ProfileEditLimitsTests.limits(ProfileEditLimitsTests.contractLimitsJSON.replacingOccurrences(of: "\"maxSegments\": 48", with: "\"maxSegments\": 12")) + #expect(!ProfileValidation.scheduleProblems(Array(stride(from: 0, to: 13 * 1800, by: 1800)), kind: .carbRatio, limits: twelve).isEmpty) + } + + @Test("out-of-bounds values, unsupported basal rates, inverted ranges and unit mismatches are rejected") + func bounds() throws { + let limits = try ProfileEditLimitsTests.limits() + let base = (basal: [(0, 0.1)], carb: [(0, 10.0)], sens: [(0, 50.0)], target: [(0, 100.0, 110.0)]) + #expect(ProfileValidation.problems(values(basal: base.basal), limits: limits).isEmpty) + #expect(ProfileValidation.problems(values(basal: [(0, 0.12)]), limits: limits).count == 1) + #expect(ProfileValidation.problems(values(basal: [(0, 3.05)]), limits: limits).count == 1) + #expect(ProfileValidation.problems(values(basal: base.basal, carbRatio: [(0, 151)]), limits: limits).count == 1) + #expect(ProfileValidation.problems(values(basal: base.basal, sens: [(0, 9)]), limits: limits).count == 1) + #expect(ProfileValidation.problems(values(basal: base.basal, target: [(0, 86, 110)]), limits: limits).count == 1) + #expect(ProfileValidation.problems(values(basal: base.basal, target: [(0, 120, 110)]), limits: limits).count == 1) + #expect(ProfileValidation.problems(values(units: "mmol/L", basal: base.basal), limits: limits).count == 1) + // Outside the recommended range is a warning, not a problem. + #expect(ProfileValidation.problems(values(basal: base.basal, carbRatio: [(0, 3)], target: [(0, 90, 120)]), limits: limits).isEmpty) + } + + @Test("basal schedules stay within the pump's entry limit") + func basalSegmentLimit() throws { + let limits = try ProfileEditLimitsTests.limits(ProfileEditLimitsTests.contractLimitsJSON.replacingOccurrences(of: "\"maxSegments\": 48", with: "\"maxSegments\": 48, \"maxBasalSegments\": 24")) + let starts25 = Array(stride(from: 0, to: 25 * 1800, by: 1800)) + #expect(ProfileValidation.scheduleProblems(starts25, kind: .basal, limits: limits) == ["Basal Rates: at most 24 times are allowed."]) + #expect(ProfileValidation.scheduleProblems(Array(starts25.dropLast()), kind: .basal, limits: limits).isEmpty) + #expect(ProfileValidation.scheduleProblems(starts25, kind: .carbRatio, limits: limits).isEmpty) + let basal = starts25.map { ($0, 0.1) } + #expect(ProfileValidation.problems(values(basal: basal), limits: limits).count == 1) + } + + @Test("a pure rename is not checked against today's limits, a changed save is") + func renameSkipsValueChecks() throws { + let limits = try ProfileEditLimitsTests.limits() + let json = """ + {"timezone": "UTC", "units": "mg/dL", + "basal": [{"time": "00:00", "value": 0.12, "timeAsSeconds": 0}], + "carbratio": [{"time": "00:00", "value": 10, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": 50, "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": 80, "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": 110, "timeAsSeconds": 0}]} + """ + let store = try JSONDecoder().decode(NSProfile.Store.self, from: Data(json.utf8)) + let stored = TherapyProfile(name: "Old Sport", store: store) + #expect(ProfileValidation.problems(stored.values, limits: limits).count == 2) + #expect(ProfileValidation.problems(for: .save(name: "Sport", original: stored, values: stored.values), limits: limits).isEmpty) + + var draft = ProfileDraft(stored.values) + draft.carbRatio[0].value = 12 + #expect(ProfileValidation.problems(for: .save(name: "Old Sport", original: stored, values: draft.values), limits: limits).count == 2) + #expect(ProfileValidation.problems(for: .save(name: "Copy", original: nil, values: stored.values), limits: limits).count == 2) + #expect(ProfileValidation.problems(for: .activate(stored), limits: limits).isEmpty) + } + + @Test("new segments go one step after the last, or into the first free slot") + func addSegments() { + var segments = [EditableSegment(seconds: 0, value: 1), EditableSegment(seconds: 21600, value: 2)] + #expect(ProfileSegmentGrid.newSegmentStart(segments, segmentSeconds: 1800, maxSegments: 48) == 23400) + segments = ProfileSegmentGrid.addSegment(to: segments, segmentSeconds: 1800, maxSegments: 48) + #expect(segments.map(\.seconds) == [0, 21600, 23400]) + #expect(segments.last?.value == 2) + let late = [EditableSegment(seconds: 0, value: 1), EditableSegment(seconds: 84600, value: 2)] + #expect(ProfileSegmentGrid.newSegmentStart(late, segmentSeconds: 1800, maxSegments: 48) == 1800) + let added = ProfileSegmentGrid.addSegment(to: late, segmentSeconds: 1800, maxSegments: 48) + #expect(added.map(\.seconds) == [0, 1800, 84600]) + #expect(added[1].value == 1) + #expect(ProfileSegmentGrid.newSegmentStart(late, segmentSeconds: 1800, maxSegments: 2) == nil) + let full = stride(from: 0, to: 86400, by: 1800).map { EditableSegment(seconds: $0, value: 1) } + #expect(ProfileSegmentGrid.newSegmentStart(full, segmentSeconds: 1800, maxSegments: 100) == nil) + } + + @Test("a segment moves only between its neighbours, the first stays at midnight") + func allowedStarts() { + let segments = [EditableSegment(seconds: 0, value: 1), EditableSegment(seconds: 7200, value: 2), EditableSegment(seconds: 14400, value: 3)] + #expect(ProfileSegmentGrid.allowedStarts(for: 0, in: segments, segmentSeconds: 1800) == [0]) + #expect(ProfileSegmentGrid.allowedStarts(for: 1, in: segments, segmentSeconds: 1800) == [1800, 3600, 5400, 7200, 9000, 10800, 12600]) + #expect(ProfileSegmentGrid.allowedStarts(for: 2, in: segments, segmentSeconds: 1800).last == 84600) + } + + @Test("a draft round-trips its values and the diff shows effective values at each change") + func draftAndDiff() { + let original = values(basal: [(0, 0.85), (21600, 1.0)]) + var draft = ProfileDraft(original) + #expect(draft.values == original) + draft.basal[1].value = 1.2 + draft.basal.append(EditableSegment(seconds: 43200, value: 1.0)) + let rows = ProfileDiff.rows(old: ProfileDraft(original).basal, new: draft.basal) { String($0.value) } + #expect(rows == [ + ProfileDiffRow(seconds: 0, old: "0.85", new: "0.85"), + ProfileDiffRow(seconds: 21600, old: "1.0", new: "1.2"), + ProfileDiffRow(seconds: 43200, old: "1.0", new: "1.0"), + ]) + #expect(rows.filter(\.changed).map(\.seconds) == [21600]) + } +} diff --git a/Tests/RemoteCommandTrackerTests.swift b/Tests/RemoteCommandTrackerTests.swift index 5381ec1d0..8543dc02e 100644 --- a/Tests/RemoteCommandTrackerTests.swift +++ b/Tests/RemoteCommandTrackerTests.swift @@ -86,7 +86,7 @@ struct RemoteCommandTrackerTests { @Test("Loop profile switch acks resolve the profile named in the message") func loopProfileAdapter() { let tracker = RemoteCommandTracker(timeout: 60) - let sickDay = LoopProfileAckAdapter.key(for: "Sick Day") + let sickDay = ProfileRemote.loop.key(.activate, name: "Sick Day") tracker.begin(key: sickDay) let lateSport: [AnyHashable: Any] = [ "command_type": "profile_switch", @@ -105,4 +105,229 @@ struct RemoteCommandTrackerTests { #expect(LoopProfileAckAdapter.resolution(for: ["command_type": "profile_switch", "command_status": "failed"]) == nil) #expect(LoopProfileAckAdapter.resolution(for: ["command_type": "carbs_edit", "command_status": "success"]) == nil) } + + @Test("a Loop profile reply without the English frame resolves only the sole pending command of its type") + func loopProfileReplyWithoutFrame() { + let localized: [AnyHashable: Any] = [ + "command_type": "profile_switch", + "command_status": "success", + "result": "activated", + "aps": ["alert": ["body": "Profil 'Sport' aktiverad"]], + ] + let tracker = RemoteCommandTracker(timeout: 60) + let sport = ProfileRemote.loop.key(.activate, name: "Sport") + let save = ProfileRemote.loop.key(.save, name: "Work") + tracker.begin(key: sport) + tracker.begin(key: save) + #expect(tracker.handleNotification(userInfo: localized) == true) + #expect(tracker.states[sport] == .done(success: true, message: "Profil 'Sport' aktiverad")) + #expect(tracker.isBusy(key: save)) + + let two = RemoteCommandTracker(timeout: 60) + two.begin(key: ProfileRemote.loop.key(.activate, name: "Sport")) + two.begin(key: ProfileRemote.loop.key(.activate, name: "Work")) + #expect(two.handleNotification(userInfo: localized) == false) + } + + @Test("Loop profile save and delete acks resolve by command type and name, renames by the new name") + func loopProfileSaveDelete() { + let tracker = RemoteCommandTracker(timeout: 60) + let rename = ProfileRemote.loop.key(.save, name: "It's School") + let delete = ProfileRemote.loop.key(.delete, name: "Old") + #expect(rename == "loop-profile-save:It's School") + #expect(delete == "loop-profile-delete:Old") + tracker.begin(key: rename) + tracker.begin(key: delete) + let wrongType: [AnyHashable: Any] = [ + "command_type": "profile_delete", + "command_status": "success", + "aps": ["alert": ["body": "Profile 'It's School' saved"]], + ] + #expect(tracker.handleNotification(userInfo: wrongType) == false) + let saved: [AnyHashable: Any] = [ + "command_type": "profile_save", + "command_status": "success", + "aps": ["alert": ["title": "Command Successful", "body": "Profile 'It's School' saved"]], + ] + #expect(tracker.handleNotification(userInfo: saved) == true) + #expect(tracker.states[rename] == .done(success: true, message: "Profile 'It's School' saved")) + let notDeleted: [AnyHashable: Any] = [ + "command_type": "profile_delete", + "command_status": "failed", + "aps": ["alert": ["body": "Command failed: Profile 'Old' not deleted: Profile is active"]], + ] + #expect(tracker.handleNotification(userInfo: notDeleted) == true) + #expect(tracker.states[delete] == .done(success: false, message: "Command failed: Profile 'Old' not deleted: Profile is active")) + #expect(LoopProfileAckAdapter.profileName(in: "Profile 'A' not saved: conflict", kind: .save) == "A") + #expect(LoopProfileAckAdapter.profileName(in: "Profile 'A' saved", kind: .delete) == nil) + } + + @Test("Loop profile names with apostrophes and quoted reasons are matched exactly") + func loopProfileNames() { + #expect(LoopProfileAckAdapter.profileName(in: "Profile 'It's School' saved", kind: .save) == "It's School") + #expect(LoopProfileAckAdapter.profileName(in: "Command failed: Profile 'A' not saved: Profile 'B' saved already", kind: .save) == "A") + #expect(LoopProfileAckAdapter.profileName(in: "Command failed: Profile 'Mom's' not activated: Profile 'X' activated", kind: .activate) == "Mom's") + #expect(LoopProfileAckAdapter.profileName(in: "Profile 'A' saved and more", kind: .save) == nil) + // A name that itself contains the failure phrase is found through the pending commands. + let tricky = "x' not saved: y" + let message = "Command failed: Profile '\(tricky)' not saved: conflict" + #expect(LoopProfileAckAdapter.profileName(in: message, kind: .save) == "x") + #expect(LoopProfileAckAdapter.profileName(in: message, kind: .save, candidates: ["x", tricky]) == tricky) + + let tracker = RemoteCommandTracker(timeout: 60) + let key = ProfileRemote.loop.key(.save, name: tricky) + tracker.begin(key: key) + let reply: [AnyHashable: Any] = [ + "command_type": "profile_save", + "command_status": "failed", + "aps": ["alert": ["body": message]], + ] + #expect(tracker.handleNotification(userInfo: reply) == true) + #expect(tracker.states[key] == .done(success: false, message: message)) + } + + @Test("a pending profile command whose change shows in the uploaded profiles finishes as applied") + func profileCommandSeen() throws { + let tracker = RemoteCommandTracker(timeout: 60) + let profile = try TherapyProfile(name: "Sport", store: TherapyProfileTests.store(units: "mg/dL", sens: 45, low: 100, high: 110)) + let key = tracker.beginProfileCommand(.activate(profile), to: .trio) + + tracker.resolveProfileCommandSeen(.delete(profile)) + #expect(tracker.isBusy(key: key)) + + tracker.resolveProfileCommandSeen(.activate(profile)) + #expect(tracker.states[key] == .done(success: true, message: ProfileRemote.trio.seenMessage(.activate(profile)))) + + tracker.resolve(key: key, success: false, message: "late reply") + #expect(tracker.states[key] == .done(success: true, message: ProfileRemote.trio.seenMessage(.activate(profile)))) + } + + @Test("a profile command that times out finishes like a reply and leaves the review") + func profileTimeout() async throws { + let tracker = RemoteCommandTracker(timeout: 60) + let key = ProfileRemote.loop.key(.save, name: "Weekday") + var completed: [String] = [] + let observer = NotificationCenter.default.addObserver(forName: .remoteMealCommandDidComplete, object: nil, queue: nil) { note in + if let key = note.userInfo?["key"] as? String { completed.append(key) } + } + defer { NotificationCenter.default.removeObserver(observer) } + tracker.begin(key: key, timeout: 0.05, timeoutMessage: ProfileRemote.loop.timeoutMessage) + for _ in 0 ..< 250 where tracker.isBusy(key: key) || !completed.contains(key) { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(tracker.states[key] == .done(success: false, message: ProfileRemote.loop.timeoutMessage)) + #expect(completed.contains(key)) + #expect(ProfileRemote.isProfileCommandKey(key)) + + #expect(ProfileCommandFlow.leavesAfterResult(.init(success: false, message: ProfileRemote.loop.timeoutMessage), remote: .loop)) + #expect(ProfileCommandFlow.leavesAfterResult(.init(success: true, message: "Profile 'Weekday' saved"), remote: .loop)) + #expect(!ProfileCommandFlow.leavesAfterResult(.init(success: false, message: "Command failed: Profile 'Weekday' not saved: The basal rate 3.5 U/hr is above the maximum basal rate"), remote: .loop)) + #expect(!ProfileCommandFlow.leavesAfterResult(.init(success: false, message: ProfileRemote.trio.timeoutMessage), remote: .loop)) + #expect(ProfileCommandFlow.leavesAfterResult(.init(success: false, message: "Profile 'Weekday' not saved", outdated: true), remote: .trio)) + } + + @Test("Loop replies are classified by their result code before the alert text") + func loopResultCodes() { + let tracker = RemoteCommandTracker(timeout: 60) + func reply(_ type: String, status: String, result: String?, body: String) -> [AnyHashable: Any] { + var info: [AnyHashable: Any] = ["command_type": type, "command_status": status, "aps": ["alert": ["body": body]]] + if let result { info["result"] = result } + return info + } + // Localized text: only the code tells it is a conflict. + let conflict = ProfileRemote.loop.key(.save, name: "Weekday") + tracker.begin(key: conflict) + #expect(tracker.handleNotification(userInfo: reply("profile_save", status: "failed", result: "conflict", body: "Profile 'Weekday' not saved: Profilen har ändrats"))) + #expect(tracker.states[conflict] == .done(success: false, message: "Profile 'Weekday' not saved: Profilen har ändrats", outdated: true)) + + let gone = ProfileRemote.loop.key(.delete, name: "Old") + tracker.begin(key: gone) + #expect(tracker.handleNotification(userInfo: reply("profile_delete", status: "failed", result: "not_found", body: "Profile 'Old' not deleted: x"))) + #expect(tracker.states[gone] == .done(success: false, message: "Profile 'Old' not deleted: x", outdated: true)) + + // A rejection whose text happens to match the English fallback stays a plain refusal. + let rejected = ProfileRemote.loop.key(.save, name: "School") + tracker.begin(key: rejected) + let rejectedBody = "Profile 'School' not saved: No profile named School was expected" + #expect(tracker.handleNotification(userInfo: reply("profile_save", status: "failed", result: "rejected", body: rejectedBody))) + #expect(tracker.states[rejected] == .done(success: false, message: rejectedBody)) + + let activated = ProfileRemote.loop.key(.activate, name: "Sport") + tracker.begin(key: activated) + #expect(tracker.handleNotification(userInfo: reply("profile_switch", status: "success", result: "activated", body: "Profile 'Sport' activated"))) + #expect(tracker.states[activated] == .done(success: true, message: "Profile 'Sport' activated")) + + #expect(LoopProfileAckAdapter.outcome(status: "success", result: "saved", body: "") == (true, false)) + #expect(LoopProfileAckAdapter.outcome(status: "failed", result: nil, body: "The profile changed on the phone after it was uploaded.") == (false, true)) + #expect(LoopProfileAckAdapter.outcome(status: "failed", result: nil, body: "Profilen har ändrats") == (false, false)) + } + + @Test("refusals because the profile changed or is gone are marked outdated, other refusals are not") + func outdatedRefusals() { + let tracker = RemoteCommandTracker(timeout: 60) + let conflict = ProfileRemote.loop.key(.save, name: "Weekday") + let gone = ProfileRemote.loop.key(.activate, name: "Old") + let invalid = ProfileRemote.loop.key(.save, name: "School") + [conflict, gone, invalid].forEach { tracker.begin(key: $0) } + func reply(_ type: String, _ body: String) -> [AnyHashable: Any] { + ["command_type": type, "command_status": "failed", "aps": ["alert": ["body": body]]] + } + let conflictBody = "Command failed: Profile 'Weekday' not saved: The profile changed on the phone after it was uploaded. Review it again and resend." + #expect(tracker.handleNotification(userInfo: reply("profile_save", conflictBody))) + #expect(tracker.states[conflict] == .done(success: false, message: conflictBody, outdated: true)) + #expect(tracker.handleNotification(userInfo: reply("profile_switch", "Profile 'Old' not activated: No profile named Old"))) + #expect(tracker.states[gone] == .done(success: false, message: "Profile 'Old' not activated: No profile named Old", outdated: true)) + #expect(tracker.handleNotification(userInfo: reply("profile_save", "Profile 'School' not saved: The correction range is below the glucose safety limit"))) + #expect(tracker.states[invalid] == .done(success: false, message: "Profile 'School' not saved: The correction range is below the glucose safety limit")) + + let trioKey = ProfileRemote.trio.key(.save, name: "Weekend") + tracker.begin(key: trioKey) + tracker.trioAcks.register(commandID: "T1", key: trioKey) + #expect(tracker.handleNotification(userInfo: ["command_status": "failed", "command_id": "T1", "result": "conflict"])) + if case let .done(_, _, outdated) = tracker.states[trioKey] { #expect(outdated) } else { Issue.record("not resolved") } + let trioRejected = ProfileRemote.trio.key(.save, name: "Rejected") + tracker.begin(key: trioRejected) + tracker.trioAcks.register(commandID: "T2", key: trioRejected) + #expect(tracker.handleNotification(userInfo: ["command_status": "failed", "command_id": "T2", "result": "rejected"])) + if case let .done(_, _, outdated) = tracker.states[trioRejected] { #expect(!outdated) } else { Issue.record("not resolved") } + #expect(ProfileCommandFlow.resultMessage(.init(success: false, message: "x", outdated: true), remote: .trio).contains("loading the profiles")) + } + + @Test("Trio profile acks match the command id and fall back to a profile message") + func trioProfileAck() throws { + let tracker = RemoteCommandTracker(timeout: 60) + let command = try ProfileCommand.delete(TherapyProfile(name: "Weekend", store: Self.profileStore())) + let key = ProfileRemote.trio.key(for: command) + #expect(key == "trio-profile-delete:Weekend") + tracker.begin(key: key) + tracker.trioAcks.register(commandID: "CMD-1", key: key, successMessage: ProfileRemote.trio.successMessage(command), failureMessage: ProfileRemote.trio.failureMessage(command)) + #expect(tracker.handleNotification(userInfo: ["command_status": "success", "command_id": "OTHER", "command_type": "delete_profile"]) == false) + #expect(tracker.handleNotification(userInfo: ["command_status": "success", "command_id": "CMD-1", "command_type": "delete_profile"]) == true) + #expect(tracker.states[key] == .done(success: true, message: "Profile 'Weekend' deleted")) + #expect(ProfileRemote.isProfileCommandKey(key)) + #expect(ProfileRemote.isProfileCommandKey("loop-profile-switch:Sport")) + #expect(!ProfileRemote.isProfileCommandKey("SYNC-1")) + #expect(tracker.isBusy(keyPrefixes: ProfileRemote.trio.keyPrefixes) == false) + } + + @Test("profile commands are gated on the names each app lists") + func gating() { + #expect(ProfileRemote.loop.supports(.save, in: ["profile-switch", "profile-save"])) + #expect(!ProfileRemote.loop.supports(.delete, in: ["profile-switch", "profile-save"])) + #expect(ProfileRemote.trio.supports(.activate, in: ["switch_profile"])) + #expect(!ProfileRemote.trio.supportsAny(in: ["edit_meal", "delete_meal", "profile-save"])) + #expect(ProfileRemote.loop.supportsAny(in: ["profile-delete"])) + } + + private static func profileStore() throws -> NSProfile.Store { + let json = """ + {"timezone": "UTC", "units": "mg/dL", + "basal": [{"time": "00:00", "value": 1, "timeAsSeconds": 0}], + "carbratio": [{"time": "00:00", "value": 10, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": 50, "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": 100, "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": 110, "timeAsSeconds": 0}]} + """ + return try JSONDecoder().decode(NSProfile.Store.self, from: Data(json.utf8)) + } } diff --git a/Tests/ScheduledSwitchTests.swift b/Tests/ScheduledSwitchTests.swift new file mode 100644 index 000000000..047442417 --- /dev/null +++ b/Tests/ScheduledSwitchTests.swift @@ -0,0 +1,502 @@ +// LoopFollow +// ScheduledSwitchTests.swift + +import CryptoKit +import Foundation +@testable import LoopFollow +import Testing + +@MainActor +struct ScheduledSwitchTests { + private static let weekend = ScheduledProfileSwitch(id: "5A3D9D6E-1C7F-4A52-9C1D-2B3F9E0F7A11", profile: "Weekend", weekday: 7, time: 25200) + private static let school = ScheduledProfileSwitch(id: "B0E6C2F4-8D1B-4F0E-A6A7-3C2E5D9B1F02", profile: "Default", weekday: 2, time: 21600) + + private static func paused(_ entry: ScheduledProfileSwitch) -> ScheduledProfileSwitch { + var entry = entry + entry.paused = true + return entry + } + + // MARK: Fingerprint (shared vectors) + + @Test("the list fingerprint matches the shared vectors") + func fingerprintVectors() { + let entries = [Self.weekend, Self.school] + #expect(ScheduledProfileSwitch.canonicalString(entries) + == "profile=Default;weekday=2;date=-;time=21600;paused=false|profile=Weekend;weekday=7;date=-;time=25200;paused=false") + #expect(ScheduledProfileSwitch.fingerprint(entries) == "1f4d16404c846d2f") + + let pausedEntries = entries.map(Self.paused) + #expect(ScheduledProfileSwitch.canonicalString(pausedEntries) + == "profile=Default;weekday=2;date=-;time=21600;paused=true|profile=Weekend;weekday=7;date=-;time=25200;paused=true") + #expect(ScheduledProfileSwitch.fingerprint(pausedEntries) == "bc784950a457ba2c") + + #expect(ScheduledProfileSwitch.fingerprint([]) == "e3b0c44298fc1c14") + } + + @Test("the fingerprint ignores ids and order") + func fingerprintIgnoresIdentity() { + let renumbered = [ + ScheduledProfileSwitch(id: "X", profile: "Default", weekday: 2, time: 21600), + ScheduledProfileSwitch(id: "Y", profile: "Weekend", weekday: 7, time: 25200), + ] + #expect(ScheduledProfileSwitch.fingerprint(renumbered) == ScheduledProfileSwitch.fingerprint([Self.weekend, Self.school])) + } + + // MARK: Compact JSON + + @Test("compact entries carry paused only when true and decode back to the same entries") + func compactRoundTrip() throws { + let entries = [Self.paused(Self.weekend), Self.school] + let data = try JSONEncoder().encode(entries) + let objects = try #require(try JSONSerialization.jsonObject(with: data) as? [[String: Any]]) + #expect(Set(objects[0].keys) == ["id", "profile", "weekday", "time", "paused"]) + #expect(objects[0]["paused"] as? Bool == true) + #expect(Set(objects[1].keys) == ["id", "profile", "weekday", "time"]) + #expect(try JSONDecoder().decode([ScheduledProfileSwitch].self, from: data) == entries) + + let serialized = try JSONSerialization.data(withJSONObject: entries.map(\.jsonObject)) + #expect(try JSONDecoder().decode([ScheduledProfileSwitch].self, from: serialized) == entries) + } + + @Test("one-off entries and fractional times do not decode") + func unsupportedEntries() { + let oneOff = #"{"id":"A","profile":"Sick","date":"2026-11-14","time":28800}"# + #expect(throws: (any Error).self) { try JSONDecoder().decode(ScheduledProfileSwitch.self, from: Data(oneOff.utf8)) } + let fractional = #"{"id":"A","profile":"Sick","weekday":2,"time":28800.5}"# + #expect(throws: (any Error).self) { try JSONDecoder().decode(ScheduledProfileSwitch.self, from: Data(fractional.utf8)) } + } + + // MARK: Documents from Loop and Trio + + private static func document(store: [String], active: String, switches: String?, fingerprint: String? = nil, commands: [String] = []) throws -> NSProfile { + let entry = """ + {"timezone": "Europe/Stockholm", "units": "mg/dL", + "basal": [{"time": "00:00", "value": 1, "timeAsSeconds": 0}], + "carbratio": [{"time": "00:00", "value": 10, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": 50, "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": 100, "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": 110, "timeAsSeconds": 0}]} + """ + var json = #"{"defaultProfile": "\#(active)", "units": "mg/dL", "store": {"# + json += store.map { "\"\($0)\": \(entry)" }.joined(separator: ",") + "}" + if let switches { json += #", "scheduledSwitches": \#(switches)"# } + if let fingerprint { json += #", "scheduledSwitchesFingerprint": "\#(fingerprint)""# } + json += #", "remoteCommands": [\#(commands.map { "\"\($0)\"" }.joined(separator: ","))]}"# + return try JSONDecoder().decode(NSProfile.self, from: Data(json.utf8)) + } + + private static let loopSwitches = """ + [{"id":"5A3D9D6E-1C7F-4A52-9C1D-2B3F9E0F7A11","profile":"Weekend","weekday":7,"time":25200}, + {"id":"B0E6C2F4-8D1B-4F0E-A6A7-3C2E5D9B1F02","profile":"Default","weekday":2,"time":21600}] + """ + + @Test("a Loop document's scheduled switches are read with the uploaded fingerprint and the profile time zone") + func loopDocument() throws { + let document = try Self.document(store: ["Default", "Weekend"], active: "Default", switches: Self.loopSwitches, fingerprint: "1f4d16404c846d2f", + commands: ["profile-switch", "profile-scheduled-switches-save"]) + let list = TherapyProfileSet(document).scheduledSwitches + #expect(list.entries == [Self.weekend, Self.school]) + #expect(list.fingerprint == "1f4d16404c846d2f") + #expect(list.isComplete) + #expect(list.timeZone.identifier == "Europe/Stockholm") + #expect(ProfileRemote.loop.supports(.saveScheduledSwitches, in: document.remoteCommands ?? [])) + } + + @Test("Trio's default is shown as Default while the fingerprint is that of the uploaded names") + func trioDocument() throws { + let switches = #"[{"id":"A","profile":"default","weekday":2,"time":21600,"paused":true},{"id":"B","profile":"Weekend","weekday":7,"time":25200}]"# + let raw = [ScheduledProfileSwitch(id: "A", profile: "default", weekday: 2, time: 21600, paused: true), + ScheduledProfileSwitch(id: "B", profile: "Weekend", weekday: 7, time: 25200)] + let document = try Self.document(store: ["default", "Weekend"], active: "default", switches: switches, + fingerprint: ScheduledProfileSwitch.fingerprint(raw), commands: ["save_scheduled_switches"]) + let list = TherapyProfileSet(document).scheduledSwitches + #expect(list.entries.map(\.profile) == ["Default", "Weekend"]) + #expect(list.entries[0].paused) + #expect(list.fingerprint == ScheduledProfileSwitch.fingerprint(raw)) + #expect(list.isComplete) + #expect(ProfileRemote.trio.supports(.saveScheduledSwitches, in: document.remoteCommands ?? [])) + #expect(!ProfileRemote.loop.supports(.saveScheduledSwitches, in: document.remoteCommands ?? [])) + } + + @Test("a document without scheduled switches has an empty list that can be replaced") + func missingList() throws { + let list = try TherapyProfileSet(Self.document(store: ["Default"], active: "Default", switches: nil)).scheduledSwitches + #expect(list.entries.isEmpty) + #expect(list.fingerprint == "e3b0c44298fc1c14") + #expect(list.isComplete) + } + + @Test("a list with an unreadable entry, a value that is not a list, or another uploaded fingerprint is view-only") + func incompleteLists() throws { + let unreadable = #"[{"id":"A","profile":"Weekend","weekday":7,"time":25200},{"id":"B","profile":"Sick","date":"2026-11-14","time":28800}]"# + let partial = try TherapyProfileSet(Self.document(store: ["Default", "Weekend"], active: "Default", switches: unreadable)).scheduledSwitches + #expect(partial.entries.count == 1) + #expect(!partial.isComplete) + + let notAList = try TherapyProfileSet(Self.document(store: ["Default"], active: "Default", switches: #"{"a":1}"#)).scheduledSwitches + #expect(!notAList.isComplete) + + let mismatch = try TherapyProfileSet(Self.document(store: ["Default", "Weekend"], active: "Default", switches: Self.loopSwitches, fingerprint: "0000000000000000")).scheduledSwitches + #expect(!mismatch.isComplete) + #expect(ScheduledSwitchEditing.blocker(mismatch, supported: true, remote: .loop)?.contains("could not be read completely") == true) + #expect(ScheduledSwitchEditing.blocker(.empty, supported: false, remote: .trio)?.contains("has not said it accepts") == true) + #expect(ScheduledSwitchEditing.blocker(.empty, supported: true, remote: .loop) == nil) + } + + // MARK: Validation (the phone's rules) + + private let names = ["Default", "Weekend", "Training day"] + + @Test("a valid week passes") + func validWeek() { + #expect(ScheduledSwitches.problems([Self.weekend, Self.school], profileNames: names, appName: "Loop").isEmpty) + #expect(ScheduledSwitches.problems([], profileNames: names, appName: "Loop").isEmpty) + } + + @Test("more than 14 starts are refused") + func tooMany() { + let entries = (0 ..< 15).map { ScheduledProfileSwitch(profile: "Default", weekday: $0 % 7 + 1, time: ($0 / 7) * 3600) } + #expect(ScheduledSwitches.problems(Array(entries.prefix(14)), profileNames: names, appName: "Loop").isEmpty) + #expect(ScheduledSwitches.problems(entries, profileNames: names, appName: "Loop").first?.contains("at most 14") == true) + } + + @Test("a start of a missing profile is refused, names match case-insensitively") + func missingProfile() { + let entry = ScheduledProfileSwitch(profile: "Holiday", weekday: 2, time: 0) + #expect(ScheduledSwitches.problems([entry], profileNames: names, appName: "Trio") == ["“Holiday” is not a profile in Trio. Choose another profile or delete this start."]) + #expect(ScheduledSwitches.problems([ScheduledProfileSwitch(profile: "default", weekday: 2, time: 0)], profileNames: names, appName: "Trio").isEmpty) + } + + @Test("two starts in one slot are refused, for two profiles and for the same one") + func sameSlot() { + let other = ScheduledProfileSwitch(profile: "Training day", weekday: 7, time: 25200) + let problems = ScheduledSwitches.problems([Self.weekend, other], profileNames: names, appName: "Loop") + #expect(problems.count == 1) + #expect(problems.first?.contains("Only one profile can start at a time") == true) + + let twice = ScheduledProfileSwitch(profile: "Weekend", weekday: 7, time: 25200) + #expect(ScheduledSwitches.problems([Self.weekend, twice], profileNames: names, appName: "Loop").first?.contains("starts twice") == true) + + // The same time on another weekday is fine. + let monday = ScheduledProfileSwitch(profile: "Training day", weekday: 2, time: 25200) + #expect(ScheduledSwitches.problems([Self.weekend, monday], profileNames: names, appName: "Loop").isEmpty) + } + + @Test("times must be whole minutes of the day and weekdays 1 to 7") + func timeAndWeekday() { + for time in [-60, 86400, 25230] { + let entry = ScheduledProfileSwitch(profile: "Weekend", weekday: 7, time: time) + #expect(ScheduledSwitches.problems([entry], profileNames: names, appName: "Loop") == ["A start time must be a whole minute of the day."]) + } + for weekday in [0, 8] { + let entry = ScheduledProfileSwitch(profile: "Weekend", weekday: weekday, time: 0) + #expect(ScheduledSwitches.problems([entry], profileNames: names, appName: "Loop") == ["A start has no valid weekday."]) + } + #expect(ScheduledSwitches.problems([ScheduledProfileSwitch(profile: "Weekend", weekday: 1, time: 86340)], profileNames: names, appName: "Loop").isEmpty) + } + + // MARK: The week, spans and the next start + + @Test("the week is ordered from Monday and each start lasts until the next unpaused one") + func weekAndSpans() { + let sunday = ScheduledProfileSwitch(id: "SUN", profile: "Training day", weekday: 1, time: 21600) + let entries = [sunday, Self.weekend, Self.school] + #expect(ScheduledSwitches.sortedForWeek(entries).map(\.id) == [Self.school.id, Self.weekend.id, "SUN"]) + #expect(ScheduledSwitches.following(Self.school, in: entries)?.id == Self.weekend.id) + #expect(ScheduledSwitches.following(sunday, in: entries)?.id == Self.school.id) + + let pausedWeekend = Self.paused(Self.weekend) + let withPause = [sunday, pausedWeekend, Self.school] + #expect(ScheduledSwitches.following(Self.school, in: withPause)?.id == "SUN") + #expect(ScheduledSwitches.spanText(pausedWeekend, in: withPause) == "Paused") + #expect(ScheduledSwitches.spanText(Self.school, in: [Self.school]) == "until it starts again next week") + } + + @Test("the next start is found in the profile time zone and skips paused starts") + func nextStart() throws { + let zone = try #require(TimeZone(identifier: "America/New_York")) + // Friday 2026-11-13 12:00 in New York. + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = zone + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 11, day: 13, hour: 12))) + let next = try #require(ScheduledSwitches.next(in: [Self.weekend, Self.school], after: now, timeZone: zone)) + #expect(next.entry.id == Self.weekend.id) + #expect(next.date == calendar.date(from: DateComponents(year: 2026, month: 11, day: 14, hour: 7))) + + let afterPause = try #require(ScheduledSwitches.next(in: [Self.paused(Self.weekend), Self.school], after: now, timeZone: zone)) + #expect(afterPause.entry.id == Self.school.id) + #expect(afterPause.date == calendar.date(from: DateComponents(year: 2026, month: 11, day: 16, hour: 6))) + #expect(ScheduledSwitches.next(in: [Self.paused(Self.weekend)], after: now, timeZone: zone) == nil) + } + + @Test("a start in the hour skipped by daylight saving runs at the first valid time") + func springForward() throws { + let zone = try #require(TimeZone(identifier: "Europe/Stockholm")) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = zone + let saturday = try #require(calendar.date(from: DateComponents(year: 2026, month: 3, day: 28, hour: 12))) + let entry = ScheduledProfileSwitch(profile: "Weekend", weekday: 1, time: 2 * 3600 + 30 * 60) + let next = try #require(ScheduledSwitches.nextOccurrence(of: entry, after: saturday, timeZone: zone)) + #expect(next == calendar.date(from: DateComponents(year: 2026, month: 3, day: 29, hour: 3))) + } + + @Test("the footer says what runs next and how many starts are paused") + func footer() throws { + let zone = try #require(TimeZone(identifier: "UTC")) + let friday = Date(timeIntervalSince1970: 1_794_571_200) // 2026-11-13 12:00 UTC + func list(_ entries: [ScheduledProfileSwitch]) -> ScheduledSwitchList { + ScheduledSwitchList(entries: entries, fingerprint: ScheduledProfileSwitch.fingerprint(entries), isComplete: true, timeZone: zone) + } + let both = ScheduledSwitches.footerText(list([Self.weekend, Self.school]), appName: "Loop", now: friday) + #expect(both.hasPrefix("Next: Weekend starts Saturday ")) + #expect(!both.contains("paused")) + + let onePaused = ScheduledSwitches.footerText(list([Self.paused(Self.weekend), Self.school]), appName: "Loop", now: friday) + #expect(onePaused.hasPrefix("Next: Default starts Monday ")) + #expect(onePaused.contains("1 start paused.")) + #expect(onePaused.contains("Default starts again every Monday")) + + let allPaused = ScheduledSwitches.footerText(list([Self.paused(Self.weekend), Self.paused(Self.school)]), appName: "Trio", now: friday) + #expect(!allPaused.contains("Next:")) + #expect(allPaused.contains("All starts are paused.")) + + #expect(ScheduledSwitches.footerText(list([]), appName: "Trio", now: friday).hasPrefix("No scheduled switches.")) + #expect(ScheduledSwitches.holdText(list([Self.weekend, Self.school]), now: friday)?.hasPrefix("It stays active until Weekend starts Saturday") == true) + #expect(ScheduledSwitches.holdText(list([]), now: friday) == nil) + } + + @Test("deleting a profile names the scheduled switches that go with it, counted case-insensitively") + func deletionText() { + let weekendSunday = ScheduledProfileSwitch(id: "C4F1A2B3-5D6E-4F70-8A91-B2C3D4E5F6A7", profile: "weekend", weekday: 1, time: 25200) + let list = ScheduledSwitchList(entries: [Self.weekend, weekendSunday, Self.school], fingerprint: "", isComplete: true, timeZone: .current) + #expect(ScheduledSwitches.deletionText(list, profileName: "Weekend") + == "Weekend is used by 2 scheduled switches, which will be removed. At those times the profile active before stays active until the next scheduled start.") + #expect(ScheduledSwitches.deletionText(list, profileName: "Default")?.hasPrefix("Default is used by 1 scheduled switch, which will be removed.") == true) + #expect(ScheduledSwitches.deletionText(list, profileName: "Sport") == nil) + } + + // MARK: Pause, resume and the review diff + + @Test("pausing a start changes only that start's paused flag, and the diff lists it as paused") + func pauseOneRow() { + let old = [Self.weekend, Self.school] + let new = ScheduledSwitches.togglingPause(of: Self.weekend.id, in: old) + #expect(new[0] == Self.paused(Self.weekend)) + #expect(new[1] == Self.school) + let changes = ScheduledSwitchDiff.changes(from: old, to: new) + #expect(changes.map(\.kind) == [.paused]) + #expect(changes.first?.text.hasPrefix("Paused: Weekend starts Saturday") == true) + #expect(ScheduledProfileSwitch.fingerprint(new) != ScheduledProfileSwitch.fingerprint(old)) + + let resumed = ScheduledSwitches.togglingPause(of: Self.weekend.id, in: new) + #expect(resumed == old) + #expect(ScheduledSwitchDiff.changes(from: new, to: resumed).map(\.kind) == [.resumed]) + #expect(ScheduledSwitchDiff.newlyStartingProfiles(from: new, to: resumed) == ["Weekend"]) + #expect(ScheduledSwitchDiff.newlyStartingProfiles(from: old, to: new).isEmpty) + } + + @Test("the diff lists added, removed and changed starts by id, in week order") + func diff() { + var moved = Self.weekend + moved.time = 13 * 3600 + let added = ScheduledProfileSwitch(id: "NEW", profile: "Training day", weekday: 3, time: 21600) + let changes = ScheduledSwitchDiff.changes(from: [Self.weekend, Self.school], to: [added, moved]) + #expect(changes.map(\.kind) == [.added, .changed, .removed]) + #expect(changes[1].old == Self.weekend) + #expect(changes[1].new == moved) + #expect(changes[2].old == Self.school) + #expect(ScheduledSwitchDiff.changes(from: [Self.weekend], to: [Self.weekend]).isEmpty) + #expect(ScheduledSwitchDiff.newlyStartingProfiles(from: [Self.weekend, Self.school], to: [added, moved]) == ["Training day", "Weekend"]) + } + + // MARK: Commands + + @Test("the Loop command carries the list as a JSON array and the reviewed fingerprint") + func loopPayload() throws { + let entries = [Self.paused(Self.weekend), Self.school] + let json = LoopAPNSCommands.scheduledSwitchesSaveCommandPayload(entries: entries, baseFingerprint: "1f4d16404c846d2f", otp: "123456", + now: Date(timeIntervalSince1970: 1_700_000_000)) + #expect(Set(json.keys) == ["otp", "remote-address", "notes", "entered-by", "sent-at", "expiration", "alert", + "profile-scheduled-switches-save", "profile-scheduled-switches-base-fingerprint"]) + #expect(json["profile-scheduled-switches-base-fingerprint"] as? String == "1f4d16404c846d2f") + let body = try #require(try JSONSerialization.jsonObject(with: LoopAPNSCommands.apnsBody(json)) as? [String: Any]) + let list = try #require(body["profile-scheduled-switches-save"] as? [[String: Any]]) + #expect(list.count == 2) + #expect(list[0]["paused"] as? Bool == true) + #expect(list[1]["paused"] == nil) + #expect(list[1]["weekday"] as? Int == 2) + #expect(list[1]["time"] as? Int == 21600) + let decoded = try JSONDecoder().decode([ScheduledProfileSwitch].self, from: JSONSerialization.data(withJSONObject: list)) + #expect(decoded == entries) + + let empty = LoopAPNSCommands.scheduledSwitchesSaveCommandPayload(entries: [], baseFingerprint: "1f4d16404c846d2f", otp: "123456") + #expect((empty["profile-scheduled-switches-save"] as? [Any])?.isEmpty == true) + } + + @Test("the Trio command uses snake_case fields and its own collapse id") + func trioPayload() throws { + let payload = CommandPayload(user: "u", commandType: .saveScheduledSwitches, timestamp: 1_700_000_000, commandID: "CMD", + baseFingerprint: "1f4d16404c846d2f", scheduledSwitches: [Self.paused(Self.weekend), Self.school]) + let json = try #require(try JSONSerialization.jsonObject(with: JSONEncoder().encode(payload)) as? [String: Any]) + #expect(json["command_type"] as? String == "save_scheduled_switches") + #expect(json["base_fingerprint"] as? String == "1f4d16404c846d2f") + #expect(json["command_id"] as? String == "CMD") + #expect(json["profile_name"] == nil) + #expect(json["profile"] == nil) + let list = try #require(json["scheduled_switches"] as? [[String: Any]]) + #expect(list.map { $0["profile"] as? String } == ["Weekend", "Default"]) + #expect(list[0]["paused"] as? Bool == true) + #expect(payload.apnsCollapseID == "CMD") + #expect(TRCCommandType.saveScheduledSwitches.displayName == "Save Scheduled Switches") + } + + private static let returnInfo = ReturnNotificationInfo( + productionEnvironment: true, + deviceToken: String(repeating: "a", count: 64), + bundleId: "com.TEAMID1234.LoopFollow", + teamId: "TEAMID1234", + keyId: "KEYID12345", + apnsKey: "-----BEGIN PRIVATE KEY-----\n" + String(repeating: "A", count: 200) + "\n-----END PRIVATE KEY-----" + ) + + private static func loopSize(_ entries: [ScheduledProfileSwitch]) throws -> Int { + var payload = LoopAPNSCommands.scheduledSwitchesSaveCommandPayload(entries: entries, baseFingerprint: "1f4d16404c846d2f", otp: "123456") + payload["encrypted_return_notification"] = try #require(OTPSecureMessenger(otpCode: "123456")).encrypt(returnInfo) + return try LoopAPNSCommands.apnsBody(payload).count + } + + private static func trioSize(_ entries: [ScheduledProfileSwitch]) throws -> Int { + let payload = CommandPayload(user: "Caregiver", commandType: .saveScheduledSwitches, timestamp: Date().timeIntervalSince1970, commandID: UUID().uuidString, + baseFingerprint: "1f4d16404c846d2f", scheduledSwitches: entries, returnNotification: returnInfo) + let messenger = try #require(SecureMessenger(sharedSecret: "secret")) + return try JSONEncoder().encode(EncryptedPushMessage(encryptedData: messenger.encrypt(payload), commandType: .saveScheduledSwitches)).count + } + + @Test("14 paused starts with ordinary names fit one push for Loop and Trio; extreme names are refused by size") + func payloadSize() throws { + func week(_ name: String) -> [ScheduledProfileSwitch] { + (0 ..< ScheduledProfileSwitch.maxCount).map { ScheduledProfileSwitch(profile: name, weekday: $0 % 7 + 1, time: ($0 / 7) * 43200 + 1380 * 60, paused: true) } + } + let ordinary = week(String(repeating: "W", count: 20)) + #expect(try Self.loopSize(ordinary) <= APNSDelivery.maxPayloadBytes) + #expect(try Self.trioSize(ordinary) <= ProfilePushSize.reviewLimit(for: .trio)) + + let extreme = week(String(repeating: "👨‍👩‍👧‍👦", count: ProfileValidation.maxNameLength)) + #expect(try Self.loopSize(extreme) > APNSDelivery.maxPayloadBytes) + #expect(try Self.trioSize(extreme) > APNSDelivery.maxPayloadBytes) + #expect(ScheduledSwitchesPushSize.tooLargeMessage(5000).contains("too large to send remotely")) + } + + // MARK: Replies + + private static func loopReply(status: String, result: String?, body: String) -> [AnyHashable: Any] { + var userInfo: [AnyHashable: Any] = ["command_type": "profile_scheduled_switches_save", "command_status": status, "aps": ["alert": ["body": body]]] + if let result { userInfo["result"] = result } + return userInfo + } + + @Test("Loop replies resolve the pending scheduled switches command by their result code") + func loopReplies() { + let key = ProfileRemote.loop.key(for: .saveScheduledSwitches([], baseFingerprint: "x")) + #expect(key == "loop-profile-scheduled-switches-save:scheduled-switches") + #expect(ProfileRemote.isProfileCommandKey(key)) + let cases: [(String, String?, String, Bool, Bool)] = [ + ("success", "saved", "Scheduled switches saved", true, false), + ("failed", "conflict", "Scheduled switches not saved: The scheduled switches changed on the phone.", false, true), + ("failed", "not_found", "Scheduled switches not saved: No profile named Holiday.", false, true), + ("failed", "rejected", "Scheduled switches not saved: There can be at most 14 scheduled switches.", false, false), + ("success", nil, "Scheduled switches saved", true, false), + ("failed", nil, "Scheduled switches not saved: expired", false, false), + ] + for (status, result, body, success, outdated) in cases { + let tracker = RemoteCommandTracker(timeout: 60) + tracker.begin(key: key) + #expect(tracker.handleNotification(userInfo: Self.loopReply(status: status, result: result, body: body))) + #expect(tracker.states[key] == .done(success: success, message: body, outdated: outdated)) + } + // A profile save reply does not resolve it. + let tracker = RemoteCommandTracker(timeout: 60) + tracker.begin(key: key) + #expect(tracker.handleNotification(userInfo: ["command_type": "profile_save", "command_status": "success", "aps": ["alert": ["body": "Profile 'A' saved"]]]) == false) + #expect(tracker.isBusy(key: key)) + #expect(tracker.isBusy(keyPrefixes: ProfileRemote.loop.keyPrefixes)) + } + + @Test("Trio replies resolve by command id, with the Loop texts when the reply has no body") + func trioReplies() { + let command = ProfileCommand.saveScheduledSwitches([Self.weekend], baseFingerprint: "x") + let key = ProfileRemote.trio.key(for: command) + #expect(key == "trio-profile-scheduled-switches-save:scheduled-switches") + for (status, result, message, outdated) in [ + ("success", "saved", "Scheduled switches saved", false), + ("failed", "conflict", "Scheduled switches not saved", true), + ("failed", "rejected", "Scheduled switches not saved", false), + ] { + let tracker = RemoteCommandTracker(timeout: 60) + tracker.begin(key: key) + tracker.trioAcks.register(commandID: "CMD", key: key, successMessage: ProfileRemote.trio.successMessage(command), failureMessage: ProfileRemote.trio.failureMessage(command)) + #expect(tracker.handleNotification(userInfo: ["command_status": status, "command_id": "CMD", "command_type": "save_scheduled_switches", "result": result])) + #expect(tracker.states[key] == .done(success: status == "success", message: message, outdated: outdated)) + } + #expect(ProfileCommandFlow.resultTitle(kind: .saveScheduledSwitches, success: true, message: "", remote: .trio) == "Scheduled Switches Saved") + #expect(ProfileCommandFlow.resultTitle(kind: .saveScheduledSwitches, success: false, message: "", remote: .trio) == "Scheduled Switches Not Saved") + } + + // MARK: Before sending and after the reply + + private static func profileSet(_ entries: [ScheduledProfileSwitch], profiles: [String] = ["Default", "Weekend"]) throws -> TherapyProfileSet { + let store = try TherapyProfileTests.store(units: "mg/dL", sens: 50, low: 100, high: 110, basal: [(0, 1.0)]) + return TherapyProfileSet( + profiles: profiles.map { TherapyProfile(name: $0, store: store) }, + activeName: "Default", + uploadedAt: nil, + scheduledSwitches: ScheduledSwitchList(entries: entries, fingerprint: ScheduledProfileSwitch.fingerprint(entries), isComplete: true, timeZone: .current) + ) + } + + @Test("a newly starting profile that gives more insulin than Default is found also when its units differ") + func moreInsulinAcrossUnits() throws { + let base = try TherapyProfile(name: "Default", store: TherapyProfileTests.store(units: "mg/dL", sens: 50, low: 100, high: 110, basal: [(0, 1.0)])) + let stronger = try TherapyProfile(name: "Weekend", store: TherapyProfileTests.store(units: "mmol/L", sens: 2.0, low: 5.6, high: 6.2, basal: [(0, 1.0)])) + let same = try TherapyProfile(name: "Weekend", store: TherapyProfileTests.store(units: "mmol/L", sens: 2.8, low: 5.6, high: 6.2, basal: [(0, 1.0)])) + + let found = ScheduledSwitchDiff.moreInsulinProfiles(from: [], to: [Self.weekend], profiles: [base, stronger]) + #expect(found.map(\.profile.name) == ["Weekend"]) + #expect(found.first?.increases.map(\.kind).contains(.sensitivity) == true) + #expect(ScheduledSwitchDiff.moreInsulinProfiles(from: [], to: [Self.weekend], profiles: [base, same]).isEmpty) + #expect(ScheduledSwitchDiff.moreInsulinProfiles(from: [Self.weekend], to: [Self.weekend], profiles: [base, stronger]).isEmpty) + } + + @Test("a list is not sent when the phone's list or its profiles changed since the review") + func staleBeforeSending() throws { + let reviewed = [Self.weekend, Self.school] + let base = ScheduledProfileSwitch.fingerprint(reviewed) + let new = ScheduledSwitches.togglingPause(of: Self.weekend.id, in: reviewed) + #expect(try ScheduledSwitchesReviewView.staleReason(baseFingerprint: base, entries: new, current: Self.profileSet(reviewed), remote: .loop) == nil) + let changed = try Self.profileSet([Self.weekend]) + #expect(ScheduledSwitchesReviewView.staleReason(baseFingerprint: base, entries: new, current: changed, remote: .loop)?.contains("changed in Loop while you were editing") == true) + let deleted = try Self.profileSet(reviewed, profiles: ["Default"]) + #expect(ScheduledSwitchesReviewView.staleReason(baseFingerprint: base, entries: new, current: deleted, remote: .trio)?.contains("“Weekend” is no longer") == true) + #expect(ScheduledSwitchesReviewView.staleReason(baseFingerprint: base, entries: new, current: nil, remote: .trio)?.contains("could not load") == true) + } + + @Test("the follow-up fetches stop once the uploaded list is no longer the one replaced") + func refreshStops() throws { + let old = [Self.weekend, Self.school] + let command = ProfileCommand.saveScheduledSwitches(ScheduledSwitches.togglingPause(of: Self.weekend.id, in: old), baseFingerprint: ScheduledProfileSwitch.fingerprint(old)) + let before = try Self.profileSet(old) + #expect(!RemoteCommandRefresh.profileArrived(command, before: before, now: before)) + let after = try Self.profileSet(ScheduledSwitches.togglingPause(of: Self.weekend.id, in: old)) + #expect(RemoteCommandRefresh.profileArrived(command, before: before, now: after)) + #expect(!RemoteCommandRefresh.profileArrived(command, before: before, now: nil)) + } + + @Test("the scheduled switches command is gated on the name each app lists") + func gating() { + #expect(ProfileRemote.loop.supports(.saveScheduledSwitches, in: ["profile-scheduled-switches-save"])) + #expect(!ProfileRemote.loop.supports(.saveScheduledSwitches, in: ["profile-switch", "profile-save", "profile-delete"])) + #expect(ProfileRemote.trio.supports(.saveScheduledSwitches, in: ["save_scheduled_switches"])) + #expect(!ProfileRemote.trio.supports(.saveScheduledSwitches, in: ["switch_profile", "save_profile", "delete_profile", "profile-scheduled-switches-save"])) + } +} diff --git a/Tests/TherapyProfileTests.swift b/Tests/TherapyProfileTests.swift new file mode 100644 index 000000000..b5d92d937 --- /dev/null +++ b/Tests/TherapyProfileTests.swift @@ -0,0 +1,310 @@ +// LoopFollow +// TherapyProfileTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct TherapyProfileTests { + static func store(units: String, sens: Double, low: Double, high: Double, basal: [(Int, Double)] = [(0, 0.85), (21600, 1.1)]) throws -> NSProfile.Store { + let basalJSON = basal.map { "{\"time\": \"x\", \"value\": \($0.1), \"timeAsSeconds\": \($0.0)}" }.joined(separator: ",") + let json = """ + {"timezone": "ETC/GMT-1", "units": "\(units)", + "basal": [\(basalJSON)], + "carbratio": [{"time": "00:00", "value": 10, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": \(sens), "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": \(low), "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": \(high), "timeAsSeconds": 0}]} + """ + return try JSONDecoder().decode(NSProfile.Store.self, from: Data(json.utf8)) + } + + static func document(_ store: [String: NSProfile.Store], active: String, limits: ProfileEditLimits? = nil) -> NSProfile { + NSProfile(store: store, defaultProfile: active, units: "mg/dL", + bundleIdentifier: nil, isAPNSProduction: nil, deviceToken: nil, teamID: nil, expirationDate: nil, + startDate: nil, createdAt: nil, remoteCommands: nil, trioOverrides: nil, loopSettings: nil, + profileEditLimits: LenientDecoded(limits), scheduledSwitches: nil, scheduledSwitchesFingerprint: nil) + } + + @Test("Loop lists its profile commands in its profile document") + func loopProfileCommandsFromDocument() throws { + let json = """ + {"defaultProfile": "Default", "units": "mg/dL", "store": {}, "enteredBy": "Loop", + "remoteCommands": ["profile-switch", "profile-save", "profile-delete", "profile-scheduled-switches-save"]} + """ + let document = try JSONDecoder().decode(NSProfile.self, from: Data(json.utf8)) + let commands = try #require(document.remoteCommands) + for kind in ProfileCommandKind.allCases { + #expect(ProfileRemote.loop.supports(kind, in: commands)) + } + #expect(!ProfileRemote.trio.supportsAny(in: commands)) + #expect(!ProfileRemote.loop.supportsAny(in: [])) + } + + @Test("a Loop with the Profile Save & Load customization accepts switches it lists in its device status") + func loopCustomizationSwitchFromDeviceStatus() { + let commands = ProfileRemote.loop.acceptedCommands(profileDocument: [], loopDeviceStatus: ["carbs-delete", "profile-switch"]) + #expect(ProfileRemote.loop.supports(.activate, in: commands)) + #expect(!ProfileRemote.loop.supports(.save, in: commands)) + #expect(!ProfileRemote.loop.supports(.delete, in: commands)) + #expect(ProfileRemote.loop.acceptedCommands(profileDocument: [], loopDeviceStatus: []).isEmpty) + #expect(ProfileRemote.trio.acceptedCommands(profileDocument: [], loopDeviceStatus: ["profile-switch"]).isEmpty) + // A Loop that lists commands in both places is used through its profile document. + let document = ["profile-switch", "profile-save", "profile-delete"] + #expect(ProfileRemote.loop.acceptedCommands(profileDocument: document, loopDeviceStatus: ["profile-switch"]) == document) + #expect(ProfileRemote.loop.acceptedCommands(profileDocument: ["profile-switch"], loopDeviceStatus: ["profile-switch"]) == ["profile-switch"]) + } + + @Test("a document saved by the Nightscout profile editor, whose entries have no units, still loads") + func entriesWithoutUnitsUseTheDocumentUnits() throws { + let json = """ + {"defaultProfile": "Weekend", "units": "mmol", "enteredBy": "Trio", + "store": { + "default": {"timezone": "Europe/Stockholm", "dia": 6, "startDate": "1970-01-01T00:00:00.000Z", + "basal": [{"time": "00:00", "value": 1.0, "timeAsSeconds": 0}], + "carbratio": [{"time": "00:00", "value": 10, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": 2.5, "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": 5.6, "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": 5.6, "timeAsSeconds": 0}]}, + "Weekend": {"timezone": "Europe/Stockholm", "dia": 6, "units": "mmol", "delay": 20, + "basal": [{"time": "00:00", "value": 1.1, "timeAsSeconds": 0}], + "carbratio": [{"time": "00:00", "value": 11, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": 3.1, "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": 5.8, "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": 5.8, "timeAsSeconds": 0}]}}} + """ + let document = try JSONDecoder().decode(NSProfile.self, from: Data(json.utf8)) + let set = TherapyProfileSet(document) + #expect(set.activeProfile?.name == "Weekend") + #expect(set.profiles.map(\.values.units) == ["mmol/L", "mmol/L"]) + } + + @Test("a store entry that can not be read leaves the other profiles readable") + func unreadableEntryIsLeftOut() throws { + let json = """ + {"defaultProfile": "Default", "units": "mg/dL", "startDate": 1793527200000, + "store": { + "Default": {"timezone": "UTC", "units": "mg/dL", + "basal": [{"time": "00:00", "value": 1.0, "timeAsSeconds": 0}], + "carbratio": [{"time": "00:00", "value": 10, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": 50, "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": 100, "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": 110, "timeAsSeconds": 0}]}, + "Broken": {"timezone": "UTC", "units": "mg/dL", "basal": [{"time": "00:00", "value": "1.0"}]}}} + """ + let document = try JSONDecoder().decode(NSProfile.self, from: Data(json.utf8)) + #expect(document.store.keys.sorted() == ["Default"]) + #expect(document.startDate == nil) + } + + @Test("the shared test vector gives the contract's canonical string and hash") + func contractVector() throws { + let basal = [ProfileScheduleItem(seconds: 0, value: 0.85), ProfileScheduleItem(seconds: 21600, value: 1.0)] + let carbRatio = [ProfileScheduleItem(seconds: 0, value: 10)] + let sens = [ProfileScheduleItem(seconds: 0, value: 50)] + let canonical = TherapyProfileValues.canonicalString( + basal: basal, carbRatio: carbRatio, sensitivity: sens, + targetLow: [ProfileScheduleItem(seconds: 0, value: 100)], targetHigh: [ProfileScheduleItem(seconds: 0, value: 110)] + ) + #expect(canonical == "basal=0:0.850,21600:1.000;carbratio=0:10.000;sens=0:50.000;target_low=0:100.000;target_high=0:110.000") + let values = TherapyProfileValues(units: "mg/dL", basal: basal, carbRatio: carbRatio, sensitivity: sens, target: [ProfileRangeItem(seconds: 0, low: 100, high: 110)]) + #expect(values.fingerprint == "3e603478d512da04") + let profile = try TherapyProfile(name: "Vector", store: Self.store(units: "mg/dL", sens: 50, low: 100, high: 110, basal: [(0, 0.85), (21600, 1.0)])) + #expect(profile.fingerprint == "3e603478d512da04") + #expect(profile.values.fingerprint == profile.fingerprint) + } + + @Test("fingerprint matches the value Loop computes for the same store entry") + func fingerprint() throws { + let mgdl = try TherapyProfile(name: "Sport", store: Self.store(units: "mg/dL", sens: 45, low: 100, high: 110)) + #expect(mgdl.fingerprint == "fec4f066b5a54ecd") + let mmol = try TherapyProfile(name: "Sport", store: Self.store(units: "mmol/L", sens: 2.497_646_904_752_9, low: 5.550_747_991_045_533, high: 6.105_822_790_150_087)) + #expect(mmol.fingerprint == "f7d9520ff02af97c") + #expect(mmol.values.units == "mmol/L") + } + + @Test("basal total weights each rate by how long it runs") + func basalTotal() throws { + let profile = try TherapyProfile(name: "Sport", store: Self.store(units: "mg/dL", sens: 45, low: 100, high: 110)) + #expect(abs(profile.basalTotal - (0.85 * 6 + 1.1 * 18)) < 0.000_1) + #expect(profile.correctionRange == [ProfileRangeItem(seconds: 0, low: 100, high: 110)]) + #expect(profile.isWellFormed) + } + + @Test("Default is listed first and is the active profile when defaultProfile names it in any case") + func defaultProfile() throws { + let entry = try Self.store(units: "mg/dL", sens: 45, low: 100, high: 110) + let loop = TherapyProfileSet(Self.document(["Default": entry, "Work": entry, "Sport": entry], active: "Work")) + #expect(loop.profiles.map(\.name) == ["Default", "Sport", "Work"]) + #expect(loop.activeName == "Work") + #expect(loop.activeProfile?.name == "Work") + #expect(loop.profiles[0].isDefault) + + let trio = TherapyProfileSet(Self.document(["default": entry, "school": entry], active: "default")) + #expect(trio.profiles.map(\.name) == ["Default", "school"]) + #expect(trio.profiles[0].isDefault) + #expect(trio.activeProfile?.name == "Default") + #expect(TherapyProfile.isDefaultName("DEFAULT")) + #expect(!TherapyProfile.isDefaultName("Defaults")) + } + + @Test("a profile without a correction range is read-only") + func missingRange() throws { + let json = """ + {"timezone": "UTC", "units": "mg/dL", + "basal": [{"time": "00:00", "value": 1, "timeAsSeconds": 0}], + "carbratio": [{"time": "00:00", "value": 10, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": 50, "timeAsSeconds": 0}]} + """ + let profile = try TherapyProfile(name: "X", store: JSONDecoder().decode(NSProfile.Store.self, from: Data(json.utf8))) + #expect(!profile.isWellFormed) + #expect(try ProfileEditing.blocker(for: profile, limits: ProfileEditLimitsTests.limits(), remote: .loop) != nil) + } + + @Test("a profile in mmol/L can not be edited against limits in mg/dL") + func unitMismatch() throws { + let limits = try ProfileEditLimitsTests.limits() + let mmol = try TherapyProfile(name: "Sport", store: Self.store(units: "mmol", sens: 2.5, low: 5.5, high: 6.1)) + let blocker = try #require(ProfileEditing.blocker(for: mmol, limits: limits, remote: .trio)) + #expect(blocker.contains("mmol/L")) + let mgdl = try TherapyProfile(name: "Sport", store: Self.store(units: "mg/dL", sens: 45, low: 100, high: 110)) + #expect(ProfileEditing.blocker(for: mgdl, limits: limits, remote: .trio) == nil) + } + + @Test("mismatched correction range lows and highs make the profile read-only") + func malformedRange() throws { + let json = """ + {"timezone": "UTC", "units": "mg/dl", + "basal": [{"time": "00:00", "value": 1, "timeAsSeconds": 0}], + "carbratio": [{"time": "00:00", "value": 10, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": 50, "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": 100, "timeAsSeconds": 0}, {"time": "06:00", "value": 90, "timeAsSeconds": 21600}], + "target_high": [{"time": "00:00", "value": 110, "timeAsSeconds": 0}]} + """ + let store = try JSONDecoder().decode(NSProfile.Store.self, from: Data(json.utf8)) + let profile = TherapyProfile(name: "X", store: store) + #expect(!profile.isWellFormed) + #expect(profile.values.units == "mg/dL") + let limits = try ProfileEditLimitsTests.limits() + #expect(ProfileEditing.blocker(for: profile, limits: limits, remote: .loop) != nil) + } + + @Test("follow-up fetches after a profile command stop once its result is in the profile document") + func profileCommandArrival() throws { + let base = try Self.store(units: "mg/dL", sens: 45, low: 100, high: 110) + let changed = try Self.store(units: "mg/dL", sens: 50, low: 100, high: 110) + let before = TherapyProfileSet(Self.document(["Default": base, "Sport": base], active: "Default")) + let sport = try #require(before.profile(named: "Sport")) + func arrived(_ command: ProfileCommand, _ now: [String: NSProfile.Store], active: String = "Default") -> Bool { + RemoteCommandRefresh.profileArrived(command, before: before, now: TherapyProfileSet(Self.document(now, active: active))) + } + + #expect(!RemoteCommandRefresh.profileArrived(.activate(sport), before: before, now: nil)) + #expect(!arrived(.activate(sport), ["Default": base, "Sport": base])) + #expect(arrived(.activate(sport), ["Default": base, "Sport": base], active: "sport")) + + #expect(!arrived(.delete(sport), ["Default": base, "Sport": base])) + #expect(arrived(.delete(sport), ["Default": base])) + + let edit = ProfileCommand.save(name: "Sport", original: sport, values: sport.values) + #expect(!arrived(edit, ["Default": base, "Sport": base])) + #expect(arrived(edit, ["Default": base, "Sport": changed])) + + let rename = ProfileCommand.save(name: "Gym", original: sport, values: sport.values) + #expect(!arrived(rename, ["Default": base, "Sport": base])) + #expect(arrived(rename, ["Default": base, "Gym": base])) + } + + @Test("schedule start times show as the looper's wall-clock times, whatever this phone's time zone") + func startTimesIgnoreThisPhonesTimeZone() throws { + let starts = Array(stride(from: 0, to: 86400, by: 1800)) + let utc = DateFormatter() + utc.timeStyle = .short + utc.timeZone = try #require(TimeZone(identifier: "UTC")) + let texts = starts.map(ProfileValueFormat.timeText) + #expect(texts == starts.map { utc.string(from: Date(timeIntervalSince1970: TimeInterval($0))) }) + #expect(Set(texts).count == starts.count) + } + + @Test("Glucose values in mmol/L show one decimal; other values keep theirs") + func mmolGlucoseOneDecimal() { + func local(_ text: String) -> String { + text.replacingOccurrences(of: ".", with: Locale.current.decimalSeparator ?? ".") + } + let range = EditableSegment(seconds: 0, value: 5.551, high: 6.106) + #expect(ProfileValueFormat.segmentText(range, kind: .correctionRange, limits: nil, glucoseUnits: "mmol/L") == local("5.6 – 6.1")) + #expect(ProfileValueFormat.text(2.498, kind: .sensitivity, limits: nil, glucoseUnits: "mmol") == local("2.5")) + #expect(ProfileValueFormat.text(100, kind: .correctionRange, limits: nil, glucoseUnits: "mg/dL") == "100") + #expect(ProfileValueFormat.text(0.075, kind: .basal, limits: nil, glucoseUnits: "mmol/L") == local("0.075")) + } + + @Test("Trio profiles carry one target per time: the draft sends high equal to low") + func trioSingleTarget() throws { + let values = TherapyProfileValues( + units: "mg/dL", + basal: [ProfileScheduleItem(seconds: 0, value: 0.85)], + carbRatio: [ProfileScheduleItem(seconds: 0, value: 10)], + sensitivity: [ProfileScheduleItem(seconds: 0, value: 50)], + target: [ProfileRangeItem(seconds: 0, low: 100, high: 110)] + ) + #expect(ProfileDraft(values).values.target == [ProfileRangeItem(seconds: 0, low: 100, high: 110)]) + let trio = ProfileDraft(values, singleTarget: true) + #expect(trio.values.target == [ProfileRangeItem(seconds: 0, low: 100, high: 100)]) + #expect(ProfileValueFormat.segmentText(trio.target[0], kind: .correctionRange, limits: nil, glucoseUnits: "mg/dL", singleTarget: true) == "100") + #expect(ProfileRemote.trio.hasSingleTarget) + #expect(!ProfileRemote.loop.hasSingleTarget) + + // The fingerprint LoopFollow computes from the values it sends equals the one of the store entry Trio uploads. + let sent = trio.values + let uploaded = try TherapyProfile(name: "Sport", store: Self.store(units: "mg/dL", sens: 50, low: 100, high: 100, basal: [(0, 0.85)]), documentUnits: "mg/dL") + #expect(sent.fingerprint == uploaded.fingerprint) + } + + @Test("a save is refused when the profile changed or its active state differs from the review") + func saveStaleReason() throws { + let sport = try Self.store(units: "mg/dL", sens: 50, low: 100, high: 110) + let changed = try Self.store(units: "mg/dL", sens: 55, low: 100, high: 110) + let reviewed = TherapyProfileSet(Self.document(["Default": sport, "Sport": sport], active: "Default")) + let original = try #require(reviewed.profile(named: "Sport")) + func reason(_ store: [String: NSProfile.Store]?, active: String = "Default", reviewedActive: Bool = false) -> String? { + ProfileCommandFlow.saveStaleReason( + original: original, + reviewedActive: reviewedActive, + current: store.map { TherapyProfileSet(Self.document($0, active: active)) }, + remote: .loop + ) + } + #expect(reason(["Default": sport, "Sport": sport]) == nil) + #expect(reason(["Default": sport, "Sport": sport], active: "Sport", reviewedActive: true) == nil) + #expect(reason(["Default": sport, "Sport": sport], active: "Sport")?.contains("became active") == true) + #expect(reason(["Default": sport, "Sport": sport], reviewedActive: true)?.contains("no longer the active profile") == true) + #expect(reason(["Default": sport, "Sport": changed])?.contains("changed") == true) + #expect(reason(["Default": sport])?.contains("no longer in the list") == true) + #expect(reason(nil)?.contains("could not load") == true) + } + + @Test("a rename of the active profile is checked as active and keeps the values") + func renameActiveProfile() throws { + let sport = try Self.store(units: "mg/dL", sens: 50, low: 100, high: 110) + let profileSet = TherapyProfileSet(Self.document(["Default": sport, "Sport": sport], active: "Sport")) + let original = try #require(profileSet.profile(named: "Sport")) + let rename = ProfileCommand.save(name: "Sport 2", original: original, values: original.values) + #expect(rename.isRename) + #expect(!ProfileCommand.save(name: "Sport", original: original, values: original.values).isRename) + #expect(!ProfileCommand.save(name: "Sport 2", original: nil, values: original.values).isRename) + #expect(ProfileCommandFlow.saveStaleReason(original: original, reviewedActive: profileSet.isActive(original), current: profileSet, remote: .loop) == nil) + } + + @Test("profile command keys are logged as their command kind, without the profile name") + func profileKeysLogWithoutName() { + #expect(RemoteCommandTracker.logKey(ProfileRemote.trio.key(.save, name: "Sport")) == "trio-profile-save") + #expect(RemoteCommandTracker.logKey(ProfileRemote.loop.key(.activate, name: "A:B")) == "loop-profile-switch") + #expect(RemoteCommandTracker.logKey("meal-1234567890") == LogRedactor.tail("meal-1234567890")) + } + + @Test("Trio's measured push size keeps a margin for the nonce and timestamp of the send") + func trioPushSizeMargin() { + #expect(ProfilePushSize.reviewLimit(for: .loop) == APNSDelivery.maxPayloadBytes) + #expect(ProfilePushSize.reviewLimit(for: .trio) == APNSDelivery.maxPayloadBytes - 64) + } +}