diff --git a/cgm_sensor_notes/cgm_sensor_notes.patch b/cgm_sensor_notes/cgm_sensor_notes.patch new file mode 100644 index 0000000..45a8da7 --- /dev/null +++ b/cgm_sensor_notes/cgm_sensor_notes.patch @@ -0,0 +1,234 @@ +# ============================================================================ +# cgm_sensor_notes +# +# Reports Dexcom sensor states without reliable glucose to Nightscout as Note +# treatments, for G7 and G5/G6: "CGM: sensorFailed", "CGM: questionMarks", +# "CGM: warmup", "CGM: .unknown(23)" and so on. +# +# The kits decide and name everything. A reading is reported when the kit's +# own hasReliableGlucose is false, and the note text is the kit's own name for +# the state. No state table, titles or localizations live in the patch, so +# nothing can drift from the kits, and every state a kit knows or learns is +# covered. Lifecycle states such as warmup, stopped and session ended are +# reported too, a few per sensor session. +# +# A state is reported once and stays quiet while it persists. A reading with +# reliable glucose, or a new sensor session, clears the log, so a state that +# returns after recovery is reported again. +# +# ISOLATION: the notes ride the CGM event pipeline Loop already has +# (CGMManagerDelegate -> CgmEventStore -> RemoteDataServicesManager -> +# NightscoutService), which owns persistence and upload retry. The Loop app +# itself is untouched, and so is every .pbxproj: the patch adds no files and +# edits four Swift files whose hook points are identical on main, dev and +# next-dev, so one patch serves all three branches. +# +# A note is handed to the event store once: if it fails to store, or Nightscout +# stays unreachable until the local cache purges it, that state is not +# reported again until the sensor recovers. Same durability as Loop's own +# sensor start events. +# +# Nothing here changes dosing, glucose handling or what the app displays. +# ============================================================================ +Submodule CGMBLEKit contains modified content +diff --git a/CGMBLEKit/CGMBLEKit/TransmitterManager.swift b/CGMBLEKit/CGMBLEKit/TransmitterManager.swift +index 682d9dd..624e0eb 100644 +--- a/CGMBLEKit/CGMBLEKit/TransmitterManager.swift ++++ b/CGMBLEKit/CGMBLEKit/TransmitterManager.swift +@@ -349,6 +349,15 @@ public class TransmitterManager: TransmitterDelegate { + } + } + ++ if let event = CgmSensorStateReporter.event(for: glucose.state.sensorObservation, ++ namespace: "DexTransmitter", ++ sensorSessionStart: glucose.sessionStartDate, ++ deviceIdentifier: transmitter.ID, ++ date: glucose.readDate) ++ { ++ events.append(event) ++ } ++ + // Filter out future-dated events + // Stopgap measure for the issue described in https://github.com/LoopKit/Loop/issues/2087 + events = events.filter { event in +@@ -547,6 +556,11 @@ extension CalibrationError: LocalizedError { + } + + extension CalibrationState { ++ /// The kit's own name for the state is what gets reported. ++ var sensorObservation: CgmSensorObservation { ++ hasReliableGlucose ? .reliable : .unreliable(state: description) ++ } ++ + public var localizedDescription: String { + switch self { + case .known(let state): +Submodule G7SensorKit contains modified content +diff --git a/G7SensorKit/G7SensorKit/G7CGMManager/G7CGMManager.swift b/G7SensorKit/G7SensorKit/G7CGMManager/G7CGMManager.swift +index 3fdc27b..c30b20a 100644 +--- a/G7SensorKit/G7SensorKit/G7CGMManager/G7CGMManager.swift ++++ b/G7SensorKit/G7SensorKit/G7CGMManager/G7CGMManager.swift +@@ -385,6 +385,17 @@ extension G7CGMManager: G7SensorDelegate { + state.latestReadingTimestamp = latestReadingTimestamp + } + ++ if let event = CgmSensorStateReporter.event(for: message.algorithmState.sensorObservation, ++ namespace: "G7CGMManager", ++ sensorSessionStart: activationDate, ++ deviceIdentifier: state.sensorID ?? "Dexcom G7", ++ date: latestReadingTimestamp) ++ { ++ delegate.notify { delegate in ++ delegate?.cgmManager(self, hasNew: [event]) ++ } ++ } ++ + guard let glucose = message.glucose else { + updateDelegate(with: .noData) + return +@@ -514,3 +525,12 @@ extension G7GlucoseMessage: GlucoseDisplayable { + } + } + } ++ ++// MARK: - Sensor state reporting ++ ++extension AlgorithmState { ++ /// The kit's own name for the state is what gets reported. ++ var sensorObservation: CgmSensorObservation { ++ hasReliableGlucose ? .reliable : .unreliable(state: description) ++ } ++} +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/GlucoseKit/PersistedCgmEvent.swift b/LoopKit/LoopKit/GlucoseKit/PersistedCgmEvent.swift +index 953f415..7d8a400 100644 +--- a/LoopKit/LoopKit/GlucoseKit/PersistedCgmEvent.swift ++++ b/LoopKit/LoopKit/GlucoseKit/PersistedCgmEvent.swift +@@ -13,6 +13,7 @@ public enum CgmEventType: String { + case sensorEnd + case transmitterStart + case transmitterEnd ++ case sensorIssue + } + + public struct PersistedCgmEvent { +@@ -55,3 +56,102 @@ extension CgmEvent { + return PersistedCgmEvent(managedObject: self) + } + } ++ ++// MARK: - Sensor state reporting ++ ++/// What a single reading says about the sensor. ++public enum CgmSensorObservation: Equatable { ++ /// The reading carries glucose the kit itself considers reliable. ++ case reliable ++ /// The reading carries no reliable glucose; `state` is the kit's own name ++ /// for the sensor state behind that. ++ case unreliable(state: String) ++} ++ ++/// Decides which sensor states become `CgmEventType.sensorIssue` events. ++/// ++/// A state is reported when a reading carries no reliable glucose and the ++/// state differs from the last one reported, so a persisting state is reported ++/// once. A reliable reading or a new sensor session clears the log, so a state ++/// that returns after recovery is reported again. The log outlives the manager ++/// instance, so a relaunch during a persisting state stays quiet. ++public enum CgmSensorStateReporter { ++ private struct Log: Codable, Equatable { ++ var sensorSessionStart: Date? ++ var notedState: String? ++ } ++ ++ private static let lock = NSLock() ++ ++ /// The event to hand to the CGM manager delegate, if this reading's state ++ /// is due for a note. ++ /// ++ /// - Parameters: ++ /// - observation: What `date`'s reading says about the sensor. ++ /// - namespace: Distinguishes one CGM manager's log from another's. ++ /// - sensorSessionStart: Start of the session the reading belongs to. ++ /// A new session clears the log. Both Dexcom kits re-derive this from ++ /// the phone's clock on every reading, so it carries transport jitter ++ /// and is compared with a tolerance far below the gap between two real ++ /// sessions. ++ /// - deviceIdentifier: Sensor or transmitter identifier. ++ /// - date: Timestamp of the reading. ++ public static func event(for observation: CgmSensorObservation, ++ namespace: String, ++ sensorSessionStart: Date?, ++ deviceIdentifier: String, ++ date: Date) -> PersistedCgmEvent? ++ { ++ lock.lock() ++ defer { lock.unlock() } ++ ++ let key = "com.loopkit.LoopKit.CgmSensorStateReporter.\(namespace)" ++ var log = (UserDefaults.standard.data(forKey: key).flatMap { ++ try? JSONDecoder().decode(Log.self, from: $0) ++ }) ?? Log() ++ ++ let previous = log ++ ++ if let sensorSessionStart = sensorSessionStart, isNewSession(sensorSessionStart, from: log.sensorSessionStart) { ++ // The first date seen for a session is kept, so later readings ++ // compare against a fixed point and the jitter cannot accumulate. ++ log.sensorSessionStart = sensorSessionStart ++ log.notedState = nil ++ } ++ ++ var event: PersistedCgmEvent? ++ ++ switch observation { ++ case .reliable: ++ log.notedState = nil ++ case .unreliable(let state): ++ if state != log.notedState { ++ log.notedState = state ++ event = PersistedCgmEvent(date: date, ++ type: .sensorIssue, ++ deviceIdentifier: deviceIdentifier, ++ failureMessage: "CGM: " + state) ++ } ++ } ++ ++ if log != previous, let data = try? JSONEncoder().encode(log) { ++ UserDefaults.standard.set(data, forKey: key) ++ } ++ ++ return event ++ } ++ ++ /// Whether `sensorSessionStart` belongs to a session other than the one ++ /// already being tracked. ++ /// ++ /// The tolerance absorbs the transport jitter both kits carry: each derived ++ /// start is the true start plus the delay before the phone processed the ++ /// message, so repeated readings of one session land within seconds of each ++ /// other, while two real sessions are separated by at least a warmup. ++ private static func isNewSession(_ sensorSessionStart: Date, from tracked: Date?) -> Bool { ++ guard let tracked = tracked else { ++ return true ++ } ++ return abs(sensorSessionStart.timeIntervalSince(tracked)) > .minutes(15) ++ } ++} +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/PersistedCgmEvent.swift b/NightscoutService/NightscoutServiceKit/Extensions/PersistedCgmEvent.swift +index 6c5915f..c8d332d 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/PersistedCgmEvent.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/PersistedCgmEvent.swift +@@ -16,6 +16,11 @@ extension PersistedCgmEvent { + case .sensorStart: + let note = "SensorID: \(deviceIdentifier)" + return NightscoutTreatment(timestamp: date, enteredBy: source, notes: note, eventType: .sensorStart) ++ case .sensorIssue: ++ guard let failureMessage = failureMessage else { ++ return nil ++ } ++ return NightscoutTreatment(timestamp: date, enteredBy: source, notes: failureMessage, eventType: .note) + // NS does not have a transmitter start type event yet + // case .transmitterStart: + // let note = "TransmitterID: \(deviceIdentifier)"