Skip to content

Connect-on-demand + reading-scheduled BLE heartbeat + connection policy (DASH C00A own-pod guard, Pod Keep Alive, O5 fault research)#4

Open
loopkitdev wants to merge 97 commits into
loop-next-devfrom
o5-integration
Open

Connect-on-demand + reading-scheduled BLE heartbeat + connection policy (DASH C00A own-pod guard, Pod Keep Alive, O5 fault research)#4
loopkitdev wants to merge 97 commits into
loop-next-devfrom
o5-integration

Conversation

@loopkitdev

@loopkitdev loopkitdev commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Summary

DIY OmnipodKit work accumulated on o5-integration, targeting loop-next-dev. Major pieces:

  • Connect-on-demand + reading-scheduled BLE heartbeat. The pod is normally disconnected + advertising; it connects per command and idle-disconnects. When Loop needs a pump-provided heartbeat (e.g. a network CGM), a StartDelay connect probe wakes the app, scheduled from the CGM reading cadence via the new PumpManager.setBLEHeartbeatRequest API (requires the coupled LoopKit PR). Field-validated on a real O5 pod in deep-idle background.

  • Connection policy (DASH + O5): foreground keep-alive / background disconnect; a ~15s idle window so a Loop cycle's status read and follow-up dose share one connection.

  • StartDelay heartbeat fixes: integer StartDelay (a fractional NSNumber is rejected with CBErrorDomain Code=1), backoff on connect failure (no tight loop), and foreground suppression (StartDelay is a background-only mechanism).

  • DASH C00A connectionless fault detection, gated to our own pod (unique BLE identity) so a nearby stranger's faulted pod can't drive detection/connect/probe (no false alarm, no spurious connect/scan-suppression).

  • O5 advertising fault research in O5_ADVERTISING_FINDINGS.md: an induced occlusion flips the advert service-UUID suffix …00…02; a connectionless O5 fault scan is documented but not yet implemented.

  • Pod Keep Alive respected. Connect-on-demand's disconnects (15s idle + on background) previously ignored the Pod Keep Alive setting, defeating it for iPhone 16/17e + InPlay (Atlas) DASH pods. Now the pod is held connected while a background keep-alive mode (silentTune/rileyLink) is selected; .disabled/.whenOpen are unchanged. ⚠️ Not device-tested — no InPlay-type pods were available.

⚠️ Coupling — must merge together

The heartbeat scheduling depends on a shared LoopKit protocol addition. Merge together with the LoopKit and Loop PRs — OmnipodKit and Loop will not compile without the LoopKit change. (Cross-links added below.)

Note on next-dev pin

next-dev's .gitmodules references LoopKit/OmnipodKit (a non-writable fork of loopandlearn); this work lands on loopkitdev/OmnipodKit, so the eventual next-dev submodule bump will need to reference the loopkitdev fork for OmnipodKit.

🤖 Generated with Claude Code

https://claude.ai/code/session_017Tbv6GBAMmGATWp6zwBDy7

Coupled PRs — merge together (three-way dependency)

Loop and OmnipodKit will not compile without the LoopKit protocol change; land all three together and bump the LoopWorkspace next-dev submodule pins in one commit.

ps2 and others added 30 commits June 30, 2026 17:15
…-log)

While connected, a pod can initiate a transfer (fault/alert) over the TP DATA/CMD
characteristics without us sending a command. Today those notifications are buffered
then flushed before the next command, so faults are only learned by polling GetStatus.

Stage 1 (this commit), gated OFF by default behind
UserDefaults key OmnipodKit.unsolicitedFaultListenerEnabled:
- Detect a pod-initiated transfer while idle (Dash: unsolicited RTS on cmd char;
  O5: data seq 0 on data char).
- Reuse the existing readMessagePacket() receive path on the serial sessionQueue to
  assemble the encrypted MessagePacket.
- Decrypt it (pure EnDecrypt) against a window of nonceSeq offsets and log which offset
  succeeds + the decrypted payload + all live sequence state.
- Does NOT mutate session sequence state and does NOT route alerts.

Purpose: confirm from field logs that pods push faults unsolicited, and capture the
nonceSeq offset needed to build stage 2 (advance live state + route to FaultEventCode)
correctly, instead of guessing sequence handling into dosing comms.
…onnect loop)

Field log showed the listener firing on a 5-byte cmd-char handshake (00000100f4)
DURING session key negotiation, then readMessagePacket timing out and DISCONNECTING
the pod -> reconnect loop. Three fixes:
- Arm the listener only after an encrypted session is established (manager.unsolicitedListenerArmed,
  set by BlePodComms: false on connect/disconnect, true after establishNewSession). The
  ~idle window during negotiation is no longer observed.
- Dash RTS trigger now requires a SINGLE 0x00 byte; multi-byte 0x00-prefixed handshakes
  are excluded.
- readMessagePacket gains disconnectOnUnresponsivePod (default true); the observer path
  passes false so a timeout is silent and never cancels the connection.
Per binary analysis: the pod is request/response on the connected link and only
ORIGINATES a status frame (carrying fault/alert flags) if we register periodic
status. Arms the dead listener without polling.

- configurePeriodicStatus(): post-establishment, best-guess SN0.0=<seconds>,GN0.0
  (feature N0/attr 0, ASCII decimal 60s) via the existing sendO5AidCommand path.
  UNCONFIRMED envelope — non-fatal, logs the exact bytes + ACCEPTED/REJECTED so we
  iterate feature/attr/encoding from field logs.
