Repository navigation
Description
Symptom
A WHOOP 5/MG paired alongside a 4.0 shows Active · Live on the Devices card and streams live HR, but never syncs history, never reports firmware, and never opens the custom channel. In a two-hour capture it reconnected ten times, dropping every ~11 seconds:
14:20:12 WHOOP 5/MG detected — will send CLIENT_HELLO after subscribing (experimental).
14:20:12 Subscribed 00002a37-…
14:20:12 → DISABLE_ALARM payload=02ff (puffin)
14:20:12 Alarm: disarmed (5/MG rev2)
14:20:12 WHOOP 5/MG: live HR streaming — marking the link established (experimental).
14:20:12 Subscribed 00002a19-…
14:20:12 WHOOP 5/MG: writing CLIENT_HELLO to fd4b0002 with response (to trigger bonding, experimental).
14:20:12 CLIENT_HELLO write rejected by stack <-- the write never went out
14:20:12 [connection] bondState encryptedBond family=whoop5 (CLIENT_HELLO acked)
14:20:12 WHOOP 5/MG: CLIENT_HELLO acked — link established; subscribing notify chars (experimental).
14:20:15 [connection] reconnect n=3 reason=localTerminate
14:20:15 Disconnected (status=22); reconnecting directly in 3s (attempt 1, held 4s)
Counts across the capture: 10 × writing CLIENT_HELLO, 10 × live-HR shortcut, 8 × Disconnected (status=22), and GET_HELLO (cmd 145) zero times — the custom handshake never runs.
Cause
onCharacteristicWrite (WhoopBleClient.kt:5302) treats a successful write completion as proof the CLIENT_HELLO was acked:
} else if (!didBond && connectedFamily == DeviceFamily.WHOOP5) {
didBond = true
_state.update { it.copy(bonded = true, encryptedBond = true) }
log("WHOOP 5/MG: CLIENT_HELLO acked — link established…")It never checks WHICH characteristic completed. The callback receives characteristic, and the CLIENT_HELLO target is the known constant WHOOP5_CMD_WRITE_CHAR (fd4b0002), but neither is consulted.
So on this strap:
DISABLE_ALARMis written, then two CCCD subscriptions, then CLIENT_HELLO — all in the same instant. Android permits one outstanding GATT operation, so the CLIENT_HELLO write is rejected by the stack andwriteInFlightis cleared with no retry (:7105).DISABLE_ALARM's write then completes, firingonCharacteristicWritewithGATT_SUCCESS.- The branch above matches on family alone and declares the bond established —
didBond = true,encryptedBond = true, bond watchdog cancelled. - No bond actually exists, so the link terminates (
status=22,reason=localTerminate) and the cycle repeats.
This is the same GATT-serialisation hazard the WHOOP 4.0 path already documents and avoids — issue #12: "Android allows only ONE outstanding GATT operation, so writing the bond frame now would race the CCCD descriptor writes… the bond write is deferred to startSession()." The 5/MG path writes CLIENT_HELLO while other operations are in flight, and then mis-attributes an unrelated completion to it.
Two defects, separable
- False bond signal.
onCharacteristicWritemust confirmcharacteristic.uuid == WHOOP5_CMD_WRITE_CHARbefore treating a completion as the CLIENT_HELLO ack. As written, any successful pre-bond write on a 5/MG setsencryptedBond. - Rejected write is not retried.
:7105clearswriteInFlightand logs, then nothing. The CLIENT_HELLO should be deferred until the GATT queue drains — the same treatment the 4.0 bond write already gets — rather than fired alongside DISABLE_ALARM and the CCCD writes.
Fixing (1) alone would stop the false "Active · Live" and let the bond watchdog do its job; (2) is what actually gets the strap bonded.
Impact
Everything downstream of the custom channel is silently unavailable on a 5/MG: history offload, firmware (fw=unknown in the #1634 inventory), clock sync, deep data. The card reads Active · Live throughout, because live HR rides the unauthenticated standard profile (the #69 shortcut), so nothing surfaces the failure to the user.
Environment
- Samsung SM-F966B, Android 16 (SDK 36), NOOP 10.6.1-staging
- WHOOP 5.0 / MG alongside a WHOOP 4.0; the 4.0 bonds and syncs normally in the same capture
- Strap log available on request
Not claimed here
That status=22 originates in NOOP rather than the strap. localTerminate says the local host ended the link, but with a false didBond the watchdog is disarmed and the state machine is proceeding on a bond that does not exist — enough to explain the loop without pinning the terminate itself.
Activity
ryanbr commentedon Aug 26, 2026
Correction: I over-weighted the rejected write
A connection-domain bundle from the same device (10.6.1-staging, 14:30) shows the rejected-write path is rare, not the main failure. Across 16 CLIENT_HELLO attempts:
Plus 13 ×
Disconnected (status=22), 10 ×reason=localTerminate, and the bond watchdog firing twice (stuck for 10s,stuck for 16s).So the dominant failure is the strap never acking a CLIENT_HELLO that was written successfully — not the stack refusing the write. The issue title and the ordering above are wrong on that point.
Defect 1 in the report still stands, and this data sharpens why it matters rather than weakening it: exactly once, a rejected write was followed by a false
CLIENT_HELLO ackedandencryptedBond. That is the unverified-characteristic branch atWhoopBleClient.kt:5336doing precisely what it should not — one of the two "acks" in this capture is not an ack at all. It is a real bug that corrupts the bond state and disarms the watchdog; it is just not what happens on most cycles.Defect 2 (no retry after rejection) is correspondingly less urgent — it fires once in sixteen.
The actual open question is why 14 of 16 writes get no response.
One observation, offered as a lead rather than a cause
The handshake keeps landing on top of a heavy analytics pass. Of the 16 attempts, 10 had
hrv diagoutput within 3 seconds and 3 had a re-score begin within 10 seconds — a full pass over ~21 nights on a 190k-row-per-night store, on the main-loop cadence, inside the 10-second bond window the watchdog allows.I am not claiming this causes the missing acks. A GATT write callback is delivered on the binder thread and should not be starved by application CPU. But the overlap is high enough to be worth ruling out, and it is cheap to test: reproduce with the analytics engine quiet (immediately after a completed re-score, or with a build that defers the pass while a bond is in flight) and see whether the ack rate changes.
What would narrow it further
The capture cannot currently distinguish "the strap received the write and declined to respond" from "the write was queued locally and never reached the air". A line recording the
onCharacteristicWritestatus for the CLIENT_HELLO characteristic specifically — including the case where the callback never arrives before the watchdog fires — would separate those. That is the same shape as the diagnostic discussed for the firmware gate, and I would rather add it than keep inferring from absent lines.[-]WHOOP 5/MG never bonds: a rejected CLIENT_HELLO is mistaken for an ack, so the custom channel never opens[/-][+]WHOOP 5/MG never bonds: CLIENT_HELLO written but never acked (14 of 16), plus a false ack from an unverified write callback[/+]ryanbr commentedon Aug 26, 2026
The instrumentation answered it: 100% no-callback, and the timing is a clock
A capture on the build carrying #1638 (10.6.1-staging,
9bf0c7f):Every single attempt — 9 of 9 in the connection bundle, 8 of 8 in the strap log. Not one ack, not one rejection, and not one of the "DIFFERENT characteristic" false acks this time.
The elapsed times are the striking part:
A 10 ms spread across nine attempts. That is not a link dying of RSSI or interference — it is a timer. Something terminates the link ~3.16 s after the CLIENT_HELLO write, deterministically, and Android classifies it
status=22/reason=localTerminate.I checked for a NOOP-side timer at that interval and there isn't one: the only ~3 s constants in the BLE client are
RECONNECT_DELAY_MSandRSSI_READ_DELAY_MS, neither of which tears down a link.A concrete violation in the same window
15 of 16 cycles issue
DISABLE_ALARMwhile the CLIENT_HELLO write is still outstanding. Android permits one outstanding GATT operation, and the confirmed-write CLIENT_HELLO is holding the slot precisely so its ACK can be awaited (writeInFlight = true).This is the same hazard the WHOOP 4.0 path already documents and defends against — issue #12: "Android allows only ONE outstanding GATT operation, so writing the bond frame now would race the CCCD descriptor writes below… the bond write is deferred to
startSession()." The 4.0 defers its bond write until the queue drains. The 5/MG path writes CLIENT_HELLO and then lets the alarm sync fire a second write on top of it.The caller is
AppViewModel:2670→disableStrapAlarm(), reached whenever neither the smart alarm nor Buzz-WHOOP-4 wants the slot. It is not gated on the handshake at all.What I am and am not claiming
Established: every CLIENT_HELLO goes unacknowledged; the teardown is local and clockwork-regular at ~3.16 s; a second GATT write lands inside the outstanding window in 15 of 16 cycles.
Not established: that the second write causes the missing callback. A confirmed write to a characteristic that requires encryption also triggers Android's bonding flow, and a bonding timeout would produce a similarly regular local teardown. Both explanations fit the 3.16 s regularity, and this capture cannot separate them.
Suggested order
startSession()already does for the 4.0 bond write. This is correct regardless of whether it fixes the bond — issuing a write while a confirmed write is outstanding is a bug on its own — and it removes the confound so the next capture is readable.GATT_INSUFFICIENT_AUTHENTICATION(5)/_ENCRYPTION(15)labels added in ble: report what became of a 5/MG CLIENT_HELLO write #1638 will name it when it surfaces.Doing (1) first is also the cheaper experiment: it is a scheduling change on our side, where (2) needs the strap to tell us something it currently never does.
ryanbr commentedon Aug 26, 2026
Correction: the second write never reaches the air, so that mechanism is wrong
I claimed above that
DISABLE_ALARMlands as a second GATT write inside the outstanding CLIENT_HELLO window, in 15 of 16 cycles. That is wrong, and the deferral fix I proposed is unnecessary — the queue already does it.The
→ DISABLE_ALARMline is logged at enqueue, not at write:and the drain refuses to start anything while a write is outstanding:
The CLIENT_HELLO sets
writeInFlight = trueand holds it. SoDISABLE_ALARMis queued and held, never written — the one-outstanding-operation rule is respected throughout. My reading counted a log line as a wire event, which is exactly the mistake the enqueue/write distinction exists to prevent.Second correction to my own analysis in two comments. The instrumented facts stand; the mechanism I hung on them did not.
What still stands
writeInFlightstaystruefor the life of the link, so the whole command queue is wedged behind it.DISABLE_ALARMand anything else queued after the hello never go out. That is a real effect of the bug, not a cause of it.The evidence that would actually settle it
The prime remaining explanation is Android's own bonding flow: a confirmed write to a characteristic requiring encryption triggers pairing, and a pairing attempt that fails or times out produces exactly this shape — no write callback, and a local teardown on a fixed timer.
NOOP never observes bond state. There is no
ACTION_BOND_STATE_CHANGEDreceiver anywhere in the codebase, so the OS transitionBOND_NONE → BOND_BONDING → BOND_NONEis invisible to us. If that transition is happening at ~3.16 s, it is conclusive; if the device never entersBOND_BONDINGat all, the write is not triggering pairing and the cause is elsewhere entirely.That is a small, additive receiver and it distinguishes the two remaining hypotheses directly, rather than inferring from absent lines — which is what produced the wrong mechanism above.
ble: stop declaring a 5/MG bond from a completion that is not the CLI…
ble: let the bond give-up see an unanswered handshake (#1640)
ble: observe the OS pairing flow, which NOOP has never watched (#1639)
ble: stop the CLIENT_HELLO from knocking a working 5/MG link off ever…
Merge upstream ryanbr/noop main into fork (sync PR #19)
Correct a comment that overstated where the Swift detail line falls t…
Let a successful pairing actually reach the handshake, and stop calli…
Justify the Android-only divergence in-file, and drop a comment's sta…
Emit the write-type verdict where the write happens, not where it is …
docs: BLE lessons from #1635, and correct what app-build actually val…
ryanbr commentedon Aug 27, 2026
Root cause, from an HCI capture
A
btsnoop_hci.logfrom a 5/MG (50.41.1.0,WS50_r03,modelNumber=MG) on Android 16 closes this out. The strap refuses the handshake at two layers, and the two refusals form a closed loop.1. The strap does not support Bluetooth pairing.
Thirty exchanges across three capture sessions. Every one identical.
2. The CLIENT_HELLO requires an encrypted link.
Ten times, byte-identical timing.
So: the hello needs encryption → encryption needs a bond → bonding needs SMP → SMP is refused. The strap demands authentication for the very characteristic whose write is supposed to establish it, while declining the only standard mechanism that could provide it.
The
+3.08sis the signature from the original report — "3.158s, 3.159s, 3.155s after the write, cycle after cycle". Android spends ~3.1s attempting to elevate security, fails, and drops the ACL. That is the 4.8s reconnect loop, fully accounted for.What this rules out
Every hypothesis this issue has carried:
the write type is wrong—fd4b0002declaresproperties=0xc(WriteNoResponse+Write), and the write is deliveredAndroid never begins pairing— it transmits a Pairing Request every timeour broadcast receiver misses the transition— the direct bond poll agreed, and the radio confirms itthe strap is silent— it answers at both layers, immediately and consistentlyCorrections to our own diagnostics
Two lines in the app asserted more than they could observe, and both sent this investigation the wrong way:
bond state poll: … so Android did not begin pairing at all— false. Corrected in ble: the bond poll cannot convict Android, and an HCI capture says it is wrong #1668.HelloSuppression's doc: "the strap is not refusing a pairing" — false. It is refusing, categorically.One line was right all along and is worth keeping:
reconnect paused=bondLoop (strap refusing bond).A second finding: the hello has not been attempted for weeks
With "Ask Android to pair" enabled,
explicitBondDefersHellodefers the hello "to the next connect" — and the next connect requests a bond and defers again. The deferral never resolves. Two full captures contain zero hello writes for this reason; it took an opt-in override to get one onto the wire at all.So the recent "hello never acked" evidence was, in part, "hello never sent".
Consequences
A 5/MG in this state cannot be reached by any handshake we control. Both routes are closed by the strap itself, so this is not a bug we can fix in NOOP.
Downstream effects, all now explained rather than suspected:
0x10), and NOOP already persists themThe suppression latch (#1642) is therefore the correct behaviour, and default-off for the hello override is the evidenced position rather than the cautious one.
What could still reopen this
The strap plausibly has to be put into an accepting state, and the official app plausibly does something we have not seen. That makes an HCI capture of the official app performing a history sync (#103) the single highest-value artefact remaining — it is the only thing that could show a route we do not have.
Privacy note: raw btsnoop logs carry the strap's BLE MAC and every device nearby. Extracted offsets, not the log.
62 remaining items
feat(oura): Test Centre 0x20 user-info write + always-on 0x5c diagnos…
probe: let a re-arm clear the refusal latch it could never reach (#2136)
BLE: put the link's signal strength on its epitaph (#2332)
BLE: put the link's signal strength on its epitaph (#2332)
BLE: put the link's signal strength on its epitaph (#2333)
fix(diagnostics): put the R-R transport line in every export, not onl…
ecg: give Android the MG ECG turn-on probe Apple already drives
fix(android): only count bond-watchdog bounces that reached the comma…
fix(android): only count bond-watchdog bounces that reached the comma…
fix(android): stop the unbonded probe naming a teardown cause it was …
fix(android): stop the unbonded probe naming a teardown cause it was …
fix(ble): end the link when Bluetooth turns off or resets (Apple)
fix(ble): time out a link that connects but never finishes setup (Apple)
fix(ble): re-prove the WHOOP 5/MG encrypted bond on a re-entered link
fix(ble): sync history on an unbonded WHOOP 5/MG link and clear the b…