Skip to content

Commit 5a7350a

Browse files
committed
Eager connect watchdog: mitigate InPlay/iPhone-16 connection wedges
Backport of #133 from next-dev, squashed. Mitigates BLE connection wedges on InPlay-firmware DASH pods paired with an iPhone 16 (any variant) or 17e: a connect that stalls with no callback is torn down and retried rather than left to time out, and the pod settings view gains a persistent notice explaining the slower connections. Also carries the later fixes from that branch: the O5 fresh-advert connect, which cut foreground connects on Omnipod 5 from about six seconds to under one; the foreground-state seed, so a pump manager built after the app has already become active does not idle-disconnect between commands; and the test-beeps button no longer gating on hasConnection. Two conflicts against this line, both because dev predates the Pod Keep Alive rework (#125). Resolved by keeping dev's accessor and taking the eager-connect logic: - shouldHoldConnection keeps podType.isDash && Storage.shared.podKeepAlive.value.keepsPodConnectedInBackground rather than podKeepAliveKeepsConnectedInBackground - the pairing auto-enable keeps Storage.shared.podKeepAlive.value while adopting the consolidated UIDevice.hasPossibleInPlayBLEIssues Does not build against LoopWorkspace/dev as it stands, for reasons that predate this change -- see the pull request.
1 parent 2c603f7 commit 5a7350a

9 files changed

Lines changed: 755 additions & 39 deletions

File tree

