소스 검색

Merge pull request #793 from bjorkert/recommended-bolus-to-nightscout

Add recommended bolus and pump bolus increment to Nightscout devicestatus
Deniz Cengiz 5 달 전
부모
커밋
c5d55ef196

+ 8 - 0
Trio.xcodeproj/project.pbxproj

@@ -608,6 +608,8 @@
 		DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */; };
 		DD868FD82E381A54005D3308 /* APNSJWTClaims.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */; };
 		DD88C8E22C50420800F2D558 /* DefinitionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD88C8E12C50420800F2D558 /* DefinitionRow.swift */; };
+		DD906BF42EA6AA0100262772 /* NightscoutUploadPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */; };
+		DD906BF62EA6AAE900262772 /* BaseNightscoutManager+Subscribers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD906BF52EA6AAE900262772 /* BaseNightscoutManager+Subscribers.swift */; };
 		DD940BAA2CA7585D000830A5 /* GlucoseColorScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */; };
 		DD940BAC2CA75889000830A5 /* DynamicGlucoseColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */; };
 		DD98ACC02D71013200C0778F /* StatChartUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD98ACBF2D71013200C0778F /* StatChartUtils.swift */; };
@@ -1436,6 +1438,8 @@
 		DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertyPersistentFlags.swift; sourceTree = "<group>"; };
 		DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSJWTClaims.swift; sourceTree = "<group>"; };
 		DD88C8E12C50420800F2D558 /* DefinitionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefinitionRow.swift; sourceTree = "<group>"; };
+		DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadPipeline.swift; sourceTree = "<group>"; };
+		DD906BF52EA6AAE900262772 /* BaseNightscoutManager+Subscribers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseNightscoutManager+Subscribers.swift"; sourceTree = "<group>"; };
 		DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseColorScheme.swift; sourceTree = "<group>"; };
 		DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicGlucoseColor.swift; sourceTree = "<group>"; };
 		DD98ACBF2D71013200C0778F /* StatChartUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatChartUtils.swift; sourceTree = "<group>"; };
@@ -3474,8 +3478,10 @@
 		DDC9B9962CFD2332003E7721 /* Nightscout */ = {
 			isa = PBXGroup;
 			children = (
+				DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */,
 				3811DE9725C9D88300A708ED /* NightscoutManager.swift */,
 				38FE826C25CC8461001FF17A /* NightscoutAPI.swift */,
+				DD906BF52EA6AAE900262772 /* BaseNightscoutManager+Subscribers.swift */,
 			);
 			path = Nightscout;
 			sourceTree = "<group>";
@@ -4135,6 +4141,7 @@
 				F90692CF274B999A0037068D /* HealthKitDataFlow.swift in Sources */,
 				CE7CA3552A064973004BE681 /* ListStateIntent.swift in Sources */,
 				C28DD7262DBA9A9E00EC02DD /* GlucosePercentileDetailView.swift in Sources */,
+				DD906BF42EA6AA0100262772 /* NightscoutUploadPipeline.swift in Sources */, 
 				BDF530D82B40F8AC002CAF43 /* LockScreenView.swift in Sources */,
 				195D80B72AF697B800D25097 /* DynamicSettingsDataFlow.swift in Sources */,
 				DD98ACC02D71013200C0778F /* StatChartUtils.swift in Sources */,
@@ -4444,6 +4451,7 @@
 				BD7DA9AC2AE06EB900601B20 /* BolusCalculatorConfigRootView.swift in Sources */,
 				AD3D2CD42CD01B9EB8F26522 /* PumpConfigDataFlow.swift in Sources */,
 				DD17452E2C55AE4800211FAC /* TargetBehavoirDataFlow.swift in Sources */,
+				DD906BF62EA6AAE900262772 /* BaseNightscoutManager+Subscribers.swift in Sources */,
 				53F2382465BF74DB1A967C8B /* PumpConfigProvider.swift in Sources */,
 				5D16287A969E64D18CE40E44 /* PumpConfigStateModel.swift in Sources */,
 				DDF6902C2DA028D3008BF16C /* DiagnosticsStepView.swift in Sources */,

+ 4 - 8
Trio/Sources/APS/APSManager.swift

@@ -76,7 +76,6 @@ final class BaseAPSManager: APSManager, Injectable {
     @Injected() private var carbsStorage: CarbsStorage!
     @Injected() private var determinationStorage: DeterminationStorage!
     @Injected() private var deviceDataManager: DeviceDataManager!
-    @Injected() private var nightscout: NightscoutManager!
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var tddStorage: TDDStorage!
     @Injected() private var broadcaster: Broadcaster!
@@ -219,13 +218,10 @@ final class BaseAPSManager: APSManager, Injectable {
                 // Execute loop logic
                 try await self.executeLoop(loopStatRecord: &loopStatRecord)
 
-                // Upload data to Nightscout if available
-                if let nightscoutManager = self.nightscout {
-                    await nightscoutManager.uploadCarbs()
-                    await nightscoutManager.uploadPumpHistory()
-                    await nightscoutManager.uploadOverrides()
-                    await nightscoutManager.uploadTempTargets()
-                }
+                requestNightscoutUpload(
+                    [.carbs, .pumpHistory, .overrides, .tempTargets],
+                    source: "APSManager"
+                )
             } catch {
                 var updatedStats = loopStatRecord
                 updatedStats.end = Date()

+ 2 - 0
Trio/Sources/Models/NightscoutStatus.swift

@@ -12,6 +12,7 @@ struct OpenAPSStatus: JSON {
     let suggested: Determination?
     let enacted: Determination?
     let version: String
+    let recommendedBolus: Decimal?
 }
 
 struct NSPumpStatus: JSON {
@@ -19,6 +20,7 @@ struct NSPumpStatus: JSON {
     let battery: Battery?
     let reservoir: Decimal?
     let status: PumpStatus?
+    let bolusIncrement: Decimal
 }
 
 struct Uploader: JSON {

+ 90 - 0
Trio/Sources/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift

@@ -0,0 +1,90 @@
+import Combine
+import CoreData
+import Foundation
+
+extension BaseNightscoutManager {
+    /// Call once from init. Hooks up:
+    /// 1) external upload requests (NotificationCenter)
+    /// 2) Core Data change triggers → requests per upload pipeline
+    /// 3) Glucose storage updates → request glucose pipeline
+    func wireSubscribers() {
+        wireExternalUploadRequests()
+        wireCoreDataSubscribers()
+        wireGlucoseStorageSubscriber()
+    }
+
+    /// Listens for `.nightscoutUploadRequested`, converts userInfo pipelines to enums,
+    /// and requests those upload pipelines. Posts `.nightscoutUploadDidFinish` after enqueuing.
+    func wireExternalUploadRequests() {
+        Foundation.NotificationCenter.default.publisher(for: .nightscoutUploadRequested)
+            .sink { [weak self] note in
+                guard let self else { return }
+                let pipelines = (note.userInfo?[NightscoutNotificationKey.uploadPipelines] as? [String])?
+                    .compactMap(NightscoutUploadPipeline.init(rawValue:)) ?? []
+
+                for pipeline in pipelines { self.requestUpload(pipeline) }
+
+                var info: [AnyHashable: Any] = [NightscoutNotificationKey.uploadPipelines: pipelines.map(\.rawValue)]
+                if let src = note.userInfo?[NightscoutNotificationKey.source] { info[NightscoutNotificationKey.source] = src }
+                Foundation.NotificationCenter.default.post(name: .nightscoutUploadDidFinish, object: nil, userInfo: info)
+            }
+            .store(in: &subscriptions)
+    }
+
+    /// Maps Core Data entity changes into upload pipeline requests. We rely on
+    /// per-pipeline throttle so rapid changes don’t spam Nightscout.
+    func wireCoreDataSubscribers() {
+        coreDataPublisher?
+            .filteredByEntityName("OrefDetermination")
+            .sink { [weak self] _ in self?.requestUpload(.deviceStatus) }
+            .store(in: &subscriptions)
+
+        coreDataPublisher?
+            .filteredByEntityName("OverrideStored")
+            .sink { [weak self] _ in self?.requestUpload(.overrides) }
+            .store(in: &subscriptions)
+
+        coreDataPublisher?
+            .filteredByEntityName("OverrideRunStored")
+            .sink { [weak self] _ in self?.requestUpload(.overrides) }
+            .store(in: &subscriptions)
+
+        coreDataPublisher?
+            .filteredByEntityName("TempTargetStored")
+            .sink { [weak self] _ in self?.requestUpload(.tempTargets) }
+            .store(in: &subscriptions)
+
+        coreDataPublisher?
+            .filteredByEntityName("TempTargetRunStored")
+            .sink { [weak self] _ in self?.requestUpload(.tempTargets) }
+            .store(in: &subscriptions)
+
+        coreDataPublisher?
+            .filteredByEntityName("PumpEventStored")
+            .sink { [weak self] _ in self?.requestUpload(.pumpHistory) }
+            .store(in: &subscriptions)
+
+        coreDataPublisher?
+            .filteredByEntityName("CarbEntryStored")
+            .sink { [weak self] _ in self?.requestUpload(.carbs) }
+            .store(in: &subscriptions)
+
+        coreDataPublisher?
+            .filteredByEntityName("GlucoseStored")
+            .sink { [weak self] _ in
+                self?.requestUpload(.glucose)
+                self?.requestUpload(.manualGlucose)
+            }
+            .store(in: &subscriptions)
+    }
+
+    /// Glucose storage updates → request glucose pipeline
+    func wireGlucoseStorageSubscriber() {
+        glucoseStorage.updatePublisher
+            .receive(on: queue)
+            .sink { [weak self] _ in
+                self?.requestUpload(.glucose)
+            }
+            .store(in: &subscriptions)
+    }
+}

+ 96 - 166
Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift

@@ -28,7 +28,7 @@ protocol NightscoutManager: GlucoseSource {
 final class BaseNightscoutManager: NightscoutManager, Injectable {
     @Injected() private var keychain: Keychain!
     @Injected() private var determinationStorage: DeterminationStorage!
-    @Injected() private var glucoseStorage: GlucoseStorage!
+    @Injected() var glucoseStorage: GlucoseStorage!
     @Injected() private var tempTargetsStorage: TempTargetsStorage!
     @Injected() private var overridesStorage: OverrideStorage!
     @Injected() private var carbsStorage: CarbsStorage!
@@ -38,17 +38,69 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var reachabilityManager: ReachabilityManager!
     @Injected() var healthkitManager: HealthKitManager!
+    @Injected() private var bolusCalculationManager: BolusCalculationManager!
+    @Injected() private var apsManager: APSManager!
 
-    private let orefDeterminationSubject = PassthroughSubject<Void, Never>()
-    private let uploadOverridesSubject = PassthroughSubject<Void, Never>()
-    private let uploadPumpHistorySubject = PassthroughSubject<Void, Never>()
-    private let uploadCarbsSubject = PassthroughSubject<Void, Never>()
     private let processQueue = DispatchQueue(label: "BaseNetworkManager.processQueue")
     private var ping: TimeInterval?
 
-    private var backgroundContext = CoreDataStack.shared.newTaskContext()
+    // Queue where upload pipelines run.
+    let uploadPipelineQueue = DispatchQueue(label: "NightscoutManager.uploadPipelines", qos: .utility)
+
+    // Background Core Data context for fetches used by upload tasks.
+    var backgroundContext = CoreDataStack.shared.newTaskContext()
+
+    /// Throttle window (seconds) per upload pipeline. Any requests inside this window
+    /// coalesce into a single upload run for that pipeline.
+    let uploadPipelineInterval: [NightscoutUploadPipeline: TimeInterval] = [
+        .carbs: 2, .pumpHistory: 2, .overrides: 2, .tempTargets: 2,
+        .glucose: 2, .manualGlucose: 2, .deviceStatus: 2
+    ]
+
+    /// Subjects used to request an upload pipeline. The pipeline applies a throttle so
+    /// close calls don’t double-upload.
+    var uploadPipelineSubjects: [NightscoutUploadPipeline: PassthroughSubject<Void, Never>] = {
+        var d: [NightscoutUploadPipeline: PassthroughSubject<Void, Never>] = [:]
+        NightscoutUploadPipeline.allCases.forEach { d[$0] = PassthroughSubject<Void, Never>() }
+        return d
+    }()
+
+    /// Request an upload for a pipeline (enqueue work). Safe to call from anywhere.
+    func requestUpload(_ uploadPipeline: NightscoutUploadPipeline) {
+        uploadPipelineSubjects[uploadPipeline]?.send(())
+    }
+
+    /// Build the Combine pipelines for all upload pipelines: subject → throttle → upload.
+    /// Must be called once during init().
+    func setupLanePipelines() {
+        for pipeline in NightscoutUploadPipeline.allCases {
+            guard let subject = uploadPipelineSubjects[pipeline], let window = uploadPipelineInterval[pipeline] else { continue }
+            subject
+                .receive(on: uploadPipelineQueue)
+                .throttle(for: .seconds(window), scheduler: uploadPipelineQueue, latest: false)
+                .sink { [weak self] in
+                    guard let self else { return }
+                    Task(priority: .utility) { await self.runUploadPipeline(pipeline) }
+                }
+                .store(in: &subscriptions)
+        }
+    }
 
-    private var lifetime = Lifetime()
+    /// Runs the actual upload for a single upload pipeline.
+    /// Called by the throttled pipeline, not directly by callers.
+    func runUploadPipeline(_ uploadPipeline: NightscoutUploadPipeline) async {
+        switch uploadPipeline {
+        case .carbs: await uploadCarbs()
+        case .pumpHistory: await uploadPumpHistory()
+        case .overrides: await uploadOverrides()
+        case .tempTargets: await uploadTempTargets()
+        case .glucose: await uploadGlucose()
+        case .manualGlucose: await uploadManualGlucose()
+        case .deviceStatus:
+            do { try await uploadDeviceStatus() }
+            catch { debug(.nightscout, "deviceStatus upload failed: \(error)") }
+        }
+    }
 
     private var isNetworkReachable: Bool {
         reachabilityManager.isReachable
@@ -80,11 +132,14 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     private var lastSuggestedDetermination: Determination?
 
     // Queue for handling Core Data change notifications
-    private let queue = DispatchQueue(label: "BaseNightscoutManager.queue", qos: .background)
-    private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
-    private var subscriptions = Set<AnyCancellable>()
+    let queue = DispatchQueue(label: "BaseNightscoutManager.queue", qos: .utility)
+
+    /// Emits changed Core Data object IDs from the app. We filter by entity names
+    /// and request upload pipelines based on what changed.
+    var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
 
-    private let debouncedQueue = DispatchQueue(label: "OrefDeterminationDebounce", qos: .utility)
+    /// Bag for Combine subscriptions owned by this manager.
+    var subscriptions = Set<AnyCancellable>()
 
     init(resolver: Resolver) {
         injectServices(resolver)
@@ -96,10 +151,11 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
                 .share()
                 .eraseToAnyPublisher()
 
-        registerSubscribers()
-        registerHandlers()
         setupNotification()
 
+        setupLanePipelines()
+        wireSubscribers()
+
         /// Ensure that Nightscout Manager holds the `lastEnactedDetermination`, if one exists, on initialization.
         /// We have to set this here in `init()`, so there's a `lastEnactedDetermination` available after an app restart
         /// for `uploadDeviceStatus()`, as within that fuction `lastEnactedDetermination` is reassigned at the very end of the function.
@@ -127,157 +183,6 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
         }
     }
 
-    private func registerHandlers() {
-        /// We add debouncing behavior here for two main reasons
-        /// 1. To ensure that any upload flag updates have properly been performed, and in subsequent fetching processes only truly unuploaded data is fetched
-        /// 2. To not spam the user's NS site with a high number of uploads in a very short amount of time (less than 1sec)
-        coreDataPublisher?
-            .filteredByEntityName("OrefDetermination")
-            .debounce(for: .seconds(2), scheduler: debouncedQueue)
-            .sink { [weak self] objectIDs in
-                guard let self = self else { return }
-
-                // Now hop onto the background context's queue
-                self.backgroundContext.perform {
-                    do {
-                        // Fetch only those determination objects
-                        let request: NSFetchRequest<OrefDetermination> = OrefDetermination.fetchRequest()
-                        request.predicate = NSPredicate(
-                            format: "SELF IN %@ AND isUploadedToNS == NO",
-                            objectIDs
-                        )
-                        let results = try self.backgroundContext.fetch(request)
-
-                        // If valid, proceed to send to subject for further processing
-                        if !results.isEmpty {
-                            Task {
-                                do {
-                                    try await self.uploadDeviceStatus()
-                                } catch {
-                                    debug(.nightscout, "\(DebuggingIdentifiers.failed) failed to upload device status")
-                                }
-                            }
-                        }
-                    } catch {
-                        debug(.nightscout, "\(DebuggingIdentifiers.failed) Failed to fetch OrefDetermination objects: \(error)")
-                    }
-                }
-            }
-            .store(in: &subscriptions)
-
-        coreDataPublisher?.filteredByEntityName("OverrideStored")
-            .debounce(for: .seconds(2), scheduler: DispatchQueue.global(qos: .background))
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                Task.detached {
-                    await self.uploadOverrides()
-                }
-            }.store(in: &subscriptions)
-
-        coreDataPublisher?.filteredByEntityName("OverrideRunStored")
-            .debounce(for: .seconds(2), scheduler: DispatchQueue.global(qos: .background))
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                Task.detached {
-                    await self.uploadOverrides()
-                }
-            }.store(in: &subscriptions)
-
-        coreDataPublisher?.filteredByEntityName("TempTargetStored")
-            .debounce(for: .seconds(2), scheduler: DispatchQueue.global(qos: .background))
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                Task.detached {
-                    await self.uploadTempTargets()
-                }
-            }.store(in: &subscriptions)
-
-        coreDataPublisher?.filteredByEntityName("TempTargetRunStored")
-            .debounce(for: .seconds(2), scheduler: DispatchQueue.global(qos: .background))
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                Task.detached {
-                    await self.uploadTempTargets()
-                }
-            }.store(in: &subscriptions)
-
-        coreDataPublisher?.filteredByEntityName("PumpEventStored")
-            .debounce(for: .seconds(2), scheduler: DispatchQueue.global(qos: .background))
-            .sink { [weak self] objectIDs in
-                guard let self = self else { return }
-
-                self.backgroundContext.perform {
-                    do {
-                        let request: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
-                        request.predicate = NSPredicate(
-                            format: "SELF IN %@ AND isUploadedToNS == NO",
-                            objectIDs
-                        )
-                        let results = try self.backgroundContext.fetch(request)
-
-                        if !results.isEmpty {
-                            Task.detached {
-                                await self.uploadPumpHistory()
-                            }
-                        }
-                    } catch {
-                        debugPrint("Failed to fetch PumpEventStored objects: \(error)")
-                    }
-                }
-            }
-            .store(in: &subscriptions)
-
-        coreDataPublisher?.filteredByEntityName("CarbEntryStored")
-            .debounce(for: .seconds(2), scheduler: DispatchQueue.global(qos: .background))
-            .sink { [weak self] objectIDs in
-                guard let self = self else { return }
-
-                // Now hop onto the background context’s queue
-                self.backgroundContext.perform {
-                    do {
-                        let request: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
-                        request.predicate = NSPredicate(
-                            format: "SELF IN %@ AND isUploadedToNS == NO",
-                            objectIDs
-                        )
-                        let results = try self.backgroundContext.fetch(request)
-
-                        // If valid, proceed to send to subject for further processing
-                        if !results.isEmpty {
-                            Task.detached {
-                                await self.uploadCarbs()
-                            }
-                        }
-                    } catch {
-                        debugPrint("Failed to fetch CarbEntryStored objects: \(error)")
-                    }
-                }
-            }
-            .store(in: &subscriptions)
-
-        coreDataPublisher?.filteredByEntityName("GlucoseStored")
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                Task.detached {
-                    await self.uploadGlucose()
-                    await self.uploadManualGlucose()
-                }
-            }
-            .store(in: &subscriptions)
-    }
-
-    func registerSubscribers() {
-        glucoseStorage.updatePublisher
-            .receive(on: queue)
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                Task {
-                    await self.uploadGlucose()
-                }
-            }
-            .store(in: &subscriptions)
-    }
-
     func setupNotification() {
         Foundation.NotificationCenter.default.publisher(for: .willUpdateOverrideConfiguration)
             .sink { [weak self] _ in
@@ -588,6 +493,29 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
             fetchedEnactedDetermination = enacted
         }
 
+        // Calculate recommended bolus
+        var recommendedBolus: Decimal = 0
+
+        if let latest = fetchedSuggestedDetermination ?? fetchedEnactedDetermination {
+            let minPredBG = latest.minPredBGFromReason ?? 0
+            let simulatedCOB: Int16? = latest.cob.map { Int16(truncating: NSDecimalNumber(decimal: $0)) }
+
+            let result = await bolusCalculationManager.handleBolusCalculation(
+                carbs: 0,
+                useFattyMealCorrection: false,
+                useSuperBolus: false,
+                lastLoopDate: apsManager.lastLoopDate,
+                minPredBG: minPredBG,
+                simulatedCOB: simulatedCOB,
+                isBackdated: false
+            )
+
+            recommendedBolus = apsManager.roundBolus(amount: result.insulinCalculated)
+        }
+
+        // Bolus increment
+        let bolusIncrement = settingsManager.preferences.bolusIncrement
+
         // Gather all relevant data for OpenAPS Status
         let iob = await fetchedIOBEntry
 
@@ -598,7 +526,8 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
             iob: iob?.first,
             suggested: suggestedToUpload,
             enacted: settingsManager.settings.closedLoop ? enactedToUpload : nil,
-            version: Bundle.main.releaseVersionNumber ?? "Unknown"
+            version: Bundle.main.releaseVersionNumber ?? "Unknown",
+            recommendedBolus: recommendedBolus
         )
 
         debug(.nightscout, "To be uploaded openapsStatus: \(openapsStatus)")
@@ -611,7 +540,8 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
             clock: Date(),
             battery: battery,
             reservoir: reservoir != 0xDEAD_BEEF ? reservoir : nil,
-            status: pumpStatus
+            status: pumpStatus,
+            bolusIncrement: bolusIncrement
         )
 
         let batteryLevel = await UIDevice.current.batteryLevel

