Selaa lähdekoodia

Add directional/charging skip toggles to Low, High and Battery alarms (#692)

* Add option to skip low BG alarm while rising

The low alarm can now stay silent while BG is climbing, firing only
when the latest reading is flat or still falling. Off by default, so
existing alarms are unaffected.

* Add skip-if-falling for high alarm and skip-while-charging for battery

The high alarm can now stay silent while BG is falling (mirror of the
low alarm's skip-if-rising). The phone-battery alarm can stay silent
while the phone is charging, using the uploader's charging status from
Nightscout. Both are off by default.
Jonas Björkert 1 viikko sitten
vanhempi
commit
d347bfc94e

+ 19 - 1
LoopFollow/Alarm/Alarm.swift

@@ -76,6 +76,17 @@ struct Alarm: Identifiable, Codable, Equatable {
     /// Number of minutes that must satisfy the alarm criteria
     var persistentMinutes: Int?
 
+    /// When set, the low alarm stays silent while BG is rising (positive delta),
+    /// only firing when the latest reading is flat or still falling.
+    var suppressIfRising: Bool = false
+
+    /// When set, the high alarm stays silent while BG is falling (negative delta),
+    /// only firing when the latest reading is flat or still rising.
+    var suppressIfFalling: Bool = false
+
+    /// When set, the phone-battery alarm stays silent while the phone is charging.
+    var suppressIfCharging: Bool = false
+
     /// Size of window to observe values, for example battery drop of x within this number of minutes,
     var monitoringWindow: Int?
 
@@ -101,7 +112,8 @@ struct Alarm: Identifiable, Codable, Equatable {
     enum CodingKeys: String, CodingKey {
         case id, type, name, isEnabled, snoozedUntil
         case aboveBG, belowBG, threshold, predictiveMinutes, delta
-        case persistentMinutes, monitoringWindow, soundFile
+        case persistentMinutes, suppressIfRising, suppressIfFalling, suppressIfCharging
+        case monitoringWindow, soundFile
         case snoozeDuration, playSoundOption, repeatSoundOption
         case soundDelay, activeOption
         case missedBolusPrebolusWindow, missedBolusIgnoreSmallBolusUnits
@@ -124,6 +136,9 @@ struct Alarm: Identifiable, Codable, Equatable {
         predictiveMinutes = try container.decodeIfPresent(Int.self, forKey: .predictiveMinutes)
         delta = try container.decodeIfPresent(Double.self, forKey: .delta)
         persistentMinutes = try container.decodeIfPresent(Int.self, forKey: .persistentMinutes)
+        suppressIfRising = try container.decodeIfPresent(Bool.self, forKey: .suppressIfRising) ?? false
+        suppressIfFalling = try container.decodeIfPresent(Bool.self, forKey: .suppressIfFalling) ?? false
+        suppressIfCharging = try container.decodeIfPresent(Bool.self, forKey: .suppressIfCharging) ?? false
         monitoringWindow = try container.decodeIfPresent(Int.self, forKey: .monitoringWindow)
         soundFile = try container.decode(SoundFile.self, forKey: .soundFile)
         snoozeDuration = try container.decodeIfPresent(Int.self, forKey: .snoozeDuration) ?? 5
@@ -154,6 +169,9 @@ struct Alarm: Identifiable, Codable, Equatable {
         try container.encodeIfPresent(predictiveMinutes, forKey: .predictiveMinutes)
         try container.encodeIfPresent(delta, forKey: .delta)
         try container.encodeIfPresent(persistentMinutes, forKey: .persistentMinutes)
+        try container.encode(suppressIfRising, forKey: .suppressIfRising)
+        try container.encode(suppressIfFalling, forKey: .suppressIfFalling)
+        try container.encode(suppressIfCharging, forKey: .suppressIfCharging)
         try container.encodeIfPresent(monitoringWindow, forKey: .monitoringWindow)
         try container.encode(soundFile, forKey: .soundFile)
         try container.encode(snoozeDuration, forKey: .snoozeDuration)

+ 5 - 0
LoopFollow/Alarm/AlarmCondition/BatteryCondition.swift

@@ -11,6 +11,11 @@ struct BatteryCondition: AlarmCondition {
         guard let limit = alarm.threshold, limit > 0 else { return false }
         guard let level = data.latestBattery else { return false }
 
+        // Optional: stay silent while the phone is charging back up.
+        if alarm.suppressIfCharging, data.latestBatteryIsCharging == true {
+            return false
+        }
+
         return level <= limit
     }
 }

+ 10 - 0
LoopFollow/Alarm/AlarmCondition/HighBGCondition.swift

@@ -35,6 +35,16 @@ struct HighBGCondition: AlarmCondition {
             }
         }
 
+        // Optional: stay silent while the latest reading is below the previous one;
+        // only alarm when BG is flat or still rising.
+        if alarm.suppressIfFalling, data.bgReadings.count >= 2 {
+            let last = data.bgReadings[data.bgReadings.count - 1]
+            let previous = data.bgReadings[data.bgReadings.count - 2]
+            if last.sgv > 0, previous.sgv > 0, last.sgv < previous.sgv {
+                return false
+            }
+        }
+
         return persistentOK
     }
 }

+ 14 - 1
LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift

@@ -58,7 +58,20 @@ struct LowBGCondition: AlarmCondition {
         }
 
         // ────────────────────────────────
-        // 3. final decision
+        // 3. rising BG suppression (optional)
+        //    Stay silent while the latest reading is above the previous one;
+        //    only alarm when BG is flat or still falling.
+        // ────────────────────────────────
+        if alarm.suppressIfRising, data.bgReadings.count >= 2 {
+            let last = data.bgReadings[data.bgReadings.count - 1]
+            let previous = data.bgReadings[data.bgReadings.count - 2]
+            if last.sgv > 0, previous.sgv > 0, last.sgv > previous.sgv {
+                return false
+            }
+        }
+
+        // ────────────────────────────────
+        // 4. final decision
         // ────────────────────────────────
         return persistentOK || predictiveTrigger
     }

+ 1 - 0
LoopFollow/Alarm/AlarmData.swift

@@ -20,6 +20,7 @@ struct AlarmData: Codable {
     let IOB: Double?
     let recentBoluses: [BolusEntry]
     let latestBattery: Double?
+    let latestBatteryIsCharging: Bool?
     let latestPumpBattery: Double?
     let batteryHistory: [DataStructs.batteryStruct]
     let recentCarbs: [CarbSample]

+ 8 - 0
LoopFollow/Alarm/AlarmEditing/Editors/HighBgAlarmEditor.swift

@@ -34,6 +34,14 @@ struct HighBgAlarmEditor: View {
                 value: $alarm.persistentMinutes
             )
 
+            Section(
+                header: Text("FALLING BG"),
+                footer: Text("Stay silent while BG is falling. The alert only sounds "
+                    + "when the latest reading is flat or still rising.")
+            ) {
+                Toggle("Skip if BG is falling", isOn: $alarm.suppressIfFalling)
+            }
+
             AlarmActiveSection(alarm: $alarm)
             AlarmAudioSection(alarm: $alarm)
             AlarmSnoozeSection(alarm: $alarm)

+ 8 - 0
LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift

@@ -43,6 +43,14 @@ struct LowBgAlarmEditor: View {
                 value: $alarm.predictiveMinutes
             )
 
+            Section(
+                header: Text("RISING BG"),
+                footer: Text("Stay silent while BG is rising. The alert only sounds "
+                    + "when the latest reading is flat or still falling.")
+            ) {
+                Toggle("Skip if BG is rising", isOn: $alarm.suppressIfRising)
+            }
+
             AlarmActiveSection(alarm: $alarm)
             AlarmAudioSection(alarm: $alarm)
             AlarmSnoozeSection(alarm: $alarm)

+ 8 - 0
LoopFollow/Alarm/AlarmEditing/Editors/PhoneBatteryAlarmEditor.swift

@@ -25,6 +25,14 @@ struct PhoneBatteryAlarmEditor: View {
                 value: $alarm.threshold
             )
 
+            Section(
+                header: Text("CHARGING"),
+                footer: Text("Stay silent while the phone is charging. Requires the "
+                    + "uploader to report charging status; if it doesn't, the alert still sounds.")
+            ) {
+                Toggle("Skip while charging", isOn: $alarm.suppressIfCharging)
+            }
+
             AlarmActiveSection(alarm: $alarm)
             AlarmAudioSection(alarm: $alarm)
             AlarmSnoozeSection(alarm: $alarm)

+ 3 - 1
LoopFollow/Controllers/Nightscout/DeviceStatus.swift

@@ -134,14 +134,16 @@ extension MainViewController {
             if let uploader = lastDeviceStatus?["uploader"] as? [String: AnyObject],
                let upbat = uploader["battery"] as? Double
             {
+                let isCharging = uploader["isCharging"] as? Bool
                 let batteryText: String
-                if let isCharging = uploader["isCharging"] as? Bool, isCharging {
+                if isCharging == true {
                     batteryText = "⚡️ " + String(format: "%.0f", upbat) + "%"
                 } else {
                     batteryText = String(format: "%.0f", upbat) + "%"
                 }
                 infoManager.updateInfoData(type: .battery, value: batteryText)
                 Observable.shared.deviceBatteryLevel.value = upbat
+                Observable.shared.deviceBatteryIsCharging.value = isCharging
 
                 let timestamp = uploader["timestamp"] as? Date ?? Date()
                 let currentBattery = DataStructs.batteryStruct(batteryLevel: upbat, timestamp: timestamp)

+ 1 - 0
LoopFollow/Storage/Observable.swift

@@ -41,6 +41,7 @@ class Observable {
     var previousAlertLastLoopTime = ObservableValue<TimeInterval?>(default: nil)
     var deviceRecBolus = ObservableValue<Double?>(default: nil)
     var deviceBatteryLevel = ObservableValue<Double?>(default: nil)
+    var deviceBatteryIsCharging = ObservableValue<Bool?>(default: nil)
     var pumpBatteryLevel = ObservableValue<Double?>(default: nil)
     var dbSizePercentage = ObservableValue<Double?>(default: nil)
     var enactedOrSuggested = ObservableValue<TimeInterval?>(default: nil)

+ 1 - 0
LoopFollow/Task/AlarmTask.swift

@@ -50,6 +50,7 @@ extension MainViewController {
                 IOB: self.latestIOB?.value,
                 recentBoluses: bolusEntries,
                 latestBattery: latestBattery,
+                latestBatteryIsCharging: Observable.shared.deviceBatteryIsCharging.value,
                 latestPumpBattery: latestPumpBattery,
                 batteryHistory: self.deviceBatteryData,
                 recentCarbs: recentCarbs,

+ 3 - 0
Tests/AlarmConditions/Helpers.swift

@@ -51,6 +51,7 @@ extension AlarmData {
             IOB: nil,
             recentBoluses: [],
             latestBattery: level,
+            latestBatteryIsCharging: nil,
             latestPumpBattery: nil,
             batteryHistory: [],
             recentCarbs: []
@@ -75,6 +76,7 @@ extension AlarmData {
             IOB: nil,
             recentBoluses: [],
             latestBattery: nil,
+            latestBatteryIsCharging: nil,
             latestPumpBattery: nil,
             batteryHistory: [],
             recentCarbs: []
@@ -99,6 +101,7 @@ extension AlarmData {
             IOB: nil,
             recentBoluses: [],
             latestBattery: nil,
+            latestBatteryIsCharging: nil,
             latestPumpBattery: nil,
             batteryHistory: [],
             recentCarbs: carbs