From 40ffd9528a92c52898387291570a49c3a291d132 Mon Sep 17 00:00:00 2001 From: Deniz Cengiz <48965855+dnzxy@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:29:46 +0100 Subject: [PATCH 1/4] Reconnect with a start delay; hold through readings --- .../G7CGMManager/G7BluetoothManager.swift | 83 ++++++++++++++++++- .../G7CGMManager/G7WatchAcquisition.swift | 16 ---- 2 files changed, 79 insertions(+), 20 deletions(-) diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index d9a9a29..a32cf72 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -123,6 +123,22 @@ extension G7BluetoothManagerDelegate { func bluetoothManagerStatusDidChange(_ manager: G7BluetoothManager) {} } +/// performExpiringActivity, once-only: `onEnd` runs when `wait` elapses, when the system ends the +/// hold, or when the returned release closure is called — whichever first, exactly once. +@discardableResult +func holdProcess(reason: String, upTo wait: TimeInterval, onEnd: @escaping (_ systemEnded: Bool) -> Void = { _ in }) -> () -> Void { + let gate = DispatchSemaphore(value: 0) + let once = NSLock(); var done = false + let end: (Bool) -> Void = { s in once.lock(); let first = !done; done = true; once.unlock(); if first { onEnd(s) } } + DispatchQueue.global(qos: .userInitiated).async { + ProcessInfo.processInfo.performExpiringActivity(withReason: reason) { expired in + if expired { end(true); gate.signal(); return } + _ = gate.wait(timeout: .now() + wait); end(false) + } + } + return { gate.signal() } +} + /// Takes over acquisition where a platform cannot use the stock steps (watchOS: the app sleeps /// between readings and its scanning is rationed). Called on the manager's queue. protocol G7AcquisitionArm: AnyObject { @@ -237,6 +253,16 @@ class G7BluetoothManager: NSObject { private static let poweredOnRechecksBeforeRecreating = 10 private static let maximumCentralRecreations = 5 + /// The sensor advertises for other displays for ~20–24 s after closing a link; a connect into + /// that tail fails, and repeated failures make CoreBluetooth demand a very strong signal. + static let reconnectStartDelay: TimeInterval = 30 + + /// Upper bound for keeping the app running from link-up until the sensor closes the link. + static let connectionHoldLimit: TimeInterval = 20 + + /// Ends the hold over the active peripheral's current link. Confined to `managerQueue`. + private var releaseConnectionHold: (() -> Void)? + /// There is exactly one of these per session, and a pairing run borrows /// it rather than building a second: only one central per app may claim /// the restore identifier, and sharing the central is what lets the @@ -406,6 +432,12 @@ class G7BluetoothManager: NSObject { return } + // Leave a pending (delayed) reconnect alone; a plain connect now could land in the advertising tail. + guard currentState != .connecting else { + log.default("Not scanning: active peripheral has a connect pending") + return + } + if let peripheralID = activePeripheralIdentifier, let peripheral = centralManager.retrievePeripherals(withIdentifiers: [peripheralID]).first { log.default("Retrieved peripheral %{public}@", peripheral.identifier.uuidString) handleDiscoveredPeripheral(peripheral) @@ -505,6 +537,42 @@ class G7BluetoothManager: NSObject { } } + /// Lodges the active peripheral's reconnect at once, so it stands even if the app is suspended, + /// with a start delay that lets the sensor's advertising tail pass first. + private func reconnectAfterAdvertisingTail(_ peripheral: CBPeripheral) { + dispatchPrecondition(condition: .onQueue(managerQueue)) + log.default("Reconnecting to %{public}@ with a %{public}.0f s start delay", peripheral.identifier.uuidString, G7BluetoothManager.reconnectStartDelay) + handleDiscoveredPeripheral(peripheral, connectOptions: [CBConnectPeripheralOptionStartDelayKey: G7BluetoothManager.reconnectStartDelay]) + } + + /// Keeps the app running from link-up until the sensor closes the link, so a background wake + /// finishes the handshake and reading instead of being suspended mid-way. + private func holdForConnection(_ peripheral: CBPeripheral) { + dispatchPrecondition(condition: .onQueue(managerQueue)) + releaseConnectionHold?() + releaseConnectionHold = holdProcess(reason: "G7 reading", upTo: G7BluetoothManager.connectionHoldLimit) { [weak self] systemEnded in + if systemEnded { + self?.log.default("System ended the hold on %{public}@ before the link closed", peripheral.identifier.uuidString) + } + } + } + + private func endConnectionHold() { + dispatchPrecondition(condition: .onQueue(managerQueue)) + releaseConnectionHold?() + releaseConnectionHold = nil + } + + /// Stock path only (no arm): an active peripheral reconnects on its own; anything else rescans. + private func resumeAfterLinkEnded(_ peripheral: CBPeripheral) { + dispatchPrecondition(condition: .onQueue(managerQueue)) + if peripheral == activePeripheral, centralManager.state == .poweredOn { + reconnectAfterAdvertisingTail(peripheral) + } else { + scanAfterDelay() + } + } + // MARK: - Accessors var isScanning: Bool { @@ -539,7 +607,7 @@ class G7BluetoothManager: NSObject { return G7PeripheralManager(peripheral: peripheral, configuration: .dexcomG7, centralManager: centralManager) } - func handleDiscoveredPeripheral(_ peripheral: CBPeripheral, advertisementData: [String: Any] = [:], rssi: NSNumber = 127) { + func handleDiscoveredPeripheral(_ peripheral: CBPeripheral, advertisementData: [String: Any] = [:], rssi: NSNumber = 127, connectOptions: [String: Any]? = nil) { dispatchPrecondition(condition: .onQueue(managerQueue)) if let delegate = delegate { @@ -560,7 +628,7 @@ class G7BluetoothManager: NSObject { if let arm = acquisitionArm { arm.found(peripheral) } else { - self.centralManager.connect(peripheral) + self.centralManager.connect(peripheral, options: connectOptions) } case .connect: @@ -655,6 +723,10 @@ extension G7BluetoothManager: CBCentralManagerDelegate { log.default("%{public}@: %{public}@", #function, peripheral) + if acquisitionArm == nil, peripheral == activePeripheral { + holdForConnection(peripheral) + } + if let peripheralManager = managedPeripherals[peripheral.identifier] { peripheralManager.centralManager(central, didConnect: peripheral) @@ -692,7 +764,10 @@ extension G7BluetoothManager: CBCentralManagerDelegate { } if acquisitionArm?.disconnected(peripheral, error: error) == true { return } - scanAfterDelay() + if acquisitionArm == nil, peripheral == activePeripheral { + endConnectionHold() + } + resumeAfterLinkEnded(peripheral) } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { @@ -709,7 +784,7 @@ extension G7BluetoothManager: CBCentralManagerDelegate { managedPeripherals.removeValue(forKey: peripheral.identifier) } - scanAfterDelay() + resumeAfterLinkEnded(peripheral) } } diff --git a/G7SensorKit/G7CGMManager/G7WatchAcquisition.swift b/G7SensorKit/G7CGMManager/G7WatchAcquisition.swift index 9cab85e..1483a98 100644 --- a/G7SensorKit/G7CGMManager/G7WatchAcquisition.swift +++ b/G7SensorKit/G7CGMManager/G7WatchAcquisition.swift @@ -310,22 +310,6 @@ final class G7WatchAcquisition: G7AcquisitionArm { look(after: 2, last: false) } - /// performExpiringActivity, once-only: `onEnd` runs when `wait` elapses, when the system ends the - /// hold, or when the returned release closure is called — whichever first, exactly once. - @discardableResult - private func holdProcess(reason: String, upTo wait: TimeInterval, onEnd: @escaping (_ systemEnded: Bool) -> Void = { _ in }) -> () -> Void { - let gate = DispatchSemaphore(value: 0) - let once = NSLock(); var done = false - let end: (Bool) -> Void = { s in once.lock(); let first = !done; done = true; once.unlock(); if first { onEnd(s) } } - DispatchQueue.global(qos: .userInitiated).async { - ProcessInfo.processInfo.performExpiringActivity(withReason: reason) { expired in - if expired { end(true); gate.signal(); return } - _ = gate.wait(timeout: .now() + wait); end(false) - } - } - return { gate.signal() } - } - // MARK: - Bootstrap: the one scan pass (fresh install / sensor change / 3 misses) private func startBootstrapPass(reason: String) { From e993611cbbe22191808a615f98788d7fdc51cc05 Mon Sep 17 00:00:00 2001 From: Deniz Cengiz <48965855+dnzxy@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:32:26 +0100 Subject: [PATCH 2/4] Fix start-delay type; back off refused reconnects --- .../G7CGMManager/G7BluetoothManager.swift | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index a32cf72..1a6ec38 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -255,7 +255,12 @@ class G7BluetoothManager: NSObject { /// The sensor advertises for other displays for ~20–24 s after closing a link; a connect into /// that tail fails, and repeated failures make CoreBluetooth demand a very strong signal. - static let reconnectStartDelay: TimeInterval = 30 + /// Whole seconds: CoreBluetooth rejects a fractional start delay as an invalid parameter. + static let reconnectStartDelay = 30 + + /// A failure this soon after lodging is CoreBluetooth refusing the request itself; wait before asking again. + static let reconnectRefusalWindow: TimeInterval = 2 + static let reconnectRefusalBackoff: TimeInterval = 30 /// Upper bound for keeping the app running from link-up until the sensor closes the link. static let connectionHoldLimit: TimeInterval = 20 @@ -263,6 +268,9 @@ class G7BluetoothManager: NSObject { /// Ends the hold over the active peripheral's current link. Confined to `managerQueue`. private var releaseConnectionHold: (() -> Void)? + /// When the last delayed reconnect was lodged. Confined to `managerQueue`. + private var reconnectLodgedAt: Date? + /// There is exactly one of these per session, and a pairing run borrows /// it rather than building a second: only one central per app may claim /// the restore identifier, and sharing the central is what lets the @@ -541,8 +549,9 @@ class G7BluetoothManager: NSObject { /// with a start delay that lets the sensor's advertising tail pass first. private func reconnectAfterAdvertisingTail(_ peripheral: CBPeripheral) { dispatchPrecondition(condition: .onQueue(managerQueue)) - log.default("Reconnecting to %{public}@ with a %{public}.0f s start delay", peripheral.identifier.uuidString, G7BluetoothManager.reconnectStartDelay) - handleDiscoveredPeripheral(peripheral, connectOptions: [CBConnectPeripheralOptionStartDelayKey: G7BluetoothManager.reconnectStartDelay]) + reconnectLog("Reconnecting with a \(G7BluetoothManager.reconnectStartDelay) s start delay") + reconnectLodgedAt = Date() + handleDiscoveredPeripheral(peripheral, connectOptions: [CBConnectPeripheralOptionStartDelayKey: NSNumber(value: G7BluetoothManager.reconnectStartDelay)]) } /// Keeps the app running from link-up until the sensor closes the link, so a background wake @@ -552,11 +561,17 @@ class G7BluetoothManager: NSObject { releaseConnectionHold?() releaseConnectionHold = holdProcess(reason: "G7 reading", upTo: G7BluetoothManager.connectionHoldLimit) { [weak self] systemEnded in if systemEnded { - self?.log.default("System ended the hold on %{public}@ before the link closed", peripheral.identifier.uuidString) + self?.managerQueue.async { self?.reconnectLog("System ended the hold before the link closed") } } } } + /// To both the system log and the host's device log, so field reports show it. + private func reconnectLog(_ line: String) { + log.default("%{public}@", line) + delegate?.bluetoothManager(self, logEvent: line) + } + private func endConnectionHold() { dispatchPrecondition(condition: .onQueue(managerQueue)) releaseConnectionHold?() @@ -564,13 +579,22 @@ class G7BluetoothManager: NSObject { } /// Stock path only (no arm): an active peripheral reconnects on its own; anything else rescans. - private func resumeAfterLinkEnded(_ peripheral: CBPeripheral) { + private func resumeAfterLinkEnded(_ peripheral: CBPeripheral, refusedAt failure: Date? = nil) { dispatchPrecondition(condition: .onQueue(managerQueue)) - if peripheral == activePeripheral, centralManager.state == .poweredOn { - reconnectAfterAdvertisingTail(peripheral) - } else { + guard peripheral == activePeripheral, centralManager.state == .poweredOn else { scanAfterDelay() + return } + // Re-lodging a refused request at once would spin; back off and go through the stock scan instead. + if let failure, let lodgedAt = reconnectLodgedAt, failure.timeIntervalSince(lodgedAt) < G7BluetoothManager.reconnectRefusalWindow { + reconnectLodgedAt = nil + reconnectLog("Reconnect refused; retrying in \(Int(G7BluetoothManager.reconnectRefusalBackoff)) s") + managerQueue.asyncAfter(deadline: .now() + G7BluetoothManager.reconnectRefusalBackoff) { [weak self] in + self?.managerQueue_scanForPeripheral() + } + return + } + reconnectAfterAdvertisingTail(peripheral) } // MARK: - Accessors @@ -784,7 +808,7 @@ extension G7BluetoothManager: CBCentralManagerDelegate { managedPeripherals.removeValue(forKey: peripheral.identifier) } - resumeAfterLinkEnded(peripheral) + resumeAfterLinkEnded(peripheral, refusedAt: Date()) } } From 43fc0283a0f6382d2ee11357da3cc9bd447f3847 Mon Sep 17 00:00:00 2001 From: Deniz Cengiz <48965855+dnzxy@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:38:32 +0100 Subject: [PATCH 3/4] Drop the phone hold; holdProcess stays watch-only --- .../G7CGMManager/G7BluetoothManager.swift | 47 ------------------- .../G7CGMManager/G7WatchAcquisition.swift | 16 +++++++ 2 files changed, 16 insertions(+), 47 deletions(-) diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index 1a6ec38..25bb064 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -123,22 +123,6 @@ extension G7BluetoothManagerDelegate { func bluetoothManagerStatusDidChange(_ manager: G7BluetoothManager) {} } -/// performExpiringActivity, once-only: `onEnd` runs when `wait` elapses, when the system ends the -/// hold, or when the returned release closure is called — whichever first, exactly once. -@discardableResult -func holdProcess(reason: String, upTo wait: TimeInterval, onEnd: @escaping (_ systemEnded: Bool) -> Void = { _ in }) -> () -> Void { - let gate = DispatchSemaphore(value: 0) - let once = NSLock(); var done = false - let end: (Bool) -> Void = { s in once.lock(); let first = !done; done = true; once.unlock(); if first { onEnd(s) } } - DispatchQueue.global(qos: .userInitiated).async { - ProcessInfo.processInfo.performExpiringActivity(withReason: reason) { expired in - if expired { end(true); gate.signal(); return } - _ = gate.wait(timeout: .now() + wait); end(false) - } - } - return { gate.signal() } -} - /// Takes over acquisition where a platform cannot use the stock steps (watchOS: the app sleeps /// between readings and its scanning is rationed). Called on the manager's queue. protocol G7AcquisitionArm: AnyObject { @@ -262,12 +246,6 @@ class G7BluetoothManager: NSObject { static let reconnectRefusalWindow: TimeInterval = 2 static let reconnectRefusalBackoff: TimeInterval = 30 - /// Upper bound for keeping the app running from link-up until the sensor closes the link. - static let connectionHoldLimit: TimeInterval = 20 - - /// Ends the hold over the active peripheral's current link. Confined to `managerQueue`. - private var releaseConnectionHold: (() -> Void)? - /// When the last delayed reconnect was lodged. Confined to `managerQueue`. private var reconnectLodgedAt: Date? @@ -554,30 +532,12 @@ class G7BluetoothManager: NSObject { handleDiscoveredPeripheral(peripheral, connectOptions: [CBConnectPeripheralOptionStartDelayKey: NSNumber(value: G7BluetoothManager.reconnectStartDelay)]) } - /// Keeps the app running from link-up until the sensor closes the link, so a background wake - /// finishes the handshake and reading instead of being suspended mid-way. - private func holdForConnection(_ peripheral: CBPeripheral) { - dispatchPrecondition(condition: .onQueue(managerQueue)) - releaseConnectionHold?() - releaseConnectionHold = holdProcess(reason: "G7 reading", upTo: G7BluetoothManager.connectionHoldLimit) { [weak self] systemEnded in - if systemEnded { - self?.managerQueue.async { self?.reconnectLog("System ended the hold before the link closed") } - } - } - } - /// To both the system log and the host's device log, so field reports show it. private func reconnectLog(_ line: String) { log.default("%{public}@", line) delegate?.bluetoothManager(self, logEvent: line) } - private func endConnectionHold() { - dispatchPrecondition(condition: .onQueue(managerQueue)) - releaseConnectionHold?() - releaseConnectionHold = nil - } - /// Stock path only (no arm): an active peripheral reconnects on its own; anything else rescans. private func resumeAfterLinkEnded(_ peripheral: CBPeripheral, refusedAt failure: Date? = nil) { dispatchPrecondition(condition: .onQueue(managerQueue)) @@ -747,10 +707,6 @@ extension G7BluetoothManager: CBCentralManagerDelegate { log.default("%{public}@: %{public}@", #function, peripheral) - if acquisitionArm == nil, peripheral == activePeripheral { - holdForConnection(peripheral) - } - if let peripheralManager = managedPeripherals[peripheral.identifier] { peripheralManager.centralManager(central, didConnect: peripheral) @@ -788,9 +744,6 @@ extension G7BluetoothManager: CBCentralManagerDelegate { } if acquisitionArm?.disconnected(peripheral, error: error) == true { return } - if acquisitionArm == nil, peripheral == activePeripheral { - endConnectionHold() - } resumeAfterLinkEnded(peripheral) } diff --git a/G7SensorKit/G7CGMManager/G7WatchAcquisition.swift b/G7SensorKit/G7CGMManager/G7WatchAcquisition.swift index 1483a98..9cab85e 100644 --- a/G7SensorKit/G7CGMManager/G7WatchAcquisition.swift +++ b/G7SensorKit/G7CGMManager/G7WatchAcquisition.swift @@ -310,6 +310,22 @@ final class G7WatchAcquisition: G7AcquisitionArm { look(after: 2, last: false) } + /// performExpiringActivity, once-only: `onEnd` runs when `wait` elapses, when the system ends the + /// hold, or when the returned release closure is called — whichever first, exactly once. + @discardableResult + private func holdProcess(reason: String, upTo wait: TimeInterval, onEnd: @escaping (_ systemEnded: Bool) -> Void = { _ in }) -> () -> Void { + let gate = DispatchSemaphore(value: 0) + let once = NSLock(); var done = false + let end: (Bool) -> Void = { s in once.lock(); let first = !done; done = true; once.unlock(); if first { onEnd(s) } } + DispatchQueue.global(qos: .userInitiated).async { + ProcessInfo.processInfo.performExpiringActivity(withReason: reason) { expired in + if expired { end(true); gate.signal(); return } + _ = gate.wait(timeout: .now() + wait); end(false) + } + } + return { gate.signal() } + } + // MARK: - Bootstrap: the one scan pass (fresh install / sensor change / 3 misses) private func startBootstrapPass(reason: String) { From 0d6d60835eb5bc9ad004c757b0679531d644a2a6 Mon Sep 17 00:00:00 2001 From: Deniz Cengiz <48965855+dnzxy@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:38:32 +0100 Subject: [PATCH 4/4] Replace scanAfterDelay with managerQueue.asyncAfter --- G7SensorKit/G7CGMManager/G7BluetoothManager.swift | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index 25bb064..8cab31a 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -515,14 +515,6 @@ class G7BluetoothManager: NSObject { } } - fileprivate func scanAfterDelay() { - DispatchQueue.global(qos: .utility).async { - Thread.sleep(forTimeInterval: 2) - - self.scanForPeripheral() - } - } - /// Lodges the active peripheral's reconnect at once, so it stands even if the app is suspended, /// with a start delay that lets the sensor's advertising tail pass first. private func reconnectAfterAdvertisingTail(_ peripheral: CBPeripheral) { @@ -542,7 +534,9 @@ class G7BluetoothManager: NSObject { private func resumeAfterLinkEnded(_ peripheral: CBPeripheral, refusedAt failure: Date? = nil) { dispatchPrecondition(condition: .onQueue(managerQueue)) guard peripheral == activePeripheral, centralManager.state == .poweredOn else { - scanAfterDelay() + managerQueue.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.managerQueue_scanForPeripheral() + } return } // Re-lodging a refused request at once would spin; back off and go through the stock scan instead.