Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
DD57A7C02F7000000000000D /* Exceptions for "Shared" folder in "LoopFollowWatch" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
CarPlayWidgetState.swift,
GlucoseLiveActivityAttributes.swift,
LAAppGroupSettings.swift,
LALivenessMarker.swift,
Expand All @@ -149,6 +150,7 @@
DD57A7C02F7000000000000E /* Exceptions for "Shared" folder in "LoopFollowWatchWidgets" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
CarPlayWidgetState.swift,
GlucoseLiveActivityAttributes.swift,
LAAppGroupSettings.swift,
LALivenessMarker.swift,
Expand Down
11 changes: 9 additions & 2 deletions LoopFollow/Alarm/Alarm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ struct Alarm: Identifiable, Codable, Equatable {

var isEnabled: Bool = true

/// Show this alarm on the CarPlay widget while it's triggered. Off by
/// default, so the car only shows what the user picked.
var showInCarPlay: Bool = false

/// If the alarm is manually snoozed, we store the end time for the snooze here
var snoozedUntil: Date?

Expand Down Expand Up @@ -110,7 +114,7 @@ struct Alarm: Identifiable, Codable, Equatable {
// MARK: - Codable (Custom implementation for backward compatibility)

enum CodingKeys: String, CodingKey {
case id, type, name, isEnabled, snoozedUntil
case id, type, name, isEnabled, showInCarPlay, snoozedUntil
case aboveBG, belowBG, threshold, predictiveMinutes, delta
case persistentMinutes, suppressIfRising, suppressIfFalling, suppressIfCharging
case monitoringWindow, soundFile
Expand All @@ -129,6 +133,7 @@ struct Alarm: Identifiable, Codable, Equatable {
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
showInCarPlay = try container.decodeIfPresent(Bool.self, forKey: .showInCarPlay) ?? false
snoozedUntil = try container.decodeIfPresent(Date.self, forKey: .snoozedUntil)
aboveBG = try container.decodeIfPresent(Double.self, forKey: .aboveBG)
belowBG = try container.decodeIfPresent(Double.self, forKey: .belowBG)
Expand Down Expand Up @@ -162,6 +167,7 @@ struct Alarm: Identifiable, Codable, Equatable {
try container.encode(type, forKey: .type)
try container.encode(name, forKey: .name)
try container.encode(isEnabled, forKey: .isEnabled)
try container.encode(showInCarPlay, forKey: .showInCarPlay)
try container.encodeIfPresent(snoozedUntil, forKey: .snoozedUntil)
try container.encodeIfPresent(aboveBG, forKey: .aboveBG)
try container.encodeIfPresent(belowBG, forKey: .belowBG)
Expand Down Expand Up @@ -219,7 +225,8 @@ struct Alarm: Identifiable, Codable, Equatable {
var sensorLifetimeDays: Int?

/// Extra notification text: the CGM state behind a gap in readings.
private var notificationBody: String? {
/// Also shown under the alarm on the CarPlay widget.
var notificationBody: String? {
guard type == .missedReading || type == .notLooping,
let state = Observable.shared.cgmSensorState.value
else { return nil }
Expand Down
22 changes: 20 additions & 2 deletions LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ protocol AlarmCondition {
/// threshold, checked in `evaluate`; `false` (the default) treats them
/// as activation limits enforced by `passesBGLimits`.
var checksOwnBGLimits: Bool { get }
/// Whether the condition holds now, without side effects. A requirement
/// so the COB and recommended bolus overrides are dispatched dynamically.
func holds(alarm: Alarm, data: AlarmData, now: Date) -> Bool?
}

extension AlarmCondition {
Expand Down Expand Up @@ -69,6 +72,22 @@ extension AlarmCondition {

/// applies every global & per-alarm guard exactly once
func shouldFire(alarm: Alarm, data: AlarmData, now: Date, config: AlarmConfiguration) -> Bool {
guard passesGuards(alarm: alarm, data: data, now: now, config: config) else { return false }

// finally, run the type-specific logic
return evaluate(alarm: alarm, data: data, now: now)
}

/// Whether the condition holds now, checked without the side effects
/// `evaluate` may have, for the CarPlay widget. Nil for conditions that
/// announce an event rather than a state.
func holds(alarm: Alarm, data: AlarmData, now: Date) -> Bool? {
alarm.type.isStateCondition ? evaluate(alarm: alarm, data: data, now: now) : nil
}

/// The on/off switch, snooze, BG limits and time of day: every check
/// before the type-specific logic.
func passesGuards(alarm: Alarm, data: AlarmData, now: Date, config: AlarmConfiguration) -> Bool {
// master on/off
guard alarm.isEnabled else { return false }
// per-alarm snooze
Expand All @@ -94,7 +113,6 @@ extension AlarmCondition {
guard isNight else { return false }
}

// finally, run the type-specific logic
return evaluate(alarm: alarm, data: data, now: now)
return true
}
}
7 changes: 7 additions & 0 deletions LoopFollow/Alarm/AlarmCondition/COBCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,11 @@ struct COBCondition: AlarmCondition {
Storage.shared.lastCOBNotified.value = cob
return true
}

/// Carbs on board at or above the threshold, whether or not that was
/// already announced.
func holds(alarm: Alarm, data: AlarmData, now _: Date) -> Bool? {
guard let threshold = alarm.threshold, threshold > 0, let cob = data.COB else { return false }
return cob >= threshold
}
}
7 changes: 7 additions & 0 deletions LoopFollow/Alarm/AlarmCondition/RecBolusCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,11 @@ struct RecBolusCondition: AlarmCondition {

return shouldAlert
}

/// A recommended bolus at or above the threshold, whether or not that
/// was already announced.
func holds(alarm: Alarm, data: AlarmData, now _: Date) -> Bool? {
guard let threshold = alarm.threshold, threshold > 0, let rec = data.recBolus else { return false }
return rec >= threshold
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ struct AlarmGeneralSection: View {
var body: some View {
Section(
header: Text("General"),
footer: Text("Give each alarm a unique name—especially if you’ve added more than one of the same kind—so you can tell them apart at a glance.")
footer: Text("Give each alarm a unique name—especially if you’ve added more than one of the same kind—so you can tell them apart at a glance. Turn on CarPlay to also show this alarm on the LoopFollow widget in CarPlay while it’s triggered.")
) {
HStack {
Text("Name")
Expand All @@ -19,6 +19,7 @@ struct AlarmGeneralSection: View {
.foregroundColor(.secondary)
}
Toggle("Enabled", isOn: $alarm.isEnabled)
Toggle("CarPlay", isOn: $alarm.showInCarPlay)
}
}
}
63 changes: 63 additions & 0 deletions LoopFollow/Alarm/AlarmManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ class AlarmManager {
private let evaluators: [AlarmType: AlarmCondition]
private var lastBGAlarmTime: Date?

/// When each alarm last fired and with what title, for the CarPlay
/// widget's event alarms.
private var lastFired: [UUID: (title: String, date: Date)] = [:]

/// How long the CarPlay widget lists an alarm that announces an event,
/// such as an override starting, after it fires.
static let carPlayEventDuration: TimeInterval = 15 * 60

private init(
conditionTypes: [AlarmCondition.Type] = [
BuildExpireCondition.self,
Expand Down Expand Up @@ -136,6 +144,7 @@ class AlarmManager {
if titleOverride != Observable.shared.currentAlarmTitleOverride.value {
Observable.shared.currentAlarmTitleOverride.value = titleOverride
alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now, titleOverride: titleOverride)
lastFired[alarm.id] = (titleOverride ?? alarm.name, now)
}
break
}
Expand All @@ -145,6 +154,7 @@ class AlarmManager {
Observable.shared.currentAlarm.value = alarm.id

alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now, titleOverride: titleOverride)
lastFired[alarm.id] = (titleOverride ?? alarm.name, now)

// Store the latest bg time so we don't use it again
if alarm.type.isBGBased,
Expand Down Expand Up @@ -173,6 +183,59 @@ class AlarmManager {
}
}

/// The alarms the CarPlay widget shows: each enabled alarm marked for
/// CarPlay that is triggered now, the first of each type, most important
/// first, with the title to show. Snoozed alarms count.
///
/// Alarms about a state (low BG, carbs on board over the threshold, a
/// sensor about to expire) are listed while the state holds, checked
/// without the side effects of the alarm's own check. Alarms about an
/// event (an override starting, a missed bolus) are listed for
/// `carPlayEventDuration` after they fire. Whatever is sounding is listed.
func carPlayAlarms(data: AlarmData, now: Date) -> [(alarm: Alarm, title: String)] {
let config = Storage.shared.alarmConfiguration.value
let sounding = Observable.shared.currentAlarm.value
lastFired = lastFired.filter { now.timeIntervalSince($0.value.date) < Self.carPlayEventDuration }

// Between readings the latest is up to 5 minutes old; allow until it
// is stale so BG alerts don't flicker off while waiting for the next.
let readingIsCurrent = data.bgReadings.last.map {
now.timeIntervalSince($0.date) < GlucoseSnapshot.staleAfter
} ?? false

var result: [(alarm: Alarm, title: String)] = []
var listedTypes = Set<AlarmType>()

for alarm in Storage.shared.alarms.value.sorted(by: Alarm.byPriorityThenSpec) {
guard alarm.showInCarPlay, !listedTypes.contains(alarm.type) else { continue }

var title: String?
if alarm.id == sounding {
// A temporary alarm disables itself as it fires, so this
// comes before the check that the alarm is on.
title = Observable.shared.currentAlarmTitleOverride.value ?? alarm.name
} else if alarm.isEnabled, let checker = evaluators[alarm.type] {
if alarm.type.isBGBased, alarm.type != .missedReading, !readingIsCurrent { continue }
var unsnoozed = alarm
unsnoozed.snoozedUntil = nil

if let holds = checker.holds(alarm: unsnoozed, data: data, now: now) {
if holds, checker.passesGuards(alarm: unsnoozed, data: data, now: now, config: config) {
title = alarm.name
}
} else {
title = lastFired[alarm.id]?.title
}
}

if let title {
result.append((alarm, title))
listedTypes.insert(alarm.type)
}
}
return result
}

func performSnooze(_ snoozeUnits: Int? = nil) {
guard let alarmID = Observable.shared.currentAlarm.value else { return }
var alarms = Storage.shared.alarms.value
Expand Down
15 changes: 15 additions & 0 deletions LoopFollow/Alarm/AlarmType/AlarmType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,19 @@ extension AlarmType {
return false
}
}

/// `true` for alarms whose check only reads its inputs, so it can be run
/// again (for the CarPlay widget) without side effects. The others record
/// that they fired, to announce each event once.
var isStateCondition: Bool {
switch self {
case .low, .high, .fastDrop, .fastRise, .missedReading, .notLooping, .temporary,
.iob, .sensorChange, .pumpChange, .pump, .pumpBattery, .battery, .batteryDrop,
.buildExpire, .dbSize:
return true
case .cob, .recBolus, .missedBolus, .futureCarbs,
.overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd:
return false
}
}
}
Loading