Alarm.swift 18 KB

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