Skip to content
Closed
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
23 changes: 14 additions & 9 deletions LoopKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1790,6 +1791,7 @@
C1FAC06228C7B0A100754AE2 /* reservoir_iob_test.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = reservoir_iob_test.json; sourceTree = "<group>"; };
C1FAEC1C264AD6B400A3250B /* DeviceStatusBadge.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceStatusBadge.swift; sourceTree = "<group>"; };
C1FAEC20264AEEA300A3250B /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = "<group>"; };
D573C406C05C5D6D46B18FE4 /* BolusOriginStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BolusOriginStore.swift; sourceTree = "<group>"; };
E9077D2624ACD59F0066A88D /* InformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InformationView.swift; sourceTree = "<group>"; };
E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = "<group>"; };
E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartsTableViewController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2663,6 +2665,7 @@
4302F4E81D5066F400F0FCAF /* ReservoirValue.swift */,
A9498D6E23386C0B00DAA9B9 /* TempBasalRecommendation.swift */,
C1DB55B01F2E95FD00C483A2 /* WalshInsulinModel.swift */,
D573C406C05C5D6D46B18FE4 /* BolusOriginStore.swift */,
);
path = InsulinKit;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -4794,7 +4798,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;
Expand Down Expand Up @@ -4832,7 +4836,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;
Expand Down Expand Up @@ -4997,7 +5001,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 = (
Expand Down Expand Up @@ -5031,7 +5035,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 = (
Expand Down Expand Up @@ -5091,6 +5095,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 = (
Expand Down Expand Up @@ -5128,7 +5133,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";
Expand Down Expand Up @@ -5260,7 +5265,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)",
Expand Down Expand Up @@ -5293,7 +5298,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)",
Expand Down
27 changes: 27 additions & 0 deletions LoopKit/DeviceManager/PumpManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: UUID?, completion: @escaping (_ error: PumpManagerError?) -> Void)

/// Cancels the current, in progress, bolus.
///
/// - Parameters:
Expand Down Expand Up @@ -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: UUID?, completion: @escaping (_ error: PumpManagerError?) -> Void) {
enactBolus(units: units, activationType: activationType, completion: completion)
}

func roundToSupportedBasalRate(unitsPerHour: Double) -> Double {
return supportedBasalRates.filter({$0 <= unitsPerHour}).max() ?? 0
}
Expand Down
119 changes: 119 additions & 0 deletions LoopKit/InsulinKit/BolusOriginStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//
// 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

/// 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: BolusOrigin
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: BolusOrigin) -> 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) -> 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) -> BolusOrigin? {
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<T>(_ 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)
}
}
12 changes: 11 additions & 1 deletion LoopKit/InsulinKit/DoseEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: UUID?
public let isMutable: Bool
public let wasProgrammedByPumpUI: Bool

Expand All @@ -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: UUID? = nil) {
self.type = type
self.startDate = startDate
self.endDate = endDate ?? startDate
Expand All @@ -52,6 +55,7 @@ public struct DoseEntry: TimelineValue, Equatable {
self.manuallyEntered = manuallyEntered
self.isMutable = isMutable
self.wasProgrammedByPumpUI = wasProgrammedByPumpUI
self.bolusReference = bolusReference
}
}

Expand Down Expand Up @@ -167,6 +171,7 @@ extension DoseEntry {
"description: \(optionalString(description))",
"scheduledBasalRate: \(optionalString(scheduledBasalRate))",
"syncIdentifier: \(optionalString(syncIdentifier))",
"bolusReference: \(optionalString(bolusReference))",
].joined(separator: "\n")
}
}
Expand All @@ -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(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
Expand All @@ -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)
}
Expand All @@ -233,6 +240,7 @@ extension DoseEntry: Codable {
case scheduledBasalRateUnit
case insulinType
case automatic
case bolusReference
case manuallyEntered
case isMutable
case wasProgrammedByPumpUI
Expand Down Expand Up @@ -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).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
Expand All @@ -288,6 +297,7 @@ extension DoseEntry: RawRepresentable {
rawValue["description"] = description
rawValue["insulinType"] = insulinType?.rawValue
rawValue["automatic"] = automatic
rawValue["bolusReference"] = bolusReference?.uuidString
rawValue["syncIdentifier"] = syncIdentifier
rawValue["scheduledBasalRate"] = scheduledBasalRate?.doubleValue(for: .internationalUnitsPerHour)

Expand Down
Loading