Skip to content
Open
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
18 changes: 9 additions & 9 deletions lib/src/control.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]).
Expand Down Expand Up @@ -497,11 +496,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) {
Expand Down
33 changes: 16 additions & 17 deletions lib/src/labrador.dart
Original file line number Diff line number Diff line change
@@ -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].
Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 14 additions & 11 deletions lib/src/records.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 = <int>[];
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);
Expand Down
29 changes: 29 additions & 0 deletions test/decode_guards_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 =
Expand Down
2 changes: 1 addition & 1 deletion test/doc_conformance_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> inner(Uint8List f) =>
parseFrame(f, profile: BandProfile.gen5)!.inner;
Expand Down
5 changes: 2 additions & 3 deletions test/gen5_hello_maverick_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
4 changes: 2 additions & 2 deletions test/gen5_historical_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)!;
Expand Down
5 changes: 2 additions & 3 deletions test/labrador_r17_test.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
5 changes: 2 additions & 3 deletions test/labrador_reassembly_test.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading