From 61349b46c33eb61aee171fc3373327ae6a34adca Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Fri, 4 Sep 2026 11:27:29 +0200 Subject: [PATCH] labrador: WHOOP MG identity, R17 parser, R16 recognition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gen5HelloInfo.isMaverick: revision-1 HELLO with optical discriminator in [0,38) — the official 5.458.0 MAVERICK interval (physical MG reports 0; the ordinary WHOOP 5.0 reports 82 and stays GOOSE). Never read through a non-rev-1 layout; UUID/name/command acceptance are not MG evidence. LabradorR17: the live (type 43) filtered-ECG record, source-proven field map at inner[3..25] plus signed i16 LE samples from 26. Bounds: fixed fields present, count <= 100, 26+2*count bytes available, no fixed total length; aligned tail bytes kept unnamed. Samples stay input-referred integer microvolts — no rescale, no wrist sign flip, no lead claim. Stored type-47 R17 only on explicit opt-in (the official foreground flow never enables it). Standalone rather than a Gen5HistoricalRecord: live frames never reach that type-47 dispatch, and its inner[2] flags/ppg-rate meaning does not apply. LabradorR16Raw: recognises historical type-47 revision-16 raw ECG and keeps the exact inner bytes with the common sequence/strap-time header only — the body is not source-closed. cmdAbortHistorical gains a profile parameter so a gen5 caller pads the bodyless opcode 20 through the normal framer; gen4 default is byte-identical. Tests: identity boundaries, 0/49/100 samples, count >100, count beyond bytes, every fixed-field truncation, i16 signs, flag and unreadable bits, tail preservation, R16 recognition, 1,584-byte R16 reassembly under adversarial chunking (embedded 0xAA/fake header), and exact Labrador list bytes in doc_conformance. --- lib/openstrap_protocol.dart | 3 + lib/src/commands.dart | 23 ++- lib/src/control.dart | 23 ++- lib/src/labrador.dart | 268 +++++++++++++++++++++++++++ test/doc_conformance_test.dart | 61 ++++++- test/gen5_hello_maverick_test.dart | 59 ++++++ test/labrador_r16_test.dart | 79 ++++++++ test/labrador_r17_test.dart | 280 +++++++++++++++++++++++++++++ test/labrador_reassembly_test.dart | 111 ++++++++++++ 9 files changed, 887 insertions(+), 20 deletions(-) create mode 100644 lib/src/labrador.dart create mode 100644 test/gen5_hello_maverick_test.dart create mode 100644 test/labrador_r16_test.dart create mode 100644 test/labrador_r17_test.dart create mode 100644 test/labrador_reassembly_test.dart diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 10abe3c..d708129 100644 --- a/lib/openstrap_protocol.dart +++ b/lib/openstrap_protocol.dart @@ -69,6 +69,9 @@ export 'src/gen5_records.dart' parseGen5ImuBuffer, parseGen5Historical, reconstructSaturatedDeltaWindow; +// WHOOP MG Labrador (filtered ECG, R17) and raw ECG (R16) — see labrador.dart. +export 'src/labrador.dart' + show LabradorR17, LabradorFlags, LabradorUnreadableMask, LabradorR16Raw; export 'src/live.dart' show DecodedSample, diff --git a/lib/src/commands.dart b/lib/src/commands.dart index a5ebc35..6696669 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -101,10 +101,15 @@ Uint8List cmdGetHello(int seq) => buildCommand(seq, Cmd.getHelloHarvard, const [0x00]); Uint8List cmdGetHelloModern(int seq) => buildCommand(seq, Cmd.getHello, const [0x01]); -Uint8List cmdAbortHistorical(int seq) => - buildCommand(seq, Cmd.abortHistoricalTransmits, const [0x00]); + +/// ABORT_HISTORICAL_TRANSMITS (0x14 = 20), no semantic body. [profile] +/// selects the envelope (gen5 pads the bodyless inner to `[35][seq][20][00]`). +Uint8List cmdAbortHistorical(int seq, + {BandProfile profile = BandProfile.gen4}) => + buildCommand(seq, Cmd.abortHistoricalTransmits, const [0x00], profile); Uint8List cmdSendHistorical(int seq) => buildCommand(seq, Cmd.sendHistoricalData, const [0x00]); + /// Read the strap RTC (GET_CLOCK = 0x0B = 11) with an EMPTY body. /// /// Shared across generations — hardware-verified on WHOOP 5: opcode 11 with @@ -195,8 +200,8 @@ Uint8List cmdEnterHighFreqSync(int seq, // the permissive u16 range rather than inheriting a limit we cannot check. if (profile.isGen5) { if (intervalSeconds <= 60) { - throw ArgumentError.value(intervalSeconds, 'intervalSeconds', - 'gen5 requires > 60 seconds'); + throw ArgumentError.value( + intervalSeconds, 'intervalSeconds', 'gen5 requires > 60 seconds'); } if (durationSeconds >= 28800) { throw ArgumentError.value( @@ -405,9 +410,8 @@ Uint8List cmdSetAlarmSimple(int seq, DateTime when, /// whether this rev-1 body means anything to a gen5 is untested. Uint8List cmdSetAlarmRev1(int seq, DateTime when, {int hapticMode = 0, BandProfile profile = BandProfile.gen4}) => - buildCommand( - seq, Cmd.setAlarmTime, alarmRev1Payload(when, hapticMode: hapticMode), - profile); + buildCommand(seq, Cmd.setAlarmTime, + alarmRev1Payload(when, hapticMode: hapticMode), profile); /// The bare 9-byte payload of the REV-1 alarm form (see [cmdSetAlarmRev1]). /// @@ -484,6 +488,7 @@ Uint8List cmdSetAlarm( DateTime when, { int? index, List? hapticPattern, + /// gen5's trailing body byte — the **alarm type**, always `0` in practice. /// See the GENERATION DIFFERENCE note above for why this parameter keeps /// its third-party name. @@ -1039,8 +1044,8 @@ enum LabradorOperation { /// not something a retry fixes. Uint8List cmdLabradorDataGeneration(int seq, LabradorOperation op, {BandProfile profile = BandProfile.gen4}) => - buildCommand(seq, Cmd.toggleLabradorDataGeneration, - [revision1, op.value], profile); + buildCommand( + seq, Cmd.toggleLabradorDataGeneration, [revision1, op.value], profile); /// Enable/disable the filtered-reading RAW save (TOGGLE_LABRADOR_RAW_SAVE, /// 125) — `[0x01][0|1]`. Part of the prepare step (ON) and of the stop step diff --git a/lib/src/control.dart b/lib/src/control.dart index bd75bf8..1addcf0 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -159,8 +159,7 @@ class R10Lite { /// them from. Reports what was ACCEPTED, never what the byte declared. final List rrIntervalsMs; - R10Lite(this.tsEpoch, this.hr, this.counter, - {this.rrIntervalsMs = const []}); + R10Lite(this.tsEpoch, this.hr, this.counter, {this.rrIntervalsMs = const []}); } R10Lite? parseR10Lite(Uint8List inner) { @@ -384,6 +383,16 @@ 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. + /// + /// 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 + /// read through revision-1 offsets to grant that. The service UUID, the + /// advertised name and command acceptance are NOT MG evidence — the + /// ordinary WHOOP 5.0 shares all three and maps to `GOOSE` (optical 82). + bool get isMaverick => helloRevision == 1 && opticalDiscriminator < 38; + /// [tsSeconds] gated to the plausible unix range, null otherwise. /// /// The band ships with its RTC unset, and an unset RTC reports a near-1970 @@ -618,8 +627,7 @@ EventInfo? parseEvent( }) { if (inner.length < 4 || inner[0] != PacketType.event) return null; final eid = u16(inner, 2); - final gen5ScopedOut = - _kGen5ScopedEventIds.contains(eid) && !profile.isGen5; + final gen5ScopedOut = _kGen5ScopedEventIds.contains(eid) && !profile.isGen5; final name = gen5ScopedOut ? 'EVENT_$eid' : EventId.name(eid); // Timestamp: whole seconds u32 @ [4], sub-seconds u16 @ [8]; the event body // begins at [12]. All guarded by length so short frames degrade cleanly. @@ -956,7 +964,9 @@ CmdResponse? parseCommandResponse(Uint8List inner, if (revOk && payload.length >= 63) { final oldest = u32(payload, 35); final newest = u32(payload, 59); - if (_plausibleUnix(oldest) && _plausibleUnix(newest) && oldest <= newest) { + if (_plausibleUnix(oldest) && + _plausibleUnix(newest) && + oldest <= newest) { dec['range_oldest'] = oldest; dec['range_newest'] = newest; } @@ -1404,7 +1414,8 @@ Decoded _decodeDataRecord(Uint8List inner, // u32 timestamp, so it invents a plausible-looking bpm out of a clock. // edge routes historical frames elsewhere and never reaches this, but the // decoder must not fabricate for whoever does. - return Decoded('data_record', {'rec_type': inner.length > 1 ? inner[1] : -1}); + return Decoded( + 'data_record', {'rec_type': inner.length > 1 ? inner[1] : -1}); } final recType = inner.length > 1 ? inner[1] : -1; // Live R10 (HR + IMU) — surface HR for the live display. Checked before the diff --git a/lib/src/labrador.dart b/lib/src/labrador.dart new file mode 100644 index 0000000..bb7a7c7 --- /dev/null +++ b/lib/src/labrador.dart @@ -0,0 +1,268 @@ +// 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. +// +// 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 +// 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]. +// +// PURE Dart — dart:typed_data only. + +import 'dart:typed_data'; + +import 'constants.dart'; +import 'framing.dart'; + +ByteData _view(Uint8List b) => + b.buffer.asByteData(b.offsetInBytes, b.lengthInBytes); + +/// R17 inner[14] — HeartKey S2 state/transition and presence bits. +class LabradorFlags { + final int raw; + const LabradorFlags(this.raw); + + /// 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. + bool get currentS2One => (raw & 0x02) != 0; + + /// bit 2 — S2 transition 1 -> 2 (physically `0x0c` on the terminal frame). + bool get s2Transition1to2 => (raw & 0x04) != 0; + + /// bit 3 — HeartKey presence (electrode contact, debounced by the band). + bool get presence => (raw & 0x08) != 0; + + @override + String toString() => 'LabradorFlags(0x${raw.toRadixString(16)})'; +} + +/// R17 inner[18] — HeartKey unreadable-reason mask. +class LabradorUnreadableMask { + final int raw; + const LabradorUnreadableMask(this.raw); + + bool get lowAmplitude => (raw & 0x01) != 0; + bool get significantNoise => (raw & 0x02) != 0; + bool get unstableSignal => (raw & 0x04) != 0; + bool get notEnoughData => (raw & 0x08) != 0; + + /// The set bits by name, in bit order. Bits above 3 are reported as + /// `unknown_bits_0x..` rather than given a meaning. + List get reasons => [ + if (lowAmplitude) 'low_amplitude', + if (significantNoise) 'significant_noise', + if (unstableSignal) 'unstable_signal', + if (notEnoughData) 'not_enough_data', + if ((raw & ~0x0F) != 0) + 'unknown_bits_0x${(raw & ~0x0F).toRadixString(16).padLeft(2, '0')}', + ]; + + @override + String toString() => 'LabradorUnreadableMask(0x${raw.toRadixString(16)})'; +} + +/// One Labrador revision-17 packet: the band's live filtered-ECG cycle. +/// +/// [samples] are 100 Hz filtered/decimated input-referred INTEGER MICROVOLTS +/// exactly as transmitted: signed i16, no rescaling and no wrist-dependent +/// sign flip (the wrist selector acts inside the AFE). No anatomical lead or +/// polarity is claimed. [variabilityRaw] has no proven unit and is not a +/// category input. +class LabradorR17 { + static const int revision = 17; + + /// Fixed fields occupy inner[0..25]; samples start at 26. + static const int fixedLen = 26; + + /// Physical wire capacity: 100 i16 samples per packet. + static const int maxSamples = 100; + + /// `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`). + 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. + final int headerSecondary; + + final int sequence; // inner[3..6] u32 LE data-cycle sequence + final int strapSeconds; // inner[7..10] u32 LE + final int subseconds; // inner[11..12] u16 LE, 1/32768 s + final int quality; // inner[13] + final LabradorFlags flags; // inner[14] + final int result; // inner[15] HeartKey result code (app category input) + final int s2State; // inner[16]; 2 is terminal + final int progress; // inner[17]; 100 terminal, 255 invalid/abort + final LabradorUnreadableMask unreadable; // inner[18] + final int averageHr; // inner[19] final/stored HR — persisted category input + 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. + final int? variabilityRaw; + final int reserved; // inner[23] + final int sampleCount; // inner[24..25] u16 LE, <= [maxSamples] + final Int16List samples; // inner[26..26+2n) + + /// Aligned bytes after the sample block, byte-exact, meaning unassigned. + final Uint8List tail; + + /// The exact inner packet these fields were read from. + final Uint8List inner; + + const LabradorR17({ + required this.packetType, + required this.headerSecondary, + required this.sequence, + required this.strapSeconds, + required this.subseconds, + required this.quality, + required this.flags, + required this.result, + required this.s2State, + required this.progress, + required this.unreadable, + required this.averageHr, + required this.liveHr, + required this.variabilityRaw, + required this.reserved, + required this.sampleCount, + required this.samples, + required this.tail, + required this.inner, + }); + + bool get presence => flags.presence; + + /// The official app's completion condition. + bool get isTerminal => progress == 100 || s2State == 2; + + /// The official app's invalid/abort sentinel. + bool get isInvalid => progress == 255; + + bool get isLive => packetType == PacketType.realtimeRawData; + + /// Strap acquisition-cycle time in seconds. + double get strapTime => strapSeconds + subseconds / 32768.0; + + /// Parse an inner packet. Returns null unless it is a type-43 (or, with + /// [allowStored], type-47) packet of data revision 17 whose declared sample + /// block fits: fixed fields through offset 25 present, count <= 100 and + /// `26 + 2 * count` bytes available. No fixed total length is required; + /// bytes beyond the sample block land in [tail]. + /// + /// CRC validity is the caller's business (see [tryParseFrame]). + static LabradorR17? parse(Uint8List inner, {bool allowStored = false}) { + if (inner.length < fixedLen) return null; + final pt = inner[0]; + if (pt != PacketType.realtimeRawData && + !(allowStored && pt == PacketType.historicalData)) { + return null; + } + if (inner[1] != revision) return null; + final v = _view(inner); + final count = v.getUint16(24, Endian.little); + if (count > maxSamples) return null; + final end = fixedLen + 2 * count; + if (inner.length < end) return null; + final samples = Int16List(count); + for (var i = 0; i < count; i++) { + samples[i] = v.getInt16(fixedLen + 2 * i, Endian.little); + } + final variability = v.getUint16(21, Endian.little); + return LabradorR17( + packetType: pt, + headerSecondary: inner[2], + sequence: v.getUint32(3, Endian.little), + strapSeconds: v.getUint32(7, Endian.little), + subseconds: v.getUint16(11, Endian.little), + quality: inner[13], + flags: LabradorFlags(inner[14]), + result: inner[15], + s2State: inner[16], + progress: inner[17], + unreadable: LabradorUnreadableMask(inner[18]), + averageHr: inner[19], + liveHr: inner[20], + variabilityRaw: + variability == variabilityUnavailable ? null : variability, + reserved: inner[23], + sampleCount: count, + samples: samples, + tail: Uint8List.fromList(inner.sublist(end)), + inner: Uint8List.fromList(inner), + ); + } + + /// [parse] over a reassembled frame, accepting only a frame whose header + /// CRC, payload CRC and frame revision all check out ([Frame.decodable]). + static LabradorR17? tryParseFrame(Frame f, {bool allowStored = false}) { + if (!f.decodable) return null; + return parse(f.inner, allowStored: allowStored); + } +} + +/// A historical (type 47) revision-16 raw ECG record, recognised but NOT +/// decoded: only the proven common header (sequence and strap time, the same +/// offsets every gen5 data record shares) is read, and the exact inner bytes +/// are kept for durable storage. The body layout is not source-closed, so no +/// body field is invented here. +class LabradorR16Raw { + static const int revision = 16; + + /// Physically observed sizes (1,572-byte inner, 1,584-byte frame) — + /// documentation, not enforced: a record only needs its common header. + static const int observedInnerLen = 1572; + static const int observedFrameLen = 1584; + + /// The common data-record header ends after the u16 sub-second at 11..12. + static const int headerLen = 13; + + final int sequence; // inner[3..6] u32 LE + final int strapSeconds; // inner[7..10] u32 LE + final int subseconds; // inner[11..12] u16 LE, 1/32768 s + final Uint8List inner; // exact bytes + + const LabradorR16Raw({ + required this.sequence, + required this.strapSeconds, + required this.subseconds, + required this.inner, + }); + + double get strapTime => strapSeconds + subseconds / 32768.0; + + /// Recognise a type-47 revision-16 inner packet; null for anything else. + static LabradorR16Raw? tryParse(Uint8List inner) { + if (inner.length < headerLen) return null; + if (inner[0] != PacketType.historicalData) return null; + if (inner[1] != revision) return null; + final v = _view(inner); + return LabradorR16Raw( + sequence: v.getUint32(3, Endian.little), + strapSeconds: v.getUint32(7, Endian.little), + subseconds: v.getUint16(11, Endian.little), + inner: Uint8List.fromList(inner), + ); + } + + /// [tryParse] over a reassembled frame that passed both CRCs and the + /// revision check. + static LabradorR16Raw? tryParseFrame(Frame f) => + f.decodable ? tryParse(f.inner) : null; +} diff --git a/test/doc_conformance_test.dart b/test/doc_conformance_test.dart index 388c2bc..0673f9f 100644 --- a/test/doc_conformance_test.dart +++ b/test/doc_conformance_test.dart @@ -35,8 +35,7 @@ void main() { expect(f.inner.sublist(3, 5), [0x00, 0x00]); }); - test('success result = 01 + markerA + markerB, 9 bytes verbatim', - () { + test('success result = 01 + markerA + markerB, 9 bytes verbatim', () { final token = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04]; final f = parseFrame( buildHistoryResultOk(1, token, profile: BandProfile.gen5), @@ -62,7 +61,8 @@ void main() { expect(body.sublist(16, 18), [0, 0], reason: 'per-effect loop control'); expect(body[18], 0x07, reason: 'overall waveform loop control'); expect(body[19], 0x1e, reason: '30 s duration cap'); - expect(body[20], 0x00, reason: 'alarm type 0 — the 21st byte IS on the wire'); + expect(body[20], 0x00, + reason: 'alarm type 0 — the 21st byte IS on the wire'); }); test('GET_ALARM_TIME 04 01 / RUN_ALARM 02 01 / DISABLE 02 ff', () { @@ -96,8 +96,7 @@ void main() { expect(sub, (250 * 32768) ~/ 1000); }); - test('GET_CLOCK(11) empty body; GET_DATA_RANGE(34) empty on gen5', - () { + test('GET_CLOCK(11) empty body; GET_DATA_RANGE(34) empty on gen5', () { final c = parseFrame(cmdGetClock(1, profile: BandProfile.gen5), profile: BandProfile.gen5)!; expect(c.inner[2], 11); @@ -111,6 +110,58 @@ 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)', + () { + List inner(Uint8List f) => + parseFrame(f, profile: BandProfile.gen5)!.inner; + // PREPARE: 123 wrist (01 01 right / 01 02 left), 139 ON, 125 ON. + final right = inner( + cmdSelectWrist(1, WristSelection.right, profile: BandProfile.gen5)); + expect(right[2], 123); + expect(right.sublist(3), [0x01, 0x01, 0x00, 0x00, 0x00], + reason: 'physical wire body 01 01 00 00 00'); + final left = inner( + cmdSelectWrist(1, WristSelection.left, profile: BandProfile.gen5)); + expect(left.sublist(3, 5), [0x01, 0x02]); + final fOn = inner(cmdLabradorFiltered(1, true, profile: BandProfile.gen5)); + expect(fOn[2], 139); + expect(fOn.sublist(3), [0x01, 0x01, 0x00, 0x00, 0x00]); + final rOn = inner(cmdLabradorRawSave(1, true, profile: BandProfile.gen5)); + expect(rOn[2], 125); + expect(rOn.sublist(3), [0x01, 0x01, 0x00, 0x00, 0x00]); + // START: 20 (bodyless, one aligned pad byte), 124 start 01 02. + final abort = inner(cmdAbortHistorical(1, profile: BandProfile.gen5)); + expect(abort, [35, 1, 20, 0x00], reason: 'bodyless 20 → one pad byte'); + expect( + inner(cmdLabradorDataGeneration(1, LabradorOperation.start, + profile: BandProfile.gen5)) + .sublist(2), + [124, 0x01, 0x02, 0x00, 0x00, 0x00]); + // RESTART: 124 01 03. + expect( + inner(cmdLabradorDataGeneration(1, LabradorOperation.restart, + profile: BandProfile.gen5)) + .sublist(3, 5), + [0x01, 0x03]); + // CLEANUP: 124 stop 01 01, 139 OFF 01 00, 125 OFF 01 00. + expect( + inner(cmdLabradorDataGeneration(1, LabradorOperation.stop, + profile: BandProfile.gen5)) + .sublist(3, 5), + [0x01, 0x01]); + expect( + inner(cmdLabradorFiltered(1, false, profile: BandProfile.gen5)) + .sublist(3), + [0x01, 0x00, 0x00, 0x00, 0x00]); + expect( + inner(cmdLabradorRawSave(1, false, profile: BandProfile.gen5)) + .sublist(3), + [0x01, 0x00, 0x00, 0x00, 0x00]); + // The gen4 default of cmdAbortHistorical is byte-identical to before. + expect(hx(cmdAbortHistorical(1)), + hx(buildCommand(1, Cmd.abortHistoricalTransmits, const [0x00]))); + }); + test('toggles — 3 bare bool; 106/107 rev+bool; labrador ops', () { final hr = parseFrame(cmdToggleHr(1, true), profile: BandProfile.gen4)!; expect(hr.inner.sublist(2, 4), [3, 0x01], reason: 'opcode 3 takes bare 01'); diff --git a/test/gen5_hello_maverick_test.dart b/test/gen5_hello_maverick_test.dart new file mode 100644 index 0000000..dcf66e7 --- /dev/null +++ b/test/gen5_hello_maverick_test.dart @@ -0,0 +1,59 @@ +// 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 +// ordinary 5.0 returns 82. Only a revision-1 HELLO may be read this way. + +import 'dart:typed_data'; + +import 'package:openstrap_protocol/openstrap_protocol.dart'; +import 'package:test/test.dart'; + +Gen5HelloInfo hello({int revision = 1, required int optical}) { + final body = Uint8List(Gen5HelloInfo.semanticBodyLen); + body[0] = revision; + final v = ByteData.sublistView(body); + v.setUint32(1, 900, Endian.little); // battery raw 90.0% + v.setUint32(6, 1787822694, Endian.little); + for (var i = 0; i < 10; i++) { + // Synthetic: the serial is 10 ASCII bytes at offset 14 and no assertion + // here depends on which strap it names. + body[14 + i] = '5AM0000000'.codeUnitAt(i); + } + v.setUint32(79, 13, Endian.little); // hardware family + v.setUint32(87, optical, Endian.little); + body[91] = 50; + body[92] = 41; + body[93] = 1; + return Gen5HelloInfo.parse(body)!; +} + +void main() { + test('the physical MG (optical 0) is MAVERICK and not WHOOP 5', () { + final h = hello(optical: 0); + expect(h.isMaverick, isTrue); + expect(h.isWhoop5, isFalse); + expect(h.serial, '5AM0000000'); + expect(h.firmwareVersion, '50.41.1.0'); + }); + + test('the interval is [0, 38): 37 is in, 38 is out', () { + expect(hello(optical: 37).isMaverick, isTrue); + expect(hello(optical: 38).isMaverick, isFalse); + expect(hello(optical: 47).isMaverick, isFalse); + }); + + test('the ordinary WHOOP 5.0 (optical 82) is WHOOP 5, never MAVERICK', () { + final h = hello(optical: 82); + expect(h.isMaverick, isFalse); + expect(h.isWhoop5, isTrue); + expect(hello(optical: 48).isMaverick, isFalse); + }); + + test('an unknown HELLO revision is never read through revision-1 offsets', + () { + expect(hello(revision: 2, optical: 0).isMaverick, isFalse); + expect(hello(revision: 0, optical: 0).isMaverick, isFalse); + // isWhoop5's existing behaviour is untouched by the new gate. + expect(hello(revision: 2, optical: 82).isWhoop5, isTrue); + }); +} diff --git a/test/labrador_r16_test.dart b/test/labrador_r16_test.dart new file mode 100644 index 0000000..c55c446 --- /dev/null +++ b/test/labrador_r16_test.dart @@ -0,0 +1,79 @@ +// Labrador R16 (WHOOP MG raw ECG, historical type 47) — recognition and +// exact preservation only. The body is not source-closed; nothing here names +// a body byte. Synthetic fixtures. + +import 'dart:typed_data'; + +import 'package:openstrap_protocol/openstrap_protocol.dart'; +import 'package:test/test.dart'; + +Uint8List r16Inner({ + int packetType = 0x2F, + int revision = 16, + int sequence = 23940915, + int strapSeconds = 1787823731, + int subseconds = 24242, + int totalLen = LabradorR16Raw.observedInnerLen, +}) { + final inner = Uint8List(totalLen); + final v = ByteData.sublistView(inner); + inner[0] = packetType; + inner[1] = revision; + inner[2] = 3; // physical secondary byte + v.setUint32(3, sequence, Endian.little); + v.setUint32(7, strapSeconds, Endian.little); + v.setUint16(11, subseconds, Endian.little); + for (var i = 13; i < totalLen; i++) { + inner[i] = (i * 7 + 3) & 0xff; // arbitrary body, including 0xAA bytes + } + return inner; +} + +void main() { + test('a type-47 revision-16 record is recognised with its common header', () { + final inner = r16Inner(); + final r = LabradorR16Raw.tryParse(inner)!; + expect(r.sequence, 23940915); + expect(r.strapSeconds, 1787823731); + expect(r.subseconds, 24242); + expect(r.strapTime, closeTo(1787823731 + 24242 / 32768.0, 1e-9)); + expect(r.inner, inner, reason: 'exact bytes preserved'); + expect(r.inner, hasLength(1572)); + }); + + test('the inner is copied, not aliased', () { + final inner = r16Inner(); + final r = LabradorR16Raw.tryParse(inner)!; + inner[100] ^= 0xff; + expect(r.inner[100], isNot(inner[100])); + }); + + test('other revisions and packet types are not R16', () { + expect(LabradorR16Raw.tryParse(r16Inner(revision: 18)), isNull); + expect(LabradorR16Raw.tryParse(r16Inner(revision: 17)), isNull); + expect(LabradorR16Raw.tryParse(r16Inner(packetType: 0x2B)), isNull, + reason: 'R16 never arrives live'); + }); + + test('a record shorter than the common header is not recognised', () { + final full = r16Inner(); + for (var len = 0; len < 13; len++) { + expect( + LabradorR16Raw.tryParse(Uint8List.sublistView(full, 0, len)), isNull, + reason: 'length $len'); + } + expect( + LabradorR16Raw.tryParse(Uint8List.sublistView(full, 0, 13)), isNotNull); + }); + + test('tryParseFrame needs a decodable frame', () { + final inner = r16Inner(); + final raw = buildFrame(inner, profile: BandProfile.gen5); + expect(raw, hasLength(LabradorR16Raw.observedFrameLen)); + final f = parseFrame(raw, profile: BandProfile.gen5)!; + expect(LabradorR16Raw.tryParseFrame(f), isNotNull); + raw[8 + 500] ^= 0x01; + final bad = parseFrame(raw, profile: BandProfile.gen5)!; + expect(LabradorR16Raw.tryParseFrame(bad), isNull); + }); +} diff --git a/test/labrador_r17_test.dart b/test/labrador_r17_test.dart new file mode 100644 index 0000000..c49a8df --- /dev/null +++ b/test/labrador_r17_test.dart @@ -0,0 +1,280 @@ +// 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. + +import 'dart:typed_data'; + +import 'package:openstrap_protocol/openstrap_protocol.dart'; +import 'package:test/test.dart'; + +/// A physical-shaped inner: 228 bytes (the observed fixed inner size), with +/// every named field settable. [samples] beyond [count] are left zero, so a +/// count below capacity leaves an aligned tail exactly like the band does. +Uint8List r17Inner({ + int packetType = 0x2B, + int secondary = 0, + int sequence = 23940969, + int strapSeconds = 1787823784, + int subseconds = 12345, + int quality = 1, + int flags = 0x0a, + int result = 0, + int s2State = 1, + int progress = 3, + int unreadable = 0, + int averageHr = 0, + int liveHr = 70, + int variability = 0xffff, + int reserved = 0, + int? declaredCount, + List samples = const [], + int totalLen = 228, + int revision = 17, +}) { + final inner = Uint8List(totalLen); + final v = ByteData.sublistView(inner); + inner[0] = packetType; + inner[1] = revision; + inner[2] = secondary; + v.setUint32(3, sequence, Endian.little); + v.setUint32(7, strapSeconds, Endian.little); + v.setUint16(11, subseconds, Endian.little); + inner[13] = quality; + inner[14] = flags; + inner[15] = result; + inner[16] = s2State; + inner[17] = progress; + inner[18] = unreadable; + inner[19] = averageHr; + inner[20] = liveHr; + v.setUint16(21, variability, Endian.little); + inner[23] = reserved; + v.setUint16(24, declaredCount ?? samples.length, Endian.little); + for (var i = 0; i < samples.length && 26 + 2 * i + 1 < totalLen; i++) { + v.setInt16(26 + 2 * i, samples[i], Endian.little); + } + return inner; +} + +void main() { + group('identity', () { + test('type 43 revision 17 parses; the fixed fields read at their offsets', + () { + final r = LabradorR17.parse(r17Inner( + secondary: 0x80, + quality: 3, + flags: 0x0c, + result: 1, + s2State: 2, + progress: 100, + unreadable: 0, + averageHr: 77, + liveHr: 78, + variability: 29, + reserved: 0, + samples: List.filled(100, 5), + ))!; + expect(r.packetType, 43); + expect(r.isLive, isTrue); + expect(r.headerSecondary, 0x80); + expect(r.sequence, 23940969); + expect(r.strapSeconds, 1787823784); + expect(r.subseconds, 12345); + expect(r.strapTime, closeTo(1787823784 + 12345 / 32768.0, 1e-9)); + expect(r.quality, 3); + expect(r.flags.raw, 0x0c); + expect(r.result, 1); + expect(r.s2State, 2); + expect(r.progress, 100); + expect(r.isTerminal, isTrue); + expect(r.isInvalid, isFalse); + expect(r.averageHr, 77); + expect(r.liveHr, 78); + expect(r.variabilityRaw, 29); + expect(r.reserved, 0); + expect(r.sampleCount, 100); + expect(r.samples, hasLength(100)); + expect(r.inner, hasLength(228)); + }); + + test('type 47 is a STORED R17 — rejected unless the caller allows it', () { + final inner = r17Inner(packetType: 0x2F, samples: [1, 2]); + expect(LabradorR17.parse(inner), isNull); + final r = LabradorR17.parse(inner, allowStored: true)!; + expect(r.packetType, 47); + expect(r.isLive, isFalse); + }); + + test('any other packet type is not an R17', () { + for (final pt in [0x00, 0x23, 0x24, 0x28, 0x30, 0x31]) { + expect(LabradorR17.parse(r17Inner(packetType: pt)), isNull, + reason: 'packet type $pt'); + expect(LabradorR17.parse(r17Inner(packetType: pt), allowStored: true), + isNull); + } + }); + + test( + 'data revision must be 17 — 16, 18 and 21 in a type-43 envelope are ' + 'other records', () { + for (final rev in [16, 18, 21, 0, 255]) { + expect(LabradorR17.parse(r17Inner(revision: rev)), isNull, + reason: 'revision $rev'); + } + }); + + test('tryParseFrame requires a decodable frame', () { + final inner = r17Inner(samples: [1]); + final good = parseFrame(buildFrame(inner, profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + expect(LabradorR17.tryParseFrame(good), isNotNull); + // Corrupt one payload byte: CRC32 fails, the parser refuses. + final raw = buildFrame(inner, profile: BandProfile.gen5); + raw[8 + 30] ^= 0x01; + final bad = parseFrame(raw, profile: BandProfile.gen5)!; + expect(bad.crc32Ok, isFalse); + expect(LabradorR17.tryParseFrame(bad), isNull); + // A frame whose revision byte is not rev-1 is intact but unreadable. + final rev2 = buildFrame(inner, profile: BandProfile.gen5); + rev2[1] = 0x02; + final f2 = parseFrame(rev2, profile: BandProfile.gen5)!; + expect(f2.frameRevOk, isFalse); + expect(LabradorR17.tryParseFrame(f2), isNull); + }); + }); + + group('sample block bounds', () { + test('0, 49 and 100 samples (the physical startup counts) all parse', () { + for (final n in [0, 49, 100]) { + final r = LabradorR17.parse( + r17Inner(samples: List.generate(n, (i) => i - 20)))!; + expect(r.sampleCount, n, reason: 'count $n'); + expect(r.samples, List.generate(n, (i) => i - 20)); + } + }); + + test('a count above 100 is rejected even when the bytes would fit', () { + final inner = r17Inner(declaredCount: 101, totalLen: 26 + 2 * 101 + 4); + expect(LabradorR17.parse(inner), isNull); + }); + + test('a count whose sample block runs past the packet is rejected', () { + // 100 declared, room for 99. + final inner = r17Inner(declaredCount: 100, totalLen: 26 + 2 * 99); + expect(LabradorR17.parse(inner), isNull); + // Exactly enough bytes is fine. + expect( + LabradorR17.parse(r17Inner(declaredCount: 100, totalLen: 26 + 200)), + isNotNull); + // One byte short of the last sample is not. + expect( + LabradorR17.parse(r17Inner(declaredCount: 100, totalLen: 26 + 199)), + isNull); + }); + + test('every truncation of the fixed fields is rejected', () { + final full = r17Inner(samples: [1, 2, 3]); + for (var len = 0; len < 26; len++) { + expect(LabradorR17.parse(Uint8List.sublistView(full, 0, len)), isNull, + reason: 'length $len'); + } + // 26 bytes with count 0 is the smallest complete packet. + final zero = r17Inner(samples: const [], totalLen: 26); + expect(LabradorR17.parse(zero), isNotNull); + }); + + test('no fixed total length is required; the aligned tail is preserved', + () { + // 49 samples in a 228-byte inner leaves 228 - 124 = 104 tail bytes. + final inner = r17Inner(samples: List.filled(49, 7)); + for (var i = 26 + 98; i < 228; i++) { + inner[i] = (i * 31) & 0xff; + } + final r = LabradorR17.parse(inner)!; + expect(r.tail, hasLength(104)); + expect(r.tail, inner.sublist(124)); + // And a packet with nothing after the samples has an empty tail. + expect(LabradorR17.parse(r17Inner(samples: [1], totalLen: 28))!.tail, + isEmpty); + }); + }); + + group('sample values', () { + test('signed i16 little-endian, no rescale, no sign flip', () { + final r = LabradorR17.parse( + r17Inner(samples: [-1, 1, 0x7fff, -32768, -5396, 3377, 0]))!; + expect(r.samples, [-1, 1, 32767, -32768, -5396, 3377, 0]); + // Byte-level: -1 is ff ff, 1 is 01 00. + expect(r.inner.sublist(26, 30), [0xff, 0xff, 0x01, 0x00]); + }); + }); + + group('flag byte 14', () { + test('each bit independently', () { + expect(LabradorR17.parse(r17Inner(flags: 0x01))!.flags.enteringS2One, + isTrue); + expect(LabradorR17.parse(r17Inner(flags: 0x01))!.flags.currentS2One, + isFalse); + expect( + LabradorR17.parse(r17Inner(flags: 0x02))!.flags.currentS2One, isTrue); + expect(LabradorR17.parse(r17Inner(flags: 0x04))!.flags.s2Transition1to2, + isTrue); + expect(LabradorR17.parse(r17Inner(flags: 0x08))!.flags.presence, isTrue); + expect(LabradorR17.parse(r17Inner(flags: 0x08))!.presence, isTrue); + expect(LabradorR17.parse(r17Inner(flags: 0x00))!.presence, isFalse); + // Physical: 0x0a while contacted (presence + current S2 1), 0x0c on the + // terminal transition, 0x08 afterwards. + final contacted = LabradorR17.parse(r17Inner(flags: 0x0a))!.flags; + expect(contacted.presence && contacted.currentS2One, isTrue); + final term = LabradorR17.parse(r17Inner(flags: 0x0c))!.flags; + expect( + term.presence && term.s2Transition1to2 && !term.currentS2One, isTrue); + }); + }); + + group('unreadable mask byte 18', () { + test('each bit independently, named in bit order', () { + LabradorUnreadableMask m(int raw) => + LabradorR17.parse(r17Inner(unreadable: raw))!.unreadable; + expect(m(0x01).lowAmplitude, isTrue); + expect(m(0x01).reasons, ['low_amplitude']); + expect(m(0x02).significantNoise, isTrue); + expect(m(0x02).reasons, ['significant_noise']); + expect(m(0x04).unstableSignal, isTrue); + expect(m(0x04).reasons, ['unstable_signal']); + expect(m(0x08).notEnoughData, isTrue); + expect(m(0x08).reasons, ['not_enough_data']); + expect(m(0x0f).reasons, [ + 'low_amplitude', + 'significant_noise', + 'unstable_signal', + 'not_enough_data' + ]); + expect(m(0x00).reasons, isEmpty); + expect(m(0x10).reasons, ['unknown_bits_0x10']); + }); + }); + + group('terminal / invalid / variability', () { + test('terminal on progress 100 or S2 state 2; invalid on progress 255', () { + expect(LabradorR17.parse(r17Inner(progress: 100))!.isTerminal, isTrue); + expect(LabradorR17.parse(r17Inner(s2State: 2, progress: 96))!.isTerminal, + isTrue); + expect(LabradorR17.parse(r17Inner(progress: 99))!.isTerminal, isFalse); + expect(LabradorR17.parse(r17Inner(progress: 255))!.isInvalid, isTrue); + expect(LabradorR17.parse(r17Inner(progress: 255))!.isTerminal, isFalse); + }); + + test('variability 0xffff is unavailable (null); anything else is raw', () { + expect(LabradorR17.parse(r17Inner(variability: 0xffff))!.variabilityRaw, + isNull); + expect(LabradorR17.parse(r17Inner(variability: 44))!.variabilityRaw, 44); + expect(LabradorR17.parse(r17Inner(variability: 0))!.variabilityRaw, 0); + }); + + test('reserved byte 23 is exposed raw', () { + expect(LabradorR17.parse(r17Inner(reserved: 9))!.reserved, 9); + }); + }); +} diff --git a/test/labrador_reassembly_test.dart b/test/labrador_reassembly_test.dart new file mode 100644 index 0000000..9cd8dad --- /dev/null +++ b/test/labrador_reassembly_test.dart @@ -0,0 +1,111 @@ +// 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 +// 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. + +import 'dart:typed_data'; + +import 'package:openstrap_protocol/openstrap_protocol.dart'; +import 'package:test/test.dart'; + +Uint8List r16Frame() { + final inner = Uint8List(LabradorR16Raw.observedInnerLen); + final v = ByteData.sublistView(inner); + inner[0] = 0x2F; + inner[1] = 16; + inner[2] = 3; + v.setUint32(3, 24016883, Endian.little); + v.setUint32(7, 1787928472, Endian.little); + v.setUint16(11, 19334, Endian.little); + for (var i = 13; i < inner.length; i++) { + inner[i] = (i * 13 + 5) & 0xff; + } + // Plant an SOF byte and a plausible fake gen5 header inside the body: a + // "reset on 0xAA" reassembler would resync on it and lose the frame. + inner[300] = 0xAA; + inner[301] = 0x01; + inner[302] = 0x10; + inner[303] = 0x00; + return buildFrame(inner, profile: BandProfile.gen5); +} + +Uint8List smallR18Frame() { + final inner = Uint8List(kGen5V18InnerLen); + inner[0] = 0x2F; + inner[1] = 18; + final v = ByteData.sublistView(inner); + v.setUint32(3, 24016884, Endian.little); + v.setUint32(7, 1787928473, Endian.little); + inner[14] = 60; // plausible HR so the v18 decoder is happy if consulted + return buildFrame(inner, profile: BandProfile.gen5); +} + +List feedChunks(List stream, int chunk) { + final asm = FrameReassembler(profile: BandProfile.gen5); + final out = []; + for (var i = 0; i < stream.length; i += chunk) { + final end = i + chunk > stream.length ? stream.length : i + chunk; + out.addAll(asm.feed(stream.sublist(i, end))); + } + return out; +} + +void main() { + final frame = r16Frame(); + + test('the synthetic R16 frame has the physically observed size', () { + expect(frame, hasLength(LabradorR16Raw.observedFrameLen)); + }); + + for (final chunk in [1, 7, 20, 244, 512, 1583, 1584, 4096]) { + test('one R16 frame survives $chunk-byte chunking', () { + final frames = feedChunks(frame, chunk); + expect(frames, hasLength(1)); + final f = frames.single; + expect(f.decodable, isTrue); + expect(f.inner, hasLength(LabradorR16Raw.observedInnerLen)); + final r = LabradorR16Raw.tryParseFrame(f)!; + expect(r.sequence, 24016883); + expect(r.strapSeconds, 1787928472); + expect(r.subseconds, 19334); + }); + } + + test('a chunk boundary exactly after the header, and one inside the CRC32', + () { + final asm = FrameReassembler(profile: BandProfile.gen5); + final out = []; + out.addAll(asm.feed(frame.sublist(0, 8))); + expect(out, isEmpty); + out.addAll(asm.feed(frame.sublist(8, frame.length - 2))); + expect(out, isEmpty, reason: 'two CRC bytes still outstanding'); + out.addAll(asm.feed(frame.sublist(frame.length - 2))); + expect(out, hasLength(1)); + expect(out.single.decodable, isTrue); + }); + + test('a chunk boundary landing on the embedded fake 0xAA header', () { + final asm = FrameReassembler(profile: BandProfile.gen5); + final split = 8 + 300; // the planted SOF sits at inner[300] + final out = [ + ...asm.feed(frame.sublist(0, split)), + ...asm.feed(frame.sublist(split)), + ]; + expect(out, hasLength(1)); + expect(LabradorR16Raw.tryParseFrame(out.single), isNotNull); + }); + + test('an R16 followed by a small R18 in one stream yields both, in order', + () { + final stream = [...frame, ...smallR18Frame()]; + for (final chunk in [1, 20, 244, 5000]) { + final frames = feedChunks(stream, chunk); + expect(frames, hasLength(2), reason: 'chunk $chunk'); + expect(frames[0].inner[1], 16); + expect(frames[1].inner[1], 18); + expect(frames.every((f) => f.decodable), isTrue); + } + }); +}