From 2d9a42c645340c3b4bb79ff73953690a63041761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 27 Sep 2026 17:09:12 +0200 Subject: [PATCH] Show CGM sensor states from Nightscout notes Loop and Trio can upload a note like "CGM: temporarySensorIssue" when the Dexcom has no reliable reading. These notes get their own marker on the graph, placed at the last reading before the note. While no newer reading has arrived, the state is shown under the BG on the main screen, the snoozer and the Live Activity, and is added to the Missed Reading and Not Looping notifications. Alarm behavior is unchanged. --- LoopFollow/Alarm/Alarm.swift | 10 +- LoopFollow/Alarm/AlarmManager.swift | 5 +- LoopFollow/Charts/BGChartModel.swift | 12 ++ LoopFollow/Charts/BGChartView.swift | 12 +- LoopFollow/Controllers/NightScout.swift | 2 + .../Controllers/Nightscout/BGData.swift | 1 + .../Controllers/Nightscout/Treatments.swift | 2 +- .../Nightscout/Treatments/Notes.swift | 37 +++++- LoopFollow/Helpers/CGMSensorState.swift | 93 +++++++++++++++ LoopFollow/LiveActivity/APNSClient.swift | 1 + .../LiveActivity/GlucoseSnapshotBuilder.swift | 4 + .../StorageCurrentGlucoseStateProvider.swift | 4 + LoopFollow/Snoozer/SnoozerView.swift | 11 ++ LoopFollow/Storage/Observable.swift | 3 + LoopFollow/Task/TreatmentsTask.swift | 4 + .../ViewControllers/BGDisplayView.swift | 9 ++ .../ViewControllers/MainViewController.swift | 1 + .../LoopFollowLiveActivity.swift | 51 +++++++-- Shared/GlucoseSnapshot.swift | 11 +- Tests/CGMSensorStateTests.swift | 106 ++++++++++++++++++ 20 files changed, 361 insertions(+), 18 deletions(-) create mode 100644 LoopFollow/Helpers/CGMSensorState.swift create mode 100644 Tests/CGMSensorStateTests.swift diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index f429aa04a..ee54034f4 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -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) { @@ -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) diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 29c6128d9..cf2d44614 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -186,7 +186,7 @@ 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() @@ -194,6 +194,9 @@ class AlarmManager { 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 diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 8acc2859c..809e7c903 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -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] = [] @@ -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) } diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 74f9ec03e..ece790740 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -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) @@ -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 diff --git a/LoopFollow/Controllers/NightScout.swift b/LoopFollow/Controllers/NightScout.swift index 90e3c1e9c..b2ce1c878 100644 --- a/LoopFollow/Controllers/NightScout.swift +++ b/LoopFollow/Controllers/NightScout.swift @@ -101,6 +101,8 @@ extension MainViewController { func clearOldNotes() { noteGraphData.removeAll() + cgmSensorStates.removeAll() updateNotes() + updateCGMSensorState() } } diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index a568746c7..52e3555be 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -279,6 +279,7 @@ extension MainViewController { self.updateBGGraph() self.updateStats() + self.updateCGMSensorState() let latestEntryIndex = entries.count - 1 let latestBG = entries[latestEntryIndex].sgv diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 14e9e391b..430751bc4 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -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() } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift b/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift index cc4435212..b0374d1b1 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift @@ -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() { @@ -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") } @@ -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 } } diff --git a/LoopFollow/Helpers/CGMSensorState.swift b/LoopFollow/Helpers/CGMSensorState.swift new file mode 100644 index 000000000..5c6f33cf7 --- /dev/null +++ b/LoopFollow/Helpers/CGMSensorState.swift @@ -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: ". 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: " ") + } +} diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index ff20144c0..ca8dd5261 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -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 } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 61dd92ab7..3010a98c3 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -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. @@ -173,6 +176,7 @@ enum GlucoseSnapshotBuilder { maxBgMgdl: provider.maxBgMgdl, unit: preferredUnit, isNotLooping: provider.isNotLooping, + cgmSensorState: provider.cgmSensorState, showRenewalOverlay: provider.showRenewalOverlay, ) } diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 76b3664a4..30337ac12 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -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 { diff --git a/LoopFollow/Snoozer/SnoozerView.swift b/LoopFollow/Snoozer/SnoozerView.swift index f70daaf8c..68577b468 100644 --- a/LoopFollow/Snoozer/SnoozerView.swift +++ b/LoopFollow/Snoozer/SnoozerView.swift @@ -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 @@ -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) diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 07877f8a8..645703b6a 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -57,6 +57,9 @@ class Observable { var isNotLooping = ObservableValue(default: false) + /// CGM state reported after the newest reading; nil while readings flow. + var cgmSensorState = ObservableValue(default: nil) + /// Selected tab index used by SwiftUI TabView — set from MainViewController to switch tabs var selectedTabIndex = ObservableValue(default: 0) diff --git a/LoopFollow/Task/TreatmentsTask.swift b/LoopFollow/Task/TreatmentsTask.swift index 4e7aa0bc9..bb5a036a8 100644 --- a/LoopFollow/Task/TreatmentsTask.swift +++ b/LoopFollow/Task/TreatmentsTask.swift @@ -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 } diff --git a/LoopFollow/ViewControllers/BGDisplayView.swift b/LoopFollow/ViewControllers/BGDisplayView.swift index ae626fa47..29b0d7499 100644 --- a/LoopFollow/ViewControllers/BGDisplayView.swift +++ b/LoopFollow/ViewControllers/BGDisplayView.swift @@ -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)? @@ -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)) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 06c5e1f76..d392d3e71 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -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 = "" diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 6d438531e..610feea5c 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -237,6 +237,16 @@ private struct LockScreenLiveActivityView: View { ActiveAdjustmentsView(snapshot: s) + if let sensorState = s.cgmSensorState { + Text("CGM: \(sensorState)") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(2) + .minimumScaleFactor(0.8) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + } + Text(LAAppGroupSettings.showDisplayName() ? "\(LAAppGroupSettings.displayName()) — \(LAFormat.updated(s))" : "Last Update: \(LAFormat.updated(s))") @@ -260,10 +270,22 @@ private struct LockScreenLiveActivityView: View { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color(uiColor: UIColor.systemRed).opacity(0.85)) - Text("Not Looping") - .font(.system(size: 20, weight: .heavy, design: .rounded)) - .foregroundStyle(.white) - .tracking(1.5) + VStack(spacing: 4) { + Text("Not Looping") + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .tracking(1.5) + + if let sensorState = state.snapshot.cgmSensorState { + Text("CGM: \(sensorState)") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(2) + .minimumScaleFactor(0.8) + .multilineTextAlignment(.center) + } + } + .padding(.horizontal, 12) } } } @@ -507,16 +529,31 @@ private struct DynamicIslandTrailingView: View { private struct DynamicIslandBottomView: View { let snapshot: GlucoseSnapshot - var body: some View { - if snapshot.isNotLooping { - Text("Loop has not reported in 15+ minutes") + @ViewBuilder + private var sensorStateText: some View { + if let sensorState = snapshot.cgmSensorState { + Text("CGM: \(sensorState)") .font(.system(size: 12, weight: .semibold, design: .rounded)) .foregroundStyle(.white.opacity(0.92)) .lineLimit(1) .minimumScaleFactor(0.75) + } + } + + var body: some View { + if snapshot.isNotLooping { + VStack(spacing: 2) { + Text("Loop has not reported in 15+ minutes") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.75) + sensorStateText + } } else { VStack(spacing: 2) { ActiveAdjustmentsView(snapshot: snapshot) + sensorStateText Text("Updated at: \(LAFormat.updated(snapshot))") .font(.system(size: 13, weight: .semibold, design: .rounded)) .foregroundStyle(.white.opacity(0.92)) diff --git a/Shared/GlucoseSnapshot.swift b/Shared/GlucoseSnapshot.swift index 6800ccd3b..b2a015d17 100644 --- a/Shared/GlucoseSnapshot.swift +++ b/Shared/GlucoseSnapshot.swift @@ -128,6 +128,10 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only). let isNotLooping: Bool + /// CGM state reported after the newest reading, e.g. "Temporary sensor + /// issue at 22:33" (nil while readings flow). + let cgmSensorState: String? + // MARK: - Renewal /// True when the Live Activity is within renewalWarning seconds of its renewal deadline. @@ -168,6 +172,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { maxBgMgdl: Double? = nil, unit: Unit, isNotLooping: Bool, + cgmSensorState: String? = nil, showRenewalOverlay: Bool = false, ) { self.glucose = glucose @@ -201,6 +206,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.maxBgMgdl = maxBgMgdl self.unit = unit self.isNotLooping = isNotLooping + self.cgmSensorState = cgmSensorState self.showRenewalOverlay = showRenewalOverlay } @@ -247,6 +253,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { maxBgMgdl: maxBgMgdl, unit: unit, isNotLooping: isNotLooping, + cgmSensorState: cgmSensorState, showRenewalOverlay: value, ) } @@ -286,6 +293,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encodeIfPresent(maxBgMgdl, forKey: .maxBgMgdl) try container.encode(unit, forKey: .unit) try container.encode(isNotLooping, forKey: .isNotLooping) + try container.encodeIfPresent(cgmSensorState, forKey: .cgmSensorState) try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay) } @@ -322,6 +330,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { maxBgMgdl = try container.decodeIfPresent(Double.self, forKey: .maxBgMgdl) unit = try container.decode(Unit.self, forKey: .unit) isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false + cgmSensorState = try container.decodeIfPresent(String.self, forKey: .cgmSensorState) showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false } @@ -332,7 +341,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { case recBolus, battery, pumpBattery, basalRate, pumpReservoirU case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl - case unit, isNotLooping, showRenewalOverlay + case unit, isNotLooping, cgmSensorState, showRenewalOverlay } } diff --git a/Tests/CGMSensorStateTests.swift b/Tests/CGMSensorStateTests.swift new file mode 100644 index 000000000..cc4cf59ed --- /dev/null +++ b/Tests/CGMSensorStateTests.swift @@ -0,0 +1,106 @@ +// LoopFollow +// CGMSensorStateTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct CGMSensorStateTests { + private let t0 = Date(timeIntervalSince1970: 1_790_000_000) + + private func state(_ name: String, minutes: Double) -> CGMSensorState { + CGMSensorState(name: name, date: t0.addingTimeInterval(minutes * 60)) + } + + private func reading(_ sgv: Int, minutes: Double) -> ShareGlucoseData { + ShareGlucoseData(sgv: sgv, date: t0.addingTimeInterval(minutes * 60).timeIntervalSince1970, direction: nil) + } + + // MARK: - Parsing + + @Test("Sensor-state notes parse to the kit's state name") + func parsesSensorStateNotes() { + #expect(CGMSensorState(note: "CGM: temporarySensorIssue", date: t0)?.name == "temporarySensorIssue") + #expect(CGMSensorState(note: "CGM: warmup", date: t0)?.name == "warmup") + #expect(CGMSensorState(note: "CGM: .unknown(23)", date: t0)?.name == ".unknown(23)") + } + + @Test("Other notes are not sensor states") + func ignoresOtherNotes() { + #expect(CGMSensorState(note: "Pizza", date: t0) == nil) + #expect(CGMSensorState(note: "CGM: ", date: t0) == nil) + #expect(CGMSensorState(note: "CGM: sensor was wet after swim", date: t0) == nil) + #expect(CGMSensorState(note: "cgm: warmup", date: t0) == nil) + } + + // MARK: - Display names + + @Test("Kit names read as plain words") + func displayNames() { + #expect(CGMSensorState.displayName(for: "temporarySensorIssue") == "Temporary sensor issue") + #expect(CGMSensorState.displayName(for: "warmup") == "Warmup") + #expect(CGMSensorState.displayName(for: "sensorFailedDuetoCountsAberration") == "Sensor failed due to counts aberration") + #expect(CGMSensorState.displayName(for: "firstOfTwoBGsNeeded") == "First of two BGs needed") + #expect(CGMSensorState.displayName(for: "calibrationError8") == "Calibration error 8") + #expect(CGMSensorState.displayName(for: "sensorFailure11") == "Sensor failure 11") + #expect(CGMSensorState.displayName(for: "needCalibration7") == "Need calibration 7") + #expect(CGMSensorState.displayName(for: "questionMarks") == "Question marks (???)") + #expect(CGMSensorState.displayName(for: ".unknown(23)") == "Unknown state (23)") + } + + // MARK: - Active state + + @Test("A state reported after the newest reading is active") + func stateAfterReadingIsActive() { + let issue = state("temporarySensorIssue", minutes: 5) + #expect(CGMSensorState.active(in: [issue], latestBGDate: t0) == issue) + } + + @Test("A reading after the state ends it") + func readingAfterStateEndsIt() { + let issue = state("temporarySensorIssue", minutes: 5) + #expect(CGMSensorState.active(in: [issue], latestBGDate: t0.addingTimeInterval(20 * 60)) == nil) + } + + @Test("A note stamped at the newest reading's time is not active") + func noteAtReadingTimeIsNotActive() { + let issue = state("temporarySensorIssue", minutes: 0) + #expect(CGMSensorState.active(in: [issue], latestBGDate: t0) == nil) + } + + @Test("The newest state wins") + func newestStateWins() { + let warmup = state("warmup", minutes: 1) + let failed = state("sensorFailed", minutes: 6) + let duplicate = state("warmup", minutes: 2) + #expect(CGMSensorState.active(in: [failed, warmup, duplicate], latestBGDate: t0) == failed) + } + + @Test("Without readings the newest state is active") + func noReadings() { + let failed = state("sensorFailed", minutes: 1) + #expect(CGMSensorState.active(in: [failed], latestBGDate: nil) == failed) + #expect(CGMSensorState.active(in: [], latestBGDate: nil) == nil) + } + + // MARK: - Chart anchor + + @Test("The marker sits at the last reading before the state") + func anchorsAtLastReadingBefore() { + let readings = [reading(80, minutes: -10), reading(47, minutes: -5), reading(55, minutes: 15)] + #expect(CGMSensorState.anchorSGV(at: t0.timeIntervalSince1970, readings: readings) == 47) + } + + @Test("A state newer than every reading sits at the newest reading") + func anchorsAfterNewestReading() { + let readings = [reading(80, minutes: -10), reading(47, minutes: -5)] + #expect(CGMSensorState.anchorSGV(at: t0.timeIntervalSince1970, readings: readings) == 47) + } + + @Test("A state older than every reading sits at the first reading") + func anchorsBeforeFirstReading() { + let readings = [reading(120, minutes: 10)] + #expect(CGMSensorState.anchorSGV(at: t0.timeIntervalSince1970, readings: readings) == 120) + #expect(CGMSensorState.anchorSGV(at: t0.timeIntervalSince1970, readings: []) == nil) + } +}