From c7153cb04262e35549c5a2d6d1967f4451d4e51a Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 8 Sep 2026 07:28:09 -0500 Subject: [PATCH 1/3] Provide the BLE heartbeat for DASH pods not using a RileyLink setBLEHeartbeatRequest branched on podType.mayUseRileyLink, which is true for DASH as well as Eros because DASH can use a RileyLink under the Pod Keep Alive option. A DASH pod on direct BLE with Pod Keep Alive off therefore took the RileyLink branch and only set timerTickEnabled on a RileyLink provider that has no devices. provideHeartbeat was never assigned and BlePodComms.setHeartbeatRequest was never called, so the delayed-connect probe that produces periodic background wakes was never scheduled. issueHeartbeatIfNeeded() also gates on provideHeartbeat, so even a probe that did fire would not have notified Loop. Loop was requesting the heartbeat correctly -- DeviceDataManager sets pumpManagerMustProvideBLEHeartbeat when the CGM cannot provide one -- and the request was logged and then dropped. With no wake source the app stayed suspended in the background until something else woke it. Seen as Loop Failure alerts after gaps of 20 to 264 minutes that end the instant the app is foregrounded; a report from an affected DASH pod shows provideHeartbeat: false moments after a setBLEHeartbeatRequest log line. Only reachable when the CGM cannot supply the heartbeat itself, so it needs a remote or networked CGM such as Nightscout. A BLE Dexcom provides its own heartbeat and masks it. Branch on whether a RileyLink is actually in use instead: Eros always, DASH only under the RileyLink Pod Keep Alive option. The other mayUseRileyLink call sites want the "may" semantics and are unchanged. (cherry picked from commit 0f3295fdc82d1f22f2d9c837702ffabeed0056cb) --- OmnipodKit/PumpManager/OmniPumpManager.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 45a6176..d203605 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -341,7 +341,16 @@ public class OmniPumpManager: RileyLinkPumpManager { let mustProvide = request != nil let desc = request.map { "last=\($0.lastCGMReadingDate.map { String(describing: $0) } ?? "nil") interval=\(Int($0.expectedCGMReadingInterval))s" } ?? "nil" logDeviceCommunication("[heartbeat] pid=\(pid) setBLEHeartbeatRequest(\(desc))", type: .connection) - if self.state.podType.mayUseRileyLink { + // `mayUseRileyLink` is true for DASH as well as Eros, because DASH *can* use a RileyLink + // under the Pod Keep Alive option. That is the wrong question here: what matters is whether + // a RileyLink is actually in the loop to tick. A DASH pod on direct BLE with Pod Keep Alive + // off took the RileyLink branch, so `provideHeartbeat` was never set and the BLE pod never + // got its heartbeat request -- Loop asked for a heartbeat, we logged it, and dropped it. + // Only bites when the CGM cannot provide the heartbeat itself (a remote/networked CGM such + // as Nightscout); a BLE Dexcom masks it, which is why it went unnoticed. + let rileyLinkIsInUse = self.state.podType.isEros + || (self.state.podType.mayUseRileyLink && self.state.podKeepAlive == .rileyLink) + if rileyLinkIsInUse { rileyLinkDeviceProvider.timerTickEnabled = self.state.isPumpDataStale || mustProvide || /// RL ticks needed for traditional BLE wakups self.state.podKeepAlive == .rileyLink // RL ticks needed for PodKeepAlive rileyLink option From 8f9b355059989ea432ee7ceeec337acd23799095 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 8 Sep 2026 19:49:28 -0500 Subject: [PATCH 2/3] Do not cancel a pairing connect we just issued as a zombie The zombie check in didDiscover treats a pairable pod heard while .connecting with no watchdog as a stale connect and cancels it. That is right for a connect abandoned by an earlier attempt, but it cannot tell that case from a connect we issued moments ago: a peripheral keeps advertising until the link is actually established, and CoreBluetooth reports .connecting from the instant connect() is called, so an advert inside the first second or two is the normal case. discoverPods pre-connects any already-known disconnected pod and then starts the discovery scan. timedConnect only arms the watchdog when shouldUseEagerConnect is true, which needs an affected iPhone model, so on any other phone that pre-connect is unsupervised. The scan hears the pod ~0.8s later at state=.connecting, the zombie branch cancels it, the teardown timer reissues timedConnect (again unsupervised), the next advert ~1.5s later cancels that, and pairing loops at the advert interval without ever completing. Two Xcode captures from an iPhone SE pairing an Omnipod 5 show 62 and 104 consecutive cycles of [pairing] heard pod ... pairable=true state=1 [pairing] ... stuck in .connecting with no watchdog -- cancelling zombie connect [pairing] reconnecting to pairable pod ... after zombie teardown Pairing with a pod that is not yet a known device takes the state=0 path and races the same window: it worked in earlier testing because the ~1.1s connect landed before the next advert. Stamp when the current pairing connect was issued and only treat a .connecting pod as a zombie once that connect is older than pairingConnectZombieSeconds (default 5s, past a healthy connect and inside the pairing budget). The stamp is separate from connectRequestedAt, which is deliberately kept across retries for the latency metric, and is cleared on didConnect and didFailToConnect. (cherry picked from commit 5a32565b56df2fd180217dc5d972356128be8afb) --- OmnipodKit/Bluetooth/BluetoothManager.swift | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 9d11aba..1e3a12f 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -275,6 +275,15 @@ class BluetoothManager: NSObject { (UserDefaults.standard.object(forKey: "OmnipodKit.eagerPairingBudgetSeconds") as? Double) ?? 40.0 } + /// How long a pairing connect we issued ourselves is left alone before hearing the pod advertise + /// while it is still `.connecting` counts as a zombie. A peripheral keeps advertising until the link + /// is actually established and CoreBluetooth reports `.connecting` from the moment `connect()` is + /// called, so an advert inside the first second or two is the normal case, not a stale connect. + /// Well past the ~1s a healthy connect takes, well inside `eagerPairingBudgetSeconds`. + static var pairingConnectZombieSeconds: TimeInterval { + (UserDefaults.standard.object(forKey: "OmnipodKit.pairingConnectZombieSeconds") as? Double) ?? 5.0 + } + /// EXPERIMENT: pass CBConnectPeripheralOptionEnableAutoReconnect (iOS 17+) on eager connects, to /// probe whether it changes the low-level stack's reacquisition behavior on wedge-prone pods. /// With it, an unexpected post-establishment drop is auto-reconnected by the system, reported via @@ -406,6 +415,11 @@ class BluetoothManager: NSObject { /// Connect-request timestamps (by peripheral UUID) for measuring connect latency in didConnect. private var connectRequestedAt: [String: Date] = [:] + /// When the CURRENT pairing connect was issued (by peripheral UUID), overwritten on every + /// `timedConnect`, so the zombie check in didDiscover can tell a connect we just made from a + /// stale one. Distinct from `connectRequestedAt`, which is kept across retries for the latency metric. + private var pairingConnectIssuedAt: [String: Date] = [:] + /// Delayed-connect probe: true while a StartDelay connect is in flight (issued, awaiting didConnect), /// so didDiscover doesn't re-issue during the wait; the issue timestamp measures the true delay. private var delayedProbeInFlight = false @@ -555,6 +569,7 @@ class BluetoothManager: NSObject { if connectRequestedAt[peripheral.identifier.uuidString] == nil { connectRequestedAt[peripheral.identifier.uuidString] = Date() } + pairingConnectIssuedAt[peripheral.identifier.uuidString] = Date() let cm: CBCentralManager = manager cm.connect(peripheral, options: nil) // Pairing/discovery connect: without a watchdog, a wedged connect was abandoned on the discovery @@ -1579,6 +1594,15 @@ extension BluetoothManager: CBCentralManagerDelegate { log.default("Connecting to pairable device %{public}@ in discovery mode", peripheral) connectionDelegate?.omnipodLogDeviceEvent("[pairing] connecting to pairable pod \(peripheral.identifier.uuidString)") timedConnect(peripheral) // pairing — an explicit connect, not auto-reconnect + } else if peripheral.state == .connecting && !isConnectWatchdogActive(peripheral), + let pending = pairingConnectIssuedAt[peripheral.identifier.uuidString].map({ Date().timeIntervalSince($0) }), + pending < BluetoothManager.pairingConnectZombieSeconds { + // A connect WE issued moments ago (discoverPods pre-connect, or the post-zombie + // reconnect below) is still completing. Hearing an advert now is normal — the pod + // advertises until the link is up. Leave it alone. Without this gate, on a phone + // where the watchdog does not arm, every advert cancelled the in-flight connect and + // pairing looped forever at the advert interval (~1.6s), never completing. + log.debug("[pairing] pairable pod %{public}@ connect in flight %.1fs — leaving it to complete", peripheral.identifier.uuidString, pending) } else if peripheral.state == .connecting && !isConnectWatchdogActive(peripheral) { // ZOMBIE pending connect: we just HEARD this pod advertise, so it is not in a live // connection — a stale, unsupervised connect request (e.g. from an abandoned pairing @@ -1672,6 +1696,7 @@ extension BluetoothManager: CBCentralManagerDelegate { return } + pairingConnectIssuedAt.removeValue(forKey: peripheral.identifier.uuidString) if let requestedAt = connectRequestedAt.removeValue(forKey: peripheral.identifier.uuidString) { let latency = String(format: "%.3f", Date().timeIntervalSince(requestedAt)) log.default("[#%{public}@] CONNECTED: %{public}@ — connect latency %{public}@s (known device: %{public}@)", @@ -1819,6 +1844,7 @@ extension BluetoothManager: CBCentralManagerDelegate { dispatchPrecondition(condition: .onQueue(managerQueue)) log.error("[#%{public}@] FAILED TO CONNECT: %{public}@ error=%{public}@", instanceID, peripheral, String(describing: error)) + pairingConnectIssuedAt.removeValue(forKey: peripheral.identifier.uuidString) connectionDelegate?.omnipodPeripheralDidFailToConnect(peripheral: peripheral, error: error) From 761e4ac13b989b2d329bf6138f2410445304e465 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 8 Sep 2026 20:06:09 -0500 Subject: [PATCH 3/3] Cancel stale pairing connects deterministically; drop the zombie heuristic A "zombie" was a pending connect left behind by a pairing attempt that gave up without cancelling. The didDiscover branch tried to detect one from an advert heard while the pod was .connecting, but that is equally true of a connect issued a second ago, and on phones where the watchdog does not arm it cancelled every in-flight pairing connect and looped (see 5a32565, which only narrowed the misfire). Remove the guess and close the source instead: - endPodDiscovery cancels any .connecting peripheral. The previous autoConnectIDs exemption is what leaked the orphans: discovery adds the pod to autoConnectIDs before ending, so any later abandoned connect to a pod that had once been found was never cancelled. Only a .connected paired pod keeps the exemption. - discoverPods cancels any unsupervised .connecting peripheral before issuing its own connects, so during discovery the only pending connect is ours and the discovery timeout owns it. This also covers a connect left pending across an app suspend or kill, which comes back through state restoration already .connecting. didDiscover goes back to connecting only when .disconnected. The pairingConnectIssuedAt stamp and pairingConnectZombieSeconds from 5a32565 go with the heuristic. (cherry picked from commit d70d7b8df15c33fb526b9beadf8ffb3f9cc5c68c) --- OmnipodKit/Bluetooth/BluetoothManager.swift | 63 +++++---------------- 1 file changed, 13 insertions(+), 50 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 1e3a12f..7d508fa 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -275,15 +275,6 @@ class BluetoothManager: NSObject { (UserDefaults.standard.object(forKey: "OmnipodKit.eagerPairingBudgetSeconds") as? Double) ?? 40.0 } - /// How long a pairing connect we issued ourselves is left alone before hearing the pod advertise - /// while it is still `.connecting` counts as a zombie. A peripheral keeps advertising until the link - /// is actually established and CoreBluetooth reports `.connecting` from the moment `connect()` is - /// called, so an advert inside the first second or two is the normal case, not a stale connect. - /// Well past the ~1s a healthy connect takes, well inside `eagerPairingBudgetSeconds`. - static var pairingConnectZombieSeconds: TimeInterval { - (UserDefaults.standard.object(forKey: "OmnipodKit.pairingConnectZombieSeconds") as? Double) ?? 5.0 - } - /// EXPERIMENT: pass CBConnectPeripheralOptionEnableAutoReconnect (iOS 17+) on eager connects, to /// probe whether it changes the low-level stack's reacquisition behavior on wedge-prone pods. /// With it, an unexpected post-establishment drop is auto-reconnected by the system, reported via @@ -415,11 +406,6 @@ class BluetoothManager: NSObject { /// Connect-request timestamps (by peripheral UUID) for measuring connect latency in didConnect. private var connectRequestedAt: [String: Date] = [:] - /// When the CURRENT pairing connect was issued (by peripheral UUID), overwritten on every - /// `timedConnect`, so the zombie check in didDiscover can tell a connect we just made from a - /// stale one. Distinct from `connectRequestedAt`, which is kept across retries for the latency metric. - private var pairingConnectIssuedAt: [String: Date] = [:] - /// Delayed-connect probe: true while a StartDelay connect is in flight (issued, awaiting didConnect), /// so didDiscover doesn't re-issue during the wait; the issue timestamp measures the true delay. private var delayedProbeInFlight = false @@ -569,7 +555,6 @@ class BluetoothManager: NSObject { if connectRequestedAt[peripheral.identifier.uuidString] == nil { connectRequestedAt[peripheral.identifier.uuidString] = Date() } - pairingConnectIssuedAt[peripheral.identifier.uuidString] = Date() let cm: CBCentralManager = manager cm.connect(peripheral, options: nil) // Pairing/discovery connect: without a watchdog, a wedged connect was abandoned on the discovery @@ -740,8 +725,8 @@ class BluetoothManager: NSObject { // Disconnect from all devices not in our connection list for device in devices { let peripheral = device.manager.peripheral - if !autoConnectIDs.contains(peripheral.identifier.uuidString) && - (peripheral.state == .connected || peripheral.state == .connecting) + if peripheral.state == .connecting || + (peripheral.state == .connected && !autoConnectIDs.contains(peripheral.identifier.uuidString)) { log.default("Disconnecting from peripheral: %{public}@", peripheral) manager.cancelPeripheralConnection(peripheral) @@ -848,9 +833,15 @@ class BluetoothManager: NSObject { manager.stopScan() for device in devices { let peripheral = device.manager.peripheral - if peripheral.state == .disconnected || peripheral.state == .disconnecting { + switch peripheral.state { + case .connecting where !isConnectWatchdogActive(peripheral): + log.info("discoverPods: Cancelling stale connect: %{public}@", peripheral) + manager.cancelPeripheralConnection(peripheral) + case .disconnected, .disconnecting: log.info("discoverPods: Connecting to peripheral: %{public}@", peripheral) - timedConnect(peripheral) // pairing/discovery — an explicit connect, not auto-reconnect + timedConnect(peripheral) + default: + break } } startScanning() @@ -1585,39 +1576,13 @@ extension BluetoothManager: CBCentralManagerDelegate { connectionDelegate?.omnipodLogDeviceEvent("[pairing] heard pod \(peripheral.identifier.uuidString) pairable=\(podAdvertisement.pairable) state=\(peripheral.state.rawValue)") } if discoveryModeEnabled && podAdvertisement.pairable { - // We've heard our target pairable pod — stop the discovery scan so it doesn't starve the - // connect (an active allowDuplicates scan wedges the connect in .connecting, which is - // what stalled pairing), then connect if it's disconnected. A watchdog-managed connect - // in flight is left alone (it's supervised and will retry itself). + // Stop the scan so it doesn't starve the connect, then connect if disconnected. + // Anything already .connecting is ours: discoverPods cancels stale connects first. if manager.isScanning { manager.stopScan() } if peripheral.state == .disconnected { log.default("Connecting to pairable device %{public}@ in discovery mode", peripheral) connectionDelegate?.omnipodLogDeviceEvent("[pairing] connecting to pairable pod \(peripheral.identifier.uuidString)") - timedConnect(peripheral) // pairing — an explicit connect, not auto-reconnect - } else if peripheral.state == .connecting && !isConnectWatchdogActive(peripheral), - let pending = pairingConnectIssuedAt[peripheral.identifier.uuidString].map({ Date().timeIntervalSince($0) }), - pending < BluetoothManager.pairingConnectZombieSeconds { - // A connect WE issued moments ago (discoverPods pre-connect, or the post-zombie - // reconnect below) is still completing. Hearing an advert now is normal — the pod - // advertises until the link is up. Leave it alone. Without this gate, on a phone - // where the watchdog does not arm, every advert cancelled the in-flight connect and - // pairing looped forever at the advert interval (~1.6s), never completing. - log.debug("[pairing] pairable pod %{public}@ connect in flight %.1fs — leaving it to complete", peripheral.identifier.uuidString, pending) - } else if peripheral.state == .connecting && !isConnectWatchdogActive(peripheral) { - // ZOMBIE pending connect: we just HEARD this pod advertise, so it is not in a live - // connection — a stale, unsupervised connect request (e.g. from an abandoned pairing - // attempt) is pinning it in .connecting. Field failure mode: every rescan reported - // "heard pod ... state=1" and then declined to connect, so pairing never succeeded. - // Cancel the zombie and connect fresh (re-arming the watchdog) once teardown lands. - log.default("[pairing] pairable pod %{public}@ stuck in .connecting with no watchdog — cancelling zombie connect", peripheral.identifier.uuidString) - connectionDelegate?.omnipodLogDeviceEvent("[pairing] zombie connect on pairable pod — cancelling and reconnecting") - manager.cancelPeripheralConnection(peripheral) - managerQueue.asyncAfter(deadline: .now() + BluetoothManager.eagerConnectTeardownSeconds) { [weak self] in - guard let self = self, self.discoveryModeEnabled, peripheral.state != .connected else { return } - self.log.default("[pairing] reconnecting to pairable pod %{public}@ after zombie teardown", peripheral.identifier.uuidString) - self.connectionDelegate?.omnipodLogDeviceEvent("[pairing] connecting to pairable pod \(peripheral.identifier.uuidString) (post-zombie)") - self.timedConnect(peripheral) - } + timedConnect(peripheral) } } else if autoConnectIDs.contains(peripheral.identifier.uuidString) && peripheral.state == .disconnected { log.debug("Reconnecting to autoconnect device") @@ -1696,7 +1661,6 @@ extension BluetoothManager: CBCentralManagerDelegate { return } - pairingConnectIssuedAt.removeValue(forKey: peripheral.identifier.uuidString) if let requestedAt = connectRequestedAt.removeValue(forKey: peripheral.identifier.uuidString) { let latency = String(format: "%.3f", Date().timeIntervalSince(requestedAt)) log.default("[#%{public}@] CONNECTED: %{public}@ — connect latency %{public}@s (known device: %{public}@)", @@ -1844,7 +1808,6 @@ extension BluetoothManager: CBCentralManagerDelegate { dispatchPrecondition(condition: .onQueue(managerQueue)) log.error("[#%{public}@] FAILED TO CONNECT: %{public}@ error=%{public}@", instanceID, peripheral, String(describing: error)) - pairingConnectIssuedAt.removeValue(forKey: peripheral.identifier.uuidString) connectionDelegate?.omnipodPeripheralDidFailToConnect(peripheral: peripheral, error: error)