diff --git a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift index 19103b0..ee8793d 100644 --- a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift +++ b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift @@ -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]) @@ -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) } diff --git a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManagerState.swift b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManagerState.swift index b04108a..2af8bc7 100644 --- a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManagerState.swift +++ b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManagerState.swift @@ -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. @@ -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 { @@ -174,6 +181,10 @@ public struct LibreLoopCGMManagerState: RawRepresentable, Equatable { if sensorEndedNormally { raw["sensorEndedNormally"] = true } + raw["reportedSensorIssue"] = reportedSensorIssue + if sensorEndRecorded { + raw["sensorEndRecorded"] = true + } return raw } } diff --git a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift index c3291fa..39c566e 100644 --- a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift +++ b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift @@ -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) @@ -144,13 +146,8 @@ 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 `:` - /// 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 } @@ -158,25 +155,17 @@ extension LibreLoopCGMManager { 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]) } } - /// `:` -- 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") @@ -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 diff --git a/LibreLoopTests/LibreLoopCGMManagerStateTests.swift b/LibreLoopTests/LibreLoopCGMManagerStateTests.swift index 1e269ab..e83700a 100644 --- a/LibreLoopTests/LibreLoopCGMManagerStateTests.swift +++ b/LibreLoopTests/LibreLoopCGMManagerStateTests.swift @@ -1,5 +1,6 @@ import XCTest import LoopKit +import RoundWhiteDiscKit @testable import LibreLoop final class LibreLoopCGMManagerStateTests: XCTestCase { @@ -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) + } +}