diff --git a/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService.xcodeproj/project.pbxproj index 8935465..f33151e 100644 --- a/NightscoutService.xcodeproj/project.pbxproj +++ b/NightscoutService.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 0BC6BBE053EF4AE054EA95EE /* TherapyProfileNightscoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B32D26D24599B0C945226F4 /* TherapyProfileNightscoutTests.swift */; }; + 52E2DBF2BF2576CCD578E9EF /* TherapyProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28CAA436A466294F178734B /* TherapyProfile.swift */; }; 657F99DE2F0BC17B00F732BD /* RemoteNotificationResponseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657F99DD2F0BC17B00F732BD /* RemoteNotificationResponseManager.swift */; }; 657F99DF2F0BC17B00F732BD /* APNSJWTManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657F99DB2F0BC17B00F732BD /* APNSJWTManager.swift */; }; 657F99E02F0BC17B00F732BD /* OTPSecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657F99DC2F0BC17B00F732BD /* OTPSecureMessenger.swift */; }; @@ -33,6 +35,10 @@ A941B09229BD4A9F00F91340 /* OverrideCancelRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */; }; A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */; }; A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */; }; + D3900C5F203758CA0DCB836E /* ProfileDeleteRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5253EF8DC44C6A4F278F38C1 /* ProfileDeleteRemoteNotification.swift */; }; + E2ECF89C5ACDA50CB858E64A /* ProfileSaveRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B2B41ECFC01EBAB5133F4D8 /* ProfileSaveRemoteNotification.swift */; }; + 31E2434792EF8BB6B57BF50E /* ScheduledSwitchesSaveRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2189A4585866FD5859B04DE9 /* ScheduledSwitchesSaveRemoteNotification.swift */; }; + 4DE8139FC59BA6E7F57C8D54 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEB4ACB2213DE5F1894C68A9 /* ProfileSwitchRemoteNotification.swift */; }; A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */; }; A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */; }; A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */; }; @@ -49,6 +55,10 @@ A9AA6E6329EB07D7008FFA78 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5D29EB07D6008FFA78 /* Action.swift */; }; A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */; }; A9AA6E6529EB07D7008FFA78 /* OverrideCancelAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */; }; + 59F161B8203FAABBA0FFC075 /* ProfileDeleteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 883F786B736C5541FD2FB46B /* ProfileDeleteAction.swift */; }; + 1413C7A205EFBDDE63ECA23C /* ProfileSaveAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 363A0232FFD2EBE98018DB46 /* ProfileSaveAction.swift */; }; + 062A185E74AF5372A371B9A2 /* ScheduledSwitchesSaveAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EE4A6DB0B3CC6C9925F0BE3 /* ScheduledSwitchesSaveAction.swift */; }; + 89A4548A8132D95FE3FF035A /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DEEE634BC1BA7FFC67EEEC5 /* ProfileSwitchAction.swift */; }; A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */; }; A9B007B322BD6FF7000131DE /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B007B222BD6FF7000131DE /* LocalizedString.swift */; }; A9B007B422BD6FF7000131DE /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B007B222BD6FF7000131DE /* LocalizedString.swift */; }; @@ -139,6 +149,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 2B32D26D24599B0C945226F4 /* TherapyProfileNightscoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TherapyProfileNightscoutTests.swift; sourceTree = ""; }; + D28CAA436A466294F178734B /* TherapyProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TherapyProfile.swift; sourceTree = ""; }; 657F99DB2F0BC17B00F732BD /* APNSJWTManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSJWTManager.swift; sourceTree = ""; }; 657F99DC2F0BC17B00F732BD /* OTPSecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPSecureMessenger.swift; sourceTree = ""; }; 657F99DD2F0BC17B00F732BD /* RemoteNotificationResponseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNotificationResponseManager.swift; sourceTree = ""; }; @@ -168,6 +180,10 @@ A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotification.swift; sourceTree = ""; }; A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotification.swift; sourceTree = ""; }; A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotification.swift; sourceTree = ""; }; + 5253EF8DC44C6A4F278F38C1 /* ProfileDeleteRemoteNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDeleteRemoteNotification.swift; sourceTree = ""; }; + 5B2B41ECFC01EBAB5133F4D8 /* ProfileSaveRemoteNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSaveRemoteNotification.swift; sourceTree = ""; }; + 2189A4585866FD5859B04DE9 /* ScheduledSwitchesSaveRemoteNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledSwitchesSaveRemoteNotification.swift; sourceTree = ""; }; + BEB4ACB2213DE5F1894C68A9 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotificationTestCase.swift; sourceTree = ""; }; A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotificationTestCase.swift; sourceTree = ""; }; A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotificationTestCase.swift; sourceTree = ""; }; @@ -186,6 +202,10 @@ A9AA6E5D29EB07D6008FFA78 /* Action.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAction.swift; sourceTree = ""; }; A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelAction.swift; sourceTree = ""; }; + 883F786B736C5541FD2FB46B /* ProfileDeleteAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDeleteAction.swift; sourceTree = ""; }; + 363A0232FFD2EBE98018DB46 /* ProfileSaveAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSaveAction.swift; sourceTree = ""; }; + 7EE4A6DB0B3CC6C9925F0BE3 /* ScheduledSwitchesSaveAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledSwitchesSaveAction.swift; sourceTree = ""; }; + 2DEEE634BC1BA7FFC67EEEC5 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusAction.swift; sourceTree = ""; }; A9B007B222BD6FF7000131DE /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; A9C62DA32331CAD400535612 /* OSLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; @@ -269,6 +289,7 @@ A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, A934B6172358F49300949C8B /* StoredSettings.swift */, + D28CAA436A466294F178734B /* TherapyProfile.swift */, A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, @@ -327,6 +348,7 @@ A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, 98A0E460E47723C898DFCB0B /* ProfileContentTests.swift */, + 2B32D26D24599B0C945226F4 /* TherapyProfileNightscoutTests.swift */, C3A1D7E42F1B4A0E00A1B2C3 /* OverrideTreatmentTests.swift */, ); path = NightscoutServiceKitTests; @@ -366,6 +388,10 @@ A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */, A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + 5253EF8DC44C6A4F278F38C1 /* ProfileDeleteRemoteNotification.swift */, + 5B2B41ECFC01EBAB5133F4D8 /* ProfileSaveRemoteNotification.swift */, + 2189A4585866FD5859B04DE9 /* ScheduledSwitchesSaveRemoteNotification.swift */, + BEB4ACB2213DE5F1894C68A9 /* ProfileSwitchRemoteNotification.swift */, A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ); path = Notifications; @@ -420,6 +446,10 @@ A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */, A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */, + 883F786B736C5541FD2FB46B /* ProfileDeleteAction.swift */, + 363A0232FFD2EBE98018DB46 /* ProfileSaveAction.swift */, + 7EE4A6DB0B3CC6C9925F0BE3 /* ScheduledSwitchesSaveAction.swift */, + 2DEEE634BC1BA7FFC67EEEC5 /* ProfileSwitchAction.swift */, ); path = Actions; sourceTree = ""; @@ -713,6 +743,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 52E2DBF2BF2576CCD578E9EF /* TherapyProfile.swift in Sources */, A9AA6E5B29EB072E008FFA78 /* OverrideAction.swift in Sources */, A941B09129BD4A9E00F91340 /* BolusRemoteNotification.swift in Sources */, C177C4522ABE2D9300911B56 /* PersistedCgmEvent.swift in Sources */, @@ -727,6 +758,10 @@ A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */, A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */, A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */, + D3900C5F203758CA0DCB836E /* ProfileDeleteRemoteNotification.swift in Sources */, + E2ECF89C5ACDA50CB858E64A /* ProfileSaveRemoteNotification.swift in Sources */, + 31E2434792EF8BB6B57BF50E /* ScheduledSwitchesSaveRemoteNotification.swift in Sources */, + 4DE8139FC59BA6E7F57C8D54 /* ProfileSwitchRemoteNotification.swift in Sources */, C1E703642506BE9400DAB534 /* ObjectIdCache.swift in Sources */, A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */, C1398D2027C41E3D00416AD6 /* ProfileSet.swift in Sources */, @@ -737,6 +772,10 @@ C1E10F012ABF3B190036A416 /* PersistedPumpEvent.swift in Sources */, A90E39A122BC773A0016DFE8 /* NightscoutUploader.swift in Sources */, A9AA6E6529EB07D7008FFA78 /* OverrideCancelAction.swift in Sources */, + 59F161B8203FAABBA0FFC075 /* ProfileDeleteAction.swift in Sources */, + 1413C7A205EFBDDE63ECA23C /* ProfileSaveAction.swift in Sources */, + 062A185E74AF5372A371B9A2 /* ScheduledSwitchesSaveAction.swift in Sources */, + 89A4548A8132D95FE3FF035A /* ProfileSwitchAction.swift in Sources */, A9B007B322BD6FF7000131DE /* LocalizedString.swift in Sources */, A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */, A941B09229BD4A9F00F91340 /* OverrideCancelRemoteNotification.swift in Sources */, @@ -755,6 +794,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0BC6BBE053EF4AE054EA95EE /* TherapyProfileNightscoutTests.swift in Sources */, A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */, A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, diff --git a/NightscoutServiceKit/Extensions/ProfileSet.swift b/NightscoutServiceKit/Extensions/ProfileSet.swift index 2f3a440..bcecb8d 100644 --- a/NightscoutServiceKit/Extensions/ProfileSet.swift +++ b/NightscoutServiceKit/Extensions/ProfileSet.swift @@ -10,18 +10,20 @@ import Foundation import LoopKit import NightscoutKit -private extension LoopUnit { +extension LoopUnit { static func glucoseUnitFromNightscoutUnitString(_ unitString: String) -> LoopUnit? { // Some versions of Loop incorrectly uploaded units with // special characters to avoid line breaking. if unitString == LoopUnit.millimolesPerLiter.shortLocalizedUnitString() || - unitString == LoopUnit.millimolesPerLiter.shortLocalizedUnitString(avoidLineBreaking: false) + unitString == LoopUnit.millimolesPerLiter.shortLocalizedUnitString(avoidLineBreaking: false) || + unitString == "mmol/L" || unitString == "mmol" { return .millimolesPerLiter } if unitString == LoopUnit.milligramsPerDeciliter.shortLocalizedUnitString() || - unitString == LoopUnit.milligramsPerDeciliter.shortLocalizedUnitString(avoidLineBreaking: false) + unitString == LoopUnit.milligramsPerDeciliter.shortLocalizedUnitString(avoidLineBreaking: false) || + unitString == "mg/dL" || unitString == "mg/dl" { return .milligramsPerDeciliter } @@ -30,16 +32,71 @@ private extension LoopUnit { } } +/// Therapy settings read from a Nightscout profile set. +public struct ImportedTherapySettings { + /// A store profile that was left out, and why. + public struct SkippedProfile: Equatable { + public let name: String + public let reason: String + + public init(name: String, reason: String) { + self.name = name + self.reason = reason + } + } + + public let therapySettings: TherapySettings + /// When the profile set was saved. + public let date: Date + /// Store profiles not in `therapySettings.therapyProfiles`. + public let skippedProfiles: [SkippedProfile] + /// Scheduled switches not in `therapySettings.scheduledSwitches`, named by their description. + public let skippedScheduledSwitches: [SkippedProfile] + + public init(therapySettings: TherapySettings, date: Date, skippedProfiles: [SkippedProfile], skippedScheduledSwitches: [SkippedProfile] = []) { + self.therapySettings = therapySettings + self.date = date + self.skippedProfiles = skippedProfiles + self.skippedScheduledSwitches = skippedScheduledSwitches + } +} + extension ProfileSet { - var therapySettings: TherapySettings? { + /// Some uploaders, such as Trio, store the Default profile under this key. + static let lowercaseDefaultProfileKey = "default" - guard let profile = store["Default"], - let glucoseSafetyLimit = settings.minimumBGGuard, - let settingsGlucoseUnit = LoopUnit.glucoseUnitFromNightscoutUnitString(units) - else { + /// The live schedules are the active profile's (`defaultProfile`, as Default when its name is invalid), and every + /// store profile that passes the checks that need no pump is a therapy profile. + var importedTherapySettings: ImportedTherapySettings? { + guard let settingsGlucoseUnit = LoopUnit.glucoseUnitFromNightscoutUnitString(units) else { + return nil + } + + var namedStore: [String: Profile] = [:] + for (key, profile) in store { + namedStore[profileName(forStoreKey: key)] = profile + } + let requestedActiveName = profileName(forStoreKey: defaultProfile) + let liveName = namedStore[requestedActiveName] != nil ? requestedActiveName : TherapyProfile.defaultName + guard let profile = namedStore[liveName] else { return nil } + var activeName = liveName + var skippedProfiles: [ImportedTherapySettings.SkippedProfile] = [] + if liveName != requestedActiveName, !requestedActiveName.isEmpty { + skippedProfiles.append(.init(name: requestedActiveName, reason: LocalizedString("The active profile is not in the profile document, so the Default profile's values are imported as the therapy settings", comment: "Nightscout profile import: the document names an active profile it does not contain"))) + } + do { + try TherapyProfileValidator.validateName(liveName, isDefault: liveName == TherapyProfile.defaultName, existingNames: []) + } catch { + activeName = TherapyProfile.defaultName + let reason = namedStore[activeName] != nil + ? LocalizedString("%1$@; its values are imported as the Default profile and replace the stored Default profile", comment: "Nightscout profile import: the active profile's name is invalid and a Default profile is stored (1: why the name is invalid)") + : LocalizedString("%1$@; its values are imported as the Default profile", comment: "Nightscout profile import: the active profile's name is invalid (1: why the name is invalid)") + skippedProfiles.append(.init(name: liveName, reason: String(format: reason, error.localizedDescription))) + } + // If units are specified on the schedule, prefer those over the units specified on the ProfileSet let scheduleGlucoseUnit: LoopUnit if let profileUnitString = profile.units, let profileUnit = LoopUnit.glucoseUnitFromNightscoutUnitString(profileUnitString) @@ -49,6 +106,9 @@ extension ProfileSet { scheduleGlucoseUnit = settingsGlucoseUnit } + guard profile.targetLow.map(\.offset) == profile.targetHigh.map(\.offset) else { + return nil + } let targetItems: [RepeatingScheduleValue] = zip(profile.targetLow, profile.targetHigh).map { (low,high) in return RepeatingScheduleValue(startTime: low.offset, value: DoubleRange(minValue: low.value, maxValue: high.value)) } @@ -56,7 +116,7 @@ extension ProfileSet { let targetRangeSchedule = GlucoseRangeSchedule(unit: scheduleGlucoseUnit, dailyItems: targetItems, timeZone: profile.timeZone) let correctionRangeOverrides: CorrectionRangeOverrides? - if let range = settings.preMealTargetRange { + if let range = settings?.preMealTargetRange { correctionRangeOverrides = CorrectionRangeOverrides( preMeal: GlucoseRange(minValue: range.lowerBound, maxValue: range.upperBound, unit: settingsGlucoseUnit) ) @@ -78,21 +138,125 @@ extension ProfileSet { dailyItems: profile.carbratio.map { RepeatingScheduleValue(startTime: $0.offset, value: $0.value) }, timeZone: profile.timeZone) + let suspendThreshold = settings?.minimumBGGuard.map { GlucoseThreshold(unit: settingsGlucoseUnit, value: $0) } - return TherapySettings( + // The active profile becomes the live settings, so it passes the same checks as every other profile or + // nothing is imported. A schedule that can not be built fails them. + guard let activeProfile = TherapyProfile(name: activeName, + glucoseTargetRangeSchedule: targetRangeSchedule, + carbRatioSchedule: carbSchedule, + basalRateSchedule: basalSchedule, + insulinSensitivitySchedule: sensitivitySchedule), + (try? TherapyProfileValidator.validateValues(of: activeProfile, suspendThreshold: suspendThreshold)) != nil + else { + return nil + } + var profiles = [activeProfile] + let names = namedStore.keys.sorted { lhs, rhs in + if (lhs == TherapyProfile.defaultName) != (rhs == TherapyProfile.defaultName) { + return lhs == TherapyProfile.defaultName + } + return lhs.localizedStandardCompare(rhs) == .orderedAscending + } + for name in names where name != activeName && name != liveName { + guard let entry = namedStore[name] else { continue } + // Default is always kept, so it counts against the limit even when it is not stored. + let reserved = profiles.contains(where: \.isDefault) || name == TherapyProfile.defaultName ? 0 : 1 + guard profiles.count + reserved < TherapyProfileValidator.maximumProfileCount else { + skippedProfiles.append(.init(name: name, reason: String(format: LocalizedString("Loop keeps at most %1$d profiles", comment: "Nightscout profile import: profile left out because of the profile limit (1: maximum number of profiles)"), TherapyProfileValidator.maximumProfileCount))) + continue + } + do { + try TherapyProfileValidator.validateName(name, isDefault: name == TherapyProfile.defaultName, existingNames: profiles.map(\.name)) + let storedProfile = try TherapyProfile(name: name, nightscoutProfile: entry, defaultUnit: settingsGlucoseUnit) + try TherapyProfileValidator.validateValues(of: storedProfile, suspendThreshold: suspendThreshold) + profiles.append(storedProfile) + } catch { + skippedProfiles.append(.init(name: name, reason: error.localizedDescription)) + } + } + let therapyProfiles = TherapyProfiles.resolved(profiles, live: activeProfile) + let (scheduledSwitches, skippedScheduledSwitches) = importedScheduledSwitches(profileNames: therapyProfiles.map(\.name)) + + let therapySettings = TherapySettings( glucoseTargetRangeSchedule: targetRangeSchedule, correctionRangeOverrides: correctionRangeOverrides, - overridePresets: settings.overridePresets.compactMap { $0.loopOverride(for: settingsGlucoseUnit) }, - maximumBasalRatePerHour: settings.maximumBasalRatePerHour, - maximumBolus: settings.maximumBolus, - suspendThreshold: GlucoseThreshold(unit: settingsGlucoseUnit, value: glucoseSafetyLimit), + overridePresets: settings?.overridePresets.compactMap { $0.loopOverride(for: settingsGlucoseUnit) }, + maximumBasalRatePerHour: settings?.maximumBasalRatePerHour, + maximumBolus: settings?.maximumBolus, + suspendThreshold: suspendThreshold, insulinSensitivitySchedule: sensitivitySchedule, carbRatioSchedule: carbSchedule, basalRateSchedule: basalSchedule, - defaultRapidActingModel: nil) // Not stored in NS yet + defaultRapidActingModel: nil, // Not stored in NS yet + therapyProfiles: therapyProfiles.count > 1 ? therapyProfiles : nil, + activeProfileName: activeName == TherapyProfile.defaultName ? nil : activeName, + scheduledSwitches: scheduledSwitches) + + return ImportedTherapySettings(therapySettings: therapySettings, date: startDate, skippedProfiles: skippedProfiles, skippedScheduledSwitches: skippedScheduledSwitches) + } + + /// The scheduled switches, all of them or none: a week with some starts left out would keep profiles active at + /// times the document does not. + private func importedScheduledSwitches(profileNames: [String]) -> (entries: [ScheduledProfileSwitch]?, skipped: [ImportedTherapySettings.SkippedProfile]) { + guard let documentSwitches = scheduledSwitches, !documentSwitches.isEmpty else { + return (nil, []) + } + let entries = documentSwitches.map { + ScheduledProfileSwitch(id: $0.id, profileName: profileName(forStoreKey: $0.profile), weekday: $0.weekday, time: TimeInterval($0.time), paused: $0.paused) + } + let missingNames = Set(entries.map(\.profileName)).subtracting(profileNames) + if !missingNames.isEmpty { + return (nil, entries.map { entry in + let reason = missingNames.contains(entry.profileName) + ? String(format: LocalizedString("The profile %1$@ is not imported", comment: "Nightscout profile import: a scheduled switch to a profile that is left out (1: profile name)"), entry.profileName) + : LocalizedString("Scheduled switches are imported only when every profile they start is imported", comment: "Nightscout profile import: a scheduled switch left out because another one is") + return .init(name: entry.localizedDescription, reason: reason) + }) + } + do { + try ScheduledProfileSwitches.validate(entries, profileNames: profileNames) + return (entries, []) + } catch { + return (nil, entries.map { .init(name: $0.localizedDescription, reason: error.localizedDescription) }) + } + } + + private func profileName(forStoreKey key: String) -> String { + guard key == Self.lowercaseDefaultProfileKey, store[TherapyProfile.defaultName] == nil else { + return key + } + return TherapyProfile.defaultName } } +extension TherapyProfile { + /// A profile from a Nightscout `store` entry, with glucose values in the entry's units or else `defaultUnit`. + init(name: String, nightscoutProfile profile: ProfileSet.Profile, defaultUnit: LoopUnit) throws { + let unit = profile.units.flatMap { LoopUnit.glucoseUnitFromNightscoutUnitString($0) } ?? defaultUnit + guard profile.targetLow.map(\.offset) == profile.targetHigh.map(\.offset) else { + throw TherapyProfileError.invalidSchedule(LocalizedString("The correction range has different start times for its lower and upper values", comment: "Nightscout profile import error: target low and high start times differ")) + } + try TherapyProfileValidator.validateScheduleShape(startTimes: profile.basal.map(\.offset), settingName: LocalizedString("Basal Rates", comment: "Nightscout profile import schedule name: basal rates")) + try TherapyProfileValidator.validateScheduleShape(startTimes: profile.carbratio.map(\.offset), settingName: LocalizedString("Carb Ratios", comment: "Nightscout profile import schedule name: carb ratios")) + try TherapyProfileValidator.validateScheduleShape(startTimes: profile.sensitivity.map(\.offset), settingName: LocalizedString("Insulin Sensitivities", comment: "Nightscout profile import schedule name: insulin sensitivities")) + try TherapyProfileValidator.validateScheduleShape(startTimes: profile.targetLow.map(\.offset), settingName: LocalizedString("Correction Range", comment: "Nightscout profile import schedule name: correction range")) + guard let targets = GlucoseRangeSchedule(unit: unit, + dailyItems: zip(profile.targetLow, profile.targetHigh).map { RepeatingScheduleValue(startTime: $0.offset, value: DoubleRange(minValue: $0.value, maxValue: $1.value)) }, + timeZone: profile.timeZone), + let carbRatios = CarbRatioSchedule(unit: .gram, dailyItems: profile.carbratio.map { RepeatingScheduleValue(startTime: $0.offset, value: $0.value) }, timeZone: profile.timeZone), + let basalRates = BasalRateSchedule(dailyItems: profile.basal.map { RepeatingScheduleValue(startTime: $0.offset, value: $0.value) }, timeZone: profile.timeZone), + let sensitivities = InsulinSensitivitySchedule(unit: unit, dailyItems: profile.sensitivity.map { RepeatingScheduleValue(startTime: $0.offset, value: $0.value) }, timeZone: profile.timeZone) + else { + throw TherapyProfileError.invalidSchedule(LocalizedString("A schedule could not be read", comment: "Nightscout profile import error: schedule not built")) + } + self.init(name: name, + glucoseTargetRangeSchedule: targets, + carbRatioSchedule: carbRatios, + basalRateSchedule: basalRates, + insulinSensitivitySchedule: sensitivities) + } +} extension NightscoutKit.TemporaryScheduleOverride { diff --git a/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutServiceKit/Extensions/StoredSettings.swift index 3142223..0210e30 100644 --- a/NightscoutServiceKit/Extensions/StoredSettings.swift +++ b/NightscoutServiceKit/Extensions/StoredSettings.swift @@ -93,18 +93,68 @@ extension StoredSettings { return nil } + // One store entry per profile. The active profile's entry is the live settings. + let unit = glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit + let activeName = TherapyProfiles.activeName(activeProfileName) + var store: [String: ProfileSet.Profile] = [:] + for therapyProfile in resolvedTherapyProfiles { + guard let storeProfile = therapyProfile.nightscoutProfile(unit: unit) else { + return nil + } + store[therapyProfile.name] = storeProfile + } + store[activeName] = profile + let editLimits = therapyProfileValidator?.editLimits(unit: unit) + return ProfileSet( startDate: date, units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), enteredBy: "Loop", - defaultProfile: "Default", - store: ["Default": profile], + defaultProfile: activeName, + store: store, settings: loopSettings, syncIdentifier: syncIdentifier.uuidString, isAPNSProduction: Bundle.main.isAPNSProductionEnvironment, - timeZone: controllerTimeZone.identifier) + timeZone: controllerTimeZone.identifier, + profileEditLimits: editLimits.map { ProfileEditLimits($0) }, + // Remote profile commands need remote notifications, a pump and a maximum basal rate. + remoteCommands: deviceToken != nil && editLimits != nil ? NightscoutService.remoteProfileCommands : nil, + scheduledSwitches: scheduledSwitches.map { ProfileSet.ScheduledSwitch(id: $0.id, profile: $0.profileName, weekday: $0.weekday, time: Int($0.time), paused: $0.paused) }, + scheduledSwitchesFingerprint: ScheduledProfileSwitches.fingerprint(scheduledSwitches)) + } + + /// The note of the Profile Switch treatment for a switch to these settings' active profile. + var profileSwitchNotes: String? { + switch activeProfileSwitchTrigger { + case .scheduled?: + return "Scheduled" + case .remote?: + return "Remote" + default: + return nil + } } +} +extension ProfileEditLimits { + init(_ limits: TherapyProfileEditLimits) { + func valueLimits(_ value: TherapyProfileEditLimits.ValueLimits) -> ValueLimits { + ValueLimits(min: value.bounds.lowerBound, + max: value.bounds.upperBound, + step: value.step, + recommendedMin: value.recommended?.lowerBound, + recommendedMax: value.recommended?.upperBound) + } + self.init(units: limits.unit.shortLocalizedUnitString(avoidLineBreaking: false), + maxBasal: limits.maximumBasalRatePerHour, + basalRates: limits.basalRates, + maxBasalSegments: limits.maximumBasalScheduleEntryCount, + carbRatio: valueLimits(limits.carbRatio), + sensitivity: valueLimits(limits.sensitivity), + target: valueLimits(limits.target), + maxSegments: limits.maximumScheduleEntryCount, + segmentSeconds: Int(limits.segmentInterval)) + } } fileprivate extension Array where Element == RepeatingScheduleValue { diff --git a/NightscoutServiceKit/Extensions/TherapyProfile.swift b/NightscoutServiceKit/Extensions/TherapyProfile.swift new file mode 100644 index 0000000..88aa132 --- /dev/null +++ b/NightscoutServiceKit/Extensions/TherapyProfile.swift @@ -0,0 +1,86 @@ +// +// TherapyProfile.swift +// NightscoutServiceKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import NightscoutKit + +extension TherapyProfile { + /// The profile as a Nightscout `store` entry, with glucose values in `unit`. + func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { + guard let correctionRange = glucoseTargetRangeSchedule.schedule(for: unit), + let sensitivity = insulinSensitivitySchedule.schedule(for: unit) + else { + return nil + } + return ProfileSet.Profile( + timezone: basalRateSchedule.timeZone, + dia: .hours(6), + sensitivity: sensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, + carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, + basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, + targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, + targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, + units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) + } + + /// A profile from the compact values of a `profile-save` command. The start times are checked in the order + /// given, before any schedule sorts them. + init(name: String, compactValues values: CompactProfileValues, timeZone: TimeZone) throws { + guard let unit = LoopUnit.glucoseUnitFromNightscoutUnitString(values.units) else { + throw RemoteProfileCommandError.malformedValues(LocalizedString("units must be mg/dL or mmol/L", comment: "Remote profile error: unknown units")) + } + + func entries(_ raw: [[Double]], settingName: String, valueCount: Int) throws -> [(TimeInterval, [Double])] { + let entries: [(TimeInterval, [Double])] = try raw.map { numbers in + guard numbers.count == valueCount + 1, numbers.allSatisfy(\.isFinite) else { + throw RemoteProfileCommandError.malformedValues(String(format: LocalizedString("%1$@ has a malformed entry", comment: "Remote profile error: malformed entry (1: setting name)"), settingName)) + } + return (numbers[0], Array(numbers.dropFirst())) + } + try TherapyProfileValidator.validateScheduleShape(startTimes: entries.map(\.0), settingName: settingName) + return entries + } + + let basal = try entries(values.basal, settingName: LocalizedString("Basal Rates", comment: "Remote profile schedule name: basal rates"), valueCount: 1) + let carbRatio = try entries(values.carbratio, settingName: LocalizedString("Carb Ratios", comment: "Remote profile schedule name: carb ratios"), valueCount: 1) + let sensitivity = try entries(values.sens, settingName: LocalizedString("Insulin Sensitivities", comment: "Remote profile schedule name: insulin sensitivities"), valueCount: 1) + let target = try entries(values.target, settingName: LocalizedString("Correction Range", comment: "Remote profile schedule name: correction range"), valueCount: 2) + + guard let basalSchedule = BasalRateSchedule(dailyItems: basal.map { RepeatingScheduleValue(startTime: $0.0, value: $0.1[0]) }, timeZone: timeZone), + let carbRatioSchedule = CarbRatioSchedule(unit: .gram, dailyItems: carbRatio.map { RepeatingScheduleValue(startTime: $0.0, value: $0.1[0]) }, timeZone: timeZone), + let sensitivitySchedule = InsulinSensitivitySchedule(unit: unit, dailyItems: sensitivity.map { RepeatingScheduleValue(startTime: $0.0, value: $0.1[0]) }, timeZone: timeZone), + let targetSchedule = GlucoseRangeSchedule(unit: unit, dailyItems: target.map { RepeatingScheduleValue(startTime: $0.0, value: DoubleRange(minValue: $0.1[0], maxValue: $0.1[1])) }, timeZone: timeZone) + else { + throw RemoteProfileCommandError.malformedValues(LocalizedString("A schedule could not be read", comment: "Remote profile error: schedule not built")) + } + + self.init(name: name, + glucoseTargetRangeSchedule: targetSchedule, + carbRatioSchedule: carbRatioSchedule, + basalRateSchedule: basalSchedule, + insulinSensitivitySchedule: sensitivitySchedule) + } +} + +/// Reasons a remote profile command is refused before the phone looks at its profiles. +enum RemoteProfileCommandError: LocalizedError { + case malformedValues(String) + case missingFingerprint + case missingScheduledSwitchesFingerprint + + var errorDescription: String? { + switch self { + case .malformedValues(let reason): + return reason + case .missingFingerprint: + return LocalizedString("The command has no fingerprint of the original profile", comment: "Remote profile error: missing base fingerprint") + case .missingScheduledSwitchesFingerprint: + return LocalizedString("The command has no fingerprint of the scheduled switches it replaces", comment: "Remote scheduled switches error: missing base fingerprint") + } + } +} diff --git a/NightscoutServiceKit/Localizable.xcstrings b/NightscoutServiceKit/Localizable.xcstrings index 9d27d8c..766b015 100644 --- a/NightscoutServiceKit/Localizable.xcstrings +++ b/NightscoutServiceKit/Localizable.xcstrings @@ -4,6 +4,21 @@ "%@ Activity" : { "comment" : "Name of an activity override (1: activity type name)" }, + "%1$@ has a malformed entry" : { + "comment" : "Remote profile error: malformed entry (1: setting name)" + }, + "%1$@; its values are imported as the Default profile" : { + "comment" : "Nightscout profile import: the active profile's name is invalid (1: why the name is invalid)" + }, + "%1$@; its values are imported as the Default profile and replace the stored Default profile" : { + "comment" : "Nightscout profile import: the active profile's name is invalid and a Default profile is stored (1: why the name is invalid)" + }, + "A schedule could not be read" : { + "comment" : "Nightscout profile import error: schedule not built" + }, + "Basal Rates" : { + "comment" : "Nightscout profile import schedule name: basal rates" + }, "Bolus Entry" : { "comment" : "The remote action name for Bolus Entry", "localizations" : { @@ -289,6 +304,12 @@ } } }, + "Carb Ratios" : { + "comment" : "Nightscout profile import schedule name: carb ratios" + }, + "Correction Range" : { + "comment" : "Nightscout profile import schedule name: correction range" + }, "Custom Override" : { "comment" : "Name of custom override", "localizations" : { @@ -592,6 +613,12 @@ } } }, + "Insulin Sensitivities" : { + "comment" : "Nightscout profile import schedule name: insulin sensitivities" + }, + "Loop keeps at most %1$d profiles" : { + "comment" : "Nightscout profile import: profile left out because of the profile limit (1: maximum number of profiles)" + }, "Missing OTP" : { "comment" : "Remote command error description: Missing OTP.", "localizations" : { @@ -1026,6 +1053,36 @@ } } }, + "Profile Delete" : { + "comment" : "The remote action name for Profile Delete" + }, + "Profile Save" : { + "comment" : "The remote action name for Profile Save" + }, + "Profile Switch" : { + "comment" : "The remote action name for Profile Switch" + }, + "Scheduled switches are imported only when every profile they start is imported" : { + "comment" : "Nightscout profile import: a scheduled switch left out because another one is" + }, + "Scheduled Switches Save" : { + "comment" : "The remote action name for saving the scheduled profile switches" + }, + "The active profile is not in the profile document, so the Default profile's values are imported as the therapy settings" : { + "comment" : "Nightscout profile import: the document names an active profile it does not contain" + }, + "The command has no fingerprint of the original profile" : { + "comment" : "Remote profile error: missing base fingerprint" + }, + "The command has no fingerprint of the scheduled switches it replaces" : { + "comment" : "Remote scheduled switches error: missing base fingerprint" + }, + "The correction range has different start times for its lower and upper values" : { + "comment" : "Nightscout profile import error: target low and high start times differ" + }, + "The profile %1$@ is not imported" : { + "comment" : "Nightscout profile import: a scheduled switch to a profile that is left out (1: profile name)" + }, "U" : { "comment" : "The remote action abbreviation for bolus units", "localizations" : { @@ -1156,6 +1213,9 @@ } } }, + "units must be mg/dL or mmol/L" : { + "comment" : "Remote profile error: unknown units" + }, "Workout" : { "comment" : "Name of legacy workout override\nName uploaded to Nightscout for legacy workout override", "extractionState" : "stale", diff --git a/NightscoutServiceKit/NightscoutService.swift b/NightscoutServiceKit/NightscoutService.swift index 9ae01e0..0ba669f 100644 --- a/NightscoutServiceKit/NightscoutService.swift +++ b/NightscoutServiceKit/NightscoutService.swift @@ -53,6 +53,9 @@ public final class NightscoutService: Service { /// The last profile uploaded, without its dates, so an unchanged profile isn't uploaded again private let lastUploadedProfile: Locked + /// The remote profile commands, listed in the uploaded profile set as `remoteCommands` + static let remoteProfileCommands = [ProfileSwitchRemoteNotification.commandKey, ProfileSaveRemoteNotification.commandKey, ProfileDeleteRemoteNotification.commandKey, ScheduledSwitchesSaveRemoteNotification.commandKey] + private var _uploader: NightscoutClient? private var uploader: NightscoutClient? { @@ -379,20 +382,16 @@ extension NightscoutService: RemoteDataService { return } - var previous = lastUploadedProfile.value - var changed: [ProfileSet] = [] - for profileSet in stored.compactMap({ $0.profileSet }) { - let content = Self.profileContent(profileSet) - if content == nil || content != previous { - changed.append(profileSet) - previous = content - } - } + let (changed, switches, previous) = Self.profileUploads(for: stored, after: lastUploadedProfile.value) guard !changed.isEmpty else { return } + if !switches.isEmpty { + try await upload(switches, uploader: uploader) + } + try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) -> Void in uploader.uploadProfiles(changed) { result in switch result { @@ -418,7 +417,52 @@ extension NightscoutService: RemoteDataService { return String(data: data, encoding: .utf8) } - public func fetchStoredTherapySettings(completion: @escaping (Result<(TherapySettings,Date), Error>) -> Void) { + /// The profile sets that differ from the one uploaded before them, a Profile Switch treatment for each change of the + /// active profile (a rename is not one), and the content of the last profile set. + static func profileUploads(for stored: [StoredSettings], after lastUploaded: String?) -> (profileSets: [ProfileSet], switches: [ProfileSwitchNightscoutTreatment], lastUploaded: String?) { + var previous = lastUploaded + var previousActiveProfile = lastUploaded.flatMap(activeProfileName(inContent:)) + var profileSets: [ProfileSet] = [] + var switches: [ProfileSwitchNightscoutTreatment] = [] + for settings in stored { + guard let profileSet = settings.profileSet else { + continue + } + let content = profileContent(profileSet) + guard content == nil || content != previous else { + continue + } + if let previousActiveProfile, previousActiveProfile != profileSet.defaultProfile, profileSet.store[previousActiveProfile] != nil { + switches.append(ProfileSwitchNightscoutTreatment(timestamp: profileSet.startDate, enteredBy: "Loop", profile: profileSet.defaultProfile, notes: settings.profileSwitchNotes, syncIdentifier: profileSet.syncIdentifier)) + } + profileSets.append(profileSet) + previous = content + previousActiveProfile = profileSet.defaultProfile + } + return (profileSets, switches, previous) + } + + private static func activeProfileName(inContent content: String) -> String? { + guard let json = try? JSONSerialization.jsonObject(with: Data(content.utf8)) as? [String: Any] else { + return nil + } + return json["defaultProfile"] as? String + } + + private func upload(_ treatments: [NightscoutTreatment], uploader: NightscoutClient) async throws { + try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) -> Void in + uploader.upload(treatments) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + }) + } + + public func fetchStoredTherapySettings(completion: @escaping (Result) -> Void) { guard let uploader = uploader else { completion(.failure(NightscoutServiceError.missingCredentials)) return @@ -427,8 +471,8 @@ extension NightscoutService: RemoteDataService { uploader.fetchCurrentProfile(completion: { result in switch result { case .success(let profileSet): - if let therapySettings = profileSet.therapySettings { - completion(.success((therapySettings,profileSet.startDate))) + if let importedTherapySettings = profileSet.importedTherapySettings { + completion(.success(importedTherapySettings)) } else { completion(.failure(NightscoutServiceError.incompatibleTherapySettings)) } @@ -517,16 +561,48 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { ) success = true message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) + + case .profileSwitch(let profileCommand): + commandType = .profileSwitch + try await self.serviceDelegate?.switchRemoteTherapyProfile(name: profileCommand.name, expectedFingerprint: profileCommand.fingerprint) + success = true + message = action.therapyProfileSuccessMessage ?? "" + + case .profileSave(let profileCommand): + commandType = .profileSave + if profileCommand.originalName != nil && profileCommand.baseFingerprint == nil { + throw RemoteProfileCommandError.missingFingerprint + } + let profile = try TherapyProfile(name: profileCommand.name, compactValues: profileCommand.values, timeZone: .current) + try await self.serviceDelegate?.saveRemoteTherapyProfile(profile, originalName: profileCommand.originalName, expectedFingerprint: profileCommand.baseFingerprint) + success = true + message = action.therapyProfileSuccessMessage ?? "" + + case .profileDelete(let profileCommand): + commandType = .profileDelete + try await self.serviceDelegate?.deleteRemoteTherapyProfile(name: profileCommand.name, expectedFingerprint: profileCommand.fingerprint) + success = true + message = action.therapyProfileSuccessMessage ?? "" + + case .scheduledSwitchesSave(let scheduledSwitchesCommand): + commandType = .scheduledSwitchesSave + guard let baseFingerprint = scheduledSwitchesCommand.baseFingerprint else { + throw RemoteProfileCommandError.missingScheduledSwitchesFingerprint + } + try await self.serviceDelegate?.saveRemoteScheduledProfileSwitches(scheduledSwitchesCommand.entries, expectedFingerprint: baseFingerprint) + success = true + message = action.therapyProfileSuccessMessage ?? "" } } catch { - message = "Command failed: \(error.localizedDescription)" + message = action.therapyProfileFailureMessage(for: error) ?? "Command failed: \(error.localizedDescription)" // Send failure response before rethrowing if let returnInfo = returnInfo { await RemoteNotificationResponseManager.shared.sendResponseNotification( to: returnInfo, commandType: commandType, success: false, - message: message + message: message, + result: action.therapyProfileResult(failure: error) ) } throw error @@ -538,30 +614,45 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { to: returnInfo, commandType: commandType, success: success, - message: message + message: message, + result: action.therapyProfileResult(failure: nil) ) } } func commandSourceV1(_: RemoteCommandSourceV1, uploadError error: Error, notification: [String: AnyObject]) async throws { - guard let uploader = self.uploader else {throw NightscoutServiceError.missingCredentials} var commandDescription = "Loop Remote Action Error" + var errorDescription = error.localizedDescription if let remoteNotification = try? notification.toRemoteNotification() { - commandDescription = remoteNotification.toRemoteAction().description + let action = remoteNotification.toRemoteAction() + commandDescription = action.description + if let message = action.therapyProfileFailureMessage(for: error) { + errorDescription = message + // A command that was handled has had its response; one refused before, such as an expired command + // or a wrong OTP, gets it here. + if error is RemoteCommandValidator.NotificationValidationError || error is OTPManager.OTPValidationError { + await RemoteNotificationResponseManager.shared.sendResponseNotification(to: remoteNotification.getReturnNotificationInfo(), commandType: .init(action: action), success: false, message: message, result: action.therapyProfileResult(failure: error)) + } + } } let notificationJSON = try JSONSerialization.data(withJSONObject: notification) let notificationJSONString = String(data: notificationJSON, encoding: .utf8) ?? "" let noteBody = """ - \(error.localizedDescription) + \(errorDescription) \(notificationJSONString) """ + try await uploadNote(enteredBy: commandDescription, notes: noteBody) + } + + private func uploadNote(enteredBy: String, notes noteBody: String) async throws { + guard let uploader = self.uploader else {throw NightscoutServiceError.missingCredentials} let treatment = NightscoutTreatment( timestamp: Date(), - enteredBy: commandDescription, + enteredBy: enteredBy, notes: noteBody, eventType: .note ) diff --git a/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutServiceKit/RemoteCommands/Actions/Action.swift index a92b509..9029f09 100644 --- a/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +++ b/NightscoutServiceKit/RemoteCommands/Actions/Action.swift @@ -7,12 +7,17 @@ // import Foundation +import LoopKit public enum Action: Codable { case temporaryScheduleOverride(OverrideAction) case cancelTemporaryOverride(OverrideCancelAction) case bolusEntry(BolusAction) case carbsEntry(CarbAction) + case profileSwitch(ProfileSwitchAction) + case profileSave(ProfileSaveAction) + case profileDelete(ProfileDeleteAction) + case scheduledSwitchesSave(ScheduledSwitchesSaveAction) public var description: String { return "\(actionName) \(actionParameterDescription)" @@ -28,6 +33,14 @@ public enum Action: Codable { return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") case .temporaryScheduleOverride: return LocalizedString("Override", comment: "The remote action name for Override") + case .profileSwitch: + return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + case .profileSave: + return LocalizedString("Profile Save", comment: "The remote action name for Profile Save") + case .profileDelete: + return LocalizedString("Profile Delete", comment: "The remote action name for Profile Delete") + case .scheduledSwitchesSave: + return LocalizedString("Scheduled Switches Save", comment: "The remote action name for saving the scheduled profile switches") } } @@ -43,6 +56,14 @@ public enum Action: Codable { return "" case .temporaryScheduleOverride(let overrideAction): return overrideAction.name + case .profileSwitch(let action): + return action.name + case .profileSave(let action): + return action.name + case .profileDelete(let action): + return action.name + case .scheduledSwitchesSave: + return "" } } @@ -60,3 +81,74 @@ public enum Action: Codable { return formatter }() } + +extension Action { + /// The name of the profile a therapy profile command targets; for a rename, the new name. Nil for other commands. + var therapyProfileName: String? { + switch self { + case .profileSwitch(let action): + return action.name + case .profileSave(let action): + return action.name + case .profileDelete(let action): + return action.name + case .temporaryScheduleOverride, .cancelTemporaryOverride, .bolusEntry, .carbsEntry, .scheduledSwitchesSave: + return nil + } + } + + private var therapyProfileOutcome: String? { + switch self { + case .profileSwitch: + return "activated" + case .profileSave: + return "saved" + case .profileDelete: + return "deleted" + case .scheduledSwitchesSave: + return "saved" + case .temporaryScheduleOverride, .cancelTemporaryOverride, .bolusEntry, .carbsEntry: + return nil + } + } + + /// The reply text for a therapy profile command that succeeded, or nil for other commands. + var therapyProfileSuccessMessage: String? { + if case .scheduledSwitchesSave = self { + return "Scheduled switches saved" + } + guard let name = therapyProfileName, let outcome = therapyProfileOutcome else { + return nil + } + return "Profile '\(name)' \(outcome)" + } + + /// The reply text for a therapy profile command that failed, or nil for other commands. + func therapyProfileFailureMessage(for error: Error) -> String? { + if case .scheduledSwitchesSave = self { + return "Scheduled switches not saved: \(error.localizedDescription)" + } + guard let name = therapyProfileName, let outcome = therapyProfileOutcome else { + return nil + } + return "Profile '\(name)' not \(outcome): \(error.localizedDescription)" + } + + /// The reply's `result` code for a therapy profile command (`activated`, `saved`, `deleted`, `conflict`, + /// `not_found` or `rejected`), or nil for other commands. + func therapyProfileResult(failure: Error?) -> String? { + guard let outcome = therapyProfileOutcome else { + return nil + } + switch failure { + case nil: + return outcome + case TherapyProfileError.conflict?, ScheduledProfileSwitchError.conflict?: + return "conflict" + case TherapyProfileError.notFound(_)?: + return "not_found" + default: + return "rejected" + } + } +} diff --git a/NightscoutServiceKit/RemoteCommands/Actions/ProfileDeleteAction.swift b/NightscoutServiceKit/RemoteCommands/Actions/ProfileDeleteAction.swift new file mode 100644 index 0000000..0e0c264 --- /dev/null +++ b/NightscoutServiceKit/RemoteCommands/Actions/ProfileDeleteAction.swift @@ -0,0 +1,15 @@ +// +// ProfileDeleteAction.swift +// NightscoutServiceKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +public struct ProfileDeleteAction: Codable { + + public let name: String + /// The fingerprint of the profile the caregiver reviewed. + public let fingerprint: String +} diff --git a/NightscoutServiceKit/RemoteCommands/Actions/ProfileSaveAction.swift b/NightscoutServiceKit/RemoteCommands/Actions/ProfileSaveAction.swift new file mode 100644 index 0000000..7f932ca --- /dev/null +++ b/NightscoutServiceKit/RemoteCommands/Actions/ProfileSaveAction.swift @@ -0,0 +1,36 @@ +// +// ProfileSaveAction.swift +// NightscoutServiceKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +public struct ProfileSaveAction: Codable { + + public let name: String + /// The profile to update; nil creates a profile. + public let originalName: String? + /// The fingerprint of the original profile the caregiver edited. + public let baseFingerprint: String? + public let values: CompactProfileValues +} + +/// The values of a profile in a command: pairs of `[secondsFromMidnight, value]`, target entries +/// `[seconds, low, high]`, glucose values in `units`. +public struct CompactProfileValues: Codable, Equatable { + public let units: String + public let basal: [[Double]] + public let carbratio: [[Double]] + public let sens: [[Double]] + public let target: [[Double]] + + public init(units: String, basal: [[Double]], carbratio: [[Double]], sens: [[Double]], target: [[Double]]) { + self.units = units + self.basal = basal + self.carbratio = carbratio + self.sens = sens + self.target = target + } +} diff --git a/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift new file mode 100644 index 0000000..f4ce95e --- /dev/null +++ b/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift @@ -0,0 +1,15 @@ +// +// ProfileSwitchAction.swift +// NightscoutServiceKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +public struct ProfileSwitchAction: Codable { + + public let name: String + /// The fingerprint of the profile the caregiver reviewed. + public let fingerprint: String +} diff --git a/NightscoutServiceKit/RemoteCommands/Actions/ScheduledSwitchesSaveAction.swift b/NightscoutServiceKit/RemoteCommands/Actions/ScheduledSwitchesSaveAction.swift new file mode 100644 index 0000000..f736138 --- /dev/null +++ b/NightscoutServiceKit/RemoteCommands/Actions/ScheduledSwitchesSaveAction.swift @@ -0,0 +1,17 @@ +// +// ScheduledSwitchesSaveAction.swift +// NightscoutServiceKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +public struct ScheduledSwitchesSaveAction: Codable { + + /// The whole list that replaces the phone's scheduled switches. + public let entries: [ScheduledProfileSwitch] + /// The fingerprint of the list the caregiver edited. + public let baseFingerprint: String? +} diff --git a/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift index 95274e4..a09e2de 100644 --- a/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +++ b/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift @@ -20,12 +20,15 @@ class RemoteNotificationResponseManager { let aps: APSPayload let commandStatus: String let commandType: String + /// A code the caller can match on, independent of the alert text's language; only for some commands. + let result: String? let timestamp: TimeInterval enum CodingKeys: String, CodingKey { case aps case commandStatus = "command_status" case commandType = "command_type" + case result case timestamp } } @@ -45,13 +48,39 @@ class RemoteNotificationResponseManager { case carbs = "carbs" case override = "override" case cancelOverride = "cancel_override" + case profileSwitch = "profile_switch" + case profileSave = "profile_save" + case profileDelete = "profile_delete" + case scheduledSwitchesSave = "profile_scheduled_switches_save" + + init(action: Action) { + switch action { + case .bolusEntry: + self = .bolus + case .carbsEntry: + self = .carbs + case .temporaryScheduleOverride: + self = .override + case .cancelTemporaryOverride: + self = .cancelOverride + case .profileSwitch: + self = .profileSwitch + case .profileSave: + self = .profileSave + case .profileDelete: + self = .profileDelete + case .scheduledSwitchesSave: + self = .scheduledSwitchesSave + } + } } func sendResponseNotification( to returnInfo: ReturnNotificationInfo?, commandType: CommandType, success: Bool, - message: String + message: String, + result: String? = nil ) async { guard let returnInfo = returnInfo else { os_log("No return notification info provided, skipping response", log: log, type: .info) @@ -65,7 +94,17 @@ class RemoteNotificationResponseManager { os_log("Sending response notification - Type: %{public}@, Status: %{public}@, Message: %{public}@, DeviceToken: %{public}@", log: log, type: .info, commandType.rawValue, success ? "success" : "failed", message, returnInfo.deviceToken) - let payload = NotificationPayload( + let payload = Self.payload(commandType: commandType, success: success, message: message, result: result) + + await sendPushNotification( + payload: payload, + to: returnInfo.deviceToken, + using: returnInfo + ) + } + + static func payload(commandType: CommandType, success: Bool, message: String, result: String?, date: Date = Date()) -> NotificationPayload { + NotificationPayload( aps: APSPayload( alert: Alert( title: success ? "Command Successful" : "Command Failed", @@ -74,16 +113,11 @@ class RemoteNotificationResponseManager { ), commandStatus: success ? "success" : "failed", commandType: commandType.rawValue, - timestamp: Date().timeIntervalSince1970 - ) - - await sendPushNotification( - payload: payload, - to: returnInfo.deviceToken, - using: returnInfo + result: result, + timestamp: date.timeIntervalSince1970 ) } - + private func sendPushNotification( payload: NotificationPayload, to deviceToken: String, diff --git a/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileDeleteRemoteNotification.swift b/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileDeleteRemoteNotification.swift new file mode 100644 index 0000000..e38679b --- /dev/null +++ b/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileDeleteRemoteNotification.swift @@ -0,0 +1,47 @@ +// +// ProfileDeleteRemoteNotification.swift +// NightscoutServiceKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// Deletes a profile. `expiration` is required: decoding fails without it. +public struct ProfileDeleteRemoteNotification: RemoteNotification, Codable { + static let commandKey = "profile-delete" + + public let name: String + public let fingerprint: String + public let remoteAddress: String + private let expirationDate: Date + public let sentAt: Date? + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? + + public var expiration: Date? { expirationDate } + + enum CodingKeys: String, CodingKey { + case name = "profile-delete" + case fingerprint = "profile-delete-fingerprint" + case remoteAddress = "remote-address" + case expirationDate = "expiration" + case sentAt = "sent-at" + case otp = "otp" + case enteredBy = "entered-by" + case encryptedReturnNotification = "encrypted_return_notification" + } + + func toRemoteAction() -> Action { + .profileDelete(ProfileDeleteAction(name: name, fingerprint: fingerprint)) + } + + func otpValidationRequired() -> Bool { + true + } + + public static func includedInNotification(_ notification: [String: Any]) -> Bool { + notification[commandKey] != nil + } +} diff --git a/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSaveRemoteNotification.swift b/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSaveRemoteNotification.swift new file mode 100644 index 0000000..9fcdff9 --- /dev/null +++ b/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSaveRemoteNotification.swift @@ -0,0 +1,51 @@ +// +// ProfileSaveRemoteNotification.swift +// NightscoutServiceKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// Creates or updates a profile. `expiration` is required: decoding fails without it. +public struct ProfileSaveRemoteNotification: RemoteNotification, Codable { + static let commandKey = "profile-save" + + public let name: String + public let originalName: String? + public let baseFingerprint: String? + public let values: CompactProfileValues + public let remoteAddress: String + private let expirationDate: Date + public let sentAt: Date? + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? + + public var expiration: Date? { expirationDate } + + enum CodingKeys: String, CodingKey { + case name = "profile-save" + case originalName = "profile-save-original-name" + case baseFingerprint = "profile-save-base-fingerprint" + case values = "profile-save-values" + case remoteAddress = "remote-address" + case expirationDate = "expiration" + case sentAt = "sent-at" + case otp = "otp" + case enteredBy = "entered-by" + case encryptedReturnNotification = "encrypted_return_notification" + } + + func toRemoteAction() -> Action { + .profileSave(ProfileSaveAction(name: name, originalName: originalName, baseFingerprint: baseFingerprint, values: values)) + } + + func otpValidationRequired() -> Bool { + true + } + + public static func includedInNotification(_ notification: [String: Any]) -> Bool { + notification[commandKey] != nil + } +} diff --git a/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift new file mode 100644 index 0000000..14af1ba --- /dev/null +++ b/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift @@ -0,0 +1,47 @@ +// +// ProfileSwitchRemoteNotification.swift +// NightscoutServiceKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// Activates a profile. `expiration` is required: decoding fails without it. +public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { + static let commandKey = "profile-switch" + + public let name: String + public let fingerprint: String + public let remoteAddress: String + private let expirationDate: Date + public let sentAt: Date? + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? + + public var expiration: Date? { expirationDate } + + enum CodingKeys: String, CodingKey { + case name = "profile-switch" + case fingerprint = "profile-switch-fingerprint" + case remoteAddress = "remote-address" + case expirationDate = "expiration" + case sentAt = "sent-at" + case otp = "otp" + case enteredBy = "entered-by" + case encryptedReturnNotification = "encrypted_return_notification" + } + + func toRemoteAction() -> Action { + .profileSwitch(ProfileSwitchAction(name: name, fingerprint: fingerprint)) + } + + func otpValidationRequired() -> Bool { + true + } + + public static func includedInNotification(_ notification: [String: Any]) -> Bool { + notification[commandKey] != nil + } +} diff --git a/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift index 0c5f8e3..01c4344 100644 --- a/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +++ b/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift @@ -108,6 +108,14 @@ extension Dictionary { return try OverrideRemoteNotification(dictionary: self) } else if OverrideCancelRemoteNotification.includedInNotification(self) { return try OverrideCancelRemoteNotification(dictionary: self) + } else if ProfileSwitchRemoteNotification.includedInNotification(self) { + return try ProfileSwitchRemoteNotification(dictionary: self) + } else if ProfileSaveRemoteNotification.includedInNotification(self) { + return try ProfileSaveRemoteNotification(dictionary: self) + } else if ProfileDeleteRemoteNotification.includedInNotification(self) { + return try ProfileDeleteRemoteNotification(dictionary: self) + } else if ScheduledSwitchesSaveRemoteNotification.includedInNotification(self) { + return try ScheduledSwitchesSaveRemoteNotification(dictionary: self) } else { throw RemoteNotificationError.unhandledNotification(self) } diff --git a/NightscoutServiceKit/RemoteCommands/V1/Notifications/ScheduledSwitchesSaveRemoteNotification.swift b/NightscoutServiceKit/RemoteCommands/V1/Notifications/ScheduledSwitchesSaveRemoteNotification.swift new file mode 100644 index 0000000..636c08b --- /dev/null +++ b/NightscoutServiceKit/RemoteCommands/V1/Notifications/ScheduledSwitchesSaveRemoteNotification.swift @@ -0,0 +1,48 @@ +// +// ScheduledSwitchesSaveRemoteNotification.swift +// NightscoutServiceKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +/// Replaces the scheduled profile switches. `expiration` is required: decoding fails without it. +public struct ScheduledSwitchesSaveRemoteNotification: RemoteNotification, Codable { + static let commandKey = "profile-scheduled-switches-save" + + public let entries: [ScheduledProfileSwitch] + public let baseFingerprint: String? + public let remoteAddress: String + private let expirationDate: Date + public let sentAt: Date? + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? + + public var expiration: Date? { expirationDate } + + enum CodingKeys: String, CodingKey { + case entries = "profile-scheduled-switches-save" + case baseFingerprint = "profile-scheduled-switches-base-fingerprint" + case remoteAddress = "remote-address" + case expirationDate = "expiration" + case sentAt = "sent-at" + case otp = "otp" + case enteredBy = "entered-by" + case encryptedReturnNotification = "encrypted_return_notification" + } + + func toRemoteAction() -> Action { + .scheduledSwitchesSave(ScheduledSwitchesSaveAction(entries: entries, baseFingerprint: baseFingerprint)) + } + + func otpValidationRequired() -> Bool { + true + } + + public static func includedInNotification(_ notification: [String: Any]) -> Bool { + notification[commandKey] != nil + } +} diff --git a/NightscoutServiceKitTests/TherapyProfileNightscoutTests.swift b/NightscoutServiceKitTests/TherapyProfileNightscoutTests.swift new file mode 100644 index 0000000..042a681 --- /dev/null +++ b/NightscoutServiceKitTests/TherapyProfileNightscoutTests.swift @@ -0,0 +1,751 @@ +// +// TherapyProfileNightscoutTests.swift +// NightscoutServiceKitTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopKit +import NightscoutKit +@testable import NightscoutServiceKit + +final class TherapyProfileNightscoutTests: XCTestCase { + + private let timeZone = TimeZone(secondsFromGMT: 3600)! + + private func profile(_ name: String, basal: [(TimeInterval, Double)] = [(0, 0.85), (21600, 1.0)], target: (Double, Double) = (100, 110)) -> TherapyProfile { + TherapyProfile(name: name, + glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: target.0, maxValue: target.1))], timeZone: timeZone)!, + carbRatioSchedule: CarbRatioSchedule(unit: .gram, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 10)], timeZone: timeZone)!, + basalRateSchedule: BasalRateSchedule(dailyItems: basal.map { RepeatingScheduleValue(startTime: $0.0, value: $0.1) }, timeZone: timeZone)!, + insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: .milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 50)], timeZone: timeZone)!) + } + + private func settings(date: Date, active: String?, profiles: [TherapyProfile]) -> StoredSettings { + let live = profiles.first { $0.name == TherapyProfiles.activeName(active) } ?? profile("Default") + return StoredSettings(date: date, + glucoseTargetRangeSchedule: live.glucoseTargetRangeSchedule, + maximumBasalRatePerHour: 3, + maximumBolus: 5, + suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 75), + basalRateSchedule: live.basalRateSchedule, + insulinSensitivitySchedule: live.insulinSensitivitySchedule, + carbRatioSchedule: live.carbRatioSchedule, + bloodGlucoseUnit: .milligramsPerDeciliter, + therapyProfiles: profiles, + activeProfileName: active) + } + + private struct SwitchRecord: Equatable { + let profile: String + let date: Date + + init(profile: String, date: Date) { + self.profile = profile + self.date = date + } + + init(_ treatment: ProfileSwitchNightscoutTreatment) { + self.init(profile: treatment.profile, date: treatment.timestamp) + } + } + + private var profiles: [TherapyProfile] { + [profile("Default"), profile("Sport", basal: [(0, 0.5)], target: (120, 130))] + } + + // MARK: Profile document + + func testProfileSetFromHistoricalSnapshot() throws { + let profileSet = try XCTUnwrap(settings(date: Date(), active: "Sport", profiles: profiles).profileSet) + XCTAssertEqual(profileSet.defaultProfile, "Sport") + XCTAssertEqual(Set(profileSet.store.keys), ["Default", "Sport"]) + XCTAssertEqual(profileSet.store["Sport"]?.basal.map(\.value), [0.5]) + XCTAssertEqual(profileSet.store["Sport"]?.targetLow.map(\.value), [120]) + XCTAssertEqual(profileSet.store["Default"]?.basal.map(\.value), [0.85, 1.0]) + + let earlier = try XCTUnwrap(settings(date: Date(), active: nil, profiles: profiles).profileSet) + XCTAssertEqual(earlier.defaultProfile, "Default") + XCTAssertEqual(earlier.store["Default"]?.basal.map(\.value), [0.85, 1.0]) + } + + func testProfileSetWithoutProfilesHasDefaultOnly() throws { + let profileSet = try XCTUnwrap(settings(date: Date(), active: nil, profiles: []).profileSet) + XCTAssertEqual(profileSet.defaultProfile, "Default") + XCTAssertEqual(Array(profileSet.store.keys), ["Default"]) + } + + func testProfileSetHasEditLimits() throws { + var withPump = settings(date: Date(), active: "Sport", profiles: profiles) + withPump.pumpSupportedBasalRates = [0, 0.05, 0.1, 3.0, 3.05] + withPump.pumpMaximumBasalScheduleEntryCount = 24 + let profileSet = try XCTUnwrap(withPump.profileSet) + let limits = try XCTUnwrap(profileSet.dictionaryRepresentation["profileEditLimits"] as? [String: Any]) + XCTAssertEqual(limits["units"] as? String, "mg/dL") + XCTAssertEqual(limits["maxBasal"] as? Double, 3) + XCTAssertEqual(limits["basalRates"] as? [Double], [0, 0.05, 0.1, 3.0]) + XCTAssertEqual(limits["maxSegments"] as? Int, 48) + XCTAssertEqual(limits["segmentSeconds"] as? Int, 1800) + XCTAssertEqual(limits["maxBasalSegments"] as? Int, 24) + XCTAssertNil(limits["basalMinimum"]) + let carbRatio = try XCTUnwrap(limits["carbRatio"] as? [String: Double]) + XCTAssertEqual(carbRatio, ["min": 2, "max": 150, "step": 0.1, "recommendedMin": 4, "recommendedMax": 28]) + let sensitivity = try XCTUnwrap(limits["sensitivity"] as? [String: Double]) + XCTAssertEqual(sensitivity, ["min": 10, "max": 500, "step": 1, "recommendedMin": 16, "recommendedMax": 399]) + let target = try XCTUnwrap(limits["target"] as? [String: Double]) + XCTAssertEqual(target["min"], 87) + XCTAssertEqual(target["max"], 180) + XCTAssertEqual(profileSet.defaultProfile, "Sport") + + XCTAssertNil(settings(date: Date(), active: nil, profiles: []).profileSet?.profileEditLimits) + withPump.maximumBasalRatePerHour = nil + XCTAssertNil(withPump.profileSet?.profileEditLimits) + } + + func testChangingAProfileThatIsNotActiveChangesTheUploadedContent() throws { + let before = try XCTUnwrap(settings(date: Date(), active: nil, profiles: profiles).profileSet) + let changedProfiles = [profile("Default"), profile("Sport", basal: [(0, 0.55)], target: (120, 130))] + let after = try XCTUnwrap(settings(date: Date(), active: nil, profiles: changedProfiles).profileSet) + let unchanged = try XCTUnwrap(settings(date: Date().addingTimeInterval(60), active: nil, profiles: profiles).profileSet) + XCTAssertEqual(before.store["Default"]?.basal.map(\.value), after.store["Default"]?.basal.map(\.value)) + XCTAssertNotEqual(NightscoutService.profileContent(before), NightscoutService.profileContent(after)) + XCTAssertEqual(NightscoutService.profileContent(before), NightscoutService.profileContent(unchanged)) + } + + // MARK: Profile switches + + private func uploads(_ active: [String?], profiles: [TherapyProfile]? = nil, after lastUploaded: String? = nil) -> (profileSets: [ProfileSet], switches: [ProfileSwitchNightscoutTreatment], lastUploaded: String?) { + let start = Date(timeIntervalSince1970: 1_700_000_000) + let stored = active.enumerated().map { index, name in + settings(date: start.addingTimeInterval(TimeInterval(index) * 60), active: name, profiles: profiles ?? self.profiles) + } + return NightscoutService.profileUploads(for: stored, after: lastUploaded) + } + + func testProfileSwitchesFromConsecutiveSettings() { + let start = Date(timeIntervalSince1970: 1_700_000_000) + let result = uploads([nil, nil, "Sport", "Sport", nil]) + XCTAssertEqual(result.profileSets.map(\.defaultProfile), ["Default", "Sport", "Default"]) + XCTAssertEqual(result.switches.map(SwitchRecord.init), [.init(profile: "Sport", date: start.addingTimeInterval(120)), + .init(profile: "Default", date: start.addingTimeInterval(240))]) + } + + func testProfileSwitchesAcrossUploadBatches() { + let first = uploads([nil, "Sport"]) + XCTAssertEqual(first.switches.map(\.profile), ["Sport"]) + + let second = uploads([nil, nil], after: first.lastUploaded) + XCTAssertEqual(second.switches.map(\.profile), ["Default"]) + XCTAssertEqual(second.profileSets.count, 1) + + let unchanged = uploads(["Sport"], after: first.lastUploaded) + XCTAssertTrue(unchanged.profileSets.isEmpty) + XCTAssertTrue(unchanged.switches.isEmpty) + } + + func testFirstProfileSetGivesNoSwitch() { + let result = uploads(["Sport"]) + XCTAssertEqual(result.profileSets.count, 1) + XCTAssertTrue(result.switches.isEmpty) + } + + func testEditingAProfileIsNotASwitch() { + let first = uploads([nil]) + let edited = [profile("Default"), profile("Sport", basal: [(0, 0.55)], target: (120, 130))] + let result = uploads([nil], profiles: edited, after: first.lastUploaded) + XCTAssertEqual(result.profileSets.count, 1) + XCTAssertTrue(result.switches.isEmpty) + } + + func testRenamingActiveProfileIsNotASwitch() { + let first = uploads(["Sport"]) + let renamed = [profile("Default"), profile("Running", basal: [(0, 0.5)], target: (120, 130))] + let result = uploads(["Running"], profiles: renamed, after: first.lastUploaded) + XCTAssertEqual(result.profileSets.map(\.defaultProfile), ["Running"]) + XCTAssertTrue(result.switches.isEmpty) + } + + func testProfileSwitchTreatmentFields() throws { + let start = Date(timeIntervalSince1970: 1_700_000_000) + let stored = [settings(date: start, active: nil, profiles: profiles), + settings(date: start.addingTimeInterval(60), active: "Sport", profiles: profiles)] + let treatment = try XCTUnwrap(NightscoutService.profileUploads(for: stored, after: nil).switches.first) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONSerialization.data(withJSONObject: treatment.dictionaryRepresentation)) as? [String: Any]) + XCTAssertEqual(json["eventType"] as? String, "Profile Switch") + XCTAssertEqual(json["profile"] as? String, "Sport") + XCTAssertEqual(json["created_at"] as? String, "2023-11-14T22:14:20Z") + XCTAssertEqual(json["enteredBy"] as? String, "Loop") + XCTAssertEqual(json["duration"] as? Int, 0) + XCTAssertEqual(json["syncIdentifier"] as? String, stored[1].syncIdentifier.uuidString) + } + + // MARK: Commands + + private var compact: [String: Any] { + ["units": "mg/dL", + "basal": [[0, 0.85], [21600, 1.0]], + "carbratio": [[0, 10.0]], + "sens": [[0, 50.0]], + "target": [[0, 100.0, 110.0]]] + } + + private func values(_ overrides: [String: Any] = [:]) throws -> CompactProfileValues { + let data = try JSONSerialization.data(withJSONObject: compact.merging(overrides) { $1 }) + return try JSONDecoder().decode(CompactProfileValues.self, from: data) + } + + func testCompactValuesMakeProfile() throws { + let profile = try TherapyProfile(name: "Sport", compactValues: values(), timeZone: timeZone) + XCTAssertEqual(profile.fingerprint(unit: .milligramsPerDeciliter), "3e603478d512da04") + + let millimolar = try TherapyProfile(name: "Sport", compactValues: values(["units": "mmol/L", "sens": [[0, 2.8]], "target": [[0, 5.5, 6.1]]]), timeZone: timeZone) + XCTAssertEqual(millimolar.glucoseTargetRangeSchedule.unit, .millimolesPerLiter) + } + + func testMalformedCompactValuesAreRejected() throws { + for overrides: [String: Any] in [ + ["units": "furlongs"], + ["basal": [[1800, 0.85]]], + ["basal": [[0, 0.85], [21600, 1.0], [3600, 0.9]]], + ["basal": [[0, 0.85], [900, 1.0]]], + ["basal": [] as [Any]], + ["target": [[0, 100.0]]], + ["carbratio": [[0, 10.0, 11.0]]], + ] { + XCTAssertThrowsError(try TherapyProfile(name: "Sport", compactValues: values(overrides), timeZone: timeZone), "\(overrides)") + } + XCTAssertThrowsError(try TherapyProfile(name: "Sport", compactValues: values(["basal": [[0, 0.85], [900, 1.0]]]), timeZone: timeZone)) { error in + XCTAssertEqual(error.localizedDescription, "Basal Rates has a start time that is not on a half hour") + } + XCTAssertThrowsError(try TherapyProfile(name: "Sport", compactValues: values(["target": [[0, 100.0]]]), timeZone: timeZone)) { error in + XCTAssertEqual(error.localizedDescription, "Correction Range has a malformed entry") + } + } + + private func notification(_ fields: [String: Any]) -> [String: AnyObject] { + var notification: [String: Any] = [ + "remote-address": "LoopFollow", + "sent-at": "2026-11-07T10:00:00.000Z", + "expiration": "2026-11-07T10:05:00.000Z", + "otp": "123456", + ] + notification.merge(fields) { $1 } + return notification as [String: AnyObject] + } + + func testProfileNotificationsParse() throws { + let switchNotification = try notification(["profile-switch": "Sport", "profile-switch-fingerprint": "3e603478d512da04"]).toRemoteNotification() + guard case .profileSwitch(let switchAction) = switchNotification.toRemoteAction() else { + return XCTFail("Not a profile switch") + } + XCTAssertEqual(switchAction.name, "Sport") + XCTAssertEqual(switchAction.fingerprint, "3e603478d512da04") + XCTAssertTrue(switchNotification.otpValidationRequired()) + XCTAssertNotNil(switchNotification.expiration) + + let saveNotification = try notification(["profile-save": "Sport", "profile-save-original-name": "Running", "profile-save-base-fingerprint": "abc", "profile-save-values": compact]).toRemoteNotification() + guard case .profileSave(let saveAction) = saveNotification.toRemoteAction() else { + return XCTFail("Not a profile save") + } + XCTAssertEqual(saveAction.originalName, "Running") + XCTAssertEqual(saveAction.baseFingerprint, "abc") + XCTAssertEqual(saveAction.values, try values()) + + let deleteNotification = try notification(["profile-delete": "Sport", "profile-delete-fingerprint": "abc"]).toRemoteNotification() + guard case .profileDelete(let deleteAction) = deleteNotification.toRemoteAction() else { + return XCTFail("Not a profile delete") + } + XCTAssertEqual(deleteAction.name, "Sport") + } + + func testProfileNotificationsRequireExpirationAndFingerprint() { + var withoutExpiration = notification(["profile-switch": "Sport", "profile-switch-fingerprint": "abc"]) + withoutExpiration["expiration"] = nil + XCTAssertThrowsError(try withoutExpiration.toRemoteNotification()) + XCTAssertThrowsError(try notification(["profile-switch": "Sport"]).toRemoteNotification()) + XCTAssertThrowsError(try notification(["profile-delete": "Sport"]).toRemoteNotification()) + XCTAssertThrowsError(try notification(["profile-save": "Sport"]).toRemoteNotification()) + } + + func testReplyMessages() throws { + let switchAction = Action.profileSwitch(ProfileSwitchAction(name: "Sport", fingerprint: "abc")) + XCTAssertEqual(RemoteNotificationResponseManager.CommandType(action: switchAction).rawValue, "profile_switch") + XCTAssertEqual(switchAction.therapyProfileSuccessMessage, "Profile 'Sport' activated") + XCTAssertEqual(switchAction.therapyProfileFailureMessage(for: TherapyProfileError.noPump), "Profile 'Sport' not activated: No pump is configured, so the basal rates cannot be checked") + XCTAssertEqual(switchAction.description, "Profile Switch Sport") + + let saveAction = Action.profileSave(ProfileSaveAction(name: "Running", originalName: "Sport", baseFingerprint: "abc", values: try values())) + XCTAssertEqual(RemoteNotificationResponseManager.CommandType(action: saveAction).rawValue, "profile_save") + XCTAssertEqual(saveAction.therapyProfileSuccessMessage, "Profile 'Running' saved") + XCTAssertEqual(saveAction.therapyProfileFailureMessage(for: TherapyProfileError.conflict), "Profile 'Running' not saved: \(TherapyProfileError.conflict.localizedDescription)") + + let deleteAction = Action.profileDelete(ProfileDeleteAction(name: "Sport", fingerprint: "abc")) + XCTAssertEqual(RemoteNotificationResponseManager.CommandType(action: deleteAction).rawValue, "profile_delete") + XCTAssertEqual(deleteAction.therapyProfileSuccessMessage, "Profile 'Sport' deleted") + XCTAssertEqual(deleteAction.therapyProfileFailureMessage(for: TherapyProfileError.cannotDeleteActive), "Profile 'Sport' not deleted: The active profile cannot be deleted") + + let cancelOverride = Action.cancelTemporaryOverride(OverrideCancelAction(remoteAddress: "x")) + XCTAssertNil(cancelOverride.therapyProfileSuccessMessage) + XCTAssertNil(cancelOverride.therapyProfileFailureMessage(for: TherapyProfileError.noPump)) + XCTAssertNil(cancelOverride.therapyProfileResult(failure: nil)) + } + + func testReplyResultCodes() throws { + let switchAction = Action.profileSwitch(ProfileSwitchAction(name: "Sport", fingerprint: "abc")) + let saveAction = Action.profileSave(ProfileSaveAction(name: "Sport", originalName: nil, baseFingerprint: nil, values: try values())) + let deleteAction = Action.profileDelete(ProfileDeleteAction(name: "Sport", fingerprint: "abc")) + XCTAssertEqual(switchAction.therapyProfileResult(failure: nil), "activated") + XCTAssertEqual(saveAction.therapyProfileResult(failure: nil), "saved") + XCTAssertEqual(deleteAction.therapyProfileResult(failure: nil), "deleted") + XCTAssertEqual(switchAction.therapyProfileResult(failure: TherapyProfileError.conflict), "conflict") + XCTAssertEqual(deleteAction.therapyProfileResult(failure: TherapyProfileError.notFound("Sport")), "not_found") + XCTAssertEqual(saveAction.therapyProfileResult(failure: TherapyProfileError.duplicateName("Sport")), "rejected") + XCTAssertEqual(switchAction.therapyProfileResult(failure: TherapyProfileError.noPump), "rejected") + XCTAssertEqual(switchAction.therapyProfileResult(failure: RemoteProfileCommandError.missingFingerprint), "rejected") + XCTAssertEqual(switchAction.therapyProfileResult(failure: RemoteCommandValidator.NotificationValidationError.expiredNotification), "rejected") + + let reply = RemoteNotificationResponseManager.payload(commandType: .profileSwitch, success: false, message: "Profile 'Sport' not activated: x", result: "conflict", date: Date(timeIntervalSince1970: 0)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(reply)) as? [String: Any]) + XCTAssertEqual(json["result"] as? String, "conflict") + XCTAssertEqual(json["command_type"] as? String, "profile_switch") + XCTAssertEqual(json["command_status"] as? String, "failed") + XCTAssertEqual(((json["aps"] as? [String: Any])?["alert"] as? [String: Any])?["body"] as? String, "Profile 'Sport' not activated: x") + + let bolusReply = RemoteNotificationResponseManager.payload(commandType: .bolus, success: true, message: "ok", result: nil) + let bolusJSON = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(bolusReply)) as? [String: Any]) + XCTAssertNil(bolusJSON["result"]) + } + + func testRefusedProfileCommandReplyAndNote() throws { + let action = try notification(["profile-save": "Sport", "profile-save-values": compact]).toRemoteNotification().toRemoteAction() + XCTAssertEqual(RemoteNotificationResponseManager.CommandType(action: action).rawValue, "profile_save") + XCTAssertEqual(action.description, "Profile Save Sport") + XCTAssertEqual(action.therapyProfileFailureMessage(for: RemoteCommandValidator.NotificationValidationError.expiredNotification), "Profile 'Sport' not saved: Expired") + XCTAssertEqual(action.therapyProfileResult(failure: OTPManager.OTPValidationError.previouslyUsed(otp: "123456")), "rejected") + XCTAssertEqual(action.therapyProfileResult(failure: OTPManager.OTPValidationError.incorrectOTP(otp: "123456")), "rejected") + } + + func testSaveOfExistingProfileWithoutBaseFingerprintIsRejected() async throws { + let service = NightscoutService() + let remoteNotification = try notification(["profile-save": "Sport", "profile-save-original-name": "Sport", "profile-save-values": compact]).toRemoteNotification() + let action = remoteNotification.toRemoteAction() + do { + try await service.commandSourceV1(RemoteCommandSourceV1(otpManager: service.otpManager), handleAction: action, remoteNotification: remoteNotification) + XCTFail("Saved without a base fingerprint") + } catch { + guard case .missingFingerprint? = error as? RemoteProfileCommandError else { + return XCTFail("\(error)") + } + XCTAssertEqual(action.therapyProfileResult(failure: error), "rejected") + } + } + + func testProfileSetListsProfileCommandsWithRemoteNotificationsPumpAndMaximumBasalRate() { + XCTAssertEqual(NightscoutService.remoteProfileCommands, ["profile-switch", "profile-save", "profile-delete", "profile-scheduled-switches-save"]) + var settings = settings(date: Date(), active: nil, profiles: []) + settings.pumpSupportedBasalRates = [0, 0.05, 0.1] + XCTAssertNil(settings.profileSet?.remoteCommands, "No device token") + XCTAssertNil(settings.profileSet?.dictionaryRepresentation["remoteCommands"]) + settings.deviceToken = "token" + XCTAssertEqual(settings.profileSet?.remoteCommands, NightscoutService.remoteProfileCommands) + XCTAssertEqual(settings.profileSet?.dictionaryRepresentation["remoteCommands"] as? [String], NightscoutService.remoteProfileCommands) + settings.maximumBasalRatePerHour = nil + XCTAssertNil(settings.profileSet?.remoteCommands) + settings.maximumBasalRatePerHour = 3 + settings.pumpSupportedBasalRates = nil + XCTAssertNil(settings.profileSet?.remoteCommands) + } + + // MARK: Scheduled switches + + private let weekend = ScheduledProfileSwitch(id: "w", profileName: "Sport", weekday: 7, time: 25200, paused: true) + private let school = ScheduledProfileSwitch(id: "s", profileName: "Default", weekday: 2, time: 21600) + + func testProfileSetCarriesScheduledSwitchesAndTheirFingerprint() throws { + var stored = settings(date: Date(), active: nil, profiles: profiles) + stored.scheduledSwitches = [weekend, school] + let json = try XCTUnwrap(stored.profileSet?.dictionaryRepresentation) + let entries = try XCTUnwrap(json["scheduledSwitches"] as? [[String: Any]]) + XCTAssertEqual(entries.map { $0["profile"] as? String }, ["Sport", "Default"]) + XCTAssertEqual(entries[0]["paused"] as? Bool, true) + XCTAssertNil(entries[1]["paused"]) + XCTAssertEqual(entries[1]["weekday"] as? Int, 2) + XCTAssertEqual(entries[1]["time"] as? Int, 21600) + XCTAssertEqual(json["scheduledSwitchesFingerprint"] as? String, ScheduledProfileSwitches.fingerprint([weekend, school])) + + let none = try XCTUnwrap(settings(date: Date(), active: nil, profiles: profiles).profileSet?.dictionaryRepresentation) + XCTAssertEqual((none["scheduledSwitches"] as? [Any])?.count, 0) + XCTAssertEqual(none["scheduledSwitchesFingerprint"] as? String, "e3b0c44298fc1c14") + } + + func testSettlingScheduledSwitchesDoesNotChangeTheUploadedContent() throws { + var first = settings(date: Date(timeIntervalSince1970: 1_700_000_000), active: nil, profiles: profiles) + first.scheduledSwitches = [weekend, school] + first.scheduledSwitchesSettledAt = Date(timeIntervalSince1970: 1_700_000_000) + var settled = first + settled.date = first.date.addingTimeInterval(300) + settled.syncIdentifier = UUID() + settled.scheduledSwitchesSettledAt = settled.date + let result = NightscoutService.profileUploads(for: [first, settled], after: nil) + XCTAssertEqual(result.profileSets.count, 1) + XCTAssertTrue(result.switches.isEmpty) + } + + func testProfileSwitchNotesFollowTheTrigger() throws { + let start = Date(timeIntervalSince1970: 1_700_000_000) + func switchNotes(_ trigger: TherapyProfileSwitchTrigger?) throws -> String? { + var switched = settings(date: start.addingTimeInterval(60), active: "Sport", profiles: profiles) + switched.activeProfileSwitchTrigger = trigger + let result = NightscoutService.profileUploads(for: [settings(date: start, active: nil, profiles: profiles), switched], after: nil) + XCTAssertEqual(result.switches.count, 1) + let treatment = try XCTUnwrap(result.switches.first) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONSerialization.data(withJSONObject: treatment.dictionaryRepresentation)) as? [String: Any]) + return json["notes"] as? String + } + XCTAssertEqual(try switchNotes(.scheduled), "Scheduled") + XCTAssertEqual(try switchNotes(.remote), "Remote") + XCTAssertNil(try switchNotes(.local)) + XCTAssertNil(try switchNotes(nil)) + } + + func testScheduledSwitchesNotificationParsesAnArray() throws { + let entries: [[String: Any]] = [ + ["id": "w", "profile": "Sport", "weekday": 7, "time": 25200, "paused": true], + ["id": "s", "profile": "Default", "weekday": 2, "time": 21600], + ] + let remoteNotification = try notification(["profile-scheduled-switches-save": entries, "profile-scheduled-switches-base-fingerprint": "e3b0c44298fc1c14"]).toRemoteNotification() + guard case .scheduledSwitchesSave(let action) = remoteNotification.toRemoteAction() else { + return XCTFail("Not a scheduled switches save") + } + XCTAssertEqual(action.entries, [weekend, school]) + XCTAssertEqual(action.baseFingerprint, "e3b0c44298fc1c14") + XCTAssertTrue(remoteNotification.otpValidationRequired()) + + let cleared = try notification(["profile-scheduled-switches-save": [Any](), "profile-scheduled-switches-base-fingerprint": "abc"]).toRemoteNotification() + guard case .scheduledSwitchesSave(let clearAction) = cleared.toRemoteAction() else { + return XCTFail("Not a scheduled switches save") + } + XCTAssertEqual(clearAction.entries, []) + + XCTAssertThrowsError(try notification(["profile-scheduled-switches-save": "[]", "profile-scheduled-switches-base-fingerprint": "abc"]).toRemoteNotification()) + var withoutExpiration = notification(["profile-scheduled-switches-save": entries, "profile-scheduled-switches-base-fingerprint": "abc"]) + withoutExpiration["expiration"] = nil + XCTAssertThrowsError(try withoutExpiration.toRemoteNotification()) + } + + func testScheduledSwitchesReplyMessagesAndResultCodes() { + let action = Action.scheduledSwitchesSave(ScheduledSwitchesSaveAction(entries: [school], baseFingerprint: "abc")) + XCTAssertEqual(RemoteNotificationResponseManager.CommandType(action: action).rawValue, "profile_scheduled_switches_save") + XCTAssertEqual(action.therapyProfileSuccessMessage, "Scheduled switches saved") + XCTAssertEqual(action.therapyProfileFailureMessage(for: ScheduledProfileSwitchError.tooMany), "Scheduled switches not saved: \(ScheduledProfileSwitchError.tooMany.localizedDescription)") + XCTAssertEqual(action.therapyProfileResult(failure: nil), "saved") + XCTAssertEqual(action.therapyProfileResult(failure: ScheduledProfileSwitchError.conflict), "conflict") + XCTAssertEqual(action.therapyProfileResult(failure: TherapyProfileError.notFound("Sport")), "not_found") + XCTAssertEqual(action.therapyProfileResult(failure: ScheduledProfileSwitchError.slotTaken(school)), "rejected") + XCTAssertEqual(action.therapyProfileResult(failure: RemoteCommandValidator.NotificationValidationError.expiredNotification), "rejected") + } + + func testScheduledSwitchesSaveWithoutBaseFingerprintIsRejected() async throws { + let service = NightscoutService() + let remoteNotification = try notification(["profile-scheduled-switches-save": [[String: Any]]()]).toRemoteNotification() + let action = remoteNotification.toRemoteAction() + do { + try await service.commandSourceV1(RemoteCommandSourceV1(otpManager: service.otpManager), handleAction: action, remoteNotification: remoteNotification) + XCTFail("Saved without a base fingerprint") + } catch { + guard case .missingScheduledSwitchesFingerprint? = error as? RemoteProfileCommandError else { + return XCTFail("\(error)") + } + XCTAssertEqual(action.therapyProfileResult(failure: error), "rejected") + } + } + + func testRestoreImportsScheduledSwitches() throws { + var stored = settings(date: Date(), active: "Sport", profiles: profiles) + stored.scheduledSwitches = [weekend, school] + let imported = try XCTUnwrap(stored.profileSet?.importedTherapySettings) + XCTAssertEqual(imported.therapySettings.scheduledSwitches, [weekend, school]) + XCTAssertEqual(imported.skippedScheduledSwitches, []) + } + + func testRestoreLeavesOutEveryScheduledSwitchWhenAProfileIsNotImported() throws { + let profileSet = try fixture(Self.loopProfileDocument) + let switches: [[String: Any]] = [ + ["id": "a", "profile": "Weekend", "weekday": 7, "time": 25200], + ["id": "b", "profile": "Too Low", "weekday": 1, "time": 25200], + ] + let raw = profileSet.dictionaryRepresentation.merging(["scheduledSwitches": switches, "_id": "x"]) { $1 } + let imported = try XCTUnwrap(ProfileSet(rawValue: raw)?.importedTherapySettings) + XCTAssertNil(imported.therapySettings.scheduledSwitches) + XCTAssertEqual(imported.skippedScheduledSwitches.count, 2) + XCTAssertEqual(imported.skippedScheduledSwitches[1].reason, "The profile Too Low is not imported") + XCTAssertEqual(imported.therapySettings.therapyProfiles?.map(\.name), ["Default", "School", "Weekend"]) + } + + func testRestoreMapsTrioDefaultInScheduledSwitches() throws { + let profileSet = try fixture(Self.trioProfileDocument) + let switches: [[String: Any]] = [["id": "a", "profile": "default", "weekday": 2, "time": 21600]] + let raw = profileSet.dictionaryRepresentation.merging(["scheduledSwitches": switches, "_id": "x"]) { $1 } + let imported = try XCTUnwrap(ProfileSet(rawValue: raw)?.importedTherapySettings) + XCTAssertEqual(imported.therapySettings.scheduledSwitches?.map(\.profileName), ["Default"]) + } + + // MARK: Restore + + func testRestoreReadsActiveProfileAndImportsOthers() throws { + let profileSet = try XCTUnwrap(settings(date: Date(), active: "Sport", profiles: profiles).profileSet) + let imported = try XCTUnwrap(profileSet.importedTherapySettings) + let therapySettings = imported.therapySettings + XCTAssertEqual(therapySettings.basalRateSchedule?.items.map(\.value), [0.5]) + XCTAssertEqual(therapySettings.activeProfileName, "Sport") + XCTAssertEqual(therapySettings.therapyProfiles?.map(\.name), ["Default", "Sport"]) + XCTAssertEqual(therapySettings.therapyProfiles?[0].basalRateSchedule.items.map(\.value), [0.85, 1.0]) + XCTAssertEqual(imported.skippedProfiles, []) + } + + func testRestoreWithDefaultOnlyCarriesNoProfiles() throws { + let profileSet = try XCTUnwrap(settings(date: Date(), active: nil, profiles: []).profileSet) + let therapySettings = try XCTUnwrap(profileSet.importedTherapySettings?.therapySettings) + XCTAssertNil(therapySettings.therapyProfiles) + XCTAssertNil(therapySettings.activeProfileName) + } + + private func fixture(_ json: String) throws -> ProfileSet { + let raw = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + return try XCTUnwrap(ProfileSet(rawValue: raw)) + } + + func testRestoreKeepsAtMostTheProfileLimit() throws { + let entry = """ + {"dia": 6, "timezone": "ETC/GMT-1", "units": "mg/dL", + "basal": [{"time": "00:00", "timeAsSeconds": 0, "value": 1}], "carbratio": [{"time": "00:00", "timeAsSeconds": 0, "value": 10}], + "sens": [{"time": "00:00", "timeAsSeconds": 0, "value": 45}], + "target_low": [{"time": "00:00", "timeAsSeconds": 0, "value": 100}], "target_high": [{"time": "00:00", "timeAsSeconds": 0, "value": 110}]} + """ + let names = (1...24).map { String(format: "P%02d", $0) } + let store = (["Default"] + names).map { "\"\($0)\": \(entry)" }.joined(separator: ",") + let json = """ + {"_id": "6720d2c1a1b2c3d4e5f6071a", "defaultProfile": "P24", "startDate": "2026-11-07T12:00:00.000Z", + "units": "mg/dL", "enteredBy": "Loop", "store": {\(store)}} + """ + let imported = try XCTUnwrap(try fixture(json).importedTherapySettings) + let profiles = try XCTUnwrap(imported.therapySettings.therapyProfiles) + XCTAssertEqual(profiles.count, TherapyProfileValidator.maximumProfileCount) + XCTAssertTrue(profiles.contains { $0.name == "P24" }) + XCTAssertTrue(profiles.contains(where: \.isDefault)) + XCTAssertEqual(imported.skippedProfiles.map(\.name), ["P19", "P20", "P21", "P22", "P23"]) + } + + func testRestoreRefusesAnActiveProfileWhoseValuesAreOutOfBounds() throws { + // Saved by the Nightscout profile editor on an mmol/L site: the mg/dL values now claim mmol/L. + let json = """ + {"_id": "6720d2c1a1b2c3d4e5f60719", "defaultProfile": "Unwell", "startDate": "2026-11-07T12:00:00.000Z", + "units": "mmol", "enteredBy": "Loop", + "store": { + "Default": {"dia": 6, "timezone": "ETC/GMT-1", "startDate": "1970-01-01T00:00:00.000Z", + "basal": [{"time": "00:00", "timeAsSeconds": 0, "value": 1}], "carbratio": [{"time": "00:00", "timeAsSeconds": 0, "value": 10}], + "sens": [{"time": "00:00", "timeAsSeconds": 0, "value": 45}], + "target_low": [{"time": "00:00", "timeAsSeconds": 0, "value": 100}], "target_high": [{"time": "00:00", "timeAsSeconds": 0, "value": 110}]}, + "Unwell": {"dia": 6, "timezone": "ETC/GMT-1", "units": "mmol", "delay": 20, + "basal": [{"time": "00:00", "timeAsSeconds": 0, "value": 1.25}], "carbratio": [{"time": "00:00", "timeAsSeconds": 0, "value": 9}], + "sens": [{"time": "00:00", "timeAsSeconds": 0, "value": 40}], + "target_low": [{"time": "00:00", "timeAsSeconds": 0, "value": 90}], "target_high": [{"time": "00:00", "timeAsSeconds": 0, "value": 100}]}}} + """ + XCTAssertNil(try fixture(json).importedTherapySettings) + } + + func testRestoreRefusesAnActiveProfileWithAnUnreadableSchedule() throws { + // The sensitivities have string values, which are dropped, and the other schedules are out of bounds. + let json = """ + {"_id": "6720d2c1a1b2c3d4e5f6071b", "defaultProfile": "Unread", "startDate": "2026-11-07T12:00:00.000Z", + "units": "mmol", "enteredBy": "Loop", + "store": { + "Unread": {"dia": 6, "timezone": "ETC/GMT-1", "units": "mmol", + "basal": [{"time": "00:00", "timeAsSeconds": 0, "value": 1.25}], "carbratio": [{"time": "00:00", "timeAsSeconds": 0, "value": 9}], + "sens": [{"time": "00:00", "timeAsSeconds": 0, "value": "40"}], + "target_low": [{"time": "00:00", "timeAsSeconds": 0, "value": 90}], "target_high": [{"time": "00:00", "timeAsSeconds": 0, "value": 100}]}}} + """ + XCTAssertNil(try fixture(json).importedTherapySettings) + } + + func testRestoreRefusesAnActiveProfileWhoseTargetStartTimesDiffer() throws { + let json = """ + {"_id": "6720d2c1a1b2c3d4e5f6071c", "defaultProfile": "Default", "startDate": "2026-11-07T12:00:00.000Z", + "units": "mg/dL", "enteredBy": "Loop", + "store": { + "Default": {"dia": 6, "timezone": "ETC/GMT-1", "units": "mg/dL", + "basal": [{"time": "00:00", "timeAsSeconds": 0, "value": 1}], "carbratio": [{"time": "00:00", "timeAsSeconds": 0, "value": 10}], + "sens": [{"time": "00:00", "timeAsSeconds": 0, "value": 45}], + "target_low": [{"time": "00:00", "timeAsSeconds": 0, "value": 100}, {"time": "06:00", "timeAsSeconds": 21600, "value": 105}], + "target_high": [{"time": "00:00", "timeAsSeconds": 0, "value": 110}, {"time": "07:00", "timeAsSeconds": 25200, "value": 115}]}}} + """ + XCTAssertNil(try fixture(json).importedTherapySettings) + } + + func testRestoreListsAnActiveProfileMissingFromTheDocument() throws { + let document = Self.loopProfileDocument.replacingOccurrences(of: "\"defaultProfile\": \"School\"", with: "\"defaultProfile\": \"Gone\"") + XCTAssertNotEqual(document, Self.loopProfileDocument) + let imported = try XCTUnwrap(try fixture(document).importedTherapySettings) + XCTAssertEqual(imported.skippedProfiles.first?.name, "Gone") + XCTAssertNil(imported.therapySettings.activeProfileName) + } + + func testRestoreFromLoopDocument() throws { + let imported = try XCTUnwrap(try fixture(Self.loopProfileDocument).importedTherapySettings) + let therapySettings = imported.therapySettings + XCTAssertEqual(imported.skippedProfiles, [.init(name: "Too Low", reason: TherapyProfileError.correctionRangeBelowSuspendThreshold.localizedDescription)]) + XCTAssertEqual(therapySettings.activeProfileName, "School") + XCTAssertEqual(therapySettings.therapyProfiles?.map(\.name), ["Default", "School", "Weekend"]) + XCTAssertEqual(therapySettings.suspendThreshold?.quantity.doubleValue(for: .milligramsPerDeciliter), 90) + XCTAssertEqual(therapySettings.maximumBasalRatePerHour, 3) + XCTAssertEqual(therapySettings.maximumBolus, 8) + + let live = try XCTUnwrap(TherapyProfile(name: "School", + glucoseTargetRangeSchedule: therapySettings.glucoseTargetRangeSchedule, + carbRatioSchedule: therapySettings.carbRatioSchedule, + basalRateSchedule: therapySettings.basalRateSchedule, + insulinSensitivitySchedule: therapySettings.insulinSensitivitySchedule)) + let school = try XCTUnwrap(therapySettings.therapyProfiles?.first { $0.name == "School" }) + XCTAssertTrue(live.hasSameValues(as: school)) + XCTAssertEqual(school.basalRateSchedule.items.map(\.value), [0.8, 1.1]) + XCTAssertEqual(school.basalRateSchedule.items.map(\.startTime), [0, 25200]) + let weekend = try XCTUnwrap(therapySettings.therapyProfiles?.first { $0.name == "Weekend" }) + XCTAssertEqual(weekend.insulinSensitivitySchedule.schedule(for: .milligramsPerDeciliter)?.items.map(\.value), [60]) + XCTAssertEqual(weekend.basalRateSchedule.timeZone, TimeZone(secondsFromGMT: 3600)) + } + + func testRestoreWithInvalidActiveNameImportsLiveValuesAsDefault() throws { + let document = Self.loopProfileDocument.replacingOccurrences(of: "School", with: "Mon.Fri") + let imported = try XCTUnwrap(try fixture(document).importedTherapySettings) + let therapySettings = imported.therapySettings + XCTAssertEqual(imported.skippedProfiles.map(\.name), ["Mon.Fri", "Too Low"]) + XCTAssertEqual(imported.skippedProfiles.first?.reason, TherapyProfileError.invalidNameCharacter(".").localizedDescription + "; its values are imported as the Default profile and replace the stored Default profile") + XCTAssertNil(therapySettings.activeProfileName) + XCTAssertEqual(therapySettings.therapyProfiles?.map(\.name), ["Default", "Weekend"]) + XCTAssertEqual(therapySettings.basalRateSchedule?.items.map(\.value), [0.8, 1.1]) + XCTAssertEqual(therapySettings.therapyProfiles?.first?.basalRateSchedule.items.map(\.value), [0.8, 1.1]) + } + + func testRestoreWithInvalidActiveNameAndNoStoredDefaultImportsLiveValuesAsDefault() throws { + let profileSet = try fixture(Self.loopProfileDocument.replacingOccurrences(of: "School", with: "Mon.Fri")) + var store = try XCTUnwrap(profileSet.dictionaryRepresentation["store"] as? [String: Any]) + store["Default"] = nil + let raw = profileSet.dictionaryRepresentation.merging(["store": store, "_id": "x"]) { $1 } + let imported = try XCTUnwrap(ProfileSet(rawValue: raw)?.importedTherapySettings) + XCTAssertEqual(imported.skippedProfiles.map(\.name), ["Mon.Fri", "Too Low"]) + XCTAssertEqual(imported.skippedProfiles.first?.reason, TherapyProfileError.invalidNameCharacter(".").localizedDescription + "; its values are imported as the Default profile") + XCTAssertNil(imported.therapySettings.activeProfileName) + XCTAssertEqual(imported.therapySettings.therapyProfiles?.map(\.name), ["Default", "Weekend"]) + XCTAssertEqual(imported.therapySettings.therapyProfiles?.first?.basalRateSchedule.items.map(\.value), [0.8, 1.1]) + } + + func testRestoreFromTrioDocument() throws { + let imported = try XCTUnwrap(try fixture(Self.trioProfileDocument).importedTherapySettings) + let therapySettings = imported.therapySettings + XCTAssertEqual(imported.skippedProfiles.map(\.name), ["Off Grid", "Wild"]) + XCTAssertEqual(imported.skippedProfiles.first?.reason, "Basal Rates has a start time that is not on a half hour") + XCTAssertEqual(therapySettings.therapyProfiles?.map(\.name), ["Default", "Sport"]) + XCTAssertEqual(therapySettings.activeProfileName, "Sport") + XCTAssertNil(therapySettings.suspendThreshold) + XCTAssertNil(therapySettings.maximumBasalRatePerHour) + XCTAssertNil(therapySettings.overridePresets) + + // Values are in mmol/L in the document. + let mgdl = LoopUnit.milligramsPerDeciliter + XCTAssertEqual(therapySettings.basalRateSchedule?.items.map(\.value), [0.5, 0.6]) + XCTAssertEqual(therapySettings.glucoseTargetRangeSchedule?.unit, .millimolesPerLiter) + XCTAssertEqual(therapySettings.glucoseTargetRangeSchedule?.schedule(for: mgdl)?.items.first?.value.minValue ?? 0, 120.7, accuracy: 0.5) + let defaultProfile = try XCTUnwrap(therapySettings.therapyProfiles?.first) + XCTAssertEqual(defaultProfile.basalRateSchedule.items.map(\.value), [0.85, 1.0]) + XCTAssertEqual(defaultProfile.insulinSensitivitySchedule.schedule(for: mgdl)?.items.first?.value ?? 0, 50.4, accuracy: 0.5) + XCTAssertEqual(defaultProfile.carbRatioSchedule.items.map(\.value), [10]) + XCTAssertEqual(defaultProfile.basalRateSchedule.timeZone.identifier, "Europe/Stockholm") + } + + func testRestoreFromTrioDocumentWithDefaultActive() throws { + let profileSet = try fixture(Self.trioProfileDocument) + let raw = profileSet.dictionaryRepresentation.merging(["defaultProfile": "default", "_id": "x"]) { $1 } + let imported = try XCTUnwrap(ProfileSet(rawValue: raw)?.importedTherapySettings) + XCTAssertNil(imported.therapySettings.activeProfileName) + XCTAssertEqual(imported.therapySettings.therapyProfiles?.map(\.name), ["Default", "Sport"]) + XCTAssertEqual(imported.therapySettings.basalRateSchedule?.items.map(\.value), [0.85, 1.0]) + } + + /// A profile document as Loop uploads it: Default, School (active) and Weekend, and a profile whose correction + /// range is below the glucose safety limit. + private static let loopProfileDocument = """ + {"_id": "6720d2c1a1b2c3d4e5f60718", "defaultProfile": "School", "startDate": "2026-11-07T12:00:00Z", "mills": "1793966400000", + "units": "mg/dL", "enteredBy": "Loop", "isAPNSProduction": false, "timeZone": "Europe/Stockholm", + "loopSettings": {"dosingEnabled": true, "overridePresets": [], "minimumBGGuard": 90, "preMealTargetRange": [80, 90], + "maximumBasalRatePerHour": 3, "maximumBolus": 8, "deviceToken": "token", "dosingStrategy": "automaticBolus"}, + "profileEditLimits": {"units": "mg/dL", "maxBasal": 3, "basalRates": [0.05, 0.1], "maxSegments": 48, "segmentSeconds": 1800, + "carbRatio": {"min": 2, "max": 150, "step": 0.1, "recommendedMin": 4, "recommendedMax": 28}, + "sensitivity": {"min": 10, "max": 500, "step": 1}, "target": {"min": 90, "max": 180, "step": 1}}, + "store": { + "Default": {"dia": 6, "carbs_hr": "0", "delay": "0", "timezone": "ETC/GMT-1", "units": "mg/dL", + "basal": [{"time": "00:00", "value": 0.85, "timeAsSeconds": 0}, {"time": "06:00", "value": 1.0, "timeAsSeconds": 21600}], + "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}]}, + "School": {"dia": 6, "carbs_hr": "0", "delay": "0", "timezone": "ETC/GMT-1", "units": "mg/dL", + "basal": [{"time": "00:00", "value": 0.8, "timeAsSeconds": 0}, {"time": "07:00", "value": 1.1, "timeAsSeconds": 25200}], + "carbratio": [{"time": "00:00", "value": 9, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": 45, "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": 100, "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": 105, "timeAsSeconds": 0}]}, + "Weekend": {"dia": 6, "carbs_hr": "0", "delay": "0", "timezone": "ETC/GMT-1", "units": "mg/dL", + "basal": [{"time": "00:00", "value": 0.6, "timeAsSeconds": 0}], + "carbratio": [{"time": "00:00", "value": 12, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": 60, "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": 110, "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": 120, "timeAsSeconds": 0}]}, + "Too Low": {"dia": 6, "carbs_hr": "0", "delay": "0", "timezone": "ETC/GMT-1", "units": "mg/dL", + "basal": [{"time": "00:00", "value": 0.6, "timeAsSeconds": 0}], + "carbratio": [{"time": "00:00", "value": 12, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": 60, "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": 88, "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": 95, "timeAsSeconds": 0}]} + }} + """ + + /// A profile document as Trio uploads it, in mmol/L: Default under `default`, Sport (active), a profile with a + /// basal rate starting off the half-hour grid, and one with an insulin sensitivity outside the guardrails. + private static let trioProfileDocument = """ + {"_id": "6720d2c1a1b2c3d4e5f60719", "defaultProfile": "Sport", "startDate": "2026-11-07T12:00:00.000Z", "mills": 1793966400000, + "units": "mmol", "enteredBy": "Trio", "bundleIdentifier": "org.nightscout.TEAMID.trio", "deviceToken": "token", + "isAPNSProduction": false, "teamID": "TEAMID", "expirationDate": "2027-02-01T00:00:00.000Z", + "overridePresets": [{"name": "Exercise", "duration": 60, "percentage": 80, "target": 7.8}], + "remoteCommands": ["bolus", "temp_target", "switch_profile", "save_profile", "delete_profile"], + "profileEditLimits": {"units": "mmol/L", "maxBasal": 2, "basalRates": [0.05, 0.1], "basalMinimum": 0.05, + "carbRatio": {"min": 1, "max": 50, "step": 0.1}, "sensitivity": {"min": 0.5, "max": 30, "step": 0.1}, + "target": {"min": 4, "max": 10, "step": 0.1}, "maxSegments": 48, "segmentSeconds": 1800}, + "store": { + "default": {"dia": 6, "carbs_hr": 20, "delay": 0, "timezone": "Europe/Stockholm", "units": "mmol", + "basal": [{"time": "00:00", "value": 0.85, "timeAsSeconds": 0}, {"time": "06:00", "value": 1.0, "timeAsSeconds": 21600}], + "carbratio": [{"time": "00:00", "value": 10, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": 2.8, "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": 5.6, "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": 5.6, "timeAsSeconds": 0}]}, + "Sport": {"dia": 6, "carbs_hr": 16, "delay": 0, "timezone": "Europe/Stockholm", "units": "mmol", + "basal": [{"time": "00:00", "value": 0.5, "timeAsSeconds": 0}, {"time": "06:00", "value": 0.6, "timeAsSeconds": 21600}], + "carbratio": [{"time": "00:00", "value": 12, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": 3.3, "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": 6.7, "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": 6.7, "timeAsSeconds": 0}]}, + "Off Grid": {"dia": 6, "carbs_hr": 20, "delay": 0, "timezone": "Europe/Stockholm", "units": "mmol", + "basal": [{"time": "00:00", "value": 0.85, "timeAsSeconds": 0}, {"time": "00:15", "value": 0.9, "timeAsSeconds": 900}], + "carbratio": [{"time": "00:00", "value": 10, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": 2.8, "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": 5.6, "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": 5.6, "timeAsSeconds": 0}]}, + "Wild": {"dia": 6, "carbs_hr": 20, "delay": 0, "timezone": "Europe/Stockholm", "units": "mmol", + "basal": [{"time": "00:00", "value": 0.85, "timeAsSeconds": 0}], + "carbratio": [{"time": "00:00", "value": 10, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": 0.3, "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": 5.6, "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": 5.6, "timeAsSeconds": 0}]} + }} + """ +}