fix(ble): decode + correlate GET_CLOCK on WHOOP 5/MG (#827)#831
fix(ble): decode + correlate GET_CLOCK on WHOOP 5/MG (#827)#831jbergler wants to merge 4 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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.
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 documentedGET_DATA_RANGEfallback (on DEVICES, the strap says Clock latched: no #261) is also dead sinceGET_DATA_RANGEis never sent to a 5/MG strap.GET_CLOCKdebug 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 indecodeWhoop5CommandResponseusing 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.FrameRouternow publishes the decoded value onLiveState.strapClockUnix, which the Devices/Test Centre "Clock latched" readout falls back to when the WHOOP4-only log-scraped signal is absent.configureCollectorFamily()previously forced an identity clockRef (device==wall) for every 5/MG connection instead of ever using a realGET_CLOCKcorrelation — so a genuinely drifted 5/MG RTC was invisible, and the drift-triggeredSET_CLOCKre-issue (ClockPolicy.shouldSetClock) only ever ran on the WHOOP4 branch. Both families now share oneapplyClockCorrelation(from:)path. Since both now write into the sameclockRef, added a disconnect-time reset so a stale correlation from one family can't leak across a same-session strap-family switch.GET_CLOCKdecode fix + debug probe to Android (Framing.kt,WhoopBleClient.kt,DevicesScreen.kt), per the parity contract. Not ported: theclockRefcorrelation cleanup — Android has noGET_CLOCK-basedclockReffor either family (it always derives a rough correlation fromGET_DATA_RANGE's newest banked record, seebeginBackfill's#700comment), 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 testpass (WhoopProtocol package).xcodebuild -scheme Strand buildsucceeds;SetClockPayloadTests,ConnectionTestModeEmissionTests,BackfillContinuationTests,AlarmReadbackDecodeTestsall pass../gradlew compileFullDebugKotlinsucceeds. Could not run./gradlew testFullDebugUnitTestin the dev sandbox (missing macOSaapt2entry inverification-metadata.xml, pre-existing/environment-only, unrelated to this change) — please run on a machine where that works before merge.#827probe captures 62s apart: decoded clocks1785050961→1785051023, advancing by exactly 62s.Clock correlated: device=1785052722 wall=1785052723— cross-checked against the raw captured frame by hand, exact match. The subsequent historical offload loggedrows landed on 2026-07-26 · clock ref in sync, confirming the real correlation (not the old forced identity) drives correct date placement.SET_CLOCKre-issue correctly did not fire at 1s of drift (under the 2sClockPolicythreshold).clockRefreset.Test plan
StrandTestsFixes #827