Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,13 @@ dart test
Two things guard every change:

- **`decode_parity_cases.json`** — 2934 cases checked against the frozen
TypeScript oracle in `ts/`. If you change a decoder and parity breaks, either
your change is wrong or the oracle needs regenerating — work out which, and
say so in the PR.
oracle. This file *is* the oracle now — it was generated from a TypeScript
reference implementation (`ts/`) that was later deleted once parity was
locked in (still recoverable from git history if you need to regenerate
cases from scratch), but nothing in `dart test` spawns node/tsc against it
any more. If you change a decoder and parity breaks, either your change is
wrong or the fixture needs regenerating — work out which, and say so in the
PR.
- **`dart_header.json`** — 550 hand-checked R24 header cases.

Both are tracked in-repo and run in CI. A third set replays
Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

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.
the whole job. Covers WHOOP 4 (gen4) and WHOOP 5 (gen5), a generic Bluetooth Heart Rate
Service (0x180D) sensor, and the Oura ring's wire format.

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
Expand All @@ -30,6 +31,13 @@ server that ever sees your raw bytes.
etc.) and the control-plane decoders (HELLO, events, command responses, metadata/sync
markers).
- `constants.dart` — the GATT UUIDs, opcode tables, event IDs.
- `band.dart` — the multi-generation ("multi-band") wire-format profile: WHOOP 5 (gen5 /
"fd4b") is WHOOP 4 (gen4 / "Harvard") in a different envelope, and this is what lets
framing/records/edge stay band-agnostic instead of forking per generation.
- `gen5_records.dart` — WHOOP 5 (gen5) historical record decoders: v18/v20/v21/v26.
- `hrs.dart` — the Bluetooth SIG's generic Heart Rate Service (0x180D) as a pure
function, for any standard chest strap or optical armband, not one vendor's device.
- `oura.dart` — the Oura ring's wire format, as pure functions.

## The one record that matters most

Expand Down Expand Up @@ -155,7 +163,10 @@ 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 WHOOP reverse-engineering write-ups (e.g. the `noop`
project, or bWanShiTong's `reverse-engineering-whoop-post`) for facts/techniques is
fine; copying their code is not. These are external projects, not paths inside this
repo — go find and clone them separately if you want to compare.

## Contributing

Expand Down
46 changes: 16 additions & 30 deletions lib/src/commands.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ enum WristSelection {
final int value;
}

/// LE-encode a WHOOP (whole-seconds, subseconds) timestamp pair as the 6 bytes
/// `[sec:u32le][subsec:u16le]` shared by SET_CLOCK and every SET_ALARM form.
List<int> _leTimestamp(int sec, int subsec) => [
sec & 0xff,
(sec >> 8) & 0xff,
(sec >> 16) & 0xff,
(sec >> 24) & 0xff,
subsec & 0xff,
(subsec >> 8) & 0xff,
];

/// Build a framed command packet: [type][seq][opcode][payload].
/// [profile] selects the generation's frame envelope (default gen4 = WHOOP 4).
/// The inner bytes are identical across generations — command opcodes are
Expand Down Expand Up @@ -135,12 +146,7 @@ Uint8List cmdSetClock(int seq,
final sec = ms ~/ 1000;
final subsec = ((ms % 1000) * 32768) ~/ 1000; // 0..32767, 1/32768 s units
final payload = <int>[
sec & 0xff,
(sec >> 8) & 0xff,
(sec >> 16) & 0xff,
(sec >> 24) & 0xff,
subsec & 0xff,
(subsec >> 8) & 0xff,
..._leTimestamp(sec, subsec),
0,
0,
];
Expand Down Expand Up @@ -365,12 +371,7 @@ Uint8List cmdSetAlarmSimple(int seq, DateTime when,
final subsec = _alarmSubsec(when);
final p = <int>[
0x01,
sec & 0xff,
(sec >> 8) & 0xff,
(sec >> 16) & 0xff,
(sec >> 24) & 0xff,
subsec & 0xff,
(subsec >> 8) & 0xff,
..._leTimestamp(sec, subsec),
];
return buildCommand(seq, Cmd.setAlarmTime, p, profile);
}
Expand Down Expand Up @@ -422,12 +423,7 @@ List<int> alarmRev1Payload(DateTime when, {int hapticMode = 0}) {
final subsec = _alarmSubsec(when);
return <int>[
0x01,
sec & 0xff,
(sec >> 8) & 0xff,
(sec >> 16) & 0xff,
(sec >> 24) & 0xff,
subsec & 0xff,
(subsec >> 8) & 0xff,
..._leTimestamp(sec, subsec),
hapticMode & 0xff,
(hapticMode >> 8) & 0xff,
];
Expand Down Expand Up @@ -509,12 +505,7 @@ Uint8List cmdSetAlarm(
final p = <int>[
0x04,
slot & 0xff,
sec & 0xff,
(sec >> 8) & 0xff,
(sec >> 16) & 0xff,
(sec >> 24) & 0xff,
subsec & 0xff,
(subsec >> 8) & 0xff,
..._leTimestamp(sec, subsec),
...pattern.map((b) => b & 0xff),
if (profile.isGen5) crescendo,
];
Expand Down Expand Up @@ -652,12 +643,7 @@ Uint8List cmdSetClockGen5(int seq, {DateTime? now}) {
final subsec = ((ms % 1000) * 32768) ~/ 1000;
final payload = <int>[
revision1,
sec & 0xff,
(sec >> 8) & 0xff,
(sec >> 16) & 0xff,
(sec >> 24) & 0xff,
subsec & 0xff,
(subsec >> 8) & 0xff,
..._leTimestamp(sec, subsec),
0,
0,
];
Expand Down
21 changes: 19 additions & 2 deletions lib/src/control.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,18 @@ Decoded _decodeDataRecord(Uint8List inner,
final recType = inner.length > 1 ? inner[1] : -1;
// Compact realtime stream (small packet).
if (inner.length < 64) {
if (recType == 2) {
final v2 = parseRealtimeHrV2(inner);
if (v2 != null) {
return Decoded('realtime_hr', {
'rec_type': recType,
'ts_epoch': v2.tsEpoch,
'hr': v2.hrBpm,
'wearing': !v2.isOffBody,
'location': v2.locationRaw,
});
}
}
final hr = parseRealtimeHr(inner);
if (hr != null) {
return Decoded('realtime_hr', {
Expand All @@ -1432,16 +1444,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 legitimate off-wrist reading (see
// live.dart's `wristOn = hr > 0`), not an undecoded record. Falling
// through to 'data_record' below dropped the timestamp and wearing
// state for every wrist-off period.
return Decoded('realtime_hr', {
'rec_type': recType,
'hr': r.hr,
'rr_ms': r.rrIntervalsMs,
'wearing': true,
'wearing': r.hr > 0,
});
}
}
Expand Down
Loading