From bd446b0ea9ba50e22a1c8d2ec08247164fd18a0d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 11 Oct 2026 23:35:04 -0500 Subject: [PATCH] Record Libre 3 sensor states and ends as CGM events The sensor's patch status, about once a minute, says when something is wrong. When that changes, send Loop a sensorIssue with the sensor's own name for it (its error code, or the attention it implies) and the raw errorData and patchState. It is fatal only when LibreLoop shows the sensor as failed, so a normal end of wear isn't recorded as a failure. When the sensor needs replacing, its sensorEnd is sent then, once, rather than only when the user discards it. The last state sent is kept in the manager's persisted state, so a relaunch doesn't repeat it, and is cleared by a new pairing. The device identifier is now the sensor serial alone. Appending the receiver ID gave the same sensor different identifiers. --- .../LibreLoopCGMManager.swift | 9 +-- .../LibreLoopCGMManagerState.swift | 11 ++++ .../Pairing/LibreLoopCGMManager+Pairing.swift | 64 ++++++++++++++----- .../LibreLoopCGMManagerStateTests.swift | 45 +++++++++++++ 4 files changed, 110 insertions(+), 19 deletions(-) diff --git a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift index 19103b0..ee8793d 100644 --- a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift +++ b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift @@ -352,14 +352,13 @@ public final class LibreLoopCGMManager: CGMManager { // deviceIdentifier still resolves to the session that's ending. // Matches the .sensorStart we emitted at pairing time so Loop's // CgmEventStore has a clean session boundary. - if let serial = state.sensorSerial { - let identifier = Self.deviceIdentifier(serial: serial, receiverID: state.receiverID) + if let serial = state.sensorSerial, !state.sensorEndRecorded { let event = PersistedCgmEvent( date: Date(), type: .sensorEnd, - deviceIdentifier: identifier + deviceIdentifier: serial ) - llog("emitting CgmEvent .sensorEnd deviceIdentifier=\(identifier)") + llog("emitting CgmEvent .sensorEnd deviceIdentifier=\(serial)") delegateQueue?.async { [weak self] in guard let self else { return } self.cgmManagerDelegate?.cgmManager(self, hasNew: [event]) @@ -378,6 +377,8 @@ public final class LibreLoopCGMManager: CGMManager { blank.latestSample = nil blank.recentSamples = [] blank.expiryAlertsScheduledForActivatedAt = nil + blank.reportedSensorIssue = nil + blank.sensorEndRecorded = false setState(blank) } diff --git a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManagerState.swift b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManagerState.swift index b04108a..2af8bc7 100644 --- a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManagerState.swift +++ b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManagerState.swift @@ -93,6 +93,11 @@ public struct LibreLoopCGMManagerState: RawRepresentable, Equatable { /// and mark the CGM inoperable. Persisted alongside `sensorNeedsReplacement`. public var sensorEndedNormally: Bool = false + /// The sensor state last sent to Loop as a `sensorIssue`, nil while healthy, and whether + /// this sensor's `sensorEnd` has been sent. + public var reportedSensorIssue: String? + public var sensorEndRecorded: Bool = false + /// Human-readable sensor model. Derived from `generation` when /// available (preferred), falling back to the wear-duration heuristic /// for state paired before generation was captured. @@ -139,6 +144,8 @@ public struct LibreLoopCGMManagerState: RawRepresentable, Equatable { self.expiryAlertsScheduledForActivatedAt = rawValue["expiryAlertsScheduledForActivatedAt"] as? Date self.sensorNeedsReplacement = rawValue["sensorNeedsReplacement"] as? Bool ?? false self.sensorEndedNormally = rawValue["sensorEndedNormally"] as? Bool ?? false + self.reportedSensorIssue = rawValue["reportedSensorIssue"] as? String + self.sensorEndRecorded = rawValue["sensorEndRecorded"] as? Bool ?? false } public var rawValue: RawValue { @@ -174,6 +181,10 @@ public struct LibreLoopCGMManagerState: RawRepresentable, Equatable { if sensorEndedNormally { raw["sensorEndedNormally"] = true } + raw["reportedSensorIssue"] = reportedSensorIssue + if sensorEndRecorded { + raw["sensorEndRecorded"] = true + } return raw } } diff --git a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift index c3291fa..39c566e 100644 --- a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift +++ b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift @@ -128,6 +128,8 @@ extension LibreLoopCGMManager { // New sensor — clear any prior replace/error state. newState.sensorNeedsReplacement = false newState.sensorEndedNormally = false + newState.reportedSensorIssue = nil + newState.sensorEndRecorded = false setState(newState) emitSensorStartEvent(for: newState) @@ -144,13 +146,8 @@ extension LibreLoopCGMManager { /// data to a specific sensor session. Without it, Loop has no record /// of when the Libre 3 session began other than inference from the /// first sample timestamp. - /// - /// `deviceIdentifier` is formatted as `:` - /// so a single sensor paired against different receivers produces - /// distinct events. private func emitSensorStartEvent(for newState: LibreLoopCGMManagerState) { guard let serial = newState.sensorSerial, let activatedAt = newState.activatedAt else { return } - let identifier = Self.deviceIdentifier(serial: serial, receiverID: newState.receiverID) let lifetime = newState.wearDurationMinutes.map { TimeInterval($0) * 60 } ?? LibreLoopSensorLifecycle.activeDuration let warmup = newState.warmupDurationMinutes.map { TimeInterval($0) * 60 } @@ -158,25 +155,17 @@ extension LibreLoopCGMManager { let event = PersistedCgmEvent( date: activatedAt, type: .sensorStart, - deviceIdentifier: identifier, + deviceIdentifier: serial, expectedLifetime: lifetime, warmupPeriod: warmup ) - llog("emitting CgmEvent .sensorStart deviceIdentifier=\(identifier) activatedAt=\(activatedAt)") + llog("emitting CgmEvent .sensorStart deviceIdentifier=\(serial) activatedAt=\(activatedAt)") delegateQueue?.async { [weak self] in guard let self else { return } self.cgmManagerDelegate?.cgmManager(self, hasNew: [event]) } } - /// `:` -- matches the existing settings-view - /// formatting so the on-screen "Receiver ID" debug row and the - /// CgmEvent identifier are derivable from each other. - static func deviceIdentifier(serial: String, receiverID: Data?) -> String { - let hex = (receiverID ?? Data()).map { String(format: "%02x", $0) }.joined() - return hex.isEmpty ? serial : "\(serial):\(hex)" - } - func adopt(monitor: LibreLoopSensorMonitor) { guard !isDeleted else { llog("adopt: manager deleted; discarding monitor") @@ -336,6 +325,51 @@ extension LibreLoopCGMManager { func handlePatchStatus(_ status: PatchStatus) { seedActivatedAtIfNeeded(currentLifeCount: status.currentLifeCount, source: "patch status") notifySensorAttentionIfNeeded(status) + recordSensorStateIfChanged(status) + } + + /// The sensor's own name for what's wrong, or nil while it reports nothing. + static func sensorIssue(for status: PatchStatus) -> String? { + guard status.sensorAttention != .none else { return nil } + return status.sensorError != .none ? status.sensorError.description : status.sensorAttention.description + } + + /// Sends a `sensorIssue` when the sensor's reported state changes, and this sensor's + /// `sensorEnd` once it needs replacing. Runs after `notifySensorAttentionIfNeeded`, + /// which decides whether the sensor ended normally or failed. + func recordSensorStateIfChanged(_ status: PatchStatus) { + guard let serial = state.sensorSerial else { return } + let issue = Self.sensorIssue(for: status) + let ends = state.sensorNeedsReplacement && !state.sensorEndRecorded + guard issue != state.reportedSensorIssue || ends else { return } + + var events: [PersistedCgmEvent] = [] + if let issue, issue != state.reportedSensorIssue { + var failed = false + if case .failed = sensorLifecycle { failed = true } + events.append(PersistedCgmEvent( + date: Date(), + type: .sensorIssue, + deviceIdentifier: serial, + error: CgmEventError(code: issue, isFatal: failed), + metadata: ["errorData": String(status.errorData), "patchState": String(status.patchState)] + )) + } + if ends { + events.append(PersistedCgmEvent(date: Date(), type: .sensorEnd, deviceIdentifier: serial)) + } + + var updated = state + updated.reportedSensorIssue = issue + updated.sensorEndRecorded = updated.sensorEndRecorded || ends + setState(updated) + + guard !events.isEmpty else { return } + llog("emitting CgmEvents \(events.map(\.type.rawValue)) deviceIdentifier=\(serial)") + delegateQueue?.async { [weak self] in + guard let self else { return } + self.cgmManagerDelegate?.cgmManager(self, hasNew: events) + } } /// Surface the sensor's self-reported attention state (LibreCRKit e69dbd6's diff --git a/LibreLoopTests/LibreLoopCGMManagerStateTests.swift b/LibreLoopTests/LibreLoopCGMManagerStateTests.swift index 1e269ab..e83700a 100644 --- a/LibreLoopTests/LibreLoopCGMManagerStateTests.swift +++ b/LibreLoopTests/LibreLoopCGMManagerStateTests.swift @@ -1,5 +1,6 @@ import XCTest import LoopKit +import RoundWhiteDiscKit @testable import LibreLoop final class LibreLoopCGMManagerStateTests: XCTestCase { @@ -176,3 +177,47 @@ final class LibreLoopAlertRetractionTests: XCTestCase { "duplicate identifiers in allAlertIdentifiers") } } + +final class LibreLoopSensorStateEventTests: XCTestCase { + private func status(errorData: Int16 = 0, patchState: Int8 = 4) throws -> PatchStatus { + var bytes = [UInt8](repeating: 0, count: PatchStatus.plaintextSize) + bytes[2] = UInt8(truncatingIfNeeded: errorData) + bytes[3] = UInt8(truncatingIfNeeded: errorData >> 8) + bytes[7] = UInt8(bitPattern: patchState) + return try PatchStatus(plaintext: Data(bytes)) + } + + private func manager() -> LibreLoopCGMManager { + let manager = LibreLoopCGMManager() + var state = LibreLoopCGMManagerState() + state.sensorSerial = "0D00ABCDEF" + state.activatedAt = Date().addingTimeInterval(-3 * 24 * 60 * 60) + manager.state = state + return manager + } + + func testIssueIsTheSensorErrorOrAttention() throws { + XCTAssertNil(LibreLoopCGMManager.sensorIssue(for: try status())) + XCTAssertEqual(LibreLoopCGMManager.sensorIssue(for: try status(errorData: 7)), "transmissionError") + XCTAssertEqual(LibreLoopCGMManager.sensorIssue(for: try status(patchState: 3)), "checkSensor") + } + + func testIssueIsTrackedUntilTheSensorIsHealthy() throws { + let manager = manager() + manager.handlePatchStatus(try status(errorData: 7)) + XCTAssertEqual(manager.state.reportedSensorIssue, "transmissionError") + manager.handlePatchStatus(try status()) + XCTAssertNil(manager.state.reportedSensorIssue) + XCTAssertFalse(manager.state.sensorEndRecorded) + } + + func testReplacementRecordsTheSensorEndOnce() throws { + let manager = manager() + manager.handlePatchStatus(try status(errorData: 8)) + XCTAssertEqual(manager.state.reportedSensorIssue, "terminated") + XCTAssertTrue(manager.state.sensorEndRecorded) + let restored = LibreLoopCGMManagerState(rawValue: manager.state.rawValue) + XCTAssertEqual(restored?.reportedSensorIssue, "terminated") + XCTAssertEqual(restored?.sensorEndRecorded, true) + } +}