diff --git a/lib/src/band.dart b/lib/src/band.dart index ec31b2a..484badd 100644 --- a/lib/src/band.dart +++ b/lib/src/band.dart @@ -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, diff --git a/lib/src/commands.dart b/lib/src/commands.dart index c5209e1..e511605 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -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 @@ -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. @@ -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. @@ -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 = [ @@ -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 @@ -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 @@ -819,7 +818,7 @@ const Set 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 diff --git a/lib/src/constants.dart b/lib/src/constants.dart index 71c9487..2d11c14 100644 --- a/lib/src/constants.dart +++ b/lib/src/constants.dart @@ -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 diff --git a/lib/src/gen5_records.dart b/lib/src/gen5_records.dart index 66d105a..fd6edd0 100644 --- a/lib/src/gen5_records.dart +++ b/lib/src/gen5_records.dart @@ -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 @@ -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 @@ -202,8 +200,8 @@ class Gen5HistorySample extends Gen5HistoricalRecord { final int rrCount; final List 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. @@ -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. @@ -271,8 +268,7 @@ class Gen5HistorySample extends Gen5HistoricalRecord { final List 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 @@ -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 @@ -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; @@ -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 @@ -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 { @@ -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 @@ -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. diff --git a/lib/src/records.dart b/lib/src/records.dart index 37876b1..e92342b 100644 --- a/lib/src/records.dart +++ b/lib/src/records.dart @@ -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, diff --git a/test/gen5_command_surface_test.dart b/test/gen5_command_surface_test.dart index da491f0..a49a6ea 100644 --- a/test/gen5_command_surface_test.dart +++ b/test/gen5_command_surface_test.dart @@ -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]); diff --git a/test/gen5_historical_test.dart b/test/gen5_historical_test.dart index 10098f7..60eec2d 100644 --- a/test/gen5_historical_test.dart +++ b/test/gen5_historical_test.dart @@ -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. @@ -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); @@ -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' @@ -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; diff --git a/test/gen5_test.dart b/test/gen5_test.dart index b724e7f..8a09ecf 100644 --- a/test/gen5_test.dart +++ b/test/gen5_test.dart @@ -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);