From cebbc68902f82639a903011bffbafcb7f1fac73e Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:38:56 +0530 Subject: [PATCH 1/2] hello battery scan starts at [3], v24 rr count capped at its 4 slots, trim labrador comments --- lib/src/control.dart | 15 +++++++------- lib/src/labrador.dart | 33 +++++++++++++++--------------- lib/src/records.dart | 25 ++++++++++++---------- test/decode_guards_test.dart | 29 ++++++++++++++++++++++++++ test/gen5_hello_maverick_test.dart | 5 ++--- test/labrador_r17_test.dart | 5 ++--- 6 files changed, 71 insertions(+), 41 deletions(-) diff --git a/lib/src/control.dart b/lib/src/control.dart index ee414a2..3ce34b7 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -383,8 +383,8 @@ class Gen5HelloInfo { /// `48 <= value < 86`. bool get isWhoop5 => opticalDiscriminator >= 48 && opticalDiscriminator < 86; - /// WHOOP MG — app generation `MAVERICK` in the official revision-1 HELLO - /// parser: optical discriminator in `[0, 38)`. The physical MG reports 0. + /// WHOOP MG (generation `MAVERICK`) in a revision-1 HELLO: optical + /// discriminator in `[0, 38)`. The MG reports 0. /// /// Gated on [helloRevision] == 1, unlike [isWhoop5]: MG is the gate for the /// Labrador/ECG lifecycle, and a HELLO of an unknown revision must never be @@ -497,11 +497,12 @@ HelloInfo parseHello(Uint8List payload) { // a percentage is 0..100 by definition, so 1000 is the ceiling. Out of // range = not the battery field, keep scanning / leave batteryPct null. // - // The scan starts at payload[2] — the first byte of the response BODY. - // payload[0] is the echoed request seq and payload[1] the status, and a - // status of 1 next to a small body byte reads as a perfectly plausible - // 0.1–76.9%, so starting at 1 reported the status byte as a battery level. - for (int off = 2; off < 10; off++) { + // The scan starts at payload[3]. payload[0] is the echoed request seq, + // payload[1] the status, and payload[2] a fixed body byte (0x04) that sits + // right before the battery u32 at [3]. Starting at 2 read 0x04 | lo << 8, + // which for a battery low byte of 1..3 lands in range (26.0/51.6/77.2%) and + // stopped the scan before the real field. + for (int off = 3; off < 10; off++) { if (off + 2 <= payload.length) { final v = u16(payload, off); if (v >= 10 && v <= 1000) { diff --git a/lib/src/labrador.dart b/lib/src/labrador.dart index bb7a7c7..f1e79a2 100644 --- a/lib/src/labrador.dart +++ b/lib/src/labrador.dart @@ -1,14 +1,12 @@ // labrador.dart — WHOOP MG Labrador records: the filtered ECG (revision 17) // and the raw ECG (revision 16). // -// Evidence: official Android 5.458.0 Labrador parser + exact 50.41.1.0 -// firmware constructor, physically closed on a WHOOP MG -// (reversing-whoop docs/mg/02, docs/mg/05). Every field below is the -// source-proven use; bytes past the sample block are preserved but NOT named. +// Layout checked on a WHOOP MG (firmware 50.41.1.0). Only fields with a known +// meaning are named; bytes past the sample block are preserved but NOT named. // // Deliberately NOT part of the gen5 historical decoder family // (gen5_records.dart): R17 arrives LIVE as packet type 43 (REALTIME_RAW_DATA) -// on the official foreground path, which that type-47-only dispatch never +// during a foreground ECG reading, which that type-47-only dispatch never // sees; and the family's base `flags` (inner[2]) / `ppgSampleRateHz` would // name a byte this record gives no meaning to. R17 has its own flags byte at // inner[14]. @@ -31,9 +29,9 @@ class LabradorFlags { /// bit 0 — entering S2 state 1. bool get enteringS2One => (raw & 0x01) != 0; - /// bit 1 — current S2 state is 1. The official reducer appends ordinary - /// active frames only while this is set; a valid active frame with it clear - /// is the distinct explicit-RESTART branch. + /// bit 1 — current S2 state is 1. Ordinary active frames belong to the + /// reading only while this is set; a valid active frame with it clear + /// means the reading restarted. bool get currentS2One => (raw & 0x02) != 0; /// bit 2 — S2 transition 1 -> 2 (physically `0x0c` on the terminal frame). @@ -90,13 +88,13 @@ class LabradorR17 { /// `0xffff` at inner[21..22] means the variability value is unavailable. static const int variabilityUnavailable = 0xffff; - /// inner[0]: 43 (REALTIME_RAW_DATA, the official live path) or 47 - /// (HISTORICAL_DATA — a stored R17, which the official foreground flow never - /// enables; see [parse]'s `allowStored`). + /// inner[0]: 43 (REALTIME_RAW_DATA, the live path) or 47 + /// (HISTORICAL_DATA — a stored R17, which a foreground reading never + /// produces; see [parse]'s `allowStored`). final int packetType; - /// inner[2] — a generic packet-context marker the official R17 consumer - /// ignores (`0x80` on the first all-zero boundary packet). Kept raw. + /// inner[2] — a generic packet-context marker with no R17-specific meaning + /// (`0x80` on the first all-zero boundary packet). Kept raw. final int headerSecondary; final int sequence; // inner[3..6] u32 LE data-cycle sequence @@ -112,8 +110,9 @@ class LabradorR17 { final int liveHr; // inner[20] current HR — live category input /// inner[21..22] u16 LE, or null when the wire value is [variabilityUnavailable]. - /// Twice the RMS successive difference over 30 callback values (firmware); - /// the callback unit is unresolved, so no physiological unit is exposed. + /// Twice the RMS successive difference over 30 callback values, computed on + /// the strap; the callback unit is unresolved, so no physiological unit is + /// exposed. final int? variabilityRaw; final int reserved; // inner[23] final int sampleCount; // inner[24..25] u16 LE, <= [maxSamples] @@ -149,10 +148,10 @@ class LabradorR17 { bool get presence => flags.presence; - /// The official app's completion condition. + /// The reading is complete. bool get isTerminal => progress == 100 || s2State == 2; - /// The official app's invalid/abort sentinel. + /// The reading was invalid or aborted. bool get isInvalid => progress == 255; bool get isLive => packetType == PacketType.realtimeRawData; diff --git a/lib/src/records.dart b/lib/src/records.dart index 25162bf..37876b1 100644 --- a/lib/src/records.dart +++ b/lib/src/records.dart @@ -215,19 +215,22 @@ double _round(double v, int decimals) { return _jsRound(v * p) / p; } -/// Largest R-R interval count a single 1 s historical record can plausibly -/// declare. A larger count byte means we are reading the wrong offset — not a -/// second containing dozens of heartbeats. A record declaring more than this -/// yields NO intervals at all (absence), never a truncated read of whatever -/// bytes happen to follow (ppg, accel float32s, spo2, the optical block, -/// ambient). +/// Largest R-R interval count an R10 record can plausibly declare. A larger +/// count byte means we are reading the wrong offset — not a second containing +/// dozens of heartbeats. A record declaring more than this yields NO intervals +/// at all (absence), never a truncated read of whatever bytes happen to follow. /// -/// This is the HISTORICAL-record ceiling only. live.dart's `realtimeRr` uses -/// it for the R10 form, which declares its count inside a 1920-byte record, -/// but a 0x28 realtime packet is 20 bytes with exactly four R-R slots, so that -/// branch caps at 4 instead — see `realtimeRr`. +/// This is the R10 ceiling only (live.dart's `realtimeRr`, control.dart's +/// `_r10Rr`): R10 declares its count inside a 1920-byte record. A 0x28 +/// realtime packet and a v24/v12 historical record each have exactly four R-R +/// slots, so those cap at 4 instead — see `realtimeRr` and [_kV24RrSlots]. const int kMaxRrPerRecord = 8; +/// R-R slots in a v24/v12 historical record: i16 LE at 19/21/23/25. inner[27] +/// is a different field and ppg_green sits at 29, so a count of 5..8 would +/// read those as beats. +const int _kV24RrSlots = 4; + /// Physiologically possible beat-to-beat interval bounds, ms — 2500 ms = 24 bpm, /// 200 ms = 300 bpm. Identical to the bound control.dart's `parseRealtimeHr` /// applies to the realtime RR slots, so both decoders agree on what a beat is. @@ -440,7 +443,7 @@ R24? _parseV24Layout( // actually evidences; beats are not, so they are absent rather than invented. final declaredRrCount = validate ? 0 : inner[18]; final rrIntervalsMs = []; - if (declaredRrCount <= kMaxRrPerRecord) { + if (declaredRrCount <= _kV24RrSlots) { for (int i = 0; i < declaredRrCount && 19 + 2 * i + 2 <= inner.length; i++) { final v = view.getInt16(19 + 2 * i, Endian.little); if (v >= kMinRrMs && v <= kMaxRrMs) rrIntervalsMs.add(v); diff --git a/test/decode_guards_test.dart b/test/decode_guards_test.dart index bc01fcf..b0e9cd9 100644 --- a/test/decode_guards_test.dart +++ b/test/decode_guards_test.dart @@ -82,6 +82,19 @@ void main() { expect(r.rrIntervalsMs, isEmpty); }); + test('a v24 count of 5..8 is rejected — the record has only 4 slots', () { + // Slots are 19/21/23/25; [27:29] is another field and ppg_green is @29. + // Count 5 used to read [27:29] = 800 ms as a fifth "beat". + for (final n in [5, 6, 7, 8]) { + final r = parseR24(hexToBytes(_patched(_goodV24, { + 18: n, + for (int i = 0; i < 5; i++) ...{19 + 2 * i: 0x20, 20 + 2 * i: 0x03}, + })))!; + expect(r.rrIntervalsMs, isEmpty, reason: 'count $n'); + expect(r.rrCount, 0, reason: 'count $n'); + } + }); + test('the count guard applies on the TRUSTED v24/v12 path too', () { // v24 and v12 skip the physiological-plausibility gate entirely, so the // R-R guard has to be independent of it. @@ -419,6 +432,22 @@ void main() { expect(parseHello(payload).batteryPct, isNull); }); + test('parseHello does not read body[0] into the battery', () { + // payload[2] is 0x04 on every real body; with a battery low byte of 1..3 + // u16@2 = 0x0104/0x0204/0x0304 used to win the scan as 26.0/51.6/77.2%. + const body = + 'a001048303000000b196e201b0020000344332323438303932003865323738326237' + '346634303238346333663437363138623062613234373663356431366363303533' + '313862373532316431353635650600000002000000100000002900000011000000'; + for (final e in {0x0201: 51.3, 0x0302: 77.0, 0x0101: 25.7}.entries) { + final b = hexToBytes(body); + b[3] = e.key & 0xff; + b[4] = e.key >> 8; + expect(parseHello(b).batteryPct, closeTo(e.value, 1e-9), + reason: '0x${e.key.toRadixString(16)}'); + } + }); + test('parseHello still reads a real battery field', () { // The real captured HELLO body: battery is 89.9% at offset 3. const body = diff --git a/test/gen5_hello_maverick_test.dart b/test/gen5_hello_maverick_test.dart index dcf66e7..5adf4fc 100644 --- a/test/gen5_hello_maverick_test.dart +++ b/test/gen5_hello_maverick_test.dart @@ -1,6 +1,5 @@ -// Gen5HelloInfo.isMaverick — the WHOOP MG identity gate. Official 5.458.0 -// maps optical revision [0,38) to app generation MAVERICK and [48,86) to -// GOOSE (ordinary WHOOP 5.0); the physical MG returns 0, the retained +// Gen5HelloInfo.isMaverick — the WHOOP MG identity gate. Optical revision +// [0,38) is MAVERICK (WHOOP MG) and [48,86) is GOOSE (ordinary WHOOP 5.0); the physical MG returns 0, the retained // ordinary 5.0 returns 82. Only a revision-1 HELLO may be read this way. import 'dart:typed_data'; diff --git a/test/labrador_r17_test.dart b/test/labrador_r17_test.dart index c49a8df..a36cb9e 100644 --- a/test/labrador_r17_test.dart +++ b/test/labrador_r17_test.dart @@ -1,7 +1,6 @@ // Labrador R17 (WHOOP MG filtered ECG) parser — bounds, identity, signs, -// flags and preserved bytes. Fixtures are SYNTHETIC: the layout is the -// source-proven official parser map (docs/mg/02 §"Revision-17 packet body"), -// never a private capture. +// flags and preserved bytes. Fixtures are SYNTHETIC, built from the R17 +// layout in lib/src/labrador.dart. import 'dart:typed_data'; From 3c92e0a7708a760b067eb379dea651300b7e429f Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:58:40 +0530 Subject: [PATCH 2/2] finish trimming mg/hello comments down to field facts --- lib/src/control.dart | 3 +-- test/doc_conformance_test.dart | 2 +- test/gen5_historical_test.dart | 4 ++-- test/labrador_reassembly_test.dart | 5 ++--- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/src/control.dart b/lib/src/control.dart index 3ce34b7..3c58693 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -418,8 +418,7 @@ class Gen5HelloInfo { /// shorter than the [semanticBodyLen] the fixed-offset parser needs — a short /// body is a failed/foreign reply, never a partially-filled hello. The /// revision byte is recorded in [helloRevision] but is not a gate: the - /// official parser reads the fixed revision-1 offsets regardless of its - /// value. + /// fixed revision-1 offsets are read regardless of its value. /// /// Callers should additionally gate on the command-response STATUS byte; a /// non-success reply leaves the body unpopulated (see [parseCommandResponse]). diff --git a/test/doc_conformance_test.dart b/test/doc_conformance_test.dart index 4f2000b..1246965 100644 --- a/test/doc_conformance_test.dart +++ b/test/doc_conformance_test.dart @@ -111,7 +111,7 @@ void main() { expect(r.inner[3], 0, reason: 'alignment padding, not a body byte'); }); - test('WHOOP MG Labrador lists — exact gen5 bodies and padding (docs/mg/05)', + test('WHOOP MG Labrador lists — exact gen5 bodies and padding', () { List inner(Uint8List f) => parseFrame(f, profile: BandProfile.gen5)!.inner; diff --git a/test/gen5_historical_test.dart b/test/gen5_historical_test.dart index 66df0a0..10098f7 100644 --- a/test/gen5_historical_test.dart +++ b/test/gen5_historical_test.dart @@ -800,8 +800,8 @@ void main() { }); test('a non-1 hello revision still parses at the fixed offsets', () { - // The revision byte is recorded, not a gate — the official parser reads - // the fixed offsets regardless of its value. + // The revision byte is recorded, not a gate — the fixed offsets are + // read regardless of its value. final body = gen5HelloBody(); body[0] = 2; // some future revision final h = Gen5HelloInfo.parse(body)!; diff --git a/test/labrador_reassembly_test.dart b/test/labrador_reassembly_test.dart index 9cd8dad..b0df2c9 100644 --- a/test/labrador_reassembly_test.dart +++ b/test/labrador_reassembly_test.dart @@ -1,6 +1,5 @@ -// A 1,584-byte framed R16 does not fit one BLE notification. The official -// history sync only counts it once complete WHOOP-frame reassembly has run -// (docs/mg/05 §"Required conformance fixtures" item 3). These tests feed one +// A 1,584-byte framed R16 does not fit one BLE notification, so it only +// counts once complete WHOOP-frame reassembly has run. These tests feed one // synthetic R16 frame — and a small R18 behind it — through the gen5 // FrameReassembler under adversarial chunkings, including a body that // contains 0xAA bytes and a fake `aa 01` header.