Skip to content

fix(ble): stop a reconnected WHOOP 4.0 reading not fully paired - #2716

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
matthiasthomas:fix/whoop4-reprove-encrypted-bond
Oct 13, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
matthiasthomas:fix/whoop4-reprove-encrypted-bond

Conversation

@matthiasthomas

@matthiasthomas matthiasthomas commented Oct 6, 2026 •

Copy link
Copy Markdown

What this PR does

On macOS, a WHOOP 4.0 can end up stuck on "Live HR (not fully paired)" for the rest of a connection even though the link is genuinely bonded. History sync and the strap's own alarm keep working, but encryptedBond stays false, so the sidebar and Settings status are wrong and app-initiated buzz (AppModel.canBuzz, BiofeedbackController.canBuzz) is refused.

Root cause. didConnect clears encryptedBond so each link re-proves it at the WHOOP 4.0 confirmed-write ack (#69). didBond is only cleared by the disconnect callback. When didConnect fires again with no disconnect in between, the ack site skips its if !didBond block, so encryptedBond is never set back to true. Two ways this happened, both seen in one strap log:

  1. Bluetooth radio power cycle. CoreBluetooth sends no didDisconnectPeripheral when the radio powers off. On poweredOn, connectFromSystem() attaches to the strap macOS still holds:
    [09:44:53] Central state: 4 (5 = poweredOn)
    [09:44:53] Bluetooth is off — cannot scan or connect
    [09:44:56] Central state: 5 (5 = poweredOn)
    [09:44:56] Found existing WHOOP 4.0 connection <device> — attaching
    [09:44:56] Connected — discovering services
    [09:44:56] Bonding: confirmed write GET_BATTERY_LEVEL to 61080002
    (no BONDED line follows; status stays "Live HR (not fully paired)")
    
  2. The delayed standing-connect retry firing after the strap already reconnected. scheduleReconnect() schedules issueStandingConnect() 29 s out after an instant failure, and central.connect on an already-connected peripheral re-fires didConnect.

Fix. The WHOOP 4.0 ack site now classifies each confirmed-write ack through a small pure helper, Whoop4BondAck:

  • First bond: unchanged.
  • Re-prove: didBond is already true but encryptedBond was cleared. The helper restores encryptedBond, calls noteGenuineBond, and logs BONDED again (confirmed write acknowledged on a re-entered link).
  • Already proven: unchanged. This covers every SEND_HISTORICAL / HISTORY_END ack.

didBond, bondedAt and the once-per-connection handshake are not touched, so the bond watchdog, the #982 never-bonded detector and the bond-refusal give-up see exactly what they saw before.

Out of scope:

  • WHOOP 5.0/MG: the CLIENT_HELLO ack site has the same shape (alreadyBonded: didBond), but I left it alone because I have no 5/MG strap to validate on.
  • Android: the radio-off case is already torn down by onBluetoothRadioOff (Fix/297 #314). I did not reproduce the standing-connect re-entry on Android.
  • Possible follow-up: the delayed closure in scheduleReconnect() does not check state.connected before calling issueStandingConnect(). That is what produced the second trigger.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

  • Hardware: WHOOP 4.0, firmware 41.17.6.0, macOS 26.4.1. With the strap connected and bonded, I turned Bluetooth off in Control Center, waited, and turned it back on. The strap reconnected and bonded; the delayed standing-connect retry then re-entered didConnect on the live link, and the new path recorded the bond:
    [12:41:52] Standing connect failed instantly — re-issuing in 29s (attempt 2)
    [12:41:54] Bluetooth is off — cannot scan or connect
    [12:42:02] Central state: 5 (5 = poweredOn)
    [12:42:08] BONDED (confirmed write acknowledged) — custom channels should now flow
    [12:42:22] Leaving a STANDING connect outstanding for <device> — …
    [12:42:22] Connected — discovering services
    [12:42:22] Bonding: confirmed write GET_BATTERY_LEVEL to 61080002
    [12:42:22] BONDED again (confirmed write acknowledged on a re-entered link)
    
    The sidebar returned to Bonded · streaming. Before the fix, the same strap and Mac stayed on "Live HR (not fully paired)" after each re-attach (first log above).
  • Unit tests: Whoop4BondAckTests covers the four input combinations. HelloSuppressionTests, BondLoopHardeningTests, BondRefusalGiveUpTests and ClientHelloOutcomeTests still pass.
  • Full StrandTests (macOS): 2,277 tests ran. Two failures are timezone-dependent and unrelated to this change: SkinTempAbsoluteDisplayTests and SleepCarriedStampTests fail in America/New_York and pass with TEST_RUNNER_TZ=UTC. Neither test exercises code this PR changes.
  • Build: xcodegen generate plus xcodebuild (macOS Strand scheme) builds the commit on its own. Tools/doc_comment_lint.py reports no new detached doc comments.
  • Not verified locally: the iOS compile, because my Xcode lacks the watchOS platform the NOOPiOS scheme embeds. Whoop4BondAck.swift is Foundation-only, and app-build.yml covers this.

Checklist

  • Swift package tests pass for any package I touched (swift test in Packages/<name>): n/a, no package touched
  • Android unit tests pass if I touched android/: n/a, android/ not touched
  • No new build warnings introduced
  • UI changes use only StrandDesign tokens: n/a, no UI change
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

None found.

🤖 Generated with Claude Code

…ded link

didConnect clears encryptedBond so each link re-proves it at the WHOOP 4.0
confirmed-write ack (ryanbr#69), but didBond is only cleared by the disconnect
callback. When didConnect fires again with no disconnect in between, the
ack was skipped because didBond was still true, so encryptedBond stayed
false for the rest of the link: the sidebar read "Live HR (not fully
paired)" and buzz (AppModel.canBuzz, BiofeedbackController.canBuzz) was
refused, while history sync, which keys off didBond, kept working.

Two triggers seen on macOS in one strap log:
- the radio powering off and back on: CoreBluetooth sends no disconnect,
  and poweredOn re-attaches to the strap macOS still holds;
- the delayed standing-connect retry from scheduleReconnect firing after
  the strap has already reconnected.

The ack site now classifies the ack through Whoop4BondAck: first bond
(unchanged), re-prove (restore encryptedBond and run noteGenuineBond),
or already proven (unchanged). didBond and the post-bond handshake are
not touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@matthiasthomas matthiasthomas changed the title Re-prove the WHOOP 4.0 encrypted bond when didConnect re-enters a bonded link fix(ble): stop a reconnected WHOOP 4.0 reading not fully paired Oct 6, 2026
@ryanbr
ryanbr merged commit 0b05b84 into ryanbr:main Oct 13, 2026
5 checks passed
ryanbr added a commit that referenced this pull request Oct 13, 2026
The 5/MG twin of the WHOOP 4.0 case in #2716. `didConnect` clears
`encryptedBond` so each link earns it again, and that clear has to precede
the `connectedPeripheralUUID` publish, because SourceCoordinator's #52
re-adoption gate reads the flag at that instant. `didBond` is cleared only by
the disconnect callback, and CoreBluetooth sends no disconnect when the Mac's
Bluetooth radio powers off, so a `didConnect` that re-enters a live link
arrives with `didBond` true and `encryptedBond` false.

A fresh CLIENT_HELLO does go out on that re-entry: the write lives in
`didDiscoverCharacteristics`, not behind the once-per-connection handshake
guard, so re-discovery sends it again. Its completion then reached
`ClientHelloOutcome.isAck`, which declines on `alreadyBonded`, so the flag was
never restored: the strap read "Live HR (not fully paired)" for the rest of
the link and buzz stayed refused, while history sync kept working because that
gates on `didBond`.

`ClientHelloOutcome.classify` now returns one of four outcomes and `isAck` is
derived from it, so the two rules cannot drift. Re-proving demands the same
evidence a first bond does, the hello's own completion inside its own
outstanding window, so an offload ack on the shared command characteristic can
no more restore the flag than it could set it (#1635). Only the
`alreadyBonded` disqualification is lifted, and only while the flag is false.

The re-prove arm restores the flag and nothing else. `didBond`, `bondedAt`,
the hello suppression latch and the give-up counters all describe a bond this
link already made, so re-running them would restart the bond-loop stopwatch
and the #982 never-bonded detector on a bond that never lapsed. The #1883
timing line is kept, because a re-entered link verifies the completion's origin
no better than the first one did.

Android tears this link down at the radio-off edge (#314), so it has no state
to re-prove from and no counterpart is added.

Refs #2716
ryanbr added a commit that referenced this pull request Oct 13, 2026
Follow-up to #2785 and #2716, on both families at once.

`didBond` is process-global while the acknowledgement is evidence about one
peripheral. `forgetDevice` and `prepareForModelSwitch` both tear the link
down, but `didBond` is cleared by the disconnect CALLBACK, so a connect to a
second strap completing inside that window would let strap B's own ack take
the re-prove path: the encrypted flag restored on the strength of A's bond,
while B skips the first-bond bookkeeping it has never had. No `bondedAt`
stamp, so the bond-loop stopwatch and the #982 never-bonded detector never
start for B; no suppression clear; `state.bonded` left false, which is half of
the buzz gate.

Both classifiers now take `samePeripheralAsBond`, and both call sites pass
`lastBondedPeripheralUUID == peripheral.identifier`. A different strap's own
ack routes to `firstBond` rather than being refused, because it genuinely is
that strap's first bond and refusing it would leave B unbonded instead of
mislabelled.

The question is always answerable where re-proving is reachable: every path
that sets `didBond` also records the peripheral through `noteGenuineBond`,
including the state-restoration path. An unrecorded peripheral against a true
`didBond` compares false and takes the full first-bond path, which is the safe
direction, so the guard can never withhold a bond.

`isAck` keeps its four-argument shape and asks the same question it always
did, a first bond on the same link, so its existing assertions are unchanged.

Refs #2785
Refs #2716
devk03 added a commit to devk03/dhoop that referenced this pull request Nov 8, 2026
Adapt upstream 0b05b84, 9b27801, and 35d052d with their regression tests. Re-proving requires this peripheral's existing bond and the hello's own acknowledgement; first-bond bookkeeping remains separate. Apple-only lifecycle repair: Android tears down at radio-off. Refs ryanbr#2716, ryanbr#2785.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants