diff --git a/.gitmodules b/.gitmodules
index 2f3278527..024f06be0 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -49,3 +49,6 @@
[submodule "LoopAlgorithm"]
path = LoopAlgorithm
url = https://github.com/loopandlearn/LoopAlgorithm.git
+[submodule "SyaiKit"]
+ path = SyaiKit
+ url = https://github.com/nightscout/SyaiKit
diff --git a/ACKNOWLEDGEMENTS.md b/ACKNOWLEDGEMENTS.md
index 3e583ba60..37e19d818 100644
--- a/ACKNOWLEDGEMENTS.md
+++ b/ACKNOWLEDGEMENTS.md
@@ -4,8 +4,25 @@ Third-party material bundled with Trio, and the terms it is used under.
## Alarm and alert sounds
-The themed alarm sets (Acoustic, Electronic) and the one-off tones were
-created for Trio by a sound designer and are distributed as part of Trio.
+The themed alarm sets (`acoustic_*.caf`, `electronic_*.caf`) and the one-off
+tones (`glass_bell.caf`, `harp.caf`, `meow.caf`, `saw_wave.caf`,
+`hard_saw_wave.caf`, `sine_wave.caf`, `open_can.caf`, `open_wrapper.caf`) were
+created for Trio by **an anonymous diabetes dad and Trio user**.
+
+- Licence: **Creative Commons Zero 1.0 Universal (CC0 1.0)** —
+
+- The author states that they are the author of these files, that they hold
+ the right to license them, and that they contain no third-party material
+ they cannot license.
+- CC0 requires no attribution. The credit above is given at the author's
+ request; they have asked to remain anonymous.
+- Changes made by Trio: the one-off tones were looped by whole repeats to at
+ least five seconds, so that every alarm sounds for a comparable time. The
+ themed sets were left exactly as delivered — their length and level are
+ part of the design, and carry the urgency along with the timbre.
+
+The spoken alert sounds are generated from third-party voice models and are
+*not* covered by that dedication — see below.
## Voice alerts
diff --git a/CGMBLEKit b/CGMBLEKit
index ea224e771..776543fca 160000
--- a/CGMBLEKit
+++ b/CGMBLEKit
@@ -1 +1 @@
-Subproject commit ea224e771fb3329a957a753296232c62a003a9fe
+Subproject commit 776543fca902ad74e3a068a3ec402d62d716a322
diff --git a/Config.xcconfig b/Config.xcconfig
index 7443e63ad..b56059bf7 100644
--- a/Config.xcconfig
+++ b/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 = 1.0.2
-APP_DEV_VERSION = 1.0.2.14
+APP_DEV_VERSION = 1.0.2.26
APP_BUILD_NUMBER = 1
COPYRIGHT_NOTICE = autoISF 3.01
diff --git a/DanaKit b/DanaKit
index e157a861c..eadb9fefc 160000
--- a/DanaKit
+++ b/DanaKit
@@ -1 +1 @@
-Subproject commit e157a861c691f44c84df6071233edb935c06e7dc
+Subproject commit eadb9fefc0e48df6639e1a9de5147ecf6a110caf
diff --git a/EversenseKit b/EversenseKit
index e1bb6faf0..e7a1850fd 160000
--- a/EversenseKit
+++ b/EversenseKit
@@ -1 +1 @@
-Subproject commit e1bb6faf01bb4bd79872ae6d12787aea8bfe2ad5
+Subproject commit e7a1850fd28665de2e61831d606abc1e4112c545
diff --git a/G7SensorKit b/G7SensorKit
index 5c0fc075a..f45545185 160000
--- a/G7SensorKit
+++ b/G7SensorKit
@@ -1 +1 @@
-Subproject commit 5c0fc075a323ccceca0cec28a1ea094570bdfc5d
+Subproject commit f45545185370bc9ebc57d06e8eea5773b1ef868c
diff --git a/LibreLoop b/LibreLoop
index 82c92f2cf..a76748161 160000
--- a/LibreLoop
+++ b/LibreLoop
@@ -1 +1 @@
-Subproject commit 82c92f2cfb265dd7147a2b36ee1796e30cb49ab9
+Subproject commit a76748161d31089ba102a05204afbaca448f5f09
diff --git a/LoopAlgorithm b/LoopAlgorithm
index 4604ee36f..7ef67243a 160000
--- a/LoopAlgorithm
+++ b/LoopAlgorithm
@@ -1 +1 @@
-Subproject commit 4604ee36f5acc3d00b2d07331a3a477570761374
+Subproject commit 7ef67243adb0445f5138083c30908a4745eafc83
diff --git a/LoopKit b/LoopKit
index f974241f5..ff8c30e28 160000
--- a/LoopKit
+++ b/LoopKit
@@ -1 +1 @@
-Subproject commit f974241f5f25053d51da3e80833c85b13c387e6f
+Subproject commit ff8c30e281d04edc2237a18834e5e783ae8c63d3
diff --git a/MedtrumKit b/MedtrumKit
index ef866fa68..c33699595 160000
--- a/MedtrumKit
+++ b/MedtrumKit
@@ -1 +1 @@
-Subproject commit ef866fa68db22932271af9bce86372ced8604203
+Subproject commit c33699595b6f636d8ca1bff377a8765df5597c34
diff --git a/Model/Classes+Properties/BolusStored+CoreDataProperties.swift b/Model/Classes+Properties/BolusStored+CoreDataProperties.swift
index 488a0743a..79f489d24 100644
--- a/Model/Classes+Properties/BolusStored+CoreDataProperties.swift
+++ b/Model/Classes+Properties/BolusStored+CoreDataProperties.swift
@@ -8,6 +8,7 @@ public extension BolusStored {
@NSManaged var amount: NSDecimalNumber?
@NSManaged var isExternal: Bool
+ @NSManaged var isProgrammedByPumpUI: Bool
@NSManaged var isSMB: Bool
@NSManaged var programmedAmount: NSDecimalNumber?
@NSManaged var pumpEvent: PumpEventStored?
diff --git a/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents b/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents
index cd8de8c7c..0b4ab488d 100644
--- a/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents
+++ b/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents
@@ -3,6 +3,7 @@
+
diff --git a/OmnipodKit b/OmnipodKit
index 928bb7950..0c6d68b00 160000
--- a/OmnipodKit
+++ b/OmnipodKit
@@ -1 +1 @@
-Subproject commit 928bb79503fc7eaf20fe9b26caa55fded09f7f0c
+Subproject commit 0c6d68b00d45f4fecd9b0fc0477fc00e2d7e4c02
diff --git a/RoundWhiteDiscKit b/RoundWhiteDiscKit
index e66da6cc3..7dd6396f0 160000
--- a/RoundWhiteDiscKit
+++ b/RoundWhiteDiscKit
@@ -1 +1 @@
-Subproject commit e66da6cc34680fecbbb87812279163dcc1a842b4
+Subproject commit 7dd6396f04371330d35799c88b9cba105a200180
diff --git a/SyaiKit b/SyaiKit
new file mode 160000
index 000000000..816108980
--- /dev/null
+++ b/SyaiKit
@@ -0,0 +1 @@
+Subproject commit 816108980650863b637934367dc5e35212001956
diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj
index 91101e413..87db77f79 100644
--- a/Trio.xcodeproj/project.pbxproj
+++ b/Trio.xcodeproj/project.pbxproj
@@ -7,6 +7,10 @@
objects = {
/* Begin PBXBuildFile section */
+ 03B15F4B3077CD4B00DFEBE4 /* SyaiKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */; };
+ 03B15F4D3077CD5400DFEBE4 /* SyaiKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */; };
+ 03B15F4E3077CD6100DFEBE4 /* SyaiKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
+ 03B15F4F3077CD6100DFEBE4 /* SyaiKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
041D1E995A6AE92E9289DC49 /* TreatmentsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D1A7CA8C10C4403D4BBFA7 /* TreatmentsDataFlow.swift */; };
0D9A5E34A899219C5C4CDFAF /* HistoryStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9455FA2D92E77A6C4AFED8A3 /* HistoryStateModel.swift */; };
110AEDE32C5193D200615CC9 /* BolusIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110AEDE02C5193D100615CC9 /* BolusIntent.swift */; };
@@ -328,8 +332,6 @@
41740E936552456AAC0EDDC3 /* LoopIndicatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3919BBB515547118D684CD2 /* LoopIndicatorTests.swift */; };
41740E936552456AAC0EDCC3 /* MaxIOBSuggestionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3919BBB515547118D684CC2 /* MaxIOBSuggestionTests.swift */; };
41740E936552456AAC0EDBC3 /* DosingModeMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3919BBB515547118D684CB2 /* DosingModeMigrationTests.swift */; };
- 45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */; };
- 45717281F743594AA9D87191 /* ConfigEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920DDB21E5D0EB813197500D /* ConfigEditorRootView.swift */; };
49090A8D2E9FE8D200D0F5DB /* GarminWatchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49090A8C2E9FE8D200D0F5DB /* GarminWatchSettings.swift */; };
491D6FBD2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FBC2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift */; };
491D6FBE2D56741C00C49F67 /* TempTargetRunStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FB92D56741C00C49F67 /* TempTargetRunStored+CoreDataClass.swift */; };
@@ -828,6 +830,7 @@
CA01000000000000000010C6 /* CGMManagerAlertOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA01000000000000000010C5 /* CGMManagerAlertOwnership.swift */; };
CA01000000000000000010C8 /* AlertColdStartReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA01000000000000000010C7 /* AlertColdStartReplay.swift */; };
CA02000000000000000010C2 /* DeliveryLimitsSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA02000000000000000010C1 /* DeliveryLimitsSyncTests.swift */; };
+ CA03000000000000000010C2 /* AlertCatalogRegistryOmniFaultTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA03000000000000000010C1 /* AlertCatalogRegistryOmniFaultTests.swift */; };
CA07000000000000000010C2 /* TelemetrySendGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA07000000000000000010C1 /* TelemetrySendGateTests.swift */; };
CA05000000000000000010C2 /* AlarmGramsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA05000000000000000010C1 /* AlarmGramsSection.swift */; };
CA06000000000000000010C2 /* GlucoseAlertsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA06000000000000000010C1 /* GlucoseAlertsStoreTests.swift */; };
@@ -933,7 +936,6 @@
DD1745292C55642100211FAD /* DosingModeOverrideNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1745282C55642100211FAD /* DosingModeOverrideNote.swift */; };
DD17452B2C556E8100211FAC /* SettingInputHintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17452A2C556E8100211FAC /* SettingInputHintView.swift */; };
DD17452C2C5564FF00211FA1 /* SettingsSearchHighlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17452C2C5564FF00211FA2 /* SettingsSearchHighlight.swift */; };
- 41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */; };
DD17452E2C55AE4800211FAC /* TargetBehavoirDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17452D2C55AE4800211FAC /* TargetBehavoirDataFlow.swift */; };
DD1745302C55AE5300211FAC /* TargetBehaviorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17452F2C55AE5300211FAC /* TargetBehaviorProvider.swift */; };
DD1745322C55AE6000211FAC /* TargetBehavoirStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1745312C55AE6000211FAC /* TargetBehavoirStateModel.swift */; };
@@ -1228,6 +1230,8 @@
dstPath = "";
dstSubfolderSpec = 10;
files = (
+ 03B15F4E3077CD6100DFEBE4 /* SyaiKitUI.framework in Embed Frameworks */,
+ 03B15F4F3077CD6100DFEBE4 /* SyaiKit.framework in Embed Frameworks */,
3B4BA78F2D8DC0EC0069D5B8 /* TidepoolServiceKit.framework in Embed Frameworks */,
3B4BA7732D8DBD690069D5B8 /* G7SensorKitUI.framework in Embed Frameworks */,
3B4BA76D2D8DBD690069D5B8 /* CGMBLEKitUI.framework in Embed Frameworks */,
@@ -1297,6 +1301,8 @@
/* Begin PBXFileReference section */
07629711A990D7666480FF41 /* ProfileSchedulerProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProfileSchedulerProvider.swift; sourceTree = ""; };
+ 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SyaiKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SyaiKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
0A1E8522E7684C6AA742F6E4 /* HomeStateModel+QuickPickTreatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeStateModel+QuickPickTreatments.swift"; sourceTree = ""; };
10E61A0885164081B57C357C /* NightscoutUploadSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadSerializer.swift; sourceTree = ""; };
110AEDE02C5193D100615CC9 /* BolusIntent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusIntent.swift; sourceTree = ""; };
@@ -2110,6 +2116,7 @@
CA01000000000000000010C5 /* CGMManagerAlertOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMManagerAlertOwnership.swift; sourceTree = ""; };
CA01000000000000000010C7 /* AlertColdStartReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertColdStartReplay.swift; sourceTree = ""; };
CA02000000000000000010C1 /* DeliveryLimitsSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryLimitsSyncTests.swift; sourceTree = ""; };
+ CA03000000000000000010C1 /* AlertCatalogRegistryOmniFaultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCatalogRegistryOmniFaultTests.swift; sourceTree = ""; };
CA07000000000000000010C1 /* TelemetrySendGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetrySendGateTests.swift; sourceTree = ""; };
CA05000000000000000010C1 /* AlarmGramsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmGramsSection.swift; sourceTree = ""; };
CA06000000000000000010C1 /* GlucoseAlertsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertsStoreTests.swift; sourceTree = ""; };
@@ -2212,7 +2219,6 @@
DD1745282C55642100211FAD /* DosingModeOverrideNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DosingModeOverrideNote.swift; sourceTree = ""; };
DD17452A2C556E8100211FAC /* SettingInputHintView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingInputHintView.swift; sourceTree = ""; };
DD17452C2C5564FF00211FA2 /* SettingsSearchHighlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchHighlight.swift; sourceTree = ""; };
- B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsSearchTests.swift; sourceTree = ""; };
DD17452D2C55AE4800211FAC /* TargetBehavoirDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetBehavoirDataFlow.swift; sourceTree = ""; };
DD17452F2C55AE5300211FAC /* TargetBehaviorProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetBehaviorProvider.swift; sourceTree = ""; };
DD1745312C55AE6000211FAC /* TargetBehavoirStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetBehavoirStateModel.swift; sourceTree = ""; };
@@ -2464,6 +2470,8 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 03B15F4D3077CD5400DFEBE4 /* SyaiKitUI.framework in Frameworks */,
+ 03B15F4B3077CD4B00DFEBE4 /* SyaiKit.framework in Frameworks */,
CE95BF632BA771BE00DC3DE3 /* LoopTestingKit.framework in Frameworks */,
3B4BA7722D8DBD690069D5B8 /* G7SensorKitUI.framework in Frameworks */,
3B4BA7742D8DBD690069D5B8 /* LibreTransmitter.framework in Frameworks */,
@@ -2502,7 +2510,6 @@
CE95BF5F2BA7715800DC3DE3 /* MockKit.framework in Frameworks */,
DD17A0292E3FE0BD008E1BF0 /* SwiftJWT in Frameworks */,
3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */,
- 3B4BA7842D8DBD690069D5B8 /* RileyLinkKit.framework in Frameworks */,
CE95BF612BA7715900DC3DE3 /* MockKitUI.framework in Frameworks */,
E0CC2C5C275B9F0F00A7BC71 /* HealthKit.framework in Frameworks */,
CE95BF5D2BA770C300DC3DE3 /* LoopKitUI.framework in Frameworks */,
@@ -3182,6 +3189,8 @@
3818AA48274C267000843DB3 /* Frameworks */ = {
isa = PBXGroup;
children = (
+ 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */,
+ 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */,
3E5308A43002BC970075629E /* AccuChekKit.framework */,
B6E925122EB3932A0076D719 /* OmnipodKit.framework */,
3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */,
@@ -3660,6 +3669,7 @@
38FCF3F125E9028E0078B0D1 /* Info.plist */,
CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */,
CA02000000000000000010C1 /* DeliveryLimitsSyncTests.swift */,
+ CA03000000000000000010C1 /* AlertCatalogRegistryOmniFaultTests.swift */,
CA07000000000000000010C1 /* TelemetrySendGateTests.swift */,
3BAAE60B2DE776630049589B /* DynamicISFEnableTests.swift */,
38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
@@ -6889,8 +6899,8 @@
DDDD0FFD2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift in Sources */,
DDDD10A12F4E22C000F9C645 /* CarbsNativeConversionTests.swift in Sources */,
DDDD10B12F4E22C000F9C645 /* PumpHistoryNativeConversionTests.swift in Sources */,
- CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
CA02000000000000000010C2 /* DeliveryLimitsSyncTests.swift in Sources */,
+ CA03000000000000000010C2 /* AlertCatalogRegistryOmniFaultTests.swift in Sources */,
CA07000000000000000010C2 /* TelemetrySendGateTests.swift in Sources */,
BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */,
BD8FC0592D66189700B95AED /* TestAssembly.swift in Sources */,
diff --git a/Trio.xcworkspace/contents.xcworkspacedata b/Trio.xcworkspace/contents.xcworkspacedata
index 7239825e9..edc3da0af 100644
--- a/Trio.xcworkspace/contents.xcworkspacedata
+++ b/Trio.xcworkspace/contents.xcworkspacedata
@@ -43,7 +43,7 @@
-
+
+
diff --git a/Trio/Sources/APS/CGM/PluginSource.swift b/Trio/Sources/APS/CGM/PluginSource.swift
index 4e25c4684..cce336ca4 100644
--- a/Trio/Sources/APS/CGM/PluginSource.swift
+++ b/Trio/Sources/APS/CGM/PluginSource.swift
@@ -8,6 +8,7 @@ import LibreLoop
import LibreTransmitter
import LoopKit
import LoopKitUI
+import SyaiKit
final class PluginSource: GlucoseSource {
private let processQueue = DispatchQueue(label: "CGMPluginSource.processQueue")
@@ -308,6 +309,10 @@ extension PluginSource: CGMManagerDelegate {
sensorActivatedAt = cgmTransmitterManager.state.activatedAt
sensorStartDate = cgmTransmitterManager.state.activatedAt
sensorTransmitterID = cgmTransmitterManager.state.bleNameString
+ } else if let cgmTransmitterManager = cgmManager as? SyaiCGMManager {
+ sensorActivatedAt = cgmTransmitterManager.state.activatedAt
+ sensorStartDate = cgmTransmitterManager.state.activatedAt
+ sensorTransmitterID = cgmTransmitterManager.state.mac
}
// isDisplayOnly means "shifted for visual consistency after calibration"
diff --git a/Trio/Sources/APS/Devices/DeviceCatalog.swift b/Trio/Sources/APS/Devices/DeviceCatalog.swift
index e86b76b2e..bc2526b3a 100644
--- a/Trio/Sources/APS/Devices/DeviceCatalog.swift
+++ b/Trio/Sources/APS/Devices/DeviceCatalog.swift
@@ -22,6 +22,8 @@ import MockKit
import MockKitUI
import ObjectiveC
import OmnipodKit
+import SyaiKit
+import SyaiKitUI
import UIKit
/// Single source of truth for every CGM and pump Trio can pair with.
@@ -45,6 +47,7 @@ enum DeviceManufacturer: CaseIterable {
case roche
case senseonics
case sooil
+ case syai
case otherSources
case simulator
@@ -59,6 +62,7 @@ enum DeviceManufacturer: CaseIterable {
case .roche: return "Roche"
case .senseonics: return "Senseonics"
case .sooil: return "SOOIL"
+ case .syai: return "Syai"
case .otherSources: return String(localized: "Other Sources", comment: "Device picker section for data relays")
case .simulator: return String(localized: "Simulator", comment: "Device picker section for simulated devices")
}
@@ -389,6 +393,13 @@ extension DeviceCatalog {
icon: .managerBundle(asset: "transmitter")
),
+ CGMCatalogEntry(
+ .managed(SyaiCGMManager.self),
+ manufacturer: .syai,
+ name: "Syai Ultra",
+ icon: .uiBundle(identifier: "org.loopkit.SyaiKitUI", asset: "sensor")
+ ),
+
CGMCatalogEntry(.native(.nightscout), manufacturer: .otherSources, icon: .trio(asset: "owl")),
CGMCatalogEntry(.native(.xdrip), manufacturer: .otherSources),
diff --git a/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DosingEngine.swift b/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DosingEngine.swift
index f024ba716..4eee2bcc7 100644
--- a/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DosingEngine.swift
+++ b/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DosingEngine.swift
@@ -860,7 +860,10 @@ enum DosingEngine {
durationRequired = min(60, max(0, durationRequired))
} else {
// Note: we're using the fully adjusted basal here
- smbLowTempRequired = (basal * durationRequired / 30).jsRounded(scale: 2)
+ // Intentional deviation from oref JS (basal * durationReq / 30) as that formula is,
+ // inverted, so a longer required suspension gets a higher rate instead of a lower one.
+ // See https://github.com/nightscout/Trio/issues/1359
+ smbLowTempRequired = (basal * (30 - durationRequired) / 30).jsRounded(scale: 2)
durationRequired = 30
}
diff --git a/Trio/Sources/APS/Storage/CarbEntryMutationService.swift b/Trio/Sources/APS/Storage/CarbEntryMutationService.swift
index 235c7642e..48e46fd47 100644
--- a/Trio/Sources/APS/Storage/CarbEntryMutationService.swift
+++ b/Trio/Sources/APS/Storage/CarbEntryMutationService.swift
@@ -62,8 +62,8 @@ final class BaseCarbEntryMutationService: CarbEntryMutationService, Injectable {
}
let snapshot = try await localMutations.run {
let snapshot = try await self.snapshotRoot(rootObjectID)
- await self.carbsStorage.deleteCarbsEntryStored(rootObjectID)
- try await self.carbsStorage.storeCarbs([replacement], areFetchedFromRemote: false)
+ // One transaction, so a failed insert cannot leave the meal deleted without a replacement.
+ try await self.carbsStorage.replaceCarbs(rootObjectID, with: replacement)
return snapshot
}
let failures = await deleteFromServices(snapshot)
diff --git a/Trio/Sources/APS/Storage/CarbsStorage.swift b/Trio/Sources/APS/Storage/CarbsStorage.swift
index 36bd5cfc1..dda377646 100644
--- a/Trio/Sources/APS/Storage/CarbsStorage.swift
+++ b/Trio/Sources/APS/Storage/CarbsStorage.swift
@@ -17,6 +17,8 @@ protocol CarbsStorage {
func storeVerifiedCarbs(_ entry: CarbsEntry, authorize: @escaping () throws -> Void) async throws
/// Builds a single, real, locally-entered carb entry ready to pass to `storeCarbs`.
func makeCarbEntry(carbs: Decimal, date: Date) -> CarbsEntry
+ /// Swaps a stored meal in one Core Data transaction.
+ func replaceCarbs(_ treatmentObjectID: NSManagedObjectID, with entry: CarbsEntry) async throws
func deleteCarbsEntryStored(_ treatmentObjectID: NSManagedObjectID) async
func syncDate() -> Date
func getCarbsForAlgorithm(additionalCarbs: Decimal?, carbsDate: Date?) async throws -> [CarbsEntry]
@@ -260,22 +262,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
context.name = "saveCarbsToCoreData"
try await context.perform {
try authorize?()
- let newItem = CarbEntryStored(context: context)
- newItem.date = entry.actualDate ?? entry.createdAt
- newItem.carbs = Double(truncating: NSDecimalNumber(decimal: entry.carbs))
- newItem.fat = Double(truncating: NSDecimalNumber(decimal: entry.fat ?? 0))
- newItem.protein = Double(truncating: NSDecimalNumber(decimal: entry.protein ?? 0))
- newItem.note = entry.note
- // Nightscout `_id` values are not UUIDs and are replaced.
- newItem.id = entry.id.flatMap(UUID.init(uuidString:)) ?? UUID()
- newItem.isFPU = false
- newItem.isUploadedToNS = areFetchedFromRemote ? true : false
- newItem.isUploadedToHealth = false
- newItem.isUploadedToTidepool = false
-
- if entry.fat != nil, entry.protein != nil, let fpuId = entry.fpuID {
- newItem.fpuID = UUID(uuidString: fpuId)
- }
+ self.insertCarbEntry(entry, into: context, areFetchedFromRemote: areFetchedFromRemote)
guard context.hasChanges else { return }
do {
@@ -288,6 +275,90 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
}
}
+ /// Inserts a meal root row into `context` without saving, so callers decide when to commit.
+ private func insertCarbEntry(_ entry: CarbsEntry, into context: NSManagedObjectContext, areFetchedFromRemote: Bool) {
+ let newItem = CarbEntryStored(context: context)
+ newItem.date = entry.actualDate ?? entry.createdAt
+ newItem.carbs = Double(truncating: NSDecimalNumber(decimal: entry.carbs))
+ newItem.fat = Double(truncating: NSDecimalNumber(decimal: entry.fat ?? 0))
+ newItem.protein = Double(truncating: NSDecimalNumber(decimal: entry.protein ?? 0))
+ newItem.note = entry.note
+ // Nightscout `_id` values are not UUIDs and are replaced.
+ newItem.id = entry.id.flatMap(UUID.init(uuidString:)) ?? UUID()
+ newItem.isFPU = false
+ newItem.isUploadedToNS = areFetchedFromRemote ? true : false
+ newItem.isUploadedToHealth = false
+ newItem.isUploadedToTidepool = false
+
+ if entry.fat != nil, entry.protein != nil, let fpuId = entry.fpuID {
+ newItem.fpuID = UUID(uuidString: fpuId)
+ }
+ }
+
+ /// Inserts a locally created carb equivalent row into `context` without saving.
+ private func insertCarbEquivalent(_ entry: CarbsEntry, into context: NSManagedObjectContext) {
+ let newItem = CarbEntryStored(context: context)
+ newItem.date = entry.actualDate
+ newItem.carbs = Double(truncating: NSDecimalNumber(decimal: entry.carbs))
+ newItem.id = entry.id.flatMap(UUID.init(uuidString:))
+ newItem.fpuID = entry.fpuID.flatMap(UUID.init(uuidString:))
+ newItem.isFPU = true
+ newItem.isUploadedToNS = false
+ // do NOT set Health and Tidepool flags to ensure they will NOT be uploaded
+ }
+
+ /// Replaces a stored meal, together with any carb equivalents sharing its `fpuID`.
+ ///
+ /// The deletes and the inserts go through the same context and are committed by a single `save()`.
+ /// A failure therefore leaves the old meal in place rather than deleting it without a replacement.
+ func replaceCarbs(_ treatmentObjectID: NSManagedObjectID, with entry: CarbsEntry) async throws {
+ var carbEquivalents: [CarbsEntry] = []
+ if let fat = entry.fat, let protein = entry.protein, fat > 0 || protein > 0 {
+ (carbEquivalents, _) = processFPU(
+ entries: [entry],
+ fat: fat,
+ protein: protein,
+ createdAt: entry.createdAt,
+ actualDate: entry.actualDate
+ )
+ }
+
+ let context = makeContext()
+ context.name = "replaceCarbs"
+ context.transactionAuthor = "replaceCarbs"
+
+ // A fetch reads the store, so rows removed by a batch delete are not served from the row cache.
+ let rootRequest: NSFetchRequest = CarbEntryStored.fetchRequest()
+ rootRequest.predicate = NSPredicate(format: "SELF == %@", treatmentObjectID)
+ rootRequest.fetchLimit = 1
+
+ try await context.perform {
+ guard let storedEntry = try context.fetch(rootRequest).first else {
+ throw CoreDataError.fetchError(function: #function, file: #file)
+ }
+
+ // Deleted through the context instead of an NSBatchDeleteRequest, which would commit on its own.
+ if let fpuID = storedEntry.fpuID {
+ let familyRequest: NSFetchRequest = CarbEntryStored.fetchRequest()
+ familyRequest.predicate = NSPredicate(format: "fpuID == %@", fpuID as CVarArg)
+ for entryToDelete in try context.fetch(familyRequest) {
+ context.delete(entryToDelete)
+ }
+ } else {
+ context.delete(storedEntry)
+ }
+
+ self.insertCarbEntry(entry, into: context, areFetchedFromRemote: false)
+ for carbEquivalent in carbEquivalents {
+ self.insertCarbEquivalent(carbEquivalent, into: context)
+ }
+
+ try context.save()
+ }
+
+ updateSubject.send(())
+ }
+
private func saveFPUToCoreDataAsBatchInsert(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
let commonFPUID = UUID(
uuidString: entries.first?.fpuID ?? UUID()
diff --git a/Trio/Sources/APS/Storage/PumpHistoryStorage.swift b/Trio/Sources/APS/Storage/PumpHistoryStorage.swift
index 2b85694d0..b50b494ba 100644
--- a/Trio/Sources/APS/Storage/PumpHistoryStorage.swift
+++ b/Trio/Sources/APS/Storage/PumpHistoryStorage.swift
@@ -194,6 +194,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
decimal: self.adjustPumpedVolumeToU100(pumpedVolume: dose.programmedUnits)
)
newBolusEntry.isExternal = dose.manuallyEntered
+ newBolusEntry.isProgrammedByPumpUI = dose.wasProgrammedByPumpUI
newBolusEntry.isSMB = dose.automatic ?? true
case .tempBasal:
@@ -289,6 +290,7 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
case PumpEventStored.EventType.bolus.rawValue:
guard let bolus = event.bolus else { return }
let previousAmount = bolus.amount
+ let previousProgrammedByPumpUI = bolus.isProgrammedByPumpUI
let finalAmount = dose.deliveredUnits.map {
self.adjustPumpedVolumeToU100(pumpedVolume: $0)
}
@@ -296,8 +298,11 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
bolus.amount = finalAmount as NSDecimalNumber
}
bolus.isSMB = dose.automatic ?? true
+ bolus.isProgrammedByPumpUI = dose.wasProgrammedByPumpUI
event.isMutable = dose.isMutable
- if event.isUploadedToNS, bolus.amount != previousAmount {
+ if event.isUploadedToNS,
+ bolus.amount != previousAmount || bolus.isProgrammedByPumpUI != previousProgrammedByPumpUI
+ {
event.isUploadedToNS = false
}
if !dose.isMutable {
@@ -481,7 +486,9 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
protein: nil,
targetTop: nil,
targetBottom: nil,
- id: event.id
+ id: event.id,
+ // omitted unless set, like LoopKit's DoseEntry encoding
+ isProgrammedByPumpUI: event.bolus?.isProgrammedByPumpUI == true ? true : nil
)
case PumpEvent.tempBasal.rawValue:
return NightscoutTreatment(
diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings
index a2647da69..7bc0ec118 100644
--- a/Trio/Sources/Localizations/Main/Localizable.xcstrings
+++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings
@@ -1089,6 +1089,149 @@
}
}
},
+ "Choose the preferred lower threshold for Time in Range statistics." : {
+ "comment" : "Short description of the lower range threshold setting.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choose the preferred lower threshold for Time in Range statistics."
+ }
+ }
+ }
+ },
+ "Choose the preferred lower threshold for Time in Range statistics across all charts and displays. This applies to both Time in Range (TIR, upper threshold of %@) and Time in Tight Range (TITR, upper threshold of %@)." : {
+ "comment" : "Explains that the lower threshold applies to both TIR and TITR. Placeholders are the upper thresholds including units.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choose the preferred lower threshold for Time in Range statistics across all charts and displays. This applies to both Time in Range (TIR, upper threshold of %@) and Time in Tight Range (TITR, upper threshold of %@)."
+ }
+ }
+ }
+ },
+ "Extended (%@)" : {
+ "comment" : "Extended lower threshold for TIR and TITR statistics; placeholder includes glucose value and units.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Extended (%@)"
+ }
+ }
+ }
+ },
+ "Extended Lower Threshold" : {
+ "comment" : "Search term for the extended lower range threshold setting.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Extended Lower Threshold"
+ }
+ }
+ }
+ },
+ "Extended:" : {
+ "comment" : "Heading for the extended lower range threshold description.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Extended:"
+ }
+ }
+ }
+ },
+ "TIR Lower Threshold" : {
+ "comment" : "Setting for the lower glucose boundary shared by TIR and TITR statistics.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "TIR Lower Threshold"
+ }
+ }
+ }
+ },
+ "Standard (%@)" : {
+ "comment" : "Standard lower threshold for TIR and TITR statistics; placeholder includes glucose value and units.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Standard (%@)"
+ }
+ }
+ }
+ },
+ "Standard Lower Threshold" : {
+ "comment" : "Search term for the standard lower range threshold setting.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Standard Lower Threshold"
+ }
+ }
+ }
+ },
+ "Standard:" : {
+ "comment" : "Heading for the standard lower range threshold description.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Standard:"
+ }
+ }
+ }
+ },
+ "TIR (%@-%@)" : {
+ "comment" : "Time in Range with the selected lower threshold and the upper threshold.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "TIR (%@-%@)"
+ }
+ }
+ }
+ },
+ "TITR (%@-%@)" : {
+ "comment" : "Time in Tight Range with either the Standard or Extended lower threshold.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "TITR (%@-%@)"
+ }
+ }
+ }
+ },
+ "Uses the established lower threshold of %@." : {
+ "comment" : "Standard lower range threshold description; placeholder includes glucose value and units.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Uses the established lower threshold of %@."
+ }
+ }
+ }
+ },
+ "Uses the newer Time in Normoglycemia (TING) lower threshold of %@." : {
+ "comment" : "Extended lower range threshold description; placeholder includes glucose value and units.",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Uses the newer Time in Normoglycemia (TING) lower threshold of %@."
+ }
+ }
+ }
+ },
" " : {
"localizations" : {
"bg" : {
@@ -241315,6 +241458,9 @@
}
}
},
+ "Programmed on pump" : {
+ "comment" : "Accessibility label for bolus programmed on the pump itself"
+ },
"Protein / Fat" : {
"comment" : "Treatment type",
"localizations" : {
diff --git a/Trio/Sources/Models/NightscoutTreatment.swift b/Trio/Sources/Models/NightscoutTreatment.swift
index cbd76d3fb..81dd6e004 100644
--- a/Trio/Sources/Models/NightscoutTreatment.swift
+++ b/Trio/Sources/Models/NightscoutTreatment.swift
@@ -23,6 +23,7 @@ struct NightscoutTreatment: JSON, Hashable, Equatable {
var units: String?
var id: String?
var fpuID: String?
+ var isProgrammedByPumpUI: Bool?
static let local = "Trio"
@@ -61,5 +62,6 @@ extension NightscoutTreatment {
case units
case id
case fpuID
+ case isProgrammedByPumpUI
}
}
diff --git a/Trio/Sources/Models/TimeInRangeType.swift b/Trio/Sources/Models/TimeInRangeType.swift
index eb5c45c92..b1f4f46df 100644
--- a/Trio/Sources/Models/TimeInRangeType.swift
+++ b/Trio/Sources/Models/TimeInRangeType.swift
@@ -1,17 +1,19 @@
import Foundation
+// Keep legacy case names for saved settings compatibility.
enum TimeInRangeType: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
var id: String { rawValue }
case timeInTightRange
case timeInNormoglycemia
- var displayName: String {
+ func displayName(for units: GlucoseUnits) -> String {
+ let threshold = bottomThreshold.formatted(withUnits: units)
switch self {
case .timeInTightRange:
- return String(localized: "Time in Tight Range (TITR)", comment: "")
+ return String(localized: "Standard (\(threshold))", comment: "Standard lower threshold for TIR and TITR statistics")
case .timeInNormoglycemia:
- return String(localized: "Time in Normoglycemia (TING)", comment: "")
+ return String(localized: "Extended (\(threshold))", comment: "Extended lower threshold for TIR and TITR statistics")
}
}
diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift
index af709acf2..c2705013d 100644
--- a/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift
+++ b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift
@@ -561,8 +561,6 @@ struct EditOverrideForm: View {
guard moc.hasChanges else { return }
try moc.save()
- try await state.nightscoutManager.uploadProfiles()
-
// Disable previous active Override
if let currentActiveOverride = state.currentActiveOverride {
Task {
@@ -577,6 +575,16 @@ struct EditOverrideForm: View {
hasChanges = false
presentationMode.wrappedValue.dismiss()
+
+ // Upload after dismiss, so a Nightscout error does not keep the form open
+ do {
+ try await state.nightscoutManager.uploadProfiles()
+ } catch {
+ debug(
+ .default,
+ "\(DebuggingIdentifiers.failed) failed to upload profiles to Nightscout: \(error)"
+ )
+ }
} catch {
debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to edit Override")
}
diff --git a/Trio/Sources/Modules/History/View/HistoryRootView.swift b/Trio/Sources/Modules/History/View/HistoryRootView.swift
index dc7980bc3..189c92587 100644
--- a/Trio/Sources/Modules/History/View/HistoryRootView.swift
+++ b/Trio/Sources/Modules/History/View/HistoryRootView.swift
@@ -975,6 +975,16 @@ extension History {
if bolus.isExternal {
Text(String(localized: "External", comment: "External Insulin")).foregroundColor(.secondary)
}
+ if bolus.isProgrammedByPumpUI {
+ Image(systemName: "keyboard.onehanded.left")
+ .foregroundColor(.secondary)
+ .accessibilityLabel(
+ String(
+ localized: "Programmed on pump",
+ comment: "Accessibility label for bolus programmed on the pump itself"
+ )
+ )
+ }
} else if let tempBasal = item.tempBasal, let rate = tempBasal.rate {
Image(systemName: "circle.fill")
.foregroundColor(Color.insulin.opacity(0.4))
diff --git a/Trio/Sources/Modules/Home/HomeStateModel+Setup/StartEndMarkerSetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/StartEndMarkerSetup.swift
index 2bee748bd..88c04061f 100644
--- a/Trio/Sources/Modules/Home/HomeStateModel+Setup/StartEndMarkerSetup.swift
+++ b/Trio/Sources/Modules/Home/HomeStateModel+Setup/StartEndMarkerSetup.swift
@@ -33,5 +33,14 @@ extension Home.StateModel {
endMarker = forecastDisplayType == .lines ? threeHourSinceNow : dynamicFutureDateForCone <= threeHourSinceNow ?
dynamicFutureDateForCone.addingTimeInterval(TimeInterval(minutes: 30)) : threeHourSinceNow
+
+ // Target profiles are built at setup and end at a midnight;
+ // rebuild them when the chart window moves past the last one
+ // cf. https://github.com/nightscout/Trio/issues/1581
+ if let lastEnd = targetProfiles.last?.endTime,
+ lastEnd < endMarker.timeIntervalSinceReferenceDate
+ {
+ targetProfiles = processFetchedTargets(bgTargets, startMarker: startMarker)
+ }
}
}
diff --git a/Trio/Sources/Modules/Home/HomeStateModel.swift b/Trio/Sources/Modules/Home/HomeStateModel.swift
index b72983c0d..d74f183e2 100644
--- a/Trio/Sources/Modules/Home/HomeStateModel.swift
+++ b/Trio/Sources/Modules/Home/HomeStateModel.swift
@@ -14,6 +14,7 @@ import MockKit
import Observation
import SwiftDate
import SwiftUI
+import SyaiKit
extension Home {
@Observable final class StateModel: BaseStateModel {
@@ -1000,6 +1001,9 @@ extension Home {
case let accuChek as AccuChekCgmManager:
return accuChek.state.cgmEndTime
+ case let syai as SyaiCGMManager:
+ return syai.sensorExpirationDate
+
default:
return nil
}
@@ -1045,6 +1049,9 @@ extension Home {
}
return nil
+ case let syai as SyaiCGMManager:
+ return syai.sensorWarmupEndDate
+
default:
return nil
}
diff --git a/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift b/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift
index dd1fb095b..2a286ce37 100644
--- a/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift
+++ b/Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift
@@ -314,6 +314,14 @@ struct CurrentGlucoseView: View {
return (status.imageName, oneLine, color)
}
+ /// A status the user has to act on — sensor issue, signal loss, failure.
+ /// Not gated by sensor age: it is the reason the readings stopped making
+ /// sense, whether the sensor is on day two or day ten.
+ private var statusDemandsAttention: Bool {
+ guard let status = cgmStatus else { return false }
+ return status.status != .normal
+ }
+
/// Arc shown for warmup, the last 48 h of a time-based sensor (incl.
/// grace period), or any non-normal state from a battery-based manager.
private var shouldShowArc: Bool {
@@ -361,6 +369,11 @@ struct CurrentGlucoseView: View {
default: return .loopGreen
}
}
+ // A warning or critical status shows whatever the sensor's age; only
+ // the normal tier stays gated to the arc's 48h window.
+ if statusDemandsAttention, let status = cgmStatus {
+ return status.status == .critical ? .loopRed : .orange
+ }
if let expiresAt = cgmSensorExpiresAt {
let hoursRemaining = expiresAt.timeIntervalSinceNow / 3600
switch hoursRemaining {
@@ -387,6 +400,7 @@ struct CurrentGlucoseView: View {
}
}
if isStabilizing { return "hourglass.bottomhalf.filled" }
+ if statusDemandsAttention { return "hourglass" }
guard shouldShowArc else { return nil }
if let expiresAt = cgmSensorExpiresAt {
let hoursRemaining = expiresAt.timeIntervalSinceNow / 3600
@@ -414,6 +428,9 @@ struct CurrentGlucoseView: View {
if isStabilizing {
return String(localized: "stabilizing", comment: "Accessibility: sensor lifecycle tag")
}
+ if statusDemandsAttention, let status = cgmStatus {
+ return status.localizedMessage.replacingOccurrences(of: "\n", with: " ")
+ }
guard shouldShowArc else { return nil }
if let expiresAt = cgmSensorExpiresAt {
return SensorRemainingTimeFormatter.format(until: expiresAt)
diff --git a/Trio/Sources/Modules/Settings/SettingItems.swift b/Trio/Sources/Modules/Settings/SettingItems.swift
index ed020c828..b4673b8de 100644
--- a/Trio/Sources/Modules/Settings/SettingItems.swift
+++ b/Trio/Sources/Modules/Settings/SettingItems.swift
@@ -431,6 +431,9 @@ enum SettingItems {
"Dark Scheme",
"Light Scheme",
"Glucose Color Scheme",
+ "TIR Lower Threshold",
+ "Standard Lower Threshold",
+ "Extended Lower Threshold",
"Time in Range Type",
"Time in Tight Range (TITR)",
"Time in Normoglycemia (TING)",
@@ -445,13 +448,16 @@ enum SettingItems {
"Show COB-IOB Chart": "Show X-Axis Grid Lines",
"Show Glucose Peaks": "Show X-Axis Grid Lines",
"X-Axis Interval Step": "Show X-Axis Grid Lines",
- "Time in Tight Range (TITR)": "Time in Range Type",
- "Time in Normoglycemia (TING)": "Time in Range Type",
"High Threshold": "Low Threshold",
"Cone": "Forecast Display Type",
"Lines": "Forecast Display Type",
"Dark Mode": "Appearance",
"Light Mode": "Appearance",
+ "Standard Lower Threshold": "TIR Lower Threshold",
+ "Extended Lower Threshold": "TIR Lower Threshold",
+ "Time in Range Type": "TIR Lower Threshold",
+ "Time in Tight Range (TITR)": "TIR Lower Threshold",
+ "Time in Normoglycemia (TING)": "TIR Lower Threshold",
"Dark Scheme": "Appearance",
"Light Scheme": "Appearance",
"Carbs Required Threshold": "Show Carbs Required Badge",
diff --git a/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift b/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift
index f2699b118..e88673e83 100644
--- a/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift
+++ b/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift
@@ -1126,8 +1126,8 @@ extension SettingsExport {
addSetting(
category: featuresCategory,
subcategory: userInterfaceSubcategory,
- name: String(localized: "Time in Range Type"),
- value: trioSettings.timeInRangeType.rawValue
+ name: String(localized: "TIR Lower Threshold"),
+ value: trioSettings.timeInRangeType.displayName(for: trioSettings.units)
)
addSetting(
category: featuresCategory,
diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucosePercentileChart.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucosePercentileChart.swift
index 3c32464a8..a1cd19064 100644
--- a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucosePercentileChart.swift
+++ b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucosePercentileChart.swift
@@ -11,7 +11,7 @@ struct GlucosePercentileChart: View {
let glucose: [GlucoseStored]
/// The upper glucose limit for the chart.
let highLimit: Decimal
- /// TITR or TING
+ /// Lower threshold for TIR and TITR.
let timeInRangeType: TimeInRangeType
/// The units used for glucose measurement (mg/dL or mmol/L).
let units: GlucoseUnits
diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift
index dbeb06da4..84c37050d 100644
--- a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift
+++ b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift
@@ -32,13 +32,13 @@ struct GlucoseSectorChart: View {
let total = Decimal(glucose.count)
// Count readings greater than high limit (180 mg/dL)
let high = glucose.filter { $0.glucose > Int(highLimit) }.count
- // Count readings between low limit (TITR: 70 mg/dL, TING 63 mg/dL) and 140 mg/dL (tight control)
+ // TITR: selected lower threshold to 140 mg/dL.
let tight = glucose
.filter { $0.glucose >= timeInRangeType.bottomThreshold && $0.glucose <= timeInRangeType.topThreshold }.count
- // Count readings between 140 and high limit (normal range)
+ // TIR: selected lower threshold to 180 mg/dL.
let normal = glucose.filter { $0.glucose >= timeInRangeType.bottomThreshold && $0.glucose <= Int(highLimit) }
.count
- // Count readings less than low limit (low) (70 mg/dL if not showing chart, otherwise 70 for TITR and 63 for TING)
+ // Daily details retain a fixed below-70 measure.
let low = glucose.filter { $0.glucose < (showChart ? Int(timeInRangeType.bottomThreshold) : 70) }.count
// Count readings less than moderately low limit (63 mg/dL)
let moderatelyLow = glucose.filter { $0.glucose < 63 }.count
@@ -310,13 +310,13 @@ struct GlucoseSectorChart: View {
items: [
(
String(
- localized: "Normal (\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(highLimit.formatted(for: units)))"
+ localized: "TIR (\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(highLimit.formatted(for: units)))"
),
formatPercentage(Decimal(glucoseValues.count) / total * 100)
),
(
String(
- localized: "\(timeInRangeType == .timeInTightRange ? "TITR" : "TING") (\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(Decimal(timeInRangeType.topThreshold).formatted(for: units)))"
+ localized: "TITR (\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(Decimal(timeInRangeType.topThreshold).formatted(for: units)))"
),
formatPercentage(Decimal(tight) / total * 100)
),
diff --git a/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift b/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift
index b66bab147..5f58844e8 100644
--- a/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift
+++ b/Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift
@@ -636,16 +636,16 @@ extension UserInterfaceSettings {
VStack(alignment: .leading) {
Picker(
selection: $state.timeInRangeType,
- label: Text("Time in Range Type").multilineTextAlignment(.leading)
+ label: Text("TIR Lower Threshold").multilineTextAlignment(.leading)
) {
ForEach(TimeInRangeType.allCases) { selection in
- Text(selection.displayName).tag(selection)
+ Text(selection.displayName(for: state.units)).tag(selection)
}
}.padding(.top)
HStack(alignment: .center) {
Text(
- "Choose type of time in range to be used for Trio's statistics."
+ "Choose the preferred lower threshold for Time in Range statistics."
)
.font(.footnote)
.foregroundColor(.secondary)
@@ -655,48 +655,41 @@ extension UserInterfaceSettings {
Spacer()
Button(
action: {
- hintLabel = String(localized: "Time in Range Type")
+ hintLabel = String(localized: "TIR Lower Threshold")
selectedVerboseHint =
AnyView(
VStack(
alignment: .leading,
spacing: 10
) {
- Text(
- "Choose which type of time in range Trio should adopt for all its statistical charts and displays:"
- )
+ let tirUpperThreshold = 180.formatted(withUnits: state.units)
+ let titrUpperThreshold = 140.formatted(withUnits: state.units)
+ Text(String(
+ localized: "Choose the preferred lower threshold for Time in Range statistics across all charts and displays. This applies to both Time in Range (TIR, upper threshold of \(tirUpperThreshold)) and Time in Tight Range (TITR, upper threshold of \(titrUpperThreshold)).",
+ comment: "Explains that the lower threshold applies to both TIR and TITR"
+ ))
VStack(
alignment: .leading,
spacing: 5
) {
- Text(
- "Time in Tight Range (TITR):"
- )
- .bold()
- let titrBottomThreshold =
- "\(state.units == .mgdL ? Decimal(70) : 70.asMmolL)"
- let titrTopThreshold =
- "\(state.units == .mgdL ? Decimal(140) : 140.asMmolL)"
+ Text("Standard:").bold()
+ let standardLowerThreshold = TimeInRangeType.timeInTightRange
+ .bottomThreshold.formatted(withUnits: state.units)
Text(String(
- localized: "Uses the fairly established Time in Tight Range definition, which is defined as time between \(titrBottomThreshold) and \(titrTopThreshold) \(state.units.rawValue).",
- comment: "Time in Tight Range (TITR) verbose hint description"
+ localized: "Uses the established lower threshold of \(standardLowerThreshold).",
+ comment: "Standard lower range threshold description"
))
}
VStack(
alignment: .leading,
spacing: 5
) {
- Text(
- "Time in Normoglycemia (TING):"
- )
- .bold()
- let tingBottomThreshold =
- "\(state.units == .mgdL ? Decimal(63) : 63.asMmolL)"
- let tingTopThreshold =
- "\(state.units == .mgdL ? Decimal(140) : 140.asMmolL)"
+ Text("Extended:").bold()
+ let extendedLowerThreshold = TimeInRangeType.timeInNormoglycemia
+ .bottomThreshold.formatted(withUnits: state.units)
Text(String(
- localized: "Uses the very new – first discussed at ATTD 2025 in Amsterdam, NL – Time in Normoglycemia definition, which adopts its range as all values between the normoglycemic minimum threshold (\(tingBottomThreshold) \(state.units.rawValue)) and \(tingTopThreshold) \(state.units.rawValue).",
- comment: "Time in Normoglycemia (TING) verbose hint description"
+ localized: "Uses the newer Time in Normoglycemia (TING) lower threshold of \(extendedLowerThreshold).",
+ comment: "Extended lower range threshold description"
))
}
}
@@ -713,7 +706,7 @@ extension UserInterfaceSettings {
}.padding(.bottom)
}
.listRowBackground(Color.chart)
- .settingsSearchTarget(label: String(localized: "Time in Range Type"))
+ .settingsSearchTarget(label: String(localized: "TIR Lower Threshold"))
Section {
VStack(alignment: .leading) {
diff --git a/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift b/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift
index 3173b4c3c..7be38bd42 100644
--- a/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift
+++ b/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift
@@ -8,13 +8,14 @@ import LoopKit
/// move out of here and into the plugin repo.
///
/// Scope covers pump + CGM-issued alerts (LibreLoop sensor expiry / attention
-/// / reconnect). G5/G6/One/G7/One+ don't go through `issueAlert` — their
-/// status surfaces via `cgmStatusHighlight` instead. Trio-internal alerts
+/// / reconnect, G7 sensor lifecycle). G5/G6/One don't go through `issueAlert`
+/// — their status surfaces via `cgmStatusHighlight` only. Trio-internal alerts
/// (glucose thresholds, algorithm error, not-looping) set their level at
/// construction and don't need a catalog entry.
enum AlertCatalogRegistry {
static let entries: [Alert.CatalogEntry] =
- omniEntries + minimedEntries + danaEntries + medtrumEntries + libreLoopEntries + trioAlgorithmEntries
+ omniEntries + minimedEntries + danaEntries + medtrumEntries + libreLoopEntries + g7Entries
+ + syaiEntries + trioAlgorithmEntries
static func lookup(_ identifier: Alert.Identifier) -> Alert.CatalogEntry? {
if let exact = entries.first(where: { $0.identifier == identifier }) {
@@ -246,6 +247,95 @@ private extension AlertCatalogRegistry {
]
}
+// MARK: - Syai (Ultra)
+
+private extension AlertCatalogRegistry {
+ static let syaiEntries: [Alert.CatalogEntry] = [
+ addEntry("SyaiCGMManager", "syai.signalLost", .timeSensitive, "Signal Loss", "Connectivity", .cgmReconnectNeeded),
+ addEntry("SyaiCGMManager", "syai.expired", .timeSensitive, "Sensor Expired", "Sensor", .cgmExpired),
+ addEntry("SyaiCGMManager", "syai.failed", .timeSensitive, "Sensor Malfunction", "Sensor", .cgmReplacementNeeded),
+ addEntry("SyaiCGMManager", "syai.unactivated", .timeSensitive, "Sensor Not Activated", "Sensor", .cgmReplacementNeeded)
+ ]
+}
+
+// MARK: - Dexcom G7 / ONE+ / Stelo
+
+private extension AlertCatalogRegistry {
+ /// The plugin issues these without a sound, so without an entry here they
+ /// reach the user as a silent banner with no tier config and no snooze
+ /// mapping. G7 was the one CGM with no entries at all.
+ ///
+ /// Levels are identical to the LibreLoop set — every CGM lifecycle alert
+ /// is `.timeSensitive`. Nothing is `.critical`, sensor failure included: a
+ /// dead sensor stops CGM, it doesn't dose, and once readings stop
+ /// `loop.notActive` escalates to Critical on its own. Nothing is `.active`
+ /// either, matching how the catalog escalates LibreLoop's 24h warning.
+ ///
+ /// Both plugins declare their own levels and the catalog overrides both
+ /// the same way (`G7LifecycleAlert.interruptionLevel`,
+ /// `LibreLoopExpiryAlerts`), so these rows are what the user actually gets.
+ static let g7Entries: [Alert.CatalogEntry] = [
+ addEntry(
+ "G7CGMManager",
+ "sensorExpiringSoon",
+ .timeSensitive,
+ "Sensor Expires in 24 Hours",
+ "Sensor",
+ .cgmExpiringSoon
+ ),
+ addEntry(
+ "G7CGMManager",
+ "sensorExpiringImminently",
+ .timeSensitive,
+ "Sensor Expires in 2 Hours",
+ "Sensor",
+ .cgmExpiringSoon
+ ),
+ addEntry(
+ "G7CGMManager",
+ "sensorExpired",
+ .timeSensitive,
+ "Sensor Expired",
+ "Sensor",
+ .cgmExpired
+ ),
+ addEntry(
+ "G7CGMManager",
+ "sessionEnded",
+ .timeSensitive,
+ "Sensor Session Ended",
+ "Sensor",
+ .cgmExpired
+ ),
+ addEntry(
+ "G7CGMManager",
+ "sensorFailed",
+ .timeSensitive,
+ "Sensor Failed",
+ "Sensor",
+ .cgmReplacementNeeded
+ ),
+ addEntry(
+ "G7CGMManager",
+ "connectionRefused",
+ .timeSensitive,
+ "Sensor Connection Refused",
+ "Connectivity",
+ .cgmReconnectNeeded
+ ),
+ // The plugin no longer raises this one, only retracts it; the entry
+ // stays so a build that re-enables it is configured, not silent.
+ addEntry(
+ "G7CGMManager",
+ "signalLoss",
+ .timeSensitive,
+ "No Sensor Readings",
+ "Connectivity",
+ .cgmReconnectNeeded
+ )
+ ]
+}
+
// MARK: - Trio internal — Algorithm
private extension AlertCatalogRegistry {
diff --git a/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift b/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift
index fcbae3dbb..a39dba37d 100644
--- a/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift
+++ b/Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift
@@ -1085,6 +1085,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
switch event.type {
case PumpEventStored.EventType.bolus.rawValue:
return treatment.insulin == event.bolus?.amount as Decimal?
+ && (treatment.isProgrammedByPumpUI ?? false) == (event.bolus?.isProgrammedByPumpUI ?? false)
case PumpEventStored.EventType.tempBasal.rawValue:
return treatment.rate == event.tempBasal?.rate as Decimal?
&& treatment.duration == Int(event.tempBasal?.duration ?? 0)
diff --git a/TrioTests/AlertCatalogRegistryEntriesTests.swift b/TrioTests/AlertCatalogRegistryEntriesTests.swift
index eddeba8b5..b91d0a209 100644
--- a/TrioTests/AlertCatalogRegistryEntriesTests.swift
+++ b/TrioTests/AlertCatalogRegistryEntriesTests.swift
@@ -58,6 +58,17 @@ import Testing
("LibreLoopCGMManager", "sensorExpiry.warning2h"),
("LibreLoopCGMManager", "sensorExpiry.sessionEnded"),
("LibreLoopCGMManager", "sensorAttention"),
+ // G7 matches LibreLoop row for row: every CGM lifecycle alert is
+ // .timeSensitive. Not .critical — once readings stop,
+ // loop.notActive escalates on its own; and not .active, the same
+ // escalation the catalog applies to LibreLoop's 24h warning.
+ ("G7CGMManager", "sensorExpiringSoon"),
+ ("G7CGMManager", "sensorExpiringImminently"),
+ ("G7CGMManager", "sensorFailed"),
+ ("G7CGMManager", "sensorExpired"),
+ ("G7CGMManager", "sessionEnded"),
+ ("G7CGMManager", "connectionRefused"),
+ ("G7CGMManager", "signalLoss"),
("trio.aps", "glucoseDataStale")
]
) func timeSensitiveEntries(manager: String, alertID: String) {
@@ -164,6 +175,55 @@ import Testing
#expect(AlertCatalogRegistry.lookup(id("LibreLoopCGMManager", "reconnectNeedsReScan"))?.category == "Connectivity")
}
+ // MARK: - I2: Dexcom G7 lifecycle entries
+
+ /// The plugin issues these without a sound, so a missing entry is what
+ /// turns a sensor alert into a silent banner. The catalog is the tier
+ /// source, so these levels — not the plugin's own — are what the user
+ /// gets, and they must stay equal to the LibreLoop rows above.
+ @Test(
+ "G7 lifecycle entries map to CGM-side concepts",
+ arguments: [
+ ("sensorExpiringSoon", Alert.CatalogConcept.cgmExpiringSoon),
+ ("sensorExpiringImminently", Alert.CatalogConcept.cgmExpiringSoon),
+ ("sensorExpired", Alert.CatalogConcept.cgmExpired),
+ ("sessionEnded", Alert.CatalogConcept.cgmExpired),
+ ("sensorFailed", Alert.CatalogConcept.cgmReplacementNeeded),
+ ("connectionRefused", Alert.CatalogConcept.cgmReconnectNeeded),
+ ("signalLoss", Alert.CatalogConcept.cgmReconnectNeeded)
+ ]
+ ) func g7Concepts(alertID: String, expected: Alert.CatalogConcept) {
+ #expect(AlertCatalogRegistry.lookup(id("G7CGMManager", alertID))?.concept == expected)
+ }
+
+ @Test(
+ "G7 lifecycle entries are categorized Sensor or Connectivity",
+ arguments: [
+ ("sensorExpiringSoon", "Sensor"),
+ ("sensorExpiringImminently", "Sensor"),
+ ("sensorExpired", "Sensor"),
+ ("sessionEnded", "Sensor"),
+ ("sensorFailed", "Sensor"),
+ ("connectionRefused", "Connectivity"),
+ ("signalLoss", "Connectivity")
+ ]
+ ) func g7Categories(alertID: String, expected: String) {
+ #expect(AlertCatalogRegistry.lookup(id("G7CGMManager", alertID))?.category == expected)
+ }
+
+ /// Every case of `G7LifecycleAlert` is catalogued. The raw values are
+ /// duplicated here because the enum is internal to the plugin; if one is
+ /// renamed upstream this fails rather than going quietly silent.
+ @Test("every G7 lifecycle alert has an entry") func g7EntriesAreComplete() {
+ let allCases = [
+ "sensorExpiringSoon", "sensorExpiringImminently", "sensorExpired",
+ "sessionEnded", "sensorFailed", "signalLoss", "connectionRefused"
+ ]
+ for alertID in allCases {
+ #expect(AlertCatalogRegistry.lookup(id("G7CGMManager", alertID)) != nil)
+ }
+ }
+
// MARK: - J: Trio-internal Algorithm entries
@Test(
diff --git a/TrioTests/CoreDataTests/CarbsStorageTests.swift b/TrioTests/CoreDataTests/CarbsStorageTests.swift
index 17a088588..853f94784 100644
--- a/TrioTests/CoreDataTests/CarbsStorageTests.swift
+++ b/TrioTests/CoreDataTests/CarbsStorageTests.swift
@@ -303,4 +303,153 @@ import Testing
#expect(!children.isEmpty)
#expect(children.allSatisfy { $0.id == fpuID && $0.fpuID == fpuID })
}
+
+ // MARK: - replaceCarbs
+
+ private func allStoredRows() async throws -> [CarbEntryStored] {
+ guard let rows = try await coreDataStack.fetchEntitiesAsync(
+ ofType: CarbEntryStored.self,
+ onContext: testContext,
+ predicate: NSPredicate(format: "TRUEPREDICATE"),
+ key: "date",
+ ascending: true
+ ) as? [CarbEntryStored] else {
+ throw TestError("Failed to fetch stored rows")
+ }
+ return rows
+ }
+
+ @Test("Replace swaps the stored values") func testReplaceCarbs() async throws {
+ let original = CarbsEntry(
+ id: UUID().uuidString, createdAt: Date(), actualDate: Date(), carbs: 25, fat: nil, protein: nil,
+ note: "Before edit", enteredBy: "Test", isFPU: false, fpuID: nil
+ )
+ try await storage.storeCarbs([original], areFetchedFromRemote: false)
+ guard let objectID = try await allStoredRows().first?.objectID else {
+ throw TestError("Failed to get stored entry's ObjectID")
+ }
+
+ let editedDate = Date(timeIntervalSince1970: 1_700_003_000)
+ let edited = CarbsEntry(
+ id: UUID().uuidString, createdAt: Date(), actualDate: editedDate, carbs: 45, fat: 0, protein: 0,
+ note: "After edit", enteredBy: "Test", isFPU: false, fpuID: nil
+ )
+ try await storage.replaceCarbs(objectID, with: edited)
+
+ let rows = try await allStoredRows()
+ #expect(rows.count == 1, "Edit must not duplicate or drop the entry")
+ #expect(rows.first?.carbs == 45)
+ #expect(rows.first?.note == "After edit")
+ #expect(rows.first?.date == editedDate)
+ #expect(rows.first?.id?.uuidString == edited.id)
+ }
+
+ @Test("Replace rebuilds the carb equivalents") func testReplaceCarbsRebuildsCarbEquivalents() async throws {
+ let originalFpuID = UUID().uuidString
+ let baseDate = Date(timeIntervalSince1970: 1_700_002_000)
+ let original = CarbsEntry(
+ id: UUID().uuidString, createdAt: baseDate, actualDate: baseDate, carbs: 30, fat: 200, protein: 200,
+ note: "Before edit", enteredBy: "Test", isFPU: false, fpuID: originalFpuID
+ )
+ try await storage.storeCarbs([original], areFetchedFromRemote: false)
+ guard let objectID = try await allStoredRows().first(where: { !$0.isFPU })?.objectID else {
+ throw TestError("Failed to get stored entry's ObjectID")
+ }
+
+ // Fat and protein are reduced, so the carb equivalents have to be recalculated
+ let editedFpuID = UUID().uuidString
+ let edited = CarbsEntry(
+ id: UUID().uuidString, createdAt: baseDate, actualDate: baseDate, carbs: 30, fat: 50, protein: 100,
+ note: "After edit", enteredBy: "Test", isFPU: false, fpuID: editedFpuID
+ )
+ try await storage.replaceCarbs(objectID, with: edited)
+
+ let rows = try await allStoredRows()
+ #expect(rows.allSatisfy { $0.fpuID?.uuidString != originalFpuID }, "Old meal and its carb equivalents are gone")
+
+ let roots = rows.filter { !$0.isFPU }
+ #expect(roots.count == 1, "Edit must leave exactly one meal root")
+ #expect(roots.first?.fat == 50)
+ #expect(roots.first?.protein == 100)
+ #expect(roots.first?.fpuID?.uuidString == editedFpuID)
+
+ // fat=50g + protein=100g -> 850 kcal -> (850/10) * 0.5 = 42 equivalents -> two entries of 21g
+ let carbEquivalents = rows.filter(\.isFPU)
+ #expect(carbEquivalents.map { Int($0.carbs) } == [21, 21])
+ #expect(carbEquivalents.allSatisfy { $0.fpuID?.uuidString == editedFpuID })
+ #expect(carbEquivalents.allSatisfy { $0.isUploadedToNS == false })
+ }
+
+ @Test("Replace commits the delete and the insert in one transaction") func testReplaceCarbsIsAtomic() async throws {
+ let original = CarbsEntry(
+ id: UUID().uuidString, createdAt: Date(), actualDate: Date(), carbs: 25, fat: nil, protein: nil,
+ note: nil, enteredBy: "Test", isFPU: false, fpuID: nil
+ )
+ try await storage.storeCarbs([original], areFetchedFromRemote: false)
+ guard let objectID = try await allStoredRows().first?.objectID else {
+ throw TestError("Failed to get stored entry's ObjectID")
+ }
+
+ let saves = SaveCounter()
+ let observer = NotificationCenter.default.addObserver(
+ forName: .NSManagedObjectContextDidSave,
+ object: testContext,
+ queue: nil
+ ) { _ in saves.increment() }
+ defer { NotificationCenter.default.removeObserver(observer) }
+
+ let edited = CarbsEntry(
+ id: UUID().uuidString, createdAt: Date(), actualDate: Date(), carbs: 45, fat: 0, protein: 0,
+ note: nil, enteredBy: "Test", isFPU: false, fpuID: nil
+ )
+ try await storage.replaceCarbs(objectID, with: edited)
+
+ #expect(saves.count == 1, "A separate delete commit would leave the meal gone if the insert then failed")
+ }
+
+ @Test("Replace of a missing meal throws and keeps the other entries") func testReplaceCarbsMissingRoot() async throws {
+ let other = CarbsEntry(
+ id: UUID().uuidString, createdAt: Date(), actualDate: Date(), carbs: 12, fat: nil, protein: nil,
+ note: "Keep me", enteredBy: "Test", isFPU: false, fpuID: nil
+ )
+ let doomed = CarbsEntry(
+ id: UUID().uuidString, createdAt: Date(), actualDate: Date().addingTimeInterval(60), carbs: 25,
+ fat: nil, protein: nil, note: nil, enteredBy: "Test", isFPU: false, fpuID: nil
+ )
+ try await storage.storeCarbs([other], areFetchedFromRemote: false)
+ try await storage.storeCarbs([doomed], areFetchedFromRemote: false)
+ guard let objectID = try await allStoredRows().first(where: { $0.id?.uuidString == doomed.id })?.objectID else {
+ throw TestError("Failed to get stored entry's ObjectID")
+ }
+ await storage.deleteCarbsEntryStored(objectID)
+
+ await #expect(throws: CoreDataError.self) {
+ let replacement = CarbsEntry(
+ id: UUID().uuidString, createdAt: Date(), actualDate: Date(), carbs: 99, fat: nil, protein: nil,
+ note: nil, enteredBy: "Test", isFPU: false, fpuID: nil
+ )
+ try await storage.replaceCarbs(objectID, with: replacement)
+ }
+
+ let rows = try await allStoredRows()
+ #expect(rows.count == 1)
+ #expect(rows.first?.id?.uuidString == other.id, "A failed replace must not touch other meals")
+ }
+}
+
+private final class SaveCounter: @unchecked Sendable {
+ private let lock = NSLock()
+ private var value = 0
+
+ func increment() {
+ lock.lock()
+ value += 1
+ lock.unlock()
+ }
+
+ var count: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return value
+ }
}
diff --git a/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift b/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift
index 99f120b52..855dfc5bd 100644
--- a/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift
+++ b/TrioTests/CoreDataTests/PumpEventFinalizationTests.swift
@@ -47,7 +47,8 @@ import Testing
deliveredUnits: Double? = nil,
syncIdentifier: String?,
isMutable: Bool,
- automatic: Bool = true
+ automatic: Bool = true,
+ wasProgrammedByPumpUI: Bool = false
) -> LoopKit.NewPumpEvent {
LoopKit.NewPumpEvent(
date: date,
@@ -63,7 +64,8 @@ import Testing
insulinType: .lyumjev,
automatic: automatic,
manuallyEntered: false,
- isMutable: isMutable
+ isMutable: isMutable,
+ wasProgrammedByPumpUI: wasProgrammedByPumpUI
),
raw: Data((syncIdentifier ?? UUID().uuidString).utf8),
title: "Test Bolus",
@@ -428,6 +430,67 @@ import Testing
#expect(abs((tidepoolBolus?.programmedAmount ?? 0) - 5.0) < 0.0001, "Programmed units")
}
+ @Test("Nightscout payload marks boluses programmed on the pump") func testNightscoutPayloadCarriesPumpUIFlag() async throws {
+ let date = Date().addingTimeInterval(-30.minutes.timeInterval)
+ try await storage.storePumpEvents(
+ [
+ bolusEvent(
+ date: date,
+ units: 1.0,
+ syncIdentifier: "bolus-pump-ui",
+ isMutable: false,
+ automatic: false,
+ wasProgrammedByPumpUI: true
+ ),
+ bolusEvent(
+ date: date.addingTimeInterval(5.minutes.timeInterval),
+ units: 2.0,
+ syncIdentifier: "bolus-app",
+ isMutable: false,
+ automatic: false
+ )
+ ],
+ replacePendingEvents: false
+ )
+
+ let treatments = try await storage.getPumpHistoryNotYetUploadedToNightscout()
+ // event ids are UUIDs; tell the rows apart by amount, away from rounding noise
+ let pumpUI = treatments.first { ($0.insulin ?? 0) < 1.5 }
+ let app = treatments.first { ($0.insulin ?? 0) > 1.5 }
+ #expect(pumpUI?.isProgrammedByPumpUI == true, "Pump-programmed bolus must say so in NS")
+ #expect(app?.isProgrammedByPumpUI == nil, "Unset flag is omitted from the NS document")
+ }
+
+ @Test("Finalizing an uploaded bolus as pump-programmed resets the NS upload flag") func testPumpUIFinalizationResetsNSFlag(
+ ) async throws {
+ let date = Date().addingTimeInterval(-5.minutes.timeInterval)
+
+ try await storage.storePumpEvents(
+ [bolusEvent(date: date, units: 1.5, syncIdentifier: "bolus-ns3", isMutable: true)],
+ replacePendingEvents: false
+ )
+ try await testContext.perform {
+ let rows = try testContext.fetch(PumpEventStored.fetchRequest())
+ rows.forEach { $0.isUploadedToNS = true }
+ try testContext.save()
+ }
+ // same delivery, but the final report attributes it to the pump UI
+ try await storage.storePumpEvents(
+ [bolusEvent(
+ date: date,
+ units: 1.5,
+ deliveredUnits: 1.5,
+ syncIdentifier: "bolus-ns3",
+ isMutable: false,
+ wasProgrammedByPumpUI: true
+ )],
+ replacePendingEvents: false
+ )
+
+ let events = try await fetchAllEvents()
+ #expect(events.first?.isUploadedToNS == false, "A changed NS-visible flag must re-upload")
+ }
+
@Test("External doses expose no insulin type to uploaders") func testExternalDoseHasNoInsulinType() async throws {
await storage.storeExternalInsulinEvent(amount: 2.0, timestamp: Date().addingTimeInterval(-10.minutes.timeInterval))
diff --git a/TrioTests/OpenAPSSwiftTests/DetermineBasalAggressiveDosingTests.swift b/TrioTests/OpenAPSSwiftTests/DetermineBasalAggressiveDosingTests.swift
index b8117b4ae..db77ec8bf 100644
--- a/TrioTests/OpenAPSSwiftTests/DetermineBasalAggressiveDosingTests.swift
+++ b/TrioTests/OpenAPSSwiftTests/DetermineBasalAggressiveDosingTests.swift
@@ -1103,7 +1103,13 @@ import Testing
#expect(result.determination.duration == 60)
}
- @Test("should set low temp if durationReq < 30") func testSetLowTemp() throws {
+ @Test("should set low temp if durationReq < 30", arguments: [
+ (Decimal(95.85), Decimal(83) / 100), // durationRequired = 5m, rate = 1.0 * (30 - 5) / 30 = 0.83
+ (Decimal(91.65), Decimal(67) / 100), // durationRequired = 10m, rate = 1.0 * (30 - 10) / 30 = 0.67
+ (Decimal(87.5), Decimal(50) / 100), // durationRequired = 15m, rate = 1.0 * (30 - 15) / 30 = 0.50
+ (Decimal(79.15), Decimal(17) / 100), // durationRequired = 25m, rate = 1.0 * (30 - 25) / 30 = 0.17
+ (Decimal(75.85), Decimal(3) / 100) // durationRequired = 29m, rate = 1.0 * (30 - 29) / 30 = 0.03
+ ]) func testSetLowTemp(avgGlucose: Decimal, expectedRate: Decimal) throws {
var profile = Profile()
profile.currentBasal = 1.0
profile.maxDailyBasal = 1.5
@@ -1136,22 +1142,17 @@ import Testing
lastTemp: nil
)]
- // worstCaseInsulinReq needs to result in durationReq = 15
- // 15 = 60 * worst / 1.0 => worst = 0.25
- // worst = (100 - avg)/50 => avg = 87.5
-
let result = try callDetermineSMBDelivery(
insulinRequired: 1.0,
profile: profile,
iobData: iobData,
basal: 1.0,
- naiveEventualGlucose: 87.5,
- minIOBForecastedGlucose: 87.5
+ naiveEventualGlucose: avgGlucose,
+ minIOBForecastedGlucose: avgGlucose
)
- // Rate = basal * 15/30 = 1.0 * 0.5 = 0.5
#expect(result.shouldSetTempBasal == true)
- #expect(result.determination.rate == 0.5)
+ #expect(result.determination.rate == expectedRate)
#expect(result.determination.duration == 30)
}
diff --git a/TrioTests/OpenAPSSwiftTests/Parity/goldens/autosens-resistance+autoisf-off/determination.json b/TrioTests/OpenAPSSwiftTests/Parity/goldens/autosens-resistance+autoisf-off/determination.json
index 965eee098..e185d25a4 100644
--- a/TrioTests/OpenAPSSwiftTests/Parity/goldens/autosens-resistance+autoisf-off/determination.json
+++ b/TrioTests/OpenAPSSwiftTests/Parity/goldens/autosens-resistance+autoisf-off/determination.json
@@ -16,7 +16,6 @@
"dura_ISFratio" : 1,
"dura_avg" : 1,
"dura_min" : 1,
- "duration" : 30,
"eventualBG" : 135,
"expectedDelta" : -4.6,
"id" : "00000000-0000-0000-0000-000000000000",
@@ -165,8 +164,7 @@
126
]
},
- "rate" : 0.275,
- "reason" : "Ins.Req:, 0.55, SMB Del.Ratio:, 0.5, autosens:, 1, autoISF disabled, Standard, COB: 0, Dev: 37, BGI: -4, CR: 12, Target: 95, minPredBG 125, minGuardBG 134, IOBpredBG 108, UAMpredBG 135; Eventual BG 135 >= 95, insulinReq 0.55; setting 30m low temp of 0.27U/h. Microbolusing 0.25U. ",
+ "reason" : "Ins.Req:, 0.55, SMB Del.Ratio:, 0.5, autosens:, 1, autoISF disabled, Standard, COB: 0, Dev: 37, BGI: -4, CR: 12, Target: 95, minPredBG 125, minGuardBG 134, IOBpredBG 108, UAMpredBG 135; Eventual BG 135 >= 95, insulinReq 0.55; setting 30m low temp of 0.63U/h. Microbolusing 0.25U. 30m left and 0.75 ~ req 0.625U/hr: no temp required",
"received" : false,
"sensitivityRatio" : 1,
"temp" : "absolute",
diff --git a/TrioTests/OpenAPSSwiftTests/Parity/goldens/dynamic-isf-sigmoid+autoisf-off/determination.json b/TrioTests/OpenAPSSwiftTests/Parity/goldens/dynamic-isf-sigmoid+autoisf-off/determination.json
index 091cbae08..740b1a824 100644
--- a/TrioTests/OpenAPSSwiftTests/Parity/goldens/dynamic-isf-sigmoid+autoisf-off/determination.json
+++ b/TrioTests/OpenAPSSwiftTests/Parity/goldens/dynamic-isf-sigmoid+autoisf-off/determination.json
@@ -16,6 +16,7 @@
"dura_ISFratio" : 1,
"dura_avg" : 1,
"dura_min" : 1,
+ "duration" : 30,
"eventualBG" : 125,
"expectedDelta" : -3.9,
"id" : "00000000-0000-0000-0000-000000000000",
@@ -166,7 +167,8 @@
98
]
},
- "reason" : "Ins.Req:, 0.4, SMB Del.Ratio:, 0.5, autosens:, 1.15, autoISF disabled, Standard, COB: 0, Dev: 56, BGI: -4, CR: 12, Target: 95, minPredBG 114, minGuardBG 125, IOBpredBG 104, UAMpredBG 125, Dynamic ISF: On, Sigmoid function, AF: 0.6, Basal ratio: 1.01; Eventual BG 125 >= 95, insulinReq 0.4; setting 30m low temp of 0.87U/h. Microbolusing 0.2U. 30m left and 0.75 ~ req 0.875U/hr: no temp required",
+ "rate" : 0.025,
+ "reason" : "Ins.Req:, 0.4, SMB Del.Ratio:, 0.5, autosens:, 1.15, autoISF disabled, Standard, COB: 0, Dev: 56, BGI: -4, CR: 12, Target: 95, minPredBG 114, minGuardBG 125, IOBpredBG 104, UAMpredBG 125, Dynamic ISF: On, Sigmoid function, AF: 0.6, Basal ratio: 1.01; Eventual BG 125 >= 95, insulinReq 0.4; setting 30m low temp of 0.03U/h. Microbolusing 0.2U. ",
"received" : false,
"sensitivityRatio" : 1.152552507138643740284,
"temp" : "absolute",
diff --git a/TrioTests/OpenAPSSwiftTests/Parity/goldens/override-active+autoisf-off/determination.json b/TrioTests/OpenAPSSwiftTests/Parity/goldens/override-active+autoisf-off/determination.json
index c17e47b59..5daa65394 100644
--- a/TrioTests/OpenAPSSwiftTests/Parity/goldens/override-active+autoisf-off/determination.json
+++ b/TrioTests/OpenAPSSwiftTests/Parity/goldens/override-active+autoisf-off/determination.json
@@ -16,7 +16,6 @@
"dura_ISFratio" : 1,
"dura_avg" : 1,
"dura_min" : 1,
- "duration" : 30,
"eventualBG" : 128,
"expectedDelta" : -4.1,
"id" : "00000000-0000-0000-0000-000000000000",
@@ -168,8 +167,7 @@
104
]
},
- "rate" : 0.375,
- "reason" : "Ins.Req:, 0.7, SMB Del.Ratio:, 0.5, autosens:, 1, autoISF disabled, Standard, COB: 0, Dev: 54, BGI: -3, CR: 12→9.2, Target: 90, minPredBG 120, minGuardBG 128, IOBpredBG 112, UAMpredBG 128; Eventual BG 128 >= 90, insulinReq 0.7; setting 30m low temp of 0.38U/h. Microbolusing 0.35U. ",
+ "reason" : "Ins.Req:, 0.7, SMB Del.Ratio:, 0.5, autosens:, 1, autoISF disabled, Standard, COB: 0, Dev: 54, BGI: -3, CR: 12→9.2, Target: 90, minPredBG 120, minGuardBG 128, IOBpredBG 112, UAMpredBG 128; Eventual BG 128 >= 90, insulinReq 0.7; setting 30m low temp of 0.77U/h. Microbolusing 0.35U. 30m left and 0.75 ~ req 0.775U/hr: no temp required",
"received" : false,
"sensitivityRatio" : 1,
"temp" : "absolute",
diff --git a/TrioTests/OpenAPSSwiftTests/Parity/goldens/post-meal-cob-active+autoisf-off/determination.json b/TrioTests/OpenAPSSwiftTests/Parity/goldens/post-meal-cob-active+autoisf-off/determination.json
index 42e6596e2..9fbf02159 100644
--- a/TrioTests/OpenAPSSwiftTests/Parity/goldens/post-meal-cob-active+autoisf-off/determination.json
+++ b/TrioTests/OpenAPSSwiftTests/Parity/goldens/post-meal-cob-active+autoisf-off/determination.json
@@ -216,8 +216,8 @@
104
]
},
- "rate" : 0.575,
- "reason" : "Ins.Req:, 0.825, SMB Del.Ratio:, 0.5, autosens:, 1, autoISF disabled, Standard, COB: 26, Dev: 67, BGI: -4, CR: 12, Target: 95, minPredBG 141, minGuardBG 141, IOBpredBG 114, COBpredBG 168, UAMpredBG 105; Eventual BG 168 >= 95, insulinReq 0.825; setting 30m low temp of 0.57U/h. Microbolusing 0.4U. ",
+ "rate" : 0.325,
+ "reason" : "Ins.Req:, 0.825, SMB Del.Ratio:, 0.5, autosens:, 1, autoISF disabled, Standard, COB: 26, Dev: 67, BGI: -4, CR: 12, Target: 95, minPredBG 141, minGuardBG 141, IOBpredBG 114, COBpredBG 168, UAMpredBG 105; Eventual BG 168 >= 95, insulinReq 0.825; setting 30m low temp of 0.33U/h. Microbolusing 0.4U. ",
"received" : false,
"sensitivityRatio" : 1,
"temp" : "absolute",
diff --git a/TrioTests/SettingsSearchTests.swift b/TrioTests/SettingsSearchTests.swift
index db2c1420e..a0715eb7c 100644
--- a/TrioTests/SettingsSearchTests.swift
+++ b/TrioTests/SettingsSearchTests.swift
@@ -3,6 +3,15 @@ import Testing
@testable import Trio
@Suite("Settings Search Navigation") struct SettingsSearchTests {
+ @Test("Lower threshold names and legacy aliases navigate to the renamed setting") func searchLowerRangeThreshold() {
+ for query in ["TIR Lower Threshold", "Standard", "Extended", "Time in Range Type", "TITR", "TING"] {
+ let results = SettingItems.filteredItems(searchText: query)
+ #expect(results.contains {
+ $0.settingItem.view == .userInterfaceSettings && $0.scrollLabel == "TIR Lower Threshold"
+ }, "No lower threshold search target for \(query)")
+ }
+ }
+
@Test("Searching 'Dynamic ISF' finds the Dynamic Settings screen") func searchDynamicISF() {
let results = SettingItems.filteredItems(searchText: "Dynamic ISF")
#expect(!results.isEmpty)
diff --git a/scripts/define_common_trio.sh b/scripts/define_common_trio.sh
index 88c13311a..287937cb8 100755
--- a/scripts/define_common_trio.sh
+++ b/scripts/define_common_trio.sh
@@ -23,7 +23,7 @@ TRIO_PROJECTS=( \
loopandlearn:DanaKit:dev \
loopandlearn:dexcom-share-client-swift:dev \
loopandlearn:EversenseKit:dev \
- loopandlearn:G7SensorKit:main \
+ loopandlearn:G7SensorKit:feat/trio-g7-direct \
loopandlearn:LibreLoop:legacy-loopkit-compat \
loopandlearn:LibreTransmitter:main \
loopandlearn:LoopKit:trio \
diff --git a/scripts/swiftformat.sh b/scripts/swiftformat.sh
index 75473ac13..7b0d467e3 100755
--- a/scripts/swiftformat.sh
+++ b/scripts/swiftformat.sh
@@ -124,5 +124,6 @@ trailingClosures \
--exclude RoundWhiteDiscKit \
--exclude LoopAlgorithm \
--exclude AccuChekKit \
---exclude EversenseKit
+--exclude EversenseKit \
+--exclude SyaiKit