- Listener stage 2a: on a successful decrypt, commit the nonce advance
  (bleMessageTransportState.nonceSeq = winning offset) so a push doesn't desync the
  next command; logs decrypted payload (hex + ASCII). Stage 2b (parse->notifyPodFault)
  deferred until decrypt is confirmed in the field.

Gated by the same field-test flag. Every guess is a labeled knob.
Field log: SN0.0=60,GN0.0 (534e302e...) was being sent on every reconnect during a
faulted/incomplete pairing, feeding a connect -> register -> disconnect loop on a
screaming pod. configurePeriodicStatus now no-ops unless podState.isSetupComplete and
podState.fault == nil, so it only fires on a healthy established pod (and gives a clean
registration test there).
… run

- sendO5AidCommand: unconditional 'O5 AID RawResp' log of the pod's full decrypted
  response (hex + ascii + type/seq) BEFORE the prefix check throws it away. On a rejected
  registration this is the datum needed to iterate the SN0.0= envelope.
- configurePeriodicStatus: log pre/post transport state (nonceSeq/msgSeq/messageNumber/
  eapSeq) so we can confirm the exchange advanced sequences correctly and stayed in sync.
- Unsolicited push handler: best-effort Message decode of a decrypted push so a real fault
  frame shows its block structure (StatusResponse/DetailedStatus/PodInfoResponse), not raw
  hex. Non-fatal.

Pod status flags around the registration are captured by the post-connect getStatus that
already runs right after. No behavior change beyond logging.
Field result: SN0.0=60 is not a recognized pod command. The pod returns no response
(emptyValue), which OmnipodKit treats as an unresponsive pod and disconnects. On an
active pod this loops: reconnect -> negotiate -> SN0.0= -> emptyValue -> disconnect ->
reconnect. Confirmed twice (15:34, 15:37) on a healthy in-use pod.

Comment out the configurePeriodicStatus() call. The passive listener stays (it only
observes and never disconnects), but without a correct registration command the pod
does not push, so this is inert until we have real protocol intel. Do not send blind
command guesses to a live pod.
…sconnecting read)

Root cause of the earlier disconnect loop: SN0.0= is structurally a STANDARD S...= command,
which uses StringLengthPrefixEncoding (2-byte big-endian length prefix before the value) for
both O5 and DASH. It was built via O5AidCommands.setGetPayload (plain ASCII, no length prefix)
and sent via sendO5AidCommand. The pod got a frame whose value wasn't length-delimited, could
not parse the envelope, and stayed silent (emptyValue, never a NAK/response) — the signature of
an unparseable frame, not a rejected-but-understood command.

Fix:
- Build the payload with StringLengthPrefixEncoding.formatKeys(keys:["SN0.0=", ",GN0.0"],
  payloads:["60", ""]) -> 534e302e303d000236302c474e302e30 (16B, vs the 14B plain form).
- New BlePodMessageTransport.sendSlpeGetSetCommand: SLPE format on the way out, parseKeys on the
  response (the response is length-prefixed too, so sendO5AidCommand's plain prefix-strip cannot
  handle it), and a NON-disconnecting read so a still-wrong guess logs a failure instead of
  dropping a live pod.
- Re-enable configurePeriodicStatus() with the corrected encoding.

Instrumentation logs the wrapped request bytes, the raw SLPE response (SLPE RawResp), and pre/post
transport sequences.
…al issue

Console shows 4x centralManagerDidUpdateState / 'Recovered peripheral from autoConnectIDs'
in one process — suspected multiple leaked CBCentralManagers under the shared com.OmnipodKit
restore identifier (the likely root of the didConnect-never-fires pairing failure).

Add a per-instance ID (short UUID) to BluetoothManager, log INIT (with the creation call
stack) and DEINIT, and tag centralManagerDidUpdateState + the autoConnectIDs recovery with it.
The next capture then answers definitively: N INITs with no DEINITs = leaked centrals, and the
INIT call stack shows the creation site (podType setter / restore path / plugin lookup).
…instantiation

BluetoothManager's instance ID proved there's ONE central (same ID repeated = capture
duplication). But the plugin log had no ID, so 5x 'Instantiated' could be 5 real instances
or 1 duplicated. Loop's PluginManager.getPumpManagerTypeByIdentifier does principalClass.init()
per type lookup (pump AND cgm), so multiple real instances are plausible (benign — init only
logs, no central/state). Log a per-instance short UUID + object address so the next capture is
unambiguous: distinct IDs = real instances; one ID repeated = capture dup.
…l connects/disconnects

Registration is fire-and-forget — the pod sends no synchronous reply, so reading for N0.0=
always timed out with emptyValue (a false negative), never the actual result. Success is the
write being ACK'd; the real confirmation is the pod's first unsolicited PUSH ~interval later,
caught by the listener.

- New sendSlpeCommandFireAndForget: send SLPE command, treat sentWithAcknowledgment as success,
  no readMessagePacket. Advances msgSeq+1/nonceSeq+1 for the send (pod does the same on receipt).
- configurePeriodicStatus: use fire-and-forget, drop interval 60s -> 10s so a push arrives quickly.
- Log all central connect/disconnect/failToConnect events, tagged with the BluetoothManager
  instance ID + peripheral + reason/willReconnect.
… connect latency

Toward the 'faults via advertisement, connect on demand' model. Field data (fire-and-forget
build) showed a reconnect after ~3min idle takes ~28s and fails the first command
(podNotConnected) — because the central is NOT scanning during idle (bluetoothd isScanning:
False), so reconnect waits on iOS background scan + the pod's advertisement cadence.

- advertisementMonitorEnabled flag (default ON for field test).
- startScanning uses allowDuplicates when the flag is on; scanning is kept alive continuously
  (not stopped once autoConnect devices are known) so we observe advertisements while disconnected.
- didDiscover logs a full [ADV] dump for pod-identified peripherals: RSSI, state, connectable,
  service-UUID list (which changed between scans early on), manufacturer data, service data.
- timedConnect stamps every connect; didConnect logs the measured connect latency.

Note: iOS does not deliver advertisements for a CONNECTED peripheral, so [ADV] frames only
appear while disconnected — the next step is connect-on-demand + idle-disconnect so the pod is
disconnected (and thus advertising/observable) between commands.
…est)

Toward the advertisement model: instead of holding the pod connected, leave it disconnected
(and advertising/observable) between commands, connect on demand per session, disconnect when
idle, and scan while disconnected.

BluetoothManager (connectOnDemandEnabled flag, default ON):
- New autoReconnect() wraps the keep-connected reconnect sites (didDisconnect, didDiscover
  autoconnect, updateConnections, poweredOn recovery, retrieveKnownPod, connectToDevice, failed
  connect retry) and NO-OPs when the flag is on. Explicit pairing/discovery connects are unchanged.

PeripheralManager:
- configureAndRun: in connect-on-demand mode, connect on demand for the session instead of the
  forceful reconnect (the heuristic that caused ~28s disconnect-then-wait stalls).
- connectOnDemand(timeout:): issue a real connect() + wait for didConnect, logging measured latency.
- scheduleIdleDisconnectIfNeeded: ~4s after a session with an empty queue, cancelPeripheralConnection.

Continuous scanning (advertisementMonitor) + [ADV] logging now produce advertisement frames while
disconnected. Caveat: idle-disconnect can churn a NEW pod's activation between steps — active pods
are fine. Revert = clean rebuild with the flag off.
bleRunSession guarded 'manager.peripheral.state == .connected' and returned podNotConnected
before ever calling manager.runSession — so in connect-on-demand mode (pod normally disconnected)
every command failed instantly (podNotConnected in ~0.36s, no [connectOnDemand] line) and the
configureAndRun connect-on-demand hook was never reached.

Only require an existing connection in the classic held-connected mode. In connect-on-demand
mode, proceed to manager.runSession -> configureAndRun, which connects on demand (and re-establishes
the session) before the session block runs.
Per the RE spec §5 (deterministic normal<->alarm diff). beaconCaptureEnabled flag (default ON,
field-test only): scan withServices:nil (foreground, allowDuplicates) so we catch the pod's
alarm/beacon advertisement even if it uses a service UUID we don't yet filter on (the CE1F923D-…
128-bit alarm beacon). didDiscover logs a full raw dump — every service UUID string (128-bit
shows all 16 bytes), manufacturer data, localName, RSSI, state, connectable — tagged [BEACON]
for any CE1F923D-prefixed frame, [ADV] for normal pod frames. Diff a triggered-alert capture vs
normal to pin alarmCode/alertCode offsets + the stable background-filter UUID. Revert before merge.
…dNotConnected for all commands)

Chicken-and-egg: BlePodComms.manager is set only in omnipodPeripheralDidConnect (on connect) or
restore. In connect-on-demand the pod is normally disconnected and auto-reconnect is suppressed, so
on a fresh launch nothing connects, manager stays nil, and bleRunSession's 'guard let manager' bails
with podNotConnected — for every command — with no way to bootstrap the first connect.

Fix: BluetoothManager.peripheralManager(forIdentifier:) returns a known device's PeripheralManager
connected or not; bleRunSession adopts it from the device list when self.manager is nil in
connect-on-demand mode, so manager.runSession -> configureAndRun can start the first on-demand
connect. Also suppress the [SCAN] mfg-data log in beacon-capture (wildcard) mode to cut noise.
connectOnDemand issues the connect via runCommand + addCondition(.connect), but runCommand's
prelude guarded 'peripheral.state == .connected' and threw notReady immediately (the connect
starts from disconnected). reconnect() only worked because it ran on an already-connected pod.

Add allowDisconnected (default false) to runCommand; connectOnDemand passes true so the connect
command is allowed from the disconnected state and completes via the .connect condition/didConnect.
…scan starves connect)

The on-demand connect went to .connecting and never completed (didConnect never fired, 20s
timeout) because a continuous allowDuplicates/wildcard scan was running the whole time — on iOS
an active allowDuplicates scan starves connection completion (the radio never gets a connect
window; the scan even kept re-discovering the pod mid-connect).

- connectOnDemand: central.stopScan() before the connect; on failure, cancelPeripheralConnection
  to unstick the wedged .connecting state (so a disconnect/fail event fires).
- BluetoothManager.resumeScanIfNeeded(): restart the monitor/beacon scan on didDisconnect /
  didFailToConnect, only when nothing is connected/connecting — so we scan while disconnected but
  never during a command. Net: disconnected -> scan; command needed -> stop scan, connect, run,
  idle-disconnect -> resume scan.
…con capture

Suspend didn't change the advertisement (it's a state, not an alarm) and low-reservoir can't be
triggered above ~50U. Add a debug button (Pod Diagnostics -> Trigger Test Alert) that programs a
real expiration-reminder alert ~60s out via configureAlerts — mirrors updateExpirationReminder.
It's an alert not a fault (acknowledge to clear; pod stays usable), reservoir-independent, and
repeatable. When it fires the pod beeps and — per the RE spec — should flip its advertisement to
the AS/CE1F923D beacon, which the §5 [BEACON] logging then captures.

Note: reuses the expiration-reminder slot, so it overwrites the pod's expiration reminder — reset
it in settings after testing. Field-test only; remove before merge.
(a) DASH_BEACON_FINDINGS.md: handoff correction to the RE beacon spec. Alerts do NOT use the
CE1F923D non-connectable beacon — they ride the normal 16-bit advertisement, encoded in the 2nd
service UUID (C001 clear <-> C005 alert) and a 4-byte mfg status word (00020000 clear ->
000a0008 alert). Reversible on acknowledge. CE1F923D is presumed the fault-only path (untested).

(b) BluetoothManager.detectPodAlertStatus: prototype connectionless detection. Extracts the mfg
status word (end-anchored, before the address tail) on every pod advertisement, logs [POD-STATUS]
on change and [POD-ALERT] on clear<->alert transitions — no connection needed. Stage-2 TODO:
route a confirmed transition to the pump manager once the per-alert bit mapping is nailed down.
…atency

Going fully dark for the connect made iOS fall back to its sparse connection duty cycle (~11-14s,
sometimes >20s timeout). It's the allowDuplicates flood that starved the connect earlier, not
scanning per se — so connectOnDemand now drops the heavy monitor scan and runs a LIGHT
non-allowDuplicates scan during the connect, so iOS actively hears the pod (~1Hz) and completes
the connect fast. BluetoothManager stops this helper scan on didConnect (no scanning while
connected); the monitor scan is restored on the next disconnect via resumeScanIfNeeded.
…UUID

lowPowerMonitorEnabled (default ON): scan withServices:[alarm UUIDs] (currently [C005], the
confirmed alert-state 2nd service UUID) with allowDuplicates OFF, taking precedence over the
monitor/beacon scans. iOS then only delivers didDiscover — only wakes the app — when the pod
enters an alarm state; zero wakes during normal operation, and it carries into the background via
the existing State Preservation/Restoration (restore now saves this filter as servicesToScan).

Trade-off (see DASH_BEACON_FINDINGS.md): only catches enumerated alarm UUIDs (extend
alarmServiceUUIDs as more alert/fault types are captured); the clear transition isn't seen by
this filter — confirm on the next connect. resumeScanIfNeeded restarts it after a disconnect.
…te 128-bit alarm UUIDs

The RE binary model says alarms are 128-bit CE1F923D-…-0A<deviceId><TT> beacons (TT 02/03), but
our field captures show only 16-bit UUIDs (C001<->C005) and never a CE1F923D frame. To settle it:

- lowPowerMonitorEnabled default -> false, so beacon-capture wildcard runs and would catch a
  CE1F923D frame (our [BEACON] prefix match) if this pod ever emits one.
- [ADV]/[BEACON] now logs dt= (inter-frame delta) so the disconnected-advert cadence is directly
  measurable — the RE's DS-beacon-rate / periodic-wake question.
- alarmServiceUUIDs adds the candidate 128-bit AS/AST built from the RE template with deviceId
  guessed = pod address 179F0CF1 (UNCONFIRMED; harmless if wrong). Fix from a real [BEACON]
  capture before relying on them.
…capture

Loop's status polling triggered connect-on-demand every few min, and each attempt stopped the
wildcard scan (and mostly timed out), so the advert-cadence / CE1F923D capture kept getting
interrupted and dt reflected scan gaps, not the pod's true advertising rate. suppressCommandsEnabled
(default ON, field-test only): bleRunSession fails fast without connecting, so the pod is left
idle-disconnected and the wildcard scan runs continuously — a clean multi-minute window to measure
the true cadence and confirm whether a CE1F923D beacon ever appears. Revert before merge.
…yKey) for a timed wake

Testing whether a connect with StartDelay gives a timed (eventually background) wake — the periodic
wake the scan path can't (stable payload coalesces). delayedConnectProbeEnabled (default ON),
delayedConnectProbeSeconds (default 60): on pod discovery, stop the scan (so allowDuplicates doesn't
starve it) and issue connect(options:[CBConnectPeripheralOptionStartDelayKey: N]). didConnect logs
the measured delay ([delayedConnect] CONNECTED after Xs), then disconnects after 2s so the loop
re-arms on the next discovery. Runs alongside suppressCommands (pod otherwise idle). Field-test only.
…stent device log

- delayedConnectProbeSeconds default 60 -> 300 (real wake lands at StartDelay + ~40s iOS tail).
- Log pid= on issue and connect (os_log) — distinguishes a background WAKE (same pid) from a State
  Restoration RELAUNCH (new pid), the open question of whether StartDelay can launch a terminated app.
