Repository navigation
fix(ble): stop a reconnected WHOOP 4.0 reading not fully paired - #2716
Merged
ryanbr merged 1 commit intoOct 13, 2026
Merged
Conversation
…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>
1 task done
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
1 task done
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
5 of 12 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
encryptedBondstaysfalse, so the sidebar and Settings status are wrong and app-initiated buzz (AppModel.canBuzz,BiofeedbackController.canBuzz) is refused.Root cause.
didConnectclearsencryptedBondso each link re-proves it at the WHOOP 4.0 confirmed-write ack (#69).didBondis only cleared by the disconnect callback. WhendidConnectfires again with no disconnect in between, the ack site skips itsif !didBondblock, soencryptedBondis never set back totrue. Two ways this happened, both seen in one strap log:didDisconnectPeripheralwhen the radio powers off. OnpoweredOn,connectFromSystem()attaches to the strap macOS still holds:scheduleReconnect()schedulesissueStandingConnect()29 s out after an instant failure, andcentral.connecton an already-connected peripheral re-firesdidConnect.Fix. The WHOOP 4.0 ack site now classifies each confirmed-write ack through a small pure helper,
Whoop4BondAck:didBondis already true butencryptedBondwas cleared. The helper restoresencryptedBond, callsnoteGenuineBond, and logsBONDED again (confirmed write acknowledged on a re-entered link).SEND_HISTORICAL/HISTORY_ENDack.didBond,bondedAtand 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:
CLIENT_HELLOack site has the same shape (alreadyBonded: didBond), but I left it alone because I have no 5/MG strap to validate on.onBluetoothRadioOff(Fix/297 #314). I did not reproduce the standing-connect re-entry on Android.scheduleReconnect()does not checkstate.connectedbefore callingissueStandingConnect(). That is what produced the second trigger.Type of change
How it was tested
didConnecton the live link, and the new path recorded the bond:Whoop4BondAckTestscovers the four input combinations.HelloSuppressionTests,BondLoopHardeningTests,BondRefusalGiveUpTestsandClientHelloOutcomeTestsstill pass.StrandTests(macOS): 2,277 tests ran. Two failures are timezone-dependent and unrelated to this change:SkinTempAbsoluteDisplayTestsandSleepCarriedStampTestsfail in America/New_York and pass withTEST_RUNNER_TZ=UTC. Neither test exercises code this PR changes.xcodegen generateplusxcodebuild(macOSStrandscheme) builds the commit on its own.Tools/doc_comment_lint.pyreports no new detached doc comments.NOOPiOSscheme embeds.Whoop4BondAck.swiftis Foundation-only, andapp-build.ymlcovers this.Checklist
swift testinPackages/<name>): n/a, no package touchedandroid/: n/a,android/not touchedStrandDesigntokens: n/a, no UI changedocs/CONTRIBUTING.mdStrand.xcodeproj/) or any secrets/keystoresRelated issues
None found.
🤖 Generated with Claude Code