feat(whoop5): decode v18 sleep_state @81 + status_word @75; rename @63 -> activity_class#481
feat(whoop5): decode v18 sleep_state @81 + status_word @75; rename @63 -> activity_class#481j0b-dev wants to merge 6 commits into
Conversation
…s (ICM-45686 APEX class)
…ss (parity with Swift)
…59, dsp_metric @113 (Swift + Android) Adds three v18 type-47 fields to decodeWhoop5Historical on both platforms, keeping the parsed key set identical: - record_index (u32 LE @11): monotonic per-record counter (low byte = byte@11; same field the v20/v21 bulk layouts expose, NoopApp#344). Validated on the real worn fixture (25443699) and off-wrist (25443642). - apex_cadence (u8 @59): ICM-45686 APEX samples-per-step, between step_motion_counter @57 and activity_class @63; raw (scale chip-gated). - dsp_metric (f32 LE @113): DSP telemetry float (worn -5.2307; off-wrist 0.0 = unset); raw, meaning DSP-gated. Tests assert exact values off the same real frames already in the suite (no new device captures). swift test 158 green; Android testFullDebugUnitTest green.
c45fbfd to
afe8cb4
Compare
…p_state @81 (Swift parity) Brings the Android v18 decoder to parity with Swift decodeWhoop5Historical: - cardiac block @33-40 (cardiac_flags, hr_fixed_8_8 = HR 8.8 fixed-point, rr_packed, cardiac_status) - status_word @75 (packed; not a deep-sleep marker) - sleep_state @81 = (byte >> 4) & 3 (band SLEEPFLAG; deep/REM/light are cloud-side) Tests assert the exact values off the same real frame the Swift suite uses; the sleep-state nibble-mapping test re-stamps the CRC32 (Android gates on CRC, unlike Swift) via the existing Crc.crc32 over frame[8..len-4]. Full Android unit suite 691 green.
|
@j0b-dev — first, genuinely: the validation here is some of the best observational work the project has had. Two devices, 96/96 HR-timestamp matches, the 0.989 HR correlation, the 725–900× step-inflation tables, tracking @81 against a real night's wind-down → asleep → arousals → wake. That rigor is exactly how we want format facts established, and the behaviours you've found are real and valuable. But I can't merge this cluster as it stands, and I want to be completely straight about why — it's a hard line for NOOP specifically, not a knock on the work or your intent. The blocker is provenance, not correctness. The PRs describe the map as coming from "reverse-engineering the strap v18 record builder" / a "firmware-grounded field map," and the docs name internal parts (AS6221, the ICM-45686 APEX class, the SIGPROC DSP) that can't be observed from BLE frames — they're firmware identifiers. So the discovery ran through WHOOP's firmware, with captures used to validate. NOOP's entire defensibility — the literal promise in our README/FAQ, and the thing that lets an anonymous interop project exist — is "no WHOOP proprietary code, firmware, or assets, and no knowledge derived from disassembling the firmware." Observing the data your own strap emits is interoperability; disassembling the firmware that produces it is a different act, and the moment our code or docs carry "firmware-grounded" provenance (or WHOOP's internal part names), that shield has a hole. "It was validated with captures" doesn't close the hole if the map came from firmware, and "a contributor sent it in" isn't a defense either. I have to hold this consistently or it's worth nothing — I applied the same line to some firmware images that landed in my lap earlier today. Here's the good news, and the path I'd love to take with you — most of these fields look independently establishable from observation alone, and you've already done the hard validation:
So the ask: can you re-present the map grounded only in observation — "here's the capture, here's how @81's nibble tracks a scored night, here's the counter behaviour" — with the firmware terminology and the AS6221/APEX/SIGPROC names stripped (describe them by what they do, as observed: "a skin-temp sensor," "an activity class," "a PPG signal-quality block")? If the behaviour holds from observation alone (your data says it does), I'll reimplement the decode as a NoopApp commit with full credit to you for the capture analysis and validation. We get 5/MG wake/asleep/arousal and correct steps — I want those wins — just on footing that can't ever be turned against the project. Holding this + the companions (#476 / #472 / #474 / #483) pending that re-grounding. If a given field genuinely can't be gotten without the firmware, we leave that one out rather than launder it. Thank you for the depth — this is the work that gets 5/MG sleep over the line; I just need it to come in the front door. 🙏 |
What this does
Surfaces the WHOOP 5/MG strap's own on-device sleep + activity signals from the v18
type-47record, which were previously left in the "unmapped" region (or mislabeled). It decodessleep_state(@81) andstatus_word(@75) indecodeWhoop5Historical, and renames@63to its real meaning (activity_class) on both Swift and Android.These come from reverse-engineering the strap v18 record builder, then validating against real captures.
What it fixes / unlocks
1. Sleep that doesn't collapse to "all light" (relates to #469).
The current sleep-stage breakdown is an app-side actigraphy approximation, and on some nights it reports nearly 100% light with almost no deep/REM despite a healthy night. The strap RE explains why: deep/REM/light are not on the strap at all — WHOOP computes them in the cloud. What the band does emit, in the clear, is its own per-second 4-state determination:
sleep_state = (frame[81] >> 4) & 3→0wake /1still /2asleep /3up (arousal)Decoding it gives the app a trustworthy, device-sourced wake/asleep/arousal timeline to anchor the sleep view on — instead of inferring everything from motion. Validated against a real night: it tracks evening wind-down → asleep (resting HR drops) → brief
uparousals → morning wake. It won't invent deep/REM (those genuinely require the cloud), but it stops the "everything is light" failure mode by grounding wake vs asleep in the band's own call.2. Real activity classification, not a mislabeled field (relates to #132).
@63was decoded asmotion_wear_quality("poor contact"). The strap shows it's actually the ICM-45686 APEX activity class:0still /1walk /2run. Renaming it toactivity_classmakes it usable for activity features (e.g. walk/run minutes) and corrects a wrong label. Real-data distribution fits: mostly still, a chunk of walking, rare running. (This complements the already-upstream@57step counter.)3. Prevents a wrong "deep sleep" reading.
@75looks like it could be a deep-sleep flag (values like0x50/0xA0), but across ~258k real records its low nibble is always 0 and the value occurs as often awake as asleep — it's a packed status word, not a depth metric. Decoding it explicitly asstatus_word(with that note) keeps anyone from shipping80=deepas a sleep stage.Why it makes the features better
@63with honest, strap-grounded fields, and forecloses a plausible-but-wrong deep-sleep misread.Scope & test plan
ParsedFrame/ the decode-features store, likestep_motion_counter/skin_temp_raw); no UI or aggregation changes.swift test(Packages/WhoopProtocol) — full suite green, 157 tests, 0 failures.sleep_stateasserted on the existing real fixture (wake=0) + the(b>>4)&3mapping (states 0/1/2/3, high-nibble-only) via an in-memory@81override — no new device frames committed.status_wordasserted on the existing fixture;@63test updated toactivity_class.@63rename + cardiac block +status_word/sleep_stateat parity; covered by the Android unit suite.Notes
@81/@75/cardiac decode is now on both Swift and Android (the Android decoder reaches parity inHistoricalStreams.kt; the sleep-state nibble-mapping test re-stamps the CRC32 since Android gates on it, unlike Swift). Full Android unit suite green.Cardiac fields (added)
Also decodes the v18
@33-40cardiac block indecodeWhoop5Historical:hr_fixed_8_8(@36, u16) — HR in 8.8 fixed-point, bpm = value/256 — a higher-precision heart rate than the integerhr@22(validated: fixture 25997 -> 101.55 vs hr 102; corr 0.989 over 257k frames). Produced by the SIGPROC PPG->HR DSP.rr_packed(@38),cardiac_flags(@33),cardiac_status(@40) — emitted raw with honest "DSP-gated/unvalidated" notes (same pattern asstatus_word). HeartKey/ECG is NOT the 1 Hz record producer.Late/header fields (added)
Also decodes three more v18 fields in
decodeWhoop5Historicalon both Swift and Android, with identical keys:record_index(u32 LE @11) — monotonic per-record counter (low byte =byte@11; same field the v20/v21 layouts expose, iOS Sync Issue: Historical records use firmware layout v20/v21 #344). Validated on two devices.apex_cadence(u8 @59) — ICM-45686 APEX samples-per-step, the byte betweenstep_motion_counter@57 andactivity_class@63; raw (scale chip-gated).dsp_metric(f32 LE @113) — DSP telemetry float (−5.31…0.0; 0.0 = unset); raw, meaning DSP-gated.Doc rows for these are in #483; the cadence row is in #474.