Alarm.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. // LoopFollow
  2. // Alarm.swift
  3. import Foundation
  4. import HealthKit
  5. import UserNotifications
  6. protocol DayNightDisplayable {
  7. var displayName: String { get }
  8. }
  9. extension DayNightDisplayable where Self: RawRepresentable, Self.RawValue == String {
  10. var displayName: String {
  11. rawValue == "always" ? "Day & Night" : rawValue.capitalized
  12. }
  13. }
  14. enum PlaySoundOption: String, CaseIterable, Codable, DayNightDisplayable {
  15. case always, day, night, never
  16. }
  17. enum RepeatSoundOption: String, CaseIterable, Codable, DayNightDisplayable {
  18. case always, day, night, never
  19. }
  20. enum ActiveOption: String, CaseIterable, Codable, DayNightDisplayable {
  21. case always, day, night
  22. }
  23. extension PlaySoundOption {
  24. static func allowed(for active: ActiveOption) -> [PlaySoundOption] {
  25. switch active {
  26. case .always: return PlaySoundOption.allCases
  27. case .day: return [.day, .never]
  28. case .night: return [.night, .never]
  29. }
  30. }
  31. }
  32. extension RepeatSoundOption {
  33. static func allowed(for active: ActiveOption) -> [RepeatSoundOption] {
  34. switch active {
  35. case .always: return RepeatSoundOption.allCases
  36. case .day: return [.day, .never]
  37. case .night: return [.night, .never]
  38. }
  39. }
  40. }
  41. struct Alarm: Identifiable, Codable, Equatable {
  42. var id: UUID = .init()
  43. var type: AlarmType
  44. /// Name of the alarm, defaults to alarm type
  45. var name: String
  46. var isEnabled: Bool = true
  47. /// If the alarm is manually snoozed, we store the end time for the snooze here
  48. var snoozedUntil: Date?
  49. /// BG alarm threasholds
  50. var aboveBG: Double?
  51. var belowBG: Double?
  52. /// Alarm threashold, it can be a day for example
  53. var threshold: Double?
  54. /// If the alarm looks at predictions, this is how long into the future to look
  55. var predictiveMinutes: Int?
  56. /// If the alarm acts on delta, the delta is stored here, it can be a delta bgvalue (in mg/Dl)
  57. /// If a delta alarm is only active below a bg, that bg is stored in threshold
  58. var delta: Double?
  59. /// Number of minutes that must satisfy the alarm criteria
  60. var persistentMinutes: Int?
  61. /// When set, the low alarm stays silent while BG is rising (positive delta),
  62. /// only firing when the latest reading is flat or still falling.
  63. var suppressIfRising: Bool = false
  64. /// When set, the high alarm stays silent while BG is falling (negative delta),
  65. /// only firing when the latest reading is flat or still rising.
  66. var suppressIfFalling: Bool = false
  67. /// When set, the phone-battery alarm stays silent while the phone is charging.
  68. var suppressIfCharging: Bool = false
  69. /// Size of window to observe values, for example battery drop of x within this number of minutes,
  70. var monitoringWindow: Int?
  71. var soundFile: SoundFile
  72. /// Snooze duration, it can be minutes, days or hours. Stepping is different per alarm type
  73. var snoozeDuration: Int = 5
  74. /// When the alarm should play it's sound
  75. var playSoundOption: PlaySoundOption = .always
  76. /// When the sound should repeat
  77. var repeatSoundOption: RepeatSoundOption = .always
  78. /// Delay in seconds between repeated alarm sounds (0 = no delay, only applies when repeating)
  79. var soundDelay: Int = 0
  80. /// When is the alarm active
  81. var activeOption: ActiveOption = .always
  82. // MARK: - Codable (Custom implementation for backward compatibility)
  83. enum CodingKeys: String, CodingKey {
  84. case id, type, name, isEnabled, snoozedUntil
  85. case aboveBG, belowBG, threshold, predictiveMinutes, delta
  86. case persistentMinutes, suppressIfRising, suppressIfFalling, suppressIfCharging
  87. case monitoringWindow, soundFile
  88. case snoozeDuration, playSoundOption, repeatSoundOption
  89. case soundDelay, activeOption
  90. case missedBolusPrebolusWindow, missedBolusIgnoreSmallBolusUnits
  91. case missedBolusIgnoreUnderGrams, missedBolusIgnoreUnderBG
  92. case bolusCountThreshold, bolusWindowMinutes
  93. case sensorLifetimeDays
  94. }
  95. init(from decoder: Decoder) throws {
  96. let container = try decoder.container(keyedBy: CodingKeys.self)
  97. id = try container.decode(UUID.self, forKey: .id)
  98. type = try container.decode(AlarmType.self, forKey: .type)
  99. name = try container.decode(String.self, forKey: .name)
  100. isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
  101. snoozedUntil = try container.decodeIfPresent(Date.self, forKey: .snoozedUntil)
  102. aboveBG = try container.decodeIfPresent(Double.self, forKey: .aboveBG)
  103. belowBG = try container.decodeIfPresent(Double.self, forKey: .belowBG)
  104. threshold = try container.decodeIfPresent(Double.self, forKey: .threshold)
  105. predictiveMinutes = try container.decodeIfPresent(Int.self, forKey: .predictiveMinutes)
  106. delta = try container.decodeIfPresent(Double.self, forKey: .delta)
  107. persistentMinutes = try container.decodeIfPresent(Int.self, forKey: .persistentMinutes)
  108. suppressIfRising = try container.decodeIfPresent(Bool.self, forKey: .suppressIfRising) ?? false
  109. suppressIfFalling = try container.decodeIfPresent(Bool.self, forKey: .suppressIfFalling) ?? false
  110. suppressIfCharging = try container.decodeIfPresent(Bool.self, forKey: .suppressIfCharging) ?? false
  111. monitoringWindow = try container.decodeIfPresent(Int.self, forKey: .monitoringWindow)
  112. soundFile = try container.decode(SoundFile.self, forKey: .soundFile)
  113. snoozeDuration = try container.decodeIfPresent(Int.self, forKey: .snoozeDuration) ?? 5
  114. playSoundOption = try container.decodeIfPresent(PlaySoundOption.self, forKey: .playSoundOption) ?? .always
  115. repeatSoundOption = try container.decodeIfPresent(RepeatSoundOption.self, forKey: .repeatSoundOption) ?? .always
  116. // Handle backward compatibility: default to 0 if soundDelay is missing
  117. soundDelay = try container.decodeIfPresent(Int.self, forKey: .soundDelay) ?? 0
  118. activeOption = try container.decodeIfPresent(ActiveOption.self, forKey: .activeOption) ?? .always
  119. missedBolusPrebolusWindow = try container.decodeIfPresent(Int.self, forKey: .missedBolusPrebolusWindow)
  120. missedBolusIgnoreSmallBolusUnits = try container.decodeIfPresent(Double.self, forKey: .missedBolusIgnoreSmallBolusUnits)
  121. missedBolusIgnoreUnderGrams = try container.decodeIfPresent(Double.self, forKey: .missedBolusIgnoreUnderGrams)
  122. missedBolusIgnoreUnderBG = try container.decodeIfPresent(Double.self, forKey: .missedBolusIgnoreUnderBG)
  123. bolusCountThreshold = try container.decodeIfPresent(Int.self, forKey: .bolusCountThreshold)
  124. bolusWindowMinutes = try container.decodeIfPresent(Int.self, forKey: .bolusWindowMinutes)
  125. sensorLifetimeDays = try container.decodeIfPresent(Int.self, forKey: .sensorLifetimeDays)
  126. }
  127. func encode(to encoder: Encoder) throws {
  128. var container = encoder.container(keyedBy: CodingKeys.self)
  129. try container.encode(id, forKey: .id)
  130. try container.encode(type, forKey: .type)
  131. try container.encode(name, forKey: .name)
  132. try container.encode(isEnabled, forKey: .isEnabled)
  133. try container.encodeIfPresent(snoozedUntil, forKey: .snoozedUntil)
  134. try container.encodeIfPresent(aboveBG, forKey: .aboveBG)
  135. try container.encodeIfPresent(belowBG, forKey: .belowBG)
  136. try container.encodeIfPresent(threshold, forKey: .threshold)
  137. try container.encodeIfPresent(predictiveMinutes, forKey: .predictiveMinutes)
  138. try container.encodeIfPresent(delta, forKey: .delta)
  139. try container.encodeIfPresent(persistentMinutes, forKey: .persistentMinutes)
  140. try container.encode(suppressIfRising, forKey: .suppressIfRising)
  141. try container.encode(suppressIfFalling, forKey: .suppressIfFalling)
  142. try container.encode(suppressIfCharging, forKey: .suppressIfCharging)
  143. try container.encodeIfPresent(monitoringWindow, forKey: .monitoringWindow)
  144. try container.encode(soundFile, forKey: .soundFile)
  145. try container.encode(snoozeDuration, forKey: .snoozeDuration)
  146. try container.encode(playSoundOption, forKey: .playSoundOption)
  147. try container.encode(repeatSoundOption, forKey: .repeatSoundOption)
  148. try container.encode(soundDelay, forKey: .soundDelay)
  149. try container.encode(activeOption, forKey: .activeOption)
  150. try container.encodeIfPresent(missedBolusPrebolusWindow, forKey: .missedBolusPrebolusWindow)
  151. try container.encodeIfPresent(missedBolusIgnoreSmallBolusUnits, forKey: .missedBolusIgnoreSmallBolusUnits)
  152. try container.encodeIfPresent(missedBolusIgnoreUnderGrams, forKey: .missedBolusIgnoreUnderGrams)
  153. try container.encodeIfPresent(missedBolusIgnoreUnderBG, forKey: .missedBolusIgnoreUnderBG)
  154. try container.encodeIfPresent(bolusCountThreshold, forKey: .bolusCountThreshold)
  155. try container.encodeIfPresent(bolusWindowMinutes, forKey: .bolusWindowMinutes)
  156. try container.encodeIfPresent(sensorLifetimeDays, forKey: .sensorLifetimeDays)
  157. }
  158. // ─────────────────────────────────────────────────────────────
  159. // Missed‑Bolus‑specific settings
  160. // ─────────────────────────────────────────────────────────────
  161. /// “Prebolus Max Time” (if a bolus comes within this many minutes *before* the carbs, treat it as prebolus)
  162. var missedBolusPrebolusWindow: Int?
  163. /// “Ignore Bolus <= X units” (don’t count any bolus smaller than or equal to this)
  164. var missedBolusIgnoreSmallBolusUnits: Double?
  165. /// “Ignore Under Grams” (if carb entry is under this many grams, skip the alert)
  166. var missedBolusIgnoreUnderGrams: Double?
  167. /// “Ignore Under BG” (if current BG is below this, skip the alert)
  168. var missedBolusIgnoreUnderBG: Double?
  169. // ─────────────────────────────────────────────────────────────
  170. // Bolus‑Count fields ─
  171. // ─────────────────────────────────────────────────────────────
  172. /// trigger when N or more of those boluses occur...
  173. var bolusCountThreshold: Int?
  174. /// ...within this many minutes
  175. var bolusWindowMinutes: Int?
  176. // ─────────────────────────────────────────────────────────────
  177. // Sensor‑Change fields ─
  178. // ─────────────────────────────────────────────────────────────
  179. /// CGM sensor lifetime in days (e.g. 10 for Dexcom G6, 15 for G7 15-day)
  180. var sensorLifetimeDays: Int?
  181. /// Function for when the alarm is triggered.
  182. /// 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.
  183. func trigger(config: AlarmConfiguration, now: Date) {
  184. LogManager.shared.log(category: .alarm, message: "Alarm triggered: \(type.rawValue)")
  185. var playSound = true
  186. // Global mute
  187. if let until = config.muteUntil, until > now {
  188. playSound = false
  189. }
  190. // Mute during calls
  191. if !config.audioDuringCalls, isOnPhoneCall() {
  192. playSound = false
  193. }
  194. // Mute this alarm day or night or always
  195. let cal = Calendar.current
  196. let today = cal.startOfDay(for: now)
  197. let dayStart = cal.date(bySettingHour: config.dayStart.hour,
  198. minute: config.dayStart.minute,
  199. second: 0,
  200. of: today)!
  201. let nightStart = cal.date(bySettingHour: config.nightStart.hour,
  202. minute: config.nightStart.minute,
  203. second: 0,
  204. of: today)!
  205. let isNight: Bool
  206. if nightStart >= dayStart {
  207. isNight = (now >= nightStart) || (now < dayStart)
  208. } else {
  209. isNight = (now >= nightStart) && (now < dayStart)
  210. }
  211. let isDay = !isNight
  212. switch playSoundOption {
  213. case .always:
  214. break
  215. case .never:
  216. playSound = false
  217. case .day where !isDay:
  218. playSound = false
  219. case .night where !isNight:
  220. playSound = false
  221. default:
  222. break
  223. }
  224. let shouldRepeat: Bool = {
  225. switch repeatSoundOption {
  226. case .always: return true
  227. case .never: return false
  228. case .day: return isDay
  229. case .night: return isNight
  230. }
  231. }()
  232. AlarmManager.shared.sendNotification(title: type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze")
  233. if playSound {
  234. AlarmSound.setSoundFile(str: soundFile.rawValue)
  235. // Only use delay if repeating is enabled, otherwise delay doesn't make sense
  236. let delay = shouldRepeat ? soundDelay : 0
  237. AlarmSound.play(repeating: shouldRepeat, delay: delay)
  238. }
  239. }
  240. init(type: AlarmType) {
  241. self.type = type
  242. name = type.rawValue
  243. switch type {
  244. case .buildExpire:
  245. /// Alert 7 days before the build expires
  246. threshold = 7
  247. soundFile = .wrongAnswer
  248. snoozeDuration = 1
  249. repeatSoundOption = .always
  250. case .low:
  251. soundFile = .indeed
  252. belowBG = 80
  253. persistentMinutes = 0
  254. predictiveMinutes = 0
  255. case .iob:
  256. soundFile = .alertToneRingtone1
  257. delta = 1
  258. monitoringWindow = 2
  259. predictiveMinutes = 30
  260. threshold = 6
  261. case .cob:
  262. soundFile = .alertToneRingtone2
  263. threshold = 20
  264. case .high:
  265. soundFile = .timeHasCome
  266. aboveBG = 180
  267. persistentMinutes = 0
  268. case .fastDrop:
  269. soundFile = .bigClockTicking
  270. delta = 18
  271. monitoringWindow = 2
  272. case .fastRise:
  273. soundFile = .cartoonFailStringsTrumpet
  274. delta = 10
  275. monitoringWindow = 3
  276. case .missedReading:
  277. soundFile = .cartoonTipToeSneakyWalk
  278. threshold = 16
  279. case .notLooping:
  280. soundFile = .sciFiEngineShutDown
  281. threshold = 31
  282. case .missedBolus:
  283. soundFile = .dholShuffleloop
  284. monitoringWindow = 15
  285. predictiveMinutes = 15
  286. delta = 0.1
  287. threshold = 4
  288. case .futureCarbs:
  289. soundFile = .alertToneRingtone1
  290. threshold = 45 // max lookahead minutes
  291. delta = 5 // min grams
  292. snoozeDuration = 0
  293. repeatSoundOption = .never
  294. case .sensorChange:
  295. soundFile = .wakeUpWillYou
  296. threshold = 12
  297. sensorLifetimeDays = 10
  298. case .pumpChange:
  299. soundFile = .wakeUpWillYou
  300. threshold = 12
  301. case .pump:
  302. soundFile = .marimbaDescend
  303. threshold = 20
  304. case .pumpBattery:
  305. soundFile = .machineCharge
  306. threshold = 20
  307. case .battery:
  308. soundFile = .machineCharge
  309. threshold = 20
  310. case .batteryDrop:
  311. soundFile = .machineCharge
  312. delta = 10
  313. monitoringWindow = 15
  314. case .recBolus:
  315. soundFile = .dholShuffleloop
  316. threshold = 1
  317. case .overrideStart:
  318. soundFile = .endingReached
  319. repeatSoundOption = .never
  320. case .overrideEnd:
  321. soundFile = .alertToneBusy
  322. repeatSoundOption = .never
  323. case .tempTargetStart:
  324. soundFile = .endingReached
  325. repeatSoundOption = .never
  326. case .tempTargetEnd:
  327. soundFile = .alertToneBusy
  328. repeatSoundOption = .never
  329. case .temporary:
  330. soundFile = .indeed
  331. snoozeDuration = 0
  332. aboveBG = 180
  333. belowBG = 70
  334. case .dbSize:
  335. /// Nightscout's own dbsize plugin warns at 60% and calls 75% urgent
  336. soundFile = .wrongAnswer
  337. threshold = 75
  338. snoozeDuration = 1
  339. /// The database fills over weeks, so there is never a reason to wake someone for it
  340. activeOption = .day
  341. playSoundOption = .day
  342. repeatSoundOption = .day
  343. }
  344. }
  345. }
  346. extension AlarmType {
  347. enum Group: String, CaseIterable {
  348. case glucose = "Glucose"
  349. case insulin = "Insulin / Food"
  350. case device = "Device / System"
  351. case other = "Override / Target"
  352. }
  353. var group: Group {
  354. switch self {
  355. case .low, .high, .fastDrop, .fastRise, .missedReading, .temporary:
  356. return .glucose
  357. case .iob, .cob, .missedBolus, .futureCarbs, .recBolus:
  358. return .insulin
  359. case .battery, .batteryDrop, .pump, .pumpBattery, .pumpChange,
  360. .sensorChange, .notLooping, .buildExpire, .dbSize:
  361. return .device
  362. case .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd:
  363. return .other
  364. }
  365. }
  366. var icon: String {
  367. switch self {
  368. case .low: return "arrow.down.to.line"
  369. case .high: return "arrow.up.to.line"
  370. case .fastDrop: return "chevron.down.2"
  371. case .fastRise: return "chevron.up.2"
  372. case .missedReading: return "wifi.slash"
  373. case .iob: return "syringe"
  374. case .cob: return "fork.knife"
  375. case .missedBolus: return "exclamationmark.arrow.triangle.2.circlepath"
  376. case .futureCarbs: return "clock.arrow.circlepath"
  377. case .recBolus: return "bolt.horizontal"
  378. case .battery: return "battery.25"
  379. case .batteryDrop: return "battery.100.bolt"
  380. case .pump: return "drop"
  381. case .pumpBattery: return "powermeter"
  382. case .pumpChange: return "arrow.triangle.2.circlepath"
  383. case .sensorChange: return "sensor.tag.radiowaves.forward"
  384. case .notLooping: return "circle.slash"
  385. case .buildExpire: return "calendar.badge.exclamationmark"
  386. case .dbSize: return "externaldrive.badge.exclamationmark"
  387. case .overrideStart: return "play.circle"
  388. case .overrideEnd: return "stop.circle"
  389. case .tempTargetStart: return "flag"
  390. case .tempTargetEnd: return "flag.slash"
  391. case .temporary: return "bell"
  392. }
  393. }
  394. var blurb: String {
  395. switch self {
  396. case .low: return "Alerts when BG goes below a limit."
  397. case .high: return "Alerts when BG rises above a limit."
  398. case .fastDrop: return "Rapid downward BG trend."
  399. case .fastRise: return "Rapid upward BG trend."
  400. case .missedReading: return "No CGM data for X minutes."
  401. case .iob: return "High insulin-on-board."
  402. case .cob: return "High carbs-on-board."
  403. case .missedBolus: return "Carbs without bolus."
  404. case .futureCarbs: return "Reminder when future carbs are due."
  405. case .recBolus: return "Recommended bolus issued."
  406. case .battery: return "Phone battery low."
  407. case .batteryDrop: return "Battery drops quickly."
  408. case .pump: return "Reservoir level low."
  409. case .pumpBattery: return "Pump battery low."
  410. case .pumpChange: return "Pump change due."
  411. case .sensorChange: return "Sensor change due."
  412. case .notLooping: return "Loop hasn’t completed."
  413. case .buildExpire: return "Looping-app build expiring."
  414. case .dbSize: return "Nightscout database filling up."
  415. case .overrideStart: return "Override just started."
  416. case .overrideEnd: return "Override ended."
  417. case .tempTargetStart: return "Temp target started."
  418. case .tempTargetEnd: return "Temp target ended."
  419. case .temporary: return "One-time BG limit alert."
  420. }
  421. }
  422. }