From 5c225850201851d5a664a4bb036c2e9081d80d1d Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 25 Oct 2026 18:49:57 +0100 Subject: [PATCH 01/34] Add a watch-agnostic pipeline for state-changing watch commands Any watch transport can hand a parsed command to WatchCommandPipeline, which runs it at most once per app and request ID, rejects it outside a 2-minute freshness window (60 s clock lead), and keeps it revocable while it waits: a revocation epoch plus a feature gate are re-checked at every side-effect boundary through WatchCommandContext.finalCheck(). - WatchCommandPipeline, WatchCommandContext, WatchCommandAuthorization, WatchCommandResult and WatchCommandRejection - WatchCommandRequestCache keyed by app and request ID, content-checked so a reused request ID is a conflict, never evicting a running request - in_progress acknowledgment code for duplicates of a running request - content-free error categories for logs --- Trio.xcodeproj/project.pbxproj | 36 +++ Trio/Sources/Assemblies/ServiceAssembly.swift | 7 + .../Localizations/Main/Localizable.xcstrings | 15 + .../WatchCommand/WatchCommandPipeline.swift | 216 +++++++++++++++ .../WatchCommandRequestCache.swift | 114 ++++++++ .../WatchManager/AppleWatchManager.swift | 3 + .../WatchCommandPipelineTests.swift | 257 ++++++++++++++++++ .../WatchCommandRequestCacheTests.swift | 148 ++++++++++ .../WatchCommandTestDoubles.swift | 117 ++++++++ 9 files changed, 913 insertions(+) create mode 100644 Trio/Sources/Services/WatchCommand/WatchCommandPipeline.swift create mode 100644 Trio/Sources/Services/WatchCommand/WatchCommandRequestCache.swift create mode 100644 TrioTests/WatchCommandTests/WatchCommandPipelineTests.swift create mode 100644 TrioTests/WatchCommandTests/WatchCommandRequestCacheTests.swift create mode 100644 TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index cac7c4de35..744c5473c2 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 9A3CE4DF1D01738B99BBF795 /* WatchCommandPipelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C9B44D186FF8A7079468A /* WatchCommandPipelineTests.swift */; }; + 9A3C0ECD1617389255D168CF /* WatchCommandRequestCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C3CA768CF7D5036326306 /* WatchCommandRequestCacheTests.swift */; }; + 9A3C70FF5CD33060FE5626A7 /* WatchCommandTestDoubles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C3F1091C03368A75DD96E /* WatchCommandTestDoubles.swift */; }; + 9A3C3B50509CE7ABFE2E3D1D /* WatchCommandRequestCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3CA06D5CC8AB3491F284C1 /* WatchCommandRequestCache.swift */; }; + 9A3C76A08493CDC6FBF71373 /* WatchCommandPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C6BB18B32890597D63BD9 /* WatchCommandPipeline.swift */; }; 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, ); }; }; @@ -1239,6 +1244,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 9A3C9B44D186FF8A7079468A /* WatchCommandPipelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandPipelineTests.swift; sourceTree = ""; }; + 9A3C3CA768CF7D5036326306 /* WatchCommandRequestCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandRequestCacheTests.swift; sourceTree = ""; }; + 9A3C3F1091C03368A75DD96E /* WatchCommandTestDoubles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandTestDoubles.swift; sourceTree = ""; }; + 9A3CA06D5CC8AB3491F284C1 /* WatchCommandRequestCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandRequestCache.swift; sourceTree = ""; }; + 9A3C6BB18B32890597D63BD9 /* WatchCommandPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandPipeline.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 = ""; }; @@ -2433,6 +2443,25 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9A3C6118713FEDFA9AD437CA /* WatchCommandTests */ = { + isa = PBXGroup; + children = ( + 9A3C9B44D186FF8A7079468A /* WatchCommandPipelineTests.swift */, + 9A3C3CA768CF7D5036326306 /* WatchCommandRequestCacheTests.swift */, + 9A3C3F1091C03368A75DD96E /* WatchCommandTestDoubles.swift */, + ); + path = WatchCommandTests; + sourceTree = ""; + }; + 9A3CC598A206D8D945713F97 /* WatchCommand */ = { + isa = PBXGroup; + children = ( + 9A3CA06D5CC8AB3491F284C1 /* WatchCommandRequestCache.swift */, + 9A3C6BB18B32890597D63BD9 /* WatchCommandPipeline.swift */, + ); + path = WatchCommand; + sourceTree = ""; + }; 0610F7D6D2EC00E3BA1569F0 /* ConfigEditor */ = { isa = PBXGroup; children = ( @@ -2861,6 +2890,7 @@ 3811DEA525C9D88300A708ED /* UnlockManager */, 38E87406274F9AA500975559 /* UserNotifications */, 38E8754D275556E100975559 /* WatchManager */, + 9A3CC598A206D8D945713F97 /* WatchCommand */, ); path = Services; sourceTree = ""; @@ -3517,6 +3547,7 @@ B015AFF62E600000000D7351 /* AdjustmentsTests */, B015AFE62E500000000D7351 /* BolusSafetyTests */, BD8FC0552D66187700B95AED /* CoreDataTests */, + 9A3C6118713FEDFA9AD437CA /* WatchCommandTests */, 38FCF3F125E9028E0078B0D1 /* Info.plist */, CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */, CA02000000000000000010C1 /* DeliveryLimitsSyncTests.swift */, @@ -5767,6 +5798,8 @@ E00EEC0527368630002FF094 /* StorageAssembly.swift in Sources */, DD1745552C55CA6C00211FAC /* UnitsLimitsSettingsRootView.swift in Sources */, CE94597E29E9E1EE0047C9C6 /* GarminManager.swift in Sources */, + 9A3C3B50509CE7ABFE2E3D1D /* WatchCommandRequestCache.swift in Sources */, + 9A3C76A08493CDC6FBF71373 /* WatchCommandPipeline.swift in Sources */, 3883583425EEB38000E024B2 /* PumpSettings.swift in Sources */, 38DAB280260CBB7F00F74C1A /* PumpView.swift in Sources */, DDD1631C2C4C697400CD525A /* AddOverrideForm.swift in Sources */, @@ -6556,6 +6589,9 @@ 41740E936552456AAC0EDCC3 /* MaxIOBSuggestionTests.swift in Sources */, 41740E936552456AAC0EDBC3 /* DosingModeMigrationTests.swift in Sources */, B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */, + 9A3CE4DF1D01738B99BBF795 /* WatchCommandPipelineTests.swift in Sources */, + 9A3C0ECD1617389255D168CF /* WatchCommandRequestCacheTests.swift in Sources */, + 9A3C70FF5CD33060FE5626A7 /* WatchCommandTestDoubles.swift in Sources */, BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */, CA03000000000000000010C2 /* AlertCatalogRegistryOmniFaultTests.swift in Sources */, CD1235B000000000000000F2 /* TidepoolUploadSerializerTests.swift in Sources */, diff --git a/Trio/Sources/Assemblies/ServiceAssembly.swift b/Trio/Sources/Assemblies/ServiceAssembly.swift index a6a9f4fe62..e71708c2c9 100644 --- a/Trio/Sources/Assemblies/ServiceAssembly.swift +++ b/Trio/Sources/Assemblies/ServiceAssembly.swift @@ -32,6 +32,13 @@ final class ServiceAssembly: Assembly { .inObjectScope(.container) container.register(AdjustmentManager.self) { r in BaseAdjustmentManager(resolver: r) } .inObjectScope(.container) + container.register(WatchCommandAuthorization.self) { _ in WatchCommandAuthorization() } + .inObjectScope(.container) + // one instance, so its request-ID cache sees every watch request + container.register(WatchCommandPipeline.self) { r in + WatchCommandPipeline(authorization: r.resolve(WatchCommandAuthorization.self)!) + } + .inObjectScope(.container) container.register(TrioRemoteControl.self) { r in TrioRemoteControl(resolver: r) } .inObjectScope(.container) container.register(TelemetryClient.self) { r in TelemetryClient(resolver: r) } diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 0f2ab1b973..86e49f4cfb 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -1129,6 +1129,9 @@ } } }, + "Command expired. Please try again." : { + "comment" : "Watch command ack" + }, "Extended (%@)" : { "comment" : "Extended lower threshold for TIR and TITR statistics; placeholder includes glucose value and units.", "localizations" : { @@ -1162,6 +1165,12 @@ } } }, + "Request ID was already used." : { + "comment" : "Watch command ack" + }, + "Request is already in progress." : { + "comment" : "Watch command ack" + }, "TIR Lower Threshold" : { "comment" : "Setting for the lower glucose boundary shared by TIR and TITR statistics.", "localizations" : { @@ -1228,6 +1237,9 @@ } } }, + "Too many requests in progress. Please try again." : { + "comment" : "Watch command ack" + }, "Uses the established lower threshold of %@." : { "comment" : "Standard lower range threshold description; placeholder includes glucose value and units.", "localizations" : { @@ -1250,6 +1262,9 @@ } } }, + "Watch command settings changed." : { + "comment" : "Watch command ack" + }, " " : { "localizations" : { "bg" : { diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandPipeline.swift b/Trio/Sources/Services/WatchCommand/WatchCommandPipeline.swift new file mode 100644 index 0000000000..b287f8f808 --- /dev/null +++ b/Trio/Sources/Services/WatchCommand/WatchCommandPipeline.swift @@ -0,0 +1,216 @@ +import CoreData +import Foundation + +/// Terminal answer to a watch command, sent back to the requesting app only. +struct WatchCommandResult: Equatable { + let acknowledged: Bool + let ackCode: BaseWatchManager.AcknowledgmentCode + /// Short, user-facing text; never carries amounts or internal error details. + let message: String + + /// Something may have changed on the phone, so the watch needs fresh state. + var refreshesState: Bool { + acknowledged + } + + static func success(_ ackCode: BaseWatchManager.AcknowledgmentCode, _ message: String) -> WatchCommandResult { + WatchCommandResult(acknowledged: true, ackCode: ackCode, message: message) + } + + static func failure(_ message: String) -> WatchCommandResult { + WatchCommandResult(acknowledged: false, ackCode: .genericFailure, message: message) + } +} + +/// Thrown by a final check right before a side effect writes or issues anything; nothing was written. +struct WatchCommandRejection: Error { + let result: WatchCommandResult +} + +/// Revocation epoch for watch commands; a command admitted under an older epoch fails its next check. +final class WatchCommandAuthorization: @unchecked Sendable { + private let lock = NSLock() + private var epoch: UInt64 = 0 + + var current: UInt64 { lock.withLock { epoch } } + + /// Call on the writing queue before a permission-withdrawing setting is stored; settings + /// notifications are async and latest-value only, so they miss an off-then-on sequence. + func revoke() { + lock.withLock { epoch += 1 } + } +} + +/// Writes watch command log lines at their level and keeps the caller's location; tests replace `write`. +struct WatchCommandLogger: Sendable { + enum Level: Sendable { + case debug + case info + case warning + } + + var write: @Sendable(Level, String, String, String, UInt) -> Void + + static let app = WatchCommandLogger { level, message, file, function, line in + switch level { + case .debug: Trio.debug(.watch, message, file: file, function: function, line: line) + case .info: Trio.info(.watch, message, file: file, function: function, line: line) + case .warning: Trio.warning(.watch, message, file: file, function: function, line: line) + } + } + + func debug(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) { + write(.debug, message, file, function, line) + } + + func info(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) { + write(.info, message, file, function, line) + } + + func warning(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) { + write(.warning, message, file, function, line) + } +} + +/// The checks an admitted command re-runs at every side-effect boundary. +struct WatchCommandContext: Sendable { + let issuedAt: Date + let name: String + + fileprivate let grant: UInt64 + fileprivate let authorization: WatchCommandAuthorization + fileprivate let gate: @Sendable() -> WatchCommandResult? + fileprivate let now: @Sendable() -> Date + fileprivate let log: WatchCommandLogger + + /// Expiry is opt-in after admission: only waits that can outlast the window need it again. + func rejection(checkingExpiry: Bool = false) -> WatchCommandResult? { + if checkingExpiry, let expired = expiryRejection() { + return expired + } + guard authorization.current == grant else { + log.info("Watch \(name) command refused, command settings changed while it was pending") + return .failure(String(localized: "Watch command settings changed.", comment: "Watch command ack")) + } + return gate() + } + + /// Run synchronously in the side effect's last serialized hop, with nothing awaited before the + /// write or pump request, so a revocation cannot land between check and effect. + func finalCheck(checkingExpiry: Bool = false) -> @Sendable() throws -> Void { + { [self] in + if let rejection = rejection(checkingExpiry: checkingExpiry) { + throw WatchCommandRejection(result: rejection) + } + } + } + + private func expiryRejection() -> WatchCommandResult? { + let age = now().timeIntervalSince(issuedAt) + guard age <= WatchCommandPipeline.maximumCommandAge, age >= -WatchCommandPipeline.maximumCommandLead else { + log.info("Watch \(name) command refused, sent outside the 2 minute window") + return .failure(String(localized: "Command expired. Please try again.", comment: "Watch command ack")) + } + return nil + } +} + +/// Runs state-changing watch commands for any transport: at most once per app and request ID, +/// inside a freshness window, and revocable while pending. Transports parse; features execute. +final class WatchCommandPipeline { + /// Commands arrive within seconds; two minutes only keeps a late or replayed one from running. + static let maximumCommandAge: TimeInterval = 2 * 60 + /// Clock skew tolerance for watches that run slightly ahead of the phone. + static let maximumCommandLead: TimeInterval = 60 + + private let authorization: WatchCommandAuthorization + private let cache: WatchCommandRequestCache + private let now: @Sendable() -> Date + private let log: WatchCommandLogger + + init( + authorization: WatchCommandAuthorization, + cache: WatchCommandRequestCache = WatchCommandRequestCache(), + now: @escaping @Sendable() -> Date = { Date() }, + log: WatchCommandLogger = .app + ) { + self.authorization = authorization + self.cache = cache + self.now = now + self.log = log + } + + /// `command` identifies the request's content, so a reused request ID is caught; `gate` holds + /// the feature switches and is re-read with every check. + func run( + requestID: UUID, + appUUID: UUID, + issuedAt: Date, + command: AnyHashable, + name: String, + gate: @escaping @Sendable() -> WatchCommandResult? = { nil }, + execute: (WatchCommandContext) async -> WatchCommandResult + ) async -> WatchCommandResult { + let key = WatchCommandRequestCache.Key(appUUID: appUUID, requestID: requestID) + // read before admission, so a revocation while queued is still seen + let grant = authorization.current + + let ticket: WatchCommandRequestCache.Ticket + switch await cache.admit(key, command: command, now: now()) { + case let .accepted(admitted): + ticket = admitted + case let .completed(result): + log.debug("Watch \(name) command repeated, sending the earlier result again") + return result + case .inProgress: + log.debug("Watch \(name) command repeated while the first one is still running") + return WatchCommandResult( + acknowledged: false, + ackCode: .inProgress, + message: String(localized: "Request is already in progress.", comment: "Watch command ack") + ) + case .conflict: + log.warning("Watch \(name) command refused, its request ID was already used for another command") + return .failure(String(localized: "Request ID was already used.", comment: "Watch command ack")) + case .full: + log.warning("Watch \(name) command refused, too many commands are running") + return .failure(String(localized: "Too many requests in progress. Please try again.", comment: "Watch command ack")) + } + + let context = WatchCommandContext( + issuedAt: issuedAt, + name: name, + grant: grant, + authorization: authorization, + gate: gate, + now: now, + log: log + ) + let result: WatchCommandResult + if let rejection = context.rejection(checkingExpiry: true) { + result = rejection + } else { + result = await execute(context) + } + await cache.complete(ticket, result: result) + + log.info("Watch \(name) command \(result.acknowledged ? "done" : "not done") (\(result.ackCode.rawValue))") + return result + } +} + +/// Stable, content-free labels for errors: Core Data errors can carry treatment values or store paths. +enum WatchCommandErrorCategory { + static func name(for error: Error) -> String { + switch error { + case is CoreDataError: + return "coreData" + case is CancellationError: + return "cancelled" + default: + let nsError = error as NSError + // the Cocoa code identifies Core Data failures without their user info + return nsError.domain == NSCocoaErrorDomain ? "cocoa(\(nsError.code))" : "unexpected" + } + } +} diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandRequestCache.swift b/Trio/Sources/Services/WatchCommand/WatchCommandRequestCache.swift new file mode 100644 index 0000000000..d378733342 --- /dev/null +++ b/Trio/Sources/Services/WatchCommand/WatchCommandRequestCache.swift @@ -0,0 +1,114 @@ +import Foundation + +/// Remembers recent watch request IDs so a relayed or retried command executes at most once. +/// In memory only: after an app restart the freshness window is what stops a replay. +actor WatchCommandRequestCache { + struct Key: Hashable { + let appUUID: UUID + let requestID: UUID + } + + /// Proof of one admission; only its holder may complete the entry it reserved. + struct Ticket: Equatable { + let key: Key + let generation: UInt64 + } + + enum Admission: Equatable { + /// First sighting; the caller must execute and then call `complete` with the ticket. + case accepted(Ticket) + case inProgress + /// The request ID was already used for a different command. + case conflict + case completed(WatchCommandResult) + /// Every slot holds an executing request, and those are never evicted. + case full + } + + private enum State { + case inFlight(generation: UInt64) + case completed(WatchCommandResult) + } + + private struct Entry { + let command: AnyHashable + var state: State + let insertedAt: Date + var lastUsed: UInt64 + + var isInFlight: Bool { + if case .inFlight = state { return true } + return false + } + } + + /// Must exceed the distinct requests one freshness window can bring; an evicted entry's repeat would run again. + static let defaultCapacity = 256 + /// Must outlast the freshness window (2 min plus 60 s lead); longer so a late relayed repeat gets its real result. + static let defaultLifetime: TimeInterval = 15 * 60 + + private let capacity: Int + private let lifetime: TimeInterval + + private var entries: [Key: Entry] = [:] + /// Monotonic use counter; a clock would tie when several requests arrive within one tick. + private var useCounter: UInt64 = 0 + private var generationCounter: UInt64 = 0 + + init(capacity: Int = defaultCapacity, lifetime: TimeInterval = defaultLifetime) { + self.capacity = capacity + self.lifetime = lifetime + } + + var count: Int { entries.count } + + /// Reserves `key` before anything executes, or reports why it must not run now. + func admit(_ key: Key, command: AnyHashable, now: Date) -> Admission { + evictExpired(now: now) + useCounter += 1 + + if var entry = entries[key] { + guard entry.command == command else { return .conflict } + entry.lastUsed = useCounter + entries[key] = entry + switch entry.state { + case .inFlight: return .inProgress + case let .completed(result): return .completed(result) + } + } + + if entries.count >= capacity { + // evicting a running request would let its duplicate execute a second time + guard let leastRecentlyUsed = entries.filter({ !$0.value.isInFlight }) + .min(by: { $0.value.lastUsed < $1.value.lastUsed })?.key + else { return .full } + entries[leastRecentlyUsed] = nil + } + + generationCounter += 1 + let ticket = Ticket(key: key, generation: generationCounter) + entries[key] = Entry( + command: command, + state: .inFlight(generation: ticket.generation), + insertedAt: now, + lastUsed: useCounter + ) + return .accepted(ticket) + } + + /// Ignored unless `ticket` still owns the reservation, so a stale execution cannot overwrite + /// the result of a newer admission under the same key. + func complete(_ ticket: Ticket, result: WatchCommandResult) { + guard var entry = entries[ticket.key], + case let .inFlight(generation) = entry.state, + generation == ticket.generation + else { return } + entry.state = .completed(result) + entries[ticket.key] = entry + } + + /// Executing entries outlive their lifetime until they complete. + private func evictExpired(now: Date) { + entries = entries.filter { $0.value.isInFlight || now.timeIntervalSince($0.value.insertedAt) < lifetime } + } +} diff --git a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift index cc2743603a..5b14c9fb58 100644 --- a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift +++ b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift @@ -1164,5 +1164,8 @@ extension BaseWatchManager { case tempTargetStopped = "temp_target_stopped" case genericSuccess = "success" case genericFailure = "failure" + /// A duplicate of a request that is still running; that request's own ack follows. + /// Not a failure, although `acknowledged` is false. + case inProgress = "in_progress" } } diff --git a/TrioTests/WatchCommandTests/WatchCommandPipelineTests.swift b/TrioTests/WatchCommandTests/WatchCommandPipelineTests.swift new file mode 100644 index 0000000000..37774ec5f3 --- /dev/null +++ b/TrioTests/WatchCommandTests/WatchCommandPipelineTests.swift @@ -0,0 +1,257 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Watch Command Pipeline Tests") struct WatchCommandPipelineTests { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = TestClock(Date(timeIntervalSince1970: 1_800_000_000)) + let appUUID = UUID() + let authorization = WatchCommandAuthorization() + let cache = WatchCommandRequestCache() + let logs = LogCapture() + let executions = ExecutionCounter() + let done = WatchCommandResult.success(.genericSuccess, "Done.") + let pipeline: WatchCommandPipeline + + init() { + pipeline = WatchCommandPipeline( + authorization: authorization, + cache: cache, + now: { [clock] in clock.now }, + log: logs.logger + ) + } + + private func run( + _ command: String = "carbs:10", + id: UUID = UUID(), + app: UUID? = nil, + age: TimeInterval = 5, + on pipeline: WatchCommandPipeline? = nil, + gate: @escaping @Sendable() -> WatchCommandResult? = { nil }, + execute: ((WatchCommandContext) async -> WatchCommandResult)? = nil + ) async -> WatchCommandResult { + await (pipeline ?? self.pipeline).run( + requestID: id, + appUUID: app ?? appUUID, + issuedAt: now.addingTimeInterval(-age), + command: command, + name: "carbs", + gate: gate + ) { context in + executions.increment() + guard let execute else { return done } + return await execute(context) + } + } + + /// Executes like a side effect: parks on `gate`, then runs the final check right before writing. + private func guardedWrite( + parkingOn gate: TestGate, + checkingExpiry: Bool = false + ) -> (WatchCommandContext) async -> WatchCommandResult { + { [done] context in + await gate.pass() + do { + try context.finalCheck(checkingExpiry: checkingExpiry)() + return done + } catch let rejection as WatchCommandRejection { + return rejection.result + } catch { + return .failure("unexpected") + } + } + } + + // MARK: - Registration + + @Test("Pipeline and authorization are registered once for the whole app") func testContainerScoped() { + let resolver = TrioApp().resolver + #expect(resolver.resolve(WatchCommandPipeline.self) === resolver.resolve(WatchCommandPipeline.self)) + #expect(resolver.resolve(WatchCommandAuthorization.self) === resolver.resolve(WatchCommandAuthorization.self)) + } + + // MARK: - Freshness + + @Test( + "Timestamps are accepted from 2 minutes old to 60 seconds ahead", + arguments: [ + (TimeInterval(120), true), + (TimeInterval(120.001), false), + (TimeInterval(-60), true), + (TimeInterval(-60.001), false), + (TimeInterval(0), true) + ] + ) func testFreshnessWindow(age: TimeInterval, accepted: Bool) async { + let result = await run(age: age) + + #expect(result.acknowledged == accepted) + #expect(executions.value == (accepted ? 1 : 0)) + } + + @Test("Expiry is re-checked only where the side effect asks for it") func testExpiryRecheckIsOptIn() async { + let gate = TestGate() + let plain = Task { await run(age: 100, execute: guardedWrite(parkingOn: gate)) } + let strict = Task { await run(age: 100, execute: guardedWrite(parkingOn: gate, checkingExpiry: true)) } + await gate.waitForArrivals(2) + clock.advance(by: 30) + gate.open() + + #expect(await plain.value.acknowledged) + #expect(await strict.value.message == "Command expired. Please try again.") + } + + // MARK: - Gates & Revocation + + @Test("A closed gate rejects before execution") func testGateRejects() async { + let result = await run(gate: { .failure("Off.") }) + + #expect(result == .failure("Off.")) + #expect(executions.value == 0) + } + + @Test("The gate is re-read at the final check") func testGateRereadAtFinalCheck() async { + let isOff = TestFlag() + let gate = TestGate() + let pending = Task { await run(gate: { isOff.value ? .failure("Off.") : nil }, execute: guardedWrite(parkingOn: gate)) } + await gate.waitForArrivals() + isOff.set() + gate.open() + + #expect(await pending.value == .failure("Off.")) + } + + @Test("A revocation while pending fails the final check, even if switched back on") func testRevokedWhilePending() async { + let gate = TestGate() + let pending = Task { await run(execute: guardedWrite(parkingOn: gate)) } + await gate.waitForArrivals() + authorization.revoke() + gate.open() + + let result = await pending.value + #expect(!result.acknowledged) + #expect(result.message == "Watch command settings changed.") + #expect(await run().acknowledged, "A command admitted after the revocation runs") + } + + // MARK: - Deduplication + + @Test("A completed duplicate replays its result without executing again") func testCompletedDuplicate() async { + let id = UUID() + let first = await run(id: id) + let second = await run(id: id) + + #expect(first == second) + #expect(executions.value == 1) + } + + @Test("Rejections are cached as terminal results too") func testRejectionCached() async { + let id = UUID() + let first = await run(id: id, gate: { .failure("Off.") }) + let second = await run(id: id) + + #expect(first == second) + #expect(executions.value == 0) + } + + @Test("A duplicate of an in-flight request is rejected with the in-progress code") func testInFlightDuplicate() async { + let gate = TestGate() + let id = UUID() + let pending = Task { await run(id: id, execute: guardedWrite(parkingOn: gate)) } + await gate.waitForArrivals() + + let duplicate = await run(id: id) + gate.open() + + #expect(!duplicate.acknowledged) + #expect(duplicate.ackCode == .inProgress, "Watches tell a running duplicate apart by code, not by text") + #expect(await pending.value.acknowledged) + #expect(executions.value == 1) + } + + @Test("Reusing a request ID for another command is rejected") func testConflict() async { + let id = UUID() + _ = await run("carbs:10", id: id) + let result = await run("carbs:20", id: id) + + #expect(!result.acknowledged) + #expect(executions.value == 1) + } + + @Test("Request IDs are scoped per app") func testScopedPerApp() async { + let id = UUID() + _ = await run(id: id) + _ = await run(id: id, app: UUID()) + + #expect(executions.value == 2) + } + + @Test("A cache full of running requests rejects instead of evicting one") func testFull() async { + let small = WatchCommandPipeline( + authorization: authorization, + cache: WatchCommandRequestCache(capacity: 1), + now: { [clock] in clock.now } + ) + let gate = TestGate() + let pending = Task { await run(on: small, execute: guardedWrite(parkingOn: gate)) } + await gate.waitForArrivals() + + let rejected = await run(on: small) + gate.open() + + #expect(rejected.message == "Too many requests in progress. Please try again.") + #expect(await pending.value.acknowledged) + } + + @Test("An expired request replays its cached result without re-running") func testExpiredReplay() async { + let id = UUID() + let first = await run(id: id, age: WatchCommandPipeline.maximumCommandAge - 5) + clock.advance(by: 10) + + #expect(await run(id: id, age: WatchCommandPipeline.maximumCommandAge - 5) == first) + #expect(executions.value == 1) + } + + // MARK: - Safe Logging + + @Test("Outcomes are logged at info and refusals of misuse at warning") func testLogLevels() async { + _ = await run() + let id = UUID() + _ = await run("carbs:10", id: id) + _ = await run("carbs:20", id: id) + + #expect(logs.entries.contains { $0.level == .info && $0.line == "Watch carbs command done (success)" }) + #expect(logs.entries.contains { $0.level == .warning && $0.line.contains("request ID was already used") }) + } + + @Test("Error categories are stable and content-free") func testErrorCategories() { + #expect(WatchCommandErrorCategory.name(for: CoreDataError.fetchError(function: "f", file: "g")) == "coreData") + #expect(WatchCommandErrorCategory.name(for: CancellationError()) == "cancelled") + let saveError = NSError(domain: NSCocoaErrorDomain, code: 134_030, userInfo: ["detail": SensitiveTestError.marker]) + #expect(WatchCommandErrorCategory.name(for: saveError) == "cocoa(134030)") + #expect(WatchCommandErrorCategory.name(for: SensitiveTestError()) == "unexpected") + } +} + +final class ExecutionCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { lock.withLock { count } } + + func increment() { + lock.withLock { count += 1 } + } +} + +final class TestFlag: @unchecked Sendable { + private let lock = NSLock() + private var isSet = false + + var value: Bool { lock.withLock { isSet } } + + func set() { + lock.withLock { isSet = true } + } +} diff --git a/TrioTests/WatchCommandTests/WatchCommandRequestCacheTests.swift b/TrioTests/WatchCommandTests/WatchCommandRequestCacheTests.swift new file mode 100644 index 0000000000..c764b5ae8d --- /dev/null +++ b/TrioTests/WatchCommandTests/WatchCommandRequestCacheTests.swift @@ -0,0 +1,148 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Watch Command Request Cache Tests") struct WatchCommandRequestCacheTests { + let appUUID = UUID() + let start = Date(timeIntervalSince1970: 1_800_000_000) + let done = WatchCommandResult.success(.overrideStopped, "Override stopped.") + + private func key(_ requestID: UUID = UUID(), app: UUID? = nil) -> WatchCommandRequestCache.Key { + WatchCommandRequestCache.Key(appUUID: app ?? appUUID, requestID: requestID) + } + + private func ticket(_ admission: WatchCommandRequestCache.Admission) -> WatchCommandRequestCache.Ticket? { + guard case let .accepted(ticket) = admission else { return nil } + return ticket + } + + private func admitted( + _ cache: WatchCommandRequestCache, + _ key: WatchCommandRequestCache.Key, + _ command: AnyHashable, + at now: Date + ) async throws -> WatchCommandRequestCache.Ticket { + let admission = await cache.admit(key, command: command, now: now) + return try #require(ticket(admission)) + } + + @Test("A request stays in flight until completed, then replays its result") func testInFlightThenCompleted() async throws { + let cache = WatchCommandRequestCache() + let key = key() + let result = WatchCommandResult.success(.carbsLogged, "Carbs logged.") + + let reservation = try await admitted(cache, key, "carbs:20", at: start) + #expect(await cache.admit(key, command: "carbs:20", now: start) == .inProgress) + + await cache.complete(reservation, result: result) + #expect(await cache.admit(key, command: "carbs:20", now: start) == .completed(result)) + } + + @Test("Reusing a request ID for another command is a conflict") func testConflict() async throws { + let cache = WatchCommandRequestCache() + let key = key() + + _ = try await admitted(cache, key, "carbs:20", at: start) + #expect(await cache.admit(key, command: "carbs:25", now: start) == .conflict) + #expect(await cache.admit(key, command: "cancelOverride", now: start) == .conflict) + } + + @Test("The same request ID from another app is a separate request") func testScopedByApp() async { + let cache = WatchCommandRequestCache() + let requestID = UUID() + + #expect(ticket(await cache.admit(key(requestID), command: "cancelOverride", now: start)) != nil) + #expect(ticket(await cache.admit(key(requestID, app: UUID()), command: "cancelOverride", now: start)) != nil) + } + + @Test("Completed entries expire after their lifetime") func testExpiry() async throws { + let cache = WatchCommandRequestCache(capacity: 256, lifetime: 15 * 60) + let key = key() + + let reservation = try await admitted(cache, key, "cancelOverride", at: start) + await cache.complete(reservation, result: done) + #expect(await cache.admit(key, command: "cancelOverride", now: start.addingTimeInterval(15 * 60 - 1)) == .completed(done)) + #expect(ticket(await cache.admit(key, command: "cancelOverride", now: start.addingTimeInterval(15 * 60))) != nil) + } + + @Test("An executing entry outlives its lifetime") func testInFlightNeverExpires() async throws { + let cache = WatchCommandRequestCache(capacity: 256, lifetime: 15 * 60) + let key = key() + + _ = try await admitted(cache, key, "cancelOverride", at: start) + #expect(await cache.admit(key, command: "cancelOverride", now: start.addingTimeInterval(60 * 60)) == .inProgress) + } + + @Test("A full cache evicts the least recently used completed entry") func testLeastRecentlyUsedEviction() async throws { + let cache = WatchCommandRequestCache(capacity: 2, lifetime: 15 * 60) + let first = key() + let second = key() + + try await cache.complete(admitted(cache, first, "cancelOverride", at: start), result: done) + try await cache.complete(admitted(cache, second, "cancelOverride", at: start), result: done) + // touching the first entry makes the second the least recently used + #expect(await cache.admit(first, command: "cancelOverride", now: start) == .completed(done)) + #expect(ticket(await cache.admit(key(), command: "cancelOverride", now: start)) != nil) + + #expect(await cache.count == 2) + #expect(await cache.admit(first, command: "cancelOverride", now: start) == .completed(done)) + #expect(ticket(await cache.admit(second, command: "cancelOverride", now: start)) != nil, "Second was evicted") + } + + @Test("Executing entries are never evicted; a full cache rejects instead") func testCapacityKeepsInFlight() async throws { + let cache = WatchCommandRequestCache(capacity: 2, lifetime: 15 * 60) + let first = key() + let second = key() + + let firstTicket = try await admitted(cache, first, "bolus:1", at: start) + _ = try await admitted(cache, second, "bolus:2", at: start) + + #expect(await cache.admit(key(), command: "bolus:3", now: start) == .full) + // a replay of a running request is still recognized, never re-admitted + #expect(await cache.admit(first, command: "bolus:1", now: start) == .inProgress) + #expect(await cache.admit(first, command: "bolus:5", now: start) == .conflict) + + await cache.complete(firstTicket, result: done) + #expect(ticket(await cache.admit(key(), command: "bolus:3", now: start)) != nil, "A completed slot frees up") + #expect(await cache.admit(second, command: "bolus:2", now: start) == .inProgress) + } + + @Test("A stale execution cannot complete a newer admission of the same key") func testStaleCompletion() async throws { + let cache = WatchCommandRequestCache(capacity: 256, lifetime: 15 * 60) + let key = key() + let stale = WatchCommandResult.failure("stale") + let fresh = WatchCommandResult.success(.carbsLogged, "fresh") + + let oldTicket = try await admitted(cache, key, "carbs:10", at: start) + await cache.complete(oldTicket, result: stale) + let later = start.addingTimeInterval(15 * 60) + let newTicket = try await admitted(cache, key, "carbs:10", at: later) + #expect(newTicket != oldTicket) + + await cache.complete(oldTicket, result: stale) + #expect(await cache.admit(key, command: "carbs:10", now: later) == .inProgress) + + await cache.complete(newTicket, result: fresh) + #expect(await cache.admit(key, command: "carbs:10", now: later) == .completed(fresh)) + await cache.complete(oldTicket, result: stale) + #expect(await cache.admit(key, command: "carbs:10", now: later) == .completed(fresh), "Completed results are final") + } + + @Test("Expired entries are evicted before live ones") func testExpiredEvictedFirst() async throws { + let cache = WatchCommandRequestCache(capacity: 2, lifetime: 15 * 60) + let old = key() + let recent = key() + + try await cache.complete(admitted(cache, old, "cancelOverride", at: start), result: done) + _ = await cache.admit(recent, command: "cancelOverride", now: start.addingTimeInterval(10 * 60)) + #expect(ticket(await cache.admit(key(), command: "cancelOverride", now: start.addingTimeInterval(16 * 60))) != nil) + + #expect(await cache.admit(recent, command: "cancelOverride", now: start.addingTimeInterval(16 * 60)) == .inProgress) + } + + @Test("Default limits are 256 entries for 15 minutes") func testDefaults() { + #expect(WatchCommandRequestCache.defaultCapacity == 256) + #expect(WatchCommandRequestCache.defaultLifetime == 15 * 60) + } +} diff --git a/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift b/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift new file mode 100644 index 0000000000..57950d35ef --- /dev/null +++ b/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift @@ -0,0 +1,117 @@ +import Foundation +@testable import Trio + +/// Parks async calls until the test opens it, and lets the test wait until calls have arrived, +/// so suspended-operation tests do not depend on scheduling luck. +final class TestGate: @unchecked Sendable { + private let lock = NSLock() + private var isOpen: Bool + private var arrivals = 0 + private var parked: [CheckedContinuation] = [] + private var arrivalWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + init(isOpen: Bool = false) { + self.isOpen = isOpen + } + + var arrivalCount: Int { lock.withLock { arrivals } } + + func pass() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + lock.lock() + arrivals += 1 + let reached = arrivalWaiters.filter { $0.count <= arrivals } + arrivalWaiters.removeAll { $0.count <= arrivals } + if isOpen { + lock.unlock() + continuation.resume() + } else { + parked.append(continuation) + lock.unlock() + } + reached.forEach { $0.continuation.resume() } + } + } + + /// Counts an arrival from synchronous code without parking it. + func arrive() { + lock.lock() + arrivals += 1 + let reached = arrivalWaiters.filter { $0.count <= arrivals } + arrivalWaiters.removeAll { $0.count <= arrivals } + lock.unlock() + reached.forEach { $0.continuation.resume() } + } + + func waitForArrivals(_ count: Int = 1) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + lock.lock() + if arrivals >= count { + lock.unlock() + continuation.resume() + } else { + arrivalWaiters.append((count, continuation)) + lock.unlock() + } + } + } + + func open() { + lock.lock() + isOpen = true + let waiting = parked + parked = [] + lock.unlock() + waiting.forEach { $0.resume() } + } +} + +/// A clock the test moves forward by hand, so expiry during a wait needs no real sleep. +final class TestClock: @unchecked Sendable { + private let lock = NSLock() + private var date: Date + + init(_ date: Date) { + self.date = date + } + + var now: Date { lock.withLock { date } } + + func advance(by interval: TimeInterval) { + lock.withLock { date = date.addingTimeInterval(interval) } + } +} + +/// Polls `condition` for up to two seconds; for state that has no callback to await. +func eventually(_ condition: () async -> Bool) async -> Bool { + for _ in 0 ..< 400 { + if await condition() { return true } + try? await Task.sleep(nanoseconds: 5_000_000) + } + return await condition() +} + +/// Error whose every description carries a marker that must never reach a log or an ack. +struct SensitiveTestError: LocalizedError, CustomStringConvertible { + static let marker = "SENSITIVE-7f3a" + + var description: String { "carbs=42g bolus=3.5U \(Self.marker)" } + var errorDescription: String? { description } +} + +/// Collects log lines from the injectable log sinks. +final class LogCapture: @unchecked Sendable { + private let lock = NSLock() + private var storage: [(level: WatchCommandLogger.Level, line: String)] = [] + + var lines: [String] { lock.withLock { storage.map(\.line) } } + var entries: [(level: WatchCommandLogger.Level, line: String)] { lock.withLock { storage } } + + var logger: WatchCommandLogger { + WatchCommandLogger { [self] level, line, _, _, _ in record(line, level: level) } + } + + func record(_ line: String, level: WatchCommandLogger.Level = .debug) { + lock.withLock { storage.append((level, line)) } + } +} From 682b0886efd798c148d9897d903a17ed89d53d44 Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 25 Oct 2026 18:56:13 +0100 Subject: [PATCH 02/34] Serve the Garmin Complication glance and deliver by send result Watch side of the Complication glance: Connect IQ messages go through GarminMessageRouter, which answers v1 status requests and triggers a state refresh; nothing else goes through it yet. Delivery no longer asks the watch for app status before every send. That round trip cost the watch a BLE exchange per app per broadcast, and its completions were unreliable enough that a readiness probe built on them retried around the clock. Instead: - every app gets one send per broadcast, unless it already confirmed the same state; skips are logged per app - the send result is the readiness signal: success clears what the app was owed, AppNotFound marks it absent without a resend, other failures keep the single 2 s resend and then leave the state owed - owed state goes out with the next broadcast, a reconnect or the next message from that app; no timers - only a lost connection gates sends; readiness never does, so a missed characteristics callback cannot block delivery Also routes Connect IQ calls through GarminConnectIQClient so delivery is testable without a watch, and fixes the stale 20 s fallback comments (the delay is 10 s). --- Trio.xcodeproj/project.pbxproj | 12 + Trio/Sources/Models/WatchMessageKeys.swift | 4 + .../Services/WatchManager/GarminManager.swift | 311 +++++++++++++----- .../WatchManager/GarminMessageRouter.swift | 96 ++++++ .../GarminManagerIntegrationTests.swift | 224 +++++++++++++ .../GarminMessageRouterTests.swift | 58 ++++ 6 files changed, 620 insertions(+), 85 deletions(-) create mode 100644 Trio/Sources/Services/WatchManager/GarminMessageRouter.swift create mode 100644 TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift create mode 100644 TrioTests/WatchCommandTests/GarminMessageRouterTests.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 744c5473c2..b49baf4ebe 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 9A3C034686E6B4AC8CE5E7D8 /* GarminManagerIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C4DF6510DE4DB0E1F1F3A /* GarminManagerIntegrationTests.swift */; }; + 9A3CF45C4133651CC90484F5 /* GarminMessageRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0B63D2B89008769E452C /* GarminMessageRouterTests.swift */; }; + 9A3CA6DBA84BDF295314EAC5 /* GarminMessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3CF86D09F46856D09A2ED2 /* GarminMessageRouter.swift */; }; 9A3CE4DF1D01738B99BBF795 /* WatchCommandPipelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C9B44D186FF8A7079468A /* WatchCommandPipelineTests.swift */; }; 9A3C0ECD1617389255D168CF /* WatchCommandRequestCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C3CA768CF7D5036326306 /* WatchCommandRequestCacheTests.swift */; }; 9A3C70FF5CD33060FE5626A7 /* WatchCommandTestDoubles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C3F1091C03368A75DD96E /* WatchCommandTestDoubles.swift */; }; @@ -1244,6 +1247,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 9A3C4DF6510DE4DB0E1F1F3A /* GarminManagerIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminManagerIntegrationTests.swift; sourceTree = ""; }; + 9A3C0B63D2B89008769E452C /* GarminMessageRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminMessageRouterTests.swift; sourceTree = ""; }; + 9A3CF86D09F46856D09A2ED2 /* GarminMessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminMessageRouter.swift; sourceTree = ""; }; 9A3C9B44D186FF8A7079468A /* WatchCommandPipelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandPipelineTests.swift; sourceTree = ""; }; 9A3C3CA768CF7D5036326306 /* WatchCommandRequestCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandRequestCacheTests.swift; sourceTree = ""; }; 9A3C3F1091C03368A75DD96E /* WatchCommandTestDoubles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandTestDoubles.swift; sourceTree = ""; }; @@ -2446,6 +2452,8 @@ 9A3C6118713FEDFA9AD437CA /* WatchCommandTests */ = { isa = PBXGroup; children = ( + 9A3C4DF6510DE4DB0E1F1F3A /* GarminManagerIntegrationTests.swift */, + 9A3C0B63D2B89008769E452C /* GarminMessageRouterTests.swift */, 9A3C9B44D186FF8A7079468A /* WatchCommandPipelineTests.swift */, 9A3C3CA768CF7D5036326306 /* WatchCommandRequestCacheTests.swift */, 9A3C3F1091C03368A75DD96E /* WatchCommandTestDoubles.swift */, @@ -3505,6 +3513,7 @@ isa = PBXGroup; children = ( BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */, + 9A3CF86D09F46856D09A2ED2 /* GarminMessageRouter.swift */, CE94597D29E9E1EE0047C9C6 /* GarminManager.swift */, ); path = WatchManager; @@ -5798,6 +5807,7 @@ E00EEC0527368630002FF094 /* StorageAssembly.swift in Sources */, DD1745552C55CA6C00211FAC /* UnitsLimitsSettingsRootView.swift in Sources */, CE94597E29E9E1EE0047C9C6 /* GarminManager.swift in Sources */, + 9A3CA6DBA84BDF295314EAC5 /* GarminMessageRouter.swift in Sources */, 9A3C3B50509CE7ABFE2E3D1D /* WatchCommandRequestCache.swift in Sources */, 9A3C76A08493CDC6FBF71373 /* WatchCommandPipeline.swift in Sources */, 3883583425EEB38000E024B2 /* PumpSettings.swift in Sources */, @@ -6589,6 +6599,8 @@ 41740E936552456AAC0EDCC3 /* MaxIOBSuggestionTests.swift in Sources */, 41740E936552456AAC0EDBC3 /* DosingModeMigrationTests.swift in Sources */, B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */, + 9A3C034686E6B4AC8CE5E7D8 /* GarminManagerIntegrationTests.swift in Sources */, + 9A3CF45C4133651CC90484F5 /* GarminMessageRouterTests.swift in Sources */, 9A3CE4DF1D01738B99BBF795 /* WatchCommandPipelineTests.swift in Sources */, 9A3C0ECD1617389255D168CF /* WatchCommandRequestCacheTests.swift in Sources */, 9A3C70FF5CD33060FE5626A7 /* WatchCommandTestDoubles.swift in Sources */, diff --git a/Trio/Sources/Models/WatchMessageKeys.swift b/Trio/Sources/Models/WatchMessageKeys.swift index 68cac8b00d..ef37c64ce0 100644 --- a/Trio/Sources/Models/WatchMessageKeys.swift +++ b/Trio/Sources/Models/WatchMessageKeys.swift @@ -63,4 +63,8 @@ enum WatchMessageKeys { static let forecastConeMin = "forecastConeMin" static let forecastConeMax = "forecastConeMax" static let forecastLines = "forecastLines" + + // Garmin v1 Envelope Keys + static let protocolVersion = "v" + static let request = "req" } diff --git a/Trio/Sources/Services/WatchManager/GarminManager.swift b/Trio/Sources/Services/WatchManager/GarminManager.swift index c811f8239c..a87da089ef 100644 --- a/Trio/Sources/Services/WatchManager/GarminManager.swift +++ b/Trio/Sources/Services/WatchManager/GarminManager.swift @@ -27,6 +27,30 @@ protocol GarminManager { var devices: [IQDevice] { get } } +// MARK: - Connect IQ Client + +/// The Connect IQ calls that register and reach watch apps, behind a protocol so delivery can be tested +/// without a watch. There is deliberately no app-status call: each one is a BLE round trip to the watch. +protocol GarminConnectIQClient: AnyObject { + func registerDevice(_ device: IQDevice, delegate: IQDeviceEventDelegate) + func registerApp(_ app: IQApp, delegate: IQAppMessageDelegate) + func send(_ message: Any, to app: IQApp, completion: @escaping (IQSendMessageResult) -> Void) +} + +extension ConnectIQ: GarminConnectIQClient { + func registerDevice(_ device: IQDevice, delegate: IQDeviceEventDelegate) { + register(forDeviceEvents: device, delegate: delegate) + } + + func registerApp(_ app: IQApp, delegate: IQAppMessageDelegate) { + register(forAppMessages: app, delegate: delegate) + } + + func send(_ message: Any, to app: IQApp, completion: @escaping (IQSendMessageResult) -> Void) { + sendMessage(message, to: app, progress: { _, _ in }, completion: completion) + } +} + // MARK: - BaseGarminManager /// Concrete implementation of `GarminManager` that handles: @@ -69,6 +93,14 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { /// Garmin ConnectIQ shared instance for watch interactions. private let connectIQ = ConnectIQ.sharedInstance() + /// Registers and messages watch apps; the shared `connectIQ` unless a test injects a spy. + private let connectIQClient: GarminConnectIQClient? + + /// Wait before the single resend of a failed message. + private let sendRetryDelay: TimeInterval + + private let broadcastDebounce: DispatchQueue.SchedulerTimeType.Stride + /// Keeps references to watch apps (both watchface & data field) for each registered device. private var watchApps: [IQApp] = [] @@ -81,6 +113,9 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { /// Subject for debouncing watch state updates private let watchStateSubject = PassthroughSubject() + /// Publishes the trigger name of every watch state refresh that is started. + let stateRefreshTriggers = PassthroughSubject() + /// Current glucose units, either mg/dL or mmol/L, read from user settings. private var units: GlucoseUnits = .mgdL @@ -112,17 +147,30 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { debug(.watch, message) } - // MARK: - Device Ready State (SDK 1.8+) + // MARK: - Delivery + + /// What an app is still owed after its send failed for good; latest state wins. Main queue only. + private struct OwedDelivery { + var stateHash: Int? + } + + /// The send result is the readiness signal: readiness never gates a send, because a missed + /// characteristics callback would otherwise block delivery until the next reconnect. + private var owedDeliveries: [String: OwedDelivery] = [:] + + /// Apps that answered `AppNotFound`, skipped until re-registration or a message from them. + private var absentApps: Set = [] - /// Tracks which devices have completed characteristic discovery and are ready for communication. - /// In SDK 1.8+, `deviceStatusChanged: connected` does NOT mean the device is ready. - /// We must wait for `deviceCharacteristicsDiscovered:` before sending messages. - private var readyDevices: Set = [] + /// The only send gate, owned by the SDK's device status callbacks. + private var disconnectedDevices: Set = [] + + /// Latest broadcast payload, redelivered to apps that are owed state. + private var lastBroadcast: (payload: Any, hash: Int)? // MARK: - Deduplication - /// Hash of last sent data to prevent duplicate broadcasts - private var lastSentDataHash: Int? + /// Hash of the last state each app confirmed receiving, keyed by `deliveryKey(for:)`. + private var lastSentDataHashes: [String: Int] = [:] /// Hash of last prepared data to skip redundant preparation private var lastPreparedDataHash: Int? @@ -183,9 +231,21 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { /// Creates a new `BaseGarminManager`, injecting required services, restoring any persisted devices, /// and setting up watchers for data changes (e.g., glucose updates). - /// - Parameter resolver: Swinject resolver for injecting dependencies like the Router. - init(resolver: Resolver) { + /// - Parameters: + /// - resolver: Swinject resolver for injecting dependencies like the Router. + /// - connectIQClient: Replaces the Connect IQ SDK for registration and sending. + /// - sendRetryDelay: Wait before resending a failed message. + /// - broadcastDebounce: Quiet time before a state broadcast goes out. + init( + resolver: Resolver, + connectIQClient: GarminConnectIQClient? = nil, + sendRetryDelay: TimeInterval = 2, + broadcastDebounce: DispatchQueue.SchedulerTimeType.Stride = .seconds(2) + ) { router = resolver.resolve(Router.self)! + self.connectIQClient = connectIQClient ?? ConnectIQ.sharedInstance() + self.sendRetryDelay = sendRetryDelay + self.broadcastDebounce = broadcastDebounce super.init() injectServices(resolver) @@ -211,9 +271,9 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { .share() .eraseToAnyPublisher() - // Glucose updates - start 20s fallback timer + // Glucose updates - start 10s fallback timer // When loop is working: determination arrives within ~5s, cancels timer, sends complete data - // When loop is slow/failing: timer fires after 20s, sends glucose with stale loop data + // When loop is slow/failing: timer fires after 10s, sends glucose with stale loop data // This ensures watch gets fresh glucose even if loop doesn't complete glucoseStorage.updatePublisher .receive(on: DispatchQueue.global(qos: .background)) @@ -356,7 +416,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { private func handleIOBUpdate() { guard !devices.isEmpty else { return } - // Cancel any existing fallback timer (restart the 20s window) + // Cancel any existing fallback timer (restart the 10s window) pendingGlucoseFallback?.cancel() // Create new fallback task @@ -390,6 +450,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { /// If triggered by Determination, cancels pending glucose fallback timer private func triggerWatchStateUpdate(triggeredBy trigger: String) { guard !devices.isEmpty else { return } + stateRefreshTriggers.send(trigger) // If determination arrived, cancel the glucose fallback timer // Determination includes both fresh glucose and loop data @@ -753,16 +814,14 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { private func registerDevices(_ devices: [IQDevice]) { watchApps.removeAll() - // Reset broadcast hash so newly registered apps receive data + // Reset broadcast hashes so newly registered apps receive data // Without this, hash deduplication could skip sending to new apps if data unchanged - lastSentDataHash = nil - - // Note: Do NOT clear readyDevices here. The device ready state is based on BLE - // characteristic discovery, which only happens on new connections. Re-registering - // for app messages doesn't affect the underlying BLE connection state. + lastSentDataHashes.removeAll() + // re-registration is the retry permission for apps that answered AppNotFound + absentApps.removeAll() for device in devices { - connectIQ?.register(forDeviceEvents: device, delegate: self) + connectIQClient?.registerDevice(device, delegate: self) // Register watchface if enabled if isWatchfaceDataEnabled, @@ -771,7 +830,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { { debugGarmin("Registered Garmin \(appDetailedName(for: watchfaceUUID))") watchApps.append(watchfaceApp) - connectIQ?.register(forAppMessages: watchfaceApp, delegate: self) + connectIQClient?.registerApp(watchfaceApp, delegate: self) } else if !isWatchfaceDataEnabled { debugGarmin("Garmin watchface data disabled, watchface not registered") } @@ -782,9 +841,12 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { { debugGarmin("Registered Garmin \(appDetailedName(for: datafieldUUID))") watchApps.append(datafieldApp) - connectIQ?.register(forAppMessages: datafieldApp, delegate: self) + connectIQClient?.registerApp(datafieldApp, delegate: self) } } + + let registeredKeys = Set(watchApps.compactMap(deliveryKey(for:))) + owedDeliveries = owedDeliveries.filter { registeredKeys.contains($0.key) } } /// Restores previously persisted devices from local storage into `devices`. @@ -850,7 +912,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { /// Subscribes to watch state updates with debouncing private func subscribeToWatchState() { watchStateSubject - .debounce(for: .seconds(2), scheduler: DispatchQueue.main) + .debounce(for: broadcastDebounce, scheduler: DispatchQueue.main) .sink { [weak self] data in self?.broadcastWatchStateData(data) } @@ -868,7 +930,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { deviceSelectionPromise = nil } - /// Broadcasts watch state data to all registered apps + /// Sends the state once to every registered app that has not confirmed this exact state yet. private func broadcastWatchStateData(_ data: Data) { // Deduplicate: Use stable content-based hash (sorted JSON bytes) let currentHash: Int @@ -881,43 +943,91 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { currentHash = data.count // Fallback } - if currentHash == lastSentDataHash { - if debugWatchState { - debug(.watch, "Garmin broadcast skipped, data unchanged") - } - return - } - guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) else { warning(.watch, "Garmin watch state is not valid JSON") return } + lastBroadcast = (jsonObject, currentHash) - watchApps.forEach { app in - guard let appUUID = app.uuid else { - debug(.watch, "Skipped Garmin app without UUID") - return - } - let appName = self.appDisplayName(for: appUUID) - - // Check if device is ready (SDK 1.8+ requirement) - guard let device = app.device, readyDevices.contains(device.uuid) else { - debugGarmin("Skipped Garmin \(appName), device not ready") - return - } + for app in watchApps { + guard let key = deliveryKey(for: app), let device = app.device else { continue } + let appName = appDisplayName(for: app.uuid) - connectIQ?.getAppStatus(app) { [weak self] status in - guard status?.isInstalled == true else { - debug(.watch, "Garmin \(appName) not installed") - return + if lastSentDataHashes[key] == currentHash, owedDeliveries[key]?.stateHash == nil { + if debugWatchState { + debug(.watch, "Garmin \(appName) skipped, data unchanged") } - self?.debugGarmin("Sending to Garmin \(appName)") - self?.sendMessage(jsonObject as Any, to: app, appName: appName) + continue + } + guard isReachable(key: key, device: device, appName: appName) else { + recordOwed(for: key, stateHash: currentHash) + continue } + debugGarmin("Sending state to Garmin \(appName)") + sendMessage(jsonObject, to: app, appName: appName, stateHash: currentHash) } + } + + // MARK: - Delivery - // Update last sent hash after initiating send - lastSentDataHash = currentHash + private func deliveryKey(for app: IQApp) -> String? { + guard let appUUID = app.uuid, let deviceUUID = app.device?.uuid else { return nil } + return "\(appUUID.uuidString)-\(deviceUUID.uuidString)" + } + + /// SDK completions are not documented to arrive on main; delivery state lives there. + private func onMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } + } + + /// Skipping keeps whatever the app is owed for the next trigger. + private func isReachable(key: String, device: IQDevice, appName: String) -> Bool { + if absentApps.contains(key) { + debugGarmin("Garmin \(appName) skipped, app not installed") + return false + } + if disconnectedDevices.contains(device.uuid) { + debugGarmin("Garmin \(appName) skipped, device disconnected") + return false + } + return true + } + + /// Acks and other replies are never owed; the watch expires an unanswered command itself. + private func recordOwed(for key: String, stateHash: Int? = nil) { + guard stateHash != nil else { return } + var owed = owedDeliveries[key] ?? OwedDelivery() + owed.stateHash = stateHash + owedDeliveries[key] = owed + } + + /// Any delivered message proves the channel, so what the app is still owed goes out now. + private func flushOwed(to app: IQApp, key: String, appName: String) { + guard let owed = owedDeliveries.removeValue(forKey: key) else { return } + if owed.stateHash != nil, let broadcast = lastBroadcast, lastSentDataHashes[key] != broadcast.hash { + debugGarmin("Sending owed state to Garmin \(appName)") + sendMessage(broadcast.payload, to: app, appName: appName, stateHash: broadcast.hash) + } + } + + /// Redelivers on the triggers that occur anyway (broadcast, reconnect, inbound message) instead of timers. + private func flushOwed(on device: IQDevice) { + for app in watchApps where app.device?.uuid == device.uuid { + guard let key = deliveryKey(for: app), !absentApps.contains(key) else { continue } + flushOwed(to: app, key: key, appName: appDisplayName(for: app.uuid)) + } + } + + /// Answers one app directly, never through `broadcastWatchStateData`, whose content hash would + /// otherwise suppress the next state update. + private func sendTargetedResponse(_ response: [String: Any], to app: IQApp, appName: String) { + let kind = response[WatchMessageKeys.request] as? String ?? "response" + debug(.watch, "Sending \(kind) to Garmin \(appName)") + sendMessage(response, to: app, appName: appName) } // MARK: - GarminManager Conformance @@ -953,36 +1063,51 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { // MARK: - Helper: Sending Messages - /// Sends a message to a given IQApp with optional progress and completion callbacks. - /// Retries once after a short delay if the first attempt fails (SDK may need time after re-registration). - /// - Parameters: - /// - msg: The data to send to the watch app. - /// - app: The `IQApp` instance representing the watchface or data field. - /// - appName: The display name of the app for logging. - /// - isRetry: Whether this is a retry attempt (to prevent infinite retries). - private func sendMessage(_ msg: Any, to app: IQApp, appName: String, isRetry: Bool = false) { - connectIQ?.sendMessage( - msg, - to: app, - progress: { _, _ in }, - completion: { [weak self] result in + /// Sends with one resend after `sendRetryDelay` for transient failures; a final failure leaves the + /// state owed. `AppNotFound` is never resent: the app is provably not installed. + private func sendMessage( + _ msg: Any, + to app: IQApp, + appName: String, + stateHash: Int? = nil, + isRetry: Bool = false + ) { + connectIQClient?.send(msg, to: app) { [weak self] result in + self?.onMain { + guard let self, let key = self.deliveryKey(for: app) else { return } switch result { case .success: debug(.watch, "Sent to Garmin \(appName)") + self.absentApps.remove(key) + if let stateHash { + self.lastSentDataHashes[key] = stateHash + } + self.flushOwed(to: app, key: key, appName: appName) + case .failure_AppNotFound: + debug(.watch, "Garmin \(appName) not installed, skipped until re-registered or heard from") + self.absentApps.insert(key) + self.recordOwed(for: key, stateHash: stateHash) default: + let reason = NSStringFromSendMessageResult(result) ?? "\(result.rawValue)" if isRetry { - error(.watch, "Sending to Garmin \(appName) failed after retry") + error(.watch, "Sending to Garmin \(appName) failed after retry (\(reason))") + self.recordOwed(for: key, stateHash: stateHash) } else { - warning(.watch, "Sending to Garmin \(appName) failed, retrying in 2 s") - // Retry after delay - SDK may need time after re-registration - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + warning(.watch, "Sending to Garmin \(appName) failed, retrying (\(reason))") + DispatchQueue.main.asyncAfter(deadline: .now() + self.sendRetryDelay) { [weak self] in self?.debugGarmin("Retrying send to Garmin \(appName)") - self?.sendMessage(msg, to: app, appName: appName, isRetry: true) + self?.sendMessage( + msg, + to: app, + appName: appName, + stateHash: stateHash, + isRetry: true + ) } } } } - ) + } } } @@ -1020,8 +1145,7 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM // MARK: - IQDeviceEventDelegate /// Called whenever the status of a registered Garmin device changes (e.g., connected, not found, etc.). - /// Note: In SDK 1.8+, `connected` does NOT mean ready for communication. - /// Wait for `deviceCharacteristicsDiscovered:` before sending messages. + /// Only a lost connection gates sends; nothing here costs a round trip to the watch. /// - Parameters: /// - device: The device whose status has changed. /// - status: The new status for the device. @@ -1030,18 +1154,19 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM switch status { case .invalidDevice: warning(.watch, "Garmin device invalid") - readyDevices.remove(device.uuid) + disconnectedDevices.insert(device.uuid) case .bluetoothNotReady: info(.watch, "Garmin device unavailable, Bluetooth not ready") - readyDevices.remove(device.uuid) + disconnectedDevices.insert(device.uuid) case .notFound: info(.watch, "Garmin device not found") - readyDevices.remove(device.uuid) + disconnectedDevices.insert(device.uuid) case .notConnected: info(.watch, "Garmin device disconnected") - readyDevices.remove(device.uuid) + disconnectedDevices.insert(device.uuid) case .connected: debug(.watch, "Garmin device connected, waiting for characteristics") + disconnectedDevices.remove(device.uuid) @unknown default: debug(.watch, "Garmin device status unknown (\(status.rawValue))") } @@ -1052,7 +1177,8 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM /// - Parameter device: The device whose characteristics have been discovered. func deviceCharacteristicsDiscovered(_ device: IQDevice) { info(.watch, "Garmin device connected and ready") - readyDevices.insert(device.uuid) + disconnectedDevices.remove(device.uuid) + flushOwed(on: device) // Trigger a data send now that device is ready // This ensures newly connected devices get data promptly @@ -1061,9 +1187,8 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM // MARK: - IQAppMessageDelegate - /// Called when a message arrives from a Garmin watch app (watchface or data field). - /// If the watch requests a "status" update, we call `setupGarminWatchState()` asynchronously - /// and re-send the watch state data. + /// Called when a message arrives from a Garmin watch app (watchface, datafield or complication app). + /// Status requests refresh the broadcast state. /// - Parameters: /// - message: The message content from the watch app. /// - app: The watch app sending the message. @@ -1074,15 +1199,31 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM } let appName = appDisplayName(for: appUUID) debugGarmin("Message received from Garmin \(appName)") + let registered = watchApps.first { $0.uuid == appUUID && $0.device?.uuid == app.device?.uuid } - // If watch requests status update, send current data via unified path - guard let statusString = message as? String, statusString == "status" else { - return + // a message from the app proves the channel, so it gets what it is owed right away + if let registered, let key = deliveryKey(for: registered) { + onMain { [weak self] in + self?.absentApps.remove(key) + self?.flushOwed(to: registered, key: key, appName: appName) + } } - // Use triggerWatchStateUpdate for consistent deduplication and debouncing - // This prevents double sends when watchface request coincides with determination - triggerWatchStateUpdate(triggeredBy: "WatchRequest") + let router = GarminMessageRouter() + + Task { + let outcome = await router.route(message, from: appUUID, isRegistered: registered != nil, appName: appName) + + if let reply = outcome.reply { + onMain { [weak self] in + self?.sendTargetedResponse(reply, to: app, appName: appName) + } + } + // debounced path, so a request coinciding with a determination still sends once + if let trigger = outcome.refreshTrigger { + triggerWatchStateUpdate(triggeredBy: trigger) + } + } } } diff --git a/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift b/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift new file mode 100644 index 0000000000..890652c51e --- /dev/null +++ b/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift @@ -0,0 +1,96 @@ +import Foundation + +/// A message received from a Connect IQ app, after envelope validation. +enum GarminInboundMessage: Equatable { + /// Bare `"status"` string sent by watch apps that predate the v1 envelope. + case legacyStatus + case status + case invalid +} + +/// Garmin v1 wire contract: parsing inbound envelopes and building the targeted replies. +enum GarminMessageEnvelope { + static let protocolVersion = 1 + + enum Request: String { + case status + } + + private static let readEnvelopeKeys: Set = [WatchMessageKeys.protocolVersion, WatchMessageKeys.request] + + static func parse(_ message: Any) -> GarminInboundMessage { + if let string = message as? String { + return string == Request.status.rawValue ? .legacyStatus : .invalid + } + guard let envelope = message as? [String: Any], + integer(envelope[WatchMessageKeys.protocolVersion]) == Int64(protocolVersion), + let request = (envelope[WatchMessageKeys.request] as? String).flatMap(Request.init(rawValue:)), + Set(envelope.keys) == readEnvelopeKeys + else { + return .invalid + } + + switch request { + case .status: return .status + } + } + + // MARK: - Value Types + + /// Connect IQ delivers every number, and booleans too, as `NSNumber`; booleans are not numbers here. + static func number(_ value: Any?) -> NSNumber? { + guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } + return number.doubleValue.isFinite ? number : nil + } + + static func integer(_ value: Any?) -> Int64? { + guard let number = number(value) else { return nil } + let double = number.doubleValue + guard double.rounded() == double, abs(double) < 9.0E15 else { return nil } + return number.int64Value + } +} + +/// Connect IQ side of watch messages: one inbound message becomes at most one reply for the +/// requesting app plus an optional state refresh. It has no broadcast path by design. +struct GarminMessageRouter { + struct Outcome { + /// Sent to the requesting `IQApp` only. + var reply: [String: Any]? + /// Trigger name for a regular watch-state update, if one is due. + var refreshTrigger: String? + + static let none = Outcome(reply: nil, refreshTrigger: nil) + } + + var log: WatchCommandLogger = .app + + func route(_ message: Any, from appUUID: UUID, isRegistered: Bool, appName: String) async -> Outcome { + let inbound = GarminMessageEnvelope.parse(message) + log.debug("Garmin \(appName) sent a \(inbound.logName)") + + guard isRegistered else { + log.info("Ignored a \(inbound.logName) from unregistered Garmin app \(appUUID.uuidString)") + return .none + } + + switch inbound { + case .legacyStatus, + .status: + return Outcome(reply: nil, refreshTrigger: "WatchRequest") + + case .invalid: + return .none + } + } +} + +private extension GarminInboundMessage { + var logName: String { + switch self { + case .legacyStatus: return "legacy status request" + case .status: return "status request" + case .invalid: return "invalid message" + } + } +} diff --git a/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift b/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift new file mode 100644 index 0000000000..d77f00c001 --- /dev/null +++ b/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift @@ -0,0 +1,224 @@ +import Combine +import ConnectIQ +import CoreData +import Foundation +import Swinject +import Testing + +@testable import Trio + +func makeGarminResolver(stack: CoreDataStack, overrides: [Assembly]) -> Resolver { + Assembler([ + StorageAssembly(), + ServiceAssembly(), + APSAssembly(), + NetworkAssembly(), + UIAssembly(), + SecurityAssembly(), + TestAssembly(testContext: stack.newTaskContext()) + ] + overrides).resolver +} + +/// Stands in for the Connect IQ SDK, records every message with the exact app it targeted and +/// answers with scripted send results. It has no app-status API, like the production seam. +final class SpyConnectIQClient: GarminConnectIQClient, @unchecked Sendable { + struct Sent { + let message: Any + let appUUID: UUID + let deviceUUID: UUID + + var request: String? { (message as? [String: Any])?[WatchMessageKeys.request] as? String } + var isState: Bool { message is [Any] } + } + + private let lock = NSLock() + private var messages: [Sent] = [] + private var scripted: [String: [IQSendMessageResult]] = [:] + private var persistent: [String: IQSendMessageResult] = [:] + + var sent: [Sent] { lock.withLock { messages } } + + /// Consumed in order before falling back to the persistent result, then `.success`. + func script(_ results: [IQSendMessageResult], for appUUID: UUID, on deviceUUID: UUID) { + lock.withLock { scripted["\(appUUID)-\(deviceUUID)", default: []] += results } + } + + func setResult(_ result: IQSendMessageResult?, for appUUID: UUID, on deviceUUID: UUID) { + lock.withLock { persistent["\(appUUID)-\(deviceUUID)"] = result } + } + + func registerDevice(_: IQDevice, delegate _: IQDeviceEventDelegate) {} + + func registerApp(_: IQApp, delegate _: IQAppMessageDelegate) {} + + func send(_ message: Any, to app: IQApp, completion: @escaping (IQSendMessageResult) -> Void) { + let target = "\(app.uuid!)-\(app.device.uuid!)" + let result: IQSendMessageResult = lock.withLock { + messages.append(Sent(message: message, appUUID: app.uuid, deviceUUID: app.device.uuid)) + if var queue = scripted[target], !queue.isEmpty { + let next = queue.removeFirst() + scripted[target] = queue + return next + } + return persistent[target] ?? .success + } + completion(result) + } +} + +/// Collects the trigger names of the manager's watch state refreshes. +final class RefreshTriggerRecorder: @unchecked Sendable { + private let lock = NSLock() + private var names: [String] = [] + private var subscription: AnyCancellable? + + init(_ manager: BaseGarminManager) { + subscription = manager.stateRefreshTriggers.sink { [weak self] name in + guard let self else { return } + self.lock.withLock { self.names.append(name) } + } + } + + func count(_ name: String) -> Int { + lock.withLock { names.filter { $0 == name }.count } + } +} + +@Suite("Garmin Manager Delivery Tests", .serialized) final class GarminManagerDeliveryTests { + let complicationUUID = GarminWatchface.complication.watchfaceUUID! + let datafieldUUID = GarminDatafield.swissalpine.datafieldUUID! + let deviceA = IQDevice(id: UUID(), modelName: "fenix 8", friendlyName: "A")! + let deviceB = IQDevice(id: UUID(), modelName: "Enduro 3", friendlyName: "B")! + + let client = SpyConnectIQClient() + let manager: BaseGarminManager + + init() async throws { + let stack = try await CoreDataStack.createForTests() + let resolver = makeGarminResolver(stack: stack, overrides: []) + + let settings = try #require(resolver.resolve(SettingsManager.self)) + settings.settings.garminSettings.watchface = .complication + settings.settings.garminSettings.isWatchfaceDataEnabled = true + settings.settings.garminSettings.datafield = .swissalpine + + manager = BaseGarminManager( + resolver: resolver, + connectIQClient: client, + sendRetryDelay: 0.01, + broadcastDebounce: .milliseconds(10) + ) + manager.updateDeviceList([deviceA, deviceB]) + } + + deinit { + // the device list is persisted in user defaults + manager.updateDeviceList([]) + } + + private func app(_ uuid: UUID, on device: IQDevice) -> IQApp { + IQApp(uuid: uuid, store: UUID(), device: device) + } + + private func broadcast(_ sgv: Int) { + manager.sendWatchStateData(try! JSONSerialization.data(withJSONObject: [["sgv": sgv]])) + } + + private func states(to appUUID: UUID? = nil, on device: IQDevice? = nil) -> [SpyConnectIQClient.Sent] { + client.sent.filter { + $0.isState && (appUUID == nil || $0.appUUID == appUUID) && (device == nil || $0.deviceUUID == device?.uuid) + } + } + + private func sent(_ request: String) -> [SpyConnectIQClient.Sent] { + client.sent.filter { $0.request == request } + } + + /// Lets queued main-queue work and debounces run before asserting that nothing more was sent. + private func settle() async { + try? await Task.sleep(nanoseconds: 150_000_000) + } + + // MARK: - Broadcast + + @Test("Each app gets exactly one send per broadcast and an unchanged state is not resent") func testOneSendPerApp() async { + broadcast(120) + #expect(await eventually { self.states().count == 4 }) + + broadcast(120) + await settle() + #expect(states().count == 4) + + broadcast(121) + #expect(await eventually { self.states().count == 8 }) + } + + @Test("A state that failed for good stays owed and goes out with the next broadcast") func testFailedStateIsOwed() async { + client.script([.failure_Timeout, .failure_Timeout], for: complicationUUID, on: deviceA.uuid) + broadcast(120) + #expect(await eventually { self.states(to: self.complicationUUID, on: self.deviceA).count == 2 }) + + broadcast(120) + #expect(await eventually { self.states(to: self.complicationUUID, on: self.deviceA).count == 3 }) + await settle() + #expect(states().count == 6, "Only the owed app is resent an unchanged state") + } + + @Test("AppNotFound is not resent and the app is skipped until it is heard from") func testAppNotFound() async { + client.setResult(.failure_AppNotFound, for: datafieldUUID, on: deviceA.uuid) + broadcast(120) + #expect(await eventually { self.states().count == 4 }) + await settle() + #expect(states(to: datafieldUUID, on: deviceA).count == 1, "No resend for an app that is not installed") + + broadcast(121) + #expect(await eventually { self.states().count == 7 }) + await settle() + #expect(states(to: datafieldUUID, on: deviceA).count == 1) + + client.setResult(nil, for: datafieldUUID, on: deviceA.uuid) + // any message proves the channel; a status request would also start a refresh and blur the count + manager.receivedMessage(["v": 1, "req": "presets"], from: app(datafieldUUID, on: deviceA)) + #expect(await eventually { self.states(to: self.datafieldUUID, on: self.deviceA).count == 2 }) + } + + @Test("A disconnected device gets nothing until it reconnects, then its owed state") func testDisconnectedGate() async { + manager.deviceStatusChanged(deviceA, status: .notConnected) + broadcast(120) + #expect(await eventually { self.states(on: self.deviceB).count == 2 }) + await settle() + #expect(states(on: deviceA).isEmpty, "No SDK traffic toward a disconnected watch") + + manager.deviceCharacteristicsDiscovered(deviceA) + // the reconnect also starts a regular refresh, which may add more sends + #expect(await eventually { self.states(on: self.deviceA).count >= 2 }) + } + + @Test("A message from an owed app delivers its state right away") func testInboundFlushesOwed() async { + client.script([.failure_DeviceIsBusy, .failure_DeviceIsBusy], for: complicationUUID, on: deviceB.uuid) + broadcast(120) + #expect(await eventually { self.states(to: self.complicationUUID, on: self.deviceB).count == 2 }) + + manager.receivedMessage(["v": 1, "req": "presets"], from: app(complicationUUID, on: deviceB)) + #expect(await eventually { self.states(to: self.complicationUUID, on: self.deviceB).count == 3 }) + } + + // MARK: - Inbound + + @Test("Status requests from registered apps refresh the state") func testStatusRefreshes() async { + let triggers = RefreshTriggerRecorder(manager) + manager.receivedMessage("status", from: app(complicationUUID, on: deviceA)) + + #expect(await eventually { triggers.count("WatchRequest") == 1 }) + } + + @Test("A message from an unregistered device is ignored") func testUnregisteredDevice() async throws { + let triggers = RefreshTriggerRecorder(manager) + let stranger = try #require(IQDevice(id: UUID(), modelName: "fenix 8", friendlyName: "C")) + manager.receivedMessage(["v": 1, "req": "status"], from: app(complicationUUID, on: stranger)) + + await settle() + #expect(client.sent.isEmpty) + #expect(triggers.count("WatchRequest") == 0) + } +} diff --git a/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift b/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift new file mode 100644 index 0000000000..052c9feee5 --- /dev/null +++ b/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift @@ -0,0 +1,58 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Garmin Message Router Tests") struct GarminMessageRouterTests { + let appUUID = UUID() + + private func router(logs: LogCapture? = nil) -> GarminMessageRouter { + var router = GarminMessageRouter() + if let logs { + router.log = logs.logger + } + return router + } + + // MARK: - Parsing + + @Test("Bare status string is the legacy status request") func testLegacyStatus() { + #expect(GarminMessageEnvelope.parse("status") == .legacyStatus) + #expect(GarminMessageEnvelope.parse("bolus") == .invalid) + } + + @Test("v1 status requests carry no other fields") func testReadRequests() { + #expect(GarminMessageEnvelope.parse(["v": 1, "req": "status"]) == .status) + #expect(GarminMessageEnvelope.parse(["v": 1, "req": "status", "extra": true]) == .invalid) + #expect(GarminMessageEnvelope.parse(["v": 2, "req": "status"]) == .invalid) + #expect(GarminMessageEnvelope.parse(["v": "1", "req": "status"]) == .invalid) + #expect(GarminMessageEnvelope.parse(["v": true, "req": "status"]) == .invalid) + #expect(GarminMessageEnvelope.parse(["v": 1, "req": "unknown"]) == .invalid) + #expect(GarminMessageEnvelope.parse(42) == .invalid) + } + + // MARK: - Routing + + @Test("Status requests refresh the broadcast state and send no reply") func testStatusRouting() async { + let messages: [Any] = ["status", ["v": 1, "req": "status"] as [String: Any]] + for message in messages { + let outcome = await router().route(message, from: appUUID, isRegistered: true, appName: "test") + #expect(outcome.reply == nil) + #expect(outcome.refreshTrigger == "WatchRequest") + } + } + + @Test("Unregistered apps are ignored") func testUnregisteredApp() async { + let outcome = await router().route("status", from: appUUID, isRegistered: false, appName: "test") + + #expect(outcome.reply == nil) + #expect(outcome.refreshTrigger == nil) + } + + @Test("Invalid messages get neither a reply nor a refresh") func testInvalidIgnored() async { + let outcome = await router().route(["v": 1, "req": "bogus"], from: appUUID, isRegistered: true, appName: "test") + + #expect(outcome.reply == nil) + #expect(outcome.refreshTrigger == nil) + } +} From 0335cfb096abd3efc7e15ec13d64013e5d5ab040 Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 25 Oct 2026 19:24:57 +0100 Subject: [PATCH 03/34] Add Garmin treatment commands with their safeguard harness The Garmin watch app can log carbs, deliver a bolus, or both as a meal, once the user opts in under Watch > Garmin > Commands (both switches default off; turning commands off also turns bolus off). Commands run through the watch-agnostic pipeline; this adds the Garmin side and the treatment rules: - v1 command envelope with exact payload validation and RFC 4122 request IDs; every command is answered with a targeted ack, malformed ones too when they name a request ID - gates re-read at every boundary: the master switch, the bolus switch and max carbs; switching either off revokes pending commands from the settings setter itself, so off-then-on before the notification drains still revokes - one insulin command at a time: validation against max bolus, max IOB and recent boluses since the command was issued; after a watch bolus reached the pump, the next one is refused for the 6-minute window - final checks run inside the carb save transaction and right before APSManager.enactBolus, so nothing is written or issued after a revocation or expiry - meal carbs are stored before the bolus; a refused bolus acks partial_failure - the preset reply carries the switches and limits, which is where the Garmin app reads them, so the watch only offers what the phone accepts; its preset lists stay empty until adjustment commands land. Switch and max carbs changes push it to Complication apps best-effort Shared files touched: - AppleWatchManager: adds the partial_failure ack code to the acknowledgment codes both watch transports share; no Apple Watch behavior change - CarbsStorage: adds storeVerifiedCarbs. storeCarbs swallows save errors, so a watch could be told "Carbs logged" with nothing saved; the new call throws on failure and runs the final authorization check inside the transaction. Existing callers are unchanged. --- Trio.xcodeproj/project.pbxproj | 20 + Trio/Sources/APS/Storage/CarbsStorage.swift | 21 + Trio/Sources/Assemblies/ServiceAssembly.swift | 2 + .../Localizations/Main/Localizable.xcstrings | 33 + Trio/Sources/Models/GarminWatchSettings.swift | 38 ++ Trio/Sources/Models/TrioSettings.swift | 20 +- Trio/Sources/Models/WatchMessageKeys.swift | 6 + .../View/WatchConfigGarminAppConfigView.swift | 100 +++ .../WatchConfig/WatchConfigStateModel.swift | 8 + .../SettingsManager/SettingsManager.swift | 6 + .../Services/WatchCommand/WatchCommand.swift | 59 ++ .../WatchCommand/WatchCommandActions.swift | 58 ++ .../WatchCommand/WatchCommandPipeline.swift | 2 +- .../WatchCommand/WatchCommandProcessor.swift | 313 +++++++++ .../WatchManager/AppleWatchManager.swift | 2 + .../Services/WatchManager/GarminManager.swift | 48 +- .../WatchManager/GarminMessageRouter.swift | 147 ++++- .../GarminMessageRouterTests.swift | 255 ++++++- .../WatchCommandIntegrationTests.swift | 503 ++++++++++++++ .../WatchCommandProcessorTests.swift | 623 ++++++++++++++++++ .../WatchCommandTestDoubles.swift | 174 +++++ 21 files changed, 2412 insertions(+), 26 deletions(-) create mode 100644 Trio/Sources/Services/WatchCommand/WatchCommand.swift create mode 100644 Trio/Sources/Services/WatchCommand/WatchCommandActions.swift create mode 100644 Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift create mode 100644 TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift create mode 100644 TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index b49baf4ebe..3ccf93a9de 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 9A3CCDA32C874DE6AB45F00D /* WatchCommandIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C44BB629194B2666EAC83 /* WatchCommandIntegrationTests.swift */; }; + 9A3C1EE99DC965899EC25C4F /* WatchCommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0A1F620D72723F65D596 /* WatchCommandProcessorTests.swift */; }; + 9A3C4FC24B2DFEEB95911EDE /* WatchCommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3CF62E60DADFAC56F829E2 /* WatchCommandProcessor.swift */; }; + 9A3CD75136E1A66C2F49240C /* WatchCommandActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3CA2079F4D0DB8B1684D0B /* WatchCommandActions.swift */; }; + 9A3CB4CA0FB381954EA4BA94 /* WatchCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C78318D8159B851C0EC52 /* WatchCommand.swift */; }; 9A3C034686E6B4AC8CE5E7D8 /* GarminManagerIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C4DF6510DE4DB0E1F1F3A /* GarminManagerIntegrationTests.swift */; }; 9A3CF45C4133651CC90484F5 /* GarminMessageRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0B63D2B89008769E452C /* GarminMessageRouterTests.swift */; }; 9A3CA6DBA84BDF295314EAC5 /* GarminMessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3CF86D09F46856D09A2ED2 /* GarminMessageRouter.swift */; }; @@ -1247,6 +1252,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 9A3C44BB629194B2666EAC83 /* WatchCommandIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandIntegrationTests.swift; sourceTree = ""; }; + 9A3C0A1F620D72723F65D596 /* WatchCommandProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandProcessorTests.swift; sourceTree = ""; }; + 9A3CF62E60DADFAC56F829E2 /* WatchCommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandProcessor.swift; sourceTree = ""; }; + 9A3CA2079F4D0DB8B1684D0B /* WatchCommandActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandActions.swift; sourceTree = ""; }; + 9A3C78318D8159B851C0EC52 /* WatchCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommand.swift; sourceTree = ""; }; 9A3C4DF6510DE4DB0E1F1F3A /* GarminManagerIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminManagerIntegrationTests.swift; sourceTree = ""; }; 9A3C0B63D2B89008769E452C /* GarminMessageRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminMessageRouterTests.swift; sourceTree = ""; }; 9A3CF86D09F46856D09A2ED2 /* GarminMessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminMessageRouter.swift; sourceTree = ""; }; @@ -2452,6 +2462,8 @@ 9A3C6118713FEDFA9AD437CA /* WatchCommandTests */ = { isa = PBXGroup; children = ( + 9A3C44BB629194B2666EAC83 /* WatchCommandIntegrationTests.swift */, + 9A3C0A1F620D72723F65D596 /* WatchCommandProcessorTests.swift */, 9A3C4DF6510DE4DB0E1F1F3A /* GarminManagerIntegrationTests.swift */, 9A3C0B63D2B89008769E452C /* GarminMessageRouterTests.swift */, 9A3C9B44D186FF8A7079468A /* WatchCommandPipelineTests.swift */, @@ -2464,6 +2476,9 @@ 9A3CC598A206D8D945713F97 /* WatchCommand */ = { isa = PBXGroup; children = ( + 9A3CF62E60DADFAC56F829E2 /* WatchCommandProcessor.swift */, + 9A3CA2079F4D0DB8B1684D0B /* WatchCommandActions.swift */, + 9A3C78318D8159B851C0EC52 /* WatchCommand.swift */, 9A3CA06D5CC8AB3491F284C1 /* WatchCommandRequestCache.swift */, 9A3C6BB18B32890597D63BD9 /* WatchCommandPipeline.swift */, ); @@ -5807,6 +5822,9 @@ E00EEC0527368630002FF094 /* StorageAssembly.swift in Sources */, DD1745552C55CA6C00211FAC /* UnitsLimitsSettingsRootView.swift in Sources */, CE94597E29E9E1EE0047C9C6 /* GarminManager.swift in Sources */, + 9A3C4FC24B2DFEEB95911EDE /* WatchCommandProcessor.swift in Sources */, + 9A3CD75136E1A66C2F49240C /* WatchCommandActions.swift in Sources */, + 9A3CB4CA0FB381954EA4BA94 /* WatchCommand.swift in Sources */, 9A3CA6DBA84BDF295314EAC5 /* GarminMessageRouter.swift in Sources */, 9A3C3B50509CE7ABFE2E3D1D /* WatchCommandRequestCache.swift in Sources */, 9A3C76A08493CDC6FBF71373 /* WatchCommandPipeline.swift in Sources */, @@ -6599,6 +6617,8 @@ 41740E936552456AAC0EDCC3 /* MaxIOBSuggestionTests.swift in Sources */, 41740E936552456AAC0EDBC3 /* DosingModeMigrationTests.swift in Sources */, B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */, + 9A3CCDA32C874DE6AB45F00D /* WatchCommandIntegrationTests.swift in Sources */, + 9A3C1EE99DC965899EC25C4F /* WatchCommandProcessorTests.swift in Sources */, 9A3C034686E6B4AC8CE5E7D8 /* GarminManagerIntegrationTests.swift in Sources */, 9A3CF45C4133651CC90484F5 /* GarminMessageRouterTests.swift in Sources */, 9A3CE4DF1D01738B99BBF795 /* WatchCommandPipelineTests.swift in Sources */, diff --git a/Trio/Sources/APS/Storage/CarbsStorage.swift b/Trio/Sources/APS/Storage/CarbsStorage.swift index 8e4da1d6e4..4700f76500 100644 --- a/Trio/Sources/APS/Storage/CarbsStorage.swift +++ b/Trio/Sources/APS/Storage/CarbsStorage.swift @@ -11,6 +11,9 @@ protocol CarbsObserver { protocol CarbsStorage { var updatePublisher: AnyPublisher { get } func storeCarbs(_ carbs: [CarbsEntry], areFetchedFromRemote: Bool) async throws + /// Stores one local carb row without fat/protein equivalents and, unlike `storeCarbs`, throws when + /// the save fails. `authorize` runs inside the transaction; whatever it throws leaves nothing written. + 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. @@ -290,6 +293,24 @@ final class BaseCarbsStorage: CarbsStorage, Injectable { } } + func storeVerifiedCarbs(_ entry: CarbsEntry, authorize: @escaping () throws -> Void) async throws { + let context = makeContext() + context.name = "storeVerifiedCarbs" + try await context.perform { + try authorize() + self.insertCarbEntry(entry, into: context, areFetchedFromRemote: false) + + guard context.hasChanges else { return } + do { + try context.save() + } catch { + // an injected long-lived context would otherwise save the failed row later + context.rollback() + throw error + } + } + } + private func saveCarbsToCoreData(entries: [CarbsEntry], areFetchedFromRemote: Bool) async { guard let entry = entries.last else { return } diff --git a/Trio/Sources/Assemblies/ServiceAssembly.swift b/Trio/Sources/Assemblies/ServiceAssembly.swift index e71708c2c9..dbf695f3de 100644 --- a/Trio/Sources/Assemblies/ServiceAssembly.swift +++ b/Trio/Sources/Assemblies/ServiceAssembly.swift @@ -39,6 +39,8 @@ final class ServiceAssembly: Assembly { WatchCommandPipeline(authorization: r.resolve(WatchCommandAuthorization.self)!) } .inObjectScope(.container) + container.register(WatchCommandProcessor.self) { r in BaseWatchCommandProcessor(resolver: r) } + .inObjectScope(.container) container.register(TrioRemoteControl.self) { r in TrioRemoteControl(resolver: r) } .inObjectScope(.container) container.register(TelemetryClient.self) { r in TelemetryClient(resolver: r) } diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 86e49f4cfb..811dfe8d5a 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -1088,6 +1088,15 @@ } } } + }, + "Allow Bolus Commands" : { + + }, + "Allow the Garmin app to deliver insulin. Requires watch commands to be enabled." : { + + }, + "Allow the Garmin app to log carbs." : { + }, "Ask on Discord" : { @@ -1131,6 +1140,18 @@ }, "Command expired. Please try again." : { "comment" : "Watch command ack" + }, + "Commands" : { + + }, + "Commands are only accepted from the Garmin apps selected above, must reach the phone within 2 minutes of being confirmed on the watch, and carbs are limited by your Max Carbs setting." : { + + }, + "Enable Watch Commands" : { + + }, + "Every bolus is checked against your Max Bolus and Max IOB. A watch bolus is refused for 6 minutes after the previous watch bolus, and any watch bolus is refused if the boluses of the last 6 minutes (SMBs included) add up to 20% or more of it. These checks cannot tell who pressed the button on the watch: anyone with access to your watch can deliver insulin up to those limits." : { + }, "Extended (%@)" : { "comment" : "Extended lower threshold for TIR and TITR statistics; placeholder includes glucose value and units.", @@ -1164,6 +1185,18 @@ } } } + }, + "Invalid command." : { + "comment" : "Watch command ack" + }, + "Lets a supported Garmin app log carbs. Status and the preset list stay available with this switch off." : { + + }, + "Lets the Garmin app deliver a bolus, alone or together with carbs, without any confirmation on this phone." : { + + }, + "Only enable this if you keep your watch locked and under your control." : { + }, "Request ID was already used." : { "comment" : "Watch command ack" diff --git a/Trio/Sources/Models/GarminWatchSettings.swift b/Trio/Sources/Models/GarminWatchSettings.swift index 21f86220b4..2c7ed1be1b 100644 --- a/Trio/Sources/Models/GarminWatchSettings.swift +++ b/Trio/Sources/Models/GarminWatchSettings.swift @@ -131,4 +131,42 @@ struct GarminWatchSettings: Codable, Hashable { var primaryAttributeChoice: GarminPrimaryAttributeChoice = .cob var secondaryAttributeChoice: GarminSecondaryAttributeChoice = .tbr var isWatchfaceDataEnabled: Bool = false + /// Master switch for state-changing watch commands; status and preset reads ignore it. + var isCommandControlEnabled: Bool = false + /// Allows bolus and meal+bolus commands; only effective while `isCommandControlEnabled` is on. + var isBolusCommandEnabled: Bool = false +} + +extension GarminWatchSettings { + /// Keys missing from settings written by older versions keep their defaults, so commands stay off. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init() + if let watchface = try? container.decode(GarminWatchface.self, forKey: .watchface) { + self.watchface = watchface + } + if let datafield = try? container.decode(GarminDatafield.self, forKey: .datafield) { + self.datafield = datafield + } + if let primaryAttributeChoice = try? container + .decode(GarminPrimaryAttributeChoice.self, forKey: .primaryAttributeChoice) + { + self.primaryAttributeChoice = primaryAttributeChoice + } + if let secondaryAttributeChoice = try? container.decode( + GarminSecondaryAttributeChoice.self, + forKey: .secondaryAttributeChoice + ) { + self.secondaryAttributeChoice = secondaryAttributeChoice + } + if let isWatchfaceDataEnabled = try? container.decode(Bool.self, forKey: .isWatchfaceDataEnabled) { + self.isWatchfaceDataEnabled = isWatchfaceDataEnabled + } + if let isCommandControlEnabled = try? container.decode(Bool.self, forKey: .isCommandControlEnabled) { + self.isCommandControlEnabled = isCommandControlEnabled + } + if let isBolusCommandEnabled = try? container.decode(Bool.self, forKey: .isBolusCommandEnabled) { + self.isBolusCommandEnabled = isBolusCommandEnabled + } + } } diff --git a/Trio/Sources/Models/TrioSettings.swift b/Trio/Sources/Models/TrioSettings.swift index 797a6bf0a6..0d112e5850 100644 --- a/Trio/Sources/Models/TrioSettings.swift +++ b/Trio/Sources/Models/TrioSettings.swift @@ -89,6 +89,12 @@ struct TrioSettings: JSON, Equatable, Encodable { /// Controls whether watchface data transmission is enabled var isWatchfaceDataEnabled: Bool = false + /// Master switch for Garmin watch commands, off until the user opts in + var isGarminCommandControlEnabled: Bool = false + + /// Allows Garmin bolus commands; only effective while `isGarminCommandControlEnabled` is on + var isGarminBolusCommandEnabled: Bool = false + /// Computed property that groups all Garmin settings into a single struct var garminSettings: GarminWatchSettings { get { @@ -97,7 +103,9 @@ struct TrioSettings: JSON, Equatable, Encodable { datafield: garminDatafield, primaryAttributeChoice: primaryAttributeChoice, secondaryAttributeChoice: secondaryAttributeChoice, - isWatchfaceDataEnabled: isWatchfaceDataEnabled + isWatchfaceDataEnabled: isWatchfaceDataEnabled, + isCommandControlEnabled: isGarminCommandControlEnabled, + isBolusCommandEnabled: isGarminBolusCommandEnabled ) } set { @@ -106,6 +114,8 @@ struct TrioSettings: JSON, Equatable, Encodable { primaryAttributeChoice = newValue.primaryAttributeChoice secondaryAttributeChoice = newValue.secondaryAttributeChoice isWatchfaceDataEnabled = newValue.isWatchfaceDataEnabled + isGarminCommandControlEnabled = newValue.isCommandControlEnabled + isGarminBolusCommandEnabled = newValue.isBolusCommandEnabled } } } @@ -402,6 +412,14 @@ extension TrioSettings: Decodable { settings.isWatchfaceDataEnabled = isWatchfaceDataEnabled } + if let isGarminCommandControlEnabled = try? container.decode(Bool.self, forKey: .isGarminCommandControlEnabled) { + settings.isGarminCommandControlEnabled = isGarminCommandControlEnabled + } + + if let isGarminBolusCommandEnabled = try? container.decode(Bool.self, forKey: .isGarminBolusCommandEnabled) { + settings.isGarminBolusCommandEnabled = isGarminBolusCommandEnabled + } + self = settings } } diff --git a/Trio/Sources/Models/WatchMessageKeys.swift b/Trio/Sources/Models/WatchMessageKeys.swift index ef37c64ce0..bd228d570a 100644 --- a/Trio/Sources/Models/WatchMessageKeys.swift +++ b/Trio/Sources/Models/WatchMessageKeys.swift @@ -67,4 +67,10 @@ enum WatchMessageKeys { // Garmin v1 Envelope Keys static let protocolVersion = "v" static let request = "req" + static let requestID = "requestId" + static let command = "command" + static let payload = "payload" + static let mealBolus = "mealBolus" + static let isCommandControlEnabled = "isCommandControlEnabled" + static let isBolusCommandEnabled = "isBolusCommandEnabled" } diff --git a/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift b/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift index 99adb20af6..f6aca39cda 100644 --- a/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift +++ b/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift @@ -7,6 +7,8 @@ struct WatchConfigGarminAppConfigView: View { @State private var shouldDisplayHint2: Bool = false @State private var shouldDisplayHint3: Bool = false @State private var shouldDisplayHint4: Bool = false + @State private var shouldDisplayCommandsHint: Bool = false + @State private var shouldDisplayBolusCommandsHint: Bool = false @State var hintDetent = PresentationDetent.large @State private var shouldShowWatchfaceSwitchConfirmDialog: Bool = false @@ -94,6 +96,69 @@ struct WatchConfigGarminAppConfigView: View { } ).listRowBackground(Color.chart) + // MARK: - Commands Section + + Section( + header: Text("Commands"), + content: { + VStack { + Toggle("Enable Watch Commands", isOn: Binding( + get: { state.garminSettings.isCommandControlEnabled }, + set: { state.setCommandControlEnabled($0) } + )) + + HStack(alignment: .center) { + Text( + "Allow the Garmin app to log carbs." + ) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(nil) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + Spacer() + Button( + action: { + shouldDisplayCommandsHint.toggle() + }, + label: { + HStack { + Image(systemName: "questionmark.circle").accessibilityLabel(Text("More information")) + } + } + ).buttonStyle(BorderlessButtonStyle()) + }.padding(.top) + }.padding(.bottom) + + VStack { + Toggle("Allow Bolus Commands", isOn: $state.garminSettings.isBolusCommandEnabled) + .disabled(!state.garminSettings.isCommandControlEnabled) + + HStack(alignment: .center) { + Text( + "Allow the Garmin app to deliver insulin. Requires watch commands to be enabled." + ) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(nil) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + Spacer() + Button( + action: { + shouldDisplayBolusCommandsHint.toggle() + }, + label: { + HStack { + Image(systemName: "questionmark.circle").accessibilityLabel(Text("More information")) + } + } + ).buttonStyle(BorderlessButtonStyle()) + }.padding(.top) + }.padding(.bottom) + } + ).listRowBackground(Color.chart) + // MARK: - Datafield Selection Section Section( @@ -228,6 +293,41 @@ struct WatchConfigGarminAppConfigView: View { sheetTitle: String(localized: "Help", comment: "Help sheet title") ) } + .sheet(isPresented: $shouldDisplayCommandsHint) { + SettingInputHintView( + hintDetent: $hintDetent, + shouldDisplayHint: $shouldDisplayCommandsHint, + hintLabel: String(localized: "Enable Watch Commands"), + hintText: VStack(alignment: .leading, spacing: 10) { + Text("Default: OFF").bold() + Text( + "Lets a supported Garmin app log carbs. Status and the preset list stay available with this switch off." + ) + Text( + "Commands are only accepted from the Garmin apps selected above, must reach the phone within 2 minutes of being confirmed on the watch, and carbs are limited by your Max Carbs setting." + ) + }, + sheetTitle: String(localized: "Help", comment: "Help sheet title") + ) + } + .sheet(isPresented: $shouldDisplayBolusCommandsHint) { + SettingInputHintView( + hintDetent: $hintDetent, + shouldDisplayHint: $shouldDisplayBolusCommandsHint, + hintLabel: String(localized: "Allow Bolus Commands"), + hintText: VStack(alignment: .leading, spacing: 10) { + Text("Default: OFF").bold() + Text( + "Lets the Garmin app deliver a bolus, alone or together with carbs, without any confirmation on this phone." + ) + Text( + "Every bolus is checked against your Max Bolus and Max IOB. A watch bolus is refused for 6 minutes after the previous watch bolus, and any watch bolus is refused if the boluses of the last 6 minutes (SMBs included) add up to 20% or more of it. These checks cannot tell who pressed the button on the watch: anyone with access to your watch can deliver insulin up to those limits." + ) + Text("Only enable this if you keep your watch locked and under your control.").bold() + }, + sheetTitle: String(localized: "Help", comment: "Help sheet title") + ) + } .sheet(isPresented: $shouldDisplayHint4) { SettingInputHintView( hintDetent: $hintDetent, diff --git a/Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift b/Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift index b4cb129c52..8e8062afcc 100644 --- a/Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift +++ b/Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift @@ -51,6 +51,14 @@ extension WatchConfig { func resumeDataTransmission() { garminSettings.isWatchfaceDataEnabled = true } + + /// Turning commands off also turns bolus off, so re-enabling commands never re-arms insulin silently. + func setCommandControlEnabled(_ isEnabled: Bool) { + garminSettings.isCommandControlEnabled = isEnabled + if !isEnabled { + garminSettings.isBolusCommandEnabled = false + } + } } } diff --git a/Trio/Sources/Services/SettingsManager/SettingsManager.swift b/Trio/Sources/Services/SettingsManager/SettingsManager.swift index c2d3779cf9..0f89106291 100644 --- a/Trio/Sources/Services/SettingsManager/SettingsManager.swift +++ b/Trio/Sources/Services/SettingsManager/SettingsManager.swift @@ -20,8 +20,14 @@ protocol PreferencesObserver { final class BaseSettingsManager: SettingsManager, Injectable { @Injected() var broadcaster: Broadcaster! @Injected() var storage: FileStorage! + @Injected() private var watchCommandAuthorization: WatchCommandAuthorization! @SyncAccess var settings: TrioSettings { + // on the writing queue: the async observer notification only carries the latest value and + // cannot tell that a command switch went off and back on in between + willSet { + watchCommandAuthorization.settingsWillChange(from: settings, to: newValue) + } didSet { if oldValue != settings { saveSettings() diff --git a/Trio/Sources/Services/WatchCommand/WatchCommand.swift b/Trio/Sources/Services/WatchCommand/WatchCommand.swift new file mode 100644 index 0000000000..f772cc82f5 --- /dev/null +++ b/Trio/Sources/Services/WatchCommand/WatchCommand.swift @@ -0,0 +1,59 @@ +import Foundation + +/// A state-changing command sent from a watch, already parsed and type-checked by its transport. +enum WatchCommand: Hashable { + case bolus(Decimal) + case carbs(Int) + /// Carbs are stored before the bolus runs, so a rejected bolus still leaves the meal logged. + case mealBolus(carbs: Int, bolus: Decimal) + + /// Wire name of the command; also the only command detail that is safe to log. + var name: String { + switch self { + case .bolus: return "bolus" + case .carbs: return "carbs" + case .mealBolus: return "mealBolus" + } + } + + var bolusAmount: Decimal? { + switch self { + case let .bolus(units), + let .mealBolus(_, units): + return units + default: + return nil + } + } + + var carbsAmount: Int? { + switch self { + case let .carbs(grams), + let .mealBolus(grams, _): + return grams + default: + return nil + } + } + + var isInsulin: Bool { bolusAmount != nil } +} + +struct WatchCommandRequest: Equatable { + let requestID: UUID + /// Request IDs are only unique per watch app. + let appUUID: UUID + /// When the watch issued the command. + let date: Date + let command: WatchCommand +} + +/// Switches and limits the watch uses to offer only what the phone accepts; the phone still enforces +/// each of them. The defaults describe a phone that accepts nothing. +struct WatchCommandCapabilities: Equatable { + var isCommandControlEnabled = false + var isBolusCommandEnabled = false + var maxBolus: Decimal = 0 + var maxCarbs: Decimal = 0 + var bolusIncrement: Decimal = 0 +} diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandActions.swift b/Trio/Sources/Services/WatchCommand/WatchCommandActions.swift new file mode 100644 index 0000000000..3eb156eff4 --- /dev/null +++ b/Trio/Sources/Services/WatchCommand/WatchCommandActions.swift @@ -0,0 +1,58 @@ +import Foundation +import Swinject + +/// Pump and Core Data side effects of watch commands, behind a protocol so validation and +/// sequencing can be tested without a pump. +protocol WatchCommandActions { + /// Throws unless the carb row was saved. `authorize` runs inside the save transaction; whatever + /// it throws is rethrown with nothing written. + func storeCarbs(_ grams: Int, date: Date, authorize: @escaping () throws -> Void) async throws + /// Returns once the pump accepted or refused the bolus. Throws only what `authorize` threw, + /// right before issuance; nothing reached the pump then. + func enactBolus(_ units: Decimal, authorize: @escaping () throws -> Void) async throws -> Bool +} + +final class BaseWatchCommandActions: WatchCommandActions, Injectable { + @Injected() private var apsManager: APSManager! + @Injected() private var carbsStorage: CarbsStorage! + + init(resolver: Resolver) { + injectServices(resolver) + } + + func storeCarbs(_ grams: Int, date: Date, authorize: @escaping () throws -> Void) async throws { + let entry = CarbsEntry( + id: UUID().uuidString, + createdAt: date, + actualDate: date, + carbs: Decimal(grams), + fat: 0, + protein: 0, + note: String(localized: "Via Watch", comment: "Note added to carb entry when entered via watch"), + enteredBy: CarbsEntry.local, + isFPU: false, + fpuID: nil + ) + try await carbsStorage.storeVerifiedCarbs(entry, authorize: authorize) + } + + /// `authorize` runs in the task that issues the request, right before `APSManager.enactBolus` with + /// nothing awaited in between; the task lets the ack return before the follow-up loop run. + func enactBolus(_ units: Decimal, authorize: @escaping () throws -> Void) async throws -> Bool { + // enactBolus never calls back for non-positive amounts, which would never resume + guard units > 0, let apsManager = self.apsManager else { return false } + return try await withCheckedThrowingContinuation { continuation in + Task { + do { + try authorize() + } catch { + continuation.resume(throwing: error) + return + } + await apsManager.enactBolus(amount: Double(truncating: units as NSNumber), isSMB: false) { success, _ in + continuation.resume(returning: success) + } + } + } + } +} diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandPipeline.swift b/Trio/Sources/Services/WatchCommand/WatchCommandPipeline.swift index b287f8f808..3bab8f0ea4 100644 --- a/Trio/Sources/Services/WatchCommand/WatchCommandPipeline.swift +++ b/Trio/Sources/Services/WatchCommand/WatchCommandPipeline.swift @@ -10,7 +10,7 @@ struct WatchCommandResult: Equatable { /// Something may have changed on the phone, so the watch needs fresh state. var refreshesState: Bool { - acknowledged + acknowledged || ackCode == .partialFailure } static func success(_ ackCode: BaseWatchManager.AcknowledgmentCode, _ message: String) -> WatchCommandResult { diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift b/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift new file mode 100644 index 0000000000..42372b524a --- /dev/null +++ b/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift @@ -0,0 +1,313 @@ +import Foundation +import Swinject + +/// Validates and executes watch commands through `WatchCommandPipeline`, independent of the transport. +protocol WatchCommandProcessor { + /// Runs `request` at most once per app and request ID and returns its terminal result. + func process(_ request: WatchCommandRequest) async -> WatchCommandResult + + /// Read at call time, so a switch turned off is reported off in the very next reply. + func capabilities() -> WatchCommandCapabilities +} + +extension WatchCommandAuthorization { + /// Call before `new` is stored. Turning either Garmin command switch off always revokes, even + /// when it is switched back on right after; turning one on never does. + func settingsWillChange(from old: TrioSettings, to new: TrioSettings) { + let masterTurnedOff = old.isGarminCommandControlEnabled && !new.isGarminCommandControlEnabled + let bolusTurnedOff = old.isGarminBolusCommandEnabled && !new.isGarminBolusCommandEnabled + guard masterTurnedOff || bolusTurnedOff else { return } + revoke() + info(.watch, "Garmin command setting turned off, pending watch commands revoked") + } +} + +/// Admits one watch insulin command at a time, so each validation sees the previous command's delivery. +/// Waiters suspend on a continuation and never hold the actor. +/// +/// Stricter than the shared 20% recent-bolus check: after a watch bolus reached `APSManager`, the next +/// one is refused for `BolusSafetyEvaluator.recentBolusWindowMinutes`, since pump history may lag and a +/// refusal can still mean partial delivery. +actor WatchCommandInsulinLane { + private var isOccupied = false + private var waiters: [CheckedContinuation] = [] + /// Last pump request issued through the lane; pump history may not show it yet. + private(set) var lastEnactment: Date? + + var waitingCount: Int { waiters.count } + + func enter() async { + guard isOccupied else { + isOccupied = true + return + } + await withCheckedContinuation { waiters.append($0) } + } + + /// Hands the lane straight to the next waiter, so no newcomer can slip in between. + func leave() { + if waiters.isEmpty { + isOccupied = false + } else { + waiters.removeFirst().resume() + } + } + + func recordEnactment(at date: Date) { + lastEnactment = date + } + + /// Puts back what `recordEnactment` replaced when the request never reached the pump. + func restoreEnactment(_ date: Date?) { + lastEnactment = date + } +} + +/// Garmin is the only transport sending commands, so the gates read the Garmin settings. +final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { + @Injected() private var settingsManager: SettingsManager! + @Injected() private var bolusSafetyValidator: BolusSafetyValidator! + @Injected() private var pipeline: WatchCommandPipeline! + + private let actions: WatchCommandActions + private let insulinLane: WatchCommandInsulinLane + private let now: () -> Date + private let log: WatchCommandLogger + + init( + resolver: Resolver, + actions: WatchCommandActions? = nil, + insulinLane: WatchCommandInsulinLane = WatchCommandInsulinLane(), + now: @escaping () -> Date = Date.init, + log: WatchCommandLogger = .app + ) { + self.actions = actions ?? BaseWatchCommandActions(resolver: resolver) + self.insulinLane = insulinLane + self.now = now + self.log = log + injectServices(resolver) + } + + func process(_ request: WatchCommandRequest) async -> WatchCommandResult { + await pipeline.run( + requestID: request.requestID, + appUUID: request.appUUID, + issuedAt: request.date, + command: request.command, + name: request.command.name, + gate: { [self] in gateRejection(for: request.command) } + ) { context in + await execute(request.command, context: context) + } + } + + func capabilities() -> WatchCommandCapabilities { + let settings = settingsManager.settings + return WatchCommandCapabilities( + isCommandControlEnabled: settings.isGarminCommandControlEnabled, + isBolusCommandEnabled: settings.isGarminBolusCommandEnabled, + maxBolus: settingsManager.pumpSettings.maxBolus, + maxCarbs: settings.maxCarbs, + bolusIncrement: settingsManager.preferences.bolusIncrement + ) + } + + // MARK: - Validation + + private func execute(_ command: WatchCommand, context: WatchCommandContext) async -> WatchCommandResult { + if let rejection = amountRejection(for: command) { + return rejection + } + + switch command { + case let .bolus(units): + return await bolus(units, context: context) + case let .carbs(grams): + return await carbs(grams, context: context) + case let .mealBolus(grams, units): + return await mealBolus(carbs: grams, bolus: units, context: context) + } + } + + /// Re-read at every check: settings can change while a command is suspended. + private func gateRejection(for command: WatchCommand) -> WatchCommandResult? { + let garminSettings = settingsManager.settings.garminSettings + guard garminSettings.isCommandControlEnabled else { + log.info("Garmin \(command.name) command refused, watch commands are off") + return .failure(String(localized: "Watch commands are disabled.", comment: "Watch command ack")) + } + guard !command.isInsulin || garminSettings.isBolusCommandEnabled else { + log.info("Garmin \(command.name) command refused, bolus commands are off") + return .failure(String(localized: "Bolus from watch is disabled.", comment: "Watch command ack")) + } + return nil + } + + private func amountRejection(for command: WatchCommand) -> WatchCommandResult? { + if let units = command.bolusAmount, units <= 0 { + return .failure(String(localized: "Invalid bolus amount.", comment: "Watch command ack")) + } + if let grams = command.carbsAmount { + guard grams > 0 else { + return .failure(String(localized: "Invalid carbs amount.", comment: "Watch command ack")) + } + guard Decimal(grams) <= settingsManager.settings.maxCarbs else { + log.info("Garmin \(command.name) command refused, carbs above max carbs") + return .failure(String(localized: "Carbs exceed the maximum allowed.", comment: "Watch command ack")) + } + } + return nil + } + + // MARK: - Treatments + + private enum BolusOutcome { + case delivered + /// Nothing was sent to the pump. + case rejected(String) + /// The pump refused or errored; part of the bolus may still have been delivered. + case failed + } + + private func bolus(_ units: Decimal, context: WatchCommandContext) async -> WatchCommandResult { + switch await deliverBolus(units, context: context) { + case .delivered: + return .success(.genericSuccess, String(localized: "Bolus started.", comment: "Watch command ack")) + case let .rejected(message): + return .failure(message) + case .failed: + return .failure(String(localized: "Bolus failed. Check pump history before repeating.", comment: "Watch command ack")) + } + } + + private func carbs(_ grams: Int, context: WatchCommandContext) async -> WatchCommandResult { + if let rejection = context.rejection() { + return rejection + } + do { + try await actions.storeCarbs(grams, date: context.issuedAt, authorize: context.finalCheck()) + return .success(.carbsLogged, String(localized: "Carbs logged.", comment: "Watch command ack")) + } catch let rejection as WatchCommandRejection { + return rejection.result + } catch { + log.warning("Storing carbs from Garmin failed (\(WatchCommandErrorCategory.name(for: error)))") + return .failure(String(localized: "Could not log carbs.", comment: "Watch command ack")) + } + } + + private func mealBolus(carbs grams: Int, bolus units: Decimal, context: WatchCommandContext) async -> WatchCommandResult { + if let rejection = context.rejection() { + return rejection + } + do { + // a meal command is insulin, so its carbs also re-check the freshness window + try await actions.storeCarbs(grams, date: context.issuedAt, authorize: context.finalCheck(checkingExpiry: true)) + } catch let rejection as WatchCommandRejection { + return rejection.result + } catch { + log + .warning( + "Storing meal carbs from Garmin failed, no bolus delivered (\(WatchCommandErrorCategory.name(for: error)))" + ) + return .failure(String(localized: "Could not log carbs. No bolus was delivered.", comment: "Watch command ack")) + } + + switch await deliverBolus(units, context: context) { + case .delivered: + return .success(.genericSuccess, String(localized: "Carbs logged and bolus started.", comment: "Watch command ack")) + case .rejected: + return WatchCommandResult( + acknowledged: false, + ackCode: .partialFailure, + message: String(localized: "Carbs logged. No bolus was delivered.", comment: "Watch command ack") + ) + case .failed: + return WatchCommandResult( + acknowledged: false, + ackCode: .partialFailure, + message: String( + localized: "Carbs logged. Bolus failed, check pump history before repeating.", + comment: "Watch command ack" + ) + ) + } + } + + private func deliverBolus(_ units: Decimal, context: WatchCommandContext) async -> BolusOutcome { + await insulinLane.enter() + let outcome = await deliverBolusInLane(units, context: context) + await insulinLane.leave() + return outcome + } + + /// Everything is re-checked: the wait for the lane can last as long as another command's + /// validation and pump request. + private func deliverBolusInLane(_ units: Decimal, context: WatchCommandContext) async -> BolusOutcome { + if let rejection = context.rejection(checkingExpiry: true) { + return .rejected(rejection.message) + } + + let recentWindow = Double(BolusSafetyEvaluator.recentBolusWindowMinutes * 60) + let previousEnactment = await insulinLane.lastEnactment + if let previousEnactment, now().timeIntervalSince(previousEnactment) < recentWindow { + log.info("Garmin bolus refused, previous watch bolus may not be in pump history yet") + return .rejected(BolusSafetyRejection.recentBolusWithinWindow(totalRecent: 0).watchMessage) + } + + // cover every bolus since the watch sent the command, and never less than the standard window + let lookbackStart = min(context.issuedAt, now().addingTimeInterval(-recentWindow)) + + do { + switch try await bolusSafetyValidator.validate(bolusAmount: units, lookbackStart: lookbackStart) { + case .allowed: + break + case let .rejected(reason): + log.info("Garmin bolus refused by safety check (\(reason.logName))") + return .rejected(reason.watchMessage) + } + } catch { + log.warning("Garmin bolus safety check failed, no bolus delivered (\(WatchCommandErrorCategory.name(for: error)))") + return .rejected(String(localized: "Could not verify bolus safety.", comment: "Watch command ack")) + } + + // validation awaited, so the request may have expired or been revoked meanwhile + if let rejection = context.rejection(checkingExpiry: true) { + return .rejected(rejection.message) + } + // recorded before the request, since a pump error can still mean partial delivery + await insulinLane.recordEnactment(at: now()) + do { + return try await actions.enactBolus(units, authorize: context.finalCheck(checkingExpiry: true)) ? .delivered : .failed + } catch { + // the final check stopped it before `APSManager`, so nothing reached the pump + await insulinLane.restoreEnactment(previousEnactment) + let message = (error as? WatchCommandRejection)?.result.message + return .rejected(message ?? String(localized: "Could not verify bolus safety.", comment: "Watch command ack")) + } + } +} + +private extension BolusSafetyRejection { + /// Case name only; the associated values are insulin amounts, which stay out of the log. + var logName: String { + switch self { + case .exceedsMaxBolus: return "exceedsMaxBolus" + case .iobUnavailable: return "iobUnavailable" + case .exceedsMaxIOB: return "exceedsMaxIOB" + case .recentBolusWithinWindow: return "recentBolusWithinWindow" + } + } + + var watchMessage: String { + switch self { + case .exceedsMaxBolus: + return String(localized: "Bolus exceeds max bolus.", comment: "Watch command ack") + case .iobUnavailable: + return String(localized: "IOB unavailable. Bolus not delivered.", comment: "Watch command ack") + case .exceedsMaxIOB: + return String(localized: "Bolus would exceed max IOB.", comment: "Watch command ack") + case .recentBolusWithinWindow: + return String(localized: "A bolus was given in the last 6 minutes.", comment: "Watch command ack") + } + } +} diff --git a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift index 5b14c9fb58..bcf43ca0a8 100644 --- a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift +++ b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift @@ -1164,6 +1164,8 @@ extension BaseWatchManager { case tempTargetStopped = "temp_target_stopped" case genericSuccess = "success" case genericFailure = "failure" + /// Meal logged but its bolus was not delivered. + case partialFailure = "partial_failure" /// A duplicate of a request that is still running; that request's own ack follows. /// Not a failure, although `acknowledged` is false. case inProgress = "in_progress" diff --git a/Trio/Sources/Services/WatchManager/GarminManager.swift b/Trio/Sources/Services/WatchManager/GarminManager.swift index a87da089ef..253be2f9ab 100644 --- a/Trio/Sources/Services/WatchManager/GarminManager.swift +++ b/Trio/Sources/Services/WatchManager/GarminManager.swift @@ -83,6 +83,10 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { @Injected() private var iobService: IOBService! @Injected() private var trioAlertManager: TrioAlertManager! + @Injected() private var watchCommandProcessor: WatchCommandProcessor! + + /// Revoked by `BaseSettingsManager` itself; read here only to refresh the watch after a revocation. + @Injected() private var watchCommandAuthorization: WatchCommandAuthorization! /// Persists the user's device list between app launches. @Persisted(key: "BaseGarminManager.persistedDevices") private var persistedDevices: [GarminDevice] = [] @@ -193,6 +197,12 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { /// Track previous Garmin settings to detect what specifically changed private var previousGarminSettings = GarminWatchSettings() + /// Max carbs at the last settings notification; the watch caps its carbs picker with it. + private var previousMaxCarbs: Decimal = 0 + + /// A newer epoch means a command switch went off since, even if it now shows on again. + private var lastSeenCommandRevocation: UInt64 = 0 + /// Pending settings update task - waits for user to finish making changes private var pendingSettingsUpdate: DispatchWorkItem? @@ -259,6 +269,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { units = settingsManager.settings.units glucoseColorScheme = settingsManager.settings.glucoseColorScheme previousGarminSettings = settingsManager.settings.garminSettings + previousMaxCarbs = settingsManager.settings.maxCarbs + lastSeenCommandRevocation = watchCommandAuthorization.current broadcaster.register(SettingsObserver.self, observer: self) // Glucose targets are not part of TrioSettings, so editing the target profile @@ -1022,6 +1034,21 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { } } + /// Main queue only. Best-effort: a dropped push waits for the watch's next preset request. + private func pushPresetsToComplications(triggeredBy _: String) { + let complicationUUID = GarminWatchface.complication.watchfaceUUID + let targets = watchApps.filter { $0.uuid != nil && $0.uuid == complicationUUID } + guard !targets.isEmpty else { return } + + let response = GarminMessageEnvelope.presetsResponse(capabilities: watchCommandProcessor.capabilities()) + for app in targets { + guard let key = deliveryKey(for: app), let device = app.device else { continue } + let appName = appDisplayName(for: app.uuid) + guard isReachable(key: key, device: device, appName: appName) else { continue } + sendTargetedResponse(response, to: app, appName: appName) + } + } + /// Answers one app directly, never through `broadcastWatchStateData`, whose content hash would /// otherwise suppress the next state update. private func sendTargetedResponse(_ response: [String: Any], to app: IQApp, appName: String) { @@ -1188,7 +1215,8 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM // MARK: - IQAppMessageDelegate /// Called when a message arrives from a Garmin watch app (watchface, datafield or complication app). - /// Status requests refresh the broadcast state. + /// Status requests refresh the broadcast state; preset requests and commands are answered only to + /// the requesting app. /// - Parameters: /// - message: The message content from the watch app. /// - app: The watch app sending the message. @@ -1209,7 +1237,7 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM } } - let router = GarminMessageRouter() + let router = GarminMessageRouter(processor: watchCommandProcessor) Task { let outcome = await router.route(message, from: appUUID, isRegistered: registered != nil, appName: appName) @@ -1246,6 +1274,14 @@ extension BaseGarminManager: SettingsObserver { let displayAttributesChanged = currentGarminSettings.primaryAttributeChoice != previousGarminSettings .primaryAttributeChoice || currentGarminSettings.secondaryAttributeChoice != previousGarminSettings.secondaryAttributeChoice + let commandRevocation = watchCommandAuthorization.current + let commandSettingsChanged = currentGarminSettings.isCommandControlEnabled != previousGarminSettings + .isCommandControlEnabled || + currentGarminSettings.isBolusCommandEnabled != previousGarminSettings.isBolusCommandEnabled || + commandRevocation != lastSeenCommandRevocation + lastSeenCommandRevocation = commandRevocation + let maxCarbsChanged = settingsManager.settings.maxCarbs != previousMaxCarbs + previousMaxCarbs = settingsManager.settings.maxCarbs // Update stored values units = currentUnits @@ -1270,6 +1306,8 @@ extension BaseGarminManager: SettingsObserver { debug(.watch, "Garmin watchface data enabled, sending update") } triggerWatchStateUpdate(triggeredBy: "Settings") + } else if commandSettingsChanged { + triggerWatchStateUpdate(triggeredBy: "CommandSettings") } else if unitsChanged || displayAttributesChanged || colorSchemeChanged { // Throttle other settings changes in case user makes multiple changes if debugWatchState { @@ -1278,6 +1316,12 @@ extension BaseGarminManager: SettingsObserver { sendSettingsUpdateThrottled() } + // the complication builds its menu and pickers from these; max bolus and bolus increment live + // outside TrioSettings and reach it with the next preset request + if commandSettingsChanged || maxCarbsChanged { + pushPresetsToComplications(triggeredBy: "CommandSettings") + } + // Store current Garmin settings for next comparison previousGarminSettings = currentGarminSettings } diff --git a/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift b/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift index 890652c51e..d50f456956 100644 --- a/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift +++ b/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift @@ -5,7 +5,11 @@ enum GarminInboundMessage: Equatable { /// Bare `"status"` string sent by watch apps that predate the v1 envelope. case legacyStatus case status - case invalid + case presets + /// `requestIDString` is echoed back verbatim so the watch can match the ack. + case command(requestIDString: String, requestID: UUID, date: Date, command: WatchCommand) + /// Malformed or unsupported; carries the request ID when the envelope still named one to answer. + case invalid(requestID: String?) } /// Garmin v1 wire contract: parsing inbound envelopes and building the targeted replies. @@ -14,24 +18,107 @@ enum GarminMessageEnvelope { enum Request: String { case status + case presets + case command + case ack } private static let readEnvelopeKeys: Set = [WatchMessageKeys.protocolVersion, WatchMessageKeys.request] + private static let commandEnvelopeKeys: Set = [ + WatchMessageKeys.protocolVersion, + WatchMessageKeys.request, + WatchMessageKeys.requestID, + WatchMessageKeys.date, + WatchMessageKeys.command, + WatchMessageKeys.payload + ] static func parse(_ message: Any) -> GarminInboundMessage { if let string = message as? String { - return string == Request.status.rawValue ? .legacyStatus : .invalid + return string == Request.status.rawValue ? .legacyStatus : .invalid(requestID: nil) } - guard let envelope = message as? [String: Any], - integer(envelope[WatchMessageKeys.protocolVersion]) == Int64(protocolVersion), - let request = (envelope[WatchMessageKeys.request] as? String).flatMap(Request.init(rawValue:)), - Set(envelope.keys) == readEnvelopeKeys + guard let envelope = message as? [String: Any] else { + return .invalid(requestID: nil) + } + + let requestIDString = envelope[WatchMessageKeys.requestID] as? String + let keys = Set(envelope.keys) + guard integer(envelope[WatchMessageKeys.protocolVersion]) == Int64(protocolVersion), + let request = (envelope[WatchMessageKeys.request] as? String).flatMap(Request.init(rawValue:)) else { - return .invalid + return .invalid(requestID: requestIDString) } switch request { - case .status: return .status + case .status: + return keys == readEnvelopeKeys ? .status : .invalid(requestID: requestIDString) + case .presets: + return keys == readEnvelopeKeys ? .presets : .invalid(requestID: requestIDString) + case .command: + guard keys == commandEnvelopeKeys, + let requestIDString, + let requestID = rfc4122UUID(requestIDString), + let milliseconds = integer(envelope[WatchMessageKeys.date]), milliseconds >= 0, + let name = envelope[WatchMessageKeys.command] as? String, + let payload = envelope[WatchMessageKeys.payload] as? [String: Any], + let command = command(named: name, payload: payload) + else { + return .invalid(requestID: requestIDString) + } + let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000) + return .command(requestIDString: requestIDString, requestID: requestID, date: date, command: command) + case .ack: + return .invalid(requestID: requestIDString) + } + } + + static func acknowledgment(requestID: String, result: WatchCommandResult) -> [String: Any] { + [ + WatchMessageKeys.protocolVersion: protocolVersion, + WatchMessageKeys.request: Request.ack.rawValue, + WatchMessageKeys.requestID: requestID, + WatchMessageKeys.acknowledged: result.acknowledged, + WatchMessageKeys.ackCode: result.ackCode.rawValue, + WatchMessageKeys.message: result.message + ] + } + + /// The watch reads the switches and limits from the preset reply, so they ride along; the preset + /// lists stay empty until adjustment commands exist. Amounts go out as doubles in U and g. + static func presetsResponse(capabilities: WatchCommandCapabilities) -> [String: Any] { + [ + WatchMessageKeys.protocolVersion: protocolVersion, + WatchMessageKeys.request: Request.presets.rawValue, + WatchMessageKeys.overridePresets: [[String: Any]](), + WatchMessageKeys.tempTargetPresets: [[String: Any]](), + WatchMessageKeys.isCommandControlEnabled: capabilities.isCommandControlEnabled, + WatchMessageKeys.isBolusCommandEnabled: capabilities.isBolusCommandEnabled, + WatchMessageKeys.maxBolus: NSDecimalNumber(decimal: capabilities.maxBolus).doubleValue, + WatchMessageKeys.maxCarbs: NSDecimalNumber(decimal: capabilities.maxCarbs).doubleValue, + WatchMessageKeys.bolusIncrement: NSDecimalNumber(decimal: capabilities.bolusIncrement).doubleValue + ] + } + + // MARK: - Payloads + + private static func command(named name: String, payload: [String: Any]) -> WatchCommand? { + let keys = Set(payload.keys) + + switch name { + case WatchMessageKeys.bolus: + guard keys == [WatchMessageKeys.bolus], let units = units(payload[WatchMessageKeys.bolus]) else { return nil } + return .bolus(units) + case WatchMessageKeys.carbs: + guard keys == [WatchMessageKeys.carbs], let grams = grams(payload[WatchMessageKeys.carbs]) else { return nil } + return .carbs(grams) + case WatchMessageKeys.mealBolus: + guard keys == [WatchMessageKeys.carbs, WatchMessageKeys.bolus], + let grams = grams(payload[WatchMessageKeys.carbs]), + let units = units(payload[WatchMessageKeys.bolus]) + else { return nil } + return .mealBolus(carbs: grams, bolus: units) + default: + return nil } } @@ -49,6 +136,25 @@ enum GarminMessageEnvelope { guard double.rounded() == double, abs(double) < 9.0E15 else { return nil } return number.int64Value } + + private static func grams(_ value: Any?) -> Int? { + integer(value).flatMap { Int(exactly: $0) } + } + + /// Rounds to 0.001 U: a Monkey C `Float` of 0.1 arrives as 0.10000000149. + private static func units(_ value: Any?) -> Decimal? { + guard let number = number(value) else { return nil } + var raw = Decimal(number.doubleValue) + var rounded = Decimal() + NSDecimalRound(&rounded, &raw, 3, .plain) + return rounded + } + + static func rfc4122UUID(_ string: String) -> UUID? { + guard string.count == 36, let uuid = UUID(uuidString: string) else { return nil } + // variant bits 10xx mark the RFC 4122 layout + return uuid.uuid.8 & 0xC0 == 0x80 ? uuid : nil + } } /// Connect IQ side of watch messages: one inbound message becomes at most one reply for the @@ -63,6 +169,7 @@ struct GarminMessageRouter { static let none = Outcome(reply: nil, refreshTrigger: nil) } + let processor: WatchCommandProcessor var log: WatchCommandLogger = .app func route(_ message: Any, from appUUID: UUID, isRegistered: Bool, appName: String) async -> Outcome { @@ -79,17 +186,37 @@ struct GarminMessageRouter { .status: return Outcome(reply: nil, refreshTrigger: "WatchRequest") - case .invalid: - return .none + case .presets: + return Outcome( + reply: GarminMessageEnvelope.presetsResponse(capabilities: processor.capabilities()), + refreshTrigger: nil + ) + + case let .command(requestIDString, requestID, date, command): + let request = WatchCommandRequest(requestID: requestID, appUUID: appUUID, date: date, command: command) + let result = await processor.process(request) + return Outcome( + reply: GarminMessageEnvelope.acknowledgment(requestID: requestIDString, result: result), + refreshTrigger: result.refreshesState ? "WatchCommand" : nil + ) + + case let .invalid(requestID): + // without a request ID the watch could not match an answer, so none is sent + guard let requestID else { return .none } + let result = WatchCommandResult.failure(String(localized: "Invalid command.", comment: "Watch command ack")) + return Outcome(reply: GarminMessageEnvelope.acknowledgment(requestID: requestID, result: result), refreshTrigger: nil) } } } private extension GarminInboundMessage { + /// Never includes payload values: a command body can carry an insulin amount. var logName: String { switch self { case .legacyStatus: return "legacy status request" case .status: return "status request" + case .presets: return "presets request" + case let .command(_, _, _, command): return "\(command.name) command" case .invalid: return "invalid message" } } diff --git a/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift b/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift index 052c9feee5..e9e2ffd19e 100644 --- a/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift +++ b/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift @@ -4,31 +4,131 @@ import Testing @testable import Trio @Suite("Garmin Message Router Tests") struct GarminMessageRouterTests { + let requestID = "3f2b8c1e-4a5d-4e6f-9a7b-1c2d3e4f5a6b" let appUUID = UUID() + let milliseconds: UInt64 = 1_800_000_000_123 - private func router(logs: LogCapture? = nil) -> GarminMessageRouter { - var router = GarminMessageRouter() + private func router( + _ processor: WatchCommandProcessor = SpyWatchCommandProcessor(), + logs: LogCapture? = nil + ) -> GarminMessageRouter { + var router = GarminMessageRouter(processor: processor) if let logs { router.log = logs.logger } return router } + private func envelope(command: String, payload: [String: Any]) -> [String: Any] { + [ + "v": 1, + "req": "command", + "requestId": requestID, + "date": NSNumber(value: milliseconds), + "command": command, + "payload": payload + ] + } + + private func parsedCommand(_ message: Any) -> WatchCommand? { + guard case let .command(_, _, _, command) = GarminMessageEnvelope.parse(message) else { return nil } + return command + } + // MARK: - Parsing @Test("Bare status string is the legacy status request") func testLegacyStatus() { #expect(GarminMessageEnvelope.parse("status") == .legacyStatus) - #expect(GarminMessageEnvelope.parse("bolus") == .invalid) + #expect(GarminMessageEnvelope.parse("bolus") == .invalid(requestID: nil)) } - @Test("v1 status requests carry no other fields") func testReadRequests() { + @Test("v1 status and presets requests carry no other fields") func testReadRequests() { #expect(GarminMessageEnvelope.parse(["v": 1, "req": "status"]) == .status) - #expect(GarminMessageEnvelope.parse(["v": 1, "req": "status", "extra": true]) == .invalid) - #expect(GarminMessageEnvelope.parse(["v": 2, "req": "status"]) == .invalid) - #expect(GarminMessageEnvelope.parse(["v": "1", "req": "status"]) == .invalid) - #expect(GarminMessageEnvelope.parse(["v": true, "req": "status"]) == .invalid) - #expect(GarminMessageEnvelope.parse(["v": 1, "req": "unknown"]) == .invalid) - #expect(GarminMessageEnvelope.parse(42) == .invalid) + #expect(GarminMessageEnvelope.parse(["v": 1, "req": "presets"]) == .presets) + #expect(GarminMessageEnvelope.parse(["v": 1, "req": "presets", "extra": true]) == .invalid(requestID: nil)) + #expect(GarminMessageEnvelope.parse(["v": 2, "req": "status"]) == .invalid(requestID: nil)) + #expect(GarminMessageEnvelope.parse(["v": "1", "req": "status"]) == .invalid(requestID: nil)) + #expect(GarminMessageEnvelope.parse(["v": true, "req": "status"]) == .invalid(requestID: nil)) + #expect(GarminMessageEnvelope.parse(["v": 1, "req": "unknown"]) == .invalid(requestID: nil)) + #expect(GarminMessageEnvelope.parse(42) == .invalid(requestID: nil)) + } + + @Test("Every treatment command parses with its exact payload") func testCommands() { + #expect(parsedCommand(envelope(command: "bolus", payload: ["bolus": 1.5])) == .bolus(1.5)) + #expect(parsedCommand(envelope(command: "bolus", payload: ["bolus": 2])) == .bolus(2)) + #expect(parsedCommand(envelope(command: "carbs", payload: ["carbs": 45])) == .carbs(45)) + #expect( + parsedCommand(envelope(command: "mealBolus", payload: ["carbs": 45, "bolus": 3.25])) + == .mealBolus(carbs: 45, bolus: 3.25) + ) + } + + @Test("The command date is read as Unix milliseconds") func testDate() { + guard case let .command(echoedID, uuid, date, _) = GarminMessageEnvelope.parse( + envelope(command: "carbs", payload: ["carbs": 10]) + ) else { + Issue.record("Expected a command") + return + } + #expect(echoedID == requestID) + #expect(uuid == UUID(uuidString: requestID)) + #expect(abs(date.timeIntervalSince1970 - 1_800_000_000.123) < 0.000_1) + } + + @Test("A Monkey C Float bolus is rounded to 0.001 U") func testFloatRounding() { + let payload: [String: Any] = ["bolus": NSNumber(value: Float(0.1))] + #expect(parsedCommand(envelope(command: "bolus", payload: payload)) == .bolus(Decimal(string: "0.1")!)) + } + + @Test("Malformed commands are rejected but keep their request ID for the ack") func testMalformedPayloads() { + let cases: [(command: String, payload: [String: Any])] = [ + ("bolus", ["bolus": true]), + ("bolus", ["bolus": "1"]), + ("bolus", ["bolus": 1, "note": "x"]), + ("bolus", [:]), + ("carbs", ["carbs": 12.5]), + ("carbs", ["carbs": "12"]), + ("mealBolus", ["carbs": 20]), + ("requestBolusRecommendation", [:]), + ("unknown", [:]) + ] + for (command, payload) in cases { + #expect( + GarminMessageEnvelope.parse(envelope(command: command, payload: payload)) == .invalid(requestID: requestID), + "\(command) with \(payload.keys.sorted())" + ) + } + } + + @Test("Malformed envelopes are rejected") func testMalformedEnvelopes() { + var extraField = envelope(command: "carbs", payload: ["carbs": 10]) + extraField["auth"] = ["scheme": "hmac-sha256"] + #expect(GarminMessageEnvelope.parse(extraField) == .invalid(requestID: requestID)) + + var missingDate = envelope(command: "carbs", payload: ["carbs": 10]) + missingDate["date"] = nil + #expect(GarminMessageEnvelope.parse(missingDate) == .invalid(requestID: requestID)) + + var negativeDate = envelope(command: "carbs", payload: ["carbs": 10]) + negativeDate["date"] = -1 + #expect(GarminMessageEnvelope.parse(negativeDate) == .invalid(requestID: requestID)) + + var badPayload = envelope(command: "carbs", payload: ["carbs": 10]) + badPayload["payload"] = "none" + #expect(GarminMessageEnvelope.parse(badPayload) == .invalid(requestID: requestID)) + + var wrongVersion = envelope(command: "carbs", payload: ["carbs": 10]) + wrongVersion["v"] = 2 + #expect(GarminMessageEnvelope.parse(wrongVersion) == .invalid(requestID: requestID)) + } + + @Test("Request IDs must be RFC 4122 UUIDs") func testRequestIDFormat() { + #expect(GarminMessageEnvelope.rfc4122UUID(requestID) != nil) + #expect(GarminMessageEnvelope.rfc4122UUID(requestID.uppercased()) != nil) + // variant nibble 'c' is the Microsoft layout, not RFC 4122 + #expect(GarminMessageEnvelope.rfc4122UUID("3f2b8c1e-4a5d-4e6f-ca7b-1c2d3e4f5a6b") == nil) + #expect(GarminMessageEnvelope.rfc4122UUID("3f2b8c1e4a5d4e6f9a7b1c2d3e4f5a6b") == nil) + #expect(GarminMessageEnvelope.rfc4122UUID("not-a-uuid") == nil) } // MARK: - Routing @@ -42,11 +142,18 @@ import Testing } } - @Test("Unregistered apps are ignored") func testUnregisteredApp() async { - let outcome = await router().route("status", from: appUUID, isRegistered: false, appName: "test") + @Test("Unregistered apps are ignored without executing") func testUnregisteredApp() async { + let processor = SpyWatchCommandProcessor() + let outcome = await router(processor).route( + envelope(command: "carbs", payload: ["carbs": 10]), + from: appUUID, + isRegistered: false, + appName: "test" + ) #expect(outcome.reply == nil) #expect(outcome.refreshTrigger == nil) + #expect(processor.processed.isEmpty) } @Test("Invalid messages get neither a reply nor a refresh") func testInvalidIgnored() async { @@ -55,4 +162,128 @@ import Testing #expect(outcome.reply == nil) #expect(outcome.refreshTrigger == nil) } + + @Test("A preset request gets the switches and limits the watch reads, with empty preset lists") func testPresetsReply( + ) async throws { + let processor = SpyWatchCommandProcessor() + processor.capabilitiesResult = WatchCommandCapabilities( + isCommandControlEnabled: true, + isBolusCommandEnabled: false, + maxBolus: 5, + maxCarbs: 120, + bolusIncrement: 0.05 + ) + + let outcome = await router(processor) + .route(["v": 1, "req": "presets"], from: appUUID, isRegistered: true, appName: "test") + + let reply = try #require(outcome.reply) + #expect(Set(reply.keys) == [ + "v", "req", "overridePresets", "tempTargetPresets", + "isCommandControlEnabled", "isBolusCommandEnabled", "maxBolus", "maxCarbs", "bolusIncrement" + ]) + #expect(reply["req"] as? String == "presets") + #expect((reply["overridePresets"] as? [[String: Any]])?.isEmpty == true) + #expect((reply["tempTargetPresets"] as? [[String: Any]])?.isEmpty == true) + #expect(reply["isCommandControlEnabled"] as? Bool == true) + #expect(reply["maxCarbs"] as? Double == 120) + // a Decimal float literal is not exact, so compare with a tolerance + #expect(abs((reply["bolusIncrement"] as? Double ?? 0) - 0.05) < 1E-9) + #expect(outcome.refreshTrigger == nil) + } + + @Test("A command gets exactly the v1 ack and triggers a refresh on success") func testCommandAck() async throws { + let processor = SpyWatchCommandProcessor() + let sut = router(processor) + + let outcome = await sut.route( + envelope(command: "carbs", payload: ["carbs": 10]), + from: appUUID, + isRegistered: true, + appName: "test" + ) + + let reply = try #require(outcome.reply) + #expect(Set(reply.keys) == ["v", "req", "requestId", "acknowledged", "ackCode", "message"]) + #expect(reply["v"] as? Int == 1) + #expect(reply["req"] as? String == "ack") + #expect(reply["requestId"] as? String == requestID) + #expect(reply["acknowledged"] as? Bool == true) + #expect(reply["ackCode"] as? String == "carbs_logged") + #expect(outcome.refreshTrigger == "WatchCommand") + + #expect(processor.processed.count == 1) + #expect(processor.processed.first?.appUUID == appUUID) + #expect(processor.processed.first?.command == .carbs(10)) + } + + @Test("Failures do not refresh; partial failures do") func testRefreshRules() async { + let processor = SpyWatchCommandProcessor() + let sut = router(processor) + let message = envelope(command: "mealBolus", payload: ["carbs": 10, "bolus": 1]) + + processor.result = .failure("Watch commands are disabled.") + let failed = await sut.route(message, from: appUUID, isRegistered: true, appName: "test") + #expect(failed.refreshTrigger == nil) + #expect(failed.reply?["ackCode"] as? String == "failure") + + processor.result = WatchCommandResult(acknowledged: false, ackCode: .partialFailure, message: "Carbs logged.") + let partial = await sut.route(message, from: appUUID, isRegistered: true, appName: "test") + #expect(partial.refreshTrigger == "WatchCommand") + #expect(partial.reply?["ackCode"] as? String == "partial_failure") + } + + @Test("Invalid commands are answered only when they name a request ID") func testInvalidReplies() async { + let processor = SpyWatchCommandProcessor() + let sut = router(processor) + + let named = await sut.route( + envelope(command: "carbs", payload: ["carbs": "ten"]), + from: appUUID, + isRegistered: true, + appName: "test" + ) + #expect(named.reply?["requestId"] as? String == requestID) + #expect(named.reply?["acknowledged"] as? Bool == false) + #expect(named.refreshTrigger == nil) + + let anonymous = await sut.route(["v": 1, "req": "command"], from: appUUID, isRegistered: true, appName: "test") + #expect(anonymous.reply == nil) + #expect(processor.processed.isEmpty) + } +} + +@Suite("Garmin Command Settings Migration Tests") struct GarminCommandSettingsMigrationTests { + @Test("Settings written before commands existed decode with both switches off") func testMissingKeysDefaultOff() throws { + let settings = try JSONDecoder().decode(TrioSettings.self, from: Data(#"{"isWatchfaceDataEnabled": true}"#.utf8)) + + #expect(settings.isWatchfaceDataEnabled == true) + #expect(settings.garminSettings.isCommandControlEnabled == false) + #expect(settings.garminSettings.isBolusCommandEnabled == false) + } + + @Test("Command switches survive an encode/decode round trip") func testRoundTrip() throws { + var settings = TrioSettings() + settings.garminSettings.isCommandControlEnabled = true + settings.garminSettings.isBolusCommandEnabled = true + + let decoded = try JSONDecoder().decode(TrioSettings.self, from: JSONEncoder().encode(settings)) + + #expect(decoded.isGarminCommandControlEnabled == true) + #expect(decoded.isGarminBolusCommandEnabled == true) + #expect(decoded.garminSettings == settings.garminSettings) + } + + @Test("GarminWatchSettings without command keys keeps its other values") func testGroupedSettingsMigration() throws { + let json = + #"{"watchface": "swissalpine", "datafield": "swissalpine", "primaryAttributeChoice": "isf", "secondaryAttributeChoice": "eventualBG", "isWatchfaceDataEnabled": true}"# + let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(json.utf8)) + + #expect(settings.watchface == .swissalpine) + #expect(settings.datafield == .swissalpine) + #expect(settings.primaryAttributeChoice == .isf) + #expect(settings.isWatchfaceDataEnabled == true) + #expect(settings.isCommandControlEnabled == false) + #expect(settings.isBolusCommandEnabled == false) + } } diff --git a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift new file mode 100644 index 0000000000..da75613fa9 --- /dev/null +++ b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift @@ -0,0 +1,503 @@ +import Combine +import ConnectIQ +import CoreData +import Foundation +import Swinject +import Testing + +@testable import Trio + +/// A task context whose saves fail the way a full disk or a validation error would. +final class FailingSaveContext: NSManagedObjectContext { + override func save() throws { + throw NSError( + domain: NSCocoaErrorDomain, + code: NSValidationMultipleErrorsError, + userInfo: [NSLocalizedDescriptionKey: "carbs=40 \(SensitiveTestError.marker)"] + ) + } +} + +/// Counts saves, to prove a transaction wrote nothing. +final class CountingSaveContext: NSManagedObjectContext, @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var saveCount: Int { lock.withLock { count } } + + override func save() throws { + lock.withLock { count += 1 } + try super.save() + } +} + +/// Holds a context's private queue from inside `perform`, so later transactions queue up behind it. +final class ContextQueueBlocker: @unchecked Sendable { + private let released = DispatchSemaphore(value: 0) + + func block(_ context: NSManagedObjectContext) { + context.perform { self.released.wait() } + } + + func release() { + released.signal() + } +} + +/// Overrides the storage and safety services the production watch actions use. +private final class WatchCommandOverrideAssembly: Assembly { + private let carbsContext: () -> NSManagedObjectContext + private let validator: BolusSafetyValidator + private let processor: WatchCommandProcessor? + private let apsManager: APSManager? + + init( + carbsContext: @escaping () -> NSManagedObjectContext, + validator: BolusSafetyValidator, + processor: WatchCommandProcessor?, + apsManager: APSManager? + ) { + self.carbsContext = carbsContext + self.validator = validator + self.processor = processor + self.apsManager = apsManager + } + + func assemble(container: Container) { + container.register(CarbsStorage.self) { [carbsContext] r in + BaseCarbsStorage(resolver: r, contextProvider: carbsContext) + }.inObjectScope(.container) + container.register(BolusSafetyValidator.self) { [validator] _ in validator } + if let processor { + container.register(WatchCommandProcessor.self) { _ in processor }.inObjectScope(.container) + } + if let apsManager { + container.register(APSManager.self) { _ in apsManager }.inObjectScope(.container) + } + } +} + +private func makeResolver( + stack: CoreDataStack, + carbsContext: @escaping () -> NSManagedObjectContext, + validator: BolusSafetyValidator = StubBolusSafetyValidator(), + processor: WatchCommandProcessor? = nil, + apsManager: APSManager? = nil +) -> Resolver { + Assembler([ + StorageAssembly(), + ServiceAssembly(), + APSAssembly(), + NetworkAssembly(), + UIAssembly(), + SecurityAssembly(), + TestAssembly(testContext: stack.newTaskContext()), + WatchCommandOverrideAssembly( + carbsContext: carbsContext, + validator: validator, + processor: processor, + apsManager: apsManager + ) + // same scope as the app, so the test and the managers share one settings manager + ], parent: nil, defaultObjectScope: .container).resolver +} + +// MARK: - Carb Persistence + +@Suite("Watch Command Carb Persistence Tests", .serialized) struct WatchCommandCarbPersistenceTests { + let stack: CoreDataStack + let failingContext: FailingSaveContext + + init() async throws { + stack = try await CoreDataStack.createForTests() + failingContext = FailingSaveContext(concurrencyType: .privateQueueConcurrencyType) + failingContext.persistentStoreCoordinator = stack.persistentContainer.persistentStoreCoordinator + } + + private func storedCarbRows() async throws -> Int { + let context = stack.newTaskContext() + return try await context.perform { + try context.count(for: NSFetchRequest(entityName: "CarbEntryStored")) + } + } + + @Test("A failed save throws from the verified path but stays silent for existing callers") func testStorageBoundary( + ) async throws { + let context = failingContext + let resolver = makeResolver(stack: stack, carbsContext: { context }) + let storage = try #require(resolver.resolve(CarbsStorage.self)) + + await #expect(throws: NSError.self) { + try await storage.storeVerifiedCarbs(storage.makeCarbEntry(carbs: 40, date: Date()), authorize: {}) + } + // unchanged contract for the app's own callers + try await storage.storeCarbs([storage.makeCarbEntry(carbs: 40, date: Date())], areFetchedFromRemote: false) + #expect(try await storedCarbRows() == 0) + } + + @Test("A successful verified save persists the carb row") func testStorageSuccess() async throws { + let stack = self.stack + let resolver = makeResolver(stack: stack, carbsContext: { stack.newTaskContext() }) + let storage = try #require(resolver.resolve(CarbsStorage.self)) + + try await storage.storeVerifiedCarbs(storage.makeCarbEntry(carbs: 25, date: Date()), authorize: {}) + + #expect(try await storedCarbRows() == 1) + } + + @Test("A real carb save failure never acks carbs or reaches the bolus") func testProductionActionFailure() async throws { + let context = failingContext + let validator = StubBolusSafetyValidator() + let resolver = makeResolver(stack: stack, carbsContext: { context }, validator: validator) + let settings = try #require(resolver.resolve(SettingsManager.self)) + settings.settings.isGarminCommandControlEnabled = true + settings.settings.isGarminBolusCommandEnabled = true + + let logs = LogCapture() + let processor = BaseWatchCommandProcessor( + resolver: resolver, + actions: BaseWatchCommandActions(resolver: resolver), + log: logs.logger + ) + let appUUID = UUID() + + let carbs = await processor.process( + WatchCommandRequest(requestID: UUID(), appUUID: appUUID, date: Date(), command: .carbs(40)) + ) + let meal = await processor.process( + WatchCommandRequest(requestID: UUID(), appUUID: appUUID, date: Date(), command: .mealBolus(carbs: 40, bolus: 2)) + ) + + #expect(carbs.acknowledged == false) + #expect(carbs.ackCode == .genericFailure) + #expect(meal.acknowledged == false) + #expect(meal.ackCode == .genericFailure, "No partial failure: nothing was logged") + #expect(validator.validatedAmounts.isEmpty, "The bolus stage is never reached") + #expect(try await storedCarbRows() == 0) + #expect(logs.lines.contains { $0.contains("(cocoa(\(NSValidationMultipleErrorsError)))") }) + #expect(logs.lines.allSatisfy { !$0.contains(SensitiveTestError.marker) }) + } +} + +// MARK: - Garmin Manager + +@Suite("Garmin Manager Command Integration Tests", .serialized) final class GarminManagerCommandIntegrationTests { + let complicationUUID = GarminWatchface.complication.watchfaceUUID! + let datafieldUUID = GarminDatafield.swissalpine.datafieldUUID! + let deviceA = IQDevice(id: UUID(), modelName: "fenix 8", friendlyName: "A")! + let deviceB = IQDevice(id: UUID(), modelName: "Enduro 3", friendlyName: "B")! + + let processor: SpyWatchCommandProcessor + let client: SpyConnectIQClient + let settings: SettingsManager + let authorization: WatchCommandAuthorization + let manager: BaseGarminManager + + init() async throws { + let stack = try await CoreDataStack.createForTests() + let processor = SpyWatchCommandProcessor() + let client = SpyConnectIQClient() + let resolver = makeResolver(stack: stack, carbsContext: { stack.newTaskContext() }, processor: processor) + + let settings = try #require(resolver.resolve(SettingsManager.self)) + settings.settings.garminSettings.watchface = .complication + settings.settings.garminSettings.isWatchfaceDataEnabled = true + settings.settings.garminSettings.datafield = .swissalpine + settings.settings.garminSettings.isCommandControlEnabled = true + // settings persist on the simulator between runs, so start from a known state + settings.settings.garminSettings.isBolusCommandEnabled = false + settings.settings.maxCarbs = 100 + + self.processor = processor + self.client = client + self.settings = settings + authorization = try #require(resolver.resolve(WatchCommandAuthorization.self)) + manager = BaseGarminManager( + resolver: resolver, + connectIQClient: client, + sendRetryDelay: 0.01, + broadcastDebounce: .milliseconds(10) + ) + manager.updateDeviceList([deviceA, deviceB]) + } + + deinit { + // the device list is persisted in user defaults + manager.updateDeviceList([]) + } + + private func app(_ uuid: UUID, on device: IQDevice) -> IQApp { + IQApp(uuid: uuid, store: UUID(), device: device) + } + + private func command(_ name: String, payload: [String: Any], requestID: String) -> [String: Any] { + [ + "v": 1, + "req": "command", + "requestId": requestID, + "date": NSNumber(value: UInt64(Date().timeIntervalSince1970 * 1000)), + "command": name, + "payload": payload + ] + } + + private func sent(_ request: String) -> [SpyConnectIQClient.Sent] { + client.sent.filter { $0.request == request } + } + + @Test("An ack goes only to the app and device that sent the command") func testAckTargetsOriginatingApp() async { + let requestID = UUID().uuidString + manager.receivedMessage( + command("carbs", payload: ["carbs": 20], requestID: requestID), + from: app(complicationUUID, on: deviceB) + ) + + #expect(await eventually { !self.sent("ack").isEmpty }) + let acks = sent("ack") + #expect(acks.count == 1) + #expect(acks.allSatisfy { $0.appUUID == complicationUUID && $0.deviceUUID == deviceB.uuid }) + #expect(acks.first.flatMap { ($0.message as? [String: Any])?["requestId"] as? String } == requestID) + #expect(processor.processed.map(\.appUUID) == [complicationUUID]) + #expect( + client.sent.filter { !$0.isState }.allSatisfy { $0.appUUID != datafieldUUID }, + "Nothing command-related is fanned out" + ) + } + + @Test("A failed ack is resent once to the same app and never owed") func testAckRetryTarget() async { + client.script([.failure_Timeout, .failure_Timeout], for: complicationUUID, on: deviceA.uuid) + let requestID = UUID().uuidString + manager.receivedMessage( + command("carbs", payload: ["carbs": 20], requestID: requestID), + from: app(complicationUUID, on: deviceA) + ) + + #expect(await eventually { self.sent("ack").count == 2 }) + let acks = sent("ack") + #expect(acks.allSatisfy { $0.appUUID == complicationUUID && $0.deviceUUID == deviceA.uuid }) + #expect(acks.allSatisfy { ($0.message as? [String: Any])?["requestId"] as? String == requestID }) + + // the watch expires an unanswered command itself, so later deliveries carry no stale ack + manager.sendWatchStateData(try! JSONSerialization.data(withJSONObject: [["sgv": 120]])) + #expect(await eventually { self.client.sent.contains { $0.isState } }) + try? await Task.sleep(nanoseconds: 150_000_000) + #expect(sent("ack").count == 2) + } + + @Test( + "Command switch and max carbs changes push the preset reply to Complication apps only" + ) func testSettingsPushPresets() async { + settings.settings.garminSettings.isBolusCommandEnabled = true + + #expect(await eventually { self.sent("presets").count == 2 }) + #expect(sent("presets").allSatisfy { $0.appUUID == complicationUUID }) + + settings.settings.maxCarbs = 99 + #expect(await eventually { self.sent("presets").count == 4 }) + + settings.settings.garminSettings.primaryAttributeChoice = .isf + try? await Task.sleep(nanoseconds: 200_000_000) + #expect(sent("presets").count == 4, "Display settings do not push the preset reply") + } + + @Test("A command from an unregistered device is ignored") func testUnregisteredDevice() async throws { + let stranger = try #require(IQDevice(id: UUID(), modelName: "fenix 8", friendlyName: "C")) + manager.receivedMessage( + command("carbs", payload: ["carbs": 20], requestID: UUID().uuidString), + from: app(complicationUUID, on: stranger) + ) + + try await Task.sleep(nanoseconds: 200_000_000) + #expect(client.sent.isEmpty) + #expect(processor.processed.isEmpty) + } + + @Test("Switching a command setting off revokes in the setter; changes refresh the watch") func testSettingsRevoke() async { + let triggers = RefreshTriggerRecorder(manager) + let start = authorization.current + + settings.settings.garminSettings.isCommandControlEnabled = false + #expect(authorization.current == start + 1, "Revoked before the notification is even queued") + #expect(await eventually { triggers.count("CommandSettings") == 1 }) + + settings.settings.garminSettings.isCommandControlEnabled = true + settings.settings.garminSettings.isBolusCommandEnabled = true + #expect(authorization.current == start + 1, "Turning settings on revokes nothing") + #expect(await eventually { triggers.count("CommandSettings") >= 2 }) + + let refreshes = triggers.count("CommandSettings") + settings.settings.garminSettings.primaryAttributeChoice = .isf + try? await Task.sleep(nanoseconds: 200_000_000) + #expect(authorization.current == start + 1, "Display settings leave commands alone") + #expect(triggers.count("CommandSettings") == refreshes) + } +} + +// MARK: - Revocation And Final Boundaries + +/// Runs revocation through the production settings manager, Garmin manager, processor and storage; +/// only the pump, the safety validator and the Connect IQ SDK are stand-ins. +@Suite("Watch Command Revocation Integration Tests", .serialized) struct WatchCommandRevocationIntegrationTests { + let stack: CoreDataStack + + init() async throws { + stack = try await CoreDataStack.createForTests() + } + + private func countingContext() -> CountingSaveContext { + let context = CountingSaveContext(concurrencyType: .privateQueueConcurrencyType) + context.persistentStoreCoordinator = stack.persistentContainer.persistentStoreCoordinator + return context + } + + private func carbRows() async throws -> Int { + let context = stack.newTaskContext() + return try await context.perform { + try context.count(for: NSFetchRequest(entityName: "CarbEntryStored")) + } + } + + private func enableCommands(_ settings: SettingsManager) { + settings.settings.isGarminCommandControlEnabled = true + settings.settings.isGarminBolusCommandEnabled = true + } + + private func request(_ command: WatchCommand) -> WatchCommandRequest { + WatchCommandRequest(requestID: UUID(), appUUID: UUID(), date: Date(), command: command) + } + + // MARK: R1 + + @Test( + "Off-then-on before the settings notification drains still stops a suspended bolus", + arguments: [false, true] + ) func testRevokedBeforeNotificationDrains(isMeal: Bool) async throws { + let stack = self.stack + let validator = StubBolusSafetyValidator() + validator.gate = TestGate() + let pump = SpyAPSManager() + let client = SpyConnectIQClient() + let resolver = makeResolver( + stack: stack, + carbsContext: { stack.newTaskContext() }, + validator: validator, + apsManager: pump + ) + #expect(resolver.resolve(WatchCommandProcessor.self) is BaseWatchCommandProcessor) + let settings = try #require(resolver.resolve(SettingsManager.self)) + let authorization = try #require(resolver.resolve(WatchCommandAuthorization.self)) + settings.settings.garminSettings.watchface = .complication + settings.settings.garminSettings.isWatchfaceDataEnabled = true + enableCommands(settings) + + let device = try #require(IQDevice(id: UUID(), modelName: "fenix 8", friendlyName: "R1")) + let manager = BaseGarminManager( + resolver: resolver, + connectIQClient: client, + sendRetryDelay: 0.01, + broadcastDebounce: .milliseconds(10) + ) + manager.updateDeviceList([device]) + defer { manager.updateDeviceList([]) } + let triggers = RefreshTriggerRecorder(manager) + + let complicationUUID = try #require(GarminWatchface.complication.watchfaceUUID) + manager.receivedMessage( + [ + "v": 1, + "req": "command", + "requestId": UUID().uuidString, + "date": NSNumber(value: UInt64(Date().timeIntervalSince1970 * 1000)), + "command": isMeal ? "mealBolus" : "bolus", + "payload": isMeal ? ["carbs": 30, "bolus": 2] : ["bolus": 2] + ] as [String: Any], + from: IQApp(uuid: complicationUUID, store: UUID(), device: device) + ) + await validator.gate.waitForArrivals() + + let start = authorization.current + let revokedInsideBlock = await MainActor.run { + // the main queue cannot deliver a settings notification before this block returns + settings.settings.garminSettings.isCommandControlEnabled = false + settings.settings.garminSettings.isCommandControlEnabled = true + settings.settings.garminSettings.isBolusCommandEnabled = false + settings.settings.garminSettings.isBolusCommandEnabled = true + return authorization.current - start + } + #expect(revokedInsideBlock == 2) + validator.gate.open() + + #expect(await eventually { client.sent.contains { $0.request == "ack" } }) + let ack = try #require(client.sent.first { $0.request == "ack" }?.message as? [String: Any]) + #expect(ack[WatchMessageKeys.ackCode] as? String == (isMeal ? "partial_failure" : "failure")) + #expect(pump.bolusRequests.isEmpty, "No pump request after the revocation") + #expect(try await carbRows() == (isMeal ? 1 : 0)) + #expect( + await eventually { triggers.count("CommandSettings") >= 1 }, + "Settings ended where they started, but the revocation still refreshes the watch" + ) + } + + @Test( + "Carbs queued behind a busy context are not stored once revoked", + arguments: [false, true] + ) func testCarbsRevokedWhileQueued(isMeal: Bool) async throws { + let context = countingContext() + let transactions = TestGate(isOpen: true) + let pump = SpyAPSManager() + let resolver = makeResolver( + stack: stack, + carbsContext: { + transactions.arrive() + return context + }, + apsManager: pump + ) + let settings = try #require(resolver.resolve(SettingsManager.self)) + enableCommands(settings) + let processor = BaseWatchCommandProcessor(resolver: resolver, actions: BaseWatchCommandActions(resolver: resolver)) + + let blocker = ContextQueueBlocker() + blocker.block(context) + let command: WatchCommand = isMeal ? .mealBolus(carbs: 40, bolus: 2) : .carbs(40) + let pending = Task { await processor.process(request(command)) } + await transactions.waitForArrivals() + + settings.settings.isGarminCommandControlEnabled = false + settings.settings.isGarminCommandControlEnabled = true + blocker.release() + let result = await pending.value + + #expect(result.ackCode == .genericFailure, "Nothing was logged, so this is no partial failure") + #expect(context.saveCount == 0) + #expect(try await carbRows() == 0) + #expect(pump.bolusRequests.isEmpty) + + #expect(await processor.process(request(.carbs(40))).acknowledged) + #expect(try await carbRows() == 1) + } + + @Test("The production bolus action checks right before it calls the pump") func testProductionIssuanceCheck() async throws { + let stack = self.stack + let pump = SpyAPSManager() + let resolver = makeResolver(stack: stack, carbsContext: { stack.newTaskContext() }, apsManager: pump) + let actions = BaseWatchCommandActions(resolver: resolver) + + await #expect(throws: WatchCommandRejection.self) { + try await actions.enactBolus(2) { throw WatchCommandRejection(result: .failure("revoked")) } + } + #expect(pump.bolusRequests.isEmpty) + + let checks = LogCapture() + let delivered = try await actions.enactBolus(2) { + checks.record(pump.bolusRequests.isEmpty ? "before" : "after") + } + #expect(delivered) + #expect(checks.lines == ["before"]) + #expect(pump.bolusRequests == [2]) + + pump.bolusSucceeds = false + let refused = try await actions.enactBolus(1) {} + #expect(refused == false) + } +} diff --git a/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift b/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift new file mode 100644 index 0000000000..744b0682dd --- /dev/null +++ b/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift @@ -0,0 +1,623 @@ +import Foundation +import Swinject +import Testing + +@testable import Trio + +/// What the processor decides before and around the side effects; pump and Core Data are stubbed. +@Suite("Watch Command Processor Tests") struct WatchCommandProcessorTests { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = TestClock(Date(timeIntervalSince1970: 1_800_000_000)) + let appUUID = UUID() + + let settings = StubSettingsManager() + let validator = StubBolusSafetyValidator() + let actions = StubWatchCommandActions() + let cache = WatchCommandRequestCache() + let logs = LogCapture() + let container = Container() + let processor: BaseWatchCommandProcessor + + init() { + settings.settings.isGarminCommandControlEnabled = true + settings.settings.isGarminBolusCommandEnabled = true + settings.settings.maxCarbs = 100 + + container.register(SettingsManager.self) { [settings] _ in settings } + container.register(BolusSafetyValidator.self) { [validator] _ in validator } + container.register(WatchCommandAuthorization.self) { [settings] _ in settings.authorization } + container.register(WatchCommandPipeline.self) { [settings, cache, clock, logs] _ in + WatchCommandPipeline(authorization: settings.authorization, cache: cache, now: { clock.now }, log: logs.logger) + }.inObjectScope(.container) + + processor = Self.makeProcessor(container: container, actions: actions, clock: clock, logs: logs) + } + + private static func makeProcessor( + container: Container, + actions: WatchCommandActions, + insulinLane: WatchCommandInsulinLane = WatchCommandInsulinLane(), + clock: TestClock, + logs: LogCapture + ) -> BaseWatchCommandProcessor { + BaseWatchCommandProcessor( + resolver: container, + actions: actions, + insulinLane: insulinLane, + now: { clock.now }, + log: logs.logger + ) + } + + private func request( + _ command: WatchCommand, + id: UUID = UUID(), + app: UUID? = nil, + age: TimeInterval = 5 + ) -> WatchCommandRequest { + WatchCommandRequest(requestID: id, appUUID: app ?? appUUID, date: now.addingTimeInterval(-age), command: command) + } + + // MARK: - Registration + + @Test("Processor is registered once for the whole app") func testContainerScoped() { + let resolver = TrioApp().resolver + let first = resolver.resolve(WatchCommandProcessor.self) + let second = resolver.resolve(WatchCommandProcessor.self) + #expect(first is BaseWatchCommandProcessor) + #expect(first as AnyObject? === second as AnyObject?, "One instance means one request-ID cache") + } + + // MARK: - Freshness + + @Test( + "Timestamps are accepted from 2 minutes old to 60 seconds ahead", + arguments: [ + (TimeInterval(120), true), + (TimeInterval(120.001), false), + (TimeInterval(-60), true), + (TimeInterval(-60.001), false), + (TimeInterval(0), true) + ] + ) func testFreshnessWindow(age: TimeInterval, accepted: Bool) async { + let result = await processor.process(request(.carbs(10), age: age)) + + #expect(result.acknowledged == accepted) + #expect(actions.storedCarbs.count == (accepted ? 1 : 0)) + } + + // MARK: - Gates + + @Test("Master switch off rejects every mutable command without side effects") func testMasterSwitchOff() async { + settings.settings.isGarminCommandControlEnabled = false + + let commands: [WatchCommand] = [.carbs(10), .bolus(1), .mealBolus(carbs: 10, bolus: 1)] + for command in commands { + let result = await processor.process(request(command)) + #expect(result.acknowledged == false) + #expect(result.ackCode == .genericFailure) + } + + #expect(actions.events.isEmpty) + #expect(validator.validatedAmounts.isEmpty) + } + + @Test("Bolus switch off rejects bolus and meal bolus but not carbs") func testBolusSwitchOff() async { + settings.settings.isGarminBolusCommandEnabled = false + + #expect(await processor.process(request(.bolus(1))).acknowledged == false) + #expect(await processor.process(request(.mealBolus(carbs: 20, bolus: 1))).acknowledged == false) + #expect(actions.events.isEmpty, "A rejected meal bolus must not log its carbs either") + + #expect(await processor.process(request(.carbs(20))).acknowledged == true) + } + + // MARK: - Carbs + + @Test("Carbs are stored with the command date") func testCarbs() async { + let request = request(.carbs(30), age: 90) + let result = await processor.process(request) + + #expect(result == .success(.carbsLogged, result.message)) + #expect(actions.storedCarbs.map(\.grams) == [30]) + #expect(actions.storedCarbs.first?.date == request.date) + #expect(result.refreshesState) + } + + @Test("Carbs above max carbs are rejected; exactly max carbs is allowed") func testMaxCarbs() async { + settings.settings.maxCarbs = 50 + + #expect(await processor.process(request(.carbs(51))).acknowledged == false) + #expect(await processor.process(request(.mealBolus(carbs: 51, bolus: 1))).acknowledged == false) + #expect(actions.events.isEmpty) + + #expect(await processor.process(request(.carbs(50))).acknowledged == true) + } + + @Test("Non-positive amounts are rejected") func testNonPositiveAmounts() async { + #expect(await processor.process(request(.carbs(0))).acknowledged == false) + #expect(await processor.process(request(.carbs(-5))).acknowledged == false) + #expect(await processor.process(request(.bolus(0))).acknowledged == false) + #expect(actions.events.isEmpty) + #expect(validator.validatedAmounts.isEmpty) + } + + @Test("A failed carb save is a failure") func testCarbsStoreFailure() async { + actions.carbsError = TestError("save failed") + let result = await processor.process(request(.carbs(10))) + + #expect(result.acknowledged == false) + #expect(result.ackCode == .genericFailure) + } + + // MARK: - Bolus + + @Test("An allowed bolus is enacted") func testBolusAllowed() async { + let result = await processor.process(request(.bolus(1.5))) + + #expect(result.acknowledged) + #expect(result.ackCode == .genericSuccess) + #expect(validator.validatedAmounts == [1.5]) + #expect(actions.enactedBoluses == [1.5]) + } + + @Test( + "Every safety rejection stops the bolus", + arguments: [ + BolusSafetyRejection.exceedsMaxBolus(maxBolus: 5), + .iobUnavailable, + .exceedsMaxIOB(currentIOB: 4, maxIOB: 5), + .recentBolusWithinWindow(totalRecent: 1) + ] + ) func testBolusSafetyRejections(reason: BolusSafetyRejection) async { + validator.result = .rejected(reason) + let result = await processor.process(request(.bolus(2))) + + #expect(result.acknowledged == false) + #expect(result.ackCode == .genericFailure) + #expect(actions.enactedBoluses.isEmpty) + #expect(!result.message.contains("2"), "Acks never carry treatment amounts") + } + + @Test("A pump failure is reported as a failure") func testBolusPumpFailure() async { + actions.bolusSucceeds = false + let result = await processor.process(request(.bolus(1))) + + #expect(result.acknowledged == false) + #expect(result.ackCode == .genericFailure) + } + + @Test("Recent-bolus lookback covers the standard window for every fresh command") func testLookbackStart() async { + let standardWindowStart = now.addingTimeInterval(-Double(BolusSafetyEvaluator.recentBolusWindowMinutes * 60)) + + // separate processors: a second bolus through one lane is stopped before validation. + // The freshness window is shorter than the recent-bolus window, so even the oldest + // accepted command looks back over the full standard window. + _ = await processor.process(request(.bolus(1), age: 0)) + let second = Self.makeProcessor(container: container, actions: StubWatchCommandActions(), clock: clock, logs: logs) + _ = await second.process(request(.bolus(1), age: WatchCommandPipeline.maximumCommandAge)) + + #expect(validator.lookbackStarts == [standardWindowStart, standardWindowStart]) + } + + // MARK: - Meal Bolus + + @Test("Meal bolus logs carbs before enacting the bolus") func testMealBolusOrder() async { + let result = await processor.process(request(.mealBolus(carbs: 40, bolus: 3))) + + #expect(result.acknowledged) + #expect(result.ackCode == .genericSuccess) + #expect(actions.events == ["carbs", "bolus"]) + #expect(actions.storedCarbs.map(\.grams) == [40]) + #expect(actions.enactedBoluses == [3]) + } + + @Test("A rejected meal bolus keeps the carbs and reports a partial failure") func testMealBolusPartialFailure() async { + validator.result = .rejected(.exceedsMaxIOB(currentIOB: 4, maxIOB: 5)) + let result = await processor.process(request(.mealBolus(carbs: 40, bolus: 3))) + + #expect(result.acknowledged == false) + #expect(result.ackCode == .partialFailure) + #expect(result.refreshesState, "Carbs changed, so the watch needs fresh state") + #expect(actions.storedCarbs.map(\.grams) == [40]) + #expect(actions.enactedBoluses.isEmpty) + } + + @Test("A pump failure after meal carbs is a partial failure") func testMealBolusPumpFailure() async { + actions.bolusSucceeds = false + let result = await processor.process(request(.mealBolus(carbs: 40, bolus: 3))) + + #expect(result.ackCode == .partialFailure) + #expect(actions.storedCarbs.count == 1) + } + + @Test("A failed meal carb save never reaches the bolus") func testMealBolusCarbsFailure() async { + actions.carbsError = TestError("save failed") + let result = await processor.process(request(.mealBolus(carbs: 40, bolus: 3))) + + #expect(result.ackCode == .genericFailure) + #expect(validator.validatedAmounts.isEmpty) + #expect(actions.enactedBoluses.isEmpty) + } + + // MARK: - Deduplication + + @Test("A completed duplicate replays its result without executing again") func testCompletedDuplicate() async { + let request = request(.bolus(1)) + + let first = await processor.process(request) + let second = await processor.process(request) + + #expect(first == second) + #expect(actions.enactedBoluses.count == 1) + #expect(validator.validatedAmounts.count == 1) + } + + @Test("Rejections are cached as terminal results too") func testRejectionCached() async { + settings.settings.isGarminCommandControlEnabled = false + let request = request(.carbs(10)) + let first = await processor.process(request) + + settings.settings.isGarminCommandControlEnabled = true + let second = await processor.process(request) + + #expect(first == second) + #expect(actions.storedCarbs.isEmpty) + } + + @Test("A duplicate of an in-flight request is rejected") func testInFlightDuplicate() async { + let request = request(.bolus(1)) + let key = WatchCommandRequestCache.Key(appUUID: request.appUUID, requestID: request.requestID) + _ = await cache.admit(key, command: request.command, now: now) + + let result = await processor.process(request) + + #expect(result.acknowledged == false) + #expect(result.ackCode == .inProgress, "Watches tell a running duplicate apart by code, not by text") + #expect(validator.validatedAmounts.isEmpty) + #expect(actions.enactedBoluses.isEmpty) + } + + @Test("Reusing a request ID for another command is rejected") func testConflict() async { + let id = UUID() + _ = await processor.process(request(.carbs(10), id: id)) + let result = await processor.process(request(.carbs(20), id: id)) + + #expect(result.acknowledged == false) + #expect(actions.storedCarbs.map(\.grams) == [10]) + } + + @Test("Request IDs are scoped per app") func testScopedPerApp() async { + let id = UUID() + _ = await processor.process(request(.carbs(10), id: id)) + _ = await processor.process(request(.carbs(10), id: id, app: UUID())) + + #expect(actions.storedCarbs.count == 2) + } + + // MARK: - Capabilities + + @Test("Capabilities report the command switches and limits as they are at call time") func testCapabilities() { + settings.settings.isGarminCommandControlEnabled = true + settings.settings.isGarminBolusCommandEnabled = true + settings.settings.maxCarbs = 120 + settings.pumpSettings = PumpSettings(insulinActionCurve: 10, maxBolus: 5, maxBasal: 2) + settings.preferences.bolusIncrement = 0.1 + + #expect(processor.capabilities() == WatchCommandCapabilities( + isCommandControlEnabled: true, + isBolusCommandEnabled: true, + maxBolus: 5, + maxCarbs: 120, + bolusIncrement: 0.1 + )) + + settings.settings.isGarminCommandControlEnabled = false + #expect(processor.capabilities().isCommandControlEnabled == false) + } + + // MARK: - Revocation + + @Test( + "Disabling commands while meal carbs save stops the bolus and reports a partial failure" + ) func testMasterRevokedDuringMealCarbs() async { + actions.carbsGate = TestGate() + let pending = Task { await processor.process(request(.mealBolus(carbs: 40, bolus: 3))) } + await actions.carbsGate.waitForArrivals() + + settings.settings.isGarminCommandControlEnabled = false + actions.carbsGate.open() + let result = await pending.value + + #expect(result.ackCode == .partialFailure) + #expect(actions.storedCarbs.map(\.grams) == [40]) + #expect(validator.validatedAmounts.isEmpty) + #expect(actions.enactedBoluses.isEmpty) + } + + @Test( + "Disabling bolus while validation is pending stops the bolus", + arguments: [false, true] + ) func testBolusRevokedDuringValidation(isMeal: Bool) async { + validator.gate = TestGate() + let command: WatchCommand = isMeal ? .mealBolus(carbs: 40, bolus: 3) : .bolus(3) + let pending = Task { await processor.process(request(command)) } + await validator.gate.waitForArrivals() + + settings.settings.isGarminBolusCommandEnabled = false + validator.gate.open() + let result = await pending.value + + #expect(result.acknowledged == false) + #expect(result.ackCode == (isMeal ? .partialFailure : .genericFailure)) + #expect(actions.enactedBoluses.isEmpty, "No insulin after the bolus switch went off") + } + + @Test( + "Switching either setting off and on again while pending still revokes", + arguments: [false, true] + ) func testRevokedAndReenabled(bolusSwitch: Bool) async { + validator.gate = TestGate() + let pending = Task { await processor.process(request(.bolus(2))) } + await validator.gate.waitForArrivals() + + // the setter revokes; nothing else is called + if bolusSwitch { + settings.settings.isGarminBolusCommandEnabled = false + settings.settings.isGarminBolusCommandEnabled = true + } else { + settings.settings.isGarminCommandControlEnabled = false + settings.settings.isGarminCommandControlEnabled = true + } + validator.gate.open() + let result = await pending.value + + #expect(result.acknowledged == false) + #expect(actions.enactedBoluses.isEmpty) + + #expect(await processor.process(request(.carbs(10))).acknowledged, "New commands run under the new settings") + } + + @Test("Turning a setting on revokes nothing") func testEnablingKeepsGrant() { + settings.settings.isGarminCommandControlEnabled = false + let epoch = settings.authorization.current + + settings.settings.isGarminCommandControlEnabled = true + settings.settings.maxCarbs = 80 + + #expect(settings.authorization.current == epoch) + } + + // MARK: - Final Boundary + + @Test( + "A revocation while carbs wait for their transaction stores nothing", + arguments: [false, true] + ) func testCarbsRevokedBeforeTransaction(isMeal: Bool) async { + actions.carbsTransactionGate = TestGate() + let command: WatchCommand = isMeal ? .mealBolus(carbs: 40, bolus: 3) : .carbs(40) + let pending = Task { await processor.process(request(command)) } + await actions.carbsTransactionGate.waitForArrivals() + + settings.settings.isGarminCommandControlEnabled = false + settings.settings.isGarminCommandControlEnabled = true + actions.carbsTransactionGate.open() + let result = await pending.value + + #expect(result.ackCode == .genericFailure, "Nothing was logged, so this is no partial failure") + #expect(actions.events.isEmpty) + #expect(validator.validatedAmounts.isEmpty) + } + + @Test( + "A revocation after validation but before issuance sends nothing to the pump", + arguments: [false, true] + ) func testRevokedBeforeIssuance(isMeal: Bool) async { + actions.issueGate = TestGate() + let command: WatchCommand = isMeal ? .mealBolus(carbs: 40, bolus: 3) : .bolus(3) + let pending = Task { await processor.process(request(command)) } + await actions.issueGate.waitForArrivals() + #expect(validator.validatedAmounts == [3], "Validation already passed") + + settings.settings.isGarminBolusCommandEnabled = false + settings.settings.isGarminBolusCommandEnabled = true + actions.issueGate.open() + let result = await pending.value + + #expect(result.acknowledged == false) + #expect(result.ackCode == (isMeal ? .partialFailure : .genericFailure)) + #expect(actions.storedCarbs.map(\.grams) == (isMeal ? [40] : [])) + #expect(actions.enactedBoluses.isEmpty) + } + + @Test("A bolus stopped before issuance does not hold the lane") func testFinalCheckWithdrawsLaneHold() async { + actions.issueGate = TestGate() + let pending = Task { await processor.process(request(.bolus(3))) } + await actions.issueGate.waitForArrivals() + settings.settings.isGarminBolusCommandEnabled = false + settings.settings.isGarminBolusCommandEnabled = true + actions.issueGate.open() + #expect(await pending.value.acknowledged == false) + + #expect(await processor.process(request(.bolus(1))).acknowledged, "Nothing reached the pump, so no hold") + #expect(actions.enactedBoluses == [1]) + } + + // MARK: - Freshness After Waiting + + @Test( + "A request that expires while it waits in the lane is rejected before validation", + arguments: [false, true] + ) func testExpiredInLane(isMeal: Bool) async { + validator.gate = TestGate() + // the first bolus is refused, so the lane hold cannot be what stops the second + validator.queuedResults = [.rejected(.exceedsMaxIOB(currentIOB: 4, maxIOB: 5)), .allowed] + let lane = WatchCommandInsulinLane() + let processor = Self.makeProcessor(container: container, actions: actions, insulinLane: lane, clock: clock, logs: logs) + + let first = Task { await processor.process(request(.bolus(2))) } + await validator.gate.waitForArrivals() + let command: WatchCommand = isMeal ? .mealBolus(carbs: 30, bolus: 1) : .bolus(1) + let second = Task { await processor.process(request(command, age: WatchCommandPipeline.maximumCommandAge - 10)) } + #expect(await eventually { await lane.waitingCount == 1 }) + + clock.advance(by: 20) + validator.gate.open() + #expect(await first.value.acknowledged == false) + let result = await second.value + + #expect(result.acknowledged == false) + #expect(result.ackCode == (isMeal ? .partialFailure : .genericFailure)) + #expect(validator.validatedAmounts == [2], "The expired request never validates") + #expect(actions.enactedBoluses.isEmpty) + #expect(logs.lines.contains { $0.contains("outside the 2 minute window") }) + } + + @Test( + "A request that expires during validation is not enacted", + arguments: [false, true] + ) func testExpiredDuringValidation(isMeal: Bool) async { + validator.gate = TestGate() + let command: WatchCommand = isMeal ? .mealBolus(carbs: 30, bolus: 1) : .bolus(1) + let pending = Task { await processor.process(request(command, age: WatchCommandPipeline.maximumCommandAge - 5)) } + await validator.gate.waitForArrivals() + + clock.advance(by: 10) + validator.gate.open() + let result = await pending.value + + #expect(result.ackCode == (isMeal ? .partialFailure : .genericFailure)) + #expect(actions.storedCarbs.count == (isMeal ? 1 : 0)) + #expect(actions.enactedBoluses.isEmpty) + } + + @Test( + "A request that expires right before issuance is not enacted", + arguments: [false, true] + ) func testExpiredBeforeIssuance(isMeal: Bool) async { + actions.issueGate = TestGate() + let command: WatchCommand = isMeal ? .mealBolus(carbs: 30, bolus: 1) : .bolus(1) + let pending = Task { await processor.process(request(command, age: WatchCommandPipeline.maximumCommandAge - 1)) } + await actions.issueGate.waitForArrivals() + + clock.advance(by: 2) + actions.issueGate.open() + let result = await pending.value + + #expect(result.ackCode == (isMeal ? .partialFailure : .genericFailure)) + #expect(actions.enactedBoluses.isEmpty) + } + + @Test("An expired request replays its cached result without re-running") func testExpiredReplay() async { + validator.gate = TestGate() + let request = request(.bolus(1), age: WatchCommandPipeline.maximumCommandAge - 5) + let pending = Task { await processor.process(request) } + await validator.gate.waitForArrivals() + clock.advance(by: 10) + validator.gate.open() + let first = await pending.value + + let replay = await processor.process(request) + + #expect(replay == first) + #expect(validator.validatedAmounts.count == 1) + } + + // MARK: - Insulin Lane + + @Test("A second insulin request waits for the first and never validates alongside it") func testInsulinSerialized() async { + let probe = InsulinConcurrencyProbe() + validator.probe = probe + actions.probe = probe + validator.gate = TestGate() + let lane = WatchCommandInsulinLane() + let processor = Self.makeProcessor(container: container, actions: actions, insulinLane: lane, clock: clock, logs: logs) + + let first = Task { await processor.process(request(.bolus(2))) } + await validator.gate.waitForArrivals() + let second = Task { await processor.process(request(.mealBolus(carbs: 30, bolus: 2))) } + #expect(await eventually { await lane.waitingCount == 1 }, "Second request is parked at the lane") + #expect(validator.gate.arrivalCount == 1) + + validator.gate.open() + let firstResult = await first.value + let secondResult = await second.value + + #expect(firstResult.acknowledged) + #expect(secondResult.ackCode == .partialFailure, "Its carbs are logged, its bolus is held back") + #expect(actions.enactedBoluses == [2]) + #expect(probe.maximumInFlight == 1) + #expect(probe.recordedEvents == ["validate", "enact"]) + } + + @Test("A request that waited validates afresh after the first finished") func testInsulinRevalidatedAfterWait() async { + let probe = InsulinConcurrencyProbe() + validator.probe = probe + actions.probe = probe + validator.gate = TestGate() + validator.queuedResults = [.rejected(.exceedsMaxIOB(currentIOB: 4, maxIOB: 5)), .allowed] + let lane = WatchCommandInsulinLane() + let processor = Self.makeProcessor(container: container, actions: actions, insulinLane: lane, clock: clock, logs: logs) + + let first = Task { await processor.process(request(.bolus(2))) } + await validator.gate.waitForArrivals() + let second = Task { await processor.process(request(.bolus(1))) } + #expect(await eventually { await lane.waitingCount == 1 }) + + validator.gate.open() + #expect(await first.value.acknowledged == false) + #expect(await second.value.acknowledged) + #expect(validator.validatedAmounts == [2, 1]) + #expect(actions.enactedBoluses == [1]) + #expect(probe.maximumInFlight == 1) + #expect(probe.recordedEvents == ["validate", "validate", "enact"]) + } + + /// Phase 1 keeps the lane hold even when the pump refused the first bolus: a refusal can still + /// mean partial delivery, and pump history may not show it yet. + @Test( + "A second insulin request waits while the pump works on the first, then is held back", + arguments: [true, false] + ) func testInsulinWaitsForPump(pumpSucceeds: Bool) async { + let probe = InsulinConcurrencyProbe() + validator.probe = probe + actions.probe = probe + actions.bolusGate = TestGate() + actions.bolusSucceeds = pumpSucceeds + let lane = WatchCommandInsulinLane() + let processor = Self.makeProcessor(container: container, actions: actions, insulinLane: lane, clock: clock, logs: logs) + + let first = Task { await processor.process(request(.bolus(2))) } + await actions.bolusGate.waitForArrivals() + let second = Task { await processor.process(request(.bolus(1))) } + #expect(await eventually { await lane.waitingCount == 1 }, "Parked while the pump request is open") + #expect(validator.validatedAmounts == [2]) + + actions.bolusGate.open() + #expect(await first.value.acknowledged == pumpSucceeds) + let secondResult = await second.value + + #expect(secondResult.acknowledged == false) + #expect(logs.lines.contains { $0.contains("previous watch bolus may not be in pump history yet") }) + #expect(validator.validatedAmounts == [2], "Held back before validation") + #expect(actions.enactedBoluses == [2]) + #expect(probe.maximumInFlight == 1) + } + + // MARK: - Safe Logging + + @Test("Error details never reach the log or the ack") func testSensitiveErrorsSanitized() async { + actions.carbsError = SensitiveTestError() + + let results = [ + await processor.process(request(.carbs(10))), + await processor.process(request(.mealBolus(carbs: 10, bolus: 1))) + ] + + #expect(results.allSatisfy { !$0.acknowledged }) + #expect(results.allSatisfy { !$0.message.contains(SensitiveTestError.marker) }) + #expect(!logs.lines.isEmpty) + #expect(logs.lines.allSatisfy { !$0.contains(SensitiveTestError.marker) && !$0.contains("42g") }) + #expect(logs.lines.contains { $0.contains("(unexpected)") }) + } +} diff --git a/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift b/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift index 57950d35ef..7b1e204bcc 100644 --- a/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift +++ b/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift @@ -1,4 +1,7 @@ +import Combine import Foundation +import LoopKit +import LoopKitUI @testable import Trio /// Parks async calls until the test opens it, and lets the test wait until calls have arrived, @@ -115,3 +118,174 @@ final class LogCapture: @unchecked Sendable { lock.withLock { storage.append((level, line)) } } } + +/// Revokes from its setter the way `BaseSettingsManager` does. +final class StubSettingsManager: SettingsManager { + let authorization = WatchCommandAuthorization() + var settings = TrioSettings() { + willSet { authorization.settingsWillChange(from: settings, to: newValue) } + } + + var preferences = Preferences() + var pumpSettings = PumpSettings(insulinActionCurve: 10, maxBolus: 10, maxBasal: 2) + + func updateInsulinCurve(_: InsulinType?) {} +} + +/// Counts how many validations and pump requests overlap, across concurrent requests. +final class InsulinConcurrencyProbe: @unchecked Sendable { + private let lock = NSLock() + private var inFlight = 0 + private(set) var events: [String] = [] + private(set) var maximumInFlight = 0 + + func begin(_ event: String) { + lock.withLock { + events.append(event) + inFlight += 1 + maximumInFlight = max(maximumInFlight, inFlight) + } + } + + func end() { + lock.withLock { inFlight -= 1 } + } + + var recordedEvents: [String] { lock.withLock { events } } +} + +final class StubBolusSafetyValidator: BolusSafetyValidator { + private let lock = NSLock() + private var results: [BolusSafetyResult] = [] + private var amounts: [Decimal] = [] + private var starts: [Date?] = [] + + var result: BolusSafetyResult = .allowed + /// Consumed one per call before falling back to `result`. + var queuedResults: [BolusSafetyResult] { + get { lock.withLock { results } } + set { lock.withLock { results = newValue } } + } + + var gate = TestGate(isOpen: true) + var probe: InsulinConcurrencyProbe? + var validatedAmounts: [Decimal] { lock.withLock { amounts } } + var lookbackStarts: [Date?] { lock.withLock { starts } } + + func validate(bolusAmount: Decimal, lookbackStart: Date?) async throws -> BolusSafetyResult { + probe?.begin("validate") + defer { probe?.end() } + lock.withLock { + amounts.append(bolusAmount) + starts.append(lookbackStart) + } + await gate.pass() + return lock.withLock { results.isEmpty ? result : results.removeFirst() } + } + + func fetchTotalRecentBolusAmount(since _: Date) async throws -> Decimal { 0 } +} + +final class StubWatchCommandActions: WatchCommandActions { + private let lock = NSLock() + private var recordedEvents: [String] = [] + private var carbs: [(grams: Int, date: Date)] = [] + private var boluses: [Decimal] = [] + + /// Side effects in call order, to check that meal carbs land before the bolus. + var events: [String] { lock.withLock { recordedEvents } } + var storedCarbs: [(grams: Int, date: Date)] { lock.withLock { carbs } } + var enactedBoluses: [Decimal] { lock.withLock { boluses } } + var carbsError: Error? + var bolusSucceeds = true + + /// Parks the carb save before its final check, the way a busy Core Data context would. + var carbsTransactionGate = TestGate(isOpen: true) + /// Parks the carb save after it was issued, the way a slow Core Data save would. + var carbsGate = TestGate(isOpen: true) + /// Parks the bolus before its final check, between validation and `APSManager` issuance. + var issueGate = TestGate(isOpen: true) + /// Parks the bolus after it was issued, while the pump is still working on it. + var bolusGate = TestGate(isOpen: true) + var probe: InsulinConcurrencyProbe? + + /// Side effects are recorded only after `authorize` passed, as in production. + func storeCarbs(_ grams: Int, date: Date, authorize: @escaping () throws -> Void) async throws { + await carbsTransactionGate.pass() + try authorize() + lock.withLock { recordedEvents.append("carbs") } + if let carbsError { throw carbsError } + lock.withLock { carbs.append((grams, date)) } + await carbsGate.pass() + } + + func enactBolus(_ units: Decimal, authorize: @escaping () throws -> Void) async throws -> Bool { + await issueGate.pass() + try authorize() + probe?.begin("enact") + defer { probe?.end() } + lock.withLock { + recordedEvents.append("bolus") + boluses.append(units) + } + await bolusGate.pass() + return bolusSucceeds + } +} + +final class SpyWatchCommandProcessor: WatchCommandProcessor { + var result = WatchCommandResult.success(.carbsLogged, "Carbs logged.") + var capabilitiesResult = WatchCommandCapabilities() + private(set) var processed: [WatchCommandRequest] = [] + + func process(_ request: WatchCommandRequest) async -> WatchCommandResult { + processed.append(request) + return result + } + + func capabilities() -> WatchCommandCapabilities { + capabilitiesResult + } +} + +/// Records bolus requests; everything else is inert. `enactBolus` calls back like the real one. +final class SpyAPSManager: APSManager, @unchecked Sendable { + private let lock = NSLock() + private var amounts: [Double] = [] + + var bolusRequests: [Double] { lock.withLock { amounts } } + var bolusSucceeds = true + + func enactBolus(amount: Double, isSMB _: Bool, callback: ((Bool, String) -> Void)?) async { + lock.withLock { amounts.append(amount) } + callback?(bolusSucceeds, "") + } + + func heartbeat(date _: Date) {} + func markNextLoopUserInitiated() {} + func enactManualTempBasal(rate _: Double, duration _: TimeInterval) async {} + var pumpManager: PumpManagerUI? + var bluetoothManager: BluetoothStateManager? { nil } + let pumpDisplayState = CurrentValueSubject(nil) + let pumpName = CurrentValueSubject("") + let isLooping = CurrentValueSubject(false) + var lastLoopDate: Date { .distantPast } + let lastLoopDateSubject = PassthroughSubject() + let bolusProgress = CurrentValueSubject(nil) + let pumpExpiresAtDate = CurrentValueSubject(nil) + let pumpActivatedAtDate = CurrentValueSubject(nil) + var isManualTempBasal: Bool { false } + var isScheduledBasal: Bool? { nil } + var isSuspended: Bool { false } + func determineBasal() async throws {} + func determineBasalSync() async throws {} + func simulateDetermineBasal( + simulatedCarbsAmount _: Decimal, + simulatedBolusAmount _: Decimal, + simulatedCarbsDate _: Date? + ) async -> Determination? { nil } + func roundBolus(amount: Decimal) -> Decimal { amount } + let lastError = CurrentValueSubject(nil) + func cancelBolus(_: ((Bool, String) -> Void)?) async {} + let iobFileDidUpdate = PassthroughSubject() +} From edd7088a93558ebae15a8ed1f18e8a6aa84b4097 Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 25 Oct 2026 19:29:40 +0100 Subject: [PATCH 04/34] Add Garmin adjustment commands The Garmin watch app can activate or cancel override and temp target presets through the same pipeline and gates as treatment commands: - presets are matched by exact, case-sensitive, unique name, whether or not they run; two presets that differ only in whitespace never let the watch start the wrong one - cancelling with nothing active is an idempotent success - AdjustmentManager gets guarded entry points whose authorize closure runs inside the transaction before any read or write, so a command revoked while it waits for the adjustment serializer writes nothing - the preset reply now lists override and temp target presets next to the switches and limits; a successful preset command, a preset edit or a command setting change pushes it to every Complication app, and a push that cannot be delivered stays owed like a state broadcast Tests cover the adjustment processor cases, revocation while the preset list loads and while queued behind a busy Core Data context (real BaseAdjustmentManager, save counter), router parsing and push rules. --- Trio.xcodeproj/project.pbxproj | 4 + Trio/Sources/Assemblies/ServiceAssembly.swift | 1 + .../Localizations/Main/Localizable.xcstrings | 88 +++++++++++- Trio/Sources/Models/WatchMessageKeys.swift | 2 + .../View/WatchConfigGarminAppConfigView.swift | 4 +- .../Adjustments/AdjustmentManager.swift | 107 ++++++++++++-- .../Services/WatchCommand/WatchCommand.swift | 21 +++ .../WatchCommand/WatchCommandPipeline.swift | 9 +- .../WatchCommand/WatchCommandProcessor.swift | 95 +++++++++++++ .../Services/WatchCommand/WatchPresets.swift | 85 +++++++++++ .../Services/WatchManager/GarminManager.swift | 97 ++++++++++--- .../WatchManager/GarminMessageRouter.swift | 43 ++++-- .../GarminManagerIntegrationTests.swift | 51 ++++++- .../GarminMessageRouterTests.swift | 116 ++++++++++++++- .../WatchCommandIntegrationTests.swift | 134 +++++++++++++++++- .../WatchCommandPipelineTests.swift | 1 + .../WatchCommandProcessorTests.swift | 124 +++++++++++++++- .../WatchCommandTestDoubles.swift | 99 +++++++++++++ 18 files changed, 1018 insertions(+), 63 deletions(-) create mode 100644 Trio/Sources/Services/WatchCommand/WatchPresets.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 3ccf93a9de..2599c18f53 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 9A3C70FF5CD33060FE5626A7 /* WatchCommandTestDoubles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C3F1091C03368A75DD96E /* WatchCommandTestDoubles.swift */; }; 9A3C3B50509CE7ABFE2E3D1D /* WatchCommandRequestCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3CA06D5CC8AB3491F284C1 /* WatchCommandRequestCache.swift */; }; 9A3C76A08493CDC6FBF71373 /* WatchCommandPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C6BB18B32890597D63BD9 /* WatchCommandPipeline.swift */; }; + 9A3C151EF9FCECCF212ED730 /* WatchPresets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C1083145DAD9C2A662CE1 /* WatchPresets.swift */; }; 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, ); }; }; @@ -1265,6 +1266,7 @@ 9A3C3F1091C03368A75DD96E /* WatchCommandTestDoubles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandTestDoubles.swift; sourceTree = ""; }; 9A3CA06D5CC8AB3491F284C1 /* WatchCommandRequestCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandRequestCache.swift; sourceTree = ""; }; 9A3C6BB18B32890597D63BD9 /* WatchCommandPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandPipeline.swift; sourceTree = ""; }; + 9A3C1083145DAD9C2A662CE1 /* WatchPresets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchPresets.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 = ""; }; @@ -2479,6 +2481,7 @@ 9A3CF62E60DADFAC56F829E2 /* WatchCommandProcessor.swift */, 9A3CA2079F4D0DB8B1684D0B /* WatchCommandActions.swift */, 9A3C78318D8159B851C0EC52 /* WatchCommand.swift */, + 9A3C1083145DAD9C2A662CE1 /* WatchPresets.swift */, 9A3CA06D5CC8AB3491F284C1 /* WatchCommandRequestCache.swift */, 9A3C6BB18B32890597D63BD9 /* WatchCommandPipeline.swift */, ); @@ -5825,6 +5828,7 @@ 9A3C4FC24B2DFEEB95911EDE /* WatchCommandProcessor.swift in Sources */, 9A3CD75136E1A66C2F49240C /* WatchCommandActions.swift in Sources */, 9A3CB4CA0FB381954EA4BA94 /* WatchCommand.swift in Sources */, + 9A3C151EF9FCECCF212ED730 /* WatchPresets.swift in Sources */, 9A3CA6DBA84BDF295314EAC5 /* GarminMessageRouter.swift in Sources */, 9A3C3B50509CE7ABFE2E3D1D /* WatchCommandRequestCache.swift in Sources */, 9A3C76A08493CDC6FBF71373 /* WatchCommandPipeline.swift in Sources */, diff --git a/Trio/Sources/Assemblies/ServiceAssembly.swift b/Trio/Sources/Assemblies/ServiceAssembly.swift index dbf695f3de..ef06c74f05 100644 --- a/Trio/Sources/Assemblies/ServiceAssembly.swift +++ b/Trio/Sources/Assemblies/ServiceAssembly.swift @@ -34,6 +34,7 @@ final class ServiceAssembly: Assembly { .inObjectScope(.container) container.register(WatchCommandAuthorization.self) { _ in WatchCommandAuthorization() } .inObjectScope(.container) + container.register(WatchPresetsProvider.self) { r in BaseWatchPresetsProvider(resolver: r) } // one instance, so its request-ID cache sees every watch request container.register(WatchCommandPipeline.self) { r in WatchCommandPipeline(authorization: r.resolve(WatchCommandAuthorization.self)!) diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 811dfe8d5a..23dcbb7aa0 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -1089,13 +1089,16 @@ } } }, + "A bolus was given in the last 6 minutes." : { + "comment" : "Watch command ack" + }, "Allow Bolus Commands" : { }, "Allow the Garmin app to deliver insulin. Requires watch commands to be enabled." : { }, - "Allow the Garmin app to log carbs." : { + "Allow the Garmin app to log carbs and start or stop overrides and temp targets." : { }, "Ask on Discord" : { @@ -1103,6 +1106,36 @@ }, "Ask on Facebook" : { + }, + "Bolus exceeds max bolus." : { + "comment" : "Watch command ack" + }, + "Bolus failed. Check pump history before repeating." : { + "comment" : "Watch command ack" + }, + "Bolus from watch is disabled." : { + "comment" : "Watch command ack" + }, + "Bolus started." : { + "comment" : "Watch command ack" + }, + "Bolus would exceed max IOB." : { + "comment" : "Watch command ack" + }, + "Carbs exceed the maximum allowed." : { + "comment" : "Watch command ack" + }, + "Carbs logged and bolus started." : { + "comment" : "Watch command ack" + }, + "Carbs logged." : { + "comment" : "Watch command ack" + }, + "Carbs logged. Bolus failed, check pump history before repeating." : { + "comment" : "Watch command ack" + }, + "Carbs logged. No bolus was delivered." : { + "comment" : "Watch command ack" }, "Open a GitHub Issue" : { @@ -1146,6 +1179,27 @@ }, "Commands are only accepted from the Garmin apps selected above, must reach the phone within 2 minutes of being confirmed on the watch, and carbs are limited by your Max Carbs setting." : { + }, + "Could not log carbs." : { + "comment" : "Watch command ack" + }, + "Could not log carbs. No bolus was delivered." : { + "comment" : "Watch command ack" + }, + "Could not start override." : { + "comment" : "Watch command ack" + }, + "Could not start temp target." : { + "comment" : "Watch command ack" + }, + "Could not stop override." : { + "comment" : "Watch command ack" + }, + "Could not stop temp target." : { + "comment" : "Watch command ack" + }, + "Could not verify bolus safety." : { + "comment" : "Watch command ack" }, "Enable Watch Commands" : { @@ -1186,10 +1240,19 @@ } } }, + "Invalid bolus amount." : { + "comment" : "Watch command ack" + }, + "Invalid carbs amount." : { + "comment" : "Watch command ack" + }, "Invalid command." : { "comment" : "Watch command ack" }, - "Lets a supported Garmin app log carbs. Status and the preset list stay available with this switch off." : { + "IOB unavailable. Bolus not delivered." : { + "comment" : "Watch command ack" + }, + "Lets a supported Garmin app log carbs and start or stop override and temp target presets. Status and the preset list stay available with this switch off." : { }, "Lets the Garmin app deliver a bolus, alone or together with carbs, without any confirmation on this phone." : { @@ -1197,6 +1260,15 @@ }, "Only enable this if you keep your watch locked and under your control." : { + }, + "Override preset not found." : { + "comment" : "Watch command ack" + }, + "Override started." : { + "comment" : "Watch command ack" + }, + "Override stopped." : { + "comment" : "Watch command ack" }, "Request ID was already used." : { "comment" : "Watch command ack" @@ -1204,6 +1276,15 @@ "Request is already in progress." : { "comment" : "Watch command ack" }, + "Temp target preset not found." : { + "comment" : "Watch command ack" + }, + "Temp target started." : { + "comment" : "Watch command ack" + }, + "Temp target stopped." : { + "comment" : "Watch command ack" + }, "TIR Lower Threshold" : { "comment" : "Setting for the lower glucose boundary shared by TIR and TITR statistics.", "localizations" : { @@ -1298,6 +1379,9 @@ "Watch command settings changed." : { "comment" : "Watch command ack" }, + "Watch commands are disabled." : { + "comment" : "Watch command ack" + }, " " : { "localizations" : { "bg" : { diff --git a/Trio/Sources/Models/WatchMessageKeys.swift b/Trio/Sources/Models/WatchMessageKeys.swift index bd228d570a..1defd0deb3 100644 --- a/Trio/Sources/Models/WatchMessageKeys.swift +++ b/Trio/Sources/Models/WatchMessageKeys.swift @@ -67,6 +67,8 @@ enum WatchMessageKeys { // Garmin v1 Envelope Keys static let protocolVersion = "v" static let request = "req" + static let presetName = "name" + static let presetIsEnabled = "isEnabled" static let requestID = "requestId" static let command = "command" static let payload = "payload" diff --git a/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift b/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift index f6aca39cda..d904add703 100644 --- a/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift +++ b/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift @@ -109,7 +109,7 @@ struct WatchConfigGarminAppConfigView: View { HStack(alignment: .center) { Text( - "Allow the Garmin app to log carbs." + "Allow the Garmin app to log carbs and start or stop overrides and temp targets." ) .font(.footnote) .foregroundColor(.secondary) @@ -301,7 +301,7 @@ struct WatchConfigGarminAppConfigView: View { hintText: VStack(alignment: .leading, spacing: 10) { Text("Default: OFF").bold() Text( - "Lets a supported Garmin app log carbs. Status and the preset list stay available with this switch off." + "Lets a supported Garmin app log carbs and start or stop override and temp target presets. Status and the preset list stay available with this switch off." ) Text( "Commands are only accepted from the Garmin apps selected above, must reach the phone within 2 minutes of being confirmed on the watch, and carbs are limited by your Max Carbs setting." diff --git a/Trio/Sources/Services/Adjustments/AdjustmentManager.swift b/Trio/Sources/Services/Adjustments/AdjustmentManager.swift index c4f53f1030..e36ade0e1e 100644 --- a/Trio/Sources/Services/Adjustments/AdjustmentManager.swift +++ b/Trio/Sources/Services/Adjustments/AdjustmentManager.swift @@ -80,6 +80,31 @@ protocol AdjustmentManager { ) async throws -> AdjustmentOutcome @discardableResult func cancelTempTarget(source: AdjustmentSource, waitForUpload: Bool) async throws -> AdjustmentOutcome + + // for callers whose permission can be withdrawn while queued (watch commands): `authorize` runs + // inside the transaction before any read or write, and whatever it throws leaves nothing saved + + @discardableResult func activateOverride( + _ preset: AdjustmentRef, + source: AdjustmentSource, + authorize: @escaping @Sendable() throws -> Void + ) async throws -> AdjustmentOutcome + + @discardableResult func cancelOverride( + source: AdjustmentSource, + authorize: @escaping @Sendable() throws -> Void + ) async throws -> AdjustmentOutcome + + @discardableResult func activateTempTarget( + _ preset: AdjustmentRef, + source: AdjustmentSource, + authorize: @escaping @Sendable() throws -> Void + ) async throws -> AdjustmentOutcome + + @discardableResult func cancelTempTarget( + source: AdjustmentSource, + authorize: @escaping @Sendable() throws -> Void + ) async throws -> AdjustmentOutcome } extension AdjustmentManager { @@ -136,15 +161,37 @@ final class BaseAdjustmentManager: AdjustmentManager, Injectable, @unchecked Sen source: AdjustmentSource, waitForUpload: Bool ) async throws -> AdjustmentOutcome { - let outcome = try await serializer.run { try await self.commitOverride(activating: preset) } - info(.treatments, "Override activated from \(source.rawValue): \(outcome.logDescription)") - await applyOverrideSideEffects(source: source, waitForUpload: waitForUpload) - return outcome + try await runOverride(activating: preset, source: source, waitForUpload: waitForUpload, authorize: nil) } @discardableResult func cancelOverride(source: AdjustmentSource, waitForUpload: Bool) async throws -> AdjustmentOutcome { - let outcome = try await serializer.run { try await self.commitOverride(activating: nil) } - info(.treatments, "Override cancelled from \(source.rawValue): \(outcome.logDescription)") + try await runOverride(activating: nil, source: source, waitForUpload: waitForUpload, authorize: nil) + } + + @discardableResult func activateOverride( + _ preset: AdjustmentRef, + source: AdjustmentSource, + authorize: @escaping @Sendable() throws -> Void + ) async throws -> AdjustmentOutcome { + try await runOverride(activating: preset, source: source, waitForUpload: false, authorize: authorize) + } + + @discardableResult func cancelOverride( + source: AdjustmentSource, + authorize: @escaping @Sendable() throws -> Void + ) async throws -> AdjustmentOutcome { + try await runOverride(activating: nil, source: source, waitForUpload: false, authorize: authorize) + } + + private func runOverride( + activating preset: AdjustmentRef?, + source: AdjustmentSource, + waitForUpload: Bool, + authorize: (@Sendable() throws -> Void)? + ) async throws -> AdjustmentOutcome { + let outcome = try await serializer.run { try await self.commitOverride(activating: preset, authorize: authorize) } + let action = preset == nil ? "cancelled" : "activated" + info(.treatments, "Override \(action) from \(source.rawValue): \(outcome.logDescription)") await applyOverrideSideEffects(source: source, waitForUpload: waitForUpload) return outcome } @@ -152,11 +199,15 @@ final class BaseAdjustmentManager: AdjustmentManager, Injectable, @unchecked Sen /// `activating == nil` cancels whatever runs. Ending the active rows and enabling the /// requested one share one transaction, so no command can leave the app with nothing enabled /// or with two adjustments enabled at once. - private func commitOverride(activating preset: AdjustmentRef?) async throws -> AdjustmentOutcome { + private func commitOverride( + activating preset: AdjustmentRef?, + authorize: (@Sendable() throws -> Void)? + ) async throws -> AdjustmentOutcome { let context = makeContext() context.name = "commitOverride" return try await context.perform { + try authorize?() let now = Date() let target = try preset.map { try self.fetchOverride($0, in: context) } let active = try self.fetchActiveOverrides(in: context) @@ -214,26 +265,54 @@ final class BaseAdjustmentManager: AdjustmentManager, Injectable, @unchecked Sen source: AdjustmentSource, waitForUpload: Bool ) async throws -> AdjustmentOutcome { - let (outcome, orefTarget) = try await serializer.run { try await self.commitTempTarget(activating: preset) } - info(.treatments, "Temp target activated from \(source.rawValue): \(outcome.logDescription)") - await applyTempTargetSideEffects(orefTarget: orefTarget, source: source, waitForUpload: waitForUpload) - return outcome + try await runTempTarget(activating: preset, source: source, waitForUpload: waitForUpload, authorize: nil) } @discardableResult func cancelTempTarget(source: AdjustmentSource, waitForUpload: Bool) async throws -> AdjustmentOutcome { - let (outcome, orefTarget) = try await serializer.run { try await self.commitTempTarget(activating: nil) } - info(.treatments, "Temp target cancelled from \(source.rawValue): \(outcome.logDescription)") + try await runTempTarget(activating: nil, source: source, waitForUpload: waitForUpload, authorize: nil) + } + + @discardableResult func activateTempTarget( + _ preset: AdjustmentRef, + source: AdjustmentSource, + authorize: @escaping @Sendable() throws -> Void + ) async throws -> AdjustmentOutcome { + try await runTempTarget(activating: preset, source: source, waitForUpload: false, authorize: authorize) + } + + @discardableResult func cancelTempTarget( + source: AdjustmentSource, + authorize: @escaping @Sendable() throws -> Void + ) async throws -> AdjustmentOutcome { + try await runTempTarget(activating: nil, source: source, waitForUpload: false, authorize: authorize) + } + + private func runTempTarget( + activating preset: AdjustmentRef?, + source: AdjustmentSource, + waitForUpload: Bool, + authorize: (@Sendable() throws -> Void)? + ) async throws -> AdjustmentOutcome { + let (outcome, orefTarget) = try await serializer.run { + try await self.commitTempTarget(activating: preset, authorize: authorize) + } + let action = preset == nil ? "cancelled" : "activated" + info(.treatments, "Temp target \(action) from \(source.rawValue): \(outcome.logDescription)") await applyTempTargetSideEffects(orefTarget: orefTarget, source: source, waitForUpload: waitForUpload) return outcome } /// Returns the outcome plus the entry to hand oref, which has to be built inside the /// transaction while the row is still safe to read on this context. - private func commitTempTarget(activating preset: AdjustmentRef?) async throws -> (AdjustmentOutcome, TempTarget?) { + private func commitTempTarget( + activating preset: AdjustmentRef?, + authorize: (@Sendable() throws -> Void)? + ) async throws -> (AdjustmentOutcome, TempTarget?) { let context = makeContext() context.name = "commitTempTarget" return try await context.perform { + try authorize?() let now = Date() let target = try preset.map { try self.fetchTempTarget($0, in: context) } let active = try self.fetchActiveTempTargets(in: context) diff --git a/Trio/Sources/Services/WatchCommand/WatchCommand.swift b/Trio/Sources/Services/WatchCommand/WatchCommand.swift index f772cc82f5..930b5b38b0 100644 --- a/Trio/Sources/Services/WatchCommand/WatchCommand.swift +++ b/Trio/Sources/Services/WatchCommand/WatchCommand.swift @@ -6,6 +6,10 @@ enum WatchCommand: Hashable { case carbs(Int) /// Carbs are stored before the bolus runs, so a rejected bolus still leaves the meal logged. case mealBolus(carbs: Int, bolus: Decimal) + case activateOverride(name: String) + case cancelOverride + case activateTempTarget(name: String) + case cancelTempTarget /// Wire name of the command; also the only command detail that is safe to log. var name: String { @@ -13,6 +17,10 @@ enum WatchCommand: Hashable { case .bolus: return "bolus" case .carbs: return "carbs" case .mealBolus: return "mealBolus" + case .activateOverride: return "activateOverride" + case .cancelOverride: return "cancelOverride" + case .activateTempTarget: return "activateTempTarget" + case .cancelTempTarget: return "cancelTempTarget" } } @@ -37,6 +45,19 @@ enum WatchCommand: Hashable { } var isInsulin: Bool { bolusAmount != nil } + + /// Commands whose success changes which preset is active on the watch's preset list. + var changesPresetState: Bool { + switch self { + case .activateOverride, + .activateTempTarget, + .cancelOverride, + .cancelTempTarget: + return true + default: + return false + } + } } struct WatchCommandRequest: Equatable { diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandPipeline.swift b/Trio/Sources/Services/WatchCommand/WatchCommandPipeline.swift index 3bab8f0ea4..c7476c3fd9 100644 --- a/Trio/Sources/Services/WatchCommand/WatchCommandPipeline.swift +++ b/Trio/Sources/Services/WatchCommand/WatchCommandPipeline.swift @@ -199,10 +199,17 @@ final class WatchCommandPipeline { } } -/// Stable, content-free labels for errors: Core Data errors can carry treatment values or store paths. +/// Stable, content-free labels for errors: Core Data and adjustment errors can carry treatment values, +/// preset names or store paths. enum WatchCommandErrorCategory { static func name(for error: Error) -> String { switch error { + case let adjustmentError as AdjustmentError: + switch adjustmentError { + case .presetNotFound: return "presetNotFound" + case .nothingActive: return "nothingActive" + case .persistenceFailed: return "adjustmentPersistence" + } case is CoreDataError: return "coreData" case is CancellationError: diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift b/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift index 42372b524a..e01a9c2b4b 100644 --- a/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift +++ b/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift @@ -68,6 +68,8 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { @Injected() private var settingsManager: SettingsManager! @Injected() private var bolusSafetyValidator: BolusSafetyValidator! @Injected() private var pipeline: WatchCommandPipeline! + @Injected() private var adjustmentManager: AdjustmentManager! + @Injected() private var presetsProvider: WatchPresetsProvider! private let actions: WatchCommandActions private let insulinLane: WatchCommandInsulinLane @@ -126,6 +128,14 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { return await carbs(grams, context: context) case let .mealBolus(grams, units): return await mealBolus(carbs: grams, bolus: units, context: context) + case let .activateOverride(name): + return await activateOverride(named: name, context: context) + case .cancelOverride: + return await cancelOverride(context: context) + case let .activateTempTarget(name): + return await activateTempTarget(named: name, context: context) + case .cancelTempTarget: + return await cancelTempTarget(context: context) } } @@ -287,6 +297,91 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { } } +extension BaseWatchCommandProcessor { + // MARK: - Adjustments + + private func activateOverride(named name: String, context: WatchCommandContext) async -> WatchCommandResult { + do { + let presets = try await presetsProvider.overridePresets() + guard let preset = uniquePreset(named: name, in: presets) else { + log.info("Garmin override command refused, preset missing or ambiguous") + return .failure(String(localized: "Override preset not found.", comment: "Watch command ack")) + } + if let rejection = context.rejection() { + return rejection + } + try await adjustmentManager.activateOverride(preset.ref, source: .watch, authorize: context.finalCheck()) + return .success(.overrideStarted, String(localized: "Override started.", comment: "Watch command ack")) + } catch let rejection as WatchCommandRejection { + return rejection.result + } catch { + log.warning("Starting override from Garmin failed (\(WatchCommandErrorCategory.name(for: error)))") + return .failure(String(localized: "Could not start override.", comment: "Watch command ack")) + } + } + + private func cancelOverride(context: WatchCommandContext) async -> WatchCommandResult { + if let rejection = context.rejection() { + return rejection + } + do { + try await adjustmentManager.cancelOverride(source: .watch, authorize: context.finalCheck()) + } catch let rejection as WatchCommandRejection { + return rejection.result + } catch AdjustmentError.nothingActive { + // cancelling an inactive override already has the requested outcome + } catch { + log.warning("Stopping override from Garmin failed (\(WatchCommandErrorCategory.name(for: error)))") + return .failure(String(localized: "Could not stop override.", comment: "Watch command ack")) + } + return .success(.overrideStopped, String(localized: "Override stopped.", comment: "Watch command ack")) + } + + private func activateTempTarget(named name: String, context: WatchCommandContext) async -> WatchCommandResult { + do { + let presets = try await presetsProvider.tempTargetPresets() + guard let preset = uniquePreset(named: name, in: presets) else { + log.info("Garmin temp target command refused, preset missing or ambiguous") + return .failure(String(localized: "Temp target preset not found.", comment: "Watch command ack")) + } + if let rejection = context.rejection() { + return rejection + } + try await adjustmentManager.activateTempTarget(preset.ref, source: .watch, authorize: context.finalCheck()) + return .success(.tempTargetStarted, String(localized: "Temp target started.", comment: "Watch command ack")) + } catch let rejection as WatchCommandRejection { + return rejection.result + } catch { + log.warning("Starting temp target from Garmin failed (\(WatchCommandErrorCategory.name(for: error)))") + return .failure(String(localized: "Could not start temp target.", comment: "Watch command ack")) + } + } + + private func cancelTempTarget(context: WatchCommandContext) async -> WatchCommandResult { + if let rejection = context.rejection() { + return rejection + } + do { + try await adjustmentManager.cancelTempTarget(source: .watch, authorize: context.finalCheck()) + } catch let rejection as WatchCommandRejection { + return rejection.result + } catch AdjustmentError.nothingActive { + // cancelling an inactive temp target already has the requested outcome + } catch { + log.warning("Stopping temp target from Garmin failed (\(WatchCommandErrorCategory.name(for: error)))") + return .failure(String(localized: "Could not stop temp target.", comment: "Watch command ack")) + } + return .success(.tempTargetStopped, String(localized: "Temp target stopped.", comment: "Watch command ack")) + } + + /// Exact, case-sensitive match whether or not the preset runs; unlike `AdjustmentManager`'s trimmed + /// lookup, two presets that only differ in whitespace must not let the watch start the wrong one. + private func uniquePreset(named name: String, in presets: [WatchPresetEntry]) -> WatchPresetEntry? { + let matches = presets.filter { $0.name == name } + return matches.count == 1 ? matches.first : nil + } +} + private extension BolusSafetyRejection { /// Case name only; the associated values are insulin amounts, which stay out of the log. var logName: String { diff --git a/Trio/Sources/Services/WatchCommand/WatchPresets.swift b/Trio/Sources/Services/WatchCommand/WatchPresets.swift new file mode 100644 index 0000000000..465856a37e --- /dev/null +++ b/Trio/Sources/Services/WatchCommand/WatchPresets.swift @@ -0,0 +1,85 @@ +import CoreData +import Foundation +import Swinject + +/// An adjustment preset as the watch names it, plus the reference that activates exactly that row. +struct WatchPresetEntry: Equatable { + let name: String + /// `enabled` on the stored row: whether the preset runs now, never whether it may be started. + let isActive: Bool + let ref: AdjustmentRef +} + +/// The preset lists a watch shows, sorted by display name. +struct WatchPresets: Equatable { + let overridePresets: [OverridePresetWatch] + let tempTargetPresets: [TempTargetPresetWatch] +} + +/// Every configured override and temp target preset, running or not. +protocol WatchPresetsProvider { + func overridePresets() async throws -> [WatchPresetEntry] + func tempTargetPresets() async throws -> [WatchPresetEntry] +} + +extension WatchPresetsProvider { + func presets() async throws -> WatchPresets { + async let overrides = overridePresets() + async let tempTargets = tempTargetPresets() + + return try await WatchPresets( + overridePresets: overrides.sortedByName.map { OverridePresetWatch(name: $0.name, isEnabled: $0.isActive) }, + tempTargetPresets: tempTargets.sortedByName.map { TempTargetPresetWatch(name: $0.name, isEnabled: $0.isActive) } + ) + } +} + +private extension Array where Element == WatchPresetEntry { + var sortedByName: [WatchPresetEntry] { + sorted { + switch $0.name.localizedStandardCompare($1.name) { + case .orderedAscending: return true + case .orderedDescending: return false + // equal display names keep a stable order across replies + case .orderedSame: return $0.name < $1.name + } + } + } +} + +final class BaseWatchPresetsProvider: WatchPresetsProvider, Injectable { + @Injected() private var overrideStorage: OverrideStorage! + @Injected() private var tempTargetStorage: TempTargetsStorage! + + init(resolver: Resolver) { + injectServices(resolver) + } + + func overridePresets() async throws -> [WatchPresetEntry] { + let ids = try await overrideStorage.fetchForOverridePresets() + let context = CoreDataStack.shared.newTaskContext() + context.name = "watchOverridePresets" + let rows: [OverrideStored] = try await CoreDataStack.shared.getNSManagedObject(with: ids, context: context) + + return await context.perform { + rows.compactMap { row in + guard let name = row.name, let id = row.id else { return nil } + return WatchPresetEntry(name: name, isActive: row.enabled, ref: .presetID(id)) + } + } + } + + func tempTargetPresets() async throws -> [WatchPresetEntry] { + let ids = try await tempTargetStorage.fetchForTempTargetPresets() + let context = CoreDataStack.shared.newTaskContext() + context.name = "watchTempTargetPresets" + let rows: [TempTargetStored] = try await CoreDataStack.shared.getNSManagedObject(with: ids, context: context) + + return await context.perform { + rows.compactMap { row in + guard let name = row.name, let id = row.id else { return nil } + return WatchPresetEntry(name: name, isActive: row.enabled, ref: .presetID(id.uuidString)) + } + } + } +} diff --git a/Trio/Sources/Services/WatchManager/GarminManager.swift b/Trio/Sources/Services/WatchManager/GarminManager.swift index 253be2f9ab..4e15860cde 100644 --- a/Trio/Sources/Services/WatchManager/GarminManager.swift +++ b/Trio/Sources/Services/WatchManager/GarminManager.swift @@ -83,6 +83,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { @Injected() private var iobService: IOBService! @Injected() private var trioAlertManager: TrioAlertManager! + @Injected() private var presetsProvider: WatchPresetsProvider! @Injected() private var watchCommandProcessor: WatchCommandProcessor! /// Revoked by `BaseSettingsManager` itself; read here only to refresh the watch after a revocation. @@ -156,6 +157,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { /// What an app is still owed after its send failed for good; latest state wins. Main queue only. private struct OwedDelivery { var stateHash: Int? + var pushesPresets = false } /// The send result is the readiness signal: readiness never gates a send, because a missed @@ -244,11 +246,13 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { /// - Parameters: /// - resolver: Swinject resolver for injecting dependencies like the Router. /// - connectIQClient: Replaces the Connect IQ SDK for registration and sending. + /// - presetChanges: Replaces the Core Data preset change signal. /// - sendRetryDelay: Wait before resending a failed message. /// - broadcastDebounce: Quiet time before a state broadcast goes out. init( resolver: Resolver, connectIQClient: GarminConnectIQClient? = nil, + presetChanges: AnyPublisher? = nil, sendRetryDelay: TimeInterval = 2, broadcastDebounce: DispatchQueue.SchedulerTimeType.Stride = .seconds(2) ) { @@ -302,7 +306,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { } .store(in: &subscriptions) - registerHandlers() + registerHandlers(presetChanges: presetChanges) } // MARK: - Settings Helpers @@ -361,7 +365,21 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { /// Sets up handlers for OrefDetermination and GlucoseStored entity changes in CoreData. /// When these change, we re-compute the Garmin watch state and send updates to the watch. - private func registerHandlers() { + private func registerHandlers(presetChanges: AnyPublisher?) { + // edits and activations both land in these entities, and both change the watch's preset list + let storedPresetChanges = coreDataPublisher.map { publisher in + publisher.filteredByEntityName("OverrideStored") + .merge(with: publisher.filteredByEntityName("TempTargetStored")) + .map { _ in () } + .eraseToAnyPublisher() + } + (presetChanges ?? storedPresetChanges)? + .debounce(for: .seconds(1), scheduler: DispatchQueue.main) + .sink { [weak self] in + self?.pushPresetsToComplications(triggeredBy: "PresetStorage") + } + .store(in: &subscriptions) + // OrefDetermination changes - debounce at CoreData level coreDataPublisher? .filteredByEntityName("OrefDetermination") @@ -1010,10 +1028,13 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { } /// Acks and other replies are never owed; the watch expires an unanswered command itself. - private func recordOwed(for key: String, stateHash: Int? = nil) { - guard stateHash != nil else { return } + private func recordOwed(for key: String, stateHash: Int? = nil, presets: Bool = false) { + guard stateHash != nil || presets else { return } var owed = owedDeliveries[key] ?? OwedDelivery() - owed.stateHash = stateHash + if let stateHash { + owed.stateHash = stateHash + } + owed.pushesPresets = owed.pushesPresets || presets owedDeliveries[key] = owed } @@ -1024,6 +1045,9 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { debugGarmin("Sending owed state to Garmin \(appName)") sendMessage(broadcast.payload, to: app, appName: appName, stateHash: broadcast.hash) } + if owed.pushesPresets { + pushPresets(to: [app], triggeredBy: "OwedDelivery") + } } /// Redelivers on the triggers that occur anyway (broadcast, reconnect, inbound message) instead of timers. @@ -1034,27 +1058,50 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { } } - /// Main queue only. Best-effort: a dropped push waits for the watch's next preset request. - private func pushPresetsToComplications(triggeredBy _: String) { + /// Main queue only. + private func pushPresetsToComplications(triggeredBy trigger: String) { let complicationUUID = GarminWatchface.complication.watchfaceUUID - let targets = watchApps.filter { $0.uuid != nil && $0.uuid == complicationUUID } + pushPresets(to: watchApps.filter { $0.uuid != nil && $0.uuid == complicationUUID }, triggeredBy: trigger) + } + + /// Targeted like a reply, so it never touches the state broadcast or its content hash. + private func pushPresets(to targets: [IQApp], triggeredBy trigger: String) { guard !targets.isEmpty else { return } - let response = GarminMessageEnvelope.presetsResponse(capabilities: watchCommandProcessor.capabilities()) - for app in targets { - guard let key = deliveryKey(for: app), let device = app.device else { continue } - let appName = appDisplayName(for: app.uuid) - guard isReachable(key: key, device: device, appName: appName) else { continue } - sendTargetedResponse(response, to: app, appName: appName) + Task { + do { + let response = try await GarminMessageEnvelope.presetsResponse( + loadPresets(), + capabilities: self.watchCommandProcessor.capabilities() + ) + onMain { [weak self] in + guard let self else { return } + for app in targets { + guard let key = self.deliveryKey(for: app), let device = app.device else { continue } + let appName = self.appDisplayName(for: app.uuid) + guard self.isReachable(key: key, device: device, appName: appName) else { + self.recordOwed(for: key, presets: true) + continue + } + self.sendTargetedResponse(response, to: app, appName: appName, isPresets: true) + } + } + } catch { + warning(.watch, "Loading presets for the Garmin push after \(trigger) failed (\(WatchCommandErrorCategory.name(for: error)))") + } } } + private func loadPresets() async throws -> WatchPresets { + try await presetsProvider.presets() + } + /// Answers one app directly, never through `broadcastWatchStateData`, whose content hash would /// otherwise suppress the next state update. - private func sendTargetedResponse(_ response: [String: Any], to app: IQApp, appName: String) { + private func sendTargetedResponse(_ response: [String: Any], to app: IQApp, appName: String, isPresets: Bool = false) { let kind = response[WatchMessageKeys.request] as? String ?? "response" debug(.watch, "Sending \(kind) to Garmin \(appName)") - sendMessage(response, to: app, appName: appName) + sendMessage(response, to: app, appName: appName, isPresets: isPresets) } // MARK: - GarminManager Conformance @@ -1091,12 +1138,13 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { // MARK: - Helper: Sending Messages /// Sends with one resend after `sendRetryDelay` for transient failures; a final failure leaves the - /// state owed. `AppNotFound` is never resent: the app is provably not installed. + /// state or preset list owed. `AppNotFound` is never resent: the app is provably not installed. private func sendMessage( _ msg: Any, to app: IQApp, appName: String, stateHash: Int? = nil, + isPresets: Bool = false, isRetry: Bool = false ) { connectIQClient?.send(msg, to: app) { [weak self] result in @@ -1113,12 +1161,12 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { case .failure_AppNotFound: debug(.watch, "Garmin \(appName) not installed, skipped until re-registered or heard from") self.absentApps.insert(key) - self.recordOwed(for: key, stateHash: stateHash) + self.recordOwed(for: key, stateHash: stateHash, presets: isPresets) default: let reason = NSStringFromSendMessageResult(result) ?? "\(result.rawValue)" if isRetry { error(.watch, "Sending to Garmin \(appName) failed after retry (\(reason))") - self.recordOwed(for: key, stateHash: stateHash) + self.recordOwed(for: key, stateHash: stateHash, presets: isPresets) } else { warning(.watch, "Sending to Garmin \(appName) failed, retrying (\(reason))") DispatchQueue.main.asyncAfter(deadline: .now() + self.sendRetryDelay) { [weak self] in @@ -1128,6 +1176,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { to: app, appName: appName, stateHash: stateHash, + isPresets: isPresets, isRetry: true ) } @@ -1237,7 +1286,10 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM } } - let router = GarminMessageRouter(processor: watchCommandProcessor) + let router = GarminMessageRouter(processor: watchCommandProcessor, loadPresets: { [weak self] in + guard let self else { throw CancellationError() } + return try await self.loadPresets() + }) Task { let outcome = await router.route(message, from: appUUID, isRegistered: registered != nil, appName: appName) @@ -1251,6 +1303,11 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM if let trigger = outcome.refreshTrigger { triggerWatchStateUpdate(triggeredBy: trigger) } + if outcome.pushesPresets { + onMain { [weak self] in + self?.pushPresetsToComplications(triggeredBy: "WatchCommand") + } + } } } } diff --git a/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift b/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift index d50f456956..26603a32f5 100644 --- a/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift +++ b/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift @@ -83,14 +83,18 @@ enum GarminMessageEnvelope { ] } - /// The watch reads the switches and limits from the preset reply, so they ride along; the preset - /// lists stay empty until adjustment commands exist. Amounts go out as doubles in U and g. - static func presetsResponse(capabilities: WatchCommandCapabilities) -> [String: Any] { + /// The watch reads the switches and limits from the preset reply, so they ride along. `isEnabled` + /// reports whether a preset runs now, not whether the watch may start it; amounts go out in U and g. + static func presetsResponse(_ presets: WatchPresets, capabilities: WatchCommandCapabilities) -> [String: Any] { [ WatchMessageKeys.protocolVersion: protocolVersion, WatchMessageKeys.request: Request.presets.rawValue, - WatchMessageKeys.overridePresets: [[String: Any]](), - WatchMessageKeys.tempTargetPresets: [[String: Any]](), + WatchMessageKeys.overridePresets: presets.overridePresets.map { + [WatchMessageKeys.presetName: $0.name, WatchMessageKeys.presetIsEnabled: $0.isEnabled] as [String: Any] + }, + WatchMessageKeys.tempTargetPresets: presets.tempTargetPresets.map { + [WatchMessageKeys.presetName: $0.name, WatchMessageKeys.presetIsEnabled: $0.isEnabled] as [String: Any] + }, WatchMessageKeys.isCommandControlEnabled: capabilities.isCommandControlEnabled, WatchMessageKeys.isBolusCommandEnabled: capabilities.isBolusCommandEnabled, WatchMessageKeys.maxBolus: NSDecimalNumber(decimal: capabilities.maxBolus).doubleValue, @@ -117,6 +121,18 @@ enum GarminMessageEnvelope { let units = units(payload[WatchMessageKeys.bolus]) else { return nil } return .mealBolus(carbs: grams, bolus: units) + case WatchMessageKeys.activateOverride: + guard keys == [WatchMessageKeys.presetName], let name = payload[WatchMessageKeys.presetName] as? String + else { return nil } + return .activateOverride(name: name) + case WatchMessageKeys.cancelOverride: + return keys.isEmpty ? .cancelOverride : nil + case WatchMessageKeys.activateTempTarget: + guard keys == [WatchMessageKeys.presetName], let name = payload[WatchMessageKeys.presetName] as? String + else { return nil } + return .activateTempTarget(name: name) + case WatchMessageKeys.cancelTempTarget: + return keys.isEmpty ? .cancelTempTarget : nil default: return nil } @@ -165,11 +181,14 @@ struct GarminMessageRouter { var reply: [String: Any]? /// Trigger name for a regular watch-state update, if one is due. var refreshTrigger: String? + /// A preset started or stopped, so every Complication app needs the targeted preset list. + var pushesPresets = false static let none = Outcome(reply: nil, refreshTrigger: nil) } let processor: WatchCommandProcessor + let loadPresets: () async throws -> WatchPresets var log: WatchCommandLogger = .app func route(_ message: Any, from appUUID: UUID, isRegistered: Bool, appName: String) async -> Outcome { @@ -187,17 +206,21 @@ struct GarminMessageRouter { return Outcome(reply: nil, refreshTrigger: "WatchRequest") case .presets: - return Outcome( - reply: GarminMessageEnvelope.presetsResponse(capabilities: processor.capabilities()), - refreshTrigger: nil - ) + do { + let response = try await GarminMessageEnvelope.presetsResponse(loadPresets(), capabilities: processor.capabilities()) + return Outcome(reply: response, refreshTrigger: nil) + } catch { + log.warning("Loading presets for Garmin \(appName) failed (\(WatchCommandErrorCategory.name(for: error)))") + return .none + } case let .command(requestIDString, requestID, date, command): let request = WatchCommandRequest(requestID: requestID, appUUID: appUUID, date: date, command: command) let result = await processor.process(request) return Outcome( reply: GarminMessageEnvelope.acknowledgment(requestID: requestIDString, result: result), - refreshTrigger: result.refreshesState ? "WatchCommand" : nil + refreshTrigger: result.refreshesState ? "WatchCommand" : nil, + pushesPresets: result.acknowledged && command.changesPresetState ) case let .invalid(requestID): diff --git a/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift b/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift index d77f00c001..2462efe5fb 100644 --- a/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift +++ b/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift @@ -7,6 +7,19 @@ import Testing @testable import Trio +/// Overrides the services the Garmin manager reads for watch replies. +final class GarminOverrideAssembly: Assembly { + private let presets: WatchPresetsProvider + + init(presets: WatchPresetsProvider) { + self.presets = presets + } + + func assemble(container: Container) { + container.register(WatchPresetsProvider.self) { [presets] _ in presets } + } +} + func makeGarminResolver(stack: CoreDataStack, overrides: [Assembly]) -> Resolver { Assembler([ StorageAssembly(), @@ -90,12 +103,14 @@ final class RefreshTriggerRecorder: @unchecked Sendable { let deviceA = IQDevice(id: UUID(), modelName: "fenix 8", friendlyName: "A")! let deviceB = IQDevice(id: UUID(), modelName: "Enduro 3", friendlyName: "B")! + let presets = StubWatchPresetsProvider() let client = SpyConnectIQClient() + let presetChanges = PassthroughSubject() let manager: BaseGarminManager init() async throws { let stack = try await CoreDataStack.createForTests() - let resolver = makeGarminResolver(stack: stack, overrides: []) + let resolver = makeGarminResolver(stack: stack, overrides: [GarminOverrideAssembly(presets: presets)]) let settings = try #require(resolver.resolve(SettingsManager.self)) settings.settings.garminSettings.watchface = .complication @@ -105,6 +120,7 @@ final class RefreshTriggerRecorder: @unchecked Sendable { manager = BaseGarminManager( resolver: resolver, connectIQClient: client, + presetChanges: presetChanges.eraseToAnyPublisher(), sendRetryDelay: 0.01, broadcastDebounce: .milliseconds(10) ) @@ -215,10 +231,41 @@ final class RefreshTriggerRecorder: @unchecked Sendable { @Test("A message from an unregistered device is ignored") func testUnregisteredDevice() async throws { let triggers = RefreshTriggerRecorder(manager) let stranger = try #require(IQDevice(id: UUID(), modelName: "fenix 8", friendlyName: "C")) - manager.receivedMessage(["v": 1, "req": "status"], from: app(complicationUUID, on: stranger)) + manager.receivedMessage(["v": 1, "req": "presets"], from: app(complicationUUID, on: stranger)) await settle() #expect(client.sent.isEmpty) #expect(triggers.count("WatchRequest") == 0) } + + // MARK: - Presets + + @Test("A preset request is answered only to the requesting app") func testPresetsReplyTargeted() async { + presets.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] + manager.receivedMessage(["v": 1, "req": "presets"], from: app(complicationUUID, on: deviceA)) + + #expect(await eventually { !self.sent("presets").isEmpty }) + await settle() + let replies = sent("presets") + #expect(replies.count == 1) + #expect(replies.first?.appUUID == complicationUUID) + #expect(replies.first?.deviceUUID == deviceA.uuid) + } + + @Test("Preset storage changes push to Complication apps only") func testStorageChangePushesPresets() async { + presetChanges.send(()) + + #expect(await eventually { self.sent("presets").count == 2 }) + #expect(sent("presets").allSatisfy { $0.appUUID == complicationUUID }) + #expect(Set(sent("presets").map(\.deviceUUID)) == [deviceA.uuid, deviceB.uuid]) + } + + @Test("A failed preset push stays owed and follows the next delivered message") func testPresetPushOwed() async { + client.script([.failure_Timeout, .failure_Timeout], for: complicationUUID, on: deviceA.uuid) + presetChanges.send(()) + #expect(await eventually { self.sent("presets").filter { $0.deviceUUID == self.deviceA.uuid }.count == 2 }) + + broadcast(120) + #expect(await eventually { self.sent("presets").filter { $0.deviceUUID == self.deviceA.uuid }.count == 3 }) + } } diff --git a/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift b/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift index e9e2ffd19e..3d39fb71fa 100644 --- a/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift +++ b/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift @@ -7,12 +7,13 @@ import Testing let requestID = "3f2b8c1e-4a5d-4e6f-9a7b-1c2d3e4f5a6b" let appUUID = UUID() let milliseconds: UInt64 = 1_800_000_000_123 + let presets = StubWatchPresetsProvider() private func router( _ processor: WatchCommandProcessor = SpyWatchCommandProcessor(), logs: LogCapture? = nil ) -> GarminMessageRouter { - var router = GarminMessageRouter(processor: processor) + var router = GarminMessageRouter(processor: processor, loadPresets: { [presets] in try await presets.presets() }) if let logs { router.log = logs.logger } @@ -53,7 +54,7 @@ import Testing #expect(GarminMessageEnvelope.parse(42) == .invalid(requestID: nil)) } - @Test("Every treatment command parses with its exact payload") func testCommands() { + @Test("Every command parses with its exact payload") func testCommands() { #expect(parsedCommand(envelope(command: "bolus", payload: ["bolus": 1.5])) == .bolus(1.5)) #expect(parsedCommand(envelope(command: "bolus", payload: ["bolus": 2])) == .bolus(2)) #expect(parsedCommand(envelope(command: "carbs", payload: ["carbs": 45])) == .carbs(45)) @@ -61,6 +62,15 @@ import Testing parsedCommand(envelope(command: "mealBolus", payload: ["carbs": 45, "bolus": 3.25])) == .mealBolus(carbs: 45, bolus: 3.25) ) + #expect( + parsedCommand(envelope(command: "activateOverride", payload: ["name": "Sport"])) == + .activateOverride(name: "Sport") + ) + #expect(parsedCommand(envelope(command: "cancelOverride", payload: [:])) == .cancelOverride) + #expect( + parsedCommand(envelope(command: "activateTempTarget", payload: ["name": "Walk"])) == .activateTempTarget(name: "Walk") + ) + #expect(parsedCommand(envelope(command: "cancelTempTarget", payload: [:])) == .cancelTempTarget) } @Test("The command date is read as Unix milliseconds") func testDate() { @@ -89,6 +99,9 @@ import Testing ("carbs", ["carbs": 12.5]), ("carbs", ["carbs": "12"]), ("mealBolus", ["carbs": 20]), + ("activateOverride", ["name": 3]), + ("activateOverride", [:]), + ("cancelOverride", ["name": "Sport"]), ("requestBolusRecommendation", [:]), ("unknown", [:]) ] @@ -163,8 +176,7 @@ import Testing #expect(outcome.refreshTrigger == nil) } - @Test("A preset request gets the switches and limits the watch reads, with empty preset lists") func testPresetsReply( - ) async throws { + @Test("A preset reply carries the switches and limits the watch reads") func testPresetsReplySwitches() async throws { let processor = SpyWatchCommandProcessor() processor.capabilitiesResult = WatchCommandCapabilities( isCommandControlEnabled: true, @@ -192,6 +204,38 @@ import Testing #expect(outcome.refreshTrigger == nil) } + @Test("A preset request gets a targeted presets reply and no broadcast refresh") func testPresetsReply() async throws { + presets.overrides = [WatchPresetEntry(name: "Sport", isActive: true, ref: .presetID("sport"))] + presets.tempTargets = [WatchPresetEntry(name: "Walk", isActive: false, ref: .presetID("walk"))] + + let outcome = await router().route(["v": 1, "req": "presets"], from: appUUID, isRegistered: true, appName: "test") + + let reply = try #require(outcome.reply) + #expect(Set(reply.keys) == [ + "v", "req", "overridePresets", "tempTargetPresets", + "isCommandControlEnabled", "isBolusCommandEnabled", "maxBolus", "maxCarbs", "bolusIncrement" + ]) + #expect(reply["req"] as? String == "presets") + let overrides = try #require(reply["overridePresets"] as? [[String: Any]]) + #expect(overrides.first?["name"] as? String == "Sport") + #expect(overrides.first?["isEnabled"] as? Bool == true) + let tempTargets = try #require(reply["tempTargetPresets"] as? [[String: Any]]) + #expect(tempTargets.first?["name"] as? String == "Walk") + #expect(outcome.refreshTrigger == nil) + } + + @Test("A preset load failure is logged by category only") func testPresetErrorSanitized() async { + presets.error = SensitiveTestError() + let logs = LogCapture() + + let outcome = await router(logs: logs) + .route(["v": 1, "req": "presets"], from: appUUID, isRegistered: true, appName: "test") + + #expect(outcome.reply == nil) + #expect(logs.entries.contains { $0.level == .warning && $0.line.contains("(unexpected)") }) + #expect(logs.lines.allSatisfy { !$0.contains(SensitiveTestError.marker) }) + } + @Test("A command gets exactly the v1 ack and triggers a refresh on success") func testCommandAck() async throws { let processor = SpyWatchCommandProcessor() let sut = router(processor) @@ -233,6 +277,47 @@ import Testing #expect(partial.reply?["ackCode"] as? String == "partial_failure") } + @Test("Only successful preset commands push the preset list") func testPresetPushRules() async { + let processor = SpyWatchCommandProcessor() + let sut = router(processor) + + processor.result = .success(.overrideStarted, "Override started.") + let started = await sut.route( + envelope(command: "activateOverride", payload: ["name": "Sport"]), + from: appUUID, + isRegistered: true, + appName: "test" + ) + #expect(started.pushesPresets) + + processor.result = .success(.tempTargetStopped, "Temp target stopped.") + let stopped = await sut.route( + envelope(command: "cancelTempTarget", payload: [:]), + from: appUUID, + isRegistered: true, + appName: "test" + ) + #expect(stopped.pushesPresets) + + processor.result = .failure("Override preset not found.") + let failed = await sut.route( + envelope(command: "activateOverride", payload: ["name": "Sport"]), + from: appUUID, + isRegistered: true, + appName: "test" + ) + #expect(failed.pushesPresets == false) + + processor.result = .success(.carbsLogged, "Carbs logged.") + let carbs = await sut.route( + envelope(command: "carbs", payload: ["carbs": 10]), + from: appUUID, + isRegistered: true, + appName: "test" + ) + #expect(carbs.pushesPresets == false) + } + @Test("Invalid commands are answered only when they name a request ID") func testInvalidReplies() async { let processor = SpyWatchCommandProcessor() let sut = router(processor) @@ -251,6 +336,29 @@ import Testing #expect(anonymous.reply == nil) #expect(processor.processed.isEmpty) } + + // MARK: - Presets + + @Test("Presets are sorted by name and keep their state") func testPresetsSorted() async throws { + presets.overrides = [ + WatchPresetEntry(name: "b", isActive: false, ref: .presetID("b")), + WatchPresetEntry(name: "A", isActive: true, ref: .presetID("A")), + WatchPresetEntry(name: "c", isActive: false, ref: .presetID("c")) + ] + presets.tempTargets = [ + WatchPresetEntry(name: "Walk 10", isActive: false, ref: .presetID("10")), + WatchPresetEntry(name: "Walk 9", isActive: false, ref: .presetID("9")) + ] + + let sorted = try await presets.presets() + + #expect(sorted.overridePresets == [ + OverridePresetWatch(name: "A", isEnabled: true), + OverridePresetWatch(name: "b", isEnabled: false), + OverridePresetWatch(name: "c", isEnabled: false) + ]) + #expect(sorted.tempTargetPresets.map(\.name) == ["Walk 9", "Walk 10"]) + } } @Suite("Garmin Command Settings Migration Tests") struct GarminCommandSettingsMigrationTests { diff --git a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift index da75613fa9..1a339c872c 100644 --- a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift +++ b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift @@ -50,17 +50,23 @@ private final class WatchCommandOverrideAssembly: Assembly { private let validator: BolusSafetyValidator private let processor: WatchCommandProcessor? private let apsManager: APSManager? + private let presets: WatchPresetsProvider + private let adjustmentContext: (() -> NSManagedObjectContext)? init( carbsContext: @escaping () -> NSManagedObjectContext, validator: BolusSafetyValidator, processor: WatchCommandProcessor?, - apsManager: APSManager? + apsManager: APSManager?, + presets: WatchPresetsProvider, + adjustmentContext: (() -> NSManagedObjectContext)? ) { self.carbsContext = carbsContext self.validator = validator self.processor = processor self.apsManager = apsManager + self.presets = presets + self.adjustmentContext = adjustmentContext } func assemble(container: Container) { @@ -68,12 +74,18 @@ private final class WatchCommandOverrideAssembly: Assembly { BaseCarbsStorage(resolver: r, contextProvider: carbsContext) }.inObjectScope(.container) container.register(BolusSafetyValidator.self) { [validator] _ in validator } + container.register(WatchPresetsProvider.self) { [presets] _ in presets } if let processor { container.register(WatchCommandProcessor.self) { _ in processor }.inObjectScope(.container) } if let apsManager { container.register(APSManager.self) { _ in apsManager }.inObjectScope(.container) } + if let adjustmentContext { + container.register(AdjustmentManager.self) { r in + BaseAdjustmentManager(resolver: r, contextProvider: adjustmentContext, recompute: {}) + }.inObjectScope(.container) + } } } @@ -82,7 +94,9 @@ private func makeResolver( carbsContext: @escaping () -> NSManagedObjectContext, validator: BolusSafetyValidator = StubBolusSafetyValidator(), processor: WatchCommandProcessor? = nil, - apsManager: APSManager? = nil + apsManager: APSManager? = nil, + presets: WatchPresetsProvider = StubWatchPresetsProvider(), + adjustmentContext: (() -> NSManagedObjectContext)? = nil ) -> Resolver { Assembler([ StorageAssembly(), @@ -96,7 +110,9 @@ private func makeResolver( carbsContext: carbsContext, validator: validator, processor: processor, - apsManager: apsManager + apsManager: apsManager, + presets: presets, + adjustmentContext: adjustmentContext ) // same scope as the app, so the test and the managers share one settings manager ], parent: nil, defaultObjectScope: .container).resolver @@ -284,6 +300,21 @@ private func makeResolver( #expect(sent("ack").count == 2) } + @Test("A started preset pushes the list to every Complication app, never to datafields") func testCommandPushesPresets() async { + processor.result = .success(.overrideStarted, "Override started.") + manager.receivedMessage( + command("activateOverride", payload: ["name": "Sport"], requestID: UUID().uuidString), + from: app(complicationUUID, on: deviceA) + ) + + #expect(await eventually { self.sent("presets").count == 2 }) + let pushes = sent("presets") + #expect(Set(pushes.map(\.deviceUUID)) == [deviceA.uuid, deviceB.uuid]) + #expect(pushes.allSatisfy { $0.appUUID == complicationUUID }) + #expect(sent("ack").map(\.deviceUUID) == [deviceA.uuid]) + #expect(client.sent.filter { !$0.isState }.allSatisfy { $0.appUUID != datafieldUUID }) + } + @Test( "Command switch and max carbs changes push the preset reply to Complication apps only" ) func testSettingsPushPresets() async { @@ -335,8 +366,8 @@ private func makeResolver( // MARK: - Revocation And Final Boundaries -/// Runs revocation through the production settings manager, Garmin manager, processor and storage; -/// only the pump, the safety validator and the Connect IQ SDK are stand-ins. +/// Runs revocation through the production settings manager, Garmin manager, processor, storage and +/// adjustment manager; only the pump, the safety validator and the Connect IQ SDK are stand-ins. @Suite("Watch Command Revocation Integration Tests", .serialized) struct WatchCommandRevocationIntegrationTests { let stack: CoreDataStack @@ -438,6 +469,99 @@ private func makeResolver( ) } + // MARK: R2 + + @Test( + "A Garmin adjustment queued behind a busy context writes nothing once revoked", + arguments: [ + WatchCommand.activateOverride(name: "Sport"), .cancelOverride, + .activateTempTarget(name: "Walk"), .cancelTempTarget + ] + ) func testAdjustmentRevokedWhileQueued(command: WatchCommand) async throws { + let stack = self.stack + let context = countingContext() + let transactions = TestGate(isOpen: true) + let presets = StubWatchPresetsProvider() + let resolver = makeResolver( + stack: stack, + carbsContext: { stack.newTaskContext() }, + presets: presets, + adjustmentContext: { + transactions.arrive() + return context + } + ) + let settings = try #require(resolver.resolve(SettingsManager.self)) + enableCommands(settings) + + let seed = stack.newTaskContext() + let (overrideID, tempTargetID) = try await seed.perform { + for (name, enabled) in [("Sport", false), ("Running", true)] { + let row = OverrideStored(context: seed) + row.id = UUID().uuidString + row.name = name + row.enabled = enabled + row.date = Date().addingTimeInterval(-600) + row.duration = NSDecimalNumber(value: 0) + row.indefinite = true + row.isPreset = true + row.percentage = 100 + } + for (name, enabled) in [("Walk", false), ("Hike", true)] { + let row = TempTargetStored(context: seed) + row.id = UUID() + row.name = name + row.enabled = enabled + row.date = Date().addingTimeInterval(-600) + row.duration = NSDecimalNumber(value: 60) + row.target = NSDecimalNumber(value: 120) + row.isPreset = true + row.enteredBy = TempTarget.local + } + try seed.save() + let overrides = try seed.fetch(NSFetchRequest(entityName: "OverrideStored")) + let tempTargets = try seed.fetch(NSFetchRequest(entityName: "TempTargetStored")) + return ( + overrides.first { $0.name == "Sport" }!.id!, + tempTargets.first { $0.name == "Walk" }!.id!.uuidString + ) + } + + presets.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID(overrideID))] + presets.tempTargets = [WatchPresetEntry(name: "Walk", isActive: false, ref: .presetID(tempTargetID))] + let processor = BaseWatchCommandProcessor(resolver: resolver, actions: StubWatchCommandActions()) + + let blocker = ContextQueueBlocker() + blocker.block(context) + let pending = Task { await processor.process(request(command)) } + // past every processor check and inside the adjustment manager's transaction + await transactions.waitForArrivals() + + settings.settings.isGarminCommandControlEnabled = false + settings.settings.isGarminCommandControlEnabled = true + blocker.release() + let result = await pending.value + + #expect(result.acknowledged == false) + #expect(result.ackCode == .genericFailure) + #expect(context.saveCount == 0, "Nothing was saved") + #expect(await context.perform { !context.hasChanges }, "Nothing was even staged") + + let enabled: [String] = try await seed.perform { + seed.reset() + let overrides = try seed.fetch(NSFetchRequest(entityName: "OverrideStored")) + .filter(\.enabled).compactMap(\.name) + let tempTargets = try seed.fetch(NSFetchRequest(entityName: "TempTargetStored")) + .filter(\.enabled).compactMap(\.name) + return (overrides + tempTargets).sorted() + } + #expect(enabled == ["Hike", "Running"]) + + // the same setup commits once nothing is revoked + #expect(await processor.process(request(command)).acknowledged) + #expect(context.saveCount == 1) + } + @Test( "Carbs queued behind a busy context are not stored once revoked", arguments: [false, true] diff --git a/TrioTests/WatchCommandTests/WatchCommandPipelineTests.swift b/TrioTests/WatchCommandTests/WatchCommandPipelineTests.swift index 37774ec5f3..0be54a499b 100644 --- a/TrioTests/WatchCommandTests/WatchCommandPipelineTests.swift +++ b/TrioTests/WatchCommandTests/WatchCommandPipelineTests.swift @@ -226,6 +226,7 @@ import Testing } @Test("Error categories are stable and content-free") func testErrorCategories() { + #expect(WatchCommandErrorCategory.name(for: AdjustmentError.persistenceFailed("row 42")) == "adjustmentPersistence") #expect(WatchCommandErrorCategory.name(for: CoreDataError.fetchError(function: "f", file: "g")) == "coreData") #expect(WatchCommandErrorCategory.name(for: CancellationError()) == "cancelled") let saveError = NSError(domain: NSCocoaErrorDomain, code: 134_030, userInfo: ["detail": SensitiveTestError.marker]) diff --git a/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift b/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift index 744b0682dd..5df20948ce 100644 --- a/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift +++ b/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift @@ -4,7 +4,7 @@ import Testing @testable import Trio -/// What the processor decides before and around the side effects; pump and Core Data are stubbed. +/// What the processor decides before and around the side effects; pump, Core Data and presets are stubbed. @Suite("Watch Command Processor Tests") struct WatchCommandProcessorTests { let now = Date(timeIntervalSince1970: 1_800_000_000) let clock = TestClock(Date(timeIntervalSince1970: 1_800_000_000)) @@ -12,7 +12,9 @@ import Testing let settings = StubSettingsManager() let validator = StubBolusSafetyValidator() + let adjustments = StubAdjustmentManager() let actions = StubWatchCommandActions() + let presets = StubWatchPresetsProvider() let cache = WatchCommandRequestCache() let logs = LogCapture() let container = Container() @@ -25,7 +27,9 @@ import Testing container.register(SettingsManager.self) { [settings] _ in settings } container.register(BolusSafetyValidator.self) { [validator] _ in validator } + container.register(AdjustmentManager.self) { [adjustments] _ in adjustments } container.register(WatchCommandAuthorization.self) { [settings] _ in settings.authorization } + container.register(WatchPresetsProvider.self) { [presets] _ in presets } container.register(WatchCommandPipeline.self) { [settings, cache, clock, logs] _ in WatchCommandPipeline(authorization: settings.authorization, cache: cache, now: { clock.now }, log: logs.logger) }.inObjectScope(.container) @@ -91,7 +95,11 @@ import Testing @Test("Master switch off rejects every mutable command without side effects") func testMasterSwitchOff() async { settings.settings.isGarminCommandControlEnabled = false - let commands: [WatchCommand] = [.carbs(10), .bolus(1), .mealBolus(carbs: 10, bolus: 1)] + let commands: [WatchCommand] = [ + .carbs(10), .bolus(1), .mealBolus(carbs: 10, bolus: 1), + .activateOverride(name: "Sport"), .cancelOverride, + .activateTempTarget(name: "Walk"), .cancelTempTarget + ] for command in commands { let result = await processor.process(request(command)) #expect(result.acknowledged == false) @@ -99,6 +107,7 @@ import Testing } #expect(actions.events.isEmpty) + #expect(adjustments.calls.isEmpty) #expect(validator.validatedAmounts.isEmpty) } @@ -295,6 +304,73 @@ import Testing #expect(actions.storedCarbs.count == 2) } + // MARK: - Adjustments + + @Test("Activating an override uses the exact preset and the watch source") func testActivateOverride() async { + presets.overrides = [ + WatchPresetEntry(name: "Sport ", isActive: false, ref: .presetID("trailing")), + WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("exact")) + ] + let result = await processor.process(request(.activateOverride(name: "Sport"))) + + #expect(result == .success(.overrideStarted, result.message)) + #expect(adjustments.calls == [.activateOverride(.presetID("exact"))]) + #expect(adjustments.sources == [.watch]) + } + + @Test("An inactive preset can be activated; its state only reports whether it runs") func testInactivePresetActivates( + ) async throws { + presets.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] + presets.tempTargets = [WatchPresetEntry(name: "Walk", isActive: true, ref: .presetID("walk"))] + + #expect(await processor.process(request(.activateOverride(name: "Sport"))).ackCode == .overrideStarted) + #expect(await processor.process(request(.activateTempTarget(name: "Walk"))).ackCode == .tempTargetStarted) + #expect(adjustments.calls == [.activateOverride(.presetID("sport")), .activateTempTarget(.presetID("walk"))]) + + let list = try await presets.presets() + #expect(list.overridePresets == [OverridePresetWatch(name: "Sport", isEnabled: false)]) + #expect(list.tempTargetPresets == [TempTargetPresetWatch(name: "Walk", isEnabled: true)]) + } + + @Test("Missing or ambiguous override names are rejected", arguments: ["Missing", "Sport"]) func testOverrideNotUnique( + name: String + ) async { + presets.overrides = [ + WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("a")), + WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("b")) + ] + let result = await processor.process(request(.activateOverride(name: name))) + + #expect(result.acknowledged == false) + #expect(adjustments.calls.isEmpty) + } + + @Test("Cancelling with nothing active is an idempotent success") func testCancelIdempotent() async { + adjustments.error = AdjustmentError.nothingActive + + let override = await processor.process(request(.cancelOverride)) + let tempTarget = await processor.process(request(.cancelTempTarget)) + + #expect(override == .success(.overrideStopped, override.message)) + #expect(tempTarget == .success(.tempTargetStopped, tempTarget.message)) + } + + @Test("Other cancellation errors are failures") func testCancelFailure() async { + adjustments.error = AdjustmentError.persistenceFailed("disk full") + let result = await processor.process(request(.cancelOverride)) + + #expect(result.acknowledged == false) + #expect(!result.message.contains("disk full"), "Internal errors never reach the watch") + } + + @Test("Activating a temp target uses the exact preset") func testActivateTempTarget() async { + presets.tempTargets = [WatchPresetEntry(name: "Walk", isActive: false, ref: .presetID("walk"))] + let result = await processor.process(request(.activateTempTarget(name: "Walk"))) + + #expect(result == .success(.tempTargetStarted, result.message)) + #expect(adjustments.calls == [.activateTempTarget(.presetID("walk"))]) + } + // MARK: - Capabilities @Test("Capabilities report the command switches and limits as they are at call time") func testCapabilities() { @@ -388,8 +464,45 @@ import Testing #expect(settings.authorization.current == epoch) } + @Test("Disabling commands while presets load stops the activation") func testRevokedDuringPresetLoad() async { + presets.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] + presets.gate = TestGate() + let pending = Task { await processor.process(request(.activateOverride(name: "Sport"))) } + await presets.gate.waitForArrivals() + + settings.settings.isGarminCommandControlEnabled = false + settings.settings.isGarminCommandControlEnabled = true + presets.gate.open() + + #expect(await pending.value.acknowledged == false) + #expect(adjustments.calls.isEmpty) + } + // MARK: - Final Boundary + @Test( + "A revocation while an adjustment waits for the serializer stops it before the write", + arguments: [ + WatchCommand.activateOverride(name: "Sport"), .cancelOverride, + .activateTempTarget(name: "Walk"), .cancelTempTarget + ] + ) func testAdjustmentRevokedWhileQueued(command: WatchCommand) async { + presets.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] + presets.tempTargets = [WatchPresetEntry(name: "Walk", isActive: false, ref: .presetID("walk"))] + adjustments.gate = TestGate() + let pending = Task { await processor.process(request(command)) } + await adjustments.gate.waitForArrivals() + + settings.settings.isGarminCommandControlEnabled = false + settings.settings.isGarminCommandControlEnabled = true + adjustments.gate.open() + let result = await pending.value + + #expect(result.acknowledged == false) + #expect(result.ackCode == .genericFailure) + #expect(adjustments.calls.isEmpty) + } + @Test( "A revocation while carbs wait for their transaction stores nothing", arguments: [false, true] @@ -608,10 +721,15 @@ import Testing @Test("Error details never reach the log or the ack") func testSensitiveErrorsSanitized() async { actions.carbsError = SensitiveTestError() + adjustments.error = SensitiveTestError() + presets.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] let results = [ await processor.process(request(.carbs(10))), - await processor.process(request(.mealBolus(carbs: 10, bolus: 1))) + await processor.process(request(.mealBolus(carbs: 10, bolus: 1))), + await processor.process(request(.activateOverride(name: "Sport"))), + await processor.process(request(.cancelOverride)), + await processor.process(request(.cancelTempTarget)) ] #expect(results.allSatisfy { !$0.acknowledged }) diff --git a/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift b/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift index 7b1e204bcc..3030d8beef 100644 --- a/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift +++ b/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift @@ -119,6 +119,25 @@ final class LogCapture: @unchecked Sendable { } } +final class StubWatchPresetsProvider: WatchPresetsProvider, @unchecked Sendable { + var overrides: [WatchPresetEntry] = [] + var tempTargets: [WatchPresetEntry] = [] + var error: Error? + var gate = TestGate(isOpen: true) + + func overridePresets() async throws -> [WatchPresetEntry] { + await gate.pass() + if let error { throw error } + return overrides + } + + func tempTargetPresets() async throws -> [WatchPresetEntry] { + await gate.pass() + if let error { throw error } + return tempTargets + } +} + /// Revokes from its setter the way `BaseSettingsManager` does. final class StubSettingsManager: SettingsManager { let authorization = WatchCommandAuthorization() @@ -289,3 +308,83 @@ final class SpyAPSManager: APSManager, @unchecked Sendable { func cancelBolus(_: ((Bool, String) -> Void)?) async {} let iobFileDidUpdate = PassthroughSubject() } + +final class StubAdjustmentManager: AdjustmentManager { + enum Call: Equatable { + case activateOverride(AdjustmentRef) + case cancelOverride + case activateTempTarget(AdjustmentRef) + case cancelTempTarget + } + + private(set) var calls: [Call] = [] + private(set) var sources: [AdjustmentSource] = [] + var error: Error? + /// Parks guarded calls before their final check, the way the adjustment serializer would. + var gate = TestGate(isOpen: true) + + private func record(_ call: Call, source: AdjustmentSource) throws -> AdjustmentOutcome { + calls.append(call) + sources.append(source) + if let error { throw error } + return AdjustmentOutcome(started: nil, ended: []) + } + + func activateOverride(_ preset: AdjustmentRef, source: AdjustmentSource, waitForUpload _: Bool) async throws + -> AdjustmentOutcome + { + try record(.activateOverride(preset), source: source) + } + + func cancelOverride(source: AdjustmentSource, waitForUpload _: Bool) async throws -> AdjustmentOutcome { + try record(.cancelOverride, source: source) + } + + func activateTempTarget(_ preset: AdjustmentRef, source: AdjustmentSource, waitForUpload _: Bool) async throws + -> AdjustmentOutcome + { + try record(.activateTempTarget(preset), source: source) + } + + func cancelTempTarget(source: AdjustmentSource, waitForUpload _: Bool) async throws -> AdjustmentOutcome { + try record(.cancelTempTarget, source: source) + } + + private func guarded( + _ call: Call, + source: AdjustmentSource, + authorize: @Sendable() throws -> Void + ) async throws -> AdjustmentOutcome { + await gate.pass() + try authorize() + return try record(call, source: source) + } + + func activateOverride( + _ preset: AdjustmentRef, + source: AdjustmentSource, + authorize: @escaping @Sendable() throws -> Void + ) async throws -> AdjustmentOutcome { + try await guarded(.activateOverride(preset), source: source, authorize: authorize) + } + + func cancelOverride(source: AdjustmentSource, authorize: @escaping @Sendable() throws -> Void) async throws + -> AdjustmentOutcome + { + try await guarded(.cancelOverride, source: source, authorize: authorize) + } + + func activateTempTarget( + _ preset: AdjustmentRef, + source: AdjustmentSource, + authorize: @escaping @Sendable() throws -> Void + ) async throws -> AdjustmentOutcome { + try await guarded(.activateTempTarget(preset), source: source, authorize: authorize) + } + + func cancelTempTarget(source: AdjustmentSource, authorize: @escaping @Sendable() throws -> Void) async throws + -> AdjustmentOutcome + { + try await guarded(.cancelTempTarget, source: source, authorize: authorize) + } +} From c8ce87db9779872e84177fac4f1bc6a926ad6328 Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 27 Oct 2026 11:17:57 +0100 Subject: [PATCH 05/34] Let the watchface selection pick the single Garmin command app With Trio-Complication selected as watchface it is the command app; with any other watchface Trio-WatchControls is, while watch commands are on. Only the command app's commands run, and only it gets the preset list. - Trio-WatchControls is registered, sent state and presets only while it is the command app - ownerMode notice (active, owner) on every change; a missed stand-down is repeated when the app next sends anything, a failed activation is owed like state - commands from any other app, including an unregistered command app on a selected device, are refused with an ack instead of silence - a change of command app revokes pending commands - help text names the command app Waits for proposed PRs 1-4. --- Trio/Sources/Models/GarminWatchSettings.swift | 35 ++++++ Trio/Sources/Models/WatchMessageKeys.swift | 2 + .../View/WatchConfigGarminAppConfigView.swift | 5 +- .../WatchCommand/WatchCommandProcessor.swift | 25 +++-- .../Services/WatchManager/GarminManager.swift | 101 ++++++++++++++--- .../WatchManager/GarminMessageRouter.swift | 38 ++++++- .../GarminMessageRouterTests.swift | 42 +++++++ .../WatchCommandIntegrationTests.swift | 104 ++++++++++++++++-- .../WatchCommandProcessorTests.swift | 51 ++++++++- 9 files changed, 363 insertions(+), 40 deletions(-) diff --git a/Trio/Sources/Models/GarminWatchSettings.swift b/Trio/Sources/Models/GarminWatchSettings.swift index 2c7ed1be1b..7202d302b3 100644 --- a/Trio/Sources/Models/GarminWatchSettings.swift +++ b/Trio/Sources/Models/GarminWatchSettings.swift @@ -84,6 +84,41 @@ enum GarminWatchface: String, JSON, CaseIterable, Identifiable, Codable, Hashabl } } +// MARK: - Garmin Command App + +/// Connect IQ watch app that sends commands next to a Trio or Swissalpine watchface. +enum GarminWatchControls { + static let displayName = "Trio-WatchControls" + static let appUUID = UUID(uuidString: "d504737e-8315-48d4-b07e-4c421ffb6f54") +} + +/// The one watch app allowed to send commands; with both command apps installed, both would otherwise prompt. +/// Raw values are the `owner` the watch reads from the command app notice. +enum GarminCommandApp: String { + case complication + case watchControls + case none + + /// The watchface selection decides, since Complication then runs in the watchface slot. + init(_ settings: GarminWatchSettings) { + if settings.watchface == .complication { + self = .complication + } else { + self = settings.isCommandControlEnabled ? .watchControls : .none + } + } + + var appUUID: UUID? { + switch self { + case .complication: return GarminWatchface.complication.watchfaceUUID + case .watchControls: return GarminWatchControls.appUUID + case .none: return nil + } + } + + static let appUUIDs = Set([GarminWatchface.complication.watchfaceUUID, GarminWatchControls.appUUID].compactMap { $0 }) +} + // MARK: - Garmin Datafield Setting /// Defines the available Garmin datafields with their associated UUIDs. diff --git a/Trio/Sources/Models/WatchMessageKeys.swift b/Trio/Sources/Models/WatchMessageKeys.swift index 1defd0deb3..8915216d6d 100644 --- a/Trio/Sources/Models/WatchMessageKeys.swift +++ b/Trio/Sources/Models/WatchMessageKeys.swift @@ -75,4 +75,6 @@ enum WatchMessageKeys { static let mealBolus = "mealBolus" static let isCommandControlEnabled = "isCommandControlEnabled" static let isBolusCommandEnabled = "isBolusCommandEnabled" + static let isCommandAppActive = "active" + static let commandApp = "owner" } diff --git a/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift b/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift index d904add703..5df4a70de8 100644 --- a/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift +++ b/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift @@ -304,7 +304,10 @@ struct WatchConfigGarminAppConfigView: View { "Lets a supported Garmin app log carbs and start or stop override and temp target presets. Status and the preset list stay available with this switch off." ) Text( - "Commands are only accepted from the Garmin apps selected above, must reach the phone within 2 minutes of being confirmed on the watch, and carbs are limited by your Max Carbs setting." + "Commands are only accepted from one Garmin app: Trio-Complication when it is selected as watchface, otherwise Trio-WatchControls. Changing the watchface switches the command app right away and cancels commands still pending." + ) + Text( + "Commands must reach the phone within 2 minutes of being confirmed on the watch, and carbs are limited by your Max Carbs setting." ) }, sheetTitle: String(localized: "Help", comment: "Help sheet title") diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift b/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift index e01a9c2b4b..a100c34794 100644 --- a/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift +++ b/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift @@ -11,14 +11,21 @@ protocol WatchCommandProcessor { } extension WatchCommandAuthorization { - /// Call before `new` is stored. Turning either Garmin command switch off always revokes, even - /// when it is switched back on right after; turning one on never does. + /// Call before `new` is stored. Turning either Garmin command switch off, or a change of command app, + /// always revokes, even when it is undone right after; turning a switch on never does. func settingsWillChange(from old: TrioSettings, to new: TrioSettings) { let masterTurnedOff = old.isGarminCommandControlEnabled && !new.isGarminCommandControlEnabled let bolusTurnedOff = old.isGarminBolusCommandEnabled && !new.isGarminBolusCommandEnabled - guard masterTurnedOff || bolusTurnedOff else { return } - revoke() - info(.watch, "Garmin command setting turned off, pending watch commands revoked") + let oldCommandApp = GarminCommandApp(old.garminSettings) + // with no command app before, nothing could be pending + let commandAppChanged = oldCommandApp != .none && oldCommandApp != GarminCommandApp(new.garminSettings) + if masterTurnedOff || bolusTurnedOff { + revoke() + info(.watch, "Garmin command setting turned off, pending watch commands revoked") + } else if commandAppChanged { + revoke() + info(.watch, "Garmin command app changed, pending watch commands revoked") + } } } @@ -97,7 +104,7 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { issuedAt: request.date, command: request.command, name: request.command.name, - gate: { [self] in gateRejection(for: request.command) } + gate: { [self] in gateRejection(for: request.command, from: request.appUUID) } ) { context in await execute(request.command, context: context) } @@ -140,12 +147,16 @@ final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { } /// Re-read at every check: settings can change while a command is suspended. - private func gateRejection(for command: WatchCommand) -> WatchCommandResult? { + private func gateRejection(for command: WatchCommand, from appUUID: UUID) -> WatchCommandResult? { let garminSettings = settingsManager.settings.garminSettings guard garminSettings.isCommandControlEnabled else { log.info("Garmin \(command.name) command refused, watch commands are off") return .failure(String(localized: "Watch commands are disabled.", comment: "Watch command ack")) } + guard GarminCommandApp(garminSettings).appUUID == appUUID else { + log.info("Garmin \(command.name) command refused, sent by an app that is not the command app") + return .failure(String(localized: "Commands are handled by another watch app.", comment: "Watch command ack")) + } guard !command.isInsulin || garminSettings.isBolusCommandEnabled else { log.info("Garmin \(command.name) command refused, bolus commands are off") return .failure(String(localized: "Bolus from watch is disabled.", comment: "Watch command ack")) diff --git a/Trio/Sources/Services/WatchManager/GarminManager.swift b/Trio/Sources/Services/WatchManager/GarminManager.swift index 4e15860cde..d5d3906c29 100644 --- a/Trio/Sources/Services/WatchManager/GarminManager.swift +++ b/Trio/Sources/Services/WatchManager/GarminManager.swift @@ -158,6 +158,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { private struct OwedDelivery { var stateHash: Int? var pushesPresets = false + var announcesCommandApp = false } /// The send result is the readiness signal: readiness never gates a send, because a missed @@ -316,6 +317,11 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { settingsManager.settings.garminSettings.watchface } + /// The only watch app whose commands run, and the only one that gets the preset list. + private var currentCommandApp: GarminCommandApp { + GarminCommandApp(settingsManager.settings.garminSettings) + } + /// Returns the currently configured Garmin datafield from settings private var currentDatafield: GarminDatafield { settingsManager.settings.garminSettings.datafield @@ -343,6 +349,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { return "watchface:\(currentWatchface.displayName)" } else if uuid == currentDatafield.datafieldUUID { return "datafield:\(currentDatafield.displayName)" + } else if uuid == GarminWatchControls.appUUID { + return "watch app:\(GarminWatchControls.displayName)" } else { return "unknown app" } @@ -356,6 +364,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { return "watchface:\(currentWatchface.displayName) (\(uuid.uuidString))" } else if uuid == currentDatafield.datafieldUUID { return "datafield:\(currentDatafield.displayName) (\(uuid.uuidString))" + } else if uuid == GarminWatchControls.appUUID { + return "watch app:\(GarminWatchControls.displayName) (\(uuid.uuidString))" } else { return "unknown app (\(uuid.uuidString))" } @@ -376,7 +386,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { (presetChanges ?? storedPresetChanges)? .debounce(for: .seconds(1), scheduler: DispatchQueue.main) .sink { [weak self] in - self?.pushPresetsToComplications(triggeredBy: "PresetStorage") + self?.pushPresetsToCommandApp(triggeredBy: "PresetStorage") } .store(in: &subscriptions) @@ -839,9 +849,10 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { // MARK: - Device & App Registration /// Registers the given devices for ConnectIQ events (device status changes) and watch app messages. - /// It also creates and registers watch apps (watchface + data field) for each device. + /// It also creates and registers watch apps (watchface, data field and Trio-WatchControls) for each device. /// - Parameter devices: The devices to register. private func registerDevices(_ devices: [IQDevice]) { + let previousApps = watchApps watchApps.removeAll() // Reset broadcast hashes so newly registered apps receive data @@ -873,10 +884,27 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { watchApps.append(datafieldApp) connectIQClient?.registerApp(datafieldApp, delegate: self) } + + if currentCommandApp == .watchControls, + let watchControlsUUID = GarminWatchControls.appUUID, + let watchControlsApp = IQApp(uuid: watchControlsUUID, store: UUID(), device: device) + { + debugGarmin("Registered Garmin \(appDetailedName(for: watchControlsUUID))") + watchApps.append(watchControlsApp) + connectIQClient?.registerApp(watchControlsApp, delegate: self) + } } let registeredKeys = Set(watchApps.compactMap(deliveryKey(for:))) owedDeliveries = owedDeliveries.filter { registeredKeys.contains($0.key) } + + for app in watchApps where app.uuid == GarminWatchControls.appUUID { + sendCommandAppNotice(to: app, isActive: true) + } + for app in previousApps where app.uuid == GarminWatchControls.appUUID { + guard let key = deliveryKey(for: app), !registeredKeys.contains(key) else { continue } + sendCommandAppNotice(to: app, isActive: false) + } } /// Restores previously persisted devices from local storage into `devices`. @@ -1028,13 +1056,14 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { } /// Acks and other replies are never owed; the watch expires an unanswered command itself. - private func recordOwed(for key: String, stateHash: Int? = nil, presets: Bool = false) { - guard stateHash != nil || presets else { return } + private func recordOwed(for key: String, stateHash: Int? = nil, presets: Bool = false, commandApp: Bool = false) { + guard stateHash != nil || presets || commandApp else { return } var owed = owedDeliveries[key] ?? OwedDelivery() if let stateHash { owed.stateHash = stateHash } owed.pushesPresets = owed.pushesPresets || presets + owed.announcesCommandApp = owed.announcesCommandApp || commandApp owedDeliveries[key] = owed } @@ -1048,6 +1077,9 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { if owed.pushesPresets { pushPresets(to: [app], triggeredBy: "OwedDelivery") } + if owed.announcesCommandApp { + sendCommandAppNotice(to: app, isActive: true) + } } /// Redelivers on the triggers that occur anyway (broadcast, reconnect, inbound message) instead of timers. @@ -1059,9 +1091,23 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { } /// Main queue only. - private func pushPresetsToComplications(triggeredBy trigger: String) { - let complicationUUID = GarminWatchface.complication.watchfaceUUID - pushPresets(to: watchApps.filter { $0.uuid != nil && $0.uuid == complicationUUID }, triggeredBy: trigger) + private func pushPresetsToCommandApp(triggeredBy trigger: String) { + guard let commandAppUUID = currentCommandApp.appUUID else { return } + pushPresets(to: watchApps.filter { $0.uuid == commandAppUUID }, triggeredBy: trigger) + } + + /// Only Trio-WatchControls reads it. Only the active notice is owed: an app told to stand down that + /// missed it is told again as soon as it sends anything. + private func sendCommandAppNotice(to app: IQApp, isActive: Bool) { + guard let key = deliveryKey(for: app), let device = app.device else { return } + let appName = appDisplayName(for: app.uuid) + let notice = GarminMessageEnvelope.commandAppNotice(isActive: isActive, commandApp: currentCommandApp) + guard !isActive || isReachable(key: key, device: device, appName: appName) else { + recordOwed(for: key, commandApp: true) + return + } + debug(.watch, "Sending command app notice to Garmin \(appName), \(isActive ? "active" : "inactive")") + sendMessage(notice, to: app, appName: appName, announcesCommandApp: isActive) } /// Targeted like a reply, so it never touches the state broadcast or its content hash. @@ -1087,7 +1133,10 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { } } } catch { - warning(.watch, "Loading presets for the Garmin push after \(trigger) failed (\(WatchCommandErrorCategory.name(for: error)))") + warning( + .watch, + "Loading presets for the Garmin push after \(trigger) failed (\(WatchCommandErrorCategory.name(for: error)))" + ) } } } @@ -1145,6 +1194,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { appName: String, stateHash: Int? = nil, isPresets: Bool = false, + announcesCommandApp: Bool = false, isRetry: Bool = false ) { connectIQClient?.send(msg, to: app) { [weak self] result in @@ -1161,12 +1211,12 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { case .failure_AppNotFound: debug(.watch, "Garmin \(appName) not installed, skipped until re-registered or heard from") self.absentApps.insert(key) - self.recordOwed(for: key, stateHash: stateHash, presets: isPresets) + self.recordOwed(for: key, stateHash: stateHash, presets: isPresets, commandApp: announcesCommandApp) default: let reason = NSStringFromSendMessageResult(result) ?? "\(result.rawValue)" if isRetry { error(.watch, "Sending to Garmin \(appName) failed after retry (\(reason))") - self.recordOwed(for: key, stateHash: stateHash, presets: isPresets) + self.recordOwed(for: key, stateHash: stateHash, presets: isPresets, commandApp: announcesCommandApp) } else { warning(.watch, "Sending to Garmin \(appName) failed, retrying (\(reason))") DispatchQueue.main.asyncAfter(deadline: .now() + self.sendRetryDelay) { [weak self] in @@ -1177,6 +1227,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { appName: appName, stateHash: stateHash, isPresets: isPresets, + announcesCommandApp: announcesCommandApp, isRetry: true ) } @@ -1263,7 +1314,7 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM // MARK: - IQAppMessageDelegate - /// Called when a message arrives from a Garmin watch app (watchface, datafield or complication app). + /// Called when a message arrives from a Garmin watch app (watchface, datafield or command app). /// Status requests refresh the broadcast state; preset requests and commands are answered only to /// the requesting app. /// - Parameters: @@ -1284,6 +1335,11 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM self?.absentApps.remove(key) self?.flushOwed(to: registered, key: key, appName: appName) } + } else if appUUID == GarminWatchControls.appUUID, devices.contains(where: { $0.uuid == app.device?.uuid }) { + // it still acts as the command app, so it missed the notice to stand down + onMain { [weak self] in + self?.sendCommandAppNotice(to: app, isActive: false) + } } let router = GarminMessageRouter(processor: watchCommandProcessor, loadPresets: { [weak self] in @@ -1292,7 +1348,14 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM }) Task { - let outcome = await router.route(message, from: appUUID, isRegistered: registered != nil, appName: appName) + let outcome = await router.route( + message, + from: appUUID, + isRegistered: registered != nil, + // only on a selected device: any other watch must not reach the processor + isCommandApp: GarminCommandApp.appUUIDs.contains(appUUID) && devices.contains { $0.uuid == app.device?.uuid }, + appName: appName + ) if let reply = outcome.reply { onMain { [weak self] in @@ -1305,7 +1368,7 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM } if outcome.pushesPresets { onMain { [weak self] in - self?.pushPresetsToComplications(triggeredBy: "WatchCommand") + self?.pushPresetsToCommandApp(triggeredBy: "WatchCommand") } } } @@ -1339,13 +1402,14 @@ extension BaseGarminManager: SettingsObserver { lastSeenCommandRevocation = commandRevocation let maxCarbsChanged = settingsManager.settings.maxCarbs != previousMaxCarbs previousMaxCarbs = settingsManager.settings.maxCarbs + let commandAppChanged = GarminCommandApp(currentGarminSettings) != GarminCommandApp(previousGarminSettings) // Update stored values units = currentUnits glucoseColorScheme = currentColorScheme - // Re-register devices only if watchface/datafield configuration changed - if watchfaceChanged || datafieldChanged || watchfaceDataEnabledChanged { + // Re-register devices only if the set of watch apps changed + if watchfaceChanged || datafieldChanged || watchfaceDataEnabledChanged || commandAppChanged { debugGarmin("Garmin watchface or data field changed, re-registering devices") if !devices.isEmpty { registerDevices(devices) @@ -1363,7 +1427,8 @@ extension BaseGarminManager: SettingsObserver { debug(.watch, "Garmin watchface data enabled, sending update") } triggerWatchStateUpdate(triggeredBy: "Settings") - } else if commandSettingsChanged { + } else if commandSettingsChanged || commandAppChanged { + // a newly selected command app shows nothing until its first state triggerWatchStateUpdate(triggeredBy: "CommandSettings") } else if unitsChanged || displayAttributesChanged || colorSchemeChanged { // Throttle other settings changes in case user makes multiple changes @@ -1375,8 +1440,8 @@ extension BaseGarminManager: SettingsObserver { // the complication builds its menu and pickers from these; max bolus and bolus increment live // outside TrioSettings and reach it with the next preset request - if commandSettingsChanged || maxCarbsChanged { - pushPresetsToComplications(triggeredBy: "CommandSettings") + if commandSettingsChanged || maxCarbsChanged || commandAppChanged { + pushPresetsToCommandApp(triggeredBy: "CommandSettings") } // Store current Garmin settings for next comparison diff --git a/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift b/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift index 26603a32f5..7cd12dfe16 100644 --- a/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift +++ b/Trio/Sources/Services/WatchManager/GarminMessageRouter.swift @@ -21,6 +21,7 @@ enum GarminMessageEnvelope { case presets case command case ack + case ownerMode } private static let readEnvelopeKeys: Set = [WatchMessageKeys.protocolVersion, WatchMessageKeys.request] @@ -67,7 +68,8 @@ enum GarminMessageEnvelope { } let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000) return .command(requestIDString: requestIDString, requestID: requestID, date: date, command: command) - case .ack: + case .ack, + .ownerMode: return .invalid(requestID: requestIDString) } } @@ -103,6 +105,16 @@ enum GarminMessageEnvelope { ] } + /// Tells Trio-WatchControls whether it is the command app; `owner` lets an inactive one say which app is. + static func commandAppNotice(isActive: Bool, commandApp: GarminCommandApp) -> [String: Any] { + [ + WatchMessageKeys.protocolVersion: protocolVersion, + WatchMessageKeys.request: Request.ownerMode.rawValue, + WatchMessageKeys.isCommandAppActive: isActive, + WatchMessageKeys.commandApp: commandApp.rawValue + ] + } + // MARK: - Payloads private static func command(named name: String, payload: [String: Any]) -> WatchCommand? { @@ -181,7 +193,7 @@ struct GarminMessageRouter { var reply: [String: Any]? /// Trigger name for a regular watch-state update, if one is due. var refreshTrigger: String? - /// A preset started or stopped, so every Complication app needs the targeted preset list. + /// A preset started or stopped, so the command app needs the targeted preset list. var pushesPresets = false static let none = Outcome(reply: nil, refreshTrigger: nil) @@ -191,11 +203,19 @@ struct GarminMessageRouter { let loadPresets: () async throws -> WatchPresets var log: WatchCommandLogger = .app - func route(_ message: Any, from appUUID: UUID, isRegistered: Bool, appName: String) async -> Outcome { + /// An unregistered command app still gets its commands answered, so a watch that missed the switch to + /// the other command app sees a refusal instead of waiting for an ack that never comes. + func route( + _ message: Any, + from appUUID: UUID, + isRegistered: Bool, + isCommandApp: Bool = false, + appName: String + ) async -> Outcome { let inbound = GarminMessageEnvelope.parse(message) log.debug("Garmin \(appName) sent a \(inbound.logName)") - guard isRegistered else { + guard isRegistered || (isCommandApp && inbound.isCommand) else { log.info("Ignored a \(inbound.logName) from unregistered Garmin app \(appUUID.uuidString)") return .none } @@ -207,7 +227,10 @@ struct GarminMessageRouter { case .presets: do { - let response = try await GarminMessageEnvelope.presetsResponse(loadPresets(), capabilities: processor.capabilities()) + let response = try await GarminMessageEnvelope.presetsResponse( + loadPresets(), + capabilities: processor.capabilities() + ) return Outcome(reply: response, refreshTrigger: nil) } catch { log.warning("Loading presets for Garmin \(appName) failed (\(WatchCommandErrorCategory.name(for: error)))") @@ -234,6 +257,11 @@ struct GarminMessageRouter { private extension GarminInboundMessage { /// Never includes payload values: a command body can carry an insulin amount. + var isCommand: Bool { + if case .command = self { return true } + return false + } + var logName: String { switch self { case .legacyStatus: return "legacy status request" diff --git a/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift b/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift index 3d39fb71fa..e4062dabd8 100644 --- a/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift +++ b/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift @@ -169,6 +169,48 @@ import Testing #expect(processor.processed.isEmpty) } + @Test("An unregistered command app gets only its commands answered") func testUnregisteredCommandApp() async { + let processor = SpyWatchCommandProcessor() + let sut = router(processor) + + let command = await sut.route( + envelope(command: "carbs", payload: ["carbs": 10]), + from: appUUID, + isRegistered: false, + isCommandApp: true, + appName: "test" + ) + let presets = await sut.route( + ["v": 1, "req": "presets"], + from: appUUID, + isRegistered: false, + isCommandApp: true, + appName: "test" + ) + let status = await sut.route( + ["v": 1, "req": "status"], + from: appUUID, + isRegistered: false, + isCommandApp: true, + appName: "test" + ) + + #expect(command.reply?[WatchMessageKeys.request] as? String == "ack") + #expect(processor.processed.count == 1) + #expect(presets.reply == nil) + #expect(status.refreshTrigger == nil) + } + + @Test("The command app notice carries the exact keys Trio-WatchControls reads") func testCommandAppNotice() { + let notice = GarminMessageEnvelope.commandAppNotice(isActive: false, commandApp: .complication) + + #expect(Set(notice.keys) == ["v", "req", "active", "owner"]) + #expect(notice["req"] as? String == "ownerMode") + #expect(notice["active"] as? Bool == false) + #expect(notice["owner"] as? String == "complication") + #expect(GarminMessageEnvelope.parse(["v": 1, "req": "ownerMode"]) == .invalid(requestID: nil)) + } + @Test("Invalid messages get neither a reply nor a refresh") func testInvalidIgnored() async { let outcome = await router().route(["v": 1, "req": "bogus"], from: appUUID, isRegistered: true, appName: "test") diff --git a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift index 1a339c872c..5a66605f8a 100644 --- a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift +++ b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift @@ -175,7 +175,7 @@ private func makeResolver( actions: BaseWatchCommandActions(resolver: resolver), log: logs.logger ) - let appUUID = UUID() + let appUUID = try #require(GarminCommandApp(settings.settings.garminSettings).appUUID) let carbs = await processor.process( WatchCommandRequest(requestID: UUID(), appUUID: appUUID, date: Date(), command: .carbs(40)) @@ -362,6 +362,95 @@ private func makeResolver( #expect(authorization.current == start + 1, "Display settings leave commands alone") #expect(triggers.count("CommandSettings") == refreshes) } + + // MARK: - Command App + + private var watchControlsUUID: UUID { GarminWatchControls.appUUID! } + + private func notice(_ sent: SpyConnectIQClient.Sent) -> (active: Bool?, owner: String?) { + let message = sent.message as? [String: Any] + return (message?["active"] as? Bool, message?["owner"] as? String) + } + + @Test("Selecting another watchface hands commands to Trio-WatchControls on every device") func testHandOver() async { + let triggers = RefreshTriggerRecorder(manager) + let start = authorization.current + + settings.settings.garminSettings.watchface = .trio + + #expect(authorization.current == start + 1, "Pending Complication commands are revoked") + #expect(await eventually { self.sent("ownerMode").count == 2 }) + let notices = sent("ownerMode") + #expect(notices.allSatisfy { $0.appUUID == self.watchControlsUUID }) + #expect(Set(notices.map(\.deviceUUID)) == [deviceA.uuid, deviceB.uuid]) + #expect(notices.allSatisfy { self.notice($0) == (true, "watchControls") }) + #expect(await eventually { self.sent("presets").count == 2 }) + #expect(sent("presets").allSatisfy { $0.appUUID == self.watchControlsUUID }) + #expect(await eventually { triggers.count("CommandSettings") >= 1 }, "The new command app gets state right away") + } + + @Test("Selecting Complication again tells Trio-WatchControls to stand down") func testStandDown() async { + settings.settings.garminSettings.watchface = .trio + #expect(await eventually { self.sent("ownerMode").count == 2 }) + + settings.settings.garminSettings.watchface = .complication + + #expect(await eventually { self.sent("ownerMode").count == 4 }) + let standDowns = sent("ownerMode").suffix(2) + #expect(standDowns.allSatisfy { $0.appUUID == self.watchControlsUUID }) + #expect(standDowns.allSatisfy { self.notice($0) == (false, "complication") }) + } + + @Test("Switching commands off tells Trio-WatchControls that commands are off") func testCommandsOffNotice() async { + settings.settings.garminSettings.watchface = .trio + #expect(await eventually { self.sent("ownerMode").count == 2 }) + + settings.settings.garminSettings.isCommandControlEnabled = false + + #expect(await eventually { self.sent("ownerMode").count == 4 }) + #expect(sent("ownerMode").suffix(2).allSatisfy { self.notice($0) == (false, "none") }) + } + + @Test( + "An inactive Trio-WatchControls is told again when it sends anything, and its commands are answered" + ) func testInactiveAppContact() async { + manager.receivedMessage(["v": 1, "req": "presets"], from: app(watchControlsUUID, on: deviceA)) + + #expect(await eventually { self.sent("ownerMode").count == 1 }) + let first = sent("ownerMode")[0] + #expect(first.appUUID == watchControlsUUID && first.deviceUUID == deviceA.uuid) + #expect(notice(first) == (false, "complication")) + try? await Task.sleep(nanoseconds: 100_000_000) + #expect(sent("presets").isEmpty, "An inactive app gets no preset list") + + manager.receivedMessage( + command("carbs", payload: ["carbs": 20], requestID: UUID().uuidString), + from: app(watchControlsUUID, on: deviceA) + ) + #expect(await eventually { self.sent("ack").count == 1 }) + #expect(sent("ack")[0].appUUID == watchControlsUUID) + #expect(processor.processed.map(\.appUUID) == [watchControlsUUID], "The processor refuses it, not silence") + } + + @Test("A failed activation is owed, a failed stand-down is not") func testNoticeDelivery() async { + client.setResult(.failure_Timeout, for: watchControlsUUID, on: deviceA.uuid) + settings.settings.garminSettings.watchface = .trio + #expect(await eventually { self.sent("ownerMode").filter { $0.deviceUUID == self.deviceA.uuid }.count == 2 }) + client.setResult(nil, for: watchControlsUUID, on: deviceA.uuid) + + manager.receivedMessage(["v": 1, "req": "presets"], from: app(watchControlsUUID, on: deviceA)) + #expect(await eventually { self.sent("ownerMode").filter { $0.deviceUUID == self.deviceA.uuid }.count == 3 }) + #expect(notice(sent("ownerMode").last { $0.deviceUUID == self.deviceA.uuid }!) == (true, "watchControls")) + + client.setResult(.failure_Timeout, for: watchControlsUUID, on: deviceB.uuid) + settings.settings.garminSettings.watchface = .complication + #expect(await eventually { self.sent("ownerMode").filter { $0.deviceUUID == self.deviceB.uuid }.count == 3 }) + client.setResult(nil, for: watchControlsUUID, on: deviceB.uuid) + manager.sendWatchStateData(try! JSONSerialization.data(withJSONObject: [["sgv": 120]])) + #expect(await eventually { self.client.sent.contains { $0.isState } }) + try? await Task.sleep(nanoseconds: 150_000_000) + #expect(sent("ownerMode").filter { $0.deviceUUID == self.deviceB.uuid }.count == 3) + } } // MARK: - Revocation And Final Boundaries @@ -393,8 +482,9 @@ private func makeResolver( settings.settings.isGarminBolusCommandEnabled = true } - private func request(_ command: WatchCommand) -> WatchCommandRequest { - WatchCommandRequest(requestID: UUID(), appUUID: UUID(), date: Date(), command: command) + private func request(_ command: WatchCommand, from settings: SettingsManager) -> WatchCommandRequest { + let appUUID = GarminCommandApp(settings.settings.garminSettings).appUUID ?? UUID() + return WatchCommandRequest(requestID: UUID(), appUUID: appUUID, date: Date(), command: command) } // MARK: R1 @@ -533,7 +623,7 @@ private func makeResolver( let blocker = ContextQueueBlocker() blocker.block(context) - let pending = Task { await processor.process(request(command)) } + let pending = Task { await processor.process(request(command, from: settings)) } // past every processor check and inside the adjustment manager's transaction await transactions.waitForArrivals() @@ -558,7 +648,7 @@ private func makeResolver( #expect(enabled == ["Hike", "Running"]) // the same setup commits once nothing is revoked - #expect(await processor.process(request(command)).acknowledged) + #expect(await processor.process(request(command, from: settings)).acknowledged) #expect(context.saveCount == 1) } @@ -584,7 +674,7 @@ private func makeResolver( let blocker = ContextQueueBlocker() blocker.block(context) let command: WatchCommand = isMeal ? .mealBolus(carbs: 40, bolus: 2) : .carbs(40) - let pending = Task { await processor.process(request(command)) } + let pending = Task { await processor.process(request(command, from: settings)) } await transactions.waitForArrivals() settings.settings.isGarminCommandControlEnabled = false @@ -597,7 +687,7 @@ private func makeResolver( #expect(try await carbRows() == 0) #expect(pump.bolusRequests.isEmpty) - #expect(await processor.process(request(.carbs(40))).acknowledged) + #expect(await processor.process(request(.carbs(40), from: settings)).acknowledged) #expect(try await carbRows() == 1) } diff --git a/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift b/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift index 5df20948ce..5be936fcad 100644 --- a/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift +++ b/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift @@ -8,7 +8,8 @@ import Testing @Suite("Watch Command Processor Tests") struct WatchCommandProcessorTests { let now = Date(timeIntervalSince1970: 1_800_000_000) let clock = TestClock(Date(timeIntervalSince1970: 1_800_000_000)) - let appUUID = UUID() + // the default watchface leaves commands to Trio-WatchControls + let appUUID = GarminWatchControls.appUUID! let settings = StubSettingsManager() let validator = StubBolusSafetyValidator() @@ -299,11 +300,57 @@ import Testing @Test("Request IDs are scoped per app") func testScopedPerApp() async { let id = UUID() _ = await processor.process(request(.carbs(10), id: id)) - _ = await processor.process(request(.carbs(10), id: id, app: UUID())) + settings.settings.garminSettings.watchface = .complication + _ = await processor.process(request(.carbs(10), id: id, app: GarminWatchface.complication.watchfaceUUID)) #expect(actions.storedCarbs.count == 2) } + // MARK: - Command App + + @Test("Only the command app picked by the watchface may send commands") func testCommandAppOnly() async { + let fromComplication = await processor.process(request(.carbs(10), app: GarminWatchface.complication.watchfaceUUID)) + let fromWatchface = await processor.process(request(.carbs(10), app: GarminWatchface.trio.watchfaceUUID)) + let fromWatchControls = await processor.process(request(.carbs(10))) + + #expect(fromComplication.acknowledged == false) + #expect(fromComplication.message == "Commands are handled by another watch app.") + #expect(fromWatchface.acknowledged == false) + #expect(fromWatchControls.acknowledged) + #expect(actions.storedCarbs.count == 1) + #expect(logs.lines.contains("Garmin carbs command refused, sent by an app that is not the command app")) + } + + @Test("With commands off the refusal names the switch, not another app") func testCommandsOffMessage() async { + settings.settings.isGarminCommandControlEnabled = false + + let result = await processor.process(request(.carbs(10))) + + #expect(result.message == "Watch commands are disabled.") + } + + @Test( + "Changing the command app revokes pending commands, other watchface changes do not", + arguments: [GarminWatchface.complication, .swissalpine] + ) func testCommandAppChangeRevokes(watchface: GarminWatchface) { + let start = settings.authorization.current + settings.settings.garminSettings.watchface = watchface + + #expect(settings.authorization.current - start == (watchface == .complication ? 1 : 0)) + } + + @Test("Command app follows the watchface and the command switch") func testCommandAppMapping() { + var garmin = GarminWatchSettings() + garmin.isCommandControlEnabled = true + #expect(GarminCommandApp(garmin) == .watchControls) + garmin.isCommandControlEnabled = false + #expect(GarminCommandApp(garmin) == .none) + garmin.watchface = .complication + #expect(GarminCommandApp(garmin) == .complication) + #expect(GarminCommandApp.complication.appUUID == GarminWatchface.complication.watchfaceUUID) + #expect(GarminCommandApp.none.appUUID == nil) + } + // MARK: - Adjustments @Test("Activating an override uses the exact preset and the watch source") func testActivateOverride() async { From 1432b1de87146a3ce74bd6b1e2790ba4c80476b4 Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 27 Oct 2026 19:05:30 +0100 Subject: [PATCH 06/34] Remove superseded Garmin watch-command implementation Drop the legacy GarminCommandRouter and WatchCommand processor/cache stack together with its tests and Xcode project references. The watch-service pipeline and Garmin command stack merged next replace it. GarminDevice and GarminWatchState stay: upstream and the new stack's GarminManager and WatchConfig views still use them. --- Trio.xcodeproj/project.pbxproj | 56 -- .../Services/WatchCommand/WatchCommand.swift | 142 ---- .../WatchCommand/WatchCommandActions.swift | 93 --- .../WatchCommand/WatchCommandProcessor.swift | 536 ------------ .../WatchCommandRequestCache.swift | 114 --- .../WatchManager/GarminCommandRouter.swift | 248 ------ .../GarminCommandRouterTests.swift | 348 -------- .../WatchCommandIntegrationTests.swift | 695 ---------------- .../WatchCommandProcessorTests.swift | 776 ------------------ .../WatchCommandRequestCacheTests.swift | 148 ---- .../WatchCommandTestDoubles.swift | 384 --------- 11 files changed, 3540 deletions(-) delete mode 100644 Trio/Sources/Services/WatchCommand/WatchCommand.swift delete mode 100644 Trio/Sources/Services/WatchCommand/WatchCommandActions.swift delete mode 100644 Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift delete mode 100644 Trio/Sources/Services/WatchCommand/WatchCommandRequestCache.swift delete mode 100644 Trio/Sources/Services/WatchManager/GarminCommandRouter.swift delete mode 100644 TrioTests/WatchCommandTests/GarminCommandRouterTests.swift delete mode 100644 TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift delete mode 100644 TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift delete mode 100644 TrioTests/WatchCommandTests/WatchCommandRequestCacheTests.swift delete mode 100644 TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index ba4b44bd7d..b403a56d3f 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -857,16 +857,6 @@ CE7CA3582A064E2F004BE681 /* ListStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CA3572A064E2F004BE681 /* ListStateView.swift */; }; CE82E02528E867BA00473A9C /* AlertStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82E02428E867BA00473A9C /* AlertStorage.swift */; }; CE94597E29E9E1EE0047C9C6 /* GarminManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE94597D29E9E1EE0047C9C6 /* GarminManager.swift */; }; - 9A3C0DE10000000000000B01 /* WatchCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A01 /* WatchCommand.swift */; }; - 9A3C0DE10000000000000B02 /* WatchCommandRequestCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A02 /* WatchCommandRequestCache.swift */; }; - 9A3C0DE10000000000000B03 /* WatchCommandActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A03 /* WatchCommandActions.swift */; }; - 9A3C0DE10000000000000B04 /* WatchCommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A04 /* WatchCommandProcessor.swift */; }; - 9A3C0DE10000000000000B05 /* GarminCommandRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A05 /* GarminCommandRouter.swift */; }; - 9A3C0DE10000000000000B06 /* WatchCommandTestDoubles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A06 /* WatchCommandTestDoubles.swift */; }; - 9A3C0DE10000000000000B07 /* WatchCommandRequestCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A07 /* WatchCommandRequestCacheTests.swift */; }; - 9A3C0DE10000000000000B08 /* WatchCommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A08 /* WatchCommandProcessorTests.swift */; }; - 9A3C0DE10000000000000B09 /* GarminCommandRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A09 /* GarminCommandRouterTests.swift */; }; - 9A3C0DE10000000000000B0A /* WatchCommandIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0DE10000000000000A0A /* WatchCommandIntegrationTests.swift */; }; CE94598029E9E3BD0047C9C6 /* WatchConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE94597F29E9E3BD0047C9C6 /* WatchConfigDataFlow.swift */; }; CE94598229E9E3D30047C9C6 /* WatchConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE94598129E9E3D30047C9C6 /* WatchConfigProvider.swift */; }; CE94598429E9E3E60047C9C6 /* WatchConfigStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE94598329E9E3E60047C9C6 /* WatchConfigStateModel.swift */; }; @@ -2174,16 +2164,6 @@ CE7CA3572A064E2F004BE681 /* ListStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListStateView.swift; sourceTree = ""; }; CE82E02428E867BA00473A9C /* AlertStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStorage.swift; sourceTree = ""; }; CE94597D29E9E1EE0047C9C6 /* GarminManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminManager.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A01 /* WatchCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommand.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A02 /* WatchCommandRequestCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandRequestCache.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A03 /* WatchCommandActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandActions.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A04 /* WatchCommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandProcessor.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A05 /* GarminCommandRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminCommandRouter.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A06 /* WatchCommandTestDoubles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandTestDoubles.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A07 /* WatchCommandRequestCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandRequestCacheTests.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A08 /* WatchCommandProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandProcessorTests.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A09 /* GarminCommandRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminCommandRouterTests.swift; sourceTree = ""; }; - 9A3C0DE10000000000000A0A /* WatchCommandIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandIntegrationTests.swift; sourceTree = ""; }; CE94597F29E9E3BD0047C9C6 /* WatchConfigDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfigDataFlow.swift; sourceTree = ""; }; CE94598129E9E3D30047C9C6 /* WatchConfigProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfigProvider.swift; sourceTree = ""; }; CE94598329E9E3E60047C9C6 /* WatchConfigStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConfigStateModel.swift; sourceTree = ""; }; @@ -3028,7 +3008,6 @@ DD7E1E30000000000000000A /* Telemetry */, 3811DEA525C9D88300A708ED /* UnlockManager */, 38E87406274F9AA500975559 /* UserNotifications */, - 9A3C0DE10000000000000C01 /* WatchCommand */, 38E8754D275556E100975559 /* WatchManager */, FCF410FF2012BCB1A08D4F48 /* ProfileScheduleFirer.swift */, ); @@ -3663,7 +3642,6 @@ isa = PBXGroup; children = ( BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */, - 9A3C0DE10000000000000A05 /* GarminCommandRouter.swift */, CE94597D29E9E1EE0047C9C6 /* GarminManager.swift */, ); path = WatchManager; @@ -3711,7 +3689,6 @@ B015AFF62E600000000D7351 /* AdjustmentsTests */, B015AFE62E500000000D7351 /* BolusSafetyTests */, BD8FC0552D66187700B95AED /* CoreDataTests */, - 9A3C0DE10000000000000C02 /* WatchCommandTests */, 38FCF3F125E9028E0078B0D1 /* Info.plist */, CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */, CA02000000000000000010C1 /* DeliveryLimitsSyncTests.swift */, @@ -4300,29 +4277,6 @@ path = AdjustmentsTests; sourceTree = ""; }; - 9A3C0DE10000000000000C01 /* WatchCommand */ = { - isa = PBXGroup; - children = ( - 9A3C0DE10000000000000A01 /* WatchCommand.swift */, - 9A3C0DE10000000000000A02 /* WatchCommandRequestCache.swift */, - 9A3C0DE10000000000000A03 /* WatchCommandActions.swift */, - 9A3C0DE10000000000000A04 /* WatchCommandProcessor.swift */, - ); - path = WatchCommand; - sourceTree = ""; - }; - 9A3C0DE10000000000000C02 /* WatchCommandTests */ = { - isa = PBXGroup; - children = ( - 9A3C0DE10000000000000A06 /* WatchCommandTestDoubles.swift */, - 9A3C0DE10000000000000A07 /* WatchCommandRequestCacheTests.swift */, - 9A3C0DE10000000000000A08 /* WatchCommandProcessorTests.swift */, - 9A3C0DE10000000000000A09 /* GarminCommandRouterTests.swift */, - 9A3C0DE10000000000000A0A /* WatchCommandIntegrationTests.swift */, - ); - path = WatchCommandTests; - sourceTree = ""; - }; B015AFE22E500000000D7351 /* BolusSafety */ = { isa = PBXGroup; children = ( @@ -6239,11 +6193,6 @@ E00EEC0527368630002FF094 /* StorageAssembly.swift in Sources */, DD1745552C55CA6C00211FAC /* UnitsLimitsSettingsRootView.swift in Sources */, CE94597E29E9E1EE0047C9C6 /* GarminManager.swift in Sources */, - 9A3C0DE10000000000000B01 /* WatchCommand.swift in Sources */, - 9A3C0DE10000000000000B02 /* WatchCommandRequestCache.swift in Sources */, - 9A3C0DE10000000000000B03 /* WatchCommandActions.swift in Sources */, - 9A3C0DE10000000000000B04 /* WatchCommandProcessor.swift in Sources */, - 9A3C0DE10000000000000B05 /* GarminCommandRouter.swift in Sources */, 3883583425EEB38000E024B2 /* PumpSettings.swift in Sources */, 38DAB280260CBB7F00F74C1A /* PumpView.swift in Sources */, DDD1631C2C4C697400CD525A /* AddOverrideForm.swift in Sources */, @@ -7109,11 +7058,6 @@ 41740E936552456AAC0EDCC3 /* MaxIOBSuggestionTests.swift in Sources */, 41740E936552456AAC0EDBC3 /* DosingModeMigrationTests.swift in Sources */, B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */, - 9A3C0DE10000000000000B06 /* WatchCommandTestDoubles.swift in Sources */, - 9A3C0DE10000000000000B07 /* WatchCommandRequestCacheTests.swift in Sources */, - 9A3C0DE10000000000000B08 /* WatchCommandProcessorTests.swift in Sources */, - 9A3C0DE10000000000000B09 /* GarminCommandRouterTests.swift in Sources */, - 9A3C0DE10000000000000B0A /* WatchCommandIntegrationTests.swift in Sources */, BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */, CD1235B000000000000000F2 /* TidepoolUploadSerializerTests.swift in Sources */, CE1425B000000000000000A2 /* CarbsEntryLoopKitTests.swift in Sources */, diff --git a/Trio/Sources/Services/WatchCommand/WatchCommand.swift b/Trio/Sources/Services/WatchCommand/WatchCommand.swift deleted file mode 100644 index ce1a9273ac..0000000000 --- a/Trio/Sources/Services/WatchCommand/WatchCommand.swift +++ /dev/null @@ -1,142 +0,0 @@ -import Foundation - -/// A state-changing command sent from a watch, already parsed and type-checked by its transport. -enum WatchCommand: Equatable { - case bolus(Decimal) - case carbs(Int) - /// Carbs are stored before the bolus runs, so a rejected bolus still leaves the meal logged. - case mealBolus(carbs: Int, bolus: Decimal) - case activateOverride(name: String) - case cancelOverride - case activateTempTarget(name: String) - case cancelTempTarget - - /// Wire name of the command; also the only command detail that is safe to log. - var name: String { - switch self { - case .bolus: return "bolus" - case .carbs: return "carbs" - case .mealBolus: return "mealBolus" - case .activateOverride: return "activateOverride" - case .cancelOverride: return "cancelOverride" - case .activateTempTarget: return "activateTempTarget" - case .cancelTempTarget: return "cancelTempTarget" - } - } - - var bolusAmount: Decimal? { - switch self { - case let .bolus(units), - let .mealBolus(_, units): - return units - default: - return nil - } - } - - var carbsAmount: Int? { - switch self { - case let .carbs(grams), - let .mealBolus(grams, _): - return grams - default: - return nil - } - } -} - -struct WatchCommandRequest: Equatable { - let requestID: UUID - /// Watch app that sent the command; request IDs are only unique per app. - let appUUID: UUID - /// When the watch issued the command. - let date: Date - let command: WatchCommand -} - -extension WatchCommand { - var isInsulin: Bool { bolusAmount != nil } - - /// Commands whose success changes which preset is active on the watch's preset list. - var changesPresetState: Bool { - switch self { - case .activateOverride, - .activateTempTarget, - .cancelOverride, - .cancelTempTarget: - return true - default: - return false - } - } -} - -/// Terminal answer to a watch command, sent back to the requesting app only. -struct WatchCommandResult: Equatable { - let acknowledged: Bool - let ackCode: BaseWatchManager.AcknowledgmentCode - /// Short, user-facing text. Never carries amounts or internal error details. - let message: String - - /// Something may have changed on the phone, so the watch needs fresh state. - var refreshesState: Bool { - acknowledged || ackCode == .partialFailure - } - - static func success(_ ackCode: BaseWatchManager.AcknowledgmentCode, _ message: String) -> WatchCommandResult { - WatchCommandResult(acknowledged: true, ackCode: ackCode, message: message) - } - - static func failure(_ message: String) -> WatchCommandResult { - WatchCommandResult(acknowledged: false, ackCode: .genericFailure, message: message) - } -} - -struct WatchCommandPresets: Equatable { - let overridePresets: [OverridePresetWatch] - let tempTargetPresets: [TempTargetPresetWatch] - var capabilities = WatchCommandCapabilities() -} - -/// Switches and limits the watch uses to offer only what the phone would accept; the phone still -/// enforces every one of them. The defaults describe a phone that accepts nothing. -struct WatchCommandCapabilities: Equatable { - var isCommandControlEnabled = false - var isBolusCommandEnabled = false - var maxBolus: Decimal = 0 - var maxCarbs: Decimal = 0 - var bolusIncrement: Decimal = 0 -} - -/// An adjustment preset as the watch names it, plus the reference that activates exactly that row. -/// Every configured preset is listed and can be activated, whether it is running or not. -struct WatchPresetEntry: Equatable { - let name: String - /// Whether the preset is running right now (`enabled` on the stored row); it is sent to the - /// watch as the v1 `isEnabled` key and never restricts activation. - let isActive: Bool - let ref: AdjustmentRef -} - -/// Stable, content-free labels for command errors: Core Data and adjustment errors can carry -/// treatment values, preset names or store paths in their descriptions. -enum WatchCommandErrorCategory { - static func name(for error: Error) -> String { - switch error { - case let adjustmentError as AdjustmentError: - switch adjustmentError { - case .presetNotFound: return "presetNotFound" - case .nothingActive: return "nothingActive" - case .persistenceFailed: return "adjustmentPersistence" - } - case is CoreDataError: - return "coreData" - case is CancellationError: - return "cancelled" - default: - let nsError = error as NSError - // a Cocoa code identifies Core Data failures without their user info - return nsError.domain == NSCocoaErrorDomain ? "cocoa(\(nsError.code))" : "unexpected" - } - } -} diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandActions.swift b/Trio/Sources/Services/WatchCommand/WatchCommandActions.swift deleted file mode 100644 index f0cb71a923..0000000000 --- a/Trio/Sources/Services/WatchCommand/WatchCommandActions.swift +++ /dev/null @@ -1,93 +0,0 @@ -import CoreData -import Foundation -import Swinject - -/// Pump, Core Data and preset side effects of watch commands, kept behind a protocol so the -/// processor's validation and sequencing can be tested without a pump. -protocol WatchCommandActions { - /// Throws unless the carb row was actually saved. `authorize` runs inside the save transaction - /// before the row is inserted; whatever it throws is rethrown with nothing written. - func storeCarbs(_ grams: Int, date: Date, authorize: @escaping () throws -> Void) async throws - /// Returns once the pump accepted or refused the bolus, without waiting for the follow-up loop run. - /// Throws only what `authorize` threw, run right before issuance; nothing reached the pump then. - func enactBolus(_ units: Decimal, authorize: @escaping () throws -> Void) async throws -> Bool - func overridePresets() async throws -> [WatchPresetEntry] - func tempTargetPresets() async throws -> [WatchPresetEntry] -} - -final class BaseWatchCommandActions: WatchCommandActions, Injectable { - @Injected() private var apsManager: APSManager! - @Injected() private var carbsStorage: CarbsStorage! - @Injected() private var overrideStorage: OverrideStorage! - @Injected() private var tempTargetStorage: TempTargetsStorage! - - init(resolver: Resolver) { - injectServices(resolver) - } - - func storeCarbs(_ grams: Int, date: Date, authorize: @escaping () throws -> Void) async throws { - let entry = CarbsEntry( - id: UUID().uuidString, - createdAt: date, - actualDate: date, - carbs: Decimal(grams), - fat: 0, - protein: 0, - note: String(localized: "Via Watch", comment: "Note added to carb entry when entered via watch"), - enteredBy: CarbsEntry.local, - isFPU: false, - fpuID: nil - ) - try await carbsStorage.storeVerifiedCarbs(entry, authorize: authorize) - } - - /// `authorize` runs in the task that issues the request, immediately before `APSManager.enactBolus` - /// and with nothing awaited in between, so a revocation or expiry cannot land between the final - /// check and issuance. The task lets the ack return before the determination `enactBolus` runs - /// after a manual bolus. - func enactBolus(_ units: Decimal, authorize: @escaping () throws -> Void) async throws -> Bool { - // enactBolus returns without calling back for non-positive amounts, which would never resume - guard units > 0, let apsManager = self.apsManager else { return false } - return try await withCheckedThrowingContinuation { continuation in - Task { - do { - try authorize() - } catch { - continuation.resume(throwing: error) - return - } - await apsManager.enactBolus(amount: Double(truncating: units as NSNumber), isSMB: false) { success, _ in - continuation.resume(returning: success) - } - } - } - } - - func overridePresets() async throws -> [WatchPresetEntry] { - let ids = try await overrideStorage.fetchForOverridePresets() - let context = CoreDataStack.shared.newTaskContext() - context.name = "watchCommandOverridePresets" - let rows: [OverrideStored] = try await CoreDataStack.shared.getNSManagedObject(with: ids, context: context) - - return await context.perform { - rows.compactMap { row in - guard let name = row.name, let id = row.id else { return nil } - return WatchPresetEntry(name: name, isActive: row.enabled, ref: .presetID(id)) - } - } - } - - func tempTargetPresets() async throws -> [WatchPresetEntry] { - let ids = try await tempTargetStorage.fetchForTempTargetPresets() - let context = CoreDataStack.shared.newTaskContext() - context.name = "watchCommandTempTargetPresets" - let rows: [TempTargetStored] = try await CoreDataStack.shared.getNSManagedObject(with: ids, context: context) - - return await context.perform { - rows.compactMap { row in - guard let name = row.name, let id = row.id else { return nil } - return WatchPresetEntry(name: name, isActive: row.enabled, ref: .presetID(id.uuidString)) - } - } - } -} diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift b/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift deleted file mode 100644 index fa38e796a8..0000000000 --- a/Trio/Sources/Services/WatchCommand/WatchCommandProcessor.swift +++ /dev/null @@ -1,536 +0,0 @@ -import Foundation -import Swinject - -/// Validates and executes watch commands independent of the transport that delivered them. -protocol WatchCommandProcessor { - /// Runs `request` at most once per app and request ID and returns its terminal result; - /// a duplicate gets the cached result back without executing again. - func process(_ request: WatchCommandRequest) async -> WatchCommandResult - - /// Override and temp target presets, sorted by display name, with the current command switches - /// and limits. - func presets() async throws -> WatchCommandPresets -} - -/// Revocation epoch for watch commands; a command admitted under an older epoch was revoked. -/// -/// `BaseSettingsManager` advances it synchronously from the settings setter, on whatever queue -/// writes the settings, before a Garmin command setting that turns off is stored. The settings -/// notification is asynchronous and carries only the latest value, so an off-then-on sequence -/// that lands before the main queue drains would otherwise revoke nothing. -final class WatchCommandAuthorization { - private let lock = NSLock() - private var epoch: UInt64 = 0 - - var current: UInt64 { lock.withLock { epoch } } - - /// Call before `new` is stored. Turning either setting off always revokes, even when it is - /// switched back on right after; turning one on never does. - func settingsWillChange(from old: TrioSettings, to new: TrioSettings) { - let masterTurnedOff = old.isGarminCommandControlEnabled && !new.isGarminCommandControlEnabled - let bolusTurnedOff = old.isGarminBolusCommandEnabled && !new.isGarminBolusCommandEnabled - guard masterTurnedOff || bolusTurnedOff else { return } - lock.withLock { epoch += 1 } - info(.watch, "Garmin commands switched off, pending commands revoked") - } -} - -/// Thrown by the final check a side effect runs right before it writes or issues anything; -/// nothing was saved or sent to the pump. -struct WatchCommandRejection: Error { - let result: WatchCommandResult -} - -/// Admits one watch insulin command at a time across all request IDs, so each validation sees the -/// previous command's delivery. Waiters suspend on a continuation and never hold the actor. -/// -/// Phase 1 policy, deliberately stricter than the shared 20% recent-bolus threshold: once a watch -/// bolus was handed to `APSManager`, the next watch insulin command is rejected for -/// `BolusSafetyEvaluator.recentBolusWindowMinutes`, even when the pump or status check refused it, -/// since pump history may not show it yet and a refusal can still mean partial delivery. Only a -/// final-check rejection, which never reached `APSManager`, withdraws the record. -actor WatchCommandInsulinLane { - private var isOccupied = false - private var waiters: [CheckedContinuation] = [] - /// Last pump request issued through the lane; pump history may not show it yet. - private(set) var lastEnactment: Date? - - var waitingCount: Int { waiters.count } - - func enter() async { - guard isOccupied else { - isOccupied = true - return - } - await withCheckedContinuation { waiters.append($0) } - } - - /// Hands the lane straight to the next waiter, so no newcomer can slip in between. - func leave() { - if waiters.isEmpty { - isOccupied = false - } else { - waiters.removeFirst().resume() - } - } - - func recordEnactment(at date: Date) { - lastEnactment = date - } - - /// Puts back what `recordEnactment` replaced when the request never reached the pump. - func restoreEnactment(_ date: Date?) { - lastEnactment = date - } -} - -/// Garmin is the only caller in phase 1, so the command gates read the Garmin settings. -final class BaseWatchCommandProcessor: WatchCommandProcessor, Injectable { - /// The watch talks to the phone directly over Bluetooth (ConnectIQ SDK) and a command arrives within - /// seconds, so two minutes only keeps a late or replayed command from executing long after it was confirmed. - static let maximumCommandAge: TimeInterval = 2 * 60 - /// Clock skew tolerance for watches that run slightly ahead of the phone. - static let maximumCommandLead: TimeInterval = 60 - - @Injected() private var settingsManager: SettingsManager! - @Injected() private var bolusSafetyValidator: BolusSafetyValidator! - @Injected() private var adjustmentManager: AdjustmentManager! - @Injected() private var authorization: WatchCommandAuthorization! - - private let actions: WatchCommandActions - private let cache: WatchCommandRequestCache - private let insulinLane: WatchCommandInsulinLane - private let now: () -> Date - private let log: (LogLevel, String) -> Void - - init( - resolver: Resolver, - actions: WatchCommandActions? = nil, - cache: WatchCommandRequestCache = WatchCommandRequestCache(), - insulinLane: WatchCommandInsulinLane = WatchCommandInsulinLane(), - now: @escaping () -> Date = Date.init, - log: @escaping (LogLevel, String) -> Void = { TrioLogger.log($0, .watch, $1, file: #file, function: #function, line: #line) } - ) { - self.actions = actions ?? BaseWatchCommandActions(resolver: resolver) - self.cache = cache - self.insulinLane = insulinLane - self.now = now - self.log = log - injectServices(resolver) - } - - func process(_ request: WatchCommandRequest) async -> WatchCommandResult { - let key = WatchCommandRequestCache.Key(appUUID: request.appUUID, requestID: request.requestID) - let name = request.command.name - let grant = authorization.current - - let ticket: WatchCommandRequestCache.Ticket - switch await cache.admit(key, command: request.command, now: now()) { - case let .accepted(admitted): - ticket = admitted - case let .completed(result): - log(.debug, "Garmin duplicate \(name) request, returning cached result") - return result - case .inProgress: - log(.info, "Garmin duplicate \(name) request still in progress, rejected") - return WatchCommandResult( - acknowledged: false, - ackCode: .inProgress, - message: String(localized: "Request is already in progress.", comment: "Watch command ack") - ) - case .conflict: - log(.warning, "Garmin request ID reused for a different command (\(name)), rejected") - return .failure(String(localized: "Request ID was already used.", comment: "Watch command ack")) - case .full: - log(.warning, "Garmin sent too many \(name) requests in progress, rejected") - return .failure(String(localized: "Too many requests in progress. Please try again.", comment: "Watch command ack")) - } - - let result = await execute(request, grant: grant) - await cache.complete(ticket, result: result) - - if result.acknowledged { - log(.info, "Garmin \(name) acknowledged with \(result.ackCode.rawValue)") - } else { - log(.warning, "Garmin \(name) failed with \(result.ackCode.rawValue)") - } - return result - } - - func presets() async throws -> WatchCommandPresets { - async let overrides = actions.overridePresets() - async let tempTargets = actions.tempTargetPresets() - - return try await WatchCommandPresets( - overridePresets: sortedByName(overrides).map { OverridePresetWatch(name: $0.name, isEnabled: $0.isActive) }, - tempTargetPresets: sortedByName(tempTargets).map { TempTargetPresetWatch(name: $0.name, isEnabled: $0.isActive) }, - capabilities: capabilities() - ) - } - - /// Same sources as the Apple Watch state, read at call time so a switch turned off is reported - /// off in the very next reply. - private func capabilities() -> WatchCommandCapabilities { - let settings = settingsManager.settings - return WatchCommandCapabilities( - isCommandControlEnabled: settings.isGarminCommandControlEnabled, - isBolusCommandEnabled: settings.isGarminBolusCommandEnabled, - maxBolus: settingsManager.pumpSettings.maxBolus, - maxCarbs: settings.maxCarbs, - bolusIncrement: settingsManager.preferences.bolusIncrement - ) - } - - // MARK: - Validation - - private func execute(_ request: WatchCommandRequest, grant: UInt64) async -> WatchCommandResult { - if let rejection = rejection(for: request, grant: grant) { - return rejection - } - - switch request.command { - case let .bolus(units): - return await bolus(units, request: request, grant: grant) - case let .carbs(grams): - return await carbs(grams, request: request, grant: grant) - case let .mealBolus(grams, units): - return await mealBolus(carbs: grams, bolus: units, request: request, grant: grant) - case let .activateOverride(name): - return await activateOverride(named: name, request: request, grant: grant) - case .cancelOverride: - return await cancelOverride(request: request, grant: grant) - case let .activateTempTarget(name): - return await activateTempTarget(named: name, request: request, grant: grant) - case .cancelTempTarget: - return await cancelTempTarget(request: request, grant: grant) - } - } - - /// Checks that need no side effects: freshness, feature gates and amount limits. - private func rejection(for request: WatchCommandRequest, grant: UInt64) -> WatchCommandResult? { - if let rejection = expiryRejection(for: request) ?? authorizationRejection(for: request, grant: grant) { - return rejection - } - - if let units = request.command.bolusAmount, units <= 0 { - return .failure(String(localized: "Invalid bolus amount.", comment: "Watch command ack")) - } - - if let grams = request.command.carbsAmount { - guard grams > 0 else { - return .failure(String(localized: "Invalid carbs amount.", comment: "Watch command ack")) - } - guard Decimal(grams) <= settingsManager.settings.maxCarbs else { - log(.info, "Garmin \(request.command.name) rejected, carbs above max carbs") - return .failure(String(localized: "Carbs exceed the maximum allowed.", comment: "Watch command ack")) - } - } - - return nil - } - - /// Re-checked for insulin after every wait: queueing in the lane or waiting for validation can - /// outlast the window the request was admitted in. - private func expiryRejection(for request: WatchCommandRequest) -> WatchCommandResult? { - let age = now().timeIntervalSince(request.date) - guard age <= Self.maximumCommandAge, age >= -Self.maximumCommandLead else { - log(.warning, "Garmin \(request.command.name) timestamp outside the freshness window, rejected") - return .failure(String(localized: "Command expired. Please try again.", comment: "Watch command ack")) - } - return nil - } - - /// Re-read at every side-effect boundary: settings can change while a command is suspended. - /// - /// Settings are read before the epoch. The epoch advances before a switch-off is stored, so - /// a check that still sees the setting on after an off-then-on sequence also sees the new epoch. - private func authorizationRejection(for request: WatchCommandRequest, grant: UInt64) -> WatchCommandResult? { - let name = request.command.name - let garminSettings = settingsManager.settings.garminSettings - guard authorization.current == grant else { - log(.info, "Garmin \(name) rejected, command settings changed while pending") - return .failure(String(localized: "Watch command settings changed.", comment: "Watch command ack")) - } - - guard garminSettings.isCommandControlEnabled else { - log(.info, "Garmin \(name) rejected, watch commands disabled") - return .failure(String(localized: "Watch commands are disabled.", comment: "Watch command ack")) - } - guard !request.command.isInsulin || garminSettings.isBolusCommandEnabled else { - log(.info, "Garmin \(name) rejected, bolus commands disabled") - return .failure(String(localized: "Bolus from watch is disabled.", comment: "Watch command ack")) - } - return nil - } - - /// The linearization point of a command: side effects run this synchronously in their last - /// serialized hop, inside the Core Data transaction or in the task that calls - /// `APSManager.enactBolus`, with nothing awaited between the check and the write or issuance. - /// Insulin re-checks the freshness window as well. - private func finalCheck(for request: WatchCommandRequest, grant: UInt64) -> @Sendable() throws -> Void { - { [self] in - if request.command.isInsulin, let rejection = expiryRejection(for: request) { - throw WatchCommandRejection(result: rejection) - } - if let rejection = authorizationRejection(for: request, grant: grant) { - throw WatchCommandRejection(result: rejection) - } - } - } - - // MARK: - Treatments - - private enum BolusOutcome { - case delivered - /// Nothing was sent to the pump. - case rejected(String) - /// The pump refused or errored; part of the bolus may still have been delivered. - case failed - } - - private func bolus(_ units: Decimal, request: WatchCommandRequest, grant: UInt64) async -> WatchCommandResult { - switch await deliverBolus(units, request: request, grant: grant) { - case .delivered: - return .success(.genericSuccess, String(localized: "Bolus started.", comment: "Watch command ack")) - case let .rejected(message): - return .failure(message) - case .failed: - return .failure(String(localized: "Bolus failed. Check pump history before repeating.", comment: "Watch command ack")) - } - } - - private func carbs(_ grams: Int, request: WatchCommandRequest, grant: UInt64) async -> WatchCommandResult { - if let rejection = authorizationRejection(for: request, grant: grant) { - return rejection - } - do { - try await actions.storeCarbs(grams, date: request.date, authorize: finalCheck(for: request, grant: grant)) - return .success(.carbsLogged, String(localized: "Carbs logged.", comment: "Watch command ack")) - } catch let rejection as WatchCommandRejection { - return rejection.result - } catch { - log(.warning, "Garmin carbs could not be stored (\(WatchCommandErrorCategory.name(for: error)))") - return .failure(String(localized: "Could not log carbs.", comment: "Watch command ack")) - } - } - - private func mealBolus( - carbs grams: Int, - bolus units: Decimal, - request: WatchCommandRequest, - grant: UInt64 - ) async -> WatchCommandResult { - if let rejection = authorizationRejection(for: request, grant: grant) { - return rejection - } - do { - try await actions.storeCarbs(grams, date: request.date, authorize: finalCheck(for: request, grant: grant)) - } catch let rejection as WatchCommandRejection { - return rejection.result - } catch { - log(.warning, "Garmin meal carbs could not be stored (\(WatchCommandErrorCategory.name(for: error)))") - return .failure(String(localized: "Could not log carbs. No bolus was delivered.", comment: "Watch command ack")) - } - - switch await deliverBolus(units, request: request, grant: grant) { - case .delivered: - return .success(.genericSuccess, String(localized: "Carbs logged and bolus started.", comment: "Watch command ack")) - case .rejected: - return WatchCommandResult( - acknowledged: false, - ackCode: .partialFailure, - message: String(localized: "Carbs logged. No bolus was delivered.", comment: "Watch command ack") - ) - case .failed: - return WatchCommandResult( - acknowledged: false, - ackCode: .partialFailure, - message: String( - localized: "Carbs logged. Bolus failed, check pump history before repeating.", - comment: "Watch command ack" - ) - ) - } - } - - private func deliverBolus(_ units: Decimal, request: WatchCommandRequest, grant: UInt64) async -> BolusOutcome { - await insulinLane.enter() - let outcome = await deliverBolusInLane(units, request: request, grant: grant) - await insulinLane.leave() - return outcome - } - - /// Only runs while holding the insulin lane; everything is re-checked because the wait for the - /// lane can take as long as another command's validation and pump request. - private func deliverBolusInLane(_ units: Decimal, request: WatchCommandRequest, grant: UInt64) async -> BolusOutcome { - if let rejection = expiryRejection(for: request) ?? authorizationRejection(for: request, grant: grant) { - return .rejected(rejection.message) - } - - let recentWindow = Double(BolusSafetyEvaluator.recentBolusWindowMinutes * 60) - let previousEnactment = await insulinLane.lastEnactment - if let previousEnactment, now().timeIntervalSince(previousEnactment) < recentWindow { - log(.info, "Garmin bolus rejected, previous watch bolus may not be in pump history yet") - return .rejected(BolusSafetyRejection.recentBolusWithinWindow(totalRecent: 0).watchMessage) - } - - // cover every bolus since the watch sent the command, and never less than the standard window - let lookbackStart = min(request.date, now().addingTimeInterval(-recentWindow)) - - do { - switch try await bolusSafetyValidator.validate(bolusAmount: units, lookbackStart: lookbackStart) { - case .allowed: - break - case let .rejected(reason): - log(.info, "Garmin bolus rejected by safety check (\(reason.logName))") - return .rejected(reason.watchMessage) - } - } catch { - log(.warning, "Garmin bolus safety check failed (\(WatchCommandErrorCategory.name(for: error)))") - return .rejected(String(localized: "Could not verify bolus safety.", comment: "Watch command ack")) - } - - // validation awaited, so the request may have expired or been revoked meanwhile - if let rejection = expiryRejection(for: request) ?? authorizationRejection(for: request, grant: grant) { - return .rejected(rejection.message) - } - // recorded before the request, since a pump error can still mean partial delivery - await insulinLane.recordEnactment(at: now()) - do { - return try await actions.enactBolus(units, authorize: finalCheck(for: request, grant: grant)) ? .delivered : .failed - } catch { - // the final check stopped it before `APSManager`, so nothing reached the pump - await insulinLane.restoreEnactment(previousEnactment) - let message = (error as? WatchCommandRejection)?.result.message - return .rejected(message ?? String(localized: "Could not verify bolus safety.", comment: "Watch command ack")) - } - } - - // MARK: - Adjustments - - private func activateOverride(named name: String, request: WatchCommandRequest, grant: UInt64) async -> WatchCommandResult { - do { - let presets = try await actions.overridePresets() - guard let preset = uniquePreset(named: name, in: presets) else { - log(.warning, "Garmin override preset missing or ambiguous") - return .failure(String(localized: "Override preset not found.", comment: "Watch command ack")) - } - if let rejection = authorizationRejection(for: request, grant: grant) { - return rejection - } - try await adjustmentManager.activateOverride( - preset.ref, - source: .watch, - authorize: finalCheck(for: request, grant: grant) - ) - return .success(.overrideStarted, String(localized: "Override started.", comment: "Watch command ack")) - } catch let rejection as WatchCommandRejection { - return rejection.result - } catch { - log(.warning, "Garmin override activation failed (\(WatchCommandErrorCategory.name(for: error)))") - return .failure(String(localized: "Could not start override.", comment: "Watch command ack")) - } - } - - private func cancelOverride(request: WatchCommandRequest, grant: UInt64) async -> WatchCommandResult { - if let rejection = authorizationRejection(for: request, grant: grant) { - return rejection - } - do { - try await adjustmentManager.cancelOverride(source: .watch, authorize: finalCheck(for: request, grant: grant)) - } catch let rejection as WatchCommandRejection { - return rejection.result - } catch AdjustmentError.nothingActive { - // cancelling an inactive override already has the requested outcome - } catch { - log(.warning, "Garmin override cancellation failed (\(WatchCommandErrorCategory.name(for: error)))") - return .failure(String(localized: "Could not stop override.", comment: "Watch command ack")) - } - return .success(.overrideStopped, String(localized: "Override stopped.", comment: "Watch command ack")) - } - - private func activateTempTarget(named name: String, request: WatchCommandRequest, grant: UInt64) async -> WatchCommandResult { - do { - let presets = try await actions.tempTargetPresets() - guard let preset = uniquePreset(named: name, in: presets) else { - log(.warning, "Garmin temp target preset missing or ambiguous") - return .failure(String(localized: "Temp target preset not found.", comment: "Watch command ack")) - } - if let rejection = authorizationRejection(for: request, grant: grant) { - return rejection - } - try await adjustmentManager.activateTempTarget( - preset.ref, - source: .watch, - authorize: finalCheck(for: request, grant: grant) - ) - return .success(.tempTargetStarted, String(localized: "Temp target started.", comment: "Watch command ack")) - } catch let rejection as WatchCommandRejection { - return rejection.result - } catch { - log(.warning, "Garmin temp target activation failed (\(WatchCommandErrorCategory.name(for: error)))") - return .failure(String(localized: "Could not start temp target.", comment: "Watch command ack")) - } - } - - private func cancelTempTarget(request: WatchCommandRequest, grant: UInt64) async -> WatchCommandResult { - if let rejection = authorizationRejection(for: request, grant: grant) { - return rejection - } - do { - try await adjustmentManager.cancelTempTarget(source: .watch, authorize: finalCheck(for: request, grant: grant)) - } catch let rejection as WatchCommandRejection { - return rejection.result - } catch AdjustmentError.nothingActive { - // cancelling an inactive temp target already has the requested outcome - } catch { - log(.warning, "Garmin temp target cancellation failed (\(WatchCommandErrorCategory.name(for: error)))") - return .failure(String(localized: "Could not stop temp target.", comment: "Watch command ack")) - } - return .success(.tempTargetStopped, String(localized: "Temp target stopped.", comment: "Watch command ack")) - } - - /// Exact, case-sensitive match that ignores whether the preset is running; unlike - /// `AdjustmentManager`'s trimmed name lookup, two presets that only differ in whitespace must - /// not let the watch start the wrong one. - private func uniquePreset(named name: String, in presets: [WatchPresetEntry]) -> WatchPresetEntry? { - let matches = presets.filter { $0.name == name } - return matches.count == 1 ? matches.first : nil - } - - private func sortedByName(_ presets: [WatchPresetEntry]) -> [WatchPresetEntry] { - presets.sorted { - switch $0.name.localizedStandardCompare($1.name) { - case .orderedAscending: return true - case .orderedDescending: return false - // tie-break on raw names so equal display names keep a stable order - case .orderedSame: return $0.name < $1.name - } - } - } -} - -private extension BolusSafetyRejection { - /// Case name only; the associated values are insulin amounts, which stay out of the log. - var logName: String { - switch self { - case .exceedsMaxBolus: return "exceedsMaxBolus" - case .iobUnavailable: return "iobUnavailable" - case .exceedsMaxIOB: return "exceedsMaxIOB" - case .recentBolusWithinWindow: return "recentBolusWithinWindow" - } - } - - var watchMessage: String { - switch self { - case .exceedsMaxBolus: - return String(localized: "Bolus exceeds max bolus.", comment: "Watch command ack") - case .iobUnavailable: - return String(localized: "IOB unavailable. Bolus not delivered.", comment: "Watch command ack") - case .exceedsMaxIOB: - return String(localized: "Bolus would exceed max IOB.", comment: "Watch command ack") - case .recentBolusWithinWindow: - return String(localized: "A bolus was given in the last 6 minutes.", comment: "Watch command ack") - } - } -} diff --git a/Trio/Sources/Services/WatchCommand/WatchCommandRequestCache.swift b/Trio/Sources/Services/WatchCommand/WatchCommandRequestCache.swift deleted file mode 100644 index aac154d534..0000000000 --- a/Trio/Sources/Services/WatchCommand/WatchCommandRequestCache.swift +++ /dev/null @@ -1,114 +0,0 @@ -import Foundation - -/// Remembers recent watch request IDs so a relayed or retried command executes at most once. -/// -/// In memory only: after an app restart the freshness window and bolus safety checks are what -/// stop a replay. -actor WatchCommandRequestCache { - struct Key: Hashable { - let appUUID: UUID - let requestID: UUID - } - - /// Proof of one admission; only its holder may complete the entry it reserved. - struct Ticket: Equatable { - let key: Key - let generation: UInt64 - } - - enum Admission: Equatable { - /// First sighting; the caller must execute and then call `complete` with the ticket. - case accepted(Ticket) - case inProgress - /// The request ID was already used for a different command. - case conflict - case completed(WatchCommandResult) - /// Every slot holds an executing request, and those are never evicted. - case full - } - - private enum State { - case inFlight(generation: UInt64) - case completed(WatchCommandResult) - } - - private struct Entry { - let command: WatchCommand - var state: State - let insertedAt: Date - var lastUsed: UInt64 - - var isInFlight: Bool { - if case .inFlight = state { return true } - return false - } - } - - static let defaultCapacity = 256 - static let defaultLifetime: TimeInterval = 15 * 60 - - private let capacity: Int - private let lifetime: TimeInterval - - private var entries: [Key: Entry] = [:] - /// Monotonic use counter; a clock would tie when several requests arrive within one tick. - private var useCounter: UInt64 = 0 - private var generationCounter: UInt64 = 0 - - init(capacity: Int = defaultCapacity, lifetime: TimeInterval = defaultLifetime) { - self.capacity = capacity - self.lifetime = lifetime - } - - var count: Int { entries.count } - - /// Reserves `key` before anything executes, or reports why it must not run now. - func admit(_ key: Key, command: WatchCommand, now: Date) -> Admission { - evictExpired(now: now) - useCounter += 1 - - if var entry = entries[key] { - guard entry.command == command else { return .conflict } - entry.lastUsed = useCounter - entries[key] = entry - switch entry.state { - case .inFlight: return .inProgress - case let .completed(result): return .completed(result) - } - } - - if entries.count >= capacity { - // evicting a running request would let its duplicate execute a second time - guard let leastRecentlyUsed = entries.filter({ !$0.value.isInFlight }) - .min(by: { $0.value.lastUsed < $1.value.lastUsed })?.key - else { return .full } - entries[leastRecentlyUsed] = nil - } - - generationCounter += 1 - let ticket = Ticket(key: key, generation: generationCounter) - entries[key] = Entry( - command: command, - state: .inFlight(generation: ticket.generation), - insertedAt: now, - lastUsed: useCounter - ) - return .accepted(ticket) - } - - /// Ignored unless `ticket` still owns the reservation, so a stale execution cannot overwrite - /// the result of a newer admission under the same key. - func complete(_ ticket: Ticket, result: WatchCommandResult) { - guard var entry = entries[ticket.key], - case let .inFlight(generation) = entry.state, - generation == ticket.generation - else { return } - entry.state = .completed(result) - entries[ticket.key] = entry - } - - /// Executing entries outlive their lifetime until they complete. - private func evictExpired(now: Date) { - entries = entries.filter { $0.value.isInFlight || now.timeIntervalSince($0.value.insertedAt) < lifetime } - } -} diff --git a/Trio/Sources/Services/WatchManager/GarminCommandRouter.swift b/Trio/Sources/Services/WatchManager/GarminCommandRouter.swift deleted file mode 100644 index ec7ec350e7..0000000000 --- a/Trio/Sources/Services/WatchManager/GarminCommandRouter.swift +++ /dev/null @@ -1,248 +0,0 @@ -import Foundation - -/// A message received from a Connect IQ app, after envelope validation. -enum GarminInboundMessage: Equatable { - /// Bare `"status"` string sent by watch apps that predate the v1 envelope. - case legacyStatus - case status - case presets - /// `requestIDString` is echoed back verbatim so the watch can match the ack. - case command(requestIDString: String, requestID: UUID, date: Date, command: WatchCommand) - /// Malformed or unsupported; carries the request ID when the envelope still named one to answer. - case invalid(requestID: String?) -} - -/// Garmin v1 wire contract: parsing inbound envelopes and building the targeted responses. -enum GarminCommandEnvelope { - static let protocolVersion = 1 - - enum Request: String { - case status - case presets - case command - case ack - } - - private static let readEnvelopeKeys: Set = [WatchMessageKeys.protocolVersion, WatchMessageKeys.request] - private static let commandEnvelopeKeys: Set = [ - WatchMessageKeys.protocolVersion, - WatchMessageKeys.request, - WatchMessageKeys.requestID, - WatchMessageKeys.date, - WatchMessageKeys.command, - WatchMessageKeys.payload - ] - - static func parse(_ message: Any) -> GarminInboundMessage { - if let string = message as? String { - return string == Request.status.rawValue ? .legacyStatus : .invalid(requestID: nil) - } - guard let envelope = message as? [String: Any] else { - return .invalid(requestID: nil) - } - - let requestIDString = envelope[WatchMessageKeys.requestID] as? String - let keys = Set(envelope.keys) - guard integer(envelope[WatchMessageKeys.protocolVersion]) == Int64(protocolVersion), - let request = (envelope[WatchMessageKeys.request] as? String).flatMap(Request.init(rawValue:)) - else { - return .invalid(requestID: requestIDString) - } - - switch request { - case .status: - return keys == readEnvelopeKeys ? .status : .invalid(requestID: requestIDString) - case .presets: - return keys == readEnvelopeKeys ? .presets : .invalid(requestID: requestIDString) - case .command: - guard keys == commandEnvelopeKeys, - let requestIDString, - let requestID = rfc4122UUID(requestIDString), - let milliseconds = integer(envelope[WatchMessageKeys.date]), milliseconds >= 0, - let name = envelope[WatchMessageKeys.command] as? String, - let payload = envelope[WatchMessageKeys.payload] as? [String: Any], - let command = command(named: name, payload: payload) - else { - return .invalid(requestID: requestIDString) - } - let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000) - return .command(requestIDString: requestIDString, requestID: requestID, date: date, command: command) - case .ack: - return .invalid(requestID: requestIDString) - } - } - - static func acknowledgment(requestID: String, result: WatchCommandResult) -> [String: Any] { - [ - WatchMessageKeys.protocolVersion: protocolVersion, - WatchMessageKeys.request: Request.ack.rawValue, - WatchMessageKeys.requestID: requestID, - WatchMessageKeys.acknowledged: result.acknowledged, - WatchMessageKeys.ackCode: result.ackCode.rawValue, - WatchMessageKeys.message: result.message - ] - } - - /// Lists every configured preset; `isEnabled` reports whether it is running now, not whether - /// the watch may activate it. The switches and limits let the watch hide what the phone would - /// refuse; amounts go out as doubles in U and g. - static func presetsResponse(_ presets: WatchCommandPresets) -> [String: Any] { - let capabilities = presets.capabilities - return [ - WatchMessageKeys.protocolVersion: protocolVersion, - WatchMessageKeys.request: Request.presets.rawValue, - WatchMessageKeys.overridePresets: presets.overridePresets.map { - [WatchMessageKeys.presetName: $0.name, WatchMessageKeys.presetIsEnabled: $0.isEnabled] as [String: Any] - }, - WatchMessageKeys.tempTargetPresets: presets.tempTargetPresets.map { - [WatchMessageKeys.presetName: $0.name, WatchMessageKeys.presetIsEnabled: $0.isEnabled] as [String: Any] - }, - WatchMessageKeys.isCommandControlEnabled: capabilities.isCommandControlEnabled, - WatchMessageKeys.isBolusCommandEnabled: capabilities.isBolusCommandEnabled, - WatchMessageKeys.maxBolus: NSDecimalNumber(decimal: capabilities.maxBolus).doubleValue, - WatchMessageKeys.maxCarbs: NSDecimalNumber(decimal: capabilities.maxCarbs).doubleValue, - WatchMessageKeys.bolusIncrement: NSDecimalNumber(decimal: capabilities.bolusIncrement).doubleValue - ] - } - - // MARK: - Payloads - - private static func command(named name: String, payload: [String: Any]) -> WatchCommand? { - let keys = Set(payload.keys) - - switch name { - case WatchMessageKeys.bolus: - guard keys == [WatchMessageKeys.bolus], let units = units(payload[WatchMessageKeys.bolus]) else { return nil } - return .bolus(units) - case WatchMessageKeys.carbs: - guard keys == [WatchMessageKeys.carbs], let grams = grams(payload[WatchMessageKeys.carbs]) else { return nil } - return .carbs(grams) - case WatchMessageKeys.mealBolus: - guard keys == [WatchMessageKeys.carbs, WatchMessageKeys.bolus], - let grams = grams(payload[WatchMessageKeys.carbs]), - let units = units(payload[WatchMessageKeys.bolus]) - else { return nil } - return .mealBolus(carbs: grams, bolus: units) - case WatchMessageKeys.activateOverride: - guard keys == [WatchMessageKeys.presetName], let name = payload[WatchMessageKeys.presetName] as? String - else { return nil } - return .activateOverride(name: name) - case WatchMessageKeys.cancelOverride: - return keys.isEmpty ? .cancelOverride : nil - case WatchMessageKeys.activateTempTarget: - guard keys == [WatchMessageKeys.presetName], let name = payload[WatchMessageKeys.presetName] as? String - else { return nil } - return .activateTempTarget(name: name) - case WatchMessageKeys.cancelTempTarget: - return keys.isEmpty ? .cancelTempTarget : nil - default: - return nil - } - } - - // MARK: - Value Types - - /// Connect IQ delivers every number, and booleans too, as `NSNumber`; booleans are not numbers here. - private static func number(_ value: Any?) -> NSNumber? { - guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } - return number.doubleValue.isFinite ? number : nil - } - - private static func integer(_ value: Any?) -> Int64? { - guard let number = number(value) else { return nil } - let double = number.doubleValue - guard double.rounded() == double, abs(double) < 9.0E15 else { return nil } - return number.int64Value - } - - private static func grams(_ value: Any?) -> Int? { - integer(value).flatMap { Int(exactly: $0) } - } - - /// Rounds to 0.001 U: a Monkey C `Float` of 0.1 arrives as 0.10000000149. - private static func units(_ value: Any?) -> Decimal? { - guard let number = number(value) else { return nil } - var raw = Decimal(number.doubleValue) - var rounded = Decimal() - NSDecimalRound(&rounded, &raw, 3, .plain) - return rounded - } - - static func rfc4122UUID(_ string: String) -> UUID? { - guard string.count == 36, let uuid = UUID(uuidString: string) else { return nil } - // variant bits 10xx mark the RFC 4122 layout - return uuid.uuid.8 & 0xC0 == 0x80 ? uuid : nil - } -} - -/// Connect IQ side of watch commands: turns one inbound message into at most one reply for the -/// requesting app plus an optional state refresh. It has no broadcast path by design. -struct GarminCommandRouter { - struct Outcome { - /// Sent to the requesting `IQApp` only. - var reply: [String: Any]? - /// Trigger name for a regular watch-state update, if one is due. - var refreshTrigger: String? - /// A preset started or stopped, so every Complication app needs the targeted preset list. - var pushesPresets = false - - static let none = Outcome(reply: nil, refreshTrigger: nil) - } - - let processor: WatchCommandProcessor - var log: (LogLevel, String) -> Void = { TrioLogger.log($0, .watch, $1, file: #file, function: #function, line: #line) } - - func route(_ message: Any, from appUUID: UUID, isRegistered: Bool, appName: String) async -> Outcome { - let inbound = GarminCommandEnvelope.parse(message) - log(.debug, "Garmin \(inbound.logName) received from \(appName)") - - guard isRegistered else { - log(.warning, "Garmin \(inbound.logName) from unregistered app \(appUUID.uuidString) ignored") - return .none - } - - switch inbound { - case .legacyStatus, - .status: - return Outcome(reply: nil, refreshTrigger: "WatchRequest") - - case .presets: - do { - let presets = try await processor.presets() - return Outcome(reply: GarminCommandEnvelope.presetsResponse(presets), refreshTrigger: nil) - } catch { - log(.warning, "Garmin presets could not be loaded (\(WatchCommandErrorCategory.name(for: error)))") - return .none - } - - case let .command(requestIDString, requestID, date, command): - log(.info, "Garmin \(command.name) accepted from \(appName)") - let request = WatchCommandRequest(requestID: requestID, appUUID: appUUID, date: date, command: command) - let result = await processor.process(request) - return Outcome( - reply: GarminCommandEnvelope.acknowledgment(requestID: requestIDString, result: result), - refreshTrigger: result.refreshesState ? "WatchCommand" : nil, - pushesPresets: result.acknowledged && command.changesPresetState - ) - - case let .invalid(requestID): - // without a request ID the watch could not match an answer, so none is sent - guard let requestID else { return .none } - let result = WatchCommandResult.failure(String(localized: "Invalid command.", comment: "Watch command ack")) - return Outcome(reply: GarminCommandEnvelope.acknowledgment(requestID: requestID, result: result), refreshTrigger: nil) - } - } -} - -private extension GarminInboundMessage { - /// Never includes payload values: a command body can carry an insulin amount. - var logName: String { - switch self { - case .legacyStatus: return "legacy status request" - case .status: return "status request" - case .presets: return "presets request" - case let .command(_, _, _, command): return "\(command.name) command" - case .invalid: return "invalid message" - } - } -} diff --git a/TrioTests/WatchCommandTests/GarminCommandRouterTests.swift b/TrioTests/WatchCommandTests/GarminCommandRouterTests.swift deleted file mode 100644 index d24155ee48..0000000000 --- a/TrioTests/WatchCommandTests/GarminCommandRouterTests.swift +++ /dev/null @@ -1,348 +0,0 @@ -import Foundation -import Testing - -@testable import Trio - -@Suite("Garmin Command Router Tests") struct GarminCommandRouterTests { - let requestID = "3f2b8c1e-4a5d-4e6f-9a7b-1c2d3e4f5a6b" - let appUUID = UUID() - let milliseconds: UInt64 = 1_800_000_000_123 - - private func envelope(command: String, payload: [String: Any]) -> [String: Any] { - [ - "v": 1, - "req": "command", - "requestId": requestID, - "date": NSNumber(value: milliseconds), - "command": command, - "payload": payload - ] - } - - private func parsedCommand(_ message: Any) -> WatchCommand? { - guard case let .command(_, _, _, command) = GarminCommandEnvelope.parse(message) else { return nil } - return command - } - - // MARK: - Parsing - - @Test("Bare status string is the legacy status request") func testLegacyStatus() { - #expect(GarminCommandEnvelope.parse("status") == .legacyStatus) - #expect(GarminCommandEnvelope.parse("bolus") == .invalid(requestID: nil)) - } - - @Test("v1 status and presets requests carry no other fields") func testReadRequests() { - #expect(GarminCommandEnvelope.parse(["v": 1, "req": "status"]) == .status) - #expect(GarminCommandEnvelope.parse(["v": 1, "req": "presets"]) == .presets) - #expect(GarminCommandEnvelope.parse(["v": 1, "req": "presets", "extra": true]) == .invalid(requestID: nil)) - #expect(GarminCommandEnvelope.parse(["v": 2, "req": "status"]) == .invalid(requestID: nil)) - #expect(GarminCommandEnvelope.parse(["v": "1", "req": "status"]) == .invalid(requestID: nil)) - #expect(GarminCommandEnvelope.parse(["v": true, "req": "status"]) == .invalid(requestID: nil)) - } - - @Test("Every phase-1 command parses with its exact payload") func testCommands() { - #expect(parsedCommand(envelope(command: "bolus", payload: ["bolus": 1.5])) == .bolus(1.5)) - #expect(parsedCommand(envelope(command: "bolus", payload: ["bolus": 2])) == .bolus(2)) - #expect(parsedCommand(envelope(command: "carbs", payload: ["carbs": 45])) == .carbs(45)) - #expect( - parsedCommand(envelope(command: "mealBolus", payload: ["carbs": 45, "bolus": 3.25])) - == .mealBolus(carbs: 45, bolus: 3.25) - ) - #expect( - parsedCommand(envelope(command: "activateOverride", payload: ["name": "Sport"])) == - .activateOverride(name: "Sport") - ) - #expect(parsedCommand(envelope(command: "cancelOverride", payload: [:])) == .cancelOverride) - #expect( - parsedCommand(envelope(command: "activateTempTarget", payload: ["name": "Walk"])) == .activateTempTarget(name: "Walk") - ) - #expect(parsedCommand(envelope(command: "cancelTempTarget", payload: [:])) == .cancelTempTarget) - } - - @Test("The command date is read as Unix milliseconds") func testDate() { - guard case let .command(echoedID, uuid, date, _) = GarminCommandEnvelope.parse( - envelope(command: "cancelOverride", payload: [:]) - ) else { - Issue.record("Expected a command") - return - } - #expect(echoedID == requestID) - #expect(uuid == UUID(uuidString: requestID)) - #expect(abs(date.timeIntervalSince1970 - 1_800_000_000.123) < 0.000_1) - } - - @Test("A Monkey C Float bolus is rounded to 0.001 U") func testFloatRounding() { - let payload: [String: Any] = ["bolus": NSNumber(value: Float(0.1))] - #expect(parsedCommand(envelope(command: "bolus", payload: payload)) == .bolus(Decimal(string: "0.1")!)) - } - - @Test("Malformed commands are rejected but keep their request ID for the ack") func testMalformedPayloads() { - let cases: [(command: String, payload: [String: Any])] = [ - ("bolus", ["bolus": true]), - ("bolus", ["bolus": "1"]), - ("bolus", ["bolus": 1, "note": "x"]), - ("bolus", [:]), - ("carbs", ["carbs": 12.5]), - ("carbs", ["carbs": "12"]), - ("mealBolus", ["carbs": 20]), - ("activateOverride", ["name": 3]), - ("activateOverride", [:]), - ("cancelOverride", ["name": "Sport"]), - ("requestBolusRecommendation", [:]), - ("unknown", [:]) - ] - for (command, payload) in cases { - #expect( - GarminCommandEnvelope.parse(envelope(command: command, payload: payload)) == .invalid(requestID: requestID), - "\(command) with \(payload.keys.sorted())" - ) - } - } - - @Test("Malformed envelopes are rejected") func testMalformedEnvelopes() { - var extraField = envelope(command: "cancelOverride", payload: [:]) - extraField["auth"] = ["scheme": "hmac-sha256"] - #expect(GarminCommandEnvelope.parse(extraField) == .invalid(requestID: requestID)) - - var missingDate = envelope(command: "cancelOverride", payload: [:]) - missingDate["date"] = nil - #expect(GarminCommandEnvelope.parse(missingDate) == .invalid(requestID: requestID)) - - var negativeDate = envelope(command: "cancelOverride", payload: [:]) - negativeDate["date"] = -1 - #expect(GarminCommandEnvelope.parse(negativeDate) == .invalid(requestID: requestID)) - - var badPayload = envelope(command: "cancelOverride", payload: [:]) - badPayload["payload"] = "none" - #expect(GarminCommandEnvelope.parse(badPayload) == .invalid(requestID: requestID)) - - var wrongVersion = envelope(command: "cancelOverride", payload: [:]) - wrongVersion["v"] = 2 - #expect(GarminCommandEnvelope.parse(wrongVersion) == .invalid(requestID: requestID)) - } - - @Test("Request IDs must be RFC 4122 UUIDs") func testRequestIDFormat() { - #expect(GarminCommandEnvelope.rfc4122UUID(requestID) != nil) - #expect(GarminCommandEnvelope.rfc4122UUID(requestID.uppercased()) != nil) - // variant nibble 'c' is the Microsoft layout, not RFC 4122 - #expect(GarminCommandEnvelope.rfc4122UUID("3f2b8c1e-4a5d-4e6f-ca7b-1c2d3e4f5a6b") == nil) - #expect(GarminCommandEnvelope.rfc4122UUID("3f2b8c1e4a5d4e6f9a7b1c2d3e4f5a6b") == nil) - #expect(GarminCommandEnvelope.rfc4122UUID("not-a-uuid") == nil) - } - - // MARK: - Routing - - @Test("Status requests refresh the broadcast state and send no reply") func testStatusRouting() async { - let router = GarminCommandRouter(processor: SpyWatchCommandProcessor()) - - let messages: [Any] = ["status", ["v": 1, "req": "status"] as [String: Any]] - for message in messages { - let outcome = await router.route(message, from: appUUID, isRegistered: true, appName: "test") - #expect(outcome.reply == nil) - #expect(outcome.refreshTrigger == "WatchRequest") - } - } - - @Test("Unregistered apps are ignored without executing") func testUnregisteredApp() async { - let processor = SpyWatchCommandProcessor() - let router = GarminCommandRouter(processor: processor) - - let outcome = await router.route( - envelope(command: "carbs", payload: ["carbs": 10]), - from: appUUID, - isRegistered: false, - appName: "test" - ) - - #expect(outcome.reply == nil) - #expect(outcome.refreshTrigger == nil) - #expect(processor.processed.isEmpty) - } - - @Test("A command gets exactly the v1 ack and triggers a refresh on success") func testCommandAck() async throws { - let processor = SpyWatchCommandProcessor() - let router = GarminCommandRouter(processor: processor) - - let outcome = await router.route( - envelope(command: "carbs", payload: ["carbs": 10]), - from: appUUID, - isRegistered: true, - appName: "test" - ) - - let reply = try #require(outcome.reply) - #expect(Set(reply.keys) == ["v", "req", "requestId", "acknowledged", "ackCode", "message"]) - #expect(reply["v"] as? Int == 1) - #expect(reply["req"] as? String == "ack") - #expect(reply["requestId"] as? String == requestID) - #expect(reply["acknowledged"] as? Bool == true) - #expect(reply["ackCode"] as? String == "carbs_logged") - #expect(outcome.refreshTrigger == "WatchCommand") - - #expect(processor.processed.count == 1) - #expect(processor.processed.first?.appUUID == appUUID) - #expect(processor.processed.first?.command == .carbs(10)) - } - - @Test("Failures do not refresh; partial failures do") func testRefreshRules() async { - let processor = SpyWatchCommandProcessor() - let router = GarminCommandRouter(processor: processor) - let message = envelope(command: "mealBolus", payload: ["carbs": 10, "bolus": 1]) - - processor.result = .failure("Watch commands are disabled.") - let failed = await router.route(message, from: appUUID, isRegistered: true, appName: "test") - #expect(failed.refreshTrigger == nil) - #expect(failed.reply?["ackCode"] as? String == "failure") - - processor.result = WatchCommandResult(acknowledged: false, ackCode: .partialFailure, message: "Carbs logged.") - let partial = await router.route(message, from: appUUID, isRegistered: true, appName: "test") - #expect(partial.refreshTrigger == "WatchCommand") - #expect(partial.reply?["ackCode"] as? String == "partial_failure") - } - - @Test("A preset request gets a targeted presets reply and no broadcast refresh") func testPresetsReply() async throws { - let processor = SpyWatchCommandProcessor() - processor.presetsResult = WatchCommandPresets( - overridePresets: [OverridePresetWatch(name: "Sport", isEnabled: true)], - tempTargetPresets: [TempTargetPresetWatch(name: "Walk", isEnabled: false)], - capabilities: WatchCommandCapabilities( - isCommandControlEnabled: true, - isBolusCommandEnabled: false, - maxBolus: 5, - maxCarbs: 120, - bolusIncrement: 0.05 - ) - ) - let router = GarminCommandRouter(processor: processor) - - let outcome = await router.route(["v": 1, "req": "presets"], from: appUUID, isRegistered: true, appName: "test") - - let reply = try #require(outcome.reply) - #expect(Set(reply.keys) == [ - "v", "req", "overridePresets", "tempTargetPresets", - "isCommandControlEnabled", "isBolusCommandEnabled", "maxBolus", "maxCarbs", "bolusIncrement" - ]) - #expect(reply["isCommandControlEnabled"] as? Bool == true) - #expect(reply["isBolusCommandEnabled"] as? Bool == false) - #expect(reply["maxBolus"] as? Double == 5) - #expect(reply["maxCarbs"] as? Double == 120) - // a Decimal float literal is not exact, so compare with a tolerance - #expect(abs((reply["bolusIncrement"] as? Double ?? 0) - 0.05) < 1E-9) - #expect(reply["req"] as? String == "presets") - let overrides = try #require(reply["overridePresets"] as? [[String: Any]]) - #expect(overrides.first?["name"] as? String == "Sport") - #expect(overrides.first?["isEnabled"] as? Bool == true) - let tempTargets = try #require(reply["tempTargetPresets"] as? [[String: Any]]) - #expect(tempTargets.first?["name"] as? String == "Walk") - #expect(outcome.refreshTrigger == nil) - } - - @Test("Only successful preset commands push the preset list") func testPresetPushRules() async { - let processor = SpyWatchCommandProcessor() - let router = GarminCommandRouter(processor: processor) - - processor.result = .success(.overrideStarted, "Override started.") - let started = await router.route( - envelope(command: "activateOverride", payload: ["name": "Sport"]), - from: appUUID, - isRegistered: true, - appName: "test" - ) - #expect(started.pushesPresets) - - processor.result = .success(.tempTargetStopped, "Temp target stopped.") - let stopped = await router.route( - envelope(command: "cancelTempTarget", payload: [:]), - from: appUUID, - isRegistered: true, - appName: "test" - ) - #expect(stopped.pushesPresets) - - processor.result = .failure("Override preset not found.") - let failed = await router.route( - envelope(command: "activateOverride", payload: ["name": "Sport"]), - from: appUUID, - isRegistered: true, - appName: "test" - ) - #expect(failed.pushesPresets == false) - - processor.result = .success(.carbsLogged, "Carbs logged.") - let carbs = await router.route( - envelope(command: "carbs", payload: ["carbs": 10]), - from: appUUID, - isRegistered: true, - appName: "test" - ) - #expect(carbs.pushesPresets == false) - } - - @Test("A preset load failure is logged by category only") func testPresetErrorSanitized() async { - let processor = SpyWatchCommandProcessor() - processor.presetsError = SensitiveTestError() - let logs = LogCapture() - var router = GarminCommandRouter(processor: processor) - router.log = logs.record - - let outcome = await router.route(["v": 1, "req": "presets"], from: appUUID, isRegistered: true, appName: "test") - - #expect(outcome.reply == nil) - #expect(logs.entries.contains { $0.level == .warning && $0.line.contains("(unexpected)") }) - #expect(logs.lines.allSatisfy { !$0.contains(SensitiveTestError.marker) }) - } - - @Test("Invalid commands are answered only when they name a request ID") func testInvalidReplies() async { - let processor = SpyWatchCommandProcessor() - let router = GarminCommandRouter(processor: processor) - - let named = await router.route( - envelope(command: "carbs", payload: ["carbs": "ten"]), - from: appUUID, - isRegistered: true, - appName: "test" - ) - #expect(named.reply?["requestId"] as? String == requestID) - #expect(named.reply?["acknowledged"] as? Bool == false) - #expect(named.refreshTrigger == nil) - - let anonymous = await router.route(["v": 1, "req": "command"], from: appUUID, isRegistered: true, appName: "test") - #expect(anonymous.reply == nil) - #expect(processor.processed.isEmpty) - } -} - -@Suite("Garmin Command Settings Migration Tests") struct GarminCommandSettingsMigrationTests { - @Test("Settings written before commands existed decode with both switches off") func testMissingKeysDefaultOff() throws { - let settings = try JSONDecoder().decode(TrioSettings.self, from: Data(#"{"isWatchfaceDataEnabled": true}"#.utf8)) - - #expect(settings.isWatchfaceDataEnabled == true) - #expect(settings.garminSettings.isCommandControlEnabled == false) - #expect(settings.garminSettings.isBolusCommandEnabled == false) - } - - @Test("Command switches survive an encode/decode round trip") func testRoundTrip() throws { - var settings = TrioSettings() - settings.garminSettings.isCommandControlEnabled = true - settings.garminSettings.isBolusCommandEnabled = true - - let decoded = try JSONDecoder().decode(TrioSettings.self, from: JSONEncoder().encode(settings)) - - #expect(decoded.isGarminCommandControlEnabled == true) - #expect(decoded.isGarminBolusCommandEnabled == true) - #expect(decoded.garminSettings == settings.garminSettings) - } - - @Test("GarminWatchSettings without command keys keeps its other values") func testGroupedSettingsMigration() throws { - let json = - #"{"watchface": "swissalpine", "datafields": ["loopgraph"], "primaryAttributeChoice": "isf", "secondaryAttributeChoice": "eventualBG", "isWatchfaceDataEnabled": true}"# - let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(json.utf8)) - - #expect(settings.watchface == .swissalpine) - #expect(settings.datafields == [.loopgraph]) - #expect(settings.primaryAttributeChoice == .isf) - #expect(settings.isWatchfaceDataEnabled == true) - #expect(settings.isCommandControlEnabled == false) - #expect(settings.isBolusCommandEnabled == false) - } -} diff --git a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift deleted file mode 100644 index d1238ff382..0000000000 --- a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift +++ /dev/null @@ -1,695 +0,0 @@ -import Combine -import ConnectIQ -import CoreData -import Foundation -import Swinject -import Testing - -@testable import Trio - -/// A task context whose saves fail the way a full disk or a validation error would. -final class FailingSaveContext: NSManagedObjectContext { - override func save() throws { - throw NSError( - domain: NSCocoaErrorDomain, - code: NSValidationMultipleErrorsError, - userInfo: [NSLocalizedDescriptionKey: "carbs=40 \(SensitiveTestError.marker)"] - ) - } -} - -/// Counts saves, to prove a transaction wrote nothing. -final class CountingSaveContext: NSManagedObjectContext, @unchecked Sendable { - private let lock = NSLock() - private var count = 0 - - var saveCount: Int { lock.withLock { count } } - - override func save() throws { - lock.withLock { count += 1 } - try super.save() - } -} - -/// Holds a context's private queue from inside `perform`, so later transactions queue up behind it. -final class ContextQueueBlocker: @unchecked Sendable { - private let released = DispatchSemaphore(value: 0) - - func block(_ context: NSManagedObjectContext) { - context.perform { self.released.wait() } - } - - func release() { - released.signal() - } -} - -/// Overrides the storage and safety services the production watch actions use. -private final class WatchCommandOverrideAssembly: Assembly { - private let carbsContext: () -> NSManagedObjectContext - private let validator: BolusSafetyValidator - private let processor: WatchCommandProcessor? - private let apsManager: APSManager? - private let adjustmentContext: (() -> NSManagedObjectContext)? - - init( - carbsContext: @escaping () -> NSManagedObjectContext, - validator: BolusSafetyValidator, - processor: WatchCommandProcessor?, - apsManager: APSManager?, - adjustmentContext: (() -> NSManagedObjectContext)? - ) { - self.carbsContext = carbsContext - self.validator = validator - self.processor = processor - self.apsManager = apsManager - self.adjustmentContext = adjustmentContext - } - - func assemble(container: Container) { - container.register(CarbsStorage.self) { [carbsContext] r in - BaseCarbsStorage(resolver: r, contextProvider: carbsContext) - }.inObjectScope(.container) - container.register(BolusSafetyValidator.self) { [validator] _ in validator } - if let processor { - container.register(WatchCommandProcessor.self) { _ in processor }.inObjectScope(.container) - } - if let apsManager { - container.register(APSManager.self) { _ in apsManager }.inObjectScope(.container) - } - if let adjustmentContext { - container.register(AdjustmentManager.self) { r in - BaseAdjustmentManager(resolver: r, contextProvider: adjustmentContext, recompute: {}) - }.inObjectScope(.container) - } - } -} - -private func makeResolver( - stack: CoreDataStack, - carbsContext: @escaping () -> NSManagedObjectContext, - validator: BolusSafetyValidator = StubBolusSafetyValidator(), - processor: WatchCommandProcessor? = nil, - apsManager: APSManager? = nil, - adjustmentContext: (() -> NSManagedObjectContext)? = nil -) -> Resolver { - Assembler([ - StorageAssembly(), - ServiceAssembly(), - APSAssembly(), - NetworkAssembly(), - UIAssembly(), - SecurityAssembly(), - TestAssembly(testContext: stack.newTaskContext()), - WatchCommandOverrideAssembly( - carbsContext: carbsContext, - validator: validator, - processor: processor, - apsManager: apsManager, - adjustmentContext: adjustmentContext - ) - ]).resolver -} - -// MARK: - Carb Persistence - -@Suite("Watch Command Carb Persistence Tests", .serialized) struct WatchCommandCarbPersistenceTests { - let stack: CoreDataStack - let failingContext: FailingSaveContext - - init() async throws { - stack = try await CoreDataStack.createForTests() - failingContext = FailingSaveContext(concurrencyType: .privateQueueConcurrencyType) - failingContext.persistentStoreCoordinator = stack.persistentContainer.persistentStoreCoordinator - } - - private func storedCarbRows() async throws -> Int { - let context = stack.newTaskContext() - return try await context.perform { - try context.count(for: NSFetchRequest(entityName: "CarbEntryStored")) - } - } - - @Test("A failed save throws from the verified path but stays silent for existing callers") func testStorageBoundary( - ) async throws { - let context = failingContext - let resolver = makeResolver(stack: stack, carbsContext: { context }) - let storage = try #require(resolver.resolve(CarbsStorage.self)) - - await #expect(throws: NSError.self) { - try await storage.storeVerifiedCarbs(storage.makeCarbEntry(carbs: 40, date: Date()), authorize: {}) - } - // unchanged contract for the app's own callers - try await storage.storeCarbs([storage.makeCarbEntry(carbs: 40, date: Date())], areFetchedFromRemote: false) - #expect(try await storedCarbRows() == 0) - } - - @Test("A successful verified save persists the carb row") func testStorageSuccess() async throws { - let stack = self.stack - let resolver = makeResolver(stack: stack, carbsContext: { stack.newTaskContext() }) - let storage = try #require(resolver.resolve(CarbsStorage.self)) - - try await storage.storeVerifiedCarbs(storage.makeCarbEntry(carbs: 25, date: Date()), authorize: {}) - - #expect(try await storedCarbRows() == 1) - } - - @Test("A real carb save failure never acks carbs or reaches the bolus") func testProductionActionFailure() async throws { - let context = failingContext - let validator = StubBolusSafetyValidator() - let resolver = makeResolver(stack: stack, carbsContext: { context }, validator: validator) - let settings = try #require(resolver.resolve(SettingsManager.self)) - settings.settings.isGarminCommandControlEnabled = true - settings.settings.isGarminBolusCommandEnabled = true - - let logs = LogCapture() - let processor = BaseWatchCommandProcessor( - resolver: resolver, - actions: BaseWatchCommandActions(resolver: resolver), - log: logs.record - ) - let appUUID = UUID() - - let carbs = await processor.process( - WatchCommandRequest(requestID: UUID(), appUUID: appUUID, date: Date(), command: .carbs(40)) - ) - let meal = await processor.process( - WatchCommandRequest(requestID: UUID(), appUUID: appUUID, date: Date(), command: .mealBolus(carbs: 40, bolus: 2)) - ) - - #expect(carbs.acknowledged == false) - #expect(carbs.ackCode == .genericFailure) - #expect(meal.acknowledged == false) - #expect(meal.ackCode == .genericFailure, "No partial failure: nothing was logged") - #expect(validator.validatedAmounts.isEmpty, "The bolus stage is never reached") - #expect(try await storedCarbRows() == 0) - #expect(logs.lines.contains { $0.contains("(cocoa(\(NSValidationMultipleErrorsError)))") }) - #expect(logs.lines.allSatisfy { !$0.contains(SensitiveTestError.marker) }) - } -} - -// MARK: - Garmin Manager - -final class InstalledAppStatus: IQAppStatus { - override var isInstalled: Bool { true } -} - -/// Stands in for the Connect IQ SDK and records every message with the exact app it targeted. -final class SpyConnectIQClient: GarminConnectIQClient, @unchecked Sendable { - struct Sent { - let message: Any - let appUUID: UUID - let deviceUUID: UUID - - var request: String? { (message as? [String: Any])?[WatchMessageKeys.request] as? String } - } - - private let lock = NSLock() - private var messages: [Sent] = [] - /// Remaining failures per app UUID + device UUID, to exercise the retry. - private var failures: [String: Int] = [:] - - var sent: [Sent] { lock.withLock { messages } } - - func failNextSend(to appUUID: UUID, on deviceUUID: UUID) { - lock.withLock { failures["\(appUUID)-\(deviceUUID)", default: 0] += 1 } - } - - func registerDevice(_: IQDevice, delegate _: IQDeviceEventDelegate) {} - - func registerApp(_: IQApp, delegate _: IQAppMessageDelegate) {} - - func appStatus(of _: IQApp, completion: @escaping (IQAppStatus?) -> Void) { - completion(InstalledAppStatus()) - } - - func send(_ message: Any, to app: IQApp, completion: @escaping (IQSendMessageResult) -> Void) { - let target = "\(app.uuid!)-\(app.device.uuid!)" - let fails: Bool = lock.withLock { - messages.append(Sent(message: message, appUUID: app.uuid, deviceUUID: app.device.uuid)) - guard let remaining = failures[target], remaining > 0 else { return false } - failures[target] = remaining - 1 - return true - } - // raw value 1 is IQSendMessageResult_Failure_Unknown - completion(fails ? IQSendMessageResult(rawValue: 1)! : .success) - } -} - -@Suite("Garmin Manager Command Integration Tests", .serialized) final class GarminManagerCommandIntegrationTests { - let complicationUUID = GarminWatchface.complication.watchfaceUUID! - let datafieldUUID = GarminDatafield.loopgraph.datafieldUUID! - let deviceA = IQDevice(id: UUID(), modelName: "fenix 8", friendlyName: "A")! - let deviceB = IQDevice(id: UUID(), modelName: "Enduro 3", friendlyName: "B")! - - let processor: SpyWatchCommandProcessor - let client: SpyConnectIQClient - let presetChanges: PassthroughSubject - let settings: SettingsManager - let authorization: WatchCommandAuthorization - let manager: BaseGarminManager - - init() async throws { - let stack = try await CoreDataStack.createForTests() - let processor = SpyWatchCommandProcessor() - let client = SpyConnectIQClient() - let presetChanges = PassthroughSubject() - let resolver = makeResolver(stack: stack, carbsContext: { stack.newTaskContext() }, processor: processor) - - let settings = try #require(resolver.resolve(SettingsManager.self)) - settings.settings.garminSettings.watchface = .complication - settings.settings.garminSettings.isWatchfaceDataEnabled = true - settings.settings.garminSettings.datafields = [.loopgraph] - settings.settings.garminSettings.isCommandControlEnabled = true - - self.processor = processor - self.client = client - self.presetChanges = presetChanges - self.settings = settings - authorization = try #require(resolver.resolve(WatchCommandAuthorization.self)) - manager = BaseGarminManager( - resolver: resolver, - connectIQClient: client, - presetChanges: presetChanges.eraseToAnyPublisher(), - sendRetryDelay: 0.01 - ) - manager.updateDeviceList([deviceA, deviceB]) - } - - deinit { - // the device list is persisted in user defaults - manager.updateDeviceList([]) - } - - private func app(_ uuid: UUID, on device: IQDevice) -> IQApp { - IQApp(uuid: uuid, store: UUID(), device: device) - } - - private func command(_ name: String, payload: [String: Any], requestID: String) -> [String: Any] { - [ - "v": 1, - "req": "command", - "requestId": requestID, - "date": NSNumber(value: UInt64(Date().timeIntervalSince1970 * 1000)), - "command": name, - "payload": payload - ] - } - - private func sent(_ request: String) -> [SpyConnectIQClient.Sent] { - client.sent.filter { $0.request == request } - } - - @Test("An ack goes only to the app and device that sent the command") func testAckTargetsOriginatingApp() async { - let requestID = UUID().uuidString - manager.receivedMessage( - command("carbs", payload: ["carbs": 20], requestID: requestID), - from: app(complicationUUID, on: deviceB) - ) - - #expect(await eventually { !self.sent("ack").isEmpty }) - let acks = sent("ack") - #expect(acks.count == 1) - #expect(acks.allSatisfy { $0.appUUID == complicationUUID && $0.deviceUUID == deviceB.uuid }) - #expect(acks.first.flatMap { ($0.message as? [String: Any])?["requestId"] as? String } == requestID) - #expect(processor.processed.map(\.appUUID) == [complicationUUID]) - #expect(client.sent.allSatisfy { $0.appUUID != datafieldUUID }, "Nothing command-related is fanned out") - } - - @Test("A preset request is answered only to the requesting app") func testPresetsReplyTargeted() async { - processor.presetsResult = WatchCommandPresets( - overridePresets: [OverridePresetWatch(name: "Sport", isEnabled: false)], - tempTargetPresets: [] - ) - manager.receivedMessage(["v": 1, "req": "presets"], from: app(complicationUUID, on: deviceA)) - - #expect(await eventually { !self.sent("presets").isEmpty }) - let replies = sent("presets") - #expect(replies.count == 1) - #expect(replies.first?.appUUID == complicationUUID) - #expect(replies.first?.deviceUUID == deviceA.uuid) - } - - @Test("A failed ack is resent to the same app") func testAckRetryTarget() async { - client.failNextSend(to: complicationUUID, on: deviceA.uuid) - let requestID = UUID().uuidString - manager.receivedMessage( - command("carbs", payload: ["carbs": 20], requestID: requestID), - from: app(complicationUUID, on: deviceA) - ) - - #expect(await eventually { self.sent("ack").count == 2 }) - let acks = sent("ack") - #expect(acks.allSatisfy { $0.appUUID == complicationUUID && $0.deviceUUID == deviceA.uuid }) - #expect(acks.allSatisfy { ($0.message as? [String: Any])?["requestId"] as? String == requestID }) - } - - @Test("A started preset pushes the list to every Complication app, never to datafields") func testCommandPushesPresets() async { - processor.result = .success(.overrideStarted, "Override started.") - manager.receivedMessage( - command("activateOverride", payload: ["name": "Sport"], requestID: UUID().uuidString), - from: app(complicationUUID, on: deviceA) - ) - - #expect(await eventually { self.sent("presets").count == 2 }) - let pushes = sent("presets") - #expect(Set(pushes.map(\.deviceUUID)) == [deviceA.uuid, deviceB.uuid]) - #expect(pushes.allSatisfy { $0.appUUID == complicationUUID }) - #expect(sent("ack").map(\.deviceUUID) == [deviceA.uuid]) - #expect(client.sent.allSatisfy { $0.appUUID != datafieldUUID }) - } - - @Test("Preset storage changes push to Complication apps only") func testStorageChangePushesPresets() async { - presetChanges.send(()) - - #expect(await eventually { self.sent("presets").count == 2 }) - #expect(sent("presets").allSatisfy { $0.appUUID == complicationUUID }) - #expect(Set(sent("presets").map(\.deviceUUID)) == [deviceA.uuid, deviceB.uuid]) - #expect(processor.processed.isEmpty) - } - - @Test("Command switch and max carbs changes push presets to Complication apps only") func testSettingsPushPresets() async { - settings.settings.garminSettings.isBolusCommandEnabled = true - - #expect(await eventually { self.sent("presets").count == 2 }) - #expect(sent("presets").allSatisfy { $0.appUUID == complicationUUID }) - - settings.settings.maxCarbs = 99 - #expect(await eventually { self.sent("presets").count == 4 }) - - settings.settings.garminSettings.primaryAttributeChoice = .isf - try? await Task.sleep(nanoseconds: 200_000_000) - #expect(sent("presets").count == 4, "Display settings do not push presets") - } - - @Test("A message from an unregistered device is ignored") func testUnregisteredDevice() async throws { - let stranger = try #require(IQDevice(id: UUID(), modelName: "fenix 8", friendlyName: "C")) - manager.receivedMessage( - command("carbs", payload: ["carbs": 20], requestID: UUID().uuidString), - from: app(complicationUUID, on: stranger) - ) - - try await Task.sleep(nanoseconds: 200_000_000) - #expect(client.sent.isEmpty) - #expect(processor.processed.isEmpty) - } - - @Test("Switching a command setting off revokes in the setter; changes refresh the watch") func testSettingsRevoke() async { - let triggers = RefreshTriggerRecorder(manager) - let start = authorization.current - - settings.settings.garminSettings.isCommandControlEnabled = false - #expect(authorization.current == start + 1, "Revoked before the notification is even queued") - #expect(await eventually { triggers.count("CommandSettings") == 1 }) - - settings.settings.garminSettings.isCommandControlEnabled = true - settings.settings.garminSettings.isBolusCommandEnabled = true - #expect(authorization.current == start + 1, "Turning settings on revokes nothing") - #expect(await eventually { triggers.count("CommandSettings") >= 2 }) - - let refreshes = triggers.count("CommandSettings") - settings.settings.garminSettings.primaryAttributeChoice = .isf - try? await Task.sleep(nanoseconds: 200_000_000) - #expect(authorization.current == start + 1, "Display settings leave commands alone") - #expect(triggers.count("CommandSettings") == refreshes) - } -} - -/// Collects the trigger names of the manager's watch state refreshes. -final class RefreshTriggerRecorder: @unchecked Sendable { - private let lock = NSLock() - private var names: [String] = [] - private var subscription: AnyCancellable? - - init(_ manager: BaseGarminManager) { - subscription = manager.stateRefreshTriggers.sink { [weak self] name in - guard let self else { return } - self.lock.withLock { self.names.append(name) } - } - } - - func count(_ name: String) -> Int { - lock.withLock { names.filter { $0 == name }.count } - } -} - -// MARK: - Revocation And Final Boundaries - -/// Runs revocation through the production settings manager, Garmin manager, processor, storage and -/// adjustment manager; only the pump, the safety validator and the Connect IQ SDK are stand-ins. -@Suite("Watch Command Revocation Integration Tests", .serialized) struct WatchCommandRevocationIntegrationTests { - let stack: CoreDataStack - - init() async throws { - stack = try await CoreDataStack.createForTests() - } - - private func countingContext() -> CountingSaveContext { - let context = CountingSaveContext(concurrencyType: .privateQueueConcurrencyType) - context.persistentStoreCoordinator = stack.persistentContainer.persistentStoreCoordinator - return context - } - - private func carbRows() async throws -> Int { - let context = stack.newTaskContext() - return try await context.perform { - try context.count(for: NSFetchRequest(entityName: "CarbEntryStored")) - } - } - - private func enableCommands(_ settings: SettingsManager) { - settings.settings.isGarminCommandControlEnabled = true - settings.settings.isGarminBolusCommandEnabled = true - } - - private func request(_ command: WatchCommand) -> WatchCommandRequest { - WatchCommandRequest(requestID: UUID(), appUUID: UUID(), date: Date(), command: command) - } - - // MARK: R1 - - @Test( - "Off-then-on before the settings notification drains still stops a suspended bolus", - arguments: [false, true] - ) func testRevokedBeforeNotificationDrains(isMeal: Bool) async throws { - let stack = self.stack - let validator = StubBolusSafetyValidator() - validator.gate = TestGate() - let pump = SpyAPSManager() - let client = SpyConnectIQClient() - let resolver = makeResolver( - stack: stack, - carbsContext: { stack.newTaskContext() }, - validator: validator, - apsManager: pump - ) - #expect(resolver.resolve(WatchCommandProcessor.self) is BaseWatchCommandProcessor) - let settings = try #require(resolver.resolve(SettingsManager.self)) - let authorization = try #require(resolver.resolve(WatchCommandAuthorization.self)) - settings.settings.garminSettings.watchface = .complication - settings.settings.garminSettings.isWatchfaceDataEnabled = true - enableCommands(settings) - - let device = try #require(IQDevice(id: UUID(), modelName: "fenix 8", friendlyName: "R1")) - let manager = BaseGarminManager( - resolver: resolver, - connectIQClient: client, - presetChanges: Empty().eraseToAnyPublisher(), - sendRetryDelay: 0.01 - ) - manager.updateDeviceList([device]) - defer { manager.updateDeviceList([]) } - let triggers = RefreshTriggerRecorder(manager) - - let complicationUUID = try #require(GarminWatchface.complication.watchfaceUUID) - manager.receivedMessage( - [ - "v": 1, - "req": "command", - "requestId": UUID().uuidString, - "date": NSNumber(value: UInt64(Date().timeIntervalSince1970 * 1000)), - "command": isMeal ? "mealBolus" : "bolus", - "payload": isMeal ? ["carbs": 30, "bolus": 2] : ["bolus": 2] - ] as [String: Any], - from: IQApp(uuid: complicationUUID, store: UUID(), device: device) - ) - await validator.gate.waitForArrivals() - - let start = authorization.current - let revokedInsideBlock = await MainActor.run { - // the main queue cannot deliver a settings notification before this block returns - settings.settings.garminSettings.isCommandControlEnabled = false - settings.settings.garminSettings.isCommandControlEnabled = true - settings.settings.garminSettings.isBolusCommandEnabled = false - settings.settings.garminSettings.isBolusCommandEnabled = true - return authorization.current - start - } - #expect(revokedInsideBlock == 2) - validator.gate.open() - - #expect(await eventually { client.sent.contains { $0.request == "ack" } }) - let ack = try #require(client.sent.first { $0.request == "ack" }?.message as? [String: Any]) - #expect(ack[WatchMessageKeys.ackCode] as? String == (isMeal ? "partial_failure" : "failure")) - #expect(pump.bolusRequests.isEmpty, "No pump request after the revocation") - #expect(try await carbRows() == (isMeal ? 1 : 0)) - #expect( - await eventually { triggers.count("CommandSettings") >= 1 }, - "Settings ended where they started, but the revocation still refreshes the watch" - ) - } - - // MARK: R2 - - @Test( - "A Garmin adjustment queued behind a busy context writes nothing once revoked", - arguments: [ - WatchCommand.activateOverride(name: "Sport"), .cancelOverride, - .activateTempTarget(name: "Walk"), .cancelTempTarget - ] - ) func testAdjustmentRevokedWhileQueued(command: WatchCommand) async throws { - let stack = self.stack - let context = countingContext() - let transactions = TestGate(isOpen: true) - let resolver = makeResolver( - stack: stack, - carbsContext: { stack.newTaskContext() }, - adjustmentContext: { - transactions.arrive() - return context - } - ) - let settings = try #require(resolver.resolve(SettingsManager.self)) - enableCommands(settings) - - let seed = stack.newTaskContext() - let (overrideID, tempTargetID) = try await seed.perform { - for (name, enabled) in [("Sport", false), ("Running", true)] { - let row = OverrideStored(context: seed) - row.id = UUID().uuidString - row.name = name - row.enabled = enabled - row.date = Date().addingTimeInterval(-600) - row.duration = NSDecimalNumber(value: 0) - row.indefinite = true - row.isPreset = true - row.percentage = 100 - } - for (name, enabled) in [("Walk", false), ("Hike", true)] { - let row = TempTargetStored(context: seed) - row.id = UUID() - row.name = name - row.enabled = enabled - row.date = Date().addingTimeInterval(-600) - row.duration = NSDecimalNumber(value: 60) - row.target = NSDecimalNumber(value: 120) - row.isPreset = true - row.enteredBy = TempTarget.local - } - try seed.save() - let overrides = try seed.fetch(NSFetchRequest(entityName: "OverrideStored")) - let tempTargets = try seed.fetch(NSFetchRequest(entityName: "TempTargetStored")) - return ( - overrides.first { $0.name == "Sport" }!.id!, - tempTargets.first { $0.name == "Walk" }!.id!.uuidString - ) - } - - let actions = StubWatchCommandActions() - actions.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID(overrideID))] - actions.tempTargets = [WatchPresetEntry(name: "Walk", isActive: false, ref: .presetID(tempTargetID))] - let processor = BaseWatchCommandProcessor(resolver: resolver, actions: actions) - - let blocker = ContextQueueBlocker() - blocker.block(context) - let pending = Task { await processor.process(request(command)) } - // past every processor check and inside the adjustment manager's transaction - await transactions.waitForArrivals() - - settings.settings.isGarminCommandControlEnabled = false - settings.settings.isGarminCommandControlEnabled = true - blocker.release() - let result = await pending.value - - #expect(result.acknowledged == false) - #expect(result.ackCode == .genericFailure) - #expect(context.saveCount == 0, "Nothing was saved") - #expect(await context.perform { !context.hasChanges }, "Nothing was even staged") - - let enabled: [String] = try await seed.perform { - seed.reset() - let overrides = try seed.fetch(NSFetchRequest(entityName: "OverrideStored")) - .filter(\.enabled).compactMap(\.name) - let tempTargets = try seed.fetch(NSFetchRequest(entityName: "TempTargetStored")) - .filter(\.enabled).compactMap(\.name) - return (overrides + tempTargets).sorted() - } - #expect(enabled == ["Hike", "Running"]) - - // the same setup commits once nothing is revoked - #expect(await processor.process(request(command)).acknowledged) - #expect(context.saveCount == 1) - } - - @Test( - "Carbs queued behind a busy context are not stored once revoked", - arguments: [false, true] - ) func testCarbsRevokedWhileQueued(isMeal: Bool) async throws { - let context = countingContext() - let transactions = TestGate(isOpen: true) - let pump = SpyAPSManager() - let resolver = makeResolver( - stack: stack, - carbsContext: { - transactions.arrive() - return context - }, - apsManager: pump - ) - let settings = try #require(resolver.resolve(SettingsManager.self)) - enableCommands(settings) - let processor = BaseWatchCommandProcessor(resolver: resolver, actions: BaseWatchCommandActions(resolver: resolver)) - - let blocker = ContextQueueBlocker() - blocker.block(context) - let command: WatchCommand = isMeal ? .mealBolus(carbs: 40, bolus: 2) : .carbs(40) - let pending = Task { await processor.process(request(command)) } - await transactions.waitForArrivals() - - settings.settings.isGarminCommandControlEnabled = false - settings.settings.isGarminCommandControlEnabled = true - blocker.release() - let result = await pending.value - - #expect(result.ackCode == .genericFailure, "Nothing was logged, so this is no partial failure") - #expect(context.saveCount == 0) - #expect(try await carbRows() == 0) - #expect(pump.bolusRequests.isEmpty) - - #expect(await processor.process(request(.carbs(40))).acknowledged) - #expect(try await carbRows() == 1) - } - - @Test("The production bolus action checks right before it calls the pump") func testProductionIssuanceCheck() async throws { - let stack = self.stack - let pump = SpyAPSManager() - let resolver = makeResolver(stack: stack, carbsContext: { stack.newTaskContext() }, apsManager: pump) - let actions = BaseWatchCommandActions(resolver: resolver) - - await #expect(throws: WatchCommandRejection.self) { - try await actions.enactBolus(2) { throw WatchCommandRejection(result: .failure("revoked")) } - } - #expect(pump.bolusRequests.isEmpty) - - let checks = LogCapture() - let delivered = try await actions.enactBolus(2) { - checks.record(.debug, pump.bolusRequests.isEmpty ? "before" : "after") - } - #expect(delivered) - #expect(checks.lines == ["before"]) - #expect(pump.bolusRequests == [2]) - - pump.bolusSucceeds = false - let refused = try await actions.enactBolus(1) {} - #expect(refused == false) - } -} diff --git a/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift b/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift deleted file mode 100644 index f1f6466713..0000000000 --- a/TrioTests/WatchCommandTests/WatchCommandProcessorTests.swift +++ /dev/null @@ -1,776 +0,0 @@ -import Foundation -import Swinject -import Testing - -@testable import Trio - -/// Covers what the processor decides before and around the side effects: freshness, gates, -/// limits, deduplication and the meal sequencing. Pump, Core Data and presets are stubbed. -@Suite("Watch Command Processor Tests") struct WatchCommandProcessorTests { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let clock = TestClock(Date(timeIntervalSince1970: 1_800_000_000)) - let appUUID = UUID() - - let settings = StubSettingsManager() - let validator = StubBolusSafetyValidator() - let adjustments = StubAdjustmentManager() - let actions = StubWatchCommandActions() - let cache = WatchCommandRequestCache() - let logs = LogCapture() - let container = Container() - let processor: BaseWatchCommandProcessor - - init() { - settings.settings.isGarminCommandControlEnabled = true - settings.settings.isGarminBolusCommandEnabled = true - settings.settings.maxCarbs = 100 - - container.register(SettingsManager.self) { [settings] _ in settings } - container.register(BolusSafetyValidator.self) { [validator] _ in validator } - container.register(AdjustmentManager.self) { [adjustments] _ in adjustments } - container.register(WatchCommandAuthorization.self) { [settings] _ in settings.authorization } - - processor = Self.makeProcessor(container: container, actions: actions, cache: cache, clock: clock, logs: logs) - } - - private static func makeProcessor( - container: Container, - actions: WatchCommandActions, - cache: WatchCommandRequestCache = WatchCommandRequestCache(), - insulinLane: WatchCommandInsulinLane = WatchCommandInsulinLane(), - clock: TestClock, - logs: LogCapture - ) -> BaseWatchCommandProcessor { - BaseWatchCommandProcessor( - resolver: container, - actions: actions, - cache: cache, - insulinLane: insulinLane, - now: { clock.now }, - log: logs.record - ) - } - - private func request( - _ command: WatchCommand, - id: UUID = UUID(), - app: UUID? = nil, - age: TimeInterval = 5 - ) -> WatchCommandRequest { - WatchCommandRequest(requestID: id, appUUID: app ?? appUUID, date: now.addingTimeInterval(-age), command: command) - } - - // MARK: - Registration - - @Test("Processor is registered once for the whole app") func testContainerScoped() { - let resolver = TrioApp().resolver - let first = resolver.resolve(WatchCommandProcessor.self) - let second = resolver.resolve(WatchCommandProcessor.self) - #expect(first is BaseWatchCommandProcessor) - #expect(first as AnyObject? === second as AnyObject?, "One instance means one request-ID cache") - } - - // MARK: - Freshness - - @Test( - "Timestamps are accepted from 2 minutes old to 60 seconds ahead", - arguments: [ - (TimeInterval(120), true), - (TimeInterval(120.001), false), - (TimeInterval(-60), true), - (TimeInterval(-60.001), false), - (TimeInterval(0), true) - ] - ) func testFreshnessWindow(age: TimeInterval, accepted: Bool) async { - let result = await processor.process(request(.carbs(10), age: age)) - - #expect(result.acknowledged == accepted) - #expect(actions.storedCarbs.count == (accepted ? 1 : 0)) - } - - // MARK: - Gates - - @Test("Master switch off rejects every mutable command without side effects") func testMasterSwitchOff() async { - settings.settings.isGarminCommandControlEnabled = false - - let commands: [WatchCommand] = [ - .carbs(10), .bolus(1), .mealBolus(carbs: 10, bolus: 1), - .activateOverride(name: "Sport"), .cancelOverride, - .activateTempTarget(name: "Walk"), .cancelTempTarget - ] - for command in commands { - let result = await processor.process(request(command)) - #expect(result.acknowledged == false) - #expect(result.ackCode == .genericFailure) - } - - #expect(actions.events.isEmpty) - #expect(adjustments.calls.isEmpty) - #expect(validator.validatedAmounts.isEmpty) - } - - @Test("Bolus switch off rejects bolus and meal bolus but not carbs") func testBolusSwitchOff() async { - settings.settings.isGarminBolusCommandEnabled = false - - #expect(await processor.process(request(.bolus(1))).acknowledged == false) - #expect(await processor.process(request(.mealBolus(carbs: 20, bolus: 1))).acknowledged == false) - #expect(actions.events.isEmpty, "A rejected meal bolus must not log its carbs either") - - #expect(await processor.process(request(.carbs(20))).acknowledged == true) - } - - // MARK: - Carbs - - @Test("Carbs are stored with the command date") func testCarbs() async { - let request = request(.carbs(30), age: 90) - let result = await processor.process(request) - - #expect(result == .success(.carbsLogged, result.message)) - #expect(actions.storedCarbs.map(\.grams) == [30]) - #expect(actions.storedCarbs.first?.date == request.date) - #expect(result.refreshesState) - } - - @Test("Carbs above max carbs are rejected; exactly max carbs is allowed") func testMaxCarbs() async { - settings.settings.maxCarbs = 50 - - #expect(await processor.process(request(.carbs(51))).acknowledged == false) - #expect(await processor.process(request(.mealBolus(carbs: 51, bolus: 1))).acknowledged == false) - #expect(actions.events.isEmpty) - - #expect(await processor.process(request(.carbs(50))).acknowledged == true) - } - - @Test("Non-positive amounts are rejected") func testNonPositiveAmounts() async { - #expect(await processor.process(request(.carbs(0))).acknowledged == false) - #expect(await processor.process(request(.carbs(-5))).acknowledged == false) - #expect(await processor.process(request(.bolus(0))).acknowledged == false) - #expect(actions.events.isEmpty) - #expect(validator.validatedAmounts.isEmpty) - } - - @Test("A failed carb save is a failure") func testCarbsStoreFailure() async { - actions.carbsError = TestError("save failed") - let result = await processor.process(request(.carbs(10))) - - #expect(result.acknowledged == false) - #expect(result.ackCode == .genericFailure) - } - - // MARK: - Bolus - - @Test("An allowed bolus is enacted") func testBolusAllowed() async { - let result = await processor.process(request(.bolus(1.5))) - - #expect(result.acknowledged) - #expect(result.ackCode == .genericSuccess) - #expect(validator.validatedAmounts == [1.5]) - #expect(actions.enactedBoluses == [1.5]) - } - - @Test( - "Every safety rejection stops the bolus", - arguments: [ - BolusSafetyRejection.exceedsMaxBolus(maxBolus: 5), - .iobUnavailable, - .exceedsMaxIOB(currentIOB: 4, maxIOB: 5), - .recentBolusWithinWindow(totalRecent: 1) - ] - ) func testBolusSafetyRejections(reason: BolusSafetyRejection) async { - validator.result = .rejected(reason) - let result = await processor.process(request(.bolus(2))) - - #expect(result.acknowledged == false) - #expect(result.ackCode == .genericFailure) - #expect(actions.enactedBoluses.isEmpty) - #expect(!result.message.contains("2"), "Acks never carry treatment amounts") - } - - @Test("A pump failure is reported as a failure") func testBolusPumpFailure() async { - actions.bolusSucceeds = false - let result = await processor.process(request(.bolus(1))) - - #expect(result.acknowledged == false) - #expect(result.ackCode == .genericFailure) - } - - @Test("Recent-bolus lookback covers the standard window for every fresh command") func testLookbackStart() async { - let standardWindowStart = now.addingTimeInterval(-Double(BolusSafetyEvaluator.recentBolusWindowMinutes * 60)) - - // separate processors: a second bolus through one lane is stopped before validation. - // The freshness window is shorter than the recent-bolus window, so even the oldest - // accepted command looks back over the full standard window. - _ = await processor.process(request(.bolus(1), age: 0)) - let second = Self.makeProcessor(container: container, actions: StubWatchCommandActions(), clock: clock, logs: logs) - _ = await second.process(request(.bolus(1), age: BaseWatchCommandProcessor.maximumCommandAge)) - - #expect(validator.lookbackStarts == [standardWindowStart, standardWindowStart]) - } - - // MARK: - Meal Bolus - - @Test("Meal bolus logs carbs before enacting the bolus") func testMealBolusOrder() async { - let result = await processor.process(request(.mealBolus(carbs: 40, bolus: 3))) - - #expect(result.acknowledged) - #expect(result.ackCode == .genericSuccess) - #expect(actions.events == ["carbs", "bolus"]) - #expect(actions.storedCarbs.map(\.grams) == [40]) - #expect(actions.enactedBoluses == [3]) - } - - @Test("A rejected meal bolus keeps the carbs and reports a partial failure") func testMealBolusPartialFailure() async { - validator.result = .rejected(.exceedsMaxIOB(currentIOB: 4, maxIOB: 5)) - let result = await processor.process(request(.mealBolus(carbs: 40, bolus: 3))) - - #expect(result.acknowledged == false) - #expect(result.ackCode == .partialFailure) - #expect(result.refreshesState, "Carbs changed, so the watch needs fresh state") - #expect(actions.storedCarbs.map(\.grams) == [40]) - #expect(actions.enactedBoluses.isEmpty) - } - - @Test("A pump failure after meal carbs is a partial failure") func testMealBolusPumpFailure() async { - actions.bolusSucceeds = false - let result = await processor.process(request(.mealBolus(carbs: 40, bolus: 3))) - - #expect(result.ackCode == .partialFailure) - #expect(actions.storedCarbs.count == 1) - } - - @Test("A failed meal carb save never reaches the bolus") func testMealBolusCarbsFailure() async { - actions.carbsError = TestError("save failed") - let result = await processor.process(request(.mealBolus(carbs: 40, bolus: 3))) - - #expect(result.ackCode == .genericFailure) - #expect(validator.validatedAmounts.isEmpty) - #expect(actions.enactedBoluses.isEmpty) - } - - // MARK: - Deduplication - - @Test("A completed duplicate replays its result without executing again") func testCompletedDuplicate() async { - let request = request(.bolus(1)) - - let first = await processor.process(request) - let second = await processor.process(request) - - #expect(first == second) - #expect(actions.enactedBoluses.count == 1) - #expect(validator.validatedAmounts.count == 1) - } - - @Test("Rejections are cached as terminal results too") func testRejectionCached() async { - settings.settings.isGarminCommandControlEnabled = false - let request = request(.carbs(10)) - let first = await processor.process(request) - - settings.settings.isGarminCommandControlEnabled = true - let second = await processor.process(request) - - #expect(first == second) - #expect(actions.storedCarbs.isEmpty) - } - - @Test("A duplicate of an in-flight request is rejected") func testInFlightDuplicate() async { - let request = request(.bolus(1)) - let key = WatchCommandRequestCache.Key(appUUID: request.appUUID, requestID: request.requestID) - _ = await cache.admit(key, command: request.command, now: now) - - let result = await processor.process(request) - - #expect(result.acknowledged == false) - #expect(result.ackCode == .inProgress, "Watches tell a running duplicate apart by code, not by text") - #expect(validator.validatedAmounts.isEmpty) - #expect(actions.enactedBoluses.isEmpty) - } - - @Test("Reusing a request ID for another command is rejected") func testConflict() async { - let id = UUID() - _ = await processor.process(request(.carbs(10), id: id)) - let result = await processor.process(request(.carbs(20), id: id)) - - #expect(result.acknowledged == false) - #expect(actions.storedCarbs.map(\.grams) == [10]) - } - - @Test("Request IDs are scoped per app") func testScopedPerApp() async { - let id = UUID() - _ = await processor.process(request(.carbs(10), id: id)) - _ = await processor.process(request(.carbs(10), id: id, app: UUID())) - - #expect(actions.storedCarbs.count == 2) - } - - // MARK: - Adjustments - - @Test("Activating an override uses the exact preset and the watch source") func testActivateOverride() async { - actions.overrides = [ - WatchPresetEntry(name: "Sport ", isActive: false, ref: .presetID("trailing")), - WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("exact")) - ] - let result = await processor.process(request(.activateOverride(name: "Sport"))) - - #expect(result == .success(.overrideStarted, result.message)) - #expect(adjustments.calls == [.activateOverride(.presetID("exact"))]) - #expect(adjustments.sources == [.watch]) - } - - @Test("An inactive preset can be activated; its state only reports whether it runs") func testInactivePresetActivates( - ) async throws { - actions.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] - actions.tempTargets = [WatchPresetEntry(name: "Walk", isActive: true, ref: .presetID("walk"))] - - #expect(await processor.process(request(.activateOverride(name: "Sport"))).ackCode == .overrideStarted) - #expect(await processor.process(request(.activateTempTarget(name: "Walk"))).ackCode == .tempTargetStarted) - #expect(adjustments.calls == [.activateOverride(.presetID("sport")), .activateTempTarget(.presetID("walk"))]) - - let presets = try await processor.presets() - #expect(presets.overridePresets == [OverridePresetWatch(name: "Sport", isEnabled: false)]) - #expect(presets.tempTargetPresets == [TempTargetPresetWatch(name: "Walk", isEnabled: true)]) - } - - @Test("Missing or ambiguous override names are rejected", arguments: ["Missing", "Sport"]) func testOverrideNotUnique( - name: String - ) async { - actions.overrides = [ - WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("a")), - WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("b")) - ] - let result = await processor.process(request(.activateOverride(name: name))) - - #expect(result.acknowledged == false) - #expect(adjustments.calls.isEmpty) - } - - @Test("Cancelling with nothing active is an idempotent success") func testCancelIdempotent() async { - adjustments.error = AdjustmentError.nothingActive - - let override = await processor.process(request(.cancelOverride)) - let tempTarget = await processor.process(request(.cancelTempTarget)) - - #expect(override == .success(.overrideStopped, override.message)) - #expect(tempTarget == .success(.tempTargetStopped, tempTarget.message)) - } - - @Test("Other cancellation errors are failures") func testCancelFailure() async { - adjustments.error = AdjustmentError.persistenceFailed("disk full") - let result = await processor.process(request(.cancelOverride)) - - #expect(result.acknowledged == false) - #expect(!result.message.contains("disk full"), "Internal errors never reach the watch") - } - - @Test("Activating a temp target uses the exact preset") func testActivateTempTarget() async { - actions.tempTargets = [WatchPresetEntry(name: "Walk", isActive: false, ref: .presetID("walk"))] - let result = await processor.process(request(.activateTempTarget(name: "Walk"))) - - #expect(result == .success(.tempTargetStarted, result.message)) - #expect(adjustments.calls == [.activateTempTarget(.presetID("walk"))]) - } - - // MARK: - Presets - - @Test("Presets are sorted by name and keep their state") func testPresetsSorted() async throws { - actions.overrides = [ - WatchPresetEntry(name: "b", isActive: false, ref: .presetID("b")), - WatchPresetEntry(name: "A", isActive: true, ref: .presetID("A")), - WatchPresetEntry(name: "c", isActive: false, ref: .presetID("c")) - ] - actions.tempTargets = [ - WatchPresetEntry(name: "Walk 10", isActive: false, ref: .presetID("10")), - WatchPresetEntry(name: "Walk 9", isActive: false, ref: .presetID("9")) - ] - - let presets = try await processor.presets() - - #expect(presets.overridePresets == [ - OverridePresetWatch(name: "A", isEnabled: true), - OverridePresetWatch(name: "b", isEnabled: false), - OverridePresetWatch(name: "c", isEnabled: false) - ]) - #expect(presets.tempTargetPresets.map(\.name) == ["Walk 9", "Walk 10"]) - } - - @Test("Presets stay readable with commands disabled") func testPresetsIgnoreMasterSwitch() async throws { - settings.settings.isGarminCommandControlEnabled = false - actions.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("s"))] - - #expect(try await processor.presets().overridePresets.count == 1) - } - - @Test("Presets carry the command switches and limits as they are at call time") func testPresetsCapabilities( - ) async throws { - settings.settings.isGarminCommandControlEnabled = true - settings.settings.isGarminBolusCommandEnabled = true - settings.settings.maxCarbs = 120 - settings.pumpSettings = PumpSettings(insulinActionCurve: 10, maxBolus: 5, maxBasal: 2) - settings.preferences.bolusIncrement = 0.1 - - #expect(try await processor.presets().capabilities == WatchCommandCapabilities( - isCommandControlEnabled: true, - isBolusCommandEnabled: true, - maxBolus: 5, - maxCarbs: 120, - bolusIncrement: 0.1 - )) - - settings.settings.isGarminCommandControlEnabled = false - #expect(try await processor.presets().capabilities.isCommandControlEnabled == false) - } - - // MARK: - Revocation - - @Test( - "Disabling commands while meal carbs save stops the bolus and reports a partial failure" - ) func testMasterRevokedDuringMealCarbs() async { - actions.carbsGate = TestGate() - let pending = Task { await processor.process(request(.mealBolus(carbs: 40, bolus: 3))) } - await actions.carbsGate.waitForArrivals() - - settings.settings.isGarminCommandControlEnabled = false - actions.carbsGate.open() - let result = await pending.value - - #expect(result.ackCode == .partialFailure) - #expect(actions.storedCarbs.map(\.grams) == [40]) - #expect(validator.validatedAmounts.isEmpty) - #expect(actions.enactedBoluses.isEmpty) - } - - @Test( - "Disabling bolus while validation is pending stops the bolus", - arguments: [false, true] - ) func testBolusRevokedDuringValidation(isMeal: Bool) async { - validator.gate = TestGate() - let command: WatchCommand = isMeal ? .mealBolus(carbs: 40, bolus: 3) : .bolus(3) - let pending = Task { await processor.process(request(command)) } - await validator.gate.waitForArrivals() - - settings.settings.isGarminBolusCommandEnabled = false - validator.gate.open() - let result = await pending.value - - #expect(result.acknowledged == false) - #expect(result.ackCode == (isMeal ? .partialFailure : .genericFailure)) - #expect(actions.enactedBoluses.isEmpty, "No insulin after the bolus switch went off") - } - - @Test( - "Switching either setting off and on again while pending still revokes", - arguments: [false, true] - ) func testRevokedAndReenabled(bolusSwitch: Bool) async { - validator.gate = TestGate() - let pending = Task { await processor.process(request(.bolus(2))) } - await validator.gate.waitForArrivals() - - // the setter revokes; nothing else is called - if bolusSwitch { - settings.settings.isGarminBolusCommandEnabled = false - settings.settings.isGarminBolusCommandEnabled = true - } else { - settings.settings.isGarminCommandControlEnabled = false - settings.settings.isGarminCommandControlEnabled = true - } - validator.gate.open() - let result = await pending.value - - #expect(result.acknowledged == false) - #expect(actions.enactedBoluses.isEmpty) - - #expect(await processor.process(request(.carbs(10))).acknowledged, "New commands run under the new settings") - } - - @Test("Turning a setting on revokes nothing") func testEnablingKeepsGrant() { - settings.settings.isGarminCommandControlEnabled = false - let epoch = settings.authorization.current - - settings.settings.isGarminCommandControlEnabled = true - settings.settings.maxCarbs = 80 - - #expect(settings.authorization.current == epoch) - } - - @Test("Disabling commands while presets load stops the activation") func testRevokedDuringPresetLoad() async { - actions.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] - actions.presetsGate = TestGate() - let pending = Task { await processor.process(request(.activateOverride(name: "Sport"))) } - await actions.presetsGate.waitForArrivals() - - settings.settings.isGarminCommandControlEnabled = false - settings.settings.isGarminCommandControlEnabled = true - actions.presetsGate.open() - - #expect(await pending.value.acknowledged == false) - #expect(adjustments.calls.isEmpty) - } - - // MARK: - Final Boundary - - @Test( - "A revocation while an adjustment waits for the serializer stops it before the write", - arguments: [ - WatchCommand.activateOverride(name: "Sport"), .cancelOverride, - .activateTempTarget(name: "Walk"), .cancelTempTarget - ] - ) func testAdjustmentRevokedWhileQueued(command: WatchCommand) async { - actions.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] - actions.tempTargets = [WatchPresetEntry(name: "Walk", isActive: false, ref: .presetID("walk"))] - adjustments.gate = TestGate() - let pending = Task { await processor.process(request(command)) } - await adjustments.gate.waitForArrivals() - - settings.settings.isGarminCommandControlEnabled = false - settings.settings.isGarminCommandControlEnabled = true - adjustments.gate.open() - let result = await pending.value - - #expect(result.acknowledged == false) - #expect(result.ackCode == .genericFailure) - #expect(adjustments.calls.isEmpty) - } - - @Test( - "A revocation while carbs wait for their transaction stores nothing", - arguments: [false, true] - ) func testCarbsRevokedBeforeTransaction(isMeal: Bool) async { - actions.carbsTransactionGate = TestGate() - let command: WatchCommand = isMeal ? .mealBolus(carbs: 40, bolus: 3) : .carbs(40) - let pending = Task { await processor.process(request(command)) } - await actions.carbsTransactionGate.waitForArrivals() - - settings.settings.isGarminCommandControlEnabled = false - settings.settings.isGarminCommandControlEnabled = true - actions.carbsTransactionGate.open() - let result = await pending.value - - #expect(result.ackCode == .genericFailure, "Nothing was logged, so this is no partial failure") - #expect(actions.events.isEmpty) - #expect(validator.validatedAmounts.isEmpty) - } - - @Test( - "A revocation after validation but before issuance sends nothing to the pump", - arguments: [false, true] - ) func testRevokedBeforeIssuance(isMeal: Bool) async { - actions.issueGate = TestGate() - let command: WatchCommand = isMeal ? .mealBolus(carbs: 40, bolus: 3) : .bolus(3) - let pending = Task { await processor.process(request(command)) } - await actions.issueGate.waitForArrivals() - #expect(validator.validatedAmounts == [3], "Validation already passed") - - settings.settings.isGarminBolusCommandEnabled = false - settings.settings.isGarminBolusCommandEnabled = true - actions.issueGate.open() - let result = await pending.value - - #expect(result.acknowledged == false) - #expect(result.ackCode == (isMeal ? .partialFailure : .genericFailure)) - #expect(actions.storedCarbs.map(\.grams) == (isMeal ? [40] : [])) - #expect(actions.enactedBoluses.isEmpty) - } - - @Test("A bolus stopped before issuance does not hold the lane") func testFinalCheckWithdrawsLaneHold() async { - actions.issueGate = TestGate() - let pending = Task { await processor.process(request(.bolus(3))) } - await actions.issueGate.waitForArrivals() - settings.settings.isGarminBolusCommandEnabled = false - settings.settings.isGarminBolusCommandEnabled = true - actions.issueGate.open() - #expect(await pending.value.acknowledged == false) - - #expect(await processor.process(request(.bolus(1))).acknowledged, "Nothing reached the pump, so no hold") - #expect(actions.enactedBoluses == [1]) - } - - // MARK: - Freshness After Waiting - - @Test( - "A request that expires while it waits in the lane is rejected before validation", - arguments: [false, true] - ) func testExpiredInLane(isMeal: Bool) async { - validator.gate = TestGate() - // the first bolus is refused, so the lane hold cannot be what stops the second - validator.queuedResults = [.rejected(.exceedsMaxIOB(currentIOB: 4, maxIOB: 5)), .allowed] - let lane = WatchCommandInsulinLane() - let processor = Self.makeProcessor(container: container, actions: actions, insulinLane: lane, clock: clock, logs: logs) - - let first = Task { await processor.process(request(.bolus(2))) } - await validator.gate.waitForArrivals() - let command: WatchCommand = isMeal ? .mealBolus(carbs: 30, bolus: 1) : .bolus(1) - let second = Task { await processor.process(request(command, age: BaseWatchCommandProcessor.maximumCommandAge - 10)) } - #expect(await eventually { await lane.waitingCount == 1 }) - - clock.advance(by: 20) - validator.gate.open() - #expect(await first.value.acknowledged == false) - let result = await second.value - - #expect(result.acknowledged == false) - #expect(result.ackCode == (isMeal ? .partialFailure : .genericFailure)) - #expect(validator.validatedAmounts == [2], "The expired request never validates") - #expect(actions.enactedBoluses.isEmpty) - #expect(logs.lines.contains { $0.contains("outside the freshness window") }) - } - - @Test( - "A request that expires during validation is not enacted", - arguments: [false, true] - ) func testExpiredDuringValidation(isMeal: Bool) async { - validator.gate = TestGate() - let command: WatchCommand = isMeal ? .mealBolus(carbs: 30, bolus: 1) : .bolus(1) - let pending = Task { await processor.process(request(command, age: BaseWatchCommandProcessor.maximumCommandAge - 5)) } - await validator.gate.waitForArrivals() - - clock.advance(by: 10) - validator.gate.open() - let result = await pending.value - - #expect(result.ackCode == (isMeal ? .partialFailure : .genericFailure)) - #expect(actions.storedCarbs.count == (isMeal ? 1 : 0)) - #expect(actions.enactedBoluses.isEmpty) - } - - @Test( - "A request that expires right before issuance is not enacted", - arguments: [false, true] - ) func testExpiredBeforeIssuance(isMeal: Bool) async { - actions.issueGate = TestGate() - let command: WatchCommand = isMeal ? .mealBolus(carbs: 30, bolus: 1) : .bolus(1) - let pending = Task { await processor.process(request(command, age: BaseWatchCommandProcessor.maximumCommandAge - 1)) } - await actions.issueGate.waitForArrivals() - - clock.advance(by: 2) - actions.issueGate.open() - let result = await pending.value - - #expect(result.ackCode == (isMeal ? .partialFailure : .genericFailure)) - #expect(actions.enactedBoluses.isEmpty) - } - - @Test("An expired request replays its cached result without re-running") func testExpiredReplay() async { - validator.gate = TestGate() - let request = request(.bolus(1), age: BaseWatchCommandProcessor.maximumCommandAge - 5) - let pending = Task { await processor.process(request) } - await validator.gate.waitForArrivals() - clock.advance(by: 10) - validator.gate.open() - let first = await pending.value - - let replay = await processor.process(request) - - #expect(replay == first) - #expect(validator.validatedAmounts.count == 1) - } - - // MARK: - Insulin Lane - - @Test("A second insulin request waits for the first and never validates alongside it") func testInsulinSerialized() async { - let probe = InsulinConcurrencyProbe() - validator.probe = probe - actions.probe = probe - validator.gate = TestGate() - let lane = WatchCommandInsulinLane() - let processor = Self.makeProcessor(container: container, actions: actions, insulinLane: lane, clock: clock, logs: logs) - - let first = Task { await processor.process(request(.bolus(2))) } - await validator.gate.waitForArrivals() - let second = Task { await processor.process(request(.mealBolus(carbs: 30, bolus: 2))) } - #expect(await eventually { await lane.waitingCount == 1 }, "Second request is parked at the lane") - #expect(validator.gate.arrivalCount == 1) - - validator.gate.open() - let firstResult = await first.value - let secondResult = await second.value - - #expect(firstResult.acknowledged) - #expect(secondResult.ackCode == .partialFailure, "Its carbs are logged, its bolus is held back") - #expect(actions.enactedBoluses == [2]) - #expect(probe.maximumInFlight == 1) - #expect(probe.recordedEvents == ["validate", "enact"]) - } - - @Test("A request that waited validates afresh after the first finished") func testInsulinRevalidatedAfterWait() async { - let probe = InsulinConcurrencyProbe() - validator.probe = probe - actions.probe = probe - validator.gate = TestGate() - validator.queuedResults = [.rejected(.exceedsMaxIOB(currentIOB: 4, maxIOB: 5)), .allowed] - let lane = WatchCommandInsulinLane() - let processor = Self.makeProcessor(container: container, actions: actions, insulinLane: lane, clock: clock, logs: logs) - - let first = Task { await processor.process(request(.bolus(2))) } - await validator.gate.waitForArrivals() - let second = Task { await processor.process(request(.bolus(1))) } - #expect(await eventually { await lane.waitingCount == 1 }) - - validator.gate.open() - #expect(await first.value.acknowledged == false) - #expect(await second.value.acknowledged) - #expect(validator.validatedAmounts == [2, 1]) - #expect(actions.enactedBoluses == [1]) - #expect(probe.maximumInFlight == 1) - #expect(probe.recordedEvents == ["validate", "validate", "enact"]) - } - - /// Phase 1 keeps the lane hold even when the pump refused the first bolus: a refusal can still - /// mean partial delivery, and pump history may not show it yet. - @Test( - "A second insulin request waits while the pump works on the first, then is held back", - arguments: [true, false] - ) func testInsulinWaitsForPump(pumpSucceeds: Bool) async { - let probe = InsulinConcurrencyProbe() - validator.probe = probe - actions.probe = probe - actions.bolusGate = TestGate() - actions.bolusSucceeds = pumpSucceeds - let lane = WatchCommandInsulinLane() - let processor = Self.makeProcessor(container: container, actions: actions, insulinLane: lane, clock: clock, logs: logs) - - let first = Task { await processor.process(request(.bolus(2))) } - await actions.bolusGate.waitForArrivals() - let second = Task { await processor.process(request(.bolus(1))) } - #expect(await eventually { await lane.waitingCount == 1 }, "Parked while the pump request is open") - #expect(validator.validatedAmounts == [2]) - - actions.bolusGate.open() - #expect(await first.value.acknowledged == pumpSucceeds) - let secondResult = await second.value - - #expect(secondResult.acknowledged == false) - #expect(logs.lines.contains { $0.contains("previous watch bolus may not be in pump history yet") }) - #expect(validator.validatedAmounts == [2], "Held back before validation") - #expect(actions.enactedBoluses == [2]) - #expect(probe.maximumInFlight == 1) - } - - // MARK: - Safe Logging - - @Test("Error details never reach the log or the ack") func testSensitiveErrorsSanitized() async { - actions.carbsError = SensitiveTestError() - adjustments.error = SensitiveTestError() - actions.overrides = [WatchPresetEntry(name: "Sport", isActive: false, ref: .presetID("sport"))] - - let results = [ - await processor.process(request(.carbs(10))), - await processor.process(request(.mealBolus(carbs: 10, bolus: 1))), - await processor.process(request(.activateOverride(name: "Sport"))), - await processor.process(request(.cancelOverride)), - await processor.process(request(.cancelTempTarget)) - ] - - #expect(results.allSatisfy { !$0.acknowledged }) - #expect(results.allSatisfy { !$0.message.contains(SensitiveTestError.marker) }) - #expect(!logs.lines.isEmpty) - #expect(logs.lines.allSatisfy { !$0.contains(SensitiveTestError.marker) && !$0.contains("42g") }) - #expect(logs.lines.contains { $0.contains("(unexpected)") }) - } - - @Test("Error categories are stable and content-free") func testErrorCategories() { - #expect(WatchCommandErrorCategory.name(for: AdjustmentError.persistenceFailed("row 42")) == "adjustmentPersistence") - #expect(WatchCommandErrorCategory.name(for: CoreDataError.fetchError(function: "f", file: "g")) == "coreData") - let saveError = NSError(domain: NSCocoaErrorDomain, code: 134_030, userInfo: ["detail": SensitiveTestError.marker]) - #expect(WatchCommandErrorCategory.name(for: saveError) == "cocoa(134030)") - #expect(WatchCommandErrorCategory.name(for: SensitiveTestError()) == "unexpected") - } -} diff --git a/TrioTests/WatchCommandTests/WatchCommandRequestCacheTests.swift b/TrioTests/WatchCommandTests/WatchCommandRequestCacheTests.swift deleted file mode 100644 index 29fb7becb6..0000000000 --- a/TrioTests/WatchCommandTests/WatchCommandRequestCacheTests.swift +++ /dev/null @@ -1,148 +0,0 @@ -import Foundation -import Testing - -@testable import Trio - -@Suite("Watch Command Request Cache Tests") struct WatchCommandRequestCacheTests { - let appUUID = UUID() - let start = Date(timeIntervalSince1970: 1_800_000_000) - let done = WatchCommandResult.success(.overrideStopped, "Override stopped.") - - private func key(_ requestID: UUID = UUID(), app: UUID? = nil) -> WatchCommandRequestCache.Key { - WatchCommandRequestCache.Key(appUUID: app ?? appUUID, requestID: requestID) - } - - private func ticket(_ admission: WatchCommandRequestCache.Admission) -> WatchCommandRequestCache.Ticket? { - guard case let .accepted(ticket) = admission else { return nil } - return ticket - } - - private func admitted( - _ cache: WatchCommandRequestCache, - _ key: WatchCommandRequestCache.Key, - _ command: WatchCommand, - at now: Date - ) async throws -> WatchCommandRequestCache.Ticket { - let admission = await cache.admit(key, command: command, now: now) - return try #require(ticket(admission)) - } - - @Test("A request stays in flight until completed, then replays its result") func testInFlightThenCompleted() async throws { - let cache = WatchCommandRequestCache() - let key = key() - let result = WatchCommandResult.success(.carbsLogged, "Carbs logged.") - - let reservation = try await admitted(cache, key, .carbs(20), at: start) - #expect(await cache.admit(key, command: .carbs(20), now: start) == .inProgress) - - await cache.complete(reservation, result: result) - #expect(await cache.admit(key, command: .carbs(20), now: start) == .completed(result)) - } - - @Test("Reusing a request ID for another command is a conflict") func testConflict() async throws { - let cache = WatchCommandRequestCache() - let key = key() - - _ = try await admitted(cache, key, .carbs(20), at: start) - #expect(await cache.admit(key, command: .carbs(25), now: start) == .conflict) - #expect(await cache.admit(key, command: .cancelOverride, now: start) == .conflict) - } - - @Test("The same request ID from another app is a separate request") func testScopedByApp() async { - let cache = WatchCommandRequestCache() - let requestID = UUID() - - #expect(ticket(await cache.admit(key(requestID), command: .cancelOverride, now: start)) != nil) - #expect(ticket(await cache.admit(key(requestID, app: UUID()), command: .cancelOverride, now: start)) != nil) - } - - @Test("Completed entries expire after their lifetime") func testExpiry() async throws { - let cache = WatchCommandRequestCache(capacity: 256, lifetime: 15 * 60) - let key = key() - - let reservation = try await admitted(cache, key, .cancelOverride, at: start) - await cache.complete(reservation, result: done) - #expect(await cache.admit(key, command: .cancelOverride, now: start.addingTimeInterval(15 * 60 - 1)) == .completed(done)) - #expect(ticket(await cache.admit(key, command: .cancelOverride, now: start.addingTimeInterval(15 * 60))) != nil) - } - - @Test("An executing entry outlives its lifetime") func testInFlightNeverExpires() async throws { - let cache = WatchCommandRequestCache(capacity: 256, lifetime: 15 * 60) - let key = key() - - _ = try await admitted(cache, key, .cancelOverride, at: start) - #expect(await cache.admit(key, command: .cancelOverride, now: start.addingTimeInterval(60 * 60)) == .inProgress) - } - - @Test("A full cache evicts the least recently used completed entry") func testLeastRecentlyUsedEviction() async throws { - let cache = WatchCommandRequestCache(capacity: 2, lifetime: 15 * 60) - let first = key() - let second = key() - - try await cache.complete(admitted(cache, first, .cancelOverride, at: start), result: done) - try await cache.complete(admitted(cache, second, .cancelOverride, at: start), result: done) - // touching the first entry makes the second the least recently used - #expect(await cache.admit(first, command: .cancelOverride, now: start) == .completed(done)) - #expect(ticket(await cache.admit(key(), command: .cancelOverride, now: start)) != nil) - - #expect(await cache.count == 2) - #expect(await cache.admit(first, command: .cancelOverride, now: start) == .completed(done)) - #expect(ticket(await cache.admit(second, command: .cancelOverride, now: start)) != nil, "Second was evicted") - } - - @Test("Executing entries are never evicted; a full cache rejects instead") func testCapacityKeepsInFlight() async throws { - let cache = WatchCommandRequestCache(capacity: 2, lifetime: 15 * 60) - let first = key() - let second = key() - - let firstTicket = try await admitted(cache, first, .bolus(1), at: start) - _ = try await admitted(cache, second, .bolus(2), at: start) - - #expect(await cache.admit(key(), command: .bolus(3), now: start) == .full) - // a replay of a running request is still recognized, never re-admitted - #expect(await cache.admit(first, command: .bolus(1), now: start) == .inProgress) - #expect(await cache.admit(first, command: .bolus(5), now: start) == .conflict) - - await cache.complete(firstTicket, result: done) - #expect(ticket(await cache.admit(key(), command: .bolus(3), now: start)) != nil, "A completed slot frees up") - #expect(await cache.admit(second, command: .bolus(2), now: start) == .inProgress) - } - - @Test("A stale execution cannot complete a newer admission of the same key") func testStaleCompletion() async throws { - let cache = WatchCommandRequestCache(capacity: 256, lifetime: 15 * 60) - let key = key() - let stale = WatchCommandResult.failure("stale") - let fresh = WatchCommandResult.success(.carbsLogged, "fresh") - - let oldTicket = try await admitted(cache, key, .carbs(10), at: start) - await cache.complete(oldTicket, result: stale) - let later = start.addingTimeInterval(15 * 60) - let newTicket = try await admitted(cache, key, .carbs(10), at: later) - #expect(newTicket != oldTicket) - - await cache.complete(oldTicket, result: stale) - #expect(await cache.admit(key, command: .carbs(10), now: later) == .inProgress) - - await cache.complete(newTicket, result: fresh) - #expect(await cache.admit(key, command: .carbs(10), now: later) == .completed(fresh)) - await cache.complete(oldTicket, result: stale) - #expect(await cache.admit(key, command: .carbs(10), now: later) == .completed(fresh), "Completed results are final") - } - - @Test("Expired entries are evicted before live ones") func testExpiredEvictedFirst() async throws { - let cache = WatchCommandRequestCache(capacity: 2, lifetime: 15 * 60) - let old = key() - let recent = key() - - try await cache.complete(admitted(cache, old, .cancelOverride, at: start), result: done) - _ = await cache.admit(recent, command: .cancelOverride, now: start.addingTimeInterval(10 * 60)) - #expect(ticket(await cache.admit(key(), command: .cancelOverride, now: start.addingTimeInterval(16 * 60))) != nil) - - #expect(await cache.admit(recent, command: .cancelOverride, now: start.addingTimeInterval(16 * 60)) == .inProgress) - } - - @Test("Default limits are 256 entries for 15 minutes") func testDefaults() { - #expect(WatchCommandRequestCache.defaultCapacity == 256) - #expect(WatchCommandRequestCache.defaultLifetime == 15 * 60) - } -} diff --git a/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift b/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift deleted file mode 100644 index 24e41529b0..0000000000 --- a/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift +++ /dev/null @@ -1,384 +0,0 @@ -import Combine -import Foundation -import LoopKit -import LoopKitUI -@testable import Trio - -/// Revokes from its setter the way `BaseSettingsManager` does. -final class StubSettingsManager: SettingsManager { - let authorization = WatchCommandAuthorization() - var settings = TrioSettings() { - willSet { authorization.settingsWillChange(from: settings, to: newValue) } - } - - var preferences = Preferences() - var pumpSettings = PumpSettings(insulinActionCurve: 10, maxBolus: 10, maxBasal: 2) - - func updateInsulinCurve(_: InsulinType?) {} -} - -/// Parks async calls until the test opens it, and lets the test wait until calls have arrived, -/// so suspended-operation tests do not depend on scheduling luck. -final class TestGate: @unchecked Sendable { - private let lock = NSLock() - private var isOpen: Bool - private var arrivals = 0 - private var parked: [CheckedContinuation] = [] - private var arrivalWaiters: [(count: Int, continuation: CheckedContinuation)] = [] - - init(isOpen: Bool = false) { - self.isOpen = isOpen - } - - var arrivalCount: Int { lock.withLock { arrivals } } - - func pass() async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - lock.lock() - arrivals += 1 - let reached = arrivalWaiters.filter { $0.count <= arrivals } - arrivalWaiters.removeAll { $0.count <= arrivals } - if isOpen { - lock.unlock() - continuation.resume() - } else { - parked.append(continuation) - lock.unlock() - } - reached.forEach { $0.continuation.resume() } - } - } - - /// Counts an arrival from synchronous code without parking it. - func arrive() { - lock.lock() - arrivals += 1 - let reached = arrivalWaiters.filter { $0.count <= arrivals } - arrivalWaiters.removeAll { $0.count <= arrivals } - lock.unlock() - reached.forEach { $0.continuation.resume() } - } - - func waitForArrivals(_ count: Int = 1) async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - lock.lock() - if arrivals >= count { - lock.unlock() - continuation.resume() - } else { - arrivalWaiters.append((count, continuation)) - lock.unlock() - } - } - } - - func open() { - lock.lock() - isOpen = true - let waiting = parked - parked = [] - lock.unlock() - waiting.forEach { $0.resume() } - } -} - -/// A clock the test moves forward by hand, so expiry during a wait needs no real sleep. -final class TestClock: @unchecked Sendable { - private let lock = NSLock() - private var date: Date - - init(_ date: Date) { - self.date = date - } - - var now: Date { lock.withLock { date } } - - func advance(by interval: TimeInterval) { - lock.withLock { date = date.addingTimeInterval(interval) } - } -} - -/// Polls `condition` for up to two seconds; for state that has no callback to await. -func eventually(_ condition: () async -> Bool) async -> Bool { - for _ in 0 ..< 400 { - if await condition() { return true } - try? await Task.sleep(nanoseconds: 5_000_000) - } - return await condition() -} - -/// Counts how many validations and pump requests overlap, across concurrent requests. -final class InsulinConcurrencyProbe: @unchecked Sendable { - private let lock = NSLock() - private var inFlight = 0 - private(set) var events: [String] = [] - private(set) var maximumInFlight = 0 - - func begin(_ event: String) { - lock.withLock { - events.append(event) - inFlight += 1 - maximumInFlight = max(maximumInFlight, inFlight) - } - } - - func end() { - lock.withLock { inFlight -= 1 } - } - - var recordedEvents: [String] { lock.withLock { events } } -} - -final class StubBolusSafetyValidator: BolusSafetyValidator { - private let lock = NSLock() - private var results: [BolusSafetyResult] = [] - private var amounts: [Decimal] = [] - private var starts: [Date?] = [] - - var result: BolusSafetyResult = .allowed - /// Consumed one per call before falling back to `result`. - var queuedResults: [BolusSafetyResult] { - get { lock.withLock { results } } - set { lock.withLock { results = newValue } } - } - - var gate = TestGate(isOpen: true) - var probe: InsulinConcurrencyProbe? - var validatedAmounts: [Decimal] { lock.withLock { amounts } } - var lookbackStarts: [Date?] { lock.withLock { starts } } - - func validate(bolusAmount: Decimal, lookbackStart: Date?) async throws -> BolusSafetyResult { - probe?.begin("validate") - defer { probe?.end() } - lock.withLock { - amounts.append(bolusAmount) - starts.append(lookbackStart) - } - await gate.pass() - return lock.withLock { results.isEmpty ? result : results.removeFirst() } - } - - func fetchTotalRecentBolusAmount(since _: Date) async throws -> Decimal { 0 } -} - -final class StubAdjustmentManager: AdjustmentManager { - enum Call: Equatable { - case activateOverride(AdjustmentRef) - case cancelOverride - case activateTempTarget(AdjustmentRef) - case cancelTempTarget - } - - private(set) var calls: [Call] = [] - private(set) var sources: [AdjustmentSource] = [] - var error: Error? - /// Parks guarded calls before their final check, the way the adjustment serializer would. - var gate = TestGate(isOpen: true) - - private func record(_ call: Call, source: AdjustmentSource) throws -> AdjustmentOutcome { - calls.append(call) - sources.append(source) - if let error { throw error } - return AdjustmentOutcome(started: nil, ended: []) - } - - func activateOverride(_ preset: AdjustmentRef, source: AdjustmentSource, waitForUpload _: Bool) async throws - -> AdjustmentOutcome - { - try record(.activateOverride(preset), source: source) - } - - func cancelOverride(source: AdjustmentSource, waitForUpload _: Bool) async throws -> AdjustmentOutcome { - try record(.cancelOverride, source: source) - } - - func activateTempTarget(_ preset: AdjustmentRef, source: AdjustmentSource, waitForUpload _: Bool) async throws - -> AdjustmentOutcome - { - try record(.activateTempTarget(preset), source: source) - } - - func cancelTempTarget(source: AdjustmentSource, waitForUpload _: Bool) async throws -> AdjustmentOutcome { - try record(.cancelTempTarget, source: source) - } - - private func guarded( - _ call: Call, - source: AdjustmentSource, - authorize: @Sendable() throws -> Void - ) async throws -> AdjustmentOutcome { - await gate.pass() - try authorize() - return try record(call, source: source) - } - - func activateOverride( - _ preset: AdjustmentRef, - source: AdjustmentSource, - authorize: @escaping @Sendable() throws -> Void - ) async throws -> AdjustmentOutcome { - try await guarded(.activateOverride(preset), source: source, authorize: authorize) - } - - func cancelOverride(source: AdjustmentSource, authorize: @escaping @Sendable() throws -> Void) async throws - -> AdjustmentOutcome - { - try await guarded(.cancelOverride, source: source, authorize: authorize) - } - - func activateTempTarget( - _ preset: AdjustmentRef, - source: AdjustmentSource, - authorize: @escaping @Sendable() throws -> Void - ) async throws -> AdjustmentOutcome { - try await guarded(.activateTempTarget(preset), source: source, authorize: authorize) - } - - func cancelTempTarget(source: AdjustmentSource, authorize: @escaping @Sendable() throws -> Void) async throws - -> AdjustmentOutcome - { - try await guarded(.cancelTempTarget, source: source, authorize: authorize) - } -} - -final class StubWatchCommandActions: WatchCommandActions { - private let lock = NSLock() - private var recordedEvents: [String] = [] - private var carbs: [(grams: Int, date: Date)] = [] - private var boluses: [Decimal] = [] - - /// Side effects in call order, to check that meal carbs land before the bolus. - var events: [String] { lock.withLock { recordedEvents } } - var storedCarbs: [(grams: Int, date: Date)] { lock.withLock { carbs } } - var enactedBoluses: [Decimal] { lock.withLock { boluses } } - var carbsError: Error? - var bolusSucceeds = true - var overrides: [WatchPresetEntry] = [] - var tempTargets: [WatchPresetEntry] = [] - - /// Parks the carb save before its final check, the way a busy Core Data context would. - var carbsTransactionGate = TestGate(isOpen: true) - /// Parks the carb save after it was issued, the way a slow Core Data save would. - var carbsGate = TestGate(isOpen: true) - /// Parks the bolus before its final check, between validation and `APSManager` issuance. - var issueGate = TestGate(isOpen: true) - /// Parks the bolus after it was issued, while the pump is still working on it. - var bolusGate = TestGate(isOpen: true) - var presetsGate = TestGate(isOpen: true) - var probe: InsulinConcurrencyProbe? - - /// Side effects are recorded only after `authorize` passed, as in production. - func storeCarbs(_ grams: Int, date: Date, authorize: @escaping () throws -> Void) async throws { - await carbsTransactionGate.pass() - try authorize() - lock.withLock { recordedEvents.append("carbs") } - if let carbsError { throw carbsError } - lock.withLock { carbs.append((grams, date)) } - await carbsGate.pass() - } - - func enactBolus(_ units: Decimal, authorize: @escaping () throws -> Void) async throws -> Bool { - await issueGate.pass() - try authorize() - probe?.begin("enact") - defer { probe?.end() } - lock.withLock { - recordedEvents.append("bolus") - boluses.append(units) - } - await bolusGate.pass() - return bolusSucceeds - } - - func overridePresets() async throws -> [WatchPresetEntry] { - await presetsGate.pass() - return overrides - } - - func tempTargetPresets() async throws -> [WatchPresetEntry] { - await presetsGate.pass() - return tempTargets - } -} - -final class SpyWatchCommandProcessor: WatchCommandProcessor { - var result = WatchCommandResult.success(.carbsLogged, "Carbs logged.") - var presetsResult = WatchCommandPresets(overridePresets: [], tempTargetPresets: []) - var presetsError: Error? - private(set) var processed: [WatchCommandRequest] = [] - private(set) var presetRequests = 0 - - func process(_ request: WatchCommandRequest) async -> WatchCommandResult { - processed.append(request) - return result - } - - func presets() async throws -> WatchCommandPresets { - presetRequests += 1 - if let presetsError { throw presetsError } - return presetsResult - } -} - -/// Error whose every description carries a marker that must never reach a log or an ack. -struct SensitiveTestError: LocalizedError, CustomStringConvertible { - static let marker = "SENSITIVE-7f3a" - - var description: String { "carbs=42g bolus=3.5U \(Self.marker)" } - var errorDescription: String? { description } -} - -/// Collects log lines from the injectable log sinks. -final class LogCapture: @unchecked Sendable { - private let lock = NSLock() - private var storage: [(level: LogLevel, line: String)] = [] - - var lines: [String] { lock.withLock { storage.map(\.line) } } - var entries: [(level: LogLevel, line: String)] { lock.withLock { storage } } - - func record(_ level: LogLevel, _ line: String) { - lock.withLock { storage.append((level, line)) } - } -} - -/// Records bolus requests; everything else is inert. `enactBolus` calls back like the real one. -final class SpyAPSManager: APSManager, @unchecked Sendable { - private let lock = NSLock() - private var amounts: [Double] = [] - - var bolusRequests: [Double] { lock.withLock { amounts } } - var bolusSucceeds = true - - func enactBolus(amount: Double, isSMB _: Bool, callback: ((Bool, String) -> Void)?) async { - lock.withLock { amounts.append(amount) } - callback?(bolusSucceeds, "") - } - - func heartbeat(date _: Date) {} - func markNextLoopUserInitiated() {} - func enactManualTempBasal(rate _: Double, duration _: TimeInterval) async {} - var pumpManager: PumpManagerUI? - var bluetoothManager: BluetoothStateManager? { nil } - let pumpDisplayState = CurrentValueSubject(nil) - let pumpName = CurrentValueSubject("") - let isLooping = CurrentValueSubject(false) - var lastLoopDate: Date { .distantPast } - let lastLoopDateSubject = PassthroughSubject() - let bolusProgress = CurrentValueSubject(nil) - let pumpExpiresAtDate = CurrentValueSubject(nil) - let pumpActivatedAtDate = CurrentValueSubject(nil) - var isManualTempBasal: Bool { false } - var isScheduledBasal: Bool? { nil } - var isSuspended: Bool { false } - func determineBasal() async throws {} - func determineBasalSync() async throws {} - func simulateDetermineBasal( - simulatedCarbsAmount _: Decimal, - simulatedBolusAmount _: Decimal, - simulatedCarbsDate _: Date? - ) async -> Determination? { nil } - func roundBolus(amount: Decimal) -> Decimal { amount } - let lastError = CurrentValueSubject(nil) - func cancelBolus(_: ((Bool, String) -> Void)?) async {} - let iobFileDidUpdate = PassthroughSubject() -} From 8ed6485844f42bb57c6fb6830fd8843aa471caba Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 27 Oct 2026 21:26:56 +0100 Subject: [PATCH 07/34] Migrate Garmin settings decoding to plural datafields Old releases persisted a singular `datafield` key. Decode it as a fallback when the plural `datafields` array is missing or invalid, so existing watch settings keep their selection instead of silently reverting to the default. Update the watch command fixtures to the plural property and cover the migration: legacy singular, plural priority over legacy, explicit empty selection, invalid values falling back to the default, and an invalid plural falling back to a valid legacy singular. --- Testflight/WhatToTest.en-CA.txt | 3 +- Trio/Sources/Models/GarminWatchSettings.swift | 9 +++ .../GarminManagerIntegrationTests.swift | 2 +- .../GarminMessageRouterTests.swift | 55 ++++++++++++++++++- .../WatchCommandIntegrationTests.swift | 2 +- 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/Testflight/WhatToTest.en-CA.txt b/Testflight/WhatToTest.en-CA.txt index bfaa49b0b4..62ec44f059 100644 --- a/Testflight/WhatToTest.en-CA.txt +++ b/Testflight/WhatToTest.en-CA.txt @@ -1 +1,2 @@ -based on Trio dev 1.0.0.5 +based on Trio dev 1.0.2.27 +garmin watch complication diff --git a/Trio/Sources/Models/GarminWatchSettings.swift b/Trio/Sources/Models/GarminWatchSettings.swift index 06c5c3fca8..c004483c08 100644 --- a/Trio/Sources/Models/GarminWatchSettings.swift +++ b/Trio/Sources/Models/GarminWatchSettings.swift @@ -217,15 +217,24 @@ struct GarminWatchSettings: Codable, Hashable { } extension GarminWatchSettings { + /// Pre-multi-datafield releases wrote a singular `datafield` key; it has no backing property, + /// so it decodes through its own container rather than widening the Codable `CodingKeys`. + private enum LegacyCodingKeys: String, CodingKey { + case datafield + } + /// Keys missing from settings written by older versions keep their defaults, so commands stay off. init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + let legacyContainer = try decoder.container(keyedBy: LegacyCodingKeys.self) self.init() if let watchface = try? container.decode(GarminWatchface.self, forKey: .watchface) { self.watchface = watchface } if let datafields = try? container.decode([GarminDatafield].self, forKey: .datafields) { self.datafields = GarminDatafield.sanitizedSelection(datafields) + } else if let legacyDatafield = try? legacyContainer.decode(GarminDatafield.self, forKey: .datafield) { + datafields = GarminDatafield.sanitizedSelection([legacyDatafield]) } if let primaryAttributeChoice = try? container .decode(GarminPrimaryAttributeChoice.self, forKey: .primaryAttributeChoice) diff --git a/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift b/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift index 2462efe5fb..dd05410556 100644 --- a/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift +++ b/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift @@ -115,7 +115,7 @@ final class RefreshTriggerRecorder: @unchecked Sendable { let settings = try #require(resolver.resolve(SettingsManager.self)) settings.settings.garminSettings.watchface = .complication settings.settings.garminSettings.isWatchfaceDataEnabled = true - settings.settings.garminSettings.datafield = .swissalpine + settings.settings.garminSettings.datafields = [.swissalpine] manager = BaseGarminManager( resolver: resolver, diff --git a/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift b/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift index e4062dabd8..12b1f38192 100644 --- a/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift +++ b/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift @@ -430,10 +430,63 @@ import Testing let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(json.utf8)) #expect(settings.watchface == .swissalpine) - #expect(settings.datafield == .swissalpine) + #expect(settings.datafields == [.swissalpine]) #expect(settings.primaryAttributeChoice == .isf) #expect(settings.isWatchfaceDataEnabled == true) #expect(settings.isCommandControlEnabled == false) #expect(settings.isBolusCommandEnabled == false) } + + @Test("Legacy singular none decodes to an empty datafield selection") func testLegacyDatafieldNoneDecodesEmpty() throws { + let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(#"{"datafield": "none"}"#.utf8)) + + #expect(settings.datafields == []) + #expect(settings.isCommandControlEnabled == false) + #expect(settings.isBolusCommandEnabled == false) + } + + @Test("Plural datafields takes priority over a legacy singular datafield") func testPluralDatafieldsTakesPriorityOverLegacy() throws { + let json = #"{"datafields": ["loopgraph"], "datafield": "swissalpine"}"# + let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(json.utf8)) + + #expect(settings.datafields == [.loopgraph]) + } + + @Test( + "An explicit empty plural datafields array is kept, not replaced by the default" + ) func testExplicitEmptyPluralDatafieldsIsKept() throws { + let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(#"{"datafields": []}"#.utf8)) + + #expect(settings.datafields == []) + } + + @Test( + "Missing or invalid datafield keys fall back to the Trio default" + ) func testMissingOrInvalidDatafieldKeysFallBackToDefault() throws { + let missing = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(#"{}"#.utf8)) + let invalidPlural = try JSONDecoder() + .decode(GarminWatchSettings.self, from: Data(#"{"datafields": "not-an-array"}"#.utf8)) + let invalidSingular = try JSONDecoder() + .decode(GarminWatchSettings.self, from: Data(#"{"datafield": "not-a-datafield"}"#.utf8)) + + #expect(missing.datafields == [.trio]) + #expect(invalidPlural.datafields == [.trio]) + #expect(invalidSingular.datafields == [.trio]) + } + + @Test( + "An invalid plural datafields falls back to a valid legacy singular" + ) func testInvalidPluralDatafieldsFallsBackToLegacySingular() throws { + let json = #"{"datafields": "not-an-array", "datafield": "swissalpine"}"# + let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(json.utf8)) + + #expect(settings.datafields == [.swissalpine]) + } + + @Test("Decoded plural datafields are normalized through sanitizedSelection") func testDecodedPluralDatafieldsAreNormalized() throws { + let json = #"{"datafields": ["swissalpine", "none", "swissalpine", "trio", "loopgraph"]}"# + let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(json.utf8)) + + #expect(settings.datafields == [.swissalpine, .trio, .loopgraph]) + } } diff --git a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift index 5a66605f8a..90430554c9 100644 --- a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift +++ b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift @@ -218,7 +218,7 @@ private func makeResolver( let settings = try #require(resolver.resolve(SettingsManager.self)) settings.settings.garminSettings.watchface = .complication settings.settings.garminSettings.isWatchfaceDataEnabled = true - settings.settings.garminSettings.datafield = .swissalpine + settings.settings.garminSettings.datafields = [.swissalpine] settings.settings.garminSettings.isCommandControlEnabled = true // settings persist on the simulator between runs, so start from a known state settings.settings.garminSettings.isBolusCommandEnabled = false From b067372adcfbd8c8514b9f6cb7453c3d1cdc27a1 Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 27 Oct 2026 22:57:17 +0100 Subject: [PATCH 08/34] Restrict Garmin watch config to a single datafield Replace the plural datafield selection with one configured datafield across GarminWatchSettings, TrioSettings, the watch config state model and view, and GarminManager so that registration creates at most one datafield IQApp. Decoding keeps the plural key working for migration: the first recognized non-none entry of the stored array wins, an explicitly empty array maps to none, and otherwise the legacy singular value, the default, or the type default applies. Malformed or unknown plural values fall back safely. Tests are updated for the singular model and add coverage for the migration precedence, including the empty-array and unknown-value cases. --- Trio/Sources/Models/GarminWatchSettings.swift | 64 ++++++----------- Trio/Sources/Models/TrioSettings.swift | 25 ++++--- .../View/WatchConfigGarminAppConfigView.swift | 37 ++++------ .../WatchConfig/WatchConfigStateModel.swift | 11 --- .../Services/WatchManager/GarminManager.swift | 34 ++++----- .../GarminManagerIntegrationTests.swift | 2 +- .../GarminMessageRouterTests.swift | 70 ++++++++++++------- .../WatchCommandIntegrationTests.swift | 4 +- 8 files changed, 104 insertions(+), 143 deletions(-) diff --git a/Trio/Sources/Models/GarminWatchSettings.swift b/Trio/Sources/Models/GarminWatchSettings.swift index c004483c08..607cf6530a 100644 --- a/Trio/Sources/Models/GarminWatchSettings.swift +++ b/Trio/Sources/Models/GarminWatchSettings.swift @@ -121,7 +121,7 @@ enum GarminCommandApp: String { // MARK: - Garmin Datafield Setting -/// Defines the available Garmin datafields with their associated UUIDs. +/// Defines the available Garmin datafield apps with their associated UUIDs. enum GarminDatafield: String, JSON, CaseIterable, Identifiable, Codable, Hashable { var id: String { rawValue } @@ -161,26 +161,15 @@ enum GarminDatafield: String, JSON, CaseIterable, Identifiable, Codable, Hashabl } } - /// The datafields the user can pick from. `.none` is not offered as a choice any more: - /// "no datafield" is expressed by an empty selection. - static var selectableCases: [GarminDatafield] { - allCases.filter { $0 != .none } - } - - /// Maximum number of datafields that can receive data at the same time. - /// Every selected datafield is registered as its own Connect IQ app and gets the - /// same broadcast payload, so this caps how many apps a single update fans out to. - static let maxSelectionCount = 4 - - /// Normalizes a selection read from settings: drops `.none`, removes duplicates - /// and enforces `maxSelectionCount`, preserving the original order. - static func sanitizedSelection(_ datafields: [GarminDatafield]) -> [GarminDatafield] { - var selection = [GarminDatafield]() - for datafield in datafields where datafield != .none && !selection.contains(datafield) { - selection.append(datafield) - if selection.count == maxSelectionCount { break } - } - return selection + /// Resolves a datafield list persisted by multi-datafield builds to a single selection. + /// Unknown raw values are skipped; returns nil when the list holds nothing recognizable, + /// so the caller can fall back to the legacy singular key or the default. + static func migratedSelection(from rawValues: [String]) -> GarminDatafield? { + // An explicit empty list was the "no datafield" choice + if rawValues.isEmpty { return GarminDatafield.none } + let known = rawValues.compactMap(GarminDatafield.init(rawValue:)) + guard !known.isEmpty else { return nil } + return known.first { $0 != .none } ?? GarminDatafield.none } } @@ -190,9 +179,8 @@ enum GarminDatafield: String, JSON, CaseIterable, Identifiable, Codable, Hashabl /// Both watchfaces use the same settings: primaryAttributeChoice and secondaryAttributeChoice. struct GarminWatchSettings: Codable, Hashable { var watchface: GarminWatchface = .trio - /// Datafields that receive data, in selection order. Empty means no datafield is used. - /// Never holds more than `GarminDatafield.maxSelectionCount` entries. - var datafields: [GarminDatafield] = [.trio] + /// Single datafield that receives data; `.none` means no datafield is used. + var datafield: GarminDatafield = .trio var primaryAttributeChoice: GarminPrimaryAttributeChoice = .cob var secondaryAttributeChoice: GarminSecondaryAttributeChoice = .tbr var isWatchfaceDataEnabled: Bool = false @@ -200,27 +188,13 @@ struct GarminWatchSettings: Codable, Hashable { var isCommandControlEnabled: Bool = false /// Allows bolus and meal+bolus commands; only effective while `isCommandControlEnabled` is on. var isBolusCommandEnabled: Bool = false - - /// Adds or removes a datafield from the selection, ignoring additions beyond the maximum. - mutating func toggleDatafield(_ datafield: GarminDatafield) { - if let index = datafields.firstIndex(of: datafield) { - datafields.remove(at: index) - } else if datafields.count < GarminDatafield.maxSelectionCount { - datafields.append(datafield) - } - } - - /// Whether the given datafield can still be added to the selection. - func canSelectDatafield(_ datafield: GarminDatafield) -> Bool { - datafields.contains(datafield) || datafields.count < GarminDatafield.maxSelectionCount - } } extension GarminWatchSettings { - /// Pre-multi-datafield releases wrote a singular `datafield` key; it has no backing property, + /// Multi-datafield builds persisted a plural `datafields` array; it has no backing property, /// so it decodes through its own container rather than widening the Codable `CodingKeys`. private enum LegacyCodingKeys: String, CodingKey { - case datafield + case datafields } /// Keys missing from settings written by older versions keep their defaults, so commands stay off. @@ -231,10 +205,12 @@ extension GarminWatchSettings { if let watchface = try? container.decode(GarminWatchface.self, forKey: .watchface) { self.watchface = watchface } - if let datafields = try? container.decode([GarminDatafield].self, forKey: .datafields) { - self.datafields = GarminDatafield.sanitizedSelection(datafields) - } else if let legacyDatafield = try? legacyContainer.decode(GarminDatafield.self, forKey: .datafield) { - datafields = GarminDatafield.sanitizedSelection([legacyDatafield]) + if let rawDatafields = try? legacyContainer.decode([String].self, forKey: .datafields), + let migrated = GarminDatafield.migratedSelection(from: rawDatafields) + { + datafield = migrated + } else if let datafield = try? container.decode(GarminDatafield.self, forKey: .datafield) { + self.datafield = datafield } if let primaryAttributeChoice = try? container .decode(GarminPrimaryAttributeChoice.self, forKey: .primaryAttributeChoice) diff --git a/Trio/Sources/Models/TrioSettings.swift b/Trio/Sources/Models/TrioSettings.swift index 5a3ddc88e7..8725c54d31 100644 --- a/Trio/Sources/Models/TrioSettings.swift +++ b/Trio/Sources/Models/TrioSettings.swift @@ -102,9 +102,8 @@ struct TrioSettings: JSON, Equatable, Encodable { /// Selected Garmin watchface (Trio or SwissAlpine) var garminWatchface: GarminWatchface = .trio - /// Selected Garmin datafields, in selection order. Empty means no datafield is used. - /// Replaces the single `garminDatafield` setting; see the decoder for the migration. - var garminDatafields: [GarminDatafield] = [] + /// Selected Garmin datafield; `.none` means no datafield is used + var garminDatafield: GarminDatafield = .none /// Primary attribute choice for Garmin display (COB, ISF, or Sensitivity Ratio) var primaryAttributeChoice: GarminPrimaryAttributeChoice = .cob @@ -126,7 +125,7 @@ struct TrioSettings: JSON, Equatable, Encodable { get { GarminWatchSettings( watchface: garminWatchface, - datafields: garminDatafields, + datafield: garminDatafield, primaryAttributeChoice: primaryAttributeChoice, secondaryAttributeChoice: secondaryAttributeChoice, isWatchfaceDataEnabled: isWatchfaceDataEnabled, @@ -136,7 +135,7 @@ struct TrioSettings: JSON, Equatable, Encodable { } set { garminWatchface = newValue.watchface - garminDatafields = newValue.datafields + garminDatafield = newValue.datafield primaryAttributeChoice = newValue.primaryAttributeChoice secondaryAttributeChoice = newValue.secondaryAttributeChoice isWatchfaceDataEnabled = newValue.isWatchfaceDataEnabled @@ -149,8 +148,8 @@ struct TrioSettings: JSON, Equatable, Encodable { /// Keys that are no longer stored properties of `TrioSettings`, but still have to be read /// from settings files written by older versions. private enum LegacyGarminCodingKeys: String, CodingKey { - /// Superseded by `garminDatafields`, which holds multiple datafields - case garminDatafield + /// Written by multi-datafield builds; migrated to the single `garminDatafield` + case garminDatafields } /// Used to decode settings keys that no longer have a matching stored property (e.g. renamed keys kept for migration). @@ -444,13 +443,13 @@ extension TrioSettings: Decodable { settings.garminWatchface = garminWatchface } - if let garminDatafields = try? container.decode([GarminDatafield].self, forKey: .garminDatafields) { - settings.garminDatafields = GarminDatafield.sanitizedSelection(garminDatafields) - } else if let legacyContainer = try? decoder.container(keyedBy: LegacyGarminCodingKeys.self), - let garminDatafield = try? legacyContainer.decode(GarminDatafield.self, forKey: .garminDatafield) + if let legacyContainer = try? decoder.container(keyedBy: LegacyGarminCodingKeys.self), + let rawDatafields = try? legacyContainer.decode([String].self, forKey: .garminDatafields), + let migrated = GarminDatafield.migratedSelection(from: rawDatafields) { - // Settings written before multiple datafields were supported stored a single choice - settings.garminDatafields = garminDatafield == .none ? [] : [garminDatafield] + settings.garminDatafield = migrated + } else if let garminDatafield = try? container.decode(GarminDatafield.self, forKey: .garminDatafield) { + settings.garminDatafield = garminDatafield } if let primaryAttributeChoice = try? container diff --git a/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift b/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift index 1f77aa7d7b..e8c747f84f 100644 --- a/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift +++ b/Trio/Sources/Modules/WatchConfig/View/WatchConfigGarminAppConfigView.swift @@ -164,33 +164,20 @@ struct WatchConfigGarminAppConfigView: View { Section( header: Text("Datafield Settings"), content: { - ForEach(GarminDatafield.selectableCases) { selection in - Button( - action: { - state.toggleDatafield(selection) - }, - label: { - HStack { - Text(selection.displayName) - .foregroundStyle( - state.canSelectDatafield(selection) ? Color.primary : Color.secondary - ) - Spacer() - if state.garminSettings.datafields.contains(selection) { - Image(systemName: "checkmark") - .foregroundStyle(Color.accentColor) - } - } + VStack { + Picker( + selection: $state.garminSettings.datafield, + label: Text("Datafield Selection").multilineTextAlignment(.leading) + ) { + ForEach(GarminDatafield.allCases) { selection in + Text(selection.displayName).tag(selection) } - ) - .buttonStyle(BorderlessButtonStyle()) - .disabled(!state.canSelectDatafield(selection)) - } + } + .padding(.top) - VStack { HStack(alignment: .center) { Text( - "Choose which datafields to support. Up to four can receive data at the same time, or select none at all. Can be used independently of watchface selection." + "Choose which datafield to support. Can be used independently of watchface selection." ) .font(.footnote) .foregroundColor(.secondary) @@ -378,11 +365,11 @@ struct WatchConfigGarminAppConfigView: View { shouldDisplayHint: $shouldDisplayHint4, hintLabel: "Choose Garmin Datafield", hintText: Text( - "Choose which datafields on your Garmin device you wish to provide data for. Tap an entry to select or deselect it; up to four datafields can receive data at the same time. The datafields can be used independently from the watchface selection.\n\n" + + "Choose which datafield on your Garmin device you wish to provide data for. The datafield can be used independently from the watchface selection.\n\n" + "• Trio – The original Trio datafield, developed by Pierre.\n" + "• Swissalpine – Originally developed for AAPS, adapted to work with Trio.\n" + "• Loop Graph – Shows the glucose history and the predicted glucose envelope.\n\n" + - "Deselect all datafields if you don't want to use one, or want to preserve battery while not exercising." + "Select 'None' if you don't want to use a datafield, or want to preserve battery while not exercising." ), sheetTitle: String(localized: "Help", comment: "Help sheet title") ) diff --git a/Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift b/Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift index 8effaf51d5..8e8062afcc 100644 --- a/Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift +++ b/Trio/Sources/Modules/WatchConfig/WatchConfigStateModel.swift @@ -41,17 +41,6 @@ extension WatchConfig { garmin.updateDeviceList(devices) } - /// Adds or removes a datafield from the selection. - /// Additions beyond `GarminDatafield.maxSelectionCount` are ignored. - func toggleDatafield(_ datafield: GarminDatafield) { - garminSettings.toggleDatafield(datafield) - } - - /// Whether the given datafield can still be added to the selection - func canSelectDatafield(_ datafield: GarminDatafield) -> Bool { - garminSettings.canSelectDatafield(datafield) - } - /// Handles watchface selection changes by automatically disabling data transmission /// to allow the user to switch watchfaces on their Garmin device without data conflicts func handleWatchfaceChange() { diff --git a/Trio/Sources/Services/WatchManager/GarminManager.swift b/Trio/Sources/Services/WatchManager/GarminManager.swift index bd0544ea2b..8047279485 100644 --- a/Trio/Sources/Services/WatchManager/GarminManager.swift +++ b/Trio/Sources/Services/WatchManager/GarminManager.swift @@ -323,15 +323,9 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { GarminCommandApp(settingsManager.settings.garminSettings) } - /// Returns the currently configured Garmin datafields from settings. - /// Empty when the user has not selected any datafield. - private var currentDatafields: [GarminDatafield] { - settingsManager.settings.garminSettings.datafields - } - - /// Returns the configured datafield registered under the given app UUID, if any - private func datafield(for uuid: UUID) -> GarminDatafield? { - currentDatafields.first { $0.datafieldUUID == uuid } + /// Returns the currently configured Garmin datafield from settings + private var currentDatafield: GarminDatafield { + settingsManager.settings.garminSettings.datafield } /// Returns whether watchface data transmission is enabled in settings @@ -352,7 +346,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { private var needsHistoricalGlucoseData: Bool { currentWatchface == .swissalpine || currentWatchface == .complication - || currentDatafields.contains(.loopgraph) + || currentDatafield == .loopgraph } /// Returns the display name for an app UUID (watchface or datafield). @@ -360,8 +354,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { private func appDisplayName(for uuid: UUID) -> String { if uuid == currentWatchface.watchfaceUUID { return "watchface:\(currentWatchface.displayName)" - } else if let datafield = datafield(for: uuid) { - return "datafield:\(datafield.displayName)" + } else if uuid == currentDatafield.datafieldUUID { + return "datafield:\(currentDatafield.displayName)" } else if uuid == GarminWatchControls.appUUID { return "watch app:\(GarminWatchControls.displayName)" } else { @@ -375,8 +369,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { private func appDetailedName(for uuid: UUID) -> String { if uuid == currentWatchface.watchfaceUUID { return "watchface:\(currentWatchface.displayName) (\(uuid.uuidString))" - } else if let datafield = datafield(for: uuid) { - return "datafield:\(datafield.displayName) (\(uuid.uuidString))" + } else if uuid == currentDatafield.datafieldUUID { + return "datafield:\(currentDatafield.displayName) (\(uuid.uuidString))" } else if uuid == GarminWatchControls.appUUID { return "watch app:\(GarminWatchControls.displayName) (\(uuid.uuidString))" } else { @@ -901,12 +895,10 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { debugGarmin("Garmin watchface data disabled, watchface not registered") } - // Always register every configured datafield - all of them receive the same payload - for datafield in currentDatafields { - guard let datafieldUUID = datafield.datafieldUUID, - let datafieldApp = IQApp(uuid: datafieldUUID, store: UUID(), device: device) - else { continue } - + // Always register datafield (if configured) + if let datafieldUUID = currentDatafield.datafieldUUID, + let datafieldApp = IQApp(uuid: datafieldUUID, store: UUID(), device: device) + { debugGarmin("Registered Garmin \(appDetailedName(for: datafieldUUID))") watchApps.append(datafieldApp) connectIQClient?.registerApp(datafieldApp, delegate: self) @@ -1415,7 +1407,7 @@ extension BaseGarminManager: SettingsObserver { let unitsChanged = currentUnits != units let colorSchemeChanged = currentColorScheme != glucoseColorScheme let watchfaceChanged = currentGarminSettings.watchface != previousGarminSettings.watchface - let datafieldChanged = currentGarminSettings.datafields != previousGarminSettings.datafields + let datafieldChanged = currentGarminSettings.datafield != previousGarminSettings.datafield let watchfaceDataEnabledChanged = currentGarminSettings.isWatchfaceDataEnabled != previousGarminSettings .isWatchfaceDataEnabled let displayAttributesChanged = currentGarminSettings.primaryAttributeChoice != previousGarminSettings diff --git a/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift b/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift index dd05410556..2462efe5fb 100644 --- a/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift +++ b/TrioTests/WatchCommandTests/GarminManagerIntegrationTests.swift @@ -115,7 +115,7 @@ final class RefreshTriggerRecorder: @unchecked Sendable { let settings = try #require(resolver.resolve(SettingsManager.self)) settings.settings.garminSettings.watchface = .complication settings.settings.garminSettings.isWatchfaceDataEnabled = true - settings.settings.garminSettings.datafields = [.swissalpine] + settings.settings.garminSettings.datafield = .swissalpine manager = BaseGarminManager( resolver: resolver, diff --git a/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift b/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift index 12b1f38192..0ed27461b1 100644 --- a/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift +++ b/TrioTests/WatchCommandTests/GarminMessageRouterTests.swift @@ -430,34 +430,35 @@ import Testing let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(json.utf8)) #expect(settings.watchface == .swissalpine) - #expect(settings.datafields == [.swissalpine]) + #expect(settings.datafield == .swissalpine) #expect(settings.primaryAttributeChoice == .isf) #expect(settings.isWatchfaceDataEnabled == true) #expect(settings.isCommandControlEnabled == false) #expect(settings.isBolusCommandEnabled == false) } - @Test("Legacy singular none decodes to an empty datafield selection") func testLegacyDatafieldNoneDecodesEmpty() throws { + @Test("Legacy singular none decodes to no datafield") func testLegacyDatafieldNoneDecodesNone() throws { let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(#"{"datafield": "none"}"#.utf8)) - #expect(settings.datafields == []) + #expect(settings.datafield == GarminDatafield.none) #expect(settings.isCommandControlEnabled == false) #expect(settings.isBolusCommandEnabled == false) } - @Test("Plural datafields takes priority over a legacy singular datafield") func testPluralDatafieldsTakesPriorityOverLegacy() throws { - let json = #"{"datafields": ["loopgraph"], "datafield": "swissalpine"}"# + @Test("A plural datafields array migrates to its first valid entry") func testPluralDatafieldsMigratesToFirstValid() throws { + let json = #"{"datafields": ["not-a-datafield", "none", "loopgraph", "trio"], "datafield": "swissalpine"}"# let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(json.utf8)) - #expect(settings.datafields == [.loopgraph]) + #expect(settings.datafield == .loopgraph) } @Test( - "An explicit empty plural datafields array is kept, not replaced by the default" - ) func testExplicitEmptyPluralDatafieldsIsKept() throws { - let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(#"{"datafields": []}"#.utf8)) + "An explicit empty plural datafields array migrates to no datafield" + ) func testExplicitEmptyPluralDatafieldsMigratesToNone() throws { + let json = #"{"datafields": [], "datafield": "swissalpine"}"# + let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(json.utf8)) - #expect(settings.datafields == []) + #expect(settings.datafield == GarminDatafield.none) } @Test( @@ -466,27 +467,44 @@ import Testing let missing = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(#"{}"#.utf8)) let invalidPlural = try JSONDecoder() .decode(GarminWatchSettings.self, from: Data(#"{"datafields": "not-an-array"}"#.utf8)) + let unknownPluralEntries = try JSONDecoder() + .decode(GarminWatchSettings.self, from: Data(#"{"datafields": ["not-a-datafield", 3]}"#.utf8)) let invalidSingular = try JSONDecoder() .decode(GarminWatchSettings.self, from: Data(#"{"datafield": "not-a-datafield"}"#.utf8)) - #expect(missing.datafields == [.trio]) - #expect(invalidPlural.datafields == [.trio]) - #expect(invalidSingular.datafields == [.trio]) + #expect(missing.datafield == .trio) + #expect(invalidPlural.datafield == .trio) + #expect(unknownPluralEntries.datafield == .trio) + #expect(invalidSingular.datafield == .trio) } @Test( - "An invalid plural datafields falls back to a valid legacy singular" - ) func testInvalidPluralDatafieldsFallsBackToLegacySingular() throws { - let json = #"{"datafields": "not-an-array", "datafield": "swissalpine"}"# - let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(json.utf8)) - - #expect(settings.datafields == [.swissalpine]) - } - - @Test("Decoded plural datafields are normalized through sanitizedSelection") func testDecodedPluralDatafieldsAreNormalized() throws { - let json = #"{"datafields": ["swissalpine", "none", "swissalpine", "trio", "loopgraph"]}"# - let settings = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(json.utf8)) - - #expect(settings.datafields == [.swissalpine, .trio, .loopgraph]) + "An unusable plural datafields falls back to a valid legacy singular" + ) func testUnusablePluralDatafieldsFallsBackToLegacySingular() throws { + let invalidPluralJSON = #"{"datafields": "not-an-array", "datafield": "swissalpine"}"# + let unknownPluralJSON = #"{"datafields": ["not-a-datafield"], "datafield": "swissalpine"}"# + let invalidPlural = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(invalidPluralJSON.utf8)) + let unknownPluralEntries = try JSONDecoder().decode(GarminWatchSettings.self, from: Data(unknownPluralJSON.utf8)) + + #expect(invalidPlural.datafield == .swissalpine) + #expect(unknownPluralEntries.datafield == .swissalpine) + } + + @Test("TrioSettings migrates the plural garminDatafields array") func testTrioSettingsMigratesPluralDatafields() throws { + let decoder = JSONDecoder() + let plural = try decoder + .decode(TrioSettings.self, from: Data(#"{"garminDatafields": ["none", "swissalpine", "trio"]}"#.utf8)) + let empty = try decoder + .decode(TrioSettings.self, from: Data(#"{"garminDatafields": [], "garminDatafield": "trio"}"#.utf8)) + let singular = try decoder.decode(TrioSettings.self, from: Data(#"{"garminDatafield": "loopgraph"}"#.utf8)) + let invalid = try decoder + .decode(TrioSettings.self, from: Data(#"{"garminDatafields": ["not-a-datafield"]}"#.utf8)) + let missing = try decoder.decode(TrioSettings.self, from: Data(#"{}"#.utf8)) + + #expect(plural.garminSettings.datafield == .swissalpine) + #expect(empty.garminSettings.datafield == GarminDatafield.none) + #expect(singular.garminSettings.datafield == .loopgraph) + #expect(invalid.garminSettings.datafield == GarminDatafield.none) + #expect(missing.garminSettings.datafield == GarminDatafield.none) } } diff --git a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift index 90430554c9..edd9239021 100644 --- a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift +++ b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift @@ -218,7 +218,7 @@ private func makeResolver( let settings = try #require(resolver.resolve(SettingsManager.self)) settings.settings.garminSettings.watchface = .complication settings.settings.garminSettings.isWatchfaceDataEnabled = true - settings.settings.garminSettings.datafields = [.swissalpine] + settings.settings.garminSettings.datafield = .swissalpine settings.settings.garminSettings.isCommandControlEnabled = true // settings persist on the simulator between runs, so start from a known state settings.settings.garminSettings.isBolusCommandEnabled = false @@ -300,7 +300,7 @@ private func makeResolver( #expect(sent("ack").count == 2) } - @Test("A started preset pushes the list to every Complication app, never to datafields") func testCommandPushesPresets() async { + @Test("A started preset pushes the list to every Complication app, never to a datafield") func testCommandPushesPresets() async { processor.result = .success(.overrideStarted, "Override started.") manager.receivedMessage( command("activateOverride", payload: ["name": "Sport"], requestID: UUID().uuidString), From 7ac31f544ada03263b5bac1e77a760f14164581b Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 27 Oct 2026 23:22:11 +0100 Subject: [PATCH 09/34] Keep TestFlight commit notes when the CI deepen fetch fails The post-xcodebuild step chained "git fetch --deepen 6 && git log ...", so a failed fetch dropped the whole commit list from WhatToTest.en-CA.txt. The fetch recurses into submodules by default and aborts on stale gitlinks from deep history (MedtrumKit, EversenseKit and CGMBLEKit pins that the configured upstream no longer serves: "upload-pack: not our ref"), which is exactly what happened on build 459. Fetch history without submodule recursion as a best effort and always append the log afterwards, so the release notes no longer depend on a network-only step. --- ci_scripts/ci_post_xcodebuild.sh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/ci_scripts/ci_post_xcodebuild.sh b/ci_scripts/ci_post_xcodebuild.sh index 7f2132ae79..4fad050aac 100755 --- a/ci_scripts/ci_post_xcodebuild.sh +++ b/ci_scripts/ci_post_xcodebuild.sh @@ -4,7 +4,15 @@ if [[ -d "$CI_APP_STORE_SIGNED_APP_PATH" ]]; then TESTFLIGHT_DIR_PATH=../TestFlight mkdir -p $TESTFLIGHT_DIR_PATH - echo "" >> $TESTFLIGHT_DIR_PATH/WhatToTest.en-CA.txt - echo "Last commits:" >> $TESTFLIGHT_DIR_PATH/WhatToTest.en-CA.txt - git fetch --deepen 6 && git log -6 --pretty=format:"%h %s" | nl -s ". " >> $TESTFLIGHT_DIR_PATH/WhatToTest.en-CA.txt + WHAT_TO_TEST=$TESTFLIGHT_DIR_PATH/WhatToTest.en-CA.txt + echo "" >> $WHAT_TO_TEST + echo "Last commits:" >> $WHAT_TO_TEST + # Best effort: a shallow CI clone needs a deepen pass to see history, but the + # fetch must not recurse into submodules. Stale gitlinks from deep history + # (MedtrumKit/EversenseKit/CGMBLEKit pins that the .gitmodules upstream no + # longer has) make a recursive fetch abort with + # "upload-pack: not our ref", which used to short-circuit this line and drop + # the whole commit list from the release notes. + git -c submodule.recurse=false fetch --deepen 6 >/dev/null 2>&1 || true + git log -6 --pretty=format:"%h %s" | nl -s ". " >> $WHAT_TO_TEST fi From 09cb43721d28805a5b22bad431b6e7043671b918 Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 28 Oct 2026 08:27:03 +0100 Subject: [PATCH 10/34] Name log exports after Tai and sweep Tai's legacy log files The upstream logging rework names its export files "Trio Logs .txt" and, when the new Core Data store first opens, clears leftover plain-text logs out of Documents/logs. Both carry upstream's own conventions, which do not fit this fork. - Export files keep upstream's naming scheme but read "Tai Logs 2026-10-28 1430.log": Tai's name instead of Trio's, and the .log extension in place of .txt. The zip wrapper derives its name from the file, so the shared archive follows. - The legacy sweeper only matched "log.txt", written by the reporter upstream retired. Tai's reporter wrote dated ".log" and "watch_.log" files instead, so the sweeper collected nothing at all on a Tai install and left those files behind for good. It now matches both extensions and ages them out after the 8 days of LogRetentionPolicy .default rather than a separate 7. - Zip exports in Documents/logExports were never cleaned up either. The four newest are kept and older ones removed, close to the 3 the retired reporter held on to. - Reporting a problem opens an issue on mountrcg/Tai, and the share row above it reads "Share Tai Logs". --- .../Localizations/Main/Localizable.xcstrings | 2 +- Trio/Sources/Logger/Export/LogExport.swift | 2 +- .../Sources/Logger/Export/LogFileHeader.swift | 6 ++-- Trio/Sources/Logger/Store/AppLogStore.swift | 34 +++++++++++++++++-- .../View/Subviews/ReportProblemView.swift | 4 +-- TrioTests/Logging/LogExportTests.swift | 12 +++---- 6 files changed, 45 insertions(+), 15 deletions(-) diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 23dcbb7aa0..e77723b7ba 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -268817,7 +268817,7 @@ "Share Pump Logs" : { }, - "Share Trio Logs" : { + "Share Tai Logs" : { }, "Shared Secret" : { diff --git a/Trio/Sources/Logger/Export/LogExport.swift b/Trio/Sources/Logger/Export/LogExport.swift index f81295c7e1..80db51d277 100644 --- a/Trio/Sources/Logger/Export/LogExport.swift +++ b/Trio/Sources/Logger/Export/LogExport.swift @@ -153,7 +153,7 @@ final class LogExport: Injectable { let algorithmStack = kind == .all ? coreDataStack : nil try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let url = directory.appendingPathComponent("\(header.title) \(Self.fileStamp(now)).txt") + let url = directory.appendingPathComponent("\(header.title) \(Self.fileStamp(now)).log") try await LogFileWriter.write(header: header.text, window: window, to: url, redactor: redactor) { slice in var lines: [LogLine] = [] if let appLog { diff --git a/Trio/Sources/Logger/Export/LogFileHeader.swift b/Trio/Sources/Logger/Export/LogFileHeader.swift index aa2c0a859b..3763f9e487 100644 --- a/Trio/Sources/Logger/Export/LogFileHeader.swift +++ b/Trio/Sources/Logger/Export/LogFileHeader.swift @@ -19,9 +19,9 @@ struct LogFileHeader { var title: String { switch kind { - case .all: return "Trio Logs" - case .pump: return "Trio Pump Logs" - case .cgm: return "Trio CGM Logs" + case .all: return "Tai Logs" + case .pump: return "Tai Pump Logs" + case .cgm: return "Tai CGM Logs" } } diff --git a/Trio/Sources/Logger/Store/AppLogStore.swift b/Trio/Sources/Logger/Store/AppLogStore.swift index d3722bd59b..547acb8fd0 100644 --- a/Trio/Sources/Logger/Store/AppLogStore.swift +++ b/Trio/Sources/Logger/Store/AppLogStore.swift @@ -11,7 +11,10 @@ final class AppLogStore: LogSink, @unchecked Sendable { static let flushDelay: TimeInterval = 2 static let purgeInterval: TimeInterval = 24 * 60 * 60 static let purgeStep: TimeInterval = 24 * 60 * 60 - static let legacyFileMaxAge: TimeInterval = 7 * 24 * 60 * 60 + static let legacyFileMaxAge: TimeInterval = LogRetentionPolicy.default.maxAge + static let legacyZipRetention = 4 + + private static let legacyLogExtensions: Set = ["txt", "log"] private static let entityName = "AppLogEntry" private static let model: NSManagedObjectModel? = Bundle.main.url(forResource: "AppLog", withExtension: "momd") @@ -430,12 +433,20 @@ final class AppLogStore: LogSink, @unchecked Sendable { } private func deleteLegacyFiles() { + deleteLegacyLogs() + pruneLegacyExports() + } + + // The retired reporter wrote "log.txt" upstream and dated ".log" and "watch_.log" here. + // Nothing the current stack keeps in this directory carries either extension: the stores are + // ".sqlite" and export files are built in a temporary directory. + private func deleteLegacyLogs() { let cutoff = Date().addingTimeInterval(-Self.legacyFileMaxAge) let files = (try? FileManager.default.contentsOfDirectory( at: directory, includingPropertiesForKeys: [.contentModificationDateKey] )) ?? [] - for file in files where file.pathExtension == "txt" { + for file in files where Self.legacyLogExtensions.contains(file.pathExtension) { guard let modified = try? file.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate, modified < cutoff else { continue } @@ -443,6 +454,25 @@ final class AppLogStore: LogSink, @unchecked Sendable { } } + // Exports the user saved themselves; keep the newest few rather than age them out + private func pruneLegacyExports() { + let exports = directory.deletingLastPathComponent().appendingPathComponent( + Disk.AppDirectory.logExports, + isDirectory: true + ) + let files = (try? FileManager.default.contentsOfDirectory( + at: exports, + includingPropertiesForKeys: [.contentModificationDateKey] + )) ?? [] + let newestFirst = files + .filter { $0.pathExtension == "zip" } + .map { ($0, (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)) } + .sorted { ($0.1 ?? .distantPast, $0.0.lastPathComponent) > ($1.1 ?? .distantPast, $1.0.lastPathComponent) } + for (file, _) in newestFirst.dropFirst(Self.legacyZipRetention) { + try? FileManager.default.removeItem(at: file) + } + } + static func loadContainer(_ url: URL) -> NSPersistentContainer? { guard let model, FileManager.default.isWritableStore(at: url) else { return nil } let container = NSPersistentContainer(name: "AppLog", managedObjectModel: model) diff --git a/Trio/Sources/Modules/Settings/View/Subviews/ReportProblemView.swift b/Trio/Sources/Modules/Settings/View/Subviews/ReportProblemView.swift index b28806d580..cdf7856a09 100644 --- a/Trio/Sources/Modules/Settings/View/Subviews/ReportProblemView.swift +++ b/Trio/Sources/Modules/Settings/View/Subviews/ReportProblemView.swift @@ -10,7 +10,7 @@ struct ReportProblemView: View { List { Section { LogShareRow( - title: "Share Trio Logs", + title: "Share Tai Logs", footnote: "Save the file or send it to someone helping you on Discord or Facebook. Contains glucose, insulin and carbs; passwords and serial numbers are removed.", kinds: state.logKinds, logExport: state.logExport @@ -22,7 +22,7 @@ struct ReportProblemView: View { ReportLinkRow( title: "Open a GitHub Issue", footnote: "Public. Only for bugs you can describe without personal or health data.", - url: "https://github.com/nightscout/Trio/issues/new/choose" + url: "https://github.com/mountrcg/Tai/issues/new/choose" ) } .listRowBackground(Color.chart) diff --git a/TrioTests/Logging/LogExportTests.swift b/TrioTests/Logging/LogExportTests.swift index 6bc777d596..6b7a73ea31 100644 --- a/TrioTests/Logging/LogExportTests.swift +++ b/TrioTests/Logging/LogExportTests.swift @@ -199,7 +199,7 @@ import Testing let url = try await fixture.export.makeFile(.pump, days: 1) defer { LogExport.discard(url) } - #expect(url.lastPathComponent.hasPrefix("Trio Pump Logs ")) + #expect(url.lastPathComponent.hasPrefix("Tai Pump Logs ")) #expect(url.pathExtension == "zip") let data = try Data(contentsOf: url) #expect(Array(data.prefix(4)) == [0x50, 0x4B, 0x03, 0x04]) @@ -213,14 +213,14 @@ import Testing let pump = try await Self.contents(of: .pump, in: fixture) let cgm = try await Self.contents(of: .cgm, in: fixture) - #expect(all.name.hasPrefix("Trio Logs ")) - #expect(pump.name.hasPrefix("Trio Pump Logs ")) - #expect(cgm.name.hasPrefix("Trio CGM Logs ")) - #expect([all.name, pump.name, cgm.name].allSatisfy { $0.hasSuffix(".txt") }) + #expect(all.name.hasPrefix("Tai Logs ")) + #expect(pump.name.hasPrefix("Tai Pump Logs ")) + #expect(cgm.name.hasPrefix("Tai CGM Logs ")) + #expect([all.name, pump.name, cgm.name].allSatisfy { $0.hasSuffix(".log") }) #expect(Self.sections(all.text) == ["App", "Phone", "Pump", "CGM", "Settings", "Alerts", "Log"]) #expect(Self.sections(pump.text) == ["App", "Phone", "Pump", "Log"]) #expect(Self.sections(cgm.text) == ["App", "Phone", "CGM", "Log"]) - #expect(all.text.hasPrefix("Trio Logs\nCreated: ")) + #expect(all.text.hasPrefix("Tai Logs\nCreated: ")) #expect(all.text.contains("\nCovers: last 24 hours\n")) #expect(pump.text.contains("Name: Example Pump\nPlugin: com.example.pump\n## StubManager\n")) } From fd06e1f42b788159e9d63ce5153f9ba513d5d027 Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 28 Oct 2026 10:48:28 +0100 Subject: [PATCH 11/34] Send the command-app ownerMode notice to Complication too registerDevices and receivedMessage only checked app.uuid against GarminWatchControls.appUUID, so Complication never got told it was active/inactive and had no self-heal path when a notice was dropped - its glance kept showing stale data as if still active after switching watchfaces. GarminCommandApp.appUUIDs already unifies both apps elsewhere in this file; use it here too. Verified against Trio-Complication's TrioOwnerMode.mc: it already parses this exact envelope and drives the glance's active/inactive display, so the Connect IQ side needs no change. --- Trio/Sources/Services/WatchManager/GarminManager.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Trio/Sources/Services/WatchManager/GarminManager.swift b/Trio/Sources/Services/WatchManager/GarminManager.swift index 8047279485..62835c04b4 100644 --- a/Trio/Sources/Services/WatchManager/GarminManager.swift +++ b/Trio/Sources/Services/WatchManager/GarminManager.swift @@ -917,10 +917,10 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { let registeredKeys = Set(watchApps.compactMap(deliveryKey(for:))) owedDeliveries = owedDeliveries.filter { registeredKeys.contains($0.key) } - for app in watchApps where app.uuid == GarminWatchControls.appUUID { + for app in watchApps where app.uuid.map(GarminCommandApp.appUUIDs.contains) == true { sendCommandAppNotice(to: app, isActive: true) } - for app in previousApps where app.uuid == GarminWatchControls.appUUID { + for app in previousApps where app.uuid.map(GarminCommandApp.appUUIDs.contains) == true { guard let key = deliveryKey(for: app), !registeredKeys.contains(key) else { continue } sendCommandAppNotice(to: app, isActive: false) } @@ -1115,8 +1115,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { pushPresets(to: watchApps.filter { $0.uuid == commandAppUUID }, triggeredBy: trigger) } - /// Only Trio-WatchControls reads it. Only the active notice is owed: an app told to stand down that - /// missed it is told again as soon as it sends anything. + /// Read by both command apps, Trio-WatchControls and Complication. Only the active notice is owed: + /// an app told to stand down that missed it is told again as soon as it sends anything. private func sendCommandAppNotice(to app: IQApp, isActive: Bool) { guard let key = deliveryKey(for: app), let device = app.device else { return } let appName = appDisplayName(for: app.uuid) @@ -1354,7 +1354,7 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM self?.absentApps.remove(key) self?.flushOwed(to: registered, key: key, appName: appName) } - } else if appUUID == GarminWatchControls.appUUID, devices.contains(where: { $0.uuid == app.device?.uuid }) { + } else if GarminCommandApp.appUUIDs.contains(appUUID), devices.contains(where: { $0.uuid == app.device?.uuid }) { // it still acts as the command app, so it missed the notice to stand down onMain { [weak self] in self?.sendCommandAppNotice(to: app, isActive: false) From abdfb019fd79bc6e35200dc34d41eb2631f4b9d4 Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 28 Oct 2026 10:55:46 +0100 Subject: [PATCH 12/34] ver + lint --- Testflight/WhatToTest.en-CA.txt | 2 +- Trio.xcodeproj/xcshareddata/xcodecloud/manifest.json | 9 +++++++++ Trio/Sources/APS/FetchGlucoseManager.swift | 2 -- Trio/Sources/Logger/Store/AppLogStore.swift | 2 +- Trio/Sources/Modules/Export/ExportStateModel.swift | 3 --- Trio/Sources/Modules/Export/View/ExportRootView.swift | 9 +++++++-- 6 files changed, 18 insertions(+), 9 deletions(-) create mode 100644 Trio.xcodeproj/xcshareddata/xcodecloud/manifest.json diff --git a/Testflight/WhatToTest.en-CA.txt b/Testflight/WhatToTest.en-CA.txt index 62ec44f059..7b6e68a897 100644 --- a/Testflight/WhatToTest.en-CA.txt +++ b/Testflight/WhatToTest.en-CA.txt @@ -1,2 +1,2 @@ based on Trio dev 1.0.2.27 -garmin watch complication +garmin watch complications diff --git a/Trio.xcodeproj/xcshareddata/xcodecloud/manifest.json b/Trio.xcodeproj/xcshareddata/xcodecloud/manifest.json new file mode 100644 index 0000000000..4a275a4edd --- /dev/null +++ b/Trio.xcodeproj/xcshareddata/xcodecloud/manifest.json @@ -0,0 +1,9 @@ +{ + "id" : "6ee3a6c0-b8a4-4bee-939b-8cada959fd38", + "targets" : [ + { + "id" : "F7FE623E-2EB9-4C2F-95CB-AA0A1692FA10", + "name" : "Tai" + } + ] +} \ No newline at end of file diff --git a/Trio/Sources/APS/FetchGlucoseManager.swift b/Trio/Sources/APS/FetchGlucoseManager.swift index 762a0549ae..c6372ab890 100644 --- a/Trio/Sources/APS/FetchGlucoseManager.swift +++ b/Trio/Sources/APS/FetchGlucoseManager.swift @@ -739,7 +739,6 @@ extension BaseFetchGlucoseManager { return } - // Convert GlucoseStored to BloodGlucose array let bloodGlucoseArray: [BloodGlucose] = glucoseReadings.compactMap { stored -> BloodGlucose? in guard let date = stored.date else { return nil } @@ -773,7 +772,6 @@ extension BaseFetchGlucoseManager { var ukf = UnscentedKalmanFilter() let smoothed = ukf.smooth(bloodGlucoseArray) - // Store smoothed values back to CoreData for (idx, bloodGlucose) in smoothed.enumerated() where idx < glucoseReadings.count { if let smoothedValue = bloodGlucose.glucose { diff --git a/Trio/Sources/Logger/Store/AppLogStore.swift b/Trio/Sources/Logger/Store/AppLogStore.swift index 547acb8fd0..865952d64e 100644 --- a/Trio/Sources/Logger/Store/AppLogStore.swift +++ b/Trio/Sources/Logger/Store/AppLogStore.swift @@ -466,7 +466,7 @@ final class AppLogStore: LogSink, @unchecked Sendable { )) ?? [] let newestFirst = files .filter { $0.pathExtension == "zip" } - .map { ($0, (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)) } + .map { ($0, try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) } .sorted { ($0.1 ?? .distantPast, $0.0.lastPathComponent) > ($1.1 ?? .distantPast, $1.0.lastPathComponent) } for (file, _) in newestFirst.dropFirst(Self.legacyZipRetention) { try? FileManager.default.removeItem(at: file) diff --git a/Trio/Sources/Modules/Export/ExportStateModel.swift b/Trio/Sources/Modules/Export/ExportStateModel.swift index 740f3d0d5a..a762ac5e05 100644 --- a/Trio/Sources/Modules/Export/ExportStateModel.swift +++ b/Trio/Sources/Modules/Export/ExportStateModel.swift @@ -1308,7 +1308,6 @@ extension Export { } } - for preset in presetData { if let target = preset.target { let targetValue = trioSettings.units == .mgdL ? target : target.asMmolL @@ -1351,7 +1350,6 @@ extension Export { // Override Presets (from Core Data) do { - // Ensure Core Data is fully initialized - wait longer on first run var retryCount = 0 var overridePresetIDs: [NSManagedObjectID] = [] @@ -1431,7 +1429,6 @@ extension Export { } } - for preset in presetData { addSetting( category: presetsCategory, diff --git a/Trio/Sources/Modules/Export/View/ExportRootView.swift b/Trio/Sources/Modules/Export/View/ExportRootView.swift index 940fec2f00..66025ca408 100644 --- a/Trio/Sources/Modules/Export/View/ExportRootView.swift +++ b/Trio/Sources/Modules/Export/View/ExportRootView.swift @@ -188,8 +188,13 @@ extension Export { state.isExporting = false } } catch let attributesError { - error(.settings, "Reading settings export file attributes failed", error: attributesError) - exportErrorMessage = "Could not verify file attributes: \(attributesError.localizedDescription)" + error( + .settings, + "Reading settings export file attributes failed", + error: attributesError + ) + exportErrorMessage = + "Could not verify file attributes: \(attributesError.localizedDescription)" showExportError = true // Stop spinner on error state.isExporting = false From 138e2cfbd924228e05719ed7c726a5a49b5e86b2 Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 28 Oct 2026 12:09:23 +0100 Subject: [PATCH 13/34] version --- Config.xcconfig | 2 +- Testflight/WhatToTest.en-CA.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 445870f8c6..6911cb214a 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -9,7 +9,7 @@ DEVELOPER_TEAM = ##TEAM_ID## // Typically this is not modified unless you want to create a separate (unique) app using your ID // It must include $(DEVELOPMENT_TEAM) // Tai uses .tai at the end instead of .trio -BUNDLE_IDENTIFIER = org.nightscout.$(DEVELOPMENT_TEAM).tai +BUNDLE_IDENTIFIER = org.nightscout.$(DEVELOPMENT_TEAM).trio // Danger zone - do not modify these unless you know what you are doing diff --git a/Testflight/WhatToTest.en-CA.txt b/Testflight/WhatToTest.en-CA.txt index 7b6e68a897..6aac8dbb10 100644 --- a/Testflight/WhatToTest.en-CA.txt +++ b/Testflight/WhatToTest.en-CA.txt @@ -1,2 +1,3 @@ based on Trio dev 1.0.2.27 garmin watch complications +fix bundle id alpha From d6b40e95eb5a44cd7c7b13923e78a18bdc27ddd0 Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 29 Oct 2026 21:23:27 +0100 Subject: [PATCH 14/34] Updated submodule MedtrumKit --- MedtrumKit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MedtrumKit b/MedtrumKit index c33699595b..a39df701f8 160000 --- a/MedtrumKit +++ b/MedtrumKit @@ -1 +1 @@ -Subproject commit c33699595b6f636d8ca1bff377a8765df5597c34 +Subproject commit a39df701f8371fe154d9222147b81caf5b1842fc From 1fd6be697b92ae9adeea346b92cb0a30fb5374b4 Mon Sep 17 00:00:00 2001 From: Robert Date: Fri, 30 Oct 2026 07:13:07 +0100 Subject: [PATCH 15/34] alpha-port --- Testflight/WhatToTest.en-CA.txt | 1 + .../Localizations/Main/Localizable.xcstrings | 3358 +++++++++++++++-- .../Services/WatchManager/GarminManager.swift | 23 +- .../WatchCommandIntegrationTests.swift | 77 +- 4 files changed, 3097 insertions(+), 362 deletions(-) diff --git a/Testflight/WhatToTest.en-CA.txt b/Testflight/WhatToTest.en-CA.txt index 6aac8dbb10..58cc77502e 100644 --- a/Testflight/WhatToTest.en-CA.txt +++ b/Testflight/WhatToTest.en-CA.txt @@ -1,3 +1,4 @@ based on Trio dev 1.0.2.27 garmin watch complications fix bundle id alpha +complication diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index e77723b7ba..9cbe23fcd5 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -1089,299 +1089,6 @@ } } }, - "A bolus was given in the last 6 minutes." : { - "comment" : "Watch command ack" - }, - "Allow Bolus Commands" : { - - }, - "Allow the Garmin app to deliver insulin. Requires watch commands to be enabled." : { - - }, - "Allow the Garmin app to log carbs and start or stop overrides and temp targets." : { - - }, - "Ask on Discord" : { - - }, - "Ask on Facebook" : { - - }, - "Bolus exceeds max bolus." : { - "comment" : "Watch command ack" - }, - "Bolus failed. Check pump history before repeating." : { - "comment" : "Watch command ack" - }, - "Bolus from watch is disabled." : { - "comment" : "Watch command ack" - }, - "Bolus started." : { - "comment" : "Watch command ack" - }, - "Bolus would exceed max IOB." : { - "comment" : "Watch command ack" - }, - "Carbs exceed the maximum allowed." : { - "comment" : "Watch command ack" - }, - "Carbs logged and bolus started." : { - "comment" : "Watch command ack" - }, - "Carbs logged." : { - "comment" : "Watch command ack" - }, - "Carbs logged. Bolus failed, check pump history before repeating." : { - "comment" : "Watch command ack" - }, - "Carbs logged. No bolus was delivered." : { - "comment" : "Watch command ack" - }, - "Open a GitHub Issue" : { - - }, - "Public. Only for bugs you can describe without personal or health data." : { - - }, - "Report a Problem" : { - - }, - "Save the file or send it to someone helping you on Discord or Facebook. Contains glucose, insulin and carbs; passwords and serial numbers are removed." : { - - }, - "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 %@)." - } - } - } - }, - "Command expired. Please try again." : { - "comment" : "Watch command ack" - }, - "Commands" : { - - }, - "Commands are only accepted from the Garmin apps selected above, must reach the phone within 2 minutes of being confirmed on the watch, and carbs are limited by your Max Carbs setting." : { - - }, - "Could not log carbs." : { - "comment" : "Watch command ack" - }, - "Could not log carbs. No bolus was delivered." : { - "comment" : "Watch command ack" - }, - "Could not start override." : { - "comment" : "Watch command ack" - }, - "Could not start temp target." : { - "comment" : "Watch command ack" - }, - "Could not stop override." : { - "comment" : "Watch command ack" - }, - "Could not stop temp target." : { - "comment" : "Watch command ack" - }, - "Could not verify bolus safety." : { - "comment" : "Watch command ack" - }, - "Enable Watch Commands" : { - - }, - "Every bolus is checked against your Max Bolus and Max IOB. A watch bolus is refused for 6 minutes after the previous watch bolus, and any watch bolus is refused if the boluses of the last 6 minutes (SMBs included) add up to 20% or more of it. These checks cannot tell who pressed the button on the watch: anyone with access to your watch can deliver insulin up to those limits." : { - - }, - "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:" - } - } - } - }, - "Invalid bolus amount." : { - "comment" : "Watch command ack" - }, - "Invalid carbs amount." : { - "comment" : "Watch command ack" - }, - "Invalid command." : { - "comment" : "Watch command ack" - }, - "IOB unavailable. Bolus not delivered." : { - "comment" : "Watch command ack" - }, - "Lets a supported Garmin app log carbs and start or stop override and temp target presets. Status and the preset list stay available with this switch off." : { - - }, - "Lets the Garmin app deliver a bolus, alone or together with carbs, without any confirmation on this phone." : { - - }, - "Only enable this if you keep your watch locked and under your control." : { - - }, - "Override preset not found." : { - "comment" : "Watch command ack" - }, - "Override started." : { - "comment" : "Watch command ack" - }, - "Override stopped." : { - "comment" : "Watch command ack" - }, - "Request ID was already used." : { - "comment" : "Watch command ack" - }, - "Request is already in progress." : { - "comment" : "Watch command ack" - }, - "Temp target preset not found." : { - "comment" : "Watch command ack" - }, - "Temp target started." : { - "comment" : "Watch command ack" - }, - "Temp target stopped." : { - "comment" : "Watch command ack" - }, - "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 (%@-%@)" - } - } - } - }, - "Too many requests in progress. Please try again." : { - "comment" : "Watch command ack" - }, - "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 %@." - } - } - } - }, - "Watch command settings changed." : { - "comment" : "Watch command ack" - }, - "Watch commands are disabled." : { - "comment" : "Watch command ack" - }, " " : { "localizations" : { "bg" : { @@ -1910,6 +1617,13 @@ } } }, + " - diluted Insulin" : { + "comment" : "A description of a insulin concentration that is diluted.", + "isCommentAutoGenerated" : true + }, + " - not enacted!" : { + "comment" : "Suffix appended to Home status popup title when the suggestion was not enacted" + }, " »%@« " : { "localizations" : { "bg" : { @@ -2306,6 +2020,9 @@ } } }, + " ⚠️" : { + "comment" : "Manual Temp basal" + }, " Allow Trio to send you Notifications. These may include alerts, sounds, and icon badges." : { "localizations" : { "bg" : { @@ -3372,6 +3089,7 @@ } }, " needed as min. Glucose Target)!" : { + "comment" : "Trailing fragment of Add Temp Target validation error — concatenated with a minimum-glucose value and unit", "localizations" : { "bg" : { "stringUnit" : { @@ -3633,6 +3351,7 @@ } }, " of usage data and is not yet configurable." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -4958,6 +4677,14 @@ } } }, + "- higher custom concentration" : { + "comment" : "A description of a custom insulin concentration.", + "isCommentAutoGenerated" : true + }, + "- Standard Insulin 100 U/mL" : { + "comment" : "A description of a standard insulin concentration of 100 U/mL.", + "isCommentAutoGenerated" : true + }, "--" : { "localizations" : { "bg" : { @@ -5478,6 +5205,10 @@ } } }, + "—" : { + "comment" : "A placeholder text indicating missing data.", + "isCommentAutoGenerated" : true + }, ", %lld%%" : { "localizations" : { "bg" : { @@ -8401,6 +8132,7 @@ } }, "%@ " : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -8803,6 +8535,7 @@ } }, "%@ (%@-%@)" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -9347,6 +9080,7 @@ } }, "%@ %%" : { + "comment" : "Profile summary badge — applied therapy percentage", "localizations" : { "bg" : { "stringUnit" : { @@ -9476,6 +9210,31 @@ } } }, + "%@ →" : { + + }, + "%@ → %@ %@" : { + "comment" : "A label indicating a value change that was rounded to the nearest option. The first argument is the original displayed value. The second argument is the new displayed value. The third argument is the unit of measurement.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ → %2$@ %3$@" + } + } + } + }, + "%@ − 0.5 × (%@ − %@) = %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ − 0.5 × (%2$@ − %3$@) = %4$@" + } + } + } + }, "%@ and %lld g fat" : { "comment" : "The text to be displayed when fat is > 0 for the Add Carbs shortcut", "localizations" : { @@ -10543,6 +10302,10 @@ } } }, + "%@ is active" : { + "comment" : "A label that appears in the top-left corner of an active profile banner, displaying the profile name and a pencil icon that triggers a rename action. The text inside the label is dynamically generated based on the name of", + "isCommentAutoGenerated" : true + }, "%@ is on. %@" : { "localizations" : { "bg" : { @@ -11071,6 +10834,9 @@ } } }, + "%@ U/day" : { + "comment" : "Profile summary badge — total daily basal in insulin units per day" + }, "%@-%@" : { "localizations" : { "bg" : { @@ -11206,6 +10972,9 @@ } } } + }, + "%@-Day moving Avg of TDD" : { + }, "%@: %@" : { "localizations" : { @@ -11344,6 +11113,7 @@ } }, "%@?" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -13316,6 +13086,17 @@ } } }, + "%lld · %@ U/day" : { + "comment" : "Draft editor Basal Rates row summary — entry count and total daily insulin units per day", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld · %2$@ U/day" + } + } + } + }, "%lld %%" : { "localizations" : { "bg" : { @@ -13446,6 +13227,9 @@ } } }, + "%lld entries" : { + "comment" : "Draft editor Carb Ratios row summary — number of schedule entries\nDraft editor Glucose Targets row summary — number of schedule entries\nDraft editor ISF row summary — number of schedule entries" + }, "%lld g" : { "localizations" : { "bg" : { @@ -13707,6 +13491,7 @@ } }, "%lld hr" : { + "comment" : "Activation duration row — duration in whole hours\nTemp-activation notification body fragment — duration in whole hours", "localizations" : { "bg" : { "stringUnit" : { @@ -13836,6 +13621,17 @@ } } }, + "%lld hr %lld min" : { + "comment" : "Activation duration row — duration in hours and minutes\nTemp-activation notification body fragment — duration in hours and minutes", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld hr %2$lld min" + } + } + } + }, "%lld m" : { "localizations" : { "bg" : { @@ -13967,6 +13763,7 @@ } }, "%lld min" : { + "comment" : "Activation duration row — duration in minutes only\nTemp-activation notification body fragment — duration in minutes only", "localizations" : { "bg" : { "stringUnit" : { @@ -14225,6 +14022,9 @@ } } } + }, + "%lld-Day Moving Average of TDD" : { + }, "%lld%%" : { "localizations" : { @@ -14356,6 +14156,10 @@ } } }, + "%lldh" : { + "comment" : "A picker option in the checkout view that allows the user to select the time interval for which they want to view autoISF calculations. The argument is the time interval in hours.", + "isCommentAutoGenerated" : true + }, "•" : { "localizations" : { "bg" : { @@ -14615,6 +14419,26 @@ } } } + }, + "• 0.5 — ratioFloor 70 mg/dL (midpoint between target and 40)" : { + + }, + "• 0.5 — threshold 70 mg/dL (midway between target and 40)" : { + "comment" : "An example of the `smbThresholdRatioVerbose` hint, showing how the SMB threshold is calculated for a `smbThresholdRatio` of 0.5.", + "isCommentAutoGenerated" : true + }, + "• 0.7 — ratioFloor 82 mg/dL (SMBs cut off sooner)" : { + + }, + "• 0.7 — threshold 82 mg/dL (SMBs cut off sooner)" : { + "comment" : "An example of the `smbThresholdRatioVerbose` hint, showing a specific value and its resulting threshold.", + "isCommentAutoGenerated" : true + }, + "• 1.0 — ratioFloor 100 mg/dL (no SMBs below target)" : { + + }, + "• 1.0 — threshold 100 mg/dL (no SMBs below target)" : { + }, "• A higher number means you need less insulin for the same amount of carbs" : { "localizations" : { @@ -18651,6 +18475,9 @@ } } } + }, + "• In U100 insulin: 0.1 U increment delivers 0.1 units\n• In U50 insulin: 0.1 U increment delivers 0.05 units\n• In U200 insulin: 0.1 U increment delivers 0.2 units" : { + }, "• Insulin Sensitivity" : { "localizations" : { @@ -19691,6 +19518,9 @@ } } } + }, + "• Maximum Duration" : { + }, "• None: No value displayed." : { "localizations" : { @@ -21381,6 +21211,9 @@ } } } + }, + "⇢" : { + }, "+" : { "localizations" : { @@ -22032,6 +21865,10 @@ } } }, + "<100 % = less insulin (more sensitive). Basal drops, ISF rises, CR rises." : { + "comment" : "A description of what happens when the therapy adjustment percentage is less than 100%.", + "isCommentAutoGenerated" : true + }, "=" : { "localizations" : { "bg" : { @@ -22292,6 +22129,10 @@ } } }, + ">100 % = more insulin overall (less sensitive). Basal rises, ISF drops, CR drops." : { + "comment" : "A description of the effect of increasing the therapy adjustment percentage on a profile.", + "isCommentAutoGenerated" : true + }, "−" : { "localizations" : { "bg" : { @@ -23342,6 +23183,7 @@ }, "0.1 U and over" : { "comment" : "Name to display for the 0.1 U and over threshold in the bolus display settings.", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -23471,8 +23313,13 @@ } } }, + "0.1U and over" : { + "comment" : "Name of the bolus display threshold option that shows labels for all doses of 0.1U or more.", + "isCommentAutoGenerated" : true + }, "0.5 U and over" : { "comment" : "Name of the bolus display threshold option that represents 0.5 units.", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -23602,8 +23449,13 @@ } } }, + "0.5U and over" : { + "comment" : "Name for the 0.5U threshold in the Bolus Display Threshold picker.", + "isCommentAutoGenerated" : true + }, "1 U and over" : { "comment" : "Name to display for the \"1 U and over\" bolus display threshold option.", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -23863,6 +23715,10 @@ } } }, + "1U and over" : { + "comment" : "Name to display for the 1U threshold in the Bolus Display Threshold settings.", + "isCommentAutoGenerated" : true + }, "2 hours" : { "extractionState" : "manual", "localizations" : { @@ -24260,6 +24116,9 @@ } } }, + "2nd Order Exponential" : { + "comment" : "Exponential smoothing algorithm display name" + }, "3 M" : { "comment" : "Abbreviation for three months", "localizations" : { @@ -25056,6 +24915,7 @@ } }, "7 days" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -25185,6 +25045,10 @@ } } }, + "10 U/mL" : { + "comment" : "A text label for a insulin concentration option in the insulin concentration picker.", + "isCommentAutoGenerated" : true + }, "10-90" : { "localizations" : { "bg" : { @@ -26238,6 +26102,9 @@ } } } + }, + "24h" : { + }, "25 g" : { "localizations" : { @@ -26889,6 +26756,10 @@ } } }, + "50 U/mL" : { + "comment" : "A concentration option in the insulin concentration picker.", + "isCommentAutoGenerated" : true + }, "50% is half effect:" : { "localizations" : { "bg" : { @@ -27542,6 +27413,10 @@ } } }, + "100 U/mL" : { + "comment" : "A label for a insulin concentration option.", + "isCommentAutoGenerated" : true + }, "100% is full effect:" : { "localizations" : { "bg" : { @@ -27932,6 +27807,10 @@ } } }, + "200 U/mL" : { + "comment" : "A choice in the insulin concentration picker.", + "isCommentAutoGenerated" : true + }, "A bolus command of %@ U of insulin was sent." : { "localizations" : { "bg" : { @@ -28062,6 +27941,9 @@ } } }, + "A bolus was given in the last 6 minutes." : { + "comment" : "Watch command ack" + }, "A display of On/On indicates both Dynamic ISF and CR are enabled. On/Off indicates only Dynamic ISF is enabled. Dynamic CR cannot be enabled when Dynamic ISF is disabled." : { "localizations" : { "bg" : { @@ -28192,6 +28074,10 @@ } } }, + "A factor that multiplies the current maxSMBBasalMinutes and maxUAM/SMBBasalMinutes beyond the 180 minute limit set in Trio." : { + "comment" : "A verbose hint explaining the purpose of the \"SMB Max RangeExtension\" setting.", + "isCommentAutoGenerated" : true + }, "A few important notes…" : { "localizations" : { "bg" : { @@ -28980,6 +28866,33 @@ } } }, + "A Profile is a named snapshot of your full therapy and algorithm setup — basal rate, ISF, CR, BG targets, plus every Algorithm Settings toggle. You keep one baseline profile and build alternates (sick day, exercise, menstrual cycle, travel, test modes) from it. Switching profiles swaps the entire setup in one tap." : { + "comment" : "A description of what a Profile is.", + "isCommentAutoGenerated" : true + }, + "A safety knob that controls how close to your low-target SMBs are still allowed. It interpolates the SMB cutoff along the line between your low-target and 40 mg/dL." : { + + }, + "A safety knob that controls how close to your low-target SMBs are still allowed. It shifts the SMB cutoff (\"threshold\") along the line between your low-target and 40 mg/dL." : { + "comment" : "A verbose hint for the SMB Threshold Ratio setting, explaining how the setting affects how low glucose levels trigger scheduled meals.", + "isCommentAutoGenerated" : true + }, + "A Schedule fires a profile activation at a future time — once, or on a repeating cadence. It lets you automate profile switches that follow a predictable pattern (sick days, night mode, workouts, menstrual cycle, travel)." : { + "comment" : "A description of what a Schedule is.", + "isCommentAutoGenerated" : true + }, + "A special case: setting an even-numbered temp target below %@ %@ (e.g. %@ %@) signals that you want maximum SMB aggression — autoISF calls this full-loop mode." : { + "comment" : "A description of the \"full-loop mode\" for odd-target-disabling-SMB.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "A special case: setting an even-numbered temp target below %1$@ %2$@ (e.g. %3$@ %4$@) signals that you want maximum SMB aggression — autoISF calls this full-loop mode." + } + } + } + }, "A Temporary Target replaces the current Target Glucose specified in Therapy settings." : { "localizations" : { "bg" : { @@ -29110,6 +29023,10 @@ } } }, + "A wider range makes SMBs ramp up more gradually; a narrower range makes them reach full aggression sooner." : { + "comment" : "Explanation of why a wider or narrower SMB delivery ratio range might be preferred.", + "isCommentAutoGenerated" : true + }, "Abbreviation for 'Deviation'. How much the actual glucose change deviated from the BGI." : { "localizations" : { "bg" : { @@ -29370,6 +29287,9 @@ } } }, + "About Profiles" : { + "comment" : "Help hint label on the AdaptProfile list screen" + }, "About Quick-Pick Treatments" : { "comment" : "Info sheet title for quick-pick treatments feature", "localizations" : { @@ -29501,6 +29421,9 @@ } } }, + "About Schedules" : { + "comment" : "Help hint label on the Profile Scheduler list screen" + }, "About the Process" : { "extractionState" : "manual", "localizations" : { @@ -30166,6 +30089,25 @@ } } } + }, + "Absolute Safety TBR" : { + "comment" : "Absolute TBR" + }, + "Absolute safety TBR provides a fixed insulin rate for safety, useful for consistent protection." : { + "comment" : "Absolute TBR VerboseHint" + }, + "acce" : { + "comment" : "Label for acce" + }, + "acce_ISF is calculated by\nacce_ISF = 1 + acce_weight * fit_share * cap_weight * acceleration\nwhere fit_share is a measure of fit quality, i.e., 0% if unacceptable up to 100% if perfect;\ncap_weight is 0.5 below target and 1.0 otherwise;\nacce_weight is bgAccel_ISF_weight for acceleration away from target, i.e., mostly positive\nor bgBrake_ISF_weight for acceleration towards target, i.e., mostly negative.\n\nInitially, it was assumed that the weights for accelerating and braking are of similar size.\nFirst experiences suggest that the weight while decelerating should be 30-40% lower than for acceleration to reduce glucose oscillations. Quite often the acce_ISF contribution plays the dominant role inside autoISF and is therefore very important and delicate.\n\nWeights for acce_ISF of 0 disable this contribution. Start small with weights like 0.02 and observe the results before increasing them. Keep in mind that negative acceleration will start to happen while glucose is apparently still rising but the slope reduces. Here, acce_ISF will be <1, i.e., sensitivity grows and less insulin than normal will be required even before the glucose peak is reached." : { + "comment" : "Detailed explanation of the BG acceleration algorithm.", + "isCommentAutoGenerated" : true + }, + "Acce-ISF" : { + + }, + "acce-ISF: %@" : { + }, "Acoustic" : { "comment" : "The category of the alarm sound.", @@ -30302,6 +30244,14 @@ } } }, + "Activate a stored profile now for a chosen duration. Indefinite activation is not available via Shortcuts." : { + "comment" : "Description of the `ApplyProfileIntent`.", + "isCommentAutoGenerated" : true + }, + "Activate a Temporary Target preset now" : { + "comment" : "Title of the intent to apply a temporary target preset immediately.", + "isCommentAutoGenerated" : true + }, "Activate an override" : { "localizations" : { "bg" : { @@ -30432,6 +30382,15 @@ } } }, + "Activate autoISF" : { + "comment" : "Row label for the Activate autoISF toggle" + }, + "Activate B30 EatingSoon" : { + "comment" : "Enable B30" + }, + "Activate KetoProtection" : { + "comment" : "Enable KetoProtection" + }, "Activate Override" : { "localizations" : { "bg" : { @@ -30692,6 +30651,39 @@ } } } + }, + "Activate Profile" : { + "comment" : "The title of the sheet that appears when a user taps a non-active profile row in the \"Profiles\" list.", + "isCommentAutoGenerated" : true + }, + "Activate Profile ${profile}" : { + + }, + "Activate TT preset" : { + "comment" : "Text for the \"Activate TT preset\" shortcut.", + "isCommentAutoGenerated" : true + }, + "Activated: Safety TBR only kicks in when IOB is in neg. range below current Basal Rate and Active Insulin is also negative." : { + "comment" : "Variable Protection VerboseHint" + }, + "Activates at" : { + "comment" : "A label describing a date picker for setting the", + "isCommentAutoGenerated" : true + }, + "Activates at %@" : { + "comment" : "A sublabel below a profile name that shows when a profile will be activated. The time shown is formatted as \"HH:mm\".", + "isCommentAutoGenerated" : true + }, + "Activation" : { + "comment" : "A label describing how a schedule's activation is determined.", + "isCommentAutoGenerated" : true + }, + "Activation failed" : { + "comment" : "An alert title indicating that activation failed.", + "isCommentAutoGenerated" : true + }, + "Activation modes" : { + }, "active" : { "comment" : "Accessibility: alarms active", @@ -30955,6 +30947,10 @@ } } }, + "Active adjustment" : { + "comment" : "An accessibility label for the button that appears in the bottom-right corner of the app, when there is an active adjustment (either an override, a temporary target, or a profile).", + "isCommentAutoGenerated" : true + }, "Active During" : { "comment" : "A section header for the active glucose alert.", "localizations" : { @@ -31086,6 +31082,13 @@ } } }, + "Active Profile" : { + "comment" : "Fallback name on Home adjustments banner for the active profile when it has no name set\nHome stats panel face option" + }, + "Active schedules" : { + "comment" : "A header label for the list of active schedules in the profile scheduler view.", + "isCommentAutoGenerated" : true + }, "Active Time" : { "comment" : "description label for active time pod details rowdescription label for active time pod details row", "extractionState" : "manual", @@ -31224,6 +31227,9 @@ } } }, + "Adaptive UKF Smoothing" : { + "comment" : "Adaptive UKF smoothing algorithm display name" + }, "Add" : { "comment" : "Add profile basal", "localizations" : { @@ -31495,6 +31501,7 @@ }, "Add %lld g carbs" : { "comment" : "A confirmation message that asks the user to confirm adding a certain amount of carbs, optionally including fat and protein.", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -31884,6 +31891,10 @@ } } }, + "Add a Profile by tapping 'Add Profile +' in the top right-hand corner of the screen." : { + "comment" : "A description displayed when there are no profiles to display. It instructs the user on how to add a new profile.", + "isCommentAutoGenerated" : true + }, "Add Alarm" : { "comment" : "A title for a sheet that lets the user add a glucose alert.", "localizations" : { @@ -34795,6 +34806,10 @@ } } }, + "Add Mock Garmin Watch" : { + "comment" : "A button label that allows users to add a mock Garmin watch for testing purposes.", + "isCommentAutoGenerated" : true + }, "Add Omnipod" : { "comment" : "Add Omnipod pump", "extractionState" : "manual", @@ -35720,6 +35735,10 @@ } } }, + "Add Profile" : { + "comment" : "A button label that, when tapped, presents a sheet to add a new profile.", + "isCommentAutoGenerated" : true + }, "Add pump" : { "localizations" : { "bg" : { @@ -35981,6 +36000,10 @@ } } }, + "Add Schedule" : { + "comment" : "A button label that appears in the navigation bar of the \"Add Schedule\" sheet.", + "isCommentAutoGenerated" : true + }, "Add Simulator" : { "comment" : "Add Simulator pump", "extractionState" : "manual", @@ -37163,6 +37186,10 @@ } } }, + "Additional minutes (0–55)" : { + "comment" : "Label for a parameter that lets the user specify additional minutes when activating a profile.", + "isCommentAutoGenerated" : true + }, "Additional notification types" : { "extractionState" : "manual", "localizations" : { @@ -37301,6 +37328,7 @@ } }, "Additionals" : { + "comment" : "Navigation-link title for the additional/advanced oref settings section in the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -37843,6 +37871,9 @@ } } } + }, + "adj." : { + }, "Adjust basal" : { "comment" : "Headline \"Adjust basal\"", @@ -37983,6 +38014,7 @@ } }, "Adjust Basal" : { + "comment" : "Row label for the Dynamic-ISF Adjust-Basal toggle", "localizations" : { "bg" : { "stringUnit" : { @@ -38112,6 +38144,12 @@ } } }, + "Adjust current Therapy Setting" : { + "comment" : "Hint mini-label shown when tapping info on the therapy-adjustment percentage row of Add Profile form" + }, + "Adjust current Therapy Settings" : { + "comment" : "Row label on Add Profile form for the percentage that rescales Basal/ISF/CR" + }, "Adjust Dynamic ISF constant" : { "comment" : "Headline \"Adjust Dynamic ISF constant\"Adjust Dynamic ISF constantHeadline \"Adjust Dynamic ISF constant\"", "extractionState" : "manual", @@ -38382,6 +38420,10 @@ } } }, + "Adjust Limits for Diluted Insulin" : { + "comment" : "A section that provides information about adjusting insulin limits when using diluted insulin.", + "isCommentAutoGenerated" : true + }, "Adjust Ring Settings" : { "localizations" : { "bg" : { @@ -38641,6 +38683,13 @@ } } } + }, + "Adjusted basal rates are rounded down to the nearest pump-supported rate. ISF is rounded to 1 mg/dL steps, CR to 0.1 g/U. You can fine-tune each value manually in the editor after tapping Next." : { + "comment" : "A detailed description of how the therapy adjustment percentage affects the Basal Rate, ISF, and CR values in the new profile.", + "isCommentAutoGenerated" : true + }, + "Adjusted rates" : { + }, "Adjusting this value not only can adjust how quickly your sensitivity will respond to changing glucose readings, but also at what glucose readings you reach your Autosens Max/Min limits." : { "localizations" : { @@ -38903,6 +38952,7 @@ } }, "Adjustment Factor (AF)" : { + "comment" : "Row label for the Dynamic ISF Adjustment Factor (AF) setting", "localizations" : { "bg" : { "stringUnit" : { @@ -39033,6 +39083,7 @@ } }, "Adjustment Tabs" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -40094,6 +40145,15 @@ } } } + }, + "AIMI B30" : { + "comment" : "Navigation-link title for the AIMI B30 extension section in the draft profile editor" + }, + "AIMI B30 Settings" : { + + }, + "aiSR" : { + }, "Alarm" : { "comment" : "Name of a critical-alarm sound file.", @@ -40755,6 +40815,9 @@ } } }, + "Algo & Targets tuned" : { + "comment" : "Profile summary badge — both algorithm preferences and glucose targets differ from defaults" + }, "Algorithm" : { "comment" : "Algorithm menu item in the Settings main view.", "localizations" : { @@ -40886,6 +40949,15 @@ } } }, + "Algorithm — not looped in %lld minutes." : { + "comment" : "Loop status popup title when no recent loop — interpolation is minutes count" + }, + "Algorithm enacted at" : { + "comment" : "Headline in enacted popup" + }, + "Algorithm enacted at %@" : { + "comment" : "Loop status popup title when a recent loop has enacted — interpolation is a clock time" + }, "Algorithm Error" : { "localizations" : { "bg" : { @@ -41016,6 +41088,14 @@ } } }, + "Algorithm reasoning" : { + "comment" : "A label displayed above the list of reasons provided by the algorithm.", + "isCommentAutoGenerated" : true + }, + "Algorithm Selection" : { + "comment" : "A label displayed above the picker for selecting a glucose smoothing algorithm.", + "isCommentAutoGenerated" : true + }, "Algorithm Settings" : { "localizations" : { "bg" : { @@ -41146,7 +41226,14 @@ } } }, + "Algorithm Settings tuned" : { + "comment" : "Profile summary badge — algorithm preferences differ from defaults" + }, + "Algorithm suggested at" : { + "comment" : "Headline in suggested popup" + }, "Algorithmic Target Settings" : { + "comment" : "Section header on the Target Behavior screen grouping sensitivity-vs-target controls", "localizations" : { "bg" : { "stringUnit" : { @@ -42621,6 +42708,7 @@ } }, "All settings are at default values." : { + "comment" : "Validation error on Add Override form when the override would be a no-op (all defaults)\nValidation error on Edit Override form when the override would be a no-op (all defaults)", "localizations" : { "bg" : { "stringUnit" : { @@ -42879,6 +42967,9 @@ } } } + }, + "Allow Bolus Commands" : { + }, "Allow Bolusing with Shortcuts" : { "localizations" : { @@ -43009,6 +43100,12 @@ } } } + }, + "Allow diluted Insulin" : { + "comment" : "Allow diluted Insulin" + }, + "Allow Dilution" : { + }, "Allow Fetching from Nightscout" : { "localizations" : { @@ -43401,6 +43498,7 @@ } }, "Allow SMB for 6 hrs after a carb entry." : { + "comment" : "Mini-hint for Enable SMB After Carbs setting", "localizations" : { "bg" : { "stringUnit" : { @@ -43531,6 +43629,7 @@ } }, "Allow SMB when a manual Temporary Target is set greater than %@ %@." : { + "comment" : "Mini-hint for Allow SMB With High Temptarget — interpolated values are the threshold number and the glucose unit", "localizations" : { "bg" : { "stringUnit" : { @@ -43667,6 +43766,7 @@ } }, "Allow SMB when a manual Temporary Target is set under %@ %@." : { + "comment" : "Mini-hint for Enable SMB With Temptarget — interpolated values are the threshold number and the glucose unit", "localizations" : { "bg" : { "stringUnit" : { @@ -43803,6 +43903,7 @@ } }, "Allow SMB when carbs are on board." : { + "comment" : "Mini-hint for Enable SMB With COB setting", "localizations" : { "bg" : { "stringUnit" : { @@ -43933,6 +44034,7 @@ } }, "Allow SMB when glucose is above the High Glucose Target value." : { + "comment" : "Mini-hint for Enable SMB With High Glucose setting", "localizations" : { "bg" : { "stringUnit" : { @@ -44194,6 +44296,7 @@ } }, "Allow SMBs at all times except when a high Temp Target is set." : { + "comment" : "Mini-hint for Enable SMB Always setting", "localizations" : { "bg" : { "stringUnit" : { @@ -44452,6 +44555,12 @@ } } } + }, + "Allow the Garmin app to deliver insulin. Requires watch commands to be enabled." : { + + }, + "Allow the Garmin app to log carbs and start or stop overrides and temp targets." : { + }, "Allow to add carbs in Trio." : { "localizations" : { @@ -46856,6 +46965,7 @@ } }, "Alter the rate of Dynamic ISF (Sensitivity) adjustments." : { + "comment" : "Mini-hint for Dynamic ISF Adjustment Factor (AF) setting", "localizations" : { "bg" : { "stringUnit" : { @@ -46986,6 +47096,7 @@ } }, "Alter the rate of dynamic sensitivity adjustments for Sigmoid." : { + "comment" : "Mini-hint for Sigmoid Dynamic ISF Adjustment Factor", "localizations" : { "bg" : { "stringUnit" : { @@ -48047,6 +48158,9 @@ } } }, + "Amount in U/hr of small safety TBR to avoid ketoacidosis." : { + "comment" : "Absolute Safety TBR miniHint" + }, "Amount of Carbs on Board (COB) used in the most recent automated dosing calculation." : { "localizations" : { "bg" : { @@ -48311,6 +48425,9 @@ "comment" : "Name of a spoken alert voice.", "isCommentAutoGenerated" : true }, + "An EatingSoon TempTarget needs to be enabled to start B30 adaption. Set level for this target to be identified." : { + "comment" : "B30 TT Level miniHint" + }, "An external bolus of %@ U of insulin was recorded." : { "localizations" : { "bg" : { @@ -48441,6 +48558,9 @@ } } }, + "An indefinite activation updates the pump's scheduled basal to match this profile. The pump's basal schedule will be overwritten." : { + "comment" : "Body of schedule-fire notification warning the user that confirming will overwrite the pump's basal schedule" + }, "An unknown authentication error occurred. Please try again." : { "localizations" : { "bg" : { @@ -49099,6 +49219,7 @@ } }, "Anonymous usage statistics include the app version and build, device and iOS version, which pump and CGM you have paired, and whether Nightscout, Tidepool, and Apple Health are configured (yes/no — no URLs or credentials). They are sent to a self-hosted Trio telemetry endpoint." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -49228,6 +49349,10 @@ } } }, + "Any schedules that activate this profile will also be removed." : { + "comment" : "A message displayed in a confirmation dialog when deleting a profile.", + "isCommentAutoGenerated" : true + }, "API secret" : { "comment" : "API secret in NS", "localizations" : { @@ -51049,8 +51174,12 @@ } } } + }, + "Apply ${preset}" : { + }, "Apply a Temporary Target" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -51311,6 +51440,7 @@ } }, "Applying ${preset}" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -51570,6 +51700,10 @@ } } }, + "Are you sure to switch to U%lld?" : { + "comment" : "A confirmation dialog that asks the user to confirm switching insulin concentration.", + "isCommentAutoGenerated" : true + }, "Are you sure you want to delete %@?" : { "localizations" : { "bg" : { @@ -52111,6 +52245,22 @@ } } }, + "AS" : { + + }, + "as often diluted insulins are used for patients with small insulin dosing requirements." : { + "comment" : "A text that explains the use of diluted insulin.", + "isCommentAutoGenerated" : true + }, + "Ask on Discord" : { + + }, + "Ask on Facebook" : { + + }, + "at %@" : { + "comment" : "Schedule summary time prefix — interpolated list is two-digit HH:MM values joined with ' & '" + }, "at least" : { "localizations" : { "bg" : { @@ -53549,6 +53699,10 @@ } } }, + "Auto-revert: when a temp profile expires, you get a notification (\"Profile X expired — reverted to Y\")." : { + "comment" : "A notification that appears when a temporary (indefinite or \"until next\") profile expires, informing the user of the profile it was replaced by.", + "isCommentAutoGenerated" : true + }, "Auto-sensitivity (Autosens) adjusts insulin delivery based on observed sensitivity or resistance." : { "localizations" : { "bg" : { @@ -53679,6 +53833,65 @@ } } }, + "autoISF" : { + "comment" : "Navigation-link title for the autoISF extension section in the draft profile editor" + }, + "AutoISF" : { + + }, + "autoISF 3.01" : { + "comment" : "Title label of the autoISF extension (version 3.01)" + }, + "autoISF 3.01 calculates insulin sensitivity (ISF) each loop cycle based on glucose behaviour within set limits." : { + "comment" : "Mini-hint for Activate autoISF toggle" + }, + "autoISF 3.01 Documentation" : { + + }, + "autoISF allows to adapt the insulin sensitivity factor (ISF) in the following scenarios of glucose behaviour:" : { + + }, + "autoISF calculations" : { + "comment" : "The title of the view that lists autoISF calculations.", + "isCommentAutoGenerated" : true + }, + "autoISF can adapt ISF based on glucose delta. It was introduced to help users with gastroparesis. It is also useful for users in pure UAM mode because in their case no meal start can be detected. Given a positive short_avgdelta and glucose being above target+10, the result is:\n\npp_ISF = 1 + delta * pp_ISF_weight.\n\nAs a starting value for pp_ISF_weight, use 0.005. Observe the reactions and check the Enacted Popup before you increase it with care. A weight of 0 disables this contribution." : { + "comment" : "A description of the `autoISF` feature, explaining how it works and what its parameters do.", + "isCommentAutoGenerated" : true + }, + "AutoISF Hourly Change" : { + + }, + "autoISF IOB Threshold Percent" : { + "comment" : "IOB Threshold" + }, + "autoISF Max" : { + "comment" : "Row label on Add Override form — autoISF Max\nRow label on Edit Override form — autoISF Max\nautoISF Max" + }, + "AutoISF Max" : { + + }, + "autoISF Min" : { + "comment" : "Row label on Add Override form — autoISF Min\nRow label on Edit Override form — autoISF Min\nautoISF Min" + }, + "AutoISF Min" : { + + }, + "AutoISF Off Sport" : { + + }, + "autoISF Sens Ratio (aiSR)" : { + + }, + "autoISF Settings" : { + + }, + "AutoISF Settings" : { + + }, + "autoISF will enable SMBs for even and block them for odd Targets." : { + "comment" : "Odd Target disables SMB miniHint" + }, "Automate boluses using the iOS Shortcuts App." : { "localizations" : { "bg" : { @@ -54208,6 +54421,7 @@ } }, "Autosens" : { + "comment" : "Hint mini-label on the Autosens settings screen when explaining the live Sensitivity Ratio\nNavigation-link title for the Autosens section in the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -54727,6 +54941,10 @@ } } }, + "autosens is not needed for autoISF as it adapts on a longer time frame than autoISF, so any autosens adjustment is lagging behind what is done by autoISF. It can be kept to ON, and in some border cases the autosens ISF will be used. Check on Discord." : { + "comment" : "A verbose hint explaining the purpose of the \"Enable Autosens\" setting, when \"Enable Autosens\" is set to \"ON\".", + "isCommentAutoGenerated" : true + }, "Autosens Max" : { "comment" : "Autosens Max", "localizations" : { @@ -56030,6 +56248,9 @@ } } } + }, + "autosens Ratio (AS)" : { + }, "Autosens Ratio =\n(Weighted Average of TDD) / (10-day Average of TDD)" : { "localizations" : { @@ -56834,6 +57055,9 @@ } } } + }, + "Available basal increment:" : { + }, "Available Layouts:" : { "localizations" : { @@ -57229,15 +57453,15 @@ } } }, - "Average of the Last 24 Hours" : { - "comment" : "Stats banner subtitle" - }, "Average of the Last 3 Months" : { "comment" : "Stats banner subtitle" }, "Average of the Last 7 Days" : { "comment" : "Stats banner subtitle" }, + "Average of the Last 24 Hours" : { + "comment" : "Stats banner subtitle" + }, "Average of the Last Month" : { "comment" : "Stats banner subtitle" }, @@ -57371,9 +57595,6 @@ } } }, - "Averages" : { - "comment" : "Home stats panel face option" - }, "Avg" : { "localizations" : { "bg" : { @@ -57635,6 +57856,55 @@ } } }, + "B30" : { + + }, + "B30 Basal Factor" : { + + }, + "B30 Basal Rate Increase Factor" : { + "comment" : "B30 Factor" + }, + "B30 iTime" : { + + }, + "B30 iTime Start Bolus" : { + + }, + "B30 iTime Target" : { + + }, + "B30 Settings" : { + + }, + "B30 Upper Delta" : { + + }, + "B30 Upper Limit" : { + + }, + "B30 will only run & supress SMB as long as BG stays underneath that level. Default is %@ %@." : { + "comment" : "B30 Upper BG Limit miniHint", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "B30 will only run & supress SMB as long as BG stays underneath that level. Default is %1$@ %2$@." + } + } + } + }, + "B30 will only run & supress SMB's as long as BG delta stays below that level. Default is %@ %@." : { + "comment" : "B30 Upper Delta miniHint", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "B30 will only run & supress SMB's as long as BG delta stays below that level. Default is %1$@ %2$@." + } + } + } + }, "Back" : { "localizations" : { "bg" : { @@ -59894,6 +60164,9 @@ } } } + }, + "Basal rate" : { + }, "Basal Rate" : { "localizations" : { @@ -60416,6 +60689,7 @@ } }, "Basal Rates" : { + "comment" : "Navigation-link title for the Basal Rates editor inside the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -61471,6 +61745,10 @@ } } }, + "Because CR combines two independent physiological processes — CSF (carb absorption into blood) and ISF (insulin moving glucose out of blood) — calculating CR this way ensures it adjusts correctly across the day as your ISF profile changes, while CSF remains stable. If you do experience variation in carb absorption throughout the day, you can account for that in your CSF profile based on real physiological differences, rather than having CSF implicitly change as a mathematical side effect of a fixed CR and varying ISF." : { + "comment" : "A detailed explanation of how the Carb Sensitivity Factor (CSF) is calculated.", + "isCommentAutoGenerated" : true + }, "Because Trio utilizes SMBs and UAM SMBs to help you reach your target glucose and other AID systems do not bolus for COB the same way Trio does, this is initially set to below the full calculated amount (80%). When SMBs and UAM SMBs are enabled, you may find your current CR results in lows and needs to be increased before you increase this setting closer to or at 100%." : { "localizations" : { "bg" : { @@ -61601,6 +61879,10 @@ } } }, + "Becomes the new baseline. The basal profile is saved to the pump." : { + "comment" : "A description of what happens when the \"Once\" repeat kind is selected in the", + "isCommentAutoGenerated" : true + }, "Before you begin…" : { "localizations" : { "bg" : { @@ -62515,6 +62797,27 @@ } } } + }, + "bg" : { + "comment" : "Label for bg" + }, + "BG" : { + "comment" : "Label for BG" + }, + "BG Accel ISF Weight" : { + + }, + "BG Acceleration:" : { + + }, + "BG Brake ISF Weight" : { + + }, + "BG Plateau:" : { + + }, + "BG-ISF" : { + }, "BGI" : { "localizations" : { @@ -63696,6 +63999,10 @@ } } }, + "Blue entries differ from the source profile. Tap any row to review or adjust." : { + "comment" : "A footer label under the \"Profile\" section of the draft editor.", + "isCommentAutoGenerated" : true + }, "Bluetooth" : { "localizations" : { "bg" : { @@ -64500,7 +64807,7 @@ } }, "Bolus" : { - "comment" : "Treatment type\nWatch App Treatment Option 'Bolus'", + "comment" : "Treatment history type label — Bolus\nTreatment row label — fallback when event type is missing\nTreatment type\nWatch App Treatment Option 'Bolus'", "localizations" : { "bg" : { "stringUnit" : { @@ -65812,6 +66119,7 @@ } }, "Bolus Display Threshold" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -66202,6 +66510,9 @@ } } }, + "Bolus exceeds max bolus." : { + "comment" : "Watch command ack" + }, "Bolus failed" : { "comment" : "Bolus failed", "localizations" : { @@ -66333,6 +66644,12 @@ } } }, + "Bolus failed. Check pump history before repeating." : { + "comment" : "Watch command ack" + }, + "Bolus from watch is disabled." : { + "comment" : "Watch command ack" + }, "Bolus In Progress..." : { "localizations" : { "bg" : { @@ -66860,6 +67177,9 @@ } } } + }, + "Bolus Label Threshold" : { + }, "Bolus limit cannot be fetched from phone!" : { "localizations" : { @@ -67528,6 +67848,9 @@ } } }, + "Bolus started." : { + "comment" : "Watch command ack" + }, "Bolus statistics will appear here once data is available." : { "localizations" : { "bg" : { @@ -67796,6 +68119,9 @@ } } }, + "Bolus would exceed max IOB." : { + "comment" : "Watch command ack" + }, "Bolus/Basal %" : { "localizations" : { "bg" : { @@ -69250,6 +69576,13 @@ } } }, + "Calculate CR from CSF & ISF Profiles" : { + "comment" : "A button that calculates the user's Carb Ratio (CR) from their Insulin Sensitivity Factor (ISF) and Carb Sensitivity Factor (CSF) profiles.", + "isCommentAutoGenerated" : true + }, + "Calculate CSF from profile CR and ISF." : { + "comment" : "Mini hint for Use Profile CSF" + }, "Calculated from glucose readings in the selected period. Short periods and gaps in sensor data can make the score less representative." : { "comment" : "Explains that GRI uses readings from the selected period and may be less representative for short periods or incomplete data.", "isCommentAutoGenerated" : true @@ -69651,6 +69984,9 @@ } } } + }, + "Calculation Details for %@" : { + }, "Calculator Configuration" : { "localizations" : { @@ -70853,7 +71189,7 @@ } }, "Cancel" : { - "comment" : "Cancel preset name", + "comment" : "Schedule activation notification: cancel / dismiss action", "localizations" : { "bg" : { "stringUnit" : { @@ -71772,6 +72108,10 @@ } } }, + "Cancel Temp Basal" : { + "comment" : "A button label that dismisses the \"Manual Temp Basal\" view.", + "isCommentAutoGenerated" : true + }, "Cancel Temp Target" : { "comment" : "Alert", "extractionState" : "manual", @@ -72039,6 +72379,12 @@ } } } + }, + "Cancel TT" : { + + }, + "Cannot change Concentration" : { + }, "Cannot Enable This Setting" : { "localizations" : { @@ -72169,8 +72515,12 @@ } } } + }, + "Carb entries will be fully decayed by the number of hours specified as Max Meal Absorption Time. Meals that are high in fat and/or protein can have long lasting effects on BG levels. To allow such late meal effects to be considered by the carb decay model, a longer Max Meal Absorption Time than the default 6 hours can be set." : { + }, "Carb entries will be fully decayed by the number of hours specified as Max Meal Absorption Time. Meals that are high in fat and/or protein can have long lasting effects on glucose levels. To allow such late meal effects to be considered by the carb decay model, a longer Max Meal Absorption Time than the default 6 hours can be set." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -72828,7 +73178,7 @@ } }, "Carb Ratios" : { - "comment" : "Carb Ratios header", + "comment" : "Navigation-link title for the Carb Ratio (CR) editor inside the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -72958,6 +73308,27 @@ } } }, + "Carb Sensitivities" : { + "comment" : "The title of the screen where a user can track their carbohydrate sensitivities.", + "isCommentAutoGenerated" : true + }, + "Carb Sensitivity Factor (CSF)" : { + "comment" : "Header for CSF section" + }, + "Carb Sensitivity Factor (CSF) represents how much your blood glucose rises per gram of carbohydrate consumed. It describes the digestive process of carbs entering your bloodstream as glucose — a process that is independent of insulin sensitivity." : { + "comment" : "A description of the Carb Sensitivity Factor (CSF).", + "isCommentAutoGenerated" : true + }, + "Carb Sensitivity Factor (CSF) represents how much your blood glucose rises per gram of carbohydrate consumed. It describes your digestive process - how effectively carbs are absorbed into your blood." : { + + }, + "Carb Sensitivity Factor (CSF) represents how much your blood glucose rises per gram of carbohydrate consumed. It is calculated as: CSF = ISF / CR (Insulin Sensitivity Factor divided by Carb Ratio)." : { + "comment" : "A description of what the Carb Sensitivity Factor (CSF) is, including a calculation.", + "isCommentAutoGenerated" : true + }, + "Carb Sensitivity Profile" : { + "comment" : "Carb Sensitivity Profile" + }, "Carbohydrates" : { "localizations" : { "bg" : { @@ -73089,7 +73460,7 @@ } }, "Carbs" : { - "comment" : "Treatment type", + "comment" : "Treatment history type label — carbohydrate entry\nTreatment type", "localizations" : { "bg" : { "stringUnit" : { @@ -73488,6 +73859,9 @@ } } }, + "Carbs exceed the maximum allowed." : { + "comment" : "Watch command ack" + }, "Carbs Limit Reached!" : { "localizations" : { "bg" : { @@ -73618,6 +73992,9 @@ } } }, + "Carbs logged and bolus started." : { + "comment" : "Watch command ack" + }, "Carbs logged successfully." : { "comment" : "Success message sent to watch when carbs are logged successfully", "localizations" : { @@ -73749,6 +74126,15 @@ } } }, + "Carbs logged." : { + "comment" : "Watch command ack" + }, + "Carbs logged. Bolus failed, check pump history before repeating." : { + "comment" : "Watch command ack" + }, + "Carbs logged. No bolus was delivered." : { + "comment" : "Watch command ack" + }, "Carbs Not Logged" : { "comment" : "Alert title when a carbs-only quick pick fails to save\nAlert title when the bolus was enacted but carbs failed to save", "localizations" : { @@ -78240,6 +78626,10 @@ } } }, + "Change Setting" : { + "comment" : "A section header that allows the user to change the insulin concentration setting.", + "isCommentAutoGenerated" : true + }, "Changes the size of the current glucose reading and its trend arrow on the Simple Lock Screen widget. The delta and the time of the last reading are not affected." : { }, @@ -79553,6 +79943,14 @@ } } }, + "Check the other additional \"advanced settings\" like the basal safety multipliers, as they are also reset to defaults during onboarding." : { + "comment" : "A note explaining that the user should check the additional advanced settings, as they are also reset to their default values during onboarding.", + "isCommentAutoGenerated" : true + }, + "Check your Basal Profile after adding Pump!" : { + "comment" : "A confirmation dialog message that appears after confirming a insulin concentration switch.", + "isCommentAutoGenerated" : true + }, "Chime" : { "comment" : "Name of a sound file.", "localizations" : { @@ -79684,6 +80082,12 @@ } } }, + "Choose 'Show all labels' to keep every label, or pick a higher threshold to clean up the chart when there are many small SMBs." : { + + }, + "Choose a duration first" : { + "comment" : "Slide-to-confirm button label shown (disabled) when no duration is set and indefinite is off" + }, "Choose a value that covers your highest insulin needs — think about a correction for a very high glucose reading plus your biggest meal bolus. This gives Trio room to work while keeping you safe." : { "localizations" : { "bg" : { @@ -80597,6 +81001,28 @@ } } }, + "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", + "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 %@)." + } + } + } + }, + "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 time span the home statistics panel covers." : { }, @@ -80731,6 +81157,7 @@ } }, "Choose to hide small bolus amounts. See hint for more details." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -81122,6 +81549,7 @@ } }, "Choose type of time in range to be used for Trio's statistics." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -81381,8 +81809,8 @@ } } }, - "Choose what the statistics panel on the home screen shows by default. Tapping the panel always opens the full statistics view.\n\nTime in Range: today's time in range percentage with a glucose distribution bar.\n\nDistribution Bar Only: just the glucose distribution bar, without the percentage.\n\nToday's Averages: today's average glucose and GMI (Glucose Management Index).\n\nHide Statistics: shows no data at all, just a \"View Statistics\" shortcut." : { - "extractionState" : "stale" + "Choose what the statistics panel on the home screen shows by default. Tapping the panel always opens the full statistics view.\n\nTime in Range: time in range percentage with a glucose distribution bar.\n\nDistribution Bar Only: just the glucose distribution bar, without the percentage.\n\nToday's Averages: average glucose and GMI (Glucose Management Index).\n\nActive Profile: the active profile card. Temporary (expiring) profiles are always shown regardless of this setting.\n\nHide Statistics: shows no data at all, just a \"View Statistics\" shortcut." : { + }, "Choose what the statistics panel on the home screen shows by default. Tapping the panel always opens the full statistics view.\n\nTime in Range: today's time in range percentage with a glucose distribution bar.\n\nDistribution Bar Only: just the glucose distribution bar, without the percentage.\n\nToday's Averages: today's average glucose and GMI (Glucose Management Index).\n\nToday's Treatments: insulin delivered since midnight — no glucose statistics. Announced carbs (and fat and protein, if you log them) appear once logged that day.\n\nHide Statistics: shows no data at all, just a \"View Statistics\" shortcut." : { "extractionState" : "stale", @@ -81514,9 +81942,6 @@ } } } - }, - "Choose what the statistics panel on the home screen shows by default. Tapping the panel always opens the full statistics view.\n\nTime in Range: your time in range percentage with a glucose distribution bar.\n\nDistribution Bar Only: just the glucose distribution bar, without the percentage.\n\nAverages: your average glucose and GMI (Glucose Management Index).\n\nHide Statistics: shows no data at all, just a \"View Statistics\" shortcut.\n\nThe numbers cover the time span set in Home Statistics Range." : { - }, "Choose whether or not to display one or both X- and Y-Axis grid lines." : { "localizations" : { @@ -81908,7 +82333,12 @@ } } }, + "Choose which smoothing algorithm shall be applied." : { + "comment" : "A description below the picker in the \"Smoothing Algorithm\" section, explaining that the user can choose which algorithm to use for smoothing glucose readings.", + "isCommentAutoGenerated" : true + }, "Choose which type of time in range Trio should adopt for all its statistical charts and displays:" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -82568,6 +82998,7 @@ } }, "Close" : { + "comment" : "Close button label", "localizations" : { "bg" : { "stringUnit" : { @@ -83355,6 +83786,9 @@ } } } + }, + "COB value" : { + }, "COBpredBG" : { "localizations" : { @@ -83878,6 +84312,9 @@ }, "Combined score" : { "comment" : "Sum of the weighted GRI components before applying the cap." + }, + "Combined SMB cutoff calculation:" : { + }, "Coming soon." : { "localizations" : { @@ -84008,6 +84445,24 @@ } } } + }, + "Command expired. Please try again." : { + "comment" : "Watch command ack" + }, + "Commands" : { + + }, + "Commands are handled by another watch app." : { + "comment" : "Watch command ack" + }, + "Commands are only accepted from one Garmin app: Trio-Complication when it is selected as watchface, otherwise Trio-WatchControls. Changing the watchface switches the command app right away and cancels commands still pending." : { + + }, + "Commands are only accepted from the Garmin apps selected above, must reach the phone within 2 minutes of being confirmed on the watch, and carbs are limited by your Max Carbs setting." : { + + }, + "Commands must reach the phone within 2 minutes of being confirmed on the watch, and carbs are limited by your Max Carbs setting." : { + }, "complete" : { "comment" : "Accessibility: onboarding chapter complete", @@ -84140,6 +84595,9 @@ } } }, + "Completely switches off autoISF during a high TT with adjusted sensitivity." : { + "comment" : "Exercise toggles all autoISF adjustments off miniHint" + }, "Complication" : { "comment" : "Display name for the Garmin watchface setting option for the Connect IQ watch app.", "localizations" : { @@ -84270,6 +84728,12 @@ } } } + }, + "Concentration Information" : { + + }, + "Concentration Settings" : { + }, "Cone" : { "localizations" : { @@ -86529,6 +86993,14 @@ } } }, + "Confirm Before scheduling" : { + "comment" : "Label for a toggle that allows the user to confirm before scheduling a custom Temp Target.", + "isCommentAutoGenerated" : true + }, + "Confirm Before Scheduling" : { + "comment" : "A toggle that allows the user to confirm before a schedule is saved.", + "isCommentAutoGenerated" : true + }, "Confirm Bolus Faster" : { "localizations" : { "bg" : { @@ -86659,6 +87131,42 @@ } } }, + "Confirm Temp Target '%@': %@ for %lld min starting %@" : { + "comment" : "Confirmation dialog text for scheduling a custom Temp Target. The argument is the name of the Temp Target, the second argument is the target value, the third argument is the duration in minutes, and the fourth argument is the formatted start time.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Confirm Temp Target '%1$@': %2$@ for %3$lld min starting %4$@" + } + } + } + }, + "Confirm Temp Target '%@': %@ for %lld min, now" : { + "comment" : "Confirmation dialog text for creating a Temp Target. The first argument is the name of the Temp Target. The second argument is the formatted target value. The third argument is the duration in minutes.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Confirm Temp Target '%1$@': %2$@ for %3$lld min, now" + } + } + } + }, + "Confirm to activate profile '%@' for %@" : { + "comment" : "Confirmation dialog text for activating a profile. The first argument is the name of the profile. The second argument is the formatted duration of activation.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Confirm to activate profile '%1$@' for %2$@" + } + } + } + }, "Confirm to apply override '%@'" : { "localizations" : { "bg" : { @@ -86919,6 +87427,34 @@ } } }, + "Confirm to schedule profile '%@' at %@ for %@" : { + "comment" : "Confirmation dialog text for scheduling a profile. The first argument is the name of the profile. The second argument is the formatted date and time of activation. The third argument is the formatted duration of the activation.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Confirm to schedule profile '%1$@' at %2$@ for %3$@" + } + } + } + }, + "Confirm to schedule Temporary Target '%@' for %@" : { + "comment" : "Confirmation prompt for scheduling a Temp Target. The first argument is the name of the preset. The second argument is the formatted start time.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Confirm to schedule Temporary Target '%1$@' for %2$@" + } + } + } + }, + "Confirming pump writes" : { + "comment" : "A section header explaining how to confirm that the pump is updated with a new basal schedule.", + "isCommentAutoGenerated" : true + }, "Connect" : { "comment" : "Connect to NS", "localizations" : { @@ -89821,6 +90357,10 @@ } } }, + "Copied from" : { + "comment" : "A label displayed below the \"Name\" section, indicating that the current profile was copied from another.", + "isCommentAutoGenerated" : true + }, "Copies the recommended amount into the bolus field" : { "localizations" : { "bg" : { @@ -90340,6 +90880,9 @@ } } } + }, + "Correlation:" : { + }, "Could not access documents directory" : { "localizations" : { @@ -90738,6 +91281,12 @@ } } }, + "Could not log carbs." : { + "comment" : "Watch command ack" + }, + "Could not log carbs. No bolus was delivered." : { + "comment" : "Watch command ack" + }, "Could not prepare logs" : { }, @@ -90872,6 +91421,21 @@ } } }, + "Could not start override." : { + "comment" : "Watch command ack" + }, + "Could not start temp target." : { + "comment" : "Watch command ack" + }, + "Could not stop override." : { + "comment" : "Watch command ack" + }, + "Could not stop temp target." : { + "comment" : "Watch command ack" + }, + "Could not verify bolus safety." : { + "comment" : "Watch command ack" + }, "Count" : { "localizations" : { "bg" : { @@ -91133,6 +91697,10 @@ } } }, + "CR combines two independent physiological processes: CSF (carb absorption into blood) and ISF (insulin moving glucose out of blood). These processes operate separately, so when ISF changes due to exercise or stress, CSF remains stable, and CR adjusts accordingly through the formula CR = ISF / CSF." : { + "comment" : "A description of how the Carb Ratio (CR) is calculated.", + "isCommentAutoGenerated" : true + }, "Crash reporting and anonymous usage telemetry are enabled by default. Use the diagnostics-sharing chooser above to opt out: pick \"Crash Reports Only\" to keep crash reporting but disable telemetry, or \"Disable Sharing\" to turn off both. Changes take effect immediately." : { "comment" : "A description of the user's ability to opt out of sharing app usage data.", "localizations" : { @@ -91265,6 +91833,7 @@ } }, "Crash reports include the app's state on crash, device, iOS and general system info, and a stack trace. They are sent to a Google Firebase Crashlytics project maintained by the Trio team." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -91524,6 +92093,10 @@ } } }, + "Create and activate a Temporary Target now with name, target value and duration" : { + "comment" : "Description of the `CreateCustomTempTargetIntent`.", + "isCommentAutoGenerated" : true + }, "Create Events in Calendar" : { "extractionState" : "manual", "localizations" : { @@ -91661,8 +92234,19 @@ } } }, + "Create Temporary Target ${name} now" : { + + }, + "Create TT" : { + "comment" : "Text for a shortcut that allows the user to create a custom temporary target.", + "isCommentAutoGenerated" : true + }, "Creates one file you can send when asking for help." : { + }, + "Creating a new profile" : { + "comment" : "A label for a button that allows the user to create a new profile.", + "isCommentAutoGenerated" : true }, "Critical" : { "comment" : "Name of the critical alarm sound.", @@ -91925,8 +92509,20 @@ } } } + }, + "CSF" : { + "comment" : "Mark indicating the start and end of a CSF dose.", + "isCommentAutoGenerated" : true + }, + "CSF describes the digestive process of carbs entering your bloodstream as glucose. This process is not dependent on insulin sensitivity, which describes a different process: removing glucose from the blood." : { + "comment" : "Explanation of how the Carb Sensitivity Factor (CSF) is calculated.", + "isCommentAutoGenerated" : true + }, + "CSF is defined as the expected BG rise per 10g of carbs consumed. Using 10g units keeps values in a practical range and makes it easier to test and document: e.g. eat 10g of fast carbs and observe the BG rise." : { + }, "Current" : { + "comment" : "Fallback source-profile name used in the draft editor when no profile is currently active", "localizations" : { "bg" : { "stringUnit" : { @@ -92446,6 +93042,9 @@ } } } + }, + "Current Concentration" : { + }, "Current IOB (%@ U) > Max IOB (%@ U)" : { "localizations" : { @@ -93385,6 +93984,9 @@ } } } + }, + "Currently used Insulin Sensitivity Factor (ISF) in mg/dL or mmol/L per IU." : { + }, "Custom" : { "comment" : "Custom temp target", @@ -93525,6 +94127,7 @@ } }, "Custom Override" : { + "comment" : "Default name assigned to an override when the user saves edits without providing a name\nFallback display name for a duplicated override when the preset had no name\nFallback display name for the active override when no list entry exists\nFallback display name for the active override when no name was set\nFallback name for an unnamed historical override run uploaded to Nightscout\nFallback name for an unnamed override uploaded to Nightscout\nFallback name on Home adjustments banner for an active override without a name\nFallback name when checking whether a stored Nightscout override entry needs updating", "localizations" : { "bg" : { "stringUnit" : { @@ -93793,6 +94396,7 @@ } }, "Custom Temp Target" : { + "comment" : "Fallback display name for a duplicated temp target when the preset had no name\nFallback display name for the active temp target when no list entry exists\nFallback display name for the active temp target when no name was set", "localizations" : { "bg" : { "stringUnit" : { @@ -94311,6 +94915,9 @@ } } } + }, + "cutoff = clamp( max( Minimum Safety Threshold, ratioFloor ), 60, 120 ) mg/dL" : { + }, "CV" : { "comment" : "CV", @@ -94442,6 +95049,9 @@ } } } + }, + "Cycles the zoom through the 6, 12 and 24 hour presets." : { + }, "d" : { "comment" : "Abbreviation for Days\nabbreviation for days", @@ -94704,6 +95314,9 @@ } } } + }, + "Daily / Weekdays / Weekends / Custom weekly: fires at the chosen time on matching weekdays." : { + }, "Daily glucose distribution chart" : { "localizations" : { @@ -95484,6 +96097,9 @@ } } } + }, + "Data Interval:" : { + }, "Data transmission has been disabled. Now select the new watchface on your Garmin device and resume data transmission once done." : { "localizations" : { @@ -96005,6 +96621,18 @@ } } }, + "Date & time the preset should activate (within the next 24 hours)" : { + "comment" : "Prompt for the user to specify the date and time for scheduling a temporary target preset.", + "isCommentAutoGenerated" : true + }, + "Date & time the Temp Target should begin (within the next 24 hours)" : { + "comment" : "Prompt for the user to select a start time for the custom temporary target.", + "isCommentAutoGenerated" : true + }, + "Date and time the profile should activate" : { + "comment" : "Description of the \"Fire at\" parameter in the \"Schedule Profile\" intent.", + "isCommentAutoGenerated" : true + }, "Date of adding" : { "localizations" : { "bg" : { @@ -97456,6 +98084,13 @@ } } }, + "Days of month" : { + + }, + "Days that don't exist in a given month (e.g. 31 in February) are skipped." : { + "comment" : "A footer explaining that days like 31 in February are ignored.", + "isCommentAutoGenerated" : true + }, "DayStart" : { "localizations" : { "bg" : { @@ -98259,6 +98894,7 @@ } }, "Decrease sensitivity when glucose is below target if a manual Temp Target < %@ %@ is set." : { + "comment" : "Mini-hint for Low Temp Target Lowers Sensitivity — interpolated values are the threshold number and the glucose unit", "localizations" : { "bg" : { "stringUnit" : { @@ -98393,6 +99029,12 @@ } } } + }, + "Decreasing this setting may result in fewer FPU entries with larger carb values." : { + + }, + "Decreasing this setting may result in more FPU entries with smaller carb values." : { + }, "Default" : { "localizations" : { @@ -98525,6 +99167,7 @@ } }, "Default impact of carb absorption over a 5 minute interval." : { + "comment" : "Mini-hint for Min 5m Carb Impact setting", "localizations" : { "bg" : { "stringUnit" : { @@ -99458,6 +100101,12 @@ } } }, + "Default value: 0.5 This is the higher end of a linearly increasing SMB Delivery Ratio rather than the fix value above in SMB DeliveryRatio." : { + "comment" : "SMB DeliveryRatio Maximum miniHint" + }, + "Default value: 0.5 This is the lower end of a linearly increasing SMB Delivery Ratio rather than the fix value above in SMB DeliveryRatio." : { + "comment" : "SMB DeliveryRatio Minimum miniHint" + }, "Default values are based on internationally accepted Time in Range values of %@-%@ %@." : { "localizations" : { "bg" : { @@ -99594,6 +100243,10 @@ } } }, + "Default: OFF " : { + "comment" : "A label describing the default setting for a feature. In this case, \"OFF\" for \"Enable Autosens\".", + "isCommentAutoGenerated" : true + }, "Default: %@ %@" : { "localizations" : { "bg" : { @@ -99860,6 +100513,16 @@ } } }, + "Default: 0.5" : { + + }, + "Default: 0.5 — valid range (0.5, 1.0]" : { + + }, + "Default: 1" : { + "comment" : "The default value for the \"SMB Max RangeExtension\" setting.", + "isCommentAutoGenerated" : true + }, "Default: 2 %@" : { "localizations" : { "bg" : { @@ -100249,6 +100912,9 @@ } } } + }, + "Default: 8 hours" : { + }, "Default: 10 hours" : { "localizations" : { @@ -101550,6 +102216,10 @@ } } }, + "Default: 100 %" : { + "comment" : "A label inside the verbose hint for the therapy-adjustment percentage row on Add Profile form.", + "isCommentAutoGenerated" : true + }, "Default: 100%" : { "localizations" : { "bg" : { @@ -101680,6 +102350,10 @@ } } }, + "Default: 100% " : { + "comment" : "A label displayed next to the default value of the IOB Threshold Percent setting.", + "isCommentAutoGenerated" : true + }, "Default: 120 grams of carbs" : { "localizations" : { "bg" : { @@ -102329,6 +103003,9 @@ } } } + }, + "Default: Badge displayed" : { + }, "Default: Day starts %1$@, Night starts %2$@." : { "localizations" : { @@ -107457,6 +108134,9 @@ } } } + }, + "Delete pump if you need to change Insulin Concentration" : { + }, "Delete Selected Preset" : { "extractionState" : "manual", @@ -107725,6 +108405,10 @@ } } }, + "Delete the Profile \"%@\"?" : { + "comment" : "A confirmation dialog that lets the user delete a profile.", + "isCommentAutoGenerated" : true + }, "Delete the Temp Target Preset \"%@\"?" : { "comment" : "Delete confirmation title for temporary target presets", "localizations" : { @@ -107856,6 +108540,10 @@ } } }, + "Delete this schedule?" : { + "comment" : "A confirmation dialog title.", + "isCommentAutoGenerated" : true + }, "Delete Variant" : { "localizations" : { "bg" : { @@ -108775,6 +109463,7 @@ } }, "Deselect All" : { + "comment" : "Toggle button in History filter popover — deselects every treatment type", "localizations" : { "bg" : { "stringUnit" : { @@ -109441,6 +110130,7 @@ } }, "Determined at %@" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -110889,6 +111579,10 @@ } } }, + "Diagnostics are sent to a Google Firebase Crashlytics project, which is securely maintained and accessed only by the Tai author." : { + "comment" : "A description of how user data is handled by Tai.", + "isCommentAutoGenerated" : true + }, "Dings" : { "comment" : "Name of a sound file.", "localizations" : { @@ -112735,6 +113429,9 @@ } } } + }, + "Dismisses the loop status popup again." : { + }, "Display and allow Fat and Protein entries" : { "comment" : "UI/UX option", @@ -114884,6 +115581,9 @@ } } } + }, + "Display sensor lifecycle arc, hourglass and status around the glucose bobble." : { + }, "Display Standard Deviation (SD) instead of Coefficient of Variation (CV) in statPanel" : { "comment" : "Description for display SD", @@ -117518,6 +118218,9 @@ } } } + }, + "Double Tap" : { + }, "Double tap to confirm" : { "comment" : "Accessibility hint", @@ -117913,6 +118616,7 @@ } }, "Double-Tap" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -118174,6 +118878,9 @@ }, "Drag a widget to move it. Drag one up from Available Widgets to add it, or drop it back onto the list to remove it." : { + }, + "Drag the chart sideways to pan through your history. Flick and let go to keep it gliding; panning far enough back loads up to 72 hours." : { + }, "Drives the outer-ring + tag on the home screen's glucose bobble." : { "localizations" : { @@ -118695,6 +119402,18 @@ } } }, + "dura" : { + "comment" : "Label for dura" + }, + "dura_ISF: %@" : { + + }, + "Dura-ISF" : { + + }, + "DuraISF Weight" : { + "comment" : "DuraISF Weight\nRow label on Add Override form — DuraISF Weight\nRow label on Edit Override form — DuraISF Weight" + }, "Duration" : { "comment" : "Duration of target temp or temp basalMedian loop interval", "localizations" : { @@ -118826,6 +119545,24 @@ } } }, + "Duration (minutes)" : { + "comment" : "Label for the duration parameter in the Create Custom Temp Target intent.", + "isCommentAutoGenerated" : true + }, + "Duration in minutes?" : { + "comment" : "Label for the duration parameter in the Create Custom Temp Target intent.", + "isCommentAutoGenerated" : true + }, + "Duration must be positive" : { + "comment" : "Error message displayed when the user attempts to set a non-positive duration for activating a profile.", + "isCommentAutoGenerated" : true + }, + "Duration of Increased B30 Basal Rate" : { + "comment" : "B30 Duration" + }, + "Duration of increased basal rate that saturates the infusion site with insulin. Default 30 minutes." : { + "comment" : "B30 Duration miniHint" + }, "Duration of Insulin Action" : { "comment" : "Duration of Insulin Action", "localizations" : { @@ -118956,6 +119693,12 @@ } } } + }, + "Duration of Insulin Action (DIA)" : { + + }, + "Duration of Plateau:" : { + }, "Dynamic" : { "localizations" : { @@ -119348,6 +120091,7 @@ } }, "Dynamic ISF" : { + "comment" : "Dynamic ISF", "localizations" : { "bg" : { "stringUnit" : { @@ -119738,6 +120482,7 @@ } }, "Dynamic ISF requires at least " : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -120267,6 +121012,7 @@ } }, "Dynamic Settings" : { + "comment" : "Navigation-link title for the Dynamic ISF/Ratio section in the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -120527,6 +121273,7 @@ } }, "Dynamically adjust insulin sensitivity using Dynamic Ratio rather than Autosens Ratio." : { + "comment" : "Mini-hint for Use Dynamic ISF setting", "localizations" : { "bg" : { "stringUnit" : { @@ -120656,6 +121403,10 @@ } } }, + "DynamicISF" : { + "comment" : "A navigation link to the DynamicISF settings.", + "isCommentAutoGenerated" : true + }, "eA1c" : { "localizations" : { "bg" : { @@ -121841,6 +122592,10 @@ } } }, + "Edit Profile" : { + "comment" : "The title of the view when editing an existing profile.", + "isCommentAutoGenerated" : true + }, "Edit settings json" : { "extractionState" : "manual", "localizations" : { @@ -122107,6 +122862,9 @@ } } } + }, + "eff.TH" : { + }, "Electronic" : { "comment" : "Themed alarm sound catalog for electronic sounds.", @@ -122521,6 +123279,7 @@ } }, "Enable a Temporary Target" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -122650,6 +123409,9 @@ } } }, + "Enable Absolute Safety TBR" : { + "comment" : "Enable Absolute TBR" + }, "Enable adjustment of basal based on the ratio of current TDD / 7 day average TDD" : { "comment" : "Enable adjustment of basal profile", "extractionState" : "manual", @@ -122788,6 +123550,18 @@ } } }, + "Enable AutoISF" : { + + }, + "Enable Autosens" : { + "comment" : "Enable Autosens" + }, + "Enable B30" : { + + }, + "Enable BG Acceleration" : { + "comment" : "Enable BG Acceleration" + }, "Enable device connectivity" : { "localizations" : { "bg" : { @@ -123980,8 +124754,12 @@ } } } + }, + "Enable Indefinitely or set a duration greater than zero." : { + }, "Enable indefinitely or set a duration." : { + "comment" : "Validation error on Add Override form when neither indefinite nor a duration is set\nValidation error on Edit Override form when neither indefinite nor a duration is set", "localizations" : { "bg" : { "stringUnit" : { @@ -124110,6 +124888,9 @@ } } } + }, + "Enable KetoProtect" : { + }, "Enable Live Activity" : { "localizations" : { @@ -125023,6 +125804,9 @@ } } } + }, + "Enable SMB Even On Odd Off Always" : { + }, "Enable SMB With COB" : { "comment" : "Enable SMB With COB", @@ -126078,6 +126862,7 @@ } }, "Enable Unannounced Meals SMB." : { + "comment" : "Mini-hint for Enable UAM setting", "localizations" : { "bg" : { "stringUnit" : { @@ -126469,6 +127254,9 @@ }, "Enable uploading of sensor errors and other CGM states to Nightscout as notes." : { + }, + "Enable Watch Commands" : { + }, "Enabled" : { "comment" : "Title string for BeepPreference.manualCommands", @@ -126608,6 +127396,15 @@ } } }, + "Enables an increased basal rate after an EatingSoon TT and a manual bolus to saturate the infusion site with insulin to increase insulin absorption for SMB's following a meal with no carb counting." : { + + }, + "Enables an increased basal rate after an EatingSoon TT and a manual bolus to saturate the infusion site with insulin." : { + "comment" : "Enable B30 miniHint" + }, + "Enables the BG acceleration adaptions, adjusting ISF for accelerating/decelerating blood glucose." : { + "comment" : "Enable BG Acceleration miniHint" + }, "Enables this temp target" : { "comment" : "Accessibility hint", "localizations" : { @@ -129221,6 +130018,10 @@ } } }, + "Enact" : { + "comment" : "A button label that, when tapped, will apply the user's temporary basal rate.", + "isCommentAutoGenerated" : true + }, "Enact bolus" : { "comment" : "Button", "extractionState" : "manual", @@ -129767,6 +130568,7 @@ } }, "Enacted at %@" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -130677,6 +131479,18 @@ } } }, + "Ensure that the selected insulin concentration matches the vial or pen label. U%lld means %lld units per ml." : { + "comment" : "A message in a confirmation dialog that explains the importance of selecting the correct insulin concentration.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Ensure that the selected insulin concentration matches the vial or pen label. U%1$lld means %2$lld units per ml." + } + } + } + }, "Enter Meal Preset Name" : { "extractionState" : "manual", "localizations" : { @@ -131732,6 +132546,7 @@ } }, "Entries are capped at 33 grams each, with up to three entries, for a max total of 99 grams." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -131863,6 +132678,7 @@ }, "Error" : { "comment" : "Error title", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -132130,6 +132946,10 @@ } } }, + "Error During Algorithm Run at %@" : { + "comment" : "A section in the Home status popup displaying an error message and the date it was encountered.", + "isCommentAutoGenerated" : true + }, "Error while processing Treatment" : { "localizations" : { "bg" : { @@ -133309,6 +134129,9 @@ } } } + }, + "Eventual BG value" : { + }, "Eventual glucose" : { "comment" : "A label displayed under the loop status icon", @@ -134123,6 +134946,12 @@ } } }, + "Every bolus is checked against your Max Bolus and Max IOB. A watch bolus is refused for 6 minutes after the previous watch bolus, and any watch bolus is refused if the boluses of the last 6 minutes (SMBs included) add up to 20% or more of it. These checks cannot tell who pressed the button on the watch: anyone with access to your watch can deliver insulin up to those limits." : { + + }, + "Every day" : { + "comment" : "Schedule repeat summary — every day of the week" + }, "Everything you enter here can be adjusted later in the app." : { "localizations" : { "bg" : { @@ -134382,6 +135211,16 @@ } } } + }, + "Example:" : { + + }, + "Examples (minBG = 100 mg/dL):" : { + "comment" : "Example usage of the SMB Threshold Ratio setting, with `minBG` set to 100 mg/dL.", + "isCommentAutoGenerated" : true + }, + "Examples (TargetGlucose = 100 mg/dL):" : { + }, "exceeded" : { "comment" : "Limit Exceeded label", @@ -134659,6 +135498,9 @@ } } }, + "Exercise toggles all autoISF adjustments off" : { + "comment" : "autoISF Off for Sport" + }, "Experimental" : { "comment" : "New Experimental feature", "extractionState" : "manual", @@ -135072,6 +135914,9 @@ } } }, + "Expiring" : { + "comment" : "Countdown label shown on the active-profile banner when less than a minute of a timed activation remains\nCountdown text on Home profile banner when less than a minute of a timed activation remains" + }, "Export Categories" : { "localizations" : { "bg" : { @@ -136265,6 +137110,44 @@ } } }, + "Extended (%@)" : { + "comment" : "Extended lower threshold for TIR and TITR statistics", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extended (%@)" + } + } + } + }, + "Extended Lower Threshold" : { + "comment" : "Search term for the extended lower range threshold setting.", + "extractionState" : "stale", + "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:" + } + } + } + }, + "Extensions" : { + "comment" : "A section header in the draft profile editor, listing the various extensions that can be applied to a profile.", + "isCommentAutoGenerated" : true + }, "External" : { "comment" : "External Insulin", "localizations" : { @@ -136656,6 +137539,9 @@ } } } + }, + "External insulin keeps its rhombus marker." : { + }, "External Insulin?" : { "localizations" : { @@ -137581,6 +138467,9 @@ } } }, + "Factor that multiplies your regular basal rate from profile for B30. Max is 10. The TBR will ignore the maxBasalMultipliers but respect maxBasal setting!" : { + "comment" : "B30 Factor miniHint" + }, "Factory Calibration Parameters" : { "extractionState" : "manual", "localizations" : { @@ -137718,6 +138607,18 @@ } } }, + "Failed to activate profile '%@': %@" : { + "comment" : "Error message shown when an attempt to activate a profile fails. The first argument is the name of the profile that failed to activate. The second argument is a human-readable description of the failure.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Failed to activate profile '%1$@': %2$@" + } + } + } + }, "Failed to create a new object in %@ from %@." : { "localizations" : { "bg" : { @@ -138788,6 +139689,14 @@ } } }, + "Failed to schedule profile '%@'" : { + "comment" : "A dialog shown when scheduling a profile fails.", + "isCommentAutoGenerated" : true + }, + "Failed to schedule Temporary Target '%@'" : { + "comment" : "A localized error message that appears in a dialog when scheduling a Temporary Target fails.", + "isCommentAutoGenerated" : true + }, "Failed to Suspend Insulin Delivery" : { "comment" : "Alert title for suspend error", "extractionState" : "manual", @@ -139064,6 +139973,9 @@ } } }, + "Failed to update profile state." : { + "comment" : "Error returned when the Core Data flip marking a profile active could not be saved" + }, "Failed to validate object in %@ from %@." : { "localizations" : { "bg" : { @@ -141829,6 +142741,9 @@ } } }, + "final" : { + "comment" : "Label for final" + }, "Fine-tune the ring’s Width and Gap to suit your design preferences." : { "localizations" : { "bg" : { @@ -141958,6 +142873,13 @@ } } } + }, + "Fire at" : { + "comment" : "Label for the \"Date and time the profile should activate\" parameter in the Schedule Profile intent.", + "isCommentAutoGenerated" : true + }, + "Fires once at the chosen date and time, then the schedule is removed." : { + }, "Fires when glucose drops to or below a low threshold." : { "localizations" : { @@ -143266,6 +144188,15 @@ } } } + }, + "fit a0" : { + + }, + "fit a1" : { + + }, + "fit a2" : { + }, "Follows the phone's current color scheme setting at that time" : { "localizations" : { @@ -144048,6 +144979,23 @@ } } }, + "for %lld h" : { + "comment" : "Schedule duration summary — temporary activation in whole hours\nUpcoming-schedule row duration — whole hours" + }, + "for %lld h %lld min" : { + "comment" : "Schedule duration summary — temporary activation in hours and minutes\nUpcoming-schedule row duration — hours and minutes", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "for %1$lld h %2$lld min" + } + } + } + }, + "for %lld min" : { + "comment" : "Schedule duration summary — temporary activation under one hour\nUpcoming-schedule row duration — under one hour" + }, "For 45 g of carbs, you would need:" : { "localizations" : { "bg" : { @@ -145233,6 +146181,7 @@ } }, "Forcing loop…" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -147604,6 +148553,14 @@ } } }, + "Full-Loop exception: when autoISF's full-loop mode is active (an even-numbered temp target below 100 mg/dL, e.g. 80 or 90, signalling you want maximum SMB power), the delivery ratio becomes the greater of the fixed ratio and the ramp value — so the fixed ratio acts as a floor and the ramp can only raise it further." : { + "comment" : "Explanation of the \"Full-Loop exception\" in the verbose hint for `smbDeliveryRatioBGrange`.", + "isCommentAutoGenerated" : true + }, + "Full-Loop mode" : { + "comment" : "A heading displayed below the full-loop mode explanation.", + "isCommentAutoGenerated" : true + }, "Furthermore, you could adjust that sensitivity change independently from the Half Basal Exercise Target specified in Algorithm > Target Behavior settings by deliberately setting a customized Insulin Percentage for a Temp Target." : { "comment" : "A paragraph explaining how to set a custom insulin percentage for a temporary target.", "localizations" : { @@ -152345,6 +153302,7 @@ } }, "Glucose Deviations Algorithm" : { + "comment" : "Section header on the Autosens settings screen grouping max/min and related controls", "localizations" : { "bg" : { "stringUnit" : { @@ -154842,6 +155800,7 @@ } }, "Glucose Targets" : { + "comment" : "Navigation-link title for the Glucose Targets editor inside the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -154971,6 +155930,9 @@ } } }, + "Glucose Targets tuned" : { + "comment" : "Profile summary badge — glucose targets differ from defaults" + }, "Glucose trace WILL NOT be affected by any insulin or carb entries." : { "localizations" : { "bg" : { @@ -158190,6 +159152,9 @@ } } } + }, + "Having the U50 active but with U100 in the pump will be very dangerous. Don't hide the badge unless you do not change anything in the longterm - diluted insulins are shown in red, concentrated in yellow." : { + }, "Header" : { "localizations" : { @@ -158987,8 +159952,12 @@ } } }, + "Hey Tai user, watch out!" : { + "comment" : "Title of the warning shown on builds that are not from the main branch" + }, "Hey Trioneer, watch out!" : { "comment" : "Title of the warning shown on builds that are not from the main branch", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -159654,6 +160623,7 @@ } }, "Hide Future" : { + "comment" : "Toggle button in History — hide entries with future timestamps\nToggle button on History Carb Entries view — hide entries with future timestamps", "localizations" : { "bg" : { "stringUnit" : { @@ -159782,6 +160752,15 @@ } } } + }, + "Hide Insulin Badge" : { + + }, + "Hide Insulin Concentration badge" : { + "comment" : "Hide Badge" + }, + "Hide numeric labels next to small boluses and SMBs to reduce chart clutter." : { + }, "Hide Statistics" : { "comment" : "Home stats panel face option", @@ -160842,6 +161821,7 @@ } }, "High Glucose Target" : { + "comment" : "Row label for the high-glucose target field that gates Enable SMB With High Glucose", "localizations" : { "bg" : { "stringUnit" : { @@ -161761,6 +162741,19 @@ } } }, + "Higher = more conservative. Useful if you find the default cuts SMBs off too late for your comfort. Values at or below 0.5 fall back to the default; values above 1.0 are ignored (clamped to (0.5, 1.0])." : { + "comment" : "Explanation of the `smbThresholdRatioVerbose` view, focusing on the \"higher\" side of the `smbThresholdRatio` setting.", + "isCommentAutoGenerated" : true + }, + "Higher = more conservative. Values at or below 0.5 fall back to 0.5; values above 1.0 are ignored. The final cutoff is also clamped to 60–120 mg/dL." : { + + }, + "Higher ISF Range Weight" : { + + }, + "Highest ISF factor allowed." : { + "comment" : "autoISF Max miniHint" + }, "History" : { "comment" : "Header", "localizations" : { @@ -162028,6 +163021,9 @@ } } } + }, + "Hold Trio Button" : { + }, "Home" : { "comment" : "Home title", @@ -162159,6 +163155,9 @@ } } } + }, + "Home Screen Gestures" : { + }, "Home Statistics Panel" : { "localizations" : { @@ -162289,6 +163288,9 @@ } } } + }, + "Home Statistics Range" : { + }, "Home View Button Panel " : { "comment" : "UI/UX option", @@ -163493,6 +164495,17 @@ } } }, + "How far above your BG target the delivery ratio ramps up. Sensible values are %@–%@ %@. Set to 0 to disable the ramp and use the fixed SMB DeliveryRatio instead." : { + "comment" : "SMB DeliveryRatio BG Range miniHint", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "How far above your BG target the delivery ratio ramps up. Sensible values are %1$@–%2$@ %3$@. Set to 0 to disable the ramp and use the fixed SMB DeliveryRatio instead." + } + } + } + }, "How far back?" : { }, @@ -163626,7 +164639,16 @@ } } }, + "How long the Temp Target should run (5–1440 min)" : { + "comment" : "Description of the parameter for the duration of a custom Temp Target.", + "isCommentAutoGenerated" : true + }, + "How many additional minutes?" : { + "comment" : "Request dialog title for the \"Minutes\" parameter of the `ApplyProfileIntent`.", + "isCommentAutoGenerated" : true + }, "How many grams of carbs?" : { + "comment" : "Request carbs quantity", "localizations" : { "bg" : { "stringUnit" : { @@ -163757,6 +164779,7 @@ } }, "How many grams of fat?" : { + "comment" : "Request fat quantity", "localizations" : { "bg" : { "stringUnit" : { @@ -163887,7 +164910,7 @@ } }, "How many grams of protein?" : { - "comment" : "Request dialog text for the \"Quantity Protein\" parameter.", + "comment" : "Request protein quantity", "localizations" : { "bg" : { "stringUnit" : { @@ -164017,6 +165040,10 @@ } } }, + "How many whole hours?" : { + "comment" : "Request dialog title for the \"Hours\" parameter of the `ApplyProfileIntent`.", + "isCommentAutoGenerated" : true + }, "How to Return to Main" : { "comment" : "Button on the non-release build warning that opens documentation about returning to main", "localizations" : { @@ -164957,6 +165984,9 @@ } } } + }, + "If all insulin delivery stopped, and assuming no food is eaten or other significant changes occur, at what level would blood glucose eventually level off within 3-4 hrs." : { + }, "If an active override preset is edited, the changes will also apply to the currently running override. However, if you edit the currently running override directly, the preset stays unchanged." : { "localizations" : { @@ -165348,6 +166378,10 @@ } } }, + "If enabled this function will switch off autoISF adaptions completely if you are exercising. Exercising means you have a high TempTarget enabled and HighTTraisesSens, so that this high TT will already increase your sensitivity (will be displayed in active TempTarget)." : { + "comment" : "Verbose hint for the \"Exercise toggles all autoISF adjustments off\" hint.", + "isCommentAutoGenerated" : true + }, "If enabled, a confirmation dialog will be shown when activating adjustment presets." : { "localizations" : { "bg" : { @@ -166009,6 +167043,9 @@ } } } + }, + "If the \"Use Profile CSF\" setting is enabled in Algorithm Additionals, the algorithm will also keep CSF independent of dynamic ISF adjustments from autoISF, Autosens, or Dynamic ISF. This effectively makes your CR dynamic at runtime too — some refer to this as \"dynamic CR\"." : { + }, "If toggled, Insulin will be added to IOB but it will not be delivered" : { "localizations" : { @@ -166530,6 +167567,14 @@ } } }, + "If toggled, you will need to confirm before scheduling" : { + "comment" : "Label for a toggle that allows the user to confirm before scheduling a custom Temp Target.", + "isCommentAutoGenerated" : true + }, + "If toggled, you will need to confirm before the schedule is saved" : { + "comment" : "A toggle that allows the user to confirm before saving a scheduled profile.", + "isCommentAutoGenerated" : true + }, "If using Dynamic ISF (with Sigmoid), overriding your ISF will adjust the ISF used at your glucose target which extends to the ISF used at other glucose. Overriding your glucose target will change glucose level your ISF will be set to your profile ISF. Both of these can be combined in a single Override." : { "localizations" : { "bg" : { @@ -166926,6 +167971,10 @@ } } }, + "If you are an expert user and mix your own insulin, it’s essential to review DIA and Custom Peak Time of your insulin after the onboarding, as they are reset to defaults." : { + "comment" : "A note for users who mix their own insulin, reminding them to review the DIA and Custom Peak Time settings after onboarding.", + "isCommentAutoGenerated" : true + }, "If you are not seeing calendars to choose here, please go to Settings -> Trio -> Calendars and change permissions to \"Full Access\"" : { "localizations" : { "bg" : { @@ -167446,6 +168495,16 @@ } } }, + "If your glucose target is %@ %@ and ratio = 0.5, ratioFloor = %@ %@. Setting Minimum Safety Threshold below %@ has no effect; setting it above raises the cutoff." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "If your glucose target is %1$@ %2$@ and ratio = 0.5, ratioFloor = %3$@ %4$@. Setting Minimum Safety Threshold below %5$@ has no effect; setting it above raises the cutoff." + } + } + } + }, "If your glucose target is %@ %@, Trio will use a safety threshold of %@ %@, unless you set Minimum Safety Threshold to something > %@ %@." : { "localizations" : { "bg" : { @@ -167711,6 +168770,9 @@ } } } + }, + "Immediately apply ${preset}" : { + }, "Immediately applying ${bolusQuantity} U" : { "localizations" : { @@ -167843,6 +168905,7 @@ } }, "Immediately applying ${preset}" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -169438,6 +170501,10 @@ } } }, + "Important: Enabling this setting changes how the algorithm handles carb absorption. Review your algorithm and therapy settings, and consider starting with a higher glucose target while you adjust. Settings will generally need to be less aggressive, as the stable CSF combined with a reduced CR in insulin resistance situations can increase insulin dosing for meals." : { + "comment" : "A detailed explanation of why enabling the \"Use Profile CSF\" setting is important.", + "isCommentAutoGenerated" : true + }, "Importing Settings..." : { "comment" : "Progress text when importing settings via Nightscout", "localizations" : { @@ -169569,6 +170636,9 @@ } } }, + "In addition to the Zero Temp the activiation of KetoProtect is dependant on IOB levels and last Active Insulin." : { + "comment" : "Variable protection miniHint" + }, "In case you're using both profiles and temp targets" : { "comment" : "UI/UX option", "extractionState" : "manual", @@ -169707,6 +170777,14 @@ } } }, + "In full-loop mode, the SMB delivery ratio becomes the greater of the fixed SMB DeliveryRatio and the linearly-rising ramp value — so the fixed ratio acts as a floor the ramp can only raise, never lower." : { + "comment" : "Explanation of the \"full-loop mode\" in the context of the \"Odd Target disables SMB for autoISF\" setting.", + "isCommentAutoGenerated" : true + }, + "In oref smb_delivery_ratio is normally hard coded as 0.5 of the insulin requested. This is a safety feature for master/follower setups in case both phones trigger an SMB in the same situation. If this does not apply in your case you may increase this setting to a value above 0.5 and up to even 1.0 if you are very courageous." : { + "comment" : "A verbose hint explaining the purpose of the \"SMB DeliveryRatio (fixed)\" setting.", + "isCommentAutoGenerated" : true + }, "in progress" : { "comment" : "Accessibility: loop currently running", "localizations" : { @@ -170100,6 +171178,10 @@ } } }, + "In the algorithm, CSF is used to estimate carb absorption rates, convert carb impact to grams absorbed, project remaining carb absorption, and calculate carbs required to prevent lows. With this setting enabled, all of these calculations use a stable, profile-based CSF." : { + "comment" : "Explanation of how the \"Use Profile CSF\" setting affects carb absorption calculations.", + "isCommentAutoGenerated" : true + }, "In the next few steps, you’ll configure your algorithm settings for" : { "localizations" : { "bg" : { @@ -170361,6 +171443,7 @@ } }, "Increase sensitivity when glucose is above target if a manual Temp Target > %@ %@ is set." : { + "comment" : "Mini-hint for High Temp Target Raises Sensitivity — interpolated values are the threshold number and the glucose unit", "localizations" : { "bg" : { "stringUnit" : { @@ -170755,6 +171838,12 @@ } } } + }, + "Increasing this setting may result in fewer FPU entries with larger carb values." : { + + }, + "Increasing this setting may result in more FPU entries with smaller carb values." : { + }, "Indefinite" : { "localizations" : { @@ -170885,6 +171974,21 @@ } } } + }, + "Indefinite / until next: becomes your new baseline. The basal profile is saved to the pump and stays until you switch profiles again." : { + "comment" : "A description of the \"Indefinite / until next\" schedule type, explaining that it becomes the user's new baseline and that the basal profile is saved to the pump.", + "isCommentAutoGenerated" : true + }, + "Indefinite activation: an actionable notification (\"Save basal to pump?\") with Save to pump / Cancel buttons. Tapping the body re-opens an in-app confirmation dialog when you return to the app." : { + "comment" : "A notification that appears when the user activates a \"Save basal to pump\" profile. It includes two buttons: \"Save to pump\" and \"Cancel\". Tapping the body of the notification will open an in-", + "isCommentAutoGenerated" : true + }, + "Indefinite activations push the basal schedule to the pump. Timed activations (up to 24 h) keep the pump untouched — the algorithm compensates via temp basals, same as overrides — and auto-revert when they expire." : { + "comment" : "A longer explanation of the \"Duration\" section in the", + "isCommentAutoGenerated" : true + }, + "Indefinite: becomes your new baseline. The basal schedule is written to the pump and everything else is applied immediately." : { + }, "Indicates glucose smoothing is enabled." : { "localizations" : { @@ -171674,6 +172778,9 @@ } } } + }, + "Initiating B30 can be done by Apple Shortcuts\nhttps://tinyurl.com/aimiB30shortcut\n" : { + }, "Initiating…" : { "comment" : "A label displayed when the pump is waiting to start bolusing.", @@ -171943,8 +173050,12 @@ } } }, + "Instead of a single fixed ratio, the delivery ratio can rise linearly with glucose: starting cautiously at SMB DeliveryRatio BG Minimum when glucose is at your BG target, and reaching the more ambitious SMB DeliveryRatio BG Maximum once glucose is this many points above target." : { + "comment" : "A verbose hint explaining how the SMB delivery ratio works when using the \"SMB DeliveryRatio BG Range\" setting.", + "isCommentAutoGenerated" : true + }, "Insulin" : { - "comment" : "Title for insulin-related statistics", + "comment" : "Label for Insulin section\nTitle for insulin-related statistics", "localizations" : { "bg" : { "stringUnit" : { @@ -172203,6 +173314,16 @@ } } } + }, + "Insulin Concentration" : { + "comment" : "Insulin Concentration" + }, + "Insulin Concentration Selected" : { + "comment" : "A section header that indicates which insulin concentration the user has selected.", + "isCommentAutoGenerated" : true + }, + "Insulin Concentration:" : { + }, "Insulin curve" : { "comment" : "Insulin curve", @@ -172341,6 +173462,12 @@ } } } + }, + "Insulin Curve" : { + + }, + "Insulin Curve Parameters" : { + }, "Insulin Limit Reached" : { "localizations" : { @@ -173255,6 +174382,7 @@ } }, "Insulin Pump" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -173384,6 +174512,10 @@ } } }, + "Insulin Pump & Concentration" : { + "comment" : "A label displayed as a row in the \"Setup & Configuration\" section of the devices view.", + "isCommentAutoGenerated" : true + }, "Insulin recommended" : { "extractionState" : "manual", "localizations" : { @@ -173789,6 +174921,7 @@ } }, "Insulin Sensitivities" : { + "comment" : "Navigation-link title for the Insulin Sensitivity (ISF) editor inside the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -174981,6 +176114,19 @@ } } }, + "Invalid bolus amount." : { + "comment" : "Watch command ack" + }, + "Invalid carbs amount." : { + "comment" : "Watch command ack" + }, + "Invalid CGM reading (HIGH)." : { + "comment" : "A warning displayed in the Home status popup when a CGM reading is too high.", + "isCommentAutoGenerated" : true + }, + "Invalid command." : { + "comment" : "Watch command ack" + }, "Invalid Glucose (%@)." : { "localizations" : { "bg" : { @@ -176188,6 +177334,27 @@ } } } + }, + "IOB / maxIOB" : { + + }, + "IOB / TH" : { + + }, + "IOB Threshold %" : { + "comment" : "Row label on Add Override form — IOB Threshold %\nRow label on Edit Override form — IOB Threshold %" + }, + "IOB Threshold Percent" : { + + }, + "IOB unavailable. Bolus not delivered." : { + "comment" : "Watch command ack" + }, + "IOB value" : { + + }, + "IOB: %@ U" : { + }, "IOBpredBG" : { "localizations" : { @@ -176318,6 +177485,9 @@ } } } + }, + "iobTH %" : { + }, "is running" : { "localizations" : { @@ -176840,6 +178010,30 @@ } } }, + "ISF factors" : { + "comment" : "Label for ISF factors section" + }, + "ISF value" : { + + }, + "ISF Weight for Higher BGs" : { + "comment" : "ISF High BG Weight\nRow label on Add Override form — ISF Weight for Higher BGs\nRow label on Edit Override form — ISF Weight for Higher BGs" + }, + "ISF Weight for Lower BGs" : { + "comment" : "ISF Low BG Weight\nRow label on Add Override form — ISF Weight for Lower BGs\nRow label on Edit Override form — ISF Weight for Lower BGs" + }, + "ISF Weight for Postprandial BG Rise" : { + "comment" : "Postprandial ISF weight\nRow label on Add Override form — ISF Weight for Postprandial BG Rise\nRow label on Edit Override form — ISF Weight for Postprandial BG Rise" + }, + "ISF Weight While BG Accelerates" : { + "comment" : "BG Acceleration ISF Weight\nRow label on Add Override form — ISF Weight While BG Accelerates\nRow label on Edit Override form — ISF Weight While BG Accelerates" + }, + "ISF Weight While BG Decelerates" : { + "comment" : "Row label on Add Override form — ISF Weight While BG Decelerates\nRow label on Edit Override form — ISF Weight While BG Decelerates" + }, + "ISF Weight While BG Decelerates." : { + "comment" : "BG Brake ISF Weight" + }, "ISF/CR" : { "comment" : "Option for both ISF and CR", "localizations" : { @@ -177100,8 +178294,12 @@ } } } + }, + "It can also adapt SMB delivery settings." : { + }, "It can handle small gaps in data and ignores sensor error values. It needs 4 readings in a row, each arriving within 12 minutes of the one before it, to work properly. Only CGM readings are smoothed—manual entries are not changed." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -177360,6 +178558,9 @@ } } } + }, + "It is one factor used in combination with the Fat and Protein Delay, Spread Interval, and Fat and Protein Factor to create the FPU entries." : { + }, "It looks at the last 8-24 hours of data, excluding meal-related changes, and adjusts insulin settings like basal rates and targets when needed to match your sensitivity or resistance to insulin." : { "localizations" : { @@ -177884,6 +179085,10 @@ } } }, + "Keep at 100 % if you want the new profile to share the active profile's therapy values and only differ in algorithm toggles or BG targets." : { + "comment" : "A paragraph explaining that the \"Adjust current Therapy Settings\" percentage should be set to 100% if the new profile should share the active profile's therapy values but differ in other aspects, like algorithm togg", + "isCommentAutoGenerated" : true + }, "Keep these turned ON in your phone’s settings to ensure you receive Trio Notifications, Critical Alerts, and Time Sensitive Notifications." : { "localizations" : { "bg" : { @@ -178143,6 +179348,30 @@ } } } + }, + "Keto Protection" : { + "comment" : "Navigation-link title for the Keto Protection extension section in the draft profile editor" + }, + "Ketoacidosis protection applies a small safety Temp Basal Rate continuously or under specific conditions (Variable Strategy) to reduce ketoacidosis risk." : { + + }, + "Ketoacidosis protection will apply a small configurable Temp Basal Rate (TBR) instead of a Zero Temp. This is done either always or if certain conditions arise. For the later you need to enable the Variable KetoProtect Strategy." : { + "comment" : "KetoProtect VerboseHint" + }, + "KetoProtect" : { + + }, + "KetoProtect Absolute" : { + + }, + "KetoProtect Basal Absolute" : { + + }, + "KetoProtect Basal Percent" : { + + }, + "KetoProtect Settings" : { + }, "Large" : { "comment" : "Live Activity glucose reading font size" @@ -178407,12 +179636,6 @@ } } }, - "last 24 hours" : { - "comment" : "Stats banner scope" - }, - "Last 24 Hours" : { - "comment" : "Home stats panel range option" - }, "Last 3 Days" : { }, @@ -178428,6 +179651,12 @@ "Last 7 Days" : { "comment" : "Home stats panel range option" }, + "last 24 hours" : { + "comment" : "Stats banner scope" + }, + "Last 24 Hours" : { + "comment" : "Home stats panel range option" + }, "Last Blood Sugar prediction" : { "extractionState" : "manual", "localizations" : { @@ -178564,6 +179793,9 @@ } } } + }, + "Last Delta" : { + }, "last device communication %@" : { "comment" : "Accessibility: device age", @@ -181061,6 +182293,12 @@ } } } + }, + "Lets a supported Garmin app log carbs and start or stop override and temp target presets. Status and the preset list stay available with this switch off." : { + + }, + "Lets the Garmin app deliver a bolus, alone or together with carbs, without any confirmation on this phone." : { + }, "Libre 2 Direct" : { "comment" : "Libre 2 Direct", @@ -183060,6 +184298,7 @@ } }, "Limits the size of a single Super Micro Bolus (SMB) dose." : { + "comment" : "Mini-hint for Max SMB Basal Minutes setting", "localizations" : { "bg" : { "stringUnit" : { @@ -183190,6 +184429,7 @@ } }, "Limits the size of a single Unannounced Meal (UAM) SMB dose." : { + "comment" : "Mini-hint for Max UAM Basal Minutes setting", "localizations" : { "bg" : { "stringUnit" : { @@ -183710,6 +184950,7 @@ } }, "List State" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -186209,6 +187450,10 @@ } } }, + "Log Glucose" : { + "comment" : "A button label that allows a user to log glucose manually.", + "isCommentAutoGenerated" : true + }, "Log Meal" : { "localizations" : { "bg" : { @@ -186470,6 +187715,7 @@ } }, "Logarithmic" : { + "comment" : "Dynamic ISF picker option — logarithmic formula", "localizations" : { "bg" : { "stringUnit" : { @@ -186860,7 +188106,7 @@ } }, "Logging Failed: Max Carbs = %lld g" : { - "comment" : "An error message that the Add Carbs shortcut failed because the entered carbs exceeded the Max Carbs setting", + "comment" : "Max carbs exceeded error", "localizations" : { "bg" : { "stringUnit" : { @@ -186991,7 +188237,7 @@ } }, "Logging Failed: Max Fat = %lld g" : { - "comment" : "An error message that the Add Carbs shortcut failed because the entered fat exceeded the Max Fat setting", + "comment" : "Max fat exceeded error", "localizations" : { "bg" : { "stringUnit" : { @@ -187122,7 +188368,7 @@ } }, "Logging Failed: Max Protein = %lld g" : { - "comment" : "An error message that the Add Carbs shortcut failed because the entered protein exceeded the Max Protein setting", + "comment" : "Max protein exceeded error", "localizations" : { "bg" : { "stringUnit" : { @@ -188180,8 +189426,13 @@ } } }, + "LoopGraph" : { + "comment" : "Name of the Garmin datafield app for the LoopGraph watchface.", + "isCommentAutoGenerated" : true + }, "looping" : { "comment" : "Looping in progress", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -191482,6 +192733,13 @@ } } } + }, + "Lower end of the linearly increasing SMB delivery ratio. This value is applied at your BG target — i.e. the share of insulinReq delivered as SMB when glucose is right at target." : { + "comment" : "A hint explaining that the \"Lower end of the linearly increasing SMB delivery ratio\" is the value applied when glucose is exactly at the user's target glucose level.", + "isCommentAutoGenerated" : true + }, + "Lower ISF Range Weight" : { + }, "Lower limit of the Autosens Ratio." : { "localizations" : { @@ -191614,6 +192872,7 @@ } }, "Lower limit of the Sensitivity Ratio." : { + "comment" : "Mini-hint for Autosens Min setting", "localizations" : { "bg" : { "stringUnit" : { @@ -191744,6 +193003,7 @@ } }, "Lower target glucose when Autosens Ratio is greater than 1." : { + "comment" : "Mini-hint for Resistance Lowers Target setting", "localizations" : { "bg" : { "stringUnit" : { @@ -191873,6 +193133,9 @@ } } }, + "Lowest ISF factor allowed." : { + "comment" : "autoISF Min miniHint" + }, "m" : { "comment" : "Abbreviation for Minutes\nabbreviation for minutes", "localizations" : { @@ -192794,6 +194057,10 @@ } } }, + "Managing" : { + "comment" : "A section header explaining how to manage profiles.", + "isCommentAutoGenerated" : true + }, "Manual" : { "comment" : "Manual Bolus", "localizations" : { @@ -193063,11 +194330,12 @@ } } }, - "Manual basal %@" : { - "comment" : "Accessibility: manual temp basal rate the user set on the pump" + "Manual Bolus" : { + }, "manual entry" : { "comment" : "Accessibility: manually entered glucose", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -193326,6 +194594,9 @@ } } } + }, + "Manual Temp Basal" : { + }, "Manual Temp Basal Active" : { "localizations" : { @@ -199245,6 +200516,9 @@ } } } + }, + "Maximum Duration" : { + }, "Maximum Duration In Hours" : { "comment" : "Duration", @@ -199774,6 +201048,12 @@ } } } + }, + "maxIOB" : { + + }, + "maxIOB and iobTH% as of current settings!" : { + }, "Meal" : { "comment" : "Debug option view Meal", @@ -202687,6 +203967,7 @@ } }, "Migration of JSON-based OpenAPS Determination Data failed: %@" : { + "comment" : "Migration error on first launch — the interpolated value is the underlying system error description", "localizations" : { "bg" : { "stringUnit" : { @@ -203082,6 +204363,9 @@ } } }, + "milligrams per deciliter per 10 grams" : { + "comment" : "Accessibility: spoken unit" + }, "milligrams per deciliter per unit" : { "comment" : "Accessibility: spoken unit", "localizations" : { @@ -203475,6 +204759,9 @@ } } }, + "millimoles per liter per 10 grams" : { + "comment" : "Accessibility: spoken unit" + }, "millimoles per liter per unit" : { "comment" : "Accessibility: spoken unit", "localizations" : { @@ -204926,7 +206213,11 @@ } } }, + "Minimum manual bolus to start a B30 adaption." : { + "comment" : "B30 Start Bolus miniHint" + }, "Minimum minutes since the last SMB or manual bolus to allow an automated SMB." : { + "comment" : "Mini-hint for SMB Interval setting", "localizations" : { "bg" : { "stringUnit" : { @@ -205323,6 +206614,12 @@ } } } + }, + "Minimum Start Bolus Size" : { + "comment" : "B30 Start Bolus" + }, + "Minimum Threshold Setting is the hard floor for the SMB cutoff. Below this glucose value Trio will suspend insulin delivery and block SMBs. It is designed to protect against hypoglycemia, particularly during sleep or other vulnerable times." : { + }, "Minimum Threshold Setting is, by default, determined by your set Glucose Target. This threshold automatically suspends insulin delivery if your glucose levels are forecasted to fall below this value. It’s designed to protect against hypoglycemia, particularly during sleep or other vulnerable times." : { "localizations" : { @@ -206127,6 +207424,9 @@ } } }, + "Missing profile" : { + "comment" : "Schedule list row shown when the schedule's target profile has been deleted" + }, "Missing required inputs; cannot determine basal." : { "localizations" : { "bg" : { @@ -207052,6 +208352,9 @@ } } } + }, + "Modified" : { + }, "Monochrome" : { "localizations" : { @@ -207320,6 +208623,13 @@ } } }, + "Monthly on %@" : { + "comment" : "Schedule repeat summary for custom monthly days — interpolated list is comma-separated day numbers" + }, + "Monthly: fires on the chosen day-of-month." : { + "comment" : "A description of the \"Monthly\" repeat option for schedules.", + "isCommentAutoGenerated" : true + }, "Mood Synth" : { "comment" : "Display name for the \"Mood Synth\" alarm sound.", "localizations" : { @@ -207981,6 +209291,10 @@ } } }, + "Multiplier cap on how high the autoISF ratio can be and therefore how low it can adjust ISF." : { + "comment" : "A verbose hint explaining the purpose of the \"autoISF Max\" setting.", + "isCommentAutoGenerated" : true + }, "Mute Trio Alarms" : { "localizations" : { "bg" : { @@ -208242,6 +209556,14 @@ } } }, + "Name for this Temp Target?" : { + "comment" : "Prompt to enter a name for the custom temp target.", + "isCommentAutoGenerated" : true + }, + "Name is required" : { + "comment" : "Error message when the user does not provide a name for the temporary target.", + "isCommentAutoGenerated" : true + }, "Name Of Dish" : { "localizations" : { "bg" : { @@ -208371,6 +209693,9 @@ } } } + }, + "Name shown in history" : { + }, "Native G5 app" : { "comment" : "Native G5 app", @@ -209162,6 +210487,10 @@ } } }, + "New Schedule" : { + "comment" : "The title of the view allowing users to create new schedules.", + "isCommentAutoGenerated" : true + }, "New Sensor Detected" : { "comment" : "New Sensor Detected", "extractionState" : "manual", @@ -209429,6 +210758,9 @@ } } } + }, + "Next Delta" : { + }, "Next field" : { "localizations" : { @@ -210872,6 +212204,9 @@ } } }, + "No Algorithm result" : { + "comment" : "Home status popup title fallback when no determination exists\nHome status popup title when no determination is available" + }, "No biometric identities are enrolled. Please set up Face ID or Touch ID." : { "localizations" : { "bg" : { @@ -211531,7 +212866,12 @@ } } }, + "No Data" : { + "comment" : "A label displayed when there is no data to show.", + "isCommentAutoGenerated" : true + }, "No data." : { + "comment" : "Empty-state text on History Adjustments list\nEmpty-state text on History Glucose list\nEmpty-state text on History Treatments list", "localizations" : { "bg" : { "stringUnit" : { @@ -211661,6 +213001,9 @@ } } }, + "No days selected" : { + "comment" : "Schedule repeat summary when the monthly day set is empty\nSchedule repeat summary when the weekday set is empty (error state)" + }, "No Error" : { "extractionState" : "manual", "localizations" : { @@ -213633,6 +214976,13 @@ } } }, + "No profiles — create one first." : { + "comment" : "A message shown when a user has no profiles to choose from in the", + "isCommentAutoGenerated" : true + }, + "No pump manager available." : { + "comment" : "Pump-sync failure shown when activating a profile but no pump is paired" + }, "No recent determination." : { "localizations" : { "bg" : { @@ -214160,6 +215510,9 @@ } } } + }, + "No schedules yet" : { + }, "No Sensor Detected" : { "comment" : "No Sensor Detected", @@ -215611,7 +216964,7 @@ } }, "None" : { - "comment" : "No CGM selected\nOption for no selection", + "comment" : "No CGM selected\nOption for no selection\nSummary shown next to the Basal Rates row in the draft editor when the schedule is empty\nSummary shown next to the Carb Ratios row in the draft editor when the schedule is empty\nSummary shown next to the Glucose Targets row in the draft editor when the schedule is empty\nSummary shown next to the Insulin Sensitivities row in the draft editor when the schedule is empty", "localizations" : { "bg" : { "stringUnit" : { @@ -216017,6 +217370,7 @@ } }, "Normal (%@-%@)" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -216428,6 +217782,10 @@ } } }, + "Normally, CSF is calculated using the current dynamic ISF value, which can change frequently based on algorithms like Dynamic ISF, autoISF, or Autosens. This can cause CSF to fluctuate as your ISF adjusts." : { + "comment" : "A hint explaining that CSF is not dependent on ISF when the setting is enabled, as it is calculated using a static profile ISF value.", + "isCommentAutoGenerated" : true + }, "Not allowed" : { "localizations" : { "bg" : { @@ -218011,6 +219369,10 @@ } } }, + "Note to Tai users:" : { + "comment" : "A note for users of the Tai insulin pump, explaining the importance of reviewing their insulin settings.", + "isCommentAutoGenerated" : true + }, "Note: A CGM is considered noisy when it provides inconsistent readings." : { "localizations" : { "bg" : { @@ -218661,6 +220023,13 @@ } } }, + "Note: For practical reasons the profile is entered in units of mg/dL per 10g of carbs consumed (or mmol/L per 10g). This makes values easier to measure and document — eat 10g of fast carbs and observe the BG rise to determine your CSF. The conversion to per-gram is handled internally." : { + + }, + "Note: If \"Low Target Lowers Sensitivity\" is active, using an \"Eating Soon\" temporary target will be more aggressive than before. The lower target reduces ISF, which with a now reduced CR also increases meal insulin dosing. Review your targets and sensitivity settings accordingly." : { + "comment" : "A note explaining the behavior of the \"Low Target Lowers Sensitivity\" feature when used with \"Eating Soon\" temporary targets.", + "isCommentAutoGenerated" : true + }, "Note: If this is enabled and the criteria are met, SMBs could be utilized regardless of other SMB settings being enabled or not." : { "localizations" : { "bg" : { @@ -219701,6 +221070,10 @@ } } }, + "Note: the threshold is also clamped to at least 60 mg/dL and at most 120 mg/dL." : { + "comment" : "A note included in the verbose hint for the SMB Threshold Ratio setting, explaining that the threshold value is clamped to a minimum and maximum value.", + "isCommentAutoGenerated" : true + }, "Note: These values are not used to calculate insulin dosing." : { "localizations" : { "bg" : { @@ -222996,6 +224369,9 @@ } } }, + "Odd Target disables SMB for autoISF" : { + "comment" : "Odd Target disables SMB" + }, "of" : { "comment" : "Bolus progress view", "extractionState" : "manual", @@ -224176,6 +225552,9 @@ } } }, + "Once on %@" : { + "comment" : "Schedule repeat summary for a one-off fire — interpolated value is a localized date/time string" + }, "Once the total insulin required is calculated, this safety limit specifies what percentage of the insulin required can be delivered as an SMB." : { "localizations" : { "bg" : { @@ -224306,6 +225685,10 @@ } } }, + "Once: fires a single time at the picked date and time. The schedule auto-deletes after firing." : { + "comment" : "A description of a \"Once\" schedule.", + "isCommentAutoGenerated" : true + }, "One last thing, before you begin..." : { "localizations" : { "bg" : { @@ -224436,6 +225819,10 @@ } } }, + "Only active when SMB DeliveryRatio BG Range is non-zero; otherwise the fixed SMB DeliveryRatio applies." : { + "comment" : "A note about the effect of `smbDeliveryRatioBGrange` on `smbDeliveryRatioMin`.", + "isCommentAutoGenerated" : true + }, "Only adjust these settings if you’re an advanced or returning user who knows what they’re doing." : { "localizations" : { "bg" : { @@ -224703,6 +226090,15 @@ } } } + }, + "Only boluses and SMBs at or above the selected amount will show their numeric label on the main chart. The bar or marker itself is always drawn." : { + + }, + "Only enable this if you keep your watch locked and under your control." : { + + }, + "Only indefinite (and until-next) activations write a new basal schedule into the pump's memory. Schedules will NOT write to the pump automatically — confirm via Save to pump in the notification or the in-app dialog. Until you confirm, the previous saved schedule stays on the pump." : { + }, "Only shown for the Glucose Bobble style, which replaces Display Values, Ring Settings, and Font Settings:" : { "localizations" : { @@ -224834,6 +226230,10 @@ } } }, + "Only the name is editable here. The active Profile's therapy values (Basal Rate, ISF, CR, BG Targets) and Algorithm Settings are edited directly in Therapy and Algorithm Settings while this Profile is active." : { + "comment" : "An alert message displayed when the user attempts to rename the active profile but does not provide a name.", + "isCommentAutoGenerated" : true + }, "Oops, there was an issue!" : { "localizations" : { "bg" : { @@ -225223,6 +226623,9 @@ } } } + }, + "Open a GitHub Issue" : { + }, "Open Can" : { "comment" : "Name of a sound file.", @@ -226013,6 +227416,9 @@ "Open Wrapper" : { "comment" : "Name of a sound file.", "isCommentAutoGenerated" : true + }, + "OpenAPS documentation" : { + }, "OpenAPS main settings" : { "comment" : "Headers for settings -----------------------", @@ -226151,6 +227557,9 @@ } } } + }, + "OpenAPS on IOB and Activity calculations" : { + }, "OpenAPS other settings" : { "extractionState" : "manual", @@ -227220,6 +228629,7 @@ }, "Opens adjustments" : { "comment" : "Accessibility hint", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -227872,6 +229282,15 @@ } } } + }, + "Opens the Quick-Pick Treatments picker with your recent bolus and carb amounts, so a repeat treatment takes one slide to confirm." : { + + }, + "Opens the setup for that device — or, if none is set up yet, the picker to add one." : { + + }, + "Opens the treatment view to add carbs, insulin or a manual glucose reading." : { + }, "Opt out" : { "localizations" : { @@ -228003,6 +229422,14 @@ } } }, + "Optional name for the schedule entry" : { + "comment" : "Placeholder text for the \"Schedule name\" parameter in the Schedule Profile intent.", + "isCommentAutoGenerated" : true + }, + "Optional profile used to derive Carb Ratios from ISF." : { + "comment" : "A description of the optional Carb Sensitivity Profile setting.", + "isCommentAutoGenerated" : true + }, "Optionally sync with Nightscout and enter essentials." : { "comment" : "Description of the Prepare Trio onboarding chapter.", "localizations" : { @@ -228263,6 +229690,9 @@ } } } + }, + "Original rates" : { + }, "Other" : { "comment" : "Other CGM setting", @@ -229058,6 +230488,7 @@ } }, "Override" : { + "comment" : "Fallback name on History Adjustments list when an override run has no name", "localizations" : { "bg" : { "stringUnit" : { @@ -229447,6 +230878,9 @@ } } }, + "Override %@" : { + "comment" : "Default name for a new override when the user left the name field blank — interpolated value is a formatted date/time" + }, "Override canceled" : { "localizations" : { "bg" : { @@ -229837,6 +231271,9 @@ } } }, + "Override preset not found." : { + "comment" : "Watch command ack" + }, "Override Presets" : { "localizations" : { "bg" : { @@ -230509,6 +231946,12 @@ } } }, + "Override started." : { + "comment" : "Watch command ack" + }, + "Override stopped." : { + "comment" : "Watch command ack" + }, "Override Target" : { "localizations" : { "bg" : { @@ -232629,6 +234072,7 @@ } }, "Pans the chart through time. Flick and let go to keep it gliding." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -234491,6 +235935,7 @@ } }, "Percentage of calculated insulin required that is given as SMB." : { + "comment" : "Mini-hint for SMB Delivery Ratio (AlgorithmAdvanced) setting", "localizations" : { "bg" : { "stringUnit" : { @@ -235811,6 +237256,9 @@ } } } + }, + "Pick any combination. Example: M/W/F fires three times a week." : { + }, "Pick Treatment" : { "localizations" : { @@ -236201,6 +237649,9 @@ } } } + }, + "Pinch to zoom the visible time window continuously, between 1 and 24 hours." : { + }, "Play preview" : { "comment" : "Accessibility: play tone preview", @@ -238200,6 +239651,15 @@ } } } + }, + "Post Meal ISF Weight" : { + + }, + "pp" : { + "comment" : "Label for pp" + }, + "pp-ISF" : { + }, "Prediction Type" : { "localizations" : { @@ -239120,6 +240580,7 @@ } }, "Presets cannot be saved with a future date!" : { + "comment" : "Validation error when saving a temp target preset with a future start date", "localizations" : { "bg" : { "stringUnit" : { @@ -239250,6 +240711,7 @@ } }, "Press and Hold" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -239779,6 +241241,9 @@ } } } + }, + "Previous Release Notes" : { + }, "Previous Versions" : { "extractionState" : "stale", @@ -241090,6 +242555,36 @@ } } }, + "Profile '%@' activated for %@" : { + "comment" : "Confirmation message displayed after successfully activating a profile. The first argument is the name of the profile. The second argument is the formatted duration for which the profile was activated.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Profile '%1$@' activated for %2$@" + } + } + } + }, + "Profile '%@' scheduled for %@ for %@" : { + "comment" : "Confirmation dialog text when a profile is successfully scheduled. The first argument is the name of the profile. The second argument is the formatted time string. The third argument is the formatted duration string.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Profile '%1$@' scheduled for %2$@ for %3$@" + } + } + } + }, + "Profile \"%@\" activated" : { + "comment" : "Title of schedule-fire notification (timed Flow A) — interpolated value is the profile name" + }, + "Profile \"%@\" expired" : { + "comment" : "Title of profile-expired notification — interpolated value is the expiring profile's name" + }, "Profile at 100 %" : { "localizations" : { "bg" : { @@ -241220,6 +242715,15 @@ } } }, + "Profile missing" : { + "comment" : "Upcoming-schedule row shown when the target profile has been deleted" + }, + "Profile name" : { + + }, + "Profile not found." : { + "comment" : "Error returned when activating a profile whose Core Data record can't be loaded" + }, "Profile Override" : { "comment" : "Service Section", "extractionState" : "manual", @@ -241358,6 +242862,14 @@ } } }, + "Profile to activate" : { + "comment" : "Title of the parameter that allows the user to select a profile to activate.", + "isCommentAutoGenerated" : true + }, + "Profile to schedule" : { + "comment" : "Label for the \"Profile\" parameter in the \"Schedule Profile\" intent.", + "isCommentAutoGenerated" : true + }, "Profiles" : { "extractionState" : "manual", "localizations" : { @@ -241495,6 +243007,9 @@ } } }, + "Programmed on pump" : { + "comment" : "Accessibility label for bolus programmed on the pump itself" + }, "Protein" : { "comment" : "Add Protein", "localizations" : { @@ -241626,9 +243141,6 @@ } } }, - "Programmed on pump" : { - "comment" : "Accessibility label for bolus programmed on the pump itself" - }, "Protein / Fat" : { "comment" : "Treatment type", "localizations" : { @@ -242149,6 +243661,12 @@ } } } + }, + "Public. Only for bugs you can describe without personal or health data." : { + + }, + "Pull Down" : { + }, "Pull down to force loop" : { "localizations" : { @@ -242279,6 +243797,9 @@ } } } + }, + "Pull the home screen down past the hint to force a loop." : { + }, "Pump" : { "extractionState" : "manual", @@ -242416,6 +243937,9 @@ } } } + }, + "Pump & Concentration" : { + }, "Pump battery" : { "localizations" : { @@ -243074,6 +244598,10 @@ } } }, + "Pump confirmation required — activate from the app" : { + "comment" : "Error message returned when a one-shot profile activation requires a pump confirmation.", + "isCommentAutoGenerated" : true + }, "Pump Error" : { "localizations" : { "bg" : { @@ -243334,6 +244862,9 @@ } } }, + "Pump Event" : { + "comment" : "Treatment row label — fallback when pump event type is missing" + }, "Pump Fault" : { "localizations" : { "bg" : { @@ -244394,6 +245925,7 @@ } }, "Pump rewind initiates a reset in Sensitivity Ratio." : { + "comment" : "Mini-hint for Rewind Resets Autosens setting", "localizations" : { "bg" : { "stringUnit" : { @@ -244791,6 +246323,9 @@ } } }, + "Pump State" : { + "comment" : "Treatment history type label — pump state change" + }, "Pump State Error" : { "localizations" : { "bg" : { @@ -244920,6 +246455,9 @@ } } } + }, + "Pump supported increment:" : { + }, "Pump suspended" : { "localizations" : { @@ -246091,6 +247629,9 @@ } } }, + "Quantity of the small safety TBR in % of Profile BR, which is given to avoid ketoacidosis." : { + "comment" : "Safety TBR miniHint" + }, "Quantity Protein" : { "localizations" : { "bg" : { @@ -246746,6 +248287,7 @@ } }, "Raise target glucose when Autosens Ratio is less than 1." : { + "comment" : "Mini-hint for Sensitivity Raises Target setting", "localizations" : { "bg" : { "stringUnit" : { @@ -246875,6 +248417,9 @@ } } }, + "Raises the glucose floor below which SMBs are blocked. 0.5 keeps default behaviour, 1.0 blocks SMBs until above target." : { + "comment" : "Mini-hint for SMB Threshold Ratio setting" + }, "Random variation added to each reading to simulate real-world sensor noise." : { "localizations" : { "bg" : { @@ -247401,6 +248946,10 @@ } } }, + "rate" : { + "comment" : "Vertical lines at the start and end of each basal rate entry.", + "isCommentAutoGenerated" : true + }, "Rate" : { "comment" : "Rate basal profileLabel text for temporary basal rate summary", "localizations" : { @@ -247532,6 +249081,9 @@ } } }, + "Rate at which ISF is reduced per hour assuming BG level remains at double target for that time." : { + "comment" : "DuraISF Weight miniHint" + }, "rate-end" : { "localizations" : { "bg" : { @@ -247791,6 +249343,9 @@ } } } + }, + "Rates Adjusted" : { + }, "Ratio" : { "localizations" : { @@ -247921,6 +249476,12 @@ } } } + }, + "ratioFloor = TargetGlucose − (1 − ratio) × (TargetGlucose − 40)" : { + + }, + "ratioFloor = TargetGlucose − (1 − SMB Threshold Ratio) × (TargetGlucose − 40)" : { + }, "raw: %lf" : { "localizations" : { @@ -248182,6 +249743,9 @@ } } } + }, + "Read up on it at:" : { + }, "Readings" : { "comment" : "CGM readings in statView", @@ -249237,6 +250801,9 @@ } } } + }, + "Recenters the chart on now, from wherever you have panned to. The zoom level stays as it is." : { + }, "Recommendation" : { "comment" : "Bolus screen", @@ -251856,6 +253423,14 @@ } } }, + "Remove All Devices" : { + "comment" : "A button label that removes all connected Garmin devices.", + "isCommentAutoGenerated" : true + }, + "Remove label — make a Default" : { + "comment" : "A button that lets the user convert a labeled profile into a default one.", + "isCommentAutoGenerated" : true + }, "Remove Last" : { "localizations" : { "bg" : { @@ -252117,6 +253692,10 @@ } } }, + "Remove the label from this profile?" : { + "comment" : "A confirmation alert asking the user to remove a label from a profile.", + "isCommentAutoGenerated" : true + }, "Remove widget" : { "extractionState" : "stale", "localizations" : { @@ -252379,6 +253958,21 @@ } } }, + "Rename active Profile" : { + "comment" : "A title for an alert that appears when renaming the active profile.", + "isCommentAutoGenerated" : true + }, + "Repeat" : { + "comment" : "A label for the repeat section in the new-schedule picker.", + "isCommentAutoGenerated" : true + }, + "Repeat options" : { + "comment" : "A heading for the options that determine how often a profile activation is repeated.", + "isCommentAutoGenerated" : true + }, + "Replace" : { + "comment" : "View/Header when pod expired" + }, "Replace CGM Sensor" : { "comment" : "Title of an alert that instructs the user to replace a CGM sensor.", "localizations" : { @@ -252509,6 +254103,9 @@ } } } + }, + "Replace circles and triangles with vertical bars for bolus, SMB and carbs." : { + }, "Replace pod" : { "comment" : "View/Header when pod expired", @@ -252640,6 +254237,9 @@ } } } + }, + "Report a Problem" : { + }, "Represents carb equivalent for fat and protein, calculated using the Warsaw Method." : { "localizations" : { @@ -252771,6 +254371,15 @@ } } }, + "req." : { + "comment" : "Label for req." + }, + "Request ID was already used." : { + "comment" : "Watch command ack" + }, + "Request is already in progress." : { + "comment" : "Watch command ack" + }, "Require Adjustments Confirmation" : { "localizations" : { "bg" : { @@ -254079,6 +255688,7 @@ } }, "Rest a finger on the chart to inspect a single reading with its IOB and COB." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -254207,6 +255817,9 @@ } } } + }, + "Rest your finger on the chart to inspect a single reading with its IOB and COB." : { + }, "Restart Live Activity" : { "localizations" : { @@ -254468,6 +256081,10 @@ } } }, + "Restores the previous indefinite profile — the one whose basal schedule is already on the pump. No pump change needed." : { + "comment" : "A message displayed in the confirmation dialog for stopping a temporary profile.", + "isCommentAutoGenerated" : true + }, "Resume" : { "extractionState" : "manual", "localizations" : { @@ -255003,6 +256620,9 @@ } } } + }, + "Resume Smoothing" : { + }, "Resuming insulin delivery..." : { "comment" : "Text for suspend resume button when insulin delivery is resuming", @@ -255541,6 +257161,29 @@ } } }, + "Revert" : { + "comment" : "Action title in a glass action sheet presented when the user taps the \"Revert to previous profile?\" button in the Home screen.", + "isCommentAutoGenerated" : true + }, + "Revert to previous profile?" : { + "comment" : "Glass sheet title for reverting to a previous profile.", + "isCommentAutoGenerated" : true + }, + "Reverted to \"%@\"." : { + "comment" : "Body of profile-expired notification — interpolated value is the anchor profile name we reverted to" + }, + "Reverting" : { + "comment" : "A heading explaining how to revert to a previous profile.", + "isCommentAutoGenerated" : true + }, + "Reverts to" : { + "comment" : "A label displayed above the section that lists profiles that have been temporarily deactivated.", + "isCommentAutoGenerated" : true + }, + "Review the pre-filled therapy and algorithm values on the next screen, then save." : { + "comment" : "A footer text in the \"Add Profile\" form, instructing the user to review and save their new profile.", + "isCommentAutoGenerated" : true + }, "Rewind Resets Autosens" : { "comment" : "Rewind Resets Autosens", "localizations" : { @@ -256981,6 +258624,13 @@ } } } + }, + "Runs for" : { + "comment" : "A label describing how long a scheduled event will run.", + "isCommentAutoGenerated" : true + }, + "Runs for the chosen duration, then reverts. The pump's saved basal is not changed." : { + }, "Safety" : { "comment" : "Extra Safety Features when using dyn ISF", @@ -257250,6 +258900,9 @@ } } }, + "Safety TBR in %" : { + "comment" : "Safety TBR" + }, "Save" : { "comment" : "Save preset namebutton title for saving low reservoir reminder", "localizations" : { @@ -258184,6 +259837,21 @@ } } }, + "Save basal of Profile \"%@\" to pump?" : { + "comment" : "Title of schedule-fire notification for an indefinite activation that needs pump sync — interpolated value is the profile name" + }, + "Save basal to pump?" : { + "comment" : "A confirmation alert that appears when the user confirms saving a basal dose to the pump.", + "isCommentAutoGenerated" : true + }, + "Save Changes" : { + "comment" : "A button label that saves changes to the draft profile.", + "isCommentAutoGenerated" : true + }, + "Save failed" : { + "comment" : "An alert title when saving a profile fails.", + "isCommentAutoGenerated" : true + }, "Save on Pump" : { "extractionState" : "manual", "localizations" : { @@ -258451,6 +260119,20 @@ } } }, + "Save Profile" : { + "comment" : "A button label that saves the user's current profile.", + "isCommentAutoGenerated" : true + }, + "Save Schedule" : { + "comment" : "A button label that saves a new medication schedule.", + "isCommentAutoGenerated" : true + }, + "Save the file or send it to someone helping you on Discord or Facebook. Contains glucose, insulin and carbs; passwords and serial numbers are removed." : { + + }, + "Save to pump" : { + "comment" : "Schedule activation notification: save-basal-to-pump action" + }, "Saved Food" : { "comment" : "Saved Food Presets", "localizations" : { @@ -258986,6 +260668,7 @@ "isCommentAutoGenerated" : true }, "Scales down your basal rate to 50% at this value." : { + "comment" : "Mini-hint for Half Basal Exercise Target setting", "localizations" : { "bg" : { "stringUnit" : { @@ -259382,6 +261065,46 @@ } } }, + "Schedule ${preset} at ${startTime}" : { + + }, + "Schedule a custom Temporary Target for a date+time within the next 24 hours" : { + "comment" : "Description of the intent, used in the \"What can I do?\" section of the app.", + "isCommentAutoGenerated" : true + }, + "Schedule a profile to activate once at a specific date/time for a chosen duration. Indefinite scheduling is not available via Shortcuts." : { + "comment" : "Description of the `ScheduleProfileOnceIntent`.", + "isCommentAutoGenerated" : true + }, + "Schedule a Temporary Target preset for a date+time within the next 24 hours" : { + "comment" : "Description of the intent to schedule a Temporary Target preset for a date+time within the next 24 hours.", + "isCommentAutoGenerated" : true + }, + "Schedule name" : { + + }, + "Schedule Profile" : { + "comment" : "Title of an app shortcut that schedules a custom profile.", + "isCommentAutoGenerated" : true + }, + "Schedule Profile ${profile} at ${fireAt}" : { + + }, + "Schedule Temporary Target ${name} at ${startTime}" : { + + }, + "Schedule time must be in the future" : { + "comment" : "Error message displayed when the user attempts to schedule a profile activation in the past.", + "isCommentAutoGenerated" : true + }, + "Schedule TT" : { + "comment" : "Text for the \"Schedule TT\" shortcut in the Shortcuts app.", + "isCommentAutoGenerated" : true + }, + "Schedule TT Preset" : { + "comment" : "Text for the \"Schedule TT Preset\" shortcut.", + "isCommentAutoGenerated" : true + }, "Schedule when SMBs are Off" : { "extractionState" : "manual", "localizations" : { @@ -259649,6 +261372,9 @@ } } }, + "Scheduled profile" : { + "comment" : "Fallback profile name used in the pending-activation pump-save alert when the scheduled profile can't be resolved by id" + }, "Scheduled Reminder" : { "extractionState" : "manual", "localizations" : { @@ -259916,6 +261642,10 @@ } } }, + "Schedules" : { + "comment" : "A link in the \"Upcoming\" section of the \"Adapt Profile\" root view, leading to the \"Profile Scheduler\" view.", + "isCommentAutoGenerated" : true + }, "Schedules were saved successfully!" : { "extractionState" : "manual", "localizations" : { @@ -260707,6 +262437,10 @@ } } }, + "See compatible Trio apps on Garmin ConnectIQ." : { + "comment" : "A link that directs the user to Garmin ConnectIQ to find compatible Trio apps.", + "isCommentAutoGenerated" : true + }, "See what's new in this version." : { "localizations" : { "bg" : { @@ -261102,6 +262836,7 @@ } }, "Select All" : { + "comment" : "Toggle button in History filter popover — selects every treatment type", "localizations" : { "bg" : { "stringUnit" : { @@ -261231,6 +262966,10 @@ } } }, + "Select Concentration" : { + "comment" : "The title of the view.", + "isCommentAutoGenerated" : true + }, "Select font size, weight, and width to match your style:" : { "localizations" : { "bg" : { @@ -261491,6 +263230,10 @@ } } }, + "Select profile" : { + "comment" : "Title of the dialog presented when requesting a profile to activate.", + "isCommentAutoGenerated" : true + }, "Select simple or detailed style. See hint for more details." : { "localizations" : { "bg" : { @@ -265765,6 +267508,7 @@ }, "Set a custom time for peak insulin effect." : { "comment" : "Mini Hint for Insulin Peak Time", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -265895,6 +267639,7 @@ } }, "Set a duration!" : { + "comment" : "Validation error on Add Temp Target form when duration is zero", "localizations" : { "bg" : { "stringUnit" : { @@ -266284,6 +268029,10 @@ } } }, + "Set it conservatively; from here the ratio climbs linearly toward SMB DeliveryRatio BG Maximum as glucose rises across SMB DeliveryRatio BG Range." : { + "comment" : "A description of what the \"SMB DeliveryRatio BG Minimum\" setting does.", + "isCommentAutoGenerated" : true + }, "Set limits for each type of macro per meal entry." : { "localizations" : { "bg" : { @@ -266950,6 +268699,48 @@ } } }, + "Set the duration for the increased basal rate in B30 mode. Default is 30 minutes." : { + "comment" : "B30 Duration VerboseHint" + }, + "Set the EatingSoon TempTarget glucose level to trigger B30. Should be a low TT like %@ %@. Keep in mind it should be an even TT to allow autoISF SMB's after the duration specified, if the target would still be active. Canceling this TT will imediatly stop B30 adaptions." : { + "comment" : "B30 TT Level VerboseHint", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Set the EatingSoon TempTarget glucose level to trigger B30. Should be a low TT like %1$@ %2$@. Keep in mind it should be an even TT to allow autoISF SMB's after the duration specified, if the target would still be active. Canceling this TT will imediatly stop B30 adaptions." + } + } + } + }, + "Set the maximum BG delta limit for B30 & suppressed SMB to remain active. Default is %@ %@." : { + "comment" : "B30 Upper Delta VerboseHint", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Set the maximum BG delta limit for B30 & suppressed SMB to remain active. Default is %1$@ %2$@." + } + } + } + }, + "Set the maximum BG level for B30 & suppressed SMB to remain active. Default is %@ %@." : { + "comment" : "B30 Upper BG Limit VerboseHint", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Set the maximum BG level for B30 & suppressed SMB to remain active. Default is %1$@ %2$@." + } + } + } + }, + "Set the maximum timeframe to extend FPUs." : { + + }, + "Set the percentage of the current basal rate to apply for safety against ketoacidosis. Recommended between 10% - 20%" : { + "comment" : "Safety TBR VerboseHint" + }, "Set the size of the glucose reading." : { }, @@ -267091,6 +268882,10 @@ } } }, + "Set to 0 to disable the ramp — the fixed SMB DeliveryRatio is used instead." : { + "comment" : "A note within the verbose hint for `smbDeliveryRatioBGrange` explaining when the default value of `0` should be used.", + "isCommentAutoGenerated" : true + }, "Set to a number, e.g. 160, which means when temp target is 160 mg/dL and exercise_mode=true, run 50% basal at this level (120 = 75%; 140 = 60%). This can be adjusted, to give you more control over your exercise modes." : { "comment" : "\"Half Basal Exercise Target\"", "extractionState" : "manual", @@ -267618,6 +269413,9 @@ } } } + }, + "Settings for protective TBR" : { + }, "Settings imported" : { "extractionState" : "manual", @@ -269342,6 +271140,7 @@ }, "Show All" : { "comment" : "Name of the bolus display threshold option that shows all bolus amounts.", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -269471,6 +271270,10 @@ } } }, + "Show all labels" : { + "comment" : "Name to display for the option to show all insulin labels.", + "isCommentAutoGenerated" : true + }, "Show carbs required as a red icon on the main graph icon." : { "localizations" : { "bg" : { @@ -269730,6 +271533,12 @@ } } } + }, + "Show CGM Status Around Bobble" : { + + }, + "Show COB-IOB Chart" : { + }, "Show Delta" : { "localizations" : { @@ -270124,6 +271933,7 @@ } }, "Show Future" : { + "comment" : "Toggle button in History — show entries with future timestamps\nToggle button on History Carb Entries view — show entries with future timestamps", "localizations" : { "bg" : { "stringUnit" : { @@ -270520,6 +272330,9 @@ } } } + }, + "Show Glucose Peaks" : { + }, "Show live activity" : { "comment" : "Notification option", @@ -271438,8 +273251,18 @@ } } } + }, + "Shows a manually entered insulin dose." : { + + }, + "Shows a small automated insulin dose (super-micro-bolus)." : { + + }, + "Shows an externally administered insulin dose (e.g., a pen shot)." : { + }, "Shows an insulin dose, which can be a small automated dose (super-micro-bolus), a manually entered dose, or one given externally (e.g., a pen shot)." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -271568,6 +273391,21 @@ } } } + }, + "Shows current basal rate being delivered in insulin units per hour." : { + + }, + "Shows current COB amount in g." : { + + }, + "Shows current final ISF / Sensitivity adjustment Ratio as derived by autoISF algorithm." : { + + }, + "Shows current final ISF / Sensitivity adjustment Ratio as derived by autosens algorithm. Only shown when autoISF is not enabled." : { + + }, + "Shows current IOB in insulin units." : { + }, "Shows current or past TBRs, which can be set by the oref algorithm or manually." : { "localizations" : { @@ -271698,6 +273536,9 @@ } } } + }, + "Shows decreasing Pump Reservoir levels, progressively colorized from green to red." : { + }, "Shows forecast on the Apple Watch" : { "comment" : "Text displayed in a hint when the user enables the feature to show the forecast on the Apple Watch.", @@ -272093,8 +273934,15 @@ } } } + }, + "Shows the status popup with the details of the last loop cycle." : { + + }, + "Shows the total dose of insulin for the last 24 hours." : { + }, "Sigmoid" : { + "comment" : "Dynamic ISF picker option — sigmoid formula\nRow label for the Sigmoid (vs logarithmic) Dynamic-ISF formula toggle", "localizations" : { "bg" : { "stringUnit" : { @@ -272225,6 +274073,7 @@ } }, "Sigmoid Adjustment Factor" : { + "comment" : "Row label for the Sigmoid variant of the Dynamic ISF Adjustment Factor", "localizations" : { "bg" : { "stringUnit" : { @@ -273265,6 +275114,14 @@ } } }, + "Simulator only - for testing UI workflow" : { + "comment" : "A note displayed in the simulator that explains that the button to remove all devices is for testing purposes only.", + "isCommentAutoGenerated" : true + }, + "Simulator Testing" : { + "comment" : "A section header in the watch configuration view that allows for testing the UI without a physical Garmin watch.", + "isCommentAutoGenerated" : true + }, "Sine Wave" : { "comment" : "Name of a sound file in the catalog.", "isCommentAutoGenerated" : true @@ -273802,6 +275659,12 @@ } } }, + "Slide to activate for %@" : { + "comment" : "Slide-to-confirm button label — the interpolated value is a formatted duration like '3 hr 15 min'" + }, + "Slide to activate indefinitely" : { + "comment" : "Slide-to-confirm button label when user chose indefinite activation" + }, "Slide to Confirm" : { "comment" : "Slide to confirm label when nothing is picked yet", "localizations" : { @@ -274997,7 +276860,7 @@ } }, "SMB" : { - "comment" : "An Automatic delivered bolus (SMB)", + "comment" : "Label for SMB\nTreatment history type label — SMB (super-microbolus)\nTreatment row label — SMB", "localizations" : { "bg" : { "stringUnit" : { @@ -275258,7 +277121,7 @@ } }, "SMB Delivery Ratio" : { - "comment" : "SMB Delivery Ratio", + "comment" : "Row label on Add Override form — SMB Delivery Ratio\nRow label on Edit Override form — SMB Delivery Ratio\nSMB Delivery Ratio", "localizations" : { "bg" : { "stringUnit" : { @@ -275388,6 +277251,15 @@ } } }, + "SMB Delivery Ratio BG Range" : { + "comment" : "Row label on Add Override form — SMB Delivery Ratio BG Range\nRow label on Edit Override form — SMB Delivery Ratio BG Range" + }, + "SMB Delivery Ratio Max" : { + "comment" : "Row label on Add Override form — SMB Delivery Ratio Max\nRow label on Edit Override form — SMB Delivery Ratio Max" + }, + "SMB Delivery Ratio Min" : { + "comment" : "Row label on Add Override form — SMB Delivery Ratio Min\nRow label on Edit Override form — SMB Delivery Ratio Min" + }, "SMB Delivery Ratio of calculated insulin required that is given as SMB." : { "localizations" : { "bg" : { @@ -275518,6 +277390,9 @@ } } }, + "SMB Delivery Ratios" : { + "comment" : "Section header on the AutoISF draft editor grouping the fixed and range-based SMB delivery ratio rows" + }, "SMB DeliveryRatio" : { "comment" : "Headline \"SMB DeliveryRatio\"", "extractionState" : "manual", @@ -275656,6 +277531,18 @@ } } }, + "SMB DeliveryRatio (fixed)" : { + "comment" : "SMB DeliveryRatio" + }, + "SMB DeliveryRatio BG Maximum" : { + "comment" : "SMB DeliveryRatio Maximum" + }, + "SMB DeliveryRatio BG Minimum" : { + "comment" : "SMB DeliveryRatio Minimum" + }, + "SMB DeliveryRatio BG Range" : { + "comment" : "SMB DeliveryRatio BG Range" + }, "SMB Interval" : { "comment" : "SMB Interval", "localizations" : { @@ -275787,6 +277674,12 @@ } } }, + "SMB Max Range Extension" : { + + }, + "SMB Max RangeExtension" : { + "comment" : "SMB Max RangeExtension" + }, "SMB Minutes" : { "extractionState" : "manual", "localizations" : { @@ -276184,6 +278077,9 @@ } } }, + "SMB Threshold Ratio" : { + "comment" : "SMB Threshold Ratio" + }, "SMB:" : { "localizations" : { "bg" : { @@ -276314,7 +278210,12 @@ } } }, + "SMBs and Non-Zero Temp. Basal Rates are disabled." : { + "comment" : "A message displayed in the Home status popup when a user's glucose reading is 400 mg/dL, indicating that they have an invalid reading. This message also informs the user that they cannot set non", + "isCommentAutoGenerated" : true + }, "SMBs Off%@" : { + "comment" : "Override subtitle fragment on Home showing that SMBs are disabled — interpolated value is an optional time-range like ' 08:00-10:00'", "localizations" : { "bg" : { "stringUnit" : { @@ -276444,7 +278345,11 @@ } } }, + "Smooth CGM readings to reduce noise." : { + "comment" : "Mini hint for smooth glucose value" + }, "Smooth CGM readings using exponential smoothing." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -276575,7 +278480,7 @@ } }, "Smooth Glucose Value" : { - "comment" : "Smoothing of CGM readings", + "comment" : "Smooth Glucose Value", "localizations" : { "bg" : { "stringUnit" : { @@ -276835,6 +278740,19 @@ } } }, + "Smoothing Algorithm" : { + "comment" : "The header for the section that lets the user choose the glucose smoothing algorithm.", + "isCommentAutoGenerated" : true + }, + "Smoothing Algorithm Changed" : { + + }, + "Smoothing has been disabled to clear old smoothed values. Toggle smoothing back on to apply the new algorithm to all glucose readings." : { + + }, + "Smoothing: On" : { + "comment" : "Tag chip in Home status popup — glucose smoothing is enabled\nTag chip in Loop status popup — glucose smoothing is enabled" + }, "Snooze" : { "localizations" : { "bg" : { @@ -278537,6 +280455,9 @@ } } } + }, + "Some basal rates have been rounded to match available pump increments for U%lld insulin. Highlighted entries show adjusted values. Please REVIEW and Save." : { + }, "Some helpful reminders:" : { "localizations" : { @@ -279455,6 +281376,18 @@ } } }, + "Specify a fixed basal rate for safety against ketoacidosis." : { + "comment" : "Absolute TBR VerboseHint" + }, + "Specify an absolute TBR between 0 and 2 U/hr instead of a percentage of the current basal rate." : { + "comment" : "Enable Absolute Safety TBR miniHint" + }, + "Specify the factor to increase the basal rate during B30. Max is 10x." : { + "comment" : "B30 Factor VerboseHint" + }, + "Specify the minimum bolus size required to trigger B30." : { + "comment" : "B30 Start Bolus VerboseHint" + }, "Split" : { "localizations" : { "bg" : { @@ -279846,6 +281779,9 @@ } } }, + "stabilizing" : { + "comment" : "Accessibility: sensor lifecycle tag" + }, "STALE DATA" : { "comment" : "Information displayed when watch app data outdated or stale.", "localizations" : { @@ -279977,6 +281913,46 @@ } } }, + "Standard" : { + "comment" : "Temp target sensitivity mode — preset ratios" + }, + "Standard (%@)" : { + "comment" : "Standard lower threshold for TIR and TITR statistics", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard (%@)" + } + } + } + }, + "Standard insulin concentration is U100 (100 units per mL), which is the baseline for most insulin pump delivery systems. To show this increment independent of units delivered, it can be shown as volume in μL/hr. The Pump Increment in μL/hr = 10 * Pump Increment in standard units/hr " : { + + }, + "Standard Lower Threshold" : { + "comment" : "Search term for the standard lower range threshold setting.", + "extractionState" : "stale", + "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:" + } + } + } + }, "start" : { "localizations" : { "bg" : { @@ -281039,6 +283015,10 @@ } } }, + "Start time" : { + "comment" : "Label for the date and time parameter in the Schedule Custom Temp Target intent.", + "isCommentAutoGenerated" : true + }, "Start Time" : { "localizations" : { "bg" : { @@ -281169,6 +283149,10 @@ } } }, + "Start time must be within the next 24 hours (now until +24 h)" : { + "comment" : "Error message displayed when the requested start time for a Temp Target is outside the valid range (within the next 24 hours).", + "isCommentAutoGenerated" : true + }, "Started Override \"%@\" successfully." : { "comment" : "Start override with override name", "localizations" : { @@ -282625,9 +284609,6 @@ } } } - }, - "Home Statistics Range" : { - }, "Stay in open loop (no automated dosing) for a bit to help Trio catch up to keep you safe" : { "localizations" : { @@ -283028,6 +285009,7 @@ } }, "Steps through the zoom presets — 6, 12 and 24 hours — keeping the right-hand edge of the window where it is." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -284198,6 +286180,10 @@ } } }, + "Stop Temp Profile" : { + "comment" : "A button label that stops a temporary profile.", + "isCommentAutoGenerated" : true + }, "Stop temp target" : { "comment" : "A label displayed as a button to stop a temp target.", "localizations" : { @@ -284719,6 +286705,10 @@ } } }, + "Stop the temporary profile?" : { + "comment" : "A confirmation dialog title asking if the user wants to stop a temporary profile.", + "isCommentAutoGenerated" : true + }, "Stop Trio Alarm" : { "comment" : "Title of the intent that stops a Trio critical-alert alarm.", "localizations" : { @@ -285112,6 +287102,14 @@ } } }, + "Stopping a temp profile (or waiting for it to expire) returns you to the indefinite profile whose basal is already on the pump. Revert always targets that anchor — chains of temps can never leave the pump in an unfamiliar state." : { + "comment" : "A description of the \"revert\" functionality in the Profile editor.", + "isCommentAutoGenerated" : true + }, + "Stopping the temp profile failed" : { + "comment" : "An alert title when stopping a temporary profile fails.", + "isCommentAutoGenerated" : true + }, "Store not initialized in %@ from %@." : { "localizations" : { "bg" : { @@ -285248,6 +287246,23 @@ } } }, + "Strategy Definition" : { + + }, + "Strength of acce_ISF contribution with negative acceleration." : { + "comment" : "A verbose hint explaining the meaning of the \"ISF Weight While BG Decelerates\" setting.", + "isCommentAutoGenerated" : true + }, + "Strength of acce_ISF contribution with positive acceleration. Start with 0.02 as initial value." : { + "comment" : "A verbose hint explaining the purpose and default value of the \"ISF Weight While BG Accelerates\" setting.", + "isCommentAutoGenerated" : true + }, + "Strengthens ISF decrease while glucose accelerates." : { + "comment" : "ISF Weight While BG Accelerates miniHint" + }, + "Strengthens ISF increase while glucose decelarates." : { + "comment" : "ISF Weight While BG Accelerates miniHint" + }, "Style" : { "localizations" : { "bg" : { @@ -285768,6 +287783,10 @@ } } }, + "Suggest CSF from ISF & CR at 1pm" : { + "comment" : "A button label that suggests suggesting a default CSF value based on the ISF and CR at 1pm.", + "isCommentAutoGenerated" : true + }, "Suggested at" : { "comment" : "Headline in suggested pop up (at: at what time)", "extractionState" : "manual", @@ -286557,6 +288576,7 @@ } }, "Super Micro Bolus (SMB)" : { + "comment" : "Navigation-link title for the SMB section in the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -286687,6 +288707,7 @@ } }, "Super-Micro-Bolus" : { + "comment" : "Section header on the SMB Settings screen grouping core SMB enable toggles", "localizations" : { "bg" : { "stringUnit" : { @@ -287603,9 +289624,6 @@ } } }, - "Suspended" : { - "comment" : "Basal delivery suspended on the pump" - }, "Suspending insulin delivery..." : { "comment" : "Text for suspend resume button when insulin delivery is suspending", "extractionState" : "manual", @@ -287744,6 +289762,14 @@ } } }, + "Swipe left on a schedule row to enable, disable, or delete it. Disabled schedules sink to the bottom and don't fire. The Upcoming section on the Profiles screen shows the next two fires across all enabled schedules." : { + "comment" : "A description of how to manage profiles, including enabling, disabling, and deleting them.", + "isCommentAutoGenerated" : true + }, + "Swipe left to edit or delete a profile. Hold, drag and drop to reorder." : { + "comment" : "A footer text displayed below the list of profiles in the \"Profiles\" section of the root view of the `AdaptProfile` module.", + "isCommentAutoGenerated" : true + }, "Swipe left to edit or delete a temporary target preset. Hold, drag and drop to reorder a preset." : { "localizations" : { "bg" : { @@ -288003,6 +290029,9 @@ } } } + }, + "Swipe left to enable, disable, or delete. Enabled schedules are sorted by next fire; disabled ones sink to the bottom." : { + }, "Swipe left to end snooze." : { "localizations" : { @@ -288393,6 +290422,9 @@ } } } + }, + "Swipe Up on Status Popup" : { + }, "Swissalpine" : { "comment" : "Name of the Swissalpine watchface.", @@ -288524,6 +290556,12 @@ } } } + }, + "Switch Autosens on/off" : { + "comment" : "Autosens miniHint" + }, + "Switching insulin concentration changes the basal increments that can be delivered by pump. For diluted insulins smaller increments can be used. For high concentrations the increment will be larger than your regular U100 increment - this compulsory needs adjustment!\n\nBe aware that the pump managers will always show U100 as concentration setting, as they on their own currently only deal with U100. The seeting here in Tai will prevail nonetheless!" : { + }, "Synth" : { "comment" : "Display name for the synth alarm sound.", @@ -289046,6 +291084,47 @@ } } }, + "Tai as the Trio version of autoISF does not include ActivityTracking." : { + + }, + "Tai collects the app's state on crash, device, iOS and general system info, and a stack trace." : { + "comment" : "Explanation of the data that Tai collects on app crashes.", + "isCommentAutoGenerated" : true + }, + "Tai does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, setting values, or similar." : { + "comment" : "Explanation of why Tai does not collect health-related data.", + "isCommentAutoGenerated" : true + }, + "Tai does not track any usage metrics or any other personal data about users other than the used iPhone model and iOS version." : { + "comment" : "Explanation of why Tai does not track personal data.", + "isCommentAutoGenerated" : true + }, + "Tai specific settings like autoISF or Ketoacidosis Protection need to be done manually after Trio onboarding." : { + "comment" : "A description of a setting that is only available in the Tai app.", + "isCommentAutoGenerated" : true + }, + "Tai v%@ (%@)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tai v%1$@ (%2$@)" + } + } + } + }, + "Tai v%@ (%@) %@" : { + "comment" : "The version and copyright information for the app.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tai v%1$@ (%2$@) %3$@" + } + } + } + }, "Take a deep breath — you've got this." : { "localizations" : { "bg" : { @@ -289176,6 +291255,14 @@ } } }, + "Tap \"Add Profile\" to start from the currently active profile's settings. First pick a percentage to scale Basal, ISF and CR in one step, then fine-tune any individual setting in the editor that follows." : { + "comment" : "A description of how to create a new profile.", + "isCommentAutoGenerated" : true + }, + "Tap + to schedule an automatic profile switch." : { + "comment" : "A description below the \"No schedules yet\" message in the empty state of the root view of the `ProfileScheduler`.", + "isCommentAutoGenerated" : true + }, "Tap a bolus amount, a carb amount, or both, then slide to confirm. Face ID or Touch ID is always required before a bolus is enacted." : { "comment" : "A description of how to use the quick-pick treatments feature.", "localizations" : { @@ -289437,6 +291524,9 @@ } } }, + "Tap an entry row for details." : { + "comment" : "Text prompting user to tap an entry row for details" + }, "Tap and hold a bar in the chart to reveal more details. Swipe to scroll through time." : { "localizations" : { "bg" : { @@ -289956,6 +292046,15 @@ } } } + }, + "Tap Glucose or Pump Panel" : { + + }, + "Tap Loop Pill" : { + + }, + "Tap Tai Button" : { + }, "Tap the button below to open your Nightscout instance in your iPhone's default browser." : { "localizations" : { @@ -290347,6 +292446,9 @@ } } }, + "Tap to set" : { + "comment" : "Placeholder on activation duration row when the user hasn't chosen hours or minutes yet" + }, "tapped save schedules" : { "extractionState" : "manual", "localizations" : { @@ -290745,6 +292847,7 @@ } }, "Target Behavior" : { + "comment" : "Navigation-link title for the Target Behavior section in the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -291142,6 +293245,7 @@ } }, "Target glucose is out of range (%@)." : { + "comment" : "Validation error on Add Override form when target glucose is outside allowed range — interpolated value is the unit-dependent range\nValidation error on Edit Override form when target glucose is outside allowed range — interpolated value is the unit-dependent range", "localizations" : { "bg" : { "stringUnit" : { @@ -291270,6 +293374,9 @@ } } } + }, + "Target glucose value in the unit configured in Trio (mg/dL or mmol/L)" : { + }, "Target presets" : { "comment" : "Debug option view Target presets", @@ -291547,6 +293654,18 @@ } } }, + "Target value" : { + "comment" : "Label for the parameter that asks the user to input the target glucose value.", + "isCommentAutoGenerated" : true + }, + "Target value must be positive" : { + "comment" : "Error message when the target glucose value entered is not positive.", + "isCommentAutoGenerated" : true + }, + "Target value?" : { + "comment" : "Prompt to enter the target glucose value for the Temp Target.", + "isCommentAutoGenerated" : true + }, "TargetGlucose - 0.5 × (TargetGlucose - 40)" : { "localizations" : { "bg" : { @@ -291937,8 +294056,14 @@ } } }, + "TDD 24h" : { + + }, + "TDD: %@" : { + "comment" : "Tag chip in Loop status popup — total daily dose" + }, "Temp Basal" : { - "comment" : "Treatment label in history for temporary basal\nTreatment type", + "comment" : "Treatment history type label — temporary basal\nTreatment label in history for temporary basal\nTreatment type", "localizations" : { "bg" : { "stringUnit" : { @@ -292205,6 +294330,37 @@ } } }, + "Temp Target '%@' applied for %lld min" : { + "comment" : "Confirmation message displayed after successfully applying a Temp Target. The first argument is the name of the Temp Target. The second argument is the duration of the Temp Target in minutes.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Temp Target '%1$@' applied for %2$lld min" + } + } + } + }, + "Temp Target '%@' failed" : { + "comment" : "A description of an error that might occur when creating a custom Temp Target. The argument is the name of the Temp Target that failed to be created.", + "isCommentAutoGenerated" : true + }, + "Temp Target '%@' scheduled for %@, %lld min" : { + "comment" : "Confirmation dialog text after successfully scheduling a custom Temp Target. The first argument is the name of the Temp Target. The second argument is the time string. The third argument is the duration string.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Temp Target '%1$@' scheduled for %2$@, %3$lld min" + } + } + } + }, + "Temp target preset not found." : { + "comment" : "Watch command ack" + }, "Temp Target Presets" : { "localizations" : { "bg" : { @@ -292335,6 +294491,12 @@ } } }, + "Temp target started." : { + "comment" : "Watch command ack" + }, + "Temp target stopped." : { + "comment" : "Watch command ack" + }, "Temp Target to Stop" : { "localizations" : { "bg" : { @@ -292740,6 +294902,27 @@ } } } + }, + "Temporary — active for %@, auto-reverts at %@." : { + "comment" : "Body of schedule-fire notification for a timed activation — first interpolated value is a duration like '3 hr 15 min', second is a clock time", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Temporary — active for %1$@, auto-reverts at %2$@." + } + } + } + }, + "Temporary (up to 24 h): runs for the chosen duration, then reverts to the anchor (your last indefinite profile). Trio and the pump manager deliver the profile's basal rates live — the pump's saved basal schedule is NOT changed. If pump connectivity drops, the pump falls back to its last saved schedule." : { + "comment" : "A description of a temporary profile, explaining that it runs for a set duration before reverting to the user's last indefinite profile. It also mentions that the pump is updated in real-time with the profile'", + "isCommentAutoGenerated" : true + }, + "Temporary activation: an informational notification confirms the switch (\"Profile X activated — auto-reverts at hh:mm\"). No action required." : { + + }, + "Temporary activations don't overwrite the pump's saved schedule — Trio and the pump manager deliver the temp profile's rates live. That's what lets the temp auto-revert cleanly and also protects you if pump connectivity drops (the pump falls back to its last saved schedule)." : { + }, "Temporary Basal" : { "comment" : "Navigation Title for ManualTempBasalEntryView", @@ -293537,6 +295720,18 @@ } } }, + "Temporary Target '%@' scheduled for %@" : { + "comment" : "Confirmation dialog text when a Temporary Target is successfully scheduled. The first argument is the name of the preset. The second argument is the formatted start time.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Temporary Target '%1$@' scheduled for %2$@" + } + } + } + }, "Temporary Target canceled" : { "localizations" : { "bg" : { @@ -293797,6 +295992,9 @@ } } }, + "TempTarget Level for B30" : { + "comment" : "B30 TT Level" + }, "The Adjustment Factor (AF) allows you to control how quickly and effectively Dynamic ISF responds to changes in glucose levels." : { "localizations" : { "bg" : { @@ -294453,6 +296651,9 @@ } } } + }, + "The bar height grows with the dose or carb amount, making it easier to spot large entries at a glance." : { + }, "The bolus cannot be larger than the pump setting max bolus (%@)." : { "localizations" : { @@ -294846,6 +297047,10 @@ } } }, + "The CSF profile has a single purpose: to re-calculate your Carb Ratio (CR) from your Insulin Sensitivity Factor (ISF) using the formula: CR = ISF / CSF. It is not used directly by the algorithm or anything else." : { + "comment" : "A description of the purpose of the Carb Sensitivity Factor (CSF) in the TherapySettingsView.", + "isCommentAutoGenerated" : true + }, "The current version has a critical issue and should be updated as soon as possible." : { "comment" : "Message for critical update alert", "localizations" : { @@ -296170,6 +298375,7 @@ } }, "The duration of insulin action (DIA) is now locked to Trio’s new default of 10 hours." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -296299,6 +298505,10 @@ } } }, + "The duration of insulin action (DIA) is now reset to Trio’s new default of 10 hours." : { + "comment" : "A reminder that the duration of insulin action (DIA) is now reset to Trio’s new default of 10 hours.", + "isCommentAutoGenerated" : true + }, "The external bolus cannot be larger than 3 x the pump setting max bolus (%@)." : { "localizations" : { "bg" : { @@ -297601,6 +299811,12 @@ } } } + }, + "The insulin concentration can only be changed if you change the insulin in your pump. To make that sure for every pump model, you will have to delete the current pump, change the Insulin Concentration and add your pump again." : { + + }, + "The insulin concentration is given in Insulin Units per mL. The standard is U100 with 100 U/mL." : { + }, "The lowest forecasted glucose during the remaining duration of insulin action (DIA)." : { "localizations" : { @@ -298512,6 +300728,10 @@ } } }, + "The normal case is to pick the strongest factor as the one and only factor to be applied. Here autosense is also part of the game. But how about the exceptions, i.e., when different factors pull in different directions? In order of precedence they are:" : { + "comment" : "A hint explaining how the autoISF algorithm works.", + "isCommentAutoGenerated" : true + }, "The order of Fat and Protein inputs has changed." : { "localizations" : { "bg" : { @@ -299169,6 +301389,13 @@ } } } + }, + "the preset to schedule" : { + "comment" : "Title of the parameter that allows the user to specify which temporary target preset to schedule.", + "isCommentAutoGenerated" : true + }, + "The profile couldn't be saved. Check the name and try again." : { + }, "The ratio of how sensitive or resistant to insulin you are in the current loop cycle. Baseline = 1.0, Sensitive < 1.0, Resistant > 1.0" : { "localizations" : { @@ -299689,6 +301916,9 @@ } } } + }, + "The shorter the interval, the smoother the correlating dosing result." : { + }, "The Sigmoid Adjustment Factor (AF) allows you to control how quickly Sigmoid Dynamic ISF responds to changes in glucose levels and at what glucose value you will reach your Autosens Max and Autosens Min limits." : { "localizations" : { @@ -299949,6 +302179,9 @@ } } } + }, + "The time between new glucose readings (default: 5 minutes). Reducing this will generate readings more frequently." : { + }, "The time it takes to complete one full cycle from high to low and back to high." : { "localizations" : { @@ -300210,6 +302443,10 @@ } } }, + "The variable IOB Threshold Percent holds a percentage of the maxIOB which is used as the threshold to disable SMB. If this is enabled by setting it lower than 100%, any sensitivity changes defined by the user are modulated internally into an effective IOB Threshold.\n\nThe new capabilities are:" : { + "comment" : "A paragraph explaining the purpose and behaviour of the \"autoISF IOB Threshold Percent\" setting.", + "isCommentAutoGenerated" : true + }, "The Warsaw Method helps account for the delayed glucose spikes caused by fat and protein in meals. It uses Fat-Protein Units (FPU) to calculate the carb effect from fat and protein. The system spreads insulin delivery over several hours to mimic natural insulin release, helping to manage post-meal glucose spikes." : { "localizations" : { "bg" : { @@ -300471,6 +302708,13 @@ } } }, + "Therapy Adjustment" : { + "comment" : "Section header for the therapy-adjustment percentage on Add Profile form" + }, + "Therapy and algorithm values will be kept. The profile will no longer show a percent or tuned badge." : { + "comment" : "A message displayed in an alert when a user confirms removing a label from a profile. It explains that the profile will no longer show a percent or tuned badge, but that the therapy and algorithm values will be kept.", + "isCommentAutoGenerated" : true + }, "Therapy Settings" : { "localizations" : { "bg" : { @@ -302306,6 +304550,13 @@ } } }, + "This also means that when calculating meal insulin, your Carb Ratio effectively becomes dynamic: since CR = ISF / CSF and CSF is held constant, CR automatically adjusts as ISF changes throughout the day. Some refer to this as \"dynamic CR\"." : { + + }, + "This button re-calculates your Carb Ratio (CR) profile from your Insulin Sensitivity Factor (ISF) and Carb Sensitivity Factor (CSF) profiles using the formula: CR = ISF / CSF." : { + "comment" : "A description of the Calculate CR from CSF & ISF Profiles button.", + "isCommentAutoGenerated" : true + }, "This cap prevents the system from overestimating how much insulin is needed when carb absorption isn't visible, offering a safeguard for accurate dosing." : { "localizations" : { "bg" : { @@ -303890,6 +306141,9 @@ } } }, + "This feature enables a small safety Temp Basal Rate (TBR) to reduce ketoacidosis risk. Without the Variable Protection, the safety TBR is always applied." : { + "comment" : "Mini-hint for Activate KetoProtection setting" + }, "This feature ensures more accurate insulin adjustments when carb entries are missing or incorrect." : { "localizations" : { "bg" : { @@ -304280,7 +306534,12 @@ } } }, + "This feature smooths your CGM readings to reduce noise and make them easier to read. 2 Algorithms can be used, exponential smoothing is the method used in stock AndroidAPS (AAPS). Alternatively, a more sophisticated method using UKF is adopted from the AAPS Tsunami branch." : { + "comment" : "A verbose hint for smooth glucose value, describing how it works.", + "isCommentAutoGenerated" : true + }, "This feature smooths your CGM readings to reduce noise and make them easier to read. It is based on a method used in AndroidAPS (AAPS). It uses two approaches: one that reacts quickly to recent changes, and one that looks at longer trends. These are combined to give a balanced result." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -305076,6 +307335,10 @@ } } }, + "This is a classical insulin resistance and is typically caused by free fatty acids which grab available insulin before glucose can. Quite often, users get impatient in such a situation and administer one or even more rage boluses. Again and again, that leads to hypos later which the dura_ISF approach avoids if carefully tuned.\n\nThe strengthening of ISF is stronger the longer the situation lasts and the higher the average glucose is above target:\n\ndura_ISF = 1 + (avg05 - target_bg) / target_bg * dura05 * dura_ISF_weight\n\nwhere:\navg05 = dura_ISF_average\ndura05 = dura_ISF_minutes\n\nThe user can apply his personal weighting by using dura_ISF_weight. Start cautiously with a value of 0.2 and be very careful when you approach 1.5 or even higher. By using 0 this effect is disabled." : { + "comment" : "A description of the dura_ISF effect.", + "isCommentAutoGenerated" : true + }, "This is a limit on the size of a single SMB. One SMB can only be as large as this many minutes of your current profile basal rate." : { "localizations" : { "bg" : { @@ -305336,6 +307599,10 @@ } } }, + "This is a multiplier cap for autoISF to set a limit on how low the autoISF ratio can be, which in turn determines how high it can adjust ISF." : { + "comment" : "A verbose hint explaining the purpose of the \"autoISF Min\" setting.", + "isCommentAutoGenerated" : true + }, "This is a multiplier cap for autosens (and autotune) to set a 20% max limit on how high the autosens ratio can be, which in turn determines how high autosens can adjust basals, how low it can adjust ISF, and how low it can set the BG target." : { "comment" : "\"Autosens Max\"", "extractionState" : "manual", @@ -306147,6 +308414,12 @@ } } } + }, + "This is another key OpenAPS safety cap, and specifies what share of the total insulin required can be delivered as SMB." : { + "comment" : "SMB DeliveryRatio miniHint" + }, + "This is important because creating separate CR and ISF profiles implicitly defines an unreviewed CSF profile (calculated as CSF = ISF / CR) that may be incorrect. By explicitly defining your CSF profile, you ensure that CR automatically adjusts correctly throughout the day as your ISF changes, maintaining physiologically accurate insulin dosing for carbohydrates." : { + }, "This is the amount of the maximum number of carbs we’ll assume will absorb over 4h if we don’t yet see carb absorption." : { "comment" : "\"Remaining Carbs Cap\"", @@ -306286,8 +308559,12 @@ } } }, + "This is the development version of Tai, built from the dev branch.\n\nAny updates on this branch may contain new, lightly tested features, and may be unsafe. If you are not a tester, please do not use this branch, and switch to main." : { + "comment" : "Body of the warning shown on builds from the dev branch" + }, "This is the development version of Trio, built from the dev branch.\n\nAny updates on this branch may contain new, lightly tested features, and may be unsafe. If you are not a tester, please do not use this branch, and switch to main." : { "comment" : "Body of the warning shown on builds from the dev branch", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -307075,6 +309352,22 @@ } } }, + "This is the original effect of autoISF in action since August 2020. Because autoISF is now a toolbox of several effects, this original effect was renamed dura_ISF. It addresses situations when:" : { + "comment" : "A paragraph explaining the original purpose of the autoISF effect.", + "isCommentAutoGenerated" : true + }, + "This is the share of maxIOB above which autoISF will disable SMB. 100% neutralizes it's effect." : { + "comment" : "autoISF IOB Threshold miniHint" + }, + "This is the weight applied to the linear slope while glucose rises and adapts ISF. With 0 this contribution is effectively disabled. Start with 0.01 - it hardly goes beyond 0.05!" : { + "comment" : "ISF weight for postprandial BG rise miniHint" + }, + "This is the weight applied to the polygon which adapts ISF if glucose is above target." : { + "comment" : "ISF Weight for Higher BGs miniHint" + }, + "This is the weight applied to the polygon which adapts ISF if glucose is below target." : { + "comment" : "ISF Weight for Lower BGs miniHint" + }, "This is useful for slow-absorbing meals where high fat, protein, or fiber can delay carb absorption and cause a slower rise in blood sugar, so a reduced bolus helps match insulin to the delayed effect." : { "localizations" : { "bg" : { @@ -307465,6 +309758,10 @@ } } }, + "This makes CSF independent of short-term ISF changes that occur with features like Dynamic ISF, autoISF, or Autosens." : { + "comment" : "Explanation of how the \"Use Profile CSF\" setting affects the Carb Ratio.", + "isCommentAutoGenerated" : true + }, "This means Trio may not have complete information about how much active insulin or carbs were still on board when you switched over." : { "localizations" : { "bg" : { @@ -307993,6 +310290,10 @@ } } }, + "This percentage rescales the three therapy profiles — Basal Rate, Insulin Sensitivity (ISF) and Carb Ratio (CR) — in one step. It's the same mechanism an override uses, baked into the profile so every loop cycle sees the adjusted values." : { + "comment" : "A detailed hint beneath the therapy-adjustment percentage row on Add Profile form.", + "isCommentAutoGenerated" : true + }, "This prevents excessive dosing, especially during times of variable insulin sensitivity, enhancing safety." : { "localizations" : { "bg" : { @@ -308390,6 +310691,9 @@ } } } + }, + "This sets the maximum length of time that Fat and Protein Carb Equivalents (FPUs) will be extended over from a single Fat and/or Protein bolus calcultor entry." : { + }, "This setting adjusts how much weight is given to your recent total daily insulin dose when calculating Dynamic ISF and Dynamic CR." : { "localizations" : { @@ -308912,6 +311216,7 @@ } }, "This setting controls which bolus amount labels are shown on Trio’s main chart. Boluses appear as blue upside-down triangles, with a number showing the amount. Depending on the option you choose, only boluses at or above that amount will show a label. For example, if you choose ‘0.5 U and over’, only boluses of 0.5 U or more will show a label." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -309567,6 +311872,16 @@ } } }, + "This setting is limited to values between %@ - %@ %@." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This setting is limited to values between %1$@ - %2$@ %3$@." + } + } + } + }, "This setting restricts the maximum temporary basal rate Trio can set. At the default of 300%, it caps it at 3 times your highest programmed basal rate." : { "localizations" : { "bg" : { @@ -309697,6 +312012,9 @@ } } }, + "This specifies by what factor you can exceed the limit of 180 maxSMB/maxUAM minutes." : { + "comment" : "SMB Max RangeExtension miniHint" + }, "This Temp Target preset is currently running. Deleting will stop it." : { "localizations" : { "bg" : { @@ -309827,8 +312145,12 @@ } } }, + "This version of Tai was built from '%@', not the released main branch.\n\nAny updates on this branch may contain new, lightly tested features, and may be unsafe. If you are not a tester, please do not use this branch, and switch to main." : { + "comment" : "Body of the warning shown on builds from a branch other than main or dev; the placeholder is the branch name" + }, "This version of Trio was built from '%@', not the released main branch.\n\nAny updates on this branch may contain new, lightly tested features, and may be unsafe. If you are not a tester, please do not use this branch, and switch to main." : { "comment" : "Body of the warning shown on builds from a branch other than main or dev; the placeholder is the branch name", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -310493,6 +312815,10 @@ } } }, + "threshold = minBG − (1 − ratio) × (minBG − 40)" : { + "comment" : "Explanation of the formula used to calculate the SMB threshold.", + "isCommentAutoGenerated" : true + }, "Tidepool" : { "localizations" : { "bg" : { @@ -310754,7 +313080,7 @@ } }, "Time" : { - "comment" : "Time basal profile", + "comment" : "Label for Time", "localizations" : { "bg" : { "stringUnit" : { @@ -311015,6 +313341,7 @@ } }, "Time in Normoglycemia (TING)" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -311145,6 +313472,7 @@ } }, "Time in Normoglycemia (TING):" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -311536,6 +313864,7 @@ } }, "Time in Range Type" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -311666,6 +313995,7 @@ } }, "Time in Tight Range (TITR)" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -311796,6 +314126,7 @@ } }, "Time in Tight Range (TITR):" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -312454,6 +314785,10 @@ } } }, + "Timed (up to 24 h): applied until the duration expires, then auto-reverts to the indefinite baseline. Trio and the pump manager deliver the timed profile's basal rates live — the pump's saved basal schedule is NOT changed. If pump connectivity drops, the pump falls back to its last saved schedule." : { + "comment" : "A description of the \"Timed (up to 24 h)\" activation mode in the \"RootView\" of the `AdaptProfile` module.", + "isCommentAutoGenerated" : true + }, "Tip: Decreasing this value allows automatic adjustments of basal rates to be lower and ISF to be higher." : { "localizations" : { "bg" : { @@ -313364,6 +315699,14 @@ } } }, + "Tip: Start with a single CSF value for the entire day. Look at your current ISF and CR profile values at a time of day where you feel most confident they are working well — usually around midday — and calculate CSF = ISF / CR from those values." : { + "comment" : "A tip explaining how to set up a consistent Carb Sensitivity Factor (CSF) value.", + "isCommentAutoGenerated" : true + }, + "Tip: Start with a single CSF value for the entire day. Look at your current ISF and CR values at a time of day where you feel most confident they are working well — usually around midday — and calculate CSF = ISF / CR from those values." : { + "comment" : "A tip explaining how to calculate the Carb Sensitivity Factor (CSF) from your Insulin Sensitivity Factor (ISF) and Carb Ratio (CR) values.", + "isCommentAutoGenerated" : true + }, "Tip: You may find that your normal carb ratio needs to increase to a larger number when you begin adding fat and protein entries. For this reason, it is best to start with a factor of about 50%." : { "localizations" : { "bg" : { @@ -313494,6 +315837,39 @@ } } }, + "TIR (%@-%@)" : { + "comment" : "Time in Range with the selected lower threshold and the upper threshold.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "TIR (%@-%@)" + } + } + } + }, + "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" + } + } + } + }, + "TITR (%@-%@)" : { + "comment" : "Time in Tight Range with either the Standard or Extended lower threshold.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "TITR (%@-%@)" + } + } + } + }, "To" : { "localizations" : { "bg" : { @@ -314403,6 +316779,9 @@ } } } + }, + "To edit insulin concentration, you must first remove the pump." : { + }, "To enable or configure Warsaw Method application in Trio, go to Settings > Features > Meal Settings." : { "localizations" : { @@ -315445,6 +317824,9 @@ } } } + }, + "To understand the Variable Keto Protection Strategy, read up on IOB and active Insulin (activity):" : { + }, "today" : { "comment" : "Stats banner scope", @@ -315847,7 +318229,6 @@ }, "Today's Averages" : { "comment" : "Home stats panel face option", - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -316370,6 +318751,9 @@ } } }, + "Too many requests in progress. Please try again." : { + "comment" : "Watch command ack" + }, "Top target" : { "comment" : "Upper temp target limit", "extractionState" : "manual", @@ -317824,6 +320208,9 @@ } } } + }, + "Touch and Hold" : { + }, "Tracks the carbohydrates you eat, entered to guide insulin dosing." : { "localizations" : { @@ -320717,6 +323104,7 @@ } }, "Trio does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, therapy setting values, or similar." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -321107,6 +323495,7 @@ } }, "Trio has not looped in %lld minutes." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -321237,6 +323626,7 @@ } }, "Trio has not run in %lld minutes." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -322017,6 +324407,7 @@ } }, "Trio is designed to help manage your diabetes efficiently. To get the most out of the app, we'll guide you through setting up some essential parameters." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -322146,6 +324537,10 @@ } } }, + "Trio is designed to help manage your diabetes efficiently. To get the most out of the app, we'll guide you through setting up some essential Trio parameters. Tai specific settings need to be done manually after Trio onboarding." : { + "comment" : "A paragraph explaining the purpose of the app and the importance of following the onboarding process.", + "isCommentAutoGenerated" : true + }, "Trio is holding sensitivity at 100% and ignoring these settings." : { "localizations" : { "bg" : { @@ -324751,6 +327146,9 @@ } } } + }, + "Trio uses whichever is higher: the Minimum Safety Threshold above or the ratio-derived floor below. So this ratio only takes effect when ratioFloor exceeds the Minimum Safety Threshold." : { + }, "Trio v%@" : { "localizations" : { @@ -324883,6 +327281,7 @@ } }, "Trio v%@ (%@)" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -326317,6 +328716,9 @@ } } } + }, + "Triple Tap" : { + }, "Try again in a moment, or configure your Therapy Settings manually instead." : { "localizations" : { @@ -326579,6 +328981,9 @@ } } }, + "TTs" : { + "comment" : "Selected Tab — short for Temp Targets" + }, "Turn this setting on to give basal adjustments more agility. Keep this setting off if your basal needs are not highly variable." : { "localizations" : { "bg" : { @@ -327230,6 +329635,46 @@ } } }, + "Typical: 0.1 " : { + "comment" : "A label with the typical value for `bgAccelISFweight`.", + "isCommentAutoGenerated" : true + }, + "Typical: 0.07 " : { + "comment" : "Example value for the \"ISF Weight While BG Decelerates.\" setting.", + "isCommentAutoGenerated" : true + }, + "Typical: %@ %@" : { + "comment" : "The text inside the bolded part of this string is a placeholder that should be replaced with the default value for SMB DeliveryRatio BG Maximum, in the appropriate glucose unit (e.g. \"90 mg/dL\").", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Typical: %1$@ %2$@" + } + } + } + }, + "Typical: 0.02" : { + "comment" : "A label for the typical value of the \"ISF Weight for Postprandial BG Rise\" setting.", + "isCommentAutoGenerated" : true + }, + "Typical: 0.4" : { + "comment" : "Example value for the weight applied to the polygon which adapts ISF if glucose is above target.", + "isCommentAutoGenerated" : true + }, + "Typical: 0.6" : { + "comment" : "A label displayed above a hint explaining the purpose of a setting.", + "isCommentAutoGenerated" : true + }, + "Typical: 0.7" : { + "comment" : "A label for the typical value of the \"autoISF Min\" setting.", + "isCommentAutoGenerated" : true + }, + "Typical: 2" : { + "comment" : "A note included in the verbose hint for the `autoISFmax` setting, explaining that the default value is 2.", + "isCommentAutoGenerated" : true + }, "U" : { "comment" : "Insulin unit\nInsulin unit abbreviation\nInsulin unit abbreviation shown next to a quick-pick bolus amount\nThe short unit display string for international units of insulin\nUnits for bolus amount", "localizations" : { @@ -327492,7 +329937,7 @@ } }, "U/hr" : { - "comment" : "Insulin unit per hour abbreviation", + "comment" : "Insulin unit per hour abbreviation\nUnits text for temporary basal rate", "localizations" : { "bg" : { "stringUnit" : { @@ -327621,6 +330066,15 @@ } } } + }, + "U%lld" : { + + }, + "U50 or other diluted insulins will lead to more insulin volume being pumped. So it is essential to be aware of the setting that concentrated or especially diluted insulin is used." : { + + }, + "U50 or other diluted insulins will lead to more insulin volume being pumped. Using a diluted Insulin concentration will inject larger volumes. If not set correctly potential errors can lead to severe overdosing of insulin." : { + }, "UAM" : { "localizations" : { @@ -330398,6 +332852,9 @@ } } }, + "Unscented Kalman Filter" : { + "comment" : "UKF smoothing algorithm display name" + }, "Unsuspend If No Temp" : { "comment" : "Unsuspend If No Temp", "localizations" : { @@ -330659,6 +333116,9 @@ } } }, + "until next change" : { + "comment" : "Schedule duration summary — indefinite/until-next activation\nUpcoming-schedule row duration — indefinite or until-next activation" + }, "up to date" : { "comment" : "Accessibility: version status", "localizations" : { @@ -330790,6 +333250,10 @@ } } }, + "Upcoming" : { + "comment" : "A header for the list of upcoming reminders.", + "isCommentAutoGenerated" : true + }, "update available" : { "comment" : "Accessibility: version status", "localizations" : { @@ -332901,6 +335365,16 @@ } } }, + "Upper BG Limit for B30" : { + "comment" : "B30 Upper BG Limit" + }, + "Upper Delta Limit for B30" : { + "comment" : "B30 Upper Delta" + }, + "Upper end of the linearly increasing SMB delivery ratio. Reached once glucose sits at BG target plus SMB DeliveryRatio BG Range — i.e. the maximum share of insulinReq delivered as SMB when glucose is deep above target." : { + "comment" : "A description of the upper end of the linearly increasing SMB delivery ratio.", + "isCommentAutoGenerated" : true + }, "Upper limit of the Autosens Ratio." : { "localizations" : { "bg" : { @@ -333032,6 +335506,7 @@ } }, "Upper limit of the Sensitivity Ratio." : { + "comment" : "Mini-hint for Autosens Max setting", "localizations" : { "bg" : { "stringUnit" : { @@ -333687,6 +336162,7 @@ } }, "Use \"What's sent\" above to inspect the exact JSON payload before deciding." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -334090,6 +336566,9 @@ } } } + }, + "Use Bars for Bolus and Carbs" : { + }, "Use calendar events to display current data." : { "localizations" : { @@ -334620,7 +337099,11 @@ } } }, + "Use Dynamic ISF" : { + "comment" : "Row label for the Use Dynamic ISF toggle" + }, "Use Dynamic Ratio to adjust basal rates." : { + "comment" : "Mini-hint for Dynamic-ISF Adjust-Basal toggle", "localizations" : { "bg" : { "stringUnit" : { @@ -335017,6 +337500,9 @@ } } }, + "Use Profile CSF" : { + "comment" : "Use Profile CSF" + }, "Use Sigmoid Function" : { "comment" : "Headline Use Sigmoid Function", "extractionState" : "manual", @@ -335155,6 +337641,9 @@ } } }, + "Use the sigmoid dynISF formula instead of logarithmic." : { + "comment" : "Mini-hint for the Sigmoid (vs logarithmic) Dynamic-ISF formula toggle" + }, "Use this to check whether your basal rates hold you steady. Trio makes no changes unless your glucose goes low. Then it stops your basal until you come back up.\nTrio also stops adjusting for sensitivity, so you see your own settings at work. Turning this on cancels any temp basal Trio set. Use it for short, planned times." : { "localizations" : { "bg" : { @@ -335415,6 +337904,14 @@ } } }, + "Used above target, strengthens ISF the more the higher this weight is. 0 disables this contribution, i.e., ISF is constant in the whole range above target.\n\nStart with a weight of 0.2 and observe the reactions.\n\nThere are indicators that higher glucose needs stronger ISF. This was evident from all the successful AAPS users defining automation rules which strengthen the profile at higher glucose levels. The drawback is that there are sudden jumps in ISF at switch points and no further or minor adaptations in between.\n\nIn autoISF a polygon is provided that defines a relationship between glucose and ISF and interpolates in between. This is currently hard coded but the user can apply weights to easily strengthen or weaken it in order to fit personal needs. In principle the polygon itself can be edited and the apk rebuilt if a different shape is required. Developing a GUI for that purpose was considered very tedious especially before knowing whether the results warrant the effort. With this approach you could even approximate the formula well enough that is used in DynamicISF for the ISF dependency on glucose.\n\nThere is a special case possible, namely below target i.e. when bg_ISF < 1. ISF will be weakened and there is no point in checking the remaining effects. Only with positive acceleration the weakening will be less pronounced as that is a sign of rising glucose to come soon." : { + "comment" : "A paragraph explaining the purpose and behaviour of a setting in the context of the app.", + "isCommentAutoGenerated" : true + }, + "Used below target, weakens ISF the more the higher this weight is. 0 disables this contribution, i.e., ISF is constant in the whole range below target. This weight is less critical as the loop is probably running at Temp basal Rate = 0 anyway and you can start around 0.2." : { + "comment" : "A description of the ISF weight parameter, explaining its purpose and typical value.", + "isCommentAutoGenerated" : true + }, "User Interface" : { "localizations" : { "bg" : { @@ -335675,8 +338172,20 @@ } } }, + "Uses the established lower threshold of %@." : { + "comment" : "Standard lower range threshold description", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uses the established lower threshold of %@." + } + } + } + }, "Uses the fairly established Time in Tight Range definition, which is defined as time between %@ and %@ %@." : { "comment" : "Time in Tight Range (TITR) verbose hint description", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -335942,8 +338451,20 @@ } } }, + "Uses the newer Time in Normoglycemia (TING) lower threshold of %@." : { + "comment" : "Extended lower range threshold description", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uses the newer Time in Normoglycemia (TING) lower threshold of %@." + } + } + } + }, "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 (%@ %@) and %@ %@." : { "comment" : "Time in Normoglycemia (TING) verbose hint description", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -336616,6 +339137,10 @@ } } }, + "value" : { + "comment" : "Value of the line at the start of the rectangle", + "isCommentAutoGenerated" : true + }, "Value" : { "localizations" : { "bg" : { @@ -337006,6 +339531,26 @@ } } }, + "Values >100% = less sensitive, more insulin nedded, Basal Rates profile is lifted, ISF and CR profiles decrease. 100% preserves current Therapy Settings." : { + "comment" : "Mini-hint beneath the therapy-adjustment percentage row on Add Profile form" + }, + "Values above 0.5 make SMBs more aggressive at high glucose. Raise with care; the ratio climbs linearly from SMB DeliveryRatio BG Minimum up to this value." : { + "comment" : "A verbose hint explaining that values above 0.5 make SMBs more aggressive at high glucose.", + "isCommentAutoGenerated" : true + }, + "Values shown are adjusted for your insulin concentration. The pump receives the correct physical delivery rates." : { + + }, + "Variable KetoProtect" : { + + }, + "Variable SMB DelRatio" : { + "comment" : "A label for a section in the \"Add Override\" form related to the SMB delivery ratio, specifically for when it can vary.", + "isCommentAutoGenerated" : true + }, + "Variable Strategy" : { + "comment" : "Variable Keto Protection" + }, "Very High (>%@)" : { "localizations" : { "bg" : { @@ -337527,6 +340072,16 @@ } } }, + "Very neat feature that allows the use of profile and temporary targets to trigger SMB's being enabled or disabled. So a profile target at 3:00 am of %@ %@ will prevent any SMB's in that time window. Schedule a TT of %@ %@ at 3:20 am and from then on SMB's can be enacted." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Very neat feature that allows the use of profile and temporary targets to trigger SMB's being enabled or disabled. So a profile target at 3:00 am of %1$@ %2$@ will prevent any SMB's in that time window. Schedule a TT of %3$@ %4$@ at 3:20 am and from then on SMB's can be enacted." + } + } + } + }, "Via Watch" : { "comment" : "Note added to carb entry when entered via watch", "localizations" : { @@ -338321,6 +340876,9 @@ } } }, + "warming up" : { + "comment" : "Accessibility: sensor lifecycle tag" + }, "Warning when bolusing with a very low or forecasted very low glucose." : { "localizations" : { "bg" : { @@ -339889,6 +342447,12 @@ } } }, + "Watch command settings changed." : { + "comment" : "Watch command ack" + }, + "Watch commands are disabled." : { + "comment" : "Watch command ack" + }, "Watch Configuration" : { "extractionState" : "manual", "localizations" : { @@ -340943,7 +343507,17 @@ } } }, + "Weekdays" : { + "comment" : "Schedule repeat summary — Monday through Friday" + }, + "Weekends" : { + "comment" : "Schedule repeat summary — Saturday and Sunday" + }, + "Weekly on %@" : { + "comment" : "Schedule repeat summary prefix for a custom weekly pattern — interpolated list is comma-separated weekday abbreviations" + }, "Weight of 24-hr TDD against 10-day TDD." : { + "comment" : "Mini-hint for Dynamic ISF Weighted-Average-of-TDD setting", "localizations" : { "bg" : { "stringUnit" : { @@ -341074,6 +343648,7 @@ } }, "Weighted Average of TDD" : { + "comment" : "Row label for the Dynamic ISF Weighted-Average-of-TDD setting", "localizations" : { "bg" : { "stringUnit" : { @@ -341347,6 +343922,10 @@ "Weighted hypoglycemia" : { "comment" : "Hypoglycemia component multiplied by its GRI weight." }, + "Welcome to Tai - an automated insulin delivery system for iOS based on Trio using the OpenAPS algorithm with autoISF and other adaptations." : { + "comment" : "A longer description of the welcome message, including the purpose of the app.", + "isCommentAutoGenerated" : true + }, "Welcome to Trio" : { "localizations" : { "bg" : { @@ -341478,6 +344057,7 @@ } }, "Welcome to Trio — an automated insulin delivery system for iOS based on the OpenAPS algorithm with adaptations." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -341607,6 +344187,17 @@ } } }, + "What is a Profile?" : { + + }, + "What is a Schedule?" : { + "comment" : "A heading in the Profile Scheduler root view explaining what a schedule is.", + "isCommentAutoGenerated" : true + }, + "What is CSF?" : { + "comment" : "A heading within the verbose hint for what CSF is.", + "isCommentAutoGenerated" : true + }, "What is sent" : { "localizations" : { "bg" : { @@ -342909,7 +345500,24 @@ } } }, + "When all 4 effects are configured, how to deduce an end result?" : { + "comment" : "A question displayed in the verbose hint for `activateAutoISF`.", + "isCommentAutoGenerated" : true + }, + "When autoISF is turned off Autosens will always be activated and on HomwView, the Autosens Sensitivity Ratio (AS) will be shown instead of the autoISF Ratio (aiSR)" : { + "comment" : "Verbose hint for the \"Enable Autosens\" setting, explaining that when the setting is disabled, the app will always use the \"Autosens Sensitivity Ratio\" instead of the \"autoISF Ratio\".", + "isCommentAutoGenerated" : true + }, + "When autoISF is turned on the autoISF Ratio (aiSR) will be displayed on Homeview, showing the final ISF / Sensitivity adaption, instead of the regular Autosens Sensitivity Ratio (AS)" : { + "comment" : "A description of how the autoISF Ratio is displayed on the Home view.", + "isCommentAutoGenerated" : true + }, + "When changing TO or FROM diluted insulins, please check whether you want to decrease (TO) or increase (FROM):" : { + "comment" : "A bullet point in the \"Adjust Limits for Diluted Insulin\" section of the Insulin Concentration RootView.", + "isCommentAutoGenerated" : true + }, "When did you eat ?" : { + "comment" : "Request meal time", "localizations" : { "bg" : { "stringUnit" : { @@ -343038,6 +345646,12 @@ } } } + }, + "When disabled, the bobble only shows the glucose reading, trend arrow, and delta." : { + + }, + "When enabled, boluses and SMBs are drawn as blue bars hanging down from above the glucose curve, and carbs as orange bars rising up from below." : { + }, "When enabled, long-pressing the + button on the home screen opens a Quick-Pick Treatments sheet. It suggests up to three bolus amounts and, if you have carb history, up to three carb amounts based on what you typically enter at this time of day, weighted by recency and day type (weekday vs. weekend)." : { "comment" : "A description of the Quick-Pick Treatments setting.", @@ -343170,6 +345784,10 @@ } } }, + "When enabled, oref will calculate CSF (Carb Sensitivity Factor) from your profile's Carb Ratio and profile's Insulin Sensitivity Factor, keeping CSF stable as a profile-based value." : { + "comment" : "A description of how the `useProfileCSF` setting affects the calculation of the Carb Sensitivity Factor (CSF).", + "isCommentAutoGenerated" : true + }, "When enabled, Super Micro Boluses (SMBs) will always be allowed if dosing calculations determine insulin is needed via the SMB delivery method, except when a high Temp Target is set." : { "localizations" : { "bg" : { @@ -343559,6 +346177,9 @@ } } } + }, + "When enabled, the glucose bobble shows CGM sensor status: a lifecycle arc during warmup and the last 48 hours before expiry, an hourglass icon tracking the sensor's remaining time, and compact status views for error states like signal loss or sensor failure." : { + }, "When enabled, the live activity widget on the lock screen will show glucose forecasts. The visual representation will be the same as in the Main view. To change between Cone and Lines, change the setting under Features - User Interface - Forecast Display Type." : { "localizations" : { @@ -344080,6 +346701,14 @@ } } }, + "When should the profile activate?" : { + "comment" : "The prompt to show to the user when requesting the \"Fire at\" parameter.", + "isCommentAutoGenerated" : true + }, + "When should the Temp Target start?" : { + "comment" : "A prompt to ask the user when they would like the Temp Target to start.", + "isCommentAutoGenerated" : true + }, "When Skip Neutral Temps is enabled, Trio will not set neutral basal rates shortly before the hour, minimizing hourly pump alerts on MDT pumps. This can help light sleepers avoid alerts but will delay basal adjustments. This will also only come into effect if SMB's are disabled for whatever reason." : { "localizations" : { "bg" : { @@ -344600,6 +347229,10 @@ } } }, + "When the even/odd target toggle is enabled, setting an even-numbered temp target below 100 mg/dL (e.g. 80 or 90) puts autoISF into full-loop mode — a signal that you want maximum SMB power. In that mode the SMB delivery ratio is forced to at least the fixed SMB DeliveryRatio, even when the BG-range ramp would otherwise give a lower value." : { + "comment" : "Explanation of the \"full-loop mode\" feature in autoISF.", + "isCommentAutoGenerated" : true + }, "When there are carbs on board (COB > 0), enabling this feature allows Trio to use Super Micro Boluses (SMB) to deliver the insulin required." : { "localizations" : { "bg" : { @@ -345803,6 +348436,17 @@ } } } + }, + "Which preset to schedule?" : { + "comment" : "Prompt to ask the user which temporary target preset to schedule.", + "isCommentAutoGenerated" : true + }, + "Which profile do you want to activate?" : { + "comment" : "Prompt to ask the user which profile they want to activate.", + "isCommentAutoGenerated" : true + }, + "Which profile do you want to schedule?" : { + }, "While onboarding, Trio continues to operate with your prior settings." : { "localizations" : { @@ -346064,7 +348708,16 @@ } } }, + "Whole hours of activation (0–24)" : { + "comment" : "Title and description for a parameter that specifies the number of whole hours for which a profile should be activated.", + "isCommentAutoGenerated" : true + }, + "Why does Tai collect this data?" : { + "comment" : "A heading that explains why Tai collects data.", + "isCommentAutoGenerated" : true + }, "Why does Trio collect this data?" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -346194,6 +348847,10 @@ } } }, + "Why is CSF not dependent on ISF when this setting is enabled?" : { + "comment" : "A footnote within the verbose hint for the \"useProfileCSF\" setting, explaining how the setting works.", + "isCommentAutoGenerated" : true + }, "Wide BG Target Range" : { "comment" : "Headline \"Wide BG Target Range\"", "extractionState" : "manual", @@ -346858,6 +349515,9 @@ } } } + }, + "With the default ratio of 0.5 this collapses to TargetGlucose − 0.5 × (TargetGlucose − 40), i.e. the midpoint between target and 40 mg/dL. Trio always uses whichever is higher: this Minimum Safety Threshold or that ratio-derived floor." : { + }, "With this option enabled, the SMB algorithm can recognize unannounced meals. This is helpful, if you forget to tell Trio about your carbs or estimate your carbs wrong and the amount of entered carbs is wrong or if a meal with lots of fat and protein has a longer duration than expected. Without any carb entry, UAM can recognize fast glucose increasments caused by carbs, adrenaline, etc, and tries to adjust it with SMBs. This also works the opposite way: if there is a fast glucose decreasement, it can stop SMBs earlier." : { "comment" : "\"Enable UAM\"", @@ -346997,6 +349657,14 @@ } } }, + "With this setting enabled, CSF is calculated using your static profile ISF value instead, ensuring CSF remains constant and predictable, unaffected by temporary ISF adjustments." : { + "comment" : "Explanation of the \"dynamic CR\" feature in the context of the \"Use Profile CSF\" setting.", + "isCommentAutoGenerated" : true + }, + "With v3.01 the following 5 settings were withdrawn because over time it proved they were not really necessary. One direct impact is a flatter menu structure for the remaining settings:" : { + "comment" : "A note about changes to the algorithm settings.", + "isCommentAutoGenerated" : true + }, "𝒳 = Max SMB Basal Minutes" : { "localizations" : { "bg" : { @@ -347655,6 +350323,10 @@ } } }, + "You also have to redo the Tai specific algorithm settings in Settings > Algorithm > Extensions!" : { + "comment" : "An important note about the need to update the Tai-specific algorithm settings.", + "isCommentAutoGenerated" : true + }, "You can change these permissions any time in the iOS Settings app." : { "localizations" : { "bg" : { @@ -347785,6 +350457,10 @@ } } }, + "You can change these settings in Therapy > Units and Limits after saving the new concentration and after you have added a pump!" : { + "comment" : "A message explaining how to change insulin dose limits after setting a new insulin concentration.", + "isCommentAutoGenerated" : true + }, "You can connect Trio to seamlessly upload and manage your diabetes data on Tidepool." : { "localizations" : { "bg" : { @@ -351212,6 +353888,7 @@ "comment" : "GRI chart legend: risk zone E and its score range." }, "Zooms continuously, between one hour and a full day on screen." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -351600,7 +354277,10 @@ } } } + }, + "μL/hr" : { + } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/Trio/Sources/Services/WatchManager/GarminManager.swift b/Trio/Sources/Services/WatchManager/GarminManager.swift index 62835c04b4..c55ae5ee5c 100644 --- a/Trio/Sources/Services/WatchManager/GarminManager.swift +++ b/Trio/Sources/Services/WatchManager/GarminManager.swift @@ -109,6 +109,10 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { /// Keeps references to watch apps (both watchface & data field) for each registered device. private var watchApps: [IQApp] = [] + /// The command app Trio-WatchControls was last told about, so a change still reaches it while it is + /// deregistered. Nil until the first registration: the selection cannot change while Trio is not running. + private var announcedCommandApp: GarminCommandApp? + /// A set of Combine cancellables for managing the lifecycle of various subscriptions. private var cancellables = Set() @@ -917,11 +921,26 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { let registeredKeys = Set(watchApps.compactMap(deliveryKey(for:))) owedDeliveries = owedDeliveries.filter { registeredKeys.contains($0.key) } + let commandAppChanged = announcedCommandApp != nil && announcedCommandApp != currentCommandApp + announcedCommandApp = currentCommandApp + for app in watchApps where app.uuid.map(GarminCommandApp.appUUIDs.contains) == true { sendCommandAppNotice(to: app, isActive: true) } - for app in previousApps where app.uuid.map(GarminCommandApp.appUUIDs.contains) == true { - guard let key = deliveryKey(for: app), !registeredKeys.contains(key) else { continue } + + // Trio-WatchControls is registered only while it is the command app, so a change between the other + // two owners never reaches it through `previousApps`: going from commands off straight to + // Complication would leave it saying commands are off in Trio. While inactive its glance neither + // sends nor polls, so nothing on the watch would correct that. + var standDowns = previousApps.filter { $0.uuid.map(GarminCommandApp.appUUIDs.contains) == true } + if commandAppChanged, currentCommandApp != .watchControls, let watchControlsUUID = GarminWatchControls.appUUID { + standDowns += devices.compactMap { IQApp(uuid: watchControlsUUID, store: UUID(), device: $0) } + } + + var noticedKeys = Set() + for app in standDowns { + guard let key = deliveryKey(for: app), !registeredKeys.contains(key), noticedKeys.insert(key).inserted + else { continue } sendCommandAppNotice(to: app, isActive: false) } } diff --git a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift index edd9239021..49ac647769 100644 --- a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift +++ b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift @@ -339,7 +339,10 @@ private func makeResolver( ) try await Task.sleep(nanoseconds: 200_000_000) - #expect(client.sent.isEmpty) + // Registration notices to the known devices are unrelated traffic; what this guards is that + // the stranger itself is answered with nothing at all. + #expect(!client.sent.contains { $0.deviceUUID == stranger.uuid }) + #expect(sent("ack").isEmpty) #expect(processor.processed.isEmpty) } @@ -372,6 +375,17 @@ private func makeResolver( return (message?["active"] as? Bool, message?["owner"] as? String) } + /// Both command apps receive ownerMode notices, so assertions address one app's stream rather + /// than counting all of them: the Complication is told it is active at registration and told to + /// stand down whenever another app takes over. + private func notices(for appUUID: UUID) -> [SpyConnectIQClient.Sent] { + sent("ownerMode").filter { $0.appUUID == appUUID } + } + + private func notices(for appUUID: UUID, on device: IQDevice) -> [SpyConnectIQClient.Sent] { + sent("ownerMode").filter { $0.appUUID == appUUID && $0.deviceUUID == device.uuid } + } + @Test("Selecting another watchface hands commands to Trio-WatchControls on every device") func testHandOver() async { let triggers = RefreshTriggerRecorder(manager) let start = authorization.current @@ -379,11 +393,16 @@ private func makeResolver( settings.settings.garminSettings.watchface = .trio #expect(authorization.current == start + 1, "Pending Complication commands are revoked") - #expect(await eventually { self.sent("ownerMode").count == 2 }) - let notices = sent("ownerMode") - #expect(notices.allSatisfy { $0.appUUID == self.watchControlsUUID }) - #expect(Set(notices.map(\.deviceUUID)) == [deviceA.uuid, deviceB.uuid]) - #expect(notices.allSatisfy { self.notice($0) == (true, "watchControls") }) + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 2 }) + let activations = notices(for: watchControlsUUID) + #expect(Set(activations.map(\.deviceUUID)) == [deviceA.uuid, deviceB.uuid]) + #expect(activations.allSatisfy { self.notice($0) == (true, "watchControls") }) + #expect( + await eventually { self.notices(for: self.complicationUUID).suffix(2) + .allSatisfy { self.notice($0) == (false, "watchControls") } + }, + "The Complication is told who took over" + ) #expect(await eventually { self.sent("presets").count == 2 }) #expect(sent("presets").allSatisfy { $0.appUUID == self.watchControlsUUID }) #expect(await eventually { triggers.count("CommandSettings") >= 1 }, "The new command app gets state right away") @@ -391,24 +410,40 @@ private func makeResolver( @Test("Selecting Complication again tells Trio-WatchControls to stand down") func testStandDown() async { settings.settings.garminSettings.watchface = .trio - #expect(await eventually { self.sent("ownerMode").count == 2 }) + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 2 }) settings.settings.garminSettings.watchface = .complication - #expect(await eventually { self.sent("ownerMode").count == 4 }) - let standDowns = sent("ownerMode").suffix(2) - #expect(standDowns.allSatisfy { $0.appUUID == self.watchControlsUUID }) + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 4 }) + let standDowns = notices(for: watchControlsUUID).suffix(2) + #expect(Set(standDowns.map(\.deviceUUID)) == [deviceA.uuid, deviceB.uuid]) #expect(standDowns.allSatisfy { self.notice($0) == (false, "complication") }) } @Test("Switching commands off tells Trio-WatchControls that commands are off") func testCommandsOffNotice() async { settings.settings.garminSettings.watchface = .trio - #expect(await eventually { self.sent("ownerMode").count == 2 }) + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 2 }) + + settings.settings.garminSettings.isCommandControlEnabled = false + + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 4 }) + #expect(notices(for: watchControlsUUID).suffix(2).allSatisfy { self.notice($0) == (false, "none") }) + } + @Test( + "Picking Complication while commands are off tells the deregistered Trio-WatchControls who owns them" + ) func testOwnerChangeWhileDeregistered() async { + settings.settings.garminSettings.watchface = .trio + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 2 }) settings.settings.garminSettings.isCommandControlEnabled = false + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 4 }) + + settings.settings.garminSettings.watchface = .complication - #expect(await eventually { self.sent("ownerMode").count == 4 }) - #expect(sent("ownerMode").suffix(2).allSatisfy { self.notice($0) == (false, "none") }) + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 6 }) + let standDowns = notices(for: watchControlsUUID).suffix(2) + #expect(Set(standDowns.map(\.deviceUUID)) == [deviceA.uuid, deviceB.uuid]) + #expect(standDowns.allSatisfy { self.notice($0) == (false, "complication") }) } @Test( @@ -416,9 +451,9 @@ private func makeResolver( ) func testInactiveAppContact() async { manager.receivedMessage(["v": 1, "req": "presets"], from: app(watchControlsUUID, on: deviceA)) - #expect(await eventually { self.sent("ownerMode").count == 1 }) - let first = sent("ownerMode")[0] - #expect(first.appUUID == watchControlsUUID && first.deviceUUID == deviceA.uuid) + #expect(await eventually { self.notices(for: self.watchControlsUUID).count == 1 }) + let first = notices(for: watchControlsUUID)[0] + #expect(first.deviceUUID == deviceA.uuid) #expect(notice(first) == (false, "complication")) try? await Task.sleep(nanoseconds: 100_000_000) #expect(sent("presets").isEmpty, "An inactive app gets no preset list") @@ -435,21 +470,21 @@ private func makeResolver( @Test("A failed activation is owed, a failed stand-down is not") func testNoticeDelivery() async { client.setResult(.failure_Timeout, for: watchControlsUUID, on: deviceA.uuid) settings.settings.garminSettings.watchface = .trio - #expect(await eventually { self.sent("ownerMode").filter { $0.deviceUUID == self.deviceA.uuid }.count == 2 }) + #expect(await eventually { self.notices(for: self.watchControlsUUID, on: self.deviceA).count == 2 }) client.setResult(nil, for: watchControlsUUID, on: deviceA.uuid) manager.receivedMessage(["v": 1, "req": "presets"], from: app(watchControlsUUID, on: deviceA)) - #expect(await eventually { self.sent("ownerMode").filter { $0.deviceUUID == self.deviceA.uuid }.count == 3 }) - #expect(notice(sent("ownerMode").last { $0.deviceUUID == self.deviceA.uuid }!) == (true, "watchControls")) + #expect(await eventually { self.notices(for: self.watchControlsUUID, on: self.deviceA).count == 3 }) + #expect(notice(notices(for: watchControlsUUID, on: deviceA).last!) == (true, "watchControls")) client.setResult(.failure_Timeout, for: watchControlsUUID, on: deviceB.uuid) settings.settings.garminSettings.watchface = .complication - #expect(await eventually { self.sent("ownerMode").filter { $0.deviceUUID == self.deviceB.uuid }.count == 3 }) + #expect(await eventually { self.notices(for: self.watchControlsUUID, on: self.deviceB).count == 3 }) client.setResult(nil, for: watchControlsUUID, on: deviceB.uuid) manager.sendWatchStateData(try! JSONSerialization.data(withJSONObject: [["sgv": 120]])) #expect(await eventually { self.client.sent.contains { $0.isState } }) try? await Task.sleep(nanoseconds: 150_000_000) - #expect(sent("ownerMode").filter { $0.deviceUUID == self.deviceB.uuid }.count == 3) + #expect(notices(for: watchControlsUUID, on: deviceB).count == 3) } } From c7d00cce4774777e2931292264ff466598d0167b Mon Sep 17 00:00:00 2001 From: Robert Date: Fri, 30 Oct 2026 14:47:41 +0100 Subject: [PATCH 16/34] update medt --- MedtrumKit | 2 +- Testflight/WhatToTest.en-CA.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/MedtrumKit b/MedtrumKit index a39df701f8..ab3690d72a 160000 --- a/MedtrumKit +++ b/MedtrumKit @@ -1 +1 @@ -Subproject commit a39df701f8371fe154d9222147b81caf5b1842fc +Subproject commit ab3690d72ab1a14eae192d8cdb1de610310561ca diff --git a/Testflight/WhatToTest.en-CA.txt b/Testflight/WhatToTest.en-CA.txt index 58cc77502e..0e019f1da1 100644 --- a/Testflight/WhatToTest.en-CA.txt +++ b/Testflight/WhatToTest.en-CA.txt @@ -2,3 +2,4 @@ based on Trio dev 1.0.2.27 garmin watch complications fix bundle id alpha complication +medtrum From 6b55b25e066b650d7fe2067694392da13f9f794e Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 5 Nov 2026 18:25:51 +0100 Subject: [PATCH 17/34] Updated submodule MedtrumKit --- MedtrumKit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MedtrumKit b/MedtrumKit index ab3690d72a..df2fa136cf 160000 --- a/MedtrumKit +++ b/MedtrumKit @@ -1 +1 @@ -Subproject commit ab3690d72ab1a14eae192d8cdb1de610310561ca +Subproject commit df2fa136cf29232796ff74cad14ab774d212c0c0 From 645f757da23b9cff6ae668ae601e3c016b7f6c74 Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 5 Nov 2026 18:54:28 +0100 Subject: [PATCH 18/34] ver --- Testflight/WhatToTest.en-CA.txt | 2 +- .../Localizations/Main/Localizable.xcstrings | 16 ---------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/Testflight/WhatToTest.en-CA.txt b/Testflight/WhatToTest.en-CA.txt index 0e019f1da1..970be6502f 100644 --- a/Testflight/WhatToTest.en-CA.txt +++ b/Testflight/WhatToTest.en-CA.txt @@ -1,4 +1,4 @@ -based on Trio dev 1.0.2.27 +based on Trio dev 1.0.2.30 garmin watch complications fix bundle id alpha complication diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 9cbe23fcd5..582e304230 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -34806,10 +34806,6 @@ } } }, - "Add Mock Garmin Watch" : { - "comment" : "A button label that allows users to add a mock Garmin watch for testing purposes.", - "isCommentAutoGenerated" : true - }, "Add Omnipod" : { "comment" : "Add Omnipod pump", "extractionState" : "manual", @@ -253423,10 +253419,6 @@ } } }, - "Remove All Devices" : { - "comment" : "A button label that removes all connected Garmin devices.", - "isCommentAutoGenerated" : true - }, "Remove label — make a Default" : { "comment" : "A button that lets the user convert a labeled profile into a default one.", "isCommentAutoGenerated" : true @@ -275114,14 +275106,6 @@ } } }, - "Simulator only - for testing UI workflow" : { - "comment" : "A note displayed in the simulator that explains that the button to remove all devices is for testing purposes only.", - "isCommentAutoGenerated" : true - }, - "Simulator Testing" : { - "comment" : "A section header in the watch configuration view that allows for testing the UI without a physical Garmin watch.", - "isCommentAutoGenerated" : true - }, "Sine Wave" : { "comment" : "Name of a sound file in the catalog.", "isCommentAutoGenerated" : true From cb21621d42bdfb07af082c8d1397695c620d4f46 Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 7 Nov 2026 06:25:00 +0100 Subject: [PATCH 19/34] sub medtrum --- MedtrumKit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MedtrumKit b/MedtrumKit index df2fa136cf..2b01ac1ccd 160000 --- a/MedtrumKit +++ b/MedtrumKit @@ -1 +1 @@ -Subproject commit df2fa136cf29232796ff74cad14ab774d212c0c0 +Subproject commit 2b01ac1ccdca5b4fc786b6f503ccce3ff503b00a From d27e2df5e91a1fbbc6008600a2a600002c2255f7 Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 7 Nov 2026 07:19:17 +0100 Subject: [PATCH 20/34] fix pbx --- Testflight/WhatToTest.en-CA.txt | 5 +- Trio.xcodeproj/project.pbxproj | 29 + Trio/Resources/InfoPlist.xcstrings | 144 +- .../Localizations/Main/Localizable.xcstrings | 3582 +++++++++++++++-- Trio/Sources/Logger/Store/AppLogStore.swift | 2 +- 5 files changed, 3319 insertions(+), 443 deletions(-) diff --git a/Testflight/WhatToTest.en-CA.txt b/Testflight/WhatToTest.en-CA.txt index e7c733205b..971941a681 100644 --- a/Testflight/WhatToTest.en-CA.txt +++ b/Testflight/WhatToTest.en-CA.txt @@ -1,5 +1,2 @@ based on Trio dev 1.0.2.59 -garmin watch complications -fix bundle id alpha -complication -medtrum + diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index eb5578f42f..532201e511 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -1211,6 +1211,20 @@ 2A3D41ADE8834E8485A9504D /* PumpStatusSuspendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2DC7144FFD427B96B8A2B8 /* PumpStatusSuspendTests.swift */; }; B52DBBF355407A732FDCE1DE /* ReportClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12ECFA954DA8F662969E3223 /* ReportClientTests.swift */; }; 99506C3B9CC23E2F925009D3 /* ReportProblemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70C3B7105DC451594DF96299 /* ReportProblemTests.swift */; }; + 9A3C4FC24B2DFEEB95911EDE /* WatchCommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3CF62E60DADFAC56F829E2 /* WatchCommandProcessor.swift */; }; + 9A3CD75136E1A66C2F49240C /* WatchCommandActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3CA2079F4D0DB8B1684D0B /* WatchCommandActions.swift */; }; + 9A3CB4CA0FB381954EA4BA94 /* WatchCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C78318D8159B851C0EC52 /* WatchCommand.swift */; }; + 9A3C151EF9FCECCF212ED730 /* WatchPresets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C1083145DAD9C2A662CE1 /* WatchPresets.swift */; }; + 9A3C3B50509CE7ABFE2E3D1D /* WatchCommandRequestCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3CA06D5CC8AB3491F284C1 /* WatchCommandRequestCache.swift */; }; + 9A3C76A08493CDC6FBF71373 /* WatchCommandPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C6BB18B32890597D63BD9 /* WatchCommandPipeline.swift */; }; + 9A3CCDA32C874DE6AB45F00D /* WatchCommandIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C44BB629194B2666EAC83 /* WatchCommandIntegrationTests.swift */; }; + 9A3C1EE99DC965899EC25C4F /* WatchCommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0A1F620D72723F65D596 /* WatchCommandProcessorTests.swift */; }; + 9A3CE4DF1D01738B99BBF795 /* WatchCommandPipelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C9B44D186FF8A7079468A /* WatchCommandPipelineTests.swift */; }; + 9A3C0ECD1617389255D168CF /* WatchCommandRequestCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C3CA768CF7D5036326306 /* WatchCommandRequestCacheTests.swift */; }; + 9A3C70FF5CD33060FE5626A7 /* WatchCommandTestDoubles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C3F1091C03368A75DD96E /* WatchCommandTestDoubles.swift */; }; + 9A3CA6DBA84BDF295314EAC5 /* GarminMessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3CF86D09F46856D09A2ED2 /* GarminMessageRouter.swift */; }; + 9A3CF45C4133651CC90484F5 /* GarminMessageRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C0B63D2B89008769E452C /* GarminMessageRouterTests.swift */; }; + 9A3C034686E6B4AC8CE5E7D8 /* GarminManagerIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3C4DF6510DE4DB0E1F1F3A /* GarminManagerIntegrationTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -2513,6 +2527,20 @@ AE2DC7144FFD427B96B8A2B8 /* PumpStatusSuspendTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpStatusSuspendTests.swift; sourceTree = ""; }; 12ECFA954DA8F662969E3223 /* ReportClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportClientTests.swift; sourceTree = ""; }; 70C3B7105DC451594DF96299 /* ReportProblemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportProblemTests.swift; sourceTree = ""; }; + 9A3CF62E60DADFAC56F829E2 /* WatchCommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandProcessor.swift; sourceTree = ""; }; + 9A3CA2079F4D0DB8B1684D0B /* WatchCommandActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandActions.swift; sourceTree = ""; }; + 9A3C78318D8159B851C0EC52 /* WatchCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommand.swift; sourceTree = ""; }; + 9A3C1083145DAD9C2A662CE1 /* WatchPresets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchPresets.swift; sourceTree = ""; }; + 9A3CA06D5CC8AB3491F284C1 /* WatchCommandRequestCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandRequestCache.swift; sourceTree = ""; }; + 9A3C6BB18B32890597D63BD9 /* WatchCommandPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandPipeline.swift; sourceTree = ""; }; + 9A3C44BB629194B2666EAC83 /* WatchCommandIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandIntegrationTests.swift; sourceTree = ""; }; + 9A3C0A1F620D72723F65D596 /* WatchCommandProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandProcessorTests.swift; sourceTree = ""; }; + 9A3C9B44D186FF8A7079468A /* WatchCommandPipelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandPipelineTests.swift; sourceTree = ""; }; + 9A3C3CA768CF7D5036326306 /* WatchCommandRequestCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandRequestCacheTests.swift; sourceTree = ""; }; + 9A3C3F1091C03368A75DD96E /* WatchCommandTestDoubles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCommandTestDoubles.swift; sourceTree = ""; }; + 9A3CF86D09F46856D09A2ED2 /* GarminMessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminMessageRouter.swift; sourceTree = ""; }; + 9A3C0B63D2B89008769E452C /* GarminMessageRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminMessageRouterTests.swift; sourceTree = ""; }; + 9A3C4DF6510DE4DB0E1F1F3A /* GarminManagerIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminManagerIntegrationTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -3069,6 +3097,7 @@ DD7E1E30000000000000000A /* Telemetry */, 3811DEA525C9D88300A708ED /* UnlockManager */, 38E87406274F9AA500975559 /* UserNotifications */, + 9A3CC598A206D8D945713F97 /* WatchCommand */, 38E8754D275556E100975559 /* WatchManager */, FCF410FF2012BCB1A08D4F48 /* ProfileScheduleFirer.swift */, 196DA87A7331DB0D3420E81B /* AppAttest */, diff --git a/Trio/Resources/InfoPlist.xcstrings b/Trio/Resources/InfoPlist.xcstrings index fbf2be848f..1bf88b3487 100644 --- a/Trio/Resources/InfoPlist.xcstrings +++ b/Trio/Resources/InfoPlist.xcstrings @@ -7,133 +7,133 @@ "localizations" : { "bg" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "cs" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "da" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Trio" + "value" : "Tai" } }, "es" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "he" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "ko" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "nb-NO" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "nl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "pt-PT" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "ro" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "ru" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "sv" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "tr" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "uk" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "vi" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } } @@ -145,133 +145,133 @@ "localizations" : { "bg" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "cs" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "da" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Trio" + "value" : "Tai" } }, "es" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "he" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "ko" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "nb-NO" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "nl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "pt-PT" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "ro" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "ru" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "sv" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "tr" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "uk" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "Trio" } }, "vi" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Trio" } } @@ -1105,6 +1105,18 @@ } } }, + "NSCameraUsageDescription" : { + "comment" : "Privacy - Camera Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Camera is used to scan the code on your Dexcom sensor packaging." + } + } + } + }, "NSContactsUsageDescription" : { "comment" : "Privacy - Contacts Usage Description", "localizations" : { @@ -1660,133 +1672,133 @@ "localizations" : { "bg" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "cs" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "da" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "de" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "" + "value" : "autoISF 3.01" } }, "es" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "fr" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "he" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "it" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "ko" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "nb-NO" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "nl" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "pl" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "pt-PT" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "ro" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "ru" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "sv" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "tr" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "uk" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "vi" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "" } }, "zh-Hant" : { "stringUnit" : { - "state" : "new", + "state" : "needs_review", "value" : "" } } diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 4292db903b..25446854c1 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -1089,323 +1089,6 @@ } } }, - "%lld of %lld characters" : { - - }, - "Add Screenshots" : { - - }, - "Also Sent" : { - - }, - "Ask on Discord" : { - - }, - "Ask on Facebook" : { - - }, - "Attach My Logs" : { - - }, - "Build" : { - - }, - "Change Screenshots" : { - - }, - "Contact" : { - - }, - "Copy ID" : { - - }, - "Couldn't Delete Report" : { - - }, - "Couldn't Send Report" : { - - }, - "Delete Report" : { - - }, - "Delete Report?" : { - - }, - "Deleting" : { - - }, - "Deleting a report removes it and its files from the Trio team's server." : { - - }, - "Describe what happened and what you expected" : { - - }, - "Discord Name" : { - - }, - "Email" : { - - }, - "Fill in any of these so the team can follow up with you." : { - - }, - "Get help from other Trio users." : { - - }, - "GitHub Username" : { - - }, - "Includes glucose, insulin, carbs and your settings. Passwords and serial numbers are removed." : { - - }, - "iOS Version" : { - - }, - "iPhone Model" : { - - }, - "One of the screenshots couldn't be read. Try choosing it again or leave it out." : { - - }, - "Open a GitHub Issue" : { - - }, - "Private. Describe the problem, add screenshots and your logs. Only the Trio team can see it." : { - - }, - "Public. Only for bugs you can describe without personal or health data." : { - - }, - "Report %@ and its files will be deleted from the Trio team's server." : { - - }, - "Report a Problem" : { - - }, - "Report Actions" : { - - }, - "Report Sent" : { - - }, - "Reports go only to the Trio team and are deleted after 90 days. You can delete a report yourself at any time." : { - - }, - "Save the file or send it to someone helping you on Discord or Facebook. Contains glucose, insulin and carbs; passwords and serial numbers are removed." : { - - }, - "Screenshot %lld" : { - - }, - "Screenshots" : { - - }, - "Send to the Trio Team" : { - - }, - "Sending" : { - - }, - "Some files didn't reach the Trio team's server. Please try again." : { - - }, - "Sending Report" : { - - }, - "Sent Reports" : { - - }, - "Stay Anonymous" : { - - }, - "Submit" : { - - }, - "Thank you. Your report ID is %@. Mention it if you ask about this on Discord or Facebook." : { - - }, - "The report is too large. Try fewer or smaller screenshots." : { - - }, - "The report won't include your name or contact details." : { - - }, - "The Trio team's server couldn't take the report right now (error %lld)." : { - - }, - "The Trio team's server sent an answer the app doesn't understand." : { - - }, - "This build of Trio can't send reports. Please share your logs on Discord or Facebook instead." : { - - }, - "Title (optional)" : { - - }, - "Trio couldn't confirm with Apple that this copy of the app is genuine. Please try again later." : { - - }, - "Try Again" : { - - }, - "Up to %lld screenshots or photos." : { - - }, - "You've sent several reports today. Please try again tomorrow." : { - - }, - "Your Logs" : { - - }, - "Your logs are too large to send. Turn off Attach My Logs and share them on Discord or Facebook instead." : { - - }, - "Your logs couldn't be prepared. Try again, or turn off Attach My Logs." : { - - }, - "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" : { @@ -1934,6 +1617,13 @@ } } }, + " - diluted Insulin" : { + "comment" : "A description of a insulin concentration that is lower than 1.", + "isCommentAutoGenerated" : true + }, + " - not enacted!" : { + "comment" : "Suffix appended to Home status popup title when the suggestion was not enacted" + }, " »%@« " : { "localizations" : { "bg" : { @@ -2065,6 +1755,7 @@ } }, " %@" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -2330,6 +2021,9 @@ } } }, + " ⚠️" : { + "comment" : "Manual Temp basal" + }, " Allow Trio to send you Notifications. These may include alerts, sounds, and icon badges." : { "localizations" : { "bg" : { @@ -3396,6 +3090,7 @@ } }, " needed as min. Glucose Target)!" : { + "comment" : "Trailing fragment of Add Temp Target validation error — concatenated with a minimum-glucose value and unit", "localizations" : { "bg" : { "stringUnit" : { @@ -3657,6 +3352,7 @@ } }, " of usage data and is not yet configurable." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -4982,6 +4678,14 @@ } } }, + "- higher custom concentration" : { + "comment" : "A description of a insulin concentration that is higher than 1.", + "isCommentAutoGenerated" : true + }, + "- Standard Insulin 100 U/mL" : { + "comment" : "A description of the insulin concentration of 100 U/mL.", + "isCommentAutoGenerated" : true + }, "--" : { "localizations" : { "bg" : { @@ -5502,6 +5206,10 @@ } } }, + "—" : { + "comment" : "A placeholder value.", + "isCommentAutoGenerated" : true + }, ", %lld%%" : { "localizations" : { "bg" : { @@ -8425,6 +8133,7 @@ } }, "%@ " : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -8827,6 +8536,7 @@ } }, "%@ (%@-%@)" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -9371,6 +9081,7 @@ } }, "%@ %%" : { + "comment" : "Profile summary badge — applied therapy percentage", "localizations" : { "bg" : { "stringUnit" : { @@ -9500,6 +9211,31 @@ } } }, + "%@ →" : { + + }, + "%@ → %@ %@" : { + "comment" : "A label that indicates that the value has been changed from its original value.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ → %2$@ %3$@" + } + } + } + }, + "%@ − 0.5 × (%@ − %@) = %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ − 0.5 × (%2$@ − %3$@) = %4$@" + } + } + } + }, "%@ and %lld g fat" : { "comment" : "The text to be displayed when fat is > 0 for the Add Carbs shortcut", "localizations" : { @@ -10567,6 +10303,10 @@ } } }, + "%@ is active" : { + "comment" : "A label that displays the name of an active profile. The label is white on a blue background.", + "isCommentAutoGenerated" : true + }, "%@ is on. %@" : { "localizations" : { "bg" : { @@ -11095,6 +10835,21 @@ } } }, + "%@ to below %@" : { + "comment" : "A label that displays the percentage of times the patient's glucose was below 70 mg/dL. The color of the text is determined by the `color` parameter.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ to below %2$@" + } + } + } + }, + "%@ U/day" : { + "comment" : "Profile summary badge — total daily basal in insulin units per day" + }, "%@-%@" : { "localizations" : { "bg" : { @@ -11230,6 +10985,9 @@ } } } + }, + "%@-Day moving Avg of TDD" : { + }, "%@: %@" : { "localizations" : { @@ -11368,6 +11126,7 @@ } }, "%@?" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -13340,6 +13099,17 @@ } } }, + "%lld · %@ U/day" : { + "comment" : "Draft editor Basal Rates row summary — entry count and total daily insulin units per day", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld · %2$@ U/day" + } + } + } + }, "%lld %%" : { "localizations" : { "bg" : { @@ -13470,6 +13240,9 @@ } } }, + "%lld entries" : { + "comment" : "Draft editor Carb Ratios row summary — number of schedule entries\nDraft editor Glucose Targets row summary — number of schedule entries\nDraft editor ISF row summary — number of schedule entries" + }, "%lld g" : { "localizations" : { "bg" : { @@ -13731,6 +13504,7 @@ } }, "%lld hr" : { + "comment" : "Activation duration row — duration in whole hours\nTemp-activation notification body fragment — duration in whole hours", "localizations" : { "bg" : { "stringUnit" : { @@ -13860,6 +13634,17 @@ } } }, + "%lld hr %lld min" : { + "comment" : "Activation duration row — duration in hours and minutes\nTemp-activation notification body fragment — duration in hours and minutes", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld hr %2$lld min" + } + } + } + }, "%lld m" : { "localizations" : { "bg" : { @@ -13991,6 +13776,7 @@ } }, "%lld min" : { + "comment" : "Activation duration row — duration in minutes only\nTemp-activation notification body fragment — duration in minutes only", "localizations" : { "bg" : { "stringUnit" : { @@ -14119,6 +13905,9 @@ } } } + }, + "%lld of %lld characters" : { + }, "%lld x" : { "localizations" : { @@ -14249,6 +14038,9 @@ } } } + }, + "%lld-Day Moving Average of TDD" : { + }, "%lld%%" : { "localizations" : { @@ -14380,6 +14172,10 @@ } } }, + "%lldh" : { + "comment" : "A picker item showing the number of hours in the given index.", + "isCommentAutoGenerated" : true + }, "•" : { "localizations" : { "bg" : { @@ -14640,6 +14436,27 @@ } } }, + "• 0.5 — ratioFloor 70 mg/dL (midpoint between target and 40)" : { + + }, + "• 0.5 — threshold 70 mg/dL (midway between target and 40)" : { + "comment" : "A description of how the SMB threshold is calculated.", + "isCommentAutoGenerated" : true + }, + "• 0.7 — ratioFloor 82 mg/dL (SMBs cut off sooner)" : { + + }, + "• 0.7 — threshold 82 mg/dL (SMBs cut off sooner)" : { + "comment" : "A description of how the SMB threshold is calculated.", + "isCommentAutoGenerated" : true + }, + "• 1.0 — ratioFloor 100 mg/dL (no SMBs below target)" : { + + }, + "• 1.0 — threshold 100 mg/dL (no SMBs below target)" : { + "comment" : "A description of how the SMB threshold is calculated.", + "isCommentAutoGenerated" : true + }, "• A higher number means you need less insulin for the same amount of carbs" : { "localizations" : { "bg" : { @@ -18675,6 +18492,9 @@ } } } + }, + "• In U100 insulin: 0.1 U increment delivers 0.1 units\n• In U50 insulin: 0.1 U increment delivers 0.05 units\n• In U200 insulin: 0.1 U increment delivers 0.2 units" : { + }, "• Insulin Sensitivity" : { "localizations" : { @@ -19715,6 +19535,9 @@ } } } + }, + "• Maximum Duration" : { + }, "• None: No value displayed." : { "localizations" : { @@ -21405,6 +21228,9 @@ } } } + }, + "⇢" : { + }, "+" : { "localizations" : { @@ -22055,6 +21881,9 @@ } } } + }, + "<100 % = less insulin (more sensitive). Basal drops, ISF rises, CR rises." : { + }, "=" : { "localizations" : { @@ -22316,6 +22145,10 @@ } } }, + ">100 % = more insulin overall (less sensitive). Basal rises, ISF drops, CR drops." : { + "comment" : "A description of how to adjust the therapy percentage to be greater than 100%.", + "isCommentAutoGenerated" : true + }, "−" : { "localizations" : { "bg" : { @@ -23366,6 +23199,7 @@ }, "0.1 U and over" : { "comment" : "Name to display for the 0.1 U and over threshold in the bolus display settings.", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -23495,8 +23329,13 @@ } } }, + "0.1U and over" : { + "comment" : "Display name for the 0.1U and over bolus display threshold.", + "isCommentAutoGenerated" : true + }, "0.5 U and over" : { "comment" : "Name of the bolus display threshold option that represents 0.5 units.", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -23626,8 +23465,13 @@ } } }, + "0.5U and over" : { + "comment" : "Display name for the 0.5U and over bolus display threshold.", + "isCommentAutoGenerated" : true + }, "1 U and over" : { "comment" : "Name to display for the \"1 U and over\" bolus display threshold option.", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -23887,6 +23731,10 @@ } } }, + "1U and over" : { + "comment" : "Display name for a bolus display threshold of 1 unit.", + "isCommentAutoGenerated" : true + }, "2 hours" : { "extractionState" : "manual", "localizations" : { @@ -24284,6 +24132,9 @@ } } }, + "2nd Order Exponential" : { + "comment" : "Exponential smoothing algorithm display name" + }, "3 M" : { "comment" : "Abbreviation for three months", "localizations" : { @@ -25080,6 +24931,7 @@ } }, "7 days" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -25209,6 +25061,10 @@ } } }, + "10 U/mL" : { + "comment" : "A label for a 10 U/mL insulin concentration.", + "isCommentAutoGenerated" : true + }, "10-90" : { "localizations" : { "bg" : { @@ -26262,6 +26118,9 @@ } } } + }, + "24h" : { + }, "25 g" : { "localizations" : { @@ -26913,6 +26772,10 @@ } } }, + "50 U/mL" : { + "comment" : "A label for a insulin concentration option.", + "isCommentAutoGenerated" : true + }, "50% is half effect:" : { "localizations" : { "bg" : { @@ -27043,9 +26906,6 @@ } } }, - "54 to below 70 mg/dL" : { - "comment" : "Percentage of glucose readings in the low GRI range." - }, "75%:" : { "localizations" : { "bg" : { @@ -27566,6 +27426,10 @@ } } }, + "100 U/mL" : { + "comment" : "A label for a insulin concentration option.", + "isCommentAutoGenerated" : true + }, "100% is full effect:" : { "localizations" : { "bg" : { @@ -27956,6 +27820,10 @@ } } }, + "200 U/mL" : { + "comment" : "A label for a insulin concentration option.", + "isCommentAutoGenerated" : true + }, "A bolus command of %@ U of insulin was sent." : { "localizations" : { "bg" : { @@ -28086,6 +27954,9 @@ } } }, + "A bolus was given in the last 6 minutes." : { + "comment" : "Watch command ack" + }, "A display of On/On indicates both Dynamic ISF and CR are enabled. On/Off indicates only Dynamic ISF is enabled. Dynamic CR cannot be enabled when Dynamic ISF is disabled." : { "localizations" : { "bg" : { @@ -28216,6 +28087,10 @@ } } }, + "A factor that multiplies the current maxSMBBasalMinutes and maxUAM/SMBBasalMinutes beyond the 180 minute limit set in Trio." : { + "comment" : "A verbose hint for the `smbMaxRangeExtension` setting.", + "isCommentAutoGenerated" : true + }, "A few important notes…" : { "localizations" : { "bg" : { @@ -29004,6 +28879,33 @@ } } }, + "A Profile is a named snapshot of your full therapy and algorithm setup — basal rate, ISF, CR, BG targets, plus every Algorithm Settings toggle. You keep one baseline profile and build alternates (sick day, exercise, menstrual cycle, travel, test modes) from it. Switching profiles swaps the entire setup in one tap." : { + "comment" : "A description of what a profile is.", + "isCommentAutoGenerated" : true + }, + "A safety knob that controls how close to your low-target SMBs are still allowed. It interpolates the SMB cutoff along the line between your low-target and 40 mg/dL." : { + + }, + "A safety knob that controls how close to your low-target SMBs are still allowed. It shifts the SMB cutoff (\"threshold\") along the line between your low-target and 40 mg/dL." : { + "comment" : "A hint for the SMB Threshold Ratio setting.", + "isCommentAutoGenerated" : true + }, + "A Schedule fires a profile activation at a future time — once, or on a repeating cadence. It lets you automate profile switches that follow a predictable pattern (sick days, night mode, workouts, menstrual cycle, travel)." : { + "comment" : "A description of what a schedule is.", + "isCommentAutoGenerated" : true + }, + "A special case: setting an even-numbered temp target below %@ %@ (e.g. %@ %@) signals that you want maximum SMB aggression — autoISF calls this full-loop mode." : { + "comment" : "A hint that describes how the \"Odd Target disables SMB for autoISF\" setting works.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "A special case: setting an even-numbered temp target below %1$@ %2$@ (e.g. %3$@ %4$@) signals that you want maximum SMB aggression — autoISF calls this full-loop mode." + } + } + } + }, "A Temporary Target replaces the current Target Glucose specified in Therapy settings." : { "localizations" : { "bg" : { @@ -29134,6 +29036,10 @@ } } }, + "A wider range makes SMBs ramp up more gradually; a narrower range makes them reach full aggression sooner." : { + "comment" : "A hint for the `smbDeliveryRatioBGrange` setting.", + "isCommentAutoGenerated" : true + }, "Abbreviation for 'Deviation'. How much the actual glucose change deviated from the BGI." : { "localizations" : { "bg" : { @@ -29394,6 +29300,9 @@ } } }, + "About Profiles" : { + "comment" : "Help hint label on the AdaptProfile list screen" + }, "About Quick-Pick Treatments" : { "comment" : "Info sheet title for quick-pick treatments feature", "localizations" : { @@ -29525,6 +29434,9 @@ } } }, + "About Schedules" : { + "comment" : "Help hint label on the Profile Scheduler list screen" + }, "About the Process" : { "extractionState" : "manual", "localizations" : { @@ -30054,11 +29966,18 @@ } } }, - "Above 180 through 250 mg/dL" : { - "comment" : "Percentage of glucose readings in the high GRI range." + "Above %@" : { + }, - "Above 250 mg/dL" : { - "comment" : "Percentage of glucose readings in the very high GRI range." + "Above %@ through %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Above %1$@ through %2$@" + } + } + } }, "above range" : { "comment" : "Accessibility: glucose range", @@ -30190,6 +30109,25 @@ } } } + }, + "Absolute Safety TBR" : { + "comment" : "Absolute TBR" + }, + "Absolute safety TBR provides a fixed insulin rate for safety, useful for consistent protection." : { + "comment" : "Absolute TBR VerboseHint" + }, + "acce" : { + "comment" : "Label for acce" + }, + "acce_ISF is calculated by\nacce_ISF = 1 + acce_weight * fit_share * cap_weight * acceleration\nwhere fit_share is a measure of fit quality, i.e., 0% if unacceptable up to 100% if perfect;\ncap_weight is 0.5 below target and 1.0 otherwise;\nacce_weight is bgAccel_ISF_weight for acceleration away from target, i.e., mostly positive\nor bgBrake_ISF_weight for acceleration towards target, i.e., mostly negative.\n\nInitially, it was assumed that the weights for accelerating and braking are of similar size.\nFirst experiences suggest that the weight while decelerating should be 30-40% lower than for acceleration to reduce glucose oscillations. Quite often the acce_ISF contribution plays the dominant role inside autoISF and is therefore very important and delicate.\n\nWeights for acce_ISF of 0 disable this contribution. Start small with weights like 0.02 and observe the results before increasing them. Keep in mind that negative acceleration will start to happen while glucose is apparently still rising but the slope reduces. Here, acce_ISF will be <1, i.e., sensitivity grows and less insulin than normal will be required even before the glucose peak is reached." : { + "comment" : "A longer description of the BG acceleration feature.", + "isCommentAutoGenerated" : true + }, + "Acce-ISF" : { + + }, + "acce-ISF: %@" : { + }, "Acoustic" : { "comment" : "The category of the alarm sound.", @@ -30326,6 +30264,14 @@ } } }, + "Activate a stored profile now for a chosen duration. Indefinite activation is not available via Shortcuts." : { + "comment" : "Description of the Apply Profile intent, including the restriction that indefinite activation is not available via Shortcuts.", + "isCommentAutoGenerated" : true + }, + "Activate a Temporary Target preset now" : { + "comment" : "Title of the intent to apply a Temp Target preset immediately.", + "isCommentAutoGenerated" : true + }, "Activate an override" : { "localizations" : { "bg" : { @@ -30456,6 +30402,15 @@ } } }, + "Activate autoISF" : { + "comment" : "Row label for the Activate autoISF toggle" + }, + "Activate B30 EatingSoon" : { + "comment" : "Enable B30" + }, + "Activate KetoProtection" : { + "comment" : "Enable KetoProtection" + }, "Activate Override" : { "localizations" : { "bg" : { @@ -30717,6 +30672,40 @@ } } }, + "Activate Profile" : { + "comment" : "The title of a sheet that allows the user to activate a profile.", + "isCommentAutoGenerated" : true + }, + "Activate Profile ${profile}" : { + + }, + "Activate TT preset" : { + "comment" : "Shortcut to activate a temporary target preset.", + "isCommentAutoGenerated" : true + }, + "Activated: Safety TBR only kicks in when IOB is in neg. range below current Basal Rate and Active Insulin is also negative." : { + "comment" : "Variable Protection VerboseHint" + }, + "Activates at" : { + "comment" : "A label that reads \"Activates at\".", + "isCommentAutoGenerated" : true + }, + "Activates at %@" : { + "comment" : "A label showing the time at which a profile will be activated.", + "isCommentAutoGenerated" : true + }, + "Activation" : { + "comment" : "A label for the duration picker.", + "isCommentAutoGenerated" : true + }, + "Activation failed" : { + "comment" : "A title for an alert that indicates that a basal activation failed.", + "isCommentAutoGenerated" : true + }, + "Activation modes" : { + "comment" : "A heading for the activation modes of a profile.", + "isCommentAutoGenerated" : true + }, "active" : { "comment" : "Accessibility: alarms active", "localizations" : { @@ -30979,6 +30968,10 @@ } } }, + "Active adjustment" : { + "comment" : "A label describing an active adjustment.", + "isCommentAutoGenerated" : true + }, "Active During" : { "comment" : "A section header for the active glucose alert.", "localizations" : { @@ -31110,6 +31103,13 @@ } } }, + "Active Profile" : { + "comment" : "Fallback name on Home adjustments banner for the active profile when it has no name set\nHome stats panel face option" + }, + "Active schedules" : { + "comment" : "A heading for the list of active schedules.", + "isCommentAutoGenerated" : true + }, "Active Time" : { "comment" : "description label for active time pod details rowdescription label for active time pod details row", "extractionState" : "manual", @@ -31248,6 +31248,9 @@ } } }, + "Adaptive UKF Smoothing" : { + "comment" : "Adaptive UKF smoothing algorithm display name" + }, "Add" : { "comment" : "Add profile basal", "localizations" : { @@ -31519,6 +31522,7 @@ }, "Add %lld g carbs" : { "comment" : "A confirmation message that asks the user to confirm adding a certain amount of carbs, optionally including fat and protein.", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -31908,6 +31912,10 @@ } } }, + "Add a Profile by tapping 'Add Profile +' in the top right-hand corner of the screen." : { + "comment" : "A description of how to add a profile.", + "isCommentAutoGenerated" : true + }, "Add Alarm" : { "comment" : "A title for a sheet that lets the user add a glucose alert.", "localizations" : { @@ -35744,6 +35752,10 @@ } } }, + "Add Profile" : { + "comment" : "A button to add a new profile.", + "isCommentAutoGenerated" : true + }, "Add pump" : { "localizations" : { "bg" : { @@ -36004,6 +36016,13 @@ } } } + }, + "Add Schedule" : { + "comment" : "A button to add a new schedule.", + "isCommentAutoGenerated" : true + }, + "Add Screenshots" : { + }, "Add Simulator" : { "comment" : "Add Simulator pump", @@ -37187,6 +37206,10 @@ } } }, + "Additional minutes (0–55)" : { + "comment" : "Label for a parameter that lets the user specify the number of additional minutes to add to the profile activation duration.", + "isCommentAutoGenerated" : true + }, "Additional notification types" : { "extractionState" : "manual", "localizations" : { @@ -37325,6 +37348,7 @@ } }, "Additionals" : { + "comment" : "Navigation-link title for the additional/advanced oref settings section in the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -37867,6 +37891,9 @@ } } } + }, + "adj." : { + }, "Adjust basal" : { "comment" : "Headline \"Adjust basal\"", @@ -38007,6 +38034,7 @@ } }, "Adjust Basal" : { + "comment" : "Row label for the Dynamic-ISF Adjust-Basal toggle", "localizations" : { "bg" : { "stringUnit" : { @@ -38136,6 +38164,12 @@ } } }, + "Adjust current Therapy Setting" : { + "comment" : "Hint mini-label shown when tapping info on the therapy-adjustment percentage row of Add Profile form" + }, + "Adjust current Therapy Settings" : { + "comment" : "Row label on Add Profile form for the percentage that rescales Basal/ISF/CR" + }, "Adjust Dynamic ISF constant" : { "comment" : "Headline \"Adjust Dynamic ISF constant\"Adjust Dynamic ISF constantHeadline \"Adjust Dynamic ISF constant\"", "extractionState" : "manual", @@ -38406,6 +38440,10 @@ } } }, + "Adjust Limits for Diluted Insulin" : { + "comment" : "A section that explains how to adjust insulin limits when using diluted insulin.", + "isCommentAutoGenerated" : true + }, "Adjust Ring Settings" : { "localizations" : { "bg" : { @@ -38665,6 +38703,12 @@ } } } + }, + "Adjusted basal rates are rounded down to the nearest pump-supported rate. ISF is rounded to 1 mg/dL steps, CR to 0.1 g/U. You can fine-tune each value manually in the editor after tapping Next." : { + + }, + "Adjusted rates" : { + }, "Adjusting this value not only can adjust how quickly your sensitivity will respond to changing glucose readings, but also at what glucose readings you reach your Autosens Max/Min limits." : { "localizations" : { @@ -38927,6 +38971,7 @@ } }, "Adjustment Factor (AF)" : { + "comment" : "Row label for the Dynamic ISF Adjustment Factor (AF) setting", "localizations" : { "bg" : { "stringUnit" : { @@ -39057,6 +39102,7 @@ } }, "Adjustment Tabs" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -40118,6 +40164,15 @@ } } } + }, + "AIMI B30" : { + "comment" : "Navigation-link title for the AIMI B30 extension section in the draft profile editor" + }, + "AIMI B30 Settings" : { + + }, + "aiSR" : { + }, "Alarm" : { "comment" : "Name of a critical-alarm sound file.", @@ -40387,6 +40442,10 @@ } } }, + "Alarms active" : { + "comment" : "A description of the state of the user's alarms.", + "isCommentAutoGenerated" : true + }, "Alarms cannot alert you. Tap to fix." : { "localizations" : { "bg" : { @@ -40779,6 +40838,9 @@ } } }, + "Algo & Targets tuned" : { + "comment" : "Profile summary badge — both algorithm preferences and glucose targets differ from defaults" + }, "Algorithm" : { "comment" : "Algorithm menu item in the Settings main view.", "localizations" : { @@ -40910,6 +40972,15 @@ } } }, + "Algorithm — not looped in %lld minutes." : { + "comment" : "Loop status popup title when no recent loop — interpolation is minutes count" + }, + "Algorithm enacted at" : { + "comment" : "Headline in enacted popup" + }, + "Algorithm enacted at %@" : { + "comment" : "Loop status popup title when a recent loop has enacted — interpolation is a clock time" + }, "Algorithm Error" : { "localizations" : { "bg" : { @@ -41040,6 +41111,14 @@ } } }, + "Algorithm reasoning" : { + "comment" : "A heading for the algorithm reasoning.", + "isCommentAutoGenerated" : true + }, + "Algorithm Selection" : { + "comment" : "A label displayed above the selection of a glucose smoothing algorithm.", + "isCommentAutoGenerated" : true + }, "Algorithm Settings" : { "localizations" : { "bg" : { @@ -41170,7 +41249,14 @@ } } }, + "Algorithm Settings tuned" : { + "comment" : "Profile summary badge — algorithm preferences differ from defaults" + }, + "Algorithm suggested at" : { + "comment" : "Headline in suggested popup" + }, "Algorithmic Target Settings" : { + "comment" : "Section header on the Target Behavior screen grouping sensitivity-vs-target controls", "localizations" : { "bg" : { "stringUnit" : { @@ -42645,6 +42731,7 @@ } }, "All settings are at default values." : { + "comment" : "Validation error on Add Override form when the override would be a no-op (all defaults)\nValidation error on Edit Override form when the override would be a no-op (all defaults)", "localizations" : { "bg" : { "stringUnit" : { @@ -42903,6 +42990,9 @@ } } } + }, + "Allow Bolus Commands" : { + }, "Allow Bolusing with Shortcuts" : { "localizations" : { @@ -43033,6 +43123,12 @@ } } } + }, + "Allow diluted Insulin" : { + "comment" : "Allow diluted Insulin" + }, + "Allow Dilution" : { + }, "Allow Fetching from Nightscout" : { "localizations" : { @@ -43425,6 +43521,7 @@ } }, "Allow SMB for 6 hrs after a carb entry." : { + "comment" : "Mini-hint for Enable SMB After Carbs setting", "localizations" : { "bg" : { "stringUnit" : { @@ -43555,6 +43652,7 @@ } }, "Allow SMB when a manual Temporary Target is set greater than %@ %@." : { + "comment" : "Mini-hint for Allow SMB With High Temptarget — interpolated values are the threshold number and the glucose unit", "localizations" : { "bg" : { "stringUnit" : { @@ -43691,6 +43789,7 @@ } }, "Allow SMB when a manual Temporary Target is set under %@ %@." : { + "comment" : "Mini-hint for Enable SMB With Temptarget — interpolated values are the threshold number and the glucose unit", "localizations" : { "bg" : { "stringUnit" : { @@ -43827,6 +43926,7 @@ } }, "Allow SMB when carbs are on board." : { + "comment" : "Mini-hint for Enable SMB With COB setting", "localizations" : { "bg" : { "stringUnit" : { @@ -43957,6 +44057,7 @@ } }, "Allow SMB when glucose is above the High Glucose Target value." : { + "comment" : "Mini-hint for Enable SMB With High Glucose setting", "localizations" : { "bg" : { "stringUnit" : { @@ -44218,6 +44319,7 @@ } }, "Allow SMBs at all times except when a high Temp Target is set." : { + "comment" : "Mini-hint for Enable SMB Always setting", "localizations" : { "bg" : { "stringUnit" : { @@ -44476,6 +44578,12 @@ } } } + }, + "Allow the Garmin app to deliver insulin. Requires watch commands to be enabled." : { + + }, + "Allow the Garmin app to log carbs and start or stop overrides and temp targets." : { + }, "Allow to add carbs in Trio." : { "localizations" : { @@ -46605,6 +46713,9 @@ } } } + }, + "Also Sent" : { + }, "Also triggered when the lowest forecasted glucose (minPredBG) is < %@ %@." : { "localizations" : { @@ -46880,6 +46991,7 @@ } }, "Alter the rate of Dynamic ISF (Sensitivity) adjustments." : { + "comment" : "Mini-hint for Dynamic ISF Adjustment Factor (AF) setting", "localizations" : { "bg" : { "stringUnit" : { @@ -47010,6 +47122,7 @@ } }, "Alter the rate of dynamic sensitivity adjustments for Sigmoid." : { + "comment" : "Mini-hint for Sigmoid Dynamic ISF Adjustment Factor", "localizations" : { "bg" : { "stringUnit" : { @@ -48071,6 +48184,9 @@ } } }, + "Amount in U/hr of small safety TBR to avoid ketoacidosis." : { + "comment" : "Absolute Safety TBR miniHint" + }, "Amount of Carbs on Board (COB) used in the most recent automated dosing calculation." : { "localizations" : { "bg" : { @@ -48335,6 +48451,9 @@ "comment" : "Name of a spoken alert voice.", "isCommentAutoGenerated" : true }, + "An EatingSoon TempTarget needs to be enabled to start B30 adaption. Set level for this target to be identified." : { + "comment" : "B30 TT Level miniHint" + }, "An external bolus of %@ U of insulin was recorded." : { "localizations" : { "bg" : { @@ -48465,6 +48584,9 @@ } } }, + "An indefinite activation updates the pump's scheduled basal to match this profile. The pump's basal schedule will be overwritten." : { + "comment" : "Body of schedule-fire notification warning the user that confirming will overwrite the pump's basal schedule" + }, "An unknown authentication error occurred. Please try again." : { "localizations" : { "bg" : { @@ -49123,6 +49245,7 @@ } }, "Anonymous usage statistics include the app version and build, device and iOS version, which pump and CGM you have paired, and whether Nightscout, Tidepool, and Apple Health are configured (yes/no — no URLs or credentials). They are sent to a self-hosted Trio telemetry endpoint." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -49252,6 +49375,10 @@ } } }, + "Any schedules that activate this profile will also be removed." : { + "comment" : "A message displayed in a confirmation dialog when deleting a profile.", + "isCommentAutoGenerated" : true + }, "API secret" : { "comment" : "API secret in NS", "localizations" : { @@ -51073,8 +51200,12 @@ } } } + }, + "Apply ${preset}" : { + }, "Apply a Temporary Target" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -51335,6 +51466,7 @@ } }, "Applying ${preset}" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -51594,6 +51726,10 @@ } } }, + "Are you sure to switch to U%lld?" : { + "comment" : "A confirmation dialog that asks the user to confirm switching insulin concentration. The argument is the insulin concentration.", + "isCommentAutoGenerated" : true + }, "Are you sure you want to delete %@?" : { "localizations" : { "bg" : { @@ -52135,6 +52271,22 @@ } } }, + "AS" : { + + }, + "as often diluted insulins are used for patients with small insulin dosing requirements." : { + "comment" : "A description of the benefits of using diluted insulin.", + "isCommentAutoGenerated" : true + }, + "Ask on Discord" : { + + }, + "Ask on Facebook" : { + + }, + "at %@" : { + "comment" : "Schedule summary time prefix — interpolated list is two-digit HH:MM values joined with ' & '" + }, "at least" : { "localizations" : { "bg" : { @@ -52394,6 +52546,9 @@ } } } + }, + "Attach My Logs" : { + }, "Attach Pod" : { "comment" : "-------------------------------------------------------------------------------------------\n DASH strings", @@ -53573,6 +53728,10 @@ } } }, + "Auto-revert: when a temp profile expires, you get a notification (\"Profile X expired — reverted to Y\")." : { + "comment" : "A notification that appears when a temporary profile expires.", + "isCommentAutoGenerated" : true + }, "Auto-sensitivity (Autosens) adjusts insulin delivery based on observed sensitivity or resistance." : { "localizations" : { "bg" : { @@ -53703,6 +53862,65 @@ } } }, + "autoISF" : { + "comment" : "Navigation-link title for the autoISF extension section in the draft profile editor" + }, + "AutoISF" : { + + }, + "autoISF 3.01" : { + "comment" : "Title label of the autoISF extension (version 3.01)" + }, + "autoISF 3.01 calculates insulin sensitivity (ISF) each loop cycle based on glucose behaviour within set limits." : { + "comment" : "Mini-hint for Activate autoISF toggle" + }, + "autoISF 3.01 Documentation" : { + + }, + "autoISF allows to adapt the insulin sensitivity factor (ISF) in the following scenarios of glucose behaviour:" : { + + }, + "autoISF calculations" : { + "comment" : "A label displayed above the autoISF calculations.", + "isCommentAutoGenerated" : true + }, + "autoISF can adapt ISF based on glucose delta. It was introduced to help users with gastroparesis. It is also useful for users in pure UAM mode because in their case no meal start can be detected. Given a positive short_avgdelta and glucose being above target+10, the result is:\n\npp_ISF = 1 + delta * pp_ISF_weight.\n\nAs a starting value for pp_ISF_weight, use 0.005. Observe the reactions and check the Enacted Popup before you increase it with care. A weight of 0 disables this contribution." : { + "comment" : "A description of the postprandial ISF weight.", + "isCommentAutoGenerated" : true + }, + "AutoISF Hourly Change" : { + + }, + "autoISF IOB Threshold Percent" : { + "comment" : "IOB Threshold" + }, + "autoISF Max" : { + "comment" : "Row label on Add Override form — autoISF Max\nRow label on Edit Override form — autoISF Max\nautoISF Max" + }, + "AutoISF Max" : { + + }, + "autoISF Min" : { + "comment" : "Row label on Add Override form — autoISF Min\nRow label on Edit Override form — autoISF Min\nautoISF Min" + }, + "AutoISF Min" : { + + }, + "AutoISF Off Sport" : { + + }, + "autoISF Sens Ratio (aiSR)" : { + + }, + "autoISF Settings" : { + + }, + "AutoISF Settings" : { + + }, + "autoISF will enable SMBs for even and block them for odd Targets." : { + "comment" : "Odd Target disables SMB miniHint" + }, "Automate boluses using the iOS Shortcuts App." : { "localizations" : { "bg" : { @@ -54232,6 +54450,7 @@ } }, "Autosens" : { + "comment" : "Hint mini-label on the Autosens settings screen when explaining the live Sensitivity Ratio\nNavigation-link title for the Autosens section in the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -54751,6 +54970,10 @@ } } }, + "autosens is not needed for autoISF as it adapts on a longer time frame than autoISF, so any autosens adjustment is lagging behind what is done by autoISF. It can be kept to ON, and in some border cases the autosens ISF will be used. Check on Discord." : { + "comment" : "A longer description of the `enableAutosensAutoISF` setting.", + "isCommentAutoGenerated" : true + }, "Autosens Max" : { "comment" : "Autosens Max", "localizations" : { @@ -56054,6 +56277,9 @@ } } } + }, + "autosens Ratio (AS)" : { + }, "Autosens Ratio =\n(Weighted Average of TDD) / (10-day Average of TDD)" : { "localizations" : { @@ -56858,6 +57084,9 @@ } } } + }, + "Available basal increment:" : { + }, "Available Layouts:" : { "localizations" : { @@ -57253,15 +57482,15 @@ } } }, - "Average of the Last 24 Hours" : { - "comment" : "Stats banner subtitle" - }, "Average of the Last 3 Months" : { "comment" : "Stats banner subtitle" }, "Average of the Last 7 Days" : { "comment" : "Stats banner subtitle" }, + "Average of the Last 24 Hours" : { + "comment" : "Stats banner subtitle" + }, "Average of the Last Month" : { "comment" : "Stats banner subtitle" }, @@ -57395,9 +57624,6 @@ } } }, - "Averages" : { - "comment" : "Home stats panel face option" - }, "Avg" : { "localizations" : { "bg" : { @@ -57659,6 +57885,55 @@ } } }, + "B30" : { + + }, + "B30 Basal Factor" : { + + }, + "B30 Basal Rate Increase Factor" : { + "comment" : "B30 Factor" + }, + "B30 iTime" : { + + }, + "B30 iTime Start Bolus" : { + + }, + "B30 iTime Target" : { + + }, + "B30 Settings" : { + + }, + "B30 Upper Delta" : { + + }, + "B30 Upper Limit" : { + + }, + "B30 will only run & supress SMB as long as BG stays underneath that level. Default is %@ %@." : { + "comment" : "B30 Upper BG Limit miniHint", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "B30 will only run & supress SMB as long as BG stays underneath that level. Default is %1$@ %2$@." + } + } + } + }, + "B30 will only run & supress SMB's as long as BG delta stays below that level. Default is %@ %@." : { + "comment" : "B30 Upper Delta miniHint", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "B30 will only run & supress SMB's as long as BG delta stays below that level. Default is %1$@ %2$@." + } + } + } + }, "Back" : { "localizations" : { "bg" : { @@ -59918,6 +60193,9 @@ } } } + }, + "Basal rate" : { + }, "Basal Rate" : { "localizations" : { @@ -60440,6 +60718,7 @@ } }, "Basal Rates" : { + "comment" : "Navigation-link title for the Basal Rates editor inside the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -61495,6 +61774,10 @@ } } }, + "Because CR combines two independent physiological processes — CSF (carb absorption into blood) and ISF (insulin moving glucose out of blood) — calculating CR this way ensures it adjusts correctly across the day as your ISF profile changes, while CSF remains stable. If you do experience variation in carb absorption throughout the day, you can account for that in your CSF profile based on real physiological differences, rather than having CSF implicitly change as a mathematical side effect of a fixed CR and varying ISF." : { + "comment" : "A description of how to calculate the Carb Sensitivity Factor (CSF) from the Carb Ratio (CR) and Insulin Sensitivity Factor (ISF).", + "isCommentAutoGenerated" : true + }, "Because Trio utilizes SMBs and UAM SMBs to help you reach your target glucose and other AID systems do not bolus for COB the same way Trio does, this is initially set to below the full calculated amount (80%). When SMBs and UAM SMBs are enabled, you may find your current CR results in lows and needs to be increased before you increase this setting closer to or at 100%." : { "localizations" : { "bg" : { @@ -61625,6 +61908,10 @@ } } }, + "Becomes the new baseline. The basal profile is saved to the pump." : { + "comment" : "A description of the effect of setting a new basal profile as a", + "isCommentAutoGenerated" : true + }, "Before you begin…" : { "localizations" : { "bg" : { @@ -62146,8 +62433,9 @@ } } }, - "Below 54 mg/dL" : { - "comment" : "Percentage of glucose readings in the very low GRI range." + "Below %@" : { + "comment" : "A section of the GRIScoreBreakdownView that shows the percentage of times glucose was below or above certain thresholds.", + "isCommentAutoGenerated" : true }, "Below is the exact JSON object Trio would send right now. No glucose, insulin, carbs, credentials, or settings values are included." : { "localizations" : { @@ -62539,6 +62827,27 @@ } } } + }, + "bg" : { + "comment" : "Label for bg" + }, + "BG" : { + "comment" : "Label for BG" + }, + "BG Accel ISF Weight" : { + + }, + "BG Acceleration:" : { + + }, + "BG Brake ISF Weight" : { + + }, + "BG Plateau:" : { + + }, + "BG-ISF" : { + }, "BGI" : { "localizations" : { @@ -63720,6 +64029,10 @@ } } }, + "Blue entries differ from the source profile. Tap any row to review or adjust." : { + "comment" : "A footer for the Draft Editor, explaining that the values in blue are different from the source profile.", + "isCommentAutoGenerated" : true + }, "Bluetooth" : { "localizations" : { "bg" : { @@ -64524,7 +64837,7 @@ } }, "Bolus" : { - "comment" : "Treatment type\nWatch App Treatment Option 'Bolus'", + "comment" : "Treatment history type label — Bolus\nTreatment row label — fallback when event type is missing\nTreatment type\nWatch App Treatment Option 'Bolus'", "localizations" : { "bg" : { "stringUnit" : { @@ -65836,6 +66149,7 @@ } }, "Bolus Display Threshold" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -66226,6 +66540,9 @@ } } }, + "Bolus exceeds max bolus." : { + "comment" : "Watch command ack" + }, "Bolus failed" : { "comment" : "Bolus failed", "localizations" : { @@ -66357,6 +66674,12 @@ } } }, + "Bolus failed. Check pump history before repeating." : { + "comment" : "Watch command ack" + }, + "Bolus from watch is disabled." : { + "comment" : "Watch command ack" + }, "Bolus In Progress..." : { "localizations" : { "bg" : { @@ -66884,6 +67207,9 @@ } } } + }, + "Bolus Label Threshold" : { + }, "Bolus limit cannot be fetched from phone!" : { "localizations" : { @@ -67552,6 +67878,9 @@ } } }, + "Bolus started." : { + "comment" : "Watch command ack" + }, "Bolus statistics will appear here once data is available." : { "localizations" : { "bg" : { @@ -67820,6 +68149,9 @@ } } }, + "Bolus would exceed max IOB." : { + "comment" : "Watch command ack" + }, "Bolus/Basal %" : { "localizations" : { "bg" : { @@ -68873,6 +69205,9 @@ "Bryce" : { "comment" : "Name of a spoken alert voice.", "isCommentAutoGenerated" : true + }, + "Build" : { + }, "Build Number" : { "localizations" : { @@ -69274,6 +69609,13 @@ } } }, + "Calculate CR from CSF & ISF Profiles" : { + "comment" : "A button that calculates the Carb Ratio (CR) from the Insulin Sensitivity Factor (ISF) and Carb Sensitivity Factor (CSF) profiles.", + "isCommentAutoGenerated" : true + }, + "Calculate CSF from profile CR and ISF." : { + "comment" : "Mini hint for Use Profile CSF" + }, "Calculated from glucose readings in the selected period. Short periods and gaps in sensor data can make the score less representative." : { "comment" : "Explains that GRI uses readings from the selected period and may be less representative for short periods or incomplete data.", "isCommentAutoGenerated" : true @@ -69675,6 +70017,9 @@ } } } + }, + "Calculation Details for %@" : { + }, "Calculator Configuration" : { "localizations" : { @@ -70877,7 +71222,7 @@ } }, "Cancel" : { - "comment" : "Cancel preset name", + "comment" : "Schedule activation notification: cancel / dismiss action", "localizations" : { "bg" : { "stringUnit" : { @@ -71796,6 +72141,10 @@ } } }, + "Cancel Temp Basal" : { + "comment" : "A button that cancels a temporary basal.", + "isCommentAutoGenerated" : true + }, "Cancel Temp Target" : { "comment" : "Alert", "extractionState" : "manual", @@ -72063,6 +72412,13 @@ } } } + }, + "Cancel TT" : { + "comment" : "Text displayed in a notification when a temporary target is cancelled.", + "isCommentAutoGenerated" : true + }, + "Cannot change Concentration" : { + }, "Cannot Enable This Setting" : { "localizations" : { @@ -72193,8 +72549,12 @@ } } } + }, + "Carb entries will be fully decayed by the number of hours specified as Max Meal Absorption Time. Meals that are high in fat and/or protein can have long lasting effects on BG levels. To allow such late meal effects to be considered by the carb decay model, a longer Max Meal Absorption Time than the default 6 hours can be set." : { + }, "Carb entries will be fully decayed by the number of hours specified as Max Meal Absorption Time. Meals that are high in fat and/or protein can have long lasting effects on glucose levels. To allow such late meal effects to be considered by the carb decay model, a longer Max Meal Absorption Time than the default 6 hours can be set." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -72852,7 +73212,7 @@ } }, "Carb Ratios" : { - "comment" : "Carb Ratios header", + "comment" : "Navigation-link title for the Carb Ratio (CR) editor inside the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -72982,6 +73342,28 @@ } } }, + "Carb Sensitivities" : { + "comment" : "The title of the CSF/CGM settings screen.", + "isCommentAutoGenerated" : true + }, + "Carb Sensitivity Factor (CSF)" : { + "comment" : "Header for CSF section" + }, + "Carb Sensitivity Factor (CSF) represents how much your blood glucose rises per gram of carbohydrate consumed. It describes the digestive process of carbs entering your bloodstream as glucose — a process that is independent of insulin sensitivity." : { + "comment" : "A description of the Carb Sensitivity Factor.", + "isCommentAutoGenerated" : true + }, + "Carb Sensitivity Factor (CSF) represents how much your blood glucose rises per gram of carbohydrate consumed. It describes your digestive process - how effectively carbs are absorbed into your blood." : { + "comment" : "A description of the Carb Sensitivity Factor (CSF) in the Therapy Settings.", + "isCommentAutoGenerated" : true + }, + "Carb Sensitivity Factor (CSF) represents how much your blood glucose rises per gram of carbohydrate consumed. It is calculated as: CSF = ISF / CR (Insulin Sensitivity Factor divided by Carb Ratio)." : { + "comment" : "A description of CSF, the carb absorption rate.", + "isCommentAutoGenerated" : true + }, + "Carb Sensitivity Profile" : { + "comment" : "Carb Sensitivity Profile" + }, "Carbohydrates" : { "localizations" : { "bg" : { @@ -73113,7 +73495,7 @@ } }, "Carbs" : { - "comment" : "Treatment type", + "comment" : "Treatment history type label — carbohydrate entry\nTreatment type", "localizations" : { "bg" : { "stringUnit" : { @@ -73512,6 +73894,9 @@ } } }, + "Carbs exceed the maximum allowed." : { + "comment" : "Watch command ack" + }, "Carbs Limit Reached!" : { "localizations" : { "bg" : { @@ -73642,6 +74027,9 @@ } } }, + "Carbs logged and bolus started." : { + "comment" : "Watch command ack" + }, "Carbs logged successfully." : { "comment" : "Success message sent to watch when carbs are logged successfully", "localizations" : { @@ -73773,6 +74161,15 @@ } } }, + "Carbs logged." : { + "comment" : "Watch command ack" + }, + "Carbs logged. Bolus failed, check pump history before repeating." : { + "comment" : "Watch command ack" + }, + "Carbs logged. No bolus was delivered." : { + "comment" : "Watch command ack" + }, "Carbs Not Logged" : { "comment" : "Alert title when a carbs-only quick pick fails to save\nAlert title when the bolus was enacted but carbs failed to save", "localizations" : { @@ -78264,6 +78661,13 @@ } } }, + "Change Screenshots" : { + + }, + "Change Setting" : { + "comment" : "A label displayed above a picker that lets the user change the insulin concentration.", + "isCommentAutoGenerated" : true + }, "Changes the size of the current glucose reading and its trend arrow on the Simple Lock Screen widget. The delta and the time of the last reading are not affected." : { }, @@ -79577,6 +79981,14 @@ } } }, + "Check the other additional \"advanced settings\" like the basal safety multipliers, as they are also reset to defaults during onboarding." : { + "comment" : "A note that explains that the basal safety multiplier is reset to default.", + "isCommentAutoGenerated" : true + }, + "Check your Basal Profile after adding Pump!" : { + "comment" : "A confirmation dialog that appears after the user has selected a custom insulin concentration.", + "isCommentAutoGenerated" : true + }, "Chime" : { "comment" : "Name of a sound file.", "localizations" : { @@ -79708,6 +80120,12 @@ } } }, + "Choose 'Show all labels' to keep every label, or pick a higher threshold to clean up the chart when there are many small SMBs." : { + + }, + "Choose a duration first" : { + "comment" : "Slide-to-confirm button label shown (disabled) when no duration is set and indefinite is off" + }, "Choose a value that covers your highest insulin needs — think about a correction for a very high glucose reading plus your biggest meal bolus. This gives Trio room to work while keeping you safe." : { "localizations" : { "bg" : { @@ -80621,6 +81039,28 @@ } } }, + "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", + "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 %@)." + } + } + } + }, + "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 time span the home statistics panel covers." : { }, @@ -80755,6 +81195,7 @@ } }, "Choose to hide small bolus amounts. See hint for more details." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -81146,6 +81587,7 @@ } }, "Choose type of time in range to be used for Trio's statistics." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -81405,8 +81847,8 @@ } } }, - "Choose what the statistics panel on the home screen shows by default. Tapping the panel always opens the full statistics view.\n\nTime in Range: today's time in range percentage with a glucose distribution bar.\n\nDistribution Bar Only: just the glucose distribution bar, without the percentage.\n\nToday's Averages: today's average glucose and GMI (Glucose Management Index).\n\nHide Statistics: shows no data at all, just a \"View Statistics\" shortcut." : { - "extractionState" : "stale" + "Choose what the statistics panel on the home screen shows by default. Tapping the panel always opens the full statistics view.\n\nTime in Range: time in range percentage with a glucose distribution bar.\n\nDistribution Bar Only: just the glucose distribution bar, without the percentage.\n\nToday's Averages: average glucose and GMI (Glucose Management Index).\n\nActive Profile: the active profile card. Temporary (expiring) profiles are always shown regardless of this setting.\n\nHide Statistics: shows no data at all, just a \"View Statistics\" shortcut." : { + }, "Choose what the statistics panel on the home screen shows by default. Tapping the panel always opens the full statistics view.\n\nTime in Range: today's time in range percentage with a glucose distribution bar.\n\nDistribution Bar Only: just the glucose distribution bar, without the percentage.\n\nToday's Averages: today's average glucose and GMI (Glucose Management Index).\n\nToday's Treatments: insulin delivered since midnight — no glucose statistics. Announced carbs (and fat and protein, if you log them) appear once logged that day.\n\nHide Statistics: shows no data at all, just a \"View Statistics\" shortcut." : { "extractionState" : "stale", @@ -81538,9 +81980,6 @@ } } } - }, - "Choose what the statistics panel on the home screen shows by default. Tapping the panel always opens the full statistics view.\n\nTime in Range: your time in range percentage with a glucose distribution bar.\n\nDistribution Bar Only: just the glucose distribution bar, without the percentage.\n\nAverages: your average glucose and GMI (Glucose Management Index).\n\nHide Statistics: shows no data at all, just a \"View Statistics\" shortcut.\n\nThe numbers cover the time span set in Home Statistics Range." : { - }, "Choose whether or not to display one or both X- and Y-Axis grid lines." : { "localizations" : { @@ -81932,7 +82371,12 @@ } } }, + "Choose which smoothing algorithm shall be applied." : { + "comment" : "A description of the smoothing algorithm picker.", + "isCommentAutoGenerated" : true + }, "Choose which type of time in range Trio should adopt for all its statistical charts and displays:" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -82592,6 +83036,7 @@ } }, "Close" : { + "comment" : "Close button label", "localizations" : { "bg" : { "stringUnit" : { @@ -83379,6 +83824,9 @@ } } } + }, + "COB value" : { + }, "COBpredBG" : { "localizations" : { @@ -83902,6 +84350,9 @@ }, "Combined score" : { "comment" : "Sum of the weighted GRI components before applying the cap." + }, + "Combined SMB cutoff calculation:" : { + }, "Coming soon." : { "localizations" : { @@ -84032,6 +84483,24 @@ } } } + }, + "Command expired. Please try again." : { + "comment" : "Watch command ack" + }, + "Commands" : { + + }, + "Commands are handled by another watch app." : { + "comment" : "Watch command ack" + }, + "Commands are only accepted from one Garmin app: Trio-Complication when it is selected as watchface, otherwise Trio-WatchControls. Changing the watchface switches the command app right away and cancels commands still pending." : { + + }, + "Commands are only accepted from the Garmin apps selected above, must reach the phone within 2 minutes of being confirmed on the watch, and carbs are limited by your Max Carbs setting." : { + + }, + "Commands must reach the phone within 2 minutes of being confirmed on the watch, and carbs are limited by your Max Carbs setting." : { + }, "complete" : { "comment" : "Accessibility: onboarding chapter complete", @@ -84164,6 +84633,9 @@ } } }, + "Completely switches off autoISF during a high TT with adjusted sensitivity." : { + "comment" : "Exercise toggles all autoISF adjustments off miniHint" + }, "Complication" : { "comment" : "Display name for the Garmin watchface setting option for the Connect IQ watch app.", "localizations" : { @@ -84294,6 +84766,12 @@ } } } + }, + "Concentration Information" : { + + }, + "Concentration Settings" : { + }, "Cone" : { "localizations" : { @@ -86553,6 +87031,14 @@ } } }, + "Confirm Before scheduling" : { + "comment" : "Toggle to confirm before scheduling a Temp Target.", + "isCommentAutoGenerated" : true + }, + "Confirm Before Scheduling" : { + "comment" : "Toggle to confirm before scheduling a profile.", + "isCommentAutoGenerated" : true + }, "Confirm Bolus Faster" : { "localizations" : { "bg" : { @@ -86683,6 +87169,42 @@ } } }, + "Confirm Temp Target '%@': %@ for %lld min starting %@" : { + "comment" : "Confirmation prompt for scheduling a custom Temp Target.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Confirm Temp Target '%1$@': %2$@ for %3$lld min starting %4$@" + } + } + } + }, + "Confirm Temp Target '%@': %@ for %lld min, now" : { + "comment" : "Confirmation prompt for creating a custom Temp Target.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Confirm Temp Target '%1$@': %2$@ for %3$lld min, now" + } + } + } + }, + "Confirm to activate profile '%@' for %@" : { + "comment" : "Confirmation prompt for activating a profile for a given duration. The first argument is the profile name. The second argument is the string “%1$arg h %2$arg min”, the string “%arg h” or the string “%arg min”.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Confirm to activate profile '%1$@' for %2$@" + } + } + } + }, "Confirm to apply override '%@'" : { "localizations" : { "bg" : { @@ -86943,6 +87465,34 @@ } } }, + "Confirm to schedule profile '%@' at %@ for %@" : { + "comment" : "Confirmation prompt for scheduling a profile. The argument is the string “%1$arg h %2$arg min”, the string “%arg h” or the string “%arg min”.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Confirm to schedule profile '%1$@' at %2$@ for %3$@" + } + } + } + }, + "Confirm to schedule Temporary Target '%@' for %@" : { + "comment" : "Confirmation prompt to schedule a Temp Target. The first argument is the name of the preset. The second argument is the formatted start time.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Confirm to schedule Temporary Target '%1$@' for %2$@" + } + } + } + }, + "Confirming pump writes" : { + "comment" : "A section of the help screen that explains how to confirm that the pump has received a basal schedule.", + "isCommentAutoGenerated" : true + }, "Connect" : { "comment" : "Connect to NS", "localizations" : { @@ -88658,6 +89208,9 @@ } } } + }, + "Contact" : { + }, "Contact Image" : { "localizations" : { @@ -89845,6 +90398,10 @@ } } }, + "Copied from" : { + "comment" : "A label that indicates that the profile was copied from another profile.", + "isCommentAutoGenerated" : true + }, "Copies the recommended amount into the bolus field" : { "localizations" : { "bg" : { @@ -89974,6 +90531,9 @@ } } } + }, + "Copy ID" : { + }, "Copy JSON" : { "localizations" : { @@ -90364,6 +90924,9 @@ } } } + }, + "Correlation:" : { + }, "Could not access documents directory" : { "localizations" : { @@ -90762,6 +91325,12 @@ } } }, + "Could not log carbs." : { + "comment" : "Watch command ack" + }, + "Could not log carbs. No bolus was delivered." : { + "comment" : "Watch command ack" + }, "Could not prepare logs" : { }, @@ -90895,6 +91464,27 @@ } } } + }, + "Could not start override." : { + "comment" : "Watch command ack" + }, + "Could not start temp target." : { + "comment" : "Watch command ack" + }, + "Could not stop override." : { + "comment" : "Watch command ack" + }, + "Could not stop temp target." : { + "comment" : "Watch command ack" + }, + "Could not verify bolus safety." : { + "comment" : "Watch command ack" + }, + "Couldn't Delete Report" : { + + }, + "Couldn't Send Report" : { + }, "Count" : { "localizations" : { @@ -91157,6 +91747,10 @@ } } }, + "CR combines two independent physiological processes: CSF (carb absorption into blood) and ISF (insulin moving glucose out of blood). These processes operate separately, so when ISF changes due to exercise or stress, CSF remains stable, and CR adjusts accordingly through the formula CR = ISF / CSF." : { + "comment" : "A description of how the Carb Ratio is calculated.", + "isCommentAutoGenerated" : true + }, "Crash reporting and anonymous usage telemetry are enabled by default. Use the diagnostics-sharing chooser above to opt out: pick \"Crash Reports Only\" to keep crash reporting but disable telemetry, or \"Disable Sharing\" to turn off both. Changes take effect immediately." : { "comment" : "A description of the user's ability to opt out of sharing app usage data.", "localizations" : { @@ -91289,6 +91883,7 @@ } }, "Crash reports include the app's state on crash, device, iOS and general system info, and a stack trace. They are sent to a Google Firebase Crashlytics project maintained by the Trio team." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -91548,6 +92143,10 @@ } } }, + "Create and activate a Temporary Target now with name, target value and duration" : { + "comment" : "Description of the intent to create and activate a temporary target with a name, target value, and duration.", + "isCommentAutoGenerated" : true + }, "Create Events in Calendar" : { "extractionState" : "manual", "localizations" : { @@ -91685,8 +92284,19 @@ } } }, + "Create Temporary Target ${name} now" : { + + }, + "Create TT" : { + "comment" : "Text for the title of a shortcut that allows the user to create a custom temporary target.", + "isCommentAutoGenerated" : true + }, "Creates one file you can send when asking for help." : { + }, + "Creating a new profile" : { + "comment" : "A heading for the process of creating a new profile.", + "isCommentAutoGenerated" : true }, "Critical" : { "comment" : "Name of the critical alarm sound.", @@ -91950,7 +92560,20 @@ } } }, + "CSF" : { + "comment" : "Marks on the CSF chart indicating the start and end of each therapy.", + "isCommentAutoGenerated" : true + }, + "CSF describes the digestive process of carbs entering your bloodstream as glucose. This process is not dependent on insulin sensitivity, which describes a different process: removing glucose from the blood." : { + "comment" : "A description of how CSF is calculated.", + "isCommentAutoGenerated" : true + }, + "CSF is defined as the expected BG rise per 10g of carbs consumed. Using 10g units keeps values in a practical range and makes it easier to test and document: e.g. eat 10g of fast carbs and observe the BG rise." : { + "comment" : "A description of the CSF measurement.", + "isCommentAutoGenerated" : true + }, "Current" : { + "comment" : "Fallback source-profile name used in the draft editor when no profile is currently active", "localizations" : { "bg" : { "stringUnit" : { @@ -92470,6 +93093,9 @@ } } } + }, + "Current Concentration" : { + }, "Current IOB (%@ U) > Max IOB (%@ U)" : { "localizations" : { @@ -93409,6 +94035,9 @@ } } } + }, + "Currently used Insulin Sensitivity Factor (ISF) in mg/dL or mmol/L per IU." : { + }, "Custom" : { "comment" : "Custom temp target", @@ -93549,6 +94178,7 @@ } }, "Custom Override" : { + "comment" : "Default name assigned to an override when the user saves edits without providing a name\nFallback display name for a duplicated override when the preset had no name\nFallback display name for the active override when no list entry exists\nFallback display name for the active override when no name was set\nFallback name for an unnamed historical override run uploaded to Nightscout\nFallback name for an unnamed override uploaded to Nightscout\nFallback name on Home adjustments banner for an active override without a name\nFallback name when checking whether a stored Nightscout override entry needs updating", "localizations" : { "bg" : { "stringUnit" : { @@ -93817,6 +94447,7 @@ } }, "Custom Temp Target" : { + "comment" : "Fallback display name for a duplicated temp target when the preset had no name\nFallback display name for the active temp target when no list entry exists\nFallback display name for the active temp target when no name was set", "localizations" : { "bg" : { "stringUnit" : { @@ -94335,6 +94966,9 @@ } } } + }, + "cutoff = clamp( max( Minimum Safety Threshold, ratioFloor ), 60, 120 ) mg/dL" : { + }, "CV" : { "comment" : "CV", @@ -94466,6 +95100,9 @@ } } } + }, + "Cycles the zoom through the 6, 12 and 24 hour presets." : { + }, "d" : { "comment" : "Abbreviation for Days\nabbreviation for days", @@ -94729,6 +95366,10 @@ } } }, + "Daily / Weekdays / Weekends / Custom weekly: fires at the chosen time on matching weekdays." : { + "comment" : "A description of the \"Daily / Weekdays / Weekends / Custom weekly\" option for a schedule.", + "isCommentAutoGenerated" : true + }, "Daily glucose distribution chart" : { "localizations" : { "bg" : { @@ -95508,6 +96149,9 @@ } } } + }, + "Data Interval:" : { + }, "Data transmission has been disabled. Now select the new watchface on your Garmin device and resume data transmission once done." : { "localizations" : { @@ -96029,6 +96673,18 @@ } } }, + "Date & time the preset should activate (within the next 24 hours)" : { + "comment" : "Date & time the preset should activate (within the next 24 hours).", + "isCommentAutoGenerated" : true + }, + "Date & time the Temp Target should begin (within the next 24 hours)" : { + "comment" : "Label for the start time parameter in the Schedule Custom Temp Target intent.", + "isCommentAutoGenerated" : true + }, + "Date and time the profile should activate" : { + "comment" : "Label for the date and time picker in the Schedule Profile intent.", + "isCommentAutoGenerated" : true + }, "Date of adding" : { "localizations" : { "bg" : { @@ -97479,6 +98135,13 @@ } } } + }, + "Days of month" : { + "comment" : "A label displayed under a grid of month days.", + "isCommentAutoGenerated" : true + }, + "Days that don't exist in a given month (e.g. 31 in February) are skipped." : { + }, "DayStart" : { "localizations" : { @@ -98283,6 +98946,7 @@ } }, "Decrease sensitivity when glucose is below target if a manual Temp Target < %@ %@ is set." : { + "comment" : "Mini-hint for Low Temp Target Lowers Sensitivity — interpolated values are the threshold number and the glucose unit", "localizations" : { "bg" : { "stringUnit" : { @@ -98417,6 +99081,12 @@ } } } + }, + "Decreasing this setting may result in fewer FPU entries with larger carb values." : { + + }, + "Decreasing this setting may result in more FPU entries with smaller carb values." : { + }, "Default" : { "localizations" : { @@ -98549,6 +99219,7 @@ } }, "Default impact of carb absorption over a 5 minute interval." : { + "comment" : "Mini-hint for Min 5m Carb Impact setting", "localizations" : { "bg" : { "stringUnit" : { @@ -99482,6 +100153,12 @@ } } }, + "Default value: 0.5 This is the higher end of a linearly increasing SMB Delivery Ratio rather than the fix value above in SMB DeliveryRatio." : { + "comment" : "SMB DeliveryRatio Maximum miniHint" + }, + "Default value: 0.5 This is the lower end of a linearly increasing SMB Delivery Ratio rather than the fix value above in SMB DeliveryRatio." : { + "comment" : "SMB DeliveryRatio Minimum miniHint" + }, "Default values are based on internationally accepted Time in Range values of %@-%@ %@." : { "localizations" : { "bg" : { @@ -99618,6 +100295,10 @@ } } }, + "Default: OFF " : { + "comment" : "A label that describes the default value of a setting.", + "isCommentAutoGenerated" : true + }, "Default: %@ %@" : { "localizations" : { "bg" : { @@ -99884,6 +100565,17 @@ } } }, + "Default: 0.5" : { + "comment" : "A hint for the user that the default value of 0.5 is a safety measure.", + "isCommentAutoGenerated" : true + }, + "Default: 0.5 — valid range (0.5, 1.0]" : { + + }, + "Default: 1" : { + "comment" : "A label for the default value of a setting.", + "isCommentAutoGenerated" : true + }, "Default: 2 %@" : { "localizations" : { "bg" : { @@ -100273,6 +100965,9 @@ } } } + }, + "Default: 8 hours" : { + }, "Default: 10 hours" : { "localizations" : { @@ -101574,6 +102269,10 @@ } } }, + "Default: 100 %" : { + "comment" : "A description of the default therapy adjustment percentage.", + "isCommentAutoGenerated" : true + }, "Default: 100%" : { "localizations" : { "bg" : { @@ -101704,6 +102403,10 @@ } } }, + "Default: 100% " : { + "comment" : "A label displayed in a verbose hint for IOB Threshold Percent.", + "isCommentAutoGenerated" : true + }, "Default: 120 grams of carbs" : { "localizations" : { "bg" : { @@ -102353,6 +103056,9 @@ } } } + }, + "Default: Badge displayed" : { + }, "Default: Day starts %1$@, Night starts %2$@." : { "localizations" : { @@ -107481,6 +108187,15 @@ } } } + }, + "Delete pump if you need to change Insulin Concentration" : { + + }, + "Delete Report" : { + + }, + "Delete Report?" : { + }, "Delete Selected Preset" : { "extractionState" : "manual", @@ -107749,6 +108464,10 @@ } } }, + "Delete the Profile \"%@\"?" : { + "comment" : "A confirmation dialog that lets the user delete a profile.", + "isCommentAutoGenerated" : true + }, "Delete the Temp Target Preset \"%@\"?" : { "comment" : "Delete confirmation title for temporary target presets", "localizations" : { @@ -107880,6 +108599,10 @@ } } }, + "Delete this schedule?" : { + "comment" : "A confirmation dialog asking the user to delete a schedule.", + "isCommentAutoGenerated" : true + }, "Delete Variant" : { "localizations" : { "bg" : { @@ -108009,6 +108732,12 @@ } } } + }, + "Deleting" : { + + }, + "Deleting a report removes it and its files from the Trio team's server." : { + }, "Delivery limits" : { "extractionState" : "manual", @@ -108797,8 +109526,12 @@ } } } + }, + "Describe what happened and what you expected" : { + }, "Deselect All" : { + "comment" : "Toggle button in History filter popover — deselects every treatment type", "localizations" : { "bg" : { "stringUnit" : { @@ -109465,6 +110198,7 @@ } }, "Determined at %@" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -110913,6 +111647,10 @@ } } }, + "Diagnostics are sent to a Google Firebase Crashlytics project, which is securely maintained and accessed only by the Tai author." : { + "comment" : "A description of the data that is sent to Google Firebase Crashlytics.", + "isCommentAutoGenerated" : true + }, "Dings" : { "comment" : "Name of a sound file.", "localizations" : { @@ -112491,6 +113229,9 @@ } } } + }, + "Discord Name" : { + }, "Dismiss" : { "extractionState" : "manual", @@ -112759,6 +113500,9 @@ } } } + }, + "Dismisses the loop status popup again." : { + }, "Display and allow Fat and Protein entries" : { "comment" : "UI/UX option", @@ -114908,6 +115652,9 @@ } } } + }, + "Display sensor lifecycle arc, hourglass and status around the glucose bobble." : { + }, "Display Standard Deviation (SD) instead of Coefficient of Variation (CV) in statPanel" : { "comment" : "Description for display SD", @@ -117542,6 +118289,9 @@ } } } + }, + "Double Tap" : { + }, "Double tap to confirm" : { "comment" : "Accessibility hint", @@ -117937,6 +118687,7 @@ } }, "Double-Tap" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -118198,6 +118949,9 @@ }, "Drag a widget to move it. Drag one up from Available Widgets to add it, or drop it back onto the list to remove it." : { + }, + "Drag the chart sideways to pan through your history. Flick and let go to keep it gliding; panning far enough back loads up to 72 hours." : { + }, "Drives the outer-ring + tag on the home screen's glucose bobble." : { "localizations" : { @@ -118719,6 +119473,18 @@ } } }, + "dura" : { + "comment" : "Label for dura" + }, + "dura_ISF: %@" : { + + }, + "Dura-ISF" : { + + }, + "DuraISF Weight" : { + "comment" : "DuraISF Weight\nRow label on Add Override form — DuraISF Weight\nRow label on Edit Override form — DuraISF Weight" + }, "Duration" : { "comment" : "Duration of target temp or temp basalMedian loop interval", "localizations" : { @@ -118850,6 +119616,24 @@ } } }, + "Duration (minutes)" : { + "comment" : "Label for the duration parameter in the CreateCustomTempTargetIntent.", + "isCommentAutoGenerated" : true + }, + "Duration in minutes?" : { + "comment" : "Label for the duration parameter in the Create Custom Temp Target intent.", + "isCommentAutoGenerated" : true + }, + "Duration must be positive" : { + "comment" : "Error message displayed when the duration of the profile activation is not positive.", + "isCommentAutoGenerated" : true + }, + "Duration of Increased B30 Basal Rate" : { + "comment" : "B30 Duration" + }, + "Duration of increased basal rate that saturates the infusion site with insulin. Default 30 minutes." : { + "comment" : "B30 Duration miniHint" + }, "Duration of Insulin Action" : { "comment" : "Duration of Insulin Action", "localizations" : { @@ -118980,6 +119764,12 @@ } } } + }, + "Duration of Insulin Action (DIA)" : { + + }, + "Duration of Plateau:" : { + }, "Dynamic" : { "localizations" : { @@ -119372,6 +120162,7 @@ } }, "Dynamic ISF" : { + "comment" : "Dynamic ISF", "localizations" : { "bg" : { "stringUnit" : { @@ -119762,6 +120553,7 @@ } }, "Dynamic ISF requires at least " : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -120291,6 +121083,7 @@ } }, "Dynamic Settings" : { + "comment" : "Navigation-link title for the Dynamic ISF/Ratio section in the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -120551,6 +121344,7 @@ } }, "Dynamically adjust insulin sensitivity using Dynamic Ratio rather than Autosens Ratio." : { + "comment" : "Mini-hint for Use Dynamic ISF setting", "localizations" : { "bg" : { "stringUnit" : { @@ -120680,6 +121474,10 @@ } } }, + "DynamicISF" : { + "comment" : "A label displayed in the settings view.", + "isCommentAutoGenerated" : true + }, "eA1c" : { "localizations" : { "bg" : { @@ -121865,6 +122663,10 @@ } } }, + "Edit Profile" : { + "comment" : "A title for a view that lets the user edit a profile.", + "isCommentAutoGenerated" : true + }, "Edit settings json" : { "extractionState" : "manual", "localizations" : { @@ -122131,10 +122933,16 @@ } } } + }, + "eff.TH" : { + }, "Electronic" : { "comment" : "Themed alarm sound catalog for electronic sounds.", "isCommentAutoGenerated" : true + }, + "Email" : { + }, "Emoji or short text" : { "localizations" : { @@ -122545,6 +123353,7 @@ } }, "Enable a Temporary Target" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -122674,6 +123483,9 @@ } } }, + "Enable Absolute Safety TBR" : { + "comment" : "Enable Absolute TBR" + }, "Enable adjustment of basal based on the ratio of current TDD / 7 day average TDD" : { "comment" : "Enable adjustment of basal profile", "extractionState" : "manual", @@ -122812,6 +123624,18 @@ } } }, + "Enable AutoISF" : { + + }, + "Enable Autosens" : { + "comment" : "Enable Autosens" + }, + "Enable B30" : { + + }, + "Enable BG Acceleration" : { + "comment" : "Enable BG Acceleration" + }, "Enable device connectivity" : { "localizations" : { "bg" : { @@ -124004,8 +124828,12 @@ } } } + }, + "Enable Indefinitely or set a duration greater than zero." : { + }, "Enable indefinitely or set a duration." : { + "comment" : "Validation error on Add Override form when neither indefinite nor a duration is set\nValidation error on Edit Override form when neither indefinite nor a duration is set", "localizations" : { "bg" : { "stringUnit" : { @@ -124134,6 +124962,9 @@ } } } + }, + "Enable KetoProtect" : { + }, "Enable Live Activity" : { "localizations" : { @@ -125047,6 +125878,9 @@ } } } + }, + "Enable SMB Even On Odd Off Always" : { + }, "Enable SMB With COB" : { "comment" : "Enable SMB With COB", @@ -126102,6 +126936,7 @@ } }, "Enable Unannounced Meals SMB." : { + "comment" : "Mini-hint for Enable UAM setting", "localizations" : { "bg" : { "stringUnit" : { @@ -126493,6 +127328,9 @@ }, "Enable uploading of sensor errors and other CGM states to Nightscout as notes." : { + }, + "Enable Watch Commands" : { + }, "Enabled" : { "comment" : "Title string for BeepPreference.manualCommands", @@ -126632,6 +127470,15 @@ } } }, + "Enables an increased basal rate after an EatingSoon TT and a manual bolus to saturate the infusion site with insulin to increase insulin absorption for SMB's following a meal with no carb counting." : { + + }, + "Enables an increased basal rate after an EatingSoon TT and a manual bolus to saturate the infusion site with insulin." : { + "comment" : "Enable B30 miniHint" + }, + "Enables the BG acceleration adaptions, adjusting ISF for accelerating/decelerating blood glucose." : { + "comment" : "Enable BG Acceleration miniHint" + }, "Enables this temp target" : { "comment" : "Accessibility hint", "localizations" : { @@ -129245,6 +130092,10 @@ } } }, + "Enact" : { + "comment" : "A button to enact a temporary basal.", + "isCommentAutoGenerated" : true + }, "Enact bolus" : { "comment" : "Button", "extractionState" : "manual", @@ -129791,6 +130642,7 @@ } }, "Enacted at %@" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -130701,6 +131553,18 @@ } } }, + "Ensure that the selected insulin concentration matches the vial or pen label. U%lld means %lld units per ml." : { + "comment" : "A message in a confirmation dialog that asks the user to check the Basal Profile after adding the pump.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Ensure that the selected insulin concentration matches the vial or pen label. U%1$lld means %2$lld units per ml." + } + } + } + }, "Enter Meal Preset Name" : { "extractionState" : "manual", "localizations" : { @@ -131756,6 +132620,7 @@ } }, "Entries are capped at 33 grams each, with up to three entries, for a max total of 99 grams." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -131887,6 +132752,7 @@ }, "Error" : { "comment" : "Error title", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -132154,6 +133020,10 @@ } } }, + "Error During Algorithm Run at %@" : { + "comment" : "A popup that shows an error message and the date of the error.", + "isCommentAutoGenerated" : true + }, "Error while processing Treatment" : { "localizations" : { "bg" : { @@ -133333,6 +134203,9 @@ } } } + }, + "Eventual BG value" : { + }, "Eventual glucose" : { "comment" : "A label displayed under the loop status icon", @@ -134147,6 +135020,12 @@ } } }, + "Every bolus is checked against your Max Bolus and Max IOB. A watch bolus is refused for 6 minutes after the previous watch bolus, and any watch bolus is refused if the boluses of the last 6 minutes (SMBs included) add up to 20% or more of it. These checks cannot tell who pressed the button on the watch: anyone with access to your watch can deliver insulin up to those limits." : { + + }, + "Every day" : { + "comment" : "Schedule repeat summary — every day of the week" + }, "Everything you enter here can be adjusted later in the app." : { "localizations" : { "bg" : { @@ -134406,6 +135285,16 @@ } } } + }, + "Example:" : { + + }, + "Examples (minBG = 100 mg/dL):" : { + "comment" : "A heading for examples of the SMB threshold.", + "isCommentAutoGenerated" : true + }, + "Examples (TargetGlucose = 100 mg/dL):" : { + }, "exceeded" : { "comment" : "Limit Exceeded label", @@ -134683,6 +135572,9 @@ } } }, + "Exercise toggles all autoISF adjustments off" : { + "comment" : "autoISF Off for Sport" + }, "Experimental" : { "comment" : "New Experimental feature", "extractionState" : "manual", @@ -135096,6 +135988,9 @@ } } }, + "Expiring" : { + "comment" : "Countdown label shown on the active-profile banner when less than a minute of a timed activation remains\nCountdown text on Home profile banner when less than a minute of a timed activation remains" + }, "Export Categories" : { "localizations" : { "bg" : { @@ -136289,6 +137184,44 @@ } } }, + "Extended (%@)" : { + "comment" : "Extended lower threshold for TIR and TITR statistics", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extended (%@)" + } + } + } + }, + "Extended Lower Threshold" : { + "comment" : "Search term for the extended lower range threshold setting.", + "extractionState" : "stale", + "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:" + } + } + } + }, + "Extensions" : { + "comment" : "A section of the draft profile editor that contains the", + "isCommentAutoGenerated" : true + }, "External" : { "comment" : "External Insulin", "localizations" : { @@ -136680,6 +137613,9 @@ } } } + }, + "External insulin keeps its rhombus marker." : { + }, "External Insulin?" : { "localizations" : { @@ -137605,6 +138541,9 @@ } } }, + "Factor that multiplies your regular basal rate from profile for B30. Max is 10. The TBR will ignore the maxBasalMultipliers but respect maxBasal setting!" : { + "comment" : "B30 Factor miniHint" + }, "Factory Calibration Parameters" : { "extractionState" : "manual", "localizations" : { @@ -137742,6 +138681,18 @@ } } }, + "Failed to activate profile '%@': %@" : { + "comment" : "Error message when a profile activation fails. The argument is the name of the profile.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Failed to activate profile '%1$@': %2$@" + } + } + } + }, "Failed to create a new object in %@ from %@." : { "localizations" : { "bg" : { @@ -138812,6 +139763,14 @@ } } }, + "Failed to schedule profile '%@'" : { + "comment" : "A message that indicates that the schedule failed.", + "isCommentAutoGenerated" : true + }, + "Failed to schedule Temporary Target '%@'" : { + "comment" : "A message that is displayed when scheduling a Temp Target fails.", + "isCommentAutoGenerated" : true + }, "Failed to Suspend Insulin Delivery" : { "comment" : "Alert title for suspend error", "extractionState" : "manual", @@ -139088,6 +140047,9 @@ } } }, + "Failed to update profile state." : { + "comment" : "Error returned when the Core Data flip marking a profile active could not be saved" + }, "Failed to validate object in %@ from %@." : { "localizations" : { "bg" : { @@ -141722,6 +142684,9 @@ } } } + }, + "Fill in any of these so the team can follow up with you." : { + }, "Filter" : { "localizations" : { @@ -141853,6 +142818,9 @@ } } }, + "final" : { + "comment" : "Label for final" + }, "Fine-tune the ring’s Width and Gap to suit your design preferences." : { "localizations" : { "bg" : { @@ -141983,6 +142951,14 @@ } } }, + "Fire at" : { + "comment" : "Label for the date and time picker in the Schedule Profile intent.", + "isCommentAutoGenerated" : true + }, + "Fires once at the chosen date and time, then the schedule is removed." : { + "comment" : "A description of the behavior of a `.once` schedule.", + "isCommentAutoGenerated" : true + }, "Fires when glucose drops to or below a low threshold." : { "localizations" : { "bg" : { @@ -143290,6 +144266,15 @@ } } } + }, + "fit a0" : { + + }, + "fit a1" : { + + }, + "fit a2" : { + }, "Follows the phone's current color scheme setting at that time" : { "localizations" : { @@ -144072,6 +145057,23 @@ } } }, + "for %lld h" : { + "comment" : "Schedule duration summary — temporary activation in whole hours\nUpcoming-schedule row duration — whole hours" + }, + "for %lld h %lld min" : { + "comment" : "Schedule duration summary — temporary activation in hours and minutes\nUpcoming-schedule row duration — hours and minutes", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "for %1$lld h %2$lld min" + } + } + } + }, + "for %lld min" : { + "comment" : "Schedule duration summary — temporary activation under one hour\nUpcoming-schedule row duration — under one hour" + }, "For 45 g of carbs, you would need:" : { "localizations" : { "bg" : { @@ -145257,6 +146259,7 @@ } }, "Forcing loop…" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -147628,6 +148631,14 @@ } } }, + "Full-Loop exception: when autoISF's full-loop mode is active (an even-numbered temp target below 100 mg/dL, e.g. 80 or 90, signalling you want maximum SMB power), the delivery ratio becomes the greater of the fixed ratio and the ramp value — so the fixed ratio acts as a floor and the ramp can only raise it further." : { + "comment" : "A hint that describes how the delivery ratio is modified when in full-loop mode.", + "isCommentAutoGenerated" : true + }, + "Full-Loop mode" : { + "comment" : "A hint that describes the full-loop mode.", + "isCommentAutoGenerated" : true + }, "Furthermore, you could adjust that sensitivity change independently from the Half Basal Exercise Target specified in Algorithm > Target Behavior settings by deliberately setting a customized Insulin Percentage for a Temp Target." : { "comment" : "A paragraph explaining how to set a custom insulin percentage for a temporary target.", "localizations" : { @@ -149461,6 +150472,9 @@ } } } + }, + "GitHub Username" : { + }, "Give Apple Health Write Permissions" : { "localizations" : { @@ -152369,6 +153383,7 @@ } }, "Glucose Deviations Algorithm" : { + "comment" : "Section header on the Autosens settings screen grouping max/min and related controls", "localizations" : { "bg" : { "stringUnit" : { @@ -154866,6 +155881,7 @@ } }, "Glucose Targets" : { + "comment" : "Navigation-link title for the Glucose Targets editor inside the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -154995,6 +156011,9 @@ } } }, + "Glucose Targets tuned" : { + "comment" : "Profile summary badge — glucose targets differ from defaults" + }, "Glucose trace WILL NOT be affected by any insulin or carb entries." : { "localizations" : { "bg" : { @@ -158214,6 +159233,9 @@ } } } + }, + "Having the U50 active but with U100 in the pump will be very dangerous. Don't hide the badge unless you do not change anything in the longterm - diluted insulins are shown in red, concentrated in yellow." : { + }, "Header" : { "localizations" : { @@ -159011,8 +160033,12 @@ } } }, + "Hey Tai user, watch out!" : { + "comment" : "Title of the warning shown on builds that are not from the main branch" + }, "Hey Trioneer, watch out!" : { "comment" : "Title of the warning shown on builds that are not from the main branch", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -159678,6 +160704,7 @@ } }, "Hide Future" : { + "comment" : "Toggle button in History — hide entries with future timestamps\nToggle button on History Carb Entries view — hide entries with future timestamps", "localizations" : { "bg" : { "stringUnit" : { @@ -159806,6 +160833,15 @@ } } } + }, + "Hide Insulin Badge" : { + + }, + "Hide Insulin Concentration badge" : { + "comment" : "Hide Badge" + }, + "Hide numeric labels next to small boluses and SMBs to reduce chart clutter." : { + }, "Hide Statistics" : { "comment" : "Home stats panel face option", @@ -160866,6 +161902,7 @@ } }, "High Glucose Target" : { + "comment" : "Row label for the high-glucose target field that gates Enable SMB With High Glucose", "localizations" : { "bg" : { "stringUnit" : { @@ -161785,6 +162822,19 @@ } } }, + "Higher = more conservative. Useful if you find the default cuts SMBs off too late for your comfort. Values at or below 0.5 fall back to the default; values above 1.0 are ignored (clamped to (0.5, 1.0])." : { + "comment" : "A description of how the SMB threshold affects the size of SMBs.", + "isCommentAutoGenerated" : true + }, + "Higher = more conservative. Values at or below 0.5 fall back to 0.5; values above 1.0 are ignored. The final cutoff is also clamped to 60–120 mg/dL." : { + + }, + "Higher ISF Range Weight" : { + + }, + "Highest ISF factor allowed." : { + "comment" : "autoISF Max miniHint" + }, "History" : { "comment" : "Header", "localizations" : { @@ -162052,6 +163102,9 @@ } } } + }, + "Hold Trio Button" : { + }, "Home" : { "comment" : "Home title", @@ -162183,6 +163236,9 @@ } } } + }, + "Home Screen Gestures" : { + }, "Home Statistics Panel" : { "localizations" : { @@ -162313,6 +163369,9 @@ } } } + }, + "Home Statistics Range" : { + }, "Home View Button Panel " : { "comment" : "UI/UX option", @@ -163517,6 +164576,17 @@ } } }, + "How far above your BG target the delivery ratio ramps up. Sensible values are %@–%@ %@. Set to 0 to disable the ramp and use the fixed SMB DeliveryRatio instead." : { + "comment" : "SMB DeliveryRatio BG Range miniHint", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "How far above your BG target the delivery ratio ramps up. Sensible values are %1$@–%2$@ %3$@. Set to 0 to disable the ramp and use the fixed SMB DeliveryRatio instead." + } + } + } + }, "How far back?" : { }, @@ -163650,7 +164720,16 @@ } } }, + "How long the Temp Target should run (5–1440 min)" : { + "comment" : "Label for the duration parameter in the Create Custom Temp Target intent.", + "isCommentAutoGenerated" : true + }, + "How many additional minutes?" : { + "comment" : "Label for a parameter in the Shortcuts interface that asks the user to input the number of additional minutes to be added to the profile activation duration.", + "isCommentAutoGenerated" : true + }, "How many grams of carbs?" : { + "comment" : "Request carbs quantity", "localizations" : { "bg" : { "stringUnit" : { @@ -163781,6 +164860,7 @@ } }, "How many grams of fat?" : { + "comment" : "Request fat quantity", "localizations" : { "bg" : { "stringUnit" : { @@ -163911,7 +164991,7 @@ } }, "How many grams of protein?" : { - "comment" : "Request dialog text for the \"Quantity Protein\" parameter.", + "comment" : "Request protein quantity", "localizations" : { "bg" : { "stringUnit" : { @@ -164041,6 +165121,10 @@ } } }, + "How many whole hours?" : { + "comment" : "Label for the number of hours parameter in the Activate Profile intent.", + "isCommentAutoGenerated" : true + }, "How to Return to Main" : { "comment" : "Button on the non-release build warning that opens documentation about returning to main", "localizations" : { @@ -164981,6 +166065,9 @@ } } } + }, + "If all insulin delivery stopped, and assuming no food is eaten or other significant changes occur, at what level would blood glucose eventually level off within 3-4 hrs." : { + }, "If an active override preset is edited, the changes will also apply to the currently running override. However, if you edit the currently running override directly, the preset stays unchanged." : { "localizations" : { @@ -165372,6 +166459,10 @@ } } }, + "If enabled this function will switch off autoISF adaptions completely if you are exercising. Exercising means you have a high TempTarget enabled and HighTTraisesSens, so that this high TT will already increase your sensitivity (will be displayed in active TempTarget)." : { + "comment" : "A verbose hint for the \"Exercise toggles all autoISF adjustments off\" setting.", + "isCommentAutoGenerated" : true + }, "If enabled, a confirmation dialog will be shown when activating adjustment presets." : { "localizations" : { "bg" : { @@ -166034,6 +167125,10 @@ } } }, + "If the \"Use Profile CSF\" setting is enabled in Algorithm Additionals, the algorithm will also keep CSF independent of dynamic ISF adjustments from autoISF, Autosens, or Dynamic ISF. This effectively makes your CR dynamic at runtime too — some refer to this as \"dynamic CR\"." : { + "comment" : "A description of how to use the \"Use Profile CSF\" setting.", + "isCommentAutoGenerated" : true + }, "If toggled, Insulin will be added to IOB but it will not be delivered" : { "localizations" : { "bg" : { @@ -166554,6 +167649,14 @@ } } }, + "If toggled, you will need to confirm before scheduling" : { + "comment" : "Label for a checkbox that allows users to confirm before scheduling a task.", + "isCommentAutoGenerated" : true + }, + "If toggled, you will need to confirm before the schedule is saved" : { + "comment" : "Label for a checkbox that allows the user to confirm before scheduling a new schedule.", + "isCommentAutoGenerated" : true + }, "If using Dynamic ISF (with Sigmoid), overriding your ISF will adjust the ISF used at your glucose target which extends to the ISF used at other glucose. Overriding your glucose target will change glucose level your ISF will be set to your profile ISF. Both of these can be combined in a single Override." : { "localizations" : { "bg" : { @@ -166949,6 +168052,9 @@ } } } + }, + "If you are an expert user and mix your own insulin, it’s essential to review DIA and Custom Peak Time of your insulin after the onboarding, as they are reset to defaults." : { + }, "If you are not seeing calendars to choose here, please go to Settings -> Trio -> Calendars and change permissions to \"Full Access\"" : { "localizations" : { @@ -167470,6 +168576,16 @@ } } }, + "If your glucose target is %@ %@ and ratio = 0.5, ratioFloor = %@ %@. Setting Minimum Safety Threshold below %@ has no effect; setting it above raises the cutoff." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "If your glucose target is %1$@ %2$@ and ratio = 0.5, ratioFloor = %3$@ %4$@. Setting Minimum Safety Threshold below %5$@ has no effect; setting it above raises the cutoff." + } + } + } + }, "If your glucose target is %@ %@, Trio will use a safety threshold of %@ %@, unless you set Minimum Safety Threshold to something > %@ %@." : { "localizations" : { "bg" : { @@ -167735,6 +168851,9 @@ } } } + }, + "Immediately apply ${preset}" : { + }, "Immediately applying ${bolusQuantity} U" : { "localizations" : { @@ -167867,6 +168986,7 @@ } }, "Immediately applying ${preset}" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -169462,6 +170582,10 @@ } } }, + "Important: Enabling this setting changes how the algorithm handles carb absorption. Review your algorithm and therapy settings, and consider starting with a higher glucose target while you adjust. Settings will generally need to be less aggressive, as the stable CSF combined with a reduced CR in insulin resistance situations can increase insulin dosing for meals." : { + "comment" : "A longer, more detailed description of the \"Use Profile CSF\" setting.", + "isCommentAutoGenerated" : true + }, "Importing Settings..." : { "comment" : "Progress text when importing settings via Nightscout", "localizations" : { @@ -169593,6 +170717,9 @@ } } }, + "In addition to the Zero Temp the activiation of KetoProtect is dependant on IOB levels and last Active Insulin." : { + "comment" : "Variable protection miniHint" + }, "In case you're using both profiles and temp targets" : { "comment" : "UI/UX option", "extractionState" : "manual", @@ -169731,6 +170858,14 @@ } } }, + "In full-loop mode, the SMB delivery ratio becomes the greater of the fixed SMB DeliveryRatio and the linearly-rising ramp value — so the fixed ratio acts as a floor the ramp can only raise, never lower." : { + "comment" : "A verbose hint for the full-loop mode of the SMB delivery ratio.", + "isCommentAutoGenerated" : true + }, + "In oref smb_delivery_ratio is normally hard coded as 0.5 of the insulin requested. This is a safety feature for master/follower setups in case both phones trigger an SMB in the same situation. If this does not apply in your case you may increase this setting to a value above 0.5 and up to even 1.0 if you are very courageous." : { + "comment" : "A longer description of the `smb_delivery_ratio` setting.", + "isCommentAutoGenerated" : true + }, "in progress" : { "comment" : "Accessibility: loop currently running", "localizations" : { @@ -170124,6 +171259,10 @@ } } }, + "In the algorithm, CSF is used to estimate carb absorption rates, convert carb impact to grams absorbed, project remaining carb absorption, and calculate carbs required to prevent lows. With this setting enabled, all of these calculations use a stable, profile-based CSF." : { + "comment" : "A description of how CSF is used in the algorithm.", + "isCommentAutoGenerated" : true + }, "In the next few steps, you’ll configure your algorithm settings for" : { "localizations" : { "bg" : { @@ -170383,8 +171522,12 @@ } } } + }, + "Includes glucose, insulin, carbs and your settings. Passwords and serial numbers are removed." : { + }, "Increase sensitivity when glucose is above target if a manual Temp Target > %@ %@ is set." : { + "comment" : "Mini-hint for High Temp Target Raises Sensitivity — interpolated values are the threshold number and the glucose unit", "localizations" : { "bg" : { "stringUnit" : { @@ -170779,6 +171922,12 @@ } } } + }, + "Increasing this setting may result in fewer FPU entries with larger carb values." : { + + }, + "Increasing this setting may result in more FPU entries with smaller carb values." : { + }, "Indefinite" : { "localizations" : { @@ -170910,6 +172059,21 @@ } } }, + "Indefinite / until next: becomes your new baseline. The basal profile is saved to the pump and stays until you switch profiles again." : { + "comment" : "A description of indefinite schedules.", + "isCommentAutoGenerated" : true + }, + "Indefinite activation: an actionable notification (\"Save basal to pump?\") with Save to pump / Cancel buttons. Tapping the body re-opens an in-app confirmation dialog when you return to the app." : { + "comment" : "A notification that lets the user confirm or cancel a basal rate change.", + "isCommentAutoGenerated" : true + }, + "Indefinite activations push the basal schedule to the pump. Timed activations (up to 24 h) keep the pump untouched — the algorithm compensates via temp basals, same as overrides — and auto-revert when they expire." : { + + }, + "Indefinite: becomes your new baseline. The basal schedule is written to the pump and everything else is applied immediately." : { + "comment" : "A description of the indefinite profile.", + "isCommentAutoGenerated" : true + }, "Indicates glucose smoothing is enabled." : { "localizations" : { "bg" : { @@ -171698,6 +172862,9 @@ } } } + }, + "Initiating B30 can be done by Apple Shortcuts\nhttps://tinyurl.com/aimiB30shortcut\n" : { + }, "Initiating…" : { "comment" : "A label displayed when the pump is waiting to start bolusing.", @@ -171967,8 +173134,12 @@ } } }, + "Instead of a single fixed ratio, the delivery ratio can rise linearly with glucose: starting cautiously at SMB DeliveryRatio BG Minimum when glucose is at your BG target, and reaching the more ambitious SMB DeliveryRatio BG Maximum once glucose is this many points above target." : { + "comment" : "A longer description of the `SMB DeliveryRatio BG Range` setting.", + "isCommentAutoGenerated" : true + }, "Insulin" : { - "comment" : "Title for insulin-related statistics", + "comment" : "Label for Insulin section\nTitle for insulin-related statistics", "localizations" : { "bg" : { "stringUnit" : { @@ -172227,6 +173398,16 @@ } } } + }, + "Insulin Concentration" : { + "comment" : "Insulin Concentration" + }, + "Insulin Concentration Selected" : { + "comment" : "A section header for the insulin concentration selected.", + "isCommentAutoGenerated" : true + }, + "Insulin Concentration:" : { + }, "Insulin curve" : { "comment" : "Insulin curve", @@ -172365,6 +173546,12 @@ } } } + }, + "Insulin Curve" : { + + }, + "Insulin Curve Parameters" : { + }, "Insulin Limit Reached" : { "localizations" : { @@ -173279,6 +174466,7 @@ } }, "Insulin Pump" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -173408,6 +174596,10 @@ } } }, + "Insulin Pump & Concentration" : { + "comment" : "A label displayed in a list item.", + "isCommentAutoGenerated" : true + }, "Insulin recommended" : { "extractionState" : "manual", "localizations" : { @@ -173813,6 +175005,7 @@ } }, "Insulin Sensitivities" : { + "comment" : "Navigation-link title for the Insulin Sensitivity (ISF) editor inside the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -175005,6 +176198,19 @@ } } }, + "Invalid bolus amount." : { + "comment" : "Watch command ack" + }, + "Invalid carbs amount." : { + "comment" : "Watch command ack" + }, + "Invalid CGM reading (HIGH)." : { + "comment" : "A warning displayed when a CGM reading is too high.", + "isCommentAutoGenerated" : true + }, + "Invalid command." : { + "comment" : "Watch command ack" + }, "Invalid Glucose (%@)." : { "localizations" : { "bg" : { @@ -176212,6 +177418,27 @@ } } } + }, + "IOB / maxIOB" : { + + }, + "IOB / TH" : { + + }, + "IOB Threshold %" : { + "comment" : "Row label on Add Override form — IOB Threshold %\nRow label on Edit Override form — IOB Threshold %" + }, + "IOB Threshold Percent" : { + + }, + "IOB unavailable. Bolus not delivered." : { + "comment" : "Watch command ack" + }, + "IOB value" : { + + }, + "IOB: %@ U" : { + }, "IOBpredBG" : { "localizations" : { @@ -176342,6 +177569,15 @@ } } } + }, + "iobTH %" : { + + }, + "iOS Version" : { + + }, + "iPhone Model" : { + }, "is running" : { "localizations" : { @@ -176864,6 +178100,30 @@ } } }, + "ISF factors" : { + "comment" : "Label for ISF factors section" + }, + "ISF value" : { + + }, + "ISF Weight for Higher BGs" : { + "comment" : "ISF High BG Weight\nRow label on Add Override form — ISF Weight for Higher BGs\nRow label on Edit Override form — ISF Weight for Higher BGs" + }, + "ISF Weight for Lower BGs" : { + "comment" : "ISF Low BG Weight\nRow label on Add Override form — ISF Weight for Lower BGs\nRow label on Edit Override form — ISF Weight for Lower BGs" + }, + "ISF Weight for Postprandial BG Rise" : { + "comment" : "Postprandial ISF weight\nRow label on Add Override form — ISF Weight for Postprandial BG Rise\nRow label on Edit Override form — ISF Weight for Postprandial BG Rise" + }, + "ISF Weight While BG Accelerates" : { + "comment" : "BG Acceleration ISF Weight\nRow label on Add Override form — ISF Weight While BG Accelerates\nRow label on Edit Override form — ISF Weight While BG Accelerates" + }, + "ISF Weight While BG Decelerates" : { + "comment" : "Row label on Add Override form — ISF Weight While BG Decelerates\nRow label on Edit Override form — ISF Weight While BG Decelerates" + }, + "ISF Weight While BG Decelerates." : { + "comment" : "BG Brake ISF Weight" + }, "ISF/CR" : { "comment" : "Option for both ISF and CR", "localizations" : { @@ -177124,8 +178384,12 @@ } } } + }, + "It can also adapt SMB delivery settings." : { + }, "It can handle small gaps in data and ignores sensor error values. It needs 4 readings in a row, each arriving within 12 minutes of the one before it, to work properly. Only CGM readings are smoothed—manual entries are not changed." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -177384,6 +178648,9 @@ } } } + }, + "It is one factor used in combination with the Fat and Protein Delay, Spread Interval, and Fat and Protein Factor to create the FPU entries." : { + }, "It looks at the last 8-24 hours of data, excluding meal-related changes, and adjusts insulin settings like basal rates and targets when needed to match your sensitivity or resistance to insulin." : { "localizations" : { @@ -177908,6 +179175,10 @@ } } }, + "Keep at 100 % if you want the new profile to share the active profile's therapy values and only differ in algorithm toggles or BG targets." : { + "comment" : "A description of the therapy adjustment percentage.", + "isCommentAutoGenerated" : true + }, "Keep these turned ON in your phone’s settings to ensure you receive Trio Notifications, Critical Alerts, and Time Sensitive Notifications." : { "localizations" : { "bg" : { @@ -178167,6 +179438,30 @@ } } } + }, + "Keto Protection" : { + "comment" : "Navigation-link title for the Keto Protection extension section in the draft profile editor" + }, + "Ketoacidosis protection applies a small safety Temp Basal Rate continuously or under specific conditions (Variable Strategy) to reduce ketoacidosis risk." : { + + }, + "Ketoacidosis protection will apply a small configurable Temp Basal Rate (TBR) instead of a Zero Temp. This is done either always or if certain conditions arise. For the later you need to enable the Variable KetoProtect Strategy." : { + "comment" : "KetoProtect VerboseHint" + }, + "KetoProtect" : { + + }, + "KetoProtect Absolute" : { + + }, + "KetoProtect Basal Absolute" : { + + }, + "KetoProtect Basal Percent" : { + + }, + "KetoProtect Settings" : { + }, "Large" : { "comment" : "Live Activity glucose reading font size" @@ -178431,12 +179726,6 @@ } } }, - "last 24 hours" : { - "comment" : "Stats banner scope" - }, - "Last 24 Hours" : { - "comment" : "Home stats panel range option" - }, "Last 3 Days" : { }, @@ -178452,6 +179741,12 @@ "Last 7 Days" : { "comment" : "Home stats panel range option" }, + "last 24 hours" : { + "comment" : "Stats banner scope" + }, + "Last 24 Hours" : { + "comment" : "Home stats panel range option" + }, "Last Blood Sugar prediction" : { "extractionState" : "manual", "localizations" : { @@ -178588,6 +179883,9 @@ } } } + }, + "Last Delta" : { + }, "last device communication %@" : { "comment" : "Accessibility: device age", @@ -181085,6 +182383,12 @@ } } } + }, + "Lets a supported Garmin app log carbs and start or stop override and temp target presets. Status and the preset list stay available with this switch off." : { + + }, + "Lets the Garmin app deliver a bolus, alone or together with carbs, without any confirmation on this phone." : { + }, "Libre 2 Direct" : { "comment" : "Libre 2 Direct", @@ -183084,6 +184388,7 @@ } }, "Limits the size of a single Super Micro Bolus (SMB) dose." : { + "comment" : "Mini-hint for Max SMB Basal Minutes setting", "localizations" : { "bg" : { "stringUnit" : { @@ -183214,6 +184519,7 @@ } }, "Limits the size of a single Unannounced Meal (UAM) SMB dose." : { + "comment" : "Mini-hint for Max UAM Basal Minutes setting", "localizations" : { "bg" : { "stringUnit" : { @@ -183734,6 +185040,7 @@ } }, "List State" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -186233,6 +187540,10 @@ } } }, + "Log Glucose" : { + "comment" : "A button that logs glucose.", + "isCommentAutoGenerated" : true + }, "Log Meal" : { "localizations" : { "bg" : { @@ -186494,6 +187805,7 @@ } }, "Logarithmic" : { + "comment" : "Dynamic ISF picker option — logarithmic formula", "localizations" : { "bg" : { "stringUnit" : { @@ -186884,7 +188196,7 @@ } }, "Logging Failed: Max Carbs = %lld g" : { - "comment" : "An error message that the Add Carbs shortcut failed because the entered carbs exceeded the Max Carbs setting", + "comment" : "Max carbs exceeded error", "localizations" : { "bg" : { "stringUnit" : { @@ -187015,7 +188327,7 @@ } }, "Logging Failed: Max Fat = %lld g" : { - "comment" : "An error message that the Add Carbs shortcut failed because the entered fat exceeded the Max Fat setting", + "comment" : "Max fat exceeded error", "localizations" : { "bg" : { "stringUnit" : { @@ -187146,7 +188458,7 @@ } }, "Logging Failed: Max Protein = %lld g" : { - "comment" : "An error message that the Add Carbs shortcut failed because the entered protein exceeded the Max Protein setting", + "comment" : "Max protein exceeded error", "localizations" : { "bg" : { "stringUnit" : { @@ -188204,8 +189516,13 @@ } } }, + "LoopGraph" : { + "comment" : "Name of a Garmin datafield app.", + "isCommentAutoGenerated" : true + }, "looping" : { "comment" : "Looping in progress", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -191506,6 +192823,13 @@ } } } + }, + "Lower end of the linearly increasing SMB delivery ratio. This value is applied at your BG target — i.e. the share of insulinReq delivered as SMB when glucose is right at target." : { + "comment" : "A hint for the lower end of the linearly increasing SMB delivery ratio.", + "isCommentAutoGenerated" : true + }, + "Lower ISF Range Weight" : { + }, "Lower limit of the Autosens Ratio." : { "localizations" : { @@ -191638,6 +192962,7 @@ } }, "Lower limit of the Sensitivity Ratio." : { + "comment" : "Mini-hint for Autosens Min setting", "localizations" : { "bg" : { "stringUnit" : { @@ -191768,6 +193093,7 @@ } }, "Lower target glucose when Autosens Ratio is greater than 1." : { + "comment" : "Mini-hint for Resistance Lowers Target setting", "localizations" : { "bg" : { "stringUnit" : { @@ -191897,6 +193223,9 @@ } } }, + "Lowest ISF factor allowed." : { + "comment" : "autoISF Min miniHint" + }, "m" : { "comment" : "Abbreviation for Minutes\nabbreviation for minutes", "localizations" : { @@ -192818,6 +194147,10 @@ } } }, + "Managing" : { + "comment" : "A heading for managing schedules.", + "isCommentAutoGenerated" : true + }, "Manual" : { "comment" : "Manual Bolus", "localizations" : { @@ -193087,11 +194420,12 @@ } } }, - "Manual basal %@" : { - "comment" : "Accessibility: manual temp basal rate the user set on the pump" + "Manual Bolus" : { + }, "manual entry" : { "comment" : "Accessibility: manually entered glucose", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -193351,6 +194685,10 @@ } } }, + "Manual Temp Basal" : { + "comment" : "A button that opens a modal to manually set a temporary basal rate.", + "isCommentAutoGenerated" : true + }, "Manual Temp Basal Active" : { "localizations" : { "bg" : { @@ -199269,6 +200607,9 @@ } } } + }, + "Maximum Duration" : { + }, "Maximum Duration In Hours" : { "comment" : "Duration", @@ -199798,6 +201139,12 @@ } } } + }, + "maxIOB" : { + + }, + "maxIOB and iobTH% as of current settings!" : { + }, "Meal" : { "comment" : "Debug option view Meal", @@ -202711,6 +204058,7 @@ } }, "Migration of JSON-based OpenAPS Determination Data failed: %@" : { + "comment" : "Migration error on first launch — the interpolated value is the underlying system error description", "localizations" : { "bg" : { "stringUnit" : { @@ -203106,6 +204454,9 @@ } } }, + "milligrams per deciliter per 10 grams" : { + "comment" : "Accessibility: spoken unit" + }, "milligrams per deciliter per unit" : { "comment" : "Accessibility: spoken unit", "localizations" : { @@ -203499,6 +204850,9 @@ } } }, + "millimoles per liter per 10 grams" : { + "comment" : "Accessibility: spoken unit" + }, "millimoles per liter per unit" : { "comment" : "Accessibility: spoken unit", "localizations" : { @@ -204950,7 +206304,11 @@ } } }, + "Minimum manual bolus to start a B30 adaption." : { + "comment" : "B30 Start Bolus miniHint" + }, "Minimum minutes since the last SMB or manual bolus to allow an automated SMB." : { + "comment" : "Mini-hint for SMB Interval setting", "localizations" : { "bg" : { "stringUnit" : { @@ -205347,6 +206705,12 @@ } } } + }, + "Minimum Start Bolus Size" : { + "comment" : "B30 Start Bolus" + }, + "Minimum Threshold Setting is the hard floor for the SMB cutoff. Below this glucose value Trio will suspend insulin delivery and block SMBs. It is designed to protect against hypoglycemia, particularly during sleep or other vulnerable times." : { + }, "Minimum Threshold Setting is, by default, determined by your set Glucose Target. This threshold automatically suspends insulin delivery if your glucose levels are forecasted to fall below this value. It’s designed to protect against hypoglycemia, particularly during sleep or other vulnerable times." : { "localizations" : { @@ -206151,6 +207515,9 @@ } } }, + "Missing profile" : { + "comment" : "Schedule list row shown when the schedule's target profile has been deleted" + }, "Missing required inputs; cannot determine basal." : { "localizations" : { "bg" : { @@ -207076,6 +208443,9 @@ } } } + }, + "Modified" : { + }, "Monochrome" : { "localizations" : { @@ -207344,6 +208714,13 @@ } } }, + "Monthly on %@" : { + "comment" : "Schedule repeat summary for custom monthly days — interpolated list is comma-separated day numbers" + }, + "Monthly: fires on the chosen day-of-month." : { + "comment" : "A description of the monthly schedule type.", + "isCommentAutoGenerated" : true + }, "Mood Synth" : { "comment" : "Display name for the \"Mood Synth\" alarm sound.", "localizations" : { @@ -208005,6 +209382,10 @@ } } }, + "Multiplier cap on how high the autoISF ratio can be and therefore how low it can adjust ISF." : { + "comment" : "A verbose hint for the autoISF Max setting.", + "isCommentAutoGenerated" : true + }, "Mute Trio Alarms" : { "localizations" : { "bg" : { @@ -208266,6 +209647,14 @@ } } }, + "Name for this Temp Target?" : { + "comment" : "Label for the name parameter in the Create Custom Temp Target intent.", + "isCommentAutoGenerated" : true + }, + "Name is required" : { + "comment" : "Error message displayed when the user has not provided a name for the Temp Target.", + "isCommentAutoGenerated" : true + }, "Name Of Dish" : { "localizations" : { "bg" : { @@ -208396,6 +209785,10 @@ } } }, + "Name shown in history" : { + "comment" : "Name of the parameter that allows the user to specify a name for the Temp Target.", + "isCommentAutoGenerated" : true + }, "Native G5 app" : { "comment" : "Native G5 app", "extractionState" : "manual", @@ -209186,6 +210579,10 @@ } } }, + "New Schedule" : { + "comment" : "The title of a screen that lets the user create a new schedule.", + "isCommentAutoGenerated" : true + }, "New Sensor Detected" : { "comment" : "New Sensor Detected", "extractionState" : "manual", @@ -209453,6 +210850,9 @@ } } } + }, + "Next Delta" : { + }, "Next field" : { "localizations" : { @@ -210896,6 +212296,9 @@ } } }, + "No Algorithm result" : { + "comment" : "Home status popup title fallback when no determination exists\nHome status popup title when no determination is available" + }, "No biometric identities are enrolled. Please set up Face ID or Touch ID." : { "localizations" : { "bg" : { @@ -211555,7 +212958,12 @@ } } }, + "No Data" : { + "comment" : "A label displayed when there is no data to show.", + "isCommentAutoGenerated" : true + }, "No data." : { + "comment" : "Empty-state text on History Adjustments list\nEmpty-state text on History Glucose list\nEmpty-state text on History Treatments list", "localizations" : { "bg" : { "stringUnit" : { @@ -211685,6 +213093,9 @@ } } }, + "No days selected" : { + "comment" : "Schedule repeat summary when the monthly day set is empty\nSchedule repeat summary when the weekday set is empty (error state)" + }, "No Error" : { "extractionState" : "manual", "localizations" : { @@ -213657,6 +215068,13 @@ } } }, + "No profiles — create one first." : { + "comment" : "A message displayed when a user has no profiles to choose from.", + "isCommentAutoGenerated" : true + }, + "No pump manager available." : { + "comment" : "Pump-sync failure shown when activating a profile but no pump is paired" + }, "No recent determination." : { "localizations" : { "bg" : { @@ -214185,6 +215603,10 @@ } } }, + "No schedules yet" : { + "comment" : "A description of the empty state when there are no schedules.", + "isCommentAutoGenerated" : true + }, "No Sensor Detected" : { "comment" : "No Sensor Detected", "extractionState" : "manual", @@ -215635,7 +217057,7 @@ } }, "None" : { - "comment" : "No CGM selected\nOption for no selection", + "comment" : "No CGM selected\nOption for no selection\nSummary shown next to the Basal Rates row in the draft editor when the schedule is empty\nSummary shown next to the Carb Ratios row in the draft editor when the schedule is empty\nSummary shown next to the Glucose Targets row in the draft editor when the schedule is empty\nSummary shown next to the Insulin Sensitivities row in the draft editor when the schedule is empty", "localizations" : { "bg" : { "stringUnit" : { @@ -216041,6 +217463,7 @@ } }, "Normal (%@-%@)" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -216452,6 +217875,10 @@ } } }, + "Normally, CSF is calculated using the current dynamic ISF value, which can change frequently based on algorithms like Dynamic ISF, autoISF, or Autosens. This can cause CSF to fluctuate as your ISF adjusts." : { + "comment" : "A hint that describes how CSF is calculated when using a dynamic ISF.", + "isCommentAutoGenerated" : true + }, "Not allowed" : { "localizations" : { "bg" : { @@ -218035,6 +219462,10 @@ } } }, + "Note to Tai users:" : { + "comment" : "A note for users using the Tai app.", + "isCommentAutoGenerated" : true + }, "Note: A CGM is considered noisy when it provides inconsistent readings." : { "localizations" : { "bg" : { @@ -218685,6 +220116,14 @@ } } }, + "Note: For practical reasons the profile is entered in units of mg/dL per 10g of carbs consumed (or mmol/L per 10g). This makes values easier to measure and document — eat 10g of fast carbs and observe the BG rise to determine your CSF. The conversion to per-gram is handled internally." : { + "comment" : "A note about how the carb sensitivity factor is calculated.", + "isCommentAutoGenerated" : true + }, + "Note: If \"Low Target Lowers Sensitivity\" is active, using an \"Eating Soon\" temporary target will be more aggressive than before. The lower target reduces ISF, which with a now reduced CR also increases meal insulin dosing. Review your targets and sensitivity settings accordingly." : { + "comment" : "A note about how the \"Eating Soon\" target is affected by the \"Low Target Lowers Sensitivity\" setting.", + "isCommentAutoGenerated" : true + }, "Note: If this is enabled and the criteria are met, SMBs could be utilized regardless of other SMB settings being enabled or not." : { "localizations" : { "bg" : { @@ -219725,6 +221164,10 @@ } } }, + "Note: the threshold is also clamped to at least 60 mg/dL and at most 120 mg/dL." : { + "comment" : "A note that the threshold is clamped to 60-120 mg/dL.", + "isCommentAutoGenerated" : true + }, "Note: These values are not used to calculate insulin dosing." : { "localizations" : { "bg" : { @@ -223020,6 +224463,9 @@ } } }, + "Odd Target disables SMB for autoISF" : { + "comment" : "Odd Target disables SMB" + }, "of" : { "comment" : "Bolus progress view", "extractionState" : "manual", @@ -224200,6 +225646,9 @@ } } }, + "Once on %@" : { + "comment" : "Schedule repeat summary for a one-off fire — interpolated value is a localized date/time string" + }, "Once the total insulin required is calculated, this safety limit specifies what percentage of the insulin required can be delivered as an SMB." : { "localizations" : { "bg" : { @@ -224330,6 +225779,10 @@ } } }, + "Once: fires a single time at the picked date and time. The schedule auto-deletes after firing." : { + "comment" : "A description of a schedule that fires a single time at a specified date and time.", + "isCommentAutoGenerated" : true + }, "One last thing, before you begin..." : { "localizations" : { "bg" : { @@ -224460,6 +225913,13 @@ } } }, + "One of the screenshots couldn't be read. Try choosing it again or leave it out." : { + + }, + "Only active when SMB DeliveryRatio BG Range is non-zero; otherwise the fixed SMB DeliveryRatio applies." : { + "comment" : "A hint that the delivery ratio is only active when the delivery range is non-zero.", + "isCommentAutoGenerated" : true + }, "Only adjust these settings if you’re an advanced or returning user who knows what they’re doing." : { "localizations" : { "bg" : { @@ -224728,6 +226188,16 @@ } } }, + "Only boluses and SMBs at or above the selected amount will show their numeric label on the main chart. The bar or marker itself is always drawn." : { + + }, + "Only enable this if you keep your watch locked and under your control." : { + + }, + "Only indefinite (and until-next) activations write a new basal schedule into the pump's memory. Schedules will NOT write to the pump automatically — confirm via Save to pump in the notification or the in-app dialog. Until you confirm, the previous saved schedule stays on the pump." : { + "comment" : "A description of how temporary basal profiles interact with the pump.", + "isCommentAutoGenerated" : true + }, "Only shown for the Glucose Bobble style, which replaces Display Values, Ring Settings, and Font Settings:" : { "localizations" : { "bg" : { @@ -224858,6 +226328,10 @@ } } }, + "Only the name is editable here. The active Profile's therapy values (Basal Rate, ISF, CR, BG Targets) and Algorithm Settings are edited directly in Therapy and Algorithm Settings while this Profile is active." : { + "comment" : "Alert message displayed when renaming an active profile.", + "isCommentAutoGenerated" : true + }, "Oops, there was an issue!" : { "localizations" : { "bg" : { @@ -225247,6 +226721,9 @@ } } } + }, + "Open a GitHub Issue" : { + }, "Open Can" : { "comment" : "Name of a sound file.", @@ -226037,6 +227514,9 @@ "Open Wrapper" : { "comment" : "Name of a sound file.", "isCommentAutoGenerated" : true + }, + "OpenAPS documentation" : { + }, "OpenAPS main settings" : { "comment" : "Headers for settings -----------------------", @@ -226175,6 +227655,9 @@ } } } + }, + "OpenAPS on IOB and Activity calculations" : { + }, "OpenAPS other settings" : { "extractionState" : "manual", @@ -227244,6 +228727,7 @@ }, "Opens adjustments" : { "comment" : "Accessibility hint", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -227896,6 +229380,15 @@ } } } + }, + "Opens the Quick-Pick Treatments picker with your recent bolus and carb amounts, so a repeat treatment takes one slide to confirm." : { + + }, + "Opens the setup for that device — or, if none is set up yet, the picker to add one." : { + + }, + "Opens the treatment view to add carbs, insulin or a manual glucose reading." : { + }, "Opt out" : { "localizations" : { @@ -228027,6 +229520,14 @@ } } }, + "Optional name for the schedule entry" : { + "comment" : "Label for the optional name of the schedule.", + "isCommentAutoGenerated" : true + }, + "Optional profile used to derive Carb Ratios from ISF." : { + "comment" : "A description of the Carb Sensitivity Profile.", + "isCommentAutoGenerated" : true + }, "Optionally sync with Nightscout and enter essentials." : { "comment" : "Description of the Prepare Trio onboarding chapter.", "localizations" : { @@ -228287,6 +229788,9 @@ } } } + }, + "Original rates" : { + }, "Other" : { "comment" : "Other CGM setting", @@ -229082,6 +230586,7 @@ } }, "Override" : { + "comment" : "Fallback name on History Adjustments list when an override run has no name", "localizations" : { "bg" : { "stringUnit" : { @@ -229471,6 +230976,9 @@ } } }, + "Override %@" : { + "comment" : "Default name for a new override when the user left the name field blank — interpolated value is a formatted date/time" + }, "Override canceled" : { "localizations" : { "bg" : { @@ -229861,6 +231369,9 @@ } } }, + "Override preset not found." : { + "comment" : "Watch command ack" + }, "Override Presets" : { "localizations" : { "bg" : { @@ -230533,6 +232044,12 @@ } } }, + "Override started." : { + "comment" : "Watch command ack" + }, + "Override stopped." : { + "comment" : "Watch command ack" + }, "Override Target" : { "localizations" : { "bg" : { @@ -232653,6 +234170,7 @@ } }, "Pans the chart through time. Flick and let go to keep it gliding." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -234515,6 +236033,7 @@ } }, "Percentage of calculated insulin required that is given as SMB." : { + "comment" : "Mini-hint for SMB Delivery Ratio (AlgorithmAdvanced) setting", "localizations" : { "bg" : { "stringUnit" : { @@ -235836,6 +237355,10 @@ } } }, + "Pick any combination. Example: M/W/F fires three times a week." : { + "comment" : "A description of the weekday selection.", + "isCommentAutoGenerated" : true + }, "Pick Treatment" : { "localizations" : { "bg" : { @@ -236225,6 +237748,9 @@ } } } + }, + "Pinch to zoom the visible time window continuously, between 1 and 24 hours." : { + }, "Play preview" : { "comment" : "Accessibility: play tone preview", @@ -238224,6 +239750,15 @@ } } } + }, + "Post Meal ISF Weight" : { + + }, + "pp" : { + "comment" : "Label for pp" + }, + "pp-ISF" : { + }, "Prediction Type" : { "localizations" : { @@ -239144,6 +240679,7 @@ } }, "Presets cannot be saved with a future date!" : { + "comment" : "Validation error when saving a temp target preset with a future start date", "localizations" : { "bg" : { "stringUnit" : { @@ -239274,6 +240810,7 @@ } }, "Press and Hold" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -239803,6 +241340,9 @@ } } } + }, + "Previous Release Notes" : { + }, "Previous Versions" : { "extractionState" : "stale", @@ -240454,6 +241994,9 @@ } } } + }, + "Private. Describe the problem, add screenshots and your logs. Only the Trio team can see it." : { + }, "Proceed" : { "comment" : "Button Label to Proceed to Bolus on Watch", @@ -241114,6 +242657,36 @@ } } }, + "Profile '%@' activated for %@" : { + "comment" : "\"Profile '...' activated for ...\". The argument is the string “%1$arg h %2$arg min”, the string “%arg h” or the string “%arg min”.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Profile '%1$@' activated for %2$@" + } + } + } + }, + "Profile '%@' scheduled for %@ for %@" : { + "comment" : "A confirmation dialog for a successful schedule The argument is the string “%1$arg h %2$arg min”, the string “%arg h” or the string “%arg min”.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Profile '%1$@' scheduled for %2$@ for %3$@" + } + } + } + }, + "Profile \"%@\" activated" : { + "comment" : "Title of schedule-fire notification (timed Flow A) — interpolated value is the profile name" + }, + "Profile \"%@\" expired" : { + "comment" : "Title of profile-expired notification — interpolated value is the expiring profile's name" + }, "Profile at 100 %" : { "localizations" : { "bg" : { @@ -241244,6 +242817,16 @@ } } }, + "Profile missing" : { + "comment" : "Upcoming-schedule row shown when the target profile has been deleted" + }, + "Profile name" : { + "comment" : "A label for the name of a new profile.", + "isCommentAutoGenerated" : true + }, + "Profile not found." : { + "comment" : "Error returned when activating a profile whose Core Data record can't be loaded" + }, "Profile Override" : { "comment" : "Service Section", "extractionState" : "manual", @@ -241382,6 +242965,14 @@ } } }, + "Profile to activate" : { + "comment" : "Label for the profile parameter in the Apply Profile intent.", + "isCommentAutoGenerated" : true + }, + "Profile to schedule" : { + "comment" : "Label for the profile parameter in the Schedule Profile intent.", + "isCommentAutoGenerated" : true + }, "Profiles" : { "extractionState" : "manual", "localizations" : { @@ -241519,6 +243110,9 @@ } } }, + "Programmed on pump" : { + "comment" : "Accessibility label for bolus programmed on the pump itself" + }, "Protein" : { "comment" : "Add Protein", "localizations" : { @@ -241650,9 +243244,6 @@ } } }, - "Programmed on pump" : { - "comment" : "Accessibility label for bolus programmed on the pump itself" - }, "Protein / Fat" : { "comment" : "Treatment type", "localizations" : { @@ -242173,6 +243764,12 @@ } } } + }, + "Public. Only for bugs you can describe without personal or health data." : { + + }, + "Pull Down" : { + }, "Pull down to force loop" : { "localizations" : { @@ -242303,6 +243900,9 @@ } } } + }, + "Pull the home screen down past the hint to force a loop." : { + }, "Pump" : { "extractionState" : "manual", @@ -242440,6 +244040,9 @@ } } } + }, + "Pump & Concentration" : { + }, "Pump battery" : { "localizations" : { @@ -243098,6 +244701,10 @@ } } }, + "Pump confirmation required — activate from the app" : { + "comment" : "Error message when a one-off profile activation requires a pump confirmation.", + "isCommentAutoGenerated" : true + }, "Pump Error" : { "localizations" : { "bg" : { @@ -243358,6 +244965,9 @@ } } }, + "Pump Event" : { + "comment" : "Treatment row label — fallback when pump event type is missing" + }, "Pump Fault" : { "localizations" : { "bg" : { @@ -244418,6 +246028,7 @@ } }, "Pump rewind initiates a reset in Sensitivity Ratio." : { + "comment" : "Mini-hint for Rewind Resets Autosens setting", "localizations" : { "bg" : { "stringUnit" : { @@ -244815,6 +246426,9 @@ } } }, + "Pump State" : { + "comment" : "Treatment history type label — pump state change" + }, "Pump State Error" : { "localizations" : { "bg" : { @@ -244944,6 +246558,9 @@ } } } + }, + "Pump supported increment:" : { + }, "Pump suspended" : { "localizations" : { @@ -246115,6 +247732,9 @@ } } }, + "Quantity of the small safety TBR in % of Profile BR, which is given to avoid ketoacidosis." : { + "comment" : "Safety TBR miniHint" + }, "Quantity Protein" : { "localizations" : { "bg" : { @@ -246770,6 +248390,7 @@ } }, "Raise target glucose when Autosens Ratio is less than 1." : { + "comment" : "Mini-hint for Sensitivity Raises Target setting", "localizations" : { "bg" : { "stringUnit" : { @@ -246899,6 +248520,9 @@ } } }, + "Raises the glucose floor below which SMBs are blocked. 0.5 keeps default behaviour, 1.0 blocks SMBs until above target." : { + "comment" : "Mini-hint for SMB Threshold Ratio setting" + }, "Random variation added to each reading to simulate real-world sensor noise." : { "localizations" : { "bg" : { @@ -247425,6 +249049,10 @@ } } }, + "rate" : { + "comment" : "Vertical line at the start and end of each basal rate entry.", + "isCommentAutoGenerated" : true + }, "Rate" : { "comment" : "Rate basal profileLabel text for temporary basal rate summary", "localizations" : { @@ -247556,6 +249184,9 @@ } } }, + "Rate at which ISF is reduced per hour assuming BG level remains at double target for that time." : { + "comment" : "DuraISF Weight miniHint" + }, "rate-end" : { "localizations" : { "bg" : { @@ -247815,6 +249446,9 @@ } } } + }, + "Rates Adjusted" : { + }, "Ratio" : { "localizations" : { @@ -247945,6 +249579,12 @@ } } } + }, + "ratioFloor = TargetGlucose − (1 − ratio) × (TargetGlucose − 40)" : { + + }, + "ratioFloor = TargetGlucose − (1 − SMB Threshold Ratio) × (TargetGlucose − 40)" : { + }, "raw: %lf" : { "localizations" : { @@ -248206,6 +249846,9 @@ } } } + }, + "Read up on it at:" : { + }, "Readings" : { "comment" : "CGM readings in statView", @@ -249261,6 +250904,9 @@ } } } + }, + "Recenters the chart on now, from wherever you have panned to. The zoom level stays as it is." : { + }, "Recommendation" : { "comment" : "Bolus screen", @@ -251880,6 +253526,10 @@ } } }, + "Remove label — make a Default" : { + "comment" : "A button that removes a label from a profile and makes it a default.", + "isCommentAutoGenerated" : true + }, "Remove Last" : { "localizations" : { "bg" : { @@ -252141,6 +253791,10 @@ } } }, + "Remove the label from this profile?" : { + "comment" : "A confirmation prompt to remove a label from a profile.", + "isCommentAutoGenerated" : true + }, "Remove widget" : { "extractionState" : "stale", "localizations" : { @@ -252403,6 +254057,21 @@ } } }, + "Rename active Profile" : { + "comment" : "A title for a dialog that renames an active profile.", + "isCommentAutoGenerated" : true + }, + "Repeat" : { + "comment" : "A label for the repeat picker.", + "isCommentAutoGenerated" : true + }, + "Repeat options" : { + "comment" : "A heading for the repeat options of a schedule.", + "isCommentAutoGenerated" : true + }, + "Replace" : { + "comment" : "View/Header when pod expired" + }, "Replace CGM Sensor" : { "comment" : "Title of an alert that instructs the user to replace a CGM sensor.", "localizations" : { @@ -252533,6 +254202,9 @@ } } } + }, + "Replace circles and triangles with vertical bars for bolus, SMB and carbs." : { + }, "Replace pod" : { "comment" : "View/Header when pod expired", @@ -252664,6 +254336,21 @@ } } } + }, + "Report %@ and its files will be deleted from the Trio team's server." : { + + }, + "Report a Problem" : { + + }, + "Report Actions" : { + + }, + "Report Sent" : { + + }, + "Reports go only to the Trio team and are deleted after 90 days. You can delete a report yourself at any time." : { + }, "Represents carb equivalent for fat and protein, calculated using the Warsaw Method." : { "localizations" : { @@ -252795,6 +254482,15 @@ } } }, + "req." : { + "comment" : "Label for req." + }, + "Request ID was already used." : { + "comment" : "Watch command ack" + }, + "Request is already in progress." : { + "comment" : "Watch command ack" + }, "Require Adjustments Confirmation" : { "localizations" : { "bg" : { @@ -253192,6 +254888,7 @@ } }, "Reservoir is empty" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -254103,6 +255800,7 @@ } }, "Rest a finger on the chart to inspect a single reading with its IOB and COB." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -254231,6 +255929,9 @@ } } } + }, + "Rest your finger on the chart to inspect a single reading with its IOB and COB." : { + }, "Restart Live Activity" : { "localizations" : { @@ -254492,6 +256193,10 @@ } } }, + "Restores the previous indefinite profile — the one whose basal schedule is already on the pump. No pump change needed." : { + "comment" : "A description of the effect of stopping a temporary profile.", + "isCommentAutoGenerated" : true + }, "Resume" : { "extractionState" : "manual", "localizations" : { @@ -255027,6 +256732,9 @@ } } } + }, + "Resume Smoothing" : { + }, "Resuming insulin delivery..." : { "comment" : "Text for suspend resume button when insulin delivery is resuming", @@ -255565,6 +257273,29 @@ } } }, + "Revert" : { + "comment" : "Button title for reverting to the previous profile.", + "isCommentAutoGenerated" : true + }, + "Revert to previous profile?" : { + "comment" : "Confirmation prompt when reverting to a previous profile.", + "isCommentAutoGenerated" : true + }, + "Reverted to \"%@\"." : { + "comment" : "Body of profile-expired notification — interpolated value is the anchor profile name we reverted to" + }, + "Reverting" : { + "comment" : "A heading for the section of the app's profile screen that describes how to revert to a previous profile.", + "isCommentAutoGenerated" : true + }, + "Reverts to" : { + "comment" : "A heading for a section that lists previous profiles that have been reverted to.", + "isCommentAutoGenerated" : true + }, + "Review the pre-filled therapy and algorithm values on the next screen, then save." : { + "comment" : "A footer for the \"Next\" button in the AddProfileForm.", + "isCommentAutoGenerated" : true + }, "Rewind Resets Autosens" : { "comment" : "Rewind Resets Autosens", "localizations" : { @@ -257006,6 +258737,14 @@ } } }, + "Runs for" : { + "comment" : "A label displayed in a picker that lets the user select the duration of", + "isCommentAutoGenerated" : true + }, + "Runs for the chosen duration, then reverts. The pump's saved basal is not changed." : { + "comment" : "A description of the effect of a custom duration.", + "isCommentAutoGenerated" : true + }, "Safety" : { "comment" : "Extra Safety Features when using dyn ISF", "extractionState" : "manual", @@ -257274,6 +259013,9 @@ } } }, + "Safety TBR in %" : { + "comment" : "Safety TBR" + }, "Save" : { "comment" : "Save preset namebutton title for saving low reservoir reminder", "localizations" : { @@ -258208,6 +259950,21 @@ } } }, + "Save basal of Profile \"%@\" to pump?" : { + "comment" : "Title of schedule-fire notification for an indefinite activation that needs pump sync — interpolated value is the profile name" + }, + "Save basal to pump?" : { + "comment" : "A confirmation dialog that asks the user to save a basal rate to the pump.", + "isCommentAutoGenerated" : true + }, + "Save Changes" : { + "comment" : "A button that saves the user's changes to their profile.", + "isCommentAutoGenerated" : true + }, + "Save failed" : { + "comment" : "A title for an alert that indicates that a save operation failed.", + "isCommentAutoGenerated" : true + }, "Save on Pump" : { "extractionState" : "manual", "localizations" : { @@ -258475,6 +260232,20 @@ } } }, + "Save Profile" : { + "comment" : "A button that saves the current draft profile.", + "isCommentAutoGenerated" : true + }, + "Save Schedule" : { + "comment" : "A button that saves a schedule.", + "isCommentAutoGenerated" : true + }, + "Save the file or send it to someone helping you on Discord or Facebook. Contains glucose, insulin and carbs; passwords and serial numbers are removed." : { + + }, + "Save to pump" : { + "comment" : "Schedule activation notification: save-basal-to-pump action" + }, "Saved Food" : { "comment" : "Saved Food Presets", "localizations" : { @@ -259010,6 +260781,7 @@ "isCommentAutoGenerated" : true }, "Scales down your basal rate to 50% at this value." : { + "comment" : "Mini-hint for Half Basal Exercise Target setting", "localizations" : { "bg" : { "stringUnit" : { @@ -259406,6 +261178,47 @@ } } }, + "Schedule ${preset} at ${startTime}" : { + + }, + "Schedule a custom Temporary Target for a date+time within the next 24 hours" : { + "comment" : "Description of the intent to schedule a custom temporary target.", + "isCommentAutoGenerated" : true + }, + "Schedule a profile to activate once at a specific date/time for a chosen duration. Indefinite scheduling is not available via Shortcuts." : { + "comment" : "Description of the Schedule Profile intent.", + "isCommentAutoGenerated" : true + }, + "Schedule a Temporary Target preset for a date+time within the next 24 hours" : { + "comment" : "Intent description for scheduling a temporary target preset.", + "isCommentAutoGenerated" : true + }, + "Schedule name" : { + "comment" : "Label for the \"Schedule name\" parameter in the Schedule Profile intent.", + "isCommentAutoGenerated" : true + }, + "Schedule Profile" : { + "comment" : "Shortcut to schedule a profile.", + "isCommentAutoGenerated" : true + }, + "Schedule Profile ${profile} at ${fireAt}" : { + + }, + "Schedule Temporary Target ${name} at ${startTime}" : { + + }, + "Schedule time must be in the future" : { + "comment" : "Error message when the schedule time is in the past.", + "isCommentAutoGenerated" : true + }, + "Schedule TT" : { + "comment" : "Text displayed in the shortcut picker for scheduling a custom temporary target.", + "isCommentAutoGenerated" : true + }, + "Schedule TT Preset" : { + "comment" : "Shortcut to schedule a temporary target preset.", + "isCommentAutoGenerated" : true + }, "Schedule when SMBs are Off" : { "extractionState" : "manual", "localizations" : { @@ -259673,6 +261486,9 @@ } } }, + "Scheduled profile" : { + "comment" : "Fallback profile name used in the pending-activation pump-save alert when the scheduled profile can't be resolved by id" + }, "Scheduled Reminder" : { "extractionState" : "manual", "localizations" : { @@ -259940,6 +261756,10 @@ } } }, + "Schedules" : { + "comment" : "A label for the schedules view.", + "isCommentAutoGenerated" : true + }, "Schedules were saved successfully!" : { "extractionState" : "manual", "localizations" : { @@ -260079,6 +261899,12 @@ }, "Score calculation" : { "comment" : "Expandable breakdown of the GRI score for the selected period." + }, + "Screenshot %lld" : { + + }, + "Screenshots" : { + }, "SD" : { "comment" : "SD", @@ -260731,6 +262557,10 @@ } } }, + "See compatible Trio apps on Garmin ConnectIQ." : { + "comment" : "A link to a Garmin ConnectIQ page that lists compatible Trio apps.", + "isCommentAutoGenerated" : true + }, "See what's new in this version." : { "localizations" : { "bg" : { @@ -261126,6 +262956,7 @@ } }, "Select All" : { + "comment" : "Toggle button in History filter popover — selects every treatment type", "localizations" : { "bg" : { "stringUnit" : { @@ -261255,6 +263086,10 @@ } } }, + "Select Concentration" : { + "comment" : "The title of the insulin concentration selection screen.", + "isCommentAutoGenerated" : true + }, "Select font size, weight, and width to match your style:" : { "localizations" : { "bg" : { @@ -261515,6 +263350,10 @@ } } }, + "Select profile" : { + "comment" : "Title of a dialog that appears when the user is prompted to select a profile.", + "isCommentAutoGenerated" : true + }, "Select simple or detailed style. See hint for more details." : { "localizations" : { "bg" : { @@ -262692,6 +264531,15 @@ } } } + }, + "Send to the Trio Team" : { + + }, + "Sending" : { + + }, + "Sending Report" : { + }, "Sens Ratio" : { "comment" : "Name of the secondary attribute choice for the SwissAlpine watchface.", @@ -265525,6 +267373,9 @@ } } } + }, + "Sent Reports" : { + }, "Series" : { "localizations" : { @@ -265789,6 +267640,7 @@ }, "Set a custom time for peak insulin effect." : { "comment" : "Mini Hint for Insulin Peak Time", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -265919,6 +267771,7 @@ } }, "Set a duration!" : { + "comment" : "Validation error on Add Temp Target form when duration is zero", "localizations" : { "bg" : { "stringUnit" : { @@ -266308,6 +268161,10 @@ } } }, + "Set it conservatively; from here the ratio climbs linearly toward SMB DeliveryRatio BG Maximum as glucose rises across SMB DeliveryRatio BG Range." : { + "comment" : "A verbose hint for the SMB DeliveryRatio BG Minimum setting.", + "isCommentAutoGenerated" : true + }, "Set limits for each type of macro per meal entry." : { "localizations" : { "bg" : { @@ -266974,6 +268831,48 @@ } } }, + "Set the duration for the increased basal rate in B30 mode. Default is 30 minutes." : { + "comment" : "B30 Duration VerboseHint" + }, + "Set the EatingSoon TempTarget glucose level to trigger B30. Should be a low TT like %@ %@. Keep in mind it should be an even TT to allow autoISF SMB's after the duration specified, if the target would still be active. Canceling this TT will imediatly stop B30 adaptions." : { + "comment" : "B30 TT Level VerboseHint", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Set the EatingSoon TempTarget glucose level to trigger B30. Should be a low TT like %1$@ %2$@. Keep in mind it should be an even TT to allow autoISF SMB's after the duration specified, if the target would still be active. Canceling this TT will imediatly stop B30 adaptions." + } + } + } + }, + "Set the maximum BG delta limit for B30 & suppressed SMB to remain active. Default is %@ %@." : { + "comment" : "B30 Upper Delta VerboseHint", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Set the maximum BG delta limit for B30 & suppressed SMB to remain active. Default is %1$@ %2$@." + } + } + } + }, + "Set the maximum BG level for B30 & suppressed SMB to remain active. Default is %@ %@." : { + "comment" : "B30 Upper BG Limit VerboseHint", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Set the maximum BG level for B30 & suppressed SMB to remain active. Default is %1$@ %2$@." + } + } + } + }, + "Set the maximum timeframe to extend FPUs." : { + + }, + "Set the percentage of the current basal rate to apply for safety against ketoacidosis. Recommended between 10% - 20%" : { + "comment" : "Safety TBR VerboseHint" + }, "Set the size of the glucose reading." : { }, @@ -267115,6 +269014,10 @@ } } }, + "Set to 0 to disable the ramp — the fixed SMB DeliveryRatio is used instead." : { + "comment" : "A hint for the `smb_delivery_ratio_bg_max` setting.", + "isCommentAutoGenerated" : true + }, "Set to a number, e.g. 160, which means when temp target is 160 mg/dL and exercise_mode=true, run 50% basal at this level (120 = 75%; 140 = 60%). This can be adjusted, to give you more control over your exercise modes." : { "comment" : "\"Half Basal Exercise Target\"", "extractionState" : "manual", @@ -267642,6 +269545,9 @@ } } } + }, + "Settings for protective TBR" : { + }, "Settings imported" : { "extractionState" : "manual", @@ -268841,8 +270747,9 @@ "Share Pump Logs" : { }, - "Share Trio Logs" : { - + "Share Tai Logs" : { + "comment" : "Label for a row that lets the user share their Tai logs.", + "isCommentAutoGenerated" : true }, "Shared Secret" : { "localizations" : { @@ -269366,6 +271273,7 @@ }, "Show All" : { "comment" : "Name of the bolus display threshold option that shows all bolus amounts.", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -269495,6 +271403,10 @@ } } }, + "Show all labels" : { + "comment" : "Display name for the case that shows all available bolus labels.", + "isCommentAutoGenerated" : true + }, "Show carbs required as a red icon on the main graph icon." : { "localizations" : { "bg" : { @@ -269754,6 +271666,12 @@ } } } + }, + "Show CGM Status Around Bobble" : { + + }, + "Show COB-IOB Chart" : { + }, "Show Delta" : { "localizations" : { @@ -270148,6 +272066,7 @@ } }, "Show Future" : { + "comment" : "Toggle button in History — show entries with future timestamps\nToggle button on History Carb Entries view — show entries with future timestamps", "localizations" : { "bg" : { "stringUnit" : { @@ -270544,6 +272463,9 @@ } } } + }, + "Show Glucose Peaks" : { + }, "Show live activity" : { "comment" : "Notification option", @@ -271462,8 +273384,18 @@ } } } + }, + "Shows a manually entered insulin dose." : { + + }, + "Shows a small automated insulin dose (super-micro-bolus)." : { + + }, + "Shows an externally administered insulin dose (e.g., a pen shot)." : { + }, "Shows an insulin dose, which can be a small automated dose (super-micro-bolus), a manually entered dose, or one given externally (e.g., a pen shot)." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -271592,6 +273524,21 @@ } } } + }, + "Shows current basal rate being delivered in insulin units per hour." : { + + }, + "Shows current COB amount in g." : { + + }, + "Shows current final ISF / Sensitivity adjustment Ratio as derived by autoISF algorithm." : { + + }, + "Shows current final ISF / Sensitivity adjustment Ratio as derived by autosens algorithm. Only shown when autoISF is not enabled." : { + + }, + "Shows current IOB in insulin units." : { + }, "Shows current or past TBRs, which can be set by the oref algorithm or manually." : { "localizations" : { @@ -271722,6 +273669,9 @@ } } } + }, + "Shows decreasing Pump Reservoir levels, progressively colorized from green to red." : { + }, "Shows forecast on the Apple Watch" : { "comment" : "Text displayed in a hint when the user enables the feature to show the forecast on the Apple Watch.", @@ -272117,8 +274067,15 @@ } } } + }, + "Shows the status popup with the details of the last loop cycle." : { + + }, + "Shows the total dose of insulin for the last 24 hours." : { + }, "Sigmoid" : { + "comment" : "Dynamic ISF picker option — sigmoid formula\nRow label for the Sigmoid (vs logarithmic) Dynamic-ISF formula toggle", "localizations" : { "bg" : { "stringUnit" : { @@ -272249,6 +274206,7 @@ } }, "Sigmoid Adjustment Factor" : { + "comment" : "Row label for the Sigmoid variant of the Dynamic ISF Adjustment Factor", "localizations" : { "bg" : { "stringUnit" : { @@ -273826,6 +275784,12 @@ } } }, + "Slide to activate for %@" : { + "comment" : "Slide-to-confirm button label — the interpolated value is a formatted duration like '3 hr 15 min'" + }, + "Slide to activate indefinitely" : { + "comment" : "Slide-to-confirm button label when user chose indefinite activation" + }, "Slide to Confirm" : { "comment" : "Slide to confirm label when nothing is picked yet", "localizations" : { @@ -275021,7 +276985,7 @@ } }, "SMB" : { - "comment" : "An Automatic delivered bolus (SMB)", + "comment" : "Label for SMB\nTreatment history type label — SMB (super-microbolus)\nTreatment row label — SMB", "localizations" : { "bg" : { "stringUnit" : { @@ -275282,7 +277246,7 @@ } }, "SMB Delivery Ratio" : { - "comment" : "SMB Delivery Ratio", + "comment" : "Row label on Add Override form — SMB Delivery Ratio\nRow label on Edit Override form — SMB Delivery Ratio\nSMB Delivery Ratio", "localizations" : { "bg" : { "stringUnit" : { @@ -275412,6 +277376,15 @@ } } }, + "SMB Delivery Ratio BG Range" : { + "comment" : "Row label on Add Override form — SMB Delivery Ratio BG Range\nRow label on Edit Override form — SMB Delivery Ratio BG Range" + }, + "SMB Delivery Ratio Max" : { + "comment" : "Row label on Add Override form — SMB Delivery Ratio Max\nRow label on Edit Override form — SMB Delivery Ratio Max" + }, + "SMB Delivery Ratio Min" : { + "comment" : "Row label on Add Override form — SMB Delivery Ratio Min\nRow label on Edit Override form — SMB Delivery Ratio Min" + }, "SMB Delivery Ratio of calculated insulin required that is given as SMB." : { "localizations" : { "bg" : { @@ -275542,6 +277515,9 @@ } } }, + "SMB Delivery Ratios" : { + "comment" : "Section header on the AutoISF draft editor grouping the fixed and range-based SMB delivery ratio rows" + }, "SMB DeliveryRatio" : { "comment" : "Headline \"SMB DeliveryRatio\"", "extractionState" : "manual", @@ -275680,6 +277656,18 @@ } } }, + "SMB DeliveryRatio (fixed)" : { + "comment" : "SMB DeliveryRatio" + }, + "SMB DeliveryRatio BG Maximum" : { + "comment" : "SMB DeliveryRatio Maximum" + }, + "SMB DeliveryRatio BG Minimum" : { + "comment" : "SMB DeliveryRatio Minimum" + }, + "SMB DeliveryRatio BG Range" : { + "comment" : "SMB DeliveryRatio BG Range" + }, "SMB Interval" : { "comment" : "SMB Interval", "localizations" : { @@ -275811,6 +277799,12 @@ } } }, + "SMB Max Range Extension" : { + + }, + "SMB Max RangeExtension" : { + "comment" : "SMB Max RangeExtension" + }, "SMB Minutes" : { "extractionState" : "manual", "localizations" : { @@ -276208,6 +278202,9 @@ } } }, + "SMB Threshold Ratio" : { + "comment" : "SMB Threshold Ratio" + }, "SMB:" : { "localizations" : { "bg" : { @@ -276338,7 +278335,12 @@ } } }, + "SMBs and Non-Zero Temp. Basal Rates are disabled." : { + "comment" : "A warning displayed when the user has an invalid CGM reading.", + "isCommentAutoGenerated" : true + }, "SMBs Off%@" : { + "comment" : "Override subtitle fragment on Home showing that SMBs are disabled — interpolated value is an optional time-range like ' 08:00-10:00'", "localizations" : { "bg" : { "stringUnit" : { @@ -276468,7 +278470,11 @@ } } }, + "Smooth CGM readings to reduce noise." : { + "comment" : "Mini hint for smooth glucose value" + }, "Smooth CGM readings using exponential smoothing." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -276599,7 +278605,7 @@ } }, "Smooth Glucose Value" : { - "comment" : "Smoothing of CGM readings", + "comment" : "Smooth Glucose Value", "localizations" : { "bg" : { "stringUnit" : { @@ -276859,6 +278865,19 @@ } } }, + "Smoothing Algorithm" : { + "comment" : "A section header for the user to select a smoothing algorithm.", + "isCommentAutoGenerated" : true + }, + "Smoothing Algorithm Changed" : { + + }, + "Smoothing has been disabled to clear old smoothed values. Toggle smoothing back on to apply the new algorithm to all glucose readings." : { + + }, + "Smoothing: On" : { + "comment" : "Tag chip in Home status popup — glucose smoothing is enabled\nTag chip in Loop status popup — glucose smoothing is enabled" + }, "Snooze" : { "localizations" : { "bg" : { @@ -278561,6 +280580,12 @@ } } } + }, + "Some basal rates have been rounded to match available pump increments for U%lld insulin. Highlighted entries show adjusted values. Please REVIEW and Save." : { + + }, + "Some files didn't reach the Trio team's server. Please try again." : { + }, "Some helpful reminders:" : { "localizations" : { @@ -279479,6 +281504,18 @@ } } }, + "Specify a fixed basal rate for safety against ketoacidosis." : { + "comment" : "Absolute TBR VerboseHint" + }, + "Specify an absolute TBR between 0 and 2 U/hr instead of a percentage of the current basal rate." : { + "comment" : "Enable Absolute Safety TBR miniHint" + }, + "Specify the factor to increase the basal rate during B30. Max is 10x." : { + "comment" : "B30 Factor VerboseHint" + }, + "Specify the minimum bolus size required to trigger B30." : { + "comment" : "B30 Start Bolus VerboseHint" + }, "Split" : { "localizations" : { "bg" : { @@ -279870,6 +281907,9 @@ } } }, + "stabilizing" : { + "comment" : "Accessibility: sensor lifecycle tag" + }, "STALE DATA" : { "comment" : "Information displayed when watch app data outdated or stale.", "localizations" : { @@ -280001,6 +282041,46 @@ } } }, + "Standard" : { + "comment" : "Temp target sensitivity mode — preset ratios" + }, + "Standard (%@)" : { + "comment" : "Standard lower threshold for TIR and TITR statistics", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard (%@)" + } + } + } + }, + "Standard insulin concentration is U100 (100 units per mL), which is the baseline for most insulin pump delivery systems. To show this increment independent of units delivered, it can be shown as volume in μL/hr. The Pump Increment in μL/hr = 10 * Pump Increment in standard units/hr " : { + + }, + "Standard Lower Threshold" : { + "comment" : "Search term for the standard lower range threshold setting.", + "extractionState" : "stale", + "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:" + } + } + } + }, "start" : { "localizations" : { "bg" : { @@ -281063,6 +283143,10 @@ } } }, + "Start time" : { + "comment" : "Date & time the Temp Target should begin (within the next 24 hours).", + "isCommentAutoGenerated" : true + }, "Start Time" : { "localizations" : { "bg" : { @@ -281193,6 +283277,10 @@ } } }, + "Start time must be within the next 24 hours (now until +24 h)" : { + "comment" : "Error message when the start time is not within the next 24 hours.", + "isCommentAutoGenerated" : true + }, "Started Override \"%@\" successfully." : { "comment" : "Start override with override name", "localizations" : { @@ -282650,7 +284738,7 @@ } } }, - "Home Statistics Range" : { + "Stay Anonymous" : { }, "Stay in open loop (no automated dosing) for a bit to help Trio catch up to keep you safe" : { @@ -283052,6 +285140,7 @@ } }, "Steps through the zoom presets — 6, 12 and 24 hours — keeping the right-hand edge of the window where it is." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -284222,6 +286311,10 @@ } } }, + "Stop Temp Profile" : { + "comment" : "A button that stops a temporary profile.", + "isCommentAutoGenerated" : true + }, "Stop temp target" : { "comment" : "A label displayed as a button to stop a temp target.", "localizations" : { @@ -284743,6 +286836,10 @@ } } }, + "Stop the temporary profile?" : { + "comment" : "A confirmation dialog asking the user to stop the temporary profile.", + "isCommentAutoGenerated" : true + }, "Stop Trio Alarm" : { "comment" : "Title of the intent that stops a Trio critical-alert alarm.", "localizations" : { @@ -285136,6 +287233,14 @@ } } }, + "Stopping a temp profile (or waiting for it to expire) returns you to the indefinite profile whose basal is already on the pump. Revert always targets that anchor — chains of temps can never leave the pump in an unfamiliar state." : { + "comment" : "A description of how to revert to a profile.", + "isCommentAutoGenerated" : true + }, + "Stopping the temp profile failed" : { + "comment" : "A title for an alert that appears when stopping a temporary profile fails.", + "isCommentAutoGenerated" : true + }, "Store not initialized in %@ from %@." : { "localizations" : { "bg" : { @@ -285272,6 +287377,22 @@ } } }, + "Strategy Definition" : { + + }, + "Strength of acce_ISF contribution with negative acceleration." : { + + }, + "Strength of acce_ISF contribution with positive acceleration. Start with 0.02 as initial value." : { + "comment" : "A verbose hint for the `bgAccelISFweight` setting.", + "isCommentAutoGenerated" : true + }, + "Strengthens ISF decrease while glucose accelerates." : { + "comment" : "ISF Weight While BG Accelerates miniHint" + }, + "Strengthens ISF increase while glucose decelarates." : { + "comment" : "ISF Weight While BG Accelerates miniHint" + }, "Style" : { "localizations" : { "bg" : { @@ -285401,6 +287522,9 @@ } } } + }, + "Submit" : { + }, "Submodules" : { "localizations" : { @@ -285792,6 +287916,10 @@ } } }, + "Suggest CSF from ISF & CR at 1pm" : { + "comment" : "A button that suggests a CSF value based on the user's input.", + "isCommentAutoGenerated" : true + }, "Suggested at" : { "comment" : "Headline in suggested pop up (at: at what time)", "extractionState" : "manual", @@ -286581,6 +288709,7 @@ } }, "Super Micro Bolus (SMB)" : { + "comment" : "Navigation-link title for the SMB section in the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -286711,6 +288840,7 @@ } }, "Super-Micro-Bolus" : { + "comment" : "Section header on the SMB Settings screen grouping core SMB enable toggles", "localizations" : { "bg" : { "stringUnit" : { @@ -287627,9 +289757,6 @@ } } }, - "Suspended" : { - "comment" : "Basal delivery suspended on the pump" - }, "Suspending insulin delivery..." : { "comment" : "Text for suspend resume button when insulin delivery is suspending", "extractionState" : "manual", @@ -287768,6 +289895,14 @@ } } }, + "Swipe left on a schedule row to enable, disable, or delete it. Disabled schedules sink to the bottom and don't fire. The Upcoming section on the Profiles screen shows the next two fires across all enabled schedules." : { + "comment" : "A description of how to manage schedules.", + "isCommentAutoGenerated" : true + }, + "Swipe left to edit or delete a profile. Hold, drag and drop to reorder." : { + "comment" : "A description of how to interact with the list of profiles.", + "isCommentAutoGenerated" : true + }, "Swipe left to edit or delete a temporary target preset. Hold, drag and drop to reorder a preset." : { "localizations" : { "bg" : { @@ -288028,6 +290163,10 @@ } } }, + "Swipe left to enable, disable, or delete. Enabled schedules are sorted by next fire; disabled ones sink to the bottom." : { + "comment" : "A description of how to interact with the list of schedules.", + "isCommentAutoGenerated" : true + }, "Swipe left to end snooze." : { "localizations" : { "bg" : { @@ -288417,6 +290556,9 @@ } } } + }, + "Swipe Up on Status Popup" : { + }, "Swissalpine" : { "comment" : "Name of the Swissalpine watchface.", @@ -288549,6 +290691,13 @@ } } }, + "Switch Autosens on/off" : { + "comment" : "Autosens miniHint" + }, + "Switching insulin concentration changes the basal increments that can be delivered by pump. For diluted insulins smaller increments can be used. For high concentrations the increment will be larger than your regular U100 increment - this compulsory needs adjustment!\n\nBe aware that the pump managers will always show U100 as concentration setting, as they on their own currently only deal with U100. The seeting here in Tai will prevail nonetheless!" : { + "comment" : "A description of the insulin concentration settings.", + "isCommentAutoGenerated" : true + }, "Synth" : { "comment" : "Display name for the synth alarm sound.", "localizations" : { @@ -289070,6 +291219,47 @@ } } }, + "Tai as the Trio version of autoISF does not include ActivityTracking." : { + + }, + "Tai collects the app's state on crash, device, iOS and general system info, and a stack trace." : { + "comment" : "Explanation of the data that Tai collects on crash.", + "isCommentAutoGenerated" : true + }, + "Tai does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, setting values, or similar." : { + "comment" : "Privacy policy bullet point 3.", + "isCommentAutoGenerated" : true + }, + "Tai does not track any usage metrics or any other personal data about users other than the used iPhone model and iOS version." : { + "comment" : "Privacy policy text describing the data that Tai collects and how it is used.", + "isCommentAutoGenerated" : true + }, + "Tai specific settings like autoISF or Ketoacidosis Protection need to be done manually after Trio onboarding." : { + "comment" : "A description of the need to configure Tai specific settings after Trio onboarding.", + "isCommentAutoGenerated" : true + }, + "Tai v%@ (%@)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tai v%1$@ (%2$@)" + } + } + } + }, + "Tai v%@ (%@) %@" : { + "comment" : "The version number of Tai, followed by the build number and copyright information.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tai v%1$@ (%2$@) %3$@" + } + } + } + }, "Take a deep breath — you've got this." : { "localizations" : { "bg" : { @@ -289200,6 +291390,14 @@ } } }, + "Tap \"Add Profile\" to start from the currently active profile's settings. First pick a percentage to scale Basal, ISF and CR in one step, then fine-tune any individual setting in the editor that follows." : { + "comment" : "A description of how to create a new profile.", + "isCommentAutoGenerated" : true + }, + "Tap + to schedule an automatic profile switch." : { + "comment" : "A description of the action to take to add a new schedule.", + "isCommentAutoGenerated" : true + }, "Tap a bolus amount, a carb amount, or both, then slide to confirm. Face ID or Touch ID is always required before a bolus is enacted." : { "comment" : "A description of how to use the quick-pick treatments feature.", "localizations" : { @@ -289461,6 +291659,9 @@ } } }, + "Tap an entry row for details." : { + "comment" : "Text prompting user to tap an entry row for details" + }, "Tap and hold a bar in the chart to reveal more details. Swipe to scroll through time." : { "localizations" : { "bg" : { @@ -289980,6 +292181,15 @@ } } } + }, + "Tap Glucose or Pump Panel" : { + + }, + "Tap Loop Pill" : { + + }, + "Tap Tai Button" : { + }, "Tap the button below to open your Nightscout instance in your iPhone's default browser." : { "localizations" : { @@ -290371,6 +292581,9 @@ } } }, + "Tap to set" : { + "comment" : "Placeholder on activation duration row when the user hasn't chosen hours or minutes yet" + }, "tapped save schedules" : { "extractionState" : "manual", "localizations" : { @@ -290769,6 +292982,7 @@ } }, "Target Behavior" : { + "comment" : "Navigation-link title for the Target Behavior section in the draft profile editor", "localizations" : { "bg" : { "stringUnit" : { @@ -291166,6 +293380,7 @@ } }, "Target glucose is out of range (%@)." : { + "comment" : "Validation error on Add Override form when target glucose is outside allowed range — interpolated value is the unit-dependent range\nValidation error on Edit Override form when target glucose is outside allowed range — interpolated value is the unit-dependent range", "localizations" : { "bg" : { "stringUnit" : { @@ -291295,6 +293510,10 @@ } } }, + "Target glucose value in the unit configured in Trio (mg/dL or mmol/L)" : { + "comment" : "Label for the target glucose value parameter in the Create Custom Temp Target intent.", + "isCommentAutoGenerated" : true + }, "Target presets" : { "comment" : "Debug option view Target presets", "extractionState" : "manual", @@ -291571,6 +293790,18 @@ } } }, + "Target value" : { + "comment" : "Label for the target glucose value parameter in the Create Custom Temp Target intent.", + "isCommentAutoGenerated" : true + }, + "Target value must be positive" : { + "comment" : "Error message displayed when the target glucose value is not positive.", + "isCommentAutoGenerated" : true + }, + "Target value?" : { + "comment" : "Label for the target glucose value input field in the Create Custom Temp Target intent.", + "isCommentAutoGenerated" : true + }, "TargetGlucose - 0.5 × (TargetGlucose - 40)" : { "localizations" : { "bg" : { @@ -291961,8 +294192,14 @@ } } }, + "TDD 24h" : { + + }, + "TDD: %@" : { + "comment" : "Tag chip in Loop status popup — total daily dose" + }, "Temp Basal" : { - "comment" : "Treatment label in history for temporary basal\nTreatment type", + "comment" : "Treatment history type label — temporary basal\nTreatment label in history for temporary basal\nTreatment type", "localizations" : { "bg" : { "stringUnit" : { @@ -292229,6 +294466,37 @@ } } }, + "Temp Target '%@' applied for %lld min" : { + "comment" : "Text displayed in a dialog after a Temp Target is successfully created and applied. The first argument is the name of the Temp Target. The second argument is the duration of the Temp Target.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Temp Target '%1$@' applied for %2$lld min" + } + } + } + }, + "Temp Target '%@' failed" : { + "comment" : "A description of an error that might occur when creating a custom Temp Target.", + "isCommentAutoGenerated" : true + }, + "Temp Target '%@' scheduled for %@, %lld min" : { + "comment" : "A confirmation dialog for a successful Temp Target schedule.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Temp Target '%1$@' scheduled for %2$@, %3$lld min" + } + } + } + }, + "Temp target preset not found." : { + "comment" : "Watch command ack" + }, "Temp Target Presets" : { "localizations" : { "bg" : { @@ -292359,6 +294627,12 @@ } } }, + "Temp target started." : { + "comment" : "Watch command ack" + }, + "Temp target stopped." : { + "comment" : "Watch command ack" + }, "Temp Target to Stop" : { "localizations" : { "bg" : { @@ -292765,6 +295039,28 @@ } } }, + "Temporary — active for %@, auto-reverts at %@." : { + "comment" : "Body of schedule-fire notification for a timed activation — first interpolated value is a duration like '3 hr 15 min', second is a clock time", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Temporary — active for %1$@, auto-reverts at %2$@." + } + } + } + }, + "Temporary (up to 24 h): runs for the chosen duration, then reverts to the anchor (your last indefinite profile). Trio and the pump manager deliver the profile's basal rates live — the pump's saved basal schedule is NOT changed. If pump connectivity drops, the pump falls back to its last saved schedule." : { + "comment" : "A description of a temporary profile.", + "isCommentAutoGenerated" : true + }, + "Temporary activation: an informational notification confirms the switch (\"Profile X activated — auto-reverts at hh:mm\"). No action required." : { + + }, + "Temporary activations don't overwrite the pump's saved schedule — Trio and the pump manager deliver the temp profile's rates live. That's what lets the temp auto-revert cleanly and also protects you if pump connectivity drops (the pump falls back to its last saved schedule)." : { + "comment" : "A description of how temporary profile activations interact with the pump.", + "isCommentAutoGenerated" : true + }, "Temporary Basal" : { "comment" : "Navigation Title for ManualTempBasalEntryView", "extractionState" : "manual", @@ -293561,6 +295857,18 @@ } } }, + "Temporary Target '%@' scheduled for %@" : { + "comment" : "Confirmation dialog text when a Temp Target is successfully scheduled.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Temporary Target '%1$@' scheduled for %2$@" + } + } + } + }, "Temporary Target canceled" : { "localizations" : { "bg" : { @@ -293820,6 +296128,12 @@ } } } + }, + "TempTarget Level for B30" : { + "comment" : "B30 TT Level" + }, + "Thank you. Your report ID is %@. Mention it if you ask about this on Discord or Facebook." : { + }, "The Adjustment Factor (AF) allows you to control how quickly and effectively Dynamic ISF responds to changes in glucose levels." : { "localizations" : { @@ -294477,6 +296791,9 @@ } } } + }, + "The bar height grows with the dose or carb amount, making it easier to spot large entries at a glance." : { + }, "The bolus cannot be larger than the pump setting max bolus (%@)." : { "localizations" : { @@ -294870,6 +297187,10 @@ } } }, + "The CSF profile has a single purpose: to re-calculate your Carb Ratio (CR) from your Insulin Sensitivity Factor (ISF) using the formula: CR = ISF / CSF. It is not used directly by the algorithm or anything else." : { + "comment" : "A description of the carb sensitivity factor.", + "isCommentAutoGenerated" : true + }, "The current version has a critical issue and should be updated as soon as possible." : { "comment" : "Message for critical update alert", "localizations" : { @@ -296194,6 +298515,7 @@ } }, "The duration of insulin action (DIA) is now locked to Trio’s new default of 10 hours." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -296323,6 +298645,10 @@ } } }, + "The duration of insulin action (DIA) is now reset to Trio’s new default of 10 hours." : { + "comment" : "A reminder that the duration of insulin action (DIA) has been reset to Trio’s new default of 10 hours.", + "isCommentAutoGenerated" : true + }, "The external bolus cannot be larger than 3 x the pump setting max bolus (%@)." : { "localizations" : { "bg" : { @@ -297625,6 +299951,12 @@ } } } + }, + "The insulin concentration can only be changed if you change the insulin in your pump. To make that sure for every pump model, you will have to delete the current pump, change the Insulin Concentration and add your pump again." : { + + }, + "The insulin concentration is given in Insulin Units per mL. The standard is U100 with 100 U/mL." : { + }, "The lowest forecasted glucose during the remaining duration of insulin action (DIA)." : { "localizations" : { @@ -298536,6 +300868,10 @@ } } }, + "The normal case is to pick the strongest factor as the one and only factor to be applied. Here autosense is also part of the game. But how about the exceptions, i.e., when different factors pull in different directions? In order of precedence they are:" : { + "comment" : "A hint that describes how the autoISF is calculated.", + "isCommentAutoGenerated" : true + }, "The order of Fat and Protein inputs has changed." : { "localizations" : { "bg" : { @@ -299194,6 +301530,14 @@ } } }, + "the preset to schedule" : { + "comment" : "The parameter title for the TempPreset parameter in ScheduleTempPresetIntent.", + "isCommentAutoGenerated" : true + }, + "The profile couldn't be saved. Check the name and try again." : { + "comment" : "A message displayed when a user tries to save a profile but it fails.", + "isCommentAutoGenerated" : true + }, "The ratio of how sensitive or resistant to insulin you are in the current loop cycle. Baseline = 1.0, Sensitive < 1.0, Resistant > 1.0" : { "localizations" : { "bg" : { @@ -299453,6 +301797,12 @@ } } } + }, + "The report is too large. Try fewer or smaller screenshots." : { + + }, + "The report won't include your name or contact details." : { + }, "The Sensitivity Ratio is used to calculate the amount of adjustment needed to basal rates and ISF." : { "localizations" : { @@ -299713,6 +302063,9 @@ } } } + }, + "The shorter the interval, the smoother the correlating dosing result." : { + }, "The Sigmoid Adjustment Factor (AF) allows you to control how quickly Sigmoid Dynamic ISF responds to changes in glucose levels and at what glucose value you will reach your Autosens Max and Autosens Min limits." : { "localizations" : { @@ -299973,6 +302326,9 @@ } } } + }, + "The time between new glucose readings (default: 5 minutes). Reducing this will generate readings more frequently." : { + }, "The time it takes to complete one full cycle from high to low and back to high." : { "localizations" : { @@ -300103,6 +302459,12 @@ } } } + }, + "The Trio team's server couldn't take the report right now (error %lld)." : { + + }, + "The Trio team's server sent an answer the app doesn't understand." : { + }, "The Upload Treatments toggle enables uploading of the following data sets to your connected Nightscout URL:" : { "localizations" : { @@ -300234,6 +302596,10 @@ } } }, + "The variable IOB Threshold Percent holds a percentage of the maxIOB which is used as the threshold to disable SMB. If this is enabled by setting it lower than 100%, any sensitivity changes defined by the user are modulated internally into an effective IOB Threshold.\n\nThe new capabilities are:" : { + "comment" : "A description of how the IOB Threshold Percent works.", + "isCommentAutoGenerated" : true + }, "The Warsaw Method helps account for the delayed glucose spikes caused by fat and protein in meals. It uses Fat-Protein Units (FPU) to calculate the carb effect from fat and protein. The system spreads insulin delivery over several hours to mimic natural insulin release, helping to manage post-meal glucose spikes." : { "localizations" : { "bg" : { @@ -300495,6 +302861,13 @@ } } }, + "Therapy Adjustment" : { + "comment" : "Section header for the therapy-adjustment percentage on Add Profile form" + }, + "Therapy and algorithm values will be kept. The profile will no longer show a percent or tuned badge." : { + "comment" : "A message displayed in an alert when the user confirms removing a label from a profile.", + "isCommentAutoGenerated" : true + }, "Therapy Settings" : { "localizations" : { "bg" : { @@ -302330,6 +304703,17 @@ } } }, + "This also means that when calculating meal insulin, your Carb Ratio effectively becomes dynamic: since CR = ISF / CSF and CSF is held constant, CR automatically adjusts as ISF changes throughout the day. Some refer to this as \"dynamic CR\"." : { + "comment" : "A description of how the Carb Ratio is calculated when using a static Carb Ratio.", + "isCommentAutoGenerated" : true + }, + "This build of Trio can't send reports. Please share your logs on Discord or Facebook instead." : { + + }, + "This button re-calculates your Carb Ratio (CR) profile from your Insulin Sensitivity Factor (ISF) and Carb Sensitivity Factor (CSF) profiles using the formula: CR = ISF / CSF." : { + "comment" : "A hint for calculating the Carb Ratio (CR) from the Insulin Sensitivity Factor (ISF) and Carb Sensitivity Factor (CSF) profiles.", + "isCommentAutoGenerated" : true + }, "This cap prevents the system from overestimating how much insulin is needed when carb absorption isn't visible, offering a safeguard for accurate dosing." : { "localizations" : { "bg" : { @@ -303914,6 +306298,9 @@ } } }, + "This feature enables a small safety Temp Basal Rate (TBR) to reduce ketoacidosis risk. Without the Variable Protection, the safety TBR is always applied." : { + "comment" : "Mini-hint for Activate KetoProtection setting" + }, "This feature ensures more accurate insulin adjustments when carb entries are missing or incorrect." : { "localizations" : { "bg" : { @@ -304304,7 +306691,12 @@ } } }, + "This feature smooths your CGM readings to reduce noise and make them easier to read. 2 Algorithms can be used, exponential smoothing is the method used in stock AndroidAPS (AAPS). Alternatively, a more sophisticated method using UKF is adopted from the AAPS Tsunami branch." : { + "comment" : "A longer description of the smooth glucose value setting.", + "isCommentAutoGenerated" : true + }, "This feature smooths your CGM readings to reduce noise and make them easier to read. It is based on a method used in AndroidAPS (AAPS). It uses two approaches: one that reacts quickly to recent changes, and one that looks at longer trends. These are combined to give a balanced result." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -305100,6 +307492,10 @@ } } }, + "This is a classical insulin resistance and is typically caused by free fatty acids which grab available insulin before glucose can. Quite often, users get impatient in such a situation and administer one or even more rage boluses. Again and again, that leads to hypos later which the dura_ISF approach avoids if carefully tuned.\n\nThe strengthening of ISF is stronger the longer the situation lasts and the higher the average glucose is above target:\n\ndura_ISF = 1 + (avg05 - target_bg) / target_bg * dura05 * dura_ISF_weight\n\nwhere:\navg05 = dura_ISF_average\ndura05 = dura_ISF_minutes\n\nThe user can apply his personal weighting by using dura_ISF_weight. Start cautiously with a value of 0.2 and be very careful when you approach 1.5 or even higher. By using 0 this effect is disabled." : { + "comment" : "A flowchart of the DuraISF algorithm.", + "isCommentAutoGenerated" : true + }, "This is a limit on the size of a single SMB. One SMB can only be as large as this many minutes of your current profile basal rate." : { "localizations" : { "bg" : { @@ -305360,6 +307756,10 @@ } } }, + "This is a multiplier cap for autoISF to set a limit on how low the autoISF ratio can be, which in turn determines how high it can adjust ISF." : { + "comment" : "A verbose hint for the autoISF min setting.", + "isCommentAutoGenerated" : true + }, "This is a multiplier cap for autosens (and autotune) to set a 20% max limit on how high the autosens ratio can be, which in turn determines how high autosens can adjust basals, how low it can adjust ISF, and how low it can set the BG target." : { "comment" : "\"Autosens Max\"", "extractionState" : "manual", @@ -306172,6 +308572,13 @@ } } }, + "This is another key OpenAPS safety cap, and specifies what share of the total insulin required can be delivered as SMB." : { + "comment" : "SMB DeliveryRatio miniHint" + }, + "This is important because creating separate CR and ISF profiles implicitly defines an unreviewed CSF profile (calculated as CSF = ISF / CR) that may be incorrect. By explicitly defining your CSF profile, you ensure that CR automatically adjusts correctly throughout the day as your ISF changes, maintaining physiologically accurate insulin dosing for carbohydrates." : { + "comment" : "A description of the importance of the carb sensitivity profile.", + "isCommentAutoGenerated" : true + }, "This is the amount of the maximum number of carbs we’ll assume will absorb over 4h if we don’t yet see carb absorption." : { "comment" : "\"Remaining Carbs Cap\"", "extractionState" : "manual", @@ -306310,8 +308717,12 @@ } } }, + "This is the development version of Tai, built from the dev branch.\n\nAny updates on this branch may contain new, lightly tested features, and may be unsafe. If you are not a tester, please do not use this branch, and switch to main." : { + "comment" : "Body of the warning shown on builds from the dev branch" + }, "This is the development version of Trio, built from the dev branch.\n\nAny updates on this branch may contain new, lightly tested features, and may be unsafe. If you are not a tester, please do not use this branch, and switch to main." : { "comment" : "Body of the warning shown on builds from the dev branch", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -307099,6 +309510,22 @@ } } }, + "This is the original effect of autoISF in action since August 2020. Because autoISF is now a toolbox of several effects, this original effect was renamed dura_ISF. It addresses situations when:" : { + "comment" : "A paragraph of text describing the original effect of autoISF.", + "isCommentAutoGenerated" : true + }, + "This is the share of maxIOB above which autoISF will disable SMB. 100% neutralizes it's effect." : { + "comment" : "autoISF IOB Threshold miniHint" + }, + "This is the weight applied to the linear slope while glucose rises and adapts ISF. With 0 this contribution is effectively disabled. Start with 0.01 - it hardly goes beyond 0.05!" : { + "comment" : "ISF weight for postprandial BG rise miniHint" + }, + "This is the weight applied to the polygon which adapts ISF if glucose is above target." : { + "comment" : "ISF Weight for Higher BGs miniHint" + }, + "This is the weight applied to the polygon which adapts ISF if glucose is below target." : { + "comment" : "ISF Weight for Lower BGs miniHint" + }, "This is useful for slow-absorbing meals where high fat, protein, or fiber can delay carb absorption and cause a slower rise in blood sugar, so a reduced bolus helps match insulin to the delayed effect." : { "localizations" : { "bg" : { @@ -307489,6 +309916,10 @@ } } }, + "This makes CSF independent of short-term ISF changes that occur with features like Dynamic ISF, autoISF, or Autosens." : { + "comment" : "A description of how CSF is calculated when using a profile.", + "isCommentAutoGenerated" : true + }, "This means Trio may not have complete information about how much active insulin or carbs were still on board when you switched over." : { "localizations" : { "bg" : { @@ -308016,6 +310447,9 @@ } } } + }, + "This percentage rescales the three therapy profiles — Basal Rate, Insulin Sensitivity (ISF) and Carb Ratio (CR) — in one step. It's the same mechanism an override uses, baked into the profile so every loop cycle sees the adjusted values." : { + }, "This prevents excessive dosing, especially during times of variable insulin sensitivity, enhancing safety." : { "localizations" : { @@ -308414,6 +310848,9 @@ } } } + }, + "This sets the maximum length of time that Fat and Protein Carb Equivalents (FPUs) will be extended over from a single Fat and/or Protein bolus calcultor entry." : { + }, "This setting adjusts how much weight is given to your recent total daily insulin dose when calculating Dynamic ISF and Dynamic CR." : { "localizations" : { @@ -308936,6 +311373,7 @@ } }, "This setting controls which bolus amount labels are shown on Trio’s main chart. Boluses appear as blue upside-down triangles, with a number showing the amount. Depending on the option you choose, only boluses at or above that amount will show a label. For example, if you choose ‘0.5 U and over’, only boluses of 0.5 U or more will show a label." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -309591,6 +312029,16 @@ } } }, + "This setting is limited to values between %@ - %@ %@." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This setting is limited to values between %1$@ - %2$@ %3$@." + } + } + } + }, "This setting restricts the maximum temporary basal rate Trio can set. At the default of 300%, it caps it at 3 times your highest programmed basal rate." : { "localizations" : { "bg" : { @@ -309721,6 +312169,9 @@ } } }, + "This specifies by what factor you can exceed the limit of 180 maxSMB/maxUAM minutes." : { + "comment" : "SMB Max RangeExtension miniHint" + }, "This Temp Target preset is currently running. Deleting will stop it." : { "localizations" : { "bg" : { @@ -309851,8 +312302,12 @@ } } }, + "This version of Tai was built from '%@', not the released main branch.\n\nAny updates on this branch may contain new, lightly tested features, and may be unsafe. If you are not a tester, please do not use this branch, and switch to main." : { + "comment" : "Body of the warning shown on builds from a branch other than main or dev; the placeholder is the branch name" + }, "This version of Trio was built from '%@', not the released main branch.\n\nAny updates on this branch may contain new, lightly tested features, and may be unsafe. If you are not a tester, please do not use this branch, and switch to main." : { "comment" : "Body of the warning shown on builds from a branch other than main or dev; the placeholder is the branch name", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -310517,6 +312972,10 @@ } } }, + "threshold = minBG − (1 − ratio) × (minBG − 40)" : { + "comment" : "Formula for calculating the SMB threshold.", + "isCommentAutoGenerated" : true + }, "Tidepool" : { "localizations" : { "bg" : { @@ -310778,7 +313237,7 @@ } }, "Time" : { - "comment" : "Time basal profile", + "comment" : "Label for Time", "localizations" : { "bg" : { "stringUnit" : { @@ -311039,6 +313498,7 @@ } }, "Time in Normoglycemia (TING)" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -311169,6 +313629,7 @@ } }, "Time in Normoglycemia (TING):" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -311560,6 +314021,7 @@ } }, "Time in Range Type" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -311690,6 +314152,7 @@ } }, "Time in Tight Range (TITR)" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -311820,6 +314283,7 @@ } }, "Time in Tight Range (TITR):" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -312478,6 +314942,10 @@ } } }, + "Timed (up to 24 h): applied until the duration expires, then auto-reverts to the indefinite baseline. Trio and the pump manager deliver the timed profile's basal rates live — the pump's saved basal schedule is NOT changed. If pump connectivity drops, the pump falls back to its last saved schedule." : { + "comment" : "A description of how a timed profile works.", + "isCommentAutoGenerated" : true + }, "Tip: Decreasing this value allows automatic adjustments of basal rates to be lower and ISF to be higher." : { "localizations" : { "bg" : { @@ -313388,6 +315856,14 @@ } } }, + "Tip: Start with a single CSF value for the entire day. Look at your current ISF and CR profile values at a time of day where you feel most confident they are working well — usually around midday — and calculate CSF = ISF / CR from those values." : { + "comment" : "A tip for creating a carb sensitivity factor (csf) profile.", + "isCommentAutoGenerated" : true + }, + "Tip: Start with a single CSF value for the entire day. Look at your current ISF and CR values at a time of day where you feel most confident they are working well — usually around midday — and calculate CSF = ISF / CR from those values." : { + "comment" : "A tip for calculating the Carb Sensitivity Factor (CSF).", + "isCommentAutoGenerated" : true + }, "Tip: You may find that your normal carb ratio needs to increase to a larger number when you begin adding fat and protein entries. For this reason, it is best to start with a factor of about 50%." : { "localizations" : { "bg" : { @@ -313518,6 +315994,42 @@ } } }, + "TIR (%@-%@)" : { + "comment" : "Time in Range with the selected lower threshold and the upper threshold.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "TIR (%@-%@)" + } + } + } + }, + "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" + } + } + } + }, + "Title (optional)" : { + + }, + "TITR (%@-%@)" : { + "comment" : "Time in Tight Range with either the Standard or Extended lower threshold.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "TITR (%@-%@)" + } + } + } + }, "To" : { "localizations" : { "bg" : { @@ -314427,6 +316939,9 @@ } } } + }, + "To edit insulin concentration, you must first remove the pump." : { + }, "To enable or configure Warsaw Method application in Trio, go to Settings > Features > Meal Settings." : { "localizations" : { @@ -315469,6 +317984,9 @@ } } } + }, + "To understand the Variable Keto Protection Strategy, read up on IOB and active Insulin (activity):" : { + }, "today" : { "comment" : "Stats banner scope", @@ -315871,7 +318389,6 @@ }, "Today's Averages" : { "comment" : "Home stats panel face option", - "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -316394,6 +318911,9 @@ } } }, + "Too many requests in progress. Please try again." : { + "comment" : "Watch command ack" + }, "Top target" : { "comment" : "Upper temp target limit", "extractionState" : "manual", @@ -317848,6 +320368,9 @@ } } } + }, + "Touch and Hold" : { + }, "Tracks the carbohydrates you eat, entered to guide insulin dosing." : { "localizations" : { @@ -320609,6 +323132,9 @@ } } } + }, + "Trio couldn't confirm with Apple that this copy of the app is genuine. Please try again later." : { + }, "Trio Docs" : { "localizations" : { @@ -320741,6 +323267,7 @@ } }, "Trio does not collect any health related data, e.g. glucose readings, insulin rates or doses, meal data, therapy setting values, or similar." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -321131,6 +323658,7 @@ } }, "Trio has not looped in %lld minutes." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -321261,6 +323789,7 @@ } }, "Trio has not run in %lld minutes." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -322041,6 +324570,7 @@ } }, "Trio is designed to help manage your diabetes efficiently. To get the most out of the app, we'll guide you through setting up some essential parameters." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -322170,6 +324700,10 @@ } } }, + "Trio is designed to help manage your diabetes efficiently. To get the most out of the app, we'll guide you through setting up some essential Trio parameters. Tai specific settings need to be done manually after Trio onboarding." : { + "comment" : "A description of the welcome step.", + "isCommentAutoGenerated" : true + }, "Trio is holding sensitivity at 100% and ignoring these settings." : { "localizations" : { "bg" : { @@ -324775,6 +327309,9 @@ } } } + }, + "Trio uses whichever is higher: the Minimum Safety Threshold above or the ratio-derived floor below. So this ratio only takes effect when ratioFloor exceeds the Minimum Safety Threshold." : { + }, "Trio v%@" : { "localizations" : { @@ -324907,6 +327444,7 @@ } }, "Trio v%@ (%@)" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -326341,6 +328879,12 @@ } } } + }, + "Triple Tap" : { + + }, + "Try Again" : { + }, "Try again in a moment, or configure your Therapy Settings manually instead." : { "localizations" : { @@ -326603,6 +329147,9 @@ } } }, + "TTs" : { + "comment" : "Selected Tab — short for Temp Targets" + }, "Turn this setting on to give basal adjustments more agility. Keep this setting off if your basal needs are not highly variable." : { "localizations" : { "bg" : { @@ -327254,6 +329801,46 @@ } } }, + "Typical: 0.1 " : { + "comment" : "A label for the typical value of `bgAccelISFweight`.", + "isCommentAutoGenerated" : true + }, + "Typical: 0.07 " : { + "comment" : "A label for the typical value of the BG Brake ISF weight.", + "isCommentAutoGenerated" : true + }, + "Typical: %@ %@" : { + "comment" : "A description of the algorithm's \"SMB delivery ratio\" feature, which is a multiplier for the amount of insulin delivered as a scheduled bolus.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Typical: %1$@ %2$@" + } + } + } + }, + "Typical: 0.02" : { + "comment" : "A label for the typical value of the `postMealISFweight` setting.", + "isCommentAutoGenerated" : true + }, + "Typical: 0.4" : { + "comment" : "A label for the typical value of the `higherISFrangeWeight` setting.", + "isCommentAutoGenerated" : true + }, + "Typical: 0.6" : { + "comment" : "A label that describes the weight of the ISF adaptation for low BGs.", + "isCommentAutoGenerated" : true + }, + "Typical: 0.7" : { + "comment" : "A label for the typical value of the autoISF min setting.", + "isCommentAutoGenerated" : true + }, + "Typical: 2" : { + "comment" : "A hint for the typical value of the autoISF Max setting.", + "isCommentAutoGenerated" : true + }, "U" : { "comment" : "Insulin unit\nInsulin unit abbreviation\nInsulin unit abbreviation shown next to a quick-pick bolus amount\nThe short unit display string for international units of insulin\nUnits for bolus amount", "localizations" : { @@ -327516,7 +330103,7 @@ } }, "U/hr" : { - "comment" : "Insulin unit per hour abbreviation", + "comment" : "Insulin unit per hour abbreviation\nUnits text for temporary basal rate", "localizations" : { "bg" : { "stringUnit" : { @@ -327645,6 +330232,15 @@ } } } + }, + "U%lld" : { + + }, + "U50 or other diluted insulins will lead to more insulin volume being pumped. So it is essential to be aware of the setting that concentrated or especially diluted insulin is used." : { + + }, + "U50 or other diluted insulins will lead to more insulin volume being pumped. Using a diluted Insulin concentration will inject larger volumes. If not set correctly potential errors can lead to severe overdosing of insulin." : { + }, "UAM" : { "localizations" : { @@ -330422,6 +333018,9 @@ } } }, + "Unscented Kalman Filter" : { + "comment" : "UKF smoothing algorithm display name" + }, "Unsuspend If No Temp" : { "comment" : "Unsuspend If No Temp", "localizations" : { @@ -330682,6 +333281,12 @@ } } } + }, + "until next change" : { + "comment" : "Schedule duration summary — indefinite/until-next activation\nUpcoming-schedule row duration — indefinite or until-next activation" + }, + "Up to %lld screenshots or photos." : { + }, "up to date" : { "comment" : "Accessibility: version status", @@ -330814,6 +333419,10 @@ } } }, + "Upcoming" : { + "comment" : "A heading for the upcoming schedules.", + "isCommentAutoGenerated" : true + }, "update available" : { "comment" : "Accessibility: version status", "localizations" : { @@ -332925,6 +335534,16 @@ } } }, + "Upper BG Limit for B30" : { + "comment" : "B30 Upper BG Limit" + }, + "Upper Delta Limit for B30" : { + "comment" : "B30 Upper Delta" + }, + "Upper end of the linearly increasing SMB delivery ratio. Reached once glucose sits at BG target plus SMB DeliveryRatio BG Range — i.e. the maximum share of insulinReq delivered as SMB when glucose is deep above target." : { + "comment" : "A verbose hint for the `SMB DeliveryRatio Max` setting.", + "isCommentAutoGenerated" : true + }, "Upper limit of the Autosens Ratio." : { "localizations" : { "bg" : { @@ -333056,6 +335675,7 @@ } }, "Upper limit of the Sensitivity Ratio." : { + "comment" : "Mini-hint for Autosens Max setting", "localizations" : { "bg" : { "stringUnit" : { @@ -333711,6 +336331,7 @@ } }, "Use \"What's sent\" above to inspect the exact JSON payload before deciding." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -334114,6 +336735,9 @@ } } } + }, + "Use Bars for Bolus and Carbs" : { + }, "Use calendar events to display current data." : { "localizations" : { @@ -334644,7 +337268,11 @@ } } }, + "Use Dynamic ISF" : { + "comment" : "Row label for the Use Dynamic ISF toggle" + }, "Use Dynamic Ratio to adjust basal rates." : { + "comment" : "Mini-hint for Dynamic-ISF Adjust-Basal toggle", "localizations" : { "bg" : { "stringUnit" : { @@ -335041,6 +337669,9 @@ } } }, + "Use Profile CSF" : { + "comment" : "Use Profile CSF" + }, "Use Sigmoid Function" : { "comment" : "Headline Use Sigmoid Function", "extractionState" : "manual", @@ -335179,6 +337810,9 @@ } } }, + "Use the sigmoid dynISF formula instead of logarithmic." : { + "comment" : "Mini-hint for the Sigmoid (vs logarithmic) Dynamic-ISF formula toggle" + }, "Use this to check whether your basal rates hold you steady. Trio makes no changes unless your glucose goes low. Then it stops your basal until you come back up.\nTrio also stops adjusting for sensitivity, so you see your own settings at work. Turning this on cancels any temp basal Trio set. Use it for short, planned times." : { "localizations" : { "bg" : { @@ -335439,6 +338073,14 @@ } } }, + "Used above target, strengthens ISF the more the higher this weight is. 0 disables this contribution, i.e., ISF is constant in the whole range above target.\n\nStart with a weight of 0.2 and observe the reactions.\n\nThere are indicators that higher glucose needs stronger ISF. This was evident from all the successful AAPS users defining automation rules which strengthen the profile at higher glucose levels. The drawback is that there are sudden jumps in ISF at switch points and no further or minor adaptations in between.\n\nIn autoISF a polygon is provided that defines a relationship between glucose and ISF and interpolates in between. This is currently hard coded but the user can apply weights to easily strengthen or weaken it in order to fit personal needs. In principle the polygon itself can be edited and the apk rebuilt if a different shape is required. Developing a GUI for that purpose was considered very tedious especially before knowing whether the results warrant the effort. With this approach you could even approximate the formula well enough that is used in DynamicISF for the ISF dependency on glucose.\n\nThere is a special case possible, namely below target i.e. when bg_ISF < 1. ISF will be weakened and there is no point in checking the remaining effects. Only with positive acceleration the weakening will be less pronounced as that is a sign of rising glucose to come soon." : { + "comment" : "A paragraph of text that describes the effect of the ISF weight on the ISF profile.", + "isCommentAutoGenerated" : true + }, + "Used below target, weakens ISF the more the higher this weight is. 0 disables this contribution, i.e., ISF is constant in the whole range below target. This weight is less critical as the loop is probably running at Temp basal Rate = 0 anyway and you can start around 0.2." : { + "comment" : "A description of the effect of the `lowerISFrangeWeight` parameter on the `ISF`.", + "isCommentAutoGenerated" : true + }, "User Interface" : { "localizations" : { "bg" : { @@ -335699,8 +338341,20 @@ } } }, + "Uses the established lower threshold of %@." : { + "comment" : "Standard lower range threshold description", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uses the established lower threshold of %@." + } + } + } + }, "Uses the fairly established Time in Tight Range definition, which is defined as time between %@ and %@ %@." : { "comment" : "Time in Tight Range (TITR) verbose hint description", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -335966,8 +338620,20 @@ } } }, + "Uses the newer Time in Normoglycemia (TING) lower threshold of %@." : { + "comment" : "Extended lower range threshold description", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uses the newer Time in Normoglycemia (TING) lower threshold of %@." + } + } + } + }, "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 (%@ %@) and %@ %@." : { "comment" : "Time in Normoglycemia (TING) verbose hint description", + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -336640,6 +339306,10 @@ } } }, + "value" : { + "comment" : "Value of the y-axis", + "isCommentAutoGenerated" : true + }, "Value" : { "localizations" : { "bg" : { @@ -337030,6 +339700,26 @@ } } }, + "Values >100% = less sensitive, more insulin nedded, Basal Rates profile is lifted, ISF and CR profiles decrease. 100% preserves current Therapy Settings." : { + "comment" : "Mini-hint beneath the therapy-adjustment percentage row on Add Profile form" + }, + "Values above 0.5 make SMBs more aggressive at high glucose. Raise with care; the ratio climbs linearly from SMB DeliveryRatio BG Minimum up to this value." : { + "comment" : "A longer version of the SMB DeliveryRatio Maximum hint.", + "isCommentAutoGenerated" : true + }, + "Values shown are adjusted for your insulin concentration. The pump receives the correct physical delivery rates." : { + + }, + "Variable KetoProtect" : { + + }, + "Variable SMB DelRatio" : { + "comment" : "A label for a section of the Add Override form that allows the user to set a variable SMB delivery ratio.", + "isCommentAutoGenerated" : true + }, + "Variable Strategy" : { + "comment" : "Variable Keto Protection" + }, "Very High (>%@)" : { "localizations" : { "bg" : { @@ -337551,6 +340241,16 @@ } } }, + "Very neat feature that allows the use of profile and temporary targets to trigger SMB's being enabled or disabled. So a profile target at 3:00 am of %@ %@ will prevent any SMB's in that time window. Schedule a TT of %@ %@ at 3:20 am and from then on SMB's can be enacted." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Very neat feature that allows the use of profile and temporary targets to trigger SMB's being enabled or disabled. So a profile target at 3:00 am of %1$@ %2$@ will prevent any SMB's in that time window. Schedule a TT of %3$@ %4$@ at 3:20 am and from then on SMB's can be enacted." + } + } + } + }, "Via Watch" : { "comment" : "Note added to carb entry when entered via watch", "localizations" : { @@ -338345,6 +341045,9 @@ } } }, + "warming up" : { + "comment" : "Accessibility: sensor lifecycle tag" + }, "Warning when bolusing with a very low or forecasted very low glucose." : { "localizations" : { "bg" : { @@ -339913,6 +342616,12 @@ } } }, + "Watch command settings changed." : { + "comment" : "Watch command ack" + }, + "Watch commands are disabled." : { + "comment" : "Watch command ack" + }, "Watch Configuration" : { "extractionState" : "manual", "localizations" : { @@ -340967,7 +343676,17 @@ } } }, + "Weekdays" : { + "comment" : "Schedule repeat summary — Monday through Friday" + }, + "Weekends" : { + "comment" : "Schedule repeat summary — Saturday and Sunday" + }, + "Weekly on %@" : { + "comment" : "Schedule repeat summary prefix for a custom weekly pattern — interpolated list is comma-separated weekday abbreviations" + }, "Weight of 24-hr TDD against 10-day TDD." : { + "comment" : "Mini-hint for Dynamic ISF Weighted-Average-of-TDD setting", "localizations" : { "bg" : { "stringUnit" : { @@ -341098,6 +343817,7 @@ } }, "Weighted Average of TDD" : { + "comment" : "Row label for the Dynamic ISF Weighted-Average-of-TDD setting", "localizations" : { "bg" : { "stringUnit" : { @@ -341371,6 +344091,10 @@ "Weighted hypoglycemia" : { "comment" : "Hypoglycemia component multiplied by its GRI weight." }, + "Welcome to Tai - an automated insulin delivery system for iOS based on Trio using the OpenAPS algorithm with autoISF and other adaptations." : { + "comment" : "A welcome message to the user.", + "isCommentAutoGenerated" : true + }, "Welcome to Trio" : { "localizations" : { "bg" : { @@ -341502,6 +344226,7 @@ } }, "Welcome to Trio — an automated insulin delivery system for iOS based on the OpenAPS algorithm with adaptations." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -341631,6 +344356,18 @@ } } }, + "What is a Profile?" : { + "comment" : "A heading for the description of a profile.", + "isCommentAutoGenerated" : true + }, + "What is a Schedule?" : { + "comment" : "A heading for the definition of a schedule.", + "isCommentAutoGenerated" : true + }, + "What is CSF?" : { + "comment" : "A heading for the definition of CSF.", + "isCommentAutoGenerated" : true + }, "What is sent" : { "localizations" : { "bg" : { @@ -342933,7 +345670,24 @@ } } }, + "When all 4 effects are configured, how to deduce an end result?" : { + "comment" : "A hint for the user on how to deduce an end result when all 4 effects are configured.", + "isCommentAutoGenerated" : true + }, + "When autoISF is turned off Autosens will always be activated and on HomwView, the Autosens Sensitivity Ratio (AS) will be shown instead of the autoISF Ratio (aiSR)" : { + "comment" : "A longer description of the Autosens Sensitivity Ratio (AS) displayed in the HomeView when autoISF is disabled.", + "isCommentAutoGenerated" : true + }, + "When autoISF is turned on the autoISF Ratio (aiSR) will be displayed on Homeview, showing the final ISF / Sensitivity adaption, instead of the regular Autosens Sensitivity Ratio (AS)" : { + "comment" : "A hint that describes how the autoISF Ratio is displayed on the Home view.", + "isCommentAutoGenerated" : true + }, + "When changing TO or FROM diluted insulins, please check whether you want to decrease (TO) or increase (FROM):" : { + "comment" : "A description of the insulin concentration picker.", + "isCommentAutoGenerated" : true + }, "When did you eat ?" : { + "comment" : "Request meal time", "localizations" : { "bg" : { "stringUnit" : { @@ -343062,6 +345816,12 @@ } } } + }, + "When disabled, the bobble only shows the glucose reading, trend arrow, and delta." : { + + }, + "When enabled, boluses and SMBs are drawn as blue bars hanging down from above the glucose curve, and carbs as orange bars rising up from below." : { + }, "When enabled, long-pressing the + button on the home screen opens a Quick-Pick Treatments sheet. It suggests up to three bolus amounts and, if you have carb history, up to three carb amounts based on what you typically enter at this time of day, weighted by recency and day type (weekday vs. weekend)." : { "comment" : "A description of the Quick-Pick Treatments setting.", @@ -343194,6 +345954,10 @@ } } }, + "When enabled, oref will calculate CSF (Carb Sensitivity Factor) from your profile's Carb Ratio and profile's Insulin Sensitivity Factor, keeping CSF stable as a profile-based value." : { + "comment" : "A description of CSF, a value that describes how much blood glucose rises per gram of carbohydrate.", + "isCommentAutoGenerated" : true + }, "When enabled, Super Micro Boluses (SMBs) will always be allowed if dosing calculations determine insulin is needed via the SMB delivery method, except when a high Temp Target is set." : { "localizations" : { "bg" : { @@ -343583,6 +346347,9 @@ } } } + }, + "When enabled, the glucose bobble shows CGM sensor status: a lifecycle arc during warmup and the last 48 hours before expiry, an hourglass icon tracking the sensor's remaining time, and compact status views for error states like signal loss or sensor failure." : { + }, "When enabled, the live activity widget on the lock screen will show glucose forecasts. The visual representation will be the same as in the Main view. To change between Cone and Lines, change the setting under Features - User Interface - Forecast Display Type." : { "localizations" : { @@ -344104,6 +346871,14 @@ } } }, + "When should the profile activate?" : { + "comment" : "Date and time the profile should activate.", + "isCommentAutoGenerated" : true + }, + "When should the Temp Target start?" : { + "comment" : "Date & time the Temp Target should begin (within the next 24 hours)", + "isCommentAutoGenerated" : true + }, "When Skip Neutral Temps is enabled, Trio will not set neutral basal rates shortly before the hour, minimizing hourly pump alerts on MDT pumps. This can help light sleepers avoid alerts but will delay basal adjustments. This will also only come into effect if SMB's are disabled for whatever reason." : { "localizations" : { "bg" : { @@ -344624,6 +347399,10 @@ } } }, + "When the even/odd target toggle is enabled, setting an even-numbered temp target below 100 mg/dL (e.g. 80 or 90) puts autoISF into full-loop mode — a signal that you want maximum SMB power. In that mode the SMB delivery ratio is forced to at least the fixed SMB DeliveryRatio, even when the BG-range ramp would otherwise give a lower value." : { + "comment" : "A hint for the full-loop mode of autoISF.", + "isCommentAutoGenerated" : true + }, "When there are carbs on board (COB > 0), enabling this feature allows Trio to use Super Micro Boluses (SMB) to deliver the insulin required." : { "localizations" : { "bg" : { @@ -345828,6 +348607,18 @@ } } }, + "Which preset to schedule?" : { + "comment" : "Title of the dialog to ask the user which preset to schedule.", + "isCommentAutoGenerated" : true + }, + "Which profile do you want to activate?" : { + "comment" : "Request dialog for the profile parameter in the ApplyProfileIntent.", + "isCommentAutoGenerated" : true + }, + "Which profile do you want to schedule?" : { + "comment" : "Request value dialog text for the profile parameter in ScheduleProfileOnceIntent.", + "isCommentAutoGenerated" : true + }, "While onboarding, Trio continues to operate with your prior settings." : { "localizations" : { "bg" : { @@ -346088,7 +348879,16 @@ } } }, + "Whole hours of activation (0–24)" : { + "comment" : "Label for the number of hours of activation.", + "isCommentAutoGenerated" : true + }, + "Why does Tai collect this data?" : { + "comment" : "A heading for the privacy policy.", + "isCommentAutoGenerated" : true + }, "Why does Trio collect this data?" : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -346218,6 +349018,10 @@ } } }, + "Why is CSF not dependent on ISF when this setting is enabled?" : { + "comment" : "A label describing why CSF is not dependent on ISF when the CSF is based on a profile.", + "isCommentAutoGenerated" : true + }, "Wide BG Target Range" : { "comment" : "Headline \"Wide BG Target Range\"", "extractionState" : "manual", @@ -346882,6 +349686,9 @@ } } } + }, + "With the default ratio of 0.5 this collapses to TargetGlucose − 0.5 × (TargetGlucose − 40), i.e. the midpoint between target and 40 mg/dL. Trio always uses whichever is higher: this Minimum Safety Threshold or that ratio-derived floor." : { + }, "With this option enabled, the SMB algorithm can recognize unannounced meals. This is helpful, if you forget to tell Trio about your carbs or estimate your carbs wrong and the amount of entered carbs is wrong or if a meal with lots of fat and protein has a longer duration than expected. Without any carb entry, UAM can recognize fast glucose increasments caused by carbs, adrenaline, etc, and tries to adjust it with SMBs. This also works the opposite way: if there is a fast glucose decreasement, it can stop SMBs earlier." : { "comment" : "\"Enable UAM\"", @@ -347020,6 +349827,13 @@ } } } + }, + "With this setting enabled, CSF is calculated using your static profile ISF value instead, ensuring CSF remains constant and predictable, unaffected by temporary ISF adjustments." : { + "comment" : "A description of how CSF is calculated when the \"Use Profile CSF\" setting is enabled.", + "isCommentAutoGenerated" : true + }, + "With v3.01 the following 5 settings were withdrawn because over time it proved they were not really necessary. One direct impact is a flatter menu structure for the remaining settings:" : { + }, "𝒳 = Max SMB Basal Minutes" : { "localizations" : { @@ -347679,6 +350493,10 @@ } } }, + "You also have to redo the Tai specific algorithm settings in Settings > Algorithm > Extensions!" : { + "comment" : "A note that the user has to redo the algorithm settings in the Tai app.", + "isCommentAutoGenerated" : true + }, "You can change these permissions any time in the iOS Settings app." : { "localizations" : { "bg" : { @@ -347809,6 +350627,10 @@ } } }, + "You can change these settings in Therapy > Units and Limits after saving the new concentration and after you have added a pump!" : { + "comment" : "A description of the settings that can be changed when using diluted insulin.", + "isCommentAutoGenerated" : true + }, "You can connect Trio to seamlessly upload and manage your diabetes data on Tidepool." : { "localizations" : { "bg" : { @@ -348848,6 +351670,9 @@ } } } + }, + "You've sent several reports today. Please try again tomorrow." : { + }, "You've successfully completed the initial setup of Trio. Tap 'Get Started' to save your settings and start using Trio." : { "localizations" : { @@ -350416,6 +353241,15 @@ } } } + }, + "Your Logs" : { + + }, + "Your logs are too large to send. Turn off Attach My Logs and share them on Discord or Facebook instead." : { + + }, + "Your logs couldn't be prepared. Try again, or turn off Attach My Logs." : { + }, "Your most-used amounts at similar times on similar days. Tap a bolus, a carb amount, or both." : { "comment" : "Subtitle of the quick-pick treatments pill rows", @@ -351236,6 +354070,7 @@ "comment" : "GRI chart legend: risk zone E and its score range." }, "Zooms continuously, between one hour and a full day on screen." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -351624,7 +354459,10 @@ } } } + }, + "μL/hr" : { + } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/Trio/Sources/Logger/Store/AppLogStore.swift b/Trio/Sources/Logger/Store/AppLogStore.swift index 547acb8fd0..865952d64e 100644 --- a/Trio/Sources/Logger/Store/AppLogStore.swift +++ b/Trio/Sources/Logger/Store/AppLogStore.swift @@ -466,7 +466,7 @@ final class AppLogStore: LogSink, @unchecked Sendable { )) ?? [] let newestFirst = files .filter { $0.pathExtension == "zip" } - .map { ($0, (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)) } + .map { ($0, try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) } .sorted { ($0.1 ?? .distantPast, $0.0.lastPathComponent) > ($1.1 ?? .distantPast, $1.0.lastPathComponent) } for (file, _) in newestFirst.dropFirst(Self.legacyZipRetention) { try? FileManager.default.removeItem(at: file) From d0b62df51d46cb576fdb265cf9a1ad63c47a32a1 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Sat, 7 Nov 2026 13:55:43 -0500 Subject: [PATCH 21/34] Stop the watch from sending a 0 U bolus after the phone reconnects The watch reset bolusAmount, carbsAmount and confirmationProgress every time the phone became reachable again, which happens on each wrist raise. A user on the confirm screen who lowered and raised their wrist had their amount replaced with 0 and confirmed a 0 U bolus. The phone's enactBolus returned without calling back for amounts of 0 or less, so the watch never got an answer, and the Processing screen's 5 s fallback never started because showCommsAnimation was already true when the view appeared. Watch: - Reachability changes no longer touch entries in progress; the main view still resets them when it appears. - sendBolusRequest refuses amounts of 0 or less with a visible message. - Every treatment request shows "iPhone not reachable. Nothing was sent." instead of an empty screen when the phone is unreachable. - WatchState times out a missing answer after 15 s, and on a send error, with a message to check Trio on the iPhone (a send error does not prove the request was not delivered). The view-level timer is removed. Phone: - enactBolus answers every caller with a failure for amounts of 0 or less, or amounts that round to 0 on the pump. Tests: - Watch: reconnect keeps the bolus entry, 0 U and negative boluses are refused, unreachable requests say nothing was sent, the timeout fires and is cancelled by an answer, a send error ends processing. - Phone: enactBolus calls back once with a failure for 0, negative and NaN. - Bring the watch test target back to compiling: drop assertions on members that no longer exist (isBolusCanceled, maxCOB, bolus cancel) and import SwiftUI instead of SwiftUICore. Co-Authored-By: Claude Opus 5.5 --- .../Views/AcknowledgementPendingView.swift | 13 +- .../WatchState+Requests.swift | 135 +++++++-------- Trio Watch App Extension/WatchState.swift | 71 ++++++-- Trio Watch App Tests/Unit Tests.swift | 160 +++++++++++++----- Trio.xcodeproj/project.pbxproj | 4 + Trio/Sources/APS/APSManager.swift | 16 +- .../Localizations/Main/Localizable.xcstrings | 9 + .../EnactBolusAmountTests.swift | 40 +++++ 8 files changed, 307 insertions(+), 141 deletions(-) create mode 100644 TrioTests/BolusSafetyTests/EnactBolusAmountTests.swift diff --git a/Trio Watch App Extension/Views/AcknowledgementPendingView.swift b/Trio Watch App Extension/Views/AcknowledgementPendingView.swift index 22e951844e..b3218c35a3 100644 --- a/Trio Watch App Extension/Views/AcknowledgementPendingView.swift +++ b/Trio Watch App Extension/Views/AcknowledgementPendingView.swift @@ -43,17 +43,8 @@ struct AcknowledgementPendingView: View { .navigationBarBackButtonHidden(true) .toolbar(.hidden) .background(trioBackgroundColor) - .onChange(of: state.showCommsAnimation) { oldValue, newValue in - if newValue && !oldValue { - DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - // If after 5 seconds there is still no acknowledgement banner, return to root - if !state.showAcknowledgmentBanner { - // Navigate back to the root - navigationPath.removeLast(navigationPath.count) - } - } - } - } + // WatchState times out a missing answer by showing a banner (beginAwaitingAcknowledgment), so every + // request ends here. A timer in this view never started: the flag is already true when the view appears. .onChange(of: state.showAcknowledgmentBanner) { _, newValue in if !newValue { // Navigate back to the root when acknowledgment banner disappears diff --git a/Trio Watch App Extension/WatchState+Requests.swift b/Trio Watch App Extension/WatchState+Requests.swift index 30799a2c88..330d67538c 100644 --- a/Trio Watch App Extension/WatchState+Requests.swift +++ b/Trio Watch App Extension/WatchState+Requests.swift @@ -8,23 +8,17 @@ extension WatchState { /// - Parameters: /// - amount: The insulin amount to be delivered func sendBolusRequest(_ amount: Decimal) { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.bolus, "Bolus request not sent, phone unreachable") + guard amount > 0 else { + WatchLogger.shared.warning(.bolus, "Bolus of \(amount) U not sent, amount must be above 0") + handleRequestNotSent(Self.invalidBolusAmountMessage) return } - WatchLogger.shared.info(.bolus, "Bolus of \(amount) U requested") + guard let session = reachableSession(.bolus, notSentLog: "Bolus request not sent, phone unreachable") else { return } - let message: [String: Any] = [ - WatchMessageKeys.bolus: amount - ] - - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.bolus, "Sending bolus request failed", error: error) - } + WatchLogger.shared.info(.bolus, "Bolus of \(amount) U requested") - // Display pending communication animation - showCommsAnimation = true + send([WatchMessageKeys.bolus: amount], on: session, .bolus, failureLog: "Sending bolus request failed") } /// Sends a carbohydrate entry request to the paired iPhone @@ -32,10 +26,7 @@ extension WatchState { /// - amount: The amount of carbs in grams /// - date: The timestamp for the carb entry (defaults to current time) func sendCarbsRequest(_ amount: Int, _ date: Date = Date()) { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.treatments, "Carbs request not sent, phone unreachable") - return - } + guard let session = reachableSession(.treatments, notSentLog: "Carbs request not sent, phone unreachable") else { return } WatchLogger.shared.info(.treatments, "Carbs of \(amount) g requested for \(date)") @@ -43,99 +34,91 @@ extension WatchState { WatchMessageKeys.carbs: amount, WatchMessageKeys.date: date.timeIntervalSince1970 ] - - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.treatments, "Sending carbs request failed", error: error) - } - - // Display pending communication animation - showCommsAnimation = true + send(message, on: session, .treatments, failureLog: "Sending carbs request failed") } /// Sends a request to cancel the current override preset to the paired iPhone func sendCancelOverrideRequest() { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.treatments, "Override cancel not sent, phone unreachable") - return - } + guard let session = reachableSession(.treatments, notSentLog: "Override cancel not sent, phone unreachable") + else { return } WatchLogger.shared.info(.treatments, "Override cancel requested") - let message: [String: Any] = [ - WatchMessageKeys.cancelOverride: true - ] - - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.treatments, "Sending override cancel failed", error: error) - } - - // Display pending communication animation - showCommsAnimation = true + send([WatchMessageKeys.cancelOverride: true], on: session, .treatments, failureLog: "Sending override cancel failed") } /// Sends a request to activate an override preset to the paired iPhone /// - Parameter presetName: The name of the override preset to activate func sendActivateOverrideRequest(presetName: String) { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.treatments, "Override request not sent, phone unreachable") - return - } + guard let session = reachableSession(.treatments, notSentLog: "Override request not sent, phone unreachable") + else { return } WatchLogger.shared.info(.treatments, "Override preset \(presetName) requested") - let message: [String: Any] = [ - WatchMessageKeys.activateOverride: presetName - ] - - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.treatments, "Sending override request failed", error: error) - } - - // Display pending communication animation - showCommsAnimation = true + send( + [WatchMessageKeys.activateOverride: presetName], + on: session, + .treatments, + failureLog: "Sending override request failed" + ) } /// Sends a request to cancel the current temporary target to the paired iPhone func sendCancelTempTargetRequest() { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.treatments, "Temp target cancel not sent, phone unreachable") - return - } + guard let session = reachableSession(.treatments, notSentLog: "Temp target cancel not sent, phone unreachable") + else { return } WatchLogger.shared.info(.treatments, "Temp target cancel requested") - let message: [String: Any] = [ - WatchMessageKeys.cancelTempTarget: true - ] - - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.treatments, "Sending temp target cancel failed", error: error) - } - - // Display pending communication animation - showCommsAnimation = true + send( + [WatchMessageKeys.cancelTempTarget: true], + on: session, + .treatments, + failureLog: "Sending temp target cancel failed" + ) } /// Sends a request to activate a temporary target preset to the paired iPhone /// - Parameter presetName: The name of the temporary target preset to activate func sendActivateTempTargetRequest(presetName: String) { - guard let session = session, session.isReachable else { - WatchLogger.shared.warning(.treatments, "Temp target request not sent, phone unreachable") - return - } + guard let session = reachableSession(.treatments, notSentLog: "Temp target request not sent, phone unreachable") + else { return } WatchLogger.shared.info(.treatments, "Temp target preset \(presetName) requested") - let message: [String: Any] = [ - WatchMessageKeys.activateTempTarget: presetName - ] + send( + [WatchMessageKeys.activateTempTarget: presetName], + on: session, + .treatments, + failureLog: "Sending temp target request failed" + ) + } - session.sendMessage(message, replyHandler: nil) { error in - WatchLogger.shared.error(.treatments, "Sending temp target request failed", error: error) + /// The session when the phone can take a request; otherwise tells the user nothing was sent + private func reachableSession(_ category: WatchLogEntry.Category, notSentLog: String) -> WCSession? { + guard let session = session, session.isReachable else { + WatchLogger.shared.warning(category, notSentLog) + handleRequestNotSent(Self.phoneUnreachableMessage) + return nil } + return session + } - // Display pending communication animation - showCommsAnimation = true + /// Sends a treatment request and waits for the phone's acknowledgment + private func send( + _ message: [String: Any], + on session: WCSession, + _ category: WatchLogEntry.Category, + failureLog: String + ) { + beginAwaitingAcknowledgment() + + session.sendMessage(message, replyHandler: nil) { error in + WatchLogger.shared.error(category, failureLog, error: error) + DispatchQueue.main.async { + self.handleMissingAcknowledgment() + } + } } /// Sends a request to calculate a bolus recommendation based on the current carbs amount diff --git a/Trio Watch App Extension/WatchState.swift b/Trio Watch App Extension/WatchState.swift index 25558fb583..1b1f170aa4 100644 --- a/Trio Watch App Extension/WatchState.swift +++ b/Trio Watch App Extension/WatchState.swift @@ -61,6 +61,22 @@ import WatchConnectivity var acknowledgementStatus: AcknowledgementStatus = .pending var acknowledgmentMessage: String = "" var shouldNavigateToRoot: Bool = true + @ObservationIgnored private var acknowledgmentTimeoutWorkItem: DispatchWorkItem? + + /// Long enough for a pump to accept a bolus, short enough that the user isn't left guessing + static let acknowledgmentTimeout: TimeInterval = 15 + static let noAnswerMessage = String( + localized: "No answer from iPhone. Check Trio on your iPhone before trying again.", + comment: "Watch message when the phone did not confirm a request" + ) + static let phoneUnreachableMessage = String( + localized: "iPhone not reachable. Nothing was sent.", + comment: "Watch message when a request could not be sent to the phone" + ) + static let invalidBolusAmountMessage = String( + localized: "Error! Bolus amount must be greater than 0 U.", + comment: "Error message when a bolus of zero or less is requested" + ) // Bolus calculation progress var showBolusCalculationProgress: Bool = false @@ -109,7 +125,37 @@ import WatchConnectivity // MARK: – Handle Acknowledgement Messages FROM Phone + /// Shows "Processing…" and starts the clock for the phone's answer + func beginAwaitingAcknowledgment(timeout: TimeInterval = WatchState.acknowledgmentTimeout) { + acknowledgmentTimeoutWorkItem?.cancel() + acknowledgementStatus = .pending + showCommsAnimation = true + + let workItem = DispatchWorkItem { [weak self] in + self?.handleMissingAcknowledgment() + } + acknowledgmentTimeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: workItem) + } + + /// No answer arrived in time, or sending reported an error. Sending can report an error and + /// still deliver the request, so this tells the user to check the phone rather than claiming + /// the request failed. + func handleMissingAcknowledgment() { + guard showCommsAnimation else { return } + WatchLogger.shared.warning(.treatments, "No answer from the phone for the last request") + handleAcknowledgment(success: false, message: Self.noAnswerMessage) + } + + /// The request was refused on the watch and never sent + func handleRequestNotSent(_ message: String) { + handleAcknowledgment(success: false, message: message) + } + func handleAcknowledgment(success: Bool, message: String, isFinal: Bool = true) { + acknowledgmentTimeoutWorkItem?.cancel() + acknowledgmentTimeoutWorkItem = nil + if success { acknowledgementStatus = .success acknowledgmentMessage = message @@ -251,21 +297,22 @@ import WatchConnectivity /// Called when the reachability status of the paired iPhone changes /// Updates the local reachability status func sessionReachabilityDidChange(_ session: WCSession) { + let isReachable = session.isReachable DispatchQueue.main.async { - if WatchLogger.shared.shouldLog("reachability", state: "\(session.isReachable)") { - WatchLogger.shared.debug(.connectivity, "Phone reachable: \(session.isReachable)") - } - - if session.isReachable { - self.forceConditionalWatchStateUpdate() + self.handleReachabilityChange(isReachable: isReachable) + } + } - // reset input amounts - self.bolusAmount = 0 - self.carbsAmount = 0 + /// Reachability flips on every wrist raise, so this must leave entries in progress alone: + /// clearing `bolusAmount` here turned a confirmed bolus into a 0 U request. + /// The main view resets the entry fields when it appears. + func handleReachabilityChange(isReachable: Bool) { + if WatchLogger.shared.shouldLog("reachability", state: "\(isReachable)") { + WatchLogger.shared.debug(.connectivity, "Phone reachable: \(isReachable)") + } - // reset auth progress - self.confirmationProgress = 0 - } + if isReachable { + forceConditionalWatchStateUpdate() } } diff --git a/Trio Watch App Tests/Unit Tests.swift b/Trio Watch App Tests/Unit Tests.swift index 0d0cde0eb4..b42ca8fc8a 100644 --- a/Trio Watch App Tests/Unit Tests.swift +++ b/Trio Watch App Tests/Unit Tests.swift @@ -1,11 +1,15 @@ -import SwiftUICore +import SwiftUI import Testing @testable import Trio_Watch_App -import XCTest -@Suite("Watch App Tests") final class TrioWatchAppTests { +@Suite("Watch App Tests", .serialized) @MainActor final class TrioWatchAppTests { var watchState = WatchState() + /// Lets main-queue work scheduled by WatchState (asyncAfter, async) run + private func waitForMainQueue(_ seconds: Double) async throws { + try await Task.sleep(for: .milliseconds(Int(seconds * 1000))) + } + // MARK: - Color Conversion Tests @Test("Hex string to color conversion") func testHexStringToColor() throws { @@ -40,61 +44,135 @@ import XCTest @Test("Carb limits have correct default values") func testCarbLimits() throws { #expect(watchState.maxCarbs == Decimal(250)) - #expect(watchState.maxCOB == Decimal(120)) } - @Test("Bolus cancellation resets all related values") func testBolusCancellation() throws { - // Given - watchState.bolusProgress = 0.5 - watchState.activeBolusAmount = 5.0 - watchState.isBolusCanceled = false + @Test("Acknowledgment states transition correctly") func testAcknowledgmentStates() async throws { + // Given - Initial state + #expect(watchState.acknowledgementStatus == .pending) + #expect(!watchState.showAcknowledgmentBanner) - // When - watchState.sendCancelBolusRequest() + // When/Then - Success acknowledgment + watchState.handleAcknowledgment(success: true, message: "Success") + #expect(watchState.acknowledgementStatus == .success) + #expect(watchState.acknowledgmentMessage == "Success") + try await waitForMainQueue(0.5) + #expect(watchState.showAcknowledgmentBanner) - // Then - #expect(watchState.isBolusCanceled) - #expect(watchState.bolusProgress == 0) - #expect(watchState.activeBolusAmount == 0) + // When/Then - Failure acknowledgment + watchState.handleAcknowledgment(success: false, message: "Error") + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == "Error") } - @Test("Meal bolus combo state transitions work correctly") func testMealBolusComboState() throws { - // Given - Initial state - #expect(!watchState.isMealBolusCombo) - #expect(watchState.mealBolusStep == .savingCarbs) + // MARK: - Reachability must not touch entries in progress - // When - Setup meal bolus combo - watchState.carbsAmount = 30 + @Test("Phone reconnecting keeps the bolus being confirmed") func testReconnectKeepsBolusEntry() throws { + // Given - the user is on the confirm screen, partway through dialing the crown watchState.bolusAmount = 3.0 + watchState.carbsAmount = 20 + watchState.confirmationProgress = 0.5 - // Then - Test state transitions - watchState.handleAcknowledgment(success: true, message: "Saving carbs...", isFinal: false) - #expect(watchState.isMealBolusCombo) - #expect(watchState.mealBolusStep == .savingCarbs) + // When - raising the wrist reconnects the phone + watchState.handleReachabilityChange(isReachable: false) + watchState.handleReachabilityChange(isReachable: true) - watchState.handleAcknowledgment(success: true, message: "Enacting bolus...", isFinal: false) - #expect(watchState.isMealBolusCombo) - #expect(watchState.mealBolusStep == .enactingBolus) + // Then - the confirmed amount is still the one the user entered, not 0 U + #expect(watchState.bolusAmount == 3.0) + #expect(watchState.carbsAmount == 20) + #expect(watchState.confirmationProgress == 0.5) + } - watchState.handleAcknowledgment(success: true, message: "Carbs and bolus logged successfully", isFinal: true) - #expect(!watchState.isMealBolusCombo) + // MARK: - Bolus requests that must not be sent + + @Test("Zero bolus is refused on the watch", arguments: [ + Decimal(0), + Decimal(-1) + ]) func testZeroBolusRefused(amount: Decimal) async throws { + watchState.sendBolusRequest(amount) + + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.invalidBolusAmountMessage) + try await waitForMainQueue(0.5) + #expect(!watchState.showCommsAnimation) + #expect(watchState.showAcknowledgmentBanner) } - @Test("Acknowledgment states transition correctly") func testAcknowledgmentStates() throws { - // Given - Initial state - #expect(watchState.acknowledgementStatus == .pending) - #expect(!watchState.showAcknowledgmentBanner) + @Test("Bolus with the phone unreachable says nothing was sent") func testUnreachableBolus() async throws { + watchState.session = nil - // When/Then - Success acknowledgment - watchState.handleAcknowledgment(success: true, message: "Success") - #expect(watchState.acknowledgementStatus == .success) + watchState.sendBolusRequest(3) + + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.phoneUnreachableMessage) + try await waitForMainQueue(0.5) + #expect(!watchState.showCommsAnimation) #expect(watchState.showAcknowledgmentBanner) - #expect(watchState.acknowledgmentMessage == "Success") + } - // When/Then - Failure acknowledgment - watchState.handleAcknowledgment(success: false, message: "Error") + @Test("Other treatment requests with the phone unreachable say nothing was sent") func testUnreachableTreatments() throws { + watchState.session = nil + let requests: [() -> Void] = [ + { self.watchState.sendCarbsRequest(20) }, + { self.watchState.sendCancelOverrideRequest() }, + { self.watchState.sendActivateOverrideRequest(presetName: "Exercise") }, + { self.watchState.sendCancelTempTargetRequest() }, + { self.watchState.sendActivateTempTargetRequest(presetName: "Eating soon") } + ] + + for request in requests { + watchState.acknowledgementStatus = .pending + watchState.acknowledgmentMessage = "" + request() + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.phoneUnreachableMessage) + } + } + + // MARK: - Waiting for the phone's answer + + @Test("Processing ends with a check-your-phone message when the phone never answers") func testAcknowledgmentTimeout() async throws { + watchState.beginAwaitingAcknowledgment(timeout: 0.1) + #expect(watchState.showCommsAnimation) + #expect(watchState.acknowledgementStatus == .pending) + + try await waitForMainQueue(0.6) + + #expect(!watchState.showCommsAnimation) #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.noAnswerMessage) #expect(watchState.showAcknowledgmentBanner) - #expect(watchState.acknowledgmentMessage == "Error") + } + + @Test("An answer from the phone cancels the timeout") func testAcknowledgmentCancelsTimeout() async throws { + watchState.beginAwaitingAcknowledgment(timeout: 0.1) + + watchState.handleAcknowledgment(success: true, message: "Bolus enacted successfully.") + try await waitForMainQueue(0.6) + + #expect(watchState.acknowledgementStatus == .success) + #expect(watchState.acknowledgmentMessage == "Bolus enacted successfully.") + } + + @Test("A send error ends processing with the check-your-phone message") func testSendErrorEndsProcessing() async throws { + watchState.beginAwaitingAcknowledgment(timeout: 60) + + // What the sendMessage error handler does + watchState.handleMissingAcknowledgment() + + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.noAnswerMessage) + try await waitForMainQueue(0.5) + #expect(!watchState.showCommsAnimation) + } + + @Test("A late timeout does not overwrite an answer already shown") func testMissingAcknowledgmentAfterAnswer() async throws { + watchState.beginAwaitingAcknowledgment(timeout: 60) + watchState.handleAcknowledgment(success: true, message: "Bolus enacted successfully.") + try await waitForMainQueue(0.3) + + watchState.handleMissingAcknowledgment() + + #expect(watchState.acknowledgementStatus == .success) + #expect(watchState.acknowledgmentMessage == "Bolus enacted successfully.") } } diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 4868f0800f..f9f210e02a 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -471,6 +471,7 @@ AD3D2CD42CD01B9EB8F26522 /* PumpConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF65DA88F972B56090AD6AC3 /* PumpConfigDataFlow.swift */; }; B015AFE32E500000000D7351 /* BolusSafetyValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */; }; B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */; }; + E1B0A5D00000000000000002 /* EnactBolusAmountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0A5D00000000000000001 /* EnactBolusAmountTests.swift */; }; B015AFF32E600000000D7351 /* AdjustmentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFF12E600000000D7351 /* AdjustmentManager.swift */; }; B015AFF52E600000000D7351 /* AdjustmentManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFF42E600000000D7351 /* AdjustmentManagerTests.swift */; }; B63C9D934FC54853BC4EF29A /* NightscoutUploadSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E61A0885164081B57C357C /* NightscoutUploadSerializer.swift */; }; @@ -1697,6 +1698,7 @@ AF65DA88F972B56090AD6AC3 /* PumpConfigDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigDataFlow.swift; sourceTree = ""; }; B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidator.swift; sourceTree = ""; }; B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidatorTests.swift; sourceTree = ""; }; + E1B0A5D00000000000000001 /* EnactBolusAmountTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnactBolusAmountTests.swift; sourceTree = ""; }; B015AFF12E600000000D7351 /* AdjustmentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustmentManager.swift; sourceTree = ""; }; B015AFF42E600000000D7351 /* AdjustmentManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustmentManagerTests.swift; sourceTree = ""; }; B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsSearchTests.swift; sourceTree = ""; }; @@ -4031,6 +4033,7 @@ isa = PBXGroup; children = ( B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */, + E1B0A5D00000000000000001 /* EnactBolusAmountTests.swift */, ); path = BolusSafetyTests; sourceTree = ""; @@ -6613,6 +6616,7 @@ 41740E936552456AAC0EDCC3 /* MaxIOBSuggestionTests.swift in Sources */, 41740E936552456AAC0EDBC3 /* DosingModeMigrationTests.swift in Sources */, B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */, + E1B0A5D00000000000000002 /* EnactBolusAmountTests.swift in Sources */, BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */, CA03000000000000000010C2 /* AlertCatalogRegistryOmniFaultTests.swift in Sources */, CD1235B000000000000000F2 /* TidepoolUploadSerializerTests.swift in Sources */, diff --git a/Trio/Sources/APS/APSManager.swift b/Trio/Sources/APS/APSManager.swift index 50f017eecb..ce45d0c460 100644 --- a/Trio/Sources/APS/APSManager.swift +++ b/Trio/Sources/APS/APSManager.swift @@ -633,7 +633,10 @@ final class BaseAPSManager: APSManager, Injectable { } func enactBolus(amount: Double, isSMB: Bool, callback: ((Bool, String) -> Void)?) async { - if amount <= 0 { + // Answer every caller: a silent return here left the watch waiting on "Processing…" forever + guard amount > 0 else { + warning(.bolus, "Bolus of \(amount) U not enacted, amount must be above 0") + callback?(false, Self.invalidBolusAmountMessage) return } @@ -658,6 +661,12 @@ final class BaseAPSManager: APSManager, Injectable { let roundedAmount = pump.roundToSupportedBolusVolume(units: amount) + guard roundedAmount > 0 else { + warning(.bolus, "Bolus of \(amount) U not enacted, rounds to 0 U on this pump") + callback?(false, Self.invalidBolusAmountMessage) + return + } + debug(.bolus, "Enacting bolus \(roundedAmount) U, \(isSMB ? "automatic" : "manual")") do { @@ -696,6 +705,11 @@ final class BaseAPSManager: APSManager, Injectable { } } + static let invalidBolusAmountMessage = String( + localized: "Error! Bolus amount must be greater than 0 U.", + comment: "Error message when a bolus of zero or less is requested" + ) + func cancelBolus(_ callback: ((Bool, String) -> Void)?) async { guard let pump = pumpManager, pump.status.pumpStatus.bolusing else { return } debug(.bolus, "Cancelling bolus") diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 4292db903b..96c454ade6 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -132544,6 +132544,9 @@ } } }, + "Error! Bolus amount must be greater than 0 U." : { + "comment" : "Error message when a bolus of zero or less is requested" + }, "Error! Bolus cancellation failed with error: %@" : { "comment" : "Error message for canceling a bolus", "localizations" : { @@ -176343,6 +176346,9 @@ } } }, + "iPhone not reachable. Nothing was sent." : { + "comment" : "Watch message when a request could not be sent to the phone" + }, "is running" : { "localizations" : { "bg" : { @@ -211026,6 +211032,9 @@ } } }, + "No answer from iPhone. Check Trio on your iPhone before trying again." : { + "comment" : "Watch message when the phone did not confirm a request" + }, "No Bolus Data" : { "localizations" : { "bg" : { diff --git a/TrioTests/BolusSafetyTests/EnactBolusAmountTests.swift b/TrioTests/BolusSafetyTests/EnactBolusAmountTests.swift new file mode 100644 index 0000000000..eeafdd37ae --- /dev/null +++ b/TrioTests/BolusSafetyTests/EnactBolusAmountTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Swinject +import Testing +@testable import Trio + +/// A bolus of zero or less must be refused with an answer. `enactBolus` used to return without +/// calling back, so a watch that sent 0 U waited on "Processing…" forever. +@Suite("Enact bolus amount validation", .serialized) struct EnactBolusAmountTests { + private static func makeAPSManager() async throws -> APSManager { + let coreDataStack = try await CoreDataStack.createForTests() + let resolver = Assembler([ + StorageAssembly(), + ServiceAssembly(), + APSAssembly(), + NetworkAssembly(), + UIAssembly(), + SecurityAssembly(), + TestAssembly(testContext: coreDataStack.newTaskContext()) + ]).resolver + return try #require(resolver.resolve(APSManager.self)) + } + + @Test("Bolus of zero or less is refused with an answer", arguments: [ + 0.0, + -0.05, + -3.0, + Double.nan + ]) func testInvalidAmountAnswersFailure(amount: Double) async throws { + let apsManager = try await Self.makeAPSManager() + var answers: [(success: Bool, message: String)] = [] + + await apsManager.enactBolus(amount: amount, isSMB: false) { success, message in + answers.append((success, message)) + } + + #expect(answers.count == 1) + #expect(answers.first?.success == false) + #expect(answers.first?.message == BaseAPSManager.invalidBolusAmountMessage) + } +} From 9a5cac061fb968eb6243c1c5b25f4327602a5bad Mon Sep 17 00:00:00 2001 From: James Woglom Date: Sat, 7 Nov 2026 14:00:06 -0500 Subject: [PATCH 22/34] Expire watch carbs/bolus entries after 2 minutes An amount entered on the watch stayed on the input and confirm screens for as long as the app was left open, so an old entry could be confirmed much later. Entries now expire 2 minutes after their last edit. - Opening the carbs or bolus input screen, or changing the amount there (crown, +/-, a recommendation landing), restarts the entry's clock. - Confirming with the crown and "Log Carbs" send only an entry edited within the last 2 minutes; an older one is cleared and the watch says nothing was sent. An entry with no recorded edit is never sent. - When the app becomes active again, an entry older than 2 minutes is cleared and the watch returns to the main screen. - Returning to the main screen, Cancel and sending end the entry. Tests cover the 2 minute boundary, refusing expired and unedited entries, sending fresh ones, carbs-only entries and clearing on return to the app. Co-Authored-By: Claude Opus 5.5 --- .../Views/BolusConfirmationView.swift | 15 +-- .../Views/BolusInputView.swift | 6 +- .../Views/CarbsInputView.swift | 5 +- .../Views/TrioMainWatchView.swift | 10 ++ .../WatchState+Requests.swift | 29 ++++++ Trio Watch App Extension/WatchState.swift | 41 ++++++++ Trio Watch App Tests/Unit Tests.swift | 98 +++++++++++++++++++ .../Localizations/Main/Localizable.xcstrings | 3 + 8 files changed, 192 insertions(+), 15 deletions(-) diff --git a/Trio Watch App Extension/Views/BolusConfirmationView.swift b/Trio Watch App Extension/Views/BolusConfirmationView.swift index 39b08c4ba2..e839a8f31f 100644 --- a/Trio Watch App Extension/Views/BolusConfirmationView.swift +++ b/Trio Watch App Extension/Views/BolusConfirmationView.swift @@ -52,11 +52,7 @@ struct BolusConfirmationView: View { Spacer() Button("Cancel") { - if state.carbsAmount > 0 { - state.carbsAmount = 0 // reset carbs in state - } - bolusAmount = 0 // reset bolus in state - confirmationProgress = 0 // reset auth progress + state.clearEntry() navigationPath.removeLast(navigationPath.count) } .buttonStyle(.bordered) @@ -80,13 +76,8 @@ struct BolusConfirmationView: View { WKInterfaceDevice.current().play(.success) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if state.carbsAmount > 0 { - state.sendCarbsRequest(state.carbsAmount, Date()) - state.carbsAmount = 0 // reset carbs in state - } - state.sendBolusRequest(Decimal(bolusAmount)) - bolusAmount = 0 // reset bolus in state - confirmationProgress = 0 // reset auth progress + // Sends carbs and bolus, or refuses an entry older than 2 min; either way the entry is cleared + state.sendConfirmedEntry(bolusAmount: bolusAmount) navigationPath.append(NavigationDestinations.acknowledgmentPending) } } else if newValue > 0 { diff --git a/Trio Watch App Extension/Views/BolusInputView.swift b/Trio Watch App Extension/Views/BolusInputView.swift index 01efe3838d..80705119c5 100644 --- a/Trio Watch App Extension/Views/BolusInputView.swift +++ b/Trio Watch App Extension/Views/BolusInputView.swift @@ -122,8 +122,7 @@ struct BolusInputView: View { Button(actionButtonLabel) { if isCarbsOnly { - state.sendCarbsRequest(state.carbsAmount) - state.carbsAmount = 0 // reset carbs in state + state.sendCarbsEntry(state.carbsAmount) navigationPath.append(NavigationDestinations.acknowledgmentPending) } else { state.bolusAmount = min(bolusAmount, effectiveBolusLimit) @@ -156,7 +155,10 @@ struct BolusInputView: View { .clipShape(Circle()) } } + // The entry's 2 min clock restarts whenever the amount changes, including a recommendation landing + .onChange(of: bolusAmount) { state.markEntryEdited() } .onAppear { + state.markEntryEdited() // Set initial bolus amount to recommended value // Only do this if user has not updated amount previously, e.g., when navigating to next and then back to this view if bolusAmount == 0 { diff --git a/Trio Watch App Extension/Views/CarbsInputView.swift b/Trio Watch App Extension/Views/CarbsInputView.swift index 4a209c95ed..0517f7ec72 100644 --- a/Trio Watch App Extension/Views/CarbsInputView.swift +++ b/Trio Watch App Extension/Views/CarbsInputView.swift @@ -94,7 +94,7 @@ struct CarbsInputView: View { state.carbsAmount = Int(min(carbsAmount, effectiveCarbsLimit)) navigationPath.append(NavigationDestinations.bolusInput) } else { - state.sendCarbsRequest(Int(min(carbsAmount, effectiveCarbsLimit))) + state.sendCarbsEntry(Int(min(carbsAmount, effectiveCarbsLimit))) navigationPath.append(NavigationDestinations.acknowledgmentPending) } } @@ -103,6 +103,9 @@ struct CarbsInputView: View { .disabled(!(carbsAmount > 0.0) || carbsAmount > effectiveCarbsLimit) } .background(trioBackgroundColor) + // The entry's 2 min clock restarts whenever the user touches it + .onAppear { state.markEntryEdited() } + .onChange(of: carbsAmount) { state.markEntryEdited() } .toolbar { ToolbarItem(placement: .topBarTrailing) { Image(systemName: "fork.knife") diff --git a/Trio Watch App Extension/Views/TrioMainWatchView.swift b/Trio Watch App Extension/Views/TrioMainWatchView.swift index 7ea3b95eb1..ad26386a99 100644 --- a/Trio Watch App Extension/Views/TrioMainWatchView.swift +++ b/Trio Watch App Extension/Views/TrioMainWatchView.swift @@ -3,6 +3,7 @@ import SwiftUI import WatchKit struct TrioMainWatchView: View { + @Environment(\.scenePhase) private var scenePhase @State private var state = WatchState() // misc @@ -226,6 +227,15 @@ struct TrioMainWatchView: View { if newPath.isEmpty { // Reset conditional view navigation when returning to root view continueToBolus = false + // Back on the main screen, so no entry is in progress + state.clearEntry() + } + } + .onChange(of: scenePhase) { _, newPhase in + // An entry left for more than 2 min (wrist down, app in the background) is + // cleared and the input screens are closed, so a stale amount can't be confirmed + if newPhase == .active, state.expireStaleEntry() { + navigationPath = NavigationPath() } } } diff --git a/Trio Watch App Extension/WatchState+Requests.swift b/Trio Watch App Extension/WatchState+Requests.swift index 330d67538c..fc8b348a6e 100644 --- a/Trio Watch App Extension/WatchState+Requests.swift +++ b/Trio Watch App Extension/WatchState+Requests.swift @@ -4,6 +4,35 @@ import WatchConnectivity // MARK: - Send Data to Phone extension WatchState { + /// Sends the entry confirmed with the crown: its carbs, if any, then the bolus. + /// Refuses an entry older than `entryLifetime` and clears it instead. + func sendConfirmedEntry(bolusAmount amount: Double, at now: Date = Date()) { + guard refuseIfExpired(at: now) == false else { return } + + let carbs = carbsAmount + clearEntry() + if carbs > 0 { + sendCarbsRequest(carbs, now) + } + sendBolusRequest(Decimal(amount)) + } + + /// Sends a carbs-only entry ("Log Carbs"). Refuses an entry older than `entryLifetime` and clears it instead. + func sendCarbsEntry(_ amount: Int, at now: Date = Date()) { + guard refuseIfExpired(at: now) == false else { return } + + clearEntry() + sendCarbsRequest(amount, now) + } + + private func refuseIfExpired(at now: Date) -> Bool { + guard !isEntryFresh(at: now) else { return false } + WatchLogger.shared.warning(.treatments, "Entry older than \(Int(Self.entryLifetime / 60)) min not sent") + clearEntry() + handleRequestNotSent(Self.entryExpiredMessage) + return true + } + /// Sends a bolus insulin request to the paired iPhone /// - Parameters: /// - amount: The insulin amount to be delivered diff --git a/Trio Watch App Extension/WatchState.swift b/Trio Watch App Extension/WatchState.swift index 1b1f170aa4..30e223cf79 100644 --- a/Trio Watch App Extension/WatchState.swift +++ b/Trio Watch App Extension/WatchState.swift @@ -36,6 +36,14 @@ import WatchConnectivity var proteinAmount: Int = 0 var bolusAmount: Double = 0.0 var confirmationProgress: Double = 0.0 + /// Last time the user opened or changed a carbs/bolus entry; nil when no entry is in progress + @ObservationIgnored private(set) var entryEditedAt: Date? + /// An entry left alone for longer than this is cleared and never sent + static let entryLifetime: TimeInterval = 2 * 60 + static let entryExpiredMessage = String( + localized: "Entry expired after 2 minutes. Nothing was sent. Please enter it again.", + comment: "Watch message when a carbs or bolus entry was too old to send" + ) // Safety limits var maxBolus: Decimal = 10 @@ -123,6 +131,39 @@ import WatchConnectivity } } + // MARK: - Entry lifetime + + /// Starts or restarts the entry's clock. Call when an input screen appears and on every amount change. + func markEntryEdited(at now: Date = Date()) { + entryEditedAt = now + } + + /// True only for an entry changed within `entryLifetime`; an entry with no recorded edit is never fresh + func isEntryFresh(at now: Date = Date()) -> Bool { + guard let entryEditedAt else { return false } + return now.timeIntervalSince(entryEditedAt) <= Self.entryLifetime + } + + /// Ends the entry: clears amounts, the recommendation and the confirmation progress + func clearEntry() { + bolusAmount = 0 + carbsAmount = 0 + fatAmount = 0 + proteinAmount = 0 + recommendedBolus = 0 + confirmationProgress = 0 + entryEditedAt = nil + } + + /// Clears an entry older than `entryLifetime`. Returns true when it did, so the caller can + /// leave the input screens. + @discardableResult func expireStaleEntry(at now: Date = Date()) -> Bool { + guard entryEditedAt != nil, !isEntryFresh(at: now) else { return false } + WatchLogger.shared.info(.treatments, "Entry older than \(Int(Self.entryLifetime / 60)) min cleared") + clearEntry() + return true + } + // MARK: – Handle Acknowledgement Messages FROM Phone /// Shows "Processing…" and starts the clock for the phone's answer diff --git a/Trio Watch App Tests/Unit Tests.swift b/Trio Watch App Tests/Unit Tests.swift index b42ca8fc8a..996dfb5a53 100644 --- a/Trio Watch App Tests/Unit Tests.swift +++ b/Trio Watch App Tests/Unit Tests.swift @@ -175,4 +175,102 @@ import Testing #expect(watchState.acknowledgementStatus == .success) #expect(watchState.acknowledgmentMessage == "Bolus enacted successfully.") } + + // MARK: - Entries expire after 2 minutes + + private let entryStart = Date(timeIntervalSince1970: 1_800_000_000) + + /// An entry on the confirm screen: 20 g carbs and 3 U, last edited at `entryStart` + private func enterMealBolus() { + watchState.carbsAmount = 20 + watchState.bolusAmount = 3.0 + watchState.confirmationProgress = 0.5 + watchState.markEntryEdited(at: entryStart) + } + + private func expectEntryCleared() { + #expect(watchState.bolusAmount == 0) + #expect(watchState.carbsAmount == 0) + #expect(watchState.confirmationProgress == 0) + #expect(watchState.entryEditedAt == nil) + } + + @Test("An entry is fresh for 2 minutes after its last edit") func testEntryFreshness() throws { + #expect(!watchState.isEntryFresh(at: entryStart), "no entry has been edited yet") + + watchState.markEntryEdited(at: entryStart) + + #expect(watchState.isEntryFresh(at: entryStart.addingTimeInterval(119))) + #expect(watchState.isEntryFresh(at: entryStart.addingTimeInterval(120))) + #expect(!watchState.isEntryFresh(at: entryStart.addingTimeInterval(121))) + + // Editing again restarts the clock + watchState.markEntryEdited(at: entryStart.addingTimeInterval(100)) + #expect(watchState.isEntryFresh(at: entryStart.addingTimeInterval(200))) + } + + @Test("Confirming an entry older than 2 minutes sends nothing and clears it") + func testExpiredEntryNotSent() throws { + enterMealBolus() + + watchState.sendConfirmedEntry(bolusAmount: 3.0, at: entryStart.addingTimeInterval(121)) + + // The expired message, not "phone unreachable", shows nothing was attempted + #expect(watchState.acknowledgementStatus == .failure) + #expect(watchState.acknowledgmentMessage == WatchState.entryExpiredMessage) + expectEntryCleared() + } + + @Test("Confirming an entry with no recorded edit sends nothing") func testUneditedEntryNotSent() throws { + watchState.bolusAmount = 3.0 + + watchState.sendConfirmedEntry(bolusAmount: 3.0, at: entryStart) + + #expect(watchState.acknowledgmentMessage == WatchState.entryExpiredMessage) + expectEntryCleared() + } + + @Test("Confirming a fresh entry sends it and clears it") func testFreshEntrySent() throws { + enterMealBolus() + // No phone in tests: reaching the "unreachable" refusal proves the send was attempted + watchState.session = nil + + watchState.sendConfirmedEntry(bolusAmount: 3.0, at: entryStart.addingTimeInterval(60)) + + #expect(watchState.acknowledgmentMessage == WatchState.phoneUnreachableMessage) + expectEntryCleared() + } + + @Test("Logging carbs older than 2 minutes sends nothing") func testExpiredCarbsNotSent() throws { + watchState.markEntryEdited(at: entryStart) + + watchState.sendCarbsEntry(20, at: entryStart.addingTimeInterval(121)) + + #expect(watchState.acknowledgmentMessage == WatchState.entryExpiredMessage) + expectEntryCleared() + } + + @Test("Logging fresh carbs sends them") func testFreshCarbsSent() throws { + watchState.markEntryEdited(at: entryStart) + watchState.session = nil + + watchState.sendCarbsEntry(20, at: entryStart.addingTimeInterval(60)) + + #expect(watchState.acknowledgmentMessage == WatchState.phoneUnreachableMessage) + expectEntryCleared() + } + + @Test("Returning to the app clears an entry older than 2 minutes") func testStaleEntryExpiresOnReturn() throws { + enterMealBolus() + + #expect(!watchState.expireStaleEntry(at: entryStart.addingTimeInterval(120))) + #expect(watchState.bolusAmount == 3.0) + + #expect(watchState.expireStaleEntry(at: entryStart.addingTimeInterval(121))) + expectEntryCleared() + } + + @Test("Returning to the app with no entry in progress changes nothing") func testNoEntryOnReturn() throws { + #expect(!watchState.expireStaleEntry(at: entryStart)) + } } diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 96c454ade6..0501941092 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -131885,6 +131885,9 @@ } } }, + "Entry expired after 2 minutes. Nothing was sent. Please enter it again." : { + "comment" : "Watch message when a carbs or bolus entry was too old to send" + }, "Error" : { "comment" : "Error title", "localizations" : { From ade3a11a6342857fc5f51b3e2cd918362b374615 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Nov 2026 04:36:35 +0000 Subject: [PATCH 23/34] CI: Bump APP_DEV_VERSION to 1.0.2.67 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index f28200b894..4facbe4067 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.66 +APP_DEV_VERSION = 1.0.2.67 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 67110b99a3894f34f90eb7569a29178f12979757 Mon Sep 17 00:00:00 2001 From: Liroy van Hoewijk <4643445+LiroyvH@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:10:07 +0100 Subject: [PATCH 24/34] Remove localization strings for CFBundleName and CFBundleDisplayName To fix issue 1754 --- Trio/Resources/InfoPlist.xcstrings | 276 ----------------------------- 1 file changed, 276 deletions(-) diff --git a/Trio/Resources/InfoPlist.xcstrings b/Trio/Resources/InfoPlist.xcstrings index fbf2be848f..bcba902aef 100644 --- a/Trio/Resources/InfoPlist.xcstrings +++ b/Trio/Resources/InfoPlist.xcstrings @@ -1,282 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "CFBundleDisplayName" : { - "comment" : "Bundle display name", - "extractionState" : "extracted_with_value", - "localizations" : { - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "da" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "es" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "he" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "ro" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "ru" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "sv" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "tr" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "uk" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - } - } - }, - "CFBundleName" : { - "comment" : "Bundle name", - "extractionState" : "extracted_with_value", - "localizations" : { - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "da" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "es" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "he" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "ro" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "ru" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "sv" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "tr" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "uk" : { - "stringUnit" : { - "state" : "new", - "value" : "Trio" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trio" - } - } - } - }, "NFCReaderUsageDescription" : { "comment" : "Privacy - NFC Scan Usage Description", "extractionState" : "extracted_with_value", From 233c31aaf4a6e75f89bd08f629e41a9c06d80f39 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Nov 2026 23:51:47 +0000 Subject: [PATCH 25/34] CI: Bump APP_DEV_VERSION to 1.0.2.68 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 4facbe4067..0af9a5ca6c 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.67 +APP_DEV_VERSION = 1.0.2.68 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From d1dda095d33ed0aaa2ecf9138ec3ae65211f1be0 Mon Sep 17 00:00:00 2001 From: Deniz Cengiz <48965855+dnzxy@users.noreply.github.com> Date: Thu, 12 Nov 2026 00:52:27 +0100 Subject: [PATCH 26/34] Alert when Tidepool login is rejected (#1706) * Alert and flag when Tidepool login is rejected * Drop Tidepool sign-out now TidepoolKit clears it * Skip Tidepool re-login alert when deleting service --- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../Localizations/Main/Localizable.xcstrings | 6 +++ .../Sources/Modules/Home/HomeStateModel.swift | 7 +++ .../View/HomeRootView+BottomControls.swift | 15 ++++++ .../Home/View/MultiUsePanelState.swift | 4 ++ .../Settings/View/TidepoolStartView.swift | 8 ++-- .../Alerts/AlertCatalogRegistry.swift | 17 ++++++- .../Services/Alerts/AlertCatalogVendor.swift | 4 ++ .../Services/Network/TidepoolManager.swift | 47 ++++++++++++++++++- .../AlertCatalogRegistryEntriesTests.swift | 7 +++ .../CarbEntryMutationServiceTests.swift | 1 + .../MultiUsePanelStateTests.swift | 13 +++++ 12 files changed, 124 insertions(+), 7 deletions(-) diff --git a/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved index accbe9aa1e..cc9ad05622 100644 --- a/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -295,7 +295,7 @@ "location" : "https://github.com/tidepool-org/TidepoolKit", "state" : { "branch" : "dev", - "revision" : "290bd0431c55e8d0e0e542b163de13b1f07b77d9" + "revision" : "0f127debeecf5b3b378f7d7100c8e3d7e2d99615" } } ], diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index e58c15cdde..a8e21b9891 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -1238,6 +1238,9 @@ }, "This build of Trio can't send reports. Please share your logs on Discord or Facebook instead." : { + }, + "Tidepool Login Required" : { + }, "Title (optional)" : { @@ -1250,6 +1253,9 @@ }, "Up to %lld screenshots or photos." : { + }, + "Uploads to Tidepool are paused. Tap to log in." : { + }, "You've sent several reports today. Please try again tomorrow." : { diff --git a/Trio/Sources/Modules/Home/HomeStateModel.swift b/Trio/Sources/Modules/Home/HomeStateModel.swift index aeae7646c9..e14784d9c2 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel.swift @@ -22,6 +22,7 @@ extension Home { @ObservationIgnored @Injected() var pluginCGMManager: PluginManager! @ObservationIgnored @Injected() var fetchGlucoseManager: FetchGlucoseManager! @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager! + @ObservationIgnored @Injected() var tidepoolManager: TidepoolManager! @ObservationIgnored @Injected() var determinationStorage: DeterminationStorage! @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage! @ObservationIgnored @Injected() var carbsStorage: CarbsStorage! @@ -125,6 +126,7 @@ extension Home { var pumpStatusHighlightMessage: String? var pumpStatusBadgeImage: UIImage? var pumpStatusBadgeColor: Color? + var tidepoolLoginRequired = false var cgmAvailable: Bool = false var cgmDisplayState: CgmDisplayState? var cgmProgressHighlight: DeviceLifecycleProgress? @@ -645,6 +647,11 @@ extension Home { .weakAssign(to: \.lastLoopDate, on: self) .store(in: &lifetime) + tidepoolManager.tidepoolLoginRequired + .receive(on: DispatchQueue.main) + .weakAssign(to: \.tidepoolLoginRequired, on: self) + .store(in: &lifetime) + apsManager.pumpName .receive(on: DispatchQueue.main) .weakAssign(to: \.pumpName, on: self) diff --git a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift index 24bfaf32aa..ed66e5fdd7 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView+BottomControls.swift @@ -646,6 +646,7 @@ extension Home.RootView { pumpTimeMismatch: state.pumpStatusBadgeImage != nil, lastGlucoseDate: state.glucoseFromPersistence.last?.date, maxIOB: state.maxIOB, + tidepoolLoginRequired: state.tidepoolLoginRequired, hasUnacknowledgedReleaseNotes: releaseNotesService.hasUnacknowledgedNotes, dosingMode: state.dosingMode, now: state.timerDate @@ -741,6 +742,15 @@ extension Home.RootView { ) { openMaxIOBSetting() } + case .tidepoolLoginRequired: + panelBanner( + systemImage: "icloud.slash.fill", + title: String(localized: "Tidepool Login Required"), + subtitle: String(localized: "Uploads to Tidepool are paused. Tap to log in."), + tint: .orange + ) { + openTidepoolSetting() + } case .whatsNew: panelBanner( systemImage: "sparkles", @@ -784,6 +794,11 @@ extension Home.RootView { )) } + func openTidepoolSetting() { + selectedTab = 3 + settingsPath.append(SearchResultTarget(screen: .tidepoolConfig, scrollLabel: "Tidepool Integration".localized)) + } + /// Bottom-anchored fixed zone: adjustment/bolus panel above the stats banner. @ViewBuilder func bottomControls() -> some View { VStack(spacing: HomeLayout.bottomZonePadding) { diff --git a/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift b/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift index 7772ec04e1..1b06661247 100644 --- a/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift +++ b/Trio/Sources/Modules/Home/View/MultiUsePanelState.swift @@ -6,6 +6,7 @@ enum MultiUsePanelState: Equatable { case pumpTimeMismatch case cgmStale case maxIOBZero + case tidepoolLoginRequired case whatsNew case dosingModeLimited(DosingMode) case stats @@ -18,6 +19,7 @@ enum MultiUsePanelState: Equatable { pumpTimeMismatch: Bool, lastGlucoseDate: Date?, maxIOB: Decimal, + tidepoolLoginRequired: Bool, hasUnacknowledgedReleaseNotes: Bool, dosingMode: DosingMode, now: Date @@ -30,6 +32,8 @@ enum MultiUsePanelState: Equatable { return .dosingModeLimited(dosingMode) } if maxIOB <= 0 { return .maxIOBZero } + // Uploads only, so it yields to every dosing-related warning above. + if tidepoolLoginRequired { return .tidepoolLoginRequired } // Informational, so it yields to every warning above but still displaces the stats. if hasUnacknowledgedReleaseNotes { return .whatsNew } return .stats diff --git a/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift b/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift index 0f9e0fc9dd..4d75cdbc58 100644 --- a/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift +++ b/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift @@ -30,12 +30,14 @@ struct TidepoolStartView: BaseView { state.setupTidepool.toggle() } label: { + let isAuthenticated = !state.provider.tidepoolManager.tidepoolLoginRequired.value HStack { - Text("Connected to Tidepool").font(.title3) + (isAuthenticated ? Text("Connected to Tidepool") : Text("Tidepool Login Required")) + .font(.title3) ZStack { Image(systemName: "network") - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green).font(.caption2) + Image(systemName: isAuthenticated ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundColor(isAuthenticated ? .green : .red).font(.caption2) .offset(x: 9, y: 6) } .accessibilityHidden(true) diff --git a/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift b/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift index 629a010569..9cb6bf61bd 100644 --- a/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift +++ b/Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift @@ -13,7 +13,7 @@ import LoopKit enum AlertCatalogRegistry { static let entries: [Alert.CatalogEntry] = omniEntries + minimedEntries + danaEntries + medtrumEntries + libreLoopEntries + g7Entries - + syaiEntries + trioAlgorithmEntries + + syaiEntries + trioAlgorithmEntries + tidepoolEntries static func lookup(_ identifier: Alert.Identifier) -> Alert.CatalogEntry? { if let exact = entries.first(where: { $0.identifier == identifier }) { @@ -350,6 +350,21 @@ private extension AlertCatalogRegistry { ] } +// MARK: - Tidepool + +private extension AlertCatalogRegistry { + static let tidepoolEntries: [Alert.CatalogEntry] = [ + addEntry( + "TidepoolService", + "authentication-needed", + .active, + "Tidepool Service Authorization", + "Services", + .tidepoolLoginRequired + ) + ] +} + // MARK: - Helpers private extension AlertCatalogRegistry { diff --git a/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift b/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift index d4cd35709a..6524377c1c 100644 --- a/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift +++ b/Trio/Sources/Services/Alerts/AlertCatalogVendor.swift @@ -43,6 +43,9 @@ extension Alert { case notLooping case algorithmError case glucoseDataStale + // Service concepts. Surface through `TrioAlertManager` via + // `TidepoolManager.issueAlert(_:)`. + case tidepoolLoginRequired /// Plugin-unique alarm that doesn't merge with anything else. case unspecified @@ -80,6 +83,7 @@ extension Alert { case .notLooping: return String(localized: "Trio Not Looping") case .algorithmError: return String(localized: "Algorithm Error") case .glucoseDataStale: return String(localized: "Glucose Data Stale") + case .tidepoolLoginRequired: return String(localized: "Tidepool Login Required") case .unspecified: return "" } } diff --git a/Trio/Sources/Services/Network/TidepoolManager.swift b/Trio/Sources/Services/Network/TidepoolManager.swift index 1214b1eadf..ea87f5ec10 100644 --- a/Trio/Sources/Services/Network/TidepoolManager.swift +++ b/Trio/Sources/Services/Network/TidepoolManager.swift @@ -13,6 +13,7 @@ protocol TidepoolManager { func addTidepoolService(service: Service) func getTidepoolServiceUI() -> ServiceUI? func getTidepoolPluginHost() -> PluginHost? + var tidepoolLoginRequired: CurrentValueSubject { get } func uploadCarbs() async func deleteCarbs(withSyncId id: UUID, carbs: Decimal, at: Date, enteredBy: String) func uploadInsulin() async @@ -32,6 +33,13 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { @Injected() private var apsManager: APSManager! @Injected() private var settingsManager: SettingsManager! @Injected() private var notificationCenter: NotificationCenter! + @Injected() private var trioAlertManager: TrioAlertManager! + + /// Raised by `TidepoolService` when its login session is cleared + static let authenticationAlertIdentifier = LoopKit.Alert.Identifier( + managerIdentifier: TidepoolService.serviceIdentifier, + alertIdentifier: "authentication-needed" + ) // Lazy access to avoid circular dependency (TidepoolManager ↔ FetchGlucoseManager) private var resolver: Resolver? @@ -70,6 +78,8 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { let viewContext = CoreDataStack.shared.persistentContainer.viewContext private var subscriptions = Set() + private var sessionSubscription: AnyCancellable? + let tidepoolLoginRequired = CurrentValueSubject(false) // MARK: - Upload triggers @@ -130,6 +140,7 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { }() @PersistedProperty(key: "TidepoolState") var rawTidepoolManager: Service.RawValue? + @PersistedProperty(key: "TidepoolLoginRequired") var persistedLoginRequired: Bool? init(resolver: Resolver) { self.resolver = resolver @@ -175,15 +186,35 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { broadcaster.register(PreferencesObserver.self, observer: self) } + /// Persisted because `TidepoolService` can't be restored without a session, so the flag must outlive a relaunch + private func setLoginRequired(_ required: Bool) { + persistedLoginRequired = required ? true : nil + tidepoolLoginRequired.send(required) + } + /// Loads the Tidepool service from saved state fileprivate func loadTidepoolManager() { + tidepoolLoginRequired.send(persistedLoginRequired ?? false) if let rawTidepoolManager = rawTidepoolManager { tidepoolService = tidepoolServiceFromRaw(rawTidepoolManager) tidepoolService?.serviceDelegate = self tidepoolService?.stateDelegate = self + observeTidepoolSession() } } + /// Tracks whether the Tidepool service lost its login; retracts the re-login alert once it is back + private func observeTidepoolSession() { + sessionSubscription = (tidepoolService as? TidepoolService)?.$session + .sink { [weak self] session in + guard let self else { return } + self.setLoginRequired(session == nil) + if session != nil { + self.trioAlertManager.retractAlert(identifier: Self.authenticationAlertIdentifier) + } + } + } + /// Returns the Tidepool service UI if available func getTidepoolServiceUI() -> ServiceUI? { tidepoolService as? ServiceUI @@ -197,6 +228,9 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { /// Adds a Tidepool service func addTidepoolService(service: Service) { tidepoolService = service as? RemoteDataService + tidepoolService?.serviceDelegate = self + tidepoolService?.stateDelegate = self + observeTidepoolSession() } /// Loads the Tidepool service from raw stored data @@ -261,9 +295,13 @@ extension BaseTidepoolManager: ServiceDelegate { return semanticVersion } - func issueAlert(_: LoopKit.Alert) {} + func issueAlert(_ alert: LoopKit.Alert) { + trioAlertManager.issueAlert(alert) + } - func retractAlert(identifier _: LoopKit.Alert.Identifier) {} + func retractAlert(identifier: LoopKit.Alert.Identifier) { + trioAlertManager.retractAlert(identifier: identifier) + } func enactRemoteOverride(name _: String, durationTime _: TimeInterval?, remoteAddress _: String) async throws {} @@ -877,7 +915,12 @@ extension BaseTidepoolManager: StatefulPluggableDelegate { func pluginDidUpdateState(_: LoopKit.StatefulPluggable) {} func pluginWantsDeletion(_: LoopKit.StatefulPluggable) { + // deleteService() logs out right after, which would raise the re-login alert + tidepoolService?.serviceDelegate = nil tidepoolService = nil + sessionSubscription = nil + setLoginRequired(false) + trioAlertManager.retractAlert(identifier: Self.authenticationAlertIdentifier) } } diff --git a/TrioTests/AlertCatalogRegistryEntriesTests.swift b/TrioTests/AlertCatalogRegistryEntriesTests.swift index b91d0a2095..cd81a7fefd 100644 --- a/TrioTests/AlertCatalogRegistryEntriesTests.swift +++ b/TrioTests/AlertCatalogRegistryEntriesTests.swift @@ -242,4 +242,11 @@ import Testing #expect(AlertCatalogRegistry.lookup(id("trio.aps", alertID))?.category == "Algorithm") } } + + @Test("Tidepool re-login alert is a Normal-tier Services entry") func tidepoolLoginEntry() { + let entry = AlertCatalogRegistry.lookup(id("TidepoolService", "authentication-needed")) + #expect(entry?.interruptionLevel == .active) + #expect(entry?.category == "Services") + #expect(entry?.concept == .tidepoolLoginRequired) + } } diff --git a/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift b/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift index beec5c8fdf..ed2abd815d 100644 --- a/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift +++ b/TrioTests/CoreDataTests/CarbEntryMutationServiceTests.swift @@ -264,6 +264,7 @@ private final class StubTidepoolManager: TidepoolManager { func addTidepoolService(service _: Service) {} func getTidepoolServiceUI() -> ServiceUI? { nil } func getTidepoolPluginHost() -> PluginHost? { nil } + let tidepoolLoginRequired = CurrentValueSubject(false) func uploadCarbs() async {} func deleteCarbs(withSyncId _: UUID, carbs _: Decimal, at _: Date, enteredBy _: String) {} func uploadInsulin() async {} diff --git a/TrioTests/CoreDataTests/MultiUsePanelStateTests.swift b/TrioTests/CoreDataTests/MultiUsePanelStateTests.swift index f16397ec3d..994817679b 100644 --- a/TrioTests/CoreDataTests/MultiUsePanelStateTests.swift +++ b/TrioTests/CoreDataTests/MultiUsePanelStateTests.swift @@ -13,6 +13,7 @@ import Testing pumpTimeMismatch: Bool = false, lastGlucoseDate: Date?, maxIOB: Decimal = 10, + tidepoolLoginRequired: Bool = false, hasUnacknowledgedReleaseNotes: Bool = false, dosingMode: DosingMode = .closed ) -> MultiUsePanelState { @@ -21,6 +22,7 @@ import Testing pumpTimeMismatch: pumpTimeMismatch, lastGlucoseDate: lastGlucoseDate, maxIOB: maxIOB, + tidepoolLoginRequired: tidepoolLoginRequired, hasUnacknowledgedReleaseNotes: hasUnacknowledgedReleaseNotes, dosingMode: dosingMode, now: now @@ -103,4 +105,15 @@ import Testing @Test("Acknowledged release notes fall back to stats") func testAcknowledgedShowsStats() { #expect(resolve(lastGlucoseDate: fresh, hasUnacknowledgedReleaseNotes: false) == .stats) } + + @Test("Tidepool login sits between dosing warnings and release notes") func testTidepoolLoginRequired() { + #expect(resolve(lastGlucoseDate: fresh, tidepoolLoginRequired: true) == .tidepoolLoginRequired) + #expect(resolve( + lastGlucoseDate: fresh, + tidepoolLoginRequired: true, + hasUnacknowledgedReleaseNotes: true + ) == .tidepoolLoginRequired) + #expect(resolve(lastGlucoseDate: fresh, maxIOB: 0, tidepoolLoginRequired: true) == .maxIOBZero) + #expect(resolve(lastGlucoseDate: stale, tidepoolLoginRequired: true) == .cgmStale) + } } From 6246f33bcce03e451303a87cdad5ead6cb50227a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Nov 2026 23:52:39 +0000 Subject: [PATCH 27/34] CI: Bump APP_DEV_VERSION to 1.0.2.69 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 0af9a5ca6c..8d335e5394 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.68 +APP_DEV_VERSION = 1.0.2.69 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 204c43bd64d345f3c740bb6a2cfa3e781cb8a678 Mon Sep 17 00:00:00 2001 From: Mike Plante <82073483+MikePlante1@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:54:34 -0500 Subject: [PATCH 28/34] Improve automated TestFlight notes for browser build (#1695) * Include dev version in TestFlight build notes Builds with a 4-part APP_DEV_VERSION (anything off main) now show it alongside the branch and commit, e.g. "dev 1.0.2.33 abc1234". * Create TestFlight What to Test note without a manual first entry fastlane's changelog option only updates What to Test notes that already exist on a build, so apps that never had one entered by hand got no notes. Passing the app's primary locale through localized_build_info makes fastlane create the note. * Add bundled release notes to TestFlight build notes Reads BundledReleaseNotes.json from the IPA (written by scripts/capture-release-notes.sh) and appends the newest release's summary, callouts and At A Glance bullets, matching what the in-app sheet shows. Builds without bundled notes keep the branch, version and commit line only. * Only add release notes to TestFlight notes on main builds Also fall back to en-US if the primary locale lookup fails, and read the bundled notes as UTF-8 regardless of the runner's locale. --- fastlane/Fastfile | 89 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 2497eb86ec..f2be4b4664 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -49,6 +49,70 @@ xcconfig = parse_xcconfig_file(xcconfig_path) # Access BUNDLE_IDENTIFIER from the xcconfig file after replacing $(DEVELOPMENT_TEAM) with ENV["TEAMID"] ENV["BUNDLE_ID"] = xcconfig["BUNDLE_IDENTIFIER"] +# Plain-text excerpt of the newest release notes that scripts/capture-release-notes.sh bundled +# into the IPA. Mirrors the in-app sheet (ReleaseNotes.swift): summary, callouts, then highlights. +def bundled_release_notes(ipa_path) + json = IO.popen(["unzip", "-p", ipa_path, "Payload/Trio.app/BundledReleaseNotes.json"], err: File::NULL, &:read).force_encoding("UTF-8") + release = JSON.parse(json).first + return nil unless release + + plain = ->(text) { text.gsub(/!\[[^\]]*\]\([^)]*\)/, "").gsub(/\[([^\]]*)\]\([^)]*\)/, '\1').gsub("**", "").delete("`") } + raw_lines = release["body"].gsub(/\r\n?/, "\n").split("\n") + lines = raw_lines.map(&:strip) + + summary = [] + lines.each do |line| + break if line.start_with?("##") || ["---", "***"].include?(line) + next if line.empty? || line.start_with?("#", ">") + summary << plain.(line) + end + + callouts = [] + current = nil + lines.each do |line| + unless line.start_with?(">") + current = nil + next + end + content = line[1..].strip + if (marker = content[/\A\[!([^\]]*)\]/, 1]) + kind = marker.upcase + current = %w[NOTE TIP IMPORTANT WARNING CAUTION].include?(kind) ? [kind, []] : nil + callouts << current if current + elsif current && !content.empty? + current[1] << plain.(content) + end + end + + highlights = [] + inside = false + raw_lines.each do |raw| + line = raw.strip + if line.start_with?("#") + heading = line.sub(/\A#+/, "").strip + if heading.casecmp?("What's Changed At A Glance") + inside = true + elsif inside + break + end + next + end + next unless inside + break if ["---", "***"].include?(line) + next unless line.start_with?("- ", "* ") && !line[2..].strip.empty? + level = [raw[/\A[ \t]*/].gsub("\t", " ").length / 2, 2].min + highlights << " " * level + "- " + plain.(line[2..].strip) + end + + sections = [release["name"], summary.join("\n\n")] + sections += callouts.reject { |_, text| text.empty? }.map { |kind, text| "#{kind}:\n" + text.join("\n") } + sections << "What's Changed At A Glance\n" + highlights.join("\n") unless highlights.empty? + sections.reject(&:empty?).join("\n\n") +rescue StandardError => e + UI.important("Could not read bundled release notes: #{e}") + nil +end + # limit lane names to letters and underscores platform :ios do desc "Build Trio" @@ -145,13 +209,34 @@ platform :ios do issuer_id: "#{FASTLANE_ISSUER_ID}", key_content: "#{FASTLANE_KEY}" ) - + + # APP_DEV_VERSION only has a 4th component off main (e.g. 1.0.2.33 on dev) + dev_version = xcconfig["APP_DEV_VERSION"].to_s + on_main = dev_version.split(".").length != 4 + version_note = on_main ? "" : " "+dev_version + notes = git_branch+version_note+" "+last_git_commit[:abbreviated_commit_hash] + release_notes = on_main && bundled_release_notes("#{GITHUB_WORKSPACE}/Trio.ipa") + notes += "\n\n"+release_notes if release_notes + + # changelog alone only updates existing "What to Test" notes, so name the primary locale to create one. + # Keep changelog too: without it, skip_waiting_for_build_processing returns before notes are set. + primary_locale = begin + Spaceship::ConnectAPI::App.find(ENV["BUNDLE_ID"])&.primary_locale + rescue StandardError => e + UI.important("Could not look up primary locale, using en-US: #{e}") + nil + end || "en-US" + upload_to_testflight( api_key: api_key, skip_submission: false, ipa: "Trio.ipa", skip_waiting_for_build_processing: true, - changelog: git_branch+" "+last_git_commit[:abbreviated_commit_hash], + changelog: notes, + localized_build_info: { + "default" => { whats_new: notes }, + primary_locale => { whats_new: notes }, + }, ) end From 1197b518ec2a5bfebaa4e97831e42ba3041bcf02 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 12 Nov 2026 22:54:50 +0000 Subject: [PATCH 29/34] CI: Bump APP_DEV_VERSION to 1.0.2.70 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 8d335e5394..c6a86ef2e8 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.69 +APP_DEV_VERSION = 1.0.2.70 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 86a9b5ea4f1c333936b09aaccb8957d42cfeed6a Mon Sep 17 00:00:00 2001 From: Luka Zivkovic Date: Fri, 13 Nov 2026 18:24:59 +0100 Subject: [PATCH 30/34] Fix "Sponsor this project" link --- .github/FUNDING.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 5b46bd62b9..82877d12e5 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,3 @@ # These are supported funding model platforms -custom: ["https://www.nightscoutfoundation.org/donate"] +custom: ["https://donate.nightscoutfoundation.org"] From 3f6b238b450d423f9aa2585e173d50d212d5273d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 13 Nov 2026 18:29:41 +0000 Subject: [PATCH 31/34] CI: Bump APP_DEV_VERSION to 1.0.2.71 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index c6a86ef2e8..7ddc421a39 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.70 +APP_DEV_VERSION = 1.0.2.71 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From f6933f967bac0fc9225ca988df2dd43fa7387158 Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 14 Nov 2026 09:54:48 +0100 Subject: [PATCH 32/34] Updated submodule MedtrumKit --- MedtrumKit | 2 +- Testflight/WhatToTest.en-CA.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/MedtrumKit b/MedtrumKit index 2b01ac1ccd..6541d21f64 160000 --- a/MedtrumKit +++ b/MedtrumKit @@ -1 +1 @@ -Subproject commit 2b01ac1ccdca5b4fc786b6f503ccce3ff503b00a +Subproject commit 6541d21f64b5013b842ffa64d10eb154abce33c4 diff --git a/Testflight/WhatToTest.en-CA.txt b/Testflight/WhatToTest.en-CA.txt index 81de64f0d0..826292ca81 100644 --- a/Testflight/WhatToTest.en-CA.txt +++ b/Testflight/WhatToTest.en-CA.txt @@ -1,2 +1,2 @@ based on Trio dev 1.0.2.71 -check basal inkrement +do M1 medtrum with old Loopkit From cd4165eb525b6bc709be29ae2d3b8801392e9160 Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 14 Nov 2026 10:05:35 +0100 Subject: [PATCH 33/34] Test build: pin LoopKit to sync/trio-next-dev-through-e6b922d7 and MedtrumKit to M2 --- .gitmodules | 8 ++++---- LoopKit | 2 +- MedtrumKit | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.gitmodules b/.gitmodules index 79da6ecc30..86d45762a9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "LoopKit"] path = LoopKit - url = https://github.com/loopandlearn/LoopKit.git - branch = trio-next-dev + url = https://github.com/mountrcg/LoopKit.git + branch = sync/trio-next-dev-through-e6b922d7 [submodule "CGMBLEKit"] path = CGMBLEKit url = https://github.com/loopandlearn/CGMBLEKit.git @@ -36,8 +36,8 @@ branch = trio-next-dev [submodule "MedtrumKit"] path = MedtrumKit - url = https://github.com/loopandlearn/MedtrumKit - branch = trio-next-dev + url = https://github.com/mountrcg/MedtrumKit.git + branch = feat/next-dev-connection-status-activation-ux [submodule "OmnipodKit"] path = OmnipodKit url = https://github.com/loopandlearn/OmnipodKit diff --git a/LoopKit b/LoopKit index babddf0453..40a5110810 160000 --- a/LoopKit +++ b/LoopKit @@ -1 +1 @@ -Subproject commit babddf04536c04c6852ee7c76a1905164ee9893d +Subproject commit 40a5110810e87d3da83e796c2d23fc83ca77badd diff --git a/MedtrumKit b/MedtrumKit index 6541d21f64..b007ee8554 160000 --- a/MedtrumKit +++ b/MedtrumKit @@ -1 +1 @@ -Subproject commit 6541d21f64b5013b842ffa64d10eb154abce33c4 +Subproject commit b007ee8554f4d73306fb739ede26eac1061601a8 From e8cc63e8f3185e6c6697883c0bb8e47769144073 Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 14 Nov 2026 10:08:27 +0100 Subject: [PATCH 34/34] Test build: remove Omnipod, MiniMed, Dana and RileyLink from the app build Only Medtrum and the pump simulator stay in the device catalog. The removed kits have not adopted the async delegate API of the pinned LoopKit, so they are unlinked from the Trio target, dropped from the workspace and the Trio scheme's testables, and their test cases trimmed. --- Trio.xcodeproj/project.pbxproj | 50 ---------------- .../xcshareddata/xcschemes/Trio.xcscheme | 20 ------- Trio.xcworkspace/contents.xcworkspacedata | 12 ---- Trio/Sources/APS/DeviceDataManager.swift | 35 ------------ Trio/Sources/APS/Devices/DeviceCatalog.swift | 35 ------------ .../Extensions/UserDefaultsExtensions.swift | 16 ------ .../Onboarding/OnboardingStateModel.swift | 3 - .../PumpConfig/PumpConfigProvider.swift | 1 - .../BasalRoundingDriverParityTests.swift | 15 ----- TrioTests/DeviceCatalogTests.swift | 57 ++++--------------- 10 files changed, 11 insertions(+), 233 deletions(-) diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 5769e874d2..fadf53abe2 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -228,8 +228,6 @@ 3B4BA76B2D8DBD690069D5B8 /* CGMBLEKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75B2D8DBD690069D5B8 /* CGMBLEKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3B4BA76C2D8DBD690069D5B8 /* CGMBLEKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75C2D8DBD690069D5B8 /* CGMBLEKitUI.framework */; }; 3B4BA76D2D8DBD690069D5B8 /* CGMBLEKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75C2D8DBD690069D5B8 /* CGMBLEKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 3B4BA76E2D8DBD690069D5B8 /* DanaKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75D2D8DBD690069D5B8 /* DanaKit.framework */; }; - 3B4BA76F2D8DBD690069D5B8 /* DanaKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75D2D8DBD690069D5B8 /* DanaKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3B4BA7702D8DBD690069D5B8 /* G7SensorKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75E2D8DBD690069D5B8 /* G7SensorKit.framework */; }; 3B4BA7712D8DBD690069D5B8 /* G7SensorKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75E2D8DBD690069D5B8 /* G7SensorKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3B4BA7722D8DBD690069D5B8 /* G7SensorKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA75F2D8DBD690069D5B8 /* G7SensorKitUI.framework */; }; @@ -238,16 +236,6 @@ 3B4BA7752D8DBD690069D5B8 /* LibreTransmitter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7602D8DBD690069D5B8 /* LibreTransmitter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3B4BA7762D8DBD690069D5B8 /* LibreTransmitterUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7612D8DBD690069D5B8 /* LibreTransmitterUI.framework */; }; 3B4BA7772D8DBD690069D5B8 /* LibreTransmitterUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7612D8DBD690069D5B8 /* LibreTransmitterUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 3B4BA7782D8DBD690069D5B8 /* MinimedKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7622D8DBD690069D5B8 /* MinimedKit.framework */; }; - 3B4BA7792D8DBD690069D5B8 /* MinimedKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7622D8DBD690069D5B8 /* MinimedKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 3B4BA77A2D8DBD690069D5B8 /* MinimedKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7632D8DBD690069D5B8 /* MinimedKitUI.framework */; }; - 3B4BA77B2D8DBD690069D5B8 /* MinimedKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7632D8DBD690069D5B8 /* MinimedKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 3B4BA7822D8DBD690069D5B8 /* RileyLinkBLEKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7672D8DBD690069D5B8 /* RileyLinkBLEKit.framework */; }; - 3B4BA7832D8DBD690069D5B8 /* RileyLinkBLEKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7672D8DBD690069D5B8 /* RileyLinkBLEKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 3B4BA7842D8DBD690069D5B8 /* RileyLinkKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7682D8DBD690069D5B8 /* RileyLinkKit.framework */; }; - 3B4BA7852D8DBD690069D5B8 /* RileyLinkKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7682D8DBD690069D5B8 /* RileyLinkKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 3B4BA7862D8DBD690069D5B8 /* RileyLinkKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7692D8DBD690069D5B8 /* RileyLinkKitUI.framework */; }; - 3B4BA7872D8DBD690069D5B8 /* RileyLinkKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7692D8DBD690069D5B8 /* RileyLinkKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3B4BA78A2D8DC0EC0069D5B8 /* ShareClient.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE398D1A297D69A900DF218F /* ShareClient.framework */; }; 3B4BA78B2D8DC0EC0069D5B8 /* ShareClient.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CE398D1A297D69A900DF218F /* ShareClient.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3B4BA78C2D8DC0EC0069D5B8 /* ShareClientUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE79502D29980E4D00FA576E /* ShareClientUI.framework */; }; @@ -520,8 +508,6 @@ B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */; }; E1B0A5D00000000000000002 /* EnactBolusAmountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0A5D00000000000000001 /* EnactBolusAmountTests.swift */; }; B63C9D934FC54853BC4EF29A /* NightscoutUploadSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E61A0885164081B57C357C /* NightscoutUploadSerializer.swift */; }; - B6E925132EB3932A0076D719 /* OmnipodKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6E925122EB3932A0076D719 /* OmnipodKit.framework */; }; - B6E925142EB3932A0076D719 /* OmnipodKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = B6E925122EB3932A0076D719 /* OmnipodKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; B958F1B72BA0711600484851 /* MKRingProgressView in Frameworks */ = {isa = PBXBuildFile; productRef = B958F1B62BA0711600484851 /* MKRingProgressView */; }; BA00D96F7B2FF169A06FB530 /* CGMSettingsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C018D1680307A31C9ED7120 /* CGMSettingsStateModel.swift */; }; BB5227A51D9D4377A1A70BA6 /* TempTargetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8F3B0E159844FD8746DFC0 /* TempTargetData.swift */; }; @@ -1280,28 +1266,21 @@ 3B4BA7732D8DBD690069D5B8 /* G7SensorKitUI.framework in Embed Frameworks */, 3B4BA76D2D8DBD690069D5B8 /* CGMBLEKitUI.framework in Embed Frameworks */, 3B4BA76B2D8DBD690069D5B8 /* CGMBLEKit.framework in Embed Frameworks */, - 3B4BA7792D8DBD690069D5B8 /* MinimedKit.framework in Embed Frameworks */, CE95BF5C2BA770C300DC3DE3 /* LoopKit.framework in Embed Frameworks */, 3B4BA7712D8DBD690069D5B8 /* G7SensorKit.framework in Embed Frameworks */, 3E54EF2D2E476DA40006F54D /* MedtrumKit.framework in Embed Frameworks */, CEB434FE28B90B8C00B70274 /* SwiftCharts in Embed Frameworks */, - 3B4BA76F2D8DBD690069D5B8 /* DanaKit.framework in Embed Frameworks */, - 3B4BA7852D8DBD690069D5B8 /* RileyLinkKit.framework in Embed Frameworks */, 3B4BA7752D8DBD690069D5B8 /* LibreTransmitter.framework in Embed Frameworks */, 3B4BA7772D8DBD690069D5B8 /* LibreTransmitterUI.framework in Embed Frameworks */, 5E12FE0E2EBC0000000000C1 /* LibreLoop.framework in Embed Frameworks */, 5E12FE0E2EBC0000000000C2 /* LibreLoopUI.framework in Embed Frameworks */, - 3B4BA77B2D8DBD690069D5B8 /* MinimedKitUI.framework in Embed Frameworks */, - 3B4BA7832D8DBD690069D5B8 /* RileyLinkBLEKit.framework in Embed Frameworks */, CE95BF642BA771BE00DC3DE3 /* LoopTestingKit.framework in Embed Frameworks */, CE95BF622BA7715900DC3DE3 /* MockKitUI.framework in Embed Frameworks */, 3B4BA78D2D8DC0EC0069D5B8 /* ShareClientUI.framework in Embed Frameworks */, 3E84DA412F48D96000033608 /* EversenseKit.framework in Embed Frameworks */, - 3B4BA7872D8DBD690069D5B8 /* RileyLinkKitUI.framework in Embed Frameworks */, 3B4BA78B2D8DC0EC0069D5B8 /* ShareClient.framework in Embed Frameworks */, 3B4BA7912D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework in Embed Frameworks */, CE95BF602BA7715800DC3DE3 /* MockKit.framework in Embed Frameworks */, - B6E925142EB3932A0076D719 /* OmnipodKit.framework in Embed Frameworks */, 3E5308A63002BC970075629E /* AccuChekKit.framework in Embed Frameworks */, CE95BF5E2BA770C300DC3DE3 /* LoopKitUI.framework in Embed Frameworks */, ); @@ -1442,8 +1421,6 @@ 3818AA4D274C26A300843DB3 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3818AA4E274C26A300843DB3 /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3818AA4F274C26A300843DB3 /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3818AA51274C26A300843DB3 /* MinimedKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MinimedKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3818AA52274C26A300843DB3 /* MinimedKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MinimedKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3818AA70274C278200843DB3 /* LoopTestingKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopTestingKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 38192E03261B82FA0094D973 /* ReachabilityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReachabilityManager.swift; sourceTree = ""; }; 38192E06261BA9960094D973 /* FetchTreatmentsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchTreatmentsManager.swift; sourceTree = ""; }; @@ -1573,16 +1550,10 @@ 3B4550522D862BF200551B0D /* PumpHistoryEvent+Duplicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PumpHistoryEvent+Duplicates.swift"; sourceTree = ""; }; 3B4BA75B2D8DBD690069D5B8 /* CGMBLEKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CGMBLEKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4BA75C2D8DBD690069D5B8 /* CGMBLEKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CGMBLEKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B4BA75D2D8DBD690069D5B8 /* DanaKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = DanaKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4BA75E2D8DBD690069D5B8 /* G7SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = G7SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4BA75F2D8DBD690069D5B8 /* G7SensorKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = G7SensorKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4BA7602D8DBD690069D5B8 /* LibreTransmitter.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LibreTransmitter.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4BA7612D8DBD690069D5B8 /* LibreTransmitterUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LibreTransmitterUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B4BA7622D8DBD690069D5B8 /* MinimedKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MinimedKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B4BA7632D8DBD690069D5B8 /* MinimedKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MinimedKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B4BA7672D8DBD690069D5B8 /* RileyLinkBLEKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RileyLinkBLEKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B4BA7682D8DBD690069D5B8 /* RileyLinkKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RileyLinkKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B4BA7692D8DBD690069D5B8 /* RileyLinkKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RileyLinkKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4BA7882D8DC0EC0069D5B8 /* TidepoolServiceKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = TidepoolServiceKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = TidepoolServiceKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B506FD82E635304000740B9 /* IOBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBService.swift; sourceTree = ""; }; @@ -1849,7 +1820,6 @@ B3919BBB515547118D684CD2 /* LoopIndicatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LoopIndicatorTests.swift; sourceTree = ""; }; B3919BBB515547118D684CC2 /* MaxIOBSuggestionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MaxIOBSuggestionTests.swift; sourceTree = ""; }; B3919BBB515547118D684CB2 /* DosingModeMigrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DosingModeMigrationTests.swift; sourceTree = ""; }; - B6E925122EB3932A0076D719 /* OmnipodKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OmnipodKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B9B5C0607505A38F256BF99A /* CGMSettingsDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CGMSettingsDataFlow.swift; sourceTree = ""; }; BA49538D56989D8DA6FCF538 /* TargetsEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorDataFlow.swift; sourceTree = ""; }; BD04ECCD2D299522008C5FEB /* BolusProgressOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressOverlay.swift; sourceTree = ""; }; @@ -2209,8 +2179,6 @@ CEB434E228B8F9DB00B70274 /* BluetoothStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateManager.swift; sourceTree = ""; }; CEB434E428B8FF5D00B70274 /* UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; CEB434E628B9053300B70274 /* LoopUIColorPalette+Default.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopUIColorPalette+Default.swift"; sourceTree = ""; }; - CEC751D529D88262006E9D24 /* MinimedKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MinimedKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - CEC751D729D88262006E9D24 /* MinimedKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MinimedKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CEE9A64F2BBB418300EB5194 /* CalibrationsProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsProvider.swift; sourceTree = ""; }; CEE9A6512BBB418300EB5194 /* CalibrationsRootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsRootView.swift; sourceTree = ""; }; CEE9A6522BBB418300EB5194 /* CalibrationsChart.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsChart.swift; sourceTree = ""; }; @@ -2565,21 +2533,15 @@ 5E12FE0E2EBC0000000000B1 /* LibreLoop.framework in Frameworks */, 5E12FE0E2EBC0000000000B2 /* LibreLoopUI.framework in Frameworks */, 3B4BA78C2D8DC0EC0069D5B8 /* ShareClientUI.framework in Frameworks */, - 3B4BA77A2D8DBD690069D5B8 /* MinimedKitUI.framework in Frameworks */, 3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */, 5E12FE112EBC0000000000B2 /* RoundWhiteDiscKit in Frameworks */, - 3B4BA7782D8DBD690069D5B8 /* MinimedKit.framework in Frameworks */, 3B4BA7762D8DBD690069D5B8 /* LibreTransmitterUI.framework in Frameworks */, 3E54EF2C2E476DA40006F54D /* MedtrumKit.framework in Frameworks */, 3B4BA7902D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework in Frameworks */, 3B4BA76A2D8DBD690069D5B8 /* CGMBLEKit.framework in Frameworks */, - 3B4BA7822D8DBD690069D5B8 /* RileyLinkBLEKit.framework in Frameworks */, - 3B4BA7842D8DBD690069D5B8 /* RileyLinkKit.framework in Frameworks */, - 3B4BA7862D8DBD690069D5B8 /* RileyLinkKitUI.framework in Frameworks */, 38E87403274F78C000975559 /* libswiftCoreNFC.tbd in Frameworks */, 38E87401274F77E400975559 /* CoreNFC.framework in Frameworks */, 3B4BA78A2D8DC0EC0069D5B8 /* ShareClient.framework in Frameworks */, - B6E925132EB3932A0076D719 /* OmnipodKit.framework in Frameworks */, 3E5308A53002BC970075629E /* AccuChekKit.framework in Frameworks */, 3811DE1025C9D37700A708ED /* Swinject in Frameworks */, 3B4BA78E2D8DC0EC0069D5B8 /* TidepoolServiceKit.framework in Frameworks */, @@ -2590,7 +2552,6 @@ CE95BF5B2BA770C300DC3DE3 /* LoopKit.framework in Frameworks */, 38B17B6625DD90E0005CAE3D /* SwiftDate in Frameworks */, 3833B46D26012030003021B3 /* Algorithms in Frameworks */, - 3B4BA76E2D8DBD690069D5B8 /* DanaKit.framework in Frameworks */, 3B47C6102DA0A28F00B0E5EF /* FirebaseCrashlytics in Frameworks */, CEB434FD28B90B7C00B70274 /* SwiftCharts in Frameworks */, CE95BF5F2BA7715800DC3DE3 /* MockKit.framework in Frameworks */, @@ -3326,30 +3287,21 @@ 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */, 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */, 3E5308A43002BC970075629E /* AccuChekKit.framework */, - B6E925122EB3932A0076D719 /* OmnipodKit.framework */, 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */, 3E84DA3F2F48D96000033608 /* EversenseKit.framework */, 3B4BA7882D8DC0EC0069D5B8 /* TidepoolServiceKit.framework */, 3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */, 3B4BA75B2D8DBD690069D5B8 /* CGMBLEKit.framework */, 3B4BA75C2D8DBD690069D5B8 /* CGMBLEKitUI.framework */, - 3B4BA75D2D8DBD690069D5B8 /* DanaKit.framework */, 3B4BA75E2D8DBD690069D5B8 /* G7SensorKit.framework */, 3B4BA75F2D8DBD690069D5B8 /* G7SensorKitUI.framework */, 3B4BA7602D8DBD690069D5B8 /* LibreTransmitter.framework */, 3B4BA7612D8DBD690069D5B8 /* LibreTransmitterUI.framework */, 5E12FE0E2EBC0000000000A1 /* LibreLoop.framework */, 5E12FE0E2EBC0000000000A2 /* LibreLoopUI.framework */, - 3B4BA7622D8DBD690069D5B8 /* MinimedKit.framework */, - 3B4BA7632D8DBD690069D5B8 /* MinimedKitUI.framework */, - 3B4BA7672D8DBD690069D5B8 /* RileyLinkBLEKit.framework */, - 3B4BA7682D8DBD690069D5B8 /* RileyLinkKit.framework */, - 3B4BA7692D8DBD690069D5B8 /* RileyLinkKitUI.framework */, CE95BF492BA5CED700DC3DE3 /* LoopKit.framework */, CE95BF4A2BA5CED700DC3DE3 /* LoopKitUI.framework */, CE51DD1B2A01970800F163F7 /* ConnectIQ 2.xcframework */, - CEC751D529D88262006E9D24 /* MinimedKit.framework */, - CEC751D729D88262006E9D24 /* MinimedKitUI.framework */, CE79502D29980E4D00FA576E /* ShareClientUI.framework */, CE79502929980C9F00FA576E /* G7SensorKitUI.framework */, CE79502729980C9600FA576E /* CGMBLEKitUI.framework */, @@ -3364,8 +3316,6 @@ 3818AA4D274C26A300843DB3 /* LoopKitUI.framework */, 3818AA4E274C26A300843DB3 /* MockKit.framework */, 3818AA4F274C26A300843DB3 /* MockKitUI.framework */, - 3818AA51274C26A300843DB3 /* MinimedKit.framework */, - 3818AA52274C26A300843DB3 /* MinimedKitUI.framework */, 3818AA49274C267000843DB3 /* CGMBLEKit.framework */, 6B1A8D012B14D88B00E76752 /* UniformTypeIdentifiers.framework */, 6B1A8D182B14D91600E76752 /* WidgetKit.framework */, diff --git a/Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme b/Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme index cec3d6c604..66a19df58e 100644 --- a/Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme +++ b/Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme @@ -181,26 +181,6 @@ ReferencedContainer = "container:LoopKit/LoopKit.xcodeproj"> - - - - - - - - - - - - - - - - diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index a8fc3177a5..0485838869 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -1,14 +1,11 @@ import Combine import CoreData -import DanaKit import Foundation import LoopKit import LoopKitUI import MedtrumKit -import MinimedKit import MockKit import MockKitUI -import OmnipodKit import ShareClient import SwiftDate import Swinject @@ -154,13 +151,6 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { pumpActivatedAtDate.send(medtrumPump.state.patchActivatedAt) } } - if let omni = pumpManager as? OmniPumpManager { - guard let endTime = omni.state.podState?.expiresAt else { - pumpExpiresAtDate.send(nil) - return - } - pumpExpiresAtDate.send(endTime) - } if let simulatorPump = pumpManager as? MockPumpManager { pumpDisplayState.value = PumpDisplayState(name: simulatorPump.localizedTitle, image: simulatorPump.smallImage) pumpName.send(simulatorPump.localizedTitle) @@ -537,22 +527,6 @@ extension BaseDeviceDataManager: PumpManagerDelegate { } } - if let omni = pumpManager as? OmniPumpManager { - broadcaster.notify(PumpReservoirObserver.self, on: processQueue) { - $0.pumpReservoirDidChange(omni.currentReservoir) - } - - guard let endTime = omni.state.podState?.expiresAt else { - pumpExpiresAtDate.send(nil) - return - } - pumpExpiresAtDate.send(endTime) - - if let startTime = omni.state.podState?.activatedAt { - storage.save(startTime, as: OpenAPS.Monitor.podAge) - } - } - if let simulatorPump = pumpManager as? MockPumpManager { broadcaster.notify(PumpReservoirObserver.self, on: processQueue) { $0.pumpReservoirDidChange(Decimal(simulatorPump.state.reservoirUnitsRemaining)) @@ -717,15 +691,6 @@ extension PumpManager { case let medtrum as MedtrumPumpManager: // MedtrumKit keeps the last patch's reservoir until a new patch is activated return medtrum.state.patchSessionEnded ? nil : Decimal(medtrum.state.reservoir) - case let omni as OmniPumpManager: - guard let podState = omni.state.podState else { return nil } - guard let level = podState.lastInsulinMeasurements?.reservoirLevel, level <= 50 else { return 0xDEAD_BEEF } - return Decimal(level) - case let dana as DanaKitPumpManager: - // pumpTime is set on the first sync, after the same connection has read the reservoir - return dana.state.pumpTime == nil ? nil : Decimal(dana.state.reservoirLevel) - case let minimed as MinimedPumpManager: - return minimed.state.lastReservoirReading.map { Decimal($0.units) } case let mock as MockPumpManager: return Decimal(mock.state.reservoirUnitsRemaining) default: diff --git a/Trio/Sources/APS/Devices/DeviceCatalog.swift b/Trio/Sources/APS/Devices/DeviceCatalog.swift index 15d0f8d131..392ee01926 100644 --- a/Trio/Sources/APS/Devices/DeviceCatalog.swift +++ b/Trio/Sources/APS/Devices/DeviceCatalog.swift @@ -3,7 +3,6 @@ import AccuChekKit import CGMBLEKit import CGMBLEKitUI -import DanaKit import EversenseKit import Foundation import G7SensorKit @@ -16,12 +15,9 @@ import LoopKit import LoopKitUI import MachO import MedtrumKit -import MinimedKit -import MinimedKitUI import MockKit import MockKitUI import ObjectiveC -import OmnipodKit import SyaiKit import SyaiKitUI import UIKit @@ -415,27 +411,6 @@ extension DeviceCatalog { ] static let pumps: [PumpCatalogEntry] = [ - PumpCatalogEntry( - OmniPumpManager.self, - id: "Omni", - manufacturer: .insulet, - name: "Omnipod", - supportedModels: ["Classic", "DASH", "5"], - icon: .managerBundle(asset: "Pod"), - legacyIdentifierPrefixes: ["Omni"], - basalCapability: BasalRateCapability(supportedRates: OmniPumpManager.onboardingSupportedBasalRates), - reportsRewindEvents: false - ), - PumpCatalogEntry( - MinimedPumpManager.self, - id: "Minimed500", - manufacturer: .medtronic, - name: "MiniMed", - supportedModels: ["x15", "x22", "x23", "x54"], - icon: .uiBundle(identifier: "org.loopkit.MinimedKitUI", asset: "5xx Small Outline"), - basalCapability: BasalRateCapability(supportedRates: MinimedPumpManager.onboardingSupportedBasalRates), - reportsRewindEvents: true - ), PumpCatalogEntry( MedtrumPumpManager.self, id: "Medtrum", @@ -446,16 +421,6 @@ extension DeviceCatalog { basalCapability: BasalRateCapability(supportedRates: MedtrumPumpManager.onboardingSupportedBasalRates), reportsRewindEvents: false ), - PumpCatalogEntry( - DanaKitPumpManager.self, - id: "Dana", - manufacturer: .sooil, - name: "Dana", - supportedModels: ["DanaRS", "Dana-i"], - icon: .managerBundle(asset: "danars"), - basalCapability: BasalRateCapability(supportedRates: DanaKitPumpManager.onboardingSupportedBasalRates), - reportsRewindEvents: true - ), PumpCatalogEntry( MockPumpManager.self, id: "MockPumpManager", diff --git a/Trio/Sources/APS/Extensions/UserDefaultsExtensions.swift b/Trio/Sources/APS/Extensions/UserDefaultsExtensions.swift index 31d53c02d5..8734fe6d93 100644 --- a/Trio/Sources/APS/Extensions/UserDefaultsExtensions.swift +++ b/Trio/Sources/APS/Extensions/UserDefaultsExtensions.swift @@ -1,29 +1,13 @@ import Foundation import LoopKit -import RileyLinkBLEKit -import RileyLinkKit extension UserDefaults { private enum Key: String { case legacyPumpManagerRawValue = "com.rileylink.PumpManagerRawValue" - case rileyLinkConnectionManagerState = "com.rileylink.RileyLinkConnectionManagerState" case legacyPumpManagerState = "com.loopkit.Loop.PumpManagerState" case legacyCGMManagerState = "com.loopkit.Loop.CGMManagerState" } - var rileyLinkConnectionManagerState: RileyLinkConnectionState? { - get { - guard let rawValue = dictionary(forKey: Key.rileyLinkConnectionManagerState.rawValue) - else { - return nil - } - return RileyLinkConnectionState(rawValue: rawValue) - } - set { - set(newValue?.rawValue, forKey: Key.rileyLinkConnectionManagerState.rawValue) - } - } - var legacyPumpManagerRawValue: PumpManager.RawValue? { dictionary(forKey: Key.legacyPumpManagerRawValue.rawValue) } diff --git a/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift b/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift index 43e4df60a2..8c555db919 100644 --- a/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift +++ b/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift @@ -1,11 +1,8 @@ import Combine -import DanaKit import Foundation import LoopKit import MedtrumKit -import MinimedKit import Observation -import OmnipodKit import SwiftUI /// Model that holds the data collected during onboarding. diff --git a/Trio/Sources/Modules/PumpConfig/PumpConfigProvider.swift b/Trio/Sources/Modules/PumpConfig/PumpConfigProvider.swift index 8126bccdb4..1374db92cc 100644 --- a/Trio/Sources/Modules/PumpConfig/PumpConfigProvider.swift +++ b/Trio/Sources/Modules/PumpConfig/PumpConfigProvider.swift @@ -2,7 +2,6 @@ import Combine import HealthKit import LoopKit import LoopKitUI -import RileyLinkBLEKit extension PumpConfig { final class Provider: BaseProvider, PumpConfigProvider { diff --git a/TrioTests/BasalRoundingDriverParityTests.swift b/TrioTests/BasalRoundingDriverParityTests.swift index b9259bc32b..6319b9ce70 100644 --- a/TrioTests/BasalRoundingDriverParityTests.swift +++ b/TrioTests/BasalRoundingDriverParityTests.swift @@ -1,7 +1,5 @@ -import DanaKit import Foundation import MedtrumKit -import MinimedKit import Testing @testable import Trio @@ -35,9 +33,6 @@ import Testing ] private static let tables: [(String, [Double])] = [ - ("Minimed 723 (gen >= 23)", PumpModel.model723.supportedBasalRates), - ("Minimed 522 (pre-x23)", PumpModel.model522.supportedBasalRates), - ("Dana", DanaKitPumpManager.onboardingSupportedBasalRates), ("Medtrum", MedtrumPumpManager.onboardingSupportedBasalRates), // Pod's table is a local inside OmnipodKit's BasalDeliveryTable, so replicate Eros here ("Omnipod Eros", (1 ... 600).map { Double($0) / 20 }) @@ -85,16 +80,6 @@ import Testing } } - @Test("the lossy conversion this replaced really did drop an increment") func lossyConversionRegression() { - // pins why nearestDouble exists, so nobody reverts it to Double(truncating:) - let dana = DanaKitPumpManager.onboardingSupportedBasalRates - for rate in [Decimal(7) / 100, Decimal(14) / 100, Decimal(199) / 100] { - let lossy = Double(truncating: rate as NSNumber) - #expect((dana.last { $0 <= lossy } ?? 0) != rate.nearestDouble) - #expect((dana.last { $0 <= rate.nearestDouble } ?? 0) == rate.nearestDouble) - } - } - @Test("every kit table is non-empty and normalises without collisions") func tablesNormaliseCleanly() { for (_, table) in Self.tables { let rates = normalised(table) diff --git a/TrioTests/DeviceCatalogTests.swift b/TrioTests/DeviceCatalogTests.swift index b7f76c678f..68a6a0989c 100644 --- a/TrioTests/DeviceCatalogTests.swift +++ b/TrioTests/DeviceCatalogTests.swift @@ -1,11 +1,8 @@ -import DanaKit import Foundation import LoopKitUI import MachO import MedtrumKit -import MinimedKit import ObjectiveC -import OmnipodKit import Testing @testable import Trio import UIKit @@ -36,10 +33,7 @@ import UIKit /// LoopKit only exposes `pluginIdentifier` per instance, so the catalog states it; these are persisted values. @Test("Pump entry ids are their manager's persisted plugin identifier") func testPumpIdentifiersMatchManagers() { let expected = [ - "OmniPumpManager": "Omni", - "MinimedPumpManager": "Minimed500", "MedtrumPumpManager": "Medtrum", - "DanaKitPumpManager": "Dana", "MockPumpManager": "MockPumpManager" ] for entry in DeviceCatalog.pumps { @@ -59,20 +53,6 @@ import UIKit // MARK: - Persisted identifier resolution - @Test("Legacy Omnipod identifiers resolve to the universal Omnipod manager") func testLegacyOmnipodIdentifiers() { - // OmniKit persisted "Omnipod" and OmniBLE persisted "Omnipod-DASH" before OmnipodKit unified them. - for identifier in ["Omni", "Omnipod", "Omnipod-DASH"] { - let entry = DeviceCatalog.pumpEntry(forPersistedIdentifier: identifier) - #expect(entry?.name == "Omnipod", "\(identifier) should resolve to Omnipod, got \(entry?.name ?? "nil")") - } - } - - @Test("MiniMed resolves by its real plugin identifier") func testMinimedIdentifier() { - // MinimedPumpManager.pluginIdentifier is "Minimed500", not "Minimed". - #expect(DeviceCatalog.pumpEntry(forPersistedIdentifier: "Minimed500")?.name == "MiniMed") - #expect(DeviceCatalog.pumpEntry(forPersistedIdentifier: "Minimed") == nil) - } - @Test("Unknown identifiers do not resolve") func testUnknownIdentifier() { #expect(DeviceCatalog.pumpEntry(forPersistedIdentifier: "NotARealPump") == nil) } @@ -130,9 +110,9 @@ import UIKit // MARK: - Model sub-lines @Test("Model sub-lines render as a comma-joined list") func testSupportedModelsLine() { - let omnipod = DeviceCatalog.pumps.first { $0.name == "Omnipod" } - #expect(omnipod?.supportedModelsLine == "Classic, DASH, 5") - #expect(omnipod?.hintLine == "Omnipod (Classic, DASH, 5)") + let medtrum = DeviceCatalog.pumps.first { $0.name == "Medtrum Nano" } + #expect(medtrum?.supportedModelsLine == "200U, 300U") + #expect(medtrum?.hintLine == "Medtrum Nano (200U, 300U)") let g5 = DeviceCatalog.cgms.first { $0.name == "Dexcom G5" } #expect(g5?.supportedModelsLine == nil, "An empty model list must collapse the row to one line") @@ -159,7 +139,11 @@ import UIKit "G6SensorKit" // native G6 transport, not wired up in Trio ] - let missingPumps = declared.pumps.subtracting(DeviceCatalog.pumps.map(\.id)) + let excludedPumps: Set = ["Omni", "Minimed500", "Dana"] + + let missingPumps = declared.pumps + .subtracting(DeviceCatalog.pumps.map(\.id)) + .subtracting(excludedPumps) #expect(missingPumps.isEmpty, "Vendored pumps absent from DeviceCatalog: \(missingPumps.sorted())") let missingCGMs = declared.cgms @@ -259,21 +243,9 @@ import UIKit #expect(!offered.contains { $0.manufacturer == .simulator }) } - @Test("Omnipod stays the onboarding fallback") func testFallbackIsOmnipod() { - #expect(DeviceCatalog.defaultOnboardingPump.name == "Omnipod") - // The old code fell back to the DASH bounds when nothing was selected. - // Now the kit's own grid, which excludes 0 for Eros compatibility. - let capability = DeviceCatalog.defaultOnboardingPump.basalCapability - #expect(capability == BasalRateCapability(minimum: 0.05, maximum: 30, step: 0.05)) - } - @Test("Basal bounds come from each kit's own declared grid") func testBasalBounds() { // Derived from each kit's declared grid, with 0 dropped because oref rejects a 0 basal rate. let expected: [String: BasalRateCapability] = [ - "Omnipod": BasalRateCapability(minimum: 0.05, maximum: 30, step: 0.05), - "MiniMed": BasalRateCapability(minimum: 0.05, maximum: 35, step: 0.05), - // Dana delivers in 0.01 steps; Trio previously offered 0.05. - "Dana": BasalRateCapability(minimum: 0.01, maximum: 3, step: 0.01), "Medtrum Nano": BasalRateCapability(minimum: 0.05, maximum: 30, step: 0.05) ] for (name, capability) in expected { @@ -286,9 +258,6 @@ import UIKit // A single min/max/step picker can only represent a uniform grid. If a kit ever ships a banded onboarding // grid (MiniMed's paired gen-23 grid is banded 0.025/0.05/0.1), this fails instead of silently coarsening. let grids: [String: [Double]] = [ - "Omnipod": OmniPumpManager.onboardingSupportedBasalRates, - "MiniMed": MinimedPumpManager.onboardingSupportedBasalRates, - "Dana": DanaKitPumpManager.onboardingSupportedBasalRates, "Medtrum Nano": MedtrumPumpManager.onboardingSupportedBasalRates ] for (name, grid) in grids { @@ -297,19 +266,15 @@ import UIKit } @Test("An upgrading user's paired pump is preselected") func testUpgradePreselection() { - #expect(DeviceCatalog.onboardingPump(forPersistedIdentifier: "Minimed500").name == "MiniMed") - #expect(DeviceCatalog.onboardingPump(forPersistedIdentifier: "Dana").name == "Dana") - // Legacy identifiers from the retired OmniKit / OmniBLE managers. - #expect(DeviceCatalog.onboardingPump(forPersistedIdentifier: "Omnipod-DASH").name == "Omnipod") // The simulator is catalogued but never offered in onboarding, so it must fall back rather than // preselecting an entry the picker has no row for. - #expect(DeviceCatalog.onboardingPump(forPersistedIdentifier: "MockPumpManager").name == "Omnipod") - #expect(DeviceCatalog.onboardingPump(forPersistedIdentifier: "NotAPump").name == "Omnipod") + #expect(DeviceCatalog.onboardingPump(forPersistedIdentifier: "MockPumpManager").name == "Medtrum Nano") + #expect(DeviceCatalog.onboardingPump(forPersistedIdentifier: "NotAPump").name == "Medtrum Nano") } @Test("Only tubed pumps report a rewind") func testRewindReporting() { // rewindResetsAutosens is only meaningful for pumps with a reservoir to rewind. - let expected = ["Omnipod": false, "MiniMed": true, "Dana": true, "Medtrum Nano": false] + let expected = ["Medtrum Nano": false] for (name, reports) in expected { let entry = DeviceCatalog.pumps.first { $0.name == name } #expect(entry?.reportsRewindEvents == reports, "\(name) rewind behaviour changed")