Skip to content
Draft
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
24 changes: 24 additions & 0 deletions LoopKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
objects = {

/* Begin PBXBuildFile section */
9D3D2DA3B9824EF48981F0E4 /* StoredInsulinModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CAB9E826A3254800A57273 /* StoredInsulinModel.swift */; };
485ACF65C30744BFBC979CEF /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A985464E251449FE0099C1A6 /* NotificationSettings.swift */; };
2383C3D7967B4602B8EB4A5B /* SettingsObject+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = A912BE2A245B9E8600CBE199 /* SettingsObject+CoreDataProperties.swift */; };
A686162EA4C349A4A58F35E5 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A919889B2354E5EB00B75EEE /* SettingsStore.swift */; };
2DBE5A4919F244E2A30A2092 /* PumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432CF87220D774220066B889 /* PumpManager.swift */; };
142CB75D2A60C01E0075748A /* ListButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142CB75C2A60C01E0075748A /* ListButtonStyle.swift */; };
142CB7612A60C0440075748A /* FavoriteFood.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142CB7602A60C0440075748A /* FavoriteFood.swift */; };
1452F4B02A85219100F8B9E4 /* AbsorptionTimePickerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1452F4AF2A85219100F8B9E4 /* AbsorptionTimePickerRow.swift */; };
Expand Down Expand Up @@ -471,6 +476,7 @@
A914472727BF044C0054A39D /* normalize_edge_case_doses_mutable_output.json in Resources */ = {isa = PBXBuildFile; fileRef = A914472627BF044C0054A39D /* normalize_edge_case_doses_mutable_output.json */; };
A919889C2354E5EB00B75EEE /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A919889B2354E5EB00B75EEE /* SettingsStore.swift */; };
A919889F2355016B00B75EEE /* DosingDecisionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A919889E2355016B00B75EEE /* DosingDecisionStore.swift */; };
5D0A1B2C3D4E5F6071829304 /* DosingDecisionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A919889E2355016B00B75EEE /* DosingDecisionStore.swift */; };
A932803B2798D63B0091D0A1 /* SyncAlertObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = A932803A2798D63B0091D0A1 /* SyncAlertObject.swift */; };
A932803C2798D63B0091D0A1 /* SyncAlertObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = A932803A2798D63B0091D0A1 /* SyncAlertObject.swift */; };
A933DB8824BF956F009B417A /* CriticalEventLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = A933DB8724BF956F009B417A /* CriticalEventLog.swift */; };
Expand All @@ -495,11 +501,14 @@
A9498D8F23386CE800DAA9B9 /* MockServiceTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9498D8E23386CE800DAA9B9 /* MockServiceTableViewController.swift */; };
A9498D9123386D0800DAA9B9 /* MockService+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9498D9023386D0800DAA9B9 /* MockService+UI.swift */; };
A95A1D7F2460BBC70079378D /* DosingDecisionObject+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95A1D7E2460BBC70079378D /* DosingDecisionObject+CoreDataClass.swift */; };
5D0A1B2C3D4E5F6071829310 /* DosingDecisionObject+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95A1D7E2460BBC70079378D /* DosingDecisionObject+CoreDataClass.swift */; };
A95A1D802460BBC70079378D /* DosingDecisionObject+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95A1D7E2460BBC70079378D /* DosingDecisionObject+CoreDataClass.swift */; };
A95A1D822460BBDC0079378D /* DosingDecisionObject+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95A1D812460BBDC0079378D /* DosingDecisionObject+CoreDataProperties.swift */; };
5D0A1B2C3D4E5F6071829311 /* DosingDecisionObject+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95A1D812460BBDC0079378D /* DosingDecisionObject+CoreDataProperties.swift */; };
A95A1D852460CAD50079378D /* CarbValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95A1D842460CAD50079378D /* CarbValueTests.swift */; };
A95A1D872460F1250079378D /* GlucoseValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95A1D862460F1250079378D /* GlucoseValueTests.swift */; };
A95A1D892460F8930079378D /* PumpManagerStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95A1D882460F8930079378D /* PumpManagerStatusTests.swift */; };
DE8BEC7668FD9453E9AAAAF2 /* SharedDeviceConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32721852AEE28F8567ECD7F4 /* SharedDeviceConfigurationTests.swift */; };
A95A1D8B2460FD620079378D /* BolusRecommendationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95A1D8A2460FD620079378D /* BolusRecommendationTests.swift */; };
A95A1D8D246101760079378D /* DoseEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95A1D8C246101760079378D /* DoseEntryTests.swift */; };
A963B278252CE2510062AA12 /* StoredCarbEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A963B277252CE2510062AA12 /* StoredCarbEntryTests.swift */; };
Expand Down Expand Up @@ -925,6 +934,8 @@
E9F54F5E25802D5E0034795E /* InsulinType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F54F5D25802D5E0034795E /* InsulinType.swift */; };
E9F54FBA258052130034795E /* InsulinType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F54F5D25802D5E0034795E /* InsulinType.swift */; };
E9F54FC62581C50D0034795E /* ExpandableDatePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F54FC52581C50C0034795E /* ExpandableDatePicker.swift */; };
D3AB066B0AC14B2FAE49D26A /* ManualBolusRecommendation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C140DFFE260276BF000A4FF7 /* ManualBolusRecommendation.swift */; };
8AFF6E7769EB4BC28C92B41C /* AutomaticDoseRecommendation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C191D26225B3815000C26C0B /* AutomaticDoseRecommendation.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -1551,6 +1562,7 @@
A95A1D842460CAD50079378D /* CarbValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbValueTests.swift; sourceTree = "<group>"; };
A95A1D862460F1250079378D /* GlucoseValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseValueTests.swift; sourceTree = "<group>"; };
A95A1D882460F8930079378D /* PumpManagerStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerStatusTests.swift; sourceTree = "<group>"; };
32721852AEE28F8567ECD7F4 /* SharedDeviceConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SharedDeviceConfigurationTests.swift"; sourceTree = "<group>"; };
A95A1D8A2460FD620079378D /* BolusRecommendationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusRecommendationTests.swift; sourceTree = "<group>"; };
A95A1D8C246101760079378D /* DoseEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseEntryTests.swift; sourceTree = "<group>"; };
A963B277252CE2510062AA12 /* StoredCarbEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCarbEntryTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2542,6 +2554,7 @@
B46E096C25F80D6C0025A04D /* OverrideTests.swift */,
A9DF02D024F7449E00B7C988 /* PersistentDeviceLogTests.swift */,
A95A1D882460F8930079378D /* PumpManagerStatusTests.swift */,
32721852AEE28F8567ECD7F4 /* SharedDeviceConfigurationTests.swift */,
434C5F9F209ABD4700B2FD1A /* QuantityFormatterTests.swift */,
43D8FE1C1C72906E0073BE78 /* QuantityScheduleTests.swift */,
A99C73782339ACDC00C80963 /* ServiceTests.swift */,
Expand Down Expand Up @@ -4312,6 +4325,7 @@
891A3FD32249990900378B27 /* DailyValueSchedule.swift in Sources */,
434C5FA0209ABD4700B2FD1A /* QuantityFormatterTests.swift in Sources */,
A95A1D892460F8930079378D /* PumpManagerStatusTests.swift in Sources */,
DE8BEC7668FD9453E9AAAAF2 /* SharedDeviceConfigurationTests.swift in Sources */,
B4CEE2A5256E912A0093111B /* DoseProgressTests.swift in Sources */,
A9D3FF1B2A6C2CA9000C891D /* ChartAxisValuesStaticGeneratorTests.swift in Sources */,
A963B278252CE2510062AA12 /* StoredCarbEntryTests.swift in Sources */,
Expand Down Expand Up @@ -4456,6 +4470,16 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
8AFF6E7769EB4BC28C92B41C /* AutomaticDoseRecommendation.swift in Sources */,
D3AB066B0AC14B2FAE49D26A /* ManualBolusRecommendation.swift in Sources */,
5D0A1B2C3D4E5F6071829311 /* DosingDecisionObject+CoreDataProperties.swift in Sources */,
5D0A1B2C3D4E5F6071829310 /* DosingDecisionObject+CoreDataClass.swift in Sources */,
5D0A1B2C3D4E5F6071829304 /* DosingDecisionStore.swift in Sources */,
9D3D2DA3B9824EF48981F0E4 /* StoredInsulinModel.swift in Sources */,
485ACF65C30744BFBC979CEF /* NotificationSettings.swift in Sources */,
2383C3D7967B4602B8EB4A5B /* SettingsObject+CoreDataProperties.swift in Sources */,
A686162EA4C349A4A58F35E5 /* SettingsStore.swift in Sources */,
2DBE5A4919F244E2A30A2092 /* PumpManager.swift in Sources */,
C1CB895925C8E7D900782BAC /* Modelv1v4.xcmappingmodel in Sources */,
D1A0B0000000000000000008 /* Modelv4ToModelv6.xcmappingmodel in Sources */,
D1A0B0000000000000000009 /* Modelv5ToModelv6.xcmappingmodel in Sources */,
Expand Down
106 changes: 106 additions & 0 deletions LoopKit/DeviceManager/DeviceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ public protocol DeviceManagerDelegate: AlertIssuer, PersistedAlertStore {
// This will be called from an unspecified queue
func deviceManager(_ manager: DeviceManager, logEventForDeviceIdentifier deviceIdentifier: String?, type: DeviceLogEntryType, message: String, completion: ((Error?) -> Void)?)

/// An `ExclusiveDeviceControl` device's link is up and the device has answered. Optional.
func deviceManagerControlDidBecomeReady(_ manager: DeviceManager)

/// Records an event with the app's analytics services. This will be called from an unspecified queue
func deviceManager(_ manager: DeviceManager, recordAnalyticsEvent name: String, properties: [AnyHashable: Any]?)
}
Expand All @@ -38,4 +41,107 @@ public protocol DeviceManager: CustomDebugStringConvertible, AlertResponder, Ale

/// Is the device manager onboarded and ready for use?
var isOnboarded: Bool { get }

/// Why the device is inoperable, rendered by the kit for display (e.g. its fault alarm's
/// title). Default: nil.
var localizedInoperableDescription: String? { get }
}

public extension DeviceManager {
var localizedInoperableDescription: String? { nil }
}

// MARK: - Sharing a device with another controller

/// What another controller needs to use a device, plus a header the host can read.
public struct SharedDeviceConfiguration: RawRepresentable {
public typealias RawValue = [String: Any]

/// Which plugin builds from it: the exporting manager's `pluginIdentifier`.
public let managerIdentifier: String
/// When it was true.
public let asOf: Date
/// Pumps: the last known delivered total at `asOf`.
public let deliveredUnits: Double?
/// Opaque: only the kit reads it.
public let state: [String: Any]

public init(managerIdentifier: String, asOf: Date, deliveredUnits: Double? = nil, state: [String: Any]) {
self.managerIdentifier = managerIdentifier
self.asOf = asOf
self.deliveredUnits = deliveredUnits
self.state = state
}

public init?(rawValue: RawValue) {
guard let managerIdentifier = rawValue["managerIdentifier"] as? String,
let asOf = rawValue["asOf"] as? Date,
let state = rawValue["state"] as? [String: Any] else {
return nil
}
self.init(managerIdentifier: managerIdentifier, asOf: asOf,
deliveredUnits: rawValue["deliveredUnits"] as? Double, state: state)
}

public var rawValue: RawValue {
var raw: RawValue = ["managerIdentifier": managerIdentifier, "asOf": asOf, "state": state]
raw["deliveredUnits"] = deliveredUnits
return raw
}
}

