diff --git a/LoopKit.xcodeproj/project.pbxproj b/LoopKit.xcodeproj/project.pbxproj index d8fe2fd62..8738ff4ec 100644 --- a/LoopKit.xcodeproj/project.pbxproj +++ b/LoopKit.xcodeproj/project.pbxproj @@ -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 */; }; @@ -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 */; }; @@ -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 */; }; @@ -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 */ @@ -1551,6 +1562,7 @@ A95A1D842460CAD50079378D /* CarbValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbValueTests.swift; sourceTree = ""; }; A95A1D862460F1250079378D /* GlucoseValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseValueTests.swift; sourceTree = ""; }; A95A1D882460F8930079378D /* PumpManagerStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerStatusTests.swift; sourceTree = ""; }; + 32721852AEE28F8567ECD7F4 /* SharedDeviceConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SharedDeviceConfigurationTests.swift"; sourceTree = ""; }; A95A1D8A2460FD620079378D /* BolusRecommendationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusRecommendationTests.swift; sourceTree = ""; }; A95A1D8C246101760079378D /* DoseEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseEntryTests.swift; sourceTree = ""; }; A963B277252CE2510062AA12 /* StoredCarbEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCarbEntryTests.swift; sourceTree = ""; }; @@ -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 */, @@ -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 */, @@ -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 */, diff --git a/LoopKit/DeviceManager/DeviceManager.swift b/LoopKit/DeviceManager/DeviceManager.swift index 348f783cc..167765c9c 100644 --- a/LoopKit/DeviceManager/DeviceManager.swift +++ b/LoopKit/DeviceManager/DeviceManager.swift @@ -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]?) } @@ -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) {} } diff --git a/LoopKit/DeviceManager/PumpManager.swift b/LoopKit/DeviceManager/PumpManager.swift index 4917c8271..2d21a1eef 100644 --- a/LoopKit/DeviceManager/PumpManager.swift +++ b/LoopKit/DeviceManager/PumpManager.swift @@ -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 } +} diff --git a/LoopKitTests/SharedDeviceConfigurationTests.swift b/LoopKitTests/SharedDeviceConfigurationTests.swift new file mode 100644 index 000000000..90296b717 --- /dev/null +++ b/LoopKitTests/SharedDeviceConfigurationTests.swift @@ -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])) + } +}