Alarm.swift 12 KB

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