/// A device manager that can export what another controller needs and be built from such an
/// export. Managers that cannot simply don't conform; callers find it by conditional cast.
public protocol DeviceConfigurationSharing: DeviceManager {
/// Export for another controller. The kit leaves out what is local to this controller.
func exportConfiguration() -> SharedDeviceConfiguration

/// Build a manager from another controller's export ("passed the configuration"), with the
/// `localState` an earlier manager of this plugin left here.
init?(adopting configuration: SharedDeviceConfiguration, localState: [String: Any]?)

/// What this controller learned about the device that is its own (for example its Bluetooth
/// handle): never in the export, and it should outlive this manager. The host keeps the latest
/// value per `pluginIdentifier` (an export's `managerIdentifier`) and passes it to the next
/// `init(adopting:localState:)`. Default: nil.
var localState: [String: Any]? { get }

/// Whether the user wants this device shared with another controller (for example a watch reading
/// a CGM itself). The host exports only while it is true. Default: true.
var isConfigurationSharingEnabled: Bool { get }
}

public extension DeviceConfigurationSharing {
var localState: [String: Any]? { nil }
var isConfigurationSharingEnabled: Bool { true }
}

/// A device whose hardware allows one controller at a time. Release, take and readiness say
/// nothing about who the controllers are; that is the host's business.
public protocol ExclusiveDeviceControl: DeviceConfigurationSharing {
/// True while control is released. Persisted by the kit, so a relaunch does not take it back.
var isControlReleased: Bool { get }

/// Stop bidding for the device; keep keys and pairing.
func releaseControl()

/// Connect, and keep reacquiring however the kit sees fit until ready or released. Readiness
/// follows through `DeviceManagerDelegate.deviceManagerControlDidBecomeReady(_:)`.
func takeControl()

/// The link is up and the device has answered. Default: true.
var isControlReady: Bool { get }

/// Whether taking control with this export and local state means finding the device first
/// (the host may need to keep its screen or radio awake). Default: false.
static func takeControlNeedsSearch(adopting configuration: SharedDeviceConfiguration, localState: [String: Any]?) -> Bool
}

