Skip to content
Open
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
382 changes: 382 additions & 0 deletions cgm_sensor_notes/cgm_sensor_notes.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,382 @@
# ============================================================================
# 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.
#
# 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..ffe612b 100644
--- a/CGMBLEKit/CGMBLEKit/TransmitterManager.swift
+++ b/CGMBLEKit/CGMBLEKit/TransmitterManager.swift
@@ -350,6 +350,15 @@ public class TransmitterManager: TransmitterDelegate {
}
}

+ if let event = CgmSensorIssueReporter.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
@@ -560,6 +569,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..0cee56a7 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,153 @@ 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, Equatable {
+ var sensorSessionStart: Date?
+ var openEpisodes: Set<String> = []
+ }
+
+ 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 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,
+ 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()
+
+ 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 = []
+ }
+
+ 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 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
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)"