| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478 |
- // LoopFollow
- // Alarm.swift
- import Foundation
- import HealthKit
- import UserNotifications
- protocol DayNightDisplayable {
- var displayName: String { get }
- }
- extension DayNightDisplayable where Self: RawRepresentable, Self.RawValue == String {
- var displayName: String {
- rawValue == "always" ? "Day & Night" : rawValue.capitalized
- }
- }
- enum PlaySoundOption: String, CaseIterable, Codable, DayNightDisplayable {
- case always, day, night, never
- }
- enum RepeatSoundOption: String, CaseIterable, Codable, DayNightDisplayable {
- case always, day, night, never
- }
- enum ActiveOption: String, CaseIterable, Codable, DayNightDisplayable {
- case always, day, night
- }
- extension PlaySoundOption {
- static func allowed(for active: ActiveOption) -> [PlaySoundOption] {
- switch active {
- case .always: return PlaySoundOption.allCases
- case .day: return [.day, .never]
- case .night: return [.night, .never]
- }
- }
- }
- extension RepeatSoundOption {
- static func allowed(for active: ActiveOption) -> [RepeatSoundOption] {
- switch active {
- case .always: return RepeatSoundOption.allCases
- case .day: return [.day, .never]
- case .night: return [.night, .never]
- }
- }
- }
- struct Alarm: Identifiable, Codable, Equatable {
- var id: UUID = .init()
- var type: AlarmType
- /// Name of the alarm, defaults to alarm type
- var name: String
- var isEnabled: Bool = true
- /// If the alarm is manually snoozed, we store the end time for the snooze here
- var snoozedUntil: Date?
- /// BG alarm threasholds
- var aboveBG: Double?
- var belowBG: Double?
- /// Alarm threashold, it can be a day for example
- var threshold: Double?
- /// If the alarm looks at predictions, this is how long into the future to look
- var predictiveMinutes: Int?
- /// If the alarm acts on delta, the delta is stored here, it can be a delta bgvalue (in mg/Dl)
- /// If a delta alarm is only active below a bg, that bg is stored in threshold
- var delta: Double?
- /// 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?
- var soundFile: SoundFile
- /// Snooze duration, it can be minutes, days or hours. Stepping is different per alarm type
- var snoozeDuration: Int = 5
- /// When the alarm should play it's sound
- var playSoundOption: PlaySoundOption = .always
- /// When the sound should repeat
- var repeatSoundOption: RepeatSoundOption = .always
- /// Delay in seconds between repeated alarm sounds (0 = no delay, only applies when repeating)
- var soundDelay: Int = 0
- /// When is the alarm active
- var activeOption: ActiveOption = .always
- // MARK: - Codable (Custom implementation for backward compatibility)
- enum CodingKeys: String, CodingKey {
- case id, type, name, isEnabled, snoozedUntil
- case aboveBG, belowBG, threshold, predictiveMinutes, delta
- case persistentMinutes, suppressIfRising, suppressIfFalling, suppressIfCharging
- case monitoringWindow, soundFile
- case snoozeDuration, playSoundOption, repeatSoundOption
- case soundDelay, activeOption
- case missedBolusPrebolusWindow, missedBolusIgnoreSmallBolusUnits
- case missedBolusIgnoreUnderGrams, missedBolusIgnoreUnderBG
- case bolusCountThreshold, bolusWindowMinutes
- case sensorLifetimeDays
- }
- init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- id = try container.decode(UUID.self, forKey: .id)
- type = try container.decode(AlarmType.self, forKey: .type)
- name = try container.decode(String.self, forKey: .name)
- isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
- snoozedUntil = try container.decodeIfPresent(Date.self, forKey: .snoozedUntil)
- aboveBG = try container.decodeIfPresent(Double.self, forKey: .aboveBG)
- belowBG = try container.decodeIfPresent(Double.self, forKey: .belowBG)
- threshold = try container.decodeIfPresent(Double.self, forKey: .threshold)
- 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
- playSoundOption = try container.decodeIfPresent(PlaySoundOption.self, forKey: .playSoundOption) ?? .always
- repeatSoundOption = try container.decodeIfPresent(RepeatSoundOption.self, forKey: .repeatSoundOption) ?? .always
- // Handle backward compatibility: default to 0 if soundDelay is missing
- soundDelay = try container.decodeIfPresent(Int.self, forKey: .soundDelay) ?? 0
- activeOption = try container.decodeIfPresent(ActiveOption.self, forKey: .activeOption) ?? .always
- missedBolusPrebolusWindow = try container.decodeIfPresent(Int.self, forKey: .missedBolusPrebolusWindow)
- missedBolusIgnoreSmallBolusUnits = try container.decodeIfPresent(Double.self, forKey: .missedBolusIgnoreSmallBolusUnits)
- missedBolusIgnoreUnderGrams = try container.decodeIfPresent(Double.self, forKey: .missedBolusIgnoreUnderGrams)
- missedBolusIgnoreUnderBG = try container.decodeIfPresent(Double.self, forKey: .missedBolusIgnoreUnderBG)
- bolusCountThreshold = try container.decodeIfPresent(Int.self, forKey: .bolusCountThreshold)
- bolusWindowMinutes = try container.decodeIfPresent(Int.self, forKey: .bolusWindowMinutes)
- sensorLifetimeDays = try container.decodeIfPresent(Int.self, forKey: .sensorLifetimeDays)
- }
- func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encode(id, forKey: .id)
- try container.encode(type, forKey: .type)
- try container.encode(name, forKey: .name)
- try container.encode(isEnabled, forKey: .isEnabled)
- try container.encodeIfPresent(snoozedUntil, forKey: .snoozedUntil)
- try container.encodeIfPresent(aboveBG, forKey: .aboveBG)
- try container.encodeIfPresent(belowBG, forKey: .belowBG)
- try container.encodeIfPresent(threshold, forKey: .threshold)
- 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)
- try container.encode(playSoundOption, forKey: .playSoundOption)
- try container.encode(repeatSoundOption, forKey: .repeatSoundOption)
- try container.encode(soundDelay, forKey: .soundDelay)
- try container.encode(activeOption, forKey: .activeOption)
- try container.encodeIfPresent(missedBolusPrebolusWindow, forKey: .missedBolusPrebolusWindow)
- try container.encodeIfPresent(missedBolusIgnoreSmallBolusUnits, forKey: .missedBolusIgnoreSmallBolusUnits)
- try container.encodeIfPresent(missedBolusIgnoreUnderGrams, forKey: .missedBolusIgnoreUnderGrams)
- try container.encodeIfPresent(missedBolusIgnoreUnderBG, forKey: .missedBolusIgnoreUnderBG)
- try container.encodeIfPresent(bolusCountThreshold, forKey: .bolusCountThreshold)
- try container.encodeIfPresent(bolusWindowMinutes, forKey: .bolusWindowMinutes)
- try container.encodeIfPresent(sensorLifetimeDays, forKey: .sensorLifetimeDays)
- }
- // ─────────────────────────────────────────────────────────────
- // Missed‑Bolus‑specific settings
- // ─────────────────────────────────────────────────────────────
- /// “Prebolus Max Time” (if a bolus comes within this many minutes *before* the carbs, treat it as prebolus)
- var missedBolusPrebolusWindow: Int?
- /// “Ignore Bolus <= X units” (don’t count any bolus smaller than or equal to this)
- var missedBolusIgnoreSmallBolusUnits: Double?
- /// “Ignore Under Grams” (if carb entry is under this many grams, skip the alert)
- var missedBolusIgnoreUnderGrams: Double?
- /// “Ignore Under BG” (if current BG is below this, skip the alert)
- var missedBolusIgnoreUnderBG: Double?
- // ─────────────────────────────────────────────────────────────
- // Bolus‑Count fields ─
- // ─────────────────────────────────────────────────────────────
- /// trigger when N or more of those boluses occur...
- var bolusCountThreshold: Int?
- /// ...within this many minutes
- var bolusWindowMinutes: Int?
- // ─────────────────────────────────────────────────────────────
- // Sensor‑Change fields ─
- // ─────────────────────────────────────────────────────────────
- /// CGM sensor lifetime in days (e.g. 10 for Dexcom G6, 15 for G7 15-day)
- var sensorLifetimeDays: Int?
- /// Function for when the alarm is triggered.
- /// If this alarm, all alarms is disabled or snoozed, then should not be called. This or all alarmd could be muted, then this function will just generate a notification.
- func trigger(config: AlarmConfiguration, now: Date) {
- LogManager.shared.log(category: .alarm, message: "Alarm triggered: \(type.rawValue)")
- var playSound = true
- // Global mute
- if let until = config.muteUntil, until > now {
- playSound = false
- }
- // Mute during calls
- if !config.audioDuringCalls, isOnPhoneCall() {
- playSound = false
- }
- // Mute this alarm day or night or always
- let cal = Calendar.current
- let today = cal.startOfDay(for: now)
- let dayStart = cal.date(bySettingHour: config.dayStart.hour,
- minute: config.dayStart.minute,
- second: 0,
- of: today)!
- let nightStart = cal.date(bySettingHour: config.nightStart.hour,
- minute: config.nightStart.minute,
- second: 0,
- of: today)!
- let isNight: Bool
- if nightStart >= dayStart {
- isNight = (now >= nightStart) || (now < dayStart)
- } else {
- isNight = (now >= nightStart) && (now < dayStart)
- }
- let isDay = !isNight
- switch playSoundOption {
- case .always:
- break
- case .never:
- playSound = false
- case .day where !isDay:
- playSound = false
- case .night where !isNight:
- playSound = false
- default:
- break
- }
- let shouldRepeat: Bool = {
- switch repeatSoundOption {
- case .always: return true
- case .never: return false
- case .day: return isDay
- case .night: return isNight
- }
- }()
- AlarmManager.shared.sendNotification(title: type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze")
- if playSound {
- AlarmSound.setSoundFile(soundFile)
- // Only use delay if repeating is enabled, otherwise delay doesn't make sense
- let delay = shouldRepeat ? soundDelay : 0
- AlarmSound.play(repeating: shouldRepeat, delay: delay)
- }
- }
- init(type: AlarmType) {
- self.type = type
- name = type.rawValue
- switch type {
- case .buildExpire:
- /// Alert 7 days before the build expires
- threshold = 7
- soundFile = .wrongAnswer
- snoozeDuration = 1
- repeatSoundOption = .always
- case .low:
- soundFile = .indeed
- belowBG = 80
- persistentMinutes = 0
- predictiveMinutes = 0
- case .iob:
- soundFile = .alertToneRingtone1
- delta = 1
- monitoringWindow = 2
- predictiveMinutes = 30
- threshold = 6
- case .cob:
- soundFile = .alertToneRingtone2
- threshold = 20
- case .high:
- soundFile = .timeHasCome
- aboveBG = 180
- persistentMinutes = 0
- case .fastDrop:
- soundFile = .bigClockTicking
- delta = 15
- monitoringWindow = 3
- case .fastRise:
- soundFile = .cartoonFailStringsTrumpet
- delta = 10
- monitoringWindow = 3
- case .missedReading:
- soundFile = .cartoonTipToeSneakyWalk
- threshold = 16
- case .notLooping:
- soundFile = .sciFiEngineShutDown
- threshold = 16
- case .missedBolus:
- soundFile = .dholShuffleloop
- monitoringWindow = 15
- predictiveMinutes = 15
- delta = 0.1
- threshold = 4
- case .futureCarbs:
- soundFile = .alertToneRingtone1
- threshold = 45 // max lookahead minutes
- delta = 5 // min grams
- snoozeDuration = 0
- repeatSoundOption = .never
- case .sensorChange:
- soundFile = .wakeUpWillYou
- threshold = 12
- sensorLifetimeDays = 10
- case .pumpChange:
- soundFile = .wakeUpWillYou
- threshold = 12
- case .pump:
- soundFile = .marimbaDescend
- threshold = 20
- case .pumpBattery:
- soundFile = .machineCharge
- threshold = 20
- case .battery:
- soundFile = .machineCharge
- threshold = 20
- case .batteryDrop:
- soundFile = .machineCharge
- delta = 10
- monitoringWindow = 15
- case .recBolus:
- soundFile = .dholShuffleloop
- threshold = 1
- case .overrideStart:
- soundFile = .endingReached
- repeatSoundOption = .never
- case .overrideEnd:
- soundFile = .alertToneBusy
- repeatSoundOption = .never
- case .tempTargetStart:
- soundFile = .endingReached
- repeatSoundOption = .never
- case .tempTargetEnd:
- soundFile = .alertToneBusy
- repeatSoundOption = .never
- case .temporary:
- soundFile = .indeed
- snoozeDuration = 0
- aboveBG = 180
- belowBG = 70
- case .dbSize:
- /// Nightscout's own dbsize plugin warns at 60% and calls 75% urgent
- soundFile = .wrongAnswer
- threshold = 75
- snoozeDuration = 1
- /// The database fills over weeks, so there is never a reason to wake someone for it
- activeOption = .day
- playSoundOption = .day
- repeatSoundOption = .day
- }
- }
- }
- extension AlarmType {
- enum Group: String, CaseIterable {
- case glucose = "Glucose"
- case insulin = "Insulin / Food"
- case device = "Device / System"
- case other = "Override / Target"
- }
- var group: Group {
- switch self {
- case .low, .high, .fastDrop, .fastRise, .missedReading, .temporary:
- return .glucose
- case .iob, .cob, .missedBolus, .futureCarbs, .recBolus:
- return .insulin
- case .battery, .batteryDrop, .pump, .pumpBattery, .pumpChange,
- .sensorChange, .notLooping, .buildExpire, .dbSize:
- return .device
- case .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd:
- return .other
- }
- }
- var icon: String {
- switch self {
- case .low: return "arrow.down.to.line"
- case .high: return "arrow.up.to.line"
- case .fastDrop: return "chevron.down.2"
- case .fastRise: return "chevron.up.2"
- case .missedReading: return "wifi.slash"
- case .iob: return "syringe"
- case .cob: return "fork.knife"
- case .missedBolus: return "exclamationmark.arrow.triangle.2.circlepath"
- case .futureCarbs: return "clock.arrow.circlepath"
- case .recBolus: return "bolt.horizontal"
- case .battery: return "battery.25"
- case .batteryDrop: return "battery.100.bolt"
- case .pump: return "drop"
- case .pumpBattery: return "powermeter"
- case .pumpChange: return "arrow.triangle.2.circlepath"
- case .sensorChange: return "sensor.tag.radiowaves.forward"
- case .notLooping: return "circle.slash"
- case .buildExpire: return "calendar.badge.exclamationmark"
- case .dbSize: return "externaldrive.badge.exclamationmark"
- case .overrideStart: return "play.circle"
- case .overrideEnd: return "stop.circle"
- case .tempTargetStart: return "flag"
- case .tempTargetEnd: return "flag.slash"
- case .temporary: return "bell"
- }
- }
- var blurb: String {
- switch self {
- case .low: return "Alerts when BG goes below a limit."
- case .high: return "Alerts when BG rises above a limit."
- case .fastDrop: return "Rapid downward BG trend."
- case .fastRise: return "Rapid upward BG trend."
- case .missedReading: return "No CGM data for X minutes."
- case .iob: return "High insulin-on-board."
- case .cob: return "High carbs-on-board."
- case .missedBolus: return "Carbs without bolus."
- case .futureCarbs: return "Reminder when future carbs are due."
- case .recBolus: return "Recommended bolus issued."
- case .battery: return "Phone battery low."
- case .batteryDrop: return "Battery drops quickly."
- case .pump: return "Reservoir level low."
- case .pumpBattery: return "Pump battery low."
- case .pumpChange: return "Pump change due."
- case .sensorChange: return "Sensor change due."
- case .notLooping: return "Loop hasn’t completed."
- case .buildExpire: return "Looping-app build expiring."
- case .dbSize: return "Nightscout database filling up."
- case .overrideStart: return "Override just started."
- case .overrideEnd: return "Override ended."
- case .tempTargetStart: return "Temp target started."
- case .tempTargetEnd: return "Temp target ended."
- case .temporary: return "One-time BG limit alert."
- }
- }
- }
|