Alarm.swift 17 KB

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