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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions Sources/NightscoutKit/Models/ProfileEditLimits.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
118 changes: 106 additions & 12 deletions Sources/NightscoutKit/Models/ProfileSet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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],
Expand All @@ -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
Expand All @@ -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] {
Expand All @@ -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
}
Expand All @@ -169,19 +193,33 @@ 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),
let units = rawValue["units"] as? String,
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
Expand All @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading