Bladeren bron

Device Alarms: tiered config + force-loop dwell bypass

* Force-loop bypass: HomeProvider.heartbeatNow marks the next loop
  user-initiated via APSManager.markNextLoopUserInitiated; loop()
  captures the flag for its scope so surfaceErrorIfNeeded bypasses
  dwell suppression. Transient errors during a manual force-loop
  surface immediately instead of waiting for count=2 / 60s.
* PumpAlertCategory: 13 fixed device-side categories mapping to one
  of three tiers via defaultSeverity.
* DeviceAlertSeverity (Critical / Time-Sensitive / Normal) +
  DeviceAlertSeverityConfig per tier with two independent toggles:
  playsSound and overridesSilenceAndDND. DeviceAlertsStore
  (UserDefaults) seeds the three tiers on load.
* TrioAlertManager.issueAlert applies tier config to pump / device
  alerts: drops sound when playsSound=false; derives interruption
  level from overridesSilenceAndDND (true→.critical,
  false→.timeSensitive). Glucose alarms bypass — owned by
  GlucoseAlertCoordinator.
* SwiftUI module DeviceAlarms — tier list with speaker-icon summary
  matching glucose rows, per-tier editor with both toggles.
* AlarmActiveSection / AlarmAudioSection refactored to take
  individual bindings so glucose + device editors share them.
* Route .deviceAlarms + nav link beneath Glucose Alarms.
Deniz Cengiz 4 weken geleden
bovenliggende
commit
271022d33a

+ 52 - 0
Trio.xcodeproj/project.pbxproj

@@ -202,6 +202,15 @@
 		BD1179792F4E22C100F90001 /* AlarmAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11797A2F4E22C100F90001 /* AlarmAudioSection.swift */; };
 		BD11797B2F4E22C100F90001 /* AlarmSoundCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11797C2F4E22C100F90001 /* AlarmSoundCatalog.swift */; };
 		BD11797D2F4E22C100F90001 /* AlarmWindowIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11797E2F4E22C100F90001 /* AlarmWindowIcon.swift */; };
+		BD1179812F4E22C100F90001 /* PumpAlertCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179822F4E22C100F90001 /* PumpAlertCategory.swift */; };
+		BD1179832F4E22C100F90001 /* DeviceAlertSeverity.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179842F4E22C100F90001 /* DeviceAlertSeverity.swift */; };
+		BD1179852F4E22C100F90001 /* DeviceAlertSeverityConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179862F4E22C100F90001 /* DeviceAlertSeverityConfig.swift */; };
+		BD1179872F4E22C100F90001 /* DeviceAlertsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179882F4E22C100F90001 /* DeviceAlertsStore.swift */; };
+		BD1179892F4E22C100F90001 /* DeviceAlarmsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11798A2F4E22C100F90001 /* DeviceAlarmsDataFlow.swift */; };
+		BD11798B2F4E22C100F90001 /* DeviceAlarmsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11798C2F4E22C100F90001 /* DeviceAlarmsProvider.swift */; };
+		BD11798D2F4E22C100F90001 /* DeviceAlarmsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11798E2F4E22C100F90001 /* DeviceAlarmsStateModel.swift */; };
+		BD11798F2F4E22C100F90001 /* DeviceAlarmsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179902F4E22C100F90001 /* DeviceAlarmsRootView.swift */; };
+		BD1179912F4E22C100F90001 /* DeviceAlarmEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179922F4E22C100F90001 /* DeviceAlarmEditorView.swift */; };
 		38E989DD25F5021400C0CED0 /* PumpStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E989DC25F5021400C0CED0 /* PumpStatus.swift */; };
 		38E98A2325F52C9300C0CED0 /* Signpost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A1B25F52C9300C0CED0 /* Signpost.swift */; };
 		38E98A2425F52C9300C0CED0 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A1C25F52C9300C0CED0 /* Logger.swift */; };
