From 88d32d09ce294bf1b223ec945451d771172c82e5 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 19 Aug 2026 12:22:44 -0500 Subject: [PATCH 01/21] Eager connect watchdog: mitigate InPlay/iPhone-16 connection wedges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InPlay-firmware DASH pods (peripheral name "InPlay BLE") silently ignore the LL CONNECTION_PARAM_REQ control PDU; on iPhone 16-class controllers iOS often issues that early in a connect, deadlocking the LL procedure queue so the connect wedges in .connecting with no callback (~7s until the pod terminates the dead link, then iOS silently auto-retries — chains of invisible ~20s stalls, and the wedged pod stops advertising so it's invisible to discovery scans). Add an eager cancel/retry strategy, gated to affected phones (hw.machine iPhone17,x / iPhone18,x) and InPlay/unknown pods (shouldUseEagerConnect), default on and fully UserDefaults-tunable: - Connect watchdog: if a connect hasn't reached .connected within ~3s (3-5x the measured healthy <1s population), presume the wedge — cancelPeripheralConnection (tears it down on-air, freeing the pod to advertise + re-arming iOS's fast connection scan), wait ~200ms, re-connect; repeat until a bounded budget. Runs under the existing runCommand .connect wait, which clears only on a real didConnect. - On-demand command connects to affected pods go direct (retrievePeripherals + plain connect, no fresh-discovery scan) + watchdog. - Pairing/discovery connects (timedConnect, incl. didDiscover new-pod) arm the watchdog — fixing the existing bug where a timed-out pairing connect was abandoned WITHOUT cancelling, leaving iOS re-wedging the pod and blinding rediscovery. - didDisconnect / didFailToConnect defer to the watchdog while it owns a connect, so their reconnect paths don't race it. - Telemetry: a distinct device-log event when the watchdog fires with state==connecting (pathognomonic) tagged with the peripheral name, to measure prevalence and confirm the 3s threshold never clips healthy connects. Non-affected phones / known-non-InPlay pods are unchanged (zero regression). --- OmnipodKit/Bluetooth/BluetoothManager.swift | 198 ++++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index da5fe75..2357178 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -215,6 +215,71 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.scanningEnabled") as? Bool ?? true } + // MARK: - Eager connect (InPlay / iPhone 16-class LL-deadlock mitigation) + + /// Master switch for the eager-connect watchdog. InPlay-firmware DASH pods (peripheral name + /// "InPlay BLE") silently ignore the LL_CONNECTION_PARAM_REQ control PDU; on iPhone 16-class + /// controllers iOS often issues that procedure early in a connect, deadlocking the LL procedure + /// queue so the connect wedges in `.connecting` with no callback (~7s until the pod terminates the + /// dead link, then iOS silently auto-retries — chains of invisible ~20s stalls). The watchdog caps + /// the cost: a connect that hasn't reached `.connected` within `eagerConnectWatchdogSeconds` is + /// presumed wedged, so we `cancelPeripheralConnection` (which tears the wedge down on-air, freeing + /// the pod to advertise again, and re-arms iOS's fast connection scan) and re-connect. Healthy + /// connects complete <1s and never trip it. Gated to affected phones + InPlay/unknown pods by + /// `shouldUseEagerConnect(for:)`. Default ON. + static var eagerConnectEnabled: Bool { + UserDefaults.standard.object(forKey: "OmnipodKit.eagerConnectEnabled") as? Bool ?? true + } + + /// Apply the eager watchdog on ANY device, bypassing the iPhone-model gate — for bench A/B testing. + static var eagerConnectForceAllDevices: Bool { + UserDefaults.standard.object(forKey: "OmnipodKit.eagerConnectForceAllDevices") as? Bool ?? false + } + + /// How long to wait for didConnect before presuming a connect is wedged (~3-5x the measured healthy + /// connect population of <1s). + static var eagerConnectWatchdogSeconds: TimeInterval { + (UserDefaults.standard.object(forKey: "OmnipodKit.eagerConnectWatchdogSeconds") as? Double) ?? 3.0 + } + + /// Pause after `cancelPeripheralConnection` before re-issuing `connect()`, to let the LL termination + /// land and the pod resume advertising (observed ~10ms; 200ms is comfortable margin). + static var eagerConnectTeardownSeconds: TimeInterval { + (UserDefaults.standard.object(forKey: "OmnipodKit.eagerConnectTeardownSeconds") as? Double) ?? 0.2 + } + + /// Overall budget for the eager cancel/retry cycle on an on-demand command connect. Kept just under + /// the PeripheralManager `runCommand` `.connect` timeout (20s) so the watchdog owns the retries + /// underneath that single wait (which only clears on a real didConnect). + static var eagerConnectBudgetSeconds: TimeInterval { + (UserDefaults.standard.object(forKey: "OmnipodKit.eagerConnectBudgetSeconds") as? Double) ?? 18.0 + } + + /// Overall budget for the eager cancel/retry cycle during pairing discovery — longer than one + /// wedge-cycle so a wedged first attempt doesn't consume the whole pairing window. + static var eagerPairingBudgetSeconds: TimeInterval { + (UserDefaults.standard.object(forKey: "OmnipodKit.eagerPairingBudgetSeconds") as? Double) ?? 28.0 + } + + /// CoreBluetooth peripheral (advertised local) name of the affected InPlay-firmware DASH pod variant. + static let inPlayPeripheralName = "InPlay BLE" + + /// hw.machine identifier of this device, e.g. "iPhone17,1". Computed once. + private static let deviceModelIdentifier: String = { + var sys = utsname() + uname(&sys) + return withUnsafeBytes(of: &sys.machine) { raw in + String(cString: raw.baseAddress!.assumingMemoryBound(to: CChar.self)) + } + }() + + /// True on the iPhone controller generations that exhibit the LL deadlock: iPhone 16 family (incl. + /// 16e) = `iPhone17,x`, and iPhone 17 family (incl. 17e) = `iPhone18,x`. A safe superset — on any + /// unaffected model a healthy connect completes <1s and never trips the watchdog. + static var isEagerConnectDeviceModel: Bool { + deviceModelIdentifier.hasPrefix("iPhone17,") || deviceModelIdentifier.hasPrefix("iPhone18,") + } + /// Fallback start delay (seconds) for the delayed-connect probe when Loop hasn't supplied a heartbeat /// schedule (no `heartbeatTargetDate`). Normally the delay is computed from the CGM reading schedule — @@ -398,11 +463,20 @@ class BluetoothManager: NSObject { /// Stamp the connect time and issue the connect, so didConnect can report the latency. private func timedConnect(_ peripheral: CBPeripheral) { + dispatchPrecondition(condition: .onQueue(managerQueue)) if connectRequestedAt[peripheral.identifier.uuidString] == nil { connectRequestedAt[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 + // timeout WITHOUT cancelling, leaving iOS silently re-wedging the pod — which then stops + // advertising and blinds the very scan trying to rediscover it. Arm the watchdog so a wedged + // pairing connect is cancelled (freeing the pod to advertise) and retried within the budget. + if shouldUseEagerConnect(for: peripheral) { + connectionDelegate?.omnipodLogDeviceEvent("[eager] pairing connect — arming watchdog") + armConnectWatchdog(peripheral, deadline: Date().addingTimeInterval(BluetoothManager.eagerPairingBudgetSeconds)) + } } /// Issue a connect with CBConnectPeripheralOptionStartDelayKey and record the time — the pump-provided @@ -731,6 +805,101 @@ class BluetoothManager: NSObject { manager.connect(target, options: nil) } + // MARK: - Eager connect watchdog (InPlay / iPhone 16-class) + + /// Governing generation token per peripheral id — bumped on every arm/disarm so a stale scheduled + /// watchdog tick no-ops (same token pattern as `pendingFreshConnectID`). + private var connectWatchdogGeneration: [String: Int] = [:] + + /// Peripheral ids currently under active watchdog management. While present, `didDisconnect` and + /// `didFailToConnect` must NOT independently reconnect (the watchdog's cancel fires those callbacks + /// and the watchdog itself owns the cancel/retry cycle — otherwise the handlers race it). + private var connectWatchdogActive: Set = [] + + /// Whether the eager watchdog currently owns (re)connection for this peripheral. + private func isConnectWatchdogActive(_ peripheral: CBPeripheral) -> Bool { + connectWatchdogActive.contains(peripheral.identifier.uuidString) + } + + /// Whether to use the eager cancel/retry connect strategy for this peripheral: the feature is on, + /// the phone is an affected model (or force-all is set), AND the pod is InPlay or its type isn't yet + /// known (pre-pairing / cold reconnect — we can't tell it's NOT InPlay). A pod whose name is known + /// and is not "InPlay BLE" opts out. + func shouldUseEagerConnect(for peripheral: CBPeripheral) -> Bool { + guard BluetoothManager.eagerConnectEnabled else { return false } + guard BluetoothManager.eagerConnectForceAllDevices || BluetoothManager.isEagerConnectDeviceModel else { return false } + if let name = peripheral.name, !name.isEmpty { + return name == BluetoothManager.inPlayPeripheralName + } + return true + } + + /// Direct eager connect: skip the fresh-discovery scan (a known/recovered peripheral is reconnected + /// via `retrievePeripherals` + a plain `connect()`, which also re-arms iOS's fast connection scan) + /// and arm the watchdog. Used for on-demand command connects to affected pods. + private func eagerConnect(_ peripheral: CBPeripheral, deadline: Date) { + dispatchPrecondition(condition: .onQueue(managerQueue)) + let target = manager.retrievePeripherals(withIdentifiers: [peripheral.identifier]).first ?? peripheral + if let device = devices.first(where: { $0.manager.peripheral.identifier == peripheral.identifier }) { + device.manager.peripheral = target + } + if manager.isScanning { manager.stopScan() } // a concurrent scan starves connection completion on iOS + log.default("[eager] direct connect for %{public}@ (name=%{public}@)", target.identifier.uuidString, target.name ?? "?") + connectionDelegate?.omnipodLogDeviceEvent("[eager] direct connect (name=\(target.name ?? "?"))") + manager.connect(target, options: nil) + armConnectWatchdog(target, deadline: deadline) + } + + /// Arm the eager-connect watchdog for `peripheral`. If it hasn't reached `.connected` within + /// `eagerConnectWatchdogSeconds`, presume the InPlay/iPhone-16 LL deadlock: log the (pathognomonic) + /// still-`.connecting` state, `cancelPeripheralConnection` to tear the wedge down on-air, wait + /// `eagerConnectTeardownSeconds`, then re-issue `connect()` and re-arm — until `deadline`. Disarmed + /// by `didConnect`. Runs entirely on `managerQueue`. + private func armConnectWatchdog(_ peripheral: CBPeripheral, deadline: Date) { + dispatchPrecondition(condition: .onQueue(managerQueue)) + let id = peripheral.identifier.uuidString + let generation = (connectWatchdogGeneration[id] ?? 0) + 1 + connectWatchdogGeneration[id] = generation + connectWatchdogActive.insert(id) + managerQueue.asyncAfter(deadline: .now() + BluetoothManager.eagerConnectWatchdogSeconds) { [weak self] in + guard let self = self, self.connectWatchdogGeneration[id] == generation else { return } // stale / disarmed + let target = self.manager.retrievePeripherals(withIdentifiers: [peripheral.identifier]).first ?? peripheral + guard target.state != .connected else { self.disarmConnectWatchdog(target); return } + self.log.default("[eager] connect watchdog FIRED for %{public}@ state=%{public}d name=%{public}@ — cancelling wedged connect", + id, target.state.rawValue, target.name ?? "?") + // Distinct telemetry: watchdog firing with state==connecting is pathognomonic for the wedge; + // tagging the pod name ("InPlay BLE") lets prevalence be measured per pod lot / phone model. + self.connectionDelegate?.omnipodLogDeviceEvent("[eager] watchdog fired state=\(target.state.rawValue) name=\(target.name ?? "?") — cancel+retry") + self.manager.cancelPeripheralConnection(target) + guard Date().addingTimeInterval(BluetoothManager.eagerConnectTeardownSeconds) < deadline else { + self.log.default("[eager] connect watchdog budget exhausted for %{public}@ — giving up", id) + self.disarmConnectWatchdog(target) + return + } + self.managerQueue.asyncAfter(deadline: .now() + BluetoothManager.eagerConnectTeardownSeconds) { [weak self] in + guard let self = self, self.connectWatchdogGeneration[id] == generation else { return } + let retryTarget = self.manager.retrievePeripherals(withIdentifiers: [peripheral.identifier]).first ?? peripheral + guard retryTarget.state != .connected else { self.disarmConnectWatchdog(retryTarget); return } + if let device = self.devices.first(where: { $0.manager.peripheral.identifier == peripheral.identifier }) { + device.manager.peripheral = retryTarget + } + self.log.default("[eager] re-issuing connect for %{public}@ after teardown", id) + self.connectionDelegate?.omnipodLogDeviceEvent("[eager] re-issue connect") + self.manager.connect(retryTarget, options: nil) + self.armConnectWatchdog(retryTarget, deadline: deadline) + } + } + } + + /// Invalidate any pending watchdog tick for this peripheral (bump the generation token). + private func disarmConnectWatchdog(_ peripheral: CBPeripheral) { + let id = peripheral.identifier.uuidString + connectWatchdogActive.remove(id) + if let gen = connectWatchdogGeneration[id] { + connectWatchdogGeneration[id] = gen + 1 + } + } + // MARK: - Central calls (MUST run on managerQueue) // // CBCentralManager was created with `managerQueue`, so every call into it has to be serialized on @@ -760,6 +929,15 @@ class BluetoothManager: NSObject { manager.cancelPeripheralConnection(peripheral) } commandConnectInFlight = true + // Eager connect (InPlay / iPhone-16 mitigation): a known pod on an affected phone connects + // directly (skipping the fresh-discovery scan), with the watchdog cancelling and retrying any + // wedged attempt within a bounded budget instead of a single blind wait. + if shouldUseEagerConnect(for: peripheral) { + log.default("[eager] command connect for %{public}@", peripheral.identifier.uuidString) + connectionDelegate?.omnipodLogDeviceEvent("[eager] command connect") + eagerConnect(peripheral, deadline: Date().addingTimeInterval(BluetoothManager.eagerConnectBudgetSeconds)) + return + } // Fresh-discovery connect: briefly scan for the pod and connect on its just-heard advert // (~1-2s) instead of a bare cold connect() that waits out iOS's duty-cycled reacquisition // (~10-16s — the slow user-initiated Suspend). Falls back to a cold connect after 4s if the @@ -1228,6 +1406,9 @@ extension BluetoothManager: CBCentralManagerDelegate { pendingFreshConnectID = nil } + // A completed connect satisfies the eager watchdog — invalidate any pending cancel/retry tick. + disarmConnectWatchdog(peripheral) + // Connected — stop the connect-helper scan (connectOnDemand started a light scan to speed the // connect). We don't scan while connected; the monitor scan is restored on the next disconnect. if manager.isScanning { @@ -1291,6 +1472,15 @@ extension BluetoothManager: CBCentralManagerDelegate { connectionDelegate?.omnipodPeripheralDidDisconnect(peripheral: peripheral, error: error) + // The eager watchdog owns this connect's cancel/retry cycle — its own cancelPeripheralConnection + // produced THIS callback. Do not independently reconnect (that would race the watchdog's retry, + // reviving the old cancel↔"reconnecting after drop" loop); the watchdog re-issues after teardown. + if isConnectWatchdogActive(peripheral) { + log.default("[eager] didDisconnect under active watchdog for %{public}@ — deferring reconnect to watchdog", peripheral.identifier.uuidString) + delayedProbeInFlight = false + return + } + if autoConnectIDs.contains(peripheral.identifier.uuidString) { log.debug("Reconnecting disconnected autoconnect peripheral") autoReconnect(peripheral) @@ -1329,6 +1519,14 @@ extension BluetoothManager: CBCentralManagerDelegate { connectionDelegate?.omnipodPeripheralDidFailToConnect(peripheral: peripheral, error: error) + // Under active watchdog: defer reconnection to it (don't start the idle scan / probe here, which + // would starve the watchdog's next connect attempt). + if isConnectWatchdogActive(peripheral) { + log.default("[eager] didFailToConnect under active watchdog for %{public}@ — deferring to watchdog", peripheral.identifier.uuidString) + delayedProbeInFlight = false + return + } + if autoConnectIDs.contains(peripheral.identifier.uuidString) { autoReconnect(peripheral) } From 52db54c6008cff05e1cca4bb190c80bd9d6cd53c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 19 Aug 2026 13:01:04 -0500 Subject: [PATCH 02/21] Narrow eager-connect device gate to iPhone 16 family + iPhone 17e only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The affected controllers are the iPhone 16 family (all variants incl. 16e = iPhone17,x) and the iPhone 17e (iPhone18,5). The rest of the iPhone 17 family (iPhone18,1-18,4) is not affected — drop the overbroad iPhone18, prefix match. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 2357178..3b19390 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -273,11 +273,11 @@ class BluetoothManager: NSObject { } }() - /// True on the iPhone controller generations that exhibit the LL deadlock: iPhone 16 family (incl. - /// 16e) = `iPhone17,x`, and iPhone 17 family (incl. 17e) = `iPhone18,x`. A safe superset — on any - /// unaffected model a healthy connect completes <1s and never trips the watchdog. + /// True on the iPhone models that exhibit the LL deadlock: the iPhone 16 family (all variants, + /// incl. 16e) = `iPhone17,x`, and the iPhone 17e specifically = `iPhone18,5`. Deliberately NOT the + /// rest of the iPhone 17 family (`iPhone18,1`-`18,4`) — those controllers are not affected. static var isEagerConnectDeviceModel: Bool { - deviceModelIdentifier.hasPrefix("iPhone17,") || deviceModelIdentifier.hasPrefix("iPhone18,") + deviceModelIdentifier.hasPrefix("iPhone17,") || deviceModelIdentifier == "iPhone18,5" } From 5c08c60979e453406bc1a1b3083ce8a47cfaf209 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 19 Aug 2026 13:15:33 -0500 Subject: [PATCH 03/21] Pod settings: persistent notice for InPlay pod + affected iPhone slowness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an InPlay-variant DASH pod (usingInPlayPod) is paired on an affected iPhone model (iPhoneWithPossibleInPlayIssues: iPhone 16 family / iPhone 17e), show a standing notice row in pod settings — 'Slower Connections Expected' — with a NavigationLink to a detail view explaining the firmware bug, what to expect (automatic stall detection/retry, occasional ~30s connects, extra pairing attempts), and that insulin delivery is unaffected. --- .../ViewModels/OmniSettingsViewModel.swift | 8 +++ .../Views/InPlayConnectionInfoView.swift | 66 +++++++++++++++++++ .../Views/OmniSettingsView.swift | 21 ++++++ 3 files changed, 95 insertions(+) create mode 100644 OmnipodKit/PumpManagerUI/Views/InPlayConnectionInfoView.swift diff --git a/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift b/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift index bf755dc..53e8b78 100644 --- a/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift +++ b/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift @@ -189,6 +189,14 @@ class OmniSettingsViewModel: ObservableObject { } } + /// Persistent advisory: this pod uses the InPlay BLE variant AND this iPhone model (iPhone 16 + /// family / iPhone 17e) is known to trigger its firmware bug — connections can stall and are + /// automatically retried, so slower-than-normal connects are expected. Shown as a standing + /// notice in settings (with a detail view), not a transient alert. + var connectionSlownessExpected: Bool { + return pumpManager.iPhoneWithPossibleInPlayIssues && pumpManager.usingInPlayPod == true + } + var isScheduledBasal: Bool { switch basalDeliveryState { case .active(_), .initiatingTempBasal: diff --git a/OmnipodKit/PumpManagerUI/Views/InPlayConnectionInfoView.swift b/OmnipodKit/PumpManagerUI/Views/InPlayConnectionInfoView.swift new file mode 100644 index 0000000..8670990 --- /dev/null +++ b/OmnipodKit/PumpManagerUI/Views/InPlayConnectionInfoView.swift @@ -0,0 +1,66 @@ +// +// InPlayConnectionInfoView.swift +// OmnipodKit +// +// Detail screen behind the persistent "slow connections expected" notice shown in pod +// settings when an InPlay-variant DASH pod is paired with an affected iPhone model +// (iPhone 16 family or iPhone 17e). See BluetoothManager's eager-connect watchdog. +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import UIKit +import LoopKitUI + +struct InPlayConnectionInfoView: View { + + var body: some View { + List { + Section { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .imageScale(.large) + Text(LocalizedString("Slower Connections Expected", comment: "Title on InPlay connection info view")) + .font(.headline) + } + Text(String(format: LocalizedString("Your pod uses an “InPlay” Bluetooth radio, and your phone (%1$@) is a model known to trigger a bug in that radio’s firmware. When it happens, the Bluetooth connection silently stalls while being established.", comment: "InPlay connection info: what is happening (1: iPhone model name)"), UIDevice.modelName)) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 8) + } + + Section(header: SectionHeader(label: LocalizedString("What to Expect", comment: "Section header on InPlay connection info view"))) { + VStack(alignment: .leading, spacing: 10) { + Text(LocalizedString("Connecting to the pod may sometimes take noticeably longer than usual — occasionally up to 30 seconds — while stalled attempts are detected and retried automatically. Commands still complete once the connection is made.", comment: "InPlay connection info: what to expect body 1")) + .fixedSize(horizontal: false, vertical: true) + Text(LocalizedString("Pairing a new pod may also need extra time or an additional attempt.", comment: "InPlay connection info: what to expect body 2")) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + } + + Section(header: SectionHeader(label: LocalizedString("What You Can Do", comment: "Section header on InPlay connection info view"))) { + VStack(alignment: .leading, spacing: 10) { + Text(LocalizedString("No action is needed — this is not a pod fault, and insulin delivery is not affected. The pod continues its programmed delivery even while disconnected.", comment: "InPlay connection info: guidance body 1")) + .fixedSize(horizontal: false, vertical: true) + Text(LocalizedString("Keeping your phone near the pod helps connections complete faster. Not every pod uses this radio — a future pod may connect normally.", comment: "InPlay connection info: guidance body 2")) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + } + } + .insetGroupedListStyle() + .navigationBarTitle(LocalizedString("Pod Connections", comment: "Navigation bar title for InPlay connection info view"), displayMode: .inline) + } +} + +struct InPlayConnectionInfoView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + InPlayConnectionInfoView() + } + } +} diff --git a/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift b/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift index 1220300..cb6bb21 100644 --- a/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift @@ -317,6 +317,27 @@ struct OmniSettingsView: View { } } + // Persistent advisory for InPlay-variant pods on affected iPhone models (iPhone 16 + // family / iPhone 17e): connection establishment can stall and is retried + // automatically, so slower-than-normal connects are expected. Tap for details. + if viewModel.connectionSlownessExpected { + Section { + NavigationLink(destination: InPlayConnectionInfoView()) { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + VStack(alignment: .leading, spacing: 2) { + Text(LocalizedString("Slower Connections Expected", comment: "Title of InPlay connection notice row")) + .font(Font.subheadline.weight(.semibold)) + Text(LocalizedString("This pod and phone combination can be slow to connect.", comment: "Subtitle of InPlay connection notice row")) + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + } + } + let lifeState = self.viewModel.lifeState Section(header: SectionHeader(label: LocalizedString("Actions", comment: "Section header for Actions section"))) { // If need to pair a pod, display this as the only action From 04f0376cc99b4573b51627744e7adfda372877c4 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 19 Aug 2026 14:18:05 -0500 Subject: [PATCH 04/21] Eager connect tuning from field data: 2s watchdog, 28s budget, dedupe racing connects - Watchdog 3s -> 2s: healthy connects are sub-second (ATT ~90-280ms after capture on-air; 0-1s app-level) and a cancel/retry cycle is ~1.3s, so 2s keeps ~2x margin while wasting ~1s less per wedge. A false trip costs ~1s. - Budget 18s -> 28s and connectOnDemand runCommand timeout 20s -> 30s: 13/35 locked- phone attempts exhausted 18s (~6 cycles) on 2026-08-19; 28s at the ~2.4s cycle gives ~11 attempts. - beginCommandConnect dedupe: racing sessions produced doubled command connects seconds apart; when the watchdog already owns an in-flight .connecting attempt, refresh its budget instead of issuing a duplicate connect. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 25 ++++++++++++++++---- OmnipodKit/Bluetooth/PeripheralManager.swift | 5 +++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 3b19390..2e8cdf3 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -239,7 +239,11 @@ class BluetoothManager: NSObject { /// How long to wait for didConnect before presuming a connect is wedged (~3-5x the measured healthy /// connect population of <1s). static var eagerConnectWatchdogSeconds: TimeInterval { - (UserDefaults.standard.object(forKey: "OmnipodKit.eagerConnectWatchdogSeconds") as? Double) ?? 3.0 + // 2s: healthy connects complete sub-second (ATT ~90-280ms after capture on-air; 0-1s + // app-level), and a post-cancel retry cycle is ~1.3s — so 2s is ~2x margin over the healthy + // population while wasting ~1s less per wedge than the original 3s. A false trip costs only + // ~1s (one extra cancel/retry); watch the fired-but-healthy telemetry to validate. + (UserDefaults.standard.object(forKey: "OmnipodKit.eagerConnectWatchdogSeconds") as? Double) ?? 2.0 } /// Pause after `cancelPeripheralConnection` before re-issuing `connect()`, to let the LL termination @@ -249,10 +253,12 @@ class BluetoothManager: NSObject { } /// Overall budget for the eager cancel/retry cycle on an on-demand command connect. Kept just under - /// the PeripheralManager `runCommand` `.connect` timeout (20s) so the watchdog owns the retries - /// underneath that single wait (which only clears on a real didConnect). + /// the PeripheralManager `runCommand` `.connect` timeout (30s) so the watchdog owns the retries + /// underneath that single wait (which only clears on a real didConnect). Field data (2026-08-19, + /// InPlay + iPhone 16 Pro): 13/35 attempts exhausted the original 18s (~6 cycles); the beep capture + /// survived 5 straight wedges with ~2.6s to spare. 28s at a ~2.4s cycle gives ~11 attempts. static var eagerConnectBudgetSeconds: TimeInterval { - (UserDefaults.standard.object(forKey: "OmnipodKit.eagerConnectBudgetSeconds") as? Double) ?? 18.0 + (UserDefaults.standard.object(forKey: "OmnipodKit.eagerConnectBudgetSeconds") as? Double) ?? 28.0 } /// Overall budget for the eager cancel/retry cycle during pairing discovery — longer than one @@ -933,6 +939,17 @@ class BluetoothManager: NSObject { // directly (skipping the fresh-discovery scan), with the watchdog cancelling and retrying any // wedged attempt within a bounded budget instead of a single blind wait. if shouldUseEagerConnect(for: peripheral) { + // Dedupe: two sessions racing (field logs show doubled command connects seconds apart) + // must not issue a second connect on top of a watchdog-managed one. If the watchdog + // already owns an in-flight connect attempt, just refresh its budget — re-arming bumps + // the generation token, superseding the old timer; the pending connect stays pending and + // didConnect satisfies every waiting session's .connect condition. + if isConnectWatchdogActive(peripheral), peripheral.state == .connecting { + log.default("[eager] command connect for %{public}@ — watchdog already managing an in-flight connect; refreshing budget", peripheral.identifier.uuidString) + connectionDelegate?.omnipodLogDeviceEvent("[eager] command connect — already in flight, refreshing budget") + armConnectWatchdog(peripheral, deadline: Date().addingTimeInterval(BluetoothManager.eagerConnectBudgetSeconds)) + return + } log.default("[eager] command connect for %{public}@", peripheral.identifier.uuidString) connectionDelegate?.omnipodLogDeviceEvent("[eager] command connect") eagerConnect(peripheral, deadline: Date().addingTimeInterval(BluetoothManager.eagerConnectBudgetSeconds)) diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index b3ba2fd..84799a7 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -135,7 +135,10 @@ extension PeripheralManager { // disconnect-then-wait stalls). If already connected (burst of sessions), no-op. if self.peripheral.state != .connected { do { - try self.connectOnDemand(timeout: 20) + // 30s: sized above BluetoothManager.eagerConnectBudgetSeconds (28s) so the + // eager watchdog's cancel/retry cycles own the recovery underneath this + // single wait, rather than this timeout firing first. + try self.connectOnDemand(timeout: 30) } catch let error { self.log.error("[connectOnDemand] on-demand connect failed: %{public}@", String(describing: error)) } From d993a1af5f9fe05c9d8da79c4afdd4619d7d6463 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 19 Aug 2026 14:39:57 -0500 Subject: [PATCH 05/21] Pairing: cancel zombie pending connects blocking rediscovered pairable pods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field failure (2026-08-19): after a pairing attempt was abandoned mid-watchdog-cycle, the last re-issued connect was left pending. Because the pairing scan hears a pod once per scan (no allowDuplicates), every subsequent discoverPods logged 'heard pod ... pairable=true state=1' (.connecting) and declined to connect — pairing could never succeed until the zombie cleared. In the didDiscover pairing branch: a pairable pod heard while .connecting with NO active watchdog cannot be in a live connection (we just heard it advertise) — cancel the stale connect and reconnect fresh (re-arming the watchdog) after teardown. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 2e8cdf3..f44fc63 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -1387,13 +1387,28 @@ extension BluetoothManager: CBCentralManagerDelegate { 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. If it's already mid-connect, - // stopping the scan lets that connect complete. + // 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). 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) { + // 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) + } } } else if autoConnectIDs.contains(peripheral.identifier.uuidString) && peripheral.state == .disconnected { log.debug("Reonnecting to autoconnect device") From 66dfba76c4a1dcc3940d2d083c86aa07cbcb9485 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 19 Aug 2026 17:10:33 -0500 Subject: [PATCH 06/21] Deepen eager-connect budget to 40s (ceiling 45s) for high-wedge-rate pods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field data shows per-attempt wedge probability is per-pod: ~55% on one InPlay pod, ~84% on another (sniffer-confirmed: every watchdog retry recaptures on-air within ~0.7-1.6s and real wedges follow — the 2s watchdog is not clipping reacquisition). At 84%, the 28s budget (~12 cycles) measured ~10% command failure, all budget exhaustions at wd=12. 40s (~17 cycles) predicts ~5%; failures self-heal next loop cycle. Pairing budget raised to match. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 11 ++++++----- OmnipodKit/Bluetooth/PeripheralManager.swift | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index f44fc63..dc06010 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -253,18 +253,19 @@ class BluetoothManager: NSObject { } /// Overall budget for the eager cancel/retry cycle on an on-demand command connect. Kept just under - /// the PeripheralManager `runCommand` `.connect` timeout (30s) so the watchdog owns the retries + /// the PeripheralManager `runCommand` `.connect` timeout (45s) so the watchdog owns the retries /// underneath that single wait (which only clears on a real didConnect). Field data (2026-08-19, - /// InPlay + iPhone 16 Pro): 13/35 attempts exhausted the original 18s (~6 cycles); the beep capture - /// survived 5 straight wedges with ~2.6s to spare. 28s at a ~2.4s cycle gives ~11 attempts. + /// InPlay + iPhone 16 Pro): per-attempt wedge probability is PER-POD (~55% and ~84% observed on two + /// pods). At 84%, 28s (~12 cycles) measured ~10% command failure (all budget exhaustions); 40s + /// (~17 cycles at ~2.3s) predicts ~5%. Failures self-heal on the next loop cycle. static var eagerConnectBudgetSeconds: TimeInterval { - (UserDefaults.standard.object(forKey: "OmnipodKit.eagerConnectBudgetSeconds") as? Double) ?? 28.0 + (UserDefaults.standard.object(forKey: "OmnipodKit.eagerConnectBudgetSeconds") as? Double) ?? 40.0 } /// Overall budget for the eager cancel/retry cycle during pairing discovery — longer than one /// wedge-cycle so a wedged first attempt doesn't consume the whole pairing window. static var eagerPairingBudgetSeconds: TimeInterval { - (UserDefaults.standard.object(forKey: "OmnipodKit.eagerPairingBudgetSeconds") as? Double) ?? 28.0 + (UserDefaults.standard.object(forKey: "OmnipodKit.eagerPairingBudgetSeconds") as? Double) ?? 40.0 } /// CoreBluetooth peripheral (advertised local) name of the affected InPlay-firmware DASH pod variant. diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index 84799a7..0ec3237 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -135,10 +135,10 @@ extension PeripheralManager { // disconnect-then-wait stalls). If already connected (burst of sessions), no-op. if self.peripheral.state != .connected { do { - // 30s: sized above BluetoothManager.eagerConnectBudgetSeconds (28s) so the + // 45s: sized above BluetoothManager.eagerConnectBudgetSeconds (40s) so the // eager watchdog's cancel/retry cycles own the recovery underneath this // single wait, rather than this timeout firing first. - try self.connectOnDemand(timeout: 30) + try self.connectOnDemand(timeout: 45) } catch let error { self.log.error("[connectOnDemand] on-demand connect failed: %{public}@", String(describing: error)) } From 38fd0d638c0551327031e002d38561834c2b26af Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 20 Aug 2026 01:58:04 -0500 Subject: [PATCH 07/21] Consolidate InPlay/affected-iPhone detection into shared definitions Review feedback (itsmojo): the eager-connect gate duplicated existing detection. - UIDevice.hasPossibleInPlayBLEIssues (Common/UIDevice.swift) is now the single affected-model predicate (iPhone 16 family + iPhone 17e), built on the existing UIDevice.modelName mapping; BluetoothManager's parallel hw.machine-based deviceModelIdentifier/isEagerConnectDeviceModel are removed and both the eager gate and OmniPumpManager.iPhoneWithPossibleInPlayIssues use the shared predicate. - OmniPumpManager.usingInPlayPod now matches BluetoothManager.inPlayPeripheralName instead of a second hardcoded string. Placed in Common so the Bluetooth layer doesn't depend on the pump manager. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 19 ++----------------- OmnipodKit/Common/UIDevice.swift | 10 ++++++++++ OmnipodKit/PumpManager/OmniPumpManager.swift | 11 +++-------- 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index dc06010..aa950fc 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -269,24 +269,9 @@ class BluetoothManager: NSObject { } /// CoreBluetooth peripheral (advertised local) name of the affected InPlay-firmware DASH pod variant. + /// (OmniPumpManager's `usingInPlayPod` matches against this same constant.) static let inPlayPeripheralName = "InPlay BLE" - /// hw.machine identifier of this device, e.g. "iPhone17,1". Computed once. - private static let deviceModelIdentifier: String = { - var sys = utsname() - uname(&sys) - return withUnsafeBytes(of: &sys.machine) { raw in - String(cString: raw.baseAddress!.assumingMemoryBound(to: CChar.self)) - } - }() - - /// True on the iPhone models that exhibit the LL deadlock: the iPhone 16 family (all variants, - /// incl. 16e) = `iPhone17,x`, and the iPhone 17e specifically = `iPhone18,5`. Deliberately NOT the - /// rest of the iPhone 17 family (`iPhone18,1`-`18,4`) — those controllers are not affected. - static var isEagerConnectDeviceModel: Bool { - deviceModelIdentifier.hasPrefix("iPhone17,") || deviceModelIdentifier == "iPhone18,5" - } - /// Fallback start delay (seconds) for the delayed-connect probe when Loop hasn't supplied a heartbeat /// schedule (no `heartbeatTargetDate`). Normally the delay is computed from the CGM reading schedule — @@ -834,7 +819,7 @@ class BluetoothManager: NSObject { /// and is not "InPlay BLE" opts out. func shouldUseEagerConnect(for peripheral: CBPeripheral) -> Bool { guard BluetoothManager.eagerConnectEnabled else { return false } - guard BluetoothManager.eagerConnectForceAllDevices || BluetoothManager.isEagerConnectDeviceModel else { return false } + guard BluetoothManager.eagerConnectForceAllDevices || UIDevice.hasPossibleInPlayBLEIssues else { return false } if let name = peripheral.name, !name.isEmpty { return name == BluetoothManager.inPlayPeripheralName } diff --git a/OmnipodKit/Common/UIDevice.swift b/OmnipodKit/Common/UIDevice.swift index 24f14c1..446e908 100644 --- a/OmnipodKit/Common/UIDevice.swift +++ b/OmnipodKit/Common/UIDevice.swift @@ -130,5 +130,15 @@ public extension UIDevice { return mapToDevice(identifier: identifier) }() + /// True on the iPhone models known to trigger the InPlay BLE DASH pod firmware bug (the pod + /// silently ignores LL_CONNECTION_PARAM_REQ, wedging connection establishment): the iPhone 16 + /// family (all variants, incl. 16e) and the iPhone 17e specifically. Deliberately NOT the rest + /// of the iPhone 17 family, which is unaffected. Single source of truth — used by both the + /// BluetoothManager eager-connect gate and OmniPumpManager's pod keep-alive/UI advisories. + static var hasPossibleInPlayBLEIssues: Bool { + let model = UIDevice.modelName + return model.contains("iPhone 16") || model == "iPhone 17e" + } + } diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 0741e45..39493c1 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -2209,21 +2209,16 @@ extension OmniPumpManager { // Running on any iPhone 16 or an iPhone 17e which are known // to have BLE reconnect issues with InPlay BLE DASH pods? + // (Shared definition — also gates BluetoothManager's eager-connect watchdog.) var iPhoneWithPossibleInPlayIssues: Bool { - - let iPhoneModel = UIDevice.modelName - if iPhoneModel.contains("iPhone 16") || iPhoneModel == "iPhone 17e" { - return true - } - - return false + return UIDevice.hasPossibleInPlayBLEIssues } // Using an InPlay BLE pod? var usingInPlayPod: Bool? { if let blePodComms = podComms as? BlePodComms, let deviceBLEName = blePodComms.manager?.peripheral.name { - return deviceBLEName == "InPlay BLE" + return deviceBLEName == BluetoothManager.inPlayPeripheralName } return nil // don't know -- maybe not paired yet } From 45fcca26ea1e3fcc78b436a0ca5b9276cb81e887 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 20 Aug 2026 12:03:00 -0500 Subject: [PATCH 08/21] Experiment: CBConnectPeripheralOptionEnableAutoReconnect on eager connects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass EnableAutoReconnect (iOS 17+, tunable OmnipodKit.eagerAutoReconnectEnabled, default on) on eager direct connects and watchdog re-issues, to probe whether it changes the stack's reacquisition behavior on wedge-prone InPlay pods. Adopt centralManager(_:didDisconnectPeripheral:timestamp:isReconnecting:error:) (called instead of the classic callback when implemented): both delegate methods route into a shared handleDisconnect. isReconnecting=true logs distinct [autoReconnect] telemetry (with drop age) and defers entirely to the system's re-establishment — no app-side reconnect or probe re-arm; didConnect completes it. The eager watchdog stays armed as a bounded supervisor; its cancel also cancels a pending system auto-reconnect before re-issuing a supervised connect. Explicit cancels (idle-disconnect) cancel auto-reconnect, preserving the normally- disconnected model. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 55 +++++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index aa950fc..81f533b 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -268,6 +268,25 @@ class BluetoothManager: NSObject { (UserDefaults.standard.object(forKey: "OmnipodKit.eagerPairingBudgetSeconds") as? Double) ?? 40.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 + /// centralManager(_:didDisconnectPeripheral:timestamp:isReconnecting:error:) with + /// isReconnecting=true (we then defer to the system; didConnect fires on re-establishment). An + /// explicit cancelPeripheralConnection (idle-disconnect, watchdog) still cancels any pending + /// auto-reconnect, so the normally-disconnected model is unaffected. + static var eagerAutoReconnectEnabled: Bool { + UserDefaults.standard.object(forKey: "OmnipodKit.eagerAutoReconnectEnabled") as? Bool ?? true + } + + /// Connect options for eager connects (auto-reconnect experiment when enabled and available). + private var eagerConnectOptions: [String: Any]? { + if #available(iOS 17.0, *), BluetoothManager.eagerAutoReconnectEnabled { + return [CBConnectPeripheralOptionEnableAutoReconnect: true] + } + return nil + } + /// CoreBluetooth peripheral (advertised local) name of the affected InPlay-firmware DASH pod variant. /// (OmniPumpManager's `usingInPlayPod` matches against this same constant.) static let inPlayPeripheralName = "InPlay BLE" @@ -838,7 +857,7 @@ class BluetoothManager: NSObject { if manager.isScanning { manager.stopScan() } // a concurrent scan starves connection completion on iOS log.default("[eager] direct connect for %{public}@ (name=%{public}@)", target.identifier.uuidString, target.name ?? "?") connectionDelegate?.omnipodLogDeviceEvent("[eager] direct connect (name=\(target.name ?? "?"))") - manager.connect(target, options: nil) + manager.connect(target, options: eagerConnectOptions) armConnectWatchdog(target, deadline: deadline) } @@ -877,7 +896,7 @@ class BluetoothManager: NSObject { } self.log.default("[eager] re-issuing connect for %{public}@ after teardown", id) self.connectionDelegate?.omnipodLogDeviceEvent("[eager] re-issue connect") - self.manager.connect(retryTarget, options: nil) + self.manager.connect(retryTarget, options: self.eagerConnectOptions) self.armConnectWatchdog(retryTarget, deadline: deadline) } } @@ -1478,10 +1497,29 @@ extension BluetoothManager: CBCentralManagerDelegate { } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + handleDisconnect(central, peripheral: peripheral, error: error, isReconnecting: false) + } + + /// iOS 17+ variant: when implemented, CoreBluetooth calls this INSTEAD of the classic + /// didDisconnectPeripheral for all disconnects. `isReconnecting == true` means the connect was made + /// with CBConnectPeripheralOptionEnableAutoReconnect and the SYSTEM is re-establishing the link + /// itself (didConnect will fire again on success) — so we log it distinctly and skip our own + /// reconnection machinery for that case. + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, timestamp: CFAbsoluteTime, isReconnecting: Bool, error: Error?) { + let age = CFAbsoluteTimeGetCurrent() - timestamp + log.default("[autoReconnect] didDisconnect(timestamp:isReconnecting:) isReconnecting=%{public}@ eventAge=%{public}.3fs error=%{public}@", + String(describing: isReconnecting), age, String(describing: error)) + if isReconnecting { + connectionDelegate?.omnipodLogDeviceEvent("[autoReconnect] system auto-reconnecting (drop \(String(format: "%.1f", age))s ago, error=\(error.map { String(describing: $0) } ?? "nil"))") + } + handleDisconnect(central, peripheral: peripheral, error: error, isReconnecting: isReconnecting) + } + + private func handleDisconnect(_ central: CBCentralManager, peripheral: CBPeripheral, error: Error?, isReconnecting: Bool) { dispatchPrecondition(condition: .onQueue(managerQueue)) - log.default("[#%{public}@] DISCONNECTED: %{public}@ error=%{public}@ willReconnect=%{public}@", instanceID, peripheral, - String(describing: error), String(describing: autoConnectIDs.contains(peripheral.identifier.uuidString))) + log.default("[#%{public}@] DISCONNECTED: %{public}@ error=%{public}@ willReconnect=%{public}@ systemReconnecting=%{public}@", instanceID, peripheral, + String(describing: error), String(describing: autoConnectIDs.contains(peripheral.identifier.uuidString)), String(describing: isReconnecting)) // Proxy disconnection events to peripheral manager for device in devices where device.manager.peripheral.identifier == peripheral.identifier { @@ -1490,6 +1528,15 @@ extension BluetoothManager: CBCentralManagerDelegate { connectionDelegate?.omnipodPeripheralDidDisconnect(peripheral: peripheral, error: error) + // The system is auto-reconnecting this link itself (EnableAutoReconnect experiment): defer to + // it — no app-side reconnect, no probe re-arm; didConnect fires when it re-establishes. The + // eager watchdog (if active) stays armed as a bounded supervisor: its cancel would also cancel + // the system's auto-reconnect before re-issuing a supervised connect. + if isReconnecting { + delayedProbeInFlight = false + return + } + // The eager watchdog owns this connect's cancel/retry cycle — its own cancelPeripheralConnection // produced THIS callback. Do not independently reconnect (that would race the watchdog's retry, // reviving the old cancel↔"reconnecting after drop" loop); the watchdog re-issues after teardown. From 84787e9b5653df08ba427892f8c43cec01381e41 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 20 Aug 2026 14:19:43 -0500 Subject: [PATCH 09/21] Remove iPhoneWithPossibleInPlayIssues forwarding var (review feedback) Use UIDevice.hasPossibleInPlayBLEIssues directly at both former call sites (pod keep-alive defaulting during pairing, settings advisory view model) and update the predicate's comment accordingly. --- OmnipodKit/Common/UIDevice.swift | 4 ++-- OmnipodKit/PumpManager/OmniPumpManager.swift | 9 +-------- .../PumpManagerUI/ViewModels/OmniSettingsViewModel.swift | 2 +- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/OmnipodKit/Common/UIDevice.swift b/OmnipodKit/Common/UIDevice.swift index 446e908..fd81b48 100644 --- a/OmnipodKit/Common/UIDevice.swift +++ b/OmnipodKit/Common/UIDevice.swift @@ -133,8 +133,8 @@ public extension UIDevice { /// True on the iPhone models known to trigger the InPlay BLE DASH pod firmware bug (the pod /// silently ignores LL_CONNECTION_PARAM_REQ, wedging connection establishment): the iPhone 16 /// family (all variants, incl. 16e) and the iPhone 17e specifically. Deliberately NOT the rest - /// of the iPhone 17 family, which is unaffected. Single source of truth — used by both the - /// BluetoothManager eager-connect gate and OmniPumpManager's pod keep-alive/UI advisories. + /// of the iPhone 17 family, which is unaffected. Single source of truth for all affected-model + /// checks (eager-connect gate, pod keep-alive defaulting, settings advisory). static var hasPossibleInPlayBLEIssues: Bool { let model = UIDevice.modelName return model.contains("iPhone 16") || model == "iPhone 17e" diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 39493c1..9e9cae3 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -1354,7 +1354,7 @@ extension OmniPumpManager { // Have new podState, reset all the per pod pump manager state self.resetPerPodPumpManagerState() - if self.usingInPlayPod == true && self.iPhoneWithPossibleInPlayIssues { + if self.usingInPlayPod == true && UIDevice.hasPossibleInPlayBLEIssues { if Storage.shared.podKeepAlive.value == .disabled { // Enable the most conservative pod keep alive mode // that should work through the for pod setup process. @@ -2207,13 +2207,6 @@ extension OmniPumpManager { } } - // Running on any iPhone 16 or an iPhone 17e which are known - // to have BLE reconnect issues with InPlay BLE DASH pods? - // (Shared definition — also gates BluetoothManager's eager-connect watchdog.) - var iPhoneWithPossibleInPlayIssues: Bool { - return UIDevice.hasPossibleInPlayBLEIssues - } - // Using an InPlay BLE pod? var usingInPlayPod: Bool? { diff --git a/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift b/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift index 53e8b78..0d099aa 100644 --- a/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift +++ b/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift @@ -194,7 +194,7 @@ class OmniSettingsViewModel: ObservableObject { /// automatically retried, so slower-than-normal connects are expected. Shown as a standing /// notice in settings (with a detail view), not a transient alert. var connectionSlownessExpected: Bool { - return pumpManager.iPhoneWithPossibleInPlayIssues && pumpManager.usingInPlayPod == true + return UIDevice.hasPossibleInPlayBLEIssues && pumpManager.usingInPlayPod == true } var isScheduledBasal: Bool { From e6b83507f6f8a0a9bd378038e3feb496eefacc94 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 20 Aug 2026 14:55:49 -0500 Subject: [PATCH 10/21] Hold connections longer on eager-gated pods (eagerIdleDisconnectSeconds, 60s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field data: with the 4s idle-disconnect, one loop cycle's status→compute→dose burst (sessions ~10-25s apart) paid 2-3 wedge storms — the idle window discarded a working connection seconds before the next session needed it (observed: instant connect, temp basal, idle-disconnect at +4s, then a 19s storm 2s later for the next session). On eager-gated pods a reconnect risks a median ~10s / worst ~30s storm, so use a 60s idle window (tunable OmnipodKit.eagerIdleDisconnectSeconds) so the whole cycle shares one connection. The cycle still ends disconnected, so the background heartbeat probe re-arms normally. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 9 +++++++++ OmnipodKit/Bluetooth/PeripheralManager.swift | 12 +++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 81f533b..a088add 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -329,6 +329,15 @@ class BluetoothManager: NSObject { (UserDefaults.standard.object(forKey: "OmnipodKit.idleDisconnectSeconds") as? Double) ?? 4 } + /// Idle-disconnect delay for eager-gated pods (InPlay + affected iPhone), where every reconnect + /// risks a wedge storm (median ~10s, worst ~30s+ measured). Long enough that one loop cycle's + /// status→compute→dose burst (sessions ~10-25s apart) shares a single connection — trading a + /// little extra connection time for 1 storm per cycle instead of 2-3. The pod still ends each + /// cycle disconnected, so the background heartbeat probe re-arms normally. + static var eagerIdleDisconnectSeconds: TimeInterval { + (UserDefaults.standard.object(forKey: "OmnipodKit.eagerIdleDisconnectSeconds") as? Double) ?? 60 + } + /// Candidate DASH alarm-state service UUIDs to filter on in low-power mode. /// - `C005`: CONFIRMED 16-bit alarm 2nd-UUID on this pod (expiration reminder). Extend as more /// alert/alarm types are captured. diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index 0ec3237..69aa489 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -664,7 +664,17 @@ extension PeripheralManager { /// hold the connection separately via `shouldHoldConnection`, so this delay only bites while backgrounded.) private func scheduleIdleDisconnectIfNeeded() { guard BluetoothManager.connectOnDemandEnabled else { return } - let idleDelay: TimeInterval = BluetoothManager.idleDisconnectSeconds + // Eager-gated pods (InPlay + affected iPhone): reconnecting costs a wedge storm (median ~10s, + // worst ~30s+), so a working connection is precious. Use a much longer idle window so one loop + // cycle's status→compute→dose burst (sessions ~10-25s apart) shares a single connection instead + // of paying 2-3 storms per cycle. The cycle still ends disconnected — the heartbeat probe + // re-arms ~a minute after the last command, well before the next CGM reading. + let idleDelay: TimeInterval + if bluetoothManager?.shouldUseEagerConnect(for: peripheral) == true { + idleDelay = BluetoothManager.eagerIdleDisconnectSeconds + } else { + idleDelay = BluetoothManager.idleDisconnectSeconds + } let idleAt = idleStart queue.asyncAfter(deadline: .now() + idleDelay) { [weak self] in guard let self = self, BluetoothManager.connectOnDemandEnabled else { return } From 36a47c66dd39c837715129eac3983d8bea5b37e4 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 20 Aug 2026 15:11:06 -0500 Subject: [PATCH 11/21] Raise eager idle-disconnect to 240s to span the inter-cycle gap The 60s window hung up ~1 min before the next ~3-min loop cycle every time, paying a wedge storm per cycle anyway. 240s holds the connection across cycles while looping; if cycles stop, the pod still disconnects and the heartbeat probe re-arms. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index a088add..b7666ed 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -330,12 +330,14 @@ class BluetoothManager: NSObject { } /// Idle-disconnect delay for eager-gated pods (InPlay + affected iPhone), where every reconnect - /// risks a wedge storm (median ~10s, worst ~30s+ measured). Long enough that one loop cycle's - /// status→compute→dose burst (sessions ~10-25s apart) shares a single connection — trading a - /// little extra connection time for 1 storm per cycle instead of 2-3. The pod still ends each - /// cycle disconnected, so the background heartbeat probe re-arms normally. + /// risks a wedge storm (median ~10s, worst ~30s+ measured). Sized ABOVE the observed inter-cycle + /// command cadence (~3 min), so the connection is effectively held continuously while looping and + /// each cycle's first command lands on a live link (a 60s window measured on 2026-08-20 hung up + /// ~1 min before the next cycle every time — paying a storm per cycle anyway). If cycles stop + /// (CGM gap, app suspended), the pod still disconnects at this deadline and the background + /// heartbeat probe re-arms as designed. static var eagerIdleDisconnectSeconds: TimeInterval { - (UserDefaults.standard.object(forKey: "OmnipodKit.eagerIdleDisconnectSeconds") as? Double) ?? 60 + (UserDefaults.standard.object(forKey: "OmnipodKit.eagerIdleDisconnectSeconds") as? Double) ?? 240 } /// Candidate DASH alarm-state service UUIDs to filter on in low-power mode. From a3e7bab28683e49333834a764da7e9d32f4045b1 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 20 Aug 2026 15:25:56 -0500 Subject: [PATCH 12/21] Eager idle-disconnect: hold-while-looping (3600s default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle cadence varies 3-5 min; 60s and 240s windows both repeatedly hung up under a minute before the next cycle, paying a wedge storm each time (pcap: every on-air terminate is iPhone-initiated — the pod never disconnects us). Hold the link as long as any command lands within the hour; a true idle hour still releases the pod. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index b7666ed..9cf75e3 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -336,8 +336,13 @@ class BluetoothManager: NSObject { /// ~1 min before the next cycle every time — paying a storm per cycle anyway). If cycles stop /// (CGM gap, app suspended), the pod still disconnects at this deadline and the background /// heartbeat probe re-arms as designed. + /// + /// Default 3600 (hold-while-looping): field data (2026-08-20) showed cycle cadence varies 3-5 min, + /// and both 60s and 240s windows repeatedly hung up <1 min before the next cycle — paying a wedge + /// storm each time for nothing. Every command resets the timer, so any activity within the hour + /// keeps the link; a true idle hour still releases the pod (advertising/probe/fault-scan resume). static var eagerIdleDisconnectSeconds: TimeInterval { - (UserDefaults.standard.object(forKey: "OmnipodKit.eagerIdleDisconnectSeconds") as? Double) ?? 240 + (UserDefaults.standard.object(forKey: "OmnipodKit.eagerIdleDisconnectSeconds") as? Double) ?? 3600 } /// Candidate DASH alarm-state service UUIDs to filter on in low-power mode. From 41fecf7c2503a2e212ab65cec9af0c1a60478701 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 20 Aug 2026 15:45:38 -0500 Subject: [PATCH 13/21] Log system auto-reconnect re-establishment latency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track peripherals with isReconnecting=true pending and log '[autoReconnect] link re-established by system after Xs' when didConnect completes it — the key observable for the EnableAutoReconnect experiment (pod-side inactivity disconnects with Pod Keep Alive disabled). --- OmnipodKit/Bluetooth/BluetoothManager.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 9cf75e3..ec1eb46 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -838,6 +838,11 @@ class BluetoothManager: NSObject { /// watchdog tick no-ops (same token pattern as `pendingFreshConnectID`). private var connectWatchdogGeneration: [String: Int] = [:] + /// Peripheral ids with a system auto-reconnect in progress (didDisconnect reported + /// isReconnecting=true), keyed to when we learned of it — used to measure and log the + /// re-establishment latency when didConnect completes it. + private var autoReconnectPendingSince: [String: Date] = [:] + /// Peripheral ids currently under active watchdog management. While present, `didDisconnect` and /// `didFailToConnect` must NOT independently reconnect (the watchdog's cancel fires those callbacks /// and the watchdog itself owns the cancel/retry cycle — otherwise the handlers race it). @@ -1462,6 +1467,14 @@ extension BluetoothManager: CBCentralManagerDelegate { // A completed connect satisfies the eager watchdog — invalidate any pending cancel/retry tick. disarmConnectWatchdog(peripheral) + // If this connect completes a system auto-reconnect (EnableAutoReconnect experiment), log the + // measured re-establishment latency — the key observable for the experiment. + if let since = autoReconnectPendingSince.removeValue(forKey: peripheral.identifier.uuidString) { + let latency = Date().timeIntervalSince(since) + log.default("[autoReconnect] link RE-ESTABLISHED by system after %{public}.1fs for %{public}@", latency, peripheral.identifier.uuidString) + connectionDelegate?.omnipodLogDeviceEvent("[autoReconnect] link re-established by system after \(String(format: "%.1f", latency))s") + } + // Connected — stop the connect-helper scan (connectOnDemand started a light scan to speed the // connect). We don't scan while connected; the monitor scan is restored on the next disconnect. if manager.isScanning { @@ -1549,6 +1562,7 @@ extension BluetoothManager: CBCentralManagerDelegate { // eager watchdog (if active) stays armed as a bounded supervisor: its cancel would also cancel // the system's auto-reconnect before re-issuing a supervised connect. if isReconnecting { + autoReconnectPendingSince[peripheral.identifier.uuidString] = Date() delayedProbeInFlight = false return } From e523688ab9c2811f48b1febc72351062eb9b1e1e Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 21 Aug 2026 16:39:47 -0500 Subject: [PATCH 14/21] Log every iOS-17 didDisconnect(timestamp:isReconnecting:) invocation Previously only isReconnecting==true reached the device log, so an Issue Report showing no [autoReconnect] events was ambiguous between 'iOS never called the new signature' and 'called with isReconnecting=false'. Log both cases. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index ec1eb46..dffd837 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -1538,8 +1538,14 @@ extension BluetoothManager: CBCentralManagerDelegate { let age = CFAbsoluteTimeGetCurrent() - timestamp log.default("[autoReconnect] didDisconnect(timestamp:isReconnecting:) isReconnecting=%{public}@ eventAge=%{public}.3fs error=%{public}@", String(describing: isReconnecting), age, String(describing: error)) + // Log EVERY invocation to the device log (not just isReconnecting==true), so an Issue Report + // proves whether iOS is calling this iOS-17+ signature at all — otherwise "no [autoReconnect] + // events" is ambiguous between "never called" and "called with isReconnecting=false". + let errStr = error.map { String(describing: $0) } ?? "nil" if isReconnecting { - connectionDelegate?.omnipodLogDeviceEvent("[autoReconnect] system auto-reconnecting (drop \(String(format: "%.1f", age))s ago, error=\(error.map { String(describing: $0) } ?? "nil"))") + connectionDelegate?.omnipodLogDeviceEvent("[autoReconnect] system auto-reconnecting (drop \(String(format: "%.1f", age))s ago, error=\(errStr))") + } else { + connectionDelegate?.omnipodLogDeviceEvent("[autoReconnect] didDisconnect isReconnecting=false (eventAge \(String(format: "%.1f", age))s, error=\(errStr))") } handleDisconnect(central, peripheral: peripheral, error: error, isReconnecting: isReconnecting) } From 8a641e5831d28be4e0bb3ebdaf2b1385df2725a7 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 22 Aug 2026 10:19:58 -0500 Subject: [PATCH 15/21] Foreground: connect aggressively and measure time-to-connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user may want to bolus the moment the app opens, so foreground connects get: - A shorter watchdog interval (eagerConnectForegroundWatchdogSeconds, 1.5s) — a wedge can't be waited out and a retry cycle costs ~1.3s, so retrying harder strictly reduces time-to-connect. - Action on a .connecting peripheral, not just .disconnected: previously foregrounding did nothing while a slow reacquisition or wedge was in flight (the exact 'user opens app and waits' case). Now re-arm the watchdog on the foreground interval, or cancel an unsupervised in-flight connect and restart the eager cycle. - Telemetry '[foreground] connected Xs after foregrounding' — the user-visible metric for how long the app couldn't talk to the pod after being opened. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 60 ++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index dffd837..ea6bd4a 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -238,6 +238,13 @@ class BluetoothManager: NSObject { /// How long to wait for didConnect before presuming a connect is wedged (~3-5x the measured healthy /// connect population of <1s). + /// Watchdog interval while the app is FOREGROUND. The user may be waiting to bolus, so retry + /// harder: a wedge is unrecoverable by waiting and a cancel/retry cycle costs ~1.3s, so a shorter + /// deadline strictly reduces time-to-connect at the cost of a few extra (cheap) retries. + static var eagerConnectForegroundWatchdogSeconds: TimeInterval { + (UserDefaults.standard.object(forKey: "OmnipodKit.eagerConnectForegroundWatchdogSeconds") as? Double) ?? 1.5 + } + static var eagerConnectWatchdogSeconds: TimeInterval { // 2s: healthy connects complete sub-second (ATT ~90-280ms after capture on-air; 0-1s // app-level), and a post-cancel retry cycle is ~1.3s — so 2s is ~2x margin over the healthy @@ -838,6 +845,10 @@ class BluetoothManager: NSObject { /// watchdog tick no-ops (same token pattern as `pendingFreshConnectID`). private var connectWatchdogGeneration: [String: Int] = [:] + /// When we began waiting for a connection with the app foregrounded (per peripheral id), for the + /// user-visible foreground time-to-connect metric. + private var foregroundConnectWaitSince: [String: Date] = [:] + /// Peripheral ids with a system auto-reconnect in progress (didDisconnect reported /// isReconnecting=true), keyed to when we learned of it — used to measure and log the /// re-establishment latency when didConnect completes it. @@ -893,7 +904,9 @@ class BluetoothManager: NSObject { let generation = (connectWatchdogGeneration[id] ?? 0) + 1 connectWatchdogGeneration[id] = generation connectWatchdogActive.insert(id) - managerQueue.asyncAfter(deadline: .now() + BluetoothManager.eagerConnectWatchdogSeconds) { [weak self] in + let interval = isAppForeground ? BluetoothManager.eagerConnectForegroundWatchdogSeconds + : BluetoothManager.eagerConnectWatchdogSeconds + managerQueue.asyncAfter(deadline: .now() + interval) { [weak self] in guard let self = self, self.connectWatchdogGeneration[id] == generation else { return } // stale / disarmed let target = self.manager.retrievePeripherals(withIdentifiers: [peripheral.identifier]).first ?? peripheral guard target.state != .connected else { self.disarmConnectWatchdog(target); return } @@ -1003,10 +1016,45 @@ class BluetoothManager: NSObject { private func enterForeground() { dispatchPrecondition(condition: .onQueue(managerQueue)) isAppForeground = true - if let peripheral = keepAlivePeripheral, peripheral.state == .disconnected { + guard let peripheral = keepAlivePeripheral else { return } + switch peripheral.state { + case .connected, .disconnecting: + return + case .disconnected: log.default("[connectOnDemand] foreground — pre-connecting for keep-alive") connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] foreground — pre-connecting for keep-alive") + noteForegroundConnectWait(peripheral) beginCommandConnect(peripheral) + case .connecting: + // The user is looking at the app and may want to bolus NOW. A `.connecting` peripheral here + // is either a slow system reacquisition or a wedge — either way, waiting it out is the worst + // option. If nothing is supervising it, cancel and restart the eager cycle immediately; if + // the watchdog already owns it, re-arm so it runs on the (shorter) foreground interval. + noteForegroundConnectWait(peripheral) + if isConnectWatchdogActive(peripheral) { + log.default("[foreground] connect in flight under watchdog — re-arming on foreground interval") + connectionDelegate?.omnipodLogDeviceEvent("[foreground] re-arming watchdog on foreground interval") + armConnectWatchdog(peripheral, deadline: Date().addingTimeInterval(BluetoothManager.eagerConnectBudgetSeconds)) + } else if shouldUseEagerConnect(for: peripheral) { + log.default("[foreground] unsupervised connect in flight — cancelling and reconnecting eagerly") + connectionDelegate?.omnipodLogDeviceEvent("[foreground] cancelling stale in-flight connect, reconnecting eagerly") + manager.cancelPeripheralConnection(peripheral) + managerQueue.asyncAfter(deadline: .now() + BluetoothManager.eagerConnectTeardownSeconds) { [weak self] in + guard let self = self, self.isAppForeground, peripheral.state != .connected else { return } + self.beginCommandConnect(peripheral) + } + } + @unknown default: + return + } + } + + /// Stamp the moment we started waiting for a connection with the app in the foreground, so + /// `didConnect` can report the user-visible "how long until the app could talk to the pod" latency. + private func noteForegroundConnectWait(_ peripheral: CBPeripheral) { + let id = peripheral.identifier.uuidString + if foregroundConnectWaitSince[id] == nil { + foregroundConnectWaitSince[id] = Date() } } @@ -1467,6 +1515,14 @@ extension BluetoothManager: CBCentralManagerDelegate { // A completed connect satisfies the eager watchdog — invalidate any pending cancel/retry tick. disarmConnectWatchdog(peripheral) + // Foreground time-to-connect: the user-facing number (how long after opening the app before we + // could talk to the pod). + if let since = foregroundConnectWaitSince.removeValue(forKey: peripheral.identifier.uuidString) { + let latency = Date().timeIntervalSince(since) + log.default("[foreground] connected %{public}.1fs after foreground wait began", latency) + connectionDelegate?.omnipodLogDeviceEvent("[foreground] connected \(String(format: "%.1f", latency))s after foregrounding") + } + // If this connect completes a system auto-reconnect (EnableAutoReconnect experiment), log the // measured re-establishment latency — the key observable for the experiment. if let since = autoReconnectPendingSince.removeValue(forKey: peripheral.identifier.uuidString) { From 69d1e1196624b32863df0992e306f73edaf48aa4 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 22 Aug 2026 22:41:01 -0500 Subject: [PATCH 16/21] Eager pods: hold link in background via auto-reconnect, eager-connect in foreground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field data showed we tore down a working connection on EVERY backgrounding (69 of 73 transitions in one 6h report) and then paid a wedge storm to get it back — on a pod that releases the link itself after ~180s anyway. Split the strategy by app state for eager-gated pods: - BACKGROUND: never tear down (shouldHoldConnection now true for these pods), and reconnect drops with a standing connect carrying EnableAutoReconnect, so the system restores the link (~27s median measured) without app CPU while suspended. - FOREGROUND: no auto-reconnect — the user may be waiting to bolus and the system's silent reacquisition is far slower than the eager cancel/retry cycle (~1.3s). Any drop while foregrounded reconnects eagerly with the fast watchdog. Non-eager pairings are unchanged. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index ea6bd4a..9b4fe77 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -288,6 +288,12 @@ class BluetoothManager: NSObject { /// Connect options for eager connects (auto-reconnect experiment when enabled and available). private var eagerConnectOptions: [String: Any]? { + // BACKGROUND: ask the system to keep the link up for us — it can re-establish while the app is + // suspended, which no app-side timer can do. + // FOREGROUND: do NOT use auto-reconnect. The user may be waiting to bolus, and the system's + // silent reacquisition (~27s median measured) is far slower than our eager cancel/retry cycle + // (~1.3s); an armed auto-reconnect would just compete with the watchdog. + if isAppForeground { return nil } if #available(iOS 17.0, *), BluetoothManager.eagerAutoReconnectEnabled { return [CBConnectPeripheralOptionEnableAutoReconnect: true] } @@ -424,6 +430,11 @@ class BluetoothManager: NSObject { /// managerQueue and cross-queue by PeripheralManager (benign bool race, like appIsForeground). var shouldHoldConnection: Bool { if isAppForeground { return true } + // Eager-gated pods (InPlay + affected iPhone): reconnecting costs a wedge storm, and the pod + // releases the link itself after ~180s of inactivity anyway — so never tear it down on + // backgrounding. The link is kept via CBConnectPeripheralOptionEnableAutoReconnect (issued on + // background connects), which restores it without needing app CPU while suspended. + if let peripheral = keepAlivePeripheral, shouldUseEagerConnect(for: peripheral) { return true } return podType.isDash && Storage.shared.podKeepAlive.value.keepsPodConnectedInBackground } @@ -1643,6 +1654,32 @@ extension BluetoothManager: CBCentralManagerDelegate { autoReconnect(peripheral) } delayedProbeInFlight = false + + // Eager-gated pods: the recovery strategy differs by app state. + // - FOREGROUND: the user may be waiting to bolus — reconnect eagerly (direct connect + fast + // watchdog cancel/retry), no auto-reconnect. + // - BACKGROUND: we may be suspended at any moment, so no app-side timer can be trusted. Issue a + // standing connect carrying CBConnectPeripheralOptionEnableAutoReconnect and let the system + // re-establish the link (measured ~27s median) with no app CPU required. This is what keeps + // the pod connected through its ~180s inactivity hangups while backgrounded. + if shouldUseEagerConnect(for: peripheral) { + if isAppForeground { + log.default("[eager] drop while foreground — reconnecting eagerly") + connectionDelegate?.omnipodLogDeviceEvent("[eager] drop while foreground — reconnecting eagerly") + noteForegroundConnectWait(peripheral) + beginCommandConnect(peripheral) + } else { + let target = manager.retrievePeripherals(withIdentifiers: [peripheral.identifier]).first ?? peripheral + if let device = devices.first(where: { $0.manager.peripheral.identifier == peripheral.identifier }) { + device.manager.peripheral = target + } + log.default("[eager] drop while background — standing connect with auto-reconnect") + connectionDelegate?.omnipodLogDeviceEvent("[eager] drop while background — standing connect (auto-reconnect)") + manager.connect(target, options: eagerConnectOptions) + } + return + } + if shouldHoldConnection && commandConnectInFlight { // Keep-alive (foreground, or a background Pod Keep Alive mode): an unintended drop while we want // to stay connected (a deliberate background/idle disconnect clears commandConnectInFlight first, From 0b973ba0cc1d53b5499830e943fca10c6a053376 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 22 Aug 2026 22:48:22 -0500 Subject: [PATCH 17/21] Warn when pump-provided heartbeat is requested on a wedge-prone combo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eager mitigation holds the pod connected, while the heartbeat's StartDelay probe requires a DISCONNECTED pod — the two connection modalities are mutually exclusive, so these combos cannot provide pump-driven background wake-ups. Rather than silently dropping a capability the host asked for, surface it: - BluetoothManager.isBLEHeartbeatRequested / BlePodComms passthrough / OmniPumpManager.bleHeartbeatUnsupportedForThisPod (InPlay + affected iPhone + heartbeat requested). - Pod settings shows a 'Pump Heartbeat Unavailable' notice explaining that looping relies on the CGM, linking to a new 'Pump Heartbeat' section in the info view. --- OmnipodKit/Bluetooth/BlePodComms.swift | 4 ++++ OmnipodKit/Bluetooth/BluetoothManager.swift | 5 +++++ OmnipodKit/PumpManager/OmniPumpManager.swift | 12 +++++++++++ .../ViewModels/OmniSettingsViewModel.swift | 7 +++++++ .../Views/InPlayConnectionInfoView.swift | 10 +++++++++ .../Views/OmniSettingsView.swift | 21 +++++++++++++++++++ 6 files changed, 59 insertions(+) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index baf495a..02141f6 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -32,6 +32,10 @@ class BlePodComms: PodComms { private var bluetoothManager: BluetoothManager! + /// Whether a host has asked the pump to provide the BLE heartbeat (see + /// OmniPumpManager.bleHeartbeatUnsupportedForThisPod). + var isBLEHeartbeatRequested: Bool { bluetoothManager?.isBLEHeartbeatRequested ?? false } + override init(podState: PodState?, podType: PodType, myId: UInt32 = 0, podId: UInt32 = 0) { super.init(podState: podState, podType: podType, myId: myId, podId: podId) bluetoothManager = BluetoothManager(podType: podType) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 9b4fe77..010c223 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -450,6 +450,11 @@ class BluetoothManager: NSObject { /// demand. managerQueue-isolated. private var heartbeatEnabled = false + /// Whether a host has asked the pump to provide the BLE heartbeat. Exposed so the pump manager can + /// warn when this is requested on a wedge-prone pod/phone combo, where we hold the connection (and + /// so the StartDelay probe — which requires a DISCONNECTED pod — can never run). + var isBLEHeartbeatRequested: Bool { heartbeatEnabled } + /// The delayed-connect (StartDelay) heartbeat probe runs when Loop asks the pump to provide the BLE /// heartbeat (`heartbeatEnabled`, via PumpManager.setBLEHeartbeatRequest) AND we are NOT holding the pod /// connected. CBConnectPeripheralOptionStartDelayKey is a background-only mechanism — iOS ignores the diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 9e9cae3..8644054 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -2207,6 +2207,18 @@ extension OmniPumpManager { } } + // A host asked the pump to provide the BLE heartbeat, but this pod/phone combination needs the + // eager-connect mitigation — which holds the pod connected, while the heartbeat's StartDelay probe + // requires a disconnected pod. The two connection modalities are mutually exclusive, so on these + // combos the pump cannot provide background heartbeats and the CGM must drive looping. + var bleHeartbeatUnsupportedForThisPod: Bool { + guard usingInPlayPod == true, UIDevice.hasPossibleInPlayBLEIssues else { return false } + if let blePodComms = podComms as? BlePodComms { + return blePodComms.isBLEHeartbeatRequested + } + return false + } + // Using an InPlay BLE pod? var usingInPlayPod: Bool? { diff --git a/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift b/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift index 0d099aa..5c6bdde 100644 --- a/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift +++ b/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift @@ -197,6 +197,13 @@ class OmniSettingsViewModel: ObservableObject { return UIDevice.hasPossibleInPlayBLEIssues && pumpManager.usingInPlayPod == true } + /// A host asked the pump to provide background heartbeats, but this pod/phone combination needs the + /// eager-connect mitigation (which holds the pod connected) — mutually exclusive with the heartbeat's + /// StartDelay probe. Surfaced as a warning: on this combination looping must be CGM-driven. + var bleHeartbeatUnsupported: Bool { + return pumpManager.bleHeartbeatUnsupportedForThisPod + } + var isScheduledBasal: Bool { switch basalDeliveryState { case .active(_), .initiatingTempBasal: diff --git a/OmnipodKit/PumpManagerUI/Views/InPlayConnectionInfoView.swift b/OmnipodKit/PumpManagerUI/Views/InPlayConnectionInfoView.swift index 8670990..70fc984 100644 --- a/OmnipodKit/PumpManagerUI/Views/InPlayConnectionInfoView.swift +++ b/OmnipodKit/PumpManagerUI/Views/InPlayConnectionInfoView.swift @@ -42,6 +42,16 @@ struct InPlayConnectionInfoView: View { .padding(.vertical, 4) } + Section(header: SectionHeader(label: LocalizedString("Pump Heartbeat", comment: "Section header on InPlay connection info view"))) { + VStack(alignment: .leading, spacing: 10) { + Text(LocalizedString("To work around the radio issue, the app keeps the pod connected instead of reconnecting for each command. That means the pod can't be used to wake the app periodically in the background.", comment: "InPlay connection info: heartbeat body 1")) + .fixedSize(horizontal: false, vertical: true) + Text(LocalizedString("If your CGM delivers readings to the app, looping continues normally — your CGM provides the wake-ups instead. A CGM that cannot wake the app may result in missed loops while the app is in the background.", comment: "InPlay connection info: heartbeat body 2")) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + } + Section(header: SectionHeader(label: LocalizedString("What You Can Do", comment: "Section header on InPlay connection info view"))) { VStack(alignment: .leading, spacing: 10) { Text(LocalizedString("No action is needed — this is not a pod fault, and insulin delivery is not affected. The pod continues its programmed delivery even while disconnected.", comment: "InPlay connection info: guidance body 1")) diff --git a/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift b/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift index cb6bb21..5d1b4b0 100644 --- a/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift @@ -338,6 +338,27 @@ struct OmniSettingsView: View { } } + // Warning: a host requested pump-provided background heartbeats, which this pod/phone + // combination cannot support (the mitigation holds the connection; the heartbeat probe + // needs a disconnected pod). + if viewModel.bleHeartbeatUnsupported { + Section { + NavigationLink(destination: InPlayConnectionInfoView()) { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + VStack(alignment: .leading, spacing: 2) { + Text(LocalizedString("Pump Heartbeat Unavailable", comment: "Title of BLE heartbeat unsupported notice row")) + .font(Font.subheadline.weight(.semibold)) + Text(LocalizedString("This pod and phone combination can't provide background wake-ups. Looping relies on your CGM.", comment: "Subtitle of BLE heartbeat unsupported notice row")) + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + } + } + let lifeState = self.viewModel.lifeState Section(header: SectionHeader(label: LocalizedString("Actions", comment: "Section header for Actions section"))) { // If need to pair a pod, display this as the only action From d7bfc0753e3082b178e5eeedbd3f8cae0a2f1a1d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 23 Aug 2026 15:03:53 -0500 Subject: [PATCH 18/21] Disconnect-driven heartbeat for wedging setups needing pump wakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a host needs pump-provided background wakes on a wedge-prone combo, the usual StartDelay probe can't be used (it needs a disconnected pod). Rather than leaving those setups with no heartbeat, use link drops as the wake source: - In this mode do NOT pass EnableAutoReconnect — the system silently re-establishing the link robs us of the wake (and produced an irregular ~9min cadence in the field). - Let the pod hang up on its own ~180s inactivity timer; CoreBluetooth delivers didDisconnect even to a suspended app (State Restoration), which is the wake. - Fire the heartbeat on that drop, then eagerly reconnect — the fresh connection re-arms the pod's timer, so the next hangup is the next wake: a self-sustaining ~3min cadence. Throttled by eagerHeartbeatMinIntervalSeconds (150s) so a wedge storm's own watchdog cancels can't each count as a wake; optional CGM-staleness gate (eagerHeartbeatStaleReadingSeconds, default 0 = always fire). Settings advisory reworded from 'Pump Heartbeat Unavailable' to 'Reduced Background Wake-Ups' to match the new behavior. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 70 +++++++++++++++++++ OmnipodKit/PumpManager/OmniPumpManager.swift | 9 ++- .../ViewModels/OmniSettingsViewModel.swift | 9 ++- .../Views/InPlayConnectionInfoView.swift | 4 +- .../Views/OmniSettingsView.swift | 12 ++-- 5 files changed, 86 insertions(+), 18 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 010c223..09c0d07 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -282,12 +282,39 @@ class BluetoothManager: NSObject { /// isReconnecting=true (we then defer to the system; didConnect fires on re-establishment). An /// explicit cancelPeripheralConnection (idle-disconnect, watchdog) still cancels any pending /// auto-reconnect, so the normally-disconnected model is unaffected. + /// Optional gate: only fire a disconnect-driven heartbeat if the host hasn't seen a CGM reading in + /// at least this long. Default 0 = always fire on a drop (Loop ignores a heartbeat it doesn't need, + /// and an extra wake is far cheaper than a missed one). + static var eagerHeartbeatStaleReadingSeconds: TimeInterval { + (UserDefaults.standard.object(forKey: "OmnipodKit.eagerHeartbeatStaleReadingSeconds") as? Double) ?? 0 + } + + /// Minimum spacing between disconnect-driven heartbeats. + static var eagerHeartbeatMinIntervalSeconds: TimeInterval { + (UserDefaults.standard.object(forKey: "OmnipodKit.eagerHeartbeatMinIntervalSeconds") as? Double) ?? 150 + } + static var eagerAutoReconnectEnabled: Bool { UserDefaults.standard.object(forKey: "OmnipodKit.eagerAutoReconnectEnabled") as? Bool ?? true } /// Connect options for eager connects (auto-reconnect experiment when enabled and available). + /// Disconnect-driven heartbeat mode: a wedge-prone pod on an affected phone whose host needs the + /// pump to provide background wakes. Here we deliberately do NOT use auto-reconnect — instead we let + /// the pod hang up on its own ~180s inactivity timer, take CoreBluetooth's didDisconnect as the wake + /// (State Restoration delivers it to a suspended app), eagerly reconnect (re-arming the next cycle), + /// and fire the heartbeat if the host's CGM data has gone stale. That yields a regular ~3min wake + /// cadence, versus the irregular ~9min observed when auto-reconnect silently holds the link up. + var isEagerHeartbeatMode: Bool { + guard heartbeatEnabled else { return false } + guard let peripheral = keepAlivePeripheral else { return false } + return shouldUseEagerConnect(for: peripheral) + } + private var eagerConnectOptions: [String: Any]? { + // Disconnect-driven heartbeat mode owns its own reconnects — auto-reconnect would silently + // re-establish the link and rob us of the wake. + if isEagerHeartbeatMode { return nil } // BACKGROUND: ask the system to keep the link up for us — it can re-establish while the app is // suspended, which no app-side timer can do. // FOREGROUND: do NOT use auto-reconnect. The user may be waiting to bolus, and the system's @@ -402,6 +429,13 @@ class BluetoothManager: NSObject { /// target no host is refreshing (advance it). managerQueue-isolated. private var heartbeatTargetSetAt: Date? + /// Most recent CGM reading time reported by the host (via PumpHeartbeatRequest). Used by the + /// disconnect-driven heartbeat to decide whether a wake is actually needed. + private var lastCGMReadingDate: Date? + + /// When we last fired a disconnect-driven heartbeat, for throttling. + private var lastEagerHeartbeatFire: Date? + /// True while a real command's connect owns the link (connect-on-demand). The heartbeat probe and /// a command connect must never be outstanding together — a command preempts the probe and, while /// it's active, the probe is neither armed nor allowed to claim a didConnect. Cleared on the @@ -477,6 +511,7 @@ class BluetoothManager: NSObject { let enabled = request != nil if let request = request { let base = request.lastCGMReadingDate ?? Date() + self.lastCGMReadingDate = request.lastCGMReadingDate self.heartbeatTargetDate = base.addingTimeInterval(request.expectedCGMReadingInterval + BluetoothManager.heartbeatBufferSeconds) self.heartbeatInterval = request.expectedCGMReadingInterval self.heartbeatTargetSetAt = Date() @@ -484,6 +519,7 @@ class BluetoothManager: NSObject { self.heartbeatTargetDate = nil self.heartbeatInterval = nil self.heartbeatTargetSetAt = nil + self.lastCGMReadingDate = nil } let wasEnabled = self.heartbeatEnabled self.heartbeatEnabled = enabled @@ -952,6 +988,31 @@ class BluetoothManager: NSObject { } } + /// Fire the pump-provided heartbeat off a real link drop (disconnect-driven heartbeat mode). + /// Throttled by `eagerHeartbeatMinIntervalSeconds` because our own watchdog cancels can produce a + /// burst of disconnects during a wedge storm — those must not each count as a wake. Optionally + /// gated on CGM staleness (`eagerHeartbeatStaleReadingSeconds`, default 0 = always fire). + private func fireEagerHeartbeatIfNeeded() { + dispatchPrecondition(condition: .onQueue(managerQueue)) + let now = Date() + if let last = lastEagerHeartbeatFire, + now.timeIntervalSince(last) < BluetoothManager.eagerHeartbeatMinIntervalSeconds { + log.debug("[heartbeat] eager drop-driven heartbeat throttled") + return + } + let staleAfter = BluetoothManager.eagerHeartbeatStaleReadingSeconds + if staleAfter > 0, let lastReading = lastCGMReadingDate, + now.timeIntervalSince(lastReading) < staleAfter { + log.debug("[heartbeat] eager drop-driven heartbeat skipped — recent CGM reading") + return + } + lastEagerHeartbeatFire = now + let sinceReading = lastCGMReadingDate.map { String(format: "%.0fs", now.timeIntervalSince($0)) } ?? "?" + log.default("[heartbeat] firing on link drop (eager heartbeat mode, lastCGM %{public}@ ago)", sinceReading) + connectionDelegate?.omnipodLogDeviceEvent("[heartbeat] firing on link drop (eager mode, lastCGM \(sinceReading) ago)") + connectionDelegate?.omnipodHeartbeatDidFire() + } + /// Invalidate any pending watchdog tick for this peripheral (bump the generation token). private func disarmConnectWatchdog(_ peripheral: CBPeripheral) { let id = peripheral.identifier.uuidString @@ -1673,6 +1734,15 @@ extension BluetoothManager: CBCentralManagerDelegate { connectionDelegate?.omnipodLogDeviceEvent("[eager] drop while foreground — reconnecting eagerly") noteForegroundConnectWait(peripheral) beginCommandConnect(peripheral) + } else if isEagerHeartbeatMode { + // Disconnect-driven heartbeat: CoreBluetooth just woke us for this drop (State + // Restoration delivers it even to a suspended app). Fire the heartbeat, then eagerly + // reconnect — the fresh connection re-arms the pod's ~180s inactivity timer, so the + // next hangup becomes the next wake, giving a self-sustaining ~3min cadence. + fireEagerHeartbeatIfNeeded() + log.default("[eager] drop while background (heartbeat mode) — eager reconnect") + connectionDelegate?.omnipodLogDeviceEvent("[eager] drop while background (heartbeat mode) — eager reconnect") + eagerConnect(peripheral, deadline: Date().addingTimeInterval(BluetoothManager.eagerConnectBudgetSeconds)) } else { let target = manager.retrievePeripherals(withIdentifiers: [peripheral.identifier]).first ?? peripheral if let device = devices.first(where: { $0.manager.peripheral.identifier == peripheral.identifier }) { diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 8644054..89d143a 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -2207,11 +2207,10 @@ extension OmniPumpManager { } } - // A host asked the pump to provide the BLE heartbeat, but this pod/phone combination needs the - // eager-connect mitigation — which holds the pod connected, while the heartbeat's StartDelay probe - // requires a disconnected pod. The two connection modalities are mutually exclusive, so on these - // combos the pump cannot provide background heartbeats and the CGM must drive looping. - var bleHeartbeatUnsupportedForThisPod: Bool { + // A host asked the pump to provide the BLE heartbeat on a combination needing the eager-connect + // mitigation. The usual StartDelay probe can't be used there, so wakes are driven by link drops + // instead (see BluetoothManager.isEagerHeartbeatMode) — workable, but less regular. + var bleHeartbeatDegradedForThisPod: Bool { guard usingInPlayPod == true, UIDevice.hasPossibleInPlayBLEIssues else { return false } if let blePodComms = podComms as? BlePodComms { return blePodComms.isBLEHeartbeatRequested diff --git a/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift b/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift index 5c6bdde..c1d59eb 100644 --- a/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift +++ b/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift @@ -197,11 +197,10 @@ class OmniSettingsViewModel: ObservableObject { return UIDevice.hasPossibleInPlayBLEIssues && pumpManager.usingInPlayPod == true } - /// A host asked the pump to provide background heartbeats, but this pod/phone combination needs the - /// eager-connect mitigation (which holds the pod connected) — mutually exclusive with the heartbeat's - /// StartDelay probe. Surfaced as a warning: on this combination looping must be CGM-driven. - var bleHeartbeatUnsupported: Bool { - return pumpManager.bleHeartbeatUnsupportedForThisPod + /// A host asked the pump to provide background heartbeats on a combination needing the eager-connect + /// mitigation: wakes come from link drops rather than the usual timer probe, so they're less regular. + var bleHeartbeatDegraded: Bool { + return pumpManager.bleHeartbeatDegradedForThisPod } var isScheduledBasal: Bool { diff --git a/OmnipodKit/PumpManagerUI/Views/InPlayConnectionInfoView.swift b/OmnipodKit/PumpManagerUI/Views/InPlayConnectionInfoView.swift index 70fc984..b4be43a 100644 --- a/OmnipodKit/PumpManagerUI/Views/InPlayConnectionInfoView.swift +++ b/OmnipodKit/PumpManagerUI/Views/InPlayConnectionInfoView.swift @@ -44,9 +44,9 @@ struct InPlayConnectionInfoView: View { Section(header: SectionHeader(label: LocalizedString("Pump Heartbeat", comment: "Section header on InPlay connection info view"))) { VStack(alignment: .leading, spacing: 10) { - Text(LocalizedString("To work around the radio issue, the app keeps the pod connected instead of reconnecting for each command. That means the pod can't be used to wake the app periodically in the background.", comment: "InPlay connection info: heartbeat body 1")) + Text(LocalizedString("The usual method for the pod to wake the app on a timer can't be used on this combination. Instead, the app is woken when the pod's connection drops, and reconnects right away.", comment: "InPlay connection info: heartbeat body 1")) .fixedSize(horizontal: false, vertical: true) - Text(LocalizedString("If your CGM delivers readings to the app, looping continues normally — your CGM provides the wake-ups instead. A CGM that cannot wake the app may result in missed loops while the app is in the background.", comment: "InPlay connection info: heartbeat body 2")) + Text(LocalizedString("These wake-ups are less regular than usual — roughly every few minutes. If your CGM delivers readings to the app, it provides the wake-ups instead and looping continues normally.", comment: "InPlay connection info: heartbeat body 2")) .fixedSize(horizontal: false, vertical: true) } .padding(.vertical, 4) diff --git a/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift b/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift index 5d1b4b0..94354e2 100644 --- a/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift @@ -338,19 +338,19 @@ struct OmniSettingsView: View { } } - // Warning: a host requested pump-provided background heartbeats, which this pod/phone - // combination cannot support (the mitigation holds the connection; the heartbeat probe - // needs a disconnected pod). - if viewModel.bleHeartbeatUnsupported { + // Advisory: a host requested pump-provided background heartbeats. On these combos the + // normal (StartDelay) heartbeat probe can't be used, so wakes come from link drops + // instead — workable, but less regular than on unaffected pods. + if viewModel.bleHeartbeatDegraded { Section { NavigationLink(destination: InPlayConnectionInfoView()) { HStack(spacing: 10) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) VStack(alignment: .leading, spacing: 2) { - Text(LocalizedString("Pump Heartbeat Unavailable", comment: "Title of BLE heartbeat unsupported notice row")) + Text(LocalizedString("Reduced Background Wake-Ups", comment: "Title of BLE heartbeat degraded notice row")) .font(Font.subheadline.weight(.semibold)) - Text(LocalizedString("This pod and phone combination can't provide background wake-ups. Looping relies on your CGM.", comment: "Subtitle of BLE heartbeat unsupported notice row")) + Text(LocalizedString("Background wake-ups from the pod are less frequent on this pod and phone combination.", comment: "Subtitle of BLE heartbeat degraded notice row")) .font(.footnote) .foregroundColor(.secondary) } From f986a4f496bc4164f91fdf054e73eedf527f9a4b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 25 Aug 2026 20:29:52 -0500 Subject: [PATCH 19/21] Hold the connection when the app launches straight into the foreground isAppForeground starts false and is only set from the didBecomeActive observer registered in BluetoothManager.init. A pump manager built lazily on a cold launch is constructed after the app has already become active, so that observer never fires for the launch and the flag stays false for the whole foreground session. shouldHoldConnection is then false while the user is looking at the screen, and the idle-disconnect drops the link ~4s after each command -- reported against the pod simulator, where the speaker icon greys out and stays that way until some other command reconnects. Seed the flag from the live application state at init instead of waiting for a transition that already happened. If the notification wins the race, the isAppForeground guard makes the seed a no-op. Route the lifecycle notifications and the new state read through a HostAppState shim rather than touching UIApplication directly. watchOS has no UIApplication, and this code is heading there; keeping the platform split in one file means BluetoothManager does not grow a third UIKit dependency to unpick later. The watchOS branch is written against WKApplication but has never been compiled -- there is no watch target yet. The UIDevice-based eager-connect gate is still UIKit-bound and is left alone here. Also stop gating the test-beeps button on hasConnection, as Joe Moran suggested. Under connect-on-demand a disconnected pod is the normal resting state rather than a fault, and a beep is exactly the check you want when the pod seems unreachable -- so the icon still greys out to show there is no live link, but the button connects on demand. Every other action on that screen already gates on podOk for the same reason. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 24 +++++++-- OmnipodKit/Common/HostAppState.swift | 51 +++++++++++++++++++ .../Views/OmniSettingsView.swift | 7 ++- 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 OmnipodKit/Common/HostAppState.swift diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index e1f2eab..f3e1969 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -11,7 +11,7 @@ import CoreBluetooth import Foundation import LoopKit import os.log -import UIKit +import UIKit // only for UIDevice (see shouldUseEagerConnect); lifecycle goes through HostAppState enum BluetoothManagerError: Error { case bluetoothNotAvailable(CBManagerState) @@ -641,7 +641,7 @@ class BluetoothManager: NSObject { // false) from a user-initiated open (foregrounds → everFg true). Log the transitions to the // persistent device log with PID for the timeline. let center = NotificationCenter.default - center.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in + center.addObserver(forName: HostAppState.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in let pid = ProcessInfo.processInfo.processIdentifier self?.managerQueue.async { guard let self = self else { return } @@ -651,7 +651,7 @@ class BluetoothManager: NSObject { self.enterForeground() } } - center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in + center.addObserver(forName: HostAppState.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in let pid = ProcessInfo.processInfo.processIdentifier self?.managerQueue.async { guard let self = self else { return } @@ -660,6 +660,24 @@ class BluetoothManager: NSObject { self.enterBackground() } } + + // Seed from the live application state. If this manager is constructed AFTER the app has + // already become active — a pump manager built lazily on a cold launch — the observer above + // never fires for that launch, so isAppForeground stays false for the whole foreground + // session. shouldHoldConnection is then false while the user is looking at the screen, and + // the idle-disconnect drops the link ~4s after each command (loopandlearn/OmnipodKit#133). + // If the notification wins the race instead, the isAppForeground guard makes this a no-op. + DispatchQueue.main.async { [weak self] in + guard HostAppState.isActive else { return } + let pid = ProcessInfo.processInfo.processIdentifier + self?.managerQueue.async { + guard let self = self, !self.isAppForeground else { return } + self.everForeground = true + self.log.default("[lifecycle] pid=%{public}d APP FOREGROUND (seeded at init)", pid) + self.connectionDelegate?.omnipodLogDeviceEvent("[lifecycle] pid=\(pid) APP FOREGROUND (seeded at init)") + self.enterForeground() + } + } } deinit { diff --git a/OmnipodKit/Common/HostAppState.swift b/OmnipodKit/Common/HostAppState.swift new file mode 100644 index 0000000..3d936ab --- /dev/null +++ b/OmnipodKit/Common/HostAppState.swift @@ -0,0 +1,51 @@ +// +// HostAppState.swift +// OmnipodKit +// +// Single seam for host-app lifecycle state. The BLE stack needs to know whether the app is +// frontmost (see BluetoothManager.shouldHoldConnection), but watchOS has no UIApplication — +// keeping the platform split here means BluetoothManager itself stays platform-neutral. +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +#if os(watchOS) +import WatchKit +#else +import UIKit +#endif + +/// Host application lifecycle, abstracted away from UIKit/WatchKit. +/// +/// NOTE: the watchOS branch is written against WKApplication (watchOS 9+) but has never been +/// compiled — there is no watch target yet. Verify the symbol names when one lands. On watchOS 8 +/// and earlier the equivalents are `WKExtension.shared().applicationState` and +/// `WKExtension.applicationDidBecomeActiveNotification`. +enum HostAppState { + + /// True when the host app is frontmost and active. Read this on the main thread. + static var isActive: Bool { + #if os(watchOS) + return WKApplication.shared().applicationState == .active + #else + return UIApplication.shared.applicationState == .active + #endif + } + + static var didBecomeActiveNotification: Notification.Name { + #if os(watchOS) + return WKApplication.didBecomeActiveNotification + #else + return UIApplication.didBecomeActiveNotification + #endif + } + + static var didEnterBackgroundNotification: Notification.Name { + #if os(watchOS) + return WKApplication.didEnterBackgroundNotification + #else + return UIApplication.didEnterBackgroundNotification + #endif + } +} diff --git a/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift b/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift index f8ea14a..c8fed1b 100644 --- a/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift @@ -288,7 +288,12 @@ struct OmniSettingsView: View { .padding(.top,5) } .buttonStyle(PlainButtonStyle()) - .disabled(!viewModel.hasConnection || sendingTestBeepsCommand) + // Not gated on hasConnection: under connect-on-demand a disconnected pod is the + // normal resting state, not a fault. The icon still greys out to show there is no + // live link, but tapping it connects on demand and beeps — which is exactly the + // check you want when the pod is unreachable. Every other action on this screen + // gates on podOk for the same reason. + .disabled(sendingTestBeepsCommand) headerImage From 61a2c9c597c5b40448dacd016af3007623fa9b73 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 25 Aug 2026 20:45:53 -0500 Subject: [PATCH 20/21] Correct the test-beeps comment: hasConnection differs by pod type The comment added with the previous commit claimed a disconnected pod is the normal resting state under connect-on-demand. That is only true for a BLE pod with Pod Keep Alive disabled. For Eros, hasConnection is rileylinkConnected -- whether any RileyLink is connected, independent of whether a pod is even paired -- so a false value there means the radio bridge is missing, not that the system is resting. With Pod Keep Alive active the BLE pod is held connected too, so disconnected is not the resting state in that case either. OmniPumpManager.hasConnection documents both meanings. The reason not to gate the button stands, and does not depend on that claim: whatever hasConnection means for the configured pod type, a false value does not mean the command cannot run, because playTestBeeps goes through the normal command path and acquires the link itself. Reported by Joe Moran. --- .../PumpManagerUI/Views/OmniSettingsView.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift b/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift index c8fed1b..4acaec0 100644 --- a/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift @@ -288,11 +288,14 @@ struct OmniSettingsView: View { .padding(.top,5) } .buttonStyle(PlainButtonStyle()) - // Not gated on hasConnection: under connect-on-demand a disconnected pod is the - // normal resting state, not a fault. The icon still greys out to show there is no - // live link, but tapping it connects on demand and beeps — which is exactly the - // check you want when the pod is unreachable. Every other action on this screen - // gates on podOk for the same reason. + // Not gated on hasConnection. That var means different things by pod type — the + // pod link for BLE pods, whether ANY RileyLink is connected (independent of pod + // availability) for Eros; see OmniPumpManager.hasConnection. In neither case does + // "not connected right now" mean the command can't run: playTestBeeps goes through + // the normal command path, which acquires the link itself. The icon still greys out + // to show there is no live link, but the button stays tappable, so a beep can be + // used to check whether the pod is actually reachable. Every other action on this + // screen gates on podOk rather than on connectivity. .disabled(sendingTestBeepsCommand) headerImage From e2f9a5fbcd5e024b2359c99f38bce8965afd2448 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 27 Aug 2026 11:53:56 -0500 Subject: [PATCH 21/21] Let O5 pods take the fresh-advert connect The fresh-discovery connect sat inside a DASH-only branch, so an O5 pod could never take it. connectViaFreshDiscovery arms pendingFreshConnectID and scans regardless of pod type, the advert arrives and is logged, and then nothing consumes it -- every O5 foreground connect waited out the full 4s fresh-discovery window and fell back to a cold connect. From a tester's report of sluggish bolusing, iPhone 15 Pro + Omnipod 5: 18:31:11 APP FOREGROUND -> pre-connecting, fresh-discovery scan started 18:31:11 [ADV] svcUUIDs=[CE1F923D-...-0A002A098C00] <- pod heard 18:31:15 no fresh discovery in 4s -- cold connect fallback 18:31:17 connected 5.9s after foregrounding A DASH pod on the same code path connects 0.4s after foregrounding. The DASH gate belongs to the connectionless alarm decode, which parses the DASH iBeacon status word and must not run against an O5 advert. The fresh connect has no such constraint: it needs only a just-heard, connectable advert from our own pod. Split it out, keeping the isOwnPod gate so a stranger's pod matching the generic C00A fault filter still cannot pull us into a connect. issueDelayedConnectProbe stays DASH-only. It drives background wake scheduling, which is a separate concern from foreground connect latency. Its position relative to the fresh connect is unchanged for DASH pods. Not related to the eager-connect mitigation on this branch: that gates on an iPhone 16/17e and an InPlay-named pod, and neither matched the reporter's setup. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 50 +++++++++++++-------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index f3e1969..9d11aba 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -1509,27 +1509,41 @@ extension BluetoothManager: CBCentralManagerDelegate { // Connectionless alarm decode is DASH-specific (parses the DASH iBeacon status word). O5 encodes // state differently (see the capture) — never run the DASH decode against an O5 advert. Gated on - // isOwnPod so a foreign pod that matched the generic C00A filter can't drive detection/connect/probe. + // isOwnPod so a foreign pod that matched the generic C00A filter can't drive detection/probe. if isOwnPod && podType.isDash { detectPodAlertStatus(peripheral: peripheral, advertisementData: advertisementData) - // Fresh-discovery connect: we just heard the pod — stop scanning and connect NOW on this - // fresh advertisement (fast) instead of waiting out iOS's cold reacquisition (~16s). - if pendingFreshConnectID == peripheral.identifier.uuidString { - pendingFreshConnectID = nil - log.default("[connectOnDemand] fresh discovery -> connect %{public}@", peripheral.identifier.uuidString) - connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] pod heard -> connecting on fresh advert") - manager.stopScan() - // Defer the connect one managerQueue tick so the scan actually tears down first. - // Connecting synchronously here (still inside the scan's didDiscover) starved the - // connect -> it wedged in .connecting and timed out at 20s. Let iOS settle the - // stopScan, then connect on the just-heard advert. Direct connect (not freshConnect): - // the peripheral was just heard and is connectable, so skip the cancel+re-retrieve - // stale-flush (an In-Play stall workaround) that added a round-trip on the good pod. - managerQueue.async { [weak self] in - self?.manager.connect(peripheral, options: nil) - } + } + + // Fresh-discovery connect: we just heard the pod — stop scanning and connect NOW on this fresh + // advertisement (fast) instead of waiting out iOS's cold reacquisition (~16s). + // + // NOT pod-type specific: all this needs is a just-heard, connectable advert from our own pod. + // It used to sit inside the DASH-only branch above, so an O5 pod could never take it — + // connectViaFreshDiscovery armed pendingFreshConnectID and scanned for any pod type, the advert + // arrived, and nothing consumed it. Every O5 foreground connect therefore waited out the full 4s + // fresh-discovery window and fell back to a cold connect: measured 5.9s, against 0.4s for a DASH + // pod on the same code path. Still gated on isOwnPod so a stranger's pod can't pull us into a + // connect (the C00A fault filter is generic). + if isOwnPod, pendingFreshConnectID == peripheral.identifier.uuidString { + pendingFreshConnectID = nil + log.default("[connectOnDemand] fresh discovery -> connect %{public}@", peripheral.identifier.uuidString) + connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] pod heard -> connecting on fresh advert") + manager.stopScan() + // Defer the connect one managerQueue tick so the scan actually tears down first. + // Connecting synchronously here (still inside the scan's didDiscover) starved the + // connect -> it wedged in .connecting and timed out at 20s. Let iOS settle the + // stopScan, then connect on the just-heard advert. Direct connect (not freshConnect): + // the peripheral was just heard and is connectable, so skip the cancel+re-retrieve + // stale-flush (an In-Play stall workaround) that added a round-trip on the good pod. + managerQueue.async { [weak self] in + self?.manager.connect(peripheral, options: nil) } - // Kick off / re-arm the delayed-connect probe once we know the pod is present + disconnected. + } + + // Kick off / re-arm the delayed-connect probe once we know the pod is present + disconnected. + // Left DASH-only deliberately: this drives background wake scheduling, a separate concern from + // foreground connect latency, and is not what this change is about. + if isOwnPod && podType.isDash { issueDelayedConnectProbe(peripheral) }