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) + } +}