diff --git a/Sources/NightscoutKit/Models/ProfileEditLimits.swift b/Sources/NightscoutKit/Models/ProfileEditLimits.swift new file mode 100644 index 0000000..0c55dab --- /dev/null +++ b/Sources/NightscoutKit/Models/ProfileEditLimits.swift @@ -0,0 +1,134 @@ +// +// ProfileEditLimits.swift +// NightscoutKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// The bounds a remote editor of a profile set's profiles must keep to, as set by the uploader. +/// Glucose limits and steps are in `units`. +public struct ProfileEditLimits: Equatable { + typealias RawValue = [String: Any] + + public struct ValueLimits: Equatable { + typealias RawValue = [String: Any] + + public let min: Double + public let max: Double + public let step: Double + /// Bounds outside which the uploader warns, when it has any. + public let recommendedMin: Double? + public let recommendedMax: Double? + + public init(min: Double, max: Double, step: Double, recommendedMin: Double? = nil, recommendedMax: Double? = nil) { + self.min = min + self.max = max + self.step = step + self.recommendedMin = recommendedMin + self.recommendedMax = recommendedMax + } + + public var dictionaryRepresentation: [String: Any] { + var rval: [String: Any] = [ + "min": min, + "max": max, + "step": step, + ] + rval["recommendedMin"] = recommendedMin + rval["recommendedMax"] = recommendedMax + return rval + } + + init?(rawValue: RawValue) { + guard + let min = rawValue["min"] as? Double, + let max = rawValue["max"] as? Double, + let step = rawValue["step"] as? Double + else { + return nil + } + + self.min = min + self.max = max + self.step = step + self.recommendedMin = rawValue["recommendedMin"] as? Double + self.recommendedMax = rawValue["recommendedMax"] as? Double + } + } + + public let units: String + public let maxBasal: Double + /// The basal rates the pump supports, up to `maxBasal`. + public let basalRates: [Double] + /// The lowest basal rate allowed, when it is above the lowest supported rate. + public let basalMinimum: Double? + /// The most basal rate entries the pump can store, when it has a limit. + public let maxBasalSegments: Int? + public let carbRatio: ValueLimits + public let sensitivity: ValueLimits + public let target: ValueLimits + /// The most entries a schedule can have. + public let maxSegments: Int + /// Schedule entries start at a multiple of this many seconds from midnight. + public let segmentSeconds: Int + + public init(units: String, maxBasal: Double, basalRates: [Double], basalMinimum: Double? = nil, maxBasalSegments: Int? = nil, carbRatio: ValueLimits, sensitivity: ValueLimits, target: ValueLimits, maxSegments: Int, segmentSeconds: Int) { + self.units = units + self.maxBasal = maxBasal + self.basalRates = basalRates + self.basalMinimum = basalMinimum + self.maxBasalSegments = maxBasalSegments + self.carbRatio = carbRatio + self.sensitivity = sensitivity + self.target = target + self.maxSegments = maxSegments + self.segmentSeconds = segmentSeconds + } + + public var dictionaryRepresentation: [String: Any] { + var rval: [String: Any] = [ + "units": units, + "maxBasal": maxBasal, + "basalRates": basalRates, + "carbRatio": carbRatio.dictionaryRepresentation, + "sensitivity": sensitivity.dictionaryRepresentation, + "target": target.dictionaryRepresentation, + "maxSegments": maxSegments, + "segmentSeconds": segmentSeconds, + ] + rval["basalMinimum"] = basalMinimum + rval["maxBasalSegments"] = maxBasalSegments + return rval + } + + init?(rawValue: RawValue) { + guard + let units = rawValue["units"] as? String, + let maxBasal = rawValue["maxBasal"] as? Double, + let basalRates = rawValue["basalRates"] as? [Double], + let carbRatioRaw = rawValue["carbRatio"] as? ValueLimits.RawValue, + let carbRatio = ValueLimits(rawValue: carbRatioRaw), + let sensitivityRaw = rawValue["sensitivity"] as? ValueLimits.RawValue, + let sensitivity = ValueLimits(rawValue: sensitivityRaw), + let targetRaw = rawValue["target"] as? ValueLimits.RawValue, + let target = ValueLimits(rawValue: targetRaw), + let maxSegments = rawValue["maxSegments"] as? Int, + let segmentSeconds = rawValue["segmentSeconds"] as? Int + else { + return nil + } + + self.units = units + self.maxBasal = maxBasal + self.basalRates = basalRates + self.basalMinimum = rawValue["basalMinimum"] as? Double + self.maxBasalSegments = rawValue["maxBasalSegments"] as? Int + self.carbRatio = carbRatio + self.sensitivity = sensitivity + self.target = target + self.maxSegments = maxSegments + self.segmentSeconds = segmentSeconds + } +} diff --git a/Sources/NightscoutKit/Models/ProfileSet.swift b/Sources/NightscoutKit/Models/ProfileSet.swift index 7ed041c..b2417d6 100644 --- a/Sources/NightscoutKit/Models/ProfileSet.swift +++ b/Sources/NightscoutKit/Models/ProfileSet.swift @@ -16,7 +16,7 @@ fileprivate let reverseTimeZoneMap = timeZoneMap.reduce(into: [String: String]() } public class ProfileSet { - typealias RawValue = [String: Any] + public typealias RawValue = [String: Any] public typealias ProfileStore = [String: Profile] typealias ProfileStoreRawValue = [String: Profile.RawValue] @@ -44,15 +44,24 @@ public class ProfileSet { init?(rawValue: RawValue) { guard - let timeAsSeconds = rawValue["timeAsSeconds"] as? Double, + let offset = (rawValue["timeAsSeconds"] as? Double) ?? (rawValue["time"] as? String).flatMap(Self.offset(fromTime:)), let value = rawValue["value"] as? Double else { return nil } - self.offset = TimeInterval(timeAsSeconds) + self.offset = offset self.value = value } + + /// The offset of an "HH:mm" time. + private static func offset(fromTime time: String) -> TimeInterval? { + let parts = time.split(separator: ":") + guard parts.count == 2, let hours = Int(parts[0]), let minutes = Int(parts[1]) else { + return nil + } + return TimeInterval(hours: Double(hours)) + TimeInterval(minutes: Double(minutes)) + } } public struct Profile { @@ -97,8 +106,7 @@ public class ProfileSet { init?(rawValue: RawValue) { guard let nsTimezoneIdentifier = rawValue["timezone"] as? String, - let timeZoneIdentifier = reverseTimeZoneMap[nsTimezoneIdentifier], - let timeZone = TimeZone(identifier: timeZoneIdentifier), + let timeZone = TimeZone(identifier: reverseTimeZoneMap[nsTimezoneIdentifier] ?? nsTimezoneIdentifier), let diaHours = rawValue["dia"] as? Double, let sensitivityRaw = rawValue["sens"] as? [ScheduleItem.RawValue], let carbratioRaw = rawValue["carbratio"] as? [ScheduleItem.RawValue], @@ -125,14 +133,23 @@ public class ProfileSet { public let enteredBy: String public let defaultProfile: String public let store: ProfileStore - public let settings: LoopSettings + /// Loop's settings, or nil for a profile set another uploader wrote. + public let settings: LoopSettings? public let syncIdentifier: String public let isAPNSProduction: Bool? /// The uploader's time zone identifier, e.g. "America/Chicago". The profiles' own /// `timezone` is the fixed offset their schedules use. public let timeZone: String? + /// The bounds a remote editor of the store's profiles must keep to. + public let profileEditLimits: ProfileEditLimits? + /// The remote commands the uploader accepts for the store's profiles, e.g. "profile-switch". + public let remoteCommands: [String]? + /// The weekly starts of the store's profiles. + public let scheduledSwitches: [ScheduledSwitch]? + /// The fingerprint of `scheduledSwitches` that a remote editor of the list sends back. + public let scheduledSwitchesFingerprint: String? - public init(startDate: Date, units: String, enteredBy: String, defaultProfile: String, store: ProfileStore, settings: LoopSettings, syncIdentifier: String, isAPNSProduction: Bool? = nil, timeZone: String? = nil) { + public init(startDate: Date, units: String, enteredBy: String, defaultProfile: String, store: ProfileStore, settings: LoopSettings?, syncIdentifier: String, isAPNSProduction: Bool? = nil, timeZone: String? = nil, profileEditLimits: ProfileEditLimits? = nil, remoteCommands: [String]? = nil, scheduledSwitches: [ScheduledSwitch]? = nil, scheduledSwitchesFingerprint: String? = nil) { self.startDate = startDate self.units = units self.enteredBy = enteredBy @@ -142,6 +159,10 @@ public class ProfileSet { self.syncIdentifier = syncIdentifier self.isAPNSProduction = isAPNSProduction self.timeZone = timeZone + self.profileEditLimits = profileEditLimits + self.remoteCommands = remoteCommands + self.scheduledSwitches = scheduledSwitches + self.scheduledSwitchesFingerprint = scheduledSwitchesFingerprint } public var dictionaryRepresentation: [String: Any] { @@ -157,10 +178,13 @@ public class ProfileSet { "mills": mills, "units": units, "enteredBy": enteredBy, - "loopSettings": settings.dictionaryRepresentation, "store": dictProfiles ] + if let settings = settings { + rval["loopSettings"] = settings.dictionaryRepresentation + } + if let isAPNSProduction = isAPNSProduction { rval["isAPNSProduction"] = isAPNSProduction } @@ -169,10 +193,26 @@ public class ProfileSet { rval["timeZone"] = timeZone } + if let profileEditLimits = profileEditLimits { + rval["profileEditLimits"] = profileEditLimits.dictionaryRepresentation + } + + if let remoteCommands = remoteCommands { + rval["remoteCommands"] = remoteCommands + } + + if let scheduledSwitches = scheduledSwitches { + rval["scheduledSwitches"] = scheduledSwitches.map(\.dictionaryRepresentation) + } + + if let scheduledSwitchesFingerprint = scheduledSwitchesFingerprint { + rval["scheduledSwitchesFingerprint"] = scheduledSwitchesFingerprint + } + return rval } - init?(rawValue: RawValue) { + public init?(rawValue: RawValue) { guard let startDateStr = rawValue["startDate"] as? String, let startDate = TimeFormat.dateFromTimestamp(startDateStr), @@ -180,8 +220,6 @@ public class ProfileSet { let enteredBy = rawValue["enteredBy"] as? String, let defaultProfile = rawValue["defaultProfile"] as? String, let storeRaw = rawValue["store"] as? ProfileStoreRawValue, - let settingsRaw = rawValue["loopSettings"] as? LoopSettings.RawValue, - let settings = LoopSettings(rawValue: settingsRaw), let syncIdentifier = rawValue["_id"] as? String else { return nil @@ -192,9 +230,65 @@ public class ProfileSet { self.enteredBy = enteredBy self.defaultProfile = defaultProfile self.store = storeRaw.compactMapValues { Profile(rawValue: $0) } - self.settings = settings + self.settings = (rawValue["loopSettings"] as? LoopSettings.RawValue).flatMap { LoopSettings(rawValue: $0) } self.syncIdentifier = syncIdentifier self.isAPNSProduction = rawValue["isAPNSProduction"] as? Bool self.timeZone = rawValue["timeZone"] as? String + self.profileEditLimits = (rawValue["profileEditLimits"] as? ProfileEditLimits.RawValue).flatMap { ProfileEditLimits(rawValue: $0) } + self.remoteCommands = rawValue["remoteCommands"] as? [String] + self.scheduledSwitches = (rawValue["scheduledSwitches"] as? [ScheduledSwitch.RawValue])?.compactMap { ScheduledSwitch(rawValue: $0) } + self.scheduledSwitchesFingerprint = rawValue["scheduledSwitchesFingerprint"] as? String + } +} + +extension ProfileSet { + /// A weekly start of the store profile named `profile`, `time` seconds after midnight in the profiles' time zone + /// on `weekday` (1–7, Sunday = 1). It stays active until the next start. + public struct ScheduledSwitch: Equatable { + typealias RawValue = [String: Any] + + public let id: String + public let profile: String + public let weekday: Int + public let time: Int + /// A paused start does not run. + public let paused: Bool + + public init(id: String, profile: String, weekday: Int, time: Int, paused: Bool = false) { + self.id = id + self.profile = profile + self.weekday = weekday + self.time = time + self.paused = paused + } + + public var dictionaryRepresentation: [String: Any] { + var rval: [String: Any] = [ + "id": id, + "profile": profile, + "weekday": weekday, + "time": time, + ] + if paused { + rval["paused"] = true + } + return rval + } + + init?(rawValue: RawValue) { + guard + let id = rawValue["id"] as? String, + let profile = rawValue["profile"] as? String, + let weekday = rawValue["weekday"] as? Int, + let time = rawValue["time"] as? Int + else { + return nil + } + self.id = id + self.profile = profile + self.weekday = weekday + self.time = time + self.paused = rawValue["paused"] as? Bool ?? false + } } } diff --git a/Sources/NightscoutKit/Models/Treatments/NightscoutTreatment.swift b/Sources/NightscoutKit/Models/Treatments/NightscoutTreatment.swift index 0085ffb..467c423 100644 --- a/Sources/NightscoutKit/Models/Treatments/NightscoutTreatment.swift +++ b/Sources/NightscoutKit/Models/Treatments/NightscoutTreatment.swift @@ -24,6 +24,7 @@ public enum TreatmentType: String { case note = "Note" case sensorStart = "Sensor Start" case siteChange = "Site Change" + case profileSwitch = "Profile Switch" public var classType: NightscoutTreatment.Type { switch self { @@ -41,6 +42,8 @@ public enum TreatmentType: String { return BGCheckNightscoutTreatment.self case .note: return NoteNightscoutTreatment.self + case .profileSwitch: + return ProfileSwitchNightscoutTreatment.self default: return NightscoutTreatment.self } diff --git a/Sources/NightscoutKit/Models/Treatments/ProfileSwitchNightscoutTreatment.swift b/Sources/NightscoutKit/Models/Treatments/ProfileSwitchNightscoutTreatment.swift new file mode 100644 index 0000000..4658b80 --- /dev/null +++ b/Sources/NightscoutKit/Models/Treatments/ProfileSwitchNightscoutTreatment.swift @@ -0,0 +1,37 @@ +// +// ProfileSwitchNightscoutTreatment.swift +// NightscoutKit +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// A switch to the store profile named `profile`, in effect until the next switch when `duration` is 0. +public class ProfileSwitchNightscoutTreatment: NightscoutTreatment { + + public let profile: String + public let duration: TimeInterval + + public init(timestamp: Date, enteredBy: String, profile: String, duration: TimeInterval = 0, notes: String? = nil, id: String? = nil, syncIdentifier: String? = nil) { + self.profile = profile + self.duration = duration + super.init(timestamp: timestamp, enteredBy: enteredBy, notes: notes, id: id, eventType: .profileSwitch, syncIdentifier: syncIdentifier) + } + + required public init?(_ entry: [String: Any]) { + guard let profile = entry["profile"] as? String else { + return nil + } + self.profile = profile + self.duration = TimeInterval(minutes: (entry["duration"] as? Double) ?? 0) + super.init(entry) + } + + override public var dictionaryRepresentation: [String: Any] { + var rval = super.dictionaryRepresentation + rval["profile"] = profile + rval["duration"] = duration.minutes + return rval + } +} diff --git a/Tests/NightscoutKitTests/NightscoutKitTests.swift b/Tests/NightscoutKitTests/NightscoutKitTests.swift index e128c1e..74faefa 100644 --- a/Tests/NightscoutKitTests/NightscoutKitTests.swift +++ b/Tests/NightscoutKitTests/NightscoutKitTests.swift @@ -67,4 +67,123 @@ final class NightscoutKitTests: XCTestCase { XCTAssertEqual("pumpDataTooOld", json["failureCode"] as? String) XCTAssertEqual("pumpDataTooOld", LoopStatus(rawValue: json)?.failureCode) } + + func testProfileEditLimitsAreSerializedAtTopLevel() throws { + let limits = ProfileEditLimits( + units: "mg/dL", + maxBasal: 3.0, + basalRates: [0.05, 0.1, 0.15], + maxBasalSegments: 24, + carbRatio: .init(min: 2, max: 150, step: 0.1, recommendedMin: 4, recommendedMax: 28), + sensitivity: .init(min: 10, max: 500, step: 1, recommendedMin: 16, recommendedMax: 399), + target: .init(min: 87, max: 180, step: 1), + maxSegments: 48, + segmentSeconds: 1800) + let schedule = [ProfileSet.ScheduleItem(offset: .hours(0), value: 1)] + let profile = ProfileSet.Profile(timezone: TimeZone(secondsFromGMT: 0)!, dia: .hours(6), sensitivity: schedule, carbratio: schedule, basal: schedule, targetLow: schedule, targetHigh: schedule, units: "mg/dL") + let settings = LoopSettings(dosingEnabled: true, overridePresets: [], scheduleOverride: nil, minimumBGGuard: nil, preMealTargetRange: nil, maximumBasalRatePerHour: nil, maximumBolus: nil, deviceToken: "token", bundleIdentifier: "com.example.loop", dosingStrategy: "automaticBolus") + let profileSet = ProfileSet(startDate: Date(), units: "mg/dL", enteredBy: "Loop", defaultProfile: "Sport", store: ["Default": profile, "Sport": profile], settings: settings, syncIdentifier: "sync", profileEditLimits: limits) + + var json = profileSet.dictionaryRepresentation + let limitsJSON = try XCTUnwrap(json["profileEditLimits"] as? [String: Any]) + XCTAssertEqual(limitsJSON["maxBasal"] as? Double, 3.0) + XCTAssertEqual(limitsJSON["segmentSeconds"] as? Int, 1800) + XCTAssertEqual((limitsJSON["carbRatio"] as? [String: Any])?["recommendedMax"] as? Double, 28) + XCTAssertNil((limitsJSON["target"] as? [String: Any])?["recommendedMin"]) + XCTAssertNil(limitsJSON["basalMinimum"]) + + json["_id"] = "sync" + XCTAssertEqual(ProfileSet(rawValue: json)?.profileEditLimits, limits) + XCTAssertNil(makeProfileSet(isAPNSProduction: nil).dictionaryRepresentation["profileEditLimits"]) + } + + func testRemoteCommandsAreSerializedAtTopLevel() { + let schedule = [ProfileSet.ScheduleItem(offset: .hours(0), value: 1)] + let profile = ProfileSet.Profile(timezone: TimeZone(secondsFromGMT: 0)!, dia: .hours(6), sensitivity: schedule, carbratio: schedule, basal: schedule, targetLow: schedule, targetHigh: schedule, units: "mg/dL") + let commands = ["profile-switch", "profile-save", "profile-delete"] + let profileSet = ProfileSet(startDate: Date(), units: "mg/dL", enteredBy: "Loop", defaultProfile: "Default", store: ["Default": profile], settings: nil, syncIdentifier: "sync", remoteCommands: commands) + + var json = profileSet.dictionaryRepresentation + XCTAssertEqual(json["remoteCommands"] as? [String], commands) + json["_id"] = "sync" + XCTAssertEqual(ProfileSet(rawValue: json)?.remoteCommands, commands) + + var withoutCommands = makeProfileSet(isAPNSProduction: nil).dictionaryRepresentation + XCTAssertNil(withoutCommands["remoteCommands"]) + withoutCommands["_id"] = "sync" + XCTAssertNil(ProfileSet(rawValue: withoutCommands)?.remoteCommands) + } + + func testScheduledSwitchesAreSerializedAtTopLevel() throws { + let schedule = [ProfileSet.ScheduleItem(offset: .hours(0), value: 1)] + let profile = ProfileSet.Profile(timezone: TimeZone(secondsFromGMT: 0)!, dia: .hours(6), sensitivity: schedule, carbratio: schedule, basal: schedule, targetLow: schedule, targetHigh: schedule, units: "mg/dL") + let switches = [ + ProfileSet.ScheduledSwitch(id: "A", profile: "Weekend", weekday: 7, time: 25200, paused: true), + ProfileSet.ScheduledSwitch(id: "B", profile: "Default", weekday: 2, time: 21600), + ] + let profileSet = ProfileSet(startDate: Date(), units: "mg/dL", enteredBy: "Loop", defaultProfile: "Default", store: ["Default": profile, "Weekend": profile], settings: nil, syncIdentifier: "sync", scheduledSwitches: switches, scheduledSwitchesFingerprint: "1f4d16404c846d2f") + + var json = profileSet.dictionaryRepresentation + let entries = try XCTUnwrap(json["scheduledSwitches"] as? [[String: Any]]) + XCTAssertEqual(entries[0]["paused"] as? Bool, true) + XCTAssertNil(entries[1]["paused"]) + XCTAssertEqual(entries[1]["time"] as? Int, 21600) + XCTAssertEqual(json["scheduledSwitchesFingerprint"] as? String, "1f4d16404c846d2f") + json["_id"] = "sync" + let decoded = try XCTUnwrap(ProfileSet(rawValue: json)) + XCTAssertEqual(decoded.scheduledSwitches, switches) + XCTAssertEqual(decoded.scheduledSwitchesFingerprint, "1f4d16404c846d2f") + + var withoutSwitches = makeProfileSet(isAPNSProduction: nil).dictionaryRepresentation + XCTAssertNil(withoutSwitches["scheduledSwitches"]) + XCTAssertNil(withoutSwitches["scheduledSwitchesFingerprint"]) + withoutSwitches["_id"] = "sync" + XCTAssertNil(ProfileSet(rawValue: withoutSwitches)?.scheduledSwitches) + } + + func testProfileSwitchTreatmentRoundTrips() throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let treatment = ProfileSwitchNightscoutTreatment(timestamp: date, enteredBy: "Loop", profile: "Sport") + var json = treatment.dictionaryRepresentation + XCTAssertEqual(json["eventType"] as? String, "Profile Switch") + XCTAssertEqual(json["profile"] as? String, "Sport") + XCTAssertEqual(json["duration"] as? Double, 0) + + json["_id"] = "abc" + let parsed = try XCTUnwrap(NightscoutTreatment.fromServer(json) as? ProfileSwitchNightscoutTreatment) + XCTAssertEqual(parsed.profile, "Sport") + XCTAssertEqual(parsed.duration, 0) + XCTAssertEqual(parsed.timestamp, date) + } + + func testProfileSetFromAnotherUploader() throws { + let json = """ + {"_id": "6720d2c1a1b2c3d4e5f60718", "defaultProfile": "Sport", "startDate": "2026-11-07T12:00:00.000Z", "mills": 1793966400000, + "units": "mmol", "enteredBy": "Trio", "bundleIdentifier": "org.nightscout.trio", "deviceToken": "token", "isAPNSProduction": false, "teamID": "TEAM", + "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": 6.1, "timeAsSeconds": 0}]}, + "Sport": {"dia": 6, "carbs_hr": 20, "delay": 0, "timezone": "Europe/Stockholm", "units": "mmol", + "basal": [{"time": "00:00", "value": 0.5}], + "carbratio": [{"time": "00:00", "value": 12}], + "sens": [{"time": "00:00", "value": 3.3}], + "target_low": [{"time": "00:00", "value": 6.7}], + "target_high": [{"time": "00:00", "value": 7.2}]} + }} + """ + let raw = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + let profileSet = try XCTUnwrap(ProfileSet(rawValue: raw)) + XCTAssertNil(profileSet.settings) + XCTAssertEqual(profileSet.defaultProfile, "Sport") + XCTAssertEqual(Set(profileSet.store.keys), ["default", "Sport"]) + let defaultProfile = try XCTUnwrap(profileSet.store["default"]) + XCTAssertEqual(defaultProfile.timeZone.identifier, "Europe/Stockholm") + XCTAssertEqual(defaultProfile.basal.map(\.offset), [0, 21600]) + XCTAssertEqual(profileSet.store["Sport"]?.targetHigh.first?.value, 7.2) + XCTAssertNil(profileSet.dictionaryRepresentation["loopSettings"]) + } }