Skip to content
Merged
Show file tree
Hide file tree
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
3 changes: 3 additions & 0 deletions LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
6 changes: 6 additions & 0 deletions LibreLoop/LibreLoopCGMManager/LibreLoopCGMManagerState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -185,6 +190,7 @@ public struct LibreLoopCGMManagerState: RawRepresentable, Equatable {
if sensorEndRecorded {
raw["sensorEndRecorded"] = true
}
raw["alertedSensorEvent"] = alertedSensorEvent
return raw
}
}
28 changes: 26 additions & 2 deletions LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ extension LibreLoopCGMManager {
newState.sensorEndedNormally = false
newState.reportedSensorIssue = nil
newState.sensorEndRecorded = false
newState.alertedSensorEvent = nil
setState(newState)

emitSensorStartEvent(for: newState)
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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:
Expand All @@ -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")),
Expand Down
35 changes: 34 additions & 1 deletion LibreLoopTests/LibreLoopCGMManagerStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down