- Route the probe events to Loop's persistent device log via a new OmniConnectionDelegate
  .omnipodLogDeviceEvent (BlePodComms forwards to OmniPumpManager.logDeviceCommunication), so they
  survive background wakes / relaunch and land in the issue report even when Console isn't attached.
… survive relaunch)

The loop stalled (~1h43m gap observed) because it re-armed via didDiscover: disconnect -> resume
scan -> discover pod -> issue next probe. That only runs if the app stays awake after a disconnect;
when iOS suspended it between cycles, the re-arm never ran and there was no pending connect left to
wake on — dead until an external relaunch.

- Re-arm in didDisconnect/didFailToConnect directly (issue the next StartDelay connect), leaving a
  pending connect that survives suspension. No dependence on the scan.
- didConnect now treats ANY connect as a probe cycle while the probe is enabled (measured shows
  ?(restored) on a fresh process), so a restored connect after a State-Restoration relaunch also
  disconnects + re-arms instead of sitting connected and stalling the loop.
…iOS relaunch from manual open

willRestoreState + a new PID do NOT prove an iOS relaunch (both happen on a manual open too). Add
the signal that does: everForeground, set true on UIApplication.didBecomeActiveNotification. A
[delayedConnect] logged with everFg=false means iOS ran that process entirely in the background —
proof of a wake/relaunch the user did not initiate. Also log APP FOREGROUND / APP BACKGROUND
transitions (with PID) to the persistent device log for the timeline. Tag both [delayedConnect]
lines with everFg=.
…t-on-demand-focus mode

- DASH_BEACON_FINDINGS.md: write up the background wake/heartbeat strategy — StartDelay connect +
  State Restoration gives a periodic background wake that survives relaunch (proven: a new PID ran
  everFg=false ~1h42m), with the timing distribution, gotchas (re-arm in didDisconnect,
  willRestoreState != relaunch, force-quit caveat), and 'off by default; only when host requests
  heartbeats' guidance.
- Flip flags for connect-on-demand focus: suppressCommands OFF (commands run again),
  delayedConnectProbe OFF (no heartbeat), beaconCapture OFF (no wildcard), lowPowerMonitor ON
  (idle = alarm-only [C005] scan). Net: disconnected + alarm-scan while idle, connect on demand for
  commands — the target model, ready to measure connect-on-demand latency.
ps2 and others added 23 commits July 9, 2026 11:08
…usion fault's advert

Run-out-of-insulin (empty reservoir) fault: does it advertise C00A like the
occlusion (so the C00A-only scan catches ALL faults, type in the mfg
FaultEventCode byte), or a DIFFERENT 2nd UUID (which the C00A scan would MISS)?
The occlusion had b1=0x14=FaultEventCode -> if b1 tracks the fault code, other
faults get other UUIDs = C0(b1/2). Disable the C00A-only alarm scan and enable
the wildcard beacon capture so we log the full advert (all UUIDs + mfg)
regardless of which UUID this fault uses. Probe stays on so Loop keeps
delivering (to actually run the pod dry).
…keep)

Advertisement data (service UUIDs + mfg) is only available in didDiscover, never
after connecting. In connect-on-demand that fires during each command connect's
fresh-discovery scan (and on a C00A fault-scan wake), so we can log it there.
Ungate the deduped pod-advert device-log from beaconCapture to isPodFrame so it
runs in production (under advertisementMonitorEnabled) — real-world Issue Reports
now capture what the pod advertises, giving us field data to decode more
fault/alert states. Deduped by svcUUIDs|mfg so it logs a transition once.
… wait)

Root cause: a cross-thread deadlock over BlePodComms.podStateLock. A pod session
runs on the command queue HOLDING podStateLock (bleRunSession) and, via the
post-connect status fetch, reaches store(doses:in:) which blocks on an untimed
semaphore.wait() until Loop's DoseStore delegate confirms — but that completion
is dispatched to .main. Meanwhile the deactivation UI's forgetPod ->
handleDiscardedPodDosing runs on .main and blocks acquiring podStateLock.
Circular wait -> permanent silent hang (observed ~10 min until force-kill; the
pod itself deactivated fine).

Newly reachable for FAULTED pods via the recent handlePodUpdatesAsNeeded change
(flush when 'status != nil || isFaulted'): a faulted pod's getStatus returns nil,
so the blocking store() used to be skipped; now it runs while podStateLock is
held during the deactivate/discard race.

Fix: bound the wait at 10s. On timeout, return false so dosesForStorage retains
the doses for a later flush (idempotent — DoseStore dedupes by syncIdentifier);
returning releases podStateLock and breaks the deadlock. Guarantees liveness for
every store(doses:in:) caller. Legit stores complete in well under a second.
…quest

Simplify delayedConnectProbeActive to just heartbeatEnabled (set via
PumpManager.setMustProvideBLEHeartbeat) — remove the heartbeatProbeSuppressed
and delayedConnectProbeEnabled experiment knobs that gated it, so nothing can
stop the StartDelay heartbeat probe from running when Loop asks for a heartbeat.
Revert the fault-type-capture experiment defaults to production
(beaconCapture=false, lowPowerMonitor=true: C00A alarm scan + keep-alive on).
Remove dead field-test-only code, preserving production behavior (connect-on-demand,
C00A alarm/fault scan, foreground keep-alive, StartDelay heartbeat probe,
connectionless fault detection, field advert [ADV] logging):
- beaconCaptureEnabled (§5 wildcard-capture) + all usages: appIsForeground gate,
  keep-alive guard, the wildcard startScanning branch, resumeScan/didDiscover gates.
