From 82fa56886630c6d89fc4c5102f2e3cb1db09570d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 30 Oct 2026 21:29:23 -0500 Subject: [PATCH] Don't fail a sensor over an event-log error while it is still active Bytes 0-6 of patch status are the sensor's latest event-log entry, and they repeat until it logs another. An error code there while the patch state is still active (4) is history, not the sensor's live state: a Libre 3 Plus logged errorData 8 at minute 8083 and kept sending readings, but LibreLoop latched "Replace sensor" / "Sensor failed" until a new sensor was paired (#29). An error code from an active sensor now raises a soft, retractable "Check sensor" notice, as code 7 already did. "Replace sensor" needs the patch state to leave active; a sensor that stops sending glucose already shows signal loss. The alert's event entry is persisted so the same entry doesn't alert again after a relaunch, and discarding a sensor now clears its replacement state, so the next sensor doesn't show "Sensor failed" from its scan until it pairs. Fixes #29 --- .../LibreLoopCGMManager.swift | 3 ++ .../LibreLoopCGMManagerState.swift | 6 ++++ .../Pairing/LibreLoopCGMManager+Pairing.swift | 28 +++++++++++++-- .../LibreLoopCGMManagerStateTests.swift | 35 ++++++++++++++++++- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift index a06b4dc..3c8fd6d 100644 --- a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift +++ b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift @@ -392,6 +392,9 @@ public final class LibreLoopCGMManager: CGMManager { blank.expiryAlertsScheduledForActivatedAt = nil blank.reportedSensorIssue = nil blank.sensorEndRecorded = false + blank.sensorNeedsReplacement = false + blank.sensorEndedNormally = false + blank.alertedSensorEvent = nil setState(blank) } diff --git a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManagerState.swift b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManagerState.swift index 2af8bc7..50198ea 100644 --- a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManagerState.swift +++ b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManagerState.swift @@ -98,6 +98,10 @@ public struct LibreLoopCGMManagerState: RawRepresentable, Equatable { public var reportedSensorIssue: String? public var sensorEndRecorded: Bool = false + /// The attention and event-log entry (`lifeCount.index`) of the sensor alert last + /// issued, so the same entry doesn't alert again after a relaunch. + public var alertedSensorEvent: String? + /// Human-readable sensor model. Derived from `generation` when /// available (preferred), falling back to the wear-duration heuristic /// for state paired before generation was captured. @@ -146,6 +150,7 @@ public struct LibreLoopCGMManagerState: RawRepresentable, Equatable { self.sensorEndedNormally = rawValue["sensorEndedNormally"] as? Bool ?? false self.reportedSensorIssue = rawValue["reportedSensorIssue"] as? String self.sensorEndRecorded = rawValue["sensorEndRecorded"] as? Bool ?? false + self.alertedSensorEvent = rawValue["alertedSensorEvent"] as? String } public var rawValue: RawValue { @@ -185,6 +190,7 @@ public struct LibreLoopCGMManagerState: RawRepresentable, Equatable { if sensorEndRecorded { raw["sensorEndRecorded"] = true } + raw["alertedSensorEvent"] = alertedSensorEvent return raw } } diff --git a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift index 4fb8ccc..f4b4443 100644 --- a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift +++ b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift @@ -130,6 +130,7 @@ extension LibreLoopCGMManager { newState.sensorEndedNormally = false newState.reportedSensorIssue = nil newState.sensorEndRecorded = false + newState.alertedSensorEvent = nil setState(newState) emitSensorStartEvent(for: newState) @@ -394,7 +395,12 @@ extension LibreLoopCGMManager { // mapping lumps code 7 in with code 8 (terminated) as `.replaceSensor`; // downgrade it here to a soft, retractable `.checkSensor` notice so it // never marks the sensor inoperable or stops Loop. - let attention: Libre3SensorAttention = status.sensorError == .transmissionError + // + // Bytes 0-6 of patch status are the sensor's latest event-log entry, and repeat + // until it logs another, so an error code there while the sensor is still active + // is history, not its live state. Only a patch state leaving active ends it. + let erroredWhileActive = status.isPatchStateActive && status.hasErrorData + let attention: Libre3SensorAttention = status.sensorError == .transmissionError || erroredWhileActive ? .checkSensor : status.sensorAttention guard attention != lastSensorAttention else { return } @@ -426,6 +432,7 @@ extension LibreLoopCGMManager { let identifier = Alert.Identifier(managerIdentifier: pluginIdentifier, alertIdentifier: Self.sensorAttentionAlertID) let delegate = cgmManagerDelegate + let eventKey = "\(attention)@\(status.lifeCount).\(status.index)" let content: (title: String, body: String, level: Alert.InterruptionLevel)? switch attention { case .replaceSensor: @@ -436,22 +443,39 @@ extension LibreLoopCGMManager { content = (LocalizedString("Sensor ended", comment: "Sensor ended alert title"), LocalizedString("Your FreeStyle Libre 3 sensor session has ended. Replace it to resume CGM readings.", comment: "Sensor ended alert body"), .timeSensitive) - case .checkSensor where status.sensorError == .transmissionError: + case .checkSensor where status.sensorError == .transmissionError && state.alertedSensorEvent != eventKey: // Transient comms fault (code 7). Soft, retractable notice that does // not interrupt or stop Loop; readings resume on their own. Retracted // as soon as the sensor reports a healthy status again. content = (LocalizedString("Check sensor", comment: "Sensor check alert title"), LocalizedString("Loop is having trouble communicating with your FreeStyle Libre 3 sensor. CGM readings may pause briefly and should resume automatically. No action is needed unless this keeps happening.", comment: "Sensor check alert body (transient transmission error)"), .active) + case .checkSensor where erroredWhileActive && state.alertedSensorEvent != eventKey: + content = (LocalizedString("Check sensor", comment: "Sensor check alert title"), + LocalizedString("Your FreeStyle Libre 3 sensor reported a problem but is still sending readings. Loop will tell you if it stops working.", comment: "Sensor check alert body (error code from an active sensor)"), + .active) + case .checkSensor where state.alertedSensorEvent == eventKey: + // Already shown for this event entry, before a relaunch or reconnect. + return case .checkSensor, .none, .unknown: // Other transient/cleared states — don't alert; retract any standing one. content = nil } guard let content else { + if state.alertedSensorEvent != nil { + var updated = state + updated.alertedSensorEvent = nil + setState(updated) + } Task { await delegate?.retractAlert(identifier: identifier) } return } + if state.alertedSensorEvent != eventKey { + var updated = state + updated.alertedSensorEvent = eventKey + setState(updated) + } let alert = Alert( identifier: identifier, foregroundContent: .init(title: content.title, body: content.body, acknowledgeActionButtonLabel: LocalizedString("OK", comment: "Alert acknowledge button")), diff --git a/LibreLoopTests/LibreLoopCGMManagerStateTests.swift b/LibreLoopTests/LibreLoopCGMManagerStateTests.swift index ce363ab..9a60066 100644 --- a/LibreLoopTests/LibreLoopCGMManagerStateTests.swift +++ b/LibreLoopTests/LibreLoopCGMManagerStateTests.swift @@ -213,13 +213,46 @@ final class LibreLoopSensorStateEventTests: XCTestCase { func testReplacementRecordsTheSensorEndOnce() throws { let manager = manager() - manager.handlePatchStatus(try status(errorData: 8)) + manager.handlePatchStatus(try status(errorData: 8, patchState: 6)) XCTAssertEqual(manager.state.reportedSensorIssue, "terminated") XCTAssertTrue(manager.state.sensorEndRecorded) let restored = LibreLoopCGMManagerState(rawValue: manager.state.rawValue) XCTAssertEqual(restored?.reportedSensorIssue, "terminated") XCTAssertEqual(restored?.sensorEndRecorded, true) } + + func testAnErrorFromAnActiveSensorIsOnlyANotice() throws { + let manager = manager() + manager.handlePatchStatus(try status(errorData: 8)) + manager.handlePatchStatus(try status(errorData: 8)) + XCTAssertFalse(manager.state.sensorNeedsReplacement) + XCTAssertFalse(manager.state.sensorEndRecorded) + XCTAssertFalse(manager.isInoperable) + XCTAssertEqual(manager.state.reportedSensorIssue, "terminated") + XCTAssertEqual(manager.state.alertedSensorEvent, "checkSensor@0.0") + XCTAssertEqual(LibreLoopCGMManagerState(rawValue: manager.state.rawValue)?.alertedSensorEvent, "checkSensor@0.0") + + manager.handlePatchStatus(try status()) + XCTAssertNil(manager.state.alertedSensorEvent) + XCTAssertNil(manager.state.reportedSensorIssue) + } + + func testTheSameErrorEndsTheSensorOnceItLeavesActive() throws { + let manager = manager() + manager.handlePatchStatus(try status(errorData: 8)) + manager.handlePatchStatus(try status(errorData: 8, patchState: 6)) + XCTAssertTrue(manager.state.sensorNeedsReplacement) + XCTAssertTrue(manager.state.sensorEndRecorded) + } + + func testDiscardingTheSensorClearsItsReplacement() throws { + let manager = manager() + manager.handlePatchStatus(try status(errorData: 8, patchState: 6)) + manager.discardSensor() + XCTAssertFalse(manager.state.sensorNeedsReplacement) + XCTAssertFalse(manager.state.sensorEndedNormally) + XCTAssertNil(manager.state.alertedSensorEvent) + } } final class LibreLoopRecentReadingsTests: XCTestCase {