diff --git a/G7SensorKit.xcodeproj/project.pbxproj b/G7SensorKit.xcodeproj/project.pbxproj index f7db84d..2d6cc01 100644 --- a/G7SensorKit.xcodeproj/project.pbxproj +++ b/G7SensorKit.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ C10760812F05B41B008B2B39 /* ExtendedVersionMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */; }; C109F14A291ECCE2008EA5B6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C109F149291ECCE2008EA5B6 /* Assets.xcassets */; }; C109F14C291ED66F008EA5B6 /* G7GlucoseMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */; }; + C1D0C0DE2F0700010000CAFE /* G7CGMManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */; }; C139829829295D7D0047DB5F /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F514A291EB6F000555EB5 /* HKUnit.swift */; }; C1409A07291EC21C006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5126291EAF2F00555EB5 /* OSLog.swift */; }; C1409A09291EC22F006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1409A08291EC22F006BE8D0 /* OSLog.swift */; }; @@ -118,6 +119,7 @@ C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedVersionMessageTests.swift; sourceTree = ""; }; C109F149291ECCE2008EA5B6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = G7GlucoseMessageTests.swift; sourceTree = ""; }; + C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = G7CGMManagerTests.swift; sourceTree = ""; }; C1409A08291EC22F006BE8D0 /* OSLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; C1409A0A291EC258006BE8D0 /* OSLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; C17F50C6291EAC3800555EB5 /* G7SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G7SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -247,6 +249,7 @@ C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */, C17F50D3291EAC3800555EB5 /* G7SensorKitTests.swift */, C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */, + C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */, ); path = G7SensorKitTests; sourceTree = ""; @@ -601,6 +604,7 @@ buildActionMask = 2147483647; files = ( C109F14C291ED66F008EA5B6 /* G7GlucoseMessageTests.swift in Sources */, + C1D0C0DE2F0700010000CAFE /* G7CGMManagerTests.swift in Sources */, C10760812F05B41B008B2B39 /* ExtendedVersionMessageTests.swift in Sources */, C17F50D4291EAC3800555EB5 /* G7SensorKitTests.swift in Sources */, ); diff --git a/G7SensorKit.xcodeproj/xcshareddata/xcschemes/G7SensorKit.xcscheme b/G7SensorKit.xcodeproj/xcshareddata/xcschemes/G7SensorKit.xcscheme new file mode 100644 index 0000000..89bed09 --- /dev/null +++ b/G7SensorKit.xcodeproj/xcshareddata/xcschemes/G7SensorKit.xcscheme @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index 156bb80..3edb0e2 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -128,10 +128,17 @@ class G7BluetoothManager: NSObject { super.init() managerQueue.sync { - self.centralManager = CBCentralManager(delegate: self, queue: managerQueue, options: [CBCentralManagerOptionRestoreIdentifierKey: "com.loudnate.CGMBLEKit"]) + self.centralManager = self.makeCentralManager(queue: self.managerQueue) } } + /// Factory seam so tests can substitute a central manager without the state + /// restoration option, which raises an exception outside an app with the + /// bluetooth-central background mode. + func makeCentralManager(queue: DispatchQueue) -> CBCentralManager { + return CBCentralManager(delegate: self, queue: queue, options: [CBCentralManagerOptionRestoreIdentifierKey: "com.loudnate.CGMBLEKit"]) + } + // MARK: - Actions func scanForPeripheral() { diff --git a/G7SensorKit/G7CGMManager/G7CGMManager.swift b/G7SensorKit/G7CGMManager/G7CGMManager.swift index 3fdc27b..feaf173 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -21,6 +21,16 @@ public protocol G7StateObserver: AnyObject { public class G7CGMManager: CGMManager { private let log = OSLog(category: "G7CGMManager") + /// How long to wait for communication to resume after a suspected session end + /// before forgetting the sensor and scanning for a new one. BLE handshake + /// failures are indistinguishable from a stopped session at disconnect time; + /// readings normally resume on the sensor's next 5-minute connection cycle. + var suspectedSessionEndGracePeriod: TimeInterval = TimeInterval(minutes: 15) + + /// Pending deferred scan-for-new-sensor, scheduled on a suspected session end + /// and cancelled when sensor communication resumes. + private let suspectedSessionEndScanItem = Locked(nil) + public var state: G7CGMManagerState { return lockedState.value } @@ -202,18 +212,20 @@ public class G7CGMManager: CGMManager { completion(.noData) } - public init() { - lockedState = Locked(G7CGMManagerState()) - sensor = G7Sensor(sensorID: nil) - sensor.delegate = self + public convenience init() { + self.init(state: G7CGMManagerState(), sensor: G7Sensor(sensorID: nil)) } - public required init?(rawState: RawStateValue) { + public required convenience init?(rawState: RawStateValue) { let state = G7CGMManagerState(rawValue: rawState) + self.init(state: state, sensor: G7Sensor(sensorID: state.sensorID)) + sensor.needsVersionInfo = state.extendedVersion == nil + } + + init(state: G7CGMManagerState, sensor: G7Sensor) { lockedState = Locked(state) - sensor = G7Sensor(sensorID: state.sensorID) + self.sensor = sensor sensor.delegate = self - sensor.needsVersionInfo = state.extendedVersion == nil } public var rawState: RawStateValue { @@ -251,6 +263,8 @@ public class G7CGMManager: CGMManager { } public func scanForNewSensor() { + cancelSuspectedSessionEndScan() + logDeviceCommunication("Forgetting existing sensor and starting scan for new sensor.", type: .connection) mutateState { state in @@ -339,8 +353,47 @@ extension G7CGMManager: G7SensorDelegate { public func sensorDisconnected(_ sensor: G7Sensor, suspectedEndOfSession: Bool) { logDeviceCommunication("Sensor disconnected: suspectedEndOfSession=\(suspectedEndOfSession)", type: .connection) if suspectedEndOfSession { - scanForNewSensor() + scheduleScanAfterSuspectedSessionEnd() + } + } + + /// A disconnect before authentication usually means the session was stopped, + /// but the same signature occurs on transient BLE handshake failures, where + /// forgetting the sensor immediately causes a long re-discovery outage. + /// Instead, keep tracking the current sensor and only scan for a new one if + /// communication does not resume within the grace period. + private func scheduleScanAfterSuspectedSessionEnd() { + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + self.suspectedSessionEndScanItem.value = nil + self.logDeviceCommunication("No sensor communication since suspected session end.", type: .connection) + self.scanForNewSensor() + } + + var scheduled = false + _ = suspectedSessionEndScanItem.mutate { item in + if item == nil { + item = workItem + scheduled = true + } } + + // A grace period is already running; keep its original deadline. + guard scheduled else { return } + + logDeviceCommunication("Suspected session end; waiting \(suspectedSessionEndGracePeriod.minutes) minutes for communication to resume before scanning for new sensor.", type: .connection) + // Wall-clock deadline: a mach-time deadline pauses while the device + // sleeps, which could postpone detection of a genuinely ended session. + DispatchQueue.global(qos: .utility).asyncAfter(wallDeadline: .now() + suspectedSessionEndGracePeriod, execute: workItem) + } + + private func cancelSuspectedSessionEndScan() { + var pendingItem: DispatchWorkItem? + _ = suspectedSessionEndScanItem.mutate { item in + pendingItem = item + item = nil + } + pendingItem?.cancel() } public func sensor(_ sensor: G7Sensor, logComms comms: String) { @@ -354,6 +407,9 @@ extension G7CGMManager: G7SensorDelegate { public func sensor(_ sensor: G7Sensor, didRead message: G7GlucoseMessage) { + // Receiving any glucose message proves the session is still active. + cancelSuspectedSessionEndScan() + guard message != latestReading else { logDeviceCommunication("Sensor reading duplicate: \(message)", type: .error) updateDelegate(with: .noData) @@ -422,6 +478,8 @@ extension G7CGMManager: G7SensorDelegate { } public func sensor(_ sensor: G7Sensor, didReadBackfill backfill: [G7BackfillMessage]) { + cancelSuspectedSessionEndScan() + for msg in backfill { logDeviceCommunication("Sensor didReadBackfill \(msg)", type: .receive) } diff --git a/G7SensorKit/G7CGMManager/G7Sensor.swift b/G7SensorKit/G7CGMManager/G7Sensor.swift index b7c7023..869c40e 100644 --- a/G7SensorKit/G7CGMManager/G7Sensor.swift +++ b/G7SensorKit/G7CGMManager/G7Sensor.swift @@ -91,14 +91,19 @@ public final class G7Sensor: G7BluetoothManagerDelegate { private let log = OSLog(category: "G7Sensor") - private let bluetoothManager = G7BluetoothManager() + private let bluetoothManager: G7BluetoothManager private let delegateQueue = DispatchQueue(label: "com.loopkit.G7Sensor.delegateQueue", qos: .unspecified) private var sensorID: String? - public init(sensorID: String?) { + public convenience init(sensorID: String?) { + self.init(sensorID: sensorID, bluetoothManager: G7BluetoothManager()) + } + + init(sensorID: String?, bluetoothManager: G7BluetoothManager) { self.sensorID = sensorID + self.bluetoothManager = bluetoothManager bluetoothManager.delegate = self } diff --git a/G7SensorKitTests/G7CGMManagerTests.swift b/G7SensorKitTests/G7CGMManagerTests.swift new file mode 100644 index 0000000..7bf8494 --- /dev/null +++ b/G7SensorKitTests/G7CGMManagerTests.swift @@ -0,0 +1,87 @@ +// +// G7CGMManagerTests.swift +// G7SensorKitTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import CoreBluetooth +@testable import G7SensorKit + +/// CBCentralManager with the state restoration option raises an exception in a +/// test bundle, which lacks the bluetooth-central background mode. +private class TestBluetoothManager: G7BluetoothManager { + override func makeCentralManager(queue: DispatchQueue) -> CBCentralManager { + return CBCentralManager(delegate: self, queue: queue) + } +} + +final class G7CGMManagerTests: XCTestCase { + + private static let sensorID = "DXCM99" + + private func makeManager(gracePeriod: TimeInterval) -> G7CGMManager { + var state = G7CGMManagerState() + state.sensorID = Self.sensorID + state.activatedAt = Date(timeIntervalSinceNow: -54000) // ~15h old session + let sensor = G7Sensor(sensorID: state.sensorID, bluetoothManager: TestBluetoothManager()) + let manager = G7CGMManager(state: state, sensor: sensor) + manager.suspectedSessionEndGracePeriod = gracePeriod + return manager + } + + private var okGlucoseMessage: G7GlucoseMessage { + // Same sample as G7GlucoseMessageTests: glucose 138, algorithm state ok + return G7GlucoseMessage(data: Data(hexadecimalString: "4e00c35501002601000106008a00060187000f")!)! + } + + func testSuspectedSessionEndKeepsSensorDuringGracePeriod() { + let manager = makeManager(gracePeriod: 10) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + } + + func testSuspectedSessionEndForgetsSensorAfterGracePeriodWithoutReadings() { + let manager = makeManager(gracePeriod: 0.1) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + + let forgotten = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in manager.state.sensorID == nil }, + object: nil + ) + wait(for: [forgotten], timeout: 5) + } + + func testReadingDuringGracePeriodPreventsForgettingSensor() { + let manager = makeManager(gracePeriod: 0.5) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + manager.sensor(manager.sensor, didRead: okGlucoseMessage) + + let graceElapsed = expectation(description: "grace period elapsed") + DispatchQueue.global().asyncAfter(deadline: .now() + 1.5) { + graceElapsed.fulfill() + } + wait(for: [graceElapsed], timeout: 5) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + } + + func testNonSuspectedDisconnectDoesNotForgetSensor() { + let manager = makeManager(gracePeriod: 0.1) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: false) + + let graceElapsed = expectation(description: "grace period elapsed") + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + graceElapsed.fulfill() + } + wait(for: [graceElapsed], timeout: 5) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + } +}