From d3f2a7b39b80a31f7192e41ca9f4647c0c49e037 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 8 Oct 2026 19:25:10 -0500 Subject: [PATCH 1/4] Parse G7 glucose messages with a nonzero status A failed sensor answers the glucose request with status 0x80, its last reading and algorithm state 27 (transmitterFailed). The parser rejected any nonzero status, so the manager logged "Unable to handle glucose control response" every five minutes, never learned the state, and showed the sensor as connecting instead of raising the sensor-failed alert. The message now keeps the status and decodes the timestamp, sequence, age and algorithm state for any status. Glucose, prediction and trend are nil when the status is nonzero, so a reading the sensor did not mark current is never stored. --- G7SensorKit/Messages/G7GlucoseMessage.swift | 16 +++++++--------- G7SensorKitTests/G7GlucoseMessageTests.swift | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/G7SensorKit/Messages/G7GlucoseMessage.swift b/G7SensorKit/Messages/G7GlucoseMessage.swift index 3d2598c..7ae12db 100644 --- a/G7SensorKit/Messages/G7GlucoseMessage.swift +++ b/G7SensorKit/Messages/G7GlucoseMessage.swift @@ -15,8 +15,8 @@ import LoopKit import LoopAlgorithm public struct G7GlucoseMessage: SensorMessage, Equatable { - //public let status: UInt8 - //public let sequence: UInt32 + /// Nonzero when the sensor has no current reading, e.g. 0x80 from a failed sensor; glucose is then nil. + public let status: UInt8 public let glucose: UInt16? public let predicted: UInt16? public let glucoseIsDisplayOnly: Bool @@ -95,9 +95,7 @@ public struct G7GlucoseMessage: SensorMessage, Equatable { return nil } - guard data[1] == 00 else { - return nil - } + status = data[1] messageTimestamp = data[2..<6].toInt() @@ -106,7 +104,7 @@ public struct G7GlucoseMessage: SensorMessage, Equatable { age = data[10..<12].to(UInt16.self) let glucoseData = data[12..<14].to(UInt16.self) - if glucoseData != 0xffff { + if glucoseData != 0xffff, status == 0 { glucose = glucoseData & 0xfff glucoseIsDisplayOnly = (data[18] & 0x10) > 0 } else { @@ -115,7 +113,7 @@ public struct G7GlucoseMessage: SensorMessage, Equatable { } let predictionData = data[16..<18].to(UInt16.self) - if predictionData != 0xffff { + if predictionData != 0xffff, status == 0 { predicted = predictionData & 0xfff } else { predicted = nil @@ -123,7 +121,7 @@ public struct G7GlucoseMessage: SensorMessage, Equatable { algorithmState = AlgorithmState(rawValue: data[14]) - if data[15] == 0x7f { + if data[15] == 0x7f || status != 0 { trend = nil } else { trend = Double(Int8(bitPattern: data[15])) / 10 @@ -136,6 +134,6 @@ public struct G7GlucoseMessage: SensorMessage, Equatable { extension G7GlucoseMessage: CustomDebugStringConvertible { public var debugDescription: String { - return "G7GlucoseMessage(glucose:\(String(describing: glucose)), sequence:\(sequence) glucoseIsDisplayOnly:\(glucoseIsDisplayOnly) state:\(String(describing: algorithmState)) messageTimestamp:\(messageTimestamp) age:\(age), data:\(data.hexadecimalString))" + return "G7GlucoseMessage(status:\(status) glucose:\(String(describing: glucose)), sequence:\(sequence) glucoseIsDisplayOnly:\(glucoseIsDisplayOnly) state:\(String(describing: algorithmState)) messageTimestamp:\(messageTimestamp) age:\(age), data:\(data.hexadecimalString))" } } diff --git a/G7SensorKitTests/G7GlucoseMessageTests.swift b/G7SensorKitTests/G7GlucoseMessageTests.swift index 5d61445..dc5cafa 100644 --- a/G7SensorKitTests/G7GlucoseMessageTests.swift +++ b/G7SensorKitTests/G7GlucoseMessageTests.swift @@ -127,6 +127,21 @@ final class G7GlucoseMessageTests: XCTestCase { XCTAssertEqual(907385, message.glucoseTimestamp) } + func testFailedSensorStatusStillReportsAlgorithmState() { + // A failed sensor keeps answering with status 0x80 and its last reading, 103 mg/dL from 2.7 h earlier. + let data = Data(hexadecimalString: "4e80856f0a00f6010001f32567001b02670000")! + let message = G7GlucoseMessage(data: data)! + XCTAssertEqual(0x80, message.status) + XCTAssertEqual(.known(.transmitterFailed), message.algorithmState) + XCTAssertTrue(message.algorithmState.sensorFailed) + XCTAssertNil(message.glucose) + XCTAssertNil(message.predicted) + XCTAssertNil(message.trend) + XCTAssertEqual(683909, message.messageTimestamp) + XCTAssertEqual(9715, message.age) + XCTAssertEqual(502, message.sequence) + } + func testBackfill() { let data = Data(hexadecimalString: "cf5802008f00060f10")! let message = G7BackfillMessage(data: data)! From c38caa5bff1c2f44bd22c1c04b70b86fa5764279 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 9 Oct 2026 08:54:36 -0500 Subject: [PATCH 2/4] Keep asking for the version info until the serial number arrives The serial and firmware come from a transmitter version query that is sent once, right after the extended version reply, and the version info was marked done as soon as the extended version arrived. G7 connections last a few seconds, so when that second query went unanswered the session never learned its serial, and the previous sensor page showed none. Mark the version info done only once the transmitter version arrives, and on launch ask again for a session that has no serial yet. --- G7SensorKit/G7CGMManager/G7CGMManager.swift | 2 +- G7SensorKit/G7CGMManager/G7Sensor.swift | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/G7SensorKit/G7CGMManager/G7CGMManager.swift b/G7SensorKit/G7CGMManager/G7CGMManager.swift index d7947cf..80e4d75 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -275,7 +275,7 @@ public class G7CGMManager: CGMManager { public required convenience init?(rawState: RawStateValue) { let state = G7CGMManagerState(rawValue: rawState) self.init(state: state, sensor: G7Sensor(mode: state.sessionMode, credentials: state.sensorCredentials)) - sensor.needsVersionInfo = state.extendedVersion == nil + sensor.needsVersionInfo = state.extendedVersion == nil || state.transmitterVersion == nil } /// Which of the sensor's display slots this app takes. A phone by diff --git a/G7SensorKit/G7CGMManager/G7Sensor.swift b/G7SensorKit/G7CGMManager/G7Sensor.swift index feeb383..01457dd 100644 --- a/G7SensorKit/G7CGMManager/G7Sensor.swift +++ b/G7SensorKit/G7CGMManager/G7Sensor.swift @@ -790,10 +790,10 @@ public final class G7Sensor: G7BluetoothManagerDelegate { log.default("Received %{public}@", String(describing: extendedVersionMessage)) delegateQueue.async { self.delegate?.sensor(self, didReceive: extendedVersionMessage) - self.needsVersionInfo = false } // The serial and firmware come from a second query, asked - // once here so a session learns them alongside its lifetime. + // here so a session learns them alongside its lifetime. The + // version info isn't done until that answer arrives. peripheralManager.perform { peripheral in let request = Data([G7Opcode.transmitterVersion.rawValue]) self.logSend(request, on: .control) @@ -809,6 +809,7 @@ public final class G7Sensor: G7BluetoothManagerDelegate { log.default("Received %{public}@", String(describing: transmitterVersionMessage)) delegateQueue.async { self.delegate?.sensor(self, didReceive: transmitterVersionMessage) + self.needsVersionInfo = false } } case .calibrate: From 47420bbce8954081def96d26500bfe419bbbd777 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 9 Oct 2026 09:13:42 -0500 Subject: [PATCH 3/4] Send the sensor start with the sensor's serial number The sensorStart event was sent when the sensor was discovered, before the transmitter version query that reports the serial number. Hold it until the serial arrives and send it then, with the serial and still dated at activation. If the serial hasn't arrived after ten minutes it is sent at the next reading without one, and a pending start is always sent before that sensor's end so the session stays bracketed. Needs LoopKit's PersistedCgmEvent.serialNumber. The pending sensor and the time it started waiting are saved, so a relaunch picks up where it left off; states saved before this have nothing pending and send no extra start. --- G7SensorKit/G7CGMManager/G7CGMManager.swift | 49 ++++++++++++--- .../G7CGMManager/G7CGMManagerState.swift | 8 +++ G7SensorKitTests/G7LifecycleAlertTests.swift | 61 ++++++++++++++++++- 3 files changed, 107 insertions(+), 11 deletions(-) diff --git a/G7SensorKit/G7CGMManager/G7CGMManager.swift b/G7SensorKit/G7CGMManager/G7CGMManager.swift index 80e4d75..2697741 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -560,6 +560,39 @@ extension G7CGMManager { } } + /// How long a sensor start waits for the serial number before it is recorded without one. + static let sensorStartSerialWait = TimeInterval(minutes: 10) + + /// Records the current sensor's `sensorStart` once its serial number is known, or without + /// it once `sensorStartSerialWait` has passed, or right away when `force` is set. + private func recordPendingSensorStart(force: Bool = false) { + guard let sensorID = state.sensorID, + state.sensorStartPendingFor == sensorID, + let activatedAt = state.activatedAt + else { + return + } + let serialNumber = state.transmitterVersion?.serialNumberString + if !force, serialNumber == nil, let since = state.sensorStartPendingSince, Date().timeIntervalSince(since) < Self.sensorStartSerialWait { + return + } + let event = PersistedCgmEvent( + date: activatedAt, + type: .sensorStart, + deviceIdentifier: sensorID, + expectedLifetime: lifetime + G7Sensor.gracePeriod, + warmupPeriod: warmupDuration, + serialNumber: serialNumber + ) + delegate.notify { delegate in + delegate?.cgmManager(self, hasNew: [event]) + } + mutateState { state in + state.sensorStartPendingFor = nil + state.sensorStartPendingSince = nil + } + } + /// Closes the current sensor's session in Loop's CGM event history, once. /// Paired with the `sensorStart` recorded at discovery, so the history /// brackets each session; Loop tolerates a missing end, which is why this @@ -568,6 +601,7 @@ extension G7CGMManager { guard let sensorID = state.sensorID, state.sensorEndRecordedFor != sensorID else { return } + recordPendingSensorStart(force: true) let event = PersistedCgmEvent( date: Date(), type: .sensorEnd, @@ -679,17 +713,10 @@ extension G7CGMManager: G7SensorDelegate { if state.pairedAt == nil { state.pairedAt = Date() } + state.sensorStartPendingFor = name + state.sensorStartPendingSince = Date() } - let event = PersistedCgmEvent( - date: activatedAt, - type: .sensorStart, - deviceIdentifier: name, - expectedLifetime: lifetime + G7Sensor.gracePeriod, - warmupPeriod: warmupDuration - ) - delegate.notify { delegate in - delegate?.cgmManager(self, hasNew: [event]) - } + recordPendingSensorStart() scheduleSessionTimedAlerts() } @@ -717,6 +744,7 @@ extension G7CGMManager: G7SensorDelegate { mutateState { state in state.transmitterVersion = transmitterVersion } + recordPendingSensorStart() shareUploader?.serial = transmitterVersion.serialNumberString shareUploader?.uploadNow() } @@ -1020,6 +1048,7 @@ extension G7CGMManager: G7SensorDelegate { retractLifecycleAlert(.signalLoss) } raiseSensorFailedAlertIfNeeded(for: message) + recordPendingSensorStart() // Receiving any glucose message proves the session is still active. cancelSuspectedSessionEndScan() diff --git a/G7SensorKit/G7CGMManager/G7CGMManagerState.swift b/G7SensorKit/G7CGMManager/G7CGMManagerState.swift index 9caf70b..b6c5c77 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManagerState.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManagerState.swift @@ -70,6 +70,10 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { /// session is closed in Loop's history exactly once. public var sensorEndRecordedFor: String? + /// The sensor whose `sensorStart` waits for its serial number, and since when. + public var sensorStartPendingFor: String? + public var sensorStartPendingSince: Date? + /// When this app paired with, or began following, the current sensor. public var pairedAt: Date? @@ -138,6 +142,8 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { self.lifecycleAlertsScheduledFor = rawValue["lifecycleAlertsScheduledFor"] as? String self.sensorFailedAlertIssuedFor = rawValue["sensorFailedAlertIssuedFor"] as? String self.sensorEndRecordedFor = rawValue["sensorEndRecordedFor"] as? String + self.sensorStartPendingFor = rawValue["sensorStartPendingFor"] as? String + self.sensorStartPendingSince = rawValue["sensorStartPendingSince"] as? Date self.pairedAt = rawValue["pairedAt"] as? Date self.sensorFailureMessage = rawValue["sensorFailureMessage"] as? String self.sensorFailedAt = rawValue["sensorFailedAt"] as? Date @@ -172,6 +178,8 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { rawValue["lifecycleAlertsScheduledFor"] = lifecycleAlertsScheduledFor rawValue["sensorFailedAlertIssuedFor"] = sensorFailedAlertIssuedFor rawValue["sensorEndRecordedFor"] = sensorEndRecordedFor + rawValue["sensorStartPendingFor"] = sensorStartPendingFor + rawValue["sensorStartPendingSince"] = sensorStartPendingSince rawValue["pairedAt"] = pairedAt rawValue["sensorFailureMessage"] = sensorFailureMessage rawValue["sensorFailedAt"] = sensorFailedAt diff --git a/G7SensorKitTests/G7LifecycleAlertTests.swift b/G7SensorKitTests/G7LifecycleAlertTests.swift index 0288672..9750fab 100644 --- a/G7SensorKitTests/G7LifecycleAlertTests.swift +++ b/G7SensorKitTests/G7LifecycleAlertTests.swift @@ -47,7 +47,10 @@ private final class RecordingDelegate: CGMManagerDelegate { nonisolated func cgmManager(_ manager: CGMManager, didUpdate status: CGMManagerStatus) {} nonisolated func startDateToFilterNewData(for manager: CGMManager) -> Date? { nil } nonisolated func cgmManager(_ manager: CGMManager, hasNew readingResult: CGMReadingResult) {} - nonisolated func cgmManager(_ manager: CGMManager, hasNew events: [PersistedCgmEvent]) {} + nonisolated(unsafe) var cgmEvents: [PersistedCgmEvent] = [] + nonisolated func cgmManager(_ manager: CGMManager, hasNew events: [PersistedCgmEvent]) { + cgmEvents.append(contentsOf: events) + } nonisolated func cgmManagerDidUpdateState(_ manager: CGMManager) {} nonisolated func credentialStoragePrefix(for manager: CGMManager) -> String { "test" } } @@ -239,4 +242,60 @@ class G7LifecycleAlertManagerTests: XCTestCase { ) XCTAssertNil(manager.state.lifecycleAlertsScheduledFor) } + + private var transmitterVersion: TransmitterVersionMessage { + TransmitterVersionMessage(data: Data(hexadecimalString: "4a002cc069489c37000031474141c03e55bcb300")!)! + } + + func testSensorStartWaitsForTheSerialNumber() { + let manager = makeManager() + let activatedAt = Date().addingTimeInterval(-.hours(1)) + _ = manager.sensor(manager.sensor, didDiscoverNewSensor: "DXCM99", activatedAt: activatedAt) + settle() + XCTAssertTrue(recorder.cgmEvents.isEmpty) + + manager.sensor(manager.sensor, didReceive: transmitterVersion) + settle() + + XCTAssertEqual(recorder.cgmEvents.count, 1) + let start = recorder.cgmEvents.first + XCTAssertEqual(start?.type, .sensorStart) + XCTAssertEqual(start?.deviceIdentifier, "DXCM99") + XCTAssertEqual(start?.date, activatedAt) + XCTAssertEqual(start?.serialNumber, transmitterVersion.serialNumberString) + + manager.sensor(manager.sensor, didReceive: transmitterVersion) + settle() + XCTAssertEqual(recorder.cgmEvents.count, 1) + } + + func testSensorStartIsRecordedWithoutASerialAfterWaiting() { + var state = G7CGMManagerState() + state.sensorID = "DXCM99" + state.activatedAt = Date().addingTimeInterval(-.hours(1)) + state.sensorStartPendingFor = "DXCM99" + state.sensorStartPendingSince = Date().addingTimeInterval(-G7CGMManager.sensorStartSerialWait - 1) + let manager = makeManager(state: state) + + manager.sensor(manager.sensor, didRead: okReading) + settle() + + XCTAssertEqual(recorder.cgmEvents.map(\.type), [.sensorStart]) + XCTAssertNil(recorder.cgmEvents.first?.serialNumber) + XCTAssertNil(manager.state.sensorStartPendingFor) + } + + func testPendingSensorStartIsRecordedBeforeTheSensorEnds() { + var state = G7CGMManagerState() + state.sensorID = "DXCM99" + state.activatedAt = Date().addingTimeInterval(-.hours(1)) + state.sensorStartPendingFor = "DXCM99" + state.sensorStartPendingSince = Date() + let manager = makeManager(state: state) + + manager.scanForNewSensor() + settle() + + XCTAssertEqual(recorder.cgmEvents.map(\.type), [.sensorStart, .sensorEnd]) + } } From f7fd353b41e9e00bff8560789698eb63b5b752ed Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 11 Oct 2026 10:09:52 -0500 Subject: [PATCH 4/4] Use the serial number as the CGM event device identifier The serial is unique to the sensor; the advertised name (DXCMxx) is only used when the serial isn't known yet. Follows LoopKit/LoopKit#635, which drops the separate serialNumber field. --- G7SensorKit/G7CGMManager/G7CGMManager.swift | 12 ++++++++---- G7SensorKitTests/G7LifecycleAlertTests.swift | 5 ++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/G7SensorKit/G7CGMManager/G7CGMManager.swift b/G7SensorKit/G7CGMManager/G7CGMManager.swift index 2697741..40d993b 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -560,6 +560,11 @@ extension G7CGMManager { } } + /// The serial number is unique to the sensor; the advertised name (DXCMxx) is only a fallback. + private func eventDeviceIdentifier(sensorID: String) -> String { + state.transmitterVersion?.serialNumberString ?? sensorID + } + /// How long a sensor start waits for the serial number before it is recorded without one. static let sensorStartSerialWait = TimeInterval(minutes: 10) @@ -579,10 +584,9 @@ extension G7CGMManager { let event = PersistedCgmEvent( date: activatedAt, type: .sensorStart, - deviceIdentifier: sensorID, + deviceIdentifier: eventDeviceIdentifier(sensorID: sensorID), expectedLifetime: lifetime + G7Sensor.gracePeriod, - warmupPeriod: warmupDuration, - serialNumber: serialNumber + warmupPeriod: warmupDuration ) delegate.notify { delegate in delegate?.cgmManager(self, hasNew: [event]) @@ -605,7 +609,7 @@ extension G7CGMManager { let event = PersistedCgmEvent( date: Date(), type: .sensorEnd, - deviceIdentifier: sensorID, + deviceIdentifier: eventDeviceIdentifier(sensorID: sensorID), failureMessage: failureMessage ) delegate.notify { delegate in diff --git a/G7SensorKitTests/G7LifecycleAlertTests.swift b/G7SensorKitTests/G7LifecycleAlertTests.swift index 9750fab..6bd8ad7 100644 --- a/G7SensorKitTests/G7LifecycleAlertTests.swift +++ b/G7SensorKitTests/G7LifecycleAlertTests.swift @@ -260,9 +260,8 @@ class G7LifecycleAlertManagerTests: XCTestCase { XCTAssertEqual(recorder.cgmEvents.count, 1) let start = recorder.cgmEvents.first XCTAssertEqual(start?.type, .sensorStart) - XCTAssertEqual(start?.deviceIdentifier, "DXCM99") + XCTAssertEqual(start?.deviceIdentifier, transmitterVersion.serialNumberString) XCTAssertEqual(start?.date, activatedAt) - XCTAssertEqual(start?.serialNumber, transmitterVersion.serialNumberString) manager.sensor(manager.sensor, didReceive: transmitterVersion) settle() @@ -281,7 +280,7 @@ class G7LifecycleAlertManagerTests: XCTestCase { settle() XCTAssertEqual(recorder.cgmEvents.map(\.type), [.sensorStart]) - XCTAssertNil(recorder.cgmEvents.first?.serialNumber) + XCTAssertEqual(recorder.cgmEvents.first?.deviceIdentifier, "DXCM99") XCTAssertNil(manager.state.sensorStartPendingFor) }