Skip to content
Open
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
4 changes: 4 additions & 0 deletions G7SensorKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -118,6 +119,7 @@
C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedVersionMessageTests.swift; sourceTree = "<group>"; };
C109F149291ECCE2008EA5B6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = G7GlucoseMessageTests.swift; sourceTree = "<group>"; };
C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = G7CGMManagerTests.swift; sourceTree = "<group>"; };
C1409A08291EC22F006BE8D0 /* OSLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = "<group>"; };
C1409A0A291EC258006BE8D0 /* OSLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = "<group>"; };
C17F50C6291EAC3800555EB5 /* G7SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G7SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -247,6 +249,7 @@
C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */,
C17F50D3291EAC3800555EB5 /* G7SensorKitTests.swift */,
C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */,
C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */,
);
path = G7SensorKitTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
);
Expand Down
68 changes: 68 additions & 0 deletions G7SensorKit.xcodeproj/xcshareddata/xcschemes/G7SensorKit.xcscheme
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C17F50C5291EAC3800555EB5"
BuildableName = "G7SensorKit.framework"
BlueprintName = "G7SensorKit"
ReferencedContainer = "container:G7SensorKit.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C17F50CD291EAC3800555EB5"
BuildableName = "G7SensorKitTests.xctest"
BlueprintName = "G7SensorKitTests"
ReferencedContainer = "container:G7SensorKit.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
9 changes: 8 additions & 1 deletion G7SensorKit/G7CGMManager/G7BluetoothManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
74 changes: 66 additions & 8 deletions G7SensorKit/G7CGMManager/G7CGMManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<DispatchWorkItem?>(nil)

public var state: G7CGMManagerState {
return lockedState.value
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a log here for future troubleshooting?

guard scheduled else { logDeviceCommunication("Suspected session end during active grace period; original deadline unchanged.", type: .connection) 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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
9 changes: 7 additions & 2 deletions G7SensorKit/G7CGMManager/G7Sensor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
87 changes: 87 additions & 0 deletions G7SensorKitTests/G7CGMManagerTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}