Просмотр исходного кода

Merge branch 'dev' of github.com:nightscout/Trio into oref-swift-sync-dev-2025-12-08

Deniz Cengiz 5 месяцев назад
Родитель
Сommit
95c1a57e3e
46 измененных файлов с 619 добавлено и 353 удалено
  1. 1 1
      CGMBLEKit
  2. 1 1
      Config.xcconfig
  3. 1 1
      DanaKit
  4. 1 1
      G7SensorKit
  5. 1 1
      LibreTransmitter
  6. 1 1
      LoopKit
  7. 4 0
      Model/Classes+Properties/DeletedGlucoseStored+CoreDataClass.swift
  8. 14 0
      Model/Classes+Properties/DeletedGlucoseStored+CoreDataProperties.swift
  9. 9 1
      Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents
  10. 1 1
      OmniBLE
  11. 1 1
      OmniKit
  12. 1 1
      RileyLinkKit
  13. 1 1
      TidepoolService
  14. 9 5
      Trio Watch App Extension/Views/TrioMainWatchView.swift
  15. 2 1
      Trio Watch App Extension/WatchState.swift
  16. 16 8
      Trio.xcodeproj/project.pbxproj
  17. 1 1
      Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved
  18. 25 9
      Trio/Sources/APS/APSManager.swift
  19. 29 5
      Trio/Sources/APS/DeviceDataManager.swift
  20. 0 10
      Trio/Sources/APS/Extensions/OmniBLEPumpManagerExtensions.swift
  21. 0 10
      Trio/Sources/APS/Extensions/OmniPodManagerExtensions.swift
  22. 9 11
      Trio/Sources/APS/FetchGlucoseManager.swift
  23. 65 10
      Trio/Sources/APS/Storage/GlucoseStorage.swift
  24. 45 34
      Trio/Sources/APS/Storage/TDDStorage.swift
  25. 3 0
      Trio/Sources/Application/TrioApp.swift
  26. 8 0
      Trio/Sources/Helpers/Formatters.swift
  27. 2 0
      Trio/Sources/Models/NightscoutStatus.swift
  28. 1 1
      Trio/Sources/Models/Preferences.swift
  29. 6 6
      Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift
  30. 3 3
      Trio/Sources/Modules/DataTable/View/DataTableRootView.swift
  31. 2 6
      Trio/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift
  32. 14 4
      Trio/Sources/Modules/Home/HomeStateModel.swift
  33. 3 2
      Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift
  34. 6 1
      Trio/Sources/Modules/Home/View/Header/PumpView.swift
  35. 73 35
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  36. 1 1
      Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift
  37. 3 3
      Trio/Sources/Modules/PumpConfig/PumpConfigDataFlow.swift
  38. 10 5
      Trio/Sources/Modules/Treatments/View/PopupView.swift
  39. 1 1
      Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift
  40. 90 0
      Trio/Sources/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift
  41. 96 166
      Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift
  42. 42 0
      Trio/Sources/Services/Network/Nightscout/NightscoutUploadPipeline.swift
  43. 11 0
      TrioTests/CoreDataTests/GlucoseStorageTests.swift
  44. 1 1
      dexcom-share-client-swift
  45. 2 2
      fastlane/testflight.md
  46. 3 1
      oref0_source_version.txt

+ 1 - 1
CGMBLEKit

@@ -1 +1 @@
-Subproject commit 1cc9e9e7627cf8fb76ccdb015dd6991196038e31
+Subproject commit 26fa00bed8c2f5e4b52ecb3241b422d058117c2c

+ 1 - 1
Config.xcconfig

@@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.6.0
-APP_DEV_VERSION = 0.6.0.12
+APP_DEV_VERSION = 0.6.0.26
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 1 - 1
DanaKit

@@ -1 +1 @@
-Subproject commit 084de69f69b1b17c92b595b4d5afeaed5b5d1e55
+Subproject commit 299331d4e540a0e7d1a74c30ddbb5be1d68892e8

+ 1 - 1
G7SensorKit

@@ -1 +1 @@
-Subproject commit 9fa27889f0b216cbe0a23844e888de6698793b63
+Subproject commit 43f55ad8e1227fa6b4bec25d152726c56c0ffb0c

+ 1 - 1
LibreTransmitter

@@ -1 +1 @@
-Subproject commit 1950f1fec2a0e9f256c1be6e5bafd06ff79d3144
+Subproject commit 25c31bae22082caaa6823179010129912d6c8f8f

+ 1 - 1
LoopKit

@@ -1 +1 @@
-Subproject commit ddee20aca806f7635b8421617a675ddbd9c6d924
+Subproject commit ce07c0993b1038f6f60ea5b6db7c23da0be3fee6

+ 4 - 0
Model/Classes+Properties/DeletedGlucoseStored+CoreDataClass.swift

@@ -0,0 +1,4 @@
+import CoreData
+import Foundation
+
+@objc(DeletedGlucoseStored) public class DeletedGlucoseStored: NSManagedObject {}

+ 14 - 0
Model/Classes+Properties/DeletedGlucoseStored+CoreDataProperties.swift

@@ -0,0 +1,14 @@
+import CoreData
+import Foundation
+
+public extension DeletedGlucoseStored {
+    @nonobjc class func fetchRequest() -> NSFetchRequest<GlucoseStored> {
+        NSFetchRequest<GlucoseStored>(entityName: "DeletedGlucoseStored")
+    }
+
+    @NSManaged var date: Date
+    @NSManaged var glucose: Int16
+    @NSManaged var isManualGlucoseEntry: Bool
+}
+
+extension DeletedGlucoseStored: Identifiable {}

+ 9 - 1
Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23605" systemVersion="24C101" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788" systemVersion="24G84" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
     <entity name="BolusStored" representedClassName="BolusStored" syncable="YES">
         <attribute name="amount" optional="YES" attributeType="Decimal" defaultValueString="0"/>
         <attribute name="isExternal" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@@ -42,6 +42,14 @@
         <attribute name="ringWidth" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
         <attribute name="top" optional="YES" attributeType="String"/>
     </entity>
+    <entity name="DeletedGlucoseStored" representedClassName="DeletedGlucoseStored" syncable="YES">
+        <attribute name="date" attributeType="Date" usesScalarValueType="NO"/>
+        <attribute name="glucose" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+        <attribute name="isManualGlucoseEntry" attributeType="Boolean" usesScalarValueType="YES"/>
+        <fetchIndex name="byDate">
+            <fetchIndexElement property="date" type="Binary" order="ascending"/>
+        </fetchIndex>
+    </entity>
     <entity name="Forecast" representedClassName="Forecast" syncable="YES">
         <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
         <attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>

+ 1 - 1
OmniBLE

@@ -1 +1 @@
-Subproject commit e4378ba744a46c5f06f9507eabceb4072c058992
+Subproject commit d8375ebf242e0d0e02ace7a03d9e1632557de38e

+ 1 - 1
OmniKit

@@ -1 +1 @@
-Subproject commit 1be14fcc27f22258cf8daa0355ac70c89737c0cc
+Subproject commit 1a73635568750289ac4d2f702b6bf191efbdda9f

+ 1 - 1
RileyLinkKit

@@ -1 +1 @@
-Subproject commit b280e8b9b7e75674b763f3ebf96d8b21dddcf80a
+Subproject commit c818fa8c90c0c98a4ba26cd18dacfeed01cc2bd5

+ 1 - 1
TidepoolService

@@ -1 +1 @@
-Subproject commit 59b0cd9384d180c7cccaf2cd2416fa2592a0ce45
+Subproject commit 84cab9b60e65b4aa814b0e12024a5e068ca65bfd

+ 9 - 5
Trio Watch App Extension/Views/TrioMainWatchView.swift

@@ -111,22 +111,26 @@ struct TrioMainWatchView: View {
             }
             .toolbar {
                 ToolbarItem(placement: .topBarLeading) {
-                    HStack {
+                    VStack {
                         Image(systemName: "syringe.fill")
                             .foregroundStyle(Color.insulin)
 
                         Text(isWatchStateDated || isSessionUnreachable ? "--" : state.iob ?? "--")
                             .foregroundStyle(isWatchStateDated ? Color.secondary : Color.white)
+                            .frame(alignment: .leading)
+                            .minimumScaleFactor(0.5)
                     }.font(.caption2)
                 }
 
                 ToolbarItem(placement: .topBarTrailing) {
-                    HStack {
-                        Text(isWatchStateDated || isSessionUnreachable ? "--" : state.cob ?? "--")
-                            .foregroundStyle(isWatchStateDated || isSessionUnreachable ? Color.secondary : Color.white)
-
+                    VStack {
                         Image(systemName: "fork.knife")
                             .foregroundStyle(Color.orange)
+
+                        Text(isWatchStateDated || isSessionUnreachable ? "--" : state.cob ?? "--")
+                            .foregroundStyle(isWatchStateDated || isSessionUnreachable ? Color.secondary : Color.white)
+                            .frame(alignment: .trailing)
+                            .minimumScaleFactor(0.5)
                     }.font(.caption2)
                 }
 

+ 2 - 1
Trio Watch App Extension/WatchState.swift

@@ -563,7 +563,8 @@ import WatchConnectivity
 
         if let bolusIncrement = message[WatchMessageKeys.bolusIncrement] {
             if let decimalValue = (bolusIncrement as? NSNumber)?.decimalValue {
-                self.bolusIncrement = decimalValue
+                // limit minimum to 0.05 to avoid dealing with 0.025 increments
+                self.bolusIncrement = max(decimalValue, 0.05)
             }
         }
 

+ 16 - 8
Trio.xcodeproj/project.pbxproj

@@ -302,6 +302,8 @@
 		3B8B5D3C2DF523C000365ED3 /* AutosensJsonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8B5D3B2DF523B800365ED3 /* AutosensJsonTests.swift */; };
 		3B8B5D3E2DF5240C00365ED3 /* TimeZoneForTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8B5D3D2DF5240600365ED3 /* TimeZoneForTests.swift */; };
 		3B8B5D402DF52D0E00365ED3 /* deviationsUnsorted.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B8B5D3F2DF52D0700365ED3 /* deviationsUnsorted.json */; };
+		3B56079E2ECD62A800C723C1 /* DeletedGlucoseStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B56079D2ECD62A800C723C1 /* DeletedGlucoseStored+CoreDataClass.swift */; };
+		3B5607A02ECD62AC00C723C1 /* DeletedGlucoseStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B56079F2ECD62AC00C723C1 /* DeletedGlucoseStored+CoreDataProperties.swift */; };
 		3B997DCB2DC00849006B6BB2 /* JSONImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B997DCA2DC00849006B6BB2 /* JSONImporter.swift */; };
 		3B997DCF2DC00A3A006B6BB2 /* JSONImporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */; };
 		3B997DD32DC02AEF006B6BB2 /* glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B997DD12DC02AEF006B6BB2 /* glucose.json */; };
@@ -563,8 +565,6 @@
 		CE1F6DE92BAF37C90064EB8D /* TidepoolConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F6DE82BAF37C90064EB8D /* TidepoolConfigView.swift */; };
 		CE2FAD3A297D93F0001A872C /* BloodGlucoseExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2FAD39297D93F0001A872C /* BloodGlucoseExtensions.swift */; };
 		CE3EEF9A2D463717001944DD /* CustomCGMOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3EEF992D46370A001944DD /* CustomCGMOptionsView.swift */; };
-		CE48C86428CA69D5007C0598 /* OmniBLEPumpManagerExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE48C86328CA69D5007C0598 /* OmniBLEPumpManagerExtensions.swift */; };
-		CE48C86628CA6B48007C0598 /* OmniPodManagerExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE48C86528CA6B48007C0598 /* OmniPodManagerExtensions.swift */; };
 		CE7950242997D81700FA576E /* CGMSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7950232997D81700FA576E /* CGMSettingsView.swift */; };
 		CE7950262998056D00FA576E /* CGMSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7950252998056D00FA576E /* CGMSetupView.swift */; };
 		CE7CA34E2A064973004BE681 /* AppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CA3432A064973004BE681 /* AppShortcuts.swift */; };
@@ -717,6 +717,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 */; };
@@ -1239,6 +1241,8 @@
 		3B8B5D3B2DF523B800365ED3 /* AutosensJsonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutosensJsonTests.swift; sourceTree = "<group>"; };
 		3B8B5D3D2DF5240600365ED3 /* TimeZoneForTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeZoneForTests.swift; sourceTree = "<group>"; };
 		3B8B5D3F2DF52D0700365ED3 /* deviationsUnsorted.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = deviationsUnsorted.json; sourceTree = "<group>"; };
+		3B56079D2ECD62A800C723C1 /* DeletedGlucoseStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeletedGlucoseStored+CoreDataClass.swift"; sourceTree = "<group>"; };
+		3B56079F2ECD62AC00C723C1 /* DeletedGlucoseStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeletedGlucoseStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		3B997DCA2DC00849006B6BB2 /* JSONImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONImporter.swift; sourceTree = "<group>"; };
 		3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONImporterTests.swift; sourceTree = "<group>"; };
 		3B997DD12DC02AEF006B6BB2 /* glucose.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = glucose.json; sourceTree = "<group>"; };
@@ -1503,8 +1507,6 @@
 		CE398D17297C9EE800DF218F /* G7SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = G7SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CE398D1A297D69A900DF218F /* ShareClient.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = ShareClient.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CE3EEF992D46370A001944DD /* CustomCGMOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCGMOptionsView.swift; sourceTree = "<group>"; };
-		CE48C86328CA69D5007C0598 /* OmniBLEPumpManagerExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmniBLEPumpManagerExtensions.swift; sourceTree = "<group>"; };
-		CE48C86528CA6B48007C0598 /* OmniPodManagerExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmniPodManagerExtensions.swift; sourceTree = "<group>"; };
 		CE51DD1B2A01970800F163F7 /* ConnectIQ 2.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "ConnectIQ 2.xcframework"; path = "Dependencies/ConnectIQ 2.xcframework"; sourceTree = "<group>"; };
 		CE6B025628F350FF000C5502 /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS9.1.sdk/System/Library/Frameworks/HealthKit.framework; sourceTree = DEVELOPER_DIR; };
 		CE7950232997D81700FA576E /* CGMSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMSettingsView.swift; sourceTree = "<group>"; };
@@ -1657,6 +1659,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>"; };
@@ -2708,8 +2712,6 @@
 				3BA8D1B22DDB870F0006191F /* DecimalExtensions.swift */,
 				DDB37CC62D05127500D99BF4 /* FontExtensions.swift */,
 				CEB434E628B9053300B70274 /* LoopUIColorPalette+Default.swift */,
-				CE48C86328CA69D5007C0598 /* OmniBLEPumpManagerExtensions.swift */,
-				CE48C86528CA6B48007C0598 /* OmniPodManagerExtensions.swift */,
 				38BF021625E7CBBC00579895 /* PumpManagerExtensions.swift */,
 				38A5049125DD9C4000C5B9E8 /* UserDefaultsExtensions.swift */,
 			);
@@ -3956,8 +3958,10 @@
 		DDC9B9962CFD2332003E7721 /* Nightscout */ = {
 			isa = PBXGroup;
 			children = (
+				DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */,
 				3811DE9725C9D88300A708ED /* NightscoutManager.swift */,
 				38FE826C25CC8461001FF17A /* NightscoutAPI.swift */,
+				DD906BF52EA6AAE900262772 /* BaseNightscoutManager+Subscribers.swift */,
 			);
 			path = Nightscout;
 			sourceTree = "<group>";
@@ -4036,6 +4040,8 @@
 				DDE1793B2C910127003CDDB7 /* CarbEntryStored+CoreDataProperties.swift */,
 				DDB37CC22D05044D00D99BF4 /* ContactTrickEntryStored+CoreDataClass.swift */,
 				DDB37CC32D05044D00D99BF4 /* ContactTrickEntryStored+CoreDataProperties.swift */,
+				3B56079D2ECD62A800C723C1 /* DeletedGlucoseStored+CoreDataClass.swift */,
+				3B56079F2ECD62AC00C723C1 /* DeletedGlucoseStored+CoreDataProperties.swift */,
 				DDE179422C910127003CDDB7 /* Forecast+CoreDataClass.swift */,
 				DDE179432C910127003CDDB7 /* Forecast+CoreDataProperties.swift */,
 				DDE179382C910127003CDDB7 /* ForecastValue+CoreDataClass.swift */,
@@ -4638,6 +4644,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 */,
@@ -4834,7 +4841,6 @@
 				383420D625FFE38C002D46C1 /* LoopView.swift in Sources */,
 				DD1745192C543B5700211FAC /* NotificationsView.swift in Sources */,
 				3811DEAD25C9D88300A708ED /* UserDefaults+Cache.swift in Sources */,
-				CE48C86628CA6B48007C0598 /* OmniPodManagerExtensions.swift in Sources */,
 				CEB434E728B9053300B70274 /* LoopUIColorPalette+Default.swift in Sources */,
 				19F95FF329F10FBC00314DDC /* StatDataFlow.swift in Sources */,
 				582DF97B2C8CE209001F516D /* CarbView.swift in Sources */,
@@ -4870,6 +4876,7 @@
 				3B4196E02D8C4BC00091DFF7 /* HomeStateModel+CGM.swift in Sources */,
 				38D0B3D925EC07C400CB6E88 /* CarbsEntry.swift in Sources */,
 				DD32CF9C2CC82499003686D6 /* TrioRemoteControl+TempTarget.swift in Sources */,
+				3B5607A02ECD62AC00C723C1 /* DeletedGlucoseStored+CoreDataProperties.swift in Sources */,
 				BD249D882D42FC0000412DEB /* BolusStatsView.swift in Sources */,
 				38A9260525F012D8009E3739 /* CarbRatios.swift in Sources */,
 				38FCF3D625E8FDF40078B0D1 /* MD5.swift in Sources */,
@@ -4962,6 +4969,7 @@
 				491D6FBD2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift in Sources */,
 				DD30B9CC2E062A7000DA677C /* ForecastResult.swift in Sources */,
 				491D6FBE2D56741C00C49F67 /* TempTargetRunStored+CoreDataClass.swift in Sources */,
+				3B56079E2ECD62A800C723C1 /* DeletedGlucoseStored+CoreDataClass.swift in Sources */,
 				491D6FBF2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift in Sources */,
 				491D6FC02D56741C00C49F67 /* TempTargetStored+CoreDataClass.swift in Sources */,
 				DD1745442C55C60E00211FAC /* AutosensSettingsDataFlow.swift in Sources */,
@@ -4975,6 +4983,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 */,
@@ -5095,7 +5104,6 @@
 				DD4C57A82D73ADEA001BFF2C /* RestartLiveActivityIntent.swift in Sources */,
 				19E1F7EA29D082ED005C8D20 /* IconConfigProvider.swift in Sources */,
 				DD09D4822C5986F6003FEA5D /* CalendarEventSettingsRootView.swift in Sources */,
-				CE48C86428CA69D5007C0598 /* OmniBLEPumpManagerExtensions.swift in Sources */,
 				DD5DC9F92CF3DAA900AB8703 /* RadioButton.swift in Sources */,
 				38E44522274E3DDC00EC9A94 /* NetworkReachabilityManager.swift in Sources */,
 				CE7CA34F2A064973004BE681 /* BaseIntentsRequest.swift in Sources */,

+ 1 - 1
Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -1,5 +1,5 @@
 {
-  "originHash" : "598841ae6fe892058ca678f5672f34299df2d62843330367c207648003263ccd",
+  "originHash" : "1e72c1cdf8ea5ec9fe527ebfab01ea55fca9e8651fe3252338fd3d4ea2cb327a",
   "pins" : [
     {
       "identity" : "abseil-cpp-binary",

+ 25 - 9
Trio/Sources/APS/APSManager.swift

@@ -19,6 +19,8 @@ protocol APSManager {
     var bolusProgress: CurrentValueSubject<Decimal?, Never> { get }
     var pumpExpiresAtDate: CurrentValueSubject<Date?, Never> { get }
     var isManualTempBasal: Bool { get }
+    var isScheduledBasal: Bool? { get }
+    var isSuspended: Bool { get }
     func enactTempBasal(rate: Double, duration: TimeInterval) async
     func determineBasal() async throws
     func determineBasalSync() async throws
@@ -76,7 +78,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!
@@ -105,6 +106,10 @@ final class BaseAPSManager: APSManager, Injectable {
 
     @Persisted(key: "isManualTempBasal") var isManualTempBasal: Bool = false
 
+    @Persisted(key: "isScheduledBasal") var isScheduledBasal: Bool? = false
+
+    @Persisted(key: "isSuspended") var isSuspended: Bool = false
+
     let isLooping = CurrentValueSubject<Bool, Never>(false)
     let lastLoopDateSubject = PassthroughSubject<Date, Never>()
     let lastError = CurrentValueSubject<Error?, Never>(nil)
@@ -184,7 +189,21 @@ final class BaseAPSManager: APSManager, Injectable {
             }
             .store(in: &lifetime)
 
-        // manage a manual Temp Basal from OmniPod - Force loop() after stop a temp basal or finished
+        deviceDataManager.scheduledBasal
+            .receive(on: processQueue)
+            .sink { scheduledBasal in
+                self.isScheduledBasal = scheduledBasal
+            }
+            .store(in: &lifetime)
+
+        deviceDataManager.suspended
+            .receive(on: processQueue)
+            .sink { suspended in
+                self.isSuspended = suspended
+            }
+            .store(in: &lifetime)
+
+        // manage a manual Temp Basal from PumpManager - force loop() after manual temp basal is cancelled or finishes
         deviceDataManager.manualTempBasal
             .receive(on: processQueue)
             .sink { manualBasal in
@@ -219,13 +238,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()

+ 29 - 5
Trio/Sources/APS/DeviceDataManager.swift

@@ -22,6 +22,8 @@ protocol DeviceDataManager: GlucoseSource {
     var recommendsLoop: PassthroughSubject<Void, Never> { get }
     var bolusTrigger: PassthroughSubject<Bool, Never> { get }
     var manualTempBasal: PassthroughSubject<Bool, Never> { get }
+    var scheduledBasal: PassthroughSubject<Bool?, Never> { get }
+    var suspended: PassthroughSubject<Bool, Never> { get }
     var errorSubject: PassthroughSubject<Error, Never> { get }
     var pumpName: CurrentValueSubject<String, Never> { get }
     var pumpExpiresAtDate: CurrentValueSubject<Date?, Never> { get }
@@ -68,6 +70,8 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
     let errorSubject = PassthroughSubject<Error, Never>()
     let pumpNewStatus = PassthroughSubject<Void, Never>()
     let manualTempBasal = PassthroughSubject<Bool, Never>()
+    let scheduledBasal = PassthroughSubject<Bool?, Never>()
+    let suspended = PassthroughSubject<Bool, Never>()
 
     private let router = TrioApp.resolver.resolve(Router.self)!
     @SyncAccess private var pumpUpdateCancellable: AnyCancellable?
@@ -77,11 +81,15 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
 
     var pumpManager: PumpManagerUI? {
         didSet {
-            pumpManager?.pumpManagerDelegate = self
-            pumpManager?.delegateQueue = processQueue
-            rawPumpManager = pumpManager?.rawValue
-            UserDefaults.standard.clearLegacyPumpManagerRawValue()
             if let pumpManager = pumpManager {
+                pumpManager.pumpManagerDelegate = self
+                pumpManager.delegateQueue = processQueue
+
+                /// Since the pump manager has been successfully instantiated from its saved state,
+                /// copy its rawValue to rawPumpManager which will be saved to persistant storage.
+                rawPumpManager = pumpManager.rawValue
+                UserDefaults.standard.clearLegacyPumpManagerRawValue()
+
                 pumpDisplayState.value = PumpDisplayState(name: pumpManager.localizedTitle, image: pumpManager.smallImage)
                 pumpName.send(pumpManager.localizedTitle)
 
@@ -94,7 +102,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                         )
                 )
                 modifiedPreferences
-                    .bolusIncrement = bolusIncrement != 0.025 ? bolusIncrement : 0.1
+                    .bolusIncrement = bolusIncrement > 0 ? bolusIncrement : 0.1
                 storage.save(modifiedPreferences, as: OpenAPS.Settings.preferences)
 
                 if let omnipod = pumpManager as? OmnipodPumpManager {
@@ -411,6 +419,22 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
             bolusTrigger.send(false)
         }
 
+        switch status.basalDeliveryState {
+        case let .active(at):
+            if at == .distantPast {
+                scheduledBasal.send(nil) // pump is not currently available
+            } else {
+                suspended.send(false)
+                scheduledBasal.send(true)
+            }
+        case .suspended:
+            suspended.send(true)
+            scheduledBasal.send(false)
+        default:
+            suspended.send(false)
+            scheduledBasal.send(false)
+        }
+
         if status.insulinType != oldStatus.insulinType {
             settingsManager.updateInsulinCurve(status.insulinType)
         }

+ 0 - 10
Trio/Sources/APS/Extensions/OmniBLEPumpManagerExtensions.swift

@@ -1,10 +0,0 @@
-import Foundation
-import OmniBLE
-import OmniKit
-
-public extension OmniBLEPumpManager {
-    static let managerIdentifier = "Omnipod-Dash"
-    var managerIdentifier: String {
-        OmniBLEPumpManager.managerIdentifier
-    }
-}

+ 0 - 10
Trio/Sources/APS/Extensions/OmniPodManagerExtensions.swift

@@ -1,10 +0,0 @@
-import Foundation
-import OmniKit
-
-public extension OmnipodPumpManager {
-    static let managerIdentifier = "Omnipod"
-
-    var managerIdentifier: String {
-        OmnipodPumpManager.managerIdentifier
-    }
-}

+ 9 - 11
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -284,17 +284,15 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             return
         }
 
-        // TODO: Fix backfill logic https://github.com/nightscout/Trio/issues/737
-        /*
-         let backfillGlucose = newGlucose.filter { $0.dateString <= syncDate }
-         if backfillGlucose.isNotEmpty {
-             debug(.deviceManager, "Backfilling glucose...")
-             do {
-                 try await glucoseStorage.storeGlucose(backfillGlucose)
-             } catch {
-                 debug(.deviceManager, "Unable to backfill glucose: \(error)")
-             }
-         }*/
+        let backfillGlucose = newGlucose.filter { $0.dateString <= syncDate }
+        if backfillGlucose.isNotEmpty {
+            debug(.deviceManager, "Backfilling glucose...")
+            do {
+                try await glucoseStorage.backfillGlucose(backfillGlucose)
+            } catch {
+                debug(.deviceManager, "Unable to backfill glucose: \(error)")
+            }
+        }
 
         filteredByDate = newGlucose.filter { $0.dateString > syncDate }
         filtered = glucoseStorage.filterTooFrequentGlucose(filteredByDate, at: syncDate)

+ 65 - 10
Trio/Sources/APS/Storage/GlucoseStorage.swift

@@ -10,6 +10,7 @@ import Swinject
 protocol GlucoseStorage {
     var updatePublisher: AnyPublisher<Void, Never> { get }
     func storeGlucose(_ glucose: [BloodGlucose]) async throws
+    func backfillGlucose(_ glucose: [BloodGlucose]) async throws
     func addManualGlucose(glucose: Int)
     func isGlucoseDataFresh(_ glucoseDate: Date?) -> Bool
     func syncDate() -> Date
@@ -62,10 +63,53 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         return formatter
     }
 
+    /// Backfills glucose values and stores in CoreData
+    ///
+    /// CGM managers will sometimes backfill glucose readings. To handle these backfilled values
+    /// correctly, we need some logic to handle a few cases:
+    ///  - _Not_ adding back previously deleted glucose
+    ///  - Avoiding duplicate values for the same reading
+    ///  - Avoiding overlapping glucose readings when switching sources
+    ///  Of these corner cases, overlapping glucose readings when switching sources is both
+    ///  the most challenging and most rare since it would happen if wearing two devices and
+    ///  switching or moving from direct glucose handling to xdrip. It's not worth the complexity
+    ///  to deal with source switching perfectly, so instead we will backfill glucose if and only if
+    ///  it isn't within 3.5 minutes of an existing glucose reading, which is simple but not perfect.
+    ///  But since this is a corner case that really shouldn't happen often, it's good enough.
+    func backfillGlucose(_ glucose: [BloodGlucose]) async throws {
+        try await context.perform {
+            // remove already deleted glucose values
+            let withoutDeletedGlucose = self.filterGlucoseValues(
+                glucose,
+                fetchRequest: DeletedGlucoseStored.fetchRequest(),
+                timeBuffer: 1
+            )
+
+            // check for a 3.5 minute difference between existing values
+            let filteredGlucose = self.filterGlucoseValues(
+                withoutDeletedGlucose,
+                fetchRequest: GlucoseStored.fetchRequest(),
+                timeBuffer: 3.5 * 60
+            )
+
+            guard !filteredGlucose.isEmpty else { return }
+
+            do {
+                // Store glucose values in Core Data
+                try self.storeGlucoseInCoreData(filteredGlucose)
+            } catch {
+                throw CoreDataError.creationError(
+                    function: #function,
+                    file: #fileID
+                )
+            }
+        }
+    }
+
     func storeGlucose(_ glucose: [BloodGlucose]) async throws {
         try await context.perform {
             // Get new glucose values that don't exist yet
-            let newGlucose = self.filterNewGlucoseValues(glucose)
+            let newGlucose = self.filterGlucoseValues(glucose, fetchRequest: GlucoseStored.fetchRequest(), timeBuffer: 1)
             guard !newGlucose.isEmpty else { return }
 
             do {
@@ -83,19 +127,22 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         }
     }
 
-    /// filter out duplicate CGM readings
+    /// filter out duplicate CGM readings using matching timestamps
     ///
-    /// This function will look through existing stored CGM values and filter out any new CGM values that
-    /// already exist. It does matching using dates and adds a small amount of time buffer for matching (1 second)
-    /// to account for precision loss that can happen with backfill CGM readings.
-    private func filterNewGlucoseValues(_ glucose: [BloodGlucose]) -> [BloodGlucose] {
+    /// This function will fetch dates from the `fetchRequest` and remove any glucose
+    /// values that are within `timeBuffer` of the fetched dates. This logic is useful for
+    /// deduplication checks or removing deleted CGM values from a list of backfilled readings.
+    private func filterGlucoseValues(
+        _ glucose: [BloodGlucose],
+        fetchRequest: NSFetchRequest<NSFetchRequestResult>,
+        timeBuffer: TimeInterval
+    ) -> [BloodGlucose] {
         let datesToCheck = glucose.map(\.dateString).sorted()
-        guard let firstDate = datesToCheck.first.map({ $0.addingTimeInterval(-1) }),
-              let lastDate = datesToCheck.last.map({ $0.addingTimeInterval(1) })
+        guard let firstDate = datesToCheck.first.map({ $0.addingTimeInterval(-timeBuffer) }),
+              let lastDate = datesToCheck.last.map({ $0.addingTimeInterval(timeBuffer) })
         else {
             return glucose
         }
-        let fetchRequest: NSFetchRequest<NSFetchRequestResult> = GlucoseStored.fetchRequest()
         fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
             NSPredicate(format: "date >= %@", firstDate as NSDate),
             NSPredicate(format: "date <= %@", lastDate as NSDate)
@@ -117,7 +164,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         return glucose.filter { glucose in
             for existingDate in existingDates {
                 let difference = abs(existingDate.timeIntervalSince(glucose.dateString))
-                if difference <= 1 {
+                if difference <= timeBuffer {
                     return false
                 }
             }
@@ -705,6 +752,14 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
                     return
                 }
 
+                // Create a new DeletedGlucoseStored object and copy the properties
+                if let date = glucoseToDelete.date {
+                    let deletedEntry = DeletedGlucoseStored(context: taskContext)
+                    deletedEntry.date = date
+                    deletedEntry.glucose = glucoseToDelete.glucose
+                    deletedEntry.isManualGlucoseEntry = glucoseToDelete.isManual
+                }
+
                 taskContext.delete(glucoseToDelete)
 
                 guard taskContext.hasChanges else { return }

+ 45 - 34
Trio/Sources/APS/Storage/TDDStorage.swift

@@ -545,41 +545,8 @@ final class BaseTDDStorage: TDDStorage, Injectable {
               let minutes = Int(timeComponents[1])
         else { return nil }
 
-        // Convert time to total minutes since midnight for easier comparison
         let totalMinutes = hours * 60 + minutes
-
-        // Special case: If profile has only one entry, it applies for full 24 hours
-        // Return its rate immediately without searching
-        if profile.count == 1 {
-            return profile[0].rate
-        }
-
-        // Use binary search to efficiently find the applicable basal rate
-        // Profile entries are sorted by minutes, so we can divide and conquer
-        var left = 0
-        var right = profile.count - 1
-
-        while left <= right {
-            let mid = (left + right) / 2
-            let entry = profile[mid]
-            // Get end time for current entry - either next entry's start time or end of day (1440 mins)
-            let nextMinutes = mid + 1 < profile.count ? profile[mid + 1].minutes : 1440
-
-            // Check if target time falls within current entry's time range
-            if totalMinutes >= entry.minutes, totalMinutes < nextMinutes {
-                return entry.rate
-            }
-
-            // Adjust search range based on comparison
-            if totalMinutes < entry.minutes {
-                right = mid - 1 // Search in left half if target time is earlier
-            } else {
-                left = mid + 1 // Search in right half if target time is later
-            }
-        }
-
-        // No applicable rate found for the given time
-        return nil
+        return findBasalRateForOffset(for: totalMinutes, in: profile)
     }
 
     /// Calculates a weighted average of Total Daily Dose (TDD) based on recent and historical data
@@ -692,3 +659,47 @@ extension Decimal {
         return result
     }
 }
+
+/// Finds the basal rate at the specified minute offset using binary search
+/// - Parameters:
+///   - totalMinutes: minute offset into a 24 hour day
+///   - profile: Array of basal profile entries sorted by time
+/// - Returns: Basal rate in units per hour, or nil if not found
+func findBasalRateForOffset(for totalMinutes: Int, in profile: [BasalProfileEntry]) -> Decimal? {
+    if profile.isEmpty {
+        return nil // not yet initalized
+    }
+
+    // Special case: If profile has only one entry, it applies for full 24 hours
+    // Return its rate immediately without searching
+    if profile.count == 1 {
+        return profile[0].rate
+    }
+
+    // Use binary search to efficiently find the applicable basal rate
+    // Profile entries are sorted by minutes, so we can divide and conquer
+    var left = 0
+    var right = profile.count - 1
+
+    while left <= right {
+        let mid = (left + right) / 2
+        let entry = profile[mid]
+        // Get end time for current entry - either next entry's start time or end of day (24 * 60 mins)
+        let nextMinutes = mid + 1 < profile.count ? profile[mid + 1].minutes : 24 * 60
+
+        // Check if target time falls within current entry's time range
+        if totalMinutes >= entry.minutes, totalMinutes < nextMinutes {
+            return entry.rate
+        }
+
+        // Adjust search range based on comparison
+        if totalMinutes < entry.minutes {
+            right = mid - 1 // Search in left half if target time is earlier
+        } else {
+            left = mid + 1 // Search in right half if target time is later
+        }
+    }
+
+    // No applicable rate found for the given time
+    return nil
+}

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

@@ -407,6 +407,8 @@ extension Notification.Name {
 
     private func purgeOldNSManagedObjects() async throws {
         async let glucoseDeletion: () = coreDataStack.batchDeleteOlderThan(GlucoseStored.self, dateKey: "date", days: 90)
+        async let archivedGlucoseDeletion: () = coreDataStack
+            .batchDeleteOlderThan(DeletedGlucoseStored.self, dateKey: "date", days: 90)
         async let pumpEventDeletion: () = coreDataStack.batchDeleteOlderThan(PumpEventStored.self, dateKey: "timestamp", days: 90)
         async let bolusDeletion: () = coreDataStack.batchDeleteOlderThan(
             parentType: PumpEventStored.self,
@@ -441,6 +443,7 @@ extension Notification.Name {
 
         // Await each task to ensure they are all completed
         try await glucoseDeletion
+        try await archivedGlucoseDeletion
         try await pumpEventDeletion
         try await bolusDeletion
         try await tempBasalDeletion

+ 8 - 0
Trio/Sources/Helpers/Formatters.swift

@@ -37,6 +37,14 @@ extension Formatter {
         return formatter
     }()
 
+    static let decimalFormatterWithThreeFractionDigits: NumberFormatter = {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        formatter.locale = .current
+        formatter.maximumFractionDigits = 3
+        return formatter
+    }()
+
     static let dateFormatter: DateFormatter = {
         let dateFormatter = DateFormatter()
         dateFormatter.timeStyle = .short

+ 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 {

+ 1 - 1
Trio/Sources/Models/Preferences.swift

@@ -253,7 +253,7 @@ extension Preferences: Decodable {
         }
 
         if let bolusIncrement = try? container.decode(Decimal.self, forKey: .bolusIncrement) {
-            preferences.bolusIncrement = bolusIncrement
+            preferences.bolusIncrement = bolusIncrement > 0 ? bolusIncrement : 0.1
         }
 
         if let curve = try? container.decode(InsulinCurve.self, forKey: .curve) {

+ 6 - 6
Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift

@@ -113,6 +113,12 @@ extension BasalProfileEditor {
                         // Successfully saved and synced
                         self.initialItems = self.items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
 
+                        DispatchQueue.main.async {
+                            self.broadcaster.notify(BasalProfileObserver.self, on: .main) {
+                                $0.basalProfileDidChange(profile)
+                            }
+                        }
+
                         Task.detached(priority: .low) {
                             do {
                                 debug(.nightscout, "Attempting to upload basal rates to Nightscout")
@@ -130,12 +136,6 @@ extension BasalProfileEditor {
                     print("We were successful")
                 }
                 .store(in: &lifetime)
-
-            DispatchQueue.main.async {
-                self.broadcaster.notify(BasalProfileObserver.self, on: .main) {
-                    $0.basalProfileDidChange(profile)
-                }
-            }
         }
 
         @MainActor func validate() {

+ 3 - 3
Trio/Sources/Modules/DataTable/View/DataTableRootView.swift

@@ -621,7 +621,7 @@ extension DataTable {
                     Image(systemName: "circle.fill").foregroundColor(Color.insulin)
                     Text(bolus.isSMB ? "SMB" : item.type ?? "Bolus")
                     Text(
-                        (Formatter.decimalFormatterWithTwoFractionDigits.string(from: amount) ?? "0") +
+                        (Formatter.decimalFormatterWithThreeFractionDigits.string(from: amount) ?? "0") +
                             String(localized: " U", comment: "Insulin unit")
                     )
                     .foregroundColor(.secondary)
@@ -632,7 +632,7 @@ extension DataTable {
                     Image(systemName: "circle.fill").foregroundColor(Color.insulin.opacity(0.4))
                     Text("Temp Basal")
                     Text(
-                        (Formatter.decimalFormatterWithTwoFractionDigits.string(from: rate) ?? "0") +
+                        (Formatter.decimalFormatterWithThreeFractionDigits.string(from: rate) ?? "0") +
                             String(localized: " U/hr", comment: "Unit insulin per hour")
                     )
                     .foregroundColor(.secondary)
@@ -657,7 +657,7 @@ extension DataTable {
                             alertTitle = String(localized: "Delete Insulin?", comment: "Alert title for deleting insulin")
                             alertMessage = Formatter.dateFormatter
                                 .string(from: item.timestamp ?? Date()) + ", " +
-                                (Formatter.decimalFormatterWithTwoFractionDigits.string(from: item.bolus?.amount ?? 0) ?? "0") +
+                                (Formatter.decimalFormatterWithThreeFractionDigits.string(from: item.bolus?.amount ?? 0) ?? "0") +
                                 String(localized: " U", comment: "Insulin unit")
 
                             if let bolus = item.bolus {

+ 2 - 6
Trio/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift

@@ -41,15 +41,11 @@ extension Home.StateModel {
         insulinFromPersistence = insulinObjects
 
         manualTempBasal = apsManager.isManualTempBasal
-        tempBasals = insulinFromPersistence.filter({ $0.tempBasal != nil })
+        tempBasals = insulinFromPersistence.filter { $0.tempBasal != nil }
 
-        suspensions = insulinFromPersistence.filter {
+        suspendAndResumeEvents = insulinFromPersistence.filter {
             $0.type == EventType.pumpSuspend.rawValue || $0.type == EventType.pumpResume.rawValue
         }
-        let lastSuspension = suspensions.last
-
-        pumpSuspended = tempBasals.last?.timestamp ?? Date() > lastSuspension?.timestamp ?? .distantPast && lastSuspension?
-            .type == EventType.pumpSuspend.rawValue
     }
 
     // Setup Last Bolus to display the bolus progress bar

+ 14 - 4
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -2,6 +2,7 @@ import CGMBLEKitUI
 import Combine
 import CoreData
 import Foundation
+import LoopKit
 import LoopKitUI
 import Observation
 import SwiftDate
@@ -40,7 +41,6 @@ extension Home {
         var targetProfiles: [TargetProfile] = []
         var timerDate = Date()
         var closedLoop = false
-        var pumpSuspended = false
         var isLooping = false
         var statusTitle = ""
         var lastLoopDate: Date = .distantPast
@@ -92,7 +92,7 @@ extension Home {
         var fetchedTDDs: [TDD] = []
         var insulinFromPersistence: [PumpEventStored] = []
         var tempBasals: [PumpEventStored] = []
-        var suspensions: [PumpEventStored] = []
+        var suspendAndResumeEvents: [PumpEventStored] = []
         var batteryFromPersistence: [OpenAPS_Battery] = []
         var lastPumpBolus: PumpEventStored?
         var overrides: [OverrideStored] = []
@@ -107,6 +107,7 @@ extension Home {
         var cgmAvailable: Bool = false
         var listOfCGM: [CGMModel] = []
         var cgmCurrent = cgmDefaultModel
+        var pumpInitialSettings = PumpConfig.PumpInitialSettings.default
         var shouldRunDeleteOnSettingsChange = true
 
         var showCarbsRequiredBadge: Bool = true
@@ -550,9 +551,11 @@ extension Home {
         }
 
         private func setupPumpSettings() async {
-            let maxBasal = await provider.pumpSettings().maxBasal
+            let settings = await provider.pumpSettings()
             await MainActor.run {
-                self.maxBasal = maxBasal
+                self.maxBasal = settings.maxBasal
+                self.pumpInitialSettings.maxBasalRateUnitsPerHour = Double(settings.maxBasal)
+                self.pumpInitialSettings.maxBolusUnits = Double(settings.maxBolus)
             }
         }
 
@@ -560,6 +563,13 @@ extension Home {
             let basalProfile = await provider.getBasalProfile()
             await MainActor.run {
                 self.basalProfile = basalProfile
+
+                if let schedule = BasalRateSchedule(
+                    dailyItems: basalProfile
+                        .map { RepeatingScheduleValue(startTime: TimeInterval($0.minutes * 60), value: Double($0.rate)) }
+                ) {
+                    self.pumpInitialSettings.basalSchedule = schedule
+                }
             }
         }
 

+ 3 - 2
Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift

@@ -104,7 +104,7 @@ extension MainChartView {
     }
 
     func drawSuspensions() -> some ChartContent {
-        let suspensions = state.suspensions
+        let suspensions = state.suspendAndResumeEvents
         return ForEach(suspensions) { suspension in
             let now = Date()
 
@@ -154,7 +154,8 @@ extension MainChartView {
             let duration = temp.tempBasal?.duration ?? 0
             let timestamp = temp.timestamp ?? Date()
             let end = timestamp + duration.minutes
-            let isInsulinSuspended = state.suspensions.contains { $0.timestamp ?? now >= timestamp && $0.timestamp ?? now <= end }
+            let isInsulinSuspended = state.suspendAndResumeEvents
+                .contains { $0.timestamp ?? now >= timestamp && $0.timestamp ?? now <= end }
 
             let rate = Double(truncating: temp.tempBasal?.rate ?? Decimal.zero as NSDecimalNumber) * (isInsulinSuspended ? 0 : 1)
 

+ 6 - 1
Trio/Sources/Modules/Home/View/Header/PumpView.swift

@@ -142,7 +142,12 @@ struct PumpView: View {
         }
 
         if hours >= 1 {
-            return "\(hours)" + String(localized: "h", comment: "abbreviation for hours")
+            var remainingHoursString = "\(hours)" + String(localized: "h", comment: "abbreviation for hours")
+            if hours < 12 {
+                remainingHoursString += " " + "\(minutes)" +
+                    String(localized: "m", comment: "abbreviation for minutes")
+            }
+            return remainingHoursString
         }
 
         return "\(minutes)" + String(localized: "m", comment: "abbreviation for minutes")

+ 73 - 35
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -54,11 +54,17 @@ extension Home {
         )) var latestTempTarget: FetchedResults<TempTargetStored>
 
         var bolusProgressFormatter: NumberFormatter {
+            let fractionDigits: Int = switch state.settingsManager.preferences.bolusIncrement {
+            case 0.1: 1
+            case 0.025: 3
+            default: 2
+            }
+
             let formatter = NumberFormatter()
             formatter.numberStyle = .decimal
             formatter.minimum = 0
-            formatter.maximumFractionDigits = state.settingsManager.preferences.bolusIncrement > 0.05 ? 1 : 2
-            formatter.minimumFractionDigits = state.settingsManager.preferences.bolusIncrement > 0.05 ? 1 : 2
+            formatter.maximumFractionDigits = fractionDigits
+            formatter.minimumFractionDigits = fractionDigits
             formatter.allowsFloats = true
             formatter.roundingIncrement = Double(state.settingsManager.preferences.bolusIncrement) as NSNumber
             return formatter
@@ -162,22 +168,51 @@ extension Home {
             }
         }
 
-        var tempBasalString: String? {
-            guard let lastTempBasal = state.tempBasals.last?.tempBasal, let tempRate = lastTempBasal.rate else {
+        var basalString: String? {
+            var rate: NSNumber = 0
+            var manualBasalString = ""
+
+            guard let apsManager = state.apsManager else {
                 return nil
             }
-            let rateString = Formatter.decimalFormatterWithTwoFractionDigits.string(from: tempRate as NSNumber) ?? "0"
-            var manualBasalString = ""
 
-            if let apsManager = state.apsManager, apsManager.isManualTempBasal {
-                manualBasalString = String(
-                    localized:
-                    " - Manual Basal ⚠️",
-                    comment: "Manual Temp basal"
-                )
+            if apsManager.isScheduledBasal == true {
+                guard let scheduledRate = scheduledBasalDeliveryRate(at: Date()) else {
+                    return nil
+                }
+                rate = scheduledRate
+            } else {
+                guard let lastTempBasal = state.tempBasals.last?.tempBasal, let tempRate = lastTempBasal.rate else {
+                    return nil
+                }
+                if apsManager.isManualTempBasal {
+                    manualBasalString = String(
+                        localized: " - Manual Basal ⚠️",
+                        comment: "Manual Temp basal"
+                    )
+                }
+                rate = tempRate
             }
 
-            return rateString + String(localized: " U/hr", comment: "Unit per hour with space") + manualBasalString
+            let rateString = Formatter.decimalFormatterWithThreeFractionDigits.string(from: rate) ?? "0"
+            return rateString + String(localized: " U/hr", comment: "Unit per hour with space") +
+                manualBasalString
+        }
+
+        // Returns the scheduled basal rate for the current time based on the saved basal scheduled.
+        // Would be better if in the future BasalDeliveryStatus could be updated to include this info.
+        func scheduledBasalDeliveryRate(at when: Date) -> NSNumber? {
+            let calendar = Calendar(identifier: .gregorian)
+            // calendar.timeZone = timeZone /// should come from pumpManager in case it's different!
+
+            let hours = calendar.component(.hour, from: when)
+            let minutes = calendar.component(.minute, from: when)
+            let totalMinutes = hours * 60 + minutes
+
+            if let rate = findBasalRateForOffset(for: totalMinutes, in: state.basalProfile) {
+                return NSDecimalNumber(decimal: rate)
+            }
+            return nil
         }
 
         var overrideString: String? {
@@ -467,31 +502,34 @@ extension Home {
                         .font(.callout)
                 } else {
                     HStack {
-                        if state.pumpSuspended {
-                            Text("Pump suspended")
-                                .font(.callout).fontWeight(.bold).fontDesign(.rounded)
-                                .foregroundColor(.loopGray)
-                        } else if let tempBasalString = tempBasalString {
+                        /// Only display the insulin delivery rate info if the pump is not
+                        /// suspended and is available (e.g., pod is paired & not faulted).
+                        let pumpAvailable = state.apsManager.isScheduledBasal != nil
+                        if !state.apsManager.isSuspended && pumpAvailable {
                             Image(systemName: "drop.circle")
                                 .font(.callout)
                                 .foregroundColor(.insulinTintColor)
-                            if tempBasalString.count > 5 {
-                                Text(tempBasalString)
-                                    .font(.callout).fontWeight(.bold).fontDesign(.rounded)
-                                    .lineLimit(1)
-                                    .minimumScaleFactor(0.85)
-                                    .truncationMode(.tail)
-                                    .allowsTightening(true)
+                            if let basalString = self.basalString {
+                                /// Adjust opacity when displaying a scheduled basal rate
+                                let opacity = state.apsManager?.isScheduledBasal == true ? 0.6 : 1.0
+                                if basalString.count > 5 {
+                                    Text(basalString)
+                                        .font(.callout).fontWeight(.bold).fontDesign(.rounded)
+                                        .lineLimit(1)
+                                        .minimumScaleFactor(0.85)
+                                        .truncationMode(.tail)
+                                        .allowsTightening(true)
+                                        .opacity(opacity)
+                                } else {
+                                    // Short strings can just display normally
+                                    Text(basalString)
+                                        .font(.callout).fontWeight(.bold).fontDesign(.rounded)
+                                        .opacity(opacity)
+                                }
                             } else {
-                                // Short strings can just display normally
-                                Text(tempBasalString).font(.callout).fontWeight(.bold).fontDesign(.rounded)
+                                Text("No Data")
+                                    .font(.callout).fontWeight(.bold).fontDesign(.rounded)
                             }
-                        } else {
-                            Image(systemName: "drop.circle")
-                                .font(.callout)
-                                .foregroundColor(.insulinTintColor)
-                            Text("No Data")
-                                .font(.callout).fontWeight(.bold).fontDesign(.rounded)
                         }
                     }
                 }
@@ -736,7 +774,7 @@ extension Home {
                 let bolusString =
                     (bolusProgressFormatter.string(from: bolusFraction as NSNumber) ?? "0")
                         + String(localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view") +
-                        (Formatter.decimalFormatterWithTwoFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
+                        (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
                         + String(localized: " U", comment: "Insulin unit")
 
                 ZStack {
@@ -964,7 +1002,7 @@ extension Home {
                 } else {
                     PumpConfig.PumpSetupView(
                         pumpType: state.setupPumpType,
-                        pumpInitialSettings: PumpConfig.PumpInitialSettings.default,
+                        pumpInitialSettings: state.pumpInitialSettings,
                         bluetoothManager: state.provider.apsManager.bluetoothManager!,
                         completionDelegate: state,
                         setupDelegate: state

+ 1 - 1
Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift

@@ -781,7 +781,7 @@ extension Onboarding {
                                 .bolusIncrement
                         )
                 )
-                preferences.bolusIncrement = bolusIncrement != 0.025 ? bolusIncrement : 0.1
+                preferences.bolusIncrement = bolusIncrement > 0 ? bolusIncrement : 0.1
             }
 
             settingsManager.preferences = preferences

+ 3 - 3
Trio/Sources/Modules/PumpConfig/PumpConfigDataFlow.swift

@@ -14,9 +14,9 @@ enum PumpConfig {
     }
 
     struct PumpInitialSettings {
-        let maxBolusUnits: Double
-        let maxBasalRateUnitsPerHour: Double
-        let basalSchedule: BasalRateSchedule
+        var maxBolusUnits: Double
+        var maxBasalRateUnitsPerHour: Double
+        var basalSchedule: BasalRateSchedule
 
         static let `default` = PumpInitialSettings(
             maxBolusUnits: 10,

+ 10 - 5
Trio/Sources/Modules/Treatments/View/PopupView.swift

@@ -915,7 +915,7 @@ struct PopupView: View {
 
                     // Final insulin recommendation
                     HStack(alignment: .firstTextBaseline, spacing: 4) {
-                        Text(insulinFormatter(state.insulinCalculated))
+                        Text(insulinFormatter(state.insulinCalculated, .down, true))
                             .largeSolutionStyle()
                             .foregroundStyle(state.insulinCalculated > 0 ? Color.accentColor : .primary)
 
@@ -939,19 +939,24 @@ struct PopupView: View {
     /// - Parameters:
     ///   - value: The insulin value to format
     ///   - roundingMode: The rounding mode to apply (default: .down for conservative dosing)
+    ///   - roundedForPump: Use a max of 3 fraction digits when rounding for pump to accomidate for 0.025 U increments if applicable
     /// - Returns: A formatted string with 2 decimal places
-    private func insulinFormatter(_ value: Decimal, _ roundingMode: NSDecimalNumber.RoundingMode = .down) -> String {
+    private func insulinFormatter(
+        _ value: Decimal,
+        _ roundingMode: NSDecimalNumber.RoundingMode = .down,
+        _ roundedForPump: Bool = false
+    ) -> String {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal
         formatter.minimumFractionDigits = 2
-        formatter.maximumFractionDigits = 2
+        formatter.maximumFractionDigits = roundedForPump ? 3 : 2
         formatter.locale = Locale.current
 
         // Create a decimal handler with the specified rounding behavior.
-        // Always rounds to 2 decimal places (0.01 U precision).
+        // Rounds to 2 decimal places (0.01 U precision), except when rounding for pump
         let handler = NSDecimalNumberHandler(
             roundingMode: roundingMode,
-            scale: 2,
+            scale: roundedForPump ? 3 : 2,
             raiseOnExactness: false,
             raiseOnOverflow: false,
             raiseOnUnderflow: false,

+ 1 - 1
Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift

@@ -37,7 +37,7 @@ extension Treatments {
             let formatter = NumberFormatter()
             formatter.numberStyle = .decimal
             formatter.maximumIntegerDigits = 2
-            formatter.maximumFractionDigits = 2
+            formatter.maximumFractionDigits = 3
             return formatter
         }
 

+ 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)
+}

+ 11 - 0
TrioTests/CoreDataTests/GlucoseStorageTests.swift

@@ -99,6 +99,17 @@ import Testing
         ) as? [GlucoseStored]
 
         #expect(remainingEntries?.isEmpty == true, "Should have no entries after deletion")
+
+        // Finally verify that it stored a copy
+        let archivedEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: DeletedGlucoseStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "glucose == 140"),
+            key: "date",
+            ascending: false
+        ) as? [DeletedGlucoseStored]
+
+        #expect(archivedEntries?.isEmpty == false, "Should have archived entries after deletion")
     }
 
     @Test("Get glucose not yet uploaded to Nightscout") func testGetGlucoseNotYetUploadedToNightscout() async throws {

+ 1 - 1
dexcom-share-client-swift

@@ -1 +1 @@
-Subproject commit 41cf95dab00f125f7a7602c433aac79fea8fc549
+Subproject commit 82a9179d444b3e79d5e9cfe99bbe7f298c4e8b40

+ 2 - 2
fastlane/testflight.md

@@ -180,8 +180,8 @@ _Referring to the table below, tap on each **IDENTIFIER** that has a different *
 1. Go to [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/identifiers/list) on the Apple developer site.
 1. Repeat this step for these three Identifier **NAMES** - refer to the [Table](#table-of-identifiers) above if your Names look different; if they do, see [Optional: Identifier Description Modification](#optional-identifier-description-modification)
     * Trio
-    * Trio Watch
-    * Trio WatchKit Extension
+    * Trio Watch App
+    * Trio Watch Complication
 1. Click on the **IDENTIFIER** row.
 1. Scroll down to the "App Groups" capabilies row, click on the "Configure" (or "Edit") button.
 1. Select the "Trio App Group" _(yes, "Trio App Group" is correct)_

+ 3 - 1
oref0_source_version.txt

@@ -1,6 +1,8 @@
-oref0 branch: remove-400-guard-trio-oref - git version: 3aeba68
+oref0 branch: dev - git version: 8282ce7
 
 Last commits:
+8282ce7 Merge pull request #49 from nightscout/remove-400-guard-trio-oref
+2319a16 feat(algo): Remove short and long delta condition to avoid early exit breaking loop; set 30min neutral temp like original openaps/oref0 does
 3aeba68 feat(algo): Remove 400 / shouldProtectDueToHIGH guard #hackdiabetes2025
 37896e5 Merge pull request #48 from nightscout/fix-bundle-naming
 f21a187 Rename output library to trio_[name]