Explorar el Código

Merge pull request #736 from kingst/yet-another-iob-decouple

Decouple IoB from determination
Deniz Cengiz hace 9 meses
padre
commit
1291a02c9c

+ 26 - 14
Trio.xcodeproj/project.pbxproj

@@ -256,6 +256,7 @@
 		3BD6CE262DC24CFD00FA0472 /* pumphistory-24h-zoned.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */; };
 		3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687B2D8DDD4600899469 /* SlideButton */; };
 		3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687E2D8DDD8800899469 /* CryptoSwift */; };
+		3BF85FE32E427312000D7351 /* IOBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF85FE12E427312000D7351 /* IOBService.swift */; };
 		45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */; };
 		45717281F743594AA9D87191 /* ConfigEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920DDB21E5D0EB813197500D /* ConfigEditorRootView.swift */; };
 		491D6FBD2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FBC2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift */; };
@@ -1074,6 +1075,7 @@
 		3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pumphistory-24h-zoned.json"; sourceTree = "<group>"; };
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
 		3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigProvider.swift; sourceTree = "<group>"; };
+		3BF85FE12E427312000D7351 /* IOBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBService.swift; sourceTree = "<group>"; };
 		3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = "<group>"; };
 		3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorDataFlow.swift; sourceTree = "<group>"; };
 		42369F66CF91F30624C0B3A6 /* BasalProfileEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorProvider.swift; sourceTree = "<group>"; };
@@ -2018,17 +2020,18 @@
 		3811DE9125C9D88200A708ED /* Services */ = {
 			isa = PBXGroup;
 			children = (
-				BD47FD112D88AA630043966B /* OnboardingManager */,
-				DDA9AC072D67291600E6F1A9 /* AppVersionChecker */,
-				BD7DB88C2D2C49FF003D3155 /* BolusCalculator */,
 				3811DE9225C9D88200A708ED /* Appearance */,
+				DDA9AC072D67291600E6F1A9 /* AppVersionChecker */,
 				CEB434E128B8F9BC00B70274 /* Bluetooth */,
+				BD7DB88C2D2C49FF003D3155 /* BolusCalculator */,
 				3862CC2C2743F9DC00BF832C /* Calendar */,
 				E592A37E2CEEC046009A472C /* ContactImage */,
 				F90692A8274B7A980037068D /* HealthKit */,
+				3BF85FE22E427312000D7351 /* IOB */,
 				6B1A8D2C2B156EC100E76752 /* LiveActivity */,
 				3811DE9425C9D88200A708ED /* Network */,
 				38B4F3C425E5016800E76A18 /* Notifications */,
+				BD47FD112D88AA630043966B /* OnboardingManager */,
 				DD9ECB662CA99EFE00AA7C45 /* RemoteControl */,
 				38AEE75025F021F10013F05B /* SettingsManager */,
 				3811DE9825C9D88300A708ED /* Storage */,
@@ -2607,6 +2610,14 @@
 			path = JSONImporterData;
 			sourceTree = "<group>";
 		};
+		3BF85FE22E427312000D7351 /* IOB */ = {
+			isa = PBXGroup;
+			children = (
+				3BF85FE12E427312000D7351 /* IOBService.swift */,
+			);
+			path = IOB;
+			sourceTree = "<group>";
+		};
 		4E8C7B59F8065047ECE20965 /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -2659,22 +2670,22 @@
 			isa = PBXGroup;
 			children = (
 				49B9B57E2D5768D2009C6B59 /* AdjustmentStored+Helper.swift */,
-				581516A82BCEEDF800BF67D7 /* NSPredicates.swift */,
-				583684052BD178DB00070A60 /* GlucoseStored+helper.swift */,
-				58F107732BD1A4D000B1A680 /* Determination+helper.swift */,
 				5837A52F2BD2E3C700A5DC04 /* CarbEntryStored+helper.swift */,
-				585E2CAD2BE7BF46006ECF1A /* PumpEvent+helper.swift */,
-				CC76E9502BD4812E008BEB61 /* Forecast+helper.swift */,
-				5887527B2BD986E1008B081D /* OpenAPSBattery.swift */,
-				581AC4382BE22ED10038760C /* JSONConverter.swift */,
-				BDB3C1182C03DD1000CEEAA1 /* UserDefaultsExtension.swift */,
+				BDB899892C565D0B006F3298 /* CarbsGlucose+helper.swift */,
 				582FAE422C05102C00D1C13F /* CoreDataError.swift */,
 				BDF34EBD2C0A31D000D51995 /* CustomNotification.swift */,
-				BDCD47AE2C1F3F1700F8BCD5 /* OverrideStored+helper.swift */,
+				58F107732BD1A4D000B1A680 /* Determination+helper.swift */,
+				CC76E9502BD4812E008BEB61 /* Forecast+helper.swift */,
+				583684052BD178DB00070A60 /* GlucoseStored+helper.swift */,
+				581AC4382BE22ED10038760C /* JSONConverter.swift */,
+				581516A82BCEEDF800BF67D7 /* NSPredicates.swift */,
+				5887527B2BD986E1008B081D /* OpenAPSBattery.swift */,
 				BD793CAF2CE7C60E00D669AC /* OverrideRunStored+helper.swift */,
-				BDB899892C565D0B006F3298 /* CarbsGlucose+helper.swift */,
-				58A3D5432C96DE11003F90FC /* TempTargetStored+Helper.swift */,
+				BDCD47AE2C1F3F1700F8BCD5 /* OverrideStored+helper.swift */,
+				585E2CAD2BE7BF46006ECF1A /* PumpEvent+helper.swift */,
 				BD793CB12CE8032E00D669AC /* TempTargetRunStored.swift */,
+				58A3D5432C96DE11003F90FC /* TempTargetStored+Helper.swift */,
+				BDB3C1182C03DD1000CEEAA1 /* UserDefaultsExtension.swift */,
 			);
 			path = Helper;
 			sourceTree = "<group>";
@@ -4322,6 +4333,7 @@
 				38BF021B25E7D06400579895 /* PumpSettingsView.swift in Sources */,
 				3811DEEA25CA063400A708ED /* SyncAccess.swift in Sources */,
 				190EBCC829FF13AA00BA767D /* UserInterfaceSettingsStateModel.swift in Sources */,
+				3BF85FE32E427312000D7351 /* IOBService.swift in Sources */,
 				DDF847EA2C5DABAC0049BB3B /* WatchConfigGarminView.swift in Sources */,
 				38BF021F25E7F0DE00579895 /* DeviceDataManager.swift in Sources */,
 				BD4E1A7A2D3681B700D21626 /* GlucoseTargetSetup.swift in Sources */,

+ 5 - 0
Trio/Sources/APS/APSManager.swift

@@ -30,6 +30,7 @@ protocol APSManager {
     func roundBolus(amount: Decimal) -> Decimal
     var lastError: CurrentValueSubject<Error?, Never> { get }
     func cancelBolus(_ callback: ((Bool, String) -> Void)?) async
+    var iobFileDidUpdate: PassthroughSubject<Void, Never> { get }
 }
 
 enum APSError: LocalizedError {
@@ -107,6 +108,7 @@ final class BaseAPSManager: APSManager, Injectable {
     let isLooping = CurrentValueSubject<Bool, Never>(false)
     let lastLoopDateSubject = PassthroughSubject<Date, Never>()
     let lastError = CurrentValueSubject<Error?, Never>(nil)
+    let iobFileDidUpdate = PassthroughSubject<Void, Never>()
 
     let bolusProgress = CurrentValueSubject<Decimal?, Never>(nil)
 
@@ -459,6 +461,7 @@ final class BaseAPSManager: APSManager, Injectable {
             _ = try await autosenseResult
             try await openAPS.createProfiles()
             let determination = try await openAPS.determineBasal(currentTemp: await currentTemp, clock: now)
+            iobFileDidUpdate.send(())
 
             guard isValidGlucoseData else {
                 throw APSError.glucoseError(message: "Glucose validation failed")
@@ -474,6 +477,8 @@ final class BaseAPSManager: APSManager, Injectable {
                 }
             }
         } catch {
+            iobFileDidUpdate.send(())
+
             // if we have a glucose validation error we might still run
             // determineBasal to try to get IoB and CoB updates but we
             // know that it will fail, so the invalidGlucoseError always

+ 1 - 0
Trio/Sources/Application/TrioApp.swift

@@ -84,6 +84,7 @@ extension Notification.Name {
         if #available(iOS 16.2, *) {
             _ = resolver.resolve(LiveActivityManager.self)!
         }
+        _ = resolver.resolve(IOBService.self)!
     }
 
     init() {

+ 1 - 0
Trio/Sources/Assemblies/ServiceAssembly.swift

@@ -28,5 +28,6 @@ final class ServiceAssembly: Assembly {
                 LiveActivityManager(resolver: r)
             }
         }
+        container.register(IOBService.self) { r in BaseIOBService(resolver: r) }
     }
 }

+ 13 - 0
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -20,6 +20,7 @@ extension Home {
         @ObservationIgnored @Injected() var tempTargetStorage: TempTargetsStorage!
         @ObservationIgnored @Injected() var overrideStorage: OverrideStorage!
         @ObservationIgnored @Injected() var bluetoothManager: BluetoothStateManager!
+        @ObservationIgnored @Injected() var iobService: IOBService!
 
         var cgmStateModel: CGMSettings.StateModel {
             CGMSettings.StateModel.shared
@@ -65,6 +66,7 @@ extension Home {
         var manualTempBasal = false
         var isSmoothingEnabled = false
         var maxIOB: Decimal = 0.0
+        var currentIOB: Decimal = 0.0
         var autosensMax: Decimal = 1.2
         var lowGlucose: Decimal = 70
         var highGlucose: Decimal = 180
@@ -215,12 +217,23 @@ extension Home {
                     group.addTask {
                         self.setupTempTargetsRunStored()
                     }
+                    group.addTask {
+                        self.iobService.updateIOB()
+                    }
                 }
             }
         }
 
         // These combine subscribers are only necessary due to the batch inserts of glucose/FPUs which do not trigger a ManagedObjectContext change notification
         private func registerSubscribers() {
+            iobService.iobPublisher
+                .receive(on: DispatchQueue.main)
+                .sink { [weak self] _ in
+                    guard let self = self else { return }
+                    self.currentIOB = self.iobService.currentIOB ?? 0
+                }
+                .store(in: &subscriptions)
+
             glucoseStorage.updatePublisher
                 .receive(on: queue)
                 .sink { [weak self] _ in

+ 1 - 1
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -432,7 +432,7 @@ extension Home {
                     Text(
                         (
                             Formatter.decimalFormatterWithTwoFractionDigits
-                                .string(from: (state.enactedAndNonEnactedDeterminations.first?.iob ?? 0) as NSNumber) ?? "0"
+                                .string(from: state.currentIOB as NSNumber) ?? "0"
                         ) +
                             String(localized: " U", comment: "Insulin unit")
                     )

+ 2 - 1
Trio/Sources/Services/Calendar/CalendarManager.swift

@@ -18,6 +18,7 @@ final class BaseCalendarManager: CalendarManager, Injectable {
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var glucoseStorage: GlucoseStorage!
     @Injected() private var storage: FileStorage!
+    @Injected() private var iobService: IOBService!
 
     // Queue for handling Core Data change notifications
     private let queue = DispatchQueue(label: "BaseCalendarManager.queue", qos: .background)
@@ -273,7 +274,7 @@ final class BaseCalendarManager: CalendarManager, Injectable {
             let deltaValue = settingsManager.settings.units == .mmolL ? delta.asMmolL : delta
             let deltaText = deltaFormatter.string(from: deltaValue as NSNumber) ?? "--"
 
-            let iobText = iobFormatter.string(from: (determinationObject.iob ?? 0) as NSNumber) ?? ""
+            let iobText = iobFormatter.string(from: (iobService.currentIOB ?? 0) as NSNumber) ?? ""
             let cobText = cobFormatter.string(from: determinationObject.cob as NSNumber) ?? ""
 
             var glucoseDisplayText = displayEmojis ? glucoseIcon + " " : ""

+ 13 - 1
Trio/Sources/Services/ContactImage/ContactImageManager.swift

@@ -23,6 +23,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
     @Injected() private var contactImageStorage: ContactImageStorage!
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var fileStorage: FileStorage!
+    @Injected() private var iobService: IOBService!
 
     private let contactStore = CNContactStore()
 
@@ -71,6 +72,17 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
             }
             .store(in: &subscriptions)
 
+        iobService.iobPublisher
+            .receive(on: DispatchQueue.global(qos: .background))
+            .sink { [weak self] _ in
+                guard let self = self else { return }
+                Task {
+                    await self.updateContactImageState()
+                    await self.updateContactImages()
+                }
+            }
+            .store(in: &subscriptions)
+
         registerHandlers()
     }
 
@@ -207,7 +219,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
 
             state.lastLoopDate = lastDetermination?.timestamp
 
-            let iobValue = lastDetermination?.iob as? Decimal ?? 0.0
+            let iobValue = iobService.currentIOB ?? 0.0
             state.iob = iobValue
             state.iobText = Formatter.decimalFormatterWithOneFractionDigit.string(from: iobValue as NSNumber)
 

+ 115 - 0
Trio/Sources/Services/IOB/IOBService.swift

@@ -0,0 +1,115 @@
+import Combine
+import CoreData
+import Foundation
+import Swinject
+
+protocol IOBService {
+    var iobPublisher: AnyPublisher<Decimal?, Never> { get }
+    var currentIOB: Decimal? { get }
+    func updateIOB()
+}
+
+/// The single source of truth for current IoB data
+///
+/// The main idea behind this class is that we want one single place to lookup IoB values that is separate
+/// from determinations. Behind the scenes it uses determinations or IoB results stored in the file system
+/// but these are implementation details that we can change with time.
+///
+// TODO: Calculate IoB using APSManager after enough time has elapsed from the last file or determination data
+final class BaseIOBService: IOBService, Injectable {
+    @Injected() private var fileStorage: FileStorage!
+    @Injected() private var determinationStorage: DeterminationStorage!
+    @Injected() private var apsManager: APSManager!
+
+    private let iobSubject = CurrentValueSubject<Decimal?, Never>(nil)
+    var iobPublisher: AnyPublisher<Decimal?, Never> {
+        iobSubject.eraseToAnyPublisher()
+    }
+
+    // Query the current IOB syncrhonously
+    var currentIOB: Decimal? {
+        lookupIOB()
+    }
+
+    private var subscriptions = Set<AnyCancellable>()
+    private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
+    private let queue = DispatchQueue(label: "BaseIOBService.queue", qos: .background)
+    private let context = CoreDataStack.shared.newTaskContext()
+
+    init(resolver: Resolver) {
+        injectServices(resolver)
+        coreDataPublisher =
+            changedObjectsOnManagedObjectContextDidSavePublisher()
+                .receive(on: queue)
+                .share()
+                .eraseToAnyPublisher()
+        subscribe()
+    }
+
+    private func subscribe() {
+        // Trigger update when a new determination is available
+        coreDataPublisher?.filteredByEntityName("OrefDetermination").sink { [weak self] _ in
+            self?.updateIOB()
+        }.store(in: &subscriptions)
+
+        // Trigger update when the iob file is updated
+        apsManager.iobFileDidUpdate
+            .sink { [weak self] _ in
+                self?.updateIOB()
+            }
+            .store(in: &subscriptions)
+    }
+
+    // Fetches the IoB and timestamp from the most recent determination
+    private func fetchLatestDeterminationIOB() -> (iob: Decimal?, date: Date?) {
+        var iob: Decimal?
+        var date: Date?
+        context.performAndWait {
+            let request = OrefDetermination.fetchRequest() as NSFetchRequest<OrefDetermination>
+            request.sortDescriptors = [NSSortDescriptor(key: "deliverAt", ascending: false)]
+            request.fetchLimit = 1
+            if let determination = try? context.fetch(request).first {
+                iob = determination.iob as? Decimal
+                date = determination.deliverAt
+            }
+        }
+        return (iob, date)
+    }
+
+    // Lookup IOB data from the file system and determinations core data, use the most
+    // recent value
+    func lookupIOB() -> Decimal? {
+        let iobFromFile = fileStorage.retrieve(OpenAPS.Monitor.iob, as: [IOBEntry].self)
+        let iobFromFileValue = iobFromFile?.first?.iob
+        let iobFromFileDate = iobFromFile?.first?.time
+
+        let (iobFromDetermination, iobFromDeterminationDate) = fetchLatestDeterminationIOB()
+
+        var mostRecentIOB: Decimal?
+
+        if let iobFromFileValue = iobFromFileValue, let iobFromFileDate = iobFromFileDate {
+            if let iobFromDetermination = iobFromDetermination, let iobFromDeterminationDate = iobFromDeterminationDate {
+                if iobFromFileDate > iobFromDeterminationDate {
+                    mostRecentIOB = iobFromFileValue
+                } else {
+                    mostRecentIOB = iobFromDetermination
+                }
+            } else {
+                mostRecentIOB = iobFromFileValue
+            }
+        } else {
+            mostRecentIOB = iobFromDetermination
+        }
+
+        return mostRecentIOB
+    }
+
+    func updateIOB() {
+        Task {
+            let mostRecentIOB = lookupIOB()
+            if iobSubject.value != mostRecentIOB {
+                iobSubject.send(mostRecentIOB)
+            }
+        }
+    }
+}

+ 1 - 2
Trio/Sources/Services/LiveActivity/Data/DataManager.swift

@@ -34,7 +34,7 @@ extension LiveActivityManager {
             key: "deliverAt",
             ascending: false,
             fetchLimit: 1,
-            propertiesToFetch: ["iob", "cob", "currentTarget", "deliverAt"]
+            propertiesToFetch: ["cob", "currentTarget", "deliverAt"]
         )
 
         let tddResults = try await CoreDataStack.shared.fetchEntitiesAsync(
@@ -60,7 +60,6 @@ extension LiveActivityManager {
 
             return DeterminationData(
                 cob: (determination["cob"] as? Int) ?? 0,
-                iob: (determination["iob"] as? NSDecimalNumber)?.decimalValue ?? 0,
                 tdd: tddValue,
                 target: (determination["currentTarget"] as? NSDecimalNumber)?.decimalValue ?? 0,
                 date: determination["deliverAt"] as? Date ?? nil

+ 0 - 1
Trio/Sources/Services/LiveActivity/Data/DeterminationData.swift

@@ -2,7 +2,6 @@ import Foundation
 
 struct DeterminationData {
     let cob: Int
-    let iob: Decimal
     let tdd: Decimal
     let target: Decimal
     let date: Date?

+ 2 - 1
Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift

@@ -62,6 +62,7 @@ extension LiveActivityAttributes.ContentState {
         chart: [GlucoseData],
         settings: TrioSettings,
         determination: DeterminationData?,
+        iob: Decimal?,
         override: OverrideData?,
         widgetItems: [LiveActivityAttributes.LiveActivityItem]?
     ) {
@@ -108,7 +109,7 @@ extension LiveActivityAttributes.ContentState {
                 chartDate: chartDate,
                 rotationDegrees: rotationDegrees,
                 cob: Decimal(determination?.cob ?? 0),
-                iob: determination?.iob ?? 0 as Decimal,
+                iob: iob ?? 0 as Decimal,
                 tdd: determination?.tdd ?? 0 as Decimal,
                 isOverrideActive: override?.isActive ?? false,
                 overrideName: override?.overrideName ?? "Override",

+ 10 - 0
Trio/Sources/Services/LiveActivity/LiveActivityManager.swift

@@ -29,6 +29,8 @@ import UIKit
 final class LiveActivityData: ObservableObject {
     /// Determination data used to update live activity state.
     @Published var determination: DeterminationData?
+    /// The most recent IoB data
+    @Published var iob: Decimal?
     /// Array of glucose readings fetched from persistent storage.
     @Published var glucoseFromPersistence: [GlucoseData]?
     /// The current override data (if any).
@@ -51,6 +53,7 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var storage: FileStorage!
     @Injected() private var glucoseStorage: GlucoseStorage!
+    @Injected() private var iobService: IOBService!
 
     private let activityAuthorizationInfo = ActivityAuthorizationInfo()
     /// Indicates whether system live activities are enabled.
@@ -147,6 +150,12 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
             .sink { [weak self] _ in
                 Task { await self?.loadDetermination() }
             }.store(in: &subscriptions)
+
+        iobService.iobPublisher
+            .debounce(for: .seconds(2), scheduler: DispatchQueue.global(qos: .utility))
+            .sink { [weak self] _ in
+                self?.data.iob = self?.iobService.currentIOB
+            }.store(in: &subscriptions)
     }
 
     /// Fetches and maps new determination data and updates the live activity content state.
@@ -374,6 +383,7 @@ extension LiveActivityManager {
             chart: glucose,
             settings: settings,
             determination: determination,
+            iob: data.iob,
             override: data.override,
             widgetItems: data.widgetItems
         )

+ 3 - 25
Trio/Sources/Services/RemoteControl/TrioRemoteControl+Bolus.swift

@@ -18,7 +18,9 @@ extension TrioRemoteControl {
         }
 
         let maxIOB = settings.preferences.maxIOB
-        let currentIOB = try await fetchCurrentIOB()
+        guard let currentIOB = iobService.currentIOB else {
+            throw CoreDataError.fetchError(function: #function, file: #file)
+        }
         if (currentIOB + bolusAmount) > maxIOB {
             await logError(
                 "Command rejected: bolus amount (\(bolusAmount) units) would exceed max IOB (\(maxIOB) units). Current IOB: \(currentIOB) units.",
@@ -56,30 +58,6 @@ extension TrioRemoteControl {
         )
     }
 
-    private func fetchCurrentIOB() async throws -> Decimal {
-        let predicate = NSPredicate.predicateFor30MinAgoForDetermination
-
-        let determinations = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OrefDetermination.self,
-            onContext: pumpHistoryFetchContext,
-            predicate: predicate,
-            key: "timestamp",
-            ascending: false,
-            fetchLimit: 1,
-            propertiesToFetch: ["iob"]
-        )
-
-        guard let fetchedResults = determinations as? [[String: Any]],
-              let firstResult = fetchedResults.first,
-              let iob = firstResult["iob"] as? Decimal
-        else {
-            await logError("Failed to fetch current IOB.")
-            throw CoreDataError.fetchError(function: #function, file: #file)
-        }
-
-        return iob
-    }
-
     private func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal {
         let predicate = NSPredicate(
             format: "type == %@ AND timestamp > %@",

+ 1 - 0
Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift

@@ -10,6 +10,7 @@ class TrioRemoteControl: Injectable {
     @Injected() internal var nightscoutManager: NightscoutManager!
     @Injected() internal var overrideStorage: OverrideStorage!
     @Injected() internal var settings: SettingsManager!
+    @Injected() internal var iobService: IOBService!
 
     private let timeWindow: TimeInterval = 600 // Defines how old messages that are accepted, 10 minutes
 

+ 15 - 3
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -24,6 +24,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     @Injected() private var overrideStorage: OverrideStorage!
     @Injected() private var tempTargetStorage: TempTargetsStorage!
     @Injected() private var bolusCalculationManager: BolusCalculationManager!
+    @Injected() private var iobService: IOBService!
 
     private var units: GlucoseUnits = .mgdL
     private var glucoseColorScheme: GlucoseColorScheme = .staticColor
@@ -79,6 +80,17 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             }
             .store(in: &subscriptions)
 
+        iobService.iobPublisher
+            .receive(on: DispatchQueue.global(qos: .background))
+            .sink { [weak self] _ in
+                guard let self = self else { return }
+                Task {
+                    let state = await self.setupWatchState()
+                    await self.sendDataToWatch(state)
+                }
+            }
+            .store(in: &subscriptions)
+
         registerHandlers()
     }
 
@@ -201,10 +213,10 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                 }
 
                 // Set IOB and COB from latest determination
-                if let latestDetermination = determinationObjects.first {
-                    let iob = latestDetermination.iob ?? 0
-                    watchState.iob = Formatter.decimalFormatterWithTwoFractionDigits.string(from: iob)
+                let iob = self.iobService.currentIOB ?? 0
+                watchState.iob = Formatter.decimalFormatterWithTwoFractionDigits.string(from: iob as NSNumber)
 
+                if let latestDetermination = determinationObjects.first {
                     let cob = NSNumber(value: latestDetermination.cob)
                     watchState.cob = Formatter.integerFormatter.string(from: cob)
                 }

+ 24 - 3
Trio/Sources/Services/WatchManager/GarminManager.swift

@@ -53,6 +53,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
     /// Stores, retrieves, and updates insulin dose determinations in CoreData.
     @Injected() private var determinationStorage: DeterminationStorage!
 
+    @Injected() private var iobService: IOBService!
+
     /// Persists the user's device list between app launches.
     @Persisted(key: "BaseGarminManager.persistedDevices") private var persistedDevices: [GarminDevice] = []
 
@@ -150,6 +152,25 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
             }
             .store(in: &subscriptions)
 
+        iobService.iobPublisher
+            .receive(on: DispatchQueue.global(qos: .background))
+            .sink { [weak self] _ in
+                guard let self = self else { return }
+                Task {
+                    do {
+                        let watchState = try await self.setupGarminWatchState()
+                        let watchStateData = try JSONEncoder().encode(watchState)
+                        self.sendWatchStateData(watchStateData)
+                    } catch {
+                        debug(
+                            .watchManager,
+                            "\(DebuggingIdentifiers.failed) Error updating watch state: \(error)"
+                        )
+                    }
+                }
+            }
+            .store(in: &subscriptions)
+
         registerHandlers()
     }
 
@@ -250,15 +271,15 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
                 var watchState = GarminWatchState()
 
                 /// Pull `glucose`, `trendRaw`, `delta`, `lastLoopDateInterval`, `iob`, `cob`,  `isf`, and `eventualBGRaw` from the latest determination.
+                let iobValue = self.iobService.currentIOB ?? 0
+                watchState.iob = self.iobFormatterWithOneFractionDigit(iobValue)
+
                 if let latestDetermination = determinationObjects.first {
                     watchState.lastLoopDateInterval = latestDetermination.timestamp.map {
                         guard $0.timeIntervalSince1970 > 0 else { return 0 }
                         return UInt64($0.timeIntervalSince1970)
                     }
 
-                    let iobValue = latestDetermination.iob ?? 0
-                    watchState.iob = self.iobFormatterWithOneFractionDigit(iobValue as Decimal)
-
                     let cobNumber = NSNumber(value: latestDetermination.cob)
                     watchState.cob = Formatter.integerFormatter.string(from: cobNumber)
 

+ 1 - 0
Trio/Sources/Shortcuts/BaseIntentsRequest.swift

@@ -16,6 +16,7 @@ import Swinject
     @Injected() var overrideStorage: OverrideStorage!
     @Injected() var liveActivityManager: LiveActivityManager!
     @Injected() var pumpHistoryStorage: PumpHistoryStorage!
+    @Injected() var iobService: IOBService!
 
     let resolver: Resolver
 

+ 1 - 1
Trio/Sources/Shortcuts/State/StateIntentRequest.swift

@@ -111,7 +111,7 @@ final class StateIntentRequest: BaseIntentsRequest {
             fetchLimit: 1
         ) as? [OrefDetermination] ?? []
 
-        let iobAsDouble = Double(truncating: (results.first?.iob ?? 0.0) as NSNumber)
+        let iobAsDouble = Double(truncating: (iobService.currentIOB ?? 0.0) as NSNumber)
         let cobAsDouble = Double(truncating: (results.first?.cob ?? 0) as NSNumber)
 
         return (iobAsDouble, cobAsDouble)