From 719eaf62eb0cc5bea3e9d9c1e782de3de3c09f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 29 Aug 2026 16:35:12 +0200 Subject: [PATCH 1/2] Add CGM sensor error reporting customization (cgm_sensor_notes) Abnormal Dexcom sensor states are uploaded to Nightscout as Note treatments: sensor issue, sensor failure, session failure, expiry, excess noise, calibration errors and unrecognized states, for G7 and G5/G6. One note per episode. A problem is reported when it starts and stays quiet while it persists. A reading the sensor's own kit calls reliable closes the open episodes, so a problem that clears and returns is reported again. Warmup, a stopped or ended session and an uncalibrated sensor leave open episodes untouched. Episodes are keyed by kind, and are cleared when a sensor session starts. The notes ride the CGM event pipeline Loop already has, from the CGM manager delegate through CgmEventStore and RemoteDataServicesManager to NightscoutService, which owns persistence and upload retry. The Loop app and every .pbxproj are left alone. The patch adds no files and edits four Swift files in CGMBLEKit, G7SensorKit, LoopKit and NightscoutService, whose hook points are identical on main, dev and next-dev, so one patch file serves all three branches. --- cgm_sensor_notes/cgm_sensor_notes.patch | 358 ++++++++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 cgm_sensor_notes/cgm_sensor_notes.patch diff --git a/cgm_sensor_notes/cgm_sensor_notes.patch b/cgm_sensor_notes/cgm_sensor_notes.patch new file mode 100644 index 0000000..1d1312f --- /dev/null +++ b/cgm_sensor_notes/cgm_sensor_notes.patch @@ -0,0 +1,358 @@ +# ============================================================================ +# cgm_sensor_notes +# +# Reports abnormal Dexcom sensor states to Nightscout as Note treatments: +# sensor issue, sensor failure, session failure, expiry, excess noise, +# calibration errors and unrecognized states, for G7 and G5/G6. +# +# One note per episode. A problem is reported when it starts and stays quiet +# while it persists; a reading the sensor's own kit calls reliable closes the +# open episodes, so a problem that clears and returns is reported again. +# Warmup, a stopped or ended session and an uncalibrated sensor are neither +# recovery nor fault, and leave open episodes untouched. Episodes are keyed by +# kind, so a sensor alternating between two flavours of one failure reports +# once, and are cleared when a sensor session starts, so a replacement sensor +# that fails during warmup still reports. +# +# 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. +# +# The kits' state enums are module-internal, so each CGM manager maps its own +# states onto a shared vocabulary added to LoopKit, and NightscoutServiceKit +# renders that as the note. Note wording is deliberately not localized: a +# historical record stays readable in aggregate only if its wording is fixed. +# +# 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 e5bd039..9d69e0d 100644 +--- a/CGMBLEKit/CGMBLEKit/TransmitterManager.swift ++++ b/CGMBLEKit/CGMBLEKit/TransmitterManager.swift +@@ -370,6 +370,17 @@ public class TransmitterManager: TransmitterDelegate { + + logDeviceCommunication("New reading: \(glucose.readDate)", type: .receive) + ++ if let event = CgmSensorIssueReporter.event(for: glucose.state.sensorObservation, ++ namespace: "DexTransmitter", ++ sensorSessionStart: glucose.sessionStartDate, ++ deviceIdentifier: transmitter.ID, ++ date: glucose.readDate) ++ { ++ shareManager.delegate.notify { delegate in ++ delegate?.cgmManager(self.shareManager, hasNew: [event]) ++ } ++ } ++ + guard glucose.state.hasReliableGlucose else { + log.default("%{public}@: Unreliable glucose: %{public}@", #function, String(describing: glucose.state)) + updateDelegate(with: .error(CalibrationError.unreliableState(glucose.state))) +@@ -560,6 +571,54 @@ extension CalibrationError: LocalizedError { + } + + extension CalibrationState { ++ /// What a reading in this state says about the sensor. Recovery is proven ++ /// by a reading the sensor itself calls reliable, never by the mere absence ++ /// of a fault. ++ var sensorObservation: CgmSensorObservation { ++ if let issue = reportableSensorIssue { ++ return .problem(issue) ++ } ++ return hasReliableGlucose ? .healthy : .indeterminate ++ } ++ ++ /// The reportable issue for this state, or `nil` for normal operation, ++ /// which here includes the routine calibration prompts these ++ /// user-calibrated sensors raise. Calibration *errors* are reported. ++ /// ++ /// `questionMarks` is the state behind the receiver's "???". ++ var reportableSensorIssue: CgmSensorIssue? { ++ switch self { ++ case .unknown(let rawValue): ++ return .unrecognized(rawValue: Int(rawValue)) ++ case .known(let state): ++ let raw = String(describing: state) ++ switch state { ++ case .needCalibration7, ++ .needCalibration14, ++ .needFirstInitialCalibration, ++ .needSecondInitialCalibration, ++ .ok, ++ .stopped, ++ .warmup: ++ return nil ++ case .questionMarks: ++ return .sensorIssue(raw) ++ case .sensorFailure11, ++ .sensorFailure12: ++ return .sensorFailed(raw) ++ case .sessionFailure15, ++ .sessionFailure16, ++ .sessionFailure17: ++ return .sessionFailed(raw) ++ case .calibrationError8, ++ .calibrationError9, ++ .calibrationError10, ++ .calibrationError13: ++ return .calibrationError(raw) ++ } ++ } ++ } ++ + 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 d940208..a38c348 100644 +--- a/G7SensorKit/G7SensorKit/G7CGMManager/G7CGMManager.swift ++++ b/G7SensorKit/G7SensorKit/G7CGMManager/G7CGMManager.swift +@@ -390,6 +390,17 @@ extension G7CGMManager: G7SensorDelegate { + state.latestReadingTimestamp = latestReadingTimestamp + } + ++ if let event = CgmSensorIssueReporter.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 +@@ -519,3 +530,63 @@ extension G7GlucoseMessage: GlucoseDisplayable { + } + } + } ++ ++// MARK: - Sensor issue reporting ++ ++extension AlgorithmState { ++ /// What a reading in this state says about the sensor. Recovery is proven ++ /// by a reading the sensor itself calls reliable, never by the mere absence ++ /// of a fault. ++ var sensorObservation: CgmSensorObservation { ++ if let issue = reportableSensorIssue { ++ return .problem(issue) ++ } ++ return hasReliableGlucose ? .healthy : .indeterminate ++ } ++ ++ /// The reportable issue for this state, or `nil` for normal operation: the ++ /// healthy state, lifecycle steps (`stopped` is usually the user ending a ++ /// session), and routine calibration requests. ++ var reportableSensorIssue: CgmSensorIssue? { ++ switch self { ++ case .unknown(let rawValue): ++ return .unrecognized(rawValue: Int(rawValue)) ++ case .known(let state): ++ let raw = String(describing: state) ++ switch state { ++ case .firstOfTwoBGsNeeded, ++ .needsCalibration, ++ .ok, ++ .outlierCalibrationRequest, ++ .secondOfTwoBGsNeeded, ++ .sessionEnded, ++ .stopped, ++ .warmup: ++ return nil ++ case .temporarySensorIssue: ++ return .sensorIssue(raw) ++ case .sensorFailed, ++ .sensorFailedDuetoCountsAberration, ++ .sensorFailedDueToHighCountsAberration, ++ .sensorFailedDueToLowCountsAberration, ++ .sensorFailedDueToProgressiveSensorDecline, ++ .sensorFailedDuetoResidualAberration, ++ .sensorFailedDueToRestart: ++ return .sensorFailed(raw) ++ case .sessionFailedDueToTransmitterError, ++ .sessionFailedDueToUnrecoverableError: ++ return .sessionFailed(raw) ++ case .expired, ++ .sessionExpired: ++ return .sensorExpired(raw) ++ case .excessNoise: ++ return .excessNoise(raw) ++ case .calibrationError1, ++ .calibrationError2, ++ .calibrationLinearityFitFailure, ++ .outOfCalibrationDueToOutlier: ++ return .calibrationError(raw) ++ } ++ } ++ } ++} +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/GlucoseKit/PersistedCgmEvent.swift b/LoopKit/LoopKit/GlucoseKit/PersistedCgmEvent.swift +index 953f415c..66632319 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,132 @@ extension CgmEvent { + return PersistedCgmEvent(managedObject: self) + } + } ++ ++// MARK: - Sensor issue reporting ++ ++/// An abnormal CGM sensor state, carrying the raw device state it came from. ++/// ++/// A CGM manager's own state type is internal to its module, so each manager ++/// maps its states onto this shared vocabulary. ++public enum CgmSensorIssue: Equatable { ++ case sensorIssue(String) ++ case sensorFailed(String) ++ case sessionFailed(String) ++ case sensorExpired(String) ++ case excessNoise(String) ++ case calibrationError(String) ++ case unrecognized(rawValue: Int) ++ ++ /// Identifies the kind of problem for de-duplication. Coarser than the raw ++ /// device state, so drifting between flavours of one failure stays quiet. ++ public var kind: String { ++ switch self { ++ case .sensorIssue: return "sensorIssue" ++ case .sensorFailed: return "sensorFailed" ++ case .sessionFailed: return "sessionFailed" ++ case .sensorExpired: return "sensorExpired" ++ case .excessNoise: return "excessNoise" ++ case .calibrationError: return "calibrationError" ++ case .unrecognized(let rawValue): return "unrecognized.\(rawValue)" ++ } ++ } ++ ++ /// Note body carried to remote data services. Not localized: a historical ++ /// record stays readable in aggregate only if its wording is fixed. ++ public var note: String { ++ switch self { ++ case .sensorIssue(let raw): return "CGM: Sensor issue (\(raw))" ++ case .sensorFailed(let raw): return "CGM: Sensor failed (\(raw))" ++ case .sessionFailed(let raw): return "CGM: Sensor session failed (\(raw))" ++ case .sensorExpired(let raw): return "CGM: Sensor expired (\(raw))" ++ case .excessNoise(let raw): return "CGM: Excess noise (\(raw))" ++ case .calibrationError(let raw): return "CGM: Sensor calibration error (\(raw))" ++ case .unrecognized(let rawValue): return "CGM: Unrecognized sensor state (raw value \(rawValue))" ++ } ++ } ++} ++ ++/// What a single sensor reading says about the sensor. ++public enum CgmSensorObservation: Equatable { ++ /// The sensor produced glucose its own manager considers reliable. ++ case healthy ++ /// A fault worth recording. ++ case problem(CgmSensorIssue) ++ /// Neither: warming up, stopped, session ended, or awaiting a first ++ /// calibration. Absence of a fault is not recovery, so this leaves open ++ /// episodes untouched. ++ case indeterminate ++} ++ ++/// Decides which observed sensor states become `CgmEventType.sensorIssue` ++/// events. ++/// ++/// One event per episode: a problem is reported when it starts and stays quiet ++/// while it persists. A healthy reading closes the open episodes, so a problem ++/// that clears and returns is reported again. Episodes are keyed by kind, so a ++/// sensor alternating between two faults reports each once, and are cleared ++/// when a sensor session starts, so a replacement sensor that fails during ++/// warmup still reports. ++/// ++/// The episodes outlive the manager instance that observed them, so a relaunch ++/// during a persisting fault stays quiet. ++public enum CgmSensorIssueReporter { ++ private struct Episodes: Codable { ++ var sensorSessionStart: Date? ++ var openEpisodes: Set = [] ++ } ++ ++ private static let lock = NSLock() ++ ++ /// The event to hand to the CGM manager delegate, if this observation opens ++ /// a new episode. ++ /// ++ /// - Parameters: ++ /// - observation: What `date`'s reading says about the sensor. ++ /// - namespace: Distinguishes one CGM manager's episodes from another's. ++ /// - sensorSessionStart: Start of the session the reading belongs to. ++ /// A change of session clears the previous session's episodes. ++ /// - 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.CgmSensorIssueReporter.\(namespace)" ++ var episodes = (UserDefaults.standard.data(forKey: key).flatMap { ++ try? JSONDecoder().decode(Episodes.self, from: $0) ++ }) ?? Episodes() ++ ++ if let sensorSessionStart = sensorSessionStart, sensorSessionStart != episodes.sensorSessionStart { ++ episodes.sensorSessionStart = sensorSessionStart ++ episodes.openEpisodes = [] ++ } ++ ++ var event: PersistedCgmEvent? ++ ++ switch observation { ++ case .healthy: ++ episodes.openEpisodes = [] ++ case .indeterminate: ++ break ++ case .problem(let issue): ++ if episodes.openEpisodes.insert(issue.kind).inserted { ++ event = PersistedCgmEvent(date: date, ++ type: .sensorIssue, ++ deviceIdentifier: deviceIdentifier, ++ failureMessage: issue.note) ++ } ++ } ++ ++ if let data = try? JSONEncoder().encode(episodes) { ++ UserDefaults.standard.set(data, forKey: key) ++ } ++ ++ return event ++ } ++} +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)" From 7befda34027eba37324e7bf8fdad7ad6a9b1f221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 29 Aug 2026 19:09:36 +0200 Subject: [PATCH 2/2] Key sensor issue episodes to the session with a tolerance Both Dexcom kits re-derive the sensor session start from the phone's clock on every reading, so consecutive readings of one session differ by the transport delay. Comparing those dates for equality treated every reading as a new session and cleared the open episodes, so a persisting fault produced a note every five minutes. A session counts as new when its start differs from the tracked one by more than fifteen minutes, far above the jitter and far below the gap between two real sessions. The first date seen for a session is the one kept, so the jitter cannot accumulate. The G5/G6 event joins the array the manager already builds, so it passes through the same future-dated event filter and arrives in the same delegate call as a sensor start. Episodes are written back only when they change. --- cgm_sensor_notes/cgm_sensor_notes.patch | 58 +++++++++++++++++-------- 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/cgm_sensor_notes/cgm_sensor_notes.patch b/cgm_sensor_notes/cgm_sensor_notes.patch index 1d1312f..848c189 100644 --- a/cgm_sensor_notes/cgm_sensor_notes.patch +++ b/cgm_sensor_notes/cgm_sensor_notes.patch @@ -26,16 +26,21 @@ # renders that as the note. Note wording is deliberately not localized: a # historical record stays readable in aggregate only if its wording is fixed. # +# 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 episode 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 e5bd039..9d69e0d 100644 +index e5bd039..ffe612b 100644 --- a/CGMBLEKit/CGMBLEKit/TransmitterManager.swift +++ b/CGMBLEKit/CGMBLEKit/TransmitterManager.swift -@@ -370,6 +370,17 @@ public class TransmitterManager: TransmitterDelegate { - - logDeviceCommunication("New reading: \(glucose.readDate)", type: .receive) +@@ -350,6 +350,15 @@ public class TransmitterManager: TransmitterDelegate { + } + } + if let event = CgmSensorIssueReporter.event(for: glucose.state.sensorObservation, + namespace: "DexTransmitter", @@ -43,15 +48,13 @@ index e5bd039..9d69e0d 100644 + deviceIdentifier: transmitter.ID, + date: glucose.readDate) + { -+ shareManager.delegate.notify { delegate in -+ delegate?.cgmManager(self.shareManager, hasNew: [event]) -+ } ++ events.append(event) + } + - guard glucose.state.hasReliableGlucose else { - log.default("%{public}@: Unreliable glucose: %{public}@", #function, String(describing: glucose.state)) - updateDelegate(with: .error(CalibrationError.unreliableState(glucose.state))) -@@ -560,6 +571,54 @@ extension CalibrationError: LocalizedError { + // Filter out future-dated events + // Stopgap measure for the issue described in https://github.com/LoopKit/Loop/issues/2087 + events = events.filter { event in +@@ -560,6 +569,54 @@ extension CalibrationError: LocalizedError { } extension CalibrationState { @@ -195,7 +198,7 @@ index d940208..a38c348 100644 +} Submodule LoopKit contains modified content diff --git a/LoopKit/LoopKit/GlucoseKit/PersistedCgmEvent.swift b/LoopKit/LoopKit/GlucoseKit/PersistedCgmEvent.swift -index 953f415c..66632319 100644 +index 953f415c..0cee56a7 100644 --- a/LoopKit/LoopKit/GlucoseKit/PersistedCgmEvent.swift +++ b/LoopKit/LoopKit/GlucoseKit/PersistedCgmEvent.swift @@ -13,6 +13,7 @@ public enum CgmEventType: String { @@ -206,7 +209,7 @@ index 953f415c..66632319 100644 } public struct PersistedCgmEvent { -@@ -55,3 +56,132 @@ extension CgmEvent { +@@ -55,3 +56,153 @@ extension CgmEvent { return PersistedCgmEvent(managedObject: self) } } @@ -280,7 +283,7 @@ index 953f415c..66632319 100644 +/// The episodes outlive the manager instance that observed them, so a relaunch +/// during a persisting fault stays quiet. +public enum CgmSensorIssueReporter { -+ private struct Episodes: Codable { ++ private struct Episodes: Codable, Equatable { + var sensorSessionStart: Date? + var openEpisodes: Set = [] + } @@ -294,7 +297,10 @@ index 953f415c..66632319 100644 + /// - observation: What `date`'s reading says about the sensor. + /// - namespace: Distinguishes one CGM manager's episodes from another's. + /// - sensorSessionStart: Start of the session the reading belongs to. -+ /// A change of session clears the previous session's episodes. ++ /// A new session clears the previous session's episodes. 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, @@ -311,7 +317,11 @@ index 953f415c..66632319 100644 + try? JSONDecoder().decode(Episodes.self, from: $0) + }) ?? Episodes() + -+ if let sensorSessionStart = sensorSessionStart, sensorSessionStart != episodes.sensorSessionStart { ++ let previous = episodes ++ ++ if let sensorSessionStart = sensorSessionStart, isNewSession(sensorSessionStart, from: episodes.sensorSessionStart) { ++ // The first date seen for a session is kept, so later readings ++ // compare against a fixed point and the jitter cannot accumulate. + episodes.sensorSessionStart = sensorSessionStart + episodes.openEpisodes = [] + } @@ -332,12 +342,26 @@ index 953f415c..66632319 100644 + } + } + -+ if let data = try? JSONEncoder().encode(episodes) { ++ if episodes != previous, let data = try? JSONEncoder().encode(episodes) { + 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