Skip to content

Commit 16d74de

Browse files
committed
Expired sensor: label it Expired (not failed) and surface Pair New Sensor
Two fixes for the end-of-life experience on a Libre 3 that reaches its rated wear: 1) Expired-vs-failed. A sensor that ends normally first reports sensorEnded ("Expired"), then on final shutdown emits the terminated code (Abbott errorData 8 -> replaceSensor). The old logic mapped that to "Sensor failed". - Lifecycle: a replace/terminated report from a sensor already at/past its rated wear duration is a normal end-of-life -> .expired, not .failed. A replaceSensor *before* rated wear is still a genuine .failed. - Pairing: make endedNormally sticky — once a sensor reported sensorEnded, a later terminated/replaceSensor can't downgrade Expired to failed (resets on the next pairing). 2) UI: when the sensor is expired/failed, show a prominent "Pair New Sensor" button above Last Reading instead of only at the bottom of the settings list. Adds LibreLoopSensorLifecycleTests covering the expired/terminated transitions. Confirmed against a field log: sensor at lifeCount 21791 min (15.1 days), sensorEnded (16:18) then replaceSensor/terminated (20:08) -> was 'Sensor failed'.
1 parent b9e8176 commit 16d74de

4 files changed

Lines changed: 97 additions & 8 deletions

File tree

‎LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,12 @@ extension LibreLoopCGMManager {
351351
let needsReplacement = (attention == .replaceSensor || attention == .sensorEnded)
352352
// A normal end-of-life (`sensorEnded`) surfaces as "Expired"; an early
353353
// failure (`replaceSensor`) as "Sensor failed". Both need replacement.
354-
let endedNormally = needsReplacement && attention == .sensorEnded
354+
// Sticky: once a sensor has reported a clean end-of-life, a later
355+
// `terminated`/`replaceSensor` shutdown must not downgrade "Expired" to
356+
// "failed" — a sensor that ended normally can't retroactively become a
357+
// failure. (Abbott emits errorData 8 → `replaceSensor` after a clean
358+
// end-of-wear.) The flag resets on the next pairing.
359+
let endedNormally = needsReplacement && (attention == .sensorEnded || state.sensorEndedNormally)
355360
if state.sensorNeedsReplacement != needsReplacement || state.sensorEndedNormally != endedNormally {
356361
var updated = state
357362
updated.sensorNeedsReplacement = needsReplacement

‎LibreLoop/Sensor/LibreLoopSensorLifecycle.swift‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,30 @@ public enum LibreLoopSensorLifecycle: Equatable {
4949
now: Date = Date()
5050
) -> LibreLoopSensorLifecycle {
5151
guard sensorPaired else { return .noSensor }
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 }
57-
guard let activatedAt else { return .initializing }
58-
let age = now.timeIntervalSince(activatedAt)
5952

6053
let sensorWearDuration: TimeInterval = wearDurationMinutes
6154
.map { TimeInterval($0) * 60 }
6255
?? activeDuration
6356

57+
// A sensor-reported replace/ended state is authoritative — it overrides
58+
// every timing-based phase (it won't resume on its own). Distinguish a
59+
// normal end-of-life from an early failure so the UI shows "Expired" vs
60+
// "Sensor failed"; both require replacement.
61+
if needsReplacement {
62+
if endedNormally { return .expired }
63+
// A replace/terminated report from a sensor that has already reached
64+
// its rated wear duration is a normal end-of-life ("Expired"), not a
65+
// failure: some sensors emit the terminated-shutdown code (errorData
66+
// 8 → `replaceSensor`) after a clean end-of-wear. Only a
67+
// `replaceSensor` *before* rated wear is a genuine "Sensor failed".
68+
if let activatedAt, now.timeIntervalSince(activatedAt) >= sensorWearDuration {
69+
return .expired
70+
}
71+
return .failed
72+
}
73+
guard let activatedAt else { return .initializing }
74+
let age = now.timeIntervalSince(activatedAt)
75+
6476
let sensorWarmupDuration: TimeInterval = warmupDurationMinutes
6577
.map { TimeInterval($0) * 60 }
6678
?? warmupDuration

‎LibreLoopTests/LibreLoopCGMManagerStateTests.swift‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,51 @@ final class LibreLoopCGMManagerStateTests: XCTestCase {
1616
XCTAssertEqual(restored.activatedAt, state.activatedAt)
1717
}
1818
}
19+
20+
final class LibreLoopSensorLifecycleTests: XCTestCase {
21+
private let day: TimeInterval = 24 * 60 * 60
22+
private let now = Date(timeIntervalSince1970: 1_700_000_000)
23+
24+
private func compute(activatedDaysAgo: Double,
25+
needsReplacement: Bool,
26+
endedNormally: Bool,
27+
wearDurationMinutes: Int? = nil) -> LibreLoopSensorLifecycle {
28+
LibreLoopSensorLifecycle.compute(
29+
sensorPaired: true,
30+
activatedAt: now.addingTimeInterval(-activatedDaysAgo * day),
31+
latestReadingAt: nil,
32+
firstReadingAt: nil,
33+
lastPairedAt: nil,
34+
hasLiveMonitor: false,
35+
wearDurationMinutes: wearDurationMinutes,
36+
needsReplacement: needsReplacement,
37+
endedNormally: endedNormally,
38+
now: now
39+
)
40+
}
41+
42+
// A clean end-of-life (`sensorEnded`) always shows Expired.
43+
func testEndedNormallyShowsExpired() {
44+
XCTAssertEqual(compute(activatedDaysAgo: 15, needsReplacement: true, endedNormally: true), .expired)
45+
}
46+
47+
// The reported bug: a sensor past its rated wear reports the terminated
48+
// shutdown code (`replaceSensor`, endedNormally=false) — it must stay
49+
// Expired, not flip to "Sensor failed".
50+
func testReplaceSensorPastRatedWearShowsExpired() {
51+
XCTAssertEqual(compute(activatedDaysAgo: 15, needsReplacement: true, endedNormally: false), .expired)
52+
}
53+
54+
// A genuine early failure (`replaceSensor` well before rated wear) stays Failed.
55+
func testReplaceSensorBeforeRatedWearShowsFailed() {
56+
XCTAssertEqual(compute(activatedDaysAgo: 3, needsReplacement: true, endedNormally: false), .failed)
57+
}
58+
59+
// Honors a sensor-reported wear duration, not just the 14-day default.
60+
func testReplaceSensorPastReportedWearShowsExpired() {
61+
XCTAssertEqual(
62+
compute(activatedDaysAgo: 11, needsReplacement: true, endedNormally: false, wearDurationMinutes: 10 * 24 * 60),
63+
.expired
64+
)
65+
}
66+
}

‎LibreLoopUI/Views/LibreLoopSettingsView.swift‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ struct LibreLoopSettingsView: View {
3232
List {
3333
sensorImageHeader
3434
sensorSection
35+
replaceSensorPromptSection
3536
lastReadingSection
3637
recentReadingsSection
3738
debugInfoSection
@@ -179,6 +180,29 @@ struct LibreLoopSettingsView: View {
179180
}
180181
}
181182

183+
/// Prominent replace CTA shown at the top (above Last Reading) only when the
184+
/// current sensor is done — expired or failed. The same action also lives in
185+
/// the management section at the bottom for a deliberate early replace, but
186+
/// when the sensor needs replacing the user shouldn't have to hunt for it.
187+
@ViewBuilder
188+
private var replaceSensorPromptSection: some View {
189+
if viewModel.lifecycle == .expired || viewModel.lifecycle == .failed {
190+
Section {
191+
Button {
192+
confirmingReplace = true
193+
} label: {
194+
Text(LocalizedString("Pair New Sensor", comment: "Prominent action to pair a new sensor when the current one needs replacement"))
195+
.fontWeight(.semibold)
196+
.frame(maxWidth: .infinity)
197+
}
198+
.buttonStyle(.borderedProminent)
199+
.controlSize(.large)
200+
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
201+
.listRowBackground(Color.clear)
202+
}
203+
}
204+
}
205+
182206
@ViewBuilder
183207
private var lastReadingSection: some View {
184208
Section(LocalizedString("Last Reading", comment: "Settings section: last reading")) {

0 commit comments

Comments
 (0)