public extension ExclusiveDeviceControl {
var isControlReady: Bool { true }
static func takeControlNeedsSearch(adopting configuration: SharedDeviceConfiguration, localState: [String: Any]?) -> Bool { false }
}

public extension DeviceManagerDelegate {
func deviceManagerControlDidBecomeReady(_ manager: DeviceManager) {}
}
17 changes: 17 additions & 0 deletions LoopKit/DeviceManager/PumpManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,20 @@ public extension PumpManager {
}
}
}

/// A pump's cumulative delivered total. Required for a pump to be handed to another controller:
/// it is the one figure both controllers can audit their records against.
public protocol PumpDeliveryOdometer {
/// The total as last read from the pump, and when.
var deliveredUnits: (units: Double, at: Date)? { get }

/// The size of one delivery pulse: the increment `deliveredUnits` counts in.
var deliveryPulseUnits: Double { get }

/// Reads the pump now, bypassing any freshness shortcut. Returns whether the read succeeded.
func refreshDeliveredUnits() async -> Bool

/// When the bolus the pump reported giving at its last read will end, whoever started it; nil
/// when it reported none. A controller's own bolus state knows only the boluses it started.
var bolusUnderWayEndsAt: Date? { get }
}
47 changes: 47 additions & 0 deletions LoopKitTests/SharedDeviceConfigurationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// SharedDeviceConfigurationTests.swift
// LoopKitTests
//
// Copyright © 2026 LoopKit Authors. All rights reserved.
//

import XCTest
@testable import LoopKit

class SharedDeviceConfigurationTests: XCTestCase {

func testRawValueRoundTripsTheHeaderAndLeavesTheStateOpaque() {
let asOf = Date(timeIntervalSinceReferenceDate: 800_000_000)
let configuration = SharedDeviceConfiguration(managerIdentifier: "Pump", asOf: asOf, deliveredUnits: 12.35,
state: ["key": "value", "nested": ["n": 1]])

let restored = SharedDeviceConfiguration(rawValue: configuration.rawValue)

XCTAssertEqual(restored?.managerIdentifier, "Pump")
XCTAssertEqual(restored?.asOf, asOf)
XCTAssertEqual(restored?.deliveredUnits, 12.35)
XCTAssertEqual(restored?.state["key"] as? String, "value")
XCTAssertEqual((restored?.state["nested"] as? [String: Any])?["n"] as? Int, 1)
}

func testDeliveredUnitsIsOptional() {
let configuration = SharedDeviceConfiguration(managerIdentifier: "CGM", asOf: Date(), state: [:])
XCTAssertNil(configuration.rawValue["deliveredUnits"])
XCTAssertNotNil(SharedDeviceConfiguration(rawValue: configuration.rawValue))
XCTAssertNil(SharedDeviceConfiguration(rawValue: configuration.rawValue)?.deliveredUnits)
}

func testAnIncompleteRawValueIsRefused() {
XCTAssertNil(SharedDeviceConfiguration(rawValue: ["managerIdentifier": "Pump", "state": [:]]))
XCTAssertNil(SharedDeviceConfiguration(rawValue: ["asOf": Date(), "state": [:]]))
XCTAssertNil(SharedDeviceConfiguration(rawValue: ["managerIdentifier": "Pump", "asOf": Date()]))
}

func testTheRawValueIsAPropertyList() throws {
let configuration = SharedDeviceConfiguration(managerIdentifier: "Pump", asOf: Date(), deliveredUnits: 1,
state: ["data": Data([1, 2]), "date": Date()])
let data = try PropertyListSerialization.data(fromPropertyList: configuration.rawValue, format: .binary, options: 0)
let decoded = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any]
XCTAssertEqual(decoded.flatMap(SharedDeviceConfiguration.init(rawValue:))?.state["data"] as? Data, Data([1, 2]))
}
}