From 2ca04ae32087572244990f594c8d3a9a5ea72511 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Mon, 3 Jun 2024 21:55:24 +0200 Subject: [PATCH 1/7] feat: Add Dana configurations --- MockKit/MockPumpManagerState.swift | 11 +++++++++++ .../MockPumpManagerSettingsViewController.swift | 2 ++ 2 files changed, 13 insertions(+) diff --git a/MockKit/MockPumpManagerState.swift b/MockKit/MockPumpManagerState.swift index ddb662fcc..9a7f5d263 100644 --- a/MockKit/MockPumpManagerState.swift +++ b/MockKit/MockPumpManagerState.swift @@ -14,6 +14,7 @@ public struct MockPumpManagerState: Equatable { case omnipod case medtronicX22 case medtronicX23 + case dana case custom var supportedBolusVolumes: [Double]? { @@ -32,6 +33,8 @@ public struct MockPumpManagerState: Equatable { let scaledRanges = (range.lowerBound * scale + 1)...(range.upperBound * scale) return scaledRanges.map { Double($0) / Double(scale) } } + case .dana: + return (1...300).map { Double($0) / 10 } case .custom: return nil } @@ -50,6 +53,9 @@ public struct MockPumpManagerState: Equatable { // 0.05 units for rates between 1-9.95 U/h // 0.1 units for rates between 10-25 U/h return "0-1-10-25 by 0.025|0.05|0.1" + case .dana: + // 0.1 units for volumes between 0.1-25U + return "0.1-30 by 0.1" case .custom: return nil } @@ -71,6 +77,8 @@ public struct MockPumpManagerState: Equatable { // 0.1 units for rates between 10-35 U/h let rateGroup3 = (100...350).map { Double($0) / 10 } return rateGroup1 + rateGroup2 + rateGroup3 + case .dana: + return (0...1600).map { Double($0) / 100 } case .custom: return nil } @@ -89,6 +97,9 @@ public struct MockPumpManagerState: Equatable { // 0.05 units for rates between 1-9.95 U/h // 0.1 units for rates between 10-35 U/h return "0-1-10-35 by 0.025|0.05|0.1" + case .dana: + // 0.01 units for rates between 0.01-16U/hr + return "0.01-16 by 0.01" case .custom: return nil } diff --git a/MockKitUI/View Controllers/MockPumpManagerSettingsViewController.swift b/MockKitUI/View Controllers/MockPumpManagerSettingsViewController.swift index 592e3153e..01a7e29ae 100644 --- a/MockKitUI/View Controllers/MockPumpManagerSettingsViewController.swift +++ b/MockKitUI/View Controllers/MockPumpManagerSettingsViewController.swift @@ -199,6 +199,8 @@ final class MockPumpManagerSettingsViewController: UITableViewController { return "x22" case .medtronicX23: return "x23" + case .dana: + return "Dana" case .custom: return "Custom" } From 84611e52d3c6bf41c400bedebdda31cf07f91643 Mon Sep 17 00:00:00 2001 From: bastiaanv Date: Mon, 3 Jun 2024 21:58:00 +0200 Subject: [PATCH 2/7] fix: Increase bolus to 80U --- MockKit/MockPumpManagerState.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MockKit/MockPumpManagerState.swift b/MockKit/MockPumpManagerState.swift index 9a7f5d263..bc55ea9f3 100644 --- a/MockKit/MockPumpManagerState.swift +++ b/MockKit/MockPumpManagerState.swift @@ -34,7 +34,7 @@ public struct MockPumpManagerState: Equatable { return scaledRanges.map { Double($0) / Double(scale) } } case .dana: - return (1...300).map { Double($0) / 10 } + return (1...800).map { Double($0) / 10 } case .custom: return nil } @@ -54,8 +54,8 @@ public struct MockPumpManagerState: Equatable { // 0.1 units for rates between 10-25 U/h return "0-1-10-25 by 0.025|0.05|0.1" case .dana: - // 0.1 units for volumes between 0.1-25U - return "0.1-30 by 0.1" + // 0.1 units for volumes between 0.1-80U + return "0.1-80 by 0.1" case .custom: return nil } From 49df814edd30a839c0afda1ece6fe9972d40aed6 Mon Sep 17 00:00:00 2001 From: Deniz Cengiz Date: Tue, 1 Oct 2024 10:41:01 +0200 Subject: [PATCH 3/7] Change apple clang module verification to NO --- LoopKit.xcodeproj/project.pbxproj | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/LoopKit.xcodeproj/project.pbxproj b/LoopKit.xcodeproj/project.pbxproj index 2ecb6c53c..1832aa295 100644 --- a/LoopKit.xcodeproj/project.pbxproj +++ b/LoopKit.xcodeproj/project.pbxproj @@ -5203,7 +5203,7 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 41; DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = YES; + ENABLE_MODULE_VERIFIER = "$(CLANG_MODULES_ENABLE_VERIFIER_TOOL)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = LoopKitUI/Info.plist; @@ -5241,7 +5241,7 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 41; DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = YES; + ENABLE_MODULE_VERIFIER = "$(CLANG_MODULES_ENABLE_VERIFIER_TOOL)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = LoopKitUI/Info.plist; @@ -5402,7 +5402,7 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 41; DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = YES; + ENABLE_MODULE_VERIFIER = "$(CLANG_MODULES_ENABLE_VERIFIER_TOOL)"; INFOPLIST_FILE = LoopKit/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5436,7 +5436,7 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 41; DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = YES; + ENABLE_MODULE_VERIFIER = "$(CLANG_MODULES_ENABLE_VERIFIER_TOOL)"; INFOPLIST_FILE = LoopKit/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5494,6 +5494,7 @@ CODE_SIGN_IDENTITY = ""; COPY_PHASE_STRIP = NO; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = "$(CLANG_MODULES_ENABLE_VERIFIER_TOOL)"; GCC_OPTIMIZATION_LEVEL = 0; INFOPLIST_FILE = MockKit/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5531,7 +5532,7 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = YES; + ENABLE_MODULE_VERIFIER = "$(CLANG_MODULES_ENABLE_VERIFIER_TOOL)"; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = LoopTestingKit/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5661,7 +5662,7 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 41; DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = YES; + ENABLE_MODULE_VERIFIER = "$(CLANG_MODULES_ENABLE_VERIFIER_TOOL)"; INFOPLIST_FILE = LoopKit/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -5694,7 +5695,7 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 41; DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = YES; + ENABLE_MODULE_VERIFIER = "$(CLANG_MODULES_ENABLE_VERIFIER_TOOL)"; INFOPLIST_FILE = LoopKit/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", From 2fb32ae503dbc0192157393b31304d7fccb21452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 1 Jul 2026 11:48:03 +0200 Subject: [PATCH 4/7] Add opaque bolusReference to PumpManager.enactBolus and DoseEntry Carries a caller-supplied reference through the bolus request and echoes it back on the reported DoseEntry so the caller can correlate a delivered dose with the request that produced it. A default protocol implementation forwards to the existing enactBolus, keeping pump managers that do not adopt it source compatible. MockKit persists and echoes the reference as a reference implementation. --- LoopKit/DeviceManager/PumpManager.swift | 27 +++++++++++++++++++++++++ LoopKit/InsulinKit/DoseEntry.swift | 12 ++++++++++- MockKit/MockPumpManager.swift | 6 +++++- MockKit/UnfinalizedDose.swift | 16 ++++++++++++--- 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/LoopKit/DeviceManager/PumpManager.swift b/LoopKit/DeviceManager/PumpManager.swift index f1ef5af39..7fad8cc17 100644 --- a/LoopKit/DeviceManager/PumpManager.swift +++ b/LoopKit/DeviceManager/PumpManager.swift @@ -169,6 +169,26 @@ public protocol PumpManager: DeviceManager { /// - error: An optional error describing why the command failed func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: PumpManagerError?) -> Void) + /// Send a bolus command, carrying an opaque caller-supplied reference, and handle the result. + /// + /// The reference is meaningless to the pump manager: it is stored alongside the in-progress dose and + /// echoed back, unchanged, on the resulting `DoseEntry.bolusReference` so the caller can correlate the + /// reported dose with the request that produced it (e.g. to recover the bolus origin). Pump managers that + /// persist their in-progress dose should persist the reference too, so the correlation survives an app + /// restart while delivery is ongoing. + /// + /// This requirement has a default implementation that drops the reference and forwards to + /// `enactBolus(units:activationType:completion:)`, so existing pump managers remain source compatible + /// without adopting it. + /// + /// - Parameters: + /// - units: The number of units to deliver + /// - activationType: Whether the dose was triggered automatically as opposed to commanded by user + /// - bolusReference: An opaque caller-supplied reference echoed back on the reported `DoseEntry`, or nil + /// - completion: A closure called after the command is complete + /// - error: An optional error describing why the command failed + func enactBolus(units: Double, activationType: BolusActivationType, bolusReference: String?, completion: @escaping (_ error: PumpManagerError?) -> Void) + /// Cancels the current, in progress, bolus. /// /// - Parameters: @@ -228,6 +248,13 @@ public protocol PumpManager: DeviceManager { public extension PumpManager { + /// Default implementation for pump managers that have not adopted bolus references. The opaque + /// reference is dropped and the call is forwarded to `enactBolus(units:activationType:completion:)`, + /// which keeps existing pump managers source compatible. + func enactBolus(units: Double, activationType: BolusActivationType, bolusReference: String?, completion: @escaping (_ error: PumpManagerError?) -> Void) { + enactBolus(units: units, activationType: activationType, completion: completion) + } + func roundToSupportedBasalRate(unitsPerHour: Double) -> Double { return supportedBasalRates.filter({$0 <= unitsPerHour}).max() ?? 0 } diff --git a/LoopKit/InsulinKit/DoseEntry.swift b/LoopKit/InsulinKit/DoseEntry.swift index fa8fec820..e641d1d27 100644 --- a/LoopKit/InsulinKit/DoseEntry.swift +++ b/LoopKit/InsulinKit/DoseEntry.swift @@ -22,6 +22,9 @@ public struct DoseEntry: TimelineValue, Equatable { public let automatic: Bool? public let manuallyEntered: Bool public internal(set) var syncIdentifier: String? + /// An opaque, caller-supplied reference echoed back from the bolus request that produced this dose, or nil. + /// The pump manager does not interpret it; it exists so the caller can correlate a reported dose with its request. + public let bolusReference: String? public let isMutable: Bool public let wasProgrammedByPumpUI: Bool @@ -37,7 +40,7 @@ public struct DoseEntry: TimelineValue, Equatable { } // If the insulin model field is nil, it's assumed that the model is the type of insulin the pump dispenses - public init(type: DoseType, startDate: Date, endDate: Date? = nil, value: Double, unit: DoseUnit, deliveredUnits: Double? = nil, description: String? = nil, syncIdentifier: String? = nil, scheduledBasalRate: HKQuantity? = nil, insulinType: InsulinType? = nil, automatic: Bool? = nil, manuallyEntered: Bool = false, isMutable: Bool = false, wasProgrammedByPumpUI: Bool = false) { + public init(type: DoseType, startDate: Date, endDate: Date? = nil, value: Double, unit: DoseUnit, deliveredUnits: Double? = nil, description: String? = nil, syncIdentifier: String? = nil, scheduledBasalRate: HKQuantity? = nil, insulinType: InsulinType? = nil, automatic: Bool? = nil, manuallyEntered: Bool = false, isMutable: Bool = false, wasProgrammedByPumpUI: Bool = false, bolusReference: String? = nil) { self.type = type self.startDate = startDate self.endDate = endDate ?? startDate @@ -52,6 +55,7 @@ public struct DoseEntry: TimelineValue, Equatable { self.manuallyEntered = manuallyEntered self.isMutable = isMutable self.wasProgrammedByPumpUI = wasProgrammedByPumpUI + self.bolusReference = bolusReference } } @@ -167,6 +171,7 @@ extension DoseEntry { "description: \(optionalString(description))", "scheduledBasalRate: \(optionalString(scheduledBasalRate))", "syncIdentifier: \(optionalString(syncIdentifier))", + "bolusReference: \(optionalString(bolusReference))", ].joined(separator: "\n") } } @@ -188,6 +193,7 @@ extension DoseEntry: Codable { self.scheduledBasalRate = HKQuantity(unit: HKUnit(from: scheduledBasalRateUnit), doubleValue: scheduledBasalRate) } self.automatic = try container.decodeIfPresent(Bool.self, forKey: .automatic) + self.bolusReference = try container.decodeIfPresent(String.self, forKey: .bolusReference) self.manuallyEntered = try container.decodeIfPresent(Bool.self, forKey: .manuallyEntered) ?? false self.isMutable = try container.decodeIfPresent(Bool.self, forKey: .isMutable) ?? false self.wasProgrammedByPumpUI = try container.decodeIfPresent(Bool.self, forKey: .wasProgrammedByPumpUI) ?? false @@ -209,6 +215,7 @@ extension DoseEntry: Codable { try container.encode(DoseEntry.unitsPerHour.unitString, forKey: .scheduledBasalRateUnit) } try container.encodeIfPresent(automatic, forKey: .automatic) + try container.encodeIfPresent(bolusReference, forKey: .bolusReference) if manuallyEntered { try container.encode(manuallyEntered, forKey: .manuallyEntered) } @@ -233,6 +240,7 @@ extension DoseEntry: Codable { case scheduledBasalRateUnit case insulinType case automatic + case bolusReference case manuallyEntered case isMutable case wasProgrammedByPumpUI @@ -266,6 +274,7 @@ extension DoseEntry: RawRepresentable { self.description = rawValue["description"] as? String self.insulinType = (rawValue["insulinType"] as? InsulinType.RawValue).flatMap { InsulinType(rawValue: $0) } self.automatic = rawValue["automatic"] as? Bool + self.bolusReference = rawValue["bolusReference"] as? String self.syncIdentifier = rawValue["syncIdentifier"] as? String self.scheduledBasalRate = (rawValue["scheduledBasalRate"] as? Double).flatMap { HKQuantity(unit: .internationalUnitsPerHour, doubleValue: $0) } self.isMutable = rawValue["isMutable"] as? Bool ?? false @@ -288,6 +297,7 @@ extension DoseEntry: RawRepresentable { rawValue["description"] = description rawValue["insulinType"] = insulinType?.rawValue rawValue["automatic"] = automatic + rawValue["bolusReference"] = bolusReference rawValue["syncIdentifier"] = syncIdentifier rawValue["scheduledBasalRate"] = scheduledBasalRate?.doubleValue(for: .internationalUnitsPerHour) diff --git a/MockKit/MockPumpManager.swift b/MockKit/MockPumpManager.swift index ddd4d4789..e6fa724a2 100644 --- a/MockKit/MockPumpManager.swift +++ b/MockKit/MockPumpManager.swift @@ -466,6 +466,10 @@ public final class MockPumpManager: TestingPumpManager { } public func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (PumpManagerError?) -> Void) { + enactBolus(units: units, activationType: activationType, bolusReference: nil, completion: completion) + } + + public func enactBolus(units: Double, activationType: BolusActivationType, bolusReference: String?, completion: @escaping (PumpManagerError?) -> Void) { logDeviceCommunication("enactBolus(\(units), \(activationType))") @@ -505,7 +509,7 @@ public final class MockPumpManager: TestingPumpManager { } - let bolus = UnfinalizedDose(bolusAmount: units, startTime: Date(), duration: .minutes(units / type(of: self).deliveryUnitsPerMinute), insulinType: state.insulinType, automatic: activationType.isAutomatic) + let bolus = UnfinalizedDose(bolusAmount: units, startTime: Date(), duration: .minutes(units / type(of: self).deliveryUnitsPerMinute), insulinType: state.insulinType, automatic: activationType.isAutomatic, bolusReference: bolusReference) state.unfinalizedBolus = bolus logDeviceComms(.receive, message: "Bolus accepted") diff --git a/MockKit/UnfinalizedDose.swift b/MockKit/UnfinalizedDose.swift index 91cb18a4d..906be34b3 100644 --- a/MockKit/UnfinalizedDose.swift +++ b/MockKit/UnfinalizedDose.swift @@ -40,6 +40,7 @@ public struct UnfinalizedDose: RawRepresentable, Equatable, CustomStringConverti var duration: TimeInterval let insulinType: InsulinType? let automatic: Bool? + let bolusReference: String? var finishTime: Date { get { @@ -82,7 +83,7 @@ public struct UnfinalizedDose: RawRepresentable, Equatable, CustomStringConverti return units } - init(bolusAmount: Double, startTime: Date, duration: TimeInterval, insulinType: InsulinType? = nil, automatic: Bool = false) { + init(bolusAmount: Double, startTime: Date, duration: TimeInterval, insulinType: InsulinType? = nil, automatic: Bool = false, bolusReference: String? = nil) { self.doseType = .bolus self.units = bolusAmount self.startTime = startTime @@ -90,6 +91,7 @@ public struct UnfinalizedDose: RawRepresentable, Equatable, CustomStringConverti self.scheduledUnits = nil self.insulinType = insulinType self.automatic = automatic + self.bolusReference = bolusReference } init(tempBasalRate: Double, startTime: Date, duration: TimeInterval, insulinType: InsulinType? = nil) { @@ -100,6 +102,7 @@ public struct UnfinalizedDose: RawRepresentable, Equatable, CustomStringConverti self.scheduledUnits = nil self.insulinType = insulinType self.automatic = true + self.bolusReference = nil } init(suspendStartTime: Date, automatic: Bool? = nil) { @@ -109,6 +112,7 @@ public struct UnfinalizedDose: RawRepresentable, Equatable, CustomStringConverti self.duration = 0 self.insulinType = nil self.automatic = automatic + self.bolusReference = nil } init(resumeStartTime: Date, insulinType: InsulinType? = nil, automatic: Bool? = nil) { @@ -118,6 +122,7 @@ public struct UnfinalizedDose: RawRepresentable, Equatable, CustomStringConverti self.duration = 0 self.insulinType = insulinType self.automatic = automatic + self.bolusReference = nil } public mutating func cancel(at date: Date) { @@ -214,6 +219,7 @@ public struct UnfinalizedDose: RawRepresentable, Equatable, CustomStringConverti } self.automatic = rawValue["automatic"] as? Bool + self.bolusReference = rawValue["bolusReference"] as? String } public var rawValue: RawValue { @@ -240,6 +246,10 @@ public struct UnfinalizedDose: RawRepresentable, Equatable, CustomStringConverti rawValue["automatic"] = automatic } + if let bolusReference = bolusReference { + rawValue["bolusReference"] = bolusReference + } + return rawValue } } @@ -261,7 +271,7 @@ extension NewPumpEvent { case .basal: return nil case .bolus: - var newDose = UnfinalizedDose(bolusAmount: dose.programmedUnits, startTime: dose.startDate, duration: duration, insulinType: dose.insulinType ?? defaultInsulinType, automatic: dose.automatic ?? false) + var newDose = UnfinalizedDose(bolusAmount: dose.programmedUnits, startTime: dose.startDate, duration: duration, insulinType: dose.insulinType ?? defaultInsulinType, automatic: dose.automatic ?? false, bolusReference: dose.bolusReference) if let delivered = dose.deliveredUnits { newDose.scheduledUnits = dose.programmedUnits newDose.units = delivered @@ -283,7 +293,7 @@ extension DoseEntry { init (_ dose: UnfinalizedDose) { switch dose.doseType { case .bolus: - self = DoseEntry(type: .bolus, startDate: dose.startTime, endDate: dose.finishTime, value: dose.scheduledUnits ?? dose.units, unit: .units, deliveredUnits: dose.finalizedUnits, insulinType: dose.insulinType, automatic: dose.automatic, isMutable: dose.isMutable) + self = DoseEntry(type: .bolus, startDate: dose.startTime, endDate: dose.finishTime, value: dose.scheduledUnits ?? dose.units, unit: .units, deliveredUnits: dose.finalizedUnits, insulinType: dose.insulinType, automatic: dose.automatic, isMutable: dose.isMutable, bolusReference: dose.bolusReference) case .tempBasal: self = DoseEntry(type: .tempBasal, startDate: dose.startTime, endDate: dose.finishTime, value: dose.scheduledTempRate ?? dose.rate, unit: .unitsPerHour, deliveredUnits: dose.finalizedUnits, insulinType: dose.insulinType, isMutable: dose.isMutable) case .suspend: From 8238d75d98871eedc9a74b0d4ea2f891e24bbeb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 1 Jul 2026 13:24:46 +0200 Subject: [PATCH 5/7] Make bolusReference a UUID Use UUID for the opaque bolus reference instead of String so the public API is strongly typed. Codable encodes it natively; the plist-backed rawValue keeps storing it as uuidString. --- LoopKit/DeviceManager/PumpManager.swift | 4 ++-- LoopKit/InsulinKit/DoseEntry.swift | 10 +++++----- MockKit/MockPumpManager.swift | 2 +- MockKit/UnfinalizedDose.swift | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/LoopKit/DeviceManager/PumpManager.swift b/LoopKit/DeviceManager/PumpManager.swift index 7fad8cc17..64213dbd8 100644 --- a/LoopKit/DeviceManager/PumpManager.swift +++ b/LoopKit/DeviceManager/PumpManager.swift @@ -187,7 +187,7 @@ public protocol PumpManager: DeviceManager { /// - bolusReference: An opaque caller-supplied reference echoed back on the reported `DoseEntry`, or nil /// - completion: A closure called after the command is complete /// - error: An optional error describing why the command failed - func enactBolus(units: Double, activationType: BolusActivationType, bolusReference: String?, completion: @escaping (_ error: PumpManagerError?) -> Void) + func enactBolus(units: Double, activationType: BolusActivationType, bolusReference: UUID?, completion: @escaping (_ error: PumpManagerError?) -> Void) /// Cancels the current, in progress, bolus. /// @@ -251,7 +251,7 @@ public extension PumpManager { /// Default implementation for pump managers that have not adopted bolus references. The opaque /// reference is dropped and the call is forwarded to `enactBolus(units:activationType:completion:)`, /// which keeps existing pump managers source compatible. - func enactBolus(units: Double, activationType: BolusActivationType, bolusReference: String?, completion: @escaping (_ error: PumpManagerError?) -> Void) { + func enactBolus(units: Double, activationType: BolusActivationType, bolusReference: UUID?, completion: @escaping (_ error: PumpManagerError?) -> Void) { enactBolus(units: units, activationType: activationType, completion: completion) } diff --git a/LoopKit/InsulinKit/DoseEntry.swift b/LoopKit/InsulinKit/DoseEntry.swift index e641d1d27..c22b536de 100644 --- a/LoopKit/InsulinKit/DoseEntry.swift +++ b/LoopKit/InsulinKit/DoseEntry.swift @@ -24,7 +24,7 @@ public struct DoseEntry: TimelineValue, Equatable { public internal(set) var syncIdentifier: String? /// An opaque, caller-supplied reference echoed back from the bolus request that produced this dose, or nil. /// The pump manager does not interpret it; it exists so the caller can correlate a reported dose with its request. - public let bolusReference: String? + public let bolusReference: UUID? public let isMutable: Bool public let wasProgrammedByPumpUI: Bool @@ -40,7 +40,7 @@ public struct DoseEntry: TimelineValue, Equatable { } // If the insulin model field is nil, it's assumed that the model is the type of insulin the pump dispenses - public init(type: DoseType, startDate: Date, endDate: Date? = nil, value: Double, unit: DoseUnit, deliveredUnits: Double? = nil, description: String? = nil, syncIdentifier: String? = nil, scheduledBasalRate: HKQuantity? = nil, insulinType: InsulinType? = nil, automatic: Bool? = nil, manuallyEntered: Bool = false, isMutable: Bool = false, wasProgrammedByPumpUI: Bool = false, bolusReference: String? = nil) { + public init(type: DoseType, startDate: Date, endDate: Date? = nil, value: Double, unit: DoseUnit, deliveredUnits: Double? = nil, description: String? = nil, syncIdentifier: String? = nil, scheduledBasalRate: HKQuantity? = nil, insulinType: InsulinType? = nil, automatic: Bool? = nil, manuallyEntered: Bool = false, isMutable: Bool = false, wasProgrammedByPumpUI: Bool = false, bolusReference: UUID? = nil) { self.type = type self.startDate = startDate self.endDate = endDate ?? startDate @@ -193,7 +193,7 @@ extension DoseEntry: Codable { self.scheduledBasalRate = HKQuantity(unit: HKUnit(from: scheduledBasalRateUnit), doubleValue: scheduledBasalRate) } self.automatic = try container.decodeIfPresent(Bool.self, forKey: .automatic) - self.bolusReference = try container.decodeIfPresent(String.self, forKey: .bolusReference) + self.bolusReference = try container.decodeIfPresent(UUID.self, forKey: .bolusReference) self.manuallyEntered = try container.decodeIfPresent(Bool.self, forKey: .manuallyEntered) ?? false self.isMutable = try container.decodeIfPresent(Bool.self, forKey: .isMutable) ?? false self.wasProgrammedByPumpUI = try container.decodeIfPresent(Bool.self, forKey: .wasProgrammedByPumpUI) ?? false @@ -274,7 +274,7 @@ extension DoseEntry: RawRepresentable { self.description = rawValue["description"] as? String self.insulinType = (rawValue["insulinType"] as? InsulinType.RawValue).flatMap { InsulinType(rawValue: $0) } self.automatic = rawValue["automatic"] as? Bool - self.bolusReference = rawValue["bolusReference"] as? String + self.bolusReference = (rawValue["bolusReference"] as? String).flatMap { UUID(uuidString: $0) } self.syncIdentifier = rawValue["syncIdentifier"] as? String self.scheduledBasalRate = (rawValue["scheduledBasalRate"] as? Double).flatMap { HKQuantity(unit: .internationalUnitsPerHour, doubleValue: $0) } self.isMutable = rawValue["isMutable"] as? Bool ?? false @@ -297,7 +297,7 @@ extension DoseEntry: RawRepresentable { rawValue["description"] = description rawValue["insulinType"] = insulinType?.rawValue rawValue["automatic"] = automatic - rawValue["bolusReference"] = bolusReference + rawValue["bolusReference"] = bolusReference?.uuidString rawValue["syncIdentifier"] = syncIdentifier rawValue["scheduledBasalRate"] = scheduledBasalRate?.doubleValue(for: .internationalUnitsPerHour) diff --git a/MockKit/MockPumpManager.swift b/MockKit/MockPumpManager.swift index e6fa724a2..bc8041fd7 100644 --- a/MockKit/MockPumpManager.swift +++ b/MockKit/MockPumpManager.swift @@ -469,7 +469,7 @@ public final class MockPumpManager: TestingPumpManager { enactBolus(units: units, activationType: activationType, bolusReference: nil, completion: completion) } - public func enactBolus(units: Double, activationType: BolusActivationType, bolusReference: String?, completion: @escaping (PumpManagerError?) -> Void) { + public func enactBolus(units: Double, activationType: BolusActivationType, bolusReference: UUID?, completion: @escaping (PumpManagerError?) -> Void) { logDeviceCommunication("enactBolus(\(units), \(activationType))") diff --git a/MockKit/UnfinalizedDose.swift b/MockKit/UnfinalizedDose.swift index 906be34b3..4a333087d 100644 --- a/MockKit/UnfinalizedDose.swift +++ b/MockKit/UnfinalizedDose.swift @@ -40,7 +40,7 @@ public struct UnfinalizedDose: RawRepresentable, Equatable, CustomStringConverti var duration: TimeInterval let insulinType: InsulinType? let automatic: Bool? - let bolusReference: String? + let bolusReference: UUID? var finishTime: Date { get { @@ -83,7 +83,7 @@ public struct UnfinalizedDose: RawRepresentable, Equatable, CustomStringConverti return units } - init(bolusAmount: Double, startTime: Date, duration: TimeInterval, insulinType: InsulinType? = nil, automatic: Bool = false, bolusReference: String? = nil) { + init(bolusAmount: Double, startTime: Date, duration: TimeInterval, insulinType: InsulinType? = nil, automatic: Bool = false, bolusReference: UUID? = nil) { self.doseType = .bolus self.units = bolusAmount self.startTime = startTime @@ -219,7 +219,7 @@ public struct UnfinalizedDose: RawRepresentable, Equatable, CustomStringConverti } self.automatic = rawValue["automatic"] as? Bool - self.bolusReference = rawValue["bolusReference"] as? String + self.bolusReference = (rawValue["bolusReference"] as? String).flatMap { UUID(uuidString: $0) } } public var rawValue: RawValue { @@ -247,7 +247,7 @@ public struct UnfinalizedDose: RawRepresentable, Equatable, CustomStringConverti } if let bolusReference = bolusReference { - rawValue["bolusReference"] = bolusReference + rawValue["bolusReference"] = bolusReference.uuidString } return rawValue From 1cc512b6e5471c5576511283b71d4e59a589b074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 1 Jul 2026 15:10:42 +0200 Subject: [PATCH 6/7] Add shared BolusOriginStore Correlates a bolus request with the dose the pump later reports, keyed by the request reference or the reported syncIdentifier, so a caller can record where a bolus came from and a consumer can read it back. File-backed so it survives an app restart while a bolus is in flight. --- LoopKit.xcodeproj/project.pbxproj | 8 +- LoopKit/InsulinKit/BolusOriginStore.swift | 99 +++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 LoopKit/InsulinKit/BolusOriginStore.swift diff --git a/LoopKit.xcodeproj/project.pbxproj b/LoopKit.xcodeproj/project.pbxproj index b93592482..e981380a4 100644 --- a/LoopKit.xcodeproj/project.pbxproj +++ b/LoopKit.xcodeproj/project.pbxproj @@ -319,7 +319,7 @@ 892155152245C57E009112BC /* SegmentedGaugeBarLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892155142245C57E009112BC /* SegmentedGaugeBarLayer.swift */; }; 892155182245FBEF009112BC /* InsulinSensitivityScalingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892155162245FBEF009112BC /* InsulinSensitivityScalingTableViewCell.swift */; }; 892155192245FBEF009112BC /* InsulinSensitivityScalingTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 892155172245FBEF009112BC /* InsulinSensitivityScalingTableViewCell.xib */; }; - 892A5D28222EF567008961AB /* (null) in Sources */ = {isa = PBXBuildFile; }; + 892A5D28222EF567008961AB /* BuildFile in Sources */ = {isa = PBXBuildFile; }; 892A5D2E222EF69A008961AB /* MockHUDProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D2D222EF69A008961AB /* MockHUDProvider.swift */; }; 892A5D46222F03CB008961AB /* LoopTestingKit.h in Headers */ = {isa = PBXBuildFile; fileRef = 892A5D36222F03CB008961AB /* LoopTestingKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; 892A5D49222F03CC008961AB /* LoopTestingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 892A5D34222F03CB008961AB /* LoopTestingKit.framework */; }; @@ -649,6 +649,7 @@ A9E675F5227140DD00E25293 /* HealthKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A9E675F4227140DD00E25293 /* HealthKit.framework */; }; A9FD046F24E310D00040F203 /* HKObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FD046E24E310D00040F203 /* HKObject.swift */; }; A9FD047024E310DD0040F203 /* HKObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FD046E24E310D00040F203 /* HKObject.swift */; }; + AD6B3F980BE3DD6565F511E7 /* BolusOriginStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D573C406C05C5D6D46B18FE4 /* BolusOriginStore.swift */; }; B400177929F0249D00532398 /* DeviceAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B400177829F0249D00532398 /* DeviceAction.swift */; }; B4001CF028CBC200002FB414 /* SingleSelectionCheckList.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4001CEF28CBC200002FB414 /* SingleSelectionCheckList.swift */; }; B40C43912707408400F5D86C /* DeliveryLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DC64C7D24BF6EBC004A63A1 /* DeliveryLimits.swift */; }; @@ -1790,6 +1791,7 @@ C1FAC06228C7B0A100754AE2 /* reservoir_iob_test.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = reservoir_iob_test.json; sourceTree = ""; }; C1FAEC1C264AD6B400A3250B /* DeviceStatusBadge.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceStatusBadge.swift; sourceTree = ""; }; C1FAEC20264AEEA300A3250B /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; + D573C406C05C5D6D46B18FE4 /* BolusOriginStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BolusOriginStore.swift; sourceTree = ""; }; E9077D2624ACD59F0066A88D /* InformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InformationView.swift; sourceTree = ""; }; E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = ""; }; E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartsTableViewController.swift; sourceTree = ""; }; @@ -2663,6 +2665,7 @@ 4302F4E81D5066F400F0FCAF /* ReservoirValue.swift */, A9498D6E23386C0B00DAA9B9 /* TempBasalRecommendation.swift */, C1DB55B01F2E95FD00C483A2 /* WalshInsulinModel.swift */, + D573C406C05C5D6D46B18FE4 /* BolusOriginStore.swift */, ); path = InsulinKit; sourceTree = ""; @@ -4260,6 +4263,7 @@ 89F6E30F244A1A5D00CB9E15 /* Guardrail.swift in Sources */, A985464F251449FE0099C1A6 /* NotificationSettings.swift in Sources */, C187337E29B9481500519CDF /* LocalizedString.swift in Sources */, + AD6B3F980BE3DD6565F511E7 /* BolusOriginStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4409,7 +4413,7 @@ B42C950E24A3BD4B00857C73 /* MeasurementFrequencyTableViewController.swift in Sources */, B45774352562CCC6004B9466 /* SupportedRangeTableViewController.swift in Sources */, B45703C22A1808ED00F615B7 /* MockCGMManagerControlsView.swift in Sources */, - 892A5D28222EF567008961AB /* (null) in Sources */, + 892A5D28222EF567008961AB /* BuildFile in Sources */, B45703C32A1808F600F615B7 /* MockCGMManagerSettingsViewModel.swift in Sources */, B451D10D2A161A3E004878F1 /* MockPumpManagerSettingsView.swift in Sources */, C1E0EEF624EB26D800086510 /* DeliveryUncertaintyRecoveryView.swift in Sources */, diff --git a/LoopKit/InsulinKit/BolusOriginStore.swift b/LoopKit/InsulinKit/BolusOriginStore.swift new file mode 100644 index 000000000..b95d9b2f8 --- /dev/null +++ b/LoopKit/InsulinKit/BolusOriginStore.swift @@ -0,0 +1,99 @@ +// +// BolusOriginStore.swift +// LoopKit +// +// Correlates a bolus request with the dose the pump later reports, so a caller (the app) can record where a +// bolus came from and a consumer (e.g. a remote-data service) can read it back. Lives in LoopKit so both the +// app and in-process service plugins can share one instance; it is file-backed so the mapping survives an app +// restart while a bolus is in flight. +// +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import Foundation + +public final class BolusOriginStore { + public static let shared = BolusOriginStore() + + private struct Entry: Codable { + let origin: String + let createdAt: Date + } + + private let lock = NSRecursiveLock() + private var entries: [String: Entry] + + /// In-flight references are short-lived; drop anything older than this so the file stays bounded. + private static let maxAge: TimeInterval = 6 * 60 * 60 + + private let fileURL: URL? + + init(fileURL: URL? = BolusOriginStore.defaultFileURL) { + self.fileURL = fileURL + let loaded = fileURL + .flatMap { try? Data(contentsOf: $0) } + .flatMap { try? JSONDecoder().decode([String: Entry].self, from: $0) } ?? [:] + let cutoff = Date().addingTimeInterval(-Self.maxAge) + entries = loaded.filter { $0.value.createdAt > cutoff } + } + + private static var defaultFileURL: URL? { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first? + .appendingPathComponent("bolus_origins.json") + } + + /// Remember an origin under a freshly generated reference and return the reference to pass to the pump. + public func makeReference(for origin: String) -> UUID { + let reference = UUID() + sync { + entries[reference.uuidString] = Entry(origin: origin, createdAt: Date()) + persist() + } + return reference + } + + /// When the reported dose is known, re-key the origin from its request reference to the pump's stable + /// `syncIdentifier`, which is what persists in the dose store and is available at upload time. + public func promoteReference(_ reference: UUID, toSyncIdentifier syncIdentifier: String) { + sync { + guard let entry = entries.removeValue(forKey: reference.uuidString) else { return } + entries[syncIdentifier] = entry + persist() + } + } + + /// Resolve the origin recorded for a reported dose's `syncIdentifier`, if any. + public func origin(forSyncIdentifier syncIdentifier: String) -> String? { + sync { entries[syncIdentifier]?.origin } + } + + /// Resolve the origin directly by its request reference. Use this when the reference is still available on + /// the reported dose (no `syncIdentifier` promotion needed). + public func origin(forReference reference: UUID) -> String? { + sync { entries[reference.uuidString]?.origin } + } + + /// Drop a mapping once it has been consumed, or clean up a request that failed. + public func remove(reference: UUID) { + sync { entries.removeValue(forKey: reference.uuidString); persist() } + } + + public func remove(syncIdentifier: String) { + sync { entries.removeValue(forKey: syncIdentifier); persist() } + } + + // MARK: - Helpers + + @discardableResult private func sync(_ body: () -> T) -> T { + lock.lock(); defer { lock.unlock() } + return body() + } + + /// Caller must hold `lock`. + private func persist() { + let cutoff = Date().addingTimeInterval(-Self.maxAge) + entries = entries.filter { $0.value.createdAt > cutoff } + guard let fileURL = fileURL, let data = try? JSONEncoder().encode(entries) else { return } + try? data.write(to: fileURL, options: .atomic) + } +} From 0ac4bb176f0b7e190f8c117a06d162df8969e782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 1 Jul 2026 19:36:18 +0200 Subject: [PATCH 7/7] Move BolusOrigin enum into LoopKit alongside the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share one BolusOrigin definition (remote/watch/manual/shortcut) so apps no longer duplicate it; the store now takes the enum directly. Autobolus/SMB is not a case here — that is conveyed by the dose's automatic flag. --- LoopKit/InsulinKit/BolusOriginStore.swift | 28 +++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/LoopKit/InsulinKit/BolusOriginStore.swift b/LoopKit/InsulinKit/BolusOriginStore.swift index b95d9b2f8..605991618 100644 --- a/LoopKit/InsulinKit/BolusOriginStore.swift +++ b/LoopKit/InsulinKit/BolusOriginStore.swift @@ -12,11 +12,31 @@ import Foundation +/// Where a user-initiated bolus came from. Autobolus / SMB is intentionally absent — that is already conveyed +/// by the dose's `automatic` flag (and, in Trio, the SMB event type). `rawValue` is the machine token uploaded +/// to Nightscout; `displayName` is the human label. Not every app uses every case (e.g. Loop has no bolus +/// shortcut), which is fine. +public enum BolusOrigin: String, Codable { + case remote + case watch + case manual + case shortcut + + public var displayName: String { + switch self { + case .remote: return "Remote" + case .watch: return "Watch" + case .manual: return "Manual" + case .shortcut: return "Shortcut" + } + } +} + public final class BolusOriginStore { public static let shared = BolusOriginStore() private struct Entry: Codable { - let origin: String + let origin: BolusOrigin let createdAt: Date } @@ -43,7 +63,7 @@ public final class BolusOriginStore { } /// Remember an origin under a freshly generated reference and return the reference to pass to the pump. - public func makeReference(for origin: String) -> UUID { + public func makeReference(for origin: BolusOrigin) -> UUID { let reference = UUID() sync { entries[reference.uuidString] = Entry(origin: origin, createdAt: Date()) @@ -63,13 +83,13 @@ public final class BolusOriginStore { } /// Resolve the origin recorded for a reported dose's `syncIdentifier`, if any. - public func origin(forSyncIdentifier syncIdentifier: String) -> String? { + public func origin(forSyncIdentifier syncIdentifier: String) -> BolusOrigin? { sync { entries[syncIdentifier]?.origin } } /// Resolve the origin directly by its request reference. Use this when the reference is still available on /// the reported dose (no `syncIdentifier` promotion needed). - public func origin(forReference reference: UUID) -> String? { + public func origin(forReference reference: UUID) -> BolusOrigin? { sync { entries[reference.uuidString]?.origin } }