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
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 */; };
C1409A07291EC21C006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5126291EAF2F00555EB5 /* OSLog.swift */; };
C1409A09291EC22F006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1409A08291EC22F006BE8D0 /* OSLog.swift */; };
C1409A0B291EC258006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1409A0A291EC258006BE8D0 /* OSLog.swift */; };
Expand Down Expand Up @@ -116,6 +117,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 @@ -244,6 +246,7 @@
C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */,
C17F50D3291EAC3800555EB5 /* G7SensorKitTests.swift */,
C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */,
C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */,
);
path = G7SensorKitTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -597,6 +600,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
112 changes: 105 additions & 7 deletions G7SensorKit/G7CGMManager/G7CGMManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ 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)

public var state: G7CGMManagerState {
return lockedState.value
}
Expand Down Expand Up @@ -209,18 +215,22 @@ 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
// A grace period may have been in flight when the app was last terminated.
restorePendingSuspectedSessionEnd()
}

public var rawState: RawStateValue {
Expand Down Expand Up @@ -256,6 +266,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 @@ -344,8 +356,88 @@ extension G7CGMManager: G7SensorDelegate {
public func sensorDisconnected(_ sensor: G7Sensor, suspectedEndOfSession: Bool) {
logDeviceCommunication("Sensor disconnected: suspectedEndOfSession=\(suspectedEndOfSession)", type: .connection)
if suspectedEndOfSession {
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() {
// `suspectedSessionEndAt` is the single record of a live grace period: it
// says whether one is running, identifies it, and survives termination.
guard state.suspectedSessionEndAt == nil else {
logDeviceCommunication("Suspected session end during active grace period; original deadline unchanged.", type: .connection)
return
}

let graceStart = Date()
mutateState { state in
state.suspectedSessionEndAt = graceStart
}

logDeviceCommunication("Suspected session end; waiting \(suspectedSessionEndGracePeriod.minutes) minutes for communication to resume before scanning for new sensor.", type: .connection)
scheduleGraceExpiry(graceStart: graceStart, after: suspectedSessionEndGracePeriod)
}

private func scheduleGraceExpiry(graceStart: Date, after delay: TimeInterval) {
// Wall-clock deadline: a mach-time deadline pauses while the device
// sleeps, which could postpone detection of a genuinely ended session.
// Not cancellable, and does not need to be -- the expiry re-reads
// `suspectedSessionEndAt` and no-ops unless it still owns the window.
DispatchQueue.global(qos: .utility).asyncAfter(wallDeadline: .now() + delay) { [weak self] in
self?.handleSuspectedSessionEndGraceExpiry(graceStart: graceStart)
}
}

func handleSuspectedSessionEndGraceExpiry(graceStart: Date) {
// Cleared by resumed communication, or replaced by a later grace period.
guard state.suspectedSessionEndAt == graceStart else {
logDeviceCommunication("Communication received during suspected session end grace period; keeping sensor.", type: .connection)
return
}

logDeviceCommunication("No sensor communication since suspected session end.", type: .connection)
scanForNewSensor()
}

/// Clearing the marker is the cancellation: a pending expiry finds a grace
/// start that is no longer current and does nothing.
private func cancelSuspectedSessionEndScan() {
// Guarded because this runs on every glucose and backfill message, and
// mutateState notifies observers and persists.
guard state.suspectedSessionEndAt != nil else { return }
mutateState { state in
state.suspectedSessionEndAt = nil
}
}

/// Re-establish a grace period that was in flight when the app was last
/// terminated. The expiry is dispatched in memory and does not survive, so
/// without this a genuinely ended session would be tracked forever -- the
/// sensor never advertises again and nothing re-arms the scan.
private func restorePendingSuspectedSessionEnd() {
guard let graceStart = state.suspectedSessionEndAt else { return }

// Normally resumed communication has already cleared the marker. This
// covers the case where that clear was not persisted before we exited.
if let latestReadingTimestamp = state.latestReadingTimestamp, latestReadingTimestamp > graceStart {
cancelSuspectedSessionEndScan()
return
}

let remaining = graceStart.addingTimeInterval(suspectedSessionEndGracePeriod).timeIntervalSinceNow
guard remaining > 0 else {
// The window elapsed while we were not running, with nothing heard since.
logDeviceCommunication("Grace period for suspected session end expired while app was not running.", type: .connection)
scanForNewSensor()
return
}

logDeviceCommunication("Resuming suspected session end grace period; \(Int(remaining / 60)) minutes remaining.", type: .connection)
scheduleGraceExpiry(graceStart: graceStart, after: remaining)
}

public func sensor(_ sensor: G7Sensor, logComms comms: String) {
Expand All @@ -359,6 +451,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 @@ -427,6 +522,9 @@ extension G7CGMManager: G7SensorDelegate {
}

public func sensor(_ sensor: G7Sensor, didReadBackfill backfill: [G7BackfillMessage]) {
// Backfill likewise proves the session is still active.
cancelSuspectedSessionEndScan()

for msg in backfill {
logDeviceCommunication("Sensor didReadBackfill \(msg)", type: .receive)
}
Expand Down
7 changes: 7 additions & 0 deletions G7SensorKit/G7CGMManager/G7CGMManagerState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ public struct G7CGMManagerState: RawRepresentable, Equatable {
public var latestReadingTimestamp: Date?
public var latestConnect: Date?
public var uploadReadings: Bool = true
/// When a suspected session end started its grace period, or nil if none is
/// pending. Persisted so a grace period survives app termination: the deferred
/// scan is an in-memory work item, so without this a genuinely ended session
/// would leave the manager tracking a sensor that will never advertise again.
public var suspectedSessionEndAt: Date?

init() {
}
Expand All @@ -36,6 +41,7 @@ public struct G7CGMManagerState: RawRepresentable, Equatable {
self.latestReadingTimestamp = rawValue["latestReadingTimestamp"] as? Date
self.latestConnect = rawValue["latestConnect"] as? Date
self.uploadReadings = rawValue["uploadReadings"] as? Bool ?? true
self.suspectedSessionEndAt = rawValue["suspectedSessionEndAt"] as? Date
}

public var rawValue: RawValue {
Expand All @@ -47,6 +53,7 @@ public struct G7CGMManagerState: RawRepresentable, Equatable {
rawValue["latestReadingTimestamp"] = latestReadingTimestamp
rawValue["latestConnect"] = latestConnect
rawValue["uploadReadings"] = uploadReadings
rawValue["suspectedSessionEndAt"] = suspectedSessionEndAt
return rawValue
}
}
20 changes: 14 additions & 6 deletions G7SensorKit/G7CGMManager/G7Sensor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,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 Expand Up @@ -127,7 +132,7 @@ public final class G7Sensor: G7BluetoothManagerDelegate {
private func handleGlucoseMessage(message: G7GlucoseMessage, peripheralManager: G7PeripheralManager) {
activationDate = Date().addingTimeInterval(-TimeInterval(message.messageTimestamp))
peripheralManager.perform { (peripheral) in
self.log.debug("Listening for backfill responses")
self.log.default("Listening for backfill responses")
// Subscribe to backfill updates
do {
try peripheral.listenToCharacteristic(.backfill)
Expand Down Expand Up @@ -194,7 +199,10 @@ public final class G7Sensor: G7BluetoothManagerDelegate {
}

peripheralManager.perform { (peripheral) in
self.log.info("Listening for authentication responses for %{public}@", String(describing: peripheralManager.peripheral.name))
// .default so this survives into a sysdiagnose: info and debug are
// memory-only and are not written to the log archive, which makes the
// auth handshake invisible in field diagnostics.
self.log.default("Listening for authentication responses for %{public}@", String(describing: peripheralManager.peripheral.name))
do {
try peripheral.listenToCharacteristic(.authentication)
self.pendingAuth = true
Expand Down Expand Up @@ -314,7 +322,7 @@ public final class G7Sensor: G7BluetoothManagerDelegate {
func bluetoothManager(_ manager: G7BluetoothManager, peripheralManager: G7PeripheralManager, didReceiveAuthenticationResponse response: Data) {

if let message = AuthChallengeRxMessage(data: response), message.isBonded, message.isAuthenticated {
log.debug("Observed authenticated session. enabling notifications for control characteristic.")
log.default("Observed authenticated session. enabling notifications for control characteristic.")
pendingAuth = false
peripheralManager.perform { (peripheral) in
do {
Expand All @@ -327,7 +335,7 @@ public final class G7Sensor: G7BluetoothManagerDelegate {
}
}
} else {
log.debug("Ignoring authentication response: %{public}@", response.hexadecimalString)
log.default("Ignoring authentication response: %{public}@", response.hexadecimalString)
}
}

Expand Down
Loading