Skip to content

fix(ble): decode + correlate GET_CLOCK on WHOOP 5/MG (#827)#831

Open
jbergler wants to merge 4 commits into
ryanbr:mainfrom
jbergler:clock-827-get-clock-whoop5
Open

fix(ble): decode + correlate GET_CLOCK on WHOOP 5/MG (#827)#831
jbergler wants to merge 4 commits into
ryanbr:mainfrom
jbergler:clock-827-get-clock-whoop5

Conversation

@jbergler

@jbergler jbergler commented Jul 26, 2026

Copy link
Copy Markdown

Summary

  • #827: "Clock latched" read no forever on every WHOOP 5/MG strap — GET_CLOCK's reply is sent on connect (Today hero: keep ring labels/pills on one line (oversized card, #74) #78) but was never decoded for that family, and the documented GET_DATA_RANGE fallback (on DEVICES, the strap says Clock latched: no #261) is also dead since GET_DATA_RANGE is never sent to a 5/MG strap.
  • Adds a GET_CLOCK debug probe (mirrors GET_EXTENDED_BATTERY_INFO / ENTER-EXIT_HIGH_FREQ_SYNC: opcode mismatch between two independent RE sources (87/85/86 vs 98/96/97) #592/Add read-only GET_BODY_LOCATION_AND_STATUS probe #690) to capture the raw reply from real hardware, then decodes it in decodeWhoop5CommandResponse using the same payload-relative offset as WHOOP4 (the "+4 rule"). Confirmed against two real WHOOP 5.0 captures 62s apart whose decoded clocks advanced by exactly that gap — locked in as a regression test.
  • FrameRouter now publishes the decoded value on LiveState.strapClockUnix, which the Devices/Test Centre "Clock latched" readout falls back to when the WHOOP4-only log-scraped signal is absent.
  • Cleanup: configureCollectorFamily() previously forced an identity clockRef (device==wall) for every 5/MG connection instead of ever using a real GET_CLOCK correlation — so a genuinely drifted 5/MG RTC was invisible, and the drift-triggered SET_CLOCK re-issue (ClockPolicy.shouldSetClock) only ever ran on the WHOOP4 branch. Both families now share one applyClockCorrelation(from:) path. Since both now write into the same clockRef, added a disconnect-time reset so a stale correlation from one family can't leak across a same-session strap-family switch.
  • Ports the same GET_CLOCK decode fix + debug probe to Android (Framing.kt, WhoopBleClient.kt, DevicesScreen.kt), per the parity contract. Not ported: the clockRef correlation cleanup — Android has no GET_CLOCK-based clockRef for either family (it always derives a rough correlation from GET_DATA_RANGE's newest banked record, see beginBackfill's #700 comment), so there's no WHOOP4 pattern for WHOOP5 to diverge from there; building that from scratch would be new work, not a port of this cleanup.

Verification

  • swift build / swift test pass (WhoopProtocol package).
  • Full macOS xcodebuild -scheme Strand build succeeds; SetClockPayloadTests, ConnectionTestModeEmissionTests, BackfillContinuationTests, AlarmReadbackDecodeTests all pass.
  • Android: ./gradlew compileFullDebugKotlin succeeds. Could not run ./gradlew testFullDebugUnitTest in the dev sandbox (missing macOS aapt2 entry in verification-metadata.xml, pre-existing/environment-only, unrelated to this change) — please run on a machine where that works before merge.
  • Real hardware, confirmed on both a probe capture and a live connect:
    • Two #827 probe captures 62s apart: decoded clocks 17850509611785051023, advancing by exactly 62s.
    • A live connect on 2026-07-26 (post-cleanup build) logged Clock correlated: device=1785052722 wall=1785052723 — cross-checked against the raw captured frame by hand, exact match. The subsequent historical offload logged rows landed on 2026-07-26 · clock ref in sync, confirming the real correlation (not the old forced identity) drives correct date placement.
    • Confirmed the drift-triggered SET_CLOCK re-issue correctly did not fire at 1s of drift (under the 2s ClockPolicy threshold).
    • A strap restart mid-session triggered a clean reconnect + fresh re-correlation, with no stale leftover value — validating the new disconnect-time clockRef reset.

Test plan

  • Swift package tests
  • macOS app build + targeted StrandTests
  • Android Kotlin compile
  • Real WHOOP 5.0 hardware: probe captures, live connect, strap restart
  • Android unit tests (please run on a machine without the sandbox's aapt2 verification gap)

Fixes #827

jbergler and others added 4 commits July 26, 2026 19:33
ryanbr#827)

"Clock latched" read no forever on every WHOOP 5/MG strap: GET_CLOCK's
reply is sent on connect (ryanbr#78) but was never decoded or routed for
that family, and the documented GET_DATA_RANGE fallback (ryanbr#261) is
also dead since GET_DATA_RANGE is never sent to a 5/MG strap at all.

Adds a GET_CLOCK debug probe (mirrors ryanbr#592/ryanbr#690) to capture the raw
reply from real hardware, then decodes it in decodeWhoop5CommandResponse
using the same payload-relative offset as WHOOP4 (the "+4 rule").
Confirmed against two real WHOOP 5.0 captures 62s apart whose decoded
clocks advanced by exactly that gap, locked in as a regression test.

FrameRouter now publishes the decoded value on LiveState.strapClockUnix,
which the Devices/Test Centre "Clock latched" readout falls back to
when the WHOOP4-only log-scraped signal is absent.

Android port and the separate GET_DATA_RANGE-never-sent-for-5/MG gap
are follow-ups, not included here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LL8wshCGUcnkn993RdMGu
…be to Android

Kotlin twin of fe8f834 (macOS/iOS): decodeCommandResponseWhoop5 in
Framing.kt never decoded GET_CLOCK, only the WHOOP4 path did, so
"Clock latched" would read no forever on a 5/MG strap there too. Adds
the same pay[2..6) u32 LE decode at the puffin offset, confirmed
against the same two real WHOOP 5.0 captures 62s apart used on the
Swift side (locked in as a FramingTest regression test).

Also ports the GET_CLOCK debug probe (formatClockProbe, probeGetClock/
clearClockProbe + StateFlow, response dispatch, DevicesScreen menu
item + confirmation/result dialogs) mirroring the existing ryanbr#592/ryanbr#690
probe pattern already in WhoopBleClient.kt.

Not ported: Android has no existing "Clock latched" UI readout to
wire a fallback signal into (ConnectionReadout.clockLatchedLabel is
unit-tested but not called from any screen) — that's a pre-existing
gap, not something this change should grow into a new feature.

Verified: ./gradlew compileFullDebugKotlin succeeds. Could not run
./gradlew testFullDebugUnitTest in this environment — verification-metadata.xml
only has a checksum for the Linux aapt2 artifact, so any
resource-touching Gradle task fails on macOS; pre-existing, unrelated
to this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LL8wshCGUcnkn993RdMGu
configureCollectorFamily() forced an identity clockRef (device==wall)
for every 5/MG connection instead of ever using a real GET_CLOCK
correlation, so a genuinely drifted 5/MG RTC was invisible to
decode/logging, and the drift-triggered SET_CLOCK re-issue
(ClockPolicy.shouldSetClock) only ever ran on the WHOOP4 branch.

Extracts the existing WHOOP4 correlation block into a shared
applyClockCorrelation(from:), now also called from the 5/MG puffin
path's GET_CLOCK COMMAND_RESPONSE handling (parsed once, ryanbr#47-style,
and threaded to both the router and the correlation check). A 5/MG's
own timestamps being already real-unix doesn't make this a no-op:
the (device, wall) offset now captures the strap's actual RTC error
vs wall time, which the old forced identity ref papered over.

Since both families now write into the one shared `clockRef`, added
a disconnect-time reset (alongside the existing clockRequested/
clockRetries reset) so a stale correlation from one family can't
carry over and mis-decode a strap of the other family after a
same-session switch.

Not ported to Android: Android has no GET_CLOCK-based clockRef for
EITHER family (it always derives a rough correlation from
GET_DATA_RANGE's newest record, see beginBackfill's ryanbr#700 comment) —
there's no WHOOP4 pattern to bring WHOOP5 in line with there. Building
that from scratch is new work, not a port of this cleanup; flagging
for a separate decision rather than silently expanding scope.

Verified: swift build/test pass; a full macOS xcodebuild (BUILD
SUCCEEDED); SetClockPayloadTests, ConnectionTestModeEmissionTests,
BackfillContinuationTests, AlarmReadbackDecodeTests all still pass.
Not yet verified on real 5/MG hardware — this changes live BLE
connect-path timing/persistence behavior for that family and should
be checked on a strap before merging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LL8wshCGUcnkn993RdMGu
The i18n audit regression gate (PR ryanbr#831) flagged the new WHOOP 5/MG
clock-probe strings as hardcoded on both platforms. Route them through
Android string resources and add String Catalog entries on Apple,
mirroring the ryanbr#592/ryanbr#690 probe strings' localization pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LL8wshCGUcnkn993RdMGu

@ryanbr ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent work — two captures 62s apart advancing by exactly 62s is the right kind of proof, and the parity section (port the decode, state why the clockRef cleanup does not port) is exactly the contract.

One blocker, and your own summary has the evidence for it.

The 5/MG used to get an identity clockRef at connect, so live persistence always worked. Now it gets nil until GET_CLOCK replies — and the #700 fallback chain cannot rescue a 5/MG: after 3 retries it needs strapNewestTs, which as you note is never populated there because GET_DATA_RANGE is never sent to that family. So a 5/MG that does not answer GET_CLOCK now has clockRef == nil forever, and Collector (line ~160, guard clockRef != nil) buffers, drops past maxPreClockFrames, and persists nothing. Silent total loss of live capture.

Not hypothetical: #700's own comment records a real strap doing exactly this ("GET_CLOCK sent, no reply"). And the retries only fire inside beginBackfill(), so a live-only session that never offloads gets a single attempt.

Suggest: when retries are exhausted and strapNewestTs is nil, install the identity ref on .whoop5 — still legitimate there (real-unix live timestamps, the original justification), and it keeps the real correlation whenever GET_CLOCK does land.

Minor: android.yml is disabled, so nobody's CI will run the Android tests you flagged — that checkbox stays unticked unless someone runs it locally.

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.

"Clock latched" reads no forever on WHOOP 5.0/MG

2 participants