diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 6e659a5..1c97915 100644 --- a/lib/openstrap_protocol.dart +++ b/lib/openstrap_protocol.dart @@ -6,9 +6,34 @@ /// only. library openstrap_protocol; +// Source 0 — multi-band wire-format profile (gen4 / gen5). +export 'src/band.dart' show DeviceType, GattProfile, BandProfile; + // Source 1 — record decoders. export 'src/records.dart' show R24, parseR24, FirmwareAwareR24Decoder, R24DecodeStrategy; +// gen5 historical-record decoders (v18/v20/v21/v26) — see gen5_records.dart +// for why these replace the old, wrong parseGen5Record/{9,12,24} set. +export 'src/gen5_records.dart' + show + Gen5HistoricalHeader, + Gen5HistoricalRecord, + Gen5HistorySample, + Gen5OpticalBlock, + Gen5OpticalBuffer, + Gen5ImuBuffer, + Gen5PpgWaveform, + Gen5RecordDecoder, + Gen5V18Decoder, + Gen5V20Decoder, + Gen5V21Decoder, + Gen5V26Decoder, + kGen5HistoricalDecoders, + kGen5V18MinInnerLen, + kGen5V20InnerLen, + kGen5V21InnerLen, + kGen5V26MinInnerLen, + parseGen5Historical; export 'src/live.dart' show DecodedSample, @@ -23,7 +48,7 @@ export 'src/live.dart' decodeBatch; // Source 2 — CRC, constants, framing, commands. -export 'src/crc.dart' show crc8, crc32; +export 'src/crc.dart' show crc8, crc32, crc16Modbus; export 'src/constants.dart'; export 'src/framing.dart' show Frame, pad4, buildFrame, parseFrame, FrameReassembler; @@ -59,7 +84,17 @@ export 'src/commands.dart' cmdSetAlarmSimple, cmdRunAlarm, cmdDisableAlarm, - kDefaultAlarmHaptics; + kDefaultAlarmHaptics, + gen5ClientHello, + cmdGetDataRangeGen5, + cmdSendHistoricalGen5, + cmdSetClockGen5, + cmdGetClockGen5, + cmdBuzzGen5Maverick, + cmdSetConfigGen5, + cmdSetDeviceConfigValueGen5, + kGen5R22EnableFlags, + buildR22EnableSequence; // Control-plane parsers (HELLO / EVENT / METADATA / COMMAND_RESPONSE / dispatch). export 'src/control.dart' @@ -84,5 +119,8 @@ export 'src/control.dart' parseCommandResponse, MetaMarker, parseMetadata, + ConsoleLogChunk, + parseConsoleLog, + ConsoleLogReassembler, Decoded, decodeFrame; diff --git a/lib/src/band.dart b/lib/src/band.dart new file mode 100644 index 0000000..ec31b2a --- /dev/null +++ b/lib/src/band.dart @@ -0,0 +1,188 @@ +// band.dart — multi-generation ("multi-band") wire-format profile. +// +// WHOOP 4 (gen4 / "Harvard") and WHOOP 5 (gen5 / "fd4b") are NOT two different +// protocols — gen5 is gen4 in a different envelope. Everything that actually +// differs between generations is captured here so the rest of the stack +// (framing, records, edge BLE) can stay band-agnostic and just carry a +// [BandProfile] around. +// +// Verified deltas (see MULTIBAND_WHOOP5_PORT_PLAN.md): +// • frame header: 4 bytes + crc8 → 8 bytes + crc16-modbus +// • payload CRC: crc32 on BOTH (unchanged) +// • command opcodes: shared, EXCEPT HELLO (gen5 = 0x91) +// • GATT service base: 6108000x-… → fd4b000x-… (same low-nibble map) +// +// PURE Dart — dart:typed_data only. + +import 'dart:typed_data'; +import 'crc.dart'; +import 'constants.dart'; + +/// Which physical WHOOP generation / wire format a link is speaking. +/// +/// gen5 covers the whole fd4b family (WHOOP 5.0 "Goose", "Maverick"/MG, +/// "Puffin" battery pack) — they share one wire format; only packet-level +/// sub-types (Puffin) differ, which is handled above the frame layer. +enum DeviceType { gen4, gen5 } + +/// GATT service + characteristic UUIDs for one generation. The low nibble is +/// identical across generations (0001 service, 0002 write, 0003 cmd-from, +/// 0004 events, 0005 data, 0007 memfault); only the 32-bit prefix + 96-bit +/// base suffix change. +class GattProfile { + final String service; + final String cmdTo; // write w/response (app → strap) + final String cmdFrom; // notify command responses (strap → app) + final String events; // notify strap events + final String data; // notify data/history packets + final String memfault; + + const GattProfile({ + required this.service, + required this.cmdTo, + required this.cmdFrom, + required this.events, + required this.data, + required this.memfault, + }); + + /// WHOOP 4 — base `6108000x-8d6d-82b8-614a-1c8cb0f8dcc6`. + static const GattProfile gen4 = GattProfile( + service: '61080001-8d6d-82b8-614a-1c8cb0f8dcc6', + cmdTo: '61080002-8d6d-82b8-614a-1c8cb0f8dcc6', + cmdFrom: '61080003-8d6d-82b8-614a-1c8cb0f8dcc6', + events: '61080004-8d6d-82b8-614a-1c8cb0f8dcc6', + data: '61080005-8d6d-82b8-614a-1c8cb0f8dcc6', + memfault: '61080007-8d6d-82b8-614a-1c8cb0f8dcc6', + ); + + /// WHOOP 5 — base `fd4b000x-cce1-4033-93ce-002d5875f58a`. + static const GattProfile gen5 = GattProfile( + service: 'fd4b0001-cce1-4033-93ce-002d5875f58a', + cmdTo: 'fd4b0002-cce1-4033-93ce-002d5875f58a', + cmdFrom: 'fd4b0003-cce1-4033-93ce-002d5875f58a', + events: 'fd4b0004-cce1-4033-93ce-002d5875f58a', + data: 'fd4b0005-cce1-4033-93ce-002d5875f58a', + memfault: 'fd4b0007-cce1-4033-93ce-002d5875f58a', + ); + + /// The service-UUID 32-bit prefix used to identify this generation from a + /// scan result (case-insensitive `startsWith`). + String get servicePrefix => service.substring(0, 8); +} + +/// Per-generation frame wire-format profile. Immutable; use the [gen4] / [gen5] +/// singletons or [BandProfile.of]. +class BandProfile { + final DeviceType type; + + /// Header length in bytes before the inner payload (gen4 = 4, gen5 = 8). + final int headerLen; + + /// Byte offset of the u16-LE declared-length field within the header + /// (gen4 = 1, gen5 = 2). `declared` counts the padded inner + 4-byte CRC32. + final int sizeFieldOffset; + + /// gen5 header bytes[4:6] when WE are the sender of a COMMAND-type frame + /// (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 + /// 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, + /// EVENT, COMMAND_RESPONSE, CONSOLE_LOGS) carries `[0x01,0x00]` instead — + /// it is a direction/session marker, not a magic validity constant. The + /// CRC16 covers whichever bytes are actually there, so a wrong assumption + /// here was never a CRC-validity bug, only a semantic one. + /// + /// [buildHeader] uses [outboundDirectionMarker] because every existing + /// builder in this package constructs an outbound COMMAND frame. Nothing in + /// this package gates *inbound*-frame validity on these bytes — do not add + /// such a gate, or every real strap→host gen5 frame (i.e. almost + /// everything a live BLE session receives) would be rejected. + final List? outboundDirectionMarker; + + /// gen5 header bytes[4:6] on a real strap→host frame of any packet type + /// other than COMMAND. Documentation-only (no code path currently gates on + /// it) — kept as profile data per the multiband port plan's Layer-1 + /// recommendation, so a future band's direction convention (if any) is data + /// here, not a literal buried in framing/BLE code. + final List? inboundDirectionMarker; + + const BandProfile._( + this.type, + this.headerLen, + this.sizeFieldOffset, { + this.outboundDirectionMarker, + this.inboundDirectionMarker, + }); + + static const BandProfile gen4 = BandProfile._(DeviceType.gen4, 4, 1); + static const BandProfile gen5 = BandProfile._( + DeviceType.gen5, + 8, + 2, + outboundDirectionMarker: [0x00, 0x01], + inboundDirectionMarker: [0x01, 0x00], + ); + + static BandProfile of(DeviceType t) => t == DeviceType.gen5 ? gen5 : gen4; + + bool get isGen5 => type == DeviceType.gen5; + + /// GATT UUIDs for this generation. + GattProfile get gatt => isGen5 ? GattProfile.gen5 : GattProfile.gen4; + + /// Read the declared length (padded inner + CRC32) from a frame's header. + /// Caller must ensure `frame.length >= sizeFieldOffset + 2`. + int declaredLen(List frame) => + frame[sizeFieldOffset] | (frame[sizeFieldOffset + 1] << 8); + + /// Total frame length on the wire for a given declared length. + int totalLen(int declared) => headerLen + declared; + + /// Validate the header integrity check. gen4 = crc8 over the 2 length bytes + /// at frame[3]; gen5 = crc16-modbus over frame[0:6] at frame[6:8] LE. + bool headerCrcValid(List frame) { + if (!isGen5) { + if (frame.length < 4) return false; + return frame[3] == crc8([frame[1], frame[2]]); + } + if (frame.length < 8) return false; + final want = frame[6] | (frame[7] << 8); + return crc16Modbus(frame.sublist(0, 6)) == want; + } + + /// Build the frame header for a given declared length. + /// gen4: `[0xAA][u16 declared LE][crc8]` + /// gen5: `[0xAA][0x01][u16 declared LE][outboundDirectionMarker][crc16modbus LE]` + /// + /// Every builder in this package constructs an OUTBOUND (host→strap) + /// COMMAND frame, so this always stamps [outboundDirectionMarker] — never + /// use this to synthesize a frame representing something the strap sent us + /// (see the field doc for why that byte pair differs by direction). + Uint8List buildHeader(int declared) { + if (!isGen5) { + final h = Uint8List(4); + h[0] = sof; + h[1] = declared & 0xFF; + h[2] = (declared >> 8) & 0xFF; + h[3] = crc8([h[1], h[2]]); + return h; + } + final h = Uint8List(8); + h[0] = sof; + h[1] = 0x01; + h[2] = declared & 0xFF; + h[3] = (declared >> 8) & 0xFF; + final dir = outboundDirectionMarker ?? const [0x00, 0x01]; + h[4] = dir[0]; + h[5] = dir[1]; + final c = crc16Modbus(h.sublist(0, 6)); + h[6] = c & 0xFF; + h[7] = (c >> 8) & 0xFF; + return h; + } +} diff --git a/lib/src/commands.dart b/lib/src/commands.dart index 5f595ff..cfead99 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'constants.dart'; import 'framing.dart'; +import 'band.dart'; enum WristSelection { right(0x01), @@ -14,21 +15,26 @@ enum WristSelection { } /// Build a framed command packet: [type][seq][opcode][payload]. +/// [profile] selects the generation's frame envelope (default gen4 = WHOOP 4). +/// The inner bytes are identical across generations — command opcodes are +/// shared — so only the envelope differs. Uint8List buildCommand(int seq, int opcode, - [List payload = const [0x00]]) { + [List payload = const [0x00], + BandProfile profile = BandProfile.gen4]) { final inner = [ PacketType.command, seq & 0xFF, opcode & 0xFF, ...payload ]; - return buildFrame(inner); + return buildFrame(inner, profile: profile); } /// WHOOP's positive historical-burst result (cmd 0x17). /// Inner = [0x23][seq][0x17][0x01] + token(8B). /// `token` is the two 4-byte slices from the HistoryEnd METADATA marker. -Uint8List buildHistoryResultOk(int seq, List token) { +Uint8List buildHistoryResultOk(int seq, List token, + {BandProfile profile = BandProfile.gen4}) { if (token.length != 8) { throw ArgumentError('batch token must be 8 bytes, got ${token.length}'); } @@ -39,17 +45,19 @@ Uint8List buildHistoryResultOk(int seq, List token) { revision1, ...token, ]; - return buildFrame(inner); + return buildFrame(inner, profile: profile); } /// The strap's negative historical-burst result (cmd 0x17). /// Payload is a single FAILURE result byte (the band only needs the code). -Uint8List buildHistoryResultFail(int seq) => - buildCommand(seq, Cmd.historicalDataResult, const [0x00]); +Uint8List buildHistoryResultFail(int seq, + {BandProfile profile = BandProfile.gen4}) => + buildCommand(seq, Cmd.historicalDataResult, const [0x00], profile); /// Legacy alias used by the app transport. -Uint8List buildBatchAck(int seq, List token) => - buildHistoryResultOk(seq, token); +Uint8List buildBatchAck(int seq, List token, + {BandProfile profile = BandProfile.gen4}) => + buildHistoryResultOk(seq, token, profile: profile); /// The 5-packet INIT handshake (hardware-verified, seq 0..4). /// buildCommand regenerates these byte-for-byte (protocol test asserts it). @@ -158,6 +166,7 @@ Uint8List cmdToggleImu(int seq, bool on) => buildCommand(seq, Cmd.toggleImuMode, [on ? 0x01 : 0x00]); Uint8List cmdEnableOptical(int seq, bool on) => buildCommand(seq, Cmd.enableOpticalData, [revision1, on ? 0x01 : 0x00]); + /// Play a haptic waveform effect (RUN_HAPTICS_PATTERN = 0x4F). /// /// [pattern] is a single u8 waveform-effect id. It is RANGE-CHECKED rather than @@ -190,6 +199,16 @@ Uint8List cmdBuzz(int seq, [int pattern = hapticShortPulse]) { // "time only" write is accepted and ACK'd but the strap never fires it (there // is no waveform to play). Our earlier 8-byte `[u32 epoch][u32 pad]` attempt // silently failed for exactly this reason. Prefer [cmdSetAlarm]. +// +// 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 +// gen5 alarm calls as feature-flagged/experimental until verified on real +// Maverick/5.0 hardware — do not promise it wakes a gen5 strap. /// The strap's built-in alarm buzz. Two short waveform effects (47, 152) played /// with no per-effect loop, the overall waveform looped 7×, for 30 s — this is @@ -218,7 +237,8 @@ int _alarmSubsec(DateTime when) => /// ⚠ This form sets the alarm TIME but ships no haptic waveform, so on real /// hardware the strap ACKs it yet never buzzes. Use [cmdSetAlarm] to actually /// arm a firing alarm; this is kept for parity / diagnostics only. -Uint8List cmdSetAlarmSimple(int seq, DateTime when) { +Uint8List cmdSetAlarmSimple(int seq, DateTime when, + {BandProfile profile = BandProfile.gen4}) { final sec = _alarmEpochSec(when); final subsec = _alarmSubsec(when); final p = [ @@ -230,7 +250,7 @@ Uint8List cmdSetAlarmSimple(int seq, DateTime when) { subsec & 0xff, (subsec >> 8) & 0xff, ]; - return buildCommand(seq, Cmd.setAlarmTime, p); + return buildCommand(seq, Cmd.setAlarmTime, p, profile); } /// RICH alarm form (SET_ALARM_TIME = 0x42) — THE form that actually fires. @@ -262,11 +282,12 @@ Uint8List cmdSetAlarm( DateTime when, { int index = 0, List? hapticPattern, + BandProfile profile = BandProfile.gen4, }) { final pattern = hapticPattern ?? kDefaultAlarmHaptics; if (pattern.length != 12) { - throw ArgumentError.value( - pattern.length, 'hapticPattern.length', 'haptic pattern must be 12 bytes'); + throw ArgumentError.value(pattern.length, 'hapticPattern.length', + 'haptic pattern must be 12 bytes'); } final sec = _alarmEpochSec(when); final subsec = _alarmSubsec(when); @@ -281,7 +302,7 @@ Uint8List cmdSetAlarm( (subsec >> 8) & 0xff, ...pattern.map((b) => b & 0xff), ]; - return buildCommand(seq, Cmd.setAlarmTime, p); + return buildCommand(seq, Cmd.setAlarmTime, p, profile); } /// Fire / test the alarm haptics immediately (RUN_ALARM = 0x44). @@ -290,9 +311,10 @@ Uint8List cmdSetAlarm( /// - revision 1 (default, [mode] == null): payload `[0x01]`. /// - revision 2 ([mode] set): payload `[0x02][u8 mode]`, where `mode` selects /// the run behaviour understood by the firmware. -Uint8List cmdRunAlarm(int seq, {int? mode}) { +Uint8List cmdRunAlarm(int seq, + {int? mode, BandProfile profile = BandProfile.gen4}) { final p = mode == null ? const [0x01] : [0x02, mode & 0xff]; - return buildCommand(seq, Cmd.runAlarm, p); + return buildCommand(seq, Cmd.runAlarm, p, profile); } /// Disable / cancel the on-device alarm (DISABLE_ALARM = 0x45). @@ -301,7 +323,195 @@ Uint8List cmdRunAlarm(int seq, {int? mode}) { /// - revision 1 (default): `[0x01]`. /// - revision 2: `[0x02][0xFF]` — the trailing 0xFF is the firmware's /// "clear all" sentinel for the rev-2 disable. -Uint8List cmdDisableAlarm(int seq, {int revision = 1}) { +Uint8List cmdDisableAlarm(int seq, + {int revision = 1, BandProfile profile = BandProfile.gen4}) { final p = revision == 2 ? const [0x02, 0xFF] : [revision & 0xff]; - return buildCommand(seq, Cmd.disableAlarm, p); + return buildCommand(seq, Cmd.disableAlarm, p, profile); +} + +// ── WHOOP 5 (gen5 / "fd4b") handshake + offload ──────────────────────────── +// +// gen5 differs from gen4's 5-packet INIT: the link opens with a single +// CLIENT_HELLO (GET_HELLO = 0x91) written with-response to trigger the +// just-works bond, then the offload is driven by GET_DATA_RANGE (0x22) and +// SEND_HISTORICAL_DATA (0x16) — the SAME opcodes as gen4, but with EMPTY +// payloads (gen4 sends a single 0x00). The HISTORY_END ACK +// ([buildHistoryResultOk]) is byte-structured identically; only the frame +// envelope differs, so pass `profile: BandProfile.gen5`. + +/// The gen5 CLIENT_HELLO frame (GET_HELLO = 0x91, payload [0x01]). +/// +/// Built through [buildFrame] with the gen5 profile; this reproduces the +/// canonical, hardware-observed 16-byte hello byte-for-byte: +/// `aa 01 08 00 00 01 e6 71 23 01 91 01 36 3e 5c 8d`. A `gen5_test` asserts +/// this equality, which simultaneously validates crc16-modbus + crc32 + the +/// gen5 header layout. Sequence defaults to 1 to match that canonical frame. +Uint8List gen5ClientHello({int seq = 1}) => + buildCommand(seq, Cmd.getHello, const [0x01], BandProfile.gen5); + +/// gen5 GET_DATA_RANGE (0x22) with the EMPTY payload gen5 expects. +Uint8List cmdGetDataRangeGen5(int seq) => + buildCommand(seq, Cmd.getDataRange, const [], BandProfile.gen5); + +/// gen5 SEND_HISTORICAL_DATA (0x16) with the EMPTY payload gen5 expects — the +/// command that starts the flash drain. +Uint8List cmdSendHistoricalGen5(int seq) => + buildCommand(seq, Cmd.sendHistoricalData, const [], BandProfile.gen5); + +// ── gen5 clock (SET_CLOCK_MAVERICK=146 / GET_CLOCK_GEN5=147) ─────────────── +// +// gen5 replaces gen4's SET_CLOCK(0x0A)/GET_CLOCK(0x0B) with distinct opcode +// VALUES (§1.4) — the payload shapes are otherwise unverified for gen5 on +// real hardware, so these mirror [cmdSetClock]/[cmdGetClock]'s +// hardware-verified gen4 payload shape (8-byte two-u32 form) as the +// best-supported assumption, clearly marked: hardware verification for gen5 +// specifically is still needed before relying on this to actually latch the +// RTC on a real Maverick/5.0 strap. + +/// gen5 SET_CLOCK_MAVERICK (146). ASSUMPTION: reuses gen4's hardware-verified +/// 8-byte `[u32 epoch][u16 subsec][pad u16]` payload shape — the opcode VALUE +/// is confirmed gen5-specific, but this payload shape has NOT been confirmed +/// against real gen5 hardware. Verify by reading the clock back +/// ([cmdGetClockGen5]) before trusting it in production. +Uint8List cmdSetClockGen5(int seq, {DateTime? now}) { + final ms = (now ?? DateTime.now()).millisecondsSinceEpoch; + final sec = ms ~/ 1000; + final subsec = ((ms % 1000) * 32768) ~/ 1000; + final payload = [ + sec & 0xff, + (sec >> 8) & 0xff, + (sec >> 16) & 0xff, + (sec >> 24) & 0xff, + subsec & 0xff, + (subsec >> 8) & 0xff, + 0, + 0, + ]; + return buildCommand(seq, Cmd.setClockMaverick, payload, BandProfile.gen5); +} + +/// gen5 GET_CLOCK_GEN5 (147). +Uint8List cmdGetClockGen5(int seq) => + buildCommand(seq, Cmd.getClockGen5, const [], BandProfile.gen5); + +// ── gen5 Maverick haptics (RUN_HAPTIC_PATTERN_MAVERICK = 0x13/19) ────────── + +/// gen5's Maverick haptic-buzz command — a DIFFERENT opcode from gen4's +/// [cmdBuzz]/RUN_HAPTICS_PATTERN (0x4F/79), not an alias of the same value +/// (§1.4: "79/19 haptics-name-differs-not-value"). Byte-verified 12-byte +/// payload shape: `[0x01, 47, 152, 0,0,0,0,0,0,0,0, overallLoop]` — the same +/// `[47, 152]` waveform-effect pair the strap uses for both its notify-buzz +/// and its wake alarm. [overallLoop] defaults to 1 (a single short buzz); +/// pass a higher value for a longer/repeated pattern. +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. + final clampedLoop = overallLoop < 0 ? 0 : (overallLoop > 0xff ? 0xff : overallLoop); + final payload = [ + 0x01, + 47, + 152, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + clampedLoop, + ]; + return buildCommand( + seq, Cmd.runHapticPatternMaverick, payload, BandProfile.gen5); +} + +// ── gen5 SET_CONFIG (opcode 120) + the R22 deep-buffer enable sequence ───── +// +// 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 +// unless this 16-flag sequence is sent first. Byte-verified body shape (a +// real `enable_r22_packets` capture): 40-byte body = ASCII key name +// NUL-padded to 32 bytes, + 1 value byte @ offset 32, + 7 zero bytes. + +/// One SET_CONFIG (120) frame: `[0x23][seq][120][0x01][name:32B NUL-padded] +/// [value:1B][zero:7B]`. [name] must fit in 31 bytes (leaving room for the +/// NUL terminator within the 32-byte field) and [value] must be a single +/// ASCII character (this package's config values are always `'1'` or `'2'`). +Uint8List cmdSetConfigGen5(int seq, String name, String value) { + if (name.codeUnits.any((c) => c > 0x7f) || name.length > 31) { + throw ArgumentError.value( + name, 'name', 'must be <=31 ASCII chars (32-byte NUL-padded field)'); + } + if (value.length != 1 || value.codeUnitAt(0) > 0x7f) { + throw ArgumentError.value( + value, 'value', 'must be a single ASCII character'); + } + final nameBytes = Uint8List(32)..setRange(0, name.length, name.codeUnits); + final payload = [ + revision1, + ...nameBytes, + value.codeUnitAt(0), + 0, 0, 0, 0, 0, 0, 0, // 7 zero bytes + ]; + return buildCommand(seq, Cmd.setFfValue, payload, BandProfile.gen5); } + +/// SET_DEVICE_CONFIG_VALUE (119) — a distinct, SMALLER sibling of SET_CONFIG +/// (120): a 33-byte body with NO trailing padding (`[name:32B NUL-padded] +/// [value:1B]`, vs 120's 40-byte body). ASSUMPTION: the multiband spec +/// confirms the body is 33 bytes with no padding but does not give a +/// byte-verified real capture for this opcode specifically — this mirrors +/// 120's name/value convention as the best-supported guess. Verify against a +/// real capture before relying on it. +Uint8List cmdSetDeviceConfigValueGen5(int seq, String name, String value) { + if (name.codeUnits.any((c) => c > 0x7f) || name.length > 31) { + throw ArgumentError.value( + name, 'name', 'must be <=31 ASCII chars (32-byte NUL-padded field)'); + } + if (value.length != 1 || value.codeUnitAt(0) > 0x7f) { + throw ArgumentError.value( + value, 'value', 'must be a single ASCII character'); + } + final nameBytes = Uint8List(32)..setRange(0, name.length, name.codeUnits); + final payload = [...nameBytes, value.codeUnitAt(0)]; + return buildCommand(seq, Cmd.setDeviceConfigValue, payload, BandProfile.gen5); +} + +/// The 16 SET_CONFIG flags (name, value) that unlock gen5's R22 deep buffers +/// (v20/v21/v26), in the exact order + values a real capture verified. +/// INDEX-SENSITIVE — do not reorder or "clean up" (per issue #423's corrected +/// `enable_sig12` value, a prior reordering attempt shipped the wrong value). +const List<(String, String)> kGen5R22EnableFlags = [ + ('enable_r22_packets', '2'), + ('enable_r22_v2_packets', '2'), + ('enable_r22_v3_packets', '2'), + ('enable_r22_v4_packets', '1'), + ('enable_r22_v5_packets', '2'), + ('enable_r22_v6_packets', '2'), + ('enable_r22_v8_packets', '2'), + ('make_hrfm_visible', '2'), + ('disable_pip_r26_packets', '2'), + ('wear_detect_bias', '2'), + ('hr_ch_switching', '2'), + ('ir_hw_switching', '2'), + ('enable_passive_strap_fit_gen5', '1'), + ('enable_sig11_during_sleep', '2'), + ('dorset_inhibit_wpt', '2'), + ('enable_sig12', '1'), +]; + +/// Build the 16-frame R22 enable sequence (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 +/// without this sequence will only ever yield v18. +List buildR22EnableSequence({int startSeq = 1}) => [ + for (int i = 0; i < kGen5R22EnableFlags.length; i++) + cmdSetConfigGen5( + (startSeq + i) & 0xFF, + kGen5R22EnableFlags[i].$1, + kGen5R22EnableFlags[i].$2, + ), + ]; diff --git a/lib/src/constants.dart b/lib/src/constants.dart index 231d9b0..bd10c97 100644 --- a/lib/src/constants.dart +++ b/lib/src/constants.dart @@ -86,6 +86,73 @@ class Cmd { static const int getHello = 0x91; static const int getBatteryPackInfo = 0x97; static const int togglePersistentR21 = 0x9A; // DANGER + + // ── gen5-exclusive opcode VALUES (replace the gen4 opcode of the same + // purpose; see BandProfile — everything else in this class is shared + // verbatim across generations) ────────────────────────────────────────── + // Replaces SET_CLOCK (0x0A) on gen5. + static const int setClockMaverick = 146; + // Replaces GET_CLOCK (0x0B) on gen5. + static const int getClockGen5 = 147; + // gen5's Maverick haptic-buzz command. NOT the same opcode as gen4's + // RUN_HAPTICS_PATTERN (0x4F/79) — "79/19 haptics-name-differs-not-value" + // per the multiband spec: these are two genuinely distinct opcodes, not an + // alias of the same numeric value. + static const int runHapticPatternMaverick = 0x13; // 19 + // SET_DEVICE_CONFIG_VALUE — smaller/older sibling of SET_FF_VALUE (120). + static const int setDeviceConfigValue = 119; + // SET_FF_VALUE / SET_CONFIG — 40-byte name+value body. Shared opcode + // number across generations; this is how the gen5 R22 deep-buffer enable + // sequence is sent (see commands.dart's buildR22EnableSequence). NOTE: this + // opcode is ALSO in [OpcodeSafety.forbidden] — see that class's doc for why + // that is not a contradiction. + static const int setFfValue = 120; +} + +/// Band-agnostic opcode safety classification, sourced from whoop-rs's +/// hardware-tested command surface (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 firmware-load 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). +/// +/// 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 + /// (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 + /// opt-in), not the kind of accidental/automatic send this gate exists to + /// stop. A call site enforcing this list needs an explicit allowlist for + /// deliberate sequences like R22, not a blanket "opcode 120 → refuse". + static const Set forbidden = { + 10, + 146, + 25, + 29, + 32, + 45, + 77, + 119, + 120, + 99, + 123, + 142, + 143, + 144, + }; + + /// The subset of [forbidden] that is actively destructive (data loss / + /// bricking), not merely "don't auto-fire". Opcodes 142-144 have no named + /// meaning in either reference codebase — treat as permanently blocked, + /// unknown-but-dangerous. + static const Set destructive = {25, 45, 142, 143, 144}; + + static bool isForbidden(int opcode) => forbidden.contains(opcode); + static bool isDestructive(int opcode) => destructive.contains(opcode); } /// Commands that can brick the link / burn battery / brick flash. NEVER auto-fire. @@ -132,6 +199,10 @@ class EventId { static const int batteryPackConnected = 21; static const int batteryPackRemoved = 22; static const int bleBonded = 23; + // gen5-only: toggling the realtime HR stream on/off is confirmed via these + // events (gen4 has no equivalent confirmation event for this action). + static const int bleRealtimeHrOn = 33; + static const int bleRealtimeHrOff = 34; // Full-flash trim (data erase) started / finished on the strap. static const int trimAllData = 26; static const int trimAllDataEnded = 27; @@ -184,6 +255,10 @@ class EventId { return 'BATTERY_PACK_REMOVED'; case bleBonded: return 'BLE_BONDED'; + case bleRealtimeHrOn: + return 'BLE_REALTIME_HR_ON'; + case bleRealtimeHrOff: + return 'BLE_REALTIME_HR_OFF'; case trimAllData: return 'TRIM_ALL_DATA'; case trimAllDataEnded: diff --git a/lib/src/control.dart b/lib/src/control.dart index b57b8e8..3dea80a 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -5,8 +5,10 @@ // Dart parseR24 (records.dart, Source 1). PURE Dart. import 'dart:typed_data'; +import 'band.dart'; import 'constants.dart'; import 'framing.dart'; +import 'gen5_records.dart'; import 'records.dart'; // ── little-endian helpers over a byte list ────────────────────────────────── @@ -250,8 +252,7 @@ class HelloInfo { /// A battery percentage is 0..100. Anything else is a mis-read field, not a /// battery level — callers must omit it, never clamp it (a clamp would report /// a confident 100% for garbage bytes). -bool _validBatteryPct(double pct) => - pct.isFinite && pct >= 0.0 && pct <= 100.0; +bool _validBatteryPct(double pct) => pct.isFinite && pct >= 0.0 && pct <= 100.0; List _asciiRuns(Uint8List data, int start, int minlen) { final runs = []; @@ -380,9 +381,8 @@ EventInfo? parseEvent(Uint8List inner) { // begins at [12]. All guarded by length so short frames degrade cleanly. final ts = inner.length >= 8 ? u32(inner, 4) : 0; final subsec = inner.length >= 10 ? u16(inner, 8) : 0; - final body = inner.length > 12 - ? Uint8List.sublistView(inner, 12) - : Uint8List(0); + final body = + inner.length > 12 ? Uint8List.sublistView(inner, 12) : Uint8List(0); final dec = {}; switch (eid) { case EventId.chargingOn: @@ -398,7 +398,23 @@ EventInfo? parseEvent(Uint8List inner) { dec['pack_connected'] = eid == EventId.batteryPackConnected; break; case EventId.doubleTap: - dec['double_tap'] = true; + dec['double_tap'] = true; // no payload beyond event+timestamp, confirmed + break; + case EventId.batteryLevel: + // Byte-verified on a real BATTERY_LEVEL fixture: soc @ body[1] (u16, + // DECI-percent — divide by 10; this is a DIFFERENT convention from + // COMMAND_RESPONSE's GET_BATTERY_LEVEL, which is direct-percent on + // gen5. Don't conflate the two.), battery_mV @ body[5] (u16), + // charging @ bit0 of body[10]. Shared across gen4/gen5 — these are + // inner-relative offsets, identical across generations. + if (body.length >= 12) { + final soc = _round(u16(body, 1) / 10.0, 1); + if (soc.isFinite && soc >= 0.0 && soc <= 100.0) { + dec['battery_pct'] = soc; + } + dec['battery_mv'] = u16(body, 5); + dec['charging'] = (body[10] & 0x01) != 0; + } break; case EventId.highFreqSyncPrompt: dec['high_freq_sync'] = 'prompt'; @@ -409,6 +425,12 @@ EventInfo? parseEvent(Uint8List inner) { case EventId.highFreqSyncDisabled: dec['high_freq_sync'] = 'disabled'; break; + case EventId.bleRealtimeHrOn: + case EventId.bleRealtimeHrOff: + // gen5-only confirmation that the realtime HR stream toggle actually + // took (gen4 has no equivalent confirmation event for this). + dec['realtime_hr_stream'] = eid == EventId.bleRealtimeHrOn; + break; } return EventInfo(eid, name, ts, dec, tsSubsec: subsec, body: body); } @@ -420,20 +442,49 @@ class CmdResponse { CmdResponse(this.opcode, this.decoded); } -CmdResponse? parseCommandResponse(Uint8List inner) { +/// Parse a COMMAND_RESPONSE (0x24) frame. [profile] selects generation- +/// specific response-BODY-SHAPE differences for opcodes that are otherwise +/// opcode-identical across gen4/gen5 (per the multiband spec §1.4: shared +/// opcodes, generational differences live in the response shape, not the +/// opcode number). Defaults to gen4 so every existing caller is unchanged. +CmdResponse? parseCommandResponse(Uint8List inner, + {BandProfile profile = BandProfile.gen4}) { if (inner.length < 3 || inner[0] != PacketType.commandResponse) return null; final op = inner[2]; final payload = Uint8List.sublistView(inner, 3); final dec = {}; - if (op == Cmd.getBatteryLevel && inner.length >= 7) { - // u16 LE @[5:7] in tenths of a percent. A battery percentage outside - // 0..100 is not a battery percentage — `ff ff` here used to surface as - // 6553.5%. Emit nothing rather than a number the UI would render. - final pct = _round(u16(inner, 5) / 10.0, 1); - if (_validBatteryPct(pct)) dec['battery_pct'] = pct; + if (op == Cmd.getBatteryLevel && inner.length >= (profile.isGen5 ? 6 : 7)) { + // Byte-verified: gen5 returns a DIRECT percent @ inner[5] (u8, e.g. + // 0x2F=47%) — NOT deci-percent like gen4's u16 LE @[5:7]. Conflating the + // two would either divide a real gen5 percent by 10 or read half of a + // gen4 deci-percent as a whole percent. + if (profile.isGen5) { + final pct = inner[5].toDouble(); + if (_validBatteryPct(pct)) dec['battery_pct'] = pct; + } else { + final pct = _round(u16(inner, 5) / 10.0, 1); + // A battery percentage outside 0..100 is not a battery percentage — + // `ff ff` here used to surface as 6553.5%. Emit nothing rather than a + // number the UI would render. + if (_validBatteryPct(pct)) dec['battery_pct'] = pct; + } } else if (op == Cmd.getHelloHarvard) { final h = parseHello(payload); dec['hello'] = h; + } else if (op == Cmd.getHello) { + // gen5's GET_HELLO (0x91) response — a DIFFERENT opcode from gen4's + // GET_HELLO_HARVARD (0x23), with its own byte-verified fields: + // device_name @ pay[16], fw_version (4 raw bytes) @ pay[93] gated on + // pay[93] == 50 (the fw major-version byte real captures show as 50 — + // e.g. "50.38.1.0" — NOT the ASCII character '5' (that would be 53); + // absent the gate, don't report a fw_version at all). + if (payload.length > 16) { + final name = _cstrAt(payload, 16); + if (name.isNotEmpty) dec['device_name'] = name; + } + if (payload.length >= 97 && payload[93] == 50) { + dec['fw_version'] = Uint8List.fromList(payload.sublist(93, 97)); + } } else if (op == Cmd.getAlarmTime && payload.isNotEmpty) { // GET_ALARM_TIME echoes whichever alarm form the strap holds, and the // epoch offset DIFFERS between them (this package writes both — see @@ -461,6 +512,22 @@ CmdResponse? parseCommandResponse(Uint8List inner) { dec['range_oldest'] = range[0]; dec['range_newest'] = range[1]; } + // Ring-buffer backlog telemetry (noop only — not in whoop-rs at all). + // Field-to-offset mapping for oldest/newest above is itself flagged as + // genuinely unresolved/inconsistent across the two reference sources + // (§1.6 TODO — needs a real-capture confirmation before hardening + // further); this backlog read is additive and independently gated on + // the known-constant capacity, so it degrades safely if wrong. + if (payload.length >= 28) { + final capacity = u32(payload, 24); + if (capacity == 131072) { + dec['pages_behind'] = { + 'written': u32(payload, 12), + 'used': u32(payload, 16), + 'capacity': capacity, + }; + } + } } else if (op == Cmd.getBodyLocationAndStatus && payload.length >= 4) { dec['body_location_status'] = BodyLocationStatusResponse( revision: payload[0], @@ -558,13 +625,26 @@ int? _firstPlausibleUnix(Uint8List payload) { return null; } +// GET_DATA_RANGE-specific bounds/scan, tighter than the generic +// _firstPlausibleUnix used for GET_CLOCK. Cross-validated against a real +// GET_DATA_RANGE capture against whoop-rs's ground truth: the generic +// [_maxPlausibleUnix] (year 2100) let a spurious far-future word through as +// "newest", and scanning every unaligned byte offset of the payload (rather +// than the u32 grid) picked up an off-grid straddle word neither field +// actually occupies. Both fixed here without touching GET_CLOCK's own +// (deliberately more permissive) scan. +const int _maxPlausibleUnixForRange = 1900000000; // ~2030 — tighter than 2100 + /// [oldest, newest] from the two plausible-unix u32s in a GET_DATA_RANGE response -/// (min and max of all plausible epochs found). Null if fewer than one is present. +/// (min and max of all plausible epochs found), scanned on the 4-byte grid +/// anchored at payload offset 0 (== frame-absolute offset 7, i.e. right after +/// the 7-byte inner header [type,seq,cmd,result]) — never at an arbitrary +/// unaligned byte offset. Null if fewer than one is present. List? _plausibleUnixRange(Uint8List payload) { final found = []; - for (int o = 0; o + 4 <= payload.length; o++) { + for (int o = 0; o + 4 <= payload.length; o += 4) { final v = u32(payload, o); - if (v >= _minPlausibleUnix && v <= _maxPlausibleUnix) found.add(v); + if (v >= _minPlausibleUnix && v <= _maxPlausibleUnixForRange) found.add(v); } if (found.isEmpty) return null; found.sort(); @@ -587,6 +667,19 @@ class MetaMarker { ); } +/// gen5 offset audit (2026-08 multiband port): parseMetadata's inner-relative +/// offsets below (sub@2, u32@9, 8-byte token@13:21) were ALREADY correct for +/// gen5 without any change — verified against a real gen5 HISTORY_END +/// fixture (meta_type@inner[2]==2, and the spec's independently-quoted +/// "trim_cursor" value 113405 falls out exactly as `u32(inner, 13)`, i.e. the +/// FIRST four bytes of this function's existing `token`). This is the +/// general "gen5's inner-relative offsets are identical to gen4's" fact +/// (§1.5) holding for METADATA too — no gen5-specific branch needed here. +/// (The multiband spec's own worked METADATA/ACK example pair does NOT +/// actually round-trip byte-for-byte against each other despite being +/// presented as a matched pair — an inconsistency in that source material, +/// not in this decoder; don't chase making that specific pair's `token` +/// values equal.) MetaMarker? parseMetadata(Uint8List inner) { if (inner.length < 3 || inner[0] != PacketType.metadata) return null; final sub = inner[2]; @@ -618,6 +711,109 @@ MetaMarker? parseMetadata(Uint8List inner) { return MetaMarker(sub, name, expectedPacketCount, token, batchId); } +// ── CONSOLE_LOGS (0x32) ────────────────────────────────────────────────────── +// +// New decoder — not previously implemented for either generation. Layout per +// the multiband spec §1.8 (frame-absolute offsets, converted -8 to inner): +// record_index u16 @ inner[1:3] (frame-abs 9) +// unix u32 @ inner[4:8] (frame-abs 12) +// subsec u16 @ inner[8:10] (frame-abs 16) +// chunk_len u16 @ inner[10:12] (frame-abs 18) +// channel u8 @ inner[12] (frame-abs 20) +// text @ inner[13:] (frame-abs 21), trailing-NUL-trimmed only +// (embedded NULs preserved), capped at 2048 chars, CRC-independent. +// A real gen5 log line looks like "146552119: BLE_CMD: Command Send +// Historical Data\n" (boot-tick-ms : tag : message). +class ConsoleLogChunk { + final int recordIndex; + final int unix; + final int subsec; + final int chunkLen; + final int channel; + final String text; + + const ConsoleLogChunk({ + required this.recordIndex, + required this.unix, + required this.subsec, + required this.chunkLen, + required this.channel, + required this.text, + }); +} + +const int _kConsoleLogTextCap = 2048; + +/// Trim a TRAILING run of NUL bytes only — embedded NULs (mid-string) are +/// preserved verbatim, since a log line can legitimately contain them before +/// reassembly trims the real terminator. Decodes as Latin-1 (byte-for-byte) +/// rather than UTF-8: firmware log text is not guaranteed valid UTF-8, and a +/// UTF-8 decode failure would drop the whole chunk instead of surfacing +/// whatever bytes are actually there. +String _consoleLogText(Uint8List raw) { + var end = raw.length; + while (end > 0 && raw[end - 1] == 0) { + end--; + } + final capped = end > _kConsoleLogTextCap ? _kConsoleLogTextCap : end; + return String.fromCharCodes(raw.sublist(0, capped)); +} + +ConsoleLogChunk? parseConsoleLog(Uint8List inner) { + if (inner.length < 13 || inner[0] != PacketType.consoleLogs) return null; + final text = inner.length > 13 + ? _consoleLogText(Uint8List.sublistView(inner, 13)) + : ''; + return ConsoleLogChunk( + recordIndex: u16(inner, 1), + unix: u32(inner, 4), + subsec: u16(inner, 8), + chunkLen: u16(inner, 10), + channel: inner[12], + text: text, + ); +} + +/// Reassembles console-log text that straddles multiple consecutive frames. +/// +/// Per §1.8: "one log line can straddle multiple consecutive record_index +/// frames — reassemble by contiguous index, not by arrival order." Feed +/// chunks as they decode; [flush] returns (and clears) whatever contiguous +/// run has accumulated so far. A gap in `record_index` (a dropped/reordered +/// frame) flushes what's buffered rather than silently splicing unrelated +/// text together. +class ConsoleLogReassembler { + final StringBuffer _buf = StringBuffer(); + int? _lastIndex; + + /// Feed one decoded chunk. Returns true if it extended the current + /// contiguous run; false if it started a new one (the old run, if any, was + /// flushed into the return value of the PREVIOUS [flush] call — callers + /// should call [flush] after a `false` return to retrieve what came before). + bool add(ConsoleLogChunk chunk) { + final contiguous = _lastIndex != null && + // record_index is a u16 — allow wraparound at 0xFFFF, matching the + // wire's own modulo-65536 counter. + (chunk.recordIndex == (_lastIndex! + 1) & 0xFFFF); + if (_lastIndex != null && !contiguous) { + // Caller must flush() before this chunk's text is appended, or the + // discontinuity is silently lost. We still start the new run here. + _buf.clear(); + } + _buf.write(chunk.text); + _lastIndex = chunk.recordIndex; + return contiguous; + } + + /// Return everything accumulated so far and reset for the next run. + String flush() { + final s = _buf.toString(); + _buf.clear(); + _lastIndex = null; + return s; + } +} + // ── decode_frame dispatch (for live UI / logging) ──────────────────────────── class Decoded { final String kind; @@ -626,13 +822,16 @@ class Decoded { } /// Route a parsed frame to the right decoder. Returns a structured Decoded. -Decoded decodeFrame(Frame frame) { +/// [profile] selects generation-specific response-shape handling (battery +/// scale, GET_HELLO opcode, historical-record version family); defaults to +/// gen4 so every existing caller is unchanged. +Decoded decodeFrame(Frame frame, {BandProfile profile = BandProfile.gen4}) { final inner = frame.inner; final pt = frame.packetType; try { switch (pt) { case PacketType.commandResponse: - final r = parseCommandResponse(inner); + final r = parseCommandResponse(inner, profile: profile); if (r != null) { return Decoded('cmd_response', {'opcode': r.opcode, ...r.decoded}); } @@ -654,10 +853,21 @@ Decoded decodeFrame(Frame frame) { return Decoded('metadata', {'sub': m.name, 'batch_id': m.batchId}); } break; + case PacketType.consoleLogs: + final c = parseConsoleLog(inner); + if (c != null) { + return Decoded('console_log', { + 'record_index': c.recordIndex, + 'ts_epoch': c.unix, + 'channel': c.channel, + 'text': c.text, + }); + } + break; case PacketType.historicalData: case PacketType.realtimeData: case PacketType.realtimeRawData: - return _decodeDataRecord(inner); + return _decodeDataRecord(inner, profile: profile); } } catch (e) { return Decoded('decode_error', {'error': e.toString()}); @@ -665,7 +875,21 @@ Decoded decodeFrame(Frame frame) { return Decoded('other', {'packet_type': pt}); } -Decoded _decodeDataRecord(Uint8List inner) { +Decoded _decodeDataRecord(Uint8List inner, + {BandProfile profile = BandProfile.gen4}) { + if (profile.isGen5 && + inner.isNotEmpty && + inner[0] == PacketType.historicalData) { + final g = parseGen5Historical(inner); + if (g != null) { + return Decoded('gen5_historical', { + 'hist_version': g.histVersion, + 'record_index': g.recordIndex, + 'ts_epoch': g.unix, + 'kind': g.runtimeType.toString(), + }); + } + } final recType = inner.length > 1 ? inner[1] : -1; // Compact realtime stream (small packet). if (inner.length < 64) { diff --git a/lib/src/crc.dart b/lib/src/crc.dart index fe069f1..27d7e9a 100644 --- a/lib/src/crc.dart +++ b/lib/src/crc.dart @@ -56,3 +56,23 @@ int crc32(List data) { } return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF; } + +/// CRC-16/MODBUS (init 0xFFFF, reflected poly 0xA001, no final XOR) over the +/// WHOOP 5 (gen5 / "fd4b") 8-byte frame header bytes [0:6]. WHOOP 4 (gen4) +/// frames use [crc8] for header integrity instead; the payload trailer stays +/// [crc32] on BOTH generations. Verified byte-exact against the gen5 client +/// HELLO frame (header `aa 01 08 00 00 01` → 0x71E6). +int crc16Modbus(List data) { + int crc = 0xFFFF; + for (final b in data) { + crc ^= b & 0xFF; + for (int i = 0; i < 8; i++) { + if ((crc & 1) != 0) { + crc = (crc >> 1) ^ 0xA001; + } else { + crc >>= 1; + } + } + } + return crc & 0xFFFF; +} diff --git a/lib/src/framing.dart b/lib/src/framing.dart index 22942ac..917318e 100644 --- a/lib/src/framing.dart +++ b/lib/src/framing.dart @@ -11,15 +11,23 @@ import 'dart:typed_data'; import 'crc.dart'; import 'constants.dart'; +import 'band.dart'; /// A fully-parsed, validated frame envelope. class Frame { final Uint8List inner; // unpadded? no — padded inner (type, seq, opcode, body…) + + /// Header-integrity check result. Named `crc8Ok` for backward compatibility + /// (gen4 header uses crc8); on gen5 it carries the crc16-modbus result. Use + /// [headerCrcOk] for band-neutral code. final bool crc8Ok; final bool crc32Ok; Frame(this.inner, this.crc8Ok, this.crc32Ok); + /// Band-neutral alias for the header-integrity result. + bool get headerCrcOk => crc8Ok; + bool get valid => crc8Ok && crc32Ok; int get packetType => inner.isNotEmpty ? inner[0] : -1; int get seq => inner.length > 1 ? inner[1] : -1; @@ -36,18 +44,18 @@ Uint8List pad4(List data) { return out; } -/// Wrap inner content in the Gen4 frame envelope. -Uint8List buildFrame(List inner) { +/// Wrap inner content in a frame envelope. [profile] selects the generation's +/// header shape; defaults to gen4 (WHOOP 4) so every existing caller is +/// byte-for-byte unchanged. The padded inner + trailing CRC32 are identical +/// across generations — only the header differs. +Uint8List buildFrame(List inner, {BandProfile profile = BandProfile.gen4}) { final innerP = pad4(inner); final declared = innerP.length + 4; // +4 = trailing CRC32 - final lenB = Uint8List(2)..buffer.asByteData().setUint16(0, declared, Endian.little); - final c8 = crc8(lenB); + final header = profile.buildHeader(declared); final c32 = crc32(innerP); final out = BytesBuilder(); - out.addByte(sof); - out.add(lenB); - out.addByte(c8); + out.add(header); out.add(innerP); final tail = Uint8List(4)..buffer.asByteData().setUint32(0, c32, Endian.little); out.add(tail); @@ -55,29 +63,34 @@ Uint8List buildFrame(List inner) { } /// Parse a single complete frame. Returns null if too short / bad SOF. -Frame? parseFrame(Uint8List raw) { - if (raw.length < 8 || raw[0] != sof) return null; - final bd = raw.buffer.asByteData(raw.offsetInBytes, raw.length); - final declared = bd.getUint16(1, Endian.little); +/// [profile] selects the generation's header shape (default gen4). +Frame? parseFrame(Uint8List raw, {BandProfile profile = BandProfile.gen4}) { + final headerLen = profile.headerLen; + if (raw.length < headerLen + 4 || raw[0] != sof) return null; + final declared = profile.declaredLen(raw); // declared has to be at least 4 (the trailing crc32) or the inner slice // math below goes negative and sublistView throws instead of us just // saying "not a valid frame" like the length checks above already do. if (declared < 4) return null; - final crc8Ok = raw[3] == crc8(Uint8List.sublistView(raw, 1, 3)); - const innerStart = 4; - final total = 4 + declared; + final headerCrcOk = profile.headerCrcValid(raw); + final innerStart = headerLen; + final total = headerLen + declared; if (raw.length < total) return null; - // inner = raw[4 : 4 + declared - 4] + // inner = raw[headerLen : headerLen + declared - 4] final inner = Uint8List.sublistView(raw, innerStart, innerStart + declared - 4); - final storedBd = raw.buffer.asByteData(raw.offsetInBytes + innerStart + declared - 4, 4); + final storedBd = + raw.buffer.asByteData(raw.offsetInBytes + innerStart + declared - 4, 4); final stored = storedBd.getUint32(0, Endian.little); - return Frame(Uint8List.fromList(inner), crc8Ok, stored == crc32(inner)); + return Frame(Uint8List.fromList(inner), headerCrcOk, stored == crc32(inner)); } /// Length-based reassembler. feed() returns every complete Frame it can carve -/// out of the running buffer. length-based reassembler. +/// out of the running buffer. [profile] selects the generation's header shape; +/// defaults to gen4 so the WHOOP 4 path is unchanged. Construct ONE per +/// BLE session (a session speaks one generation). class FrameReassembler { final List _buf = []; + final BandProfile profile; int _resyncs = 0; /// Number of times the reassembler skipped a byte because the envelope did @@ -86,6 +99,8 @@ class FrameReassembler { /// length is discarded here, so it never reaches [Frame.valid]. int get resyncs => _resyncs; + FrameReassembler({this.profile = BandProfile.gen4}); + List feed(List chunk) { final out = []; _buf.addAll(chunk); @@ -108,28 +123,33 @@ class FrameReassembler { return true; } - while (_buf.length >= 8) { + final headerLen = profile.headerLen; + while (_buf.length >= headerLen + 4) { if (_buf[0] != sof) { if (!resync()) break; continue; } - final declared = _buf[1] | (_buf[2] << 8); // u16 LE - final total = 4 + declared; + final declared = profile.declaredLen(_buf); // u16 LE + final total = headerLen + declared; if (declared < 4 || total > 4096) { // implausible length → spurious SOF if (!resync()) break; continue; } - // The crc8 protects the length field and nothing else, so check it - // before acting on `declared`. Skipping this consumes up to 4092 bytes - // of good stream on a single corrupted length byte — records the band - // is about to trim from flash and will not send again. - if (_buf[3] != crc8(Uint8List.fromList([_buf[1], _buf[2]]))) { + // The header integrity check (crc8 on gen4, crc16-modbus on gen5) + // protects the length field and nothing else, so check it before + // acting on `declared`. Skipping this consumes up to 4092 bytes of + // good stream on a single corrupted length byte — records the band is + // about to trim from flash and will not send again. Must go through + // `profile.headerCrcValid` (not a bare gen4 crc8), or every gen5 frame + // fails this guard and the reassembler never gets past resync. + if (_buf.length < headerLen || !profile.headerCrcValid(_buf)) { if (!resync()) break; continue; } if (_buf.length < total) break; // wait for the rest of this frame - final frame = parseFrame(Uint8List.fromList(_buf.sublist(0, total))); + final frame = + parseFrame(Uint8List.fromList(_buf.sublist(0, total)), profile: profile); if (frame != null) out.add(frame); _buf.removeRange(0, total); // skip inter-record null padding diff --git a/lib/src/gen5_records.dart b/lib/src/gen5_records.dart new file mode 100644 index 0000000..97a0054 --- /dev/null +++ b/lib/src/gen5_records.dart @@ -0,0 +1,766 @@ +// gen5_records.dart — WHOOP 5 (gen5 / "fd4b" / "Maverick-Goose") historical +// record decoders: v18 (per-second biometric summary), v20 (6-channel raw +// optical deep buffer), v21 (100Hz 6-axis IMU deep buffer), v26 (24Hz PPG +// waveform). +// +// REPLACES `records.dart`'s old `parseGen5Record` / `_gen5NormalHistoryVersions +// = {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), +// 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. +// +// v20 IS THE EXCEPTION to that "confirmed independently" claim — see the loud +// warning on Gen5V20Decoder/Gen5OpticalBuffer below before trusting it. +// +// KEY FACT this whole file leans on: gen5's INNER-relative field offsets are +// IDENTICAL to gen4's / to the frame-absolute offsets many sources quote, +// minus the header-length delta. Concretely: frame-absolute offset X on a +// gen5 frame == `inner[X - 8]` (gen5's header is 8 bytes; `inner` is what +// [Frame.inner] / `parseFrame` already hands you post header-strip). Every +// offset below is written as `frameAbsolute - 8` and cross-checked against a +// real, CRC-valid capture. +// +// PURE Dart — dart:typed_data only. + +import 'dart:typed_data'; + +import 'records.dart' show kMinRrMs, kMaxRrMs; + +ByteData _view(Uint8List b) => + b.buffer.asByteData(b.offsetInBytes, b.lengthInBytes); + +/// Round `v` to `decimals` places (JS `Math.round(v*p)/p` semantics, half +/// toward +Infinity). Non-finite input passed through unchanged — see +/// records.dart's `_jsRound` doc for why: folding NaN to 0.0 here would turn +/// "these bytes are not the field this map claims" into a fabricated reading. +double _round(double v, int decimals) { + if (!v.isFinite) return v; + double p = 1; + for (int i = 0; i < decimals; i++) { + p *= 10; + } + return ((v * p) + 0.5).floorToDouble() / p; +} + +// ── Shared historical-record header (§1.5's "shared v18/v20/v21/v26 header +// convention") — a cheap, version-byte-independent dispatch key. ─────────── + +/// The 11-byte header every gen5 historical record kind (v18/v20/v21/v26) +/// shares, at INNER offsets `[0:11)`: +/// ``` +/// inner[0] packet type (0x2F) +/// inner[1] hist_version (frame-abs 9) +/// inner[2] layout_marker (frame-abs 10) — raw; not deeply interpreted +/// inner[3:7] record_index u32 LE (frame-abs 11) — monotonic, not unix +/// inner[7:11] unix u32 LE (frame-abs 15) +/// ``` +class Gen5HistoricalHeader { + final int version; + final int layoutMarker; + final int recordIndex; + final int unix; + + const Gen5HistoricalHeader({ + required this.version, + required this.layoutMarker, + required this.recordIndex, + required this.unix, + }); + + static Gen5HistoricalHeader? tryParse(Uint8List inner) { + if (inner.length < 11) return null; + final v = _view(inner); + return Gen5HistoricalHeader( + version: inner[1], + layoutMarker: inner[2], + recordIndex: v.getUint32(3, Endian.little), + unix: v.getUint32(7, Endian.little), + ); + } +} + +/// Base type every decoded gen5 historical record kind extends. Callers that +/// don't care which kind they got can still read the shared header fields. +abstract class Gen5HistoricalRecord { + final int histVersion; + final int recordIndex; + final int unix; + + const Gen5HistoricalRecord({ + required this.histVersion, + required this.recordIndex, + required this.unix, + }); +} + +// ── v18 — per-second biometric summary (the gen5 analogue of gen4's v24; +// this is the record that actually ships as gen5's "1 Hz" history). ──────── + +/// 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. See PROTOCOL_FINDINGS / the multiband spec §1.7. +class Gen5HistorySample extends Gen5HistoricalRecord { + /// bpm. 0 is a legitimate reading (device warming up), not absence. + final int heartRate; + + /// Number of R-R intervals we ACCEPTED (see [rrIntervalsMs] doc) — always + /// `rrIntervalsMs.length`, never the raw declared count byte, mirroring + /// records.dart's R24 convention. + 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. + final int cardiacFlags; + + /// @ inner[28] (frame-abs 36). bit7 = HR/RR-valid this second (gates + /// whether [heartRateAlt] should be trusted); bit4 never observed set. + final int hrQualityFlags; + + /// Duplicate HR @ inner[29] (frame-abs 37), ~99.6% match to [heartRate] on + /// the reference corpus. Trust only when `hrQualityFlags & 0x80 != 0`. + final int heartRateAlt; + + /// @ inner[30:32] (frame-abs 38). Meaning UNPINNED — exposed raw, do not + /// consume as a decoded value yet. + final int rrPacked; + + /// @ inner[32] (frame-abs 40). DISPUTED: whoop-rs calls this + /// "signal_quality" and gates an HR-anomaly confidence check on `>=192`; + /// noop calls the SAME offset "cardiac_status" and treats it as + /// raw/uninterpreted. Neither claim is cross-validated against ground + /// truth — exposed raw ONLY. Do NOT wire an HR-anomaly gate off this byte + /// until validated against a labelled dataset. + final int cardiacStatusRaw; + + /// Gravity-removed motion magnitude (g) @ inner[33:37] f32 LE (frame-abs + /// 41). Gated finite ∈ [0, 8] at decode time (see [Gen5V18Decoder]). + final double dynamicAccelerationG; + + /// [x, y, z] (g) @ inner[37/41/45] f32 LE each (frame-abs 45/49/53). Gated + /// finite with magnitude ∈ [0.5, 1.5] g at decode time. + 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. + final int stepMotionCounter; + + /// Raw @ inner[51] (frame-abs 59). + final int stepCadence; + + /// RAW byte @ inner[55] (frame-abs 63). Only 0 (still) / 1 (walk) / 2 (run) + /// are valid activity-class codes — everything else (0xFF, 7, ...) is the + /// strap signaling "not classified", not a fourth activity. Kept as the raw + /// byte for diagnostics; use [activityClassKnown] for the honest, gated + /// value (never fabricate a class out of an invalid code). + final int activityClass; + + /// [activityClass] gated to the 3 known-valid codes, null otherwise — the + /// honest getter callers should actually use. + int? get activityClassKnown => + (activityClass == 0 || activityClass == 1 || activityClass == 2) + ? activityClass + : null; + + /// °C = raw/10. @ inner[61:63] i16 LE (frame-abs 69). + final double tempAux1C; + + /// °C = raw/10. @ inner[63:65] i16 LE (frame-abs 71). + final double tempAux2C; + + /// °C = raw/100 — a GEN5-SPECIFIC scale; do NOT reuse gen4's per-device + /// affine scale here. @ inner[65:67] u16 LE (frame-abs 73). Confirmed + /// worn≈30.6°C / off-wrist≈22.5°C on real captures. + final double skinTempC; + + /// Raw, not deep-sleep markers per noop. @ inner[67/69/71] u16 LE each + /// (frame-abs 75/77/79). + final int statusWord; + final int statusWord1; + final int statusWord2; + + /// Raw @ inner[73] (frame-abs 81). Packed: + /// bits 0-1: on-wrist + /// bits 2-3: wake_quality + /// bits 4-5: sleep_state — ORDERING UNRESOLVED (whoop-rs's doc comment + /// says "0 still/1 wake"; noop glosses the same shift "0 wake/1 still" + /// in one place. A real fixture's value here was 0, which is + /// ambiguous between both orderings and could not disambiguate this + /// from bytes alone.) Exposed as the raw byte ONLY — do not decode the + /// sleep_state nibble into an enum until a labelled-sleep capture + /// resolves the ordering; wiring the wrong ordering into a sleep + /// stager would silently corrupt every downstream sleep metric. + final int sleepStateByte; + + /// @ inner[74] (frame-abs 82). EXPERIMENTAL candidate SpO2% — noop treats + /// this as instrumentation-only with cross-device evidence split, never a + /// shipped metric (whoop-rs's `physio-algo` treats it as legitimate; noop's + /// caution is trusted here per §1.7 — larger, multi-strap corpus). Use + /// [spo2Candidate] for the gated (70-100) getter; never present this + /// alongside gen4's real red/IR SpO2 as an equivalent metric. + final int spo2CandidateRaw; + + /// @ inner[98] (frame-abs 106). Proven NOT the high half of a u16 with + /// [opticalBaselineB] (high byte steps without low-byte carry across + /// 18,599 corpus pairs) — kept as an independent byte, never fused. + final int opticalBaselineA; + + /// @ inner[99] (frame-abs 107). 0 = off-wrist. + final int opticalBaselineB; + + /// @ inner[100] (frame-abs 108). 128 on BOTH [opticalAmpA] and + /// [opticalAmpB] simultaneously is a signal-quality sentinel (never one + /// alone in the 757/757 reference corpus) — see [isOpticalAmpSentinel]. + final int opticalAmpA; + final int opticalAmpB; + + /// @ inner[105:109] f32 LE (frame-abs 113). Range ~-5.3..0 on the reference + /// corpus; purpose unknown. Exposed raw, don't consume. + final double unknownF32; + + const Gen5HistorySample({ + required super.histVersion, + required super.recordIndex, + required super.unix, + required this.heartRate, + required this.rrCount, + required this.rrIntervalsMs, + required this.cardiacFlags, + required this.hrQualityFlags, + required this.heartRateAlt, + required this.rrPacked, + required this.cardiacStatusRaw, + required this.dynamicAccelerationG, + required this.gravityG, + required this.stepMotionCounter, + required this.stepCadence, + required this.activityClass, + required this.tempAux1C, + required this.tempAux2C, + required this.skinTempC, + required this.statusWord, + required this.statusWord1, + required this.statusWord2, + required this.sleepStateByte, + required this.spo2CandidateRaw, + required this.opticalBaselineA, + required this.opticalBaselineB, + required this.opticalAmpA, + required this.opticalAmpB, + required this.unknownF32, + }); + + /// bit7 of [hrQualityFlags] — whether [heartRateAlt] is corroborated this + /// second. + bool get hrRrValidThisSecond => (hrQualityFlags & 0x80) != 0; + + /// [heartRateAlt] gated on [hrRrValidThisSecond]; null when unconfirmed. + int? get trustedHeartRateAlt => hrRrValidThisSecond ? heartRateAlt : null; + + /// EXPERIMENTAL candidate SpO2%, gated 70-100 per §1.7; null otherwise. This + /// is NOT gen4's real dual-wavelength SpO2 — never surface it as equivalent. + int? get spo2Candidate => (spo2CandidateRaw >= 70 && spo2CandidateRaw <= 100) + ? spo2CandidateRaw + : null; + + /// True when both optical-amp bytes read the 128 sentinel simultaneously — + /// per the reference corpus this means "signal quality flag", not a real + /// amplitude reading of 128 on each channel. + bool get isOpticalAmpSentinel => opticalAmpA == 128 && opticalAmpB == 128; + + /// bits 0-1 of [sleepStateByte] — the one sub-field of that byte NOT under + /// active ordering dispute. + int get onWristRaw => sleepStateByte & 0x03; + + /// bits 2-3 of [sleepStateByte]. + int get wakeQualityRaw => (sleepStateByte >> 2) & 0x03; + + /// bits 4-5 of [sleepStateByte] — RAW ONLY. See [sleepStateByte]'s doc for + /// why this is deliberately not decoded into a named sleep-state enum. + int get sleepStateRawNibble => (sleepStateByte >> 4) & 0x03; +} + +/// Minimum inner length to read every v18 field this decoder touches (the +/// last is [Gen5HistorySample.unknownF32], a f32 ending at inner byte 109). +/// Real captures are padded to a 4-byte boundary (109 → 112), so this is a +/// floor, not an exact match — unlike v20/v21 below, which DO have a fixed +/// exact size. +const int kGen5V18MinInnerLen = 109; + +class Gen5V18Decoder implements Gen5RecordDecoder { + const Gen5V18Decoder(); + + @override + String get name => 'gen5_v18'; + + @override + bool matches(Uint8List inner) => + inner.length >= kGen5V18MinInnerLen && inner[1] == 18; + + @override + Gen5HistorySample? decode(Uint8List inner) { + if (!matches(inner)) return null; + final hdr = Gen5HistoricalHeader.tryParse(inner); + if (hdr == null) return null; + final v = _view(inner); + + final hr = inner[14]; + if (hr != 0 && (hr < 25 || hr > 230)) return null; // implausible + + // R-R: up to 4 slots @ inner[16/18/20/22], declared count @ inner[15]. + // Same accept-only-plausible-values discipline as records.dart's R24. + final declaredRr = inner[15]; + final rr = []; + if (declaredRr <= 4) { + for (int i = 0; i < declaredRr && 16 + 2 * i + 2 <= inner.length; i++) { + final val = v.getInt16(16 + 2 * i, Endian.little); + if (val >= kMinRrMs && val <= kMaxRrMs) rr.add(val); + } + } + + final dynAccel = _round(v.getFloat32(33, Endian.little), 4); + if (!dynAccel.isFinite || dynAccel < 0 || dynAccel > 8) return null; + + final gx = _round(v.getFloat32(37, Endian.little), 4); + final gy = _round(v.getFloat32(41, Endian.little), 4); + final gz = _round(v.getFloat32(45, Endian.little), 4); + if (!gx.isFinite || !gy.isFinite || !gz.isFinite) return null; + final magSq = gx * gx + gy * gy + gz * gz; + if (magSq < 0.25 || magSq > 2.25) return null; // 0.5g..1.5g + + final unknownF32 = _round(v.getFloat32(105, Endian.little), 4); + + return Gen5HistorySample( + histVersion: hdr.version, + recordIndex: hdr.recordIndex, + unix: hdr.unix, + heartRate: hr, + rrCount: rr.length, + rrIntervalsMs: rr, + cardiacFlags: inner[25], + hrQualityFlags: inner[28], + heartRateAlt: inner[29], + rrPacked: v.getUint16(30, Endian.little), + cardiacStatusRaw: inner[32], + dynamicAccelerationG: dynAccel, + gravityG: [gx, gy, gz], + stepMotionCounter: v.getUint16(49, Endian.little), + stepCadence: inner[51], + activityClass: inner[55], + tempAux1C: _round(v.getInt16(61, Endian.little) / 10.0, 2), + tempAux2C: _round(v.getInt16(63, Endian.little) / 10.0, 2), + skinTempC: _round(v.getUint16(65, Endian.little) / 100.0, 2), + statusWord: v.getUint16(67, Endian.little), + statusWord1: v.getUint16(69, Endian.little), + statusWord2: v.getUint16(71, Endian.little), + sleepStateByte: inner[73], + spo2CandidateRaw: inner[74], + opticalBaselineA: inner[98], + opticalBaselineB: inner[99], + opticalAmpA: inner[100], + opticalAmpB: inner[101], + unknownF32: unknownF32, + ); + } +} + +// ── v20 — 6-channel raw optical deep buffer (R22 opt-in only). ───────────── + +/// One of the 5 fixed 422-byte blocks in a v20 buffer. Per the reference +/// corpus (29,203 records, both sources), only blocks 0/3/4 are ever +/// active (`sampleCount ∈ {0, 25}`); blocks 1/2 are always empty. Channel +/// role assignment ("red"/"ir"/"green") is EXPLICITLY UNPROVEN by both +/// reference sources — exposed as raw `channel0`/`channel1` samples only, +/// per noop's own naming discipline (they retired 'ppg_channel'-style names). +class Gen5OpticalBlock { + /// @ block byte 0. Shared by both channel slots. 0 or 25 in the reference + /// corpus; capped to the 50-slot capacity of a 200-byte/4-byte-sample slot. + final int activeSampleCount; + + /// Raw sign-extended 20-bit samples (returned as ints in [-524288, 524287]), + /// length == [activeSampleCount]. + final List channel0; + final List channel1; + + /// bytes[1:7] of the block — shared block metadata incl. a speculative + /// LED-current field. Not decoded further; kept for re-derivation. + final Uint8List sharedMetaRaw; + + /// bytes[7:14] — channel-0's metadata incl. a speculative offset-DAC field. + final Uint8List channel0MetaRaw; + + /// bytes[14:21] — channel-1's metadata. + final Uint8List channel1MetaRaw; + + const Gen5OpticalBlock({ + required this.activeSampleCount, + required this.channel0, + required this.channel1, + required this.sharedMetaRaw, + required this.channel0MetaRaw, + required this.channel1MetaRaw, + }); +} + +/// ⚠️ EXPERIMENTAL / UNVERIFIED LAYOUT — unlike v18/v21/v26, this record's +/// field layout is a genuine, UNRESOLVED disagreement between the two +/// reference implementations, and NEITHER has a real (non-synthetic) +/// hardware capture of a v20 record to break the tie: +/// - whoop-rs's model: 6 independent fixed-offset channels of 25 samples +/// each, at distinct inner offsets, gated on a green-LED-echo anchor. +/// - This decoder's model (below): 5 blocks of 422 bytes, each holding 2 +/// channels of up to 50 samples, gated on a per-block sample-count byte. +/// Cross-validating this decoder against whoop-rs's own synthetic test +/// fixture produces a syntactically-valid-looking but semantically wrong +/// result — silently, with no error. DO NOT treat [Gen5OpticalBuffer]'s +/// fields as trustworthy until a real captured v20 frame resolves which +/// model (if either) is correct. Callers should treat this as low-confidence +/// / diagnostic-only data, never feed it into a metric pipeline as ground +/// truth. +class Gen5OpticalBuffer extends Gen5HistoricalRecord { + final int layoutMarker; + + /// Always 5 entries (blocks 0-4), even the always-empty 1/2 slots — index + /// == block number, matching the reference corpus's `sampleCount` array + /// convention (`[25, 0, 0, 25, 25]`). + final List blocks; + + const Gen5OpticalBuffer({ + required super.histVersion, + required super.recordIndex, + required super.unix, + required this.layoutMarker, + required this.blocks, + }); +} + +/// Byte offset (inner-relative) where the 5×422-byte block body starts. +/// Frame-absolute 26 → inner 18 (26 - 8). +const int _kV20BodyStart = 18; +const int _kV20BlockLen = 422; +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). +const int kGen5V20InnerLen = + _kV20BodyStart + _kV20NumBlocks * _kV20BlockLen; // 2128 + +int _signExtend20(int raw20) { + final masked = raw20 & 0xFFFFF; + return (masked & 0x80000) != 0 ? masked - 0x100000 : masked; +} + +Gen5OpticalBlock _decodeOpticalBlock(Uint8List inner, int blockStart) { + final v = _view(inner); + final rawCount = inner[blockStart]; + final activeSampleCount = rawCount > 50 ? 0 : rawCount; // capacity guard + final ch0Start = blockStart + 21; + final ch1Start = ch0Start + 200; + + List readChannel(int start) { + final out = []; + for (int i = 0; i < activeSampleCount; i++) { + final off = start + 4 * i; + if (off + 4 > inner.length) break; + out.add(_signExtend20(v.getUint32(off, Endian.little))); + } + return out; + } + + return Gen5OpticalBlock( + activeSampleCount: activeSampleCount, + channel0: readChannel(ch0Start), + channel1: readChannel(ch1Start), + sharedMetaRaw: + Uint8List.fromList(inner.sublist(blockStart + 1, blockStart + 7)), + channel0MetaRaw: + Uint8List.fromList(inner.sublist(blockStart + 7, blockStart + 14)), + channel1MetaRaw: + Uint8List.fromList(inner.sublist(blockStart + 14, blockStart + 21)), + ); +} + +class Gen5V20Decoder implements Gen5RecordDecoder { + const Gen5V20Decoder(); + + @override + String get name => 'gen5_v20'; + + @override + bool matches(Uint8List inner) => + inner.length == kGen5V20InnerLen && inner[1] == 20; + + @override + Gen5OpticalBuffer? decode(Uint8List inner) { + if (!matches(inner)) return null; + final hdr = Gen5HistoricalHeader.tryParse(inner); + if (hdr == null) return null; + + final blocks = []; + for (int b = 0; b < _kV20NumBlocks; b++) { + final start = _kV20BodyStart + b * _kV20BlockLen; + blocks.add(_decodeOpticalBlock(inner, start)); + } + + return Gen5OpticalBuffer( + histVersion: hdr.version, + recordIndex: hdr.recordIndex, + unix: hdr.unix, + layoutMarker: hdr.layoutMarker, + blocks: blocks, + ); + } +} + +// ── 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 +/// verification (§1.5). The 100Hz sample rate is INFERRED from the sample +/// count only, never independently measured by either reference source — +/// treat the rate itself, not the samples, as unconfirmed. +class Gen5ImuBuffer extends Gen5HistoricalRecord { + final int layoutMarker; + + /// Must be 100 for a genuine v21 buffer — part of [Gen5V21Decoder.matches]'s + /// gate, since (per §1.5) this record kind "has no confirmed place in the + /// version-byte scheme" and is identified by shape, not `hist_version`. + final int countA; + final int countB; + + /// g, scale 1/4096 g/LSB. 100 samples each. + final List accelXg; + final List accelYg; + final List accelZg; + + /// deg/s, scale 2000/32768 dps/LSB (±2000 dps full-scale). 100 samples each. + final List gyroXdps; + final List gyroYdps; + final List gyroZdps; + + const Gen5ImuBuffer({ + required super.histVersion, + required super.recordIndex, + required super.unix, + required this.layoutMarker, + required this.countA, + required this.countB, + required this.accelXg, + required this.accelYg, + required this.accelZg, + required this.gyroXdps, + required this.gyroYdps, + required this.gyroZdps, + }); +} + +// Gyro full-scale ±2000dps over signed int16 → 2000/32768 deg/s per LSB. +// Identical constant to live.dart's `_gyroScale` (R10 IMU) — the scale is +// shared across every WHOOP gyro stream this package decodes. +const double _kGyroScaleDps = 2000.0 / 32768.0; +const double _kAccelScaleG = 1.0 / 4096.0; + +const int _kV21CountAOffset = 16; // frame-abs 24 +const int _kV21AxStart = 20; // frame-abs 28 +const int _kV21CountBOffset = 622; // frame-abs 630 +const int _kV21GxStart = 632; // frame-abs 640 +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 +/// count==100 gate — neither the length nor the counts depend on trusting +/// `hist_version` at all, matching how both reference repos actually +/// identify this buffer. +const int kGen5V21InnerLen = _kV21GxStart + 3 * 2 * _kV21SamplesPerAxis; // 1232 + +class Gen5V21Decoder implements Gen5RecordDecoder { + const Gen5V21Decoder(); + + @override + String get name => 'gen5_v21'; + + @override + bool matches(Uint8List inner) { + if (inner.length != kGen5V21InnerLen) return false; + final v = _view(inner); + final countA = v.getUint16(_kV21CountAOffset, Endian.little); + final countB = v.getUint16(_kV21CountBOffset, Endian.little); + return countA == 100 && countB == 100; + } + + @override + Gen5ImuBuffer? decode(Uint8List inner) { + if (!matches(inner)) return null; + final hdr = Gen5HistoricalHeader.tryParse(inner); + if (hdr == null) return null; + final v = _view(inner); + + List axis(int start, double scale) { + final out = []; + for (int i = 0; i < _kV21SamplesPerAxis; i++) { + out.add(v.getInt16(start + 2 * i, Endian.little) * scale); + } + return out; + } + + return Gen5ImuBuffer( + histVersion: hdr.version, + recordIndex: hdr.recordIndex, + unix: hdr.unix, + layoutMarker: hdr.layoutMarker, + countA: v.getUint16(_kV21CountAOffset, Endian.little), + countB: v.getUint16(_kV21CountBOffset, Endian.little), + accelXg: axis(_kV21AxStart, _kAccelScaleG), + accelYg: axis(_kV21AxStart + 200, _kAccelScaleG), + accelZg: axis(_kV21AxStart + 400, _kAccelScaleG), + gyroXdps: axis(_kV21GxStart, _kGyroScaleDps), + gyroYdps: axis(_kV21GxStart + 200, _kGyroScaleDps), + gyroZdps: axis(_kV21GxStart + 400, _kGyroScaleDps), + ); + } +} + +// ── v26 — 24Hz single-wavelength PPG waveform. ───────────────────────────── + +class Gen5PpgWaveform extends Gen5HistoricalRecord { + final int layoutMarker; + + /// Uninterpreted staging byte @ inner[11] (frame-abs 19). + final int rawByte19; + + /// Per-burst counter (NOT a channel/LED id — ranges past 26 in the + /// reference corpus). @ inner[13] (frame-abs 21). + final int burstIndex; + + /// Raw AC-coupled ADC samples, no physical unit. Always 24 in practice. + final List ppgWaveform; + + const Gen5PpgWaveform({ + required super.histVersion, + required super.recordIndex, + required super.unix, + required this.layoutMarker, + required this.rawByte19, + required this.burstIndex, + required this.ppgWaveform, + }); +} + +const int _kV26SampleCount = 24; +const int _kV26SamplesStart = 19; // frame-abs 27 + +/// Minimum inner length to read a full 24-sample waveform. v26's declared +/// length varies with the sample count in principle, but is always 24 in +/// practice — this is a floor, not the exact match v20/v21 use. +const int kGen5V26MinInnerLen = _kV26SamplesStart + 2 * _kV26SampleCount; // 67 + +class Gen5V26Decoder implements Gen5RecordDecoder { + const Gen5V26Decoder(); + + @override + String get name => 'gen5_v26'; + + @override + bool matches(Uint8List inner) => + inner.length >= kGen5V26MinInnerLen && inner[1] == 26; + + @override + Gen5PpgWaveform? decode(Uint8List inner) { + if (!matches(inner)) return null; + final hdr = Gen5HistoricalHeader.tryParse(inner); + if (hdr == null) return null; + final v = _view(inner); + + final samples = []; + for (int i = 0; i < _kV26SampleCount; i++) { + samples.add(v.getInt16(_kV26SamplesStart + 2 * i, Endian.little)); + } + + return Gen5PpgWaveform( + histVersion: hdr.version, + // v26 is the ONE exception to the shared header's u32 record_index: + // whoop-rs's ground truth (real captured frames) reads only a u16 here + // — inner[5:7] is a separate, distinct field, not the top half of a + // u32 counter. Reusing hdr.recordIndex (u32 @ inner[3:7]) inflates the + // counter ~500x on real captures. Confirmed by independent + // cross-validation against whoop-rs's real_frames.json fixtures. + recordIndex: v.getUint16(3, Endian.little), + unix: hdr.unix, + layoutMarker: hdr.layoutMarker, + rawByte19: inner[11], + burstIndex: inner[13], + ppgWaveform: samples, + ); + } +} + +// ── RecordDecoder interface + dispatch (§4's "Layer 2" recommendation). ──── +// +// 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. +// 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. +abstract class Gen5RecordDecoder { + String get name; + bool matches(Uint8List inner); + Gen5HistoricalRecord? decode(Uint8List inner); +} + +const Gen5V18Decoder _v18Decoder = Gen5V18Decoder(); +const Gen5V20Decoder _v20Decoder = Gen5V20Decoder(); +const Gen5V21Decoder _v21Decoder = Gen5V21Decoder(); +const Gen5V26Decoder _v26Decoder = Gen5V26Decoder(); + +/// Every gen5 historical-record decoder this package knows, in dispatch +/// order. v21 is checked FIRST (see [parseGen5Historical]) because it cannot +/// be trusted via `hist_version` at all; the others dispatch off the version +/// byte for speed once v21 is ruled out. +const List kGen5HistoricalDecoders = [ + _v21Decoder, + _v18Decoder, + _v20Decoder, + _v26Decoder, +]; + +/// Decode a gen5 historical record (`inner` starts at the packet-type byte +/// `0x2F`, exactly like [parseGen5Historical]'s gen4 sibling `parseR24`). +/// +/// Dispatch: v21 is tried FIRST via its own shape gate (paired 100-sample +/// counts) since it "has no confirmed place in the version-byte scheme" +/// (§1.5) — an exact-length v21 buffer whose counts both read 100 could in +/// principle collide with a mis-length-matching v18/v20/v26 record, but the +/// exact-length gate (1232 bytes) makes that practically impossible given +/// v18's much shorter length and v20's different exact length (2128). +/// Everything else dispatches off `inner[1]` (hist_version). +/// +/// Returns null for anything this package doesn't have a decoder for (e.g. an +/// unrecognised version, or a too-short/garbage frame) — the caller archives +/// those, exactly like `parseR24`'s null contract. +Gen5HistoricalRecord? parseGen5Historical(Uint8List inner) { + if (inner.length < 11) return null; // shorter than the shared header itself + + if (_v21Decoder.matches(inner)) return _v21Decoder.decode(inner); + + switch (inner[1]) { + case 18: + return _v18Decoder.decode(inner); + case 20: + return _v20Decoder.decode(inner); + case 26: + return _v26Decoder.decode(inner); + default: + return null; + } +} diff --git a/lib/src/records.dart b/lib/src/records.dart index 6e803c4..7f7e297 100644 --- a/lib/src/records.dart +++ b/lib/src/records.dart @@ -484,3 +484,25 @@ class FirmwareAwareR24Decoder { return null; // every strategy failed — caller archives as undecodable, as before. } } + +// ── gen5 (WHOOP 5) historical records ─────────────────────────────────────── +// +// 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 +// 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, +// which was itself the tell that the version set was wrong, not that gen5's +// 1 Hz record is "deliberately thin". +// +// The real decoders now live in gen5_records.dart: [parseGen5Historical] +// dispatches to the v18 (per-second biometric summary — the actual gen5 +// analogue of this file's R24/v24), v20 (optical deep buffer), v21 (IMU deep +// buffer), and v26 (PPG waveform) decoders. See that file for the full field +// maps and the byte-level verification behind them. diff --git a/test/gen5_historical_test.dart b/test/gen5_historical_test.dart new file mode 100644 index 0000000..a1d7c07 --- /dev/null +++ b/test/gen5_historical_test.dart @@ -0,0 +1,487 @@ +// gen5 (WHOOP 5) historical-record decoder tests — v18/v20/v21/v26. +// +// 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 +// 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. + +import 'dart:typed_data'; +import 'package:test/test.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +Uint8List hex(String s) { + final clean = s.replaceAll(' ', ''); + final out = Uint8List(clean.length ~/ 2); + for (int i = 0; i < out.length; i++) { + out[i] = int.parse(clean.substring(i * 2, i * 2 + 2), radix: 16); + } + return out; +} + +void main() { + group('parseGen5Historical — v18 (real fixture)', () { + // aa01740001003fb12f1280733d8401b69f266a66460066025a0265020000000000007b + // 0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000000000000000f7 + // 000901f10b0007010c020c00000000000000000000000000000000000000000000000 + // 100656f1e1e0000009d61a7c00000003e862817 + // A "worn" capture, unix=1780916150 — CRC16+CRC32 both verified. + final frame = hex( + 'aa01740001003fb12f1280733d8401b69f266a66460066025a0265020000000' + '000007b0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000' + '000000000000f7000901f10b0007010c020c000000000000000000000000000' + '00000000000000000000100656f1e1e0000009d61a7c00000003e862817', + ); + + late Gen5HistorySample sample; + + setUp(() { + final parsed = parseFrame(frame, profile: BandProfile.gen5)!; + expect(parsed.valid, isTrue, reason: 'both gen5 CRCs must check out'); + final r = parseGen5Historical(parsed.inner); + expect(r, isA()); + sample = r as Gen5HistorySample; + }); + + test('shared header', () { + expect(sample.histVersion, 18); + expect(sample.recordIndex, 25443699); + expect(sample.unix, 1780916150); + }); + + test('heart rate + RR', () { + expect(sample.heartRate, 102); + expect(sample.rrCount, 2); + expect(sample.rrIntervalsMs, [602, 613]); + }); + + test('quality flags + alt HR', () { + expect(sample.hrQualityFlags, 0x8D); + expect(sample.hrRrValidThisSecond, isTrue); // bit7 set + expect(sample.heartRateAlt, 101); + expect(sample.trustedHeartRateAlt, 101); + }); + + test('motion: gravity is unit magnitude, dynamic accel small', () { + expect(sample.gravityG[0], closeTo(-0.7252, 1e-3)); + expect(sample.gravityG[1], closeTo(0.4944, 1e-3)); + expect(sample.gravityG[2], closeTo(0.4969, 1e-3)); + final mag = sample.gravityG.map((g) => g * g).reduce((a, b) => a + b); + expect(mag, closeTo(1.0, 0.05)); + expect(sample.dynamicAccelerationG, closeTo(0.00916, 1e-3)); + }); + + test('steps + activity', () { + expect(sample.stepMotionCounter, 50); + expect(sample.stepCadence, 170); + expect(sample.activityClass, 0); // still + }); + + test('temperature (gen5-specific scales)', () { + expect(sample.tempAux1C, closeTo(24.7, 1e-6)); + expect(sample.tempAux2C, closeTo(26.5, 1e-6)); + expect(sample.skinTempC, closeTo(30.57, 1e-6)); + }); + + test('optical front-end', () { + expect(sample.opticalBaselineA, 101); + expect(sample.opticalBaselineB, 111); + expect(sample.opticalAmpA, 30); + expect(sample.opticalAmpB, 30); + expect(sample.isOpticalAmpSentinel, isFalse); // not the 128/128 sentinel + }); + + test('experimental fields exposed raw, not fabricated', () { + // cardiac_status / signal_quality (disputed offset semantics, §1.7): + // raw byte only, no anomaly gate wired off it. + expect(sample.cardiacStatusRaw, 255); + // spo2_candidate gated 70..100 — this fixture's raw byte is 0, so the + // gated getter must be null, never a fabricated "0%". + expect(sample.spo2CandidateRaw, 0); + expect(sample.spo2Candidate, isNull); + }); + }); + + group('parseGen5Historical — v26 (real fixture)', () { + // aa015000010035412f1a80ad418401f0a3266aae470100c3c5050068faccfa8dfb46fc + // 8bfd4cfebafedafe6dff56ffd5fffbff37ff6afce5f9d7f8dffa5efc98fddbfe5afe84f + // e15ff5cff405fb33c50080101006cb67c17 + // unix=1780917232 — CRC16+CRC32 both verified. + final frame = hex( + 'aa015000010035412f1a80ad418401f0a3266aae470100c3c5050068faccfa8dfb46f' + 'c8bfd4cfebafedafe6dff56ffd5fffbff37ff6afce5f9d7f8dffa5efc98fddbfe5afe8' + '4fe15ff5cff405fb33c50080101006cb67c17', + ); + + test( + 'decodes record_index as u16 (NOT u32 — cross-validated against ' + 'whoop-rs real_frames.json, where consecutive v26 frames give clean ' + 'consecutive u16 record_ids like 48077,48078,48079 — the u32 reading ' + 'jumps erratically because inner[5:7] is a distinct, unrelated field)', + () { + final parsed = parseFrame(frame, profile: BandProfile.gen5)!; + expect(parsed.valid, isTrue); + final r = parseGen5Historical(parsed.inner); + expect(r, isA()); + final wf = r as Gen5PpgWaveform; + expect(wf.histVersion, 26); + expect(wf.recordIndex, 16813); // u32 read would give 25444781 — wrong + expect(wf.unix, 1780917232); + expect(wf.rawByte19, 174); + expect(wf.burstIndex, 1); + expect(wf.ppgWaveform, [ + -1432, + -1332, + -1139, + -954, + -629, + -436, + -326, + -294, + -147, + -170, + -43, + -5, + -201, + -918, + -1563, + -1833, + -1313, + -930, + -616, + -293, + -422, + -380, + -235, + -164, + ]); + }); + + test( + 'record_index is a clean consecutive counter across real consecutive ' + 'frames (whoop-rs real_frames.json ppg_frames — the strongest ground ' + 'truth: three real captured frames one second apart give record_id ' + '48077,48078,48079; a u32 read of the same bytes would NOT be ' + 'consecutive since inner[5:7] is a distinct field)', () { + final hexes = [ + 'aa015000010035412f1a80cdbb7601e700556a33', + 'aa015000010035412f1a80cebb7601e800556a33', + 'aa015000010035412f1a80cfbb7601e900556a33', + ]; + final expectedIds = [48077, 48078, 48079]; + final expectedUnix = [1783955687, 1783955688, 1783955689]; + for (var i = 0; i < hexes.length; i++) { + // These fixtures are truncated (real-world capture excerpt, only the + // header + record_index/unix bytes) — long enough to exercise the + // shared header parse without needing the full 61-byte v26 payload. + final inner = hex(hexes[i]).sublist(8); // strip the 8-byte gen5 header + final hdr = Gen5HistoricalHeader.tryParse(inner); + expect(hdr, isNotNull); + expect(hdr!.version, 26); + // The shared header still reads the WRONG u32 value (inner[3:7]) — + // that's fine, only Gen5V26Decoder.decode() applies the v26-specific + // u16 correction. Confirm that distinction explicitly here. + expect(hdr.recordIndex, isNot(expectedIds[i])); + final u16RecordIndex = inner[3] | (inner[4] << 8); + expect(u16RecordIndex, expectedIds[i]); + expect(hdr.unix, expectedUnix[i]); + } + }); + }); + + group( + 'parseGen5Historical — v21 IMU deep buffer (structural, offsets from §1.5)', + () { + Uint8List buildV21({ + required int recordIndex, + required int unix, + List? accelXRaw, + }) { + final inner = Uint8List(kGen5V21InnerLen); + inner[0] = 0x2f; + inner[1] = 21; // hist_version (informational only — not the real gate) + inner[2] = 0x80; // layout_marker + final v = ByteData.sublistView(inner); + v.setUint32(3, recordIndex, Endian.little); + v.setUint32(7, unix, Endian.little); + v.setUint16(16, 100, Endian.little); // countA + v.setUint16(622, 100, Endian.little); // countB + final ax = accelXRaw ?? List.filled(100, 4096); // 4096/4096 = 1.0g + for (int i = 0; i < 100; i++) { + v.setInt16(20 + 2 * i, ax[i], Endian.little); // accelX + v.setInt16(220 + 2 * i, 0, Endian.little); // accelY + v.setInt16(420 + 2 * i, 0, Endian.little); // accelZ + v.setInt16(632 + 2 * i, 16384, Endian.little); // gyroX raw + v.setInt16(832 + 2 * i, 0, Endian.little); // gyroY + v.setInt16(1032 + 2 * i, 0, Endian.little); // gyroZ + } + return inner; + } + + test('is identified by shape (paired 100-sample counts), not hist_version', + () { + final inner = buildV21(recordIndex: 42, unix: 1780000000); + final r = parseGen5Historical(inner); + expect(r, isA()); + final imu = r as Gen5ImuBuffer; + expect(imu.recordIndex, 42); + expect(imu.unix, 1780000000); + expect(imu.countA, 100); + expect(imu.countB, 100); + expect(imu.accelXg.length, 100); + expect(imu.accelXg.first, closeTo(1.0, 1e-9)); // 4096 * 1/4096 + expect(imu.accelYg.first, 0.0); + expect(imu.gyroXdps.first, closeTo(16384 * (2000.0 / 32768.0), 1e-9)); + }); + + test('rejects a buffer whose counts are not both 100', () { + final inner = buildV21(recordIndex: 1, unix: 1780000000); + ByteData.sublistView(inner) + .setUint16(622, 99, Endian.little); // countB wrong + expect(parseGen5Historical(inner), isNull); + }); + + test( + 'an otherwise-v21-shaped buffer at the wrong length is not misidentified', + () { + final short = Uint8List(kGen5V21InnerLen - 1); + expect(const Gen5V21Decoder().matches(short), isFalse); + }); + }); + + group( + 'parseGen5Historical — v20 optical deep buffer (structural, offsets from §1.5)', + () { + Uint8List buildV20({required int recordIndex, required int unix}) { + final inner = Uint8List(kGen5V20InnerLen); + inner[0] = 0x2f; + inner[1] = 20; + inner[2] = 0x81; // layout_marker + final v = ByteData.sublistView(inner); + v.setUint32(3, recordIndex, Endian.little); + v.setUint32(7, unix, Endian.little); + // Block 0: active (25 samples), block 1/2 empty, block 3/4 active — + // matches the reference corpus's observed sampleCount pattern. + const blockLen = 422; + const bodyStart = 18; + final activeBlocks = {0, 3, 4}; + for (int b = 0; b < 5; b++) { + final start = bodyStart + b * blockLen; + final count = activeBlocks.contains(b) ? 25 : 0; + inner[start] = count; + final ch0 = start + 21; + final ch1 = ch0 + 200; + for (int i = 0; i < count; i++) { + // sample = block*1000 + channel*100 + i, sign-extended 20-bit safe range + v.setUint32(ch0 + 4 * i, b * 1000 + i, Endian.little); + v.setUint32(ch1 + 4 * i, b * 1000 + 100 + i, Endian.little); + } + } + return inner; + } + + test('decodes 5 blocks, only 0/3/4 active, raw channel samples verbatim', + () { + final inner = buildV20(recordIndex: 11494060, unix: 1784054004); + final r = parseGen5Historical(inner); + expect(r, isA()); + final buf = r as Gen5OpticalBuffer; + expect(buf.recordIndex, 11494060); + expect(buf.unix, 1784054004); + expect(buf.blocks.length, 5); + expect(buf.blocks[0].activeSampleCount, 25); + expect(buf.blocks[1].activeSampleCount, 0); + expect(buf.blocks[2].activeSampleCount, 0); + expect(buf.blocks[3].activeSampleCount, 25); + expect(buf.blocks[4].activeSampleCount, 25); + expect(buf.blocks[0].channel0, List.generate(25, (i) => i)); + expect(buf.blocks[0].channel1, List.generate(25, (i) => 100 + i)); + expect(buf.blocks[1].channel0, isEmpty); + }); + + test('a v20-length buffer with the wrong version byte is not matched', () { + final inner = buildV20(recordIndex: 1, unix: 1780000000); + inner[1] = 99; + 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 + // gen5_records.dart's derivation from the frame-absolute offsets + // (47,247,1313,1513,1735,1935) noop states directly. + const bodyStart = 18, blockLen = 422; + int ch0(int b) => bodyStart + b * blockLen + 21; + int ch1(int b) => ch0(b) + 200; + expect(ch0(0), 39); + expect(ch1(0), 239); + expect(ch0(3), 1305); + expect(ch1(3), 1505); + expect(ch0(4), 1727); + expect(ch1(4), 1927); + }); + }); + + group( + 'gen5 REALTIME_DATA (0x28) — proves the inner-relative offsets are gen-agnostic', + () { + test( + 'the real byte-verified fixture decodes via the existing parseRealtimeHr', + () { + // aa011800010022e128029ea0266aae4762025b024b020000000001005ed515dc + final frame = hex( + 'aa011800010022e128029ea0266aae4762025b024b020000000001005ed515dc'); + final parsed = parseFrame(frame, profile: BandProfile.gen5)!; + expect(parsed.valid, isTrue); + final r = parseRealtimeHr(parsed.inner)!; + expect(r.tsRaw, 1780916382); + expect(r.hrBpm, 98); + expect(r.rrMs, [603, 587]); + }); + }); + + group('gen5 METADATA HISTORY_END (0x31) — offset audit', () { + test( + 'the real byte-verified fixture decodes meta_type/unix correctly (no gen5-specific code needed)', + () { + // aa011c00010023d1319102b949596a705d3b000000fdba010010000000000000f269faec + final frame = hex( + 'aa011c00010023d1319102b949596a705d3b000000fdba010010000000000000f269faec'); + final parsed = parseFrame(frame, profile: BandProfile.gen5)!; + expect(parsed.valid, isTrue); + final m = parseMetadata(parsed.inner)!; + expect(m.sub, 2); + expect(m.name, 'HISTORY_END'); + // The independently-quoted "trim_cursor=113405" value falls out of this + // decoder's EXISTING `token` field's first 4 bytes — no gen5-specific + // offset change was needed. See control.dart's parseMetadata doc. + expect(m.token, isNotNull); + final trimCursor = + ByteData.sublistView(m.token!).getUint32(0, Endian.little); + expect(trimCursor, 113405); + }); + }); + + group('gen5 COMMAND_RESPONSE — battery scale + GET_HELLO', () { + test('gen5 GET_BATTERY_LEVEL is direct-percent, not deci-percent', () { + // inner = [0x24][seq][0x1A][47%] — gen5's direct-percent form. + final inner = Uint8List(6); + inner[0] = PacketType.commandResponse; + inner[1] = 0; + inner[2] = Cmd.getBatteryLevel; + inner[3] = 0; + inner[4] = 0; + inner[5] = 47; + final r = parseCommandResponse(inner, profile: BandProfile.gen5)!; + expect(r.decoded['battery_pct'], 47.0); + }); + + test('the same bytes read as gen4 would misinterpret as deci-percent', () { + final inner = Uint8List(7); + inner[0] = PacketType.commandResponse; + inner[2] = Cmd.getBatteryLevel; + inner[5] = 10; + inner[6] = 0; // u16 LE @5 = 10 → 1.0% under gen4's /10 scale + final r4 = parseCommandResponse(inner)!; + expect(r4.decoded['battery_pct'], 1.0); + }); + + test('gen5 GET_HELLO (0x91) decodes device_name + gated fw_version', () { + final inner = Uint8List(120); + inner[0] = PacketType.commandResponse; + inner[1] = 0; + inner[2] = Cmd.getHello; + final payload = Uint8List.sublistView(inner, 3); + // device_name @ pay[16] + final name = 'MyStrap'; + for (int i = 0; i < name.length; i++) { + payload[16 + i] = name.codeUnitAt(i); + } + // fw_version @ pay[93:97], gated on pay[93]==50 + payload[93] = 50; + payload[94] = 38; + payload[95] = 1; + payload[96] = 0; + final r = parseCommandResponse(inner, profile: BandProfile.gen5)!; + expect(r.decoded['device_name'], 'MyStrap'); + expect(r.decoded['fw_version'], Uint8List.fromList([50, 38, 1, 0])); + }); + + test('gen5 GET_HELLO omits fw_version when the gate byte does not match', + () { + final inner = Uint8List(120); + inner[0] = PacketType.commandResponse; + inner[2] = Cmd.getHello; + inner[3 + 93] = 99; // not 50 + final r = parseCommandResponse(inner, profile: BandProfile.gen5)!; + expect(r.decoded.containsKey('fw_version'), isFalse); + }); + }); + + group('CONSOLE_LOGS (0x32) decoder', () { + Uint8List buildConsoleLog(int recordIndex, int unix, String text) { + final textBytes = text.codeUnits; + final inner = Uint8List(13 + textBytes.length); + inner[0] = PacketType.consoleLogs; + ByteData.sublistView(inner).setUint16(1, recordIndex, Endian.little); + ByteData.sublistView(inner).setUint32(4, unix, Endian.little); + ByteData.sublistView(inner).setUint16(8, 0, Endian.little); // subsec + ByteData.sublistView(inner) + .setUint16(10, textBytes.length, Endian.little); // chunk_len + inner[12] = 1; // channel + inner.setRange(13, 13 + textBytes.length, textBytes); + return inner; + } + + test('decodes record_index/unix/channel/text', () { + final inner = buildConsoleLog(100, 1780000000, + '146552119: BLE_CMD: Command Send Historical Data\n'); + final c = parseConsoleLog(inner)!; + expect(c.recordIndex, 100); + expect(c.unix, 1780000000); + expect(c.channel, 1); + expect(c.text, '146552119: BLE_CMD: Command Send Historical Data\n'); + }); + + test('trims only a TRAILING NUL run, preserving embedded NULs', () { + final inner = buildConsoleLog(1, 1, 'ab\x00cd\x00\x00\x00'); + final c = parseConsoleLog(inner)!; + expect(c.text, 'ab\x00cd'); // trailing NULs gone, embedded one kept + }); + + test( + 'ConsoleLogReassembler joins contiguous record_index chunks and flushes on a gap', + () { + final r = ConsoleLogReassembler(); + expect(r.add(parseConsoleLog(buildConsoleLog(1, 1, 'hello '))!), + isFalse); // first chunk + expect(r.add(parseConsoleLog(buildConsoleLog(2, 1, 'world'))!), + isTrue); // contiguous + expect(r.flush(), 'hello world'); + // A gap (index jumps from 2 to 2 again, i.e. non-contiguous) starts a + // fresh run rather than splicing. + expect(r.add(parseConsoleLog(buildConsoleLog(2, 1, 'x'))!), isFalse); + expect(r.add(parseConsoleLog(buildConsoleLog(3, 1, 'y'))!), isTrue); + expect(r.flush(), 'xy'); + }); + }); + + group('decodeFrame dispatch — gen5 historical routing', () { + test('a gen5 v18 frame dispatches to gen5_historical via BandProfile.gen5', + () { + final frame = hex( + 'aa01740001003fb12f1280733d8401b69f266a66460066025a0265020000000' + '000007b0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000' + '000000000000f7000901f10b0007010c020c000000000000000000000000000' + '00000000000000000000100656f1e1e0000009d61a7c00000003e862817', + ); + final parsed = parseFrame(frame, profile: BandProfile.gen5)!; + final d = decodeFrame(parsed, profile: BandProfile.gen5); + expect(d.kind, 'gen5_historical'); + expect(d.fields['hist_version'], 18); + expect(d.fields['ts_epoch'], 1780916150); + }); + }); +} diff --git a/test/gen5_test.dart b/test/gen5_test.dart new file mode 100644 index 0000000..14fc562 --- /dev/null +++ b/test/gen5_test.dart @@ -0,0 +1,259 @@ +// gen5 (WHOOP 5 / "fd4b") multi-band framing + command tests. +// +// The framing/CRC/BandProfile tests below are unchanged in spirit from the +// original file. The historical-record decoders (v18/v20/v21/v26) moved to +// their own golden-fixture suite: gen5_historical_test.dart. + +import 'dart:typed_data'; +import 'package:test/test.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +Uint8List hex(String s) { + final clean = s.replaceAll(' ', ''); + final out = Uint8List(clean.length ~/ 2); + for (int i = 0; i < out.length; i++) { + out[i] = int.parse(clean.substring(i * 2, i * 2 + 2), radix: 16); + } + return out; +} + +void main() { + group('crc16Modbus', () { + test('gen5 hello header → 0x71E6', () { + // header bytes aa 01 08 00 00 01 → crc16-modbus 0x71E6 (LE e6 71). + expect(crc16Modbus([0xaa, 0x01, 0x08, 0x00, 0x00, 0x01]), 0x71E6); + }); + test('empty input is the init value', () { + expect(crc16Modbus(const []), 0xFFFF); + }); + }); + + group('BandProfile', () { + test('gen4/gen5 header shapes', () { + expect(BandProfile.gen4.headerLen, 4); + expect(BandProfile.gen4.sizeFieldOffset, 1); + expect(BandProfile.gen5.headerLen, 8); + expect(BandProfile.gen5.sizeFieldOffset, 2); + expect(BandProfile.of(DeviceType.gen5).isGen5, isTrue); + expect(BandProfile.of(DeviceType.gen4).isGen5, isFalse); + }); + test('GATT prefixes differ, low nibble shared', () { + expect(GattProfile.gen4.servicePrefix, '61080001'); + expect(GattProfile.gen5.servicePrefix, 'fd4b0001'); + expect(GattProfile.gen5.cmdTo.startsWith('fd4b0002'), isTrue); + expect(GattProfile.gen5.data.startsWith('fd4b0005'), isTrue); + }); + test( + 'direction markers: outbound COMMAND is [0x00,0x01], never gates inbound', + () { + // §1.1a — byte-verified against 8 real fixtures. gen4 has no such field. + expect(BandProfile.gen4.outboundDirectionMarker, isNull); + expect(BandProfile.gen5.outboundDirectionMarker, [0x00, 0x01]); + expect(BandProfile.gen5.inboundDirectionMarker, [0x01, 0x00]); + }); + }); + + group('gen5 client HELLO', () { + test('reproduces the canonical 16-byte frame byte-for-byte', () { + // Canonical gen5 CLIENT_HELLO (GET_HELLO 0x91) — independently + // byte-verified (CRC16 + CRC32 both check out). + final expected = hex('aa0108000001e67123019101363e5c8d'); + expect(gen5ClientHello(), expected); + }); + }); + + group('gen5 framing round-trip', () { + test('buildFrame(gen5) → parseFrame(gen5) preserves inner + both CRCs', () { + final inner = [0x2f, 24, 0, 1, 0, 0, 0, 0xaa, 0xbb, 0, 0, 0, 0, 55]; + final frame = buildFrame(inner, profile: BandProfile.gen5); + expect(frame[0], 0xAA); + expect(frame[1], 0x01); // gen5 fixed header byte + final parsed = parseFrame(frame, profile: BandProfile.gen5)!; + expect(parsed.headerCrcOk, isTrue); + expect(parsed.crc32Ok, isTrue); + expect(parsed.valid, isTrue); + // inner is padded to /4; the leading bytes must survive verbatim. + expect(parsed.inner.sublist(0, inner.length), Uint8List.fromList(inner)); + }); + + test('a corrupted gen5 header CRC is flagged', () { + final frame = + buildFrame(const [0x2f, 24, 0, 1], profile: BandProfile.gen5); + frame[6] ^= 0xFF; // trash the crc16 low byte + final parsed = parseFrame(frame, profile: BandProfile.gen5)!; + expect(parsed.headerCrcOk, isFalse); + }); + + test( + 'a real inbound (strap→host) frame parses despite [0x01,0x00] header bytes', + () { + // §1.1a: buildHeader always stamps the OUTBOUND marker [0x00,0x01], but + // real strap→host frames carry [0x01,0x00] instead. Nothing in + // parseFrame/headerCrcValid gates on this — it must decode regardless. + final realtimeFixture = hex( + 'aa011800010022e128029ea0266aae4762025b024b020000000001005ed515dc'); + expect(realtimeFixture[4], 0x01); + expect(realtimeFixture[5], 0x00); + final parsed = parseFrame(realtimeFixture, profile: BandProfile.gen5)!; + expect(parsed.valid, isTrue); + }); + }); + + group('gen4 regression (default profile unchanged)', () { + test('default buildFrame is still the 4-byte gen4 envelope', () { + final a = buildFrame(const [0x23, 0, 0x0b]); + final b = buildFrame(const [0x23, 0, 0x0b], profile: BandProfile.gen4); + expect(a, b); + expect(a[0], 0xAA); + final parsed = parseFrame(a)!; // default gen4 + expect(parsed.valid, isTrue); + }); + }); + + group('FrameReassembler(gen5)', () { + test('carves two concatenated gen5 frames + waits for a partial', () { + final f1 = buildFrame(const [0x2f, 24, 0, 1, 0, 0, 0], + profile: BandProfile.gen5); + final f2 = buildFrame(const [0x2f, 24, 0, 2, 0, 0, 0], + profile: BandProfile.gen5); + final ra = FrameReassembler(profile: BandProfile.gen5); + final combined = [...f1, ...f2.sublist(0, 5)]; // f2 arrives partial + final got = ra.feed(combined); + expect(got.length, 1); // only f1 is complete + expect(got.first.valid, isTrue); + final rest = ra.feed(f2.sublist(5)); // deliver the remainder + expect(rest.length, 1); + expect(rest.first.valid, isTrue); + }); + }); + + group('gen5 history ACK (safe-trim token echo)', () { + test( + 'buildHistoryResultOk(gen5) is a valid frame echoing the verbatim token', + () { + final token = [0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04]; + final ack = buildHistoryResultOk(7, token, profile: BandProfile.gen5); + final parsed = parseFrame(ack, profile: BandProfile.gen5)!; + expect(parsed.valid, isTrue); // both gen5 CRCs must check out + // inner = [0x23 COMMAND][seq][0x17 HISTORICAL_DATA_RESULT][0x01][token…] + expect(parsed.inner[2], 0x17); + expect(parsed.inner[3], 0x01); + expect(parsed.inner.sublist(4, 12), Uint8List.fromList(token)); + }); + test('rejects a non-8-byte token', () { + expect( + () => + buildHistoryResultOk(1, const [0, 0, 0], profile: BandProfile.gen5), + throwsArgumentError, + ); + }); + test('reproduces the real byte-verified ACK frame', () { + // aa0110000001e0d12300170141b6010010000000667da4fb — CRC16+CRC32 both + // independently verified. inner = [0x23][seq=0][0x17][0x01] + 8B token. + final token = hex('41b6010010000000'); + final ack = buildHistoryResultOk(0, token, profile: BandProfile.gen5); + expect(ack, hex('aa0110000001e0d12300170141b6010010000000667da4fb')); + }); + }); + + group('gen5 R22 SET_CONFIG builder', () { + test('cmdSetConfigGen5 produces the verified 44-byte inner shape', () { + final frame = cmdSetConfigGen5(1, 'enable_r22_packets', '2'); + final parsed = parseFrame(frame, profile: BandProfile.gen5)!; + expect(parsed.valid, isTrue); + final inner = parsed.inner; + expect(inner[0], 0x23); // COMMAND + expect(inner[1], 1); // seq + expect(inner[2], 120); // SET_FF_VALUE / SET_CONFIG + expect(inner[3], 1); // fixed 4th byte + final nameBytes = inner.sublist(4, 36); + final nul = nameBytes.indexOf(0); + final name = String.fromCharCodes(nameBytes.sublist(0, nul)); + expect(name, 'enable_r22_packets'); + expect(inner[36], '2'.codeUnitAt(0)); // value byte + expect(inner.sublist(37, 44), Uint8List(7)); // 7 zero bytes + }); + + test('rejects an over-length name / non-ASCII / multi-char value', () { + expect(() => cmdSetConfigGen5(1, 'x' * 32, '2'), throwsArgumentError); + expect(() => cmdSetConfigGen5(1, 'ok', '22'), throwsArgumentError); + }); + + test( + 'buildR22EnableSequence builds all 16 flags, in order, with sequential seq', + () { + final frames = buildR22EnableSequence(startSeq: 1); + expect(frames.length, 16); + expect(kGen5R22EnableFlags.length, 16); + // Spot-check ordering-sensitive entries (issue #423's corrected value). + expect(kGen5R22EnableFlags[0], ('enable_r22_packets', '2')); + expect(kGen5R22EnableFlags[3], ('enable_r22_v4_packets', '1')); + expect(kGen5R22EnableFlags[15], ('enable_sig12', '1')); + for (int i = 0; i < frames.length; i++) { + final parsed = parseFrame(frames[i], profile: BandProfile.gen5)!; + expect(parsed.valid, isTrue); + expect(parsed.inner[1], 1 + i); // sequential seq + expect(parsed.inner[2], 120); + } + }); + }); + + group('gen5 Maverick haptics + clock', () { + test('cmdBuzzGen5Maverick builds the verified 12-byte payload', () { + final frame = cmdBuzzGen5Maverick(1, overallLoop: 7); + final parsed = parseFrame(frame, profile: BandProfile.gen5)!; + expect(parsed.valid, isTrue); + expect(parsed.inner[2], 0x13); // RUN_HAPTIC_PATTERN_MAVERICK + // sublist(3, 15): the payload is 12 bytes; inner is /4-padded to 16, so + // stop before the trailing pad byte. + expect(parsed.inner.sublist(3, 15), + [0x01, 47, 152, 0, 0, 0, 0, 0, 0, 0, 0, 7]); + }); + + test( + 'cmdSetClockGen5 / cmdGetClockGen5 use the gen5-exclusive opcode values', + () { + final setFrame = cmdSetClockGen5(1, now: DateTime.utc(2026, 1, 1)); + final setParsed = parseFrame(setFrame, profile: BandProfile.gen5)!; + expect(setParsed.valid, isTrue); + expect(setParsed.inner[2], Cmd.setClockMaverick); + expect(Cmd.setClockMaverick, 146); + + final getFrame = cmdGetClockGen5(1); + final getParsed = parseFrame(getFrame, profile: BandProfile.gen5)!; + expect(getParsed.valid, isTrue); + expect(getParsed.inner[2], Cmd.getClockGen5); + expect(Cmd.getClockGen5, 147); + }); + }); + + group('OpcodeSafety', () { + test('classifies the whoop-rs forbidden/destructive lists', () { + expect(OpcodeSafety.isForbidden(Cmd.setClockMaverick), isTrue); // 146 + expect(OpcodeSafety.isForbidden(Cmd.forceTrim), isTrue); // 25 + expect(OpcodeSafety.isDestructive(Cmd.forceTrim), isTrue); + expect(OpcodeSafety.isForbidden(Cmd.setFfValue), isTrue); // 120 — see doc + expect(OpcodeSafety.isDestructive(Cmd.setFfValue), isFalse); + expect(OpcodeSafety.isForbidden(Cmd.getBatteryLevel), isFalse); + }); + }); + + group('gen5 EVENT vocabulary', () { + test('BLE_REALTIME_HR_ON/OFF are new gen5 event ids', () { + expect(EventId.name(EventId.bleRealtimeHrOn), 'BLE_REALTIME_HR_ON'); + expect(EventId.name(EventId.bleRealtimeHrOff), 'BLE_REALTIME_HR_OFF'); + expect(EventId.bleRealtimeHrOn, 33); + expect(EventId.bleRealtimeHrOff, 34); + }); + + test( + 'an unknown event id renders raw and never borrows a Cmd name for the same number', + () { + // 123 = Cmd.selectWrist (0x7B) as a COMMAND opcode — a real, documented + // numeric collision. EventId must never reuse that name for event 123. + expect(Cmd.selectWrist, 123); + expect(EventId.name(123), 'EVENT_123'); + expect(EventId.name(123), isNot(contains('WRIST'))); + }); + }); +}