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
234 changes: 234 additions & 0 deletions cgm_sensor_notes/cgm_sensor_notes.patch
Original file line number Diff line number Diff line change
@@ -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)"