@@ -1113,6 +1122,15 @@
 		BD11797A2F4E22C100F90001 /* AlarmAudioSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmAudioSection.swift; sourceTree = "<group>"; };
 		BD11797C2F4E22C100F90001 /* AlarmSoundCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSoundCatalog.swift; sourceTree = "<group>"; };
 		BD11797E2F4E22C100F90001 /* AlarmWindowIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmWindowIcon.swift; sourceTree = "<group>"; };
+		BD1179822F4E22C100F90001 /* PumpAlertCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpAlertCategory.swift; sourceTree = "<group>"; };
+		BD1179842F4E22C100F90001 /* DeviceAlertSeverity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlertSeverity.swift; sourceTree = "<group>"; };
+		BD1179862F4E22C100F90001 /* DeviceAlertSeverityConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlertSeverityConfig.swift; sourceTree = "<group>"; };
+		BD1179882F4E22C100F90001 /* DeviceAlertsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlertsStore.swift; sourceTree = "<group>"; };
+		BD11798A2F4E22C100F90001 /* DeviceAlarmsDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlarmsDataFlow.swift; sourceTree = "<group>"; };
+		BD11798C2F4E22C100F90001 /* DeviceAlarmsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlarmsProvider.swift; sourceTree = "<group>"; };
+		BD11798E2F4E22C100F90001 /* DeviceAlarmsStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlarmsStateModel.swift; sourceTree = "<group>"; };
+		BD1179902F4E22C100F90001 /* DeviceAlarmsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlarmsRootView.swift; sourceTree = "<group>"; };
+		BD1179922F4E22C100F90001 /* DeviceAlarmEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlarmEditorView.swift; sourceTree = "<group>"; };
 		38E989DC25F5021400C0CED0 /* PumpStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpStatus.swift; sourceTree = "<group>"; };
 		38E98A1B25F52C9300C0CED0 /* Signpost.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Signpost.swift; sourceTree = "<group>"; };
 		38E98A1C25F52C9300C0CED0 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
@@ -2038,6 +2056,7 @@
 				C2AA6CF52E1A734A00BF6C16 /* SettingsExport */,
 				DD17454C2C55CA0200211FAC /* GeneralSettings */,
 				BD1179602F4E22C100F90001 /* GlucoseAlerts */,
+				BD11799A2F4E22C100F90001 /* DeviceAlarms */,
 				F90692CD274B99850037068D /* HealthKit */,
 				3811DE2725C9D49500A708ED /* Home */,
 				19E1F7E629D0828B005C8D20 /* IconConfig */,
@@ -2206,6 +2225,7 @@
 				BD1179412F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift */,
 				BD11795D2F4E22C100F90001 /* GlucoseAlertCoordinator.swift */,
 				BD1179592F4E22C100F90001 /* GlucoseAlertsStore.swift */,
+				BD1179882F4E22C100F90001 /* DeviceAlertsStore.swift */,
 				BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */,
 				BD1179212F4E22C100F90001 /* TrioAlertManager.swift */,
 				BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */,
@@ -2554,6 +2574,9 @@
 				BD1179532F4E22C100F90001 /* GlucoseAlert.swift */,
 				BD1179552F4E22C100F90001 /* GlucoseAlertConfiguration.swift */,
 				BD1179572F4E22C100F90001 /* GlucoseAlertType.swift */,
+				BD1179822F4E22C100F90001 /* PumpAlertCategory.swift */,
+				BD1179842F4E22C100F90001 /* DeviceAlertSeverity.swift */,
+				BD1179862F4E22C100F90001 /* DeviceAlertSeverityConfig.swift */,
 			);
 			path = GlucoseAlerts;
 			sourceTree = "<group>";
@@ -3994,6 +4017,26 @@
 			path = GlucoseAlerts;
 			sourceTree = "<group>";
 		};
+		BD11799A2F4E22C100F90001 /* DeviceAlarms */ = {
+			isa = PBXGroup;
+			children = (
+				BD11798A2F4E22C100F90001 /* DeviceAlarmsDataFlow.swift */,
+				BD11798C2F4E22C100F90001 /* DeviceAlarmsProvider.swift */,
+				BD11798E2F4E22C100F90001 /* DeviceAlarmsStateModel.swift */,
+				BD11799B2F4E22C100F90001 /* View */,
+			);
+			path = DeviceAlarms;
+			sourceTree = "<group>";
+		};
+		BD11799B2F4E22C100F90001 /* View */ = {
+			isa = PBXGroup;
+			children = (
+				BD1179922F4E22C100F90001 /* DeviceAlarmEditorView.swift */,
+				BD1179902F4E22C100F90001 /* DeviceAlarmsRootView.swift */,
+			);
+			path = View;
+			sourceTree = "<group>";
+		};
 		BD1179612F4E22C100F90001 /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -4697,6 +4740,15 @@
 				BD1179792F4E22C100F90001 /* AlarmAudioSection.swift in Sources */,
 				BD11797B2F4E22C100F90001 /* AlarmSoundCatalog.swift in Sources */,
 				BD11797D2F4E22C100F90001 /* AlarmWindowIcon.swift in Sources */,
+				BD1179812F4E22C100F90001 /* PumpAlertCategory.swift in Sources */,
+				BD1179832F4E22C100F90001 /* DeviceAlertSeverity.swift in Sources */,
+				BD1179852F4E22C100F90001 /* DeviceAlertSeverityConfig.swift in Sources */,
+				BD1179872F4E22C100F90001 /* DeviceAlertsStore.swift in Sources */,
+				BD1179892F4E22C100F90001 /* DeviceAlarmsDataFlow.swift in Sources */,
+				BD11798B2F4E22C100F90001 /* DeviceAlarmsProvider.swift in Sources */,
+				BD11798D2F4E22C100F90001 /* DeviceAlarmsStateModel.swift in Sources */,
+				BD11798F2F4E22C100F90001 /* DeviceAlarmsRootView.swift in Sources */,
+				BD1179912F4E22C100F90001 /* DeviceAlarmEditorView.swift in Sources */,
 				DD3F1F902D9E153F00DCE7B3 /* NightscoutImportStepView.swift in Sources */,
 				CE82E02528E867BA00473A9C /* AlertStorage.swift in Sources */,
 				DD1745372C55B74200211FAC /* AlgorithmSettings.swift in Sources */,

+ 23 - 1
Trio/Sources/APS/APSManager.swift

@@ -8,6 +8,11 @@ import Swinject
 
 protocol APSManager {
     func heartbeat(date: Date)
+    /// Mark the next loop attempt as user-initiated (e.g. force-loop button).
+    /// Surfaces transient errors immediately instead of waiting for the
+    /// usual dwell threshold — when the user explicitly asks for a loop,
+    /// they want feedback even if the underlying error is "transient".
+    func markNextLoopUserInitiated()
     func enactBolus(amount: Double, isSMB: Bool, callback: ((Bool, String) -> Void)?) async
     var pumpManager: PumpManagerUI? { get set }
     var bluetoothManager: BluetoothStateManager? { get }
@@ -226,6 +231,13 @@ final class BaseAPSManager: APSManager, Injectable {
             // Check if we can start a new loop
             guard await self.canStartNewLoop() else { return }
 
+            // Consume the user-initiated flag for the duration of this loop —
+            // affects whether transient errors surface immediately instead of
+            // dwell-suppressed (see `surfaceErrorIfNeeded`).
+            self.currentLoopUserInitiated = self.nextLoopUserInitiated
+            self.nextLoopUserInitiated = false
+            defer { self.currentLoopUserInitiated = false }
+
             // Setup loop and background task
             var (loopStatRecord, backgroundTask) = await self.setupLoop()
 
@@ -1214,6 +1226,16 @@ final class BaseAPSManager: APSManager, Injectable {
     private static let transientDwellThreshold: TimeInterval = 60
     private static let transientCountThreshold = 2
 
+    /// Set by `markNextLoopUserInitiated()` (e.g. force-loop button), consumed
+    /// on the next entry into `loop()` so that errors during a user-initiated
+    /// loop surface immediately instead of being suppressed by dwell logic.
+    @SyncAccess private var nextLoopUserInitiated: Bool = false
+    private var currentLoopUserInitiated: Bool = false
+
+    func markNextLoopUserInitiated() {
+        nextLoopUserInitiated = true
+    }
+
     private func processError(_ error: Error) {
         warning(.apsManager, "\(error)")
         lastError.send(error)
@@ -1224,7 +1246,7 @@ final class BaseAPSManager: APSManager, Injectable {
         let category = TrioAlertClassifier.categorize(error: error)
         let key = String(describing: category)
 
-        if category.isAlertWorthy {
+        if category.isAlertWorthy || currentLoopUserInitiated {
             transientCategoryFirstSeen.removeValue(forKey: key)
             transientCategoryCount.removeValue(forKey: key)
             issueAlertForError(error, category: category)

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

@@ -91,6 +91,7 @@ extension Notification.Name {
         }
         _ = resolver.resolve(IOBService.self)!
         _ = resolver.resolve(GlucoseAlertCoordinator.self)!
+        _ = DeviceAlertsStore.shared
     }
 
     init() {

+ 54 - 0
Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift

@@ -0,0 +1,54 @@
+import Foundation
+import LoopKit
+
+/// Coarse-grained tier the user configures for device alarms. Wraps
+/// `Alert.InterruptionLevel` plus its visible behavior:
+/// - `.critical` overrides Silence & Focus Mode (uses the critical-audio
+///   fallback for builds without the Critical Alerts entitlement)
+/// - `.timeSensitive` pierces normal banner suppression but obeys DND/silent
+/// - `.normal` fires only when the device isn't silenced — informational
+enum DeviceAlertSeverity: String, Codable, CaseIterable, Identifiable {
+    case critical
+    case timeSensitive
+    case normal
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .critical: return String(localized: "Critical")
+        case .timeSensitive: return String(localized: "Time-Sensitive")
+        case .normal: return String(localized: "Normal")
+        }
+    }
+
+    var blurb: String {
+        switch self {
+        case .critical:
+            return String(localized: "Overrides Silence & Focus Mode. Always audible. For hazardous failures.")
+        case .timeSensitive:
+            return String(localized: "Pierces banner suppression but obeys Silence & Focus Mode.")
+        case .normal:
+            return String(localized: "Default notification banner. Suppressed by Silence and DND.")
+        }
+    }
+
+    var defaultSoundFilename: String {
+        switch self {
+        case .critical: return "alarm.caf"
+        case .timeSensitive: return "chime.caf"
+        case .normal: return "bloop.caf"
+        }
+    }
+
+    /// Default for the per-tier override toggle when seeded. Tier names are
+    /// labels now — the actual `Alert.InterruptionLevel` is derived from the
+    /// override flag at fire time (true → `.critical`, false → `.timeSensitive`).
+    var defaultOverridesSilenceAndDND: Bool {
+        switch self {
+        case .critical: return true
+        case .normal,
+             .timeSensitive: return false
+        }
+    }
+}

+ 45 - 0
Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverityConfig.swift

@@ -0,0 +1,45 @@
+import Foundation
+
+/// User config for one device-alarm severity tier. Applied to every incoming
+/// pump / device alert whose category maps to this severity. See
+/// `PumpAlertCategory.defaultSeverity` for the mapping table.
+struct DeviceAlertSeverityConfig: Codable, Equatable, Identifiable {
+    let severity: DeviceAlertSeverity
+    var soundFilename: String
+    var playsSound: Bool
+    /// When true, alarms in this tier bypass Focus Mode / silent switch /
+    /// Focus modes (maps to `Alert.InterruptionLevel.critical` and engages the
+    /// in-process `CriticalAlertAudioPlayer` fallback if `playsSound` is true).
+    /// When false, the alarm uses `.timeSensitive` — banner pierces normal
+    /// suppression but obeys silent/DND like any iOS notification.
+    var overridesSilenceAndDND: Bool
+
+    var id: String { severity.rawValue }
+
+    init(severity: DeviceAlertSeverity) {
+        self.severity = severity
+        soundFilename = severity.defaultSoundFilename
+        playsSound = true
+        overridesSilenceAndDND = severity.defaultOverridesSilenceAndDND
+    }
+
+    // MARK: - Codable
+
+    private enum CodingKeys: String, CodingKey {
+        case severity
+        case soundFilename
+        case playsSound
+        case overridesSilenceAndDND
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        severity = try container.decode(DeviceAlertSeverity.self, forKey: .severity)
+        soundFilename = try container.decodeIfPresent(String.self, forKey: .soundFilename) ?? severity.defaultSoundFilename
+        playsSound = try container.decodeIfPresent(Bool.self, forKey: .playsSound) ?? true
+        overridesSilenceAndDND = try container.decodeIfPresent(
+            Bool.self,
+            forKey: .overridesSilenceAndDND
+        ) ?? severity.defaultOverridesSilenceAndDND
+    }
+}

+ 1 - 1
Trio/Sources/Models/GlucoseAlerts/GlucoseAlert.swift

@@ -13,7 +13,7 @@ struct GlucoseAlert: Identifiable, Codable, Equatable {
     /// When false, the alarm fires the banner / notification but no sound.
     /// iOS still drives haptics from the interruption level.
     var playsSound: Bool
-    /// When true, this alarm bypasses Do Not Disturb / silent switch / Focus
+    /// When true, this alarm bypasses Focus Mode / silent switch
     /// modes. Maps to `Alert.InterruptionLevel.critical` and triggers the
     /// in-process `CriticalAlertAudioPlayer` fallback for builds without the
     /// Critical Alerts entitlement.

+ 82 - 0
Trio/Sources/Models/GlucoseAlerts/PumpAlertCategory.swift

@@ -0,0 +1,82 @@
+import Foundation
+import LoopKit
+
+/// Fixed categories of pump / device alarms. Each maps to a
+/// `DeviceAlertSeverity` tier — the user configures three tiers globally
+/// (Critical / Time-Sensitive / Normal), not 13 categories individually.
+enum PumpAlertCategory: String, Codable, CaseIterable, Identifiable {
+    case occlusion
+    case pumpFault
+    case reservoirEmpty
+    case reservoirLow
+    case batteryEmpty
+    case batteryLow
+    case bolusFailed
+    case manualTempBasalActive
+    case podExpirationReminder
+    case podExpired
+    case podShutdownImminent
+    case suspendTimeExpired
+    case glucoseDataStale
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .occlusion: return String(localized: "Occlusion")
+        case .pumpFault: return String(localized: "Pump Fault")
+        case .reservoirEmpty: return String(localized: "Reservoir Empty")
+        case .reservoirLow: return String(localized: "Reservoir Low")
+        case .batteryEmpty: return String(localized: "Battery Empty")
+        case .batteryLow: return String(localized: "Battery Low")
+        case .bolusFailed: return String(localized: "Bolus Failed")
+        case .manualTempBasalActive: return String(localized: "Manual Temp Basal Active")
+        case .podExpirationReminder: return String(localized: "Pod Expiration Reminder")
+        case .podExpired: return String(localized: "Pod Expired")
+        case .podShutdownImminent: return String(localized: "Pod Shutdown Imminent")
+        case .suspendTimeExpired: return String(localized: "Suspend Time Expired")
+        case .glucoseDataStale: return String(localized: "Glucose Data Stale")
+        }
+    }
+
+    /// Maps this category to one of the three tier configs the user edits.
+    var defaultSeverity: DeviceAlertSeverity {
+        switch self {
+        case .batteryEmpty,
+             .occlusion,
+             .pumpFault,
+             .reservoirEmpty:
+            return .critical
+        case .batteryLow,
+             .bolusFailed,
+             .glucoseDataStale,
+             .manualTempBasalActive,
+             .podExpired,
+             .podShutdownImminent,
+             .reservoirLow,
+             .suspendTimeExpired:
+            return .timeSensitive
+        case .podExpirationReminder:
+            return .normal
+        }
+    }
+
+    init?(trioCategory: TrioAlertCategory) {
+        switch trioCategory {
+        case .occlusion: self = .occlusion
+        case .pumpFault: self = .pumpFault
+        case .reservoirEmpty: self = .reservoirEmpty
+        case .reservoirLow: self = .reservoirLow
+        case .batteryEmpty: self = .batteryEmpty
+        case .batteryLow: self = .batteryLow
+        case .bolusFailed: self = .bolusFailed
+        case .manualTempBasalActive: self = .manualTempBasalActive
+        case .podExpirationReminder: self = .podExpirationReminder
+        case .podExpired: self = .podExpired
+        case .podShutdownImminent: self = .podShutdownImminent
+        case .suspendTimeExpired: self = .suspendTimeExpired
+        case .glucoseDataStale: self = .glucoseDataStale
+        default: return nil
+        }
+    }
+}

+ 7 - 0
Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsDataFlow.swift

@@ -0,0 +1,7 @@
+import Combine
+
+enum DeviceAlarms {
+    enum Config {}
+}
+
+protocol DeviceAlarmsProvider: Provider {}

+ 5 - 0
Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsProvider.swift

@@ -0,0 +1,5 @@
+import Foundation
+
+extension DeviceAlarms {
+    final class Provider: BaseProvider, DeviceAlarmsProvider {}
+}

+ 8 - 0
Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsStateModel.swift

@@ -0,0 +1,8 @@
+import Combine
+import SwiftUI
+
+extension DeviceAlarms {
+    final class StateModel: BaseStateModel<Provider> {
+        override func subscribe() {}
+    }
+}

+ 46 - 0
Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift

@@ -0,0 +1,46 @@
+import SwiftUI
+
+struct DeviceAlarmEditorView: View {
+    let severity: DeviceAlertSeverity
+    @ObservedObject var store: DeviceAlertsStore
+
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+    @State private var working: DeviceAlertSeverityConfig
+
+    init(severity: DeviceAlertSeverity, store: DeviceAlertsStore) {
+        self.severity = severity
+        self.store = store
+        _working = State(initialValue: store.config(for: severity) ?? DeviceAlertSeverityConfig(severity: severity))
+    }
+
+    var body: some View {
+        Form {
+            Section(header: Text("Behavior"), footer: Text(severity.blurb)) {
+                Text(severity.displayName).font(.headline)
+                Toggle(
+                    String(localized: "Override Silence & Focus Mode"),
+                    isOn: $working.overridesSilenceAndDND
+                )
+            }
+            .listRowBackground(Color.chart)
+
+            AlarmAudioSection(
+                playsSound: $working.playsSound,
+                soundFilename: $working.soundFilename
+            )
+
+            Section(header: Text("Applies To")) {
+                ForEach(PumpAlertCategory.allCases.filter { $0.defaultSeverity == severity }) { category in
+                    Text(category.displayName)
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+            }.listRowBackground(Color.chart)
+        }
+        .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+        .navigationTitle(severity.displayName)
+        .navigationBarTitleDisplayMode(.inline)
+        .onChange(of: working) { _, new in store.update(new) }
+    }
+}

+ 95 - 0
Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmsRootView.swift

@@ -0,0 +1,95 @@
+import SwiftUI
+import Swinject
+
+extension DeviceAlarms {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+        @StateObject private var store = DeviceAlertsStore.shared
+
+        @Environment(\.colorScheme) var colorScheme
+        @Environment(AppState.self) var appState
+
+        var body: some View {
+            List {
+                Section(
+                    header: Text("Severity Tiers"),
+                    footer: Text(
+                        "Three tiers govern every pump and device alarm. Each pump alarm maps to one tier based on how hazardous it is — you configure the tier, not each alarm. See the bottom of this screen for which category lives in which tier."
+                    )
+                ) {
+                    ForEach(DeviceAlertSeverity.allCases) { severity in
+                        NavigationLink {
+                            DeviceAlarmEditorView(severity: severity, store: store)
+                        } label: {
+                            row(for: severity)
+                        }
+                    }
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden)
+            .background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Device Alarms")
+            .navigationBarTitleDisplayMode(.inline)
+            .onAppear(perform: configureView)
+        }
+
+        @ViewBuilder private func row(for severity: DeviceAlertSeverity) -> some View {
+            let config = store.config(for: severity)
+            HStack(spacing: 12) {
+                Image(systemName: severityIcon(for: severity))
+                    .foregroundStyle(severityTint(for: severity))
+                    .font(.title3)
+                VStack(alignment: .leading, spacing: 2) {
+                    Text(severity.displayName)
+                        .foregroundColor(.primary)
+                    soundSummary(for: config)
+                }
+                Spacer()
+                Image(systemName: "chevron.right")
+                    .font(.footnote)
+                    .foregroundColor(.secondary)
+            }
+        }
+
+        private func soundSummary(for config: DeviceAlertSeverityConfig?) -> some View {
+            var icon = "speaker.fill"
+            var label = String(localized: "Sound on")
+            if let config {
+                if !config.playsSound {
+                    icon = "speaker.slash.fill"
+                    label = String(localized: "Sound off")
+                } else if config.overridesSilenceAndDND {
+                    icon = "speaker.wave.3.fill"
+                    label = String(localized: "Overrides Silence & DND")
+                }
+            }
+            return HStack(spacing: 4) {
+                Image(systemName: icon)
+                Text(label)
+            }
+            .font(.footnote)
+            .foregroundColor(.secondary)
+        }
+
+        private func severityIcon(for severity: DeviceAlertSeverity) -> String {
+            switch severity {
+            case .critical: return "exclamationmark.triangle.fill"
+            case .timeSensitive: return "bell.badge.fill"
+            case .normal: return "bell.fill"
+            }
+        }
+
+        private func severityTint(for severity: DeviceAlertSeverity) -> Color {
+            switch severity {
+            case .critical: return .red
+            case .timeSensitive: return .orange
+            case .normal: return .accentColor
+            }
+        }
+
+        private func categories(for severity: DeviceAlertSeverity) -> [PumpAlertCategory] {
+            PumpAlertCategory.allCases.filter { $0.defaultSeverity == severity }
+        }
+    }
+}

+ 2 - 2
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmActiveSection.swift

@@ -1,7 +1,7 @@
 import SwiftUI
 
 struct AlarmActiveSection: View {
-    @Binding var alarm: GlucoseAlert
+    @Binding var activeOption: ActiveOption
 
     var body: some View {
         Section(
@@ -10,7 +10,7 @@ struct AlarmActiveSection: View {
                 "Day and Night windows are configured globally on the Alarms screen."
             )
         ) {
-            AlarmEnumMenuPicker(title: String(localized: "Active"), selection: $alarm.activeOption)
+            AlarmEnumMenuPicker(title: String(localized: "Active"), selection: $activeOption)
         }.listRowBackground(Color.chart)
     }
 }

+ 6 - 5
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift

@@ -2,22 +2,23 @@ import AVFoundation
 import SwiftUI
 
 struct AlarmAudioSection: View {
-    @Binding var alarm: GlucoseAlert
+    @Binding var playsSound: Bool
+    @Binding var soundFilename: String
 
     @State private var showTonePicker = false
 
     var body: some View {
         Section(header: Text("Alert Sound")) {
-            Toggle("Play Sound", isOn: $alarm.playsSound)
+            Toggle("Play Sound", isOn: $playsSound)
 
-            if alarm.playsSound {
+            if playsSound {
                 Button {
                     showTonePicker = true
                 } label: {
                     HStack {
                         Text("Tone")
                         Spacer()
-                        Text(AlarmSoundCatalog.displayName(for: alarm.soundFilename))
+                        Text(AlarmSoundCatalog.displayName(for: soundFilename))
                             .foregroundColor(.secondary)
                         Image(systemName: "chevron.right")
                             .foregroundColor(.secondary)
@@ -26,7 +27,7 @@ struct AlarmAudioSection: View {
                 }
                 .buttonStyle(.plain)
                 .sheet(isPresented: $showTonePicker) {
-                    TonePickerSheet(selected: $alarm.soundFilename)
+                    TonePickerSheet(selected: $soundFilename)
                 }
             }
         }

+ 7 - 4
Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertEditorView.swift

@@ -42,8 +42,11 @@ struct GlucoseAlertEditorView: View {
                 case .high: highBody
                 }
 
-                AlarmActiveSection(alarm: $working)
-                AlarmAudioSection(alarm: $working)
+                AlarmActiveSection(activeOption: $working.activeOption)
+                AlarmAudioSection(
+                    playsSound: $working.playsSound,
+                    soundFilename: $working.soundFilename
+                )
 
                 if !isNew, store.canDelete(working) {
                     Section {
@@ -91,7 +94,7 @@ struct GlucoseAlertEditorView: View {
             TextField(String(localized: "Name"), text: $working.name)
             Toggle(String(localized: "Enabled"), isOn: $working.isEnabled)
             Toggle(
-                String(localized: "Override Silence & Do Not Disturb"),
+                String(localized: "Override Silence & Focus Mode"),
                 isOn: $working.overridesSilenceAndDND
             )
         }.listRowBackground(Color.chart)
@@ -101,7 +104,7 @@ struct GlucoseAlertEditorView: View {
         AlarmBGSection(
             header: String(localized: "Urgent Low Threshold"),
             footer: String(
-                localized: "Recommended to always override silence and Do Not Disturb."
+                localized: "Recommended to always override silence and Focus mode."
             ),
             title: String(localized: "Glucose"),
             range: 40 ... 80,

+ 3 - 0
Trio/Sources/Modules/Home/HomeProvider.swift

@@ -13,6 +13,9 @@ extension Home {
         }
 
         func heartbeatNow() {
+            // User-initiated force-loop: bypass dwell suppression so transient
+            // errors surface immediately rather than waiting for count/dwell.
+            apsManager.markNextLoopUserInitiated()
             apsManager.heartbeat(date: Date())
         }
 

+ 17 - 2
Trio/Sources/Modules/Settings/SettingItems.swift

@@ -332,12 +332,27 @@ enum SettingItems {
                 "Day & Night",
                 "Alert Sound",
                 "Override Silence",
-                "Show Glucose App Badge",
-                "Add Glucose Source to Alarm"
+                "Show Glucose App Badge"
             ],
             path: ["Notifications", "Glucose Alarms"]
         ),
         SettingItem(
+            title: "Device Alarms",
+            view: .deviceAlarms,
+            searchContents: [
+                "Critical",
+                "Time-Sensitive",
+                "Normal",
+                "Occlusion",
+                "Pump Fault",
+                "Reservoir",
+                "Battery",
+                "Bolus Failed",
+                "Pod"
+            ],
+            path: ["Notifications", "Device Alarms"]
+        ),
+        SettingItem(
             title: "Live Activity",
             view: .liveActivitySettings,
             searchContents: [

+ 3 - 0
Trio/Sources/Modules/Settings/View/Subviews/NotificationsView.swift

@@ -79,6 +79,9 @@ struct NotificationsView: BaseView {
                     Text("Glucose Alarms")
                         .navigationLink(to: .glucoseAlerts, from: self)
 
+                    Text("Device Alarms")
+                        .navigationLink(to: .deviceAlarms, from: self)
+
                     if #available(iOS 16.2, *) {
                         Text("Live Activity").navigationLink(to: .liveActivitySettings, from: self)
                     }

+ 3 - 0
Trio/Sources/Router/Screen.swift

@@ -21,6 +21,7 @@ enum Screen: Identifiable, Hashable {
     case cgm
     case healthkit
     case glucoseAlerts
+    case deviceAlarms
     case mealSettings
     case iconConfig
     case overrideConfig
@@ -107,6 +108,8 @@ extension Screen {
             AppleHealthKit.RootView(resolver: resolver)
         case .glucoseAlerts:
             GlucoseAlerts.RootView(resolver: resolver)
+        case .deviceAlarms:
+            DeviceAlarms.RootView(resolver: resolver)
         case .mealSettings:
             MealSettings.RootView(resolver: resolver)
         case .iconConfig:

+ 64 - 0
Trio/Sources/Services/Alerts/DeviceAlertsStore.swift

@@ -0,0 +1,64 @@
+import Combine
+import Foundation
+
+/// Persists three `DeviceAlertSeverityConfig` rows (Critical /
+/// Time-Sensitive / Normal) to `UserDefaults`. Every pump / device alarm
+/// category maps to one of these tiers via `PumpAlertCategory.defaultSeverity`.
+final class DeviceAlertsStore: ObservableObject {
+    static let shared = DeviceAlertsStore()
+
+    @Published var configs: [DeviceAlertSeverityConfig]
+
+    private let defaults: UserDefaults
+    private let configsKey: String
+
+    private var subscriptions = Set<AnyCancellable>()
+
+    init(
+        defaults: UserDefaults = .standard,
+        configsKey: String = "trio.deviceAlertSeverityConfigs.v1"
+    ) {
+        self.defaults = defaults
+        self.configsKey = configsKey
+        let loaded = Self.decode([DeviceAlertSeverityConfig].self, from: defaults, key: configsKey) ?? []
+        var bySeverity = Dictionary(uniqueKeysWithValues: loaded.map { ($0.severity, $0) })
+        for severity in DeviceAlertSeverity.allCases where bySeverity[severity] == nil {
+            bySeverity[severity] = DeviceAlertSeverityConfig(severity: severity)
+        }
+        configs = DeviceAlertSeverity.allCases.compactMap { bySeverity[$0] }
+        bind()
+    }
+
+    private func bind() {
+        $configs
+            .dropFirst()
+            .removeDuplicates()
+            .sink { [weak self] value in self?.encode(value, to: self?.configsKey ?? "") }
+            .store(in: &subscriptions)
+    }
+
+    func config(for severity: DeviceAlertSeverity) -> DeviceAlertSeverityConfig? {
+        configs.first { $0.severity == severity }
+    }
+
+    func update(_ config: DeviceAlertSeverityConfig) {
+        guard let index = configs.firstIndex(where: { $0.severity == config.severity }) else { return }
+        configs[index] = config
+    }
+
+    // MARK: - Codable helpers
+
+    private static func decode<T: Decodable>(
+        _: T.Type,
+        from defaults: UserDefaults,
+        key: String
+    ) -> T? {
+        guard let data = defaults.data(forKey: key) else { return nil }
+        return try? JSONDecoder().decode(T.self, from: data)
+    }
+
+    private func encode<T: Encodable>(_ value: T, to key: String) {
+        guard !key.isEmpty, let data = try? JSONEncoder().encode(value) else { return }
+        defaults.set(data, forKey: key)
+    }
+}

+ 38 - 10
Trio/Sources/Services/Alerts/TrioAlertManager.swift

@@ -99,30 +99,58 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
             debug(.service, "TrioAlertManager dropped \(alert.identifier.value): \(category) not alert-worthy")
             return
         }
+
+        // Apply the user's tier config for pump / device alarms. The category
+        // maps to one of three tiers (Critical / Time-Sensitive / Normal) and
+        // the tier config overrides sound + interruption level. Glucose
+        // alarms bypass this — they're owned by `GlucoseAlertCoordinator`.
+        let effective: Alert
+        if let pumpCategory = PumpAlertCategory(trioCategory: category) {
+            effective = applyDeviceSeverityConfig(to: alert, category: pumpCategory)
+        } else {
+            effective = alert
+        }
+
         let now = Date()
         // Critical alerts pierce the snooze/mute window. Everything else is
         // suppressed entirely while muted (no modal, no UN sound, no critical
         // audio fallback).
-        if alert.interruptionLevel != .critical, muter.shouldMute(at: now) {
-            debug(.service, "TrioAlertManager muted \(alert.identifier.value) (snooze window active)")
+        if effective.interruptionLevel != .critical, muter.shouldMute(at: now) {
+            debug(.service, "TrioAlertManager muted \(effective.identifier.value) (snooze window active)")
             return
         }
-        guard throttler.shouldDeliver(alert) else {
-            debug(.service, "TrioAlertManager throttled \(alert.identifier.value)")
+        guard throttler.shouldDeliver(effective) else {
+            debug(.service, "TrioAlertManager throttled \(effective.identifier.value)")
             return
         }
         queue.async {
-            self.liveAlerts[alert.identifier] = alert
+            self.liveAlerts[effective.identifier] = effective
         }
-        recordIssued(alert)
+        recordIssued(effective)
         let muted = muter.shouldMute(at: now)
-        modalScheduler.schedule(alert)
+        modalScheduler.schedule(effective)
         userNotificationScheduler.schedule(
-            alert,
+            effective,
             muted: muted,
-            soundURL: soundLoader.url(for: alert)
+            soundURL: soundLoader.url(for: effective)
+        )
+        playCriticalAudioFallbackIfNeeded(effective, muted: muted)
+    }
+
+    private func applyDeviceSeverityConfig(to alert: Alert, category: PumpAlertCategory) -> Alert {
+        let severity = category.defaultSeverity
+        guard let config = DeviceAlertsStore.shared.config(for: severity) else { return alert }
+        let sound: Alert.Sound? = config.playsSound ? .sound(name: config.soundFilename) : nil
+        let level: Alert.InterruptionLevel = config.overridesSilenceAndDND ? .critical : .timeSensitive
+        return Alert(
+            identifier: alert.identifier,
+            foregroundContent: alert.foregroundContent,
+            backgroundContent: alert.backgroundContent,
+            trigger: alert.trigger,
+            interruptionLevel: level,
+            sound: sound,
+            metadata: alert.metadata
         )
-        playCriticalAudioFallbackIfNeeded(alert, muted: muted)
     }
 
     func retractAlert(identifier: Alert.Identifier) {