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

Replace substring alert classifier with catalog-based routing

trioneer недель назад: 2
Родитель
Сommit
00bdcb84be

+ 8 - 4
Trio.xcodeproj/project.pbxproj

@@ -178,6 +178,8 @@
 		38E87403274F78C000975559 /* libswiftCoreNFC.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 38E87402274F78C000975559 /* libswiftCoreNFC.tbd */; settings = {ATTRIBUTES = (Weak, ); }; };
 		38E87408274F9AD000975559 /* UserNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E87407274F9AD000975559 /* UserNotificationsManager.swift */; };
 		BD1179202F4E22C100F90001 /* TrioAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179212F4E22C100F90001 /* TrioAlertManager.swift */; };
+		CA01000000000000000010C2 /* AlertCatalogVendor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA01000000000000000010C1 /* AlertCatalogVendor.swift */; };
+		CA01000000000000000010C4 /* AlertCatalogRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA01000000000000000010C3 /* AlertCatalogRegistry.swift */; };
 		CA01000000000000000010C6 /* CGMManagerAlertOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA01000000000000000010C5 /* CGMManagerAlertOwnership.swift */; };
 		BD1179222F4E22C100F90001 /* TrioModalAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */; };
 		BD1179242F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */; };
@@ -203,7 +205,6 @@
 		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 */; };
@@ -1175,6 +1176,8 @@
 		38E87402274F78C000975559 /* libswiftCoreNFC.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libswiftCoreNFC.tbd; path = usr/lib/swift/libswiftCoreNFC.tbd; sourceTree = SDKROOT; };
 		38E87407274F9AD000975559 /* UserNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationsManager.swift; sourceTree = "<group>"; };
 		BD1179212F4E22C100F90001 /* TrioAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertManager.swift; sourceTree = "<group>"; };
+		CA01000000000000000010C1 /* AlertCatalogVendor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCatalogVendor.swift; sourceTree = "<group>"; };
+		CA01000000000000000010C3 /* AlertCatalogRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCatalogRegistry.swift; sourceTree = "<group>"; };
 		CA01000000000000000010C5 /* CGMManagerAlertOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMManagerAlertOwnership.swift; sourceTree = "<group>"; };
 		BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioModalAlertScheduler.swift; sourceTree = "<group>"; };
 		BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioUserNotificationAlertScheduler.swift; sourceTree = "<group>"; };
@@ -1200,7 +1203,6 @@
 		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>"; };
@@ -2388,6 +2390,8 @@
 				BD1179212F4E22C100F90001 /* TrioAlertManager.swift */,
 				BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */,
 				BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */,
+				CA01000000000000000010C1 /* AlertCatalogVendor.swift */,
+				CA01000000000000000010C3 /* AlertCatalogRegistry.swift */,
 				CA01000000000000000010C5 /* CGMManagerAlertOwnership.swift */,
 			);
 			path = Alerts;
@@ -2726,7 +2730,6 @@
 				BD1179532F4E22C100F90001 /* GlucoseAlert.swift */,
 				BD1179552F4E22C100F90001 /* GlucoseAlertConfiguration.swift */,
 				BD1179572F4E22C100F90001 /* GlucoseAlertType.swift */,
-				BD1179822F4E22C100F90001 /* PumpAlertCategory.swift */,
 				BD1179842F4E22C100F90001 /* DeviceAlertSeverity.swift */,
 				BD1179862F4E22C100F90001 /* DeviceAlertSeverityConfig.swift */,
 			);
@@ -5081,6 +5084,8 @@
 				DDFF20312DB1D15500AB8A96 /* BluetoothPermissionStepView.swift in Sources */,
 				38E87408274F9AD000975559 /* UserNotificationsManager.swift in Sources */,
 				BD1179202F4E22C100F90001 /* TrioAlertManager.swift in Sources */,
+				CA01000000000000000010C2 /* AlertCatalogVendor.swift in Sources */,
+				CA01000000000000000010C4 /* AlertCatalogRegistry.swift in Sources */,
 				CA01000000000000000010C6 /* CGMManagerAlertOwnership.swift in Sources */,
 				BD1179222F4E22C100F90001 /* TrioModalAlertScheduler.swift in Sources */,
 				BD1179242F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift in Sources */,
@@ -5105,7 +5110,6 @@
 				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 */,

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

