Skip to content
Merged
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
3 changes: 1 addition & 2 deletions lib/src/band.dart
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,7 @@ class BandProfile {
/// (host → strap). Null on gen4, which has no such field at all (4-byte
/// header).
///
/// FINDING (byte-verified against 8 real gen5 fixtures, not stated
/// correctly by either upstream reference repo — both assumed a single
/// FINDING (byte-verified against 8 real gen5 fixtures — NOT a single
/// universal `[0x00,0x01]`): these bytes are NOT a fixed constant. Every
/// host→strap COMMAND frame carries `[0x00,0x01]`; every strap→host frame
/// of every OTHER packet type (METADATA, HISTORICAL_DATA, REALTIME_DATA,
Expand Down
29 changes: 14 additions & 15 deletions lib/src/commands.dart
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,8 @@ Uint8List cmdBuzz(int seq,
//
// The alarm has THREE known on-wire forms:
// • a 7-byte SHORT form ([cmdSetAlarmSimple]) — time only, no haptic-mode;
// • a 9-byte REV-1 form ([cmdSetAlarmRev1]) — time + a haptic-mode u16.
// This is what the official WHOOP app sends (btsnoop capture); and
// • a 9-byte REV-1 form ([cmdSetAlarmRev1]) — time + a haptic-mode u16;
// and
// • a 20-byte RICH form ([cmdSetAlarm]) — time + slot + a haptic waveform.
//
// ⚠ Which form a WHOOP 4 EXECUTES is firmware-dependent (evidence: issue
Expand All @@ -317,16 +317,16 @@ Uint8List cmdBuzz(int seq,
// byte-identical frames, so there is no separate short-form behaviour on
// the wire.
//
// For a gen4 wake alarm, use [cmdSetAlarmRev1] — the official app's form,
// not observed to fail on any firmware.
// For a gen4 wake alarm, use [cmdSetAlarmRev1] — not observed to fail on
// any firmware.
//
// gen5: SET_ALARM_TIME(66)/DISABLE_ALARM(69) are opcode-identical across
// generations (§1.4), so [cmdSetAlarm]/[cmdSetAlarmSimple]/[cmdDisableAlarm]/
// [cmdRunAlarm] now take an optional `profile` to build a gen5-framed
// version of the SAME payload shape. That payload shape's gen4
// hardware-verification does NOT transfer automatically — noop's own
// comments mark this REVISION_4 body / DISABLE_ALARM's REVISION_2 body as
// EXPERIMENTAL/hardware-unconfirmed-for-waking on gen5 specifically. Treat
// hardware-verification does NOT transfer automatically — this REVISION_4
// body and DISABLE_ALARM's REVISION_2 body are unconfirmed for waking on
// gen5 specifically. Treat
// gen5 alarm calls as feature-flagged/experimental until verified on real
// Maverick/5.0 hardware — do not promise it wakes a gen5 strap.

Expand Down Expand Up @@ -391,15 +391,14 @@ Uint8List cmdSetAlarmSimple(int seq, DateTime when,
return buildCommand(seq, Cmd.setAlarmTime, p, profile);
}

/// REV-1 alarm form (SET_ALARM_TIME = 0x42) — the official app's arm form
/// (btsnoop-captured; the wire vector is pinned in the tests), verified to
/// fire on fw 41.17.4.
/// REV-1 alarm form (SET_ALARM_TIME = 0x42) — the wire vector is pinned in
/// the tests; verified to fire on fw 41.17.4.
///
/// Payload = 9 bytes:
/// `[0x01][u32 epoch-seconds LE][u16 sub-seconds LE][u16 haptic-mode LE]`.
/// - `0x01` — the rev-1 form marker, as in [cmdSetAlarmSimple].
/// - epoch / sub-seconds — as everywhere else (1/32768-s units).
/// - haptic-mode — buzz selector. The official app sends 0, the stock wake
/// - haptic-mode — buzz selector. 0 is the stock wake
/// buzz (observed ~24 s, ended by HAPTICS_TERMINATED event 100). Non-zero
/// modes are accepted on the wire but unexplored — keep the default unless
/// you are experimenting.
Expand Down Expand Up @@ -679,7 +678,7 @@ Uint8List cmdGetClockGen5(int seq) =>
Uint8List cmdBuzzGen5Maverick(int seq, {int overallLoop = 1}) {
// Clamp rather than throw — a caller-supplied loop count (e.g. from a UI
// slider) out of u8 range is a caller mistake, not a reason to crash the
// buzz command entirely. Matches the reference implementation's behavior.
// buzz command entirely.
final clampedLoop =
overallLoop < 0 ? 0 : (overallLoop > 0xff ? 0xff : overallLoop);
final payload = <int>[
Expand All @@ -704,7 +703,7 @@ Uint8List cmdBuzzGen5Maverick(int seq, {int overallLoop = 1}) {
//
// Opcode-identical to gen4's SET_FF_VALUE, but gen5's R22 deep buffers
// (v20 optical / v21 IMU / v26 PPG — see gen5_records.dart) are OFF by
// default even in the official WHOOP app; a strap will only ever emit v18
// default; a strap will only ever emit v18
// unless this 16-flag sequence is sent first. Body shape: 65 bytes =
// `[0x01 revision][name:32B NUL-padded ASCII][value:32B NUL-padded ASCII]`.
// (An older note here described a 40-byte, revision-less body with the name at
Expand Down Expand Up @@ -760,7 +759,7 @@ Uint8List cmdSetDeviceConfigValueGen5(int seq, String name, String value) {
return buildCommand(seq, Cmd.setDeviceConfigValue, payload, BandProfile.gen5);
}

// ⚠ THE OFFICIAL BOOLEAN WRITE VALUES, and nothing else:
// ⚠ THE BOOLEAN WRITE VALUES, and nothing else:
// '1' — enable
// '2' — DISABLE
// ASCII '0' is NOT a value the boolean writer ever emits: a key
Expand Down Expand Up @@ -819,7 +818,7 @@ const Set<String> kGen5R22ContestedFlagNames = {
/// Build the R22 enable sequence (one SET_CONFIG per [kGen5R22EnableFlags],
/// sequential `seq` starting at [startSeq]). This is a hard prerequisite for
/// ever receiving v20 (optical)/v21 (IMU)/v26 (PPG) deep buffers from a real
/// gen5 strap — the official WHOOP app never sends it, so a fresh connection
/// gen5 strap — the flags are off by default, so a fresh connection
/// without this sequence will only ever yield v18.
///
/// PERSISTENT AND PARTLY IRREVERSIBLE. These are NVM writes that survive
Expand Down
10 changes: 5 additions & 5 deletions lib/src/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -237,19 +237,19 @@ class Cmd {
static const int setFfValue = 120;
}

/// Band-agnostic opcode safety classification, sourced from whoop-rs's
/// hardware-tested command surface (kept SEPARATE from [dangerousCmds] above,
/// Band-agnostic opcode safety classification (kept SEPARATE from
/// [dangerousCmds] above,
/// which is OpenStrap's own, independently-curated gen4 list — the two do not
/// fully overlap, e.g. this list omits the device-update opcodes (0x24-0x26)
/// that [dangerousCmds] already blocks, and adds a few whoop-rs flags ours
/// didn't have, notably 120/SET_FF_VALUE — see the note on [forbidden] below).
/// that [dangerousCmds] already blocks, and adds a few flags ours didn't
/// have, notably 120/SET_FF_VALUE — see the note on [forbidden] below).
///
/// This class only PUBLISHES the classification; it does not enforce
/// anything itself — enforcement is a call-site concern (edge, at the point
/// it issues a command write), per the multiband port plan's recommendation
/// that the guard be "profile-data, not scattered logic".
class OpcodeSafety {
/// Opcodes whoop-rs treats as never-safe-to-auto-fire. NOTE: 120
/// Opcodes that are never safe to auto-fire. NOTE: 120
/// (SET_FF_VALUE / SET_CONFIG) is in this list, yet [commands.dart]'s R22
/// enable-sequence deliberately sends opcode 120 sixteen times — that is
/// an intentional, explicit, user-opted-in action (the R22 deep-buffer
Expand Down
39 changes: 16 additions & 23 deletions lib/src/gen5_records.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
// = {9, 12, 24}`, which targeted the WRONG version set (those are WHOOP4's
// thin/rich HR-only and full-optical layouts, not anything gen5 ships). Real
// WHOOP 5.0/MG historical data (packet type 0x2F) ships hist_version bytes
// 18, 20, 21, 26 — the VERSION SET is confirmed independently by whoop-rs
// (Rust, hardware-tested) and noop (Swift, multiple straps/firmware builds),
// 18, 20, 21, 26 (confirmed across multiple straps and firmware builds),
// and v18/v21/v26's FIELD LAYOUTS are independently re-verified byte-by-byte
// here against real fixtures (CRC16 + CRC32 both checked) — see
// gen5_historical_test.dart for the golden parity tests. v26's trailing
Expand Down Expand Up @@ -185,10 +184,9 @@ enum Gen5SleepState {
}

/// Decoded gen5 v18 historical record. Field confidence/status is annotated
/// per-field below — several fields have OPEN semantic disagreements between
/// the two reference implementations (whoop-rs vs noop) that could not be
/// resolved from bytes alone; those are called out explicitly rather than
/// silently picking a side.
/// per-field below — several fields have OPEN semantic questions that could
/// not be resolved from bytes alone; those are called out explicitly rather
/// than silently picking a meaning.
class Gen5HistorySample extends Gen5HistoricalRecord {
/// bpm. 0 is the band's own "no reading this second" (warming up / off skin),
/// and it is also what we emit when the HR byte lands outside 25..230 — an
Expand All @@ -202,8 +200,8 @@ class Gen5HistorySample extends Gen5HistoricalRecord {
final int rrCount;
final List<int> rrIntervalsMs;

/// Raw @ inner[25] (frame-abs 33). whoop-rs calls this offset
/// "signal_flags"; the meaning is otherwise unconfirmed. Exposed raw.
/// Raw @ inner[25] (frame-abs 33). Possibly signal flags; the meaning is
/// unconfirmed. Exposed raw.
final int cardiacFlags;

/// @ inner[28] (frame-abs 36) — a flags-plus-counter byte.
Expand Down Expand Up @@ -246,9 +244,8 @@ class Gen5HistorySample extends Gen5HistoricalRecord {
/// consume as a decoded value yet.
final int rrPacked;

/// @ inner[32] (frame-abs 40). Meaning still unpinned. whoop-rs calls it
/// "signal_quality" and gates an HR-anomaly check on `>=192` — that gate
/// passes 96.7% of records and its rejections don't track [sleepState]
/// @ inner[32] (frame-abs 40). Meaning still unpinned. It looks like a
/// signal-quality byte, but an HR-anomaly gate on `>=192` passes 96.7% of records and its rejections don't track [sleepState]
/// consistently between bands, so it is not doing what it looks like.
///
/// Exposed raw ONLY. Do NOT wire an HR-anomaly gate off this byte.
Expand All @@ -271,8 +268,7 @@ class Gen5HistorySample extends Gen5HistoricalRecord {
final List<double> gravityG;

/// Cumulative on-chip step counter @ inner[49:51] u16 LE (frame-abs 57).
/// FULL 2 bytes — an earlier bug (fixed upstream, noop #132/#276) read
/// only the low byte. No midnight reset.
/// FULL 2 bytes — reading only the low byte is a known bug. No midnight reset.
///
/// Passive behaviour supports a counter, but the NAME is not established:
/// the byte pair is near-monotonically non-decreasing across long runs of
Expand Down Expand Up @@ -341,7 +337,7 @@ class Gen5HistorySample extends Gen5HistoricalRecord {
double? get skinTempCOrNull => skinTempAvailable ? skinTempC : null;

/// The three packed per-channel AGC/state words @ inner[67/69/71] u16 LE
/// (frame-abs 75/77/79). NOT deep-sleep markers (the noop reading).
/// (frame-abs 75/77/79). NOT deep-sleep markers.
/// Bit layout:
/// bits 0-1 channel index bits 2-3 zero
/// bits 4-7 PD-A/PD-B AGC offset-current indices
Expand All @@ -367,8 +363,8 @@ class Gen5HistorySample extends Gen5HistoricalRecord {
/// bits 2-3: **passive strap-fit classifier state** (the feature behind
/// `enable_passive_strap_fit_gen5`) — not a "wake quality".
/// bits 4-5: sleep_state — 0 wake / 1 still / 2 sleep / 3 up. Prefer
/// [sleepState] over reading the nibble yourself. whoop-rs's
/// "0 still / 1 wake" is the wrong way round.
/// [sleepState] over reading the nibble yourself. "0 still / 1 wake"
/// is the wrong way round.
/// bits 6-7: documented as the **high slot, zero**. Exposed raw so a
/// nonzero value is visible if firmware ever uses it — see [bits67Raw].
final int sleepStateByte;
Expand Down Expand Up @@ -839,8 +835,7 @@ const int _kV20NumBlocks = 5;
/// The exact inner length of a v20 buffer: total on-wire frame is 2140 bytes
/// (8-byte header + padded-inner + 4-byte CRC32) per the reference fixture,
/// so padded-inner = 2140 - 8 - 4 = 2128. Used as v20's PRIMARY identity
/// check — length-gated before the version byte is even trusted, mirroring
/// both reference repos' defensive pattern (§1.5).
/// check — length-gated before the version byte is even trusted (§1.5).
const int kGen5V20InnerLen =
_kV20BodyStart + _kV20NumBlocks * _kV20BlockLen; // 2128

Expand Down Expand Up @@ -916,8 +911,7 @@ class Gen5V20Decoder implements Gen5RecordDecoder {

// ── v21 — 100Hz 6-axis raw IMU buffer (R22 opt-in only). ───────────────────

/// Decoded gen5 v21 IMU buffer. High-confidence layout — exact 3-way
/// agreement between whoop-rs, noop, and this file's own byte-level
/// Decoded gen5 v21 IMU buffer. High-confidence layout — byte-level
/// verification (§1.5). The 100 Hz sample rate is confirmed: the band
/// configures both blocks at 100 Hz, so a full block is one second of motion.
class Gen5ImuBuffer extends Gen5HistoricalRecord {
Expand Down Expand Up @@ -977,8 +971,7 @@ const int _kV21SamplesPerAxis = 100;
/// Exact inner length: total on-wire frame is 1244 bytes, so padded-inner =
/// 1244 - 8 - 4 = 1232. PRIMARY identity check, along with [Gen5V21Decoder]'s
/// bounded-count gate — neither the length nor the counts depend on trusting
/// `hist_version` at all, matching how both reference repos actually
/// identify this buffer.
/// `hist_version` at all.
const int kGen5V21InnerLen = _kV21GxStart + 3 * 2 * _kV21SamplesPerAxis; // 1232

/// True when [inner] carries a record-21 IMU buffer, whichever packet type it
Expand Down Expand Up @@ -1972,7 +1965,7 @@ class Gen5V22Decoder implements Gen5RecordDecoder {
//
// Each decoder does its own cheap pre-check (`matches`) BEFORE trusting
// `hist_version` — v21 in particular is identified purely by shape (paired
// sample counts), matching how both reference repos actually recognise it.
// sample counts).
// Adding a future band's record kind means writing one more of these and
// registering it in [kGen5HistoricalDecoders]; nothing here needs to branch
// on a generation name.
Expand Down
8 changes: 3 additions & 5 deletions lib/src/records.dart
Original file line number Diff line number Diff line change
Expand Up @@ -611,11 +611,9 @@ class FirmwareAwareR24Decoder {
// SUPERSEDED (2026-08, multiband port): this file used to also own a
// `parseGen5Record` targeting `_gen5NormalHistoryVersions = {9, 12, 24}`.
// That version set is WRONG — 9/12/24 are WHOOP4's thin/rich HR-only and
// full-optical layouts, not anything a real WHOOP 5.0/MG strap ships. Both
// independent reference implementations (whoop-rs, hardware-tested; noop,
// tens of thousands of captured records across multiple straps/firmware
// builds) agree that real gen5 historical data (packet type 0x2F) ships
// hist_version bytes 18, 20, 21, 26 — never 9/12/24. Running gen5 bytes
// full-optical layouts, not anything a real WHOOP 5.0/MG strap ships. Real
// gen5 historical data (packet type 0x2F) ships hist_version bytes 18, 20,
// 21, 26 — never 9/12/24, across multiple straps and firmware builds. Running gen5 bytes
// through this file's v24 field map (which is what the old `parseGen5Record`
// effectively did, gated down to just the HR byte) reads all-zero garbage on
// real captures — exactly the symptom this file's old doc comment described,
Expand Down
7 changes: 3 additions & 4 deletions test/gen5_command_surface_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,9 @@ void main() {
expect(_body(cmdSetAlarmRev1(1, half)).sublist(5, 7), [0x00, 0x40]);
});

test('rev-1 pins the official app\'s wire capture (issue #32)', () {
// btsnoop of the official app arming a real WHOOP 4.0: epoch 1781912880
// (0x6A35D530), subsec 0, haptic-mode 0. The same shape fired on fw
// 41.17.4 (issue #32).
test('rev-1 pins the wire vector (issue #32)', () {
// A real WHOOP 4.0 arm: epoch 1781912880 (0x6A35D530), subsec 0,
// haptic-mode 0. This shape fired on fw 41.17.4 (issue #32).
final capture = DateTime.fromMillisecondsSinceEpoch(1781912880 * 1000);
expect(_body(cmdSetAlarmRev1(1, capture)),
[0x01, 0x30, 0xD5, 0x35, 0x6A, 0x00, 0x00, 0x00, 0x00]);
Expand Down
13 changes: 6 additions & 7 deletions test/gen5_historical_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
// The v18 and v26 fixtures below are REAL captures, independently
// byte-verified (CRC16-modbus header + CRC32 payload both check out; every
// decoded field cross-checked by hand against the multiband port spec's §1.5
// claims, which themselves come from two independent hardware-tested
// reference implementations). The v20/v21 cases are synthetic — no full real
// claims). The v20/v21 cases are synthetic — no full real
// capture was available for this task — but exercise the exact
// byte-verified offsets/scales from §1.5, so they validate the arithmetic
// even without a real fixture.
Expand Down Expand Up @@ -148,7 +147,7 @@ void main() {
});

test('experimental fields exposed raw, not fabricated', () {
// frame-abs 40: still unnamed, and the whoop-rs `>=192` gate passes
// frame-abs 40: still unnamed, and a `>=192` gate passes
// 96.7% of records, so no anomaly gate is wired off it. 255 is the
// modal value.
expect(sample.cardiacStatusRaw, 255);
Expand All @@ -173,7 +172,7 @@ void main() {
// acceleration runs 0.0773 / 0.0255 / 0.0104 / 0.0504 g and median heart
// rate 88 / 76 / 60 / 77 bpm across nibbles 0..3, so nibble 0 is the
// highest-motion, highest-HR state (it cannot be "still") and nibble 2 is
// the lowest of both. whoop-rs's "0 still / 1 wake" is reversed.
// the lowest of both. "0 still / 1 wake" is reversed.
final frame = hex(
'aa01740001003fb12f1280733d8401b69f266a66460066025a0265020000000'
'000007b0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000'
Expand Down Expand Up @@ -601,10 +600,10 @@ void main() {
expect(parseGen5Historical(inner), isNull);
});

test('channel slot start offsets match both reference repos exactly', () {
// whoop-rs's inner-relative offsets (39,239,1305,1505,1727,1927) — see
test('channel slot start offsets match the frame layout exactly', () {
// inner-relative offsets (39,239,1305,1505,1727,1927) — see
// gen5_records.dart's derivation from the frame-absolute offsets
// (47,247,1313,1513,1735,1935) noop states directly.
// (47,247,1313,1513,1735,1935).
const bodyStart = 18, blockLen = 422;
int ch0(int b) => bodyStart + b * blockLen + 21;
int ch1(int b) => ch0(b) + 200;
Expand Down
2 changes: 1 addition & 1 deletion test/gen5_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ void main() {
});

group('OpcodeSafety', () {
test('classifies the whoop-rs forbidden/destructive lists', () {
test('classifies the forbidden/destructive lists', () {
expect(OpcodeSafety.isForbidden(Cmd.setClockMaverick), isTrue); // 146
expect(OpcodeSafety.isForbidden(Cmd.forceTrim), isTrue); // 25
expect(OpcodeSafety.isDestructive(Cmd.forceTrim), isTrue);
Expand Down
Loading