From 3cdde1de3b3c8df436d6e5a9c14644ff488c2fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 6 Jul 2026 23:12:44 +0200 Subject: [PATCH 1/2] 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. --- LoopFollow/Alarm/Alarm.swift | 8 +++++++- .../Alarm/AlarmCondition/LowBGCondition.swift | 15 ++++++++++++++- .../AlarmEditing/Editors/LowBgAlarmEditor.swift | 8 ++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index 2de36fb0f..0739aecbe 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -76,6 +76,10 @@ 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 + /// Size of window to observe values, for example battery drop of x within this number of minutes, var monitoringWindow: Int? @@ -101,7 +105,7 @@ 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, monitoringWindow, soundFile case snoozeDuration, playSoundOption, repeatSoundOption case soundDelay, activeOption case missedBolusPrebolusWindow, missedBolusIgnoreSmallBolusUnits @@ -124,6 +128,7 @@ 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 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 +159,7 @@ 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.encodeIfPresent(monitoringWindow, forKey: .monitoringWindow) try container.encode(soundFile, forKey: .soundFile) try container.encode(snoozeDuration, forKey: .snoozeDuration) diff --git a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift index 77abc55a1..0ec27d7bd 100644 --- a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift +++ b/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 } diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift index 555db90cd..d31e621a4 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift +++ b/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) From 8ad776b81e6c0d1de912a14e1a0b2822b21a387b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 6 Jul 2026 23:27:36 +0200 Subject: [PATCH 2/2] 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. --- LoopFollow/Alarm/Alarm.swift | 14 +++++++++++++- .../Alarm/AlarmCondition/BatteryCondition.swift | 5 +++++ .../Alarm/AlarmCondition/HighBGCondition.swift | 10 ++++++++++ LoopFollow/Alarm/AlarmData.swift | 1 + .../AlarmEditing/Editors/HighBgAlarmEditor.swift | 8 ++++++++ .../Editors/PhoneBatteryAlarmEditor.swift | 8 ++++++++ .../Controllers/Nightscout/DeviceStatus.swift | 4 +++- LoopFollow/Storage/Observable.swift | 1 + LoopFollow/Task/AlarmTask.swift | 1 + Tests/AlarmConditions/Helpers.swift | 3 +++ 10 files changed, 53 insertions(+), 2 deletions(-) diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index 0739aecbe..33e1286d4 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -80,6 +80,13 @@ struct Alarm: Identifiable, Codable, Equatable { /// 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? @@ -105,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, suppressIfRising, monitoringWindow, soundFile + case persistentMinutes, suppressIfRising, suppressIfFalling, suppressIfCharging + case monitoringWindow, soundFile case snoozeDuration, playSoundOption, repeatSoundOption case soundDelay, activeOption case missedBolusPrebolusWindow, missedBolusIgnoreSmallBolusUnits @@ -129,6 +137,8 @@ struct Alarm: Identifiable, Codable, Equatable { 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 @@ -160,6 +170,8 @@ struct Alarm: Identifiable, Codable, Equatable { 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) diff --git a/LoopFollow/Alarm/AlarmCondition/BatteryCondition.swift b/LoopFollow/Alarm/AlarmCondition/BatteryCondition.swift index 1fcd80acf..f5618832e 100644 --- a/LoopFollow/Alarm/AlarmCondition/BatteryCondition.swift +++ b/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 } } diff --git a/LoopFollow/Alarm/AlarmCondition/HighBGCondition.swift b/LoopFollow/Alarm/AlarmCondition/HighBGCondition.swift index 26ea46083..6ba8b9b47 100644 --- a/LoopFollow/Alarm/AlarmCondition/HighBGCondition.swift +++ b/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 } } diff --git a/LoopFollow/Alarm/AlarmData.swift b/LoopFollow/Alarm/AlarmData.swift index b960a9223..8c1e32503 100644 --- a/LoopFollow/Alarm/AlarmData.swift +++ b/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] diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/HighBgAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/HighBgAlarmEditor.swift index 38243c22e..1adf217aa 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/HighBgAlarmEditor.swift +++ b/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) diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/PhoneBatteryAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/PhoneBatteryAlarmEditor.swift index 67c0bd9c9..d08ee68ed 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/PhoneBatteryAlarmEditor.swift +++ b/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) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index d2d6920d8..c3034cc1d 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -133,14 +133,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) diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 4128918db..b69062dd0 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -41,6 +41,7 @@ class Observable { var previousAlertLastLoopTime = ObservableValue(default: nil) var deviceRecBolus = ObservableValue(default: nil) var deviceBatteryLevel = ObservableValue(default: nil) + var deviceBatteryIsCharging = ObservableValue(default: nil) var pumpBatteryLevel = ObservableValue(default: nil) var enactedOrSuggested = ObservableValue(default: nil) diff --git a/LoopFollow/Task/AlarmTask.swift b/LoopFollow/Task/AlarmTask.swift index 0102d66ed..23ca31cd1 100644 --- a/LoopFollow/Task/AlarmTask.swift +++ b/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 diff --git a/Tests/AlarmConditions/Helpers.swift b/Tests/AlarmConditions/Helpers.swift index d6b05630c..1fb91ba6c 100644 --- a/Tests/AlarmConditions/Helpers.swift +++ b/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