diff --git a/LoopFollow/Controllers/Nightscout/ProfileManager.swift b/LoopFollow/Controllers/Nightscout/ProfileManager.swift index 13134f8b6..57010f961 100644 --- a/LoopFollow/Controllers/Nightscout/ProfileManager.swift +++ b/LoopFollow/Controllers/Nightscout/ProfileManager.swift @@ -123,6 +123,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)) } func currentISF() -> HKQuantity? { @@ -227,5 +228,6 @@ final class ProfileManager { units = .millimolesPerLiter timezone = TimeZone.current defaultProfile = "" + Observable.shared.loopProfiles.set(nil) } } diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift index 73de2aae4..aedeb8e3e 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 loopRemoteCommands = Storage.shared.loopRemoteCommands var body: some View { NavigationView { @@ -22,6 +23,16 @@ 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()) + } 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." + ) + } } .padding(.horizontal) } else { diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 0bd67521a..2938affba 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -92,7 +92,7 @@ class LoopAPNSService { return hasFullSetup } - private static let returnNotificationRequiredMessage = "Editing or deleting a carb entry needs LoopFollow's own APNS credentials so Loop can confirm the result. Configure them in App Settings → APN." + private static let returnNotificationRequiredMessage = "This command needs LoopFollow's own APNS credentials so Loop can confirm the result. Configure them in App Settings → APN." /// The return-notification block encrypted with the OTP, when LoopFollow's own credentials are configured. private func encryptedReturnNotification(otp: String) -> String? { @@ -161,6 +161,12 @@ class LoopAPNSService { sendConfirmedCommand(payload, otp: otp, completion: completion) } + /// 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)") + sendConfirmedCommand(LoopAPNSCommands.profileSwitchCommandPayload(name: name, fingerprint: fingerprint, otp: otp), otp: otp, completion: completion) + } + /// 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 new file mode 100644 index 000000000..99b9c7a5b --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/LoopProfile.swift @@ -0,0 +1,108 @@ +// 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 new file mode 100644 index 000000000..05733b530 --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/LoopProfilesView.swift @@ -0,0 +1,272 @@ +// 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/RemoteCommandTracker.swift b/LoopFollow/Remote/RemoteCommandTracker.swift index 1870812ec..be772414b 100644 --- a/LoopFollow/Remote/RemoteCommandTracker.swift +++ b/LoopFollow/Remote/RemoteCommandTracker.swift @@ -4,7 +4,7 @@ import Foundation extension Notification.Name { - /// Posted when a remote meal edit/delete reaches a terminal state, so treatment lists can refresh. + /// Posted when a tracked remote command reaches a terminal state, with its key in `userInfo["key"]`, so lists can refresh. static let remoteMealCommandDidComplete = Notification.Name("LoopFollow.remoteMealCommandDidComplete") } @@ -40,15 +40,23 @@ final class RemoteCommandTracker: ObservableObject { return false } - func begin(key: String) { + func begin(key: String, timeout: TimeInterval? = nil, timeoutMessage: String = RemoteCommandTracker.timeoutMessage) { onMain { self.timeouts[key]?.cancel() self.states[key] = .pending(since: Date()) let work = DispatchWorkItem { [weak self] in - self?.resolve(key: key, success: false, message: Self.timeoutMessage) + self?.resolve(key: key, success: false, message: timeoutMessage) } self.timeouts[key] = work - DispatchQueue.main.asyncAfter(deadline: .now() + self.timeout, execute: work) + DispatchQueue.main.asyncAfter(deadline: .now() + (timeout ?? self.timeout), execute: work) + } + } + + /// True while any command whose key starts with `prefix` waits for its reply. + func isBusy(keyPrefix prefix: String) -> Bool { + states.contains { key, state in + if case .pending = state { return key.hasPrefix(prefix) } + return false } } @@ -73,7 +81,10 @@ final class RemoteCommandTracker: ObservableObject { /// Returns true when the push resolved a pending command. @discardableResult func handleNotification(userInfo: [AnyHashable: Any]) -> Bool { - guard let resolution = trioAcks.resolution(for: userInfo) ?? LoopCarbAckAdapter.resolution(for: userInfo) else { return false } + guard let resolution = trioAcks.resolution(for: userInfo) + ?? LoopCarbAckAdapter.resolution(for: userInfo) + ?? LoopProfileAckAdapter.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") return false @@ -148,6 +159,34 @@ enum LoopCarbAckAdapter { } } +/// Matches Loop's return push for a profile switch by the profile name in its message +/// ("Profile '' activated" or "... Profile '' not activated: "). +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 func resolution(for userInfo: [AnyHashable: Any]) -> RemoteCommandTracker.Resolution? { + guard userInfo["command_type"] as? String == "profile_switch", + let status = userInfo["command_status"] as? String, + let body = alertBody(userInfo), + let name = profileName(in: body) + 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) + } + + static func profileName(in message: String) -> String? { + guard let match = message.firstMatch(of: /Profile '(.+)' (?:not )?activated/) else { return nil } + return String(match.1) + } +} + private func alertBody(_ userInfo: [AnyHashable: Any]) -> String? { let alert = (userInfo["aps"] as? [String: Any])?["alert"] as? [String: Any] guard let body = alert?["body"] as? String, !body.isEmpty else { return nil } @@ -223,6 +262,15 @@ 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) + } + } + private func loopOTP(key: String, completion: (Bool, String?) -> Void) -> String? { let qrCodeURL = Storage.shared.loopAPNSQrCodeURL.value let otp = TOTPGenerator.extractOTPFromURL(qrCodeURL) @@ -244,7 +292,7 @@ extension RemoteCommandTracker { DispatchQueue.main.async { if success { TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) - LogManager.shared.log(category: .apns, message: "Carb command sent for syncIdentifier=\(LogRedactor.tail(key))") + LogManager.shared.log(category: .apns, message: "Loop command sent for \(LogRedactor.tail(key))") } else { self.resolve(key: key, success: false, message: error ?? "The command could not be sent.") } diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 645703b6a..13f210f50 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -55,6 +55,9 @@ class Observable { var loopFollowDeviceToken = ObservableValue(default: "") + /// Saved profiles from the latest Loop profile upload; nil until one is loaded. + var loopProfiles = ObservableValue(default: nil) + var isNotLooping = ObservableValue(default: false) /// CGM state reported after the newest reading; nil while readings flow. diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 40c8659be..e94f9be7e 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -692,8 +692,13 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { runMigrationsIfNeeded() } - @objc func handleRemoteMealCommandDidComplete() { - TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) + @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)) + } else { + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) + } } @objc func handleBFUReloadCompleted() { diff --git a/RemoteShared/LoopAPNSCommands.swift b/RemoteShared/LoopAPNSCommands.swift index 0473df59e..e709e0989 100644 --- a/RemoteShared/LoopAPNSCommands.swift +++ b/RemoteShared/LoopAPNSCommands.swift @@ -70,6 +70,14 @@ enum LoopAPNSCommands { return fields } + /// Keys are prefixed so an unpatched Loop rejects the command. The fingerprint identifies the previewed profile values. + static func profileSwitchCommandPayload(name: String, fingerprint: String, otp: String, now: Date = Date()) -> [String: Any] { + var fields = commandHeader(otp: otp, alert: "Remote Profile Switch: \(name)", now: now) + fields["profile-switch"] = name + fields["profile-switch-fingerprint"] = fingerprint + 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" diff --git a/Tests/LoopAPNSCommandPayloadTests.swift b/Tests/LoopAPNSCommandPayloadTests.swift index 374509bc6..2040bb127 100644 --- a/Tests/LoopAPNSCommandPayloadTests.swift +++ b/Tests/LoopAPNSCommandPayloadTests.swift @@ -66,4 +66,13 @@ struct LoopAPNSCommandPayloadTests { ) #expect(withFood["carbs-edit-food-type"] as? String == "🍕") } + + @Test("profile switch names the profile and carries its fingerprint") + func profileSwitchKeys() { + let json = LoopAPNSCommands.profileSwitchCommandPayload(name: "Sport", fingerprint: "fec4f066b5a54ecd", otp: "123456", now: now) + #expect(Set(json.keys) == headerKeys.union(["profile-switch", "profile-switch-fingerprint"])) + #expect(json["profile-switch"] as? String == "Sport") + #expect(json["profile-switch-fingerprint"] as? String == "fec4f066b5a54ecd") + #expect(json["alert"] as? String == "Remote Profile Switch: Sport") + } } diff --git a/Tests/LoopProfileTests.swift b/Tests/LoopProfileTests.swift new file mode 100644 index 000000000..5a70f0941 --- /dev/null +++ b/Tests/LoopProfileTests.swift @@ -0,0 +1,46 @@ +// 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/RemoteCommandTrackerTests.swift b/Tests/RemoteCommandTrackerTests.swift index a7ec75dd2..5381ec1d0 100644 --- a/Tests/RemoteCommandTrackerTests.swift +++ b/Tests/RemoteCommandTrackerTests.swift @@ -82,4 +82,27 @@ struct RemoteCommandTrackerTests { #expect(LoopCarbAckAdapter.resolution(for: ["command_type": "meal", "command_status": "success", "sync_identifier": "SYNC-1"]) == nil) #expect(LoopCarbAckAdapter.resolution(for: ["command_type": "carbs_delete", "command_status": "failed"]) == nil) } + + @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") + tracker.begin(key: sickDay) + let lateSport: [AnyHashable: Any] = [ + "command_type": "profile_switch", + "command_status": "success", + "aps": ["alert": ["title": "Command Successful", "body": "Profile 'Sport' activated"]], + ] + #expect(tracker.handleNotification(userInfo: lateSport) == false) + #expect(tracker.isBusy(key: sickDay)) + let failed: [AnyHashable: Any] = [ + "command_type": "profile_switch", + "command_status": "failed", + "aps": ["alert": ["title": "Command Failed", "body": "Command failed: Profile 'Sick Day' not activated: Communication Failure"]], + ] + #expect(tracker.handleNotification(userInfo: failed) == true) + #expect(tracker.states[sickDay] == .done(success: false, message: "Command failed: Profile 'Sick Day' not activated: Communication Failure")) + #expect(LoopProfileAckAdapter.resolution(for: ["command_type": "profile_switch", "command_status": "failed"]) == nil) + #expect(LoopProfileAckAdapter.resolution(for: ["command_type": "carbs_edit", "command_status": "success"]) == nil) + } }