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
10 changes: 9 additions & 1 deletion LoopFollow/Alarm/Alarm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,14 @@ struct Alarm: Identifiable, Codable, Equatable {
/// CGM sensor lifetime in days (e.g. 10 for Dexcom G6, 15 for G7 15-day)
var sensorLifetimeDays: Int?

/// Extra notification text: the CGM state behind a gap in readings.
private var notificationBody: String? {
guard type == .missedReading || type == .notLooping,
let state = Observable.shared.cgmSensorState.value
else { return nil }
return "CGM reported: \(state.summary)"
}

/// 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) {
Expand Down Expand Up @@ -277,7 +285,7 @@ struct Alarm: Identifiable, Codable, Equatable {
}
}()

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

if playSound {
AlarmSound.setSoundFile(soundFile)
Expand Down
5 changes: 4 additions & 1 deletion LoopFollow/Alarm/AlarmManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,14 +186,17 @@ class AlarmManager {
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
}

func sendNotification(title: String, actionTitle: String? = nil) {
func sendNotification(title: String, body: String? = nil, actionTitle: String? = nil) {
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()

let content = UNMutableNotificationContent()
content.title = title
content.subtitle += Observable.shared.bgText.value + " "
content.subtitle += Observable.shared.directionText.value + " "
content.subtitle += Observable.shared.deltaText.value
if let body {
content.body = body
}
content.categoryIdentifier = "category"
content.sound = .default

Expand Down
12 changes: 12 additions & 0 deletions LoopFollow/Charts/BGChartModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ final class BGChartModel: ObservableObject {
@Published var resumes: [TreatmentPoint] = []
@Published var sensorStarts: [TreatmentPoint] = []
@Published var notes: [TreatmentPoint] = []
@Published var sensorStates: [TreatmentPoint] = []

@Published var overrides: [BandRect] = []
@Published var tempTargets: [BandRect] = []
Expand Down Expand Up @@ -517,6 +518,17 @@ final class BGChartModel: ObservableObject {
)
}

sensorStates = (showOtherTreatments ? vc.cgmSensorStates : []).compactMap { state in
guard let sgv = CGMSensorState.anchorSGV(at: state.date.timeIntervalSince1970, readings: vc.bgData) else { return nil }
return TreatmentPoint(
date: state.date,
value: Double(sgv),
sgv: Double(sgv),
label: state.name,
pillText: "CGM: \(state.displayName)\n\(pillTimeString(for: state.date))"
)
}

basalScheduled = (showBasal ? vc.basalScheduleData : []).map {
ScheduledBasalPoint(date: Date(timeIntervalSince1970: $0.date), rate: $0.basalRate)
}
Expand Down
12 changes: 11 additions & 1 deletion LoopFollow/Charts/BGChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -682,7 +682,7 @@ private struct MainBGChart: View {
/// lookup and the tap hit test.
private func forEachTreatmentAnchor(_ body: (BGChartModel.TreatmentPoint) -> Void) {
for group in [model.boluses, model.carbs, model.smbs, model.bgChecks,
model.notes, model.suspends, model.resumes, model.sensorStarts]
model.notes, model.sensorStates, model.suspends, model.resumes, model.sensorStarts]
{
for t in group {
body(t)
Expand Down Expand Up @@ -1422,6 +1422,16 @@ private struct BGChartCanvas: View, Equatable {
.symbolSize(isSmall ? 22 : 54)
.foregroundStyle(Color.gray.opacity(0.75))
}

ForEach(windowed(model.sensorStates) { $0.drawnDate }) { pt in
PointMark(
x: .value("time", pt.drawnDate),
y: .value("sgv", pt.sgv)
)
.symbol(.triangle)
.symbolSize(isSmall ? 22 : 54)
.foregroundStyle(Color.orange.opacity(0.85))
}
}

@ChartContentBuilder
Expand Down
2 changes: 2 additions & 0 deletions LoopFollow/Controllers/NightScout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ extension MainViewController {

func clearOldNotes() {
noteGraphData.removeAll()
cgmSensorStates.removeAll()
updateNotes()
updateCGMSensorState()
}
}
1 change: 1 addition & 0 deletions LoopFollow/Controllers/Nightscout/BGData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ extension MainViewController {

self.updateBGGraph()
self.updateStats()
self.updateCGMSensorState()

let latestEntryIndex = entries.count - 1
let latestBG = entries[latestEntryIndex].sgv
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Controllers/Nightscout/Treatments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ extension MainViewController {
if note.count > 0 {
processNotes(entries: note)
} else {
if noteGraphData.count > 0 {
if noteGraphData.count > 0 || cgmSensorStates.count > 0 {
clearOldNotes()
}
}
Expand Down
37 changes: 31 additions & 6 deletions LoopFollow/Controllers/Nightscout/Treatments/Notes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ extension MainViewController {
func processNotes(entries: [[String: AnyObject]]) {
// because it's a small array, we're going to destroy and reload every time.
noteGraphData.removeAll()
cgmSensorStates.removeAll()
var lastFoundIndex = 0

for currentEntry in entries.reversed() {
Expand All @@ -24,15 +25,21 @@ extension MainViewController {

if let parsedDate = NightscoutUtils.parseDate(date) {
let dateTimeStamp = parsedDate.timeIntervalSince1970
let sgv = findNearestBGbyTime(needle: dateTimeStamp, haystack: bgData, startingIndex: lastFoundIndex)
lastFoundIndex = sgv.foundIndex

guard let thisNote = currentEntry["notes"] as? String else { continue }
guard let thisNote = currentEntry["notes"] as? String,
dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (60 * 60))
else { continue }

if dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (60 * 60)) {
let dot = DataStructs.noteStruct(date: Double(dateTimeStamp), sgv: Int(sgv.sgv), note: thisNote)
noteGraphData.append(dot)
if let state = CGMSensorState(note: thisNote, date: parsedDate) {
cgmSensorStates.append(state)
continue
}

let sgv = findNearestBGbyTime(needle: dateTimeStamp, haystack: bgData, startingIndex: lastFoundIndex)
lastFoundIndex = sgv.foundIndex

let dot = DataStructs.noteStruct(date: Double(dateTimeStamp), sgv: Int(sgv.sgv), note: thisNote)
noteGraphData.append(dot)
} else {
print("Failed to parse date")
}
Expand All @@ -41,5 +48,23 @@ extension MainViewController {
if Storage.shared.graphOtherTreatments.value {
updateNotes()
}
updateCGMSensorState()
}

/// Publishes the CGM state reported after the newest reading, if any.
func updateCGMSensorState() {
let latestBGDate = bgData.last.map { Date(timeIntervalSince1970: $0.date) }
let active = CGMSensorState.active(in: cgmSensorStates, latestBGDate: latestBGDate)
guard active != Observable.shared.cgmSensorState.value else { return }

Observable.shared.cgmSensorState.value = active
if let active {
LogManager.shared.log(category: .nightscout, message: "CGM sensor state: \(active.name) at \(active.date)")
} else {
LogManager.shared.log(category: .nightscout, message: "CGM sensor state cleared")
}
#if !targetEnvironment(macCatalyst)
LiveActivityManager.shared.refreshFromCurrentState(reason: "cgmSensorState")
#endif
}
}
93 changes: 93 additions & 0 deletions LoopFollow/Helpers/CGMSensorState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// LoopFollow
// CGMSensorState.swift

import Foundation

/// A CGM state without reliable glucose, as uploaded by Loop or Trio in a
/// Nightscout note of the form "CGM: <state>". The state is the CGM kit's own
/// name, for example "temporarySensorIssue", "warmup" or ".unknown(23)".
struct CGMSensorState: Equatable {
/// The kit's name for the state.
let name: String
let date: Date

init(name: String, date: Date) {
self.name = name
self.date = date
}

/// Parses a note text; nil when the note is not a sensor-state note.
init?(note: String, date: Date) {
let prefix = "CGM: "
guard note.hasPrefix(prefix) else { return nil }
let name = String(note.dropFirst(prefix.count))
guard !name.isEmpty, !name.contains(where: \.isWhitespace) else { return nil }
self.init(name: name, date: date)
}

/// The state in plain words, e.g. "Temporary sensor issue".
var displayName: String {
Self.displayName(for: name)
}

/// The state and its time, e.g. "Temporary sensor issue at 22:33".
var summary: String {
"\(displayName) at \(date.formatted(date: .omitted, time: .shortened))"
}

/// The newest state reported after the newest reading. A reading after a
/// state means the CGM delivers glucose again, so that state is over.
static func active(in states: [CGMSensorState], latestBGDate: Date?) -> CGMSensorState? {
guard let newest = states.max(by: { $0.date < $1.date }) else { return nil }
if let latestBGDate, newest.date <= latestBGDate {
return nil
}
return newest
}

/// Glucose to draw a state's marker at: the last reading at or before the
/// state, else the first reading after it. `readings` are sorted by date.
static func anchorSGV(at date: TimeInterval, readings: [ShareGlucoseData]) -> Int? {
if let before = readings.last(where: { $0.date <= date }) {
return before.sgv
}
return readings.first?.sgv
}

static func displayName(for name: String) -> String {
if name.hasPrefix(".unknown("), name.hasSuffix(")") {
let code = name.dropFirst(".unknown(".count).dropLast()
return "Unknown state (\(code))"
}
if name == "questionMarks" {
return "Question marks (???)"
}

// G7SensorKit spells some names "Dueto".
let identifier = name.replacingOccurrences(of: "Dueto", with: "DueTo")

var words: [String] = []
var current = ""
var previous: Character?
for character in identifier {
if let previous {
let startsWord = (character.isUppercase && (previous.isLowercase || previous.isNumber))
|| (character.isNumber && previous.isLetter)
if startsWord {
words.append(current)
current = ""
}
}
current.append(character)
previous = character
}
words.append(current)

// Acronyms such as "BGs" keep their case.
let normalized = words.map { word -> String in
word.dropFirst().contains(where: \.isUppercase) ? word : word.lowercased()
}
guard let first = normalized.first, !first.isEmpty else { return name }
return ([first.prefix(1).uppercased() + first.dropFirst()] + normalized.dropFirst()).joined(separator: " ")
}
}
1 change: 1 addition & 0 deletions LoopFollow/LiveActivity/APNSClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ class APNSClient {
]

snapshotDict["isNotLooping"] = snapshot.isNotLooping
if let cgmSensorState = snapshot.cgmSensorState { snapshotDict["cgmSensorState"] = cgmSensorState }
snapshotDict["showRenewalOverlay"] = snapshot.showRenewalOverlay
if let iob = snapshot.iob { snapshotDict["iob"] = iob }
if let cob = snapshot.cob { snapshotDict["cob"] = cob }
Expand Down
4 changes: 4 additions & 0 deletions LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ protocol CurrentGlucoseStateProviding {
/// True when LoopFollow detects the loop has not reported in 15+ minutes.
var isNotLooping: Bool { get }

/// CGM state reported after the newest reading, as display text.
var cgmSensorState: String? { get }

// MARK: - Renewal

/// True when the Live Activity is within renewalWarning seconds of its deadline.
Expand Down Expand Up @@ -173,6 +176,7 @@ enum GlucoseSnapshotBuilder {
maxBgMgdl: provider.maxBgMgdl,
unit: preferredUnit,
isNotLooping: provider.isNotLooping,
cgmSensorState: provider.cgmSensorState,
showRenewalOverlay: provider.showRenewalOverlay,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding {
return Date().timeIntervalSince1970 - lastLoopTime >= 15 * 60
}

var cgmSensorState: String? {
Observable.shared.cgmSensorState.value?.summary
}

// MARK: - Renewal

var showRenewalOverlay: Bool {
Expand Down
11 changes: 11 additions & 0 deletions LoopFollow/Snoozer/SnoozerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct SnoozerView: View {
@ObservedObject var directionText = Observable.shared.directionText
@ObservedObject var deltaText = Observable.shared.deltaText
@ObservedObject var bgStale = Observable.shared.bgStale
@ObservedObject var cgmSensorState = Observable.shared.cgmSensorState
@ObservedObject var bg = Observable.shared.bg
@ObservedObject var snoozerEmoji = Storage.shared.snoozerEmoji

Expand Down Expand Up @@ -209,6 +210,16 @@ struct SnoozerView: View {
.minimumScaleFactor(0.5)
.foregroundColor(.white.opacity(0.6))
.frame(maxWidth: .infinity, maxHeight: ageMaxH)

if let state = cgmSensorState.value {
Text("CGM: \(state.summary)")
.font(.system(size: 30, weight: .semibold))
.minimumScaleFactor(0.5)
.lineLimit(2)
.multilineTextAlignment(.center)
.foregroundColor(.orange)
.frame(maxWidth: .infinity)
}
}
.padding(.top, topPad)
.padding(.horizontal, 16)
Expand Down
3 changes: 3 additions & 0 deletions LoopFollow/Storage/Observable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ class Observable {

var isNotLooping = ObservableValue<Bool>(default: false)

/// CGM state reported after the newest reading; nil while readings flow.
var cgmSensorState = ObservableValue<CGMSensorState?>(default: nil)

/// Selected tab index used by SwiftUI TabView — set from MainViewController to switch tabs
var selectedTabIndex = ObservableValue<Int>(default: 0)

Expand Down
4 changes: 4 additions & 0 deletions LoopFollow/Task/TreatmentsTask.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ extension MainViewController {
func treatmentsTaskAction() {
// If Nightscout not enabled, wait 60s and try again
guard IsNightscoutEnabled(), Storage.shared.downloadTreatments.value else {
if !cgmSensorStates.isEmpty {
cgmSensorStates.removeAll()
updateCGMSensorState()
}
TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date().addingTimeInterval(60))
return
}
Expand Down
9 changes: 9 additions & 0 deletions LoopFollow/ViewControllers/BGDisplayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct BGDisplayView: View {
@ObservedObject var predictionText = Observable.shared.predictionText
@ObservedObject var predictionColor = Observable.shared.predictionColor
@ObservedObject var isNotLooping = Observable.shared.isNotLooping
@ObservedObject var cgmSensorState = Observable.shared.cgmSensorState

var onRefresh: (() -> Void)?

Expand Down Expand Up @@ -49,6 +50,14 @@ struct BGDisplayView: View {
Text(minAgoText.value)
.font(.system(size: 17))

if let state = cgmSensorState.value {
Text("CGM: \(state.summary)")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.orange)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}

if isNotLooping.value {
Text(loopStatusText.value)
.font(.system(size: 18, weight: .bold))
Expand Down
1 change: 1 addition & 0 deletions LoopFollow/ViewControllers/MainViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
var resumeGraphData: [DataStructs.timestampOnlyStruct] = []
var sensorStartGraphData: [DataStructs.timestampOnlyStruct] = []
var noteGraphData: [DataStructs.noteStruct] = []
var cgmSensorStates: [CGMSensorState] = []
var deviceBatteryData: [DataStructs.batteryStruct] = []
var lastCalDate: Double = 0
var latestLoopStatusString = ""
Expand Down
Loading
Loading