@@ -14,6 +14,14 @@ enum DeviceAlertSeverity: String, Codable, CaseIterable, Identifiable {
 
     var id: String { rawValue }
 
+    init?(level: Alert.InterruptionLevel) {
+        switch level {
+        case .critical: self = .critical
+        case .timeSensitive: self = .timeSensitive
+        case .active: self = .normal
+        }
+    }
+
     var displayName: String {
         switch self {
         case .critical: return String(localized: "Critical")

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

@@ -1,98 +0,0 @@
-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 hardwareFault
-    case reservoirEmpty
-    case reservoirLow
-    case batteryEmpty
-    case batteryLow
-    case bolusFailed
-    case deliveryUncertain
-    case manualTempBasalActive
-    case notLooping
-    case sensorFailure
-    case deviceExpirationReminder
-    case deviceExpired
-    case podShutdownImminent
-    case suspendTimeExpired
-    case glucoseDataStale
-    case algorithmError
-
-    var id: String { rawValue }
-
-    var displayName: String {
-        switch self {
-        case .occlusion: return String(localized: "Occlusion")
-        case .hardwareFault: return String(localized: "Hardware 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 .deliveryUncertain: return String(localized: "Delivery Uncertain")
-        case .manualTempBasalActive: return String(localized: "Manual Temp Basal Active")
-        case .notLooping: return String(localized: "Loop Not Running")
-        case .sensorFailure: return String(localized: "Sensor Failure")
-        case .deviceExpirationReminder: return String(localized: "Device Expiration Reminder")
-        case .deviceExpired: return String(localized: "Device Expired")
-        case .podShutdownImminent: return String(localized: "Shutdown Imminent")
-        case .suspendTimeExpired: return String(localized: "Suspend Time Expired")
-        case .glucoseDataStale: return String(localized: "Glucose Data Stale")
-        case .algorithmError: return String(localized: "Algorithm Error")
-        }
-    }
-
-    /// Maps this category to one of the three tier configs the user edits.
-    var defaultSeverity: DeviceAlertSeverity {
-        switch self {
-        case .batteryEmpty,
-             .deliveryUncertain,
-             .hardwareFault,
-             .notLooping,
-             .occlusion,
-             .reservoirEmpty,
-             .sensorFailure:
-            return .critical
-        case .batteryLow,
-             .bolusFailed,
-             .deviceExpired,
-             .glucoseDataStale,
-             .manualTempBasalActive,
-             .podShutdownImminent,
-             .reservoirLow,
-             .suspendTimeExpired:
-            return .timeSensitive
-        case .algorithmError,
-             .deviceExpirationReminder:
-            return .normal
-        }
-    }
-
-    init?(trioCategory: TrioAlertCategory) {
-        switch trioCategory {
-        case .occlusion: self = .occlusion
-        case .hardwareFault: self = .hardwareFault
-        case .reservoirEmpty: self = .reservoirEmpty
-        case .reservoirLow: self = .reservoirLow
-        case .batteryEmpty: self = .batteryEmpty
-        case .batteryLow: self = .batteryLow
-        case .bolusFailed: self = .bolusFailed
-        case .deliveryUncertain: self = .deliveryUncertain
-        case .manualTempBasalActive: self = .manualTempBasalActive
-        case .notLooping: self = .notLooping
-        case .sensorFailure: self = .sensorFailure
-        case .deviceExpirationReminder: self = .deviceExpirationReminder
-        case .deviceExpired: self = .deviceExpired
-        case .podShutdownImminent: self = .podShutdownImminent
-        case .suspendTimeExpired: self = .suspendTimeExpired
-        case .glucoseDataStale: self = .glucoseDataStale
-        case .algorithmError: self = .algorithmError
-        default: return nil
-        }
-    }
-}

+ 1 - 1
Trio/Sources/Modules/AlarmWindows/View/AlarmWindowsRootView.swift

@@ -88,7 +88,7 @@ extension AlarmWindows {
                                                 )
                                             )
                                             Text(
-                                                "These windows are shared between Glucose Alarms and Pump & CGM Alarms."
+                                                "These windows are shared between Glucose Alarms and Pump Alarms."
                                             )
                                         }
                                     )

+ 18 - 2
Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift

@@ -1,3 +1,4 @@
+import LoopKit
 import SwiftUI
 
 struct DeviceAlarmEditorView: View {
@@ -60,8 +61,8 @@ struct DeviceAlarmEditorView: View {
                 )
 
                 Section(header: Text("Applies To")) {
-                    ForEach(PumpAlertCategory.allCases.filter { $0.defaultSeverity == working.severity }) { category in
-                        Text(category.displayName)
+                    ForEach(conceptsForTier(working.severity), id: \.self) { concept in
+                        Text(concept.displayTitle)
                             .font(.footnote)
                             .foregroundColor(.secondary)
                     }
@@ -110,4 +111,19 @@ struct DeviceAlarmEditorView: View {
         case .night: return String(localized: "Night only")
         }
     }
+
+    /// Distinct alarm concepts whose catalog entries fall into this tier.
+    /// Sorted by display title so the list is stable across plugin changes.
+    private func conceptsForTier(_ tier: DeviceAlertSeverity) -> [LoopKit.Alert.CatalogConcept] {
+        var seen: Set<LoopKit.Alert.CatalogConcept> = []
+        var ordered: [LoopKit.Alert.CatalogConcept] = []
+        for entry in AlertCatalogRegistry.entries
+            where DeviceAlertSeverity(level: entry.interruptionLevel) == tier
+        {
+            if seen.insert(entry.concept).inserted {
+                ordered.append(entry.concept)
+            }
+        }
+        return ordered.sorted { $0.displayTitle < $1.displayTitle }
+    }
 }

+ 1 - 1
Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmsRootView.swift

@@ -46,7 +46,7 @@ extension DeviceAlarms {
             }
             .scrollContentBackground(.hidden)
             .background(appState.trioBackgroundColor(for: colorScheme))
-            .navigationTitle("Pump & CGM Alarms")
+            .navigationTitle("Pump Alarms")
             .navigationBarTitleDisplayMode(.inline)
             .toolbar {
                 ToolbarItem(placement: .primaryAction) {

+ 17 - 13
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -133,20 +133,24 @@ extension Home {
                 cgmAvailable: state.cgmAvailable,
                 currentGlucoseTarget: state.currentGlucoseTarget,
                 glucoseColorScheme: state.glucoseColorScheme,
-                glucose: state.latestTwoGlucoseValues
-            ).scaleEffect(0.9)
-                .onTapGesture {
-                    if !state.cgmAvailable {
-                        showCGMSelection.toggle()
-                    } else {
-                        state.shouldDisplayCGMSetupSheet.toggle()
-                    }
-                }
-                .onLongPressGesture {
-                    let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
-                    impactHeavy.impactOccurred()
-                    state.showModal(for: .snooze)
+                glucose: state.latestTwoGlucoseValues,
+                cgmProgress: state.cgmProgressHighlight,
+                cgmStatus: state.cgmDisplayState,
+                cgmSensorExpiresAt: state.cgmSensorExpiresAt,
+                cgmWarmupEndsAt: state.cgmWarmupEndsAt
+            )
+            .onTapGesture {
+                if !state.cgmAvailable {
+                    showCGMSelection.toggle()
+                } else {
+                    state.shouldDisplayCGMSetupSheet.toggle()
                 }
+            }
+            .onLongPressGesture {
+                let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
+                impactHeavy.impactOccurred()
+                state.showModal(for: .snooze)
+            }
         }
 
         var pumpView: some View {

+ 13 - 1
Trio/Sources/Modules/PumpConfig/PumpConfigDataFlow.swift

@@ -5,12 +5,24 @@ import LoopKitUI
 enum PumpConfig {
     enum Config {}
 
-    enum PumpType: Equatable {
+    enum PumpType: Equatable, CaseIterable {
         case minimed
         case omni
         case dana
         case medtrum
         case simulator
+
+        /// `Alert.Identifier.managerIdentifier` emitted by this plugin's
+        /// `PumpManager`. Nil for `.simulator` — no real plugin.
+        var pluginIdentifier: String? {
+            switch self {
+            case .minimed: return "Minimed"
+            case .omni: return "Omni"
+            case .dana: return "Dana"
+            case .medtrum: return "Medtrum"
+            case .simulator: return nil
+            }
+        }
     }
 
     struct PumpInitialSettings {

+ 1 - 1
Trio/Sources/Modules/Settings/SettingItems.swift

@@ -353,7 +353,7 @@ enum SettingItems {
                 "Sensor Failure",
                 "Device Expired"
             ],
-            path: ["Notifications", "Pump & CGM Alarms"]
+            path: ["Notifications", "Pump Alarms"]
         ),
         SettingItem(
             title: "Day & Night Windows",

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

@@ -79,7 +79,7 @@ struct NotificationsView: BaseView {
                     Text("Glucose Alarms")
                         .navigationLink(to: .glucoseAlerts, from: self)
 
-                    Text("Pump & CGM Alarms")
+                    Text("Pump Alarms")
                         .navigationLink(to: .deviceAlarms, from: self)
 
                     Text("Day & Night Windows")

+ 180 - 0
Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift

@@ -0,0 +1,180 @@
+import LoopKit
+
+/// Trio-side bridge of the upstream `AlertCatalogVendor` protocol. Pump
+/// plugins don't currently set `interruptionLevel` on the alerts they issue
+/// — every emission lands at LoopKit's default (`.timeSensitive`). Trio
+/// overrides the level by looking up the alert's identifier in this
+/// registry. As plugins adopt `AlertCatalogVendor` upstream, their entries
+/// move out of here and into the plugin repo.
+///
+/// Scope is pump alerts only. CGM lifecycle alerts (LibreLoop sensor end,
+/// etc.) pass through with the plugin's chosen level. Trio-internal alerts
+/// (glucose thresholds, algorithm error, not-looping) set their level at
+/// construction and don't need a catalog entry.
+enum AlertCatalogRegistry {
+    static let entries: [Alert.CatalogEntry] = omniEntries + minimedEntries + danaEntries + medtrumEntries
+
+    static func lookup(_ identifier: Alert.Identifier) -> Alert.CatalogEntry? {
+        if let exact = entries.first(where: { $0.identifier == identifier }) {
+            return exact
+        }
+        return omniPodFaultEntry(for: identifier)
+    }
+
+    /// Omni emits pod faults via `notifyPodFault` with a separate manager
+    /// identifier (`Omni:pumpFault`) and a formatted alert identifier
+    /// (`Fault Event Code 0xNN: ...`). Map the small set of user-relevant
+    /// codes to concrete concepts; everything else falls back to a generic
+    /// `Pod Fault` so it still surfaces under the Critical tier.
+    private static func omniPodFaultEntry(for identifier: Alert.Identifier) -> Alert.CatalogEntry? {
+        guard identifier.managerIdentifier == "Omni:pumpFault" else { return nil }
+        let code = parseOmniFaultHexCode(from: identifier.alertIdentifier)
+        switch code {
+        case 0x14:
+            return Alert.CatalogEntry(
+                identifier: identifier, interruptionLevel: .critical,
+                title: "Pod Occlusion", category: "Delivery", concept: .occlusion
+            )
+        case 0x18:
+            return Alert.CatalogEntry(
+                identifier: identifier, interruptionLevel: .critical,
+                title: "Pod Reservoir Empty", category: "Reservoir", concept: .reservoirEmpty
+            )
+        case 0x1C:
+            return Alert.CatalogEntry(
+                identifier: identifier, interruptionLevel: .timeSensitive,
+                title: "Pod Expired", category: "Lifecycle", concept: .deviceExpired
+            )
+        default:
+            return Alert.CatalogEntry(
+                identifier: identifier, interruptionLevel: .critical,
+                title: "Pod Fault", category: "Hardware", concept: .hardwareFault
+            )
+        }
+    }
+
+    /// Extracts the hex code from `Fault Event Code 0xNN: ...`. Bounded
+    /// parser, not a substring classifier — the prefix shape is fixed by
+    /// `FaultEventCode.description`.
+    private static func parseOmniFaultHexCode(from alertID: String) -> UInt8? {
+        let prefix = "Fault Event Code 0x"
+        guard alertID.hasPrefix(prefix), alertID.count >= prefix.count + 2 else { return nil }
+        let start = alertID.index(alertID.startIndex, offsetBy: prefix.count)
+        let end = alertID.index(start, offsetBy: 2)
+        return UInt8(alertID[start ..< end], radix: 16)
+    }
+}
+
+// MARK: - Omnipod (Eros + DASH)
+
+private extension AlertCatalogRegistry {
+    static let omniEntries: [Alert.CatalogEntry] = [
+        addEntry("Omni", "userPodExpiration", .active, "Pod Expiration Reminder", "Lifecycle", .deviceExpirationReminder),
+        addEntry("Omni", "podExpiring", .timeSensitive, "Pod Expired", "Lifecycle", .deviceExpired),
+        addEntry("Omni", "podExpireImminent", .timeSensitive, "Pod Shutdown Imminent", "Lifecycle", .deviceShutdownImminent),
+        addEntry("Omni", "lowReservoir", .timeSensitive, "Low Reservoir", "Reservoir", .reservoirLow),
+        addEntry("Omni", "suspendInProgress", .active, "Suspend In Progress Reminder", "Delivery", .suspendInProgressReminder),
+        addEntry("Omni", "suspendEnded", .timeSensitive, "Resume Insulin", "Delivery", .insulinResumeReminder),
+        addEntry("Omni", "finishSetupReminder", .active, "Pod Pairing Incomplete", "Lifecycle", .setupIncomplete),
+        addEntry("Omni", "unexpectedAlert", .critical, "Unexpected Alert", "Hardware", .hardwareFault),
+        addEntry("Omni", "timeOffsetChangeDetected", .active, "Time Change Detected", "Lifecycle", .timeChange),
+        addEntry("Omni", "lowRLBattery", .timeSensitive, "Low RileyLink Battery", "Battery", .rileyLinkBatteryLow)
+    ]
+}
+
+// MARK: - Minimed (500/700)
+
+private extension AlertCatalogRegistry {
+    static let minimedEntries: [Alert.CatalogEntry] = [
+        addEntry("Minimed", "PumpBatteryLow", .timeSensitive, "Pump Battery Low", "Battery", .pumpBatteryLow),
+        addEntry("Minimed", "PumpReservoirEmpty", .critical, "Pump Reservoir Empty", "Reservoir", .reservoirEmpty),
+        addEntry("Minimed", "PumpReservoirLow", .timeSensitive, "Pump Reservoir Low", "Reservoir", .reservoirLow),
+        addEntry("Minimed", "lowRLBattery", .timeSensitive, "Low RileyLink Battery", "Battery", .rileyLinkBatteryLow)
+    ]
+}
+
+// MARK: - Dana (RS/i/-i)
+
+private extension AlertCatalogRegistry {
+    static let danaEntries: [Alert.CatalogEntry] = [
+        addEntry("Dana", "batteryZeroPercent", .critical, "Pump Battery 0%", "Battery", .pumpBatteryEmpty),
+        addEntry("Dana", "pumpError", .critical, "Pump Error", "Hardware", .hardwareFault),
+        addEntry("Dana", "occlusion", .critical, "Occlusion", "Delivery", .occlusion),
+        addEntry("Dana", "lowBattery", .timeSensitive, "Low Pump Battery", "Battery", .pumpBatteryLow),
+        addEntry("Dana", "shutdown", .critical, "Pump Shutdown", "Hardware", .hardwareFault),
+        addEntry("Dana", "basalCompare", .active, "Basal Compare", "Delivery", .basalProfileMismatch),
+        addEntry("Dana", "bloodSugarMeasure", .active, "Blood Glucose Measure", "Reminders", .userBloodGlucoseReminder),
+        addEntry("Dana", "remainingInsulinLevel", .timeSensitive, "Remaining Insulin Level", "Reservoir", .reservoirLow),
+        addEntry("Dana", "emptyReservoir", .critical, "Empty Reservoir", "Reservoir", .reservoirEmpty),
+        addEntry("Dana", "checkShaft", .critical, "Check Shaft", "Hardware", .hardwareFault),
+        addEntry("Dana", "basalMax", .active, "Basal Limit Reached", "Delivery", .insulinLimitWarning),
+        addEntry("Dana", "dailyMax", .active, "Daily Limit Reached", "Delivery", .insulinLimitWarning),
+        addEntry("Dana", "bloodSugarCheckMiss", .active, "Missed Blood Glucose Check", "Reminders", .userBloodGlucoseReminder),
+        addEntry("Dana", "ble5InvalidKeys", .timeSensitive, "Pairing Failed", "Connectivity", .pairingFailed),
+        addEntry("Dana", "unknown", .timeSensitive, "Unknown Pump Error", "Hardware", .hardwareFault)
+    ]
+}
+
+// MARK: - Medtrum (TouchCare nano)
+
+private extension AlertCatalogRegistry {
+    static let medtrumEntries: [Alert.CatalogEntry] = [
+        addEntry(
+            "Medtrum",
+            "com.nightscout.medtrumkit.patch-expired",
+            .active,
+            "Patch Expiring Soon",
+            "Lifecycle",
+            .deviceExpirationReminder
+        ),
+        addEntry(
+            "Medtrum",
+            "com.nightscout.medtrumkit.patch-daily-limit",
+            .timeSensitive,
+            "Daily Insulin Limit",
+            "Delivery",
+            .insulinLimitReached
+        ),
+        addEntry(
+            "Medtrum",
+            "com.nightscout.medtrumkit.patch-hourly-limit",
+            .timeSensitive,
+            "Hourly Insulin Limit",
+            "Delivery",
+            .insulinLimitReached
+        ),
+        addEntry("Medtrum", "com.nightscout.medtrumkit.patch-occlussion", .critical, "Occlusion", "Delivery", .occlusion),
+        addEntry("Medtrum", "com.nightscout.medtrumkit.patch-fault", .critical, "Patch Fault", "Hardware", .hardwareFault),
+        addEntry("Medtrum", "com.nightscout.medtrumkit.patch-empty", .critical, "Reservoir Empty", "Reservoir", .reservoirEmpty),
+        addEntry(
+            "Medtrum",
+            "com.nightscout.medtrumkit.reservoir-low",
+            .timeSensitive,
+            "Reservoir Low",
+            "Reservoir",
+            .reservoirLow
+        )
+    ]
+}
+
+// MARK: - Helpers
+
+private extension AlertCatalogRegistry {
+    static func addEntry(
+        _ manager: String,
+        _ alertID: String,
+        _ level: Alert.InterruptionLevel,
+        _ title: String,
+        _ category: String,
+        _ concept: Alert.CatalogConcept
+    ) -> Alert.CatalogEntry {
+        Alert.CatalogEntry(
+            managerIdentifier: manager,
+            alertIdentifier: alertID,
+            interruptionLevel: level,
+            title: title,
+            category: category,
+            concept: concept
+        )
+    }
+}

+ 111 - 0
Trio/Sources/Services/Alerts/AlertCatalogVendor.swift

@@ -0,0 +1,111 @@
+import Foundation
+import LoopKit
+
+/// Plugin-side declaration of every alert a device manager may issue.
+/// Mirrors LoopKit's `AlertSoundVendor` pattern (static, boot-time, no
+/// side effects). Trio uses the union catalog to drive per-alert severity,
+/// the Settings UI, and per-alert mute. When upstream LoopKit adopts this,
+/// Trio's bridge extensions move into the plugin repos verbatim.
+protocol AlertCatalogVendor {
+    var alertCatalog: [Alert.CatalogEntry] { get }
+}
+
+extension Alert {
+    /// Cross-plugin canonical key for the Settings UI. Multiple per-plugin
+    /// entries with the same concept collapse to a single row.
+    enum CatalogConcept: Hashable {
+        case occlusion
+        case reservoirLow
+        case reservoirEmpty
+        case pumpBatteryLow
+        case pumpBatteryEmpty
+        case rileyLinkBatteryLow
+        case hardwareFault
+        case insulinLimitWarning
+        case insulinLimitReached
+        case suspendInProgressReminder
+        case insulinResumeReminder
+        case deviceExpirationReminder
+        case deviceExpired
+        case deviceShutdownImminent
+        case setupIncomplete
+        case timeChange
+        case pairingFailed
+        case userBloodGlucoseReminder
+        case basalProfileMismatch
+        /// Plugin-unique alarm that doesn't merge with anything else.
+        case unspecified
+
+        var displayTitle: String {
+            switch self {
+            case .occlusion: return String(localized: "Occlusion")
+            case .reservoirLow: return String(localized: "Reservoir Low")
+            case .reservoirEmpty: return String(localized: "Reservoir Empty")
+            case .pumpBatteryLow: return String(localized: "Pump Battery Low")
+            case .pumpBatteryEmpty: return String(localized: "Pump Battery Empty")
+            case .rileyLinkBatteryLow: return String(localized: "RileyLink Battery Low")
+            case .hardwareFault: return String(localized: "Pump Fault")
+            case .insulinLimitWarning: return String(localized: "Insulin Limit Warning")
+            case .insulinLimitReached: return String(localized: "Insulin Limit Reached")
+            case .suspendInProgressReminder: return String(localized: "Suspend In Progress")
+            case .insulinResumeReminder: return String(localized: "Insulin Resume Reminder")
+            case .deviceExpirationReminder: return String(localized: "Device Expiration Reminder")
+            case .deviceExpired: return String(localized: "Device Expired")
+            case .deviceShutdownImminent: return String(localized: "Device Shutdown Imminent")
+            case .setupIncomplete: return String(localized: "Setup Incomplete")
+            case .timeChange: return String(localized: "Time Change Detected")
+            case .pairingFailed: return String(localized: "Pairing Failed")
+            case .userBloodGlucoseReminder: return String(localized: "Blood Glucose Reminder")
+            case .basalProfileMismatch: return String(localized: "Basal Profile Mismatch")
+            case .unspecified: return ""
+            }
+        }
+    }
+
+    struct CatalogEntry: Identifiable, Equatable {
+        let identifier: Alert.Identifier
+        let interruptionLevel: Alert.InterruptionLevel
+        /// Plugin's authentic title — surfaces in logs / debug paths.
+        let title: String
+        /// Soft grouping ("Delivery", "Reservoir", "Hardware", ...).
+        let category: String
+        /// UI dedupe key — entries sharing a concept collapse to one row.
+        let concept: CatalogConcept
+
+        var id: String { identifier.value }
+
+        init(
+            identifier: Alert.Identifier,
+            interruptionLevel: Alert.InterruptionLevel,
+            title: String,
+            category: String,
+            concept: CatalogConcept
+        ) {
+            self.identifier = identifier
+            self.interruptionLevel = interruptionLevel
+            self.title = title
+            self.category = category
+            self.concept = concept
+        }
+
+        init(
+            managerIdentifier: String,
+            alertIdentifier: Alert.AlertIdentifier,
+            interruptionLevel: Alert.InterruptionLevel,
+            title: String,
+            category: String,
+            concept: CatalogConcept
+        ) {
+            self.init(
+                identifier: Alert.Identifier(
+                    managerIdentifier: managerIdentifier,
+                    alertIdentifier: alertIdentifier
+                ),
+                interruptionLevel: interruptionLevel,
+                title: title,
+                category: category,
+                concept: concept
+            )
+        }
+    }
+}

+ 12 - 17
Trio/Sources/Services/Alerts/DeviceAlertsStore.swift

@@ -1,5 +1,6 @@
 import Combine
 import Foundation
+import LoopKit
 
 /// Persists a flat list of `[DeviceAlertSeverityConfig]` to `UserDefaults`.
 /// Multiple configs per severity tier are allowed — each with its own
@@ -11,10 +12,8 @@ final class DeviceAlertsStore: ObservableObject {
     static let shared = DeviceAlertsStore()
 
     @Published var configs: [DeviceAlertSeverityConfig]
-    /// Per-category snooze expirations. Keyed by `PumpAlertCategory.rawValue`
-    /// for stable Codable round-tripping. `BaseTrioAlertManager.issueAlert`
-    /// drops alerts whose category has an entry with a future date.
-    @Published var categorySnoozes: [String: Date]
+    /// Per-tier snooze expirations keyed by `DeviceAlertSeverity.rawValue`.
+    @Published var tierSnoozes: [String: Date]
 
     private let defaults: UserDefaults
     private let configsKey: String
@@ -25,15 +24,12 @@ final class DeviceAlertsStore: ObservableObject {
     init(
         defaults: UserDefaults = .standard,
         configsKey: String = "trio.deviceAlertSeverityConfigs.v1",
-        snoozesKey: String = "trio.deviceAlertCategorySnoozes.v1"
+        snoozesKey: String = "trio.deviceAlertTierSnoozes.v1"
     ) {
         self.defaults = defaults
         self.configsKey = configsKey
         self.snoozesKey = snoozesKey
         let loaded = Self.decode([DeviceAlertSeverityConfig].self, from: defaults, key: configsKey) ?? []
-        // Guarantee one default `.always` per severity on first load. Any
-        // missing severity gets a fresh seed so the lookup always finds a
-        // baseline match.
         var seeded = loaded
         for severity in DeviceAlertSeverity.allCases
             where !seeded.contains(where: { $0.severity == severity && $0.activeOption == .always })
@@ -42,23 +38,22 @@ final class DeviceAlertsStore: ObservableObject {
         }
         configs = Self.sorted(seeded)
         let snoozes = Self.decode([String: Date].self, from: defaults, key: snoozesKey) ?? [:]
-        // Prune expired entries on launch so the dictionary doesn't grow.
-        categorySnoozes = snoozes.filter { $0.value > Date() }
+        tierSnoozes = snoozes.filter { $0.value > Date() }
         bind()
     }
 
-    // MARK: - Per-category snooze
+    // MARK: - Per-tier snooze
 
-    func snoozeCategory(_ category: PumpAlertCategory, until: Date) {
+    func snoozeTier(_ tier: DeviceAlertSeverity, until: Date) {
         if until > Date() {
-            categorySnoozes[category.rawValue] = until
+            tierSnoozes[tier.rawValue] = until
         } else {
-            categorySnoozes.removeValue(forKey: category.rawValue)
+            tierSnoozes.removeValue(forKey: tier.rawValue)
         }
     }
 
-    func isCategorySnoozed(_ category: PumpAlertCategory, at date: Date) -> Bool {
-        guard let until = categorySnoozes[category.rawValue] else { return false }
+    func isTierSnoozed(_ tier: DeviceAlertSeverity, at date: Date) -> Bool {
+        guard let until = tierSnoozes[tier.rawValue] else { return false }
         return until > date
     }
 
@@ -68,7 +63,7 @@ final class DeviceAlertsStore: ObservableObject {
             .removeDuplicates()
             .sink { [weak self] value in self?.encode(value, to: self?.configsKey ?? "") }
             .store(in: &subscriptions)
-        $categorySnoozes
+        $tierSnoozes
             .dropFirst()
             .removeDuplicates()
             .sink { [weak self] value in self?.encode(value, to: self?.snoozesKey ?? "") }

+ 1 - 1
Trio/Sources/Services/Alerts/NotLoopingMonitor.swift

@@ -11,7 +11,7 @@ import Swinject
 ///
 /// Replaces the legacy `scheduleMissingLoopNotifiactions` direct-UN path
 /// in `BaseUserNotificationsManager`. The alert now flows through
-/// `TrioAlertManager` and inherits tier config from Pump & CGM Alarms
+/// `TrioAlertManager` and inherits tier config from Pump Alarms
 /// (Critical tier by default).
 final class NotLoopingMonitor: Injectable {
     @Injected() private var apsManager: APSManager!

+ 6 - 153
Trio/Sources/Services/Alerts/TrioAlertCategory.swift

@@ -1,27 +1,9 @@
 import Foundation
 import LoopKit
 
-/// Coarse-grained classification of an incoming LoopKit `Alert` or a Swift
-/// `Error` caught at the `APSManager` boundary. The user never sees these
-/// directly — they pick a *severity tier* on the Device Alarms screen, and
-/// each category maps to a tier via `PumpAlertCategory.defaultSeverity`.
-///
-/// Buckets follow the manager-audit taxonomy:
-///   - N1 Hardware Fault        → `.hardwareFault`
-///   - N2 Delivery Stopped      → `.suspendTimeExpired` / pump-managed
-///   - N3 Uncertain Delivery    → `.deliveryUncertain`
-///   - N4 Reservoir Empty       → `.reservoirEmpty`
-///   - N5 Battery Dead          → `.batteryEmpty`
-///   - N6 Device Expired        → `.deviceExpired` (pod / sensor / transmitter)
-///   - N7 Sensor / Session Fail → `.sensorFailure`
-///   - F1 Insulin Low           → `.reservoirLow`
-///   - F2 Battery Low           → `.batteryLow`
-///   - F3 Expiration Approaching → `.deviceExpirationReminder`
-///   - Bolus failure (confirmed) → `.bolusFailed`
-///   - Glucose alarms           → `.glucose*` (owned by `GlucoseAlertCoordinator`)
-///   - N8 Connectivity blips    → `.commsTransient` (dwell-suppressed)
-///   - Algorithm error          → `.algorithmError` (dwell-suppressed)
-///   - Other unclassified       → `.other(String)`
+/// Internal slug + severity producer for Trio-emitted alerts (APSManager,
+/// notLooping, glucose threshold). Pump/device alarms now flow through
+/// `AlertCatalogRegistry` and don't touch this enum.
 enum TrioAlertCategory: Equatable {
     case occlusion
     case reservoirLow
@@ -83,9 +65,7 @@ enum TrioAlertCategory: Equatable {
         }
     }
 
-    /// Canonical slug used when constructing `Alert.Identifier`. Round-trips
-    /// through `TrioAlertClassifier.categorize(alertIdentifier:)` — keep the
-    /// substring matchers in the classifier in sync with these slugs.
+    /// Slug used when Trio constructs its own `Alert.Identifier`.
     var alertIdentifier: String {
         switch self {
         case .occlusion: return "occlusion"
@@ -147,135 +127,8 @@ enum TrioAlertCategory: Equatable {
 }
 
 enum TrioAlertClassifier {
-    /// Classify a LoopKit alert identifier coming from a pump or CGM manager.
-    /// Substring-matched on the lowercased identifier. Returns `.other(identifier)`
-    /// for anything unrecognized.
-    ///
-    /// Coverage notes (manager audit):
-    ///   - Pumps: OmnipodKit (Eros + DASH), DanaKit, MinimedKit, MedtrumKit
-    ///     (MedtrumKit alerts land via jbr7rr/MedtrumKit#147 — slugs are
-    ///     `com.nightscout.medtrumkit.*`).
-    ///   - CGMs: CGMBLEKit (Dexcom G5/G6), G7SensorKit, LibreTransmitter,
-    ///     EversenseKit, NightscoutRemoteCGM, dexcom-share-client-swift.
-    static func categorize(alertIdentifier: String) -> TrioAlertCategory {
-        let id = alertIdentifier.lowercased()
-
-        // Glucose family — owned by GlucoseAlertCoordinator, return early so the
-        // pump/device interception in TrioAlertManager doesn't apply tier config.
-        if id.contains("glucose.urgentlow") || id.contains("glucoseurgentlow") { return .glucoseUrgentLow }
-        if id.contains("glucose.forecastedlow") || id.contains("glucoseforecastedlow") { return .glucoseForecastedLow }
-        if id.contains("glucose.low") || id.contains("glucoselow") { return .glucoseLow }
-        if id.contains("glucose.high") || id.contains("glucosehigh") { return .glucoseHigh }
-        if id.contains("glucose"), id.contains("stale") { return .glucoseDataStale }
-
-        // Hard-fail device states first (most severe wins on substring overlap).
-        if id.contains("occlusion") || id.contains("occluded") { return .occlusion }
-        if id.contains("reservoirempty") || id.contains("emptyreservoir") || id.contains("nodelivery")
-            || id.contains("patch-empty") || id.contains("patchempty")
-        {
-            return .reservoirEmpty
-        }
-        if id.contains("batteryempty") || id.contains("batteryzero") || id.contains("batterydepleted")
-            || id.contains("batteryout") || id.contains("emptybattery")
-        {
-            return .batteryEmpty
-        }
-
-        // Uncertain delivery — N3, must come before generic bolusFailed.
-        if id.contains("unacknowledged") || id.contains("uncertaindelivery") || id.contains("uncertain delivery")
-            || id.contains("delivery-uncertain") || id.contains("deliveryuncertain")
-            || id.contains("unabletoreachpod") || id.contains("commsrecovery")
-        {
-            return .deliveryUncertain
-        }
-
-        // Hardware fault — N1. Covers pump faults, transmitter critical faults,
-        // CGM hardware errors.
-        if id.contains("fault") || id.contains("pumperror") || id.contains("checkshaft")
-            || id.contains("autooff") || id.contains("auto-off") || id.contains("devicereset")
-            || id.contains("reprogram") || id.contains("unexpectedalert") || id.contains("criticalfault")
-            || id.contains("vibrationcurrent") || id.contains("batteryerror") || id.contains("transmittererror")
-            || id == "shutdown" || id.contains("unknownalarm")
-        {
-            return .hardwareFault
-        }
-
-        // Sensor / session failure — N7 (CGM-side).
-        if id.contains("sensorfailed") || id.contains("sensor.failed") || id.contains("sensorstopped")
-            || id.contains("sensorfailure") || id.contains("sensorerror") || id.contains("invalidsensor")
-            || id.contains("encryptedsensor") || id.contains("sensortemperature")
-            || id.contains("sensorlowtemperature") || id.contains("readertemperature")
-            || id.contains("sensorretirement") || id.contains("nosensordetected")
-            || id.contains("transmitterdisconnected") || id.contains("glucosesuspended")
-            || id.contains("sensorconnection")
-        {
-            return .sensorFailure
-        }
-
-        // Expiration reminders — F3, pre-expiry warnings (pod expiring, sensor
-        // grace period, calibration grace). Checked BEFORE `.deviceExpired`
-        // so "podexpiring" / "sensorgrace" don't get swallowed by the
-        // generic `expired` substring catch-all below.
-        // Medtrum's `patch-expired` slug is a misnomer — the underlying
-        // alert is a pre-expiry warning ("Your patch has X hours left")
-        // fired via `.delayed`, so it belongs with the reminders.
-        if id.contains("podexpiring") || id.contains("expirationreminder") || id.contains("userpodexpiration")
-            || id.contains("retiringsoon") || id.contains("sensorending") || id.contains("calibrationgrace")
-            || id.contains("sensorgrace") || id.contains("graceperiod")
-            || id.contains("patch-expired") || id.contains("patchexpired")
-        {
-            return .deviceExpirationReminder
-        }
-
-        // Device expired — N6 (pod, sensor, transmitter end-of-life).
-        if id.contains("podexpired") || id.contains("sensorexpired")
-            || id.contains("sensorretired") || id.contains("transmittereol") || id.contains("sensoragedout")
-            || id.contains("mspalarm") || id.contains("expiredsensor")
-            || (id.contains("expired") && !id.contains("suspendtimeexpired"))
-        {
-            return .deviceExpired
-        }
-        if id.contains("shutdownimminent") || id.contains("expireimminent") { return .podShutdownImminent }
-
-        // Low-supply warnings — F1 / F2.
-        if id.contains("lowreservoir") || id.contains("reservoirlow") || id.contains("reservoir-low")
-            || id.contains("remaininginsulin")
-        {
-            return .reservoirLow
-        }
-        if id.contains("lowbattery") || id.contains("batterylow") || id.contains("rlbattery")
-            || id.contains("verylowbattery") || id.contains("batterystatus")
-        {
-            return .batteryLow
-        }
-
-        // Bolus + delivery state. Medtrum's hourly/daily insulin caps auto-
-        // suspend the pump — the patient sees "Insulin has been suspended!"
-        // and needs to clear it. Map to suspendTimeExpired (N2 Delivery
-        // Stopped) so the existing tier config applies.
-        if id.contains("bolusfailed") { return .bolusFailed }
-        if id.contains("suspendtimeexpired") || id.contains("suspendended")
-            || id.contains("patch-daily-limit") || id.contains("patchdailylimit")
-            || id.contains("patch-hourly-limit") || id.contains("patchhourlylimit")
-        {
-            return .suspendTimeExpired
-        }
-        if id.contains("manualtempbasal") { return .manualTempBasalActive }
-
-        // Loop has not run for the expected interval — emitted internally
-        // by the not-looping monitor, not by any pump manager.
-        if id.contains("notlooping") || id.contains("loop.notactive") { return .notLooping }
-
-        // Algorithm error — emitted by APSManager after dwell suppression.
-        // Re-classification by identifier needs to land here so the tier
-        // config in Pump & CGM Alarms applies on the round-trip through issueAlert.
-        if id.contains("algorithmerror") || id.contains("apserror") { return .algorithmError }
-
-        return .other(alertIdentifier)
-    }
-
-    /// Classify a Swift error caught at the `APSManager` boundary — these don't
-    /// come with a LoopKit alert identifier so we inspect the type + description.
+    /// Classify a Swift error caught at the `APSManager` boundary so dwell
+    /// suppression + alert emission have a stable bucket.
     static func categorize(error: Error) -> TrioAlertCategory {
         if let apsError = error as? APSError {
             switch apsError {

+ 34 - 62
Trio/Sources/Services/Alerts/TrioAlertManager.swift

@@ -110,40 +110,27 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
     // MARK: - Issue / Retract
 
     func issueAlert(_ alert: Alert) {
-        let category = TrioAlertClassifier.categorize(alertIdentifier: alert.identifier.alertIdentifier)
         debug(
             .service,
-            "TrioAlertManager.issueAlert \(alert.identifier.value) category=\(category) level=\(alert.interruptionLevel)"
+            "TrioAlertManager.issueAlert \(alert.identifier.value) level=\(alert.interruptionLevel)"
         )
-        guard category.shouldFireImmediately else {
-            debug(.service, "TrioAlertManager dropped \(alert.identifier.value): \(category) does not surface")
-            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`.
-        // If every variant in the matched tier is disabled, drop the alert.
-        let effective: Alert
-        if let pumpCategory = PumpAlertCategory(trioCategory: category) {
-            // Per-category snooze (set by in-app banner snooze) — drop new
-            // alerts in this category for the duration so a stacked deck
-            // doesn't re-pop while the user is still acting on it.
-            if DeviceAlertsStore.shared.isCategorySnoozed(pumpCategory, at: Date()) {
-                debug(.service, "TrioAlertManager dropped \(alert.identifier.value): category \(pumpCategory) snoozed")
-                return
-            }
-            guard let configured = applyDeviceSeverityConfig(to: alert, category: pumpCategory) else {
-                debug(
-                    .service,
-                    "TrioAlertManager dropped \(alert.identifier.value): all variants in tier \(pumpCategory.defaultSeverity) disabled"
-                )
-                return
-            }
-            effective = configured
-        } else {
-            effective = alert
+        // Pump alerts: look up in catalog → override interruptionLevel.
+        // Everything else (CGM lifecycle, Trio-internal glucose / loop) is
+        // passed through with the level its producer chose.
+        let effective: Alert = AlertCatalogRegistry.lookup(alert.identifier).map { entry in
+            applyCatalogEntry(entry, to: alert)
+        } ?? alert
+
+        // Per-tier snooze for catalog-known pump alerts. Critical tier
+        // ignores snooze.
+        if let tier = DeviceAlertSeverity(level: effective.interruptionLevel),
+           AlertCatalogRegistry.lookup(effective.identifier) != nil,
+           tier != .critical,
+           DeviceAlertsStore.shared.isTierSnoozed(tier, at: Date())
+        {
+            debug(.service, "TrioAlertManager dropped \(effective.identifier.value): tier \(tier) snoozed")
+            return
         }
 
         let now = Date()
@@ -172,22 +159,14 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
         playCriticalAudioFallbackIfNeeded(effective, muted: muted)
     }
 
-    private func applyDeviceSeverityConfig(to alert: Alert, category: PumpAlertCategory) -> Alert? {
-        let severity = category.defaultSeverity
-        let now = Date()
-        let isNight = GlucoseAlertsStore.shared.configuration.isNight(at: now)
-        guard let config = DeviceAlertsStore.shared.config(for: severity, at: now, isNight: isNight) else {
-            return nil
-        }
-        let sound: Alert.Sound? = config.playsSound ? .sound(name: config.soundFilename) : nil
-        let level: Alert.InterruptionLevel = config.overridesSilenceAndDND ? .critical : .timeSensitive
-        return Alert(
+    private func applyCatalogEntry(_ entry: Alert.CatalogEntry, to alert: Alert) -> Alert {
+        Alert(
             identifier: alert.identifier,
             foregroundContent: alert.foregroundContent,
             backgroundContent: alert.backgroundContent,
             trigger: alert.trigger,
-            interruptionLevel: level,
-            sound: sound,
+            interruptionLevel: entry.interruptionLevel,
+            sound: alert.sound,
             metadata: alert.metadata
         )
     }
@@ -344,37 +323,30 @@ extension BaseTrioAlertManager: TrioModalAlertResponder, TrioUserNotificationAle
     func requestSnooze(identifier: Alert.Identifier, duration: TimeInterval) {
         let untilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast
         if let glucoseType = GlucoseAlertType(slug: identifier.alertIdentifier) {
-            // Per-type snooze for glucose alarms — the coordinator stamps
-            // `snoozedUntil` on every matching alarm AND retracts each of
-            // their in-flight alerts, so a stacked deck of multiple lows
-            // dismisses together rather than one card at a time.
             broadcaster.notify(GlucoseSnoozeObserver.self, on: .main) { (observer: GlucoseSnoozeObserver) in
                 observer.snoozeGlucoseType(glucoseType, until: untilDate)
             }
-        } else if let pumpCategory = pumpCategory(forIdentifier: identifier) {
-            // Per-category snooze for device alarms — mirrors the glucose
-            // behavior. Records the category snooze on `DeviceAlertsStore`,
-            // retracts every in-flight alert in this category so any
-            // stacked siblings dismiss together.
-            DeviceAlertsStore.shared.snoozeCategory(pumpCategory, until: untilDate)
-            retractAlertsInCategory(pumpCategory)
+        } else if let entry = AlertCatalogRegistry.lookup(identifier),
+                  let tier = DeviceAlertSeverity(level: entry.interruptionLevel),
+                  tier != .critical
+        {
+            DeviceAlertsStore.shared.snoozeTier(tier, until: untilDate)
+            retractAlertsInTier(tier)
         } else {
-            // Unknown / fallback — global mute (Snooze module, lock-screen
-            // action, etc. take this path too).
             Task { @MainActor [weak self] in
                 await self?.applySnooze(for: duration)
             }
         }
     }
 
-    private func pumpCategory(forIdentifier identifier: Alert.Identifier) -> PumpAlertCategory? {
-        PumpAlertCategory(trioCategory: TrioAlertClassifier.categorize(alertIdentifier: identifier.alertIdentifier))
-    }
-
-    private func retractAlertsInCategory(_ category: PumpAlertCategory) {
+    private func retractAlertsInTier(_ tier: DeviceAlertSeverity) {
         let toRetract: [Alert.Identifier] = queue.sync {
-            liveAlerts.keys.filter { id in
-                pumpCategory(forIdentifier: id) == category
+            liveAlerts.compactMap { id, alert in
+                guard let entry = AlertCatalogRegistry.lookup(id),
+                      DeviceAlertSeverity(level: entry.interruptionLevel) == tier
+                else { return nil }
+                _ = alert
+                return id
             }
         }
         for id in toRetract {