- Speculative CE1F923D beacon support: beaconUUIDPrefix, isBeaconFrame, [BEACON] tag.
- suppressCommandsEnabled measurement mode (+ BlePodComms bleRunSession block, didConnect term).
- Stray dead 'CBConnectPeripheralOptionStartDelayKey' expression in startScanning.
- DEBUG Trigger Test Alert: triggerTestAlert() in OmniPumpManager + DiagnosticCommands
  protocol + PodDiagnosticsView button.
Build verified (LoopWorkspace, device); no dangling references.
Drop 'field-test' framing and fix the now-wrong note claiming fault-listening and
the heartbeat don't coexist — they do (C00A scan + StartDelay probe both run while
idle). Update C005->C00A fault-scan description on lowPowerMonitorEnabled.
…agnostic OFF

- Remove the 12-frame Thread.callStackSymbols dump from BluetoothManager.init
  (debug scaffolding from the pairing-leak investigation; now just noise).
- unsolicitedFaultListenerEnabled defaults OFF (was field-test ON): it's a passive
  connected-mode diagnostic that logs pod-initiated frames but does not route alerts
  or mutate session state — production fault detection is the C00A advert scan. Kept
  as an opt-in UserDefaults diagnostic.
…ped design

Comment-only. Drop §5 / FAULT-CAPTURE / PROTOTYPE / stage-1/2 / 'experiment' framing
across BluetoothManager, BlePodComms, PeripheralManager(+OmnipodKit); reframe the
opt-in unsolicited-listener diagnostic accordingly and correct the inaccurate
'does not mutate session state' note (it advances the nonce to stay in sync; it
just doesn't route alerts).
Brings dev's Omnipod 5 support — Keychain-backed O5 registration store
(O5CertificateKeychain), the cert download/import UI (Omnipod5SupportView,
O5KeyFetchView, O5KeySetupView), App Attest provisioning (O5AppAttestService),
O5 comms/signing, and the OmniTests/O5 suite — alongside our connect-on-demand +
connectionless C00A fault detection + heartbeat probe + deactivate-deadlock fix.

Zero merge conflicts (the 7 overlapping files auto-merged in disjoint regions).
Verified our changes survived (bounded store() wait, dose-flush-on-fault, C00A
alarm scan, heartbeat-only delayedConnectProbeActive) and dev's O5 files present.
Build verified (LoopWorkspace, device).
Aligns our branch with the OmnipodKit that LoopWorkspace next-dev pins, so we
submit against current loop-next-dev. Brings its latest (OmniTests fault-time nil
for 0x0000, DASH reset-type fault time handling) on top of our connect-on-demand
+ connectionless C00A fault detection + O5 (loopandlearn/dev) work. One trivial
conflict (a blank line in the already-shared loopandlearn#85 TBR workaround). All our changes
verified intact; build green.
…stic capture

Add bleCaptureEnabled (default ON this build) for high-fidelity RE:
- Idle: wildcard advert scan (matches the pod in any state incl. O5 CE1F923D; no
  DASH-C00A assumption), allowDuplicates, keep-alive off so the pod keeps advertising.
- didDiscover: log every distinct pod advert; gate detectPodAlertStatus to podType.isDash
  so the DASH iBeacon decode never runs against an O5 advert.
- On connect: discover ALL services + characteristics, log each with properties, subscribe
  to every notify/indicate characteristic (not just command/data/heartbeat).
- didUpdateValueFor: device-log every value update (char UUID + raw hex).
Adds PeripheralManagerDelegate.logCaptureEvent -> BlePodComms.omnipodLogDeviceEvent.
…s O5 beeps)

The full-capture subscribe-all ran during applyConfiguration and enabled notify on
the command/data characteristics BEFORE the O5 AID handshake's enableNotifications,
breaking O5 message sequencing (incorrectResponse -> pod drops link -> no beeps).
Exclude the profile's own command/data/heartbeat characteristics from the capture
subscribe (the session flow owns their notify timing); only subscribe to unexpected
characteristics. O5 has none, so this is now a no-op for O5 — commands work again.
…e advert on command

Restore the debug test-alert trigger removed in cleanup 2/n: schedules an
expiration-reminder alert ~60s out (configureAlerts, pod-type-agnostic) so we can
capture the pod's alarm-state advertisement non-destructively — needed to see
whether the O5 service UUID (or only the mfg data) changes on an alarm. Behind the
diagnostics screen; revert before PR.
…nected

Wire up a test of the pod-driven periodic status the RE engineer characterized:
SN0.0=<seconds> arms a pod-side timer; the pod then pushes a clear 5-byte CMD
(2441) indication every period (300s Auto / 60s fault), which a connected app can
use as a heartbeat/fault wake. New periodicStatusEnabled flag (default ON):
- gate configurePeriodicStatus on it (was unsolicitedFaultListenerEnabled), interval 30s
- force keep-alive on so we stay connected to observe the pushes
- device-log every value update so the CMD nudge is captured
Revert before PR.
…ID envelope)

- Device-log the [periodic] arm/skip/ACK lines (were os_log only, so invisible in
  Issue Reports — we were blind to whether it armed).
