Skip to content

Commit e0282d0

Browse files
committed
Sensor status: distinguish end-of-life and transient comms errors
Two related fixes to how the Libre 3 patch status drives the sensor pill: - Sensor end-of-life (patch errorData 5/6, sensorEnded) now shows as "Expired" rather than "Sensor failed". Both still require replacement and mark the CGM inoperable, but a normal end is no longer labeled a failure. Carries the reason through a persisted sensorEndedNormally flag so the lifecycle resolves .expired vs .failed. - A code-7 "transmission error" is a transient comms fault, not a terminal replace condition (the sensor keeps producing valid glucose). It no longer latches the sensor inoperable or stops Loop; instead it surfaces a soft, retractable "Check sensor" notice that clears when the sensor reports healthy again. Terminal replace (code 8, terminated) is unchanged.
1 parent b21aa70 commit e0282d0

4 files changed

Lines changed: 57 additions & 15 deletions

File tree

‎LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,8 @@ public final class LibreLoopCGMManager: CGMManager {
174174
hasLiveMonitor: monitor != nil,
175175
wearDurationMinutes: state.wearDurationMinutes,
176176
warmupDurationMinutes: state.warmupDurationMinutes,
177-
needsReplacement: state.sensorNeedsReplacement
177+
needsReplacement: state.sensorNeedsReplacement,
178+
endedNormally: state.sensorEndedNormally
178179
)
179180
}
180181

‎LibreLoop/LibreLoopCGMManager/LibreLoopCGMManagerState.swift‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ public struct LibreLoopCGMManagerState: RawRepresentable, Equatable {
7777
/// pairing or when the sensor reports healthy again.
7878
public var sensorNeedsReplacement: Bool = false
7979

80+
/// Distinguishes *why* the sensor needs replacement, when
81+
/// `sensorNeedsReplacement` is set: `true` for a normal end-of-life
82+
/// (the sensor self-reported `sensorEnded`), `false` for an early
83+
/// failure (`replaceSensor` / patchState 7). Drives whether the UI shows
84+
/// "Sensor Expired" vs "Sensor failed" — both still require replacement
85+
/// and mark the CGM inoperable. Persisted alongside `sensorNeedsReplacement`.
86+
public var sensorEndedNormally: Bool = false
87+
8088
/// Human-readable sensor model. Derived from `generation` when
8189
/// available (preferred), falling back to the wear-duration heuristic
8290
/// for state paired before generation was captured.
@@ -119,6 +127,7 @@ public struct LibreLoopCGMManagerState: RawRepresentable, Equatable {
119127
self.generation = (rawValue["generation"] as? Int).map { UInt16(clamping: $0) }
120128
self.expiryAlertsScheduledForActivatedAt = rawValue["expiryAlertsScheduledForActivatedAt"] as? Date
121129
self.sensorNeedsReplacement = rawValue["sensorNeedsReplacement"] as? Bool ?? false
130+
self.sensorEndedNormally = rawValue["sensorEndedNormally"] as? Bool ?? false
122131
}
123132

124133
public var rawValue: RawValue {
@@ -148,6 +157,9 @@ public struct LibreLoopCGMManagerState: RawRepresentable, Equatable {
148157
if sensorNeedsReplacement {
149158
raw["sensorNeedsReplacement"] = true
150159
}
160+
if sensorEndedNormally {
161+
raw["sensorEndedNormally"] = true
162+
}
151163
return raw
152164
}
153165
}

‎LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ extension LibreLoopCGMManager {
115115
newState.firstReadingAt = nil
116116
// New sensor — clear any prior replace/error state.
117117
newState.sensorNeedsReplacement = false
118+
newState.sensorEndedNormally = false
118119
setState(newState)
119120

120121
emitSensorStartEvent(for: newState)
@@ -328,18 +329,30 @@ extension LibreLoopCGMManager {
328329
/// check-sensor and clears are logged and retract any prior alert. Patch
329330
/// status arrives ~once a minute, so we only act when the state changes.
330331
private func notifySensorAttentionIfNeeded(_ status: PatchStatus) {
331-
let attention = status.sensorAttention
332+
// A code-7 "transmission error" (`Libre3SensorError.transmissionError`)
333+
// is a transient comms fault, not a terminal replace condition — the
334+
// sensor keeps producing valid glucose. LibreCRKit's coarse attention
335+
// mapping lumps code 7 in with code 8 (terminated) as `.replaceSensor`;
336+
// downgrade it here to a soft, retractable `.checkSensor` notice so it
337+
// never marks the sensor inoperable or stops Loop.
338+
let attention: Libre3SensorAttention = status.sensorError == .transmissionError
339+
? .checkSensor
340+
: status.sensorAttention
332341
guard attention != lastSensorAttention else { return }
333342
lastSensorAttention = attention
334-
llog("sensor attention: \(attention) (patchState=\(status.patchStateKind), replace=\(status.shouldNotifyReplaceSensor))")
343+
llog("sensor attention: \(attention) (rawAttention=\(status.sensorAttention), sensorError=\(status.sensorError), patchState=\(status.patchStateKind), replace=\(status.shouldNotifyReplaceSensor))")
335344

336345
// Persist the replace/ended state so the lifecycle, status highlight,
337346
// CGM page, and isInoperable all reflect "Replace sensor" — and survive
338347
// an app relaunch. Cleared when the sensor reports healthy again.
339348
let needsReplacement = (attention == .replaceSensor || attention == .sensorEnded)
340-
if state.sensorNeedsReplacement != needsReplacement {
349+
// A normal end-of-life (`sensorEnded`) surfaces as "Expired"; an early
350+
// failure (`replaceSensor`) as "Sensor failed". Both need replacement.
351+
let endedNormally = needsReplacement && attention == .sensorEnded
352+
if state.sensorNeedsReplacement != needsReplacement || state.sensorEndedNormally != endedNormally {
341353
var updated = state
342354
updated.sensorNeedsReplacement = needsReplacement
355+
updated.sensorEndedNormally = endedNormally
343356
setState(updated)
344357
// Push a CGM status update so Loop's HUD re-reads cgmStatusHighlight /
345358
// isInoperable immediately (there's no new glucose to trigger it).
@@ -349,16 +362,25 @@ extension LibreLoopCGMManager {
349362
let identifier = Alert.Identifier(managerIdentifier: pluginIdentifier,
350363
alertIdentifier: Self.sensorAttentionAlertID)
351364
let delegate = cgmManagerDelegate
352-
let content: (title: String, body: String)?
365+
let content: (title: String, body: String, level: Alert.InterruptionLevel)?
353366
switch attention {
354367
case .replaceSensor:
355368
content = (LocalizedString("Replace sensor", comment: "Sensor failure alert title"),
356-
LocalizedString("Your FreeStyle Libre 3 sensor has stopped working and needs to be replaced to resume CGM readings.", comment: "Sensor failure alert body"))
369+
LocalizedString("Your FreeStyle Libre 3 sensor has stopped working and needs to be replaced to resume CGM readings.", comment: "Sensor failure alert body"),
370+
.timeSensitive)
357371
case .sensorEnded:
358372
content = (LocalizedString("Sensor ended", comment: "Sensor ended alert title"),
359-
LocalizedString("Your FreeStyle Libre 3 sensor session has ended. Replace it to resume CGM readings.", comment: "Sensor ended alert body"))
373+
LocalizedString("Your FreeStyle Libre 3 sensor session has ended. Replace it to resume CGM readings.", comment: "Sensor ended alert body"),
374+
.timeSensitive)
375+
case .checkSensor where status.sensorError == .transmissionError:
376+
// Transient comms fault (code 7). Soft, retractable notice that does
377+
// not interrupt or stop Loop; readings resume on their own. Retracted
378+
// as soon as the sensor reports a healthy status again.
379+
content = (LocalizedString("Check sensor", comment: "Sensor check alert title"),
380+
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)"),
381+
.active)
360382
case .checkSensor, .none, .unknown:
361-
// Transient or cleared — don't alert; retract any standing one.
383+
// Other transient/cleared states — don't alert; retract any standing one.
362384
content = nil
363385
}
364386

@@ -371,7 +393,7 @@ extension LibreLoopCGMManager {
371393
foregroundContent: .init(title: content.title, body: content.body, acknowledgeActionButtonLabel: LocalizedString("OK", comment: "Alert acknowledge button")),
372394
backgroundContent: .init(title: content.title, body: content.body, acknowledgeActionButtonLabel: LocalizedString("OK", comment: "Alert acknowledge button")),
373395
trigger: .immediate,
374-
interruptionLevel: .timeSensitive
396+
interruptionLevel: content.level
375397
)
376398
Task { await delegate?.issueAlert(alert) }
377399
}

‎LibreLoop/Sensor/LibreLoopSensorLifecycle.swift‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ public enum LibreLoopSensorLifecycle: Equatable {
1616
case pairingWarmup(pairedAt: Date)
1717
/// remaining: time left until expiry; total: sensor's full rated wear duration
1818
case active(remaining: TimeInterval, total: TimeInterval)
19+
/// End of life: either the wear clock reached the sensor's rated duration,
20+
/// or the sensor self-reported `sensorEnded`. Needs replacement, but is a
21+
/// normal outcome (shown as "Expired", not a failure).
1922
case expired
2023
case signalLost(since: Date)
21-
/// Sensor self-reported a replace/error state (patchState 7, or
22-
/// terminated/ended). Authoritative and overrides timing-based phases —
23-
/// the sensor will not produce glucose again and must be replaced.
24+
/// Sensor self-reported an early replace/error state (patchState 7 /
25+
/// `replaceSensor`) before reaching end of life. Authoritative and overrides
26+
/// timing-based phases — the sensor will not produce glucose again and must
27+
/// be replaced. A normal end-of-life reports `.expired`, not `.failed`.
2428
case failed
2529

2630
/// Libre 3 spec default wear duration (14 days). Used when the sensor
@@ -41,12 +45,15 @@ public enum LibreLoopSensorLifecycle: Equatable {
4145
wearDurationMinutes: Int? = nil,
4246
warmupDurationMinutes: Int? = nil,
4347
needsReplacement: Bool = false,
48+
endedNormally: Bool = false,
4449
now: Date = Date()
4550
) -> LibreLoopSensorLifecycle {
4651
guard sensorPaired else { return .noSensor }
47-
// Sensor-reported failure is authoritative — it overrides every
48-
// timing-based phase (a "failed" sensor won't resume on its own).
49-
if needsReplacement { return .failed }
52+
// A sensor-reported replace/ended state is authoritative — it overrides
53+
// every timing-based phase (it won't resume on its own). Distinguish a
54+
// normal end-of-life (self-reported `sensorEnded`) from an early failure
55+
// so the UI shows "Expired" vs "Sensor failed"; both require replacement.
56+
if needsReplacement { return endedNormally ? .expired : .failed }
5057
guard let activatedAt else { return .initializing }
5158
let age = now.timeIntervalSince(activatedAt)
5259

0 commit comments

Comments
 (0)