Skip to content

Stale pairing connect teardown and BLE heartbeat for direct-BLE DASH pods - #155

Open
ps2 wants to merge 3 commits into
next-devfrom
fix/pairing-connect-teardown-and-ble-heartbeat
Open

Stale pairing connect teardown and BLE heartbeat for direct-BLE DASH pods#155
ps2 wants to merge 3 commits into
next-devfrom
fix/pairing-connect-teardown-and-ble-heartbeat

Conversation

@ps2

@ps2 ps2 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Forward-ports the fixes that landed on #141 after the eager-connect backport, none of which are on next-dev.

next-dev already has the eager connect watchdog (#133, 366380d), so that commit is not carried here. The three that follow it are.

What this brings

c7153cb — BLE heartbeat for DASH pods not using a RileyLink

setBLEHeartbeatRequest branched on podType.mayUseRileyLink, which is true for DASH as well as Eros because DASH can use a RileyLink under Pod Keep Alive. A DASH pod on direct BLE with Pod Keep Alive off therefore took the RileyLink branch and only set timerTickEnabled on a provider with no devices — provideHeartbeat was never assigned and the delayed-connect probe that produces background wakes was never scheduled. Loop asked for a heartbeat, we logged it, and dropped it.

With no wake source the app stays suspended until something else wakes it: Loop Failure alerts after gaps of 20 to 264 minutes that end the instant the app is foregrounded. 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 and masks it.

Branches on whether a RileyLink is actually in use instead. The other mayUseRileyLink call sites want the "may" semantics and are unchanged.

Already on the dev line independently as #153; this is its first appearance on next-dev.

8f9b355 + 761e4ac — stale pairing connect teardown

This one is a regression already shipping on next-dev, not a new improvement. The zombie heuristic came in with #133.

didDiscover treated a pairable pod heard while .connecting with no watchdog as a stale connect and cancelled it. That cannot be distinguished from a connect issued moments ago — a peripheral keeps advertising until the link is established, and CoreBluetooth reports .connecting from the instant connect() is called.

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

761e4ac drops the heuristic and closes 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, 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.

8f9b355 only narrowed the misfire and is superseded by 761e4ac, which removes the pairingConnectIssuedAt stamp and pairingConnectZombieSeconds it added. Both are carried so 761e4ac's message resolves the SHA it references; the net effect is one commit's worth of change.

Conflicts

None. All three cherry-pick clean onto next-dev at 6106c1c.

The PR-141 conflict resolutions do not apply here — those existed only because dev predates the Pod Keep Alive rework (#125). c7153cb uses self.state.podKeepAlive, which next-dev already has, so it applies as written.

Result

Net change: BluetoothManager.swift +13/−24, OmniPumpManager.swift +10/−1.

BluetoothManager.swift comes out byte-identical to the #141 head. OmniPumpManager.swift still differs from the dev line by the expected Pod Keep Alive divergence, with the heartbeat hunk landing correctly.

Verification

** BUILD SUCCEEDED ** — LoopWorkspace next-dev, Loop scheme, iPhone 17 simulator, with this branch checked out for OmnipodKit. The usesRileyLink build break that blocks the dev line (#148) does not exist on next-dev.

Not exercised on hardware — the same gap #141 flags. The pairing loop wants a non-iPhone-16/17e phone pairing a pod; the heartbeat fix wants a DASH pod on direct BLE with Pod Keep Alive off and a networked CGM.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UVHbBJsuTRzW33Kne1CJt3

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

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

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

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

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

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

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

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

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

(cherry picked from commit 5a32565)
…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.

(cherry picked from commit d70d7b8)
@marionbarker

Copy link
Copy Markdown
Contributor

Test

✅ successful test of ble-heartbeat using CGM with no heartbeat

Configuration

Start with next-dev branch, commit 40df5dd and then point OmnipodKit to this PR and build onto an SE 2nd gen phone that is connected to an rPi DASH and is using Nightscout as a CGM (no heartbeat).

Narrative

After the build completes, confirm the rPi DASH is still connected.
The rPi is not capable of waking up from a timed response from Loop.
Disconnect the rPi DASH and pair an actual DASH.

Reproduce the issue

Restore OmnipodKit to the original commit, 6106c1c, which has the Pod Keep Alive cleanup that introduced the issue.

  • Rebuild on the phone.
  • Pair/Prime/Insert DASH SAW pod
  • CGM does not have a heartbeat (NS as a CGM)
  • wait for green loop
  • 15:07 stop xcode, unplug phone, lock phone
  • demonstrate that Loop stops looping
  • 15:23 - unlock phone, see Signal Loss with immediate restoration

Demonstrate the fix

Point OmnipodKit back to this PR and rebuild

  • wait for green loop
  • 15:27 stop xcode, unplug phone, lock phone
  • demonstrate that Loop continues looping while locked
  • 15:45 - unlock phone, see green loop and history shows autoboluses while locked

✅ With PR, DASH pod continues to wake on command at 5 minute intervals

@marionbarker marionbarker left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approve from test (ble-heartbeat fix) and code review (cancel stale pairing deterministically).

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.

2 participants