‎OmnipodKit/Bluetooth/BlePodComms.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ class BlePodComms: PodComms {
3232

3333
private var bluetoothManager: BluetoothManager!
3434

35+
/// Whether a host has asked the pump to provide the BLE heartbeat (see
36+
/// OmniPumpManager.bleHeartbeatUnsupportedForThisPod).
37+
var isBLEHeartbeatRequested: Bool { bluetoothManager?.isBLEHeartbeatRequested ?? false }
38+
3539
override init(podState: PodState?, podType: PodType, myId: UInt32 = 0, podId: UInt32 = 0) {
3640
super.init(podState: podState, podType: podType, myId: myId, podId: podId)
3741
bluetoothManager = BluetoothManager(podType: podType)

‎OmnipodKit/Bluetooth/BluetoothManager.swift‎

Lines changed: 525 additions & 26 deletions
Large diffs are not rendered by default.

‎OmnipodKit/Bluetooth/PeripheralManager.swift‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,10 @@ extension PeripheralManager {
135135
// disconnect-then-wait stalls). If already connected (burst of sessions), no-op.
136136
if self.peripheral.state != .connected {
137137
do {
138-
try self.connectOnDemand(timeout: 20)
138+
// 45s: sized above BluetoothManager.eagerConnectBudgetSeconds (40s) so the
139+
// eager watchdog's cancel/retry cycles own the recovery underneath this
140+
// single wait, rather than this timeout firing first.
141+
try self.connectOnDemand(timeout: 45)
139142
} catch let error {
140143
self.log.error("[connectOnDemand] on-demand connect failed: %{public}@", String(describing: error))
141144
}
@@ -661,7 +664,17 @@ extension PeripheralManager {
661664
/// hold the connection separately via `shouldHoldConnection`, so this delay only bites while backgrounded.)
662665
private func scheduleIdleDisconnectIfNeeded() {
663666
guard BluetoothManager.connectOnDemandEnabled else { return }
664-
let idleDelay: TimeInterval = BluetoothManager.idleDisconnectSeconds
667+
// Eager-gated pods (InPlay + affected iPhone): reconnecting costs a wedge storm (median ~10s,
668+
// worst ~30s+), so a working connection is precious. Use a much longer idle window so one loop
669+
// cycle's status→compute→dose burst (sessions ~10-25s apart) shares a single connection instead
670+
// of paying 2-3 storms per cycle. The cycle still ends disconnected — the heartbeat probe
671+
// re-arms ~a minute after the last command, well before the next CGM reading.
672+
let idleDelay: TimeInterval
673+
if bluetoothManager?.shouldUseEagerConnect(for: peripheral) == true {
674+
idleDelay = BluetoothManager.eagerIdleDisconnectSeconds
675+
} else {
676+
idleDelay = BluetoothManager.idleDisconnectSeconds
677+
}
665678
let idleAt = idleStart
666679
queue.asyncAfter(deadline: .now() + idleDelay) { [weak self] in
667680
guard let self = self, BluetoothManager.connectOnDemandEnabled else { return }
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//
2+
// HostAppState.swift
3+
// OmnipodKit
4+
//
5+
// Single seam for host-app lifecycle state. The BLE stack needs to know whether the app is
6+
// frontmost (see BluetoothManager.shouldHoldConnection), but watchOS has no UIApplication —
7+
// keeping the platform split here means BluetoothManager itself stays platform-neutral.
8+
//
9+
// Copyright © 2026 LoopKit Authors. All rights reserved.
10+
//
11+
12+
import Foundation
13+
#if os(watchOS)
14+
import WatchKit
15+
#else
16+
import UIKit
17+
#endif
18+
19+
/// Host application lifecycle, abstracted away from UIKit/WatchKit.
20+
///
21+
/// NOTE: the watchOS branch is written against WKApplication (watchOS 9+) but has never been
22+
/// compiled — there is no watch target yet. Verify the symbol names when one lands. On watchOS 8
23+
/// and earlier the equivalents are `WKExtension.shared().applicationState` and
24+
/// `WKExtension.applicationDidBecomeActiveNotification`.
25+
enum HostAppState {
26+
27+
/// True when the host app is frontmost and active. Read this on the main thread.
28+
static var isActive: Bool {
29+
#if os(watchOS)
30+
return WKApplication.shared().applicationState == .active
31+
#else
32+
return UIApplication.shared.applicationState == .active
33+
#endif
34+
}
35+
36+
static var didBecomeActiveNotification: Notification.Name {
37+
#if os(watchOS)
38+
return WKApplication.didBecomeActiveNotification
39+
#else
40+
return UIApplication.didBecomeActiveNotification
41+
#endif
42+
}
43+
44+
static var didEnterBackgroundNotification: Notification.Name {
45+
#if os(watchOS)
46+
return WKApplication.didEnterBackgroundNotification
47+
#else
48+
return UIApplication.didEnterBackgroundNotification
49+
#endif
50+
}
51+
}

‎OmnipodKit/Common/UIDevice.swift‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,5 +130,15 @@ public extension UIDevice {
130130
return mapToDevice(identifier: identifier)
131131
}()
132132

133+
/// True on the iPhone models known to trigger the InPlay BLE DASH pod firmware bug (the pod
134+
/// silently ignores LL_CONNECTION_PARAM_REQ, wedging connection establishment): the iPhone 16
135+
/// family (all variants, incl. 16e) and the iPhone 17e specifically. Deliberately NOT the rest
136+
/// of the iPhone 17 family, which is unaffected. Single source of truth for all affected-model
137+
/// checks (eager-connect gate, pod keep-alive defaulting, settings advisory).
138+
static var hasPossibleInPlayBLEIssues: Bool {
139+
let model = UIDevice.modelName
140+
return model.contains("iPhone 16") || model == "iPhone 17e"
141+
}
142+
133143
}
134144

‎OmnipodKit/PumpManager/OmniPumpManager.swift‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1411,7 +1411,7 @@ extension OmniPumpManager {
14111411
// Have new podState, reset all the per pod pump manager state
14121412
self.resetPerPodPumpManagerState()
14131413

1414-
if self.usingInPlayPod == true && self.iPhoneWithPossibleInPlayIssues {
1414+
if self.usingInPlayPod == true && UIDevice.hasPossibleInPlayBLEIssues {
14151415
if self.state.podKeepAlive == .disabled {
14161416
// Enable the most conservative pod keep alive mode
14171417
// that should continue through the pod setup process.
@@ -2264,23 +2264,22 @@ extension OmniPumpManager {
22642264
}
22652265
}
22662266

2267-
// Running on any iPhone 16 or an iPhone 17e which are known
2268-
// to have BLE reconnect issues with InPlay BLE DASH pods?
2269-
var iPhoneWithPossibleInPlayIssues: Bool {
2270-
2271-
let iPhoneModel = UIDevice.modelName
2272-
if iPhoneModel.contains("iPhone 16") || iPhoneModel == "iPhone 17e" {
2273-
return true
2267+
// A host asked the pump to provide the BLE heartbeat on a combination needing the eager-connect
2268+
// mitigation. The usual StartDelay probe can't be used there, so wakes are driven by link drops
2269+
// instead (see BluetoothManager.isEagerHeartbeatMode) — workable, but less regular.
2270+
var bleHeartbeatDegradedForThisPod: Bool {
2271+
guard usingInPlayPod == true, UIDevice.hasPossibleInPlayBLEIssues else { return false }
2272+
if let blePodComms = podComms as? BlePodComms {
2273+
return blePodComms.isBLEHeartbeatRequested
22742274
}
2275-
22762275
return false
22772276
}
22782277

22792278
// Using an InPlay BLE pod?
22802279
var usingInPlayPod: Bool? {
22812280

22822281
if let blePodComms = podComms as? BlePodComms, let deviceBLEName = blePodComms.manager?.peripheral.name {
2283-
return deviceBLEName == "InPlay BLE"
2282+
return deviceBLEName == BluetoothManager.inPlayPeripheralName
22842283
}
22852284
return nil // don't know -- maybe not paired yet
22862285
}

‎OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,20 @@ class OmniSettingsViewModel: ObservableObject {
189189
}
190190
}
191191

192+
/// Persistent advisory: this pod uses the InPlay BLE variant AND this iPhone model (iPhone 16
193+
/// family / iPhone 17e) is known to trigger its firmware bug — connections can stall and are
194+
/// automatically retried, so slower-than-normal connects are expected. Shown as a standing
195+
/// notice in settings (with a detail view), not a transient alert.
196+
var connectionSlownessExpected: Bool {
197+
return UIDevice.hasPossibleInPlayBLEIssues && pumpManager.usingInPlayPod == true
198+
}
199+
200+
/// A host asked the pump to provide background heartbeats on a combination needing the eager-connect
201+
/// mitigation: wakes come from link drops rather than the usual timer probe, so they're less regular.
202+
var bleHeartbeatDegraded: Bool {
203+
return pumpManager.bleHeartbeatDegradedForThisPod
204+
}
205+
192206
var isScheduledBasal: Bool {
193207
switch basalDeliveryState {
194208
case .active(_), .initiatingTempBasal:
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//
2+
// InPlayConnectionInfoView.swift
3+
// OmnipodKit
4+
//
5+
// Detail screen behind the persistent "slow connections expected" notice shown in pod
6+
// settings when an InPlay-variant DASH pod is paired with an affected iPhone model
7+
// (iPhone 16 family or iPhone 17e). See BluetoothManager's eager-connect watchdog.
8+
//
9+
// Copyright © 2026 LoopKit Authors. All rights reserved.
10+
//
11+
12+
import SwiftUI
13+
import UIKit
14+
import LoopKitUI
15+
16+
struct InPlayConnectionInfoView: View {
17+
18+
var body: some View {
19+
List {
20+
Section {
21+
VStack(alignment: .leading, spacing: 12) {
22+
HStack(spacing: 10) {
23+
Image(systemName: "exclamationmark.triangle.fill")
24+
.foregroundColor(.orange)
25+
.imageScale(.large)
26+
Text(LocalizedString("Slower Connections Expected", comment: "Title on InPlay connection info view"))
27+
.font(.headline)
28+
}
29+
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))
30+
.fixedSize(horizontal: false, vertical: true)
31+
}
32+
.padding(.vertical, 8)
33+
}
34+
35+
Section(header: SectionHeader(label: LocalizedString("What to Expect", comment: "Section header on InPlay connection info view"))) {
36+
VStack(alignment: .leading, spacing: 10) {
37+
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"))
38+
.fixedSize(horizontal: false, vertical: true)
39+
Text(LocalizedString("Pairing a new pod may also need extra time or an additional attempt.", comment: "InPlay connection info: what to expect body 2"))
40+
.fixedSize(horizontal: false, vertical: true)
41+
}
42+
.padding(.vertical, 4)
43+
}
44+
45+
Section(header: SectionHeader(label: LocalizedString("Pump Heartbeat", comment: "Section header on InPlay connection info view"))) {
46+
VStack(alignment: .leading, spacing: 10) {
47+
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"))
48+
.fixedSize(horizontal: false, vertical: true)
49+
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"))
50+
.fixedSize(horizontal: false, vertical: true)
51+
}
52+
.padding(.vertical, 4)
53+
}
54+
55+
Section(header: SectionHeader(label: LocalizedString("What You Can Do", comment: "Section header on InPlay connection info view"))) {
56+
VStack(alignment: .leading, spacing: 10) {
57+
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"))
58+
.fixedSize(horizontal: false, vertical: true)
59+
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"))
60+
.fixedSize(horizontal: false, vertical: true)
61+
}
62+
.padding(.vertical, 4)
63+
}
64+
}
65+
.insetGroupedListStyle()
66+
.navigationBarTitle(LocalizedString("Pod Connections", comment: "Navigation bar title for InPlay connection info view"), displayMode: .inline)
67+
}
68+
}
69+
70+
struct InPlayConnectionInfoView_Previews: PreviewProvider {
71+
static var previews: some View {
72+
NavigationView {
73+
InPlayConnectionInfoView()
74+
}
75+
}
76+
}

‎OmnipodKit/PumpManagerUI/Views/OmniSettingsView.swift‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,15 @@ struct OmniSettingsView: View {
289289
.padding(.top,5)
290290
}
291291
.buttonStyle(PlainButtonStyle())
292-
.disabled(!viewModel.hasConnection || sendingTestBeepsCommand)
292+
// Not gated on hasConnection. That var means different things by pod type — the
293+
// pod link for BLE pods, whether ANY RileyLink is connected (independent of pod
294+
// availability) for Eros; see OmniPumpManager.hasConnection. In neither case does
295+
// "not connected right now" mean the command can't run: playTestBeeps goes through
296+
// the normal command path, which acquires the link itself. The icon still greys out
297+
// to show there is no live link, but the button stays tappable, so a beep can be
298+
// used to check whether the pod is actually reachable. Every other action on this
299+
// screen gates on podOk rather than on connectivity.
300+
.disabled(sendingTestBeepsCommand)
293301

294302
headerImage
295303

@@ -318,6 +326,48 @@ struct OmniSettingsView: View {
318326
}
319327
}
320328

329+
// Persistent advisory for InPlay-variant pods on affected iPhone models (iPhone 16
330+
// family / iPhone 17e): connection establishment can stall and is retried
331+
// automatically, so slower-than-normal connects are expected. Tap for details.
332+
if viewModel.connectionSlownessExpected {
333+
Section {
334+
NavigationLink(destination: InPlayConnectionInfoView()) {
335+
HStack(spacing: 10) {
336+
Image(systemName: "exclamationmark.triangle.fill")
337+
.foregroundColor(.orange)
338+
VStack(alignment: .leading, spacing: 2) {
339+
Text(LocalizedString("Slower Connections Expected", comment: "Title of InPlay connection notice row"))
340+
.font(Font.subheadline.weight(.semibold))
341+
Text(LocalizedString("This pod and phone combination can be slow to connect.", comment: "Subtitle of InPlay connection notice row"))
342+
.font(.footnote)
343+
.foregroundColor(.secondary)
344+
}
345+
}
346+
}
347+
}
348+
}
349+
350+
// Advisory: a host requested pump-provided background heartbeats. On these combos the
351+
// normal (StartDelay) heartbeat probe can't be used, so wakes come from link drops
352+
// instead — workable, but less regular than on unaffected pods.
353+
if viewModel.bleHeartbeatDegraded {
354+
Section {
355+
NavigationLink(destination: InPlayConnectionInfoView()) {
356+
HStack(spacing: 10) {
357+
Image(systemName: "exclamationmark.triangle.fill")
358+
.foregroundColor(.orange)
359+
VStack(alignment: .leading, spacing: 2) {
360+
Text(LocalizedString("Reduced Background Wake-Ups", comment: "Title of BLE heartbeat degraded notice row"))
361+
.font(Font.subheadline.weight(.semibold))
362+
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"))
363+
.font(.footnote)
364+
.foregroundColor(.secondary)
365+
}
366+
}
367+
}
368+
}
369+
}
370+
321371
let lifeState = self.viewModel.lifeState
322372
Section(header: SectionHeader(label: LocalizedString("Actions", comment: "Section header for Actions section"))) {
323373
// If need to pair a pod, display this as the only action

0 commit comments

Comments
 (0)