diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 1bd742a..cdf0569 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 f57c4d3..9d11aba 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) @@ -215,6 +215,122 @@ 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). + /// 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 + // 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 + /// 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 (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): 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) ?? 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) ?? 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. + /// 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 + // 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] + } + 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" + /// 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 — @@ -253,6 +369,22 @@ 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). 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. + /// + /// 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) ?? 3600 + } + /// 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. @@ -297,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 @@ -327,6 +466,11 @@ class BluetoothManager: NSObject { /// managerQueue and cross-queue by PeripheralManager (benign bool race). 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 podKeepAliveKeepsConnectedInBackground } @@ -342,6 +486,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 @@ -364,6 +513,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() @@ -371,6 +521,7 @@ class BluetoothManager: NSObject { self.heartbeatTargetDate = nil self.heartbeatInterval = nil self.heartbeatTargetSetAt = nil + self.lastCGMReadingDate = nil } let wasEnabled = self.heartbeatEnabled self.heartbeatEnabled = enabled @@ -400,11 +551,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 @@ -481,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 } @@ -491,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 } @@ -500,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 { @@ -733,6 +911,137 @@ 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] = [:] + + /// 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. + 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). + 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 || UIDevice.hasPossibleInPlayBLEIssues 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: eagerConnectOptions) + 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) + 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 } + 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: self.eagerConnectOptions) + self.armConnectWatchdog(retryTarget, deadline: deadline) + } + } + } + + /// 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 + 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 @@ -762,6 +1071,26 @@ 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) { + // 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)) + 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 @@ -784,10 +1113,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() } } @@ -1145,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) } @@ -1194,13 +1572,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("Reconnecting to autoconnect device") @@ -1230,6 +1623,25 @@ extension BluetoothManager: CBCentralManagerDelegate { pendingFreshConnectID = nil } + // 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) { + 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 { @@ -1281,10 +1693,35 @@ 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)) + // 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=\(errStr))") + } else { + connectionDelegate?.omnipodLogDeviceEvent("[autoReconnect] didDisconnect isReconnecting=false (eventAge \(String(format: "%.1f", age))s, error=\(errStr))") + } + 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 { @@ -1293,11 +1730,65 @@ 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 { + autoReconnectPendingSince[peripheral.identifier.uuidString] = Date() + 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. + 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) } 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 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 }) { + 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, @@ -1331,6 +1822,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) } diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index b3ba2fd..69aa489 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) + // 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: 45) } catch let error { self.log.error("[connectOnDemand] on-demand connect failed: %{public}@", String(describing: error)) } @@ -661,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 } 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/Common/UIDevice.swift b/OmnipodKit/Common/UIDevice.swift index 24f14c1..fd81b48 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 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 7e81593..7c88fcb 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -1428,7 +1428,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 self.state.podKeepAlive == .disabled { // Enable the most conservative pod keep alive mode // that should continue through the pod setup process. @@ -2281,15 +2281,14 @@ extension OmniPumpManager { } } - // Running on any iPhone 16 or an iPhone 17e which are known - // to have BLE reconnect issues with InPlay BLE DASH pods? - var iPhoneWithPossibleInPlayIssues: Bool { - - let iPhoneModel = UIDevice.modelName - if iPhoneModel.contains("iPhone 16") || iPhoneModel == "iPhone 17e" { - return true + // 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 } - return false } @@ -2297,7 +2296,7 @@ extension OmniPumpManager { 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 } diff --git a/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift b/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift index 8144149..616a011 100644 --- a/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift +++ b/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift @@ -189,6 +189,20 @@ 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 UIDevice.hasPossibleInPlayBLEIssues && pumpManager.usingInPlayPod == true + } + + /// 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 { 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..b4be43a --- /dev/null +++ b/OmnipodKit/PumpManagerUI/Views/InPlayConnectionInfoView.swift @@ -0,0 +1,76 @@ +// +// 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("Pump Heartbeat", comment: "Section header on InPlay connection info view"))) { + VStack(alignment: .leading, spacing: 10) { + 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("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) + } + + 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 90c6b9d..4acaec0 100644 --- a/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift @@ -288,7 +288,15 @@ struct OmniSettingsView: View { .padding(.top,5) } .buttonStyle(PlainButtonStyle()) - .disabled(!viewModel.hasConnection || sendingTestBeepsCommand) + // 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 @@ -317,6 +325,48 @@ 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) + } + } + } + } + } + + // 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("Reduced Background Wake-Ups", comment: "Title of BLE heartbeat degraded notice row")) + .font(Font.subheadline.weight(.semibold)) + 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) + } + } + } + } + } + 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