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
9 changes: 5 additions & 4 deletions LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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)
}

Expand Down
11 changes: 11 additions & 0 deletions LibreLoop/LibreLoopCGMManager/LibreLoopCGMManagerState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -174,6 +181,10 @@ public struct LibreLoopCGMManagerState: RawRepresentable, Equatable {
if sensorEndedNormally {
raw["sensorEndedNormally"] = true
}
raw["reportedSensorIssue"] = reportedSensorIssue
if sensorEndRecorded {
raw["sensorEndRecorded"] = true
}
return raw
}
}
64 changes: 49 additions & 15 deletions LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -144,39 +146,26 @@ 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 `<sensorSerial>:<receiverIDHex>`
/// 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 }
?? LibreLoopSensorLifecycle.warmupDuration
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])
}
}

/// `<sensorSerial>:<receiverIDHex>` -- 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")
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions LibreLoopTests/LibreLoopCGMManagerStateTests.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import XCTest
import LoopKit
import RoundWhiteDiscKit
@testable import LibreLoop

final class LibreLoopCGMManagerStateTests: XCTestCase {
Expand Down Expand Up @@ -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)
}
}