You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Sharing: whoop-local — a single-file, stdlib-only local decoder for WHOOP 5.0/MG (HR, strain, HRV, sleep, IMU, skin-temp °C, events…) #715
A single Python file — stdlib only (bleak needed just for live BLE) — that pairs with a
WHOOP 5.0/MG you own, decodes it locally, and computes the metrics. No cloud, no account, no
subscription. Progressive: the more data it collects, the more it unlocks
(HR → strain/calories → zones → HRV → sleep → recovery).
A capture-log mode that saves everything to one JSON for offline analysis
Why it might be useful here
Every field is tagged with where it comes from (app offset / community RE / published formula),
and where a value can't be honestly derived (e.g. a calibrated SpO₂ from a single-wavelength signal)
the tool refuses to emit a number rather than guess.
Built on the community's reverse-engineering — thanks to NOOP and the other RE projects. Own-device,
non-medical, contains no WHOOP code. Happy to cross-check offsets or contribute captures.
Cloned it and checked every offset that overlaps ours. Thanks for sharing it — the cross-check was worth more than a read.
Everything that overlaps agrees. Converting your inner-relative offsets (+8 to frame): HR@22, unix@15, rr_count@23, rr@24+2i, skin_temp@73 ÷100, sleep_state@81 >>4 &3, steps@57, v26 PPG at frame[27:75]. No disagreements at all.
The v20 optical match is the one that impressed me. You list six 20-bit channel offsets. We got there differently — five 422-byte blocks from frame 26, two 200-byte slots each, and a 29,203-record corpus showing blocks 1 and 2 are always empty. Your six offsets are exactly our six non-empty slots: 47, 247, 1313, 1513, 1735, 1935. Two independent methods, same six. That's the strongest corroboration our v20 layout has.
@74 (our frame 82) as sleep-only SpO₂ is a genuinely new identification, and it lines up with something we'd already noticed but couldn't name. We carry that byte as aux_byte_82, documented as "observed nonzero only while sleep_state = asleep." WHOOP only measures SpO₂ during sleep. Those two observations fit.
I tried to confirm it and can't: every one of the 21 v18 frames in our repo — six oracle frames plus fifteen across the Swift and Kotlin suites — is sleep_state = 0 (awake), and all 21 have byte 82 = 0. Consistent with your reading, but consistent with several other readings too. We have no asleep frame at all.
So the ask, if you're willing: one v18 record captured while actually asleep, or a short run of them. What settles it immediately is the value's range — 90–100 means a percentage and you're right; 0–3 means a state code. If you have an overnight capture from your own tooling, a handful of asleep frames is enough. That would give WHOOP 5 an SpO₂ channel we currently don't decode at all, which is not a small unlock.
One thing we can't do, and I'd rather say it plainly than quietly ignore the repo. NOOP is clean-room: no decompiled app code, ever. Your README and whoop_dashboard.py:643 cite the decompiled app directly — com/whoop/service/rearchitect/c.java, zi0/*, bj0/x.java, and Strain from qh0/o, qh0/d. That's a hard scope line for us, so we can't take anything carrying a [WHOOP-app]/[JAVA] tag, no matter how correct it is. Your [whoop-rs]-sourced findings are a different matter — that's hardware RE, and the SpO₂ lead is one of them, which is exactly why it's actionable for us and the Strain formula isn't. Worth flagging too that your LICENSE clause 4 says the project "contains no WHOOP source code," which reads oddly next to those citations; that's your call, not ours, but it's the kind of thing that makes downstream projects cautious.
Not a criticism of the work — the offsets are right, and the independent confirmation is genuinely useful to us. It's a sourcing constraint on what can cross over.
Correction to my comment above: I said we have 21 v18 frames, all awake. That was an occurrence count — the Swift and Kotlin suites share fixtures, so each frame recurs in 2–7 files. There are six distinct v18 frames in the repo, from two straps, and they are the six in decoder_oracle.json.
The conclusion is the same — all six are sleep_state = 0 with byte 82 = 0, so nothing here can test the SpO₂ reading — but the evidence base is 3.5× smaller than I implied, and that difference matters if you were weighing how much our "consistent with" is worth. It is worth less than 21 frames would be.
Makes the asleep capture ask more useful, not less.
Following up on my own ask — it may be unnecessary.
Our "nonzero only while asleep" note on that byte can't have come from the six fixtures, since all six are awake. It came from an out-of-tree ~258k-record corpus that our BLE docs cite twice elsewhere, including for a neighbouring byte "occurring as often awake as asleep" — so that corpus contains asleep records.
If so, one query against it settles your SpO₂ reading immediately: the distribution of byte 82 where sleep_state != 0. 90–100 confirms the percentage; 0–3 makes it a state code.
Still glad of an independent capture if you have one — two sources beats one, especially since ours is two straps. But you shouldn't be blocked on us, and we shouldn't be blocked on you.
Two corrections to what I told you above. Both were wrong in your favour, and the second may have discouraged you from contributing things we'd actually take.
1. We already decode @82. I asked you for a capture we don't need.
I said we couldn't confirm the SpO₂ reading and asked for "one v18 record captured while actually asleep". Interpreter.swift:503 and HistoricalStreams.kt:365 have decoded it as spo2_candidate_82 for some time — tri-mode, in-band 70–100, sleep-only — with a guard test stopping it ever reaching spo2Pct, and a full write-up in docs/WHOOP5_DEEP_DATA.md.
There's already an 8-night independent validation at corr +0.99 that tracks a 92 % desaturation and a 98 % high, and is offset-specific in a 74–92 scan. So your identification isn't just plausible, it's corroborated — and your decoder applying the same 70…100 in-band gate independently is a second source on it.
The real open question is narrower than I made it sound: on the original #103 device, two checked nights moved opposite to the app value (95.50→92.83 app vs 93.62→93.80 gated mean). That contradiction is the whole blocker, and it needs multi-device correlation, not one more frame. tools/linux-capture/validate_spo2_candidate.py is the harness for it.
I got this wrong by grepping aux_byte_82 and reading a table row that omits the candidate, without ever grepping spo2.
2. "No decompiled app code, ever" was stricter than our actual rule.
I said we can't take anything carrying a [WHOOP-app]/[JAVA] tag, no matter how correct. That's not the practice. Interpreter.swift describes @82 itself as "a decompile-sourced decode (gen5.rs spo2_pct), reimplemented here as a protocol fact with attribution, like the other RE work".
The real line is: don't add decompiled code; a fact sourced from one can be reimplemented with attribution, as an unvalidated candidate that never backs a shipped metric until independent captures clear it. Offsets and field layouts are facts. Verbatim strings and lifted implementations are not — which is why the LINK_VALID handshake's "There it is." payload stays out regardless.
That distinction matters for what's worth your time. Several things I waved off as untouchable were admissible all along under attribution.
Sorry for the runaround on both. The offset work in that repo has held up everywhere I've checked it.
Wrote up the full triage of whoop-local — everything corroborated, adopted, and rejected, with reasons — in a comparison table on #520, alongside the same for tanarchytan/noop. Putting it there since #520 is the more general "does this repo help us" thread.
Short version: no code taken, your framing and every overlapping offset match ours exactly, and the v20 six-offset agreement is now recorded in BLE_REVERSE_ENGINEERING.md as the strongest confirmation that layout has.
Activity
ryanbr commentedon Jul 26, 2026
Cloned it and checked every offset that overlaps ours. Thanks for sharing it — the cross-check was worth more than a read.
Everything that overlaps agrees. Converting your inner-relative offsets (+8 to frame): HR@22, unix@15, rr_count@23, rr@24+2i, skin_temp@73 ÷100, sleep_state@81
>>4 &3, steps@57, v26 PPG atframe[27:75]. No disagreements at all.The v20 optical match is the one that impressed me. You list six 20-bit channel offsets. We got there differently — five 422-byte blocks from frame 26, two 200-byte slots each, and a 29,203-record corpus showing blocks 1 and 2 are always empty. Your six offsets are exactly our six non-empty slots:
47, 247, 1313, 1513, 1735, 1935. Two independent methods, same six. That's the strongest corroboration our v20 layout has.@74(our frame 82) as sleep-only SpO₂ is a genuinely new identification, and it lines up with something we'd already noticed but couldn't name. We carry that byte asaux_byte_82, documented as "observed nonzero only whilesleep_state= asleep." WHOOP only measures SpO₂ during sleep. Those two observations fit.I tried to confirm it and can't: every one of the 21 v18 frames in our repo — six oracle frames plus fifteen across the Swift and Kotlin suites — is
sleep_state = 0(awake), and all 21 have byte 82 = 0. Consistent with your reading, but consistent with several other readings too. We have no asleep frame at all.So the ask, if you're willing: one v18 record captured while actually asleep, or a short run of them. What settles it immediately is the value's range — 90–100 means a percentage and you're right; 0–3 means a state code. If you have an overnight capture from your own tooling, a handful of asleep frames is enough. That would give WHOOP 5 an SpO₂ channel we currently don't decode at all, which is not a small unlock.
One thing we can't do, and I'd rather say it plainly than quietly ignore the repo. NOOP is clean-room: no decompiled app code, ever. Your README and
whoop_dashboard.py:643cite the decompiled app directly —com/whoop/service/rearchitect/c.java,zi0/*,bj0/x.java, and Strain fromqh0/o, qh0/d. That's a hard scope line for us, so we can't take anything carrying a[WHOOP-app]/[JAVA]tag, no matter how correct it is. Your[whoop-rs]-sourced findings are a different matter — that's hardware RE, and the SpO₂ lead is one of them, which is exactly why it's actionable for us and the Strain formula isn't. Worth flagging too that your LICENSE clause 4 says the project "contains no WHOOP source code," which reads oddly next to those citations; that's your call, not ours, but it's the kind of thing that makes downstream projects cautious.Not a criticism of the work — the offsets are right, and the independent confirmation is genuinely useful to us. It's a sourcing constraint on what can cross over.
Leaving this open for the asleep capture.
docs(whoop5): record the byte-82 SpO2 lead and the v20 independent co…
ryanbr commentedon Jul 26, 2026
Correction to my comment above: I said we have 21 v18 frames, all awake. That was an occurrence count — the Swift and Kotlin suites share fixtures, so each frame recurs in 2–7 files. There are six distinct v18 frames in the repo, from two straps, and they are the six in
decoder_oracle.json.The conclusion is the same — all six are
sleep_state = 0with byte 82 = 0, so nothing here can test the SpO₂ reading — but the evidence base is 3.5× smaller than I implied, and that difference matters if you were weighing how much our "consistent with" is worth. It is worth less than 21 frames would be.Makes the asleep capture ask more useful, not less.
ryanbr commentedon Jul 26, 2026
Following up on my own ask — it may be unnecessary.
Our "nonzero only while asleep" note on that byte can't have come from the six fixtures, since all six are awake. It came from an out-of-tree ~258k-record corpus that our BLE docs cite twice elsewhere, including for a neighbouring byte "occurring as often awake as asleep" — so that corpus contains asleep records.
If so, one query against it settles your SpO₂ reading immediately: the distribution of byte 82 where
sleep_state != 0. 90–100 confirms the percentage; 0–3 makes it a state code.Still glad of an independent capture if you have one — two sources beats one, especially since ours is two straps. But you shouldn't be blocked on us, and we shouldn't be blocked on you.
docs(whoop5): two more findings from the whoop-local cross-check (#715)
docs(whoop5): pin dynamic_acceleration, disprove the byte-43 respirat…
docs(whoop5): record why the LINK_VALID handshake was evaluated and r…
ryanbr commentedon Jul 26, 2026
Two corrections to what I told you above. Both were wrong in your favour, and the second may have discouraged you from contributing things we'd actually take.
1. We already decode
@82. I asked you for a capture we don't need.I said we couldn't confirm the SpO₂ reading and asked for "one v18 record captured while actually asleep".
Interpreter.swift:503andHistoricalStreams.kt:365have decoded it asspo2_candidate_82for some time — tri-mode, in-band 70–100, sleep-only — with a guard test stopping it ever reachingspo2Pct, and a full write-up indocs/WHOOP5_DEEP_DATA.md.There's already an 8-night independent validation at corr +0.99 that tracks a 92 % desaturation and a 98 % high, and is offset-specific in a 74–92 scan. So your identification isn't just plausible, it's corroborated — and your decoder applying the same
70…100in-band gate independently is a second source on it.The real open question is narrower than I made it sound: on the original #103 device, two checked nights moved opposite to the app value (95.50→92.83 app vs 93.62→93.80 gated mean). That contradiction is the whole blocker, and it needs multi-device correlation, not one more frame.
tools/linux-capture/validate_spo2_candidate.pyis the harness for it.I got this wrong by grepping
aux_byte_82and reading a table row that omits the candidate, without ever greppingspo2.2. "No decompiled app code, ever" was stricter than our actual rule.
I said we can't take anything carrying a
[WHOOP-app]/[JAVA]tag, no matter how correct. That's not the practice.Interpreter.swiftdescribes@82itself as "a decompile-sourced decode (gen5.rsspo2_pct), reimplemented here as a protocol fact with attribution, like the other RE work".The real line is: don't add decompiled code; a fact sourced from one can be reimplemented with attribution, as an unvalidated candidate that never backs a shipped metric until independent captures clear it. Offsets and field layouts are facts. Verbatim strings and lifted implementations are not — which is why the
LINK_VALIDhandshake's"There it is."payload stays out regardless.That distinction matters for what's worth your time. Several things I waved off as untouchable were admissible all along under attribution.
Sorry for the runaround on both. The offset work in that repo has held up everywhere I've checked it.
docs: point CLAUDE.md's clean-room bullet at the facts-vs-code detail
docs: write down the facts-vs-code line, which the docs currently con…
ryanbr commentedon Jul 26, 2026
Wrote up the full triage of whoop-local — everything corroborated, adopted, and rejected, with reasons — in a comparison table on #520, alongside the same for tanarchytan/noop. Putting it there since #520 is the more general "does this repo help us" thread.
Short version: no code taken, your framing and every overlapping offset match ours exactly, and the v20 six-offset agreement is now recorded in
BLE_REVERSE_ENGINEERING.mdas the strongest confirmation that layout has.docs(whoop5): pin dynamic_acceleration, disprove the byte-43 respirat…
docs(whoop5): record why the LINK_VALID handshake was evaluated and r…
docs: write down the facts-vs-code line, which the docs currently con…