- Revert the forced keep-alive: a failed arm no longer churns the connection; an
  armed pod is expected to hold the link itself.
- Skip arming for O5: the DASH SN0.0= SLPE form isn't valid for O5 (zero SN frames
  reached the O5 pod; O5 uses INS. AID commands), so don't send a wrong command to
  the running O5 pod. Wire the O5 AID envelope through sendO5AidCommands once the RE
  engineer provides the INS.-framed form. DASH arming unchanged.
…connected to observe push

RE engineer (2026-07-13): the periodic-status arm is the AID Set SN2.0=<seconds>
(S=Set, N2=periodic namespace + Status command token 2, .0=attr 0, =decimal seconds).
Our earlier SN0.0= used the undefined command token 0. Change to SET-only SN2.0=,
enable for O5 (drop the DASH-only skip), and re-enable forced keep-alive so the
armed connection persists to observe the pod's periodic CMD push. Still a
best-guess per the RE — the device-logged [periodic] arm result + any 2441 pushes
will tell us. Note: if the arm is rejected the connection will churn (force-quit).
Test hypothesis (user): the SN2.0= arm may make the pod signal 'status ready' via
its ADVERTISEMENT (svc UUID / mfg change) rather than a connected CMD push — and an
advert UUID change is what iOS can background-wake on. Stop forcing keep-alive so
the pod idle-disconnects after the arm and the wildcard advert scan runs; watch for
a ~30s advert change tied to the arm. Arm (SN2.0=30) still fires on each connect.
…tion policy

Replace the fixed-interval BLE heartbeat probe with one scheduled from the
CGM reading cadence, driven by the new LoopKit PumpManager.setBLEHeartbeatRequest
API (PumpHeartbeatRequest: last CGM reading date + expected interval).

- BluetoothManager.setHeartbeatRequest: compute the StartDelay so the wake lands
  no earlier than lastCGMReading + expectedInterval + buffer (20s, tunable), with
  a 60s floor. Refreshed each CGM reading; self-correcting via the immediate
  re-arm plus the existing 2-min heartbeat throttle.
- OmniPumpManager: implement setBLEHeartbeatRequest; bridge legacy
  setMustProvideBLEHeartbeat through it. BlePodComms forwards the request.
- Connection policy (DASH + O5): re-enable foreground keep-alive / background
  disconnect by defaulting bleCaptureEnabled off; widen the idle-disconnect
  window 4s -> 15s so a Loop cycle's status read and follow-up dose share one
  connection.
- Back-burner the advertising/indication periodic-status experiment: default
  periodicStatusEnabled off. Keep DASH C00A advertising fault detection and the
  StartDelay heartbeat.

Scaffolding (bleCapture, periodicStatus, triggerTestAlert) remains behind
now-off flags for a later cleanup commit.
…round-only

Three fixes to the pump-provided BLE heartbeat (StartDelay probe):

- Integer StartDelay. CBConnectPeripheralOptionStartDelayKey requires a whole
  number of seconds; the computed delay was a fractional Double, so every probe
  connect was rejected with CBErrorDomain Code=1 'One or more parameters were
  invalid'. Round to Int seconds.
- Failure backoff. didFailToConnect re-armed the probe synchronously, so the
  invalid-parameter rejection spun into a tight connect/fail loop (~5k/sec). Re-arm
  after a backoff and re-check state, so no connect failure can tight-loop.
- Foreground-only. StartDelay is a background-only mechanism — iOS ignores the
  delay while foreground, so the probe connected instantly, fired, disconnected,
  re-armed and churned. Gate delayedConnectProbeActive on !isAppForeground; the
  keep-alive connection owns the link while foreground.

An overdue target still retries at the 60s floor (promptly catches a network CGM
value that arrives a little late).
Documents the O5 advert structure, the induced-occlusion capture (service-UUID
suffix 00->02 with fault code 0x14 in mfg, persisted ~10min while healthy 00
ceased), the RE engineer's 4-state destinationStatus model (02 = coarse
faulted/attention bucket, not fault-type-specific), the safety analysis
(controllerId from shared cert pool -> filter not pod-unique, but no false alarm
via own-pod status read), and the open questions before implementing an O5
connectionless fault scan (what are suffixes 01/03; is any non-00 state
persistent).
Strip the O5-investigation debug tooling now that the connection-policy and
heartbeat work is validated, leaving the production connect-on-demand + StartDelay
heartbeat + DASH C00A fault scan intact:

- Remove bleCaptureEnabled (wildcard advert-capture scan + keep-alive override)
  and captureAllCharsEnabled (all-characteristic capture). appIsForeground is now
  just the real foreground state; the idle scan is the production C00A (DASH) /
  monitor (O5) scan.
- Remove periodicStatusEnabled + configurePeriodicStatus (the SN2.0= periodic-push
  experiment).
- Remove the [capture] characteristic/value logging and the logCaptureEvent
  delegate hop.
- Remove the debug 'Trigger Test Alert' diagnostics button + triggerTestAlert.

Kept: [ADV] distinct-advert device logging (field diagnostics), DASH C00A
connectionless fault detection, and all connection-policy/heartbeat behavior.
The C00A fault-scan filter is generic (every DASH pod advertises C00A on fault),
so a nearby stranger's faulted pod matches it and wakes us. Detection was gated on
isPodFrame (autoConnectIDs OR any pod-shaped advert), so it also ran detectPodAlertStatus
/ fresh-connect / the StartDelay probe against foreign pods. No false alarm resulted
(the alarm comes from a connected read of our own pod), but a foreign advert could
trigger a spurious connect and briefly set alarmScanSuppressed, quieting our own fault
scan.