+ 42 - 0
Trio/Sources/Services/Network/Nightscout/NightscoutUploadPipeline.swift

@@ -0,0 +1,42 @@
+import Foundation
+
+/// Logical upload “paths” handled by NightscoutManager.
+/// Each upload pipeline has its own throttled queue so we don’t double-upload
+/// when multiple sources trigger the same work close together.
+public enum NightscoutUploadPipeline: String, CaseIterable {
+    case carbs
+    case pumpHistory
+    case overrides
+    case tempTargets
+    case glucose
+    case manualGlucose
+    case deviceStatus
+}
+
+/// Keys used in Nightscout upload notifications.
+public enum NightscoutNotificationKey {
+    /// Array of upload pipeline rawValues to upload, e.g. ["carbs", "pumpHistory"].
+    public static let uploadPipelines = "uploadPipelines"
+    /// Optional string that says who asked for the upload (debug/diagnostics).
+    public static let source = "source"
+}
+
+public extension Foundation.Notification.Name {
+    /// Post this to request one or more uploads by upload pipeline.
+    static let nightscoutUploadRequested = Notification.Name("nightscoutUploadRequested")
+    /// Posted after we enqueue all requested upload pipelines (not a network completion).
+    static let nightscoutUploadDidFinish = Notification.Name("nightscoutUploadDidFinish")
+}
+
+/// Convenience helper any component (e.g. APSManager) can call to
+/// request uploads. The work is enqueued and deduped per upload pipeline via throttle,
+/// so rapid duplicate calls won’t double-upload.
+///
+/// - Parameters:
+///   - uploadPipelines: Which pipelines to request (carbs, pumpHistory, etc).
+///   - source: Optional tag for debugging (e.g. "APSManager").
+public func requestNightscoutUpload(_ uploadPipelines: [NightscoutUploadPipeline], source: String? = nil) {
+    var userInfo: [AnyHashable: Any] = [NightscoutNotificationKey.uploadPipelines: uploadPipelines.map(\.rawValue)]
+    if let source { userInfo[NightscoutNotificationKey.source] = source }
+    Foundation.NotificationCenter.default.post(name: .nightscoutUploadRequested, object: nil, userInfo: userInfo)
+}