From 6ffb91de4cf456be309ac9d8b67f4b7b8c609ee5 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:35:01 +0530 Subject: [PATCH] wire up realtime HR v2 dispatch, fix r10 hr=0 going silent, readme cleanup decodeFrame never actually called parseRealtimeHrV2 - any short realtime packet went to the legacy v1 decoder, which misreads a v2 body's byte[9] as an RR count and drops off-body/location entirely. now it tries v2 first when the rec byte says revision 2. also r10 dispatch only emitted realtime_hr when hr > 0, so an off-wrist reading (hr==0, which is valid, not a failure) just vanished instead of saying wearing:false. readme was still describing this as WHOOP4-only and claiming a stale test count and a _external/noop path that doesn't exist in the repo. --- README.md | 64 ++++++++++++++++++++++++---- lib/src/control.dart | 27 +++++++++++- test/whoop_protocol_update_test.dart | 45 +++++++++++++++++++ 3 files changed, 126 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5feda2b..e5167a5 100644 --- a/README.md +++ b/README.md @@ -5,20 +5,43 @@ [![stars](https://img.shields.io/github/stars/OpenStrap/protocol?style=flat&color=e2825f)](https://github.com/OpenStrap/protocol/stargazers) [![Donate](https://img.shields.io/badge/donate-BTC%20%2F%20ETH-f7931a)](https://github.com/OpenStrap/edge/blob/main/DONATE.md) -Pure Dart, zero runtime deps. You hand it an already-unwrapped chunk of bytes from the -band, it hands you back a record with named fields, or a decoded command/event. That's -the whole job. +Pure Dart, zero runtime deps, multi-band. You hand it an already-unwrapped chunk of +bytes from a device — WHOOP 4, WHOOP 5, an Oura ring, or any generic Bluetooth Heart +Rate Service (0x180D) sensor — it hands you back a record with named fields, or a +decoded command/event. That's the whole job. This isn't backend-side anymore — the app ([edge](https://github.com/OpenStrap/edge)) depends on this package directly and calls it on-device. There's no cloud, no upload, no server that ever sees your raw bytes. -> Not affiliated with WHOOP. This is for reading your own band's data. +> Not affiliated with WHOOP, Oura, or any other device maker. This is for reading your +> own device's data. + +## Part of OpenStrap + +This is the byte layer of a three-repo project — reverse-engineered BLE GATT protocols +in, named records out. Nothing here talks to a server. + +- **protocol** (this repo) — bytes ↔ records/frames/commands for WHOOP 4/5, Oura, and + generic BLE heart-rate sensors. +- [analytics](https://github.com/OpenStrap/analytics) — turns those records into + metrics: HRV, sleep staging, recovery, strain. +- [edge](https://github.com/OpenStrap/edge) — the actual app: the Bluetooth connection, + storage, sync, UI. ## What's actually in here -- `records.dart` — the record decoders (`R24`, `parseR24`, and the firmware-aware - fallback chain for older/short frames — see below). +- `band.dart` — the multi-generation device profile (`BandProfile`/`DeviceType`) that + keeps framing/records/edge band-agnostic instead of forking per generation. +- `records.dart` — the WHOOP 4 (gen4) record decoders (`R24`, `parseR24`, and the + firmware-aware fallback chain for older/short frames — see below). +- `gen5_records.dart` — the WHOOP 5 (gen5) record decoders: v18/v20/v21/v26 and the rest + of the gen5-specific record map. gen5 is gen4 in a different envelope, not a separate + protocol — see `band.dart`'s doc comment for the verified deltas. +- `oura.dart` — the Oura ring wire format. +- `hrs.dart` — the Bluetooth SIG Heart Rate Service (`0x180D`) as a pure function, for + any standard chest strap or optical armband that implements the spec, not one vendor's + device. - `live.dart` — the live/high-rate stuff: R10, the 0x28 compact-HR stream, the 0x33 IMU stream, RR-interval extraction. - `framing.dart` — the actual byte-level framing: `0xAA` start-of-frame, CRC8 length @@ -31,6 +54,29 @@ server that ever sees your raw bytes. markers). - `constants.dart` — the GATT UUIDs, opcode tables, event IDs. +## WHOOP 5 (gen5) + +gen5 shares WHOOP 4's protocol almost entirely — same opcodes (except `HELLO`, which +moves to `0x91`), same CRC32 payload check — wrapped in a different frame header +(8 bytes + crc16-modbus instead of 4 bytes + crc8) and a different GATT service prefix +(`fd4b…` instead of `6108…`). `BandProfile.gen5` selects it; everything else in this +package takes a profile rather than hardcoding gen4's shape. See `gen5_records.dart` for +the gen5-specific record layouts (v18/v20/v21/v26). + +## Oura ring + +`oura.dart` decodes the Oura ring's own BLE wire format — a different GATT service and +byte layout from WHOOP entirely, reverse-engineered the same way the WHOOP records were: +real captures, plausibility checks, no vendor docs. + +## Generic Bluetooth heart rate sensors + +`hrs.dart` decodes the Bluetooth SIG's standard Heart Rate Service (`0x2A37` +characteristic under service `0x180D`) — the spec most chest straps and optical armbands +already speak, independent of any single vendor. This is how OpenStrap works with a +device it has never specifically reverse-engineered: if it speaks the SIG spec, this +decoder already reads it. + ## The one record that matters most `parseR24` decodes the 1 Hz historical record — the bulk of what comes off the band @@ -140,7 +186,7 @@ Pure Dart, no Flutter dependency: ```bash dart pub get -dart test # 71 tests, incl. the 2934-case TS-parity suite +dart test # the full suite, incl. the 2934-case TS-parity suite ``` Run tests from the repo root — the parity fixture (`decode_parity_cases.json`) is @@ -155,7 +201,9 @@ an honest "not sure." If you're touching `records.dart`'s multi-version decode c check `FirmwareAwareR24Decoder` first — chances are your case fits the existing fallback shape rather than needing a new one. -Cross-checking against `_external/noop/` `bWanShiTong/reverse-engineering-whoop-post/` for facts/techniques is fine; copying its code is not. +Cross-checking against other public reverse-engineering writeups for facts/techniques is +fine — e.g. [bWanShiTong/reverse-engineering-whoop-post](https://github.com/bWanShiTong/reverse-engineering-whoop-post) +— copying their code is not. ## Contributing diff --git a/lib/src/control.dart b/lib/src/control.dart index 482181d..f0687ed 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -1416,6 +1416,24 @@ Decoded _decodeDataRecord(Uint8List inner, final recType = inner.length > 1 ? inner[1] : -1; // Compact realtime stream (small packet). if (inner.length < 64) { + // Revision-2 realtime HR carries off-body state + garment location that + // the legacy v1 decoder below cannot see, and misreads byte [9] of a v2 + // body as a declared R-R count — try v2 first so those bytes never reach + // the v1 fallback. + if (recType == 2) { + final v2 = parseRealtimeHrV2(inner); + if (v2 != null) { + return Decoded('realtime_hr', { + 'rec_type': recType, + 'ts_epoch': v2.tsEpoch, + 'hr': v2.hrBpm, + 'hr_precise': v2.hrBpm.toDouble(), + 'rr_ms': const [], + 'wearing': !v2.isOffBody, + 'location_raw': v2.locationRaw, + }); + } + } final hr = parseRealtimeHr(inner); if (hr != null) { return Decoded('realtime_hr', { @@ -1432,16 +1450,21 @@ Decoded _decodeDataRecord(Uint8List inner, // Live R10 (HR + IMU) — surface HR for the live display. if (recType == Record.r10) { final r = parseR10Lite(inner); - if (r != null && r.hr > 0) { + if (r != null) { // rr_ms too: parseR10Lite already accepted these beats, and the short // realtime-HR branch above emits them — dropping them here silently // halved the beat supply of anything reading live R10 through // decodeFrame rather than live.dart. + // + // Emit even when hr == 0 — that's a real off-wrist reading (see + // live.dart's wristOn: hr > 0), not a failed decode. Falling through + // here used to collapse it into a bare 'data_record' with no wearing + // field at all, indistinguishable from an undecodable frame. return Decoded('realtime_hr', { 'rec_type': recType, 'hr': r.hr, 'rr_ms': r.rrIntervalsMs, - 'wearing': true, + 'wearing': r.hr > 0, }); } } diff --git a/test/whoop_protocol_update_test.dart b/test/whoop_protocol_update_test.dart index aeaa72f..3a692d0 100644 --- a/test/whoop_protocol_update_test.dart +++ b/test/whoop_protocol_update_test.dart @@ -580,5 +580,50 @@ void main() { expect(decoded.fields['hr'], 65); expect(decoded.fields['rr_ms'], isEmpty); }); + + // decodeFrame used to never route to parseRealtimeHrV2 at all — any short + // realtime packet fell straight to the v1 decoder, which reads a v2 + // body's reserved byte [9] as an RR count and never surfaces off-body or + // garment location. + test( + 'a revision-2 body routes to parseRealtimeHrV2 and surfaces off-body + location', + () { + final b = Uint8List(20); + final bd = b.buffer.asByteData(); + b[0] = 0x28; + b[1] = 2; // revision 2 + bd.setUint32(2, 1780840486, Endian.little); // ts + b[8] = 79; // hr + b[18] = 0; // off-body + b[19] = 2; // bicep + + final decoded = decodeFrame(Frame(b, true, true)); + expect(decoded.kind, 'realtime_hr'); + expect(decoded.fields['ts_epoch'], 1780840486); + expect(decoded.fields['hr'], 79); + expect(decoded.fields['wearing'], isFalse); + expect(decoded.fields['location_raw'], 2); + }); + }); + + group('live R10 dispatch', () { + // hr == 0 is a legitimate off-wrist reading (see live.dart's + // `wristOn: hr > 0`), not a failed decode. This used to fall through to a + // bare 'data_record' with no wearing field at all. + test('hr == 0 still emits realtime_hr with wearing:false, not silence', + () { + final b = Uint8List(64); + final bd = b.buffer.asByteData(); + b[0] = 0x28; + b[1] = Record.r10; + bd.setUint32(3, 42, Endian.little); // counter + bd.setUint32(7, 1780840486, Endian.little); // ts + b[17] = 0; // hr == 0: off-wrist + + final decoded = decodeFrame(Frame(b, true, true)); + expect(decoded.kind, 'realtime_hr'); + expect(decoded.fields['hr'], 0); + expect(decoded.fields['wearing'], isFalse); + }); }); }