|
@@ -1,19 +1,42 @@
|
|
|
import Foundation
|
|
import Foundation
|
|
|
import LoopKit
|
|
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)`
|
|
|
enum TrioAlertCategory: Equatable {
|
|
enum TrioAlertCategory: Equatable {
|
|
|
case occlusion
|
|
case occlusion
|
|
|
case reservoirLow
|
|
case reservoirLow
|
|
|
case reservoirEmpty
|
|
case reservoirEmpty
|
|
|
case batteryLow
|
|
case batteryLow
|
|
|
case batteryEmpty
|
|
case batteryEmpty
|
|
|
- case pumpFault
|
|
|
|
|
- case podExpirationReminder
|
|
|
|
|
- case podExpired
|
|
|
|
|
|
|
+ case hardwareFault
|
|
|
|
|
+ case deliveryUncertain
|
|
|
|
|
+ case deviceExpirationReminder
|
|
|
|
|
+ case deviceExpired
|
|
|
case podShutdownImminent
|
|
case podShutdownImminent
|
|
|
case suspendTimeExpired
|
|
case suspendTimeExpired
|
|
|
case bolusFailed
|
|
case bolusFailed
|
|
|
case manualTempBasalActive
|
|
case manualTempBasalActive
|
|
|
|
|
+ case sensorFailure
|
|
|
case glucoseUrgentLow
|
|
case glucoseUrgentLow
|
|
|
case glucoseLow
|
|
case glucoseLow
|
|
|
case glucoseForecastedLow
|
|
case glucoseForecastedLow
|
|
@@ -28,20 +51,22 @@ enum TrioAlertCategory: Equatable {
|
|
|
case .batteryEmpty,
|
|
case .batteryEmpty,
|
|
|
.batteryLow,
|
|
.batteryLow,
|
|
|
.bolusFailed,
|
|
.bolusFailed,
|
|
|
|
|
+ .deliveryUncertain,
|
|
|
|
|
+ .deviceExpirationReminder,
|
|
|
|
|
+ .deviceExpired,
|
|
|
.glucoseDataStale,
|
|
.glucoseDataStale,
|
|
|
.glucoseForecastedLow,
|
|
.glucoseForecastedLow,
|
|
|
.glucoseHigh,
|
|
.glucoseHigh,
|
|
|
.glucoseLow,
|
|
.glucoseLow,
|
|
|
.glucoseUrgentLow,
|
|
.glucoseUrgentLow,
|
|
|
|
|
+ .hardwareFault,
|
|
|
.manualTempBasalActive,
|
|
.manualTempBasalActive,
|
|
|
.occlusion,
|
|
.occlusion,
|
|
|
.other,
|
|
.other,
|
|
|
- .podExpirationReminder,
|
|
|
|
|
- .podExpired,
|
|
|
|
|
.podShutdownImminent,
|
|
.podShutdownImminent,
|
|
|
- .pumpFault,
|
|
|
|
|
.reservoirEmpty,
|
|
.reservoirEmpty,
|
|
|
.reservoirLow,
|
|
.reservoirLow,
|
|
|
|
|
+ .sensorFailure,
|
|
|
.suspendTimeExpired:
|
|
.suspendTimeExpired:
|
|
|
return true
|
|
return true
|
|
|
case .algorithmError,
|
|
case .algorithmError,
|
|
@@ -53,27 +78,29 @@ enum TrioAlertCategory: Equatable {
|
|
|
var interruptionLevel: Alert.InterruptionLevel {
|
|
var interruptionLevel: Alert.InterruptionLevel {
|
|
|
switch self {
|
|
switch self {
|
|
|
case .batteryEmpty,
|
|
case .batteryEmpty,
|
|
|
|
|
+ .deliveryUncertain,
|
|
|
.glucoseUrgentLow,
|
|
.glucoseUrgentLow,
|
|
|
|
|
+ .hardwareFault,
|
|
|
.occlusion,
|
|
.occlusion,
|
|
|
- .pumpFault,
|
|
|
|
|
.reservoirEmpty:
|
|
.reservoirEmpty:
|
|
|
return .critical
|
|
return .critical
|
|
|
case .batteryLow,
|
|
case .batteryLow,
|
|
|
.bolusFailed,
|
|
.bolusFailed,
|
|
|
|
|
+ .deviceExpired,
|
|
|
.glucoseDataStale,
|
|
.glucoseDataStale,
|
|
|
.glucoseForecastedLow,
|
|
.glucoseForecastedLow,
|
|
|
.glucoseHigh,
|
|
.glucoseHigh,
|
|
|
.glucoseLow,
|
|
.glucoseLow,
|
|
|
.manualTempBasalActive,
|
|
.manualTempBasalActive,
|
|
|
- .podExpired,
|
|
|
|
|
.podShutdownImminent,
|
|
.podShutdownImminent,
|
|
|
.reservoirLow,
|
|
.reservoirLow,
|
|
|
|
|
+ .sensorFailure,
|
|
|
.suspendTimeExpired:
|
|
.suspendTimeExpired:
|
|
|
return .timeSensitive
|
|
return .timeSensitive
|
|
|
case .algorithmError,
|
|
case .algorithmError,
|
|
|
.commsTransient,
|
|
.commsTransient,
|
|
|
- .other,
|
|
|
|
|
- .podExpirationReminder:
|
|
|
|
|
|
|
+ .deviceExpirationReminder,
|
|
|
|
|
+ .other:
|
|
|
return .active
|
|
return .active
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -81,47 +108,98 @@ enum TrioAlertCategory: Equatable {
|
|
|
|
|
|
|
|
enum TrioAlertClassifier {
|
|
enum TrioAlertClassifier {
|
|
|
/// Classify a LoopKit alert identifier coming from a pump or CGM manager.
|
|
/// Classify a LoopKit alert identifier coming from a pump or CGM manager.
|
|
|
- /// Returns `.other(identifier)` for anything not recognized — keeps untyped
|
|
|
|
|
- /// alerts flowing through but marks them as not-alert-worthy by default.
|
|
|
|
|
|
|
+ /// Substring-matched on the lowercased identifier. Returns `.other(identifier)`
|
|
|
|
|
+ /// for anything unrecognized.
|
|
|
///
|
|
///
|
|
|
- /// Coverage mapping by pump manager:
|
|
|
|
|
- /// - Omni family (OmniBLE / OmniKit / OmnipodKit): `userPodExpiration`,
|
|
|
|
|
- /// `podExpiring`, `podExpireImminent`, `lowReservoir`, `suspendEnded`
|
|
|
|
|
- /// (+ untyped: `suspendInProgress`, `finishSetupReminder`,
|
|
|
|
|
- /// `unexpectedAlert`, `timeOffsetChangeDetected`).
|
|
|
|
|
- /// - DanaKit: `occlusion`, `pumpError`, `lowBattery`, `batteryZeroPercent`,
|
|
|
|
|
- /// `shutdown`, `emptyReservoir`, `remainingInsulinLevel`, `checkShaft`
|
|
|
|
|
- /// (+ untyped: `basalCompare`, `bloodSugarMeasure`, `basalMax`,
|
|
|
|
|
- /// `dailyMax`, `bloodSugarCheckMiss`, `ble5InvalidKeys`, `unknown`).
|
|
|
|
|
- /// - MinimedKit: `lowRLBattery`, `PumpBatteryLow`, `PumpReservoirEmpty`,
|
|
|
|
|
- /// `PumpReservoirLow`.
|
|
|
|
|
- /// - MedtrumKit: bypasses LoopKit `AlertIssuer` and posts to
|
|
|
|
|
- /// `UNUserNotificationCenter` directly — alerts do not reach this
|
|
|
|
|
- /// classifier today. Requires a submodule change to issue
|
|
|
|
|
- /// `delegate?.issueAlert(_:)` instead.
|
|
|
|
|
|
|
+ /// Coverage notes (manager audit):
|
|
|
|
|
+ /// - Pumps: OmniBLE / OmniKit / OmnipodKit / DanaKit / MinimedKit /
|
|
|
|
|
+ /// MedtrumKit (MedtrumKit bypasses LoopKit `AlertIssuer` today —
|
|
|
|
|
+ /// submodule change required to route through this classifier).
|
|
|
|
|
+ /// - CGMs: CGMBLEKit (Dexcom G5/G6), G7SensorKit, LibreTransmitter,
|
|
|
|
|
+ /// EversenseKit, NightscoutRemoteCGM, dexcom-share-client-swift.
|
|
|
static func categorize(alertIdentifier: String) -> TrioAlertCategory {
|
|
static func categorize(alertIdentifier: String) -> TrioAlertCategory {
|
|
|
let id = alertIdentifier.lowercased()
|
|
let id = alertIdentifier.lowercased()
|
|
|
- if id.contains("occlusion") { return .occlusion }
|
|
|
|
|
- if id.contains("reservoirempty") || id.contains("emptyreservoir") { return .reservoirEmpty }
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 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("reservoirempty")
|
|
|
|
|
+ {
|
|
|
|
|
+ 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("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("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
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Device expired — N6 (pod, sensor, transmitter end-of-life).
|
|
|
|
|
+ if id.contains("podexpired") || id.contains("podexpiring") || id.contains("sensorexpired")
|
|
|
|
|
+ || id.contains("sensorretired") || id.contains("transmittereol") || id.contains("sensoragedout")
|
|
|
|
|
+ || id.contains("mspalarm") || id.contains("expiredsensor") || id.contains("sensorgrace")
|
|
|
|
|
+ || (id.contains("expired") && !id.contains("suspendtimeexpired"))
|
|
|
|
|
+ {
|
|
|
|
|
+ return .deviceExpired
|
|
|
|
|
+ }
|
|
|
|
|
+ if id.contains("shutdownimminent") || id.contains("expireimminent") { return .podShutdownImminent }
|
|
|
|
|
+ if id.contains("expirationreminder") || id.contains("userpodexpiration") || id.contains("retiringsoon")
|
|
|
|
|
+ || id.contains("sensorending") || id.contains("calibrationgrace") || id.contains("gracePeriod".lowercased())
|
|
|
|
|
+ {
|
|
|
|
|
+ return .deviceExpirationReminder
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Low-supply warnings — F1 / F2.
|
|
|
if id.contains("lowreservoir") || id.contains("reservoirlow") || id.contains("remaininginsulin") {
|
|
if id.contains("lowreservoir") || id.contains("reservoirlow") || id.contains("remaininginsulin") {
|
|
|
return .reservoirLow
|
|
return .reservoirLow
|
|
|
}
|
|
}
|
|
|
- if id.contains("batteryempty") || id.contains("batteryzero") { return .batteryEmpty }
|
|
|
|
|
- if id.contains("lowbattery") || id.contains("batterylow") || id.contains("rlbattery") { return .batteryLow }
|
|
|
|
|
- if id.contains("shutdownimminent") || id.contains("expireimminent") { return .podShutdownImminent }
|
|
|
|
|
- if id.contains("podexpired") || id.contains("podexpiring") || id.contains("expired") { return .podExpired }
|
|
|
|
|
- if id.contains("expirationreminder") || id.contains("userpodexpiration") { return .podExpirationReminder }
|
|
|
|
|
- if id.contains("suspendtimeexpired") || id.contains("suspendended") { return .suspendTimeExpired }
|
|
|
|
|
- if id.contains("fault") || id.contains("pumperror") || id.contains("checkshaft") || id == "shutdown" {
|
|
|
|
|
- return .pumpFault
|
|
|
|
|
|
|
+ if id.contains("lowbattery") || id.contains("batterylow") || id.contains("rlbattery")
|
|
|
|
|
+ || id.contains("verylowbattery") || id.contains("batterystatus")
|
|
|
|
|
+ {
|
|
|
|
|
+ return .batteryLow
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // Bolus + delivery state.
|
|
|
if id.contains("bolusfailed") { return .bolusFailed }
|
|
if id.contains("bolusfailed") { return .bolusFailed }
|
|
|
|
|
+ if id.contains("suspendtimeexpired") || id.contains("suspendended") { return .suspendTimeExpired }
|
|
|
if id.contains("manualtempbasal") { return .manualTempBasalActive }
|
|
if id.contains("manualtempbasal") { return .manualTempBasalActive }
|
|
|
- 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 }
|
|
|
|
|
|
|
+
|
|
|
return .other(alertIdentifier)
|
|
return .other(alertIdentifier)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -133,7 +211,7 @@ enum TrioAlertClassifier {
|
|
|
case let .pumpError(inner):
|
|
case let .pumpError(inner):
|
|
|
return categorize(pumpError: inner)
|
|
return categorize(pumpError: inner)
|
|
|
case .invalidPumpState:
|
|
case .invalidPumpState:
|
|
|
- return .pumpFault
|
|
|
|
|
|
|
+ return .hardwareFault
|
|
|
case .glucoseError:
|
|
case .glucoseError:
|
|
|
return .glucoseDataStale
|
|
return .glucoseDataStale
|
|
|
case .apsError:
|
|
case .apsError:
|
|
@@ -147,17 +225,23 @@ enum TrioAlertClassifier {
|
|
|
|
|
|
|
|
private static func categorize(pumpError: Error) -> TrioAlertCategory {
|
|
private static func categorize(pumpError: Error) -> TrioAlertCategory {
|
|
|
let description = String(describing: pumpError).lowercased()
|
|
let description = String(describing: pumpError).lowercased()
|
|
|
- if description.contains("occlusion") { return .occlusion }
|
|
|
|
|
|
|
+ if description.contains("uncertaindelivery") || description.contains("unacknowledged")
|
|
|
|
|
+ || description.contains("bolus may have failed")
|
|
|
|
|
+ {
|
|
|
|
|
+ return .deliveryUncertain
|
|
|
|
|
+ }
|
|
|
|
|
+ if description.contains("occlusion") || description.contains("occluded") { return .occlusion }
|
|
|
if description.contains("reservoirempty") || description.contains("emptyreservoir") { return .reservoirEmpty }
|
|
if description.contains("reservoirempty") || description.contains("emptyreservoir") { return .reservoirEmpty }
|
|
|
if description.contains("lowreservoir") { return .reservoirLow }
|
|
if description.contains("lowreservoir") { return .reservoirLow }
|
|
|
- if description.contains("fault") { return .pumpFault }
|
|
|
|
|
- if description.contains("podexpired") { return .podExpired }
|
|
|
|
|
|
|
+ if description.contains("fault") || description.contains("patchfault") { return .hardwareFault }
|
|
|
|
|
+ if description.contains("podexpired") || description.contains("sensorexpired") { return .deviceExpired }
|
|
|
|
|
+ if description.contains("sensorfailed") || description.contains("sensorstopped") { return .sensorFailure }
|
|
|
if description.contains("communication") || description.contains("comms") || description.contains("notconnected")
|
|
if description.contains("communication") || description.contains("comms") || description.contains("notconnected")
|
|
|
|| description.contains("noresponse") || description.contains("timeout") || description.contains("rssi")
|
|
|| description.contains("noresponse") || description.contains("timeout") || description.contains("rssi")
|
|
|
{
|
|
{
|
|
|
return .commsTransient
|
|
return .commsTransient
|
|
|
}
|
|
}
|
|
|
- if description.contains("bolusfailed") || description.contains("uncertaindelivery") { return .bolusFailed }
|
|
|
|
|
|
|
+ if description.contains("bolusfailed") { return .bolusFailed }
|
|
|
return .other(String(describing: pumpError))
|
|
return .other(String(describing: pumpError))
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|