Skip to content
Open
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
6 changes: 3 additions & 3 deletions LoopFollow/Alarm/Alarm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,8 @@ struct Alarm: Identifiable, Codable, Equatable {

/// Function for when the alarm is triggered.
/// 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.
func trigger(config: AlarmConfiguration, now: Date) {
LogManager.shared.log(category: .alarm, message: "Alarm triggered: \(type.rawValue)")
func trigger(config: AlarmConfiguration, now: Date, titleOverride: String? = nil) {
LogManager.shared.log(category: .alarm, message: "Alarm triggered: \(titleOverride ?? type.rawValue)")

var playSound = true

Expand Down Expand Up @@ -277,7 +277,7 @@ struct Alarm: Identifiable, Codable, Equatable {
}
}()

AlarmManager.shared.sendNotification(title: type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze")
AlarmManager.shared.sendNotification(title: titleOverride ?? type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze")

if playSound {
AlarmSound.setSoundFile(soundFile)
Expand Down
5 changes: 5 additions & 0 deletions LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ protocol AlarmCondition {
init()
/// pure, per-alarm logic against `AlarmData`
func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool
/// Title for the firing `evaluate` just reported; nil (the default) uses
/// the alarm type's name.
var firedTitle: String? { get }
/// `true` when `belowBG`/`aboveBG` are this condition's own trigger
/// threshold, checked in `evaluate`; `false` (the default) treats them
/// as activation limits enforced by `passesBGLimits`.
Expand All @@ -17,6 +20,8 @@ protocol AlarmCondition {
extension AlarmCondition {
var checksOwnBGLimits: Bool { false }

var firedTitle: String? { nil }

/// Returns `true` when the alarm is allowed to continue evaluating
/// after BG-limit checks; `false` blocks it immediately.
func passesBGLimits(alarm: Alarm, data: AlarmData) -> Bool {
Expand Down
47 changes: 47 additions & 0 deletions LoopFollow/Alarm/AlarmCondition/EndAlarmPhases.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// LoopFollow
// EndAlarmPhases.swift

import Foundation

/// What an end alarm reports: the event has ended, or its scheduled end is
/// within the alarm's early-warning lead time.
enum EndAlarmPhase {
case ended
case endingSoon
}

/// Two-phase evaluation shared by the temp target and override end alarms.
/// Each phase fires at most once per event; the markers hold the end
/// timestamp of the event that last fired that phase.
struct EndAlarmPhases {
/// Ends older than this are not reported.
static let endedGrace: TimeInterval = 15 * 60

let latestStart: TimeInterval?
let latestEnd: TimeInterval?
let activeEnd: TimeInterval?
let leadMinutes: Int?
let endedMarker: StorageValue<TimeInterval?>
let warnedMarker: StorageValue<TimeInterval?>

/// The ended phase takes precedence when both are due in the same tick.
func fire(now: Date) -> EndAlarmPhase? {
let nowTS = now.timeIntervalSince1970

if let endTS = latestEnd, endTS > 0,
nowTS - endTS <= Self.endedGrace,
endTS > (endedMarker.value ?? 0)
{
endedMarker.value = endTS
return .ended
}

guard let lead = leadMinutes, lead > 0, let endTS = activeEnd else { return nil }
let warningOpensAt = endTS - Double(lead) * 60
// An event shorter than the lead time is covered by its start alarm.
if let startTS = latestStart, warningOpensAt <= startTS { return nil }
guard nowTS >= warningOpensAt, warnedMarker.value != endTS else { return nil }
warnedMarker.value = endTS
return .endingSoon
}
}
25 changes: 15 additions & 10 deletions LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,23 @@

import Foundation

struct OverrideEndCondition: AlarmCondition {
/// Fires once when the active override ends and, with `predictiveMinutes`
/// set, once that many minutes before the scheduled end.
final class OverrideEndCondition: AlarmCondition {
static let type: AlarmType = .overrideEnd
private(set) var firedTitle: String?
init() {}

func evaluate(alarm _: Alarm, data: AlarmData, now: Date) -> Bool {
guard let endTS = data.latestOverrideEnd, endTS > 0 else { return false }
guard now.timeIntervalSince1970 - endTS <= 15 * 60 else { return false }

let last = Storage.shared.lastOverrideEndNotified.value ?? 0
guard endTS > last else { return false }

Storage.shared.lastOverrideEndNotified.value = endTS
return true
func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool {
let phase = EndAlarmPhases(
latestStart: data.latestOverrideStart,
latestEnd: data.latestOverrideEnd,
activeEnd: data.activeOverrideEnd,
leadMinutes: alarm.predictiveMinutes,
endedMarker: Storage.shared.lastOverrideEndNotified,
warnedMarker: Storage.shared.lastOverridePreEndNotified
).fire(now: now)
firedTitle = phase == .endingSoon ? "Override Ending Soon" : nil
return phase != nil
}
}
26 changes: 15 additions & 11 deletions LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,23 @@

import Foundation

/// Fires once when the active temp target ends.
struct TempTargetEndCondition: AlarmCondition {
/// Fires once when the active temp target ends and, with `predictiveMinutes`
/// set, once that many minutes before the scheduled end.
final class TempTargetEndCondition: AlarmCondition {
static let type: AlarmType = .tempTargetEnd
private(set) var firedTitle: String?
init() {}

func evaluate(alarm _: Alarm, data: AlarmData, now: Date) -> Bool {
guard let endTS = data.latestTempTargetEnd, endTS > 0 else { return false }
guard now.timeIntervalSince1970 - endTS <= 15 * 60 else { return false }

let last = Storage.shared.lastTempTargetEndNotified.value ?? 0
guard endTS > last else { return false }

Storage.shared.lastTempTargetEndNotified.value = endTS
return true
func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool {
let phase = EndAlarmPhases(
latestStart: data.latestTempTargetStart,
latestEnd: data.latestTempTargetEnd,
activeEnd: data.activeTempTargetEnd,
leadMinutes: alarm.predictiveMinutes,
endedMarker: Storage.shared.lastTempTargetEndNotified,
warnedMarker: Storage.shared.lastTempTargetPreEndNotified
).fire(now: now)
firedTitle = phase == .endingSoon ? "Temp Target Ending Soon" : nil
return phase != nil
}
}
4 changes: 4 additions & 0 deletions LoopFollow/Alarm/AlarmData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ struct AlarmData: Codable {
let latestOverrideEnd: TimeInterval?
let latestTempTargetStart: TimeInterval?
let latestTempTargetEnd: TimeInterval?
/// Scheduled end of the currently-active override/temp target, if known
/// in advance — used by the end alarms' early warning.
var activeOverrideEnd: TimeInterval?
var activeTempTargetEnd: TimeInterval?
let recBolus: Double?
let COB: Double?
let sageInsertTime: TimeInterval?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,21 @@ struct OverrideEndAlarmEditor: View {

var body: some View {
Group {
InfoBanner(text: "Alerts when an override ends.", alarmType: alarm.type)
InfoBanner(text: "Alerts when an override ends, with an optional early warning before the scheduled end.", alarmType: alarm.type)

AlarmGeneralSection(alarm: $alarm)

AlarmStepperSection(
header: "Early Warning",
footer: "Also alert this many minutes before the override "
+ "is scheduled to end. Set to 0 to alert only when it ends.",
title: "Warn before end",
range: 0 ... 30,
step: 5,
unitLabel: alarm.type.snoozeTimeUnit.label,
value: $alarm.predictiveMinutes
)

AlarmActiveSection(alarm: $alarm)
AlarmAudioSection(alarm: $alarm, hideRepeat: true)
AlarmSnoozeSection(alarm: $alarm)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,21 @@ struct TempTargetEndAlarmEditor: View {

var body: some View {
Group {
InfoBanner(text: "Alerts when a temp target ends.", alarmType: alarm.type)
InfoBanner(text: "Alerts when a temp target ends, with an optional early warning before the scheduled end.", alarmType: alarm.type)

AlarmGeneralSection(alarm: $alarm)

AlarmStepperSection(
header: "Early Warning",
footer: "Also alert this many minutes before the temp target "
+ "is scheduled to end. Set to 0 to alert only when it ends.",
title: "Warn before end",
range: 0 ... 30,
step: 5,
unitLabel: alarm.type.snoozeTimeUnit.label,
value: $alarm.predictiveMinutes
)

AlarmActiveSection(alarm: $alarm)
AlarmAudioSection(alarm: $alarm, hideRepeat: true)
AlarmSnoozeSection(alarm: $alarm)
Expand Down
15 changes: 12 additions & 3 deletions LoopFollow/Alarm/AlarmManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,16 +126,24 @@ class AlarmManager {
continue
}

// If this alarm is active, and still fulfill the requirements, let it be active
// Break the loop, nothing else to do
let titleOverride = checker.firedTitle

// An active alarm that still fulfills the requirements stays active.
// A different phase of it (an end alarm's early warning followed
// by the end itself) is announced with its own title.
if Observable.shared.currentAlarm.value == alarm.id {
if titleOverride != Observable.shared.currentAlarmTitleOverride.value {
Observable.shared.currentAlarmTitleOverride.value = titleOverride
alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now, titleOverride: titleOverride)
}
break
}

// Fire the alarm and break the loop; we only allow one alarm per evaluation tick.
Observable.shared.currentAlarmTitleOverride.value = titleOverride
Observable.shared.currentAlarm.value = alarm.id

alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now)
alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now, titleOverride: titleOverride)

// Store the latest bg time so we don't use it again
if alarm.type.isBGBased,
Expand Down Expand Up @@ -183,6 +191,7 @@ class AlarmManager {
func stopAlarm() {
AlarmSound.stop()
Observable.shared.currentAlarm.value = nil
Observable.shared.currentAlarmTitleOverride.value = nil
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ extension MainViewController {

var end: TimeInterval = isIndefinite ? maxEndDate : start + durationSeconds

// True end for countdown display: based on the raw start and never
// clamped to the graph edge; nil while indefinite.
// True end for countdown display and the end alarm's early
// warning: based on the raw start and never clamped to the graph
// edge; nil while indefinite.
var trueEnd: TimeInterval? = isIndefinite ? nil : startDate.timeIntervalSince1970 + durationSeconds

if let nextStart = nextStart {
Expand Down Expand Up @@ -84,7 +85,8 @@ extension MainViewController {
reason: (e["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
?? (e["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
?? "",
sgv: -20
sgv: -20,
scheduledEndDate: trueEnd
)
overrideGraphData.append(dot)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,7 @@ extension MainViewController {
let currentTime = Date().timeIntervalSince1970
if currentTime < endDate {
activeTempTarget = Int(targetValue!)
// Trio represents indefinite temp targets as a ~30-day duration;
// treat a week or longer as indefinite (no end to display).
activeTempTargetEndAt = duration >= 7 * 24 * 3600 ? nil : endDate
activeTempTargetEndAt = dot.scheduledEndDate
}
}

Expand Down
10 changes: 10 additions & 0 deletions LoopFollow/Helpers/DataStructs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ class DataStructs {
var enteredBy: String
var reason: String
var sgv: Float
/// Scheduled end (start + duration); unlike `endDate` it is never
/// clamped to the graph horizon. Nil when the override runs
/// indefinitely, so the end is not known in advance.
var scheduledEndDate: TimeInterval?
}

struct tempTargetStruct: Codable {
Expand All @@ -55,5 +59,11 @@ class DataStructs {
var correctionRange: [Int]
var enteredBy: String
var reason: String

/// Scheduled end; nil for an indefinite target, which Trio stores
/// with a duration of about 30 days.
var scheduledEndDate: TimeInterval? {
duration >= 7 * 24 * 3600 ? nil : endDate
}
}
}
2 changes: 1 addition & 1 deletion LoopFollow/Snoozer/SnoozerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ struct SnoozerView: View {
if let alarm = vm.activeAlarm {
VStack(spacing: 16) {
// Alarm name at the top
Text(alarm.name)
Text(vm.alarmTitle)
.font(.system(size: 30, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
Expand Down
9 changes: 9 additions & 0 deletions LoopFollow/Snoozer/SnoozerViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Foundation

final class SnoozerViewModel: ObservableObject {
@Published var activeAlarm: Alarm?
@Published var alarmTitle: String = ""
@Published var snoozeUnits: Int = 5
@Published var timeUnitLabel: String = "minutes"

Expand All @@ -21,11 +22,19 @@ final class SnoozerViewModel: ObservableObject {
.sink { [weak self] alarm in
self?.activeAlarm = alarm
if let a = alarm {
self?.alarmTitle = Observable.shared.currentAlarmTitleOverride.value ?? a.name
self?.snoozeUnits = a.snoozeDuration
self?.timeUnitLabel = a.type.snoozeTimeUnit.label
}
}
.store(in: &cancellables)
Observable.shared.currentAlarmTitleOverride.$value
.receive(on: DispatchQueue.main)
.sink { [weak self] title in
guard let self, let alarm = self.activeAlarm else { return }
self.alarmTitle = title ?? alarm.name
}
.store(in: &cancellables)
if let alarm = activeAlarm {
snoozeUnits = alarm.snoozeDuration
}
Expand Down
3 changes: 3 additions & 0 deletions LoopFollow/Storage/Observable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ class Observable {
var predictionColor = ObservableValue<Color>(default: .purple)

var currentAlarm = ObservableValue<UUID?>(default: nil)
/// Replaces the current alarm's name on screen when the firing needs its
/// own wording (e.g. an end alarm's early warning); nil shows the name.
var currentAlarmTitleOverride = ObservableValue<String?>(default: nil)
var alarmSoundPlaying = ObservableValue<Bool>(default: false)

var debug = ObservableValue<Bool>(default: false)
Expand Down
2 changes: 2 additions & 0 deletions LoopFollow/Storage/Storage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ class Storage {

var lastOverrideStartNotified = StorageValue<TimeInterval?>(key: "lastOverrideStartNotified", defaultValue: nil)
var lastOverrideEndNotified = StorageValue<TimeInterval?>(key: "lastOverrideEndNotified", defaultValue: nil)
var lastOverridePreEndNotified = StorageValue<TimeInterval?>(key: "lastOverridePreEndNotified", defaultValue: nil)
var lastTempTargetStartNotified = StorageValue<TimeInterval?>(key: "lastTempTargetStartNotified", defaultValue: nil)
var lastTempTargetEndNotified = StorageValue<TimeInterval?>(key: "lastTempTargetEndNotified", defaultValue: nil)
var lastTempTargetPreEndNotified = StorageValue<TimeInterval?>(key: "lastTempTargetPreEndNotified", defaultValue: nil)
var lastRecBolusNotified = StorageValue<Double?>(key: "lastRecBolusNotified", defaultValue: nil)
var lastCOBNotified = StorageValue<Double?>(key: "lastCOBNotified", defaultValue: nil)
var lastMissedBolusNotified = StorageValue<Date?>(key: "lastMissedBolusNotified", defaultValue: nil)
Expand Down
4 changes: 4 additions & 0 deletions LoopFollow/Task/AlarmTask.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ extension MainViewController {
let latestOverrideEnd = self.overrideGraphData.last { $0.endDate <= now }?.endDate
let latestTempTargetStart = self.tempTargetGraphData.last { $0.date <= now }?.date
let latestTempTargetEnd = self.tempTargetGraphData.last { $0.endDate <= now }?.endDate
let activeOverrideEnd = self.overrideGraphData.last { $0.date <= now && ($0.scheduledEndDate ?? 0) > now }?.scheduledEndDate
let activeTempTargetEnd = self.tempTargetGraphData.last { $0.date <= now && $0.endDate > now }?.scheduledEndDate
let recBolus = Observable.shared.deviceRecBolus.value
let COB = self.latestCOB?.value
let sensorInsertedAt = Storage.shared.sageInsertTime.value
Expand All @@ -40,6 +42,8 @@ extension MainViewController {
latestOverrideEnd: latestOverrideEnd,
latestTempTargetStart: latestTempTargetStart,
latestTempTargetEnd: latestTempTargetEnd,
activeOverrideEnd: activeOverrideEnd,
activeTempTargetEnd: activeTempTargetEnd,
recBolus: recBolus,
COB: COB,
sageInsertTime: sensorInsertedAt,
Expand Down
Loading
Loading