Gate that block on isOwnPod (autoConnectIDs.contains(peripheral) — our unique BLE
identity) so only our pod drives detection/connect/probe. Advert [ADV] logging stays on
any pod-shaped frame (diagnostics + pairing). Sets the pattern for the future O5 scan.
Connect-on-demand's disconnects (15s idle-disconnect + disconnect on app
background) ignored the Pod Keep Alive setting, defeating it: those modes exist
for iPhone 16/17e + InPlay (Atlas) DASH pods where a disconnect->reconnect is
unreliable, and issue a status refresh at ~2:40 to hold the pod's 3-minute
connection window. Our 15s disconnect fired ~2.5 min before that refresh, and the
background disconnect killed silentTune/rileyLink outright.

Add BluetoothManager.shouldHoldConnection = isAppForeground OR (DASH + a background
Pod Keep Alive mode). Gate the idle-disconnect, the background disconnect (now held
+ reconnected if dropped), the reconnect-after-drop, the heartbeat-disable teardown,
and the StartDelay probe on it. When Pod Keep Alive is .disabled (default) or
.whenOpen, shouldHoldConnection == isAppForeground — i.e. NO change from the
validated connect-on-demand behavior; only silentTune/rileyLink now stay connected
in the background.

Adds PodKeepAlive.keepsPodConnectedInBackground. Not device-tested (no InPlay pods
available).
ps2 added 5 commits July 18, 2026 18:56
sendSlpeCommandFireAndForget and sendSlpeGetSetCommand were only used by the
removed periodic-status (SN2.0=) experiment; no remaining callers. (lastPodStatusWord
is still live — it drives the DASH connectionless alert/fault decode in
detectPodAlertStatus — so it stays.)
The StartDelay probe only fires while the pod is DISCONNECTED (it needs a
disconnected peripheral, and iOS only honors StartDelay for a suspended app). When
the pod is instead held CONNECTED, the probe can't run — and for a network CGM the
pump heartbeat is the only loop driver, so a held connection STALLS the loop.
Observed in a tester report: an O5 pod stayed connected ~12 min (its heartbeat
characteristic keeps resetting the idle-disconnect timer via handleHeartbeat, which
never reschedules the check), the probe was suppressed the whole time, and no loop
ran — a missed loop.

Add a connected-state heartbeat timer that fires omnipodHeartbeatDidFire at the
reading interval (aligned to heartbeatTargetDate) while the pod is connected, and
reschedules itself. Started on didConnect, stopped on didDisconnect, and started/
stopped with the heartbeat request. It guards on peripheral.state == .connected, so
normal connect-on-demand cycles (connect -> command -> ~15s idle-disconnect) stop it
before it fires; it only drives when the link is held (O5 heartbeat char, foreground
keep-alive, or a Pod Keep Alive mode). The StartDelay probe still owns the
disconnected/suspended case; the two guard on opposite link states so never double-fire.

Not device-tested yet.
Root cause of the tester's ~12-min missed loop (O5 + network CGM): after a
background heartbeat-wake cycle, the 15s idle-disconnect didn't fire before iOS
suspended the app. The idle-disconnect asyncAfter froze mid-window, the pod stayed
connected, and the StartDelay probe (which needs a DISCONNECTED pod) could never
re-arm — so nothing woke the app until it resumed ~11 min later (which fired the
frozen disconnect at exactly the resume instant).

(Corrects the earlier diagnosis: the O5 'heartbeat characteristic' 7DED7A6D is an
unconfirmed placeholder the real pod doesn't expose — enableNotifications skips the
undiscovered service — so handleHeartbeat never fires and was NOT the cause. The
reverted connected-state timer also wouldn't help: asyncAfter freezes while suspended.)

Fix: shorten the idle-disconnect delay 15s -> 4s (BluetoothManager.idleDisconnectSeconds,
tunable) so the disconnect lands well within the background execution window and the
probe re-arms before suspension. The status->dose burst still shares one connection
(each session resets idleStart, so the delay is measured from the LAST command);
foreground / Pod Keep Alive hold the connection separately, so this only bites while
backgrounded. Not device-tested.
The O5 'heartbeat' characteristic/service (7DED7A6C/7DED7A6D) was an unconfirmed
placeholder the real pod doesn't expose — enableNotifications silently skips the
undiscovered service/char, so it's never subscribed and handleHeartbeat never fires.
Remove the whole dead path:

- PeripheralManager.handleHeartbeat, .lastHeartbeatTime, mostRecentHeartbeatTime
- o5Omnipod5HeartbeatServiceUUID / o5Omnipod5HeartbeatCharacteristicUUID enums
- BlePodProfile heartbeatServiceUUID/heartbeatCharacteristicUUID fields + their
  serviceCharacteristics/notifyingCharacteristics/valueUpdateMacros wiring

Unrelated: the RileyLink keep-alive device path in PodKeepAliveView
(RileyLinkHeartbeatBluetoothDevice, its own lastHeartbeatTime, expectedHeartbeatInterval)
is a separate live mechanism and is untouched. No behavior change (the removed char was
never functional).
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.

4 participants