diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 6e659a5..c96289e 100644 --- a/lib/openstrap_protocol.dart +++ b/lib/openstrap_protocol.dart @@ -6,9 +6,12 @@ /// 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; + show R24, parseR24, parseGen5Record, FirmwareAwareR24Decoder, R24DecodeStrategy; export 'src/live.dart' show DecodedSample, @@ -23,7 +26,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 +62,10 @@ export 'src/commands.dart' cmdSetAlarmSimple, cmdRunAlarm, cmdDisableAlarm, - kDefaultAlarmHaptics; + kDefaultAlarmHaptics, + gen5ClientHello, + cmdGetDataRangeGen5, + cmdSendHistoricalGen5; // Control-plane parsers (HELLO / EVENT / METADATA / COMMAND_RESPONSE / dispatch). export 'src/control.dart' diff --git a/lib/src/band.dart b/lib/src/band.dart new file mode 100644 index 0000000..4dac0a0 --- /dev/null +++ b/lib/src/band.dart @@ -0,0 +1,143 @@ +// 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; + + const BandProfile._(this.type, this.headerLen, this.sizeFieldOffset); + + static const BandProfile gen4 = BandProfile._(DeviceType.gen4, 4, 1); + static const BandProfile gen5 = BandProfile._(DeviceType.gen5, 8, 2); + + 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][0x00][0x01][crc16modbus LE]` + 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; + h[4] = 0x00; + h[5] = 0x01; + 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 6b2587f..16f3ffb 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,25 @@ 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 +44,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). @@ -292,3 +299,32 @@ Uint8List cmdDisableAlarm(int seq, {int revision = 1}) { final p = revision == 2 ? const [0x02, 0xFF] : [revision & 0xff]; return buildCommand(seq, Cmd.disableAlarm, p); } + +// ── 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); 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 d360ca9..807d1b9 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,36 @@ 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; + + FrameReassembler({this.profile = BandProfile.gen4}); List feed(List chunk) { final out = []; @@ -100,20 +115,22 @@ 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; } 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/records.dart b/lib/src/records.dart index 3d1123b..1b74cd5 100644 --- a/lib/src/records.dart +++ b/lib/src/records.dart @@ -400,3 +400,65 @@ class FirmwareAwareR24Decoder { return null; // every strategy failed — caller archives as undecodable, as before. } } + +/// gen5 (WHOOP 5) normal-history record versions carrying a 1 Hz HR marker at +/// inner[17]. Empirically, real WHOOP 5.0 ("Goose") firmware serves K24 for its +/// per-second history; K9/K12 share the same thin layout / HR offset. +const Set _gen5NormalHistoryVersions = {9, 12, 24}; + +/// Decode a WHOOP 5 (gen5) historical 1 Hz record. `inner` starts at the +/// packet-type byte (0x2F), exactly like [parseR24] — the frame layer has +/// already stripped the (8-byte) gen5 header, so the INNER offsets here are the +/// same coordinates gen4 uses. +/// +/// gen5's per-second record (K24 family) is DELIBERATELY THIN versus gen4's +/// rich R24: on validated owned captures, ONLY HR rides the 1 Hz cadence. +/// Everything else gen4 packed into R24 is either absent or lives elsewhere: +/// +/// • HR — inner[17], direct bpm, gated 25..230 (0 kept: warming device) +/// • accelG — EMPTY. No 1 Hz accel on gen5 — motion arrives on separate +/// K10/K21 streams whose accel/gyro offsets are IDENTICAL to +/// gen4's live R10 (decode via [decodeR10Imu]). +/// • RR — EMPTY. Real captures carry no RR here; gen5 sources beat +/// timing from the R17 optical stream, not the 1 Hz record. +/// A speculative RR decode here would poison HRV, so we don't. +/// • skinTemp — EMPTY. Temperature is NOT in the 1 Hz record: it arrives via +/// the TEMPERATURE_LEVEL event (id 17). (An earlier guess of a +/// u16 at inner[16] was proven to be HR<<8 aliasing, not a real +/// reading — decoding it would emit a fake temp that tracks HR.) +/// • SpO2 — EMPTY. Not exposed at any offset we have validated. +/// +/// Running gen5 bytes through gen4's v24 optical/accel map reads all-zero +/// garbage on real captures, so we decode ONLY the confirmed HR and leave the +/// rest empty (honest-null, never fabricated). +/// +/// Returns null for non-normal-history versions (e.g. K10/K21 motion) or records +/// too short to carry the HR byte — the caller archives those to `raw_archive`. +R24? parseGen5Record(Uint8List inner) { + if (inner.length < 18) return null; + final version = inner[1]; + if (!_gen5NormalHistoryVersions.contains(version)) return null; + + final view = _view(inner); + final hr = inner[17]; + if (hr != 0 && (hr < 25 || hr > 230)) return null; // implausible → archive + + return R24( + histVersion: version, + tsEpoch: view.getUint32(7, Endian.little), + tsSubsec: view.getUint16(11, Endian.little), + counter: view.getUint32(3, Endian.little), + hr: hr, + rrCount: 0, + rrIntervalsMs: const [], + ppgGreen: 0, + ppgRedIr: 0, + accelG: const [], // no 1 Hz accel on gen5 + skinContact: 0, + spo2RedRaw: 0, // not exposed at a validated offset yet + spo2IrRaw: 0, + skinTempRaw: 0, // gen5 temp arrives via TEMPERATURE_LEVEL event, not here + ambientRaw: 0, + rawTail: _hexFrom(inner, 13), + ); +} diff --git a/test/gen5_test.dart b/test/gen5_test.dart new file mode 100644 index 0000000..35ae028 --- /dev/null +++ b/test/gen5_test.dart @@ -0,0 +1,167 @@ +// gen5 (WHOOP 5 / "fd4b") multi-band framing + record tests. +// +// Test vectors are synthetic or self-derived: the canonical gen5 client-HELLO +// frame is the value independently documented in our own PROTOCOL_FINDINGS.md, +// and the record vectors are hand-built to exercise the confirmed offsets. + +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); + }); + }); + + group('gen5 client HELLO', () { + test('reproduces the canonical 16-byte frame byte-for-byte', () { + // Canonical gen5 CLIENT_HELLO (GET_HELLO 0x91), per PROTOCOL_FINDINGS.md. + // Asserting equality validates crc16-modbus (header) + crc32 (payload) + + // the gen5 header layout all at once. + 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); + }); + }); + + 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, + ); + }); + }); + + group('parseGen5Record (thin K24)', () { + Uint8List k24({required int version, required int hr, int counter = 7, int ts = 1779000000}) { + final inner = Uint8List(80); + inner[0] = 0x2f; + inner[1] = version; + inner[2] = 0; + final v = ByteData.sublistView(inner); + v.setUint32(3, counter, Endian.little); + v.setUint32(7, ts, Endian.little); + inner[17] = hr; + return inner; + } + + test('decodes HR + timing, leaves everything else empty', () { + final r = parseGen5Record(k24(version: 24, hr: 51))!; + expect(r.histVersion, 24); + expect(r.hr, 51); + expect(r.counter, 7); + expect(r.tsEpoch, 1779000000); + expect(r.accelG, isEmpty); // no 1 Hz accel on gen5 + expect(r.rrIntervalsMs, isEmpty); // RR not sourced from K24 + expect(r.skinTempRaw, 0); // temp comes from the TEMPERATURE_LEVEL event + expect(r.spo2RedRaw, 0); + }); + + test('keeps a warming-device HR of 0', () { + expect(parseGen5Record(k24(version: 24, hr: 0))!.hr, 0); + }); + + test('rejects an implausible HR (→ archived by caller)', () { + expect(parseGen5Record(k24(version: 24, hr: 240)), isNull); + }); + + test('returns null for a non-normal-history version (e.g. K10 motion)', () { + expect(parseGen5Record(k24(version: 10, hr: 60)), isNull); + }); + + test('accepts the K9/K12 thin-history variants', () { + expect(parseGen5Record(k24(version: 9, hr: 60))!.hr, 60); + expect(parseGen5Record(k24(version: 12, hr: 60))!.hr, 60); + }); + }); +}