Skip to content

WHOOP 5/MG never bonds: CLIENT_HELLO written but never acked (14 of 16), plus a false ack from an unverified write callback #1635

Open

Description

@ryanbr

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:

  1. DISABLE_ALARM is 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 and writeInFlight is cleared with no retry (:7105).
  2. DISABLE_ALARM's write then completes, firing onCharacteristicWrite with GATT_SUCCESS.
  3. The branch above matches on family alone and declares the bond established — didBond = true, encryptedBond = true, bond watchdog cancelled.
  4. 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

  1. False bond signal. onCharacteristicWrite must confirm characteristic.uuid == WHOOP5_CMD_WRITE_CHAR before treating a completion as the CLIENT_HELLO ack. As written, any successful pre-bond write on a 5/MG sets encryptedBond.
  2. Rejected write is not retried. :7105 clears writeInFlight and 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

  1. ryanbr commented on Aug 26, 2026

    @ryanbr
    OwnerAuthor

    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:

    outcome count
    written, never acked 14
    acked within 5s 1
    rejected by the stack outright 1

    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 acked and encryptedBond. That is the unverified-characteristic branch at WhoopBleClient.kt:5336 doing 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 diag output 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 onCharacteristicWrite status 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.

  2. changed the title [-]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[/+] on Aug 26, 2026
  3. ryanbr commented on Aug 26, 2026

    @ryanbr
    OwnerAuthor

    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):

    CLIENT_HELLO outcome: NO write callback after 3153ms — the link dropped before the stack reported…
    CLIENT_HELLO outcome: NO write callback after 3158ms …
    CLIENT_HELLO outcome: NO write callback after 3160ms …
    

    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:

    n=9  min=3153ms  max=3163ms  mean=3158ms  spread=10ms
    

    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_MS and RSSI_READ_DELAY_MS, neither of which tears down a link.

    A concrete violation in the same window

    15:22:35  writing CLIENT_HELLO to fd4b0002 with response
    15:22:36  → DISABLE_ALARM payload=02ff (puffin)      <-- second write, hello still outstanding
    15:22:36  live HR streaming — marking the link established
    15:22:38  CLIENT_HELLO outcome: NO write callback after 3153ms
    15:22:38  reason=localTerminate
    

    15 of 16 cycles issue DISABLE_ALARM while 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

    1. Defer every command until the 5/MG handshake completes, mirroring what 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.
    2. If the callback still never arrives with the window clean, the remaining explanation is the bonding flow itself, and the 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.

  4. ryanbr commented on Aug 26, 2026

    @ryanbr
    OwnerAuthor

    Correction: the second write never reaches the air, so that mechanism is wrong

    I claimed above that DISABLE_ALARM lands 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_ALARM line is logged at enqueue, not at write:

    enqueueWrite(PendingWrite(frame, withResponse, cmd))
    log("→ ${cmd.name} payload=…")

    and the drain refuses to start anything while a write is outstanding:

    private fun drainWriteQueue() {
        …
        if (writeInFlight) return

    The CLIENT_HELLO sets writeInFlight = true and holds it. So DISABLE_ALARM is 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

    • Every CLIENT_HELLO goes unacknowledged (9 of 9, 8 of 8 across two captures).
    • The teardown is local and clockwork-regular: mean 3158 ms, spread 10 ms across nine attempts.
    • No NOOP-side timer exists at that interval.
    • A consequence worth noting: because the ACK never arrives, writeInFlight stays true for the life of the link, so the whole command queue is wedged behind it. DISABLE_ALARM and 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_CHANGED receiver anywhere in the codebase, so the OS transition BOND_NONE → BOND_BONDING → BOND_NONE is invisible to us. If that transition is happening at ~3.16 s, it is conclusive; if the device never enters BOND_BONDING at 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.

  5. ryanbr commented on Aug 27, 2026

    @ryanbr
    OwnerAuthor

    Root cause, from an HCI capture

    A btsnoop_hci.log from 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.

    phone→strap  PAIRING REQUEST   IO=KeyboardDisplay  AuthReq=0x2d (bonding, MITM, SC)  keysize=16
    strap→phone  PAIRING FAILED  →  reason 0x05 "Pairing Not Supported"
    

    Thirty exchanges across three capture sessions. Every one identical.

    2. The CLIENT_HELLO requires an encrypted link.

    → HELLO written (Write Request, handle 0x099b = fd4b0002)
    ← ATT ERROR 0x05  Insufficient Authentication      (+0.05s)
    ✗ LINK DISCONNECTED                                (+3.08s)
    

    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.08s is 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 — fd4b0002 declares properties=0xc (WriteNoResponse+Write), and the write is delivered
    • Android never begins pairing — it transmits a Pairing Request every time
    • our broadcast receiver misses the transition — the direct bond poll agreed, and the radio confirms it
    • the strap is silent — it answers at both layers, immediately and consistently

    Corrections to our own diagnostics

    Two lines in the app asserted more than they could observe, and both sent this investigation the wrong way:

    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, explicitBondDefersHello defers 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:

    • raw captures come back empty — the puffin notify chars are subscribed only after a hello ack, so 0 CCCD writes, 0 notifications
    • no history offload, no sleep, no recovery — those streams are puffin-only
    • live HR, R-R, resting HR and HRV do work, over the standard Heart Rate profile, unbonded. The 2A37 notifications carry RR-Intervals (flags 0x10), and NOOP already persists them

    The 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.

  6. 62 remaining items

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions