Skip to content

O5 support: connect-on-demand connectivity, reading-scheduled heartbeat, connectionless fault detection#1

Open
loopkitdev wants to merge 102 commits into
LoopKit:next-devfrom
loopkitdev:o5-integration
Open

O5 support: connect-on-demand connectivity, reading-scheduled heartbeat, connectionless fault detection#1
loopkitdev wants to merge 102 commits into
LoopKit:next-devfrom
loopkitdev:o5-integration

Conversation

@loopkitdev

Copy link
Copy Markdown

Summary

Brings Omnipod 5 (O5) support to OmnipodKit alongside a reworked BLE connectivity model and connectionless fault detection. Field-tested on real DASH and O5 pods, and validated by additional testers on the o5-integration branch.

Depends on LoopKit/LoopKit#596 (PumpManager.setBLEHeartbeatRequest / PumpHeartbeatRequest). That PR must merge into next-dev first or this will not compile. The consumer wiring is LoopKit/Loop (companion PR).

What's in it

O5 (Omnipod 5) support — merged from the loopandlearn O5 work, plus O5-aware advertisement handling.

Connect-on-demand connectivity — a "normally disconnected" model: the pod stays disconnected, connects on command for a status read / dose (staying connected across both within a loop cycle), and honors the existing Pod Keep Alive setting to hold the connection when the user wants it. Foreground keep-alive gives instant in-app commands.

Reading-scheduled BLE heartbeat — implements setBLEHeartbeatRequest. When Loop wants a heartbeat, the pod schedules a background CBConnectPeripheralOptionStartDelayKey connect to land shortly after the next CGM reading is expected (lastReading + expectedInterval + buffer), rather than churning BLE on a blind timer. Falls back to a 5-minute default when Loop only signals setMustProvideBLEHeartbeat, so it degrades gracefully without the companion Loop PR.

Connectionless fault detection — a low-power idle scan filtered on the pod's alarm service UUID wakes a suspended app when the pod advertises a fault/alert, so faults surface without holding a live connection. Gated to our own pod's BLE identity so a nearby stranger's faulted pod can't cause a spurious wake, and the fault/alert itself is always confirmed by reading the connected pod status. Includes handling for DASH reset-type fault times.

DocsDASH_BEACON_FINDINGS.md and O5_ADVERTISING_FINDINGS.md capture the advertisement/fault-signalling reverse-engineering that backs the fault scan (including a deliberately-induced O5 occlusion showing the service-UUID and manufacturer-data change).

Notes for review

The branch history is an instrumented field-test log (prototypes, capture builds, cleanup n/n); squash-merge recommended. The net diff is ~1,600 lines and the shipped tree carries no capture/debug scaffolding.

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 30 commits July 9, 2026 13:23
…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).
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).
This was a default-OFF BLE-protocol diagnostic that observed pod-initiated
transfers over an active connection, assembled and decrypted them, and logged
the payload for field characterization. It never routed alerts — production
fault detection is the connectionless C00A advertisement scan, which is
unchanged. Removes the listener, its delegate hook, arming state, idle check,
and the inbound-value observation calls across BluetoothManager/Peripheral
Manager/BlePodProfile/BlePodComms.
fd38fca removed the unused O5 heartbeat service/characteristic from the source
but left OmniTests referencing the deleted symbols, breaking the test target.
Remove testBlePodProfileHeartbeatUUIDs (it only covered the removed feature) and
replace the removed o5Omnipod5HeartbeatServiceUUID reference in
testO5InvalidServiceCount with a literal second service UUID, preserving that
test's 'more than one service = invalid advert' assertion.
Wire the low-power idle fault scan for O5, mirroring the DASH C00A path.

- o5FaultAdvertisementUUID(pdmId): the pod-specific "attention" advertisement
  (status-suffix 00->02, field-captured on an induced occlusion). Unlike DASH's
  shared C00A, O5 embeds the controllerId, so it can't be a static constant.
- o5FaultScanServiceUUID: built from the paired controllerId (uuidPdmId); nil on
  DASH / pre-pairing.
- startScanning: O5 branch filters the idle scan on the ...02 UUID so the
  00->02 flip is a fresh discovery that wakes a suspended app.
- didDiscover: own-pod-gated handler surfaces the ...02 advert -> connect + read
  real status (the suffix is a coarse 4-state attention signal, not a fault
  type, so the exact fault comes from the connected read).

Open questions before shipping (see O5_ADVERTISING_FINDINGS.md): suffixes 01/03
are unknown, and whether any non-00 suffix is persistent (the DASH-C005
coalescing trap) is unconfirmed. Not exercised against a real fault through this
scan path yet.
So a connectionless fault-detection test can confirm from the pulled device log
which filter is actually live (e.g. the O5 …02 fault UUID built from the paired
controllerId) even when the app is suspended. Previously only os_log recorded it.
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