Skip to content

Backport: eager connect watchdog for InPlay/iPhone-16 wedges - #141

Open
ps2 wants to merge 4 commits into
devfrom
backport/eager-connect-dev
Open

Backport: eager connect watchdog for InPlay/iPhone-16 wedges#141
ps2 wants to merge 4 commits into
devfrom
backport/eager-connect-dev

Conversation

@ps2

@ps2 ps2 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Backport of #133 to the dev line, squashed to a single commit.

What it brings

Mitigation for 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, plus a persistent notice in pod settings explaining the slower connections and a detail screen behind it.

It also carries the later fixes from that branch:

  • O5 fresh-advert connect — the fast-connect path was nested inside a DASH-only branch, so an Omnipod 5 could never take it. Foreground connects measured 5.9s against 0.4s for DASH.
  • Foreground-state seed — a pump manager constructed after the app has already become active never saw didBecomeActive, so isAppForeground stayed false and the link was idle-disconnected ~4s after each command while the user was on screen.
  • Test-beeps button no longer gates on hasConnection.

Conflicts

Rebased onto dev at 2c603f7. Two conflicts, both with #142 (the Pod Keep Alive backport), which introduced newer accessors that this branch predated.

Neither resolution is a judgment call — each reproduces exactly what next-dev already carries, that being the lineage where both changes coexist.

1. BluetoothManager.shouldHoldConnection — kept dev's podKeepAliveKeepsConnectedInBackground accessor and layered the eager-connect early return above it:

if isAppForeground { return true }
if let peripheral = keepAlivePeripheral, shouldUseEagerConnect(for: peripheral) { return true }
return podKeepAliveKeepsConnectedInBackground

Byte-identical to next-dev.

2. OmniPumpManager pairing auto-enable — a genuine split; took each side's newer half, which is again what next-dev has:

if self.usingInPlayPod == true && UIDevice.hasPossibleInPlayBLEIssues {   // ours (consolidated helper)
    if self.state.podKeepAlive == .disabled {                            // dev's, from #142

Storage.shared.podKeepAlive.value no longer appears anywhere in the tree. The backport removes dev's local OmniPumpManager.iPhoneWithPossibleInPlayIssues and ships OmnipodKit/Common/UIDevice.swift with the consolidated helper, so there is no leftover duplicate — zero references remain to the old name.

Verification

** BUILD SUCCEEDED **, exit 0 — LoopWorkspace scheme, iPhone 17 simulator, LoopWorkspace dev with this branch checked out for OmnipodKit.

One unrelated fix was required to get there: dev still has the usesRileyLink break that #146 fixed on main, so OmnipodKit/dev cannot currently build for any simulator destination, with or without this PR. Raised separately as #148. With that applied, this branch builds clean — it was the only failure.

Not exercised on hardware. Testing on an InPlay pod with an iPhone 16 or 17e is the remaining gap, which is why this stays a draft.

Note on the earlier "does not build against LoopWorkspace/dev" section

That section is obsolete and has been removed. It described OmnipodKit/dev requiring PumpHeartbeatRequest, a LoopKit type the pinned LoopKit did not define. That has since landed via LoopKit #599 — LoopWorkspace/dev now pins LoopKit a5beee96, which defines it. The dependency is satisfied and this PR is no longer blocked on a LoopKit bump.

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.
@ps2

ps2 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current dev (2c603f7) and reconciled the conflicts with #142 (Pod Keep Alive backport). Still a single squashed commit.

Conflicts, and how they were resolved

Both were the same shape: #142 introduced newer accessors on dev, while this backport was written against the pre-#142 forms. In each case the resolution reproduces exactly what next-dev already carries — the lineage where both changes coexist — rather than being a judgment call.

1. BluetoothManager.shouldHoldConnection

dev replaced the direct Storage.shared lookup with the podKeepAliveKeepsConnectedInBackground accessor. Kept dev's accessor and layered the eager-connect early return on top:

if isAppForeground { return true }
if let peripheral = keepAlivePeripheral, shouldUseEagerConnect(for: peripheral) { return true }
return podKeepAliveKeepsConnectedInBackground

Byte-identical to next-dev.

2. OmniPumpManager pairing auto-enable

A genuine split — took each side's newer half, which is again what next-dev has:

if self.usingInPlayPod == true && UIDevice.hasPossibleInPlayBLEIssues {   // ours (consolidated helper)
    if self.state.podKeepAlive == .disabled {                            // dev's, from #142

Storage.shared.podKeepAlive.value is gone from the tree entirely after this. The commit removes dev's local iPhoneWithPossibleInPlayIssues and ships OmnipodKit/Common/UIDevice.swift with the consolidated helper, so there's no leftover duplicate — zero references remain to the old name.

The original blocker is gone

This PR was opened noting it couldn't build until dev's LoopKit gained PumpHeartbeatRequest. That landed via LoopKit #599, and LoopWorkspace dev now pins a5beee96, which has it. No longer blocked.

Build

** BUILD SUCCEEDED **, exit 0 — LoopWorkspace scheme, iPhone 17 simulator, LoopWorkspace dev with this branch checked out for OmnipodKit.

That required one unrelated fix: dev has the same usesRileyLink break that #146 fixed on main, so OmnipodKit/dev currently cannot build for any simulator destination. Raised separately as #148. With that applied, this branch builds clean — it was the only failure.

Still a draft: hardware testing on an InPlay pod + iPhone 16/17e is the remaining gap.

@ps2
ps2 marked this pull request as ready for review September 4, 2026 22:20
itsmojo
itsmojo previously approved these changes Sep 5, 2026

@itsmojo itsmojo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge looks good and basic functionality checks out on a non-iPhone 16/17e. Unfortunately I am unable to perform any real testing of the new eager connect functionality without having an iPhone that exhibits the problematic behavior with an InPlay BLE DASH pod.

setBLEHeartbeatRequest branched on podType.mayUseRileyLink, which is true
for DASH as well as Eros because DASH can use a RileyLink under the Pod
Keep Alive option. A DASH pod on direct BLE with Pod Keep Alive off
therefore took the RileyLink branch and only set timerTickEnabled on a
RileyLink provider that has no devices. provideHeartbeat was never
assigned and BlePodComms.setHeartbeatRequest was never called, so the
delayed-connect probe that produces periodic background wakes was never
scheduled. issueHeartbeatIfNeeded() also gates on provideHeartbeat, so
even a probe that did fire would not have notified Loop.

Loop was requesting the heartbeat correctly -- DeviceDataManager sets
pumpManagerMustProvideBLEHeartbeat when the CGM cannot provide one -- and
the request was logged and then dropped. With no wake source the app
stayed suspended in the background until something else woke it. Seen as
Loop Failure alerts after gaps of 20 to 264 minutes that end the instant
the app is foregrounded; a report from an affected DASH pod shows
provideHeartbeat: false moments after a setBLEHeartbeatRequest log line.

Only reachable when the CGM cannot supply the heartbeat itself, so it
needs a remote or networked CGM such as Nightscout. A BLE Dexcom provides
its own heartbeat and masks it.

Branch on whether a RileyLink is actually in use instead: Eros always,
DASH only under the RileyLink Pod Keep Alive option. The other
mayUseRileyLink call sites want the "may" semantics and are unchanged.
@ps2

ps2 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Added a second commit (0f3295f) for a separate background-wake bug found while testing this branch on a DASH pod with Nightscout Remote CGM.

setBLEHeartbeatRequest branched on podType.mayUseRileyLink, which is true for DASH as well as Eros because DASH can use a RileyLink under Pod Keep Alive. A DASH pod on direct BLE with Pod Keep Alive off took the RileyLink branch and set timerTickEnabled on a provider with no devices, so provideHeartbeat was never assigned and BlePodComms.setHeartbeatRequest was never called — the delayed-connect probe that produces periodic background wakes was never scheduled. issueHeartbeatIfNeeded() also gates on provideHeartbeat, so even a probe that fired would not have notified Loop.

Loop was asking correctly: DeviceDataManager.pumpManagerMustProvideBLEHeartbeat is true when the CGM cannot provide a heartbeat, and the request reaches us — the [heartbeat] setBLEHeartbeatRequest(last=... interval=300s) line is right there in the log — and then gets dropped. With no wake source the app stays suspended until something else wakes it. In one report: Omni activity stops at 06:33:12 on background — disconnecting, resuming heartbeat probe, nothing at all for 20m32s, Loop has not completed successfully in 20 minutes at 06:53:37, then foreground at 06:53:44 and connected in 2.9s. Same shape at 29, 39 and 264 minutes on previous days. The report shows provideHeartbeat: false under a minute after a setBLEHeartbeatRequest log line, which is the direct fingerprint.

It needs a CGM that cannot supply its own heartbeat, so remote/networked CGMs like Nightscout — a BLE Dexcom masks it entirely. That is presumably why it has gone unreported.

The fix branches on whether a RileyLink is actually in use (Eros always; DASH only under the RileyLink Pod Keep Alive option) rather than whether one is possible. I checked the other six mayUseRileyLink call sites — RL notification registration, forgetBluetoothManager, checkRileyLinkBattery, and the two in OmniPumpManagerState — and they all genuinely want the "may" semantics, so they are untouched.

This is orthogonal to the watchdog, so happy to split it into its own PR against dev if you would rather keep this branch focused — say the word.

Separately, one thing in this branch worth a look. The new [foreground] connected Xs after foregrounding metric reads high: a log line at 06:33:05 says 684.2s after foregrounding when the foreground transition was at 06:33:04 and the connect took about a second — 684.2s back-dates to an earlier foregrounding at 06:21:41. foregroundConnectWaitSince[id] is only set when nil (BluetoothManager.swift:1153) and only cleared in didConnect (:1631), so nothing resets it on background; a foreground that does not connect, then a background, then a later foreground reports the whole span including background time. Since that number is the evidence for whether the watchdog is helping, it is currently flattering to it. Instrumentation only, no dosing impact.

@marionbarker

Copy link
Copy Markdown
Contributor

Test

This test applies the eager-connect backport to iAPS.
✅ successful test but will acquire more data

  • N.B. The FreeAPS.xcodeproj/project.pbxproj must be updated for iOS 17.6 minimum deployment

Configuration

Use SE 2nd gen phone running iOS 18 using Nightscout as a CGM (no heartbeat) and OmnipodKit

Test Narrative

  • First I made a fresh clone of iAPS and made sure the dev branch builds
  • iAPS uses forks hosted under the Artificial-Pancreas GitHub name but I wanted to point to the upstream repositories for the latest updates
    • I created a script to add a remote for each submodule called original-repo and then check out a unique branch name original-repo-$branch where branch is defined by PROJECTS in define_common.sh tailored for iAPS (the scripts were modified from those found in LoopWorkspace/Scripts)
  • I built again using the tip of all the submodules at the upstream repositories
    • In order to build, I edited FreeAPS.xcodeproj/project.pbxproj for 17.6 minimum deployment
    • ✅ I tested using the rPi DASH which connected with no error
  • I did then modified OmnipodKit to include the first commit from this PR, see details below
    • ✅ I tested using the rPi DASH which connected with no error
    • ✅ I paired an Omnipod 5 with no problem
  • I remembered the second commit and added it, rebuilt and o5 pod continues to work(I'll let the test configuration run for a while and harvest data later)

Updating OmnipodKit to include PR 141

This is starting from iAPS directory, dev branch, where each submodule has a remote pointing to the original repository with a unique branch name.

This is the first commit from 141 (I forgot about the second one, but that only affects the app staying awake when the CGM does not have a heartbeat.

cd OmnipodKit
git switch original-repo-dev
git pull
git switch -c original-repo-dev_plus_pr141_eager_connect
git cherry-pick 5a7350a
cd ..
xed .

I built to the test phone and confirmed the DASH selection paired with an rPi DASH pod.
✅ I then paired an Omnipod 5 pod with no issues.

At this point I remembered there are 2 commits, so I added the next one:

cd OmnipodKit
git cherry-pick 0f3295f
cd ..
xed .

I rebuilt, unplugged the phone from Xcode and locked the phone at 14:04 PDT
✅ Unlocked to confirm it continues to Loop

The zombie check in didDiscover treats a pairable pod heard while
.connecting with no watchdog as a stale connect and cancels it. That is
right for a connect abandoned by an earlier attempt, but it cannot tell
that case from a connect we issued moments ago: a peripheral keeps
advertising until the link is actually established, and CoreBluetooth
reports .connecting from the instant connect() is called, so an advert
inside the first second or two is the normal case.

discoverPods pre-connects any already-known disconnected pod and then
starts the discovery scan. timedConnect only arms the watchdog when
shouldUseEagerConnect is true, which needs an affected iPhone model, so
on any other phone that pre-connect is unsupervised. The scan hears the
pod ~0.8s later at state=.connecting, the zombie branch cancels it, the
teardown timer reissues timedConnect (again unsupervised), the next
advert ~1.5s later cancels that, and pairing loops at the advert
interval without ever completing. Two Xcode captures from an iPhone SE
pairing an Omnipod 5 show 62 and 104 consecutive cycles of

  [pairing] heard pod ... pairable=true state=1
  [pairing] ... stuck in .connecting with no watchdog -- cancelling zombie connect
  [pairing] reconnecting to pairable pod ... after zombie teardown

Pairing with a pod that is not yet a known device takes the state=0
path and races the same window: it worked in earlier testing because
the ~1.1s connect landed before the next advert.

Stamp when the current pairing connect was issued and only treat a
.connecting pod as a zombie once that connect is older than
pairingConnectZombieSeconds (default 5s, past a healthy connect and
inside the pairing budget). The stamp is separate from
connectRequestedAt, which is deliberately kept across retries for the
latency metric, and is cleared on didConnect and didFailToConnect.
@ps2

ps2 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

@marionbarker — your two Trio captures (dev and PR 1300) show why pairing failed, and it is a bug in this branch, not Trio and not the O5 crypto. Pushed a fix as 5a32565.

What the logs show. Both captures are the same loop, every ~1.6s, forever (62 cycles in one, 104 in the other):

[pairing] heard pod 07C5C507… pairable=true state=1
[pairing] pairable pod … stuck in .connecting with no watchdog — cancelling zombie connect
[pairing] reconnecting to pairable pod … after zombie teardown

Mechanism. discoverPods pre-connects any already-known disconnected pod and then starts the discovery scan. timedConnect only arms the watchdog when shouldUseEagerConnect is true, which needs an iPhone 16/17e — on your SE every pairing connect is unsupervised. The scan hears the pod ~0.8s later at state=.connecting, and the new zombie branch (.connecting && !isConnectWatchdogActive) takes that as a stale connect and cancels it. The teardown timer reissues timedConnect, again unsupervised, the next advert ~1.5s later cancels that, and so on. The heuristic — we heard it advertise, so it cannot be in a live connection — is true for an abandoned connect and false for one we issued a second ago: a peripheral keeps advertising until the link is actually up, and CoreBluetooth reports .connecting from the instant connect() is called.

Why Loop and iAPS paired for you. Timing, not immunity. Those started with a pod that was not yet a known device, so no pre-connect; the first advert arrived at state=0, took the Connecting … in discovery mode path, and finished in ~1.1s, just inside the advert interval. The PR-1300 capture did exactly that on its first attempt and got all the way into O5 pairing, which then failed at SPS2 (pod acked, never answered → incorrectResponse). That made the pod a known device, and every retry after it — including the whole dev session an hour later — hit the pre-connect path and the deterministic loop. The SPS2 failure itself is a separate question I cannot settle from the log; it only matters here because it is what made the pod known.

Fix (5a32565). Stamp when the current pairing connect was issued and only treat a .connecting pod as a zombie once that connect is older than pairingConnectZombieSeconds (default 5s — past a healthy ~1s connect, inside the 40s pairing budget; overridable via UserDefaults like the other eager knobs). Kept separate from connectRequestedAt, which is deliberately cumulative for the latency metric.

Retest. I suspect the current pod is the one left half-paired by the SPS2 failure, so a fresh pod is the right test regardless. On the fixed build the log should have zero cancelling zombie connect lines during a normal pairing; if the gate does its job you will instead see the debug line connect in flight X.Xs — leaving it to complete on the advert that used to kill it.

The zombie branch is also in next-dev via #133, so Loop next-dev has the same latent bug — filing that separately.

…istic

A "zombie" was a pending connect left behind by a pairing attempt that
gave up without cancelling. The didDiscover branch tried to detect one
from an advert heard while the pod was .connecting, but that is equally
true of a connect issued a second ago, and on phones where the watchdog
does not arm it cancelled every in-flight pairing connect and looped
(see 5a32565, which only narrowed the misfire).

Remove the guess and close the source instead:

- endPodDiscovery cancels any .connecting peripheral. The previous
  autoConnectIDs exemption is what leaked the orphans: discovery adds
  the pod to autoConnectIDs before ending, so any later abandoned
  connect to a pod that had once been found was never cancelled. Only
  a .connected paired pod keeps the exemption.
- discoverPods cancels any unsupervised .connecting peripheral before
  issuing its own connects, so during discovery the only pending
  connect is ours and the discovery timeout owns it. This also covers a
  connect left pending across an app suspend or kill, which comes back
  through state restoration already .connecting.

didDiscover goes back to connecting only when .disconnected. The
pairingConnectIssuedAt stamp and pairingConnectZombieSeconds from
5a32565 go with the heuristic.
@ps2

ps2 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed d70d7b8, which supersedes 5a32565 — Pete pointed out the right fix, and it is simpler.

A zombie is only ever a pending connect that some path issued and then abandoned without cancelPeripheralConnection. If those paths cancel, there is nothing to detect, and the didDiscover heuristic — which tried to infer an orphan from an advert heard while .connecting — is unnecessary. It was also unsound: an advert during .connecting is equally true of a connect issued one second ago, and 5a32565 only narrowed that misfire with a timer rather than removing it.

So d70d7b8 removes the heuristic (and the 5s gate and its state with it) and closes the source:

  • endPodDiscovery cancels any .connecting peripheral. The old !autoConnectIDs exemption was the leak. Discovery adds the pod to autoConnectIDs before ending, so once a pod had been found once — say by an attempt that then failed in setup, like the SPS2 failure in Marion's capture — any later abandoned connect to it was never cancelled. That is the orphan the original branch was written to catch. Only a .connected paired pod keeps the exemption.
  • discoverPods cancels any unsupervised .connecting peripheral before issuing its own. So during discovery the only pending connect is ours and the discovery timeout owns it. This also covers a connect left pending across an app suspend/kill, which comes back through state restoration already .connectingwillRestoreState deliberately defers that cleanup and updateConnections only catches non-autoconnect pods.

didDiscover is back to connecting only when .disconnected. Net −37 lines. Retest guidance is unchanged: a normal pairing on a fresh pod should show no cancel/reconnect churn; the new discoverPods: Cancelling stale connect line will appear only if a previous attempt left something pending.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants