From b4384588d1124132033d00975993ac5b56cbe642 Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Fri, 21 Aug 2026 12:31:11 +0200 Subject: [PATCH 1/2] gen5 vocabulary: full hello map, control plane, command surface, v18/v20/v22/v26 field maps --- lib/openstrap_protocol.dart | 51 +- lib/src/commands.dart | 281 +++++++- lib/src/constants.dart | 182 ++++- lib/src/control.dart | 416 +++++++++++- lib/src/gen5_records.dart | 1276 ++++++++++++++++++++++++++++++----- 5 files changed, 1951 insertions(+), 255 deletions(-) diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index d53aae0..035e558 100644 --- a/lib/openstrap_protocol.dart +++ b/lib/openstrap_protocol.dart @@ -20,7 +20,7 @@ export 'src/records.dart' // caller routes on the real set instead of keeping its own copy, which // silently rots the day this one grows. kKnownRecordVersions; -// gen5 historical-record decoders (v18/v20/v21/v26) — see gen5_records.dart +// gen5 historical-record decoders (v18/v20/v21/v22/v26) — see gen5_records.dart // for why these replace the old, wrong parseGen5Record/{9,12,24} set. export 'src/gen5_records.dart' show @@ -30,14 +30,20 @@ export 'src/gen5_records.dart' Gen5OpticalBlock, Gen5OpticalBuffer, Gen5ImuBuffer, + Gen5PpgReconstruction, Gen5PpgWaveform, Gen5RecordDecoder, + Gen5ResearchOpticalWindow, + Gen5ResearchRecord, Gen5SleepState, Gen5V18Decoder, Gen5V20Decoder, Gen5V21Decoder, + Gen5V22Decoder, Gen5V26Decoder, kGen5HistoricalDecoders, + kGen5V22KnownTags, + kGen5V22InnerLen, // The exact lengths are the live names; the *MinInnerLen* aliases below // are deprecated and exported only so existing callers still resolve. kGen5V18InnerLen, @@ -51,7 +57,8 @@ export 'src/gen5_records.dart' kGen5GyroScaleDps, isGen5ImuBuffer, parseGen5ImuBuffer, - parseGen5Historical; + parseGen5Historical, + reconstructSaturatedDeltaWindow; export 'src/live.dart' show DecodedSample, @@ -80,6 +87,7 @@ export 'src/commands.dart' buildBatchAck, initPackets, WristSelection, + LabradorOperation, cmdLinkValid, cmdGetBattery, cmdGetHello, @@ -114,7 +122,43 @@ export 'src/commands.dart' cmdSetConfigGen5, cmdSetDeviceConfigValueGen5, kGen5R22EnableFlags, - buildR22EnableSequence; + kGen5R22ContestedFlagNames, + buildR22EnableSequence, + // Previously built + unit-tested but unreachable from this entry point. + // Every opcode below is an established WHOOP opcode, so a caller + // is reaching for something the band is known to implement. + // + // NOT exported, deliberately: + // • buildR22RestoreDefaultsSequence — writes raw '0' to every flag, + // which is not a valid boolean write value ('1'/'2' only; raw 0 + // = unset) and not the observed pre-value, so it is not a + // correct restore. A correct restore is snapshot-based (GET each + // flag first, write back the recorded value with readback). + // • cmdGyroEnable (150) / cmdGyroStatus (152) / cmdSetEventPackets + // (48) — these opcode NUMBERS are not established WHOOP opcodes. + // They came from third-party sources, the same origin as the + // unverified 146/147 "Maverick clock" opcodes. + // They stay built and tested but unexported + // until a trace or a hardware capture backs them; shipping them as + // public API would present a guess as a capability. + cmdGetAlarmTime, // 67 + cmdRawDataStart, // 81 + cmdRawDataStop, // 82 + cmdStopHaptics, // 122 + // Filtered reading ("Labrador", R17). The wrong-bodied cmdEcgControl + // (124 as a bool) and the opcode-126 cmdEcgSendRaw stay deprecated + // and unexported. + cmdLabradorDataGeneration, // 124 — R17 lifecycle + cmdLabradorRawSave, // 125 — R17 lifecycle + cmdLabradorFiltered, // 139 — R17 lifecycle + cmdGetCustomAdvertisingName, // 141 + cmdSetCustomAdvertisingName, // 140 — PERSISTENT write + cmdGetConfigKeyCount, // 115 + cmdGetConfigKeyName, // 116 + cmdGetFlagKeyCount, // 117 + cmdGetFlagKeyName, // 118 + cmdGetConfigValue, // 121 (read-only) + cmdGetFlagValue; // 128 (read-only) // Control-plane parsers (HELLO / EVENT / METADATA / COMMAND_RESPONSE / dispatch). export 'src/control.dart' @@ -133,6 +177,7 @@ export 'src/control.dart' parseRealtimeHrV2, HelloInfo, parseHello, + Gen5HelloInfo, EventInfo, parseEvent, CmdResponse, diff --git a/lib/src/commands.dart b/lib/src/commands.dart index 0f16c10..aa46d90 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -49,10 +49,21 @@ Uint8List buildHistoryResultOk(int seq, List token, } /// The strap's negative historical-burst result (cmd 0x17). -/// Payload is a single FAILURE result byte (the band only needs the code). +/// +/// On gen5 the failure payload is exactly TWO zero bytes `00 00`, versus the +/// 9-byte `01 + markerA + markerB` success payload from +/// [buildHistoryResultOk]; a one-byte body leaves the strap parsing a +/// truncated result. The gen4 form keeps its established single failure byte +/// until a gen4 capture says otherwise — the two-byte evidence is +/// gen5-scoped. Uint8List buildHistoryResultFail(int seq, {BandProfile profile = BandProfile.gen4}) => - buildCommand(seq, Cmd.historicalDataResult, const [0x00], profile); + buildCommand( + seq, + Cmd.historicalDataResult, + profile.isGen5 ? const [0x00, 0x00] : const [0x00], + profile, + ); /// Legacy alias used by the app transport. Uint8List buildBatchAck(int seq, List token, @@ -83,7 +94,17 @@ Uint8List cmdAbortHistorical(int seq) => buildCommand(seq, Cmd.abortHistoricalTransmits, const [0x00]); Uint8List cmdSendHistorical(int seq) => buildCommand(seq, Cmd.sendHistoricalData, const [0x00]); -Uint8List cmdGetClock(int seq) => buildCommand(seq, Cmd.getClock, const []); +/// Read the strap RTC (GET_CLOCK = 0x0B = 11) with an EMPTY body. +/// +/// Shared across generations — hardware-verified on WHOOP 5: opcode 11 with +/// an empty body reads a usable time from a real gen5 strap. The reply body +/// is the gen4 shape, `[u32 sec][u32 subsec]` starting at body offset 0. +/// +/// On gen5 this is only the FALLBACK path: the normal bootstrap takes its +/// timestamp from the hello response and never sends GET_CLOCK unless hello +/// supplied none. +Uint8List cmdGetClock(int seq, {BandProfile profile = BandProfile.gen4}) => + buildCommand(seq, Cmd.getClock, const [], profile); /// Set the strap RTC (SET_CLOCK = 0x0A) — WHOOP-EXACT 8-byte payload, /// hardware-verified by the edge app (`ble_engine.dart setClock()`). @@ -101,8 +122,15 @@ Uint8List cmdGetClock(int seq) => buildCommand(seq, Cmd.getClock, const []); /// hardware. After sending, read the clock back (GET_CLOCK, /// [cmdGetClock]) to confirm it latched. /// +/// SHARED ACROSS GENERATIONS — this is the correct builder for WHOOP 5 too: +/// gen5 takes `SET_CLOCK(10)` carrying +/// ``, and a real gen5 strap answers +/// `SUCCESS` for exactly this 8-byte form. Prefer this over +/// [cmdSetClockGen5], whose opcode 146 is not an established WHOOP opcode. +/// /// [now] defaults to `DateTime.now()`; pass a fixed instant for tests. -Uint8List cmdSetClock(int seq, {DateTime? now}) { +Uint8List cmdSetClock(int seq, + {DateTime? now, BandProfile profile = BandProfile.gen4}) { final ms = (now ?? DateTime.now()).millisecondsSinceEpoch; final sec = ms ~/ 1000; final subsec = ((ms % 1000) * 32768) ~/ 1000; // 0..32767, 1/32768 s units @@ -116,7 +144,7 @@ Uint8List cmdSetClock(int seq, {DateTime? now}) { 0, 0, ]; - return buildCommand(seq, Cmd.setClock, payload); + return buildCommand(seq, Cmd.setClock, payload, profile); } Uint8List cmdGetDataRange(int seq) => @@ -364,16 +392,26 @@ Uint8List cmdSetAlarmSimple(int seq, DateTime when, /// DISABLE_ALARM reject 0 and an alarm in slot 0 is un-runnable and /// un-cancellable. Omit [index] to get the right default for the profile. /// -/// GENERATION DIFFERENCE — the haptic block length: -/// • gen4 reads 12 bytes (payload 20). Hardware-verified; unchanged. -/// • gen5 reads 13 bytes (payload 21), the 13th being [crescendo], which -/// must be exactly 0 or 1 — any other value is rejected. [hapticPattern] -/// stays 12 bytes on both; the crescendo byte is appended for gen5. +/// GENERATION DIFFERENCE — the trailing byte: +/// • gen4 reads 12 haptic bytes (payload 20). Hardware-verified; unchanged. +/// • gen5 reads one MORE byte (payload 21). The Gen5 revision-4 body ends +/// with **alarm type `00`**, and 0 is the only value ever sent for it. +/// Mind the serializer trap: the body must be 21 bytes, never 20 — the +/// zero-initialised alarm-type byte IS on the wire. +/// +/// This package's [crescendo] parameter IS that byte. The name came from a +/// third-party source and is not what the layout calls it; it is kept for +/// source compatibility and still validated as 0/1, but passing 1 means +/// "alarm type 1", an unestablished type — NOT a gentler ramp. Leave it +/// at the default 0. Uint8List cmdSetAlarm( int seq, DateTime when, { int? index, List? hapticPattern, + /// gen5's trailing body byte — the **alarm type**, always `0` in practice. + /// See the GENERATION DIFFERENCE note above for why this parameter keeps + /// its third-party name. int crescendo = 0, BandProfile profile = BandProfile.gen4, }) { @@ -511,15 +549,24 @@ Uint8List cmdSendHistoricalGen5(int seq) => // strap end to end: the bytes are right, but nothing here has watched an RTC // actually latch. -/// gen5 SET_CLOCK_MAVERICK (146). +/// gen5 SET_CLOCK_MAVERICK (146) — **UNVERIFIED; prefer [cmdSetClock] (10).** /// -/// Body is `[0x01][u32 epoch][u16 subsec]` — the leading byte is the command -/// revision, exactly like GET_HELLO's. Without it the strap reads the epoch's -/// low byte as the revision, rejects the command, and the RTC silently never -/// latches; records then carry a 1970s timestamp. +/// Body is `[0x01][u32 epoch][u16 subsec]`. /// -/// Legacy [cmdSetClock] (0x0A) also still works on gen5 and takes no revision -/// byte, so either is valid — but they are NOT interchangeable body shapes. +/// Opcode **146 is not an established WHOOP opcode**, and no "Maverick clock" +/// command is known to exist. What IS established for gen5: `SET_CLOCK(10)` +/// with ``, for which a real gen5 strap answers +/// `SUCCESS` while `GET_CLOCK(11)` reads the time back. +/// +/// So this builder sends a guess where a confirmed command exists. That is +/// especially dangerous for the clock: a rejected or no-op'd set is silent — +/// the RTC simply never latches, and every absolute timestamp afterwards +/// (alarms above all) is armed against a clock that was never set. +/// +/// Kept only for source compatibility. Do NOT send it to "find out what it +/// does": a clock write is a mutation, not a read. +@Deprecated('opcode 146 is not an established opcode; gen5 ' + 'uses SET_CLOCK(10) — use cmdSetClock(seq, profile: BandProfile.gen5)') Uint8List cmdSetClockGen5(int seq, {DateTime? now}) { final ms = (now ?? DateTime.now()).millisecondsSinceEpoch; final sec = ms ~/ 1000; @@ -538,8 +585,14 @@ Uint8List cmdSetClockGen5(int seq, {DateTime? now}) { return buildCommand(seq, Cmd.setClockMaverick, payload, BandProfile.gen5); } -/// gen5 GET_CLOCK_GEN5 (147). Takes the same `[0x01]` revision body — an empty -/// body is rejected as revision 0. +/// gen5 GET_CLOCK_GEN5 (147) — **UNVERIFIED; prefer [cmdGetClock] (11).** +/// +/// Takes a `[0x01]` revision body. Same problem as [cmdSetClockGen5]: opcode +/// 147 is not an established WHOOP opcode, while `GET_CLOCK(11)` with an +/// EMPTY body is hardware-confirmed to answer on a real gen5 strap. Kept for +/// source compatibility only. +@Deprecated('opcode 147 is not an established opcode; gen5 ' + 'uses GET_CLOCK(11) — use cmdGetClock(seq, profile: BandProfile.gen5)') Uint8List cmdGetClockGen5(int seq) => buildCommand(seq, Cmd.getClockGen5, const [revision1], BandProfile.gen5); @@ -636,10 +689,13 @@ Uint8List cmdSetDeviceConfigValueGen5(int seq, String name, String value) { return buildCommand(seq, Cmd.setDeviceConfigValue, payload, BandProfile.gen5); } -// ⚠ THE THREE ACCEPTED CONFIG VALUES, and nothing else: -// '0' — restore the firmware default +// ⚠ THE OFFICIAL BOOLEAN WRITE VALUES, and nothing else: // '1' — enable // '2' — DISABLE +// ASCII '0' is NOT a value the boolean writer ever emits: a key +// READING 0 is a raw/unset record, and writing '0' back is not a proven +// restoration of that state — which is exactly why raw-zero keys are skipped +// rather than "restored". // These are PERSISTENT (NVM) writes: a wrong value survives reboot and // reconnect, and only writing '0' (or the opposite value) undoes it. Sending // '2' to a flag named `enable_*` force-DISABLES that feature — which is what @@ -653,6 +709,7 @@ Uint8List cmdSetDeviceConfigValueGen5(int seq, String name, String value) { /// not gate a deep buffer are deliberately absent, because every entry here is /// a persistent write to a real user-visible setting. /// +/// This is the FULL set with hardware evidence of producing deep buffers. /// Order is irrelevant — the strap looks each setting up BY NAME, so this list /// can be reordered or trimmed freely. /// @@ -670,18 +727,63 @@ const List<(String, String)> kGen5R22EnableFlags = [ ('disable_pip_r26_packets', '2'), ]; +/// Entries of [kGen5R22EnableFlags] a caller can choose NOT to write +/// (`buildR22EnableSequence(omitContestedFlags: true)`): +/// +/// * `enable_r22_v4_packets` reads raw `0` before any write, and raw zero +/// has no proven restoration value (the boolean writer emits only '1' and +/// '2') — so writing it is a ONE-WAY change to the user's device. +/// * `enable_r22_v8_packets` has no active consumer in firmware 50.40.1.0 +/// (the R22 selector runs v6..v2, then variant 1), so writing it persists +/// a setting to no effect. Its observed pre-value is '2', so it IS +/// restorable, unlike v4. +/// +/// The DEFAULT still writes both: the full set is the one with hardware +/// evidence of producing deep buffers, and the trimmed variant has none yet. +const Set kGen5R22ContestedFlagNames = { + 'enable_r22_v4_packets', + 'enable_r22_v8_packets', +}; + /// Build the R22 enable sequence (one 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}) => - _configSequence(kGen5R22EnableFlags, startSeq); +/// +/// PERSISTENT AND PARTLY IRREVERSIBLE. These are NVM writes that survive +/// reboots, and `enable_r22_v4_packets` cannot be restored once written (see +/// [kGen5R22ContestedFlagNames]). Treat sending this as a one-way change to +/// the user's device and get explicit consent first; +/// [omitContestedFlags] skips the irreversible/dormant pair at the cost of +/// diverging from the hardware-proven sequence. +List buildR22EnableSequence({ + int startSeq = 1, + bool omitContestedFlags = false, +}) => + _configSequence( + omitContestedFlags + ? [ + for (final f in kGen5R22EnableFlags) + if (!kGen5R22ContestedFlagNames.contains(f.$1)) f + ] + : kGen5R22EnableFlags, + startSeq, + ); -/// Undo [buildR22EnableSequence]: writes '0' (restore firmware default) to -/// every name it touched. These settings persist across reboots, so this is -/// the only way back — turning the deep buffers off is NOT a matter of -/// disconnecting. +/// UNSAFE — do not use as a restore, and not exported from the package. +/// +/// Writes raw '0' to every flag [buildR22EnableSequence] touched. But '0' is +/// NOT a valid boolean write value: the writer emits only '1' (enabled) or +/// '2' (disabled), and a returned '0' is an unset/unknown state, never a +/// real "off". It is also not the observed pre-value — straps read '2' on +/// most of these before any write, and observed values are not uniform +/// factory defaults. Restoring therefore requires a per-flag snapshot +/// (enumerate + GET each value BEFORE the enable sequence, then write each +/// recorded value back with readback), not a blanket '0'. Kept only so the +/// asymmetry is visible; retire once a snapshot-based restore exists. +@Deprecated('writes raw 0, which is not a valid write value or the observed ' + 'pre-value; use a snapshot-based restore instead. Not a correct undo.') List buildR22RestoreDefaultsSequence({int startSeq = 1}) => _configSequence( [for (final f in kGen5R22EnableFlags) (f.$1, '0')], @@ -810,24 +912,129 @@ Uint8List cmdGetAfeParams(int seq, {BandProfile profile = BandProfile.gen4}) => Uint8List cmdStopHaptics(int seq, {BandProfile profile = BandProfile.gen4}) => buildCommand(seq, Cmd.stopHaptics, const [revision1], profile); +// ── Filtered reading ("Labrador", record revision 17) ────────────────────── +// +// Three toggles, each body `[revision 01][operation]`: +// +// 124 TOGGLE_LABRADOR_DATA_GENERATION 01=stop 02=start 03=restart +// 125 TOGGLE_LABRADOR_RAW_SAVE 00=disable 01=enable +// 139 TOGGLE_LABRADOR_FILTERED 00=disable 01=enable +// +// The old `cmdEcg*` builders below modelled 124 as a boolean arm/disarm. That +// is wrong at the byte level, not just in naming: `01 01` — what the old +// builder sent to "arm" — is the STOP operation, and `01 00` is not an +// operation the strap defines at all. + +/// The operation byte of TOGGLE_LABRADOR_DATA_GENERATION (124) — the +/// filtered-reading (R17) lifecycle. It is an operation selector, not a +/// boolean: there is no `00`. +enum LabradorOperation { + /// `01` — stop generation. The first command of the stop sequence. + stop(0x01), + + /// `02` — start generation. Sent after an abort (20), and only once the + /// prepare step's 139 ON / 125 ON both came back successful. + start(0x02), + + /// `03` — restart generation. What a retry of the start step sends; there + /// is no plain resend of [start]. + restart(0x03); + + const LabradorOperation(this.value); + final int value; +} + +/// Drive filtered-reading data generation (TOGGLE_LABRADOR_DATA_GENERATION, +/// 124) — `[0x01][op]`, i.e. `01 01` stop / `01 02` start / `01 03` restart. +/// +/// **The full lifecycle** — this builder is only the 124 step of it: +/// +/// ```text +/// prepare: 20 (abort) -> 123 (select wrist) -> 139 ON -> 125 ON +/// start: 20 (abort) -> 124 start (retry uses 124 restart) +/// stop: 124 stop -> 139 OFF -> 125 OFF +/// ``` +/// +/// i.e. [cmdAbortHistorical] (20), [cmdSelectWrist] (123/0x7B), +/// [cmdLabradorFiltered] (139) and [cmdLabradorRawSave] (125), then this. +/// +/// Each command carries the standard five-second timeout, and the aggregate +/// must be REJECTED if any single response is missing or unsuccessful. +/// +/// **Failure characteristics — the caller owns them.** There is no retry loop +/// and no automatic compensating rollback anywhere in this surface: a partial +/// startup leaves components enabled on the strap. Carry a durable recovery +/// guard (one that survives an app restart, because the strap's state does) +/// and always attempt all three OFF commands on cleanup — stop, 139 OFF, +/// 125 OFF — even when an earlier one failed. +/// +/// **Some WHOOP 5 units reject the feature outright:** 20 and 123 succeed +/// while BOTH 139 ON and 125 ON answer `FAILURE`. That correctly prevents the +/// 124 start, and it is a normal path to handle — not a transport error and +/// not something a retry fixes. +Uint8List cmdLabradorDataGeneration(int seq, LabradorOperation op, + {BandProfile profile = BandProfile.gen4}) => + buildCommand(seq, Cmd.toggleLabradorDataGeneration, + [revision1, op.value], profile); + +/// Enable/disable the filtered-reading RAW save (TOGGLE_LABRADOR_RAW_SAVE, +/// 125) — `[0x01][0|1]`. Part of the prepare step (ON) and of the stop step +/// (OFF); see [cmdLabradorDataGeneration] for the sequence. +Uint8List cmdLabradorRawSave(int seq, bool on, + {BandProfile profile = BandProfile.gen4}) => + buildCommand( + seq, Cmd.toggleLabradorRawSave, [revision1, on ? 0x01 : 0x00], profile); + +/// Enable/disable the filtered trace (TOGGLE_LABRADOR_FILTERED, 139) — +/// `[0x01][0|1]`. Part of the prepare step (ON) and of the stop step (OFF); +/// see [cmdLabradorDataGeneration] for the sequence. Arrives as record +/// revision 17. +Uint8List cmdLabradorFiltered(int seq, bool on, + {BandProfile profile = BandProfile.gen4}) => + buildCommand(seq, Cmd.toggleLabradorFiltered, [revision1, on ? 0x01 : 0x00], + profile); + /// Arm / disarm an ECG reading (ECG main control, 0x7C) — `[0x01][0|1]`. -/// Select the wrist first with [cmdSelectWrist] (0x7B). +/// +/// **Wrong bytes, kept only for source compatibility.** Opcode 124 is +/// TOGGLE_LABRADOR_DATA_GENERATION and its second byte is an operation, not a +/// flag: `cmdEcgControl(seq, +/// true)` emits `01 01`, which is the **STOP** operation, and +/// `cmdEcgControl(seq, false)` emits `01 00`, which is not a defined +/// operation at all. Use [cmdLabradorDataGeneration]. +@Deprecated('opcode 124 takes an OPERATION byte, not a bool: this builder\'s ' + '`true` sends 01 01 = the STOP operation, and `false` sends the undefined ' + '01 00 — use cmdLabradorDataGeneration(seq, LabradorOperation.start)') Uint8List cmdEcgControl(int seq, bool on, {BandProfile profile = BandProfile.gen4}) => - buildCommand( - seq, Cmd.ecgMainControl, [revision1, on ? 0x01 : 0x00], profile); + buildCommand(seq, Cmd.toggleLabradorDataGeneration, + [revision1, on ? 0x01 : 0x00], profile); -/// Start/stop the raw ECG trace (0x7E) — `[0x01][on]`. The second byte is a -/// real selector, not padding: the strap reads both bytes as one word and -/// rejects anything above 1. Arrives as record version 16. +/// Start/stop the raw ECG trace (0x7E) — `[0x01][on]`. +/// +/// **Wrong opcode, kept only for source compatibility.** 0x7E (126) is not +/// an established WHOOP 5 opcode — origin unknown, most likely gen4 or +/// third-party lore. The raw save of a filtered +/// reading is TOGGLE_LABRADOR_RAW_SAVE (125): use [cmdLabradorRawSave], which +/// emits the identical `01 00` / `01 01` body on the opcode the strap +/// actually implements. +@Deprecated('opcode 126 (0x7E) is not an established WHOOP 5 opcode ' + '— the raw save is TOGGLE_LABRADOR_RAW_SAVE (125): use ' + 'cmdLabradorRawSave(seq, on)') Uint8List cmdEcgSendRaw(int seq, bool on, {BandProfile profile = BandProfile.gen4}) => + // ignore: deprecated_member_use_from_same_package buildCommand( seq, Cmd.ecgSendRawData, [revision1, on ? 0x01 : 0x00], profile); -/// Start/stop the filtered ECG trace (0x8B) — `[0x01][on]`, same two-byte -/// selector as [cmdEcgSendRaw]. Arrives as record version 17. +/// Start/stop the filtered ECG trace (0x8B) — `[0x01][on]`. +/// +/// Body-correct: this is TOGGLE_LABRADOR_FILTERED (139) under its old name, +/// and the bytes are unchanged. Renamed only, so [cmdLabradorFiltered] is a +/// drop-in replacement. +@Deprecated('renamed: opcode 139 is TOGGLE_LABRADOR_FILTERED and the trace is ' + 'a filtered reading, not an ECG — use cmdLabradorFiltered(seq, on) ' + '(identical bytes)') Uint8List cmdEcgSendFiltered(int seq, bool on, {BandProfile profile = BandProfile.gen4}) => - buildCommand( - seq, Cmd.ecgSendFilteredData, [revision1, on ? 0x01 : 0x00], profile); + cmdLabradorFiltered(seq, on, profile: profile); diff --git a/lib/src/constants.dart b/lib/src/constants.dart index 5f04007..e38263f 100644 --- a/lib/src/constants.dart +++ b/lib/src/constants.dart @@ -9,6 +9,12 @@ const int hapticShortPulse = 2; class PacketType { static const int command = 0x23; static const int commandResponse = 0x24; + // Battery-pack ("puffin") command/response. Type 37 has no known incoming + // body shape, while type 38 does carry a battery-pack-command-specific + // response — a future decode target. + // Named here so neither is misread as 'other'. + static const int puffinCommand = 0x25; // 37 + static const int puffinCommandResponse = 0x26; // 38 static const int realtimeData = 0x28; static const int realtimeRawData = 0x2B; static const int historicalData = 0x2F; @@ -17,6 +23,17 @@ class PacketType { static const int consoleLogs = 0x32; static const int realtimeImuStream = 0x33; static const int historicalImuStream = 0x34; + // Battery-pack event/log wrappers riding the generic event envelope. + // 53/54/55 are MEMBERS of the Sensor-HPS history count: each complete + // frame counts once and must be retained. 56 is battery-pack metadata + // (start/end/complete), not a count member. + // + // 53 additionally carries a state-of-charge specialization that this + // package does not decode yet. + static const int relativePuffinEvents = 0x35; // 53 + static const int puffinEventsFromStrap = 0x36; // 54 + static const int relativeBatteryPackConsoleLogs = 0x37; // 55 + static const int puffinMetadata = 0x38; // 56 } /// Command opcodes (inside a 0x23 COMMAND) —. Subset we use. @@ -130,14 +147,45 @@ class Cmd { // RUN_HAPTIC_PATTERN_MAVERICK (0x13) instead. static const int runHapticsPattern = 0x4F; static const int stopHaptics = 0x7A; // safe: cancels an in-progress buzz - // Wrist selection — also the wrist selector for an ECG reading. Safe. + // Wrist selection — also the wrist selector for a filtered-reading (R17) + // session, whose prepare step sends it right after the abort. Safe. static const int selectWrist = 0x7B; - // ECG. Main control arms/disarms a reading; the save/send pairs cover the - // raw and the filtered trace. Safe, but a reading is a battery cost. + // Filtered reading — WHOOP's internal name is "Labrador"; the trace arrives + // as record revision 17. Three toggles; every body is + // `[revision 01][operation]`: + // 124 TOGGLE_LABRADOR_DATA_GENERATION — 01=stop, 02=start, 03=restart. + // An OPERATION byte, NOT a boolean: `01 01` is the STOP operation. + // 125 TOGGLE_LABRADOR_RAW_SAVE — 00=disable, 01=enable. + // 139 TOGGLE_LABRADOR_FILTERED — 00=disable, 01=enable. + // Safe, but a reading is a battery cost, and there is no auto-rollback: a + // partial startup leaves components enabled until the caller sends the OFFs. + // See commands.dart's cmdLabradorDataGeneration for the full sequence. + static const int toggleLabradorDataGeneration = 0x7C; // 124 + static const int toggleLabradorRawSave = 0x7D; // 125 + static const int toggleLabradorFiltered = 0x8B; // 139 + // ── Deprecated "ECG" spellings of the three above ───────────────────────── + // The feature was previously modelled as an ECG arm/save/send matrix. The + // opcode NUMBERS 124/125/139 are right; the names and the arm/disarm mental + // model are not (see the operation table above). + @Deprecated('this is TOGGLE_LABRADOR_DATA_GENERATION (124): its body is an ' + 'operation byte (01=stop, 02=start, 03=restart), not an on/off flag — ' + 'use Cmd.toggleLabradorDataGeneration') static const int ecgMainControl = 0x7C; + @Deprecated('this is TOGGLE_LABRADOR_RAW_SAVE (125), 01 00 / 01 01 — ' + 'use Cmd.toggleLabradorRawSave') static const int ecgSaveRawData = 0x7D; + @Deprecated('opcode 126 (0x7E) is NOT an established WHOOP 5 opcode ' + '— origin unknown, most likely gen4 ' + 'or third-party lore. The raw trace is TOGGLE_LABRADOR_RAW_SAVE (125): ' + 'use Cmd.toggleLabradorRawSave') static const int ecgSendRawData = 0x7E; + @Deprecated('opcode 127 (0x7F) is NOT an established WHOOP 5 opcode ' + '— origin unknown, most likely gen4 ' + 'or third-party lore. The filtered trace is TOGGLE_LABRADOR_FILTERED ' + '(139): use Cmd.toggleLabradorFiltered') static const int ecgSaveFilteredData = 0x7F; + @Deprecated('this is TOGGLE_LABRADOR_FILTERED (139), 01 00 / 01 01 — ' + 'use Cmd.toggleLabradorFiltered') static const int ecgSendFilteredData = 0x8B; // Signal-processing configuration. Advanced/diagnostic: it retunes the // on-strap algorithms, so it can change what the strap itself reports. @@ -347,6 +395,13 @@ class EventId { static const int trimAllData = 26; static const int trimAllDataEnded = 27; static const int flashInitComplete = 28; + + /// Live strap-condition telemetry the band volunteers: how far the host is + /// behind flash, plus charge/wear condition. + /// The page backlog it reports is the same quantity as GET_DATA_RANGE's + /// `pages_behind` — a modular PAGE span (~15 records/page nominal), + /// never a packet count. + static const int strapConditionReport = 29; // Optical / accel front-end saturation warnings. // // ⚠ NEVER EMITTED. 40/41/42 are defined ids but nothing on the strap raises @@ -378,6 +433,20 @@ class EventId { static const int highFreqSyncEnabled = 97; static const int highFreqSyncDisabled = 98; + /// A running haptics pattern stopped, and WHY: + /// it expired, it errored, or the wearer double-tapped it away. This is the + /// only way to learn an alarm was dismissed rather than timing out. + static const int hapticsTerminated = 100; + + /// The battery pack (puck) describing itself — address, name, charge, + /// colourway, hardware family. Same content as + /// the GET_BATTERY_PACK_INFO(151) reply, volunteered as an event. + static const int batteryPackInfo = 109; + + /// Firmware-internal event wrapper carrying its own u16 sub-id + /// (see [FirmwareEventId]). + static const int genericFirmwareEvent = 123; + static String name(int id) { switch (id) { case batteryLevel: @@ -416,6 +485,8 @@ class EventId { return 'TRIM_ALL_DATA_ENDED'; case flashInitComplete: return 'FLASH_INIT_COMPLETE'; + case strapConditionReport: + return 'STRAP_CONDITION_REPORT'; case ch1Saturation: return 'CH1_SATURATION_DETECTED'; case ch2Saturation: @@ -444,8 +515,113 @@ class EventId { return 'HIGH_FREQ_SYNC_ENABLED'; case highFreqSyncDisabled: return 'HIGH_FREQ_SYNC_DISABLED'; + case hapticsTerminated: + return 'HAPTICS_TERMINATED'; + case batteryPackInfo: + return 'BATTERY_PACK_INFO'; + case genericFirmwareEvent: + return 'GENERIC_FIRMWARE_EVENT'; default: return 'EVENT_$id'; } } } + +/// Why a running haptics pattern stopped — body[1] of an +/// [EventId.hapticsTerminated] event. +/// +/// The three causes are distinct and must not be collapsed: an alarm that ran +/// its full duration, one the firmware aborted, and one the WEARER dismissed +/// with a double tap. +class HapticsTermination { + static const int expired = 0; + static const int error = 1; + static const int userDoubleTap = 2; + + static String name(int code) { + switch (code) { + case expired: + return 'expired'; + case error: + return 'error'; + case userDoubleTap: + return 'user_double_tap'; + default: + return 'code_$code'; + } + } +} + +/// The haptics/alarm status byte carried by the SET_ALARM_TIME(66) and +/// RUN_ALARM(68) responses at response-body offset 1. +/// +/// This is **in addition to** the ordinary outer command result — a caller +/// must check both. A response can carry SUCCESS as its outer result and still +/// report here that the alarm was refused, which is the whole reason the byte +/// exists: it is the only place the strap says *why*. +class AlarmStatus { + static const int unknown = 0; + static const int validInputPattern = 1; + static const int invalidWaveformEffect = 2; + static const int invalidLoopCount = 3; + static const int invalidDuration = 4; + static const int playedSuccessfully = 5; + static const int hapticsFailure = 6; + static const int hapticsTimeout = 7; + static const int hapticsBusy = 8; + static const int hapticsStopped = 9; + static const int invalidAlarmTime = 10; + static const int invalidAlarmId = 11; + + /// The input-validation family: the strap examined what we sent and refused + /// it. Separated from the run-time outcomes (6..9), which say a pattern that + /// WAS accepted then failed, timed out, was busy or was stopped — a host + /// must not treat "haptics busy" as "your alarm is not armed". + static bool isInputRejection(int code) => + code == invalidWaveformEffect || + code == invalidLoopCount || + code == invalidDuration || + code == invalidAlarmTime || + code == invalidAlarmId; + + static String name(int code) { + switch (code) { + case unknown: + return 'unknown'; + case validInputPattern: + return 'valid_input_pattern'; + case invalidWaveformEffect: + return 'invalid_waveform_effect'; + case invalidLoopCount: + return 'invalid_loop_count'; + case invalidDuration: + return 'invalid_duration'; + case playedSuccessfully: + return 'played_successfully'; + case hapticsFailure: + return 'haptics_failure'; + case hapticsTimeout: + return 'haptics_timeout'; + case hapticsBusy: + return 'haptics_busy'; + case hapticsStopped: + return 'haptics_stopped'; + case invalidAlarmTime: + return 'invalid_alarm_time'; + case invalidAlarmId: + return 'invalid_alarm_id'; + default: + return 'code_$code'; + } + } +} + +/// Sub-ids carried by an [EventId.genericFirmwareEvent] (u16 LE at body[1]). +/// Only one is named in the reference tables (doc 99); everything else stays +/// numeric rather than being guessed at. +class FirmwareEventId { + static const int dorsetDetected = 6; + + static String name(int id) => + id == dorsetDetected ? 'DORSET_DETECTED' : 'FIRMWARE_EVENT_$id'; +} diff --git a/lib/src/control.dart b/lib/src/control.dart index adf556f..d62b563 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -195,6 +195,12 @@ List _r10Rr(Uint8List inner) { /// RR slots the compact 0x28 form can hold: [10] [12] [14] [16], stopping /// before the `wearing` byte at [18]. +/// +/// Naming note: these slots have also been described as a +/// "candidate-estimate count, 0..4" plus "four candidate estimates as u16 +/// values" with an unstated physiological role — the identical field pair +/// R18 carries (body 2 / body 3..10 = inner 15 / 16..23). The data settles +/// it: the values ARE R-R intervals in milliseconds. Evidence below. const int _maxRealtimeRr = 4; class RealtimeHr { @@ -212,6 +218,36 @@ class RealtimeHr { // against the ts parity oracle, and lines up like this instead: // ts@2 (u32), hr@8 (u8, not u16/256), rr_count@9, rr1@10, rr2@12, wearing@18. // so this now just takes the whole inner frame, not a pre-sliced body. +// +// Why these slots are read as R-R and not as unnamed "candidate estimates": +// +// - the value domain is exactly 333..2400 ms, i.e. 60000/180.2 .. +// 60000/25.0 bpm, and nearly every integer in that range occurs. A field +// clamped to the reciprocal of a physiological HR range at 1 ms +// resolution is an interval. type-40 shares the same 2400 ms ceiling — +// one field in two packets. +// - slot / (60000/HR) sits at a median of ~1.0, with the bulk of slots +// within a few tens of percent of the co-reported HR's period. +// - every alternative unit is dead: essentially no slot reads as bpm, as +// bpm<<8 or as centi-bpm. The "candidate HR estimate" reading is +// arithmetically impossible. +// - they are not the HR byte restated: slots rarely equal round(60000/HR) +// exactly, so they carry beat-level information the smoothed HR byte +// does not. +// - the median per-slot value falls as the count rises — more beats in a +// second means shorter intervals. Repeated estimates of one quantity +// would not behave like that. +// +// The COUNT byte is NOT "beats in this second": it anti-correlates with HR, +// falling toward zero as HR rises. It is a detector-confidence count — most +// records declare zero, and every slot the band does declare is a valid +// interval (no zeros, none outside kMinRrMs..kMaxRrMs, no non-zero bytes +// past the declared count, so the gate below currently drops nothing). +// +// Consequence for anything computing HRV from these: the series is GAPPED, +// not contiguous. Slots from non-adjacent seconds are not successive beats, +// and a naive RMSSD across them computes far outside the human resting +// range. Window on real adjacency. RealtimeHr? parseRealtimeHr(Uint8List inner) { if (inner.length < 9) return null; final ts = u32(inner, 2); @@ -282,6 +318,151 @@ class HelloInfo { }); } +/// The WHOOP 5 (gen5) `GET_HELLO(0x91)` response body — a DIFFERENT opcode and +/// a DIFFERENT layout from gen4's `GET_HELLO_HARVARD(0x23)`/[HelloInfo], so it +/// gets its own type. Unlike gen4 (whose field offsets drift, so it scans by +/// content), the gen5 revision-1 body is a FIXED map read by absolute offset: +/// the on-band producer fills a fixed 104-byte map, and readers consume +/// offsets through byte 103. Offsets below are BODY-relative — the body is the +/// bytes after the 5-byte command-response header, i.e. `payload.sublist(2)` +/// where `payload` is what [parseCommandResponse] hands the opcode branches +/// (payload[0]=echoed req seq, payload[1]=status). +/// +/// Revision-1 hello body: 104 semantic bytes at fixed offsets. +class Gen5HelloInfo { + /// Exact semantic body length the fixed map spans. The three + /// trailing bytes of a real 107-byte reply are inner-packet alignment pad. + static const int semanticBodyLen = 104; + + final int helloRevision; // body[0] + + /// body[1..4] u32 LE, integer-divided by 10. **Null when the raw value is + /// outside 0..100** — a percentage is 0..100 by definition, so anything else + /// is a mis-read field, and this package's rule is to omit it rather than + /// report or clamp a number the bytes do not support. + final int? batteryPct; + final bool charging; // body[5] bit0 + final int tsSeconds; // body[6..9] u32 LE — whole seconds + final int tsSubseconds; // body[10..13] u32 LE — 32768 units/s + final String serial; // body[14..24] NUL-terminated ASCII + final String commitHex; // body[25..48] → lowercase hex + final String cpuHex; // body[49..78] → lowercase hex (also the signature) + final int hardwareFamily; // body[79..82] u32 LE + final int pcbaRevision; // body[83..86] u32 LE + final int opticalDiscriminator; // body[87..90] u32 LE — 48..85 ⇒ WHOOP 5 + final int fwMajor; // body[91] + final int fwMinor; // body[92] + final int fwBuild; // body[93] + final int fwUnreleased; // body[94..97] u32 LE + final int sigprocMajor; // body[98] + final int sigprocMinor; // body[99] + final int sigprocPatch; // body[100] + final bool hrBroadcast; // body[101] — parsed, not a readiness gate + final bool wristOn; // body[102] == 1 + final int errorByte; // body[103] signed — logged, not a readiness gate + final String rawHex; + + const Gen5HelloInfo({ + required this.helloRevision, + required this.batteryPct, + required this.charging, + required this.tsSeconds, + required this.tsSubseconds, + required this.serial, + required this.commitHex, + required this.cpuHex, + required this.hardwareFamily, + required this.pcbaRevision, + required this.opticalDiscriminator, + required this.fwMajor, + required this.fwMinor, + required this.fwBuild, + required this.fwUnreleased, + required this.sigprocMajor, + required this.sigprocMinor, + required this.sigprocPatch, + required this.hrBroadcast, + required this.wristOn, + required this.errorByte, + this.rawHex = '', + }); + + /// The optical discriminator selects the WHOOP 5.0 family in the interval + /// `48 <= value < 86`. + bool get isWhoop5 => opticalDiscriminator >= 48 && opticalDiscriminator < 86; + + /// [tsSeconds] gated to the plausible unix range, null otherwise. + /// + /// The band ships with its RTC unset, and an unset RTC reports a near-1970 + /// epoch through this field as if it were fact. Hello is the primary gen5 + /// clock source, so clock consumers must take THIS read — the raw + /// [tsSeconds] stays only as the wire truth. + int? get tsSecondsOrNull => _plausibleUnix(tsSeconds) ? tsSeconds : null; + + /// `major.minor.build.unreleased`, e.g. `50.40.1.0`. + String get firmwareVersion => '$fwMajor.$fwMinor.$fwBuild.$fwUnreleased'; + + /// `major.minor.patch`, e.g. `11.1.0`. + String get signalProcessorVersion => + '$sigprocMajor.$sigprocMinor.$sigprocPatch'; + + /// The all-zero serial is an EEPROM-failure signal, not a hard reject on its + /// own — a readiness gate should still accept it (it passes the + /// alphanumeric gate). Surfaced so a caller can decide. + bool get serialLooksEepromFailure => + serial.isNotEmpty && serial.split('').every((c) => c == '0'); + + /// Parse EXACTLY the response body (no header). Returns null when the body is + /// shorter than the [semanticBodyLen] the fixed-offset parser needs — a short + /// body is a failed/foreign reply, never a partially-filled hello — or when + /// the body does not announce hello revision 1, since every offset below is + /// revision-1-specific and reading them out of an unknown revision would + /// invent an identity rather than decode one. + /// + /// Callers should additionally gate on the command-response STATUS byte; a + /// non-success reply leaves the body unpopulated (see [parseCommandResponse]). + static Gen5HelloInfo? parse(Uint8List body) { + if (body.length < semanticBodyLen) return null; + if (body[0] != 1) return null; // revision-1 map only + String cstr(int start, int end) { + final sb = StringBuffer(); + for (int i = start; i < end && i < body.length; i++) { + final c = body[i]; + if (c == 0) break; + if (c < 0x20 || c >= 0x7F) return ''; + sb.writeCharCode(c); + } + return sb.toString(); + } + + final batteryRaw = u32(body, 1) ~/ 10; + return Gen5HelloInfo( + helloRevision: body[0], + batteryPct: (batteryRaw >= 0 && batteryRaw <= 100) ? batteryRaw : null, + charging: (body[5] & 0x01) != 0, + tsSeconds: u32(body, 6), + tsSubseconds: u32(body, 10), + serial: cstr(14, 25), + commitHex: _hex(Uint8List.sublistView(body, 25, 49)), + cpuHex: _hex(Uint8List.sublistView(body, 49, 79)), + hardwareFamily: u32(body, 79), + pcbaRevision: u32(body, 83), + opticalDiscriminator: u32(body, 87), + fwMajor: body[91], + fwMinor: body[92], + fwBuild: body[93], + fwUnreleased: u32(body, 94), + sigprocMajor: body[98], + sigprocMinor: body[99], + sigprocPatch: body[100], + hrBroadcast: body[101] != 0, + wristOn: body[102] == 1, + errorByte: body[103] >= 128 ? body[103] - 256 : body[103], + rawHex: _hex(body), + ); + } +} + /// 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). @@ -428,10 +609,26 @@ Uint8List _envelopeBody(Uint8List inner) { inner, 12, end < inner.length ? end : inner.length); } -EventInfo? parseEvent(Uint8List inner) { +/// Event ids whose names and body decodes are pinned on gen5 hardware only. +/// On a gen4 link the same id number may mean something else entirely (gen4's +/// neighbouring 26/27/28 are known, 29 is not), so a gen4 parse keeps these +/// numeric and un-decoded rather than confidently mislabeled. +const Set _kGen5ScopedEventIds = { + EventId.strapConditionReport, + EventId.hapticsTerminated, + EventId.batteryPackInfo, + EventId.genericFirmwareEvent, +}; + +EventInfo? parseEvent( + Uint8List inner, { + BandProfile profile = BandProfile.gen4, +}) { if (inner.length < 4 || inner[0] != PacketType.event) return null; final eid = u16(inner, 2); - final name = EventId.name(eid); + final gen5ScopedOut = + _kGen5ScopedEventIds.contains(eid) && !profile.isGen5; + final name = gen5ScopedOut ? 'EVENT_$eid' : EventId.name(eid); // Timestamp: whole seconds u32 @ [4], sub-seconds u16 @ [8]; the event body // begins at [12]. All guarded by length so short frames degrade cleanly. final ts = inner.length >= 8 ? u32(inner, 4) : 0; @@ -475,6 +672,78 @@ EventInfo? parseEvent(Uint8List inner) { dec['charging'] = body[9] != 0; } break; + case EventId.strapConditionReport: + if (gen5ScopedOut) break; + // Body: page backlog u32 @0; backlog tenths u16/10 @4; state-of-charge + // tenths u16/10 @6; then three single bytes — flash @8, charging @9, + // wrist tri-state @10. + // + // `condition_pages_behind` is the SAME quantity GET_DATA_RANGE reports + // as `pages_behind`: a modular PAGE span from trim to write (~15 + // records/page nominal), never a packet or record count. This event + // volunteers it live, so a host can watch the backlog without polling. + // + // Every field is length-gated on its own so a short frame yields the + // prefix it really carried rather than nothing (or garbage). + if (body.length >= 4) dec['condition_pages_behind'] = u32(body, 0); + if (body.length >= 6) { + dec['condition_backlog'] = _round(u16(body, 4) / 10.0, 1); + } + if (body.length >= 8) { + // Same 0..100 gate as the BATTERY_LEVEL branch: an out-of-range value + // is not a state of charge, so emit nothing rather than a number a UI + // would render. + final soc = _round(u16(body, 6) / 10.0, 1); + if (soc.isFinite && soc >= 0.0 && soc <= 100.0) { + dec['condition_soc_pct'] = soc; + } + } + if (body.length >= 9) dec['condition_flash'] = body[8]; + if (body.length >= 10) dec['condition_charging'] = body[9] != 0; + // Tri-state, and the doc names no mapping for the three values — kept + // raw rather than guessed into a bool. Deliberately NOT emitted as + // `on_wrist`: the wear truth comes from WRIST_ON/WRIST_OFF and hello. + if (body.length >= 11) dec['condition_wrist_state'] = body[10]; + break; + case EventId.hapticsTerminated: + if (gen5ScopedOut) break; + // body[0] revision, body[1] cause. `user_double_tap` is how the wearer + // dismisses a running alarm — a dismissal and a timeout are different + // facts, so both the code and its name are surfaced. + if (body.length >= 2) { + dec['haptics_revision'] = body[0]; + dec['haptics_termination_code'] = body[1]; + dec['haptics_termination'] = HapticsTermination.name(body[1]); + } + break; + case EventId.batteryPackInfo: + if (gen5ScopedOut) break; + // revision @0, BT address 1..6, device name 7..22, battery-level + // structure 23..24, colourway 25, hardware family 26 — the + // GET_BATTERY_PACK_INFO(151) content volunteered as an event. + if (body.length >= 27) { + dec['pack_revision'] = body[0]; + dec['pack_address'] = _macAddress(body, 1); + // Only a real printable name is surfaced; an all-NUL field yields + // nothing rather than an empty-string "name". + final packName = _printableRun(body, 7, 23); + if (packName.isNotEmpty) dec['pack_name'] = packName; + dec['pack_battery_raw'] = u16(body, 23); + dec['pack_colorway'] = body[25]; + dec['pack_hardware_family'] = body[26]; + } + break; + case EventId.genericFirmwareEvent: + if (gen5ScopedOut) break; + // body[0] revision, u16 LE sub-id at body[1]. Only sub-id 6 + // (DORSET_DETECTED) has a known name; the rest stay numeric. + if (body.length >= 3) { + dec['fw_event_revision'] = body[0]; + final subId = u16(body, 1); + dec['fw_event_id'] = subId; + dec['fw_event'] = FirmwareEventId.name(subId); + } + break; case EventId.highFreqSyncPrompt: dec['high_freq_sync'] = 'prompt'; break; @@ -558,20 +827,36 @@ CmdResponse? parseCommandResponse(Uint8List inner, 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[51], 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). + // GET_HELLO_HARVARD (0x23) and a DIFFERENT, FIXED layout + // ([Gen5HelloInfo]). The body is what follows the 5-byte command-response header, + // i.e. `payload.sublist(2)` (payload[0]=echoed req seq, payload[1]=status). // - // The name is a 30-byte field. pay[16] — where this used to read — is a - // binary field on gen5, not the name; that offset is gen4's serial slot. - if (payload.length >= 81) { - final name = _cstrAt(payload, 51); - if (name.isNotEmpty) dec['device_name'] = name; - } - if (payload.length >= 97 && payload[93] == 50) { - dec['fw_version'] = Uint8List.fromList(payload.sublist(93, 97)); + // The previous decode read a "device_name" at pay[51] and a 4-byte + // "fw_version" at pay[93] — but pay[51] (body offset 49) is the 30-byte + // CPU/signature field, and the firmware version lives at body 91..94 + // (pay[93]==body[91] is the fw MAJOR byte, which is only why the old + // ==50 gate happened to hold). Both are superseded by the full map. + // + // STATUS-GATED, like the battery and clock reads above/below: hello + // answers PENDING (2) before its terminal result, and FAILURE (0) / + // UNSUPPORTED (3) are real wire cases. A non-success reply does not + // populate the body, so its bytes are whatever the buffer held last — + // parsing them would mint a confident serial, battery and firmware version + // out of stale memory. + if (status == 1) { + final body = + payload.length >= 2 ? Uint8List.sublistView(payload, 2) : payload; + final h = Gen5HelloInfo.parse(body); + if (h != null) dec['gen5_hello'] = h; + // Compat: the firmware version this branch used to emit was ACCIDENTALLY + // correct (it read body[91..94], the true major/minor/build/unreleased), + // so keep the key alive for one release rather than silently returning + // null to existing callers. `device_name` is deliberately NOT restored — + // it was the CPU/signature field, i.e. a wrong value. + if (h != null) { + dec['fw_version'] = Uint8List.fromList( + [h.fwMajor, h.fwMinor, h.fwBuild, h.fwUnreleased & 0xFF]); + } } } else if (op == Cmd.getAlarmTime && payload.isNotEmpty) { // GET_ALARM_TIME echoes whichever alarm form the strap holds, and the @@ -585,13 +870,51 @@ CmdResponse? parseCommandResponse(Uint8List inner, // alarm_epoch rather than guessing an offset. // The form byte is the first byte of the reply BODY, which starts at // payload[2] — payload[0] is the echoed request seq. + // + // The revision-4 GET response is pinned: + // body[0] revision 04 body[1] ACTIVE flag (exactly 1 = active) + // body[2:6] epoch u32 LE body[6:8] subseconds u16 LE + // — which confirms the epoch offset used here, and adds the active flag. + // The response carries no alarm ID; the requested ID selects it. final form = payload.length >= 3 ? payload[2] : -1; if (form == 0x01 && payload.length >= 7) { dec['alarm_epoch'] = u32(payload, 3); } else if (form == 0x04 && payload.length >= 8) { dec['alarm_epoch'] = u32(payload, 4); + // "exactly 1 means active" — anything else is not an armed alarm, and is + // reported as inactive rather than guessed at. + dec['alarm_active'] = payload[3] == 1; + } + } else if (op == Cmd.setAlarmTime || op == Cmd.runAlarm) { + // Both replies carry a haptics/alarm STATUS at response-body offset 1 — + // the SET reply is `[revision][status]…` (remaining response bytes are + // ignored) and the RUN reply is exactly `[02, status]`. Same offset on + // both, so one branch. + // The body starts at payload[2], hence payload[3]. + // + // Deliberately NOT status-gated, unlike the battery/clock/hello reads + // above: this status arrives in addition to the ordinary outer command + // result — check both. A FAILURE reply's status byte is the + // diagnostic — it is where `invalid alarm time` and `invalid alarm ID` + // actually appear — so dropping it on a non-success outer result would + // discard the only explanation the strap ever gives. + // + // The revision byte at body 0 is read but NOT gated on: it reads 3 for + // SET and 2 for RUN, and a strap answering with a revision we do not + // recognise still put a status byte where the status byte goes. + if (payload.length >= 4) { + final code = payload[3]; + dec['alarm_status'] = code; + dec['alarm_status_name'] = AlarmStatus.name(code); } - } else if (op == Cmd.getAdvertisingNameHarvard) { + } else if (op == Cmd.getAdvertisingNameHarvard || + op == Cmd.getCustomAdvertisingName) { + // gen4's 0x4C and gen5's 0x8D (=141) replies share the same shape at the + // same payload offsets: 141's reply is revision, status, length, name — + // i.e. length at body[2] (= payload[4]) and ASCII name from body[3] + // (= payload[5]), exactly where _decodeAdvName already reads them. + // Without this branch the gen5 bootstrap's final pre-READY read was sent + // but its reply never decoded. dec['strap_name'] = _decodeAdvName(payload); } else if (op == Cmd.getClock || op == Cmd.getClockGen5) { // Reply bodies (the body starts at payload[2]): @@ -613,7 +936,12 @@ CmdResponse? parseCommandResponse(Uint8List inner, // battery read is not: the status byte is unconfirmed there, and a wrong // assumption means clock_epoch is never emitted at all, which fails // silently — no stall, no log, just an RTC that never correlates. - final statusOk = op != Cmd.getClockGen5 || status == 1; + // + // Keyed on the PROFILE, not the opcode: gen5 reads its clock with the + // shared GET_CLOCK(11) (physically confirmed — the unverified 147 is + // deprecated), so gating on `op == getClockGen5` would have quietly dropped + // this protection the moment the correct opcode started being used. + final statusOk = !profile.isGen5 || status == 1; if (statusOk && revOk && payload.length >= at + 4) { final v = u32(payload, at); // Emit nothing rather than a guess: a value outside the plausible @@ -684,7 +1012,7 @@ CmdResponse? parseCommandResponse(Uint8List inner, dec['battery_pack_info'] = BatteryPackInfoResponse( revision: payload[2], attached: payload[3] == 1, - identifier: _batteryPackId(payload), + identifier: _macAddress(payload, 4), name: _batteryPackName(payload), batteryPackTypeRaw: payload[28], statusRaw: payload[29], @@ -698,10 +1026,13 @@ CmdResponse? parseCommandResponse(Uint8List inner, return CmdResponse(op, dec); } -String _batteryPackId(Uint8List payload) { - final bytes = payload.sublist(4, 10); - return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); -} +/// The six-byte BT address at [start], rendered `aa:bb:cc:dd:ee:ff`. Shared by +/// the GET_BATTERY_PACK_INFO reply and the BATTERY_PACK_INFO(109) event, which +/// carry the same field at different offsets. +String _macAddress(Uint8List b, int start) => b + .sublist(start, start + 6) + .map((v) => v.toRadixString(16).padLeft(2, '0')) + .join(':'); String _batteryPackName(Uint8List payload) { return _printableRun(payload, 10, 26); @@ -990,16 +1321,28 @@ Decoded decodeFrame(Frame frame, {BandProfile profile = BandProfile.gen4}) { } break; case PacketType.event: - final e = parseEvent(inner); + final e = parseEvent(inner, profile: profile); if (e != null) { + // Spread FIRST so the frame-level keys stay authoritative — a + // future per-event key named `retain_raw` or `event_id` must not + // be able to clobber them (`retain_raw` decides whether a client + // keeps a burst-count member, so a collision would silently drop + // history records). return Decoded('event', { + ...e.decoded, 'event': e.name, 'event_id': e.eventId, 'ts_epoch': e.tsEpoch, - ...e.decoded + 'retain_raw': true, // history-count member }); } - break; + // A type-48 frame whose body we cannot parse is STILL a burst count + // member and still has to be retained — falling through to 'other' + // would make a client drop it and undercount the burst. + return Decoded('event_unparsed', { + 'packet_type': pt, + 'retain_raw': true, + }); case PacketType.metadata: final m = parseMetadata(inner); if (m != null) { @@ -1013,13 +1356,34 @@ Decoded decodeFrame(Frame frame, {BandProfile profile = BandProfile.gen4}) { 'record_index': c.recordIndex, 'ts_epoch': c.unix, 'text': c.text, + 'retain_raw': true, // history-count member }); } - break; + // Same reasoning as the type-48 fall-through above: a console frame we + // cannot read is still one burst count member. + return Decoded('console_unparsed', { + 'packet_type': pt, + 'retain_raw': true, + }); case PacketType.historicalData: case PacketType.realtimeData: case PacketType.realtimeRawData: return _decodeDataRecord(inner, profile: profile); + case PacketType.relativePuffinEvents: + case PacketType.puffinEventsFromStrap: + case PacketType.relativeBatteryPackConsoleLogs: + // Battery-pack ("puffin") event/log wrappers. Their bodies are not + // decoded into fields here, but each complete frame IS a member of the + // Sensor-HPS history count and must be retained — so give them + // a named kind (never 'other', which a client would drop) and flag the + // frame for the client's raw archive + burst-count paths. + return Decoded('puffin_event', {'packet_type': pt, 'retain_raw': true}); + case PacketType.puffinCommand: + case PacketType.puffinCommandResponse: + case PacketType.puffinMetadata: + // Battery-pack command/response/metadata. Named (not 'other') for the + // same reason; NOT history-count members. + return Decoded('puffin', {'packet_type': pt}); } } catch (e) { return Decoded('decode_error', {'error': e.toString()}); diff --git a/lib/src/gen5_records.dart b/lib/src/gen5_records.dart index 1d6b0a6..8005dd2 100644 --- a/lib/src/gen5_records.dart +++ b/lib/src/gen5_records.dart @@ -1,8 +1,10 @@ // gen5_records.dart — WHOOP 5 (gen5 / "fd4b" / "Maverick-Goose") historical // record decoders: v18 (per-second biometric summary), v20 (raw optical deep // buffer, 5 AFE channels × 2 photodiodes), v21 (100Hz 6-axis IMU deep buffer), -// v26 (24-SAMPLE PPG waveform — 24 is a sample count, not a rate; the rate is -// whatever PPG rate the record's own flags byte declares, see +// v26 (the Pulse Information Packet — a copied 72-byte PIP ring record holding +// a 25-sample optical window encoded as one absolute first sample plus 24 +// SATURATED DELTAS; 24/25 are sample counts, not rates — the rate is whatever +// PPG rate the record's own flags byte declares, see // [Gen5HistoricalRecord.ppgSampleRateHz]). // // REPLACES `records.dart`'s old `parseGen5Record` / `_gen5NormalHistoryVersions @@ -13,7 +15,9 @@ // (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. +// gen5_historical_test.dart for the golden parity tests. v26's trailing +// metadata block was additionally re-derived (see the v26 section below) +// after an earlier reading of it turned out to be wrong. // // v20's geometry (body start, block stride, slot offsets, sign-extended 20-bit // samples) and its per-block metadata are confirmed too — see @@ -202,26 +206,40 @@ class Gen5HistorySample extends Gen5HistoricalRecord { /// "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); the low 4 bits are a separate - /// small field — see [hrQualityCounter]. + /// @ inner[28] (frame-abs 36) — a flags-plus-counter byte. /// - /// NOT the low half of a fixed-point heart rate. **bit4 is never set** — 0 - /// of 2,663,358 records across two bands — and a fractional byte cannot - /// have a structurally dead bit. The low 4 bits are a separate field, - /// uniform over 0..15; bits 5-7 are flags. + /// **bit7 is NOT an "HR valid this second" flag.** Records routinely carry a + /// valid HR (25..230) with bit7 CLEAR, and the bit toggles roughly 50/50 + /// independent of HR presence, so it cannot gate HR validity. No discrete + /// per-second HR-valid field exists anywhere in v18: HR PRESENCE is + /// `heartRate` in range, and HR QUALITY is [signalQualityLogVariance]. + /// bit7 does track agreement between [heartRate] and [heartRateAlt] — see + /// [hrRrValidThisSecond]. + /// + /// Other bits: 0x10 = source-2 (CH3 infrared) selected, 0x20 = + /// transition/hold, 0x40 = a mirror bit. The low 4 bits are a separate small + /// field, uniform over 0..15 — see [hrQualityCounter]. final int hrQualityFlags; /// Low 4 bits of [hrQualityFlags] — a small field, uniform over 0..15, /// independent of bit7. Purpose unknown; exposed for diagnostics only. int get hrQualityCounter => hrQualityFlags & 0x0F; - /// Second heart-rate byte @ inner[29] (frame-abs 37). + /// bit `0x10` of [hrQualityFlags]: the signal processor selected source + /// index 2 — **CH3 infrared** — as the HR source this second. + /// + /// Never observed set in resting/sleep records, which is consistent with + /// green staying selected — so treat "false" as "green, or not exercised", + /// not as proof the band cannot switch. + bool get irSourceSelected => (hrQualityFlags & 0x10) != 0; + + /// Second heart-rate-like byte @ inner[29] (frame-abs 37). /// - /// Not the near-duplicate of [heartRate] this was previously documented as: - /// it agrees 58-64% of the time, rising to 67-75% when - /// [hrRrValidThisSecond]. The gate is real, but even gated this is a - /// corroboration signal — do not substitute it for [heartRate]. + /// Exposed raw and UNCLAIMED: it usually tracks [heartRate] closely, with + /// occasional near-misses of a few bpm and occasional zeros while + /// [heartRate] is present (one estimator abstaining), but its exact + /// semantics are not pinned. Do not substitute it for [heartRate] or consume + /// it as a decoded metric — see [trustedHeartRateAlt] for the gated read. final int heartRateAlt; /// @ inner[30:32] (frame-abs 38). Meaning UNPINNED — exposed raw, do not @@ -236,8 +254,11 @@ class Gen5HistorySample extends Gen5HistoricalRecord { /// Exposed raw ONLY. Do NOT wire an HR-anomaly gate off this byte. final int cardiacStatusRaw; - /// Gravity-removed motion magnitude (g) @ inner[33:37] f32 LE (frame-abs - /// 41). NULL when the bytes are not a finite in-full-scale value — absent, + /// Maximum adjacent acceleration-vector-magnitude delta (g) @ inner[33:37] + /// f32 LE (frame-abs 41), computed on-band from the same 100 raw accel + /// samples as R21. NOT a gravity-removed magnitude — it is the max adjacent + /// delta, and R26 carries the identical f32 (byte-equal on paired records). + /// NULL when the bytes are not a finite in-full-scale value — absent, /// not zero. The rest of the record is still valid (see [Gen5V18Decoder]). final double? dynamicAccelerationG; @@ -252,13 +273,28 @@ class Gen5HistorySample extends Gen5HistoricalRecord { /// 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. + /// + /// Passive behaviour supports a counter, but the NAME is not established: + /// the byte pair is near-monotonically non-decreasing across long runs of + /// records, i.e. it behaves like a wrapping counter rather than a static + /// config value. That is behaviour, not semantics — nothing yet ties these + /// bytes to steps as their producer. Two independent third-party clients + /// read it as an on-chip step counter, which is why this field keeps that + /// name; treat it as their inference until the producer is pinned. final int stepMotionCounter; /// Raw @ inner[51] u8 (frame-abs 59). inner[52] is a hard zero, so a u16 read /// here happens to give the same number — but the field is one byte. + /// + /// NAME CAVEAT, same as [stepMotionCounter]: only the behaviour is pinned + /// (smooth over 51..254); the "cadence" label is the reference clients' + /// inference, kept for compatibility until the producer is pinned. final int stepCadence; - /// RAW byte @ inner[55] (frame-abs 63). Only 0 / 1 (walk) / 2 (run) are + /// RAW byte @ inner[55] (frame-abs 63). NAME CAVEAT, same as + /// [stepMotionCounter]: only the behaviour ({0,1,2}) is pinned; the + /// walk/run reading below is the reference clients' inference. + /// Only 0 / 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 @@ -279,70 +315,110 @@ class Gen5HistorySample extends Gen5HistoricalRecord { ? activityClass : null; - /// °C = raw/10. @ inner[61:63] i16 LE (frame-abs 69). + /// Fuel-gauge CELL temperature, °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). + /// Fuel-gauge AMBIENT temperature, °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] **i16** LE (frame-abs 73). Signed: an - /// unsigned read turns anything below 0 °C into ≈ +655 °C. Confirmed - /// worn≈30.6°C / off-wrist≈22.5°C on real captures. + /// AS6221 SKIN temperature, °C = raw/100 — a GEN5-SPECIFIC scale; do NOT + /// reuse gen4's per-device affine scale here. @ inner[65:67] **i16** LE + /// (frame-abs 73). Signed: an unsigned read turns anything below 0 °C into + /// ≈ +655 °C. Confirmed worn≈30.6°C / off-wrist≈22.5°C on real captures. + /// + /// The raw sentinel **-5000 (= -50.00 °C) means unavailable/error** — use + /// [skinTempAvailable] before showing this as a temperature, or + /// [skinTempCOrNull] to abstain honestly. final double skinTempC; - /// Raw, not deep-sleep markers per noop. @ inner[67/69/71] u16 LE each - /// (frame-abs 75/77/79). + /// False when [skinTempC] is the -50.00 °C unavailable/error sentinel. + bool get skinTempAvailable => (skinTempC * 100).round() != -5000; + + /// [skinTempC] or null when the reading is the unavailable sentinel — the + /// honest accessor (never surface -50 °C as a real skin temperature). + double? get skinTempCOrNull => skinTempAvailable ? skinTempC : null; + + /// The three packed per-channel AGC/state words @ inner[67/69/71] u16 LE + /// (frame-abs 75/77/79). NOT deep-sleep markers (the noop reading). + /// Bit layout: + /// bits 0-1 channel index bits 2-3 zero + /// bits 4-7 PD-A/PD-B AGC offset-current indices + /// bits 8-11 LED-current index + /// bits 12-15 saturation-majority flags — set when more than 20 of the 25 + /// processed samples saturated high/low for PD A/B + /// An INACTIVE channel reads `0x0c00 | channel_index`. The saturation bits + /// are the useful ones for a consumer: a second whose optical channel + /// saturated is not a trustworthy optical second. final int statusWord; final int statusWord1; final int statusWord2; - /// Raw @ inner[73] (frame-abs 81). FOUR packed 2-bit fields: - /// bits 0-1: on-wrist - /// bits 2-3: wake_quality + /// True when any of the three channel words reports a saturation majority + /// (bits 12-15) — i.e. this second's optical signal was rail-pinned on at + /// least one detector. A quality gate, not a measurement. + bool get opticalSaturated => + ((statusWord | statusWord1 | statusWord2) & 0xF000) != 0; + + /// Raw @ inner[73] (frame-abs 81). Packed 2-bit slots: + /// bits 0-1: **primary-flags bit-8 snapshot** — NOT a wear indicator. (The + /// same source appears at R26 body 60; the two records mirror it.) + /// bits 2-3: **passive strap-fit classifier state** (the feature behind + /// `enable_passive_strap_fit_gen5`) — not a "wake quality". /// bits 4-5: sleep_state — 0 wake / 1 still / 2 sleep / 3 up. Prefer /// [sleepState] over reading the nibble yourself. whoop-rs's /// "0 still / 1 wake" is the wrong way round. - /// bits 6-7: a fourth 2-bit field, real but unnamed — see [bits67Raw]. + /// bits 6-7: documented as the **high slot, zero**. Exposed raw so a + /// nonzero value is visible if firmware ever uses it — see [bits67Raw]. final int sleepStateByte; - /// @ inner[74] (frame-abs 82). Opaque, sleep-gated. Do NOT surface it as a - /// metric — there is no validity signal to gate it on. + /// @ inner[74] (frame-abs 82) — the **SpO2 estimate/status byte**. The + /// family is established; the ENCODING is not (not every nonzero encoding + /// is a percentage), so this stays a raw byte and the package refuses to + /// publish a percentage from it. /// - /// It reads 0 in 99% of records and is *identically* zero unless - /// [sleepState] is [Gen5SleepState.sleep], where it fires on 2.4% of - /// records, clustering at 95-99. That distribution does not disqualify a - /// blood-oxygen reading on this band — the measurement is itself sleep-gated - /// — so treat "is it SpO2" as open rather than settled either way. Values - /// above 128 decompose as `128 + `, so bit 7 looks - /// like a flag rather than part of the number. + /// It reads 0 in the vast majority of records and is *identically* zero + /// unless [sleepState] is [Gen5SleepState.sleep], where it fires on a small + /// fraction of records clustering at 95-99 — consistent with a scheduled + /// overnight sampling cadence, and with the R22 tag-3 pairing where every + /// nonzero-SpO2 second coincides with a populated CH2/CH4 window. Values + /// above 128 decompose as `128 + `, so bit 7 + /// looks like a flag rather than part of the number. + /// + /// Surfacing a user-facing SpO2 needs the encoding pinned first. final int spo2CandidateRaw; - /// @ inner[98:100] (frame-abs 106) — ONE **big-endian** u16, not two bytes. - /// A zero low byte means off-wrist. - final int opticalBaseline; - - /// @ inner[100:102] (frame-abs 108) — ONE **big-endian** u16. The value - /// 0x8080 is a signal-quality sentinel rather than a real amplitude — see - /// [isOpticalAmpSentinel]. (Read as two bytes it looks like "128 on both - /// halves at once", which is what it was previously mistaken for.) - final int opticalAmp; - - /// High byte of [opticalBaseline]. - @Deprecated('inner[98:100] is one big-endian u16 — use opticalBaseline') - int get opticalBaselineA => opticalBaseline >> 8; - - /// Low byte of [opticalBaseline]. - @Deprecated('inner[98:100] is one big-endian u16 — use opticalBaseline') - int get opticalBaselineB => opticalBaseline & 0xFF; - - /// High byte of [opticalAmp]. - @Deprecated('inner[100:102] is one big-endian u16 — use opticalAmp') - int get opticalAmpA => opticalAmp >> 8; - - /// Low byte of [opticalAmp]. - @Deprecated('inner[100:102] is one big-endian u16 — use opticalAmp') - int get opticalAmpB => opticalAmp & 0xFF; + /// `inner[98]`/`inner[99]` (u8 each): two quantized selected-source + /// photodiode MEAN diagnostics (PD-B then PD-A, in that order). NOT one + /// big-endian u16 — the band writes these as independent bytes with an + /// exact per-detector transform but no established physical unit. + final int pdMeanB; + final int pdMeanA; + + /// `inner[100]`/`inner[101]`: two SIGNED int8 per-detector pSNR values in + /// dB for the currently selected HR source (PD-B then PD-A). + /// **-128 (`0x80`) means unavailable** — do not read it as −128 dB. This is + /// why reading [100:102] as one big-endian u16 produced the spurious `0x8080` + /// "sentinel": it was simply both detectors reporting unavailable at once. + /// The two move as a pair (never observed unavailable alone), which is why + /// the paired 0x8080 value looked like a single sentinel. + final int psnrB; + final int psnrA; + + /// Whether the pSNR value for that detector is a real reading (not the -128 + /// unavailable sentinel). + bool get psnrBAvailable => psnrB != -128; + bool get psnrAAvailable => psnrA != -128; + + /// COMPAT (deprecated): the old big-endian-u16 reads of these byte pairs. + /// Both conflated two independent fields; kept only so existing callers still + /// compile. `opticalBaseline` was `(pdMeanB<<8)|pdMeanA` and `opticalAmp` the + /// raw `(psnrB<<8)|psnrA` — the latter is the `0x8080` both-unavailable case. + @Deprecated('two independent u8 PD means, not a u16 — use pdMeanB/pdMeanA') + int get opticalBaseline => (pdMeanB << 8) | pdMeanA; + @Deprecated('two independent i8 pSNR values, not a u16 — use psnrB/psnrA') + int get opticalAmp => ((psnrB & 0xFF) << 8) | (psnrA & 0xFF); /// @ inner[105:109] f32 LE (frame-abs 113). A per-second signal-quality /// metric the band's own optical processing computes: the log-variance of @@ -384,20 +460,25 @@ class Gen5HistorySample extends Gen5HistoricalRecord { required this.statusWord2, required this.sleepStateByte, required this.spo2CandidateRaw, - required this.opticalBaseline, - required this.opticalAmp, + required this.pdMeanB, + required this.pdMeanA, + required this.psnrB, + required this.psnrA, required this.signalQualityLogVariance, }); /// bit7 of [hrQualityFlags] — whether [heartRateAlt] is corroborated this /// second. + /// + /// NOT an "HR valid" bit ([heartRate] is routinely present and in range + /// while this bit is clear — see [hrQualityFlags]). Gate nothing on it + /// except [trustedHeartRateAlt]. bool get hrRrValidThisSecond => (hrQualityFlags & 0x80) != 0; /// [heartRateAlt] gated on [hrRrValidThisSecond]; null when unconfirmed. /// - /// Note that "confirmed" still only means 67-75% agreement with - /// [heartRate] — see [heartRateAlt]. This is a corroboration signal, not a - /// substitute HR. + /// "Confirmed" is a corroboration signal between the record's two + /// heart-rate bytes, not a substitute HR — read [heartRate] for the value. int? get trustedHeartRateAlt => hrRrValidThisSecond ? heartRateAlt : null; /// **Do not use.** The byte is zero in 99% of records and nonzero *only* @@ -413,22 +494,37 @@ class Gen5HistorySample extends Gen5HistoricalRecord { ? spo2CandidateRaw : null; - /// True when [opticalAmp] reads the 0x8080 sentinel — a signal-quality flag, - /// not a real amplitude. - bool get isOpticalAmpSentinel => opticalAmp == 0x8080; + /// True when BOTH per-detector pSNR values read the -128 unavailable + /// sentinel — i.e. the old `opticalAmp == 0x8080` case, which was never a + /// real amplitude, just both detectors reporting unavailable at once. + bool get isOpticalAmpSentinel => !psnrBAvailable && !psnrAAvailable; + + /// bits 0-1 of [sleepStateByte] — the primary-flags bit-8 snapshot. + int get primaryFlagsBit8Raw => sleepStateByte & 0x03; - /// bits 0-1 of [sleepStateByte]. - int get onWristRaw => sleepStateByte & 0x03; + /// bits 2-3 of [sleepStateByte] — the passive strap-fit classifier state. + int get strapFitStateRaw => (sleepStateByte >> 2) & 0x03; - /// bits 2-3 of [sleepStateByte]. - int get wakeQualityRaw => (sleepStateByte >> 2) & 0x03; + /// **Deprecated: not a wear indicator.** Bits 0-1 are the primary-flags + /// bit-8 snapshot; nothing supports reading + /// them as on-wrist. Wear state comes from the HELLO body, the wrist + /// on/off events, or the fact that the band stops emitting type-40 off-wrist. + @Deprecated('bits 0-1 are the primary-flags bit-8 snapshot, not wear state — ' + 'use primaryFlagsBit8Raw (and do not treat it as on-wrist)') + int get onWristRaw => primaryFlagsBit8Raw; + + /// **Deprecated: misnamed.** These bits are the passive strap-fit classifier + /// state, not a wake-quality score. + @Deprecated('bits 2-3 are the passive strap-fit classifier state — ' + 'use strapFitStateRaw') + int get wakeQualityRaw => strapFitStateRaw; /// bits 4-5 of [sleepStateByte], raw. Prefer [sleepState]. int get sleepStateRawNibble => (sleepStateByte >> 4) & 0x03; - /// bits 6-7 of [sleepStateByte] — a real fourth 2-bit field in this byte, - /// alongside on-wrist / wake-quality / sleep-state. Exposed raw; no name is - /// claimed for it because none is established. + /// bits 6-7 of [sleepStateByte] — a high slot that always reads ZERO on + /// current firmware. Exposed raw rather than assumed away, + /// so a nonzero value on some future firmware is visible instead of silent. int get bits67Raw => (sleepStateByte >> 6) & 0x03; /// The band's own coarse wake/sleep state. Total over the 2-bit nibble, so @@ -450,14 +546,21 @@ const int kGen5V18InnerLen = 112; /// f32 at 105). /// /// This, not [kGen5V18InnerLen], is the ACCEPTANCE gate. Requiring exactly 112 -/// is a stricter claim than the field map needs, and gen5 has never run against -/// real hardware here — v18 is the only gen5 record that becomes a 1 Hz sample, -/// so if real inners are any other length the band yields zero rows and -/// everything lands in the archive. A prior lenient-decode fix on this branch -/// exists precisely because a strict gate rejected real captures. +/// is a stricter claim than the field map needs — v18 is the only gen5 record +/// that becomes a 1 Hz sample, so if real inners are any other length the band +/// yields zero rows and everything lands in the archive, and a prior +/// lenient-decode fix on this branch exists precisely because a strict gate +/// rejected real captures. +/// +/// Every fully-reassembled v18 record observed is exactly 112 bytes, so the +/// lenient floor costs nothing in steady state; it stays only to tolerate a +/// truncated / partially-reassembled delivery rather than to admit a +/// different real length. +/// A caller that wants the signal can compare against [kGen5V18InnerLen]. const int kGen5V18MinReadableLen = 109; -@Deprecated('v18 inner is exactly 112 bytes, not a floor — use kGen5V18InnerLen') +@Deprecated( + 'v18 inner is exactly 112 bytes, not a floor — use kGen5V18InnerLen') const int kGen5V18MinInnerLen = kGen5V18InnerLen; class Gen5V18Decoder implements Gen5RecordDecoder { @@ -550,8 +653,10 @@ class Gen5V18Decoder implements Gen5RecordDecoder { statusWord2: v.getUint16(71, Endian.little), sleepStateByte: inner[73], spo2CandidateRaw: inner[74], - opticalBaseline: v.getUint16(98, Endian.big), - opticalAmp: v.getUint16(100, Endian.big), + pdMeanB: inner[98], + pdMeanA: inner[99], + psnrB: inner[100] >= 128 ? inner[100] - 256 : inner[100], + psnrA: inner[101] >= 128 ? inner[101] - 256 : inner[101], signalQualityLogVariance: _finiteOrNull(_round(v.getFloat32(105, Endian.little), 4)), ); @@ -564,12 +669,16 @@ class Gen5V18Decoder implements Gen5RecordDecoder { /// LED drive configuration, and its two photodiode sample slots. See /// [Gen5OpticalBuffer] for which channel each block index is. /// -/// Per the reference corpus (29,203 records, both sources), only blocks 0/3/4 -/// carry samples (`activeSampleCount ∈ {0, 25}`); blocks 1/2 read empty. +/// Blocks 0/3/4 carry samples on most records (`activeSampleCount ∈ +/// {0, 25}`); blocks 1 and 2 usually read empty but DO occasionally carry a +/// full 25-sample window, so no block may be skipped on the assumption it is +/// structurally dead. /// -/// [channel0]/[channel1] are the block's two PHOTODIODES — not two -/// wavelengths. Both slots see the same LED drive; they differ in where on the -/// wrist they sit. The neutral names are kept deliberately. +/// [channel0]/[channel1] are the block's two DETECTOR PATHS — stream A is +/// TIA 1 and stream B is TIA 2, not two wavelengths and not fixed PD1/PD2: +/// which physical photodiode feeds each TIA is dynamic and must be read from +/// the descriptor ([channel0Source]/[channel1Source]). Both paths see the +/// same LED drive. The neutral names are kept deliberately. class Gen5OpticalBlock { /// @ block byte 0. Shared by both photodiode slots. 0 or 25 in the reference /// corpus; capped to the 50-slot capacity of a 200-byte/4-byte-sample slot. @@ -585,12 +694,15 @@ class Gen5OpticalBlock { /// [ledBCurrentRaw]. Kept raw as well, for re-derivation. final Uint8List sharedMetaRaw; - /// bytes[7:14] — photodiode 0's front-end configuration: - /// {source byte, u32 ADC range, u16 ADC offset}. See [channel0Source], - /// [channel0AdcRange], [channel0AdcOffset]. + /// bytes[7:14] — the TIA 1 detector path's front-end configuration: + /// {physical-PD source byte, u32 ADC full-scale range in µA, i16 + /// offset-current setting in 10 nA/LSB}, at descriptor-relative offsets + /// 6/7/11 of the 20-byte per-block descriptor. See + /// [channel0Source], [channel0AdcRange], [tia1OffsetCurrentRaw]. final Uint8List channel0MetaRaw; - /// bytes[14:21] — photodiode 1's, same three fields. + /// bytes[14:21] — the TIA 2 detector path's, same three fields (descriptor + /// relative offsets 13/14/18). final Uint8List channel1MetaRaw; const Gen5OpticalBlock({ @@ -617,22 +729,58 @@ class Gen5OpticalBlock { int get ledBCurrentRaw => _u16(sharedMetaRaw, 4); int get ledBCurrentMicroamps => ledBCurrentRaw * 10; - /// Photodiode 0's input source selector. @ channel0Meta[0]. + /// Which PHYSICAL photodiode (1..4) is routed into the TIA 1 path for this + /// block. @ channel0Meta[0] = descriptor relative 6. Routing is dynamic — + /// stream A is not permanently PD1. int get channel0Source => channel0MetaRaw[0]; - /// Photodiode 0's ADC full-scale range. @ channel0Meta[1:5] u32. + /// TIA 1's ADC full-scale range, in µA. @ channel0Meta[1:5] u32 + /// (descriptor relative 7). int get channel0AdcRange => _u32(channel0MetaRaw, 1); - /// Photodiode 0's ADC offset. @ channel0Meta[5:7] u16. - int get channel0AdcOffset => _u16(channel0MetaRaw, 5); + /// TIA 1's offset-current setting, raw wire value: **signed i16, 10 nA/LSB + /// (0.01 µA/LSB)**. @ channel0Meta[5:7] = descriptor relative 11. + /// + /// The driver quantizes the current to 0 / 8000 / 16000 / 24000 nA and the + /// descriptor builder divides by ten with signed rounding, so wire values + /// 0 / 800 / 1600 / 2400 mean 0 / 8 / 16 / 24 µA. A firmware log string + /// labels the unscaled nA value with a "µA" suffix — that is a logging bug, + /// not an alternative unit. [tia1OffsetCurrentNanoamps] is the same number + /// in nA. + int get tia1OffsetCurrentRaw => _i16(channel0MetaRaw, 5); + int get tia1OffsetCurrentNanoamps => tia1OffsetCurrentRaw * 10; - /// Photodiode 1's, same three fields at the same offsets. + @Deprecated( + 'This field is the TIA 1 offset CURRENT: a signed i16 in 10 nA/LSB, not an ' + 'unsigned ADC offset. Use tia1OffsetCurrentRaw / ' + 'tia1OffsetCurrentNanoamps.', + ) + int get channel0AdcOffset => tia1OffsetCurrentRaw & 0xFFFF; + + /// TIA 2's, same three fields at the same relative offsets (descriptor + /// relative 13/14/18). int get channel1Source => channel1MetaRaw[0]; int get channel1AdcRange => _u32(channel1MetaRaw, 1); - int get channel1AdcOffset => _u16(channel1MetaRaw, 5); + + /// TIA 2's offset-current setting — see [tia1OffsetCurrentRaw] for the unit + /// and the quantization; @ channel1Meta[5:7] = descriptor relative 18. + int get tia2OffsetCurrentRaw => _i16(channel1MetaRaw, 5); + int get tia2OffsetCurrentNanoamps => tia2OffsetCurrentRaw * 10; + + @Deprecated( + 'This field is the TIA 2 offset CURRENT: a signed i16 in 10 nA/LSB, not an ' + 'unsigned ADC offset. Use tia2OffsetCurrentRaw / ' + 'tia2OffsetCurrentNanoamps.', + ) + int get channel1AdcOffset => tia2OffsetCurrentRaw & 0xFFFF; } int _u16(Uint8List b, int i) => b[i] | (b[i + 1] << 8); +int _i16(Uint8List b, int i) { + final v = _u16(b, i); + return v >= 0x8000 ? v - 0x10000 : v; +} + int _u32(Uint8List b, int i) => b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24); @@ -657,10 +805,13 @@ int _u32(Uint8List b, int i) => /// before assuming block 3 is IR. /// /// **Amplitudes are not comparable across records without the block -/// metadata.** Each block carries its own LED drive current and per-photodiode -/// ADC range/offset ([Gen5OpticalBlock.ledACurrentMicroamps], -/// [Gen5OpticalBlock.channel0AdcRange], ...), and the band re-tunes them -/// continuously. Two records whose raw counts differ may be the same optical +/// metadata.** Each block carries its own LED drive current and, per detector +/// path (TIA 1 / TIA 2), the ADC full-scale range in µA plus a signed +/// offset-current setting in 10 nA/LSB +/// ([Gen5OpticalBlock.ledACurrentMicroamps], +/// [Gen5OpticalBlock.channel0AdcRange], +/// [Gen5OpticalBlock.tia1OffsetCurrentNanoamps], ...), and the band re-tunes +/// them continuously. Two records whose raw counts differ may be the same optical /// signal at a different gain; normalise by current and range before comparing /// or trending. class Gen5OpticalBuffer extends Gen5HistoricalRecord { @@ -907,12 +1058,141 @@ class Gen5V21Decoder implements Gen5RecordDecoder { Gen5ImuBuffer? decode(Uint8List inner) => parseGen5ImuBuffer(inner); } -// ── v26 — single-wavelength PPG waveform, 24 SAMPLES per record. ─────────── +// ── v26 — the Pulse Information Packet (PIP). ────────────────────────────── +// +// Type 47 revision 26 — Pulse Information Packet: the 76-byte inner is the +// shared 13-byte header followed by a COPIED 72-byte PIP ring record. +// Body offsets below are inner offsets minus 13: +// +// body 0 inner 13..14 u16 LE PIP state/segment counter +// body 2 inner 15..18 first CH1 stream-A optical ADC code, i32 LE +// (sign-extended 20-bit: valid -524288..524287) +// body 6 inner 19..66 24 saturated i16 LE DELTAS over a 25-sample window +// body 54 inner 67..70 f32 LE max adjacent accel-magnitude delta, g +// body 58 inner 71..72 u16 LE packed channel-0 processing-state word +// body 60 inner 73 primary-flags bit-8 snapshot +// body 61 inner 74 binary waveform-morphology acceptance result +// body 62 inner 75 aligned inner tail, OUTSIDE the copied record +// +// This replaces an earlier reading of the same bytes (`burstIndex`@13, +// `frontEndMetaRaw`@15:17, `subChannel`@17, 24 "AC samples", `signalMetric` +// f32@67, `gainSetting`@71, `gainIndex`@72, `flagA`@73, `flagB`@74) which was +// REFUTED on hardware. Every deprecated member below says which real field its +// bytes actually belong to. +// +// The pinning facts behind this layout, all exact (every R26 record has an +// R18 twin on the shared (record_index, unix) key — zero orphans): +// +// - inner[67:71] equals the twin R18's inner[33:37] BYTE FOR BYTE. Those +// are the same f32; R18's is [Gen5HistorySample.dynamicAccelerationG] +// before rounding. +// - u16 inner[71:73] equals R18 u16 inner[67:69] +// ([Gen5HistorySample.statusWord]). +// - inner[73] & 0x03 equals R18 inner[73] & 0x03. +// - inner[15:19] read as i32 LE always lands inside -524288..524287. +// inner[17] takes ONLY the values 0x00..0x07 and 0xFD..0xFF, and +// inner[18] ONLY 0x00 or 0xFF — the signature of a sign-extended 20-bit +// code, not a byte field. The old `subChannel` "0..7 with rare 0xFD..0xFF +// outliers" was byte 2 of this i32 all along (0x00..0x07 for positive +// codes, 0xF8..0xFF for negative). // -// 24 is a sample COUNT, not a rate: nothing here runs at "24 Hz". The samples -// were taken at the optical front end's configured rate, which the record -// states itself via bit 7 of its flags byte — see -// [Gen5HistoricalRecord.ppgSampleRateHz] (25 or 50 Hz). +// The delta window is 24 deltas over 25 samples, not 24 samples: sample 0 is +// the absolute code at inner[15:19] and delta i produces sample i+1. See +// [reconstructSaturatedDeltaWindow] for why the inversion is only approximate. + +/// The result of inverting a saturated-delta optical window — the encoding +/// v26 uses, and R22 tags 1, 2 and 5 as well. +/// +/// It carries the reconstruction AND the evidence about how far to trust it, +/// because the wire +/// carries **no in-band signal** that a window has diverged. A caller handed a +/// bare `List` cannot tell a clean window from a broken one, so this type +/// refuses to hand one over on its own. +class Gen5PpgReconstruction { + /// The reconstructed window: `deltas.length + 1` absolute ADC codes, sample + /// 0 being the record's own first sample. Approximate by construction — read + /// [firstAmbiguousSampleIndex] and [outOfRangeSampleIndices] first. + final List samples; + + /// Index into [samples] of the first sample produced by a saturated delta + /// (`-32768` or `32767`), or null if the window contains none. + /// + /// `-32768` and `32767` are the two i16 saturation rails: the encoder had a + /// step it could not represent and wrote the nearer rail instead, destroying + /// the operand. That sample and **everything after it** is ambiguous, + /// because every later sample is the running sum of a value that is already + /// wrong. The positive rail is rarer than the negative one but real; only + /// the exact rail values are clamps, `±32766` and below are ordinary deltas. + final int? firstAmbiguousSampleIndex; + + /// Indices into [samples] whose value falls outside the signed-20-bit range + /// the optical front end can physically produce (-524288..524287). + /// + /// A correct reconstruction cannot produce an impossible code, so a non-empty + /// list is PROOF that this particular inversion diverged. An empty list is + /// not proof of the opposite — see [firstAmbiguousSampleIndex]. + final List outOfRangeSampleIndices; + + const Gen5PpgReconstruction({ + required this.samples, + required this.firstAmbiguousSampleIndex, + required this.outOfRangeSampleIndices, + }); + + /// True when a rail delta (`-32768`/`32767`) appeared: the tail of + /// [samples] is ambiguous. + bool get hasSaturatedDelta => firstAmbiguousSampleIndex != null; + + /// True when at least one sample is physically impossible — this window is + /// KNOWN to have diverged from the true series. + bool get divergenceProven => outOfRangeSampleIndices.isNotEmpty; + + /// How many leading samples are not downstream of a saturated delta. Still + /// only "not known to be ambiguous", never "exact". + int get trustedSampleCount => firstAmbiguousSampleIndex ?? samples.length; + + /// The leading run of [samples] that no saturated delta has poisoned. + List get trustedSamples => samples.sublist(0, trustedSampleCount); +} + +/// Physically possible optical ADC codes: the front end is a sign-extended +/// 20-bit converter (see [_signExtend20], which v20 uses on the same codes). +const int _kOpticalAdcMin = -524288; +const int _kOpticalAdcMax = 524287; + +/// Invert a "first absolute sample + saturated i16 deltas" optical window by +/// cumulative sum, reporting every reason the result may be wrong. +/// +/// Delta reconstruction is lossy: a small fraction of windows reconstruct to +/// codes the front end cannot physically produce, and the rate is not +/// constant, so a small sample can easily show zero divergence and mislead +/// you. Treat every window as approximate and range-check every sample — +/// which is what this function does for you. +Gen5PpgReconstruction reconstructSaturatedDeltaWindow( + int firstSample, + List deltas, +) { + final samples = [firstSample]; + final outOfRange = []; + int? firstAmbiguous; + if (firstSample < _kOpticalAdcMin || firstSample > _kOpticalAdcMax) { + outOfRange.add(0); + } + var acc = firstSample; + for (var i = 0; i < deltas.length; i++) { + if (deltas[i] == -32768 || deltas[i] == 32767) firstAmbiguous ??= i + 1; + acc += deltas[i]; + samples.add(acc); + if (acc < _kOpticalAdcMin || acc > _kOpticalAdcMax) { + outOfRange.add(i + 1); + } + } + return Gen5PpgReconstruction( + samples: List.unmodifiable(samples), + firstAmbiguousSampleIndex: firstAmbiguous, + outOfRangeSampleIndices: List.unmodifiable(outOfRange), + ); +} class Gen5PpgWaveform extends Gen5HistoricalRecord { /// Low byte of the record's sub-second @ inner[11] (frame-abs 19). @@ -932,33 +1212,59 @@ class Gen5PpgWaveform extends Gen5HistoricalRecord { /// [segmentIndex]. final int segmentId; - /// Per-burst counter (NOT a channel/LED id — ranges past 26 in the - /// reference corpus). @ inner[13] (frame-abs 21). - final int burstIndex; - - /// u16 LE @ inner[15:17] (frame-abs 23). No meaning established — it does - /// not track heart rate, motion, the waveform, gain or [signalMetric]. - /// Exposed raw, don't consume. - final int frontEndMetaRaw; - - /// Acquisition-channel index @ inner[17] (frame-abs 25), 0..7. + /// u16 LE @ inner[13:15] (frame-abs 21) — the PIP state/segment counter. /// - /// **The band multiplexes several optical channels through one v26 stream**, - /// and this byte is what separates them. Consecutive records in a single - /// burst carry different values, each with its own gain configuration - /// ([gainIndex]/[gainSetting]) and its own amplitude, and each carries an - /// independent pulse. Without splitting on it, a burst is several channels - /// interleaved, which is not a signal. + /// R26 is episodic detector output, not a one-per-second body: episodes run + /// exactly 40 records at 1 Hz, matching the ring/state cycle, and quiet + /// stationary wear can produce none at all. This counter is the ring/state + /// position within such an episode. + final int pipStateCounter; + + /// i32 LE @ inner[15:19] (frame-abs 23) — the first CH1 stream-A optical ADC + /// code of this record's 25-sample window, sign-extended from the front + /// end's 20 bits. /// - /// Values outside 0..7 occur on about 0.2% of records (0xFD..0xFF). They are - /// not obviously invalid — the waveform still looks like a pulse — but they - /// are not a channel index either, so use [subChannelKnown]. - final int subChannel; + /// This is the ONLY absolute sample in the record; the other 24 arrive as + /// [opticalDeltas]. Unlike v20's codes it arrives pre-sign-extended, so it is + /// read as a plain i32 rather than through [_signExtend20]. + /// + /// Exposed raw even when impossible, so a corrupt frame stays visible as + /// itself rather than as a fabricated code — see [firstSampleAdcInRange] and + /// [firstSampleAdcOrNull] for the gated reads. Real records always read in + /// range, occasionally sitting exactly on the +524,287 clip code. + final int firstSampleAdc; + + /// Whether [firstSampleAdc] is a code the 20-bit front end can produce + /// (-524288..524287). False means the record is corrupt, not that the band + /// saw something unusual. + bool get firstSampleAdcInRange => + firstSampleAdc >= _kOpticalAdcMin && firstSampleAdc <= _kOpticalAdcMax; + + /// [firstSampleAdc] gated to the physically possible range, null otherwise — + /// the honest getter, mirroring [Gen5HistorySample.activityClassKnown]. + int? get firstSampleAdcOrNull => + firstSampleAdcInRange ? firstSampleAdc : null; + + /// The 24 RAW saturated i16 LE deltas @ inner[19:67] (frame-abs 27), + /// exactly as they arrived. + /// + /// These are DIFFERENCES, not samples: delta `i` steps sample `i` to sample + /// `i+1` of a 25-sample window whose sample 0 is [firstSampleAdc]. `-32768` + /// and `32767` are the i16 saturation rails, i.e. a step the encoder could + /// not represent — see [reconstructWindow]. + /// + /// Kept raw because the deltas are the wire truth and the reconstruction is + /// not; a caller that wants absolute codes asks for them explicitly. + final List opticalDeltas; - /// [subChannel] gated to the 0..7 range, null otherwise — the honest getter, - /// mirroring [Gen5HistorySample.activityClassKnown]. - int? get subChannelKnown => - (subChannel >= 0 && subChannel <= 7) ? subChannel : null; + /// Invert this record's window: 25 absolute ADC codes plus the evidence + /// about how far they can be trusted. See [reconstructSaturatedDeltaWindow]. + /// + /// Recomputed per call rather than cached — the ambiguity bookkeeping is the + /// point of the call, and callers that want the wire truth want + /// [opticalDeltas]. + Gen5PpgReconstruction reconstructWindow() => + reconstructSaturatedDeltaWindow(firstSampleAdc, opticalDeltas); /// `k` in 0..99 recovered from [segmentId]'s Q15 packing — the sub-second in /// hundredths — or null if this record's value doesn't fit the packing. @@ -967,29 +1273,130 @@ class Gen5PpgWaveform extends Gen5HistoricalRecord { return (k >= 0 && k <= 99 && (k * 32768) ~/ 100 == segmentId) ? k : null; } - /// f32 LE @ inner[67:71] (frame-abs 75). Tracks with [flagA]/[flagB] as a - /// per-record signal-quality indicator where LOW means a clean record, but - /// the scale is unpinned. Exposed raw. - /// Null when the bytes are not finite. v18 checks every float it reads; this - /// one did not, so a corrupt frame put NaN/Inf into a value the doc invites - /// callers to use as a quality weight. - final double? signalMetric; - - /// Front-end gain configuration @ inner[71] / inner[72] (frame-abs 79/80). - /// Each [subChannel] runs a characteristic gain, adjusted within a range. - final int gainSetting; - final int gainIndex; - - /// Raw flag bytes @ inner[73] / inner[74] (frame-abs 81/82). Roughly - /// complementary, and [signalMetric] is about an order of magnitude lower - /// when [flagB] is set. Meaning otherwise unestablished — exposed raw. - final int flagA; - final int flagB; - - /// Raw AC-coupled ADC samples, no physical unit. Always 24 of them — the - /// record's fixed size. Every channel is DC-removed on the band: the - /// per-record sample mean is ~0 for all values of [subChannel]. - final List ppgWaveform; + /// f32 LE @ inner[67:71] (frame-abs 75) — the maximum adjacent + /// acceleration-magnitude delta in g for this second. + /// + /// It is MOTION, not signal quality. It is the same f32 as R18's + /// [Gen5HistorySample.dynamicAccelerationG] before that field's rounding: + /// byte-for-byte identical on every paired record. The earlier + /// reading of these bytes as a per-record `signalMetric` quality weight was + /// wrong, and the correlation that made it look like one is just motion + /// degrading the optical signal. + /// + /// Null when the bytes are not finite — v18 checks every float it reads, and + /// so does this one, so a corrupt frame cannot put NaN/Inf into a number + /// callers do arithmetic on. + final double? accelDeltaG; + + /// u16 LE @ inner[71:73] (frame-abs 79) — the packed channel-0 + /// processing-state word. + /// + /// Identical to R18's [Gen5HistorySample.statusWord] on every paired + /// record. Not gain: the earlier `gainSetting`/`gainIndex` pair split this + /// one u16 down the middle. + final int channelStateWord; + + /// Raw byte @ inner[73] (frame-abs 81) — the primary-flags bit-8 snapshot. + /// + /// Same source as the low two bits of R18 body 60, equal on every paired + /// record. Kept whole; [primaryFlagsBit8Raw] is the pinned two bits. + /// + /// NOTE the same caveat R18 carries: these bits are NOT a wear/on-wrist + /// reading — see [Gen5HistorySample.onWristRaw]'s deprecation. + final int primaryFlagsByte; + + /// The low two bits of [primaryFlagsByte] — the part pinned to R18. + int get primaryFlagsBit8Raw => primaryFlagsByte & 0x03; + + /// Raw byte @ inner[74] (frame-abs 82) — the binary waveform-morphology + /// acceptance result. + /// + /// A fixed neural encoder/decoder normalizes and interpolates the 25-point + /// waveform, then compares its mean squared reconstruction error against a + /// threshold. Kept raw; [morphologyPass] is the decoded result. + final int morphologyByte; + + /// Whether the band's morphology check ACCEPTED this window ([morphologyByte] + /// == 1). + /// + /// It is a learned "this looks like the waveform shape we trained on" pass. + /// It is **not** a heartbeat, a pulse onset, a beat count or any medical + /// classification — do not count these as beats. + bool get morphologyPass => morphologyByte == 1; + + /// Raw byte @ inner[75] (frame-abs 83) — the aligned inner tail, which sits + /// OUTSIDE the copied 72-byte ring record. + /// + /// Padding to the 4-byte boundary as far as anything here knows. Exposed only + /// so nobody has to guess whether it was decoded; it carries no field. + final int alignedTailByte; + + /// COMPAT (deprecated): inner[13] read as a standalone "per-burst counter". + /// It is the LOW BYTE of the u16 PIP state/segment counter, so it wraps every + /// 256 records and drops the high half. + @Deprecated('inner[13] is the low byte of the u16 PIP state/segment counter ' + '— use pipStateCounter') + int get burstIndex => pipStateCounter & 0xFF; + + /// COMPAT (deprecated): inner[15:17] read as a u16 of unestablished meaning. + /// Those two bytes are the LOW HALF of [firstSampleAdc], the window's first + /// optical ADC code — half of a number, not a field. + @Deprecated('inner[15:17] is the low half of the i32 first optical ADC code ' + '— use firstSampleAdc') + int get frontEndMetaRaw => firstSampleAdc & 0xFFFF; + + /// COMPAT (deprecated): inner[17] read as a 0..7 "acquisition channel index". + /// It is byte 2 of [firstSampleAdc]'s sign-extended i32 — 0x00..0x07 for a + /// positive first sample, 0xF8..0xFF for a negative one. The "~0.2% outliers + /// at 0xFD..0xFF" this field's old doc comment reported were negative codes, + /// and the band does NOT multiplex channels through this byte. + @Deprecated('inner[17] is byte 2 of the sign-extended i32 first optical ADC ' + 'code, not a channel index — use firstSampleAdc') + int get subChannel => (firstSampleAdc >> 16) & 0xFF; + + /// COMPAT (deprecated): the 0..7 gate over [subChannel]. There is no channel + /// here to gate — it only ever answered "is the first sample positive?". + @Deprecated('there is no sub-channel in a v26 record — inner[17] is part of ' + 'the i32 first optical ADC code; use firstSampleAdcOrNull') + int? get subChannelKnown { + // ignore: deprecated_member_use_from_same_package + final b = subChannel; + return (b >= 0 && b <= 7) ? b : null; + } + + /// COMPAT (deprecated): the f32 at inner[67:71] read as a signal-quality + /// metric. It is the max adjacent accel-magnitude delta in g — motion. For a + /// real per-second quality weight use R18's + /// [Gen5HistorySample.signalQualityLogVariance]. + @Deprecated('inner[67:71] is the max adjacent accel-magnitude delta in g ' + '(== R18 dynamicAccelerationG), not a quality metric — use accelDeltaG') + double? get signalMetric => accelDeltaG; + + /// COMPAT (deprecated): the two halves of [channelStateWord] read as a gain + /// configuration. There is no gain field in a v26 record. + @Deprecated('inner[71] is the low byte of the u16 channel-0 processing-state ' + 'word (== R18 statusWord), not a gain — use channelStateWord') + int get gainSetting => channelStateWord & 0xFF; + @Deprecated( + 'inner[72] is the high byte of the u16 channel-0 processing-state ' + 'word (== R18 statusWord), not a gain — use channelStateWord') + int get gainIndex => (channelStateWord >> 8) & 0xFF; + + /// COMPAT (deprecated): the two trailing bytes read as unnamed flags. + @Deprecated('inner[73] is the primary-flags bit-8 snapshot — use ' + 'primaryFlagsByte / primaryFlagsBit8Raw') + int get flagA => primaryFlagsByte; + @Deprecated('inner[74] is the binary waveform-morphology acceptance result ' + '(NOT a heartbeat) — use morphologyPass / morphologyByte') + int get flagB => morphologyByte; + + /// COMPAT (deprecated): the 24 i16s read as AC-coupled SAMPLES. They are + /// DELTAS over a 25-sample window, so summing is required before anything + /// treats them as a waveform — which is also why their per-record mean sat + /// near zero and looked "DC-removed". + @Deprecated('inner[19:67] holds 24 saturated i16 DELTAS, not samples — use ' + 'opticalDeltas, or reconstructWindow() for absolute codes') + List get ppgWaveform => opticalDeltas; const Gen5PpgWaveform({ required super.histVersion, @@ -1000,26 +1407,27 @@ class Gen5PpgWaveform extends Gen5HistoricalRecord { @Deprecated('frame-abs 19 is the u16 sub-second — use tsSubsec / subSecond') required this.rawByte19, required this.segmentId, - required this.burstIndex, - required this.frontEndMetaRaw, - required this.subChannel, - required this.signalMetric, - required this.gainSetting, - required this.gainIndex, - required this.flagA, - required this.flagB, - required this.ppgWaveform, + required this.pipStateCounter, + required this.firstSampleAdc, + required this.opticalDeltas, + required this.accelDeltaG, + required this.channelStateWord, + required this.primaryFlagsByte, + required this.morphologyByte, + required this.alignedTailByte, }); } -const int _kV26SampleCount = 24; -const int _kV26SamplesStart = 19; // frame-abs 27 +/// 24 DELTAS over a 25-sample window — see [Gen5PpgWaveform.opticalDeltas]. +const int _kV26DeltaCount = 24; +const int _kV26DeltasStart = 19; // frame-abs 27, body 6 -/// The EXACT inner length of a v26 record: 76 bytes — 24 samples at -/// inner[19:67] plus the trailing metadata block ending at inner[74], padded -/// to a 4-byte boundary. An exact gate, like v20/v21: a truncated record's -/// trailing metadata would otherwise decode out of whatever bytes happened to -/// follow. +/// The EXACT inner length of a v26 record: 76 bytes — the 13-byte shared +/// header, the copied 72-byte PIP ring record's first 62 bytes (24 deltas at +/// inner[19:67] plus the trailing metadata block ending at inner[74]) and one +/// aligned tail byte at inner[75] that is outside the record. +/// An exact gate, like v20/v21: a truncated record's trailing metadata would +/// otherwise decode out of whatever bytes happened to follow. const int kGen5V26InnerLen = 76; @Deprecated('v26 inner is exactly 76 bytes, not a floor — use kGen5V26InnerLen') @@ -1045,9 +1453,9 @@ class Gen5V26Decoder implements Gen5RecordDecoder { 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)); + final deltas = []; + for (int i = 0; i < _kV26DeltaCount; i++) { + deltas.add(v.getInt16(_kV26DeltasStart + 2 * i, Endian.little)); } return Gen5PpgWaveform( @@ -1065,15 +1473,502 @@ class Gen5V26Decoder implements Gen5RecordDecoder { // ignore: deprecated_member_use_from_same_package rawByte19: inner[11], segmentId: v.getUint16(11, Endian.little), - burstIndex: inner[13], - frontEndMetaRaw: v.getUint16(15, Endian.little), - subChannel: inner[17], - signalMetric: _finiteOrNull(v.getFloat32(67, Endian.little)), - gainSetting: inner[71], - gainIndex: inner[72], - flagA: inner[73], - flagB: inner[74], - ppgWaveform: samples, + // Body offsets + 13. The trailing block (67/71/73) is byte-identical + // to the twin R18 record's 33/67/73; see this section's header comment. + pipStateCounter: v.getUint16(13, Endian.little), + firstSampleAdc: v.getInt32(15, Endian.little), + opticalDeltas: List.unmodifiable(deltas), + accelDeltaG: _finiteOrNull(v.getFloat32(67, Endian.little)), + channelStateWord: v.getUint16(71, Endian.little), + primaryFlagsByte: inner[73], + morphologyByte: inner[74], + alignedTailByte: inner[75], + ); + } +} + +// ── v22 — research/diagnostic telemetry, six tagged body layouts. ────────── +// +// Revision 22: WHOOP 5.0 emits a 188-byte full frame = 176-byte inner = +// 163-byte body, and BODY BYTE 0 (inner[13]) selects one of six layouts. +// Anything the layouts below could not prove is exposed as RAW BYTES, never +// as a named field — see [Gen5ResearchRecord.rawBody]. +// +// STRUCTURAL FACTS this section leans on: +// +// * The inner length is exactly 176 — one value, no spread. The "184-byte" +// expectation some third-party clients carry is stale. +// * Every R22 has an exact (record_index, unix) R18 twin — which is what +// makes the byte-for-byte cross-checks below possible. +// * The 50 Hz optical case (inner[2] bit 7 clear) is unverified, so no +// usable-sample count may be hardcoded from the 25 Hz shape. +// * inner[14] always reads 0x00. +// +// THE WRITER IS NOT THE TAG. Variant 3 falls back to tag 2 when both leading +// CH2 A/B words are zero, and variant 5 falls back to tag 4 when no completed +// PIP record is available — bands configured for a variant routinely emit +// mostly the fallback tag. Dispatch on the EMITTED tag byte, never on the +// configured selector (`enable_r22_packets`, priority v6→v5→v4→v3→v2→1). +// +// STALE BYTES ARE NOT ZERO PADDING: on tag-5 records, inner[83:176] is +// BYTE-IDENTICAL to the previous R22 packet (a tag-4 body for the first one). +// The float-shaped constants sitting at inner[118:140] of every tag-5 record +// are leftover tag-4 metadata. A decoder that read the tag-1/2/4 metadata +// offsets out of a tag-5 body would report the PREVIOUS packet's accel and +// state values as this record's. That is why every accessor here is gated on +// the tag and returns null off it. + +/// One "first absolute sample + saturated i16 deltas" optical window out of an +/// R22 body — the same encoding v26 uses, inheriting the same lossy-delta +/// caveat. +class Gen5ResearchOpticalWindow { + /// Inner offset of this window's i32 first sample — carried so a caller can + /// tie a decoded window back to the bytes it came from. + final int innerOffset; + + /// The window's only absolute ADC code, i32 LE, sign-extended from the front + /// end's 20 bits. + /// + /// Exposed raw even when impossible, exactly like [Gen5PpgWaveform] — see + /// [firstSampleAdcOrNull] for the gated read. Real windows occasionally sit + /// exactly on the +524,287 clip code. + final int firstSampleAdc; + + /// The RAW saturated i16 LE deltas, exactly as they arrived. `-32768` and + /// `32767` are the saturation rails — see [reconstructSaturatedDeltaWindow]. + /// + /// The SLOT COUNT is a layout constant; the USABLE SAMPLE COUNT is not, and + /// nothing on the wire carries it — there is no valid-count field. Most + /// windows end at a rail delta well before the last slot, some carry no + /// rail at all, and the per-tag window lengths differ, so consumers must + /// treat the rail as the end-of-band marker rather than assume a fixed + /// usable length. + final List deltas; + + const Gen5ResearchOpticalWindow({ + required this.innerOffset, + required this.firstSampleAdc, + required this.deltas, + }); + + /// Whether [firstSampleAdc] is a code the 20-bit front end can produce. + bool get firstSampleAdcInRange => + firstSampleAdc >= _kOpticalAdcMin && firstSampleAdc <= _kOpticalAdcMax; + + /// [firstSampleAdc] gated to the physically possible range, null otherwise. + int? get firstSampleAdcOrNull => + firstSampleAdcInRange ? firstSampleAdc : null; + + /// True when this window sits on the +524,287 clip code with every IN-BAND + /// delta zero — a window that carries no information at all. + /// + /// It reconstructs to a perfectly flat, perfectly "valid-looking" line, so + /// nothing downstream can tell it from a real flat signal without this check. + /// + /// "In-band" means up to the first rail delta (`-32768`/`32767`): the slots + /// past it are padding or stale bytes and say nothing about the channel. + bool get isClippedFlat { + if (firstSampleAdc != _kOpticalAdcMax) return false; + for (final d in deltas) { + if (d == -32768 || d == 32767) return true; + if (d != 0) return false; + } + return true; + } + + /// Invert this window: `deltas.length + 1` absolute codes plus the evidence + /// about how far they can be trusted. See [reconstructSaturatedDeltaWindow]. + Gen5PpgReconstruction reconstructWindow() => + reconstructSaturatedDeltaWindow(firstSampleAdc, deltas); +} + +/// A decoded gen5 **revision 22** research/diagnostic record. +/// +/// R22 is research telemetry, not a locally decoded health metric — it +/// belongs to the band's research accumulator, and no physiological decoder +/// for it is known to exist anywhere. Its contents are still real sensor +/// data. Nothing in this package derives a metric from it. +/// +/// [tag] selects the layout, [rawBody] is ALWAYS retained, and every typed +/// accessor returns null when this record's tag does not carry that field. +/// An unknown or future tag decodes to header + [tag] + [rawBody] and nothing +/// else, which is the honest result rather than a decode failure. +class Gen5ResearchRecord extends Gen5HistoricalRecord { + /// Body byte 0 (inner[13]) — the layout selector. Observed values 1..6. + /// + /// This is the EMITTED tag, which is what a decoder must key on: the + /// configured writer can fall back (3→2, 5→4) without changing the config. + final int tag; + + /// The whole 163-byte body (inner[13:176]) exactly as it arrived, tag byte + /// included. + /// + /// Always populated, for every tag, including tags this package has no field + /// map for. Two reasons it is not optional: most of an R22 body is either + /// unproven or stale, and only the raw bytes let a later analysis revisit + /// this record without a re-capture. + /// + /// Read it with the stale-bytes rule in mind — bytes a variant does not + /// write hold the PREVIOUS packet's content, not zeros (tag-5 bodies repeat + /// the previous packet's inner[83:176] verbatim). + final Uint8List rawBody; + + /// Every optical window this record's tag carries, in body order. + /// + /// - tags 1, 2, 4: one window — i32 @inner[15:19] + 49 delta slots + /// @inner[19:117]. + /// - tag 3: two windows — i32 @inner[15:19] + 24 slots @inner[19:67], and + /// i32 @inner[67:71] + 24 slots @inner[71:119]. Likely the CH2 A and + /// CH4 A windows; this package does not name the channels, because + /// nothing proves which is which. + /// - tag 5: one window inside the embedded PIP ring record — i32 + /// @inner[23:27] + 24 slots @inner[27:75]. + /// - tag 6 and unknown tags: empty. + final List opticalWindows; + + /// f32 LE — the maximum adjacent acceleration-magnitude delta in g, the same + /// float R18 carries at inner[33:37] + /// ([Gen5HistorySample.dynamicAccelerationG] before its rounding). + /// + /// Byte-identical to the twin R18's on every checked tag-1/2/4 record + /// (inner[121:125]) and tag-3 record (inner[123:127]). + /// + /// For tag 5 this is the EMBEDDED ring record's own value at inner[75:79], + /// and it matches the R18 of [pipRecordUnix]'s second — NOT the carrier + /// packet's second. Comparing it against the carrier's R18 fails. + /// + /// Null when this tag has no such field, or when the bytes are not finite. + final double? accelDeltaG; + + /// u16 LE — the packed channel-0 processing-state word, the same u16 R18 + /// carries at inner[67:69] ([Gen5HistorySample.statusWord]). + /// + /// Byte-identical to the twin R18's on every checked tag-1/2/4 record + /// (inner[137:139]) and tag-3 record (inner[139:141]); tag 5 carries the + /// embedded record's own at inner[79:81], matching the R18 of + /// [pipRecordUnix]'s second. + final int? channelStateWord; + + /// The primary-flags bit-8 snapshot — the same field v26 carries at its body + /// 60 ([Gen5PpgWaveform.primaryFlagsByte]). + /// + /// inner[143] (tags 1/2/4), inner[145] (tag 3), inner[81] (tag 5). Its low + /// two bits equal the twin R18's `inner[73] & 3` on every checked record of + /// all three shapes, and the byte itself only ever holds 0 or 1. Not a + /// trivial match: R18 inner[73] takes many distinct values and its &3 + /// result splits well between 0 and 1. + /// + /// Same caveat R18 and v26 carry: these bits are NOT a wear/on-wrist reading. + final int? primaryFlagsByte; + + /// The low two bits of [primaryFlagsByte] — the part pinned to R18. + int? get primaryFlagsBit8Raw { + final b = primaryFlagsByte; + return b == null ? null : b & 0x03; + } + + /// A SECOND, wider flags byte at inner[118] (tags 1/2/4) or inner[120] + /// (tag 3), of which only the low two bits are established. + /// + /// Those two bits equal the twin R18's `inner[73] & 3` on every checked + /// record — the same two bits [primaryFlagsByte] carries. The byte itself + /// is a different quantity: it takes dozens of distinct values (96, 0, 97, + /// 224, 32, 113, 112, 64, …) and rarely equals [primaryFlagsByte]. The + /// upper six bits are NOT decoded here because nothing proves what they + /// are. + /// + /// Tag 5 has no equivalent — its body region is the embedded ring record. + final int? flagsSnapshotByte; + + /// The three further float32-shaped values that follow [accelDeltaG] in the + /// metadata block, at +8/+12/+16 from it — inner[125], [129], [133] for + /// tags 1/2/4 and inner[127], [131], [135] for tag 3. + /// + /// **Deliberately unnamed.** They are finite and nonzero on every checked + /// record, almost always with |v| ≤ 1, which is what makes "these are + /// float32s" credible — but nothing identifies what they measure. An entry + /// is null when its bytes are not finite. Empty for tags that have no + /// metadata block. + final List unnamedMetadataFloats; + + /// TAG 5 ONLY — the unix second of the COMPLETED PIP ring record embedded in + /// this body, u32 LE @inner[15:19]. + /// + /// It is not the carrier packet's timestamp: across the 20 retained tag-5 + /// records the carrier's unix runs tens of seconds AHEAD of this value, + /// while this value itself steps by exactly 1 s per record. Every R18 + /// cross-check on a tag-5 body only holds against the R18 of THIS second. + /// + /// Null for every other tag. + final int? pipRecordUnix; + + /// TAG 6 ONLY — 25 raw i16 LE acceleration samples per axis, X then Y then Z, + /// at inner[18:68], inner[68:118] and inner[118:168]. + /// + /// Empty for every other tag. Multiply by [kGen5AccelScaleG] for g — or read + /// [accelXg]/[accelYg]/[accelZg]. + /// + /// The alignment is proven by within-axis smoothness, not by magnitude: + /// sliding the window by one sample keeps ‖a‖ near 1 g, so the + /// discriminator is the worst within-axis step, which is smallest at + /// offset 18 and an order of magnitude worse at every odd offset. At + /// 4096 LSB/g the mean magnitude at rest lands just above 1 g. + final List accelRawX; + final List accelRawY; + final List accelRawZ; + + /// [accelRawX]/[accelRawY]/[accelRawZ] in g — scale [kGen5AccelScaleG], the + /// same 1/4096 the v21 IMU buffer uses. + List get accelXg => + accelRawX.map((s) => s * kGen5AccelScaleG).toList(growable: false); + List get accelYg => + accelRawY.map((s) => s * kGen5AccelScaleG).toList(growable: false); + List get accelZg => + accelRawZ.map((s) => s * kGen5AccelScaleG).toList(growable: false); + + /// TAG 6 ONLY — inner[168:176] verbatim, the eight bytes after the Z axis. + /// + /// A "three per-axis sign-transition counters" reading has been proposed + /// for these bytes, and inner[168..170] do hold small values (0..14) that + /// look like counts. The literal reading is REFUTED: raw per-axis + /// sign-transition counts rarely match those bytes (all three axes can be + /// strictly positive while the bytes are nonzero, where a raw sign count + /// would be zero), and mean-removed and first-difference sign counts fail + /// too. So the bytes are handed over raw and NOT named. inner[171] and + /// inner[173:176] always read 0; inner[172] holds values around 100..122. + /// + /// Empty for every other tag. + final Uint8List accelTailRaw; + + /// TAGS 2 and 4 ONLY — inner[144:155] verbatim. + /// + /// This region has been described as tag 2's "two saturated u8 metrics and + /// four converted u16" (10 bytes). Its LOCATION is pinned — tags 2 and 4 + /// write inner[145:148] and inner[149:155] while tag 1 leaves all of + /// inner[144:176] zero — but nothing establishes the field split or the + /// conversion, so nothing inside is named. inner[144] and inner[148] are + /// 0x00 throughout. + /// + /// A proposed extra tag-4 "per-channel table/config" shows up as exactly + /// two further live bytes over tag 2, inner[139] and inner[159]; two bytes + /// is not a table, so they stay inside [rawBody] only. + /// + /// Empty for every other tag. + final Uint8List extendedMetricsRaw; + + const Gen5ResearchRecord({ + required super.histVersion, + required super.flags, + required super.recordIndex, + required super.unix, + required super.tsSubsec, + required this.tag, + required this.rawBody, + required this.opticalWindows, + required this.accelDeltaG, + required this.channelStateWord, + required this.primaryFlagsByte, + required this.flagsSnapshotByte, + required this.unnamedMetadataFloats, + required this.pipRecordUnix, + required this.accelRawX, + required this.accelRawY, + required this.accelRawZ, + required this.accelTailRaw, + required this.extendedMetricsRaw, + }); + + /// True when this package has a field map for [tag]. False means the record + /// still decoded — header, [tag] and [rawBody] are all there — but no typed + /// accessor applies. + bool get hasKnownLayout => kGen5V22KnownTags.contains(tag); +} + +/// The EXACT inner length of an R22 record: 176 bytes (188-byte full frame, +/// 163-byte body). One exact value with no spread; an exact gate +/// like v20/v21/v26's, because a truncated body would otherwise decode its +/// trailing metadata out of whatever bytes happened to follow. +const int kGen5V22InnerLen = 176; + +/// The body tags [Gen5V22Decoder] has a verified field map for. +const Set kGen5V22KnownTags = {1, 2, 3, 4, 5, 6}; + +// Body-tag geometry, all INNER offsets (body N == inner N+13). +const int _kV22TagOffset = 13; // body 0 — the layout selector +const int _kV22BodyStart = 13; + +// tags 1/2/4: one window, then a metadata block at inner[117]. +const int _kV22WideWindowStart = 15; +const int _kV22WideWindowSlots = 49; +const int _kV22WideMetaBase = 117; + +// tag 3: two 24-slot windows, then the SAME metadata block shifted +2 (which is +// exactly what 4 + 24*2 = 52 bytes per window predicts: 15 + 104 = 119). All +// four R18 mirrors line up at +2, which is what proves the shift. +const int _kV22Tag3WindowAStart = 15; +const int _kV22Tag3WindowBStart = 67; +const int _kV22Tag3WindowSlots = 24; +const int _kV22Tag3MetaBase = 119; + +// tag 5: an embedded PIP ring record. Mapping inner[21] onto R26's body 0 +// reproduces the whole v26 layout — body 2 → 23, body 6 → 27, body 54 → 75, +// body 58 → 79, body 60 → 81, body 61 → 82. +const int _kV22Tag5RingUnix = 15; +const int _kV22Tag5WindowStart = 23; +const int _kV22Tag5WindowSlots = 24; +const int _kV22Tag5AccelDelta = 75; +const int _kV22Tag5StateWord = 79; +const int _kV22Tag5PrimaryFlags = 81; + +// tag 6: three 25-sample i16 axes, then an 8-byte tail. +const int _kV22Tag6AxisStart = 18; +const int _kV22Tag6AxisSamples = 25; +const int _kV22Tag6AxisStride = 2 * _kV22Tag6AxisSamples; // 50 bytes per axis +const int _kV22Tag6TailStart = 168; + +// Offsets INSIDE the tags-1/2/4/3 metadata block, relative to its base. +const int _kV22MetaFlagsSnapshot = 1; // base + 1 +const int _kV22MetaAccelDelta = 4; // base + 4 (f32) +const int _kV22MetaFloat1 = 8; // base + 8 (f32, unnamed) +const int _kV22MetaFloat2 = 12; // base + 12 (f32, unnamed) +const int _kV22MetaFloat3 = 16; // base + 16 (f32, unnamed) +const int _kV22MetaStateWord = 20; // base + 20 (u16) +const int _kV22MetaPrimaryFlags = 26; // base + 26 + +// tags 2/4's located-but-unsplit extension region. +const int _kV22ExtendedStart = 144; +const int _kV22ExtendedEnd = 155; + +class Gen5V22Decoder implements Gen5RecordDecoder { + const Gen5V22Decoder(); + + @override + String get name => 'gen5_v22'; + + @override + bool matches(Uint8List inner) => + inner.length == kGen5V22InnerLen && inner[1] == 22; + + @override + Gen5ResearchRecord? decode(Uint8List inner) { + if (!matches(inner)) return null; + final hdr = Gen5HistoricalHeader.tryParse(inner); + if (hdr == null) return null; + final v = _view(inner); + final tag = inner[_kV22TagOffset]; + + Gen5ResearchOpticalWindow window(int start, int slots) { + final deltas = []; + for (int i = 0; i < slots; i++) { + deltas.add(v.getInt16(start + 4 + 2 * i, Endian.little)); + } + return Gen5ResearchOpticalWindow( + innerOffset: start, + firstSampleAdc: v.getInt32(start, Endian.little), + deltas: List.unmodifiable(deltas), + ); + } + + final windows = []; + double? accelDeltaG; + int? stateWord; + int? primaryFlags; + int? flagsSnapshot; + var floats = const []; + int? pipUnix; + var accelX = const []; + var accelY = const []; + var accelZ = const []; + var accelTail = Uint8List(0); + var extended = Uint8List(0); + + // The metadata block tags 1/2/3/4 share, at `base`. Off-tag reads are + // what the stale-bytes rule punishes: a tag-5 body's inner[117:176] is + // the PREVIOUS packet's bytes, so this is only ever called on a tag that + // writes it. + void readMetaBlock(int base) { + flagsSnapshot = inner[base + _kV22MetaFlagsSnapshot]; + accelDeltaG = _finiteOrNull( + v.getFloat32(base + _kV22MetaAccelDelta, Endian.little), + ); + floats = List.unmodifiable([ + _finiteOrNull(v.getFloat32(base + _kV22MetaFloat1, Endian.little)), + _finiteOrNull(v.getFloat32(base + _kV22MetaFloat2, Endian.little)), + _finiteOrNull(v.getFloat32(base + _kV22MetaFloat3, Endian.little)), + ]); + stateWord = v.getUint16(base + _kV22MetaStateWord, Endian.little); + primaryFlags = inner[base + _kV22MetaPrimaryFlags]; + } + + switch (tag) { + case 1: + case 2: + case 4: + windows.add(window(_kV22WideWindowStart, _kV22WideWindowSlots)); + readMetaBlock(_kV22WideMetaBase); + if (tag != 1) { + extended = Uint8List.fromList( + inner.sublist(_kV22ExtendedStart, _kV22ExtendedEnd), + ); + } + break; + case 3: + windows.add(window(_kV22Tag3WindowAStart, _kV22Tag3WindowSlots)); + windows.add(window(_kV22Tag3WindowBStart, _kV22Tag3WindowSlots)); + readMetaBlock(_kV22Tag3MetaBase); + break; + case 5: + pipUnix = v.getUint32(_kV22Tag5RingUnix, Endian.little); + windows.add(window(_kV22Tag5WindowStart, _kV22Tag5WindowSlots)); + accelDeltaG = _finiteOrNull( + v.getFloat32(_kV22Tag5AccelDelta, Endian.little), + ); + stateWord = v.getUint16(_kV22Tag5StateWord, Endian.little); + primaryFlags = inner[_kV22Tag5PrimaryFlags]; + break; + case 6: + List axis(int start) { + final out = []; + for (int i = 0; i < _kV22Tag6AxisSamples; i++) { + out.add(v.getInt16(start + 2 * i, Endian.little)); + } + return List.unmodifiable(out); + } + + accelX = axis(_kV22Tag6AxisStart); + accelY = axis(_kV22Tag6AxisStart + _kV22Tag6AxisStride); + accelZ = axis(_kV22Tag6AxisStart + 2 * _kV22Tag6AxisStride); + accelTail = Uint8List.fromList( + inner.sublist(_kV22Tag6TailStart, kGen5V22InnerLen), + ); + break; + default: + // Unknown/future tag: header + tag + rawBody, and nothing invented. + break; + } + + return Gen5ResearchRecord( + histVersion: hdr.version, + flags: hdr.flags, + recordIndex: hdr.recordIndex, + unix: hdr.unix, + tsSubsec: v.getUint16(11, Endian.little), + tag: tag, + rawBody: Uint8List.fromList( + inner.sublist(_kV22BodyStart, kGen5V22InnerLen), + ), + opticalWindows: List.unmodifiable(windows), + accelDeltaG: accelDeltaG, + channelStateWord: stateWord, + primaryFlagsByte: primaryFlags, + flagsSnapshotByte: flagsSnapshot, + unnamedMetadataFloats: floats, + pipRecordUnix: pipUnix, + accelRawX: accelX, + accelRawY: accelY, + accelRawZ: accelZ, + accelTailRaw: accelTail, + extendedMetricsRaw: extended, ); } } @@ -1095,6 +1990,7 @@ abstract class Gen5RecordDecoder { const Gen5V18Decoder _v18Decoder = Gen5V18Decoder(); const Gen5V20Decoder _v20Decoder = Gen5V20Decoder(); const Gen5V21Decoder _v21Decoder = Gen5V21Decoder(); +const Gen5V22Decoder _v22Decoder = Gen5V22Decoder(); const Gen5V26Decoder _v26Decoder = Gen5V26Decoder(); /// Every gen5 historical-record decoder this package knows, in dispatch @@ -1105,6 +2001,7 @@ const List kGen5HistoricalDecoders = [ _v21Decoder, _v18Decoder, _v20Decoder, + _v22Decoder, _v26Decoder, ]; @@ -1132,6 +2029,13 @@ Gen5HistoricalRecord? parseGen5Historical(Uint8List inner) { return _v18Decoder.decode(inner); case 20: return _v20Decoder.decode(inner); + case 22: + // R22 is opt-in research telemetry (`enable_r22_packets`), so most + // straps never emit it. Decoding it here returns a [Gen5ResearchRecord] + // instead of null — callers that key on the concrete record type (edge's + // `sampleFromGen5Historical` only maps [Gen5HistorySample]) are unaffected + // and keep archiving the raw bytes. + return _v22Decoder.decode(inner); case 26: return _v26Decoder.decode(inner); default: From 1f129fd522ca3f4385e87ab93ba3ec8585232e9c Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Fri, 21 Aug 2026 12:31:11 +0200 Subject: [PATCH 2/2] pin the gen5 surface: byte conformance, control-plane offsets, record and R22 layouts --- test/control_plane_offsets_test.dart | 261 +++++++++++ test/doc_conformance_test.dart | 139 ++++++ test/gen5_command_surface_test.dart | 124 ++++- test/gen5_historical_test.dart | 422 ++++++++++++++---- test/gen5_record_fields_test.dart | 100 ++++- test/gen5_test.dart | 79 +++- test/gen5_v22_test.dart | 645 +++++++++++++++++++++++++++ test/whoop_protocol_update_test.dart | 17 +- 8 files changed, 1667 insertions(+), 120 deletions(-) create mode 100644 test/doc_conformance_test.dart create mode 100644 test/gen5_v22_test.dart diff --git a/test/control_plane_offsets_test.dart b/test/control_plane_offsets_test.dart index fe78e0c..d1d5dda 100644 --- a/test/control_plane_offsets_test.dart +++ b/test/control_plane_offsets_test.dart @@ -182,6 +182,112 @@ void main() { }); }); + // The alarm/haptics status byte: the SET and RUN + // alarm replies both carry a haptics/alarm status at response-body offset 1, + // i.e. payload[3]. It is "in addition to" the outer command result — the two + // answer different questions and both have to be read. + group('alarm replies carry a status byte of their own', () { + Map setReply(int status, {int outer = 1, int rev = 3}) => + parseCommandResponse( + cmdResponse(Cmd.setAlarmTime, [rev, status], status: outer))! + .decoded; + + Map runReply(int status, {int outer = 1}) => + parseCommandResponse( + cmdResponse(Cmd.runAlarm, [2, status], status: outer))! + .decoded; + + test('SET_ALARM_TIME(66) reads body byte 1, past the revision byte', () { + final ok = setReply(AlarmStatus.validInputPattern); + expect(ok['alarm_status'], 1); + expect(ok['alarm_status_name'], 'valid_input_pattern'); + + final played = setReply(AlarmStatus.playedSuccessfully); + expect(played['alarm_status'], 5); + expect(played['alarm_status_name'], 'played_successfully'); + }); + + test('a rejected alarm time is reported even under a SUCCESS outer result', + () { + // The case the whole branch exists for: the outer result says the + // command was handled, and the strap still refused the alarm. + final r = setReply(AlarmStatus.invalidAlarmTime); + expect(r['cmd_status'], 1); + expect(r['alarm_status'], 10); + expect(r['alarm_status_name'], 'invalid_alarm_time'); + expect(AlarmStatus.isInputRejection(10), isTrue); + }); + + test('a FAILURE reply still yields its status — that is the diagnostic', + () { + final r = setReply(AlarmStatus.invalidAlarmId, outer: 0); + expect(r['cmd_status'], 0); + expect(r['alarm_status'], 11); + expect(r['alarm_status_name'], 'invalid_alarm_id'); + }); + + test('an unrecognised response revision does not suppress the status', () { + // The SET reply's revision byte reads 3, but it is not a gate: a strap + // answering with another revision still put the status where it goes. + expect(setReply(AlarmStatus.hapticsBusy, rev: 9)['alarm_status'], 8); + }); + + test('RUN_ALARM(68) decodes the same table from its [02, status] body', () { + expect(runReply(AlarmStatus.playedSuccessfully)['alarm_status_name'], + 'played_successfully'); + expect(runReply(AlarmStatus.hapticsFailure)['alarm_status'], 6); + // Run-time outcomes are NOT input rejections — a busy strap is not a + // strap that refused the request. + expect(AlarmStatus.isInputRejection(AlarmStatus.hapticsBusy), isFalse); + expect(AlarmStatus.isInputRejection(AlarmStatus.hapticsStopped), isFalse); + }); + + test('an undocumented code stays numeric rather than being guessed at', () { + expect(runReply(200)['alarm_status_name'], 'code_200'); + expect(AlarmStatus.isInputRejection(200), isFalse); + }); + + test('a body too short to hold the status emits nothing at all', () { + // Header + revision byte only: there is no status byte to read. + final short = + parseCommandResponse(cmdResponse(Cmd.setAlarmTime, [3]))!.decoded; + expect(short.containsKey('alarm_status'), isFalse); + expect(short.containsKey('alarm_status_name'), isFalse); + expect(short['cmd_status'], 1, reason: 'the outer result still decodes'); + + final empty = + parseCommandResponse(cmdResponse(Cmd.runAlarm, const []))!.decoded; + expect(empty.containsKey('alarm_status'), isFalse); + }); + + test('GET_ALARM_TIME rev-4 reports active only when the flag is exactly 1', + () { + Map get4(int activeFlag) => parseCommandResponse( + cmdResponse(Cmd.getAlarmTime, + [0x04, activeFlag, ...le32(1786000000), ...le16(0)]), + )!.decoded; + expect(get4(1)['alarm_active'], isTrue); + expect(get4(1)['alarm_epoch'], 1786000000); + expect(get4(0)['alarm_active'], isFalse); + // Anything other than exactly 1 is not an armed alarm. + expect(get4(2)['alarm_active'], isFalse); + }); + + test('gen5 GET_CUSTOM_ADVERTISING_NAME (0x8D) decodes like gen4 0x4C', () { + // Reply body: revision, status, length, then the ASCII name — the same + // shape at the same offsets on both generations. + const name = 'Band-7'; + final body = [0x01, 0x00, name.length, ...name.codeUnits]; + final g5 = parseCommandResponse( + cmdResponse(Cmd.getCustomAdvertisingName, body), + profile: BandProfile.gen5)!; + final g4 = parseCommandResponse( + cmdResponse(Cmd.getAdvertisingNameHarvard, body))!; + expect(g5.decoded['strap_name'], name); + expect(g4.decoded['strap_name'], name); + }); + }); + group('EVENT (0x30) body is bounded by the declared length', () { test('frame padding past the length field is not part of the body', () { final e = parseEvent(envelopePacket(0x30, EventId.setRtc, [0xAA, 0xBB], @@ -217,6 +323,161 @@ void main() { }); }); + // Field-level bodies exist for four event ids the + // decoder used to hand back raw. All offsets below are BODY-relative (the + // envelope body starts at inner[12]). + group('volunteered EVENT bodies are decoded (gen5)', () { + List conditionBody({ + int pagesBehind = 1234, + int backlogTenths = 456, + int socTenths = 872, + int flash = 3, + int charging = 1, + int wrist = 2, + }) => + [ + ...le32(pagesBehind), + ...le16(backlogTenths), + ...le16(socTenths), + flash, + charging, + wrist, + ]; + + test('STRAP_CONDITION_REPORT(29) reports backlog, charge and wear', () { + final e = parseEvent( + envelopePacket(0x30, EventId.strapConditionReport, conditionBody()), + profile: BandProfile.gen5)!; + expect(e.name, 'STRAP_CONDITION_REPORT'); + expect(e.decoded['condition_pages_behind'], 1234); + expect(e.decoded['condition_backlog'], closeTo(45.6, 1e-9)); + expect(e.decoded['condition_soc_pct'], closeTo(87.2, 1e-9)); + expect(e.decoded['condition_flash'], 3); + expect(e.decoded['condition_charging'], isTrue); + // Tri-state, kept raw — the doc names no mapping, and wear truth is not + // taken from here. + expect(e.decoded['condition_wrist_state'], 2); + expect(e.decoded.containsKey('on_wrist'), isFalse); + }); + + test('a state of charge outside 0..100 is not reported at all', () { + final e = parseEvent( + envelopePacket(0x30, EventId.strapConditionReport, + conditionBody(socTenths: 0xFFFF)), + profile: BandProfile.gen5)!; + expect(e.decoded.containsKey('condition_soc_pct'), isFalse); + // The fields around it still decode. + expect(e.decoded['condition_pages_behind'], 1234); + expect(e.decoded['condition_charging'], isTrue); + }); + + test('a short STRAP_CONDITION_REPORT body degrades to its prefix', () { + final e = parseEvent( + envelopePacket(0x30, EventId.strapConditionReport, + conditionBody().sublist(0, 6)), + profile: BandProfile.gen5)!; + expect(e.decoded['condition_pages_behind'], 1234); + expect(e.decoded['condition_backlog'], closeTo(45.6, 1e-9)); + expect(e.decoded.containsKey('condition_soc_pct'), isFalse); + expect(e.decoded.containsKey('condition_charging'), isFalse); + }); + + test('HAPTICS_TERMINATED(100) separates expiry, error and dismissal', () { + Map terminated(int code) => parseEvent( + envelopePacket(0x30, EventId.hapticsTerminated, [1, code]), + profile: BandProfile.gen5)! + .decoded; + + expect(terminated(0)['haptics_termination'], 'expired'); + expect(terminated(1)['haptics_termination'], 'error'); + // The one the wearer causes: a double tap on a running alarm. + final tap = terminated(2); + expect(tap['haptics_revision'], 1); + expect(tap['haptics_termination_code'], HapticsTermination.userDoubleTap); + expect(tap['haptics_termination'], 'user_double_tap'); + // An undocumented code stays numeric rather than being folded into one + // of the three known causes. + expect(terminated(9)['haptics_termination'], 'code_9'); + }); + + test('BATTERY_PACK_INFO(109) decodes address, name and hardware', () { + final body = [ + 4, // [0] revision + 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, // [1..6] BT address + ...'PACK-7'.codeUnits, // [7..] name, NUL padded to 22 + ...List.filled(16 - 'PACK-7'.length, 0), + ...le16(742), // [23..24] battery-level structure + 5, // [25] colorway + 2, // [26] hardware family + ]; + final e = parseEvent(envelopePacket(0x30, EventId.batteryPackInfo, body), + profile: BandProfile.gen5)!; + expect(e.name, 'BATTERY_PACK_INFO'); + expect(e.decoded['pack_revision'], 4); + expect(e.decoded['pack_address'], 'aa:bb:cc:dd:ee:ff'); + expect(e.decoded['pack_name'], 'PACK-7'); + expect(e.decoded['pack_battery_raw'], 742); + expect(e.decoded['pack_colorway'], 5); + expect(e.decoded['pack_hardware_family'], 2); + }); + + test('a truncated BATTERY_PACK_INFO body decodes nothing, never garbage', + () { + final e = parseEvent( + envelopePacket( + 0x30, EventId.batteryPackInfo, List.filled(20, 0x41)), + profile: BandProfile.gen5)!; + expect(e.decoded, isEmpty); + expect(e.body, hasLength(20)); // still retained raw + }); + + test('GENERIC_FIRMWARE_EVENT(123) names sub-id 6 and numbers the rest', () { + final dorset = parseEvent( + envelopePacket(0x30, EventId.genericFirmwareEvent, [1, ...le16(6)]), + profile: BandProfile.gen5)!; + expect(dorset.decoded['fw_event_revision'], 1); + expect(dorset.decoded['fw_event_id'], FirmwareEventId.dorsetDetected); + expect(dorset.decoded['fw_event'], 'DORSET_DETECTED'); + + final other = parseEvent( + envelopePacket(0x30, EventId.genericFirmwareEvent, [1, ...le16(300)]), + profile: BandProfile.gen5)!; + expect(other.decoded['fw_event_id'], 300); + expect(other.decoded['fw_event'], 'FIRMWARE_EVENT_300'); + }); + + test('the gen5-scoped ids stay numeric and un-decoded on a gen4 parse', () { + // A gen4 event 29 is not a known vocabulary entry — decoding it with + // the gen5 body map would turn an unknown byte into a confident wrong + // number (a fabricated state of charge, above all). + final e = parseEvent( + envelopePacket(0x30, EventId.strapConditionReport, conditionBody()))!; + expect(e.name, 'EVENT_${EventId.strapConditionReport}'); + expect(e.decoded, isEmpty); + final h = parseEvent( + envelopePacket(0x30, EventId.hapticsTerminated, [1, 2]))!; + expect(h.name, 'EVENT_${EventId.hapticsTerminated}'); + expect(h.decoded, isEmpty); + }); + + test('an event id outside the vocabulary still flows through as an event', + () { + // Unlisted event ids: 110 and 124 arrive with no vocabulary entry. + // They must stay retainable burst members, not become `event_unparsed`. + final e = parseEvent( + envelopePacket(0x30, 110, [0x01, 0x01, 0, 0, 0, 0, 0, 0]))!; + expect(e.eventId, 110); + expect(e.name, 'EVENT_110'); + expect(e.decoded, isEmpty); + expect(e.body, hasLength(8)); + + final d = decodeFrame(Frame( + envelopePacket(0x30, 124, [0x01, 0x01, 0x01, 0x03]), true, true)); + expect(d.kind, 'event'); + expect(d.fields['retain_raw'], isTrue); + }); + }); + group('CONSOLE_LOGS (0x32) uses the event envelope', () { Uint8List logPacket(int seq, String text, {List pad = const []}) => envelopePacket(0x32, 2, text.codeUnits, seq: seq, pad: pad); diff --git a/test/doc_conformance_test.dart b/test/doc_conformance_test.dart new file mode 100644 index 0000000..388c2bc --- /dev/null +++ b/test/doc_conformance_test.dart @@ -0,0 +1,139 @@ +// Byte-conformance pins against the literal wire examples in the WHOOP 5.0 +// BLE reference docs: every builder with a documented example body is +// asserted byte-for-byte, so a drive-by edit to a builder cannot silently +// change what goes on the wire. +import 'dart:typed_data'; +import 'package:test/test.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +String hx(Uint8List b) => + b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); + +void main() { + test('canonical GET_HELLO frame, seq 1, byte-for-byte', () { + // doc: aa0108000001e671 23019101 363e5c8d + expect(hx(gen5ClientHello(seq: 1)), 'aa0108000001e67123019101363e5c8d'); + }); + + test('ENTER_HIGH_FREQ_SYNC typical body 02 b4 00 20 1c', () { + final f = parseFrame( + cmdEnterHighFreqSync(1, + intervalSeconds: 180, + durationSeconds: 7200, + profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + // inner: [35][seq][96][02 b4 00 20 1c] + expect(f.inner[0], 35); + expect(f.inner[2], 96); + expect(hx(Uint8List.fromList(f.inner.sublist(3, 8))), '02b400201c'); + }); + + test('gen5 HISTORICAL_DATA_RESULT failure body is exactly 00 00', () { + final f = parseFrame(buildHistoryResultFail(1, profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + expect(f.inner[2], 23); + expect(f.inner.sublist(3, 5), [0x00, 0x00]); + }); + + test('success result = 01 + markerA + markerB, 9 bytes verbatim', + () { + final token = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04]; + final f = parseFrame( + buildHistoryResultOk(1, token, profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + expect(f.inner.sublist(3, 12), [0x01, ...token]); + }); + + test('SET_ALARM_TIME rev-4 body is EXACTLY 21 bytes per the map', () { + final when = DateTime.fromMillisecondsSinceEpoch(1787153377 * 1000 + 500); + final f = parseFrame(cmdSetAlarm(1, when, profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + final body = f.inner.sublist(3); + // 04 01 | epoch u32le | subsec u16le | 2f 98 00*6 | 00 00 | 07 | 1e | 00 + expect(body.length, 21, reason: '21 bytes, never 20 — the serializer trap'); + expect(body[0], 0x04, reason: 'revision'); + expect(body[1], 0x01, reason: 'alarm ID 1'); + final epoch = body[2] | body[3] << 8 | body[4] << 16 | body[5] << 24; + expect(epoch, 1787153377); + final subsec = body[6] | body[7] << 8; + expect(subsec, (500 * 32768) ~/ 1000); + expect(body.sublist(8, 16), [0x2f, 0x98, 0, 0, 0, 0, 0, 0], + reason: 'waveform effects'); + expect(body.sublist(16, 18), [0, 0], reason: 'per-effect loop control'); + expect(body[18], 0x07, reason: 'overall waveform loop control'); + expect(body[19], 0x1e, reason: '30 s duration cap'); + expect(body[20], 0x00, reason: 'alarm type 0 — the 21st byte IS on the wire'); + }); + + test('GET_ALARM_TIME 04 01 / RUN_ALARM 02 01 / DISABLE 02 ff', () { + final g = parseFrame(cmdGetAlarmTime(1, profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + expect(g.inner.sublist(3, 5), [0x04, 0x01]); + final r = parseFrame(cmdRunAlarm(1, mode: 1, profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + expect(r.inner.sublist(3, 5), [0x02, 0x01]); + final d = parseFrame(cmdDisableAlarm(1, profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + expect(d.inner.sublist(3, 5), [0x02, 0xff]); + }); + + test('Maverick haptic body 01 2f 98 00*8 …loop', () { + final f = parseFrame(cmdBuzzGen5Maverick(1, overallLoop: 1), + profile: BandProfile.gen5)!; + expect(hx(Uint8List.fromList(f.inner.sublist(3, 15))), + '012f98000000000000000001'); + }); + + test('SET_CLOCK body = u32 sec + u32 subsec (8 bytes)', () { + final now = DateTime.fromMillisecondsSinceEpoch(1787153377 * 1000 + 250); + final f = parseFrame(cmdSetClock(1, now: now, profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + expect(f.inner[2], 10, reason: 'SET_CLOCK(10), not 146'); + final body = f.inner.sublist(3, 11); + final sec = body[0] | body[1] << 8 | body[2] << 16 | body[3] << 24; + expect(sec, 1787153377); + final sub = body[4] | body[5] << 8 | body[6] << 16 | body[7] << 24; + expect(sub, (250 * 32768) ~/ 1000); + }); + + test('GET_CLOCK(11) empty body; GET_DATA_RANGE(34) empty on gen5', + () { + final c = parseFrame(cmdGetClock(1, profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + expect(c.inner[2], 11); + // EMPTY body: inner is [35][seq][11] padded to the 4-byte boundary with + // zeros — any real body byte here would be a doc deviation. + expect(c.inner.length, 4); + expect(c.inner[3], 0, reason: 'alignment padding, not a body byte'); + final r = parseFrame(cmdGetDataRangeGen5(1), profile: BandProfile.gen5)!; + expect(r.inner[2], 34); + expect(r.inner.length, 4); + expect(r.inner[3], 0, reason: 'alignment padding, not a body byte'); + }); + + test('toggles — 3 bare bool; 106/107 rev+bool; labrador ops', () { + final hr = parseFrame(cmdToggleHr(1, true), profile: BandProfile.gen4)!; + expect(hr.inner.sublist(2, 4), [3, 0x01], reason: 'opcode 3 takes bare 01'); + final imu = parseFrame(cmdToggleImu(1, true, profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + expect(imu.inner.sublist(3, 5), [0x01, 0x01]); + final lab = parseFrame( + cmdLabradorDataGeneration(1, LabradorOperation.start, + profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + expect(lab.inner[2], 124); + expect(lab.inner.sublist(3, 5), [0x01, 0x02], reason: 'start = 01 02'); + final raw = parseFrame( + cmdLabradorRawSave(1, true, profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + expect(raw.inner[2], 125); + expect(raw.inner.sublist(3, 5), [0x01, 0x01]); + }); + + test('battery pack info body 01 on opcode 151', () { + final f = parseFrame(cmdGetBatteryPackInfo(1, profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + expect(f.inner[2], 151); + expect(f.inner[3], 0x01); + }); +} diff --git a/test/gen5_command_surface_test.dart b/test/gen5_command_surface_test.dart index 2be069a..ca632c6 100644 --- a/test/gen5_command_surface_test.dart +++ b/test/gen5_command_surface_test.dart @@ -98,7 +98,8 @@ void main() { } }); - test('the r22 packet flags are all present and enabled', () { + test('the firmware-selected r22 packet flags (v6..v2 + master) are enabled', + () { final enabled = { for (final (n, v) in kGen5R22EnableFlags) if (v == '1') n @@ -109,13 +110,39 @@ void main() { 'enable_r22_packets', 'enable_r22_v2_packets', 'enable_r22_v3_packets', - 'enable_r22_v4_packets', 'enable_r22_v5_packets', 'enable_r22_v6_packets', - 'enable_r22_v8_packets', ])); }); + test('the default sequence stays the full hardware-proven set', () { + // The full set is the one with deep-buffer evidence behind it; trimming + // it is the caller's explicit choice, never a silent change. + final names = kGen5R22EnableFlags.map((f) => f.$1).toList(); + expect(names, contains('enable_r22_v4_packets')); + expect(names, contains('enable_r22_v8_packets')); + }); + + test('the contested pair is named, and omitContestedFlags skips exactly it', + () { + // v4 reads raw 0 pre-write (no proven restoration — one-way); v8 has no + // active firmware consumer. Both stay in the default; a caller that + // refuses the irreversible/dormant writes opts out explicitly. + expect(kGen5R22ContestedFlagNames, + {'enable_r22_v4_packets', 'enable_r22_v8_packets'}); + final frames = + buildR22EnableSequence(startSeq: 3, omitContestedFlags: true); + expect(frames.length, kGen5R22EnableFlags.length - 2); + final written = frames.map((fr) { + final inner = parseFrame(fr, profile: gen5)!.inner; + return String.fromCharCodes( + inner.sublist(4, 36).takeWhile((b) => b != 0)); + }).toSet(); + expect(written.intersection(kGen5R22ContestedFlagNames), isEmpty); + expect(written, contains('enable_r22_packets')); + expect(written, contains('disable_pip_r26_packets')); + }); + test('the pip suppressor is disabled so v26 packets flow', () { expect(kGen5R22EnableFlags, contains(('disable_pip_r26_packets', '2'))); }); @@ -147,7 +174,14 @@ void main() { } }); - test('restore-defaults writes "0" to every name the enable touched', () { + test('the deprecated restore builder writes raw "0" — which is NOT a valid ' + 'restore, and is why it is unexported', () { + // Pinned as a HAZARD, not as correct behaviour. The boolean writer + // emits only ASCII '1' (enabled) or '2' (disabled); a returned raw 0 + // means unset/unknown and is never a valid write value. So + // this builder cannot restore anything — a correct restore reads each + // flag first and writes the recorded value back. + // ignore: deprecated_member_use_from_same_package final restore = buildR22RestoreDefaultsSequence(startSeq: 1); expect(restore.length, kGen5R22EnableFlags.length); for (var i = 0; i < restore.length; i++) { @@ -155,9 +189,11 @@ void main() { final name = String.fromCharCodes(inner.sublist(4, 36).takeWhile((b) => b != 0)); expect(name, kGen5R22EnableFlags[i].$1); - expect(inner[36], '0'.codeUnitAt(0), reason: 'restore default'); + expect(inner[36], '0'.codeUnitAt(0), + reason: 'documents the unsafe raw-0 write this builder emits'); } }); + }); group('alarms', () { @@ -372,25 +408,81 @@ void main() { } }); - test('ECG select-wrist / control / send raw / send filtered', () { + // Filtered reading ("Labrador", R17) — the three lifecycle toggles. + // Every body is [revision 01][operation]; 124's operation is + // NOT a boolean. + test('filtered reading: wrist select + the three toggle bodies', () { final wrist = parseFrame( cmdSelectWrist(1, WristSelection.left, profile: gen5), profile: gen5)!; expect(wrist.opcode, 0x7B); expect(wrist.inner.sublist(3, 5), [0x01, WristSelection.left.value]); - final ctl = - parseFrame(cmdEcgControl(1, true, profile: gen5), profile: gen5)!; - expect(ctl.opcode, 0x7C); - expect(ctl.inner.sublist(3, 5), [0x01, 0x01]); - for (final (opcode, frame) in [ - (0x7E, cmdEcgSendRaw(1, true, profile: gen5)), - (0x8B, cmdEcgSendFiltered(1, true, profile: gen5)), + + // 124: 01 01 stop / 01 02 start / 01 03 restart. + for (final (op, expected) in [ + (LabradorOperation.stop, [0x01, 0x01]), + (LabradorOperation.start, [0x01, 0x02]), + (LabradorOperation.restart, [0x01, 0x03]), ]) { - final f = parseFrame(frame, profile: gen5)!; - expect(f.opcode, opcode); - expect(f.inner[3], 0x01); + final f = parseFrame(cmdLabradorDataGeneration(1, op, profile: gen5), + profile: gen5)!; + expect(f.opcode, 0x7C, reason: 'TOGGLE_LABRADOR_DATA_GENERATION = 124'); + expect(f.inner.sublist(3, 5), expected, reason: '$op'); + } + + // 125 and 139: 01 00 disable / 01 01 enable. + for (final (opcode, off, on) in [ + ( + 0x7D, + cmdLabradorRawSave(1, false, profile: gen5), + cmdLabradorRawSave(1, true, profile: gen5) + ), + ( + 0x8B, + cmdLabradorFiltered(1, false, profile: gen5), + cmdLabradorFiltered(1, true, profile: gen5) + ), + ]) { + final disabled = parseFrame(off, profile: gen5)!; + expect(disabled.opcode, opcode); + expect(disabled.inner.sublist(3, 5), [0x01, 0x00]); + final enabled = parseFrame(on, profile: gen5)!; + expect(enabled.opcode, opcode); + expect(enabled.inner.sublist(3, 5), [0x01, 0x01]); } }); + + // The deprecated builders keep their exact old bytes: existing callers are + // flagged, not silently re-pointed. Two of the three are wrong on the wire + // — that is what the deprecation says, and this pins it. + test('deprecated ECG builders still emit their old (wrong) bytes', () { + // ignore: deprecated_member_use_from_same_package + final armed = parseFrame(cmdEcgControl(1, true, profile: gen5), + profile: gen5)!; + expect(armed.opcode, 0x7C); + expect(armed.inner.sublist(3, 5), [0x01, 0x01], + reason: 'the old "arm" is the STOP operation'); + // ignore: deprecated_member_use_from_same_package + final disarmed = parseFrame(cmdEcgControl(1, false, profile: gen5), + profile: gen5)!; + expect(disarmed.inner.sublist(3, 5), [0x01, 0x00], + reason: 'the old "disarm" is an undefined operation byte'); + + // ignore: deprecated_member_use_from_same_package + final raw = + parseFrame(cmdEcgSendRaw(1, true, profile: gen5), profile: gen5)!; + expect(raw.opcode, 0x7E, + reason: '126 is not an established WHOOP opcode'); + expect(raw.inner.sublist(3, 5), [0x01, 0x01]); + + // 139 was only misnamed — same bytes as the replacement. + // ignore: deprecated_member_use_from_same_package + final filtered = cmdEcgSendFiltered(1, true, profile: gen5); + expect(filtered, cmdLabradorFiltered(1, true, profile: gen5)); + final f = parseFrame(filtered, profile: gen5)!; + expect(f.opcode, 0x8B); + expect(f.inner.sublist(3, 5), [0x01, 0x01]); + }); }); group('constants', () { diff --git a/test/gen5_historical_test.dart b/test/gen5_historical_test.dart index 7ec0ad0..2a917a6 100644 --- a/test/gen5_historical_test.dart +++ b/test/gen5_historical_test.dart @@ -13,6 +13,35 @@ import 'dart:typed_data'; import 'package:test/test.dart'; import 'package:openstrap_protocol/openstrap_protocol.dart'; +/// The 24 saturated i16 deltas carried by the real v26 fixture used below +/// (inner[19:67], body 6). +const List _v26Deltas = [ + -1432, + -1332, + -1139, + -954, + -629, + -436, + -326, + -294, + -147, + -170, + -43, + -5, + -201, + -918, + -1563, + -1833, + -1313, + -930, + -616, + -293, + -422, + -380, + -235, + -164, +]; + Uint8List hex(String s) { final clean = s.replaceAll(' ', ''); final out = Uint8List(clean.length ~/ 2); @@ -58,12 +87,15 @@ void main() { expect(sample.rrIntervalsMs, [602, 613]); }); - test('quality flags + alt HR', () { + test('quality flags stay raw; alt-HR is gated, never substituted', () { expect(sample.hrQualityFlags, 0x8D); - expect(sample.hrRrValidThisSecond, isTrue); // bit7 set expect(sample.hrQualityCounter, 0x0D); // low 4 bits, separate field + // inner[29] rides along raw; bit7 gates the corroborated read only. expect(sample.heartRateAlt, 101); + expect(sample.hrRrValidThisSecond, isTrue); expect(sample.trustedHeartRateAlt, 101); + // bit7 is NOT "HR valid": heartRate stands on its own range check and + // is present on plenty of records with bit7 clear. }); test('frame-abs 36/37 is NOT one fixed-point HR', () { @@ -100,11 +132,19 @@ void main() { expect(sample.skinTempC, closeTo(30.57, 1e-6)); }); - test('optical front-end', () { - // Two BIG-ENDIAN u16s, not four bytes: inner[98:100] and inner[100:102]. + test('optical front-end: four independent bytes, not two u16s', () { + // Body 85..88: two u8 quantized PD means, then two SIGNED i8 + // per-detector pSNR values in dB (-128 = unavailable). + expect(sample.pdMeanB, 0x65); + expect(sample.pdMeanA, 0x6F); + expect(sample.psnrB, 0x1E); // +30 dB + expect(sample.psnrA, 0x1E); + expect(sample.psnrBAvailable, isTrue); + expect(sample.psnrAAvailable, isTrue); + expect(sample.isOpticalAmpSentinel, isFalse); // not both-unavailable + // One compat assertion so the deprecated u16 view stays wired correctly. + // ignore: deprecated_member_use_from_same_package expect(sample.opticalBaseline, 0x656F); - expect(sample.opticalAmp, 0x1E1E); - expect(sample.isOpticalAmpSentinel, isFalse); // not the 0x8080 sentinel }); test('experimental fields exposed raw, not fabricated', () { @@ -195,34 +235,13 @@ void main() { // a separate field. expect(wf.recordIndex, 25444781); expect(wf.unix, 1780917232); + // ignore: deprecated_member_use_from_same_package 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, - ]); + expect(wf.pipStateCounter, 1); + // These 24 i16s are DELTAS over a 25-sample window (body 6), not + // samples — see `reconstructs a 25-sample window` below for the + // absolute codes they step through. + expect(wf.opticalDeltas, _v26Deltas); }); test('record_index is one consecutive u32 across real consecutive frames', @@ -288,28 +307,153 @@ void main() { expect((56 * 32768) ~/ 100, wf.segmentId); }); - test('sub-channel, gain and the trailing metadata block decode', () { + test('the PIP body decodes per the revision-26 field map', () { final wf = decode(frame); - expect(wf.subChannel, 5); - expect(wf.subChannelKnown, 5); // inside 0..7 - expect(wf.burstIndex, 1); // distinct from subChannel - expect(wf.frontEndMetaRaw, 50627); - expect(wf.signalMetric, closeTo(0.0219, 1e-4)); - expect(wf.gainSetting, 80); - expect(wf.gainIndex, 8); - expect(wf.flagA, 1); - expect(wf.flagB, 1); - }); - - test('sub-channel outside 0..7 is not fabricated into a channel', () { + // body 0 / inner[13:15] — PIP state/segment counter, a u16. + expect(wf.pipStateCounter, 1); + // body 2 / inner[15:19] — the window's first optical ADC code, i32 LE + // (bytes c3 c5 05 00). Inside the front end's signed 20-bit range. + expect(wf.firstSampleAdc, 378307); + expect(wf.firstSampleAdcInRange, isTrue); + expect(wf.firstSampleAdcOrNull, 378307); + // body 54 / inner[67:71] — max adjacent accel-magnitude delta in g. The + // SAME f32 as R18's dynamicAccelerationG (verified byte-for-byte on + // every paired record). + expect(wf.accelDeltaG, closeTo(0.0219, 1e-4)); + // body 58 / inner[71:73] — packed channel-0 processing-state word, one + // u16 (== R18 statusWord on every paired record). + expect(wf.channelStateWord, 2128); + // body 60 / inner[73] — primary-flags bit-8 snapshot. + expect(wf.primaryFlagsByte, 1); + expect(wf.primaryFlagsBit8Raw, 1); + // body 61 / inner[74] — binary waveform-morphology acceptance result. + expect(wf.morphologyByte, 1); + expect(wf.morphologyPass, isTrue); + // body 62 / inner[75] — aligned tail, outside the copied ring record. + expect(wf.alignedTailByte, 0); + }); + + test('a morphology byte other than 1 is not a pass', () { final parsed = parseFrame(frame, profile: BandProfile.gen5)!; - for (final bad in [0xFD, 0xFE, 0xFF]) { + for (final b in [0x00, 0x02, 0xFF]) { final inner = Uint8List.fromList(parsed.inner); - inner[17] = bad; + inner[74] = b; final wf = parseGen5Historical(inner) as Gen5PpgWaveform; - expect(wf.subChannel, bad); // raw byte preserved - expect(wf.subChannelKnown, isNull); // but not offered as a channel + expect(wf.morphologyByte, b); // raw byte preserved + expect(wf.morphologyPass, isFalse); + } + }); + + test('the first sample is a signed i32, not four independent bytes', () { + final parsed = parseFrame(frame, profile: BandProfile.gen5)!; + // A negative first sample sign-extends into inner[17]/inner[18]. The old + // decoder read inner[17] as a 0..7 "sub-channel" and called 0xFD..0xFF + // (~0.2% of records) outliers; they are simply negative codes. + final inner = Uint8List.fromList(parsed.inner); + inner.setRange(15, 19, [0x0A, 0xBB, 0xFD, 0xFF]); // -148726 + final wf = parseGen5Historical(inner) as Gen5PpgWaveform; + expect(wf.firstSampleAdc, -148726); + expect(wf.firstSampleAdcInRange, isTrue); + // ignore: deprecated_member_use_from_same_package + expect(wf.subChannel, 0xFD); // the "outlier" the old reading saw + }); + + test('an impossible first sample is exposed raw, not fabricated', () { + final parsed = parseFrame(frame, profile: BandProfile.gen5)!; + final inner = Uint8List.fromList(parsed.inner); + inner.setRange(15, 19, [0x00, 0x00, 0x40, 0x00]); // 4194304 > 2^19-1 + final wf = parseGen5Historical(inner) as Gen5PpgWaveform; + // The record still decodes — the raw value stays visible as itself. + expect(wf.firstSampleAdc, 4194304); + // But it is not offered as a code the front end could have produced. + expect(wf.firstSampleAdcInRange, isFalse); + expect(wf.firstSampleAdcOrNull, isNull); + // And the reconstruction reports it rather than silently summing on. + expect(wf.reconstructWindow().outOfRangeSampleIndices, contains(0)); + expect(wf.reconstructWindow().divergenceProven, isTrue); + }); + + test('reconstructs a 25-sample window from first sample + 24 deltas', () { + final wf = decode(frame); + final r = wf.reconstructWindow(); + expect(r.samples.length, 25); + expect(r.samples.first, wf.firstSampleAdc); + // Cumulative sum: sample i+1 == sample i + delta i. + for (var i = 0; i < _v26Deltas.length; i++) { + expect(r.samples[i + 1], r.samples[i] + _v26Deltas[i]); } + expect(r.samples.last, 362532); + // This real record is clean: no saturated delta, nothing impossible. + expect(r.hasSaturatedDelta, isFalse); + expect(r.firstAmbiguousSampleIndex, isNull); + expect(r.outOfRangeSampleIndices, isEmpty); + expect(r.divergenceProven, isFalse); + expect(r.trustedSampleCount, 25); + expect(r.trustedSamples, r.samples); + }); + + test('a -32768 delta marks its sample and everything after it ambiguous', + () { + // Delta reconstruction is lossy: the saturating clamp destroys its + // operand, and the wire carries no signal that it happened. + final parsed = parseFrame(frame, profile: BandProfile.gen5)!; + final inner = Uint8List.fromList(parsed.inner); + // Delta 3 lives at inner[19 + 2*3] = inner[25:27]. + inner[25] = 0x00; + inner[26] = 0x80; // -32768 LE + final wf = parseGen5Historical(inner) as Gen5PpgWaveform; + expect(wf.opticalDeltas[3], -32768); + final r = wf.reconstructWindow(); + expect(r.hasSaturatedDelta, isTrue); + // Delta 3 produces sample 4, so samples 0..3 survive and 4..24 do not. + expect(r.firstAmbiguousSampleIndex, 4); + expect(r.trustedSampleCount, 4); + expect(r.trustedSamples, r.samples.sublist(0, 4)); + // The window still reconstructs — it is approximate, not withheld. + expect(r.samples.length, 25); + }); + + test('+32767 is a rail too — the positive clamp is just as ambiguous', () { + // A delta of exactly +32767 is the positive i16 saturation rail: real + // windows hit it exactly, with nothing anywhere near it below, so it is + // a clamp, not a large step. Treating only -32768 as saturation handed + // over a fabricated ramp as `trustedSampleCount == 25`. + final r = reconstructSaturatedDeltaWindow(1000, [10, 32767, 10]); + expect(r.hasSaturatedDelta, isTrue); + expect(r.firstAmbiguousSampleIndex, 2); + expect(r.trustedSampleCount, 2); + // One rail off is still an ordinary delta. + final near = reconstructSaturatedDeltaWindow(1000, [10, 32766, -10]); + expect(near.hasSaturatedDelta, isFalse); + expect(near.trustedSampleCount, 4); + }); + + test('an out-of-range reconstructed sample proves the inversion diverged', + () { + // A correct inversion cannot land outside signed 20 bits, so a window + // that does is provably wrong. + final r = reconstructSaturatedDeltaWindow(524000, [200, 300, -100]); + expect(r.samples, [524000, 524200, 524500, 524400]); + expect(r.outOfRangeSampleIndices, [2, 3]); // 524500/524400 > 524287 + expect(r.divergenceProven, isTrue); + expect(r.hasSaturatedDelta, isFalse); // independent of the clamp + }); + + test('the deprecated members map onto the real fields they misread', () { + // Every name below is deprecated; the package's analysis_options mutes + // deprecated_member_use_from_same_package precisely so these stay + // testable. See the class for what each byte really is. + final wf = decode(frame); + expect(wf.burstIndex, wf.pipStateCounter & 0xFF); + expect(wf.frontEndMetaRaw, wf.firstSampleAdc & 0xFFFF); // 50627 + expect(wf.subChannel, (wf.firstSampleAdc >> 16) & 0xFF); // 5 + expect(wf.subChannelKnown, 5); // "inside 0..7" only ever meant positive + expect(wf.signalMetric, wf.accelDeltaG); + expect(wf.gainSetting, wf.channelStateWord & 0xFF); // 80 + expect(wf.gainIndex, (wf.channelStateWord >> 8) & 0xFF); // 8 + expect(wf.flagA, wf.primaryFlagsByte); + expect(wf.flagB, wf.morphologyByte); + expect(wf.ppgWaveform, wf.opticalDeltas); // deltas, never samples }); test('a record short of the exact 76-byte length is rejected outright', () { @@ -553,39 +697,130 @@ void main() { expect(r4.decoded['battery_pct'], 1.0); }); - test('gen5 GET_HELLO (0x91) decodes device_name + gated fw_version', () { - final inner = Uint8List(120); + // Build a gen5 GET_HELLO reply body per the revision-1 fixed map, with + // synthetic identity values. Returns the 104-byte body (offsets are + // body-relative). + Uint8List gen5HelloBody() { + final body = Uint8List(Gen5HelloInfo.semanticBodyLen); + final bd = ByteData.sublistView(body); + body[0] = 1; // hello revision + bd.setUint32(1, 901, Endian.little); // battery raw 901 → 90 + body[5] = 0; // not charging + bd.setUint32(6, 1780000000, Endian.little); // ts seconds + bd.setUint32(10, 28835, Endian.little); // ts subseconds + for (var i = 0; i < '5AG0000001'.length; i++) { + body[14 + i] = '5AG0000001'.codeUnitAt(i); // serial (NUL-padded) + } + // commit (24B) / cpu (30B): fill with recognizable bytes. + for (var i = 25; i < 49; i++) { + body[i] = 0xAB; + } + for (var i = 49; i < 79; i++) { + body[i] = 0xCD; + } + bd.setUint32(79, 13, Endian.little); // hardware family + bd.setUint32(83, 0, Endian.little); // pcba revision + bd.setUint32(87, 82, Endian.little); // optical discriminator → WHOOP 5 + body[91] = 50; // fw major + body[92] = 40; // fw minor + body[93] = 1; // fw build + bd.setUint32(94, 0, Endian.little); // fw unreleased + body[98] = 11; // sigproc major + body[99] = 1; // sigproc minor + body[100] = 0; // sigproc patch + body[101] = 0; // hr broadcast + body[102] = 1; // on-body + body[103] = 0; // error byte + return body; + } + + test('Gen5HelloInfo.parse decodes the revision-1 body map', () { + final h = Gen5HelloInfo.parse(gen5HelloBody())!; + expect(h.helloRevision, 1); + expect(h.batteryPct, 90); // 901 ~/ 10 + expect(h.charging, isFalse); + expect(h.tsSeconds, 1780000000); + expect(h.tsSubseconds, 28835); + expect(h.serial, '5AG0000001'); + expect(h.hardwareFamily, 13); + expect(h.opticalDiscriminator, 82); + expect(h.isWhoop5, isTrue); + expect(h.firmwareVersion, '50.40.1.0'); + expect(h.signalProcessorVersion, '11.1.0'); + expect(h.wristOn, isTrue); + expect(h.commitHex.length, 48); // 24 bytes → 48 hex chars + expect(h.cpuHex.length, 60); // 30 bytes → 60 hex chars + }); + + test('gen5 GET_HELLO surfaces Gen5HelloInfo through the full inner packet', + () { + // Integration vector: a COMPLETE command-response inner packet, not just + // a body — proves parseCommandResponse hands the branch the body at the + // right offset (past the 5-byte header). An off-by-two here would fail. + final body = gen5HelloBody(); + final inner = Uint8List(5 + body.length); inner[0] = PacketType.commandResponse; - inner[1] = 0; - inner[2] = Cmd.getHello; + inner[1] = 9; // response packet seq + inner[2] = Cmd.getHello; // echoed opcode inner[3] = 7; // echoed request seq - inner[4] = 1; // status: ok - final payload = Uint8List.sublistView(inner, 3); - // device_name @ pay[51] — a 30-byte field in the reply body - final name = 'MyStrap'; - for (int i = 0; i < name.length; i++) { - payload[51 + 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; + inner[4] = 1; // status: SUCCESS + inner.setRange(5, 5 + body.length, body); final r = parseCommandResponse(inner, profile: BandProfile.gen5)!; - expect(r.decoded['device_name'], 'MyStrap'); - expect(r.decoded['fw_version'], Uint8List.fromList([50, 38, 1, 0])); + expect(r.decoded['req_seq'], 7); + expect(r.decoded['cmd_status'], 1); + final h = r.decoded['gen5_hello'] as Gen5HelloInfo; + expect(h.serial, '5AG0000001'); + expect(h.firmwareVersion, '50.40.1.0'); + expect(h.wristOn, isTrue); + // `device_name` is gone for good: it read the CPU/signature field, so it + // was a wrong value, not a moved one. + expect(r.decoded.containsKey('device_name'), isFalse); + // `fw_version` is KEPT as a compat alias for one release — the old decode + // happened to read the true major/minor/build bytes, so silently + // returning null to existing callers would break a working field. + expect(r.decoded['fw_version'], Uint8List.fromList([50, 40, 1, 0])); + }); + + test('a non-SUCCESS reply publishes no hello (stale-body guard)', () { + // Hello answers PENDING first, and FAILURE/UNSUPPORTED are real + // terminal cases. A non-success body is not populated, so parsing it + // would mint a serial/battery/firmware out of whatever the buffer held. + for (final status in [0, 2, 3]) { + final body = gen5HelloBody(); + final inner = Uint8List(5 + body.length); + inner[0] = PacketType.commandResponse; + inner[2] = Cmd.getHello; + inner[3] = 7; + inner[4] = status; + inner.setRange(5, 5 + body.length, body); + final r = parseCommandResponse(inner, profile: BandProfile.gen5)!; + expect(r.decoded.containsKey('gen5_hello'), isFalse, + reason: 'status $status must not publish a hello'); + } }); - test('gen5 GET_HELLO omits fw_version when the gate byte does not match', - () { - final inner = Uint8List(120); + test('an unknown hello revision is refused, not read at rev-1 offsets', () { + final body = gen5HelloBody(); + body[0] = 2; // some future revision + expect(Gen5HelloInfo.parse(body), isNull); + }); + + test('an out-of-range battery is omitted, never clamped', () { + final body = gen5HelloBody(); + ByteData.sublistView(body).setUint32(1, 99999, Endian.little); // 9999% + final h = Gen5HelloInfo.parse(body)!; + expect(h.batteryPct, isNull); + expect(h.serial, '5AG0000001'); // the rest of the record still decodes + }); + + test('gen5 GET_HELLO omits the hello when the body is too short', () { + final inner = Uint8List(5 + 80); // < 104-byte semantic body inner[0] = PacketType.commandResponse; inner[2] = Cmd.getHello; - inner[3] = 7; // echoed request seq - inner[4] = 1; // status: ok - inner[3 + 93] = 99; // not 50 + inner[3] = 7; + inner[4] = 1; final r = parseCommandResponse(inner, profile: BandProfile.gen5)!; - expect(r.decoded.containsKey('fw_version'), isFalse); + expect(r.decoded.containsKey('gen5_hello'), isFalse); }); }); @@ -656,5 +891,42 @@ void main() { expect(d.fields['hist_version'], 18); expect(d.fields['ts_epoch'], 1780916150); }); + + // Battery-pack ("puffin") wrapper types must decode to a NAMED kind, never + // 'other' — 53/54/55 are history-count members a client has to + // count and retain; 37/38/56 are named so nothing silently drops them. + Uint8List wrapperFrame(int packetType) { + // A minimal aligned inner packet whose first byte is the packet type. + final inner = Uint8List.fromList([packetType, 0x01, 0x00, 0x00]); + return buildFrame(inner, profile: BandProfile.gen5); + } + + for (final t in [ + PacketType.relativePuffinEvents, + PacketType.puffinEventsFromStrap, + PacketType.relativeBatteryPackConsoleLogs, + ]) { + test('battery-pack wrapper type $t decodes to puffin_event (count member)', + () { + final parsed = parseFrame(wrapperFrame(t), profile: BandProfile.gen5)!; + final d = decodeFrame(parsed, profile: BandProfile.gen5); + expect(d.kind, 'puffin_event'); + expect(d.fields['packet_type'], t); + expect(d.fields['retain_raw'], isTrue); + }); + } + + for (final t in [ + PacketType.puffinCommand, + PacketType.puffinCommandResponse, + PacketType.puffinMetadata, + ]) { + test('battery-pack type $t decodes to a named kind, not other', () { + final parsed = parseFrame(wrapperFrame(t), profile: BandProfile.gen5)!; + final d = decodeFrame(parsed, profile: BandProfile.gen5); + expect(d.kind, 'puffin'); + expect(d.fields['packet_type'], t); + }); + } }); } diff --git a/test/gen5_record_fields_test.dart b/test/gen5_record_fields_test.dart index 5112330..690dc41 100644 --- a/test/gen5_record_fields_test.dart +++ b/test/gen5_record_fields_test.dart @@ -106,31 +106,75 @@ void main() { }); }); - group('v18 optical fields are two big-endian u16s', () { - test('the real fixture pairs as 0x656F / 0x1E1E', () { + group('v18 optical bytes: two u8 PD means + two i8 pSNR values', () { + test('the real fixture decodes as independent bytes', () { final inner = innerOf(_v18Frame); final s = parseGen5Historical(inner) as Gen5HistorySample; + // body 85/86: quantized selected-source PD means (B then A). + expect(s.pdMeanB, inner[98]); // 0x65 + expect(s.pdMeanA, inner[99]); // 0x6F + // body 87/88: SIGNED i8 pSNR in dB (B then A); fixture is 0x1E = +30 dB. + expect(s.psnrB, 0x1E); + expect(s.psnrA, 0x1E); + expect(s.psnrBAvailable, isTrue); + expect(s.psnrAAvailable, isTrue); + // Compat: the deprecated u16 views reconstruct the old values exactly. + // ignore: deprecated_member_use_from_same_package expect(s.opticalBaseline, 0x656F); + // ignore: deprecated_member_use_from_same_package expect(s.opticalAmp, 0x1E1E); - // Big-endian: the FIRST byte on the wire is the high half. - - expect(s.opticalAmp >> 8, inner[100]); - expect(s.opticalAmp & 0xFF, inner[101]); }); - test('the sentinel is the single value 0x8080', () { + test('0x80 is the per-detector -128 unavailable sentinel, not a value', () { final inner = innerOf(_v18Frame); inner[100] = 0x80; inner[101] = 0x80; final s = parseGen5Historical(inner) as Gen5HistorySample; - expect(s.opticalAmp, 0x8080); + // BOTH detectors unavailable — the case previously misread as one + // u16 "0x8080 sentinel" — a large share of real records read it. + expect(s.psnrB, -128); + expect(s.psnrA, -128); + expect(s.psnrBAvailable, isFalse); + expect(s.psnrAAvailable, isFalse); expect(s.isOpticalAmpSentinel, isTrue); - inner[101] = 0x7F; // one half alone is a real reading, not the sentinel + inner[101] = 0x7F; // PD-A has a real reading (+127 dB edge value) final s2 = parseGen5Historical(inner) as Gen5HistorySample; - expect(s2.opticalAmp, 0x807F); + expect(s2.psnrB, -128); + expect(s2.psnrBAvailable, isFalse); + expect(s2.psnrA, 127); + expect(s2.psnrAAvailable, isTrue); expect(s2.isOpticalAmpSentinel, isFalse); }); + + test('negative pSNR values decode as signed dB', () { + final inner = innerOf(_v18Frame); + inner[100] = 0xF6; // -10 dB + inner[101] = 0x05; // +5 dB + final s = parseGen5Historical(inner) as Gen5HistorySample; + expect(s.psnrB, -10); + expect(s.psnrA, 5); + expect(s.psnrBAvailable, isTrue); + }); + }); + + group('v18 skin-temp sentinel', () { + test('-5000 raw (= -50.00 °C) reads as unavailable, not a temperature', () { + final inner = innerOf(_v18Frame); + final bd = ByteData.sublistView(inner); + bd.setInt16(65, -5000, Endian.little); + final s = parseGen5Historical(inner) as Gen5HistorySample; + expect(s.skinTempC, -50.0); + expect(s.skinTempAvailable, isFalse); + expect(s.skinTempCOrNull, isNull); + }); + + test('a real reading passes through the honest accessor', () { + final inner = innerOf(_v18Frame); + final s = parseGen5Historical(inner) as Gen5HistorySample; + expect(s.skinTempAvailable, isTrue); + expect(s.skinTempCOrNull, s.skinTempC); + }); }); group('v18 signal quality + the fourth field in the sleep byte', () { @@ -194,7 +238,9 @@ void main() { // Synthetic — no real v20 capture is available — but it exercises the // stated geometry (body @ 18, stride 422, slots at +21 / +221) and the // metadata field boundaries. - Uint8List buildV20() { + // [ch0Offset]/[ch1Offset] are written as the raw 16-bit wire words at the + // descriptor's TIA 1 / TIA 2 offset-current fields. + Uint8List buildV20({int ch0Offset = 512, int ch1Offset = 1024}) { final inner = Uint8List(kGen5V20InnerLen); inner[0] = 0x2f; inner[1] = 20; @@ -212,14 +258,14 @@ void main() { v.setUint16(start + 2, 2500, Endian.little); // 25.00 mA inner[start + 4] = 2; v.setUint16(start + 5, 40, Endian.little); // 400 µA - // photodiode 0: [source][u32 adc range][u16 adc offset] + // TIA 1: [physical-PD source][u32 adc range µA][i16 offset current] inner[start + 7] = 3; v.setUint32(start + 8, 100000, Endian.little); - v.setUint16(start + 12, 512, Endian.little); - // photodiode 1 + v.setUint16(start + 12, ch0Offset, Endian.little); + // TIA 2 inner[start + 14] = 4; v.setUint32(start + 15, 200000, Endian.little); - v.setUint16(start + 19, 1024, Endian.little); + v.setUint16(start + 19, ch1Offset, Endian.little); } return inner; } @@ -245,15 +291,35 @@ void main() { expect(blk.ledBCurrentMicroamps, 400); expect(blk.channel0Source, 3); expect(blk.channel0AdcRange, 100000); - expect(blk.channel0AdcOffset, 512); + expect(blk.tia1OffsetCurrentRaw, 512); expect(blk.channel1Source, 4); expect(blk.channel1AdcRange, 200000); - expect(blk.channel1AdcOffset, 1024); + expect(blk.tia2OffsetCurrentRaw, 1024); // The raw blobs stay available and still cover the same bytes. expect(blk.sharedMetaRaw, hasLength(6)); expect(blk.channel0MetaRaw, hasLength(7)); expect(blk.channel1MetaRaw, hasLength(7)); } }); + + test('TIA offset current is a signed i16 in 10 nA/LSB', () { + // TIA 1: bytes 0x18 0xFC = 0xFC18 = 64536 unsigned = -1000 signed, + // i.e. -10,000 nA. TIA 2: 2400 = one of the quantized settings + // (0/800/1600/2400 on the wire = 0/8/16/24 µA) = +24,000 nA. + final buf = + parseGen5Historical(buildV20(ch0Offset: 0xFC18, ch1Offset: 2400)) + as Gen5OpticalBuffer; + for (final blk in buf.blocks) { + expect(blk.tia1OffsetCurrentRaw, -1000); + expect(blk.tia1OffsetCurrentNanoamps, -10000); + expect(blk.tia2OffsetCurrentRaw, 2400); + expect(blk.tia2OffsetCurrentNanoamps, 24000); // 24 µA + // The deprecated getters keep their old unsigned-u16 behaviour. + // ignore: deprecated_member_use_from_same_package + expect(blk.channel0AdcOffset, 64536); + // ignore: deprecated_member_use_from_same_package + expect(blk.channel1AdcOffset, 2400); + } + }); }); } diff --git a/test/gen5_test.dart b/test/gen5_test.dart index 84920b2..b724e7f 100644 --- a/test/gen5_test.dart +++ b/test/gen5_test.dart @@ -184,9 +184,9 @@ void main() { final frames = buildR22EnableSequence(startSeq: 1); expect(frames.length, kGen5R22EnableFlags.length); // '1' = enable ('2' would force-DISABLE these). See - // gen5_command_surface_test.dart for the full value coverage. + // gen5_command_surface_test.dart for the full value coverage, including + // the omitContestedFlags opt-out. expect(kGen5R22EnableFlags[0], ('enable_r22_packets', '1')); - expect(kGen5R22EnableFlags[3], ('enable_r22_v4_packets', '1')); expect(kGen5R22EnableFlags.last, ('disable_pip_r26_packets', '2')); for (int i = 0; i < frames.length; i++) { final parsed = parseFrame(frames[i], profile: BandProfile.gen5)!; @@ -209,15 +209,74 @@ void main() { [0x01, 47, 152, 0, 0, 0, 0, 0, 0, 0, 0, 7]); }); + test('gen5 uses the established clock opcodes 10/11, gen5-framed', () { + // gen5 SET_CLOCK(10) carries ; GET_CLOCK(11) is + // the empty-body fallback. Hardware-confirmed on a real gen5 strap: + // 11 reads a usable time and 10 answers SUCCESS for the 8-byte form. + final now = DateTime.fromMillisecondsSinceEpoch(0x5F000000 * 1000 + 500, + isUtc: true); + final setParsed = parseFrame( + cmdSetClock(3, now: now, profile: BandProfile.gen5), + profile: BandProfile.gen5, + )!; + expect(setParsed.valid, isTrue, reason: 'must be gen5-framed (crc16)'); + expect(setParsed.inner[2], Cmd.setClock); + expect(Cmd.setClock, 10); + // 8-byte body: u32 seconds then u32 subseconds (no revision byte). + expect(setParsed.inner.sublist(3, 11), [ + 0x00, 0x00, 0x00, 0x5F, // seconds LE + 0x00, 0x40, 0x00, 0x00, // subsec = 500ms * 32768 / 1000 = 16384 + ]); + + final getParsed = parseFrame( + cmdGetClock(4, profile: BandProfile.gen5), + profile: BandProfile.gen5, + )!; + expect(getParsed.valid, isTrue); + expect(getParsed.inner[2], Cmd.getClock); + expect(Cmd.getClock, 11); + // EMPTY body — the shape the probe physically used on gen5. + expect(getParsed.inner.sublist(0, 3), [0x23, 4, Cmd.getClock]); + }); + + test('a gen5 GET_CLOCK(11) reply decodes its epoch, and a FAILURE reply ' + 'publishes nothing', () { + // Reply body is the gen4 shape [u32 sec][u32 subsec] at body offset 0 + // (probe: whoopTimeMillis reads u32 @0 / u32 @4). + Uint8List reply(int status) { + final inner = Uint8List(5 + 8); + inner[0] = PacketType.commandResponse; + inner[2] = Cmd.getClock; + inner[3] = 4; // echoed req seq + inner[4] = status; + ByteData.sublistView(inner).setUint32(5, 1785483780, Endian.little); + return inner; + } + + final ok = parseCommandResponse(reply(1), profile: BandProfile.gen5)!; + expect(ok.decoded['clock_epoch'], 1785483780); + + // A non-success reply leaves the body unpopulated; an epoch guessed from + // stale bytes becomes the reference every alarm is armed against. + for (final bad in [0, 2, 3]) { + final r = parseCommandResponse(reply(bad), profile: BandProfile.gen5)!; + expect(r.decoded.containsKey('clock_epoch'), isFalse, + reason: 'status $bad must not publish a clock'); + } + }); + test( - 'cmdSetClockGen5 / cmdGetClockGen5 use the gen5-exclusive opcode values', + 'the deprecated Maverick clock builders still emit their documented ' + 'bytes (kept only for a guarded experiment)', () { + // ignore: deprecated_member_use_from_same_package 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); + // ignore: deprecated_member_use_from_same_package final getFrame = cmdGetClockGen5(1); final getParsed = parseFrame(getFrame, profile: BandProfile.gen5)!; expect(getParsed.valid, isTrue); @@ -262,15 +321,19 @@ void main() { expect(EventId.bleRealtimeHrOff, 34); }); - test( - 'an unknown event id renders raw and never borrows a Cmd name for the same number', - () { + test('an event id 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. + // numeric collision. Event 123 is GENERIC_FIRMWARE_EVENT; + // the two namespaces are separate and must stay that way. expect(Cmd.selectWrist, 123); - expect(EventId.name(123), 'EVENT_123'); + expect(EventId.name(123), 'GENERIC_FIRMWARE_EVENT'); expect(EventId.name(123), isNot(contains('WRIST'))); }); + + test('an id outside the vocabulary still renders raw', () { + // Unlisted event id — 110 arrives and has no vocabulary entry. + expect(EventId.name(110), 'EVENT_110'); + }); }); group('gen5 live IMU (0x2B record 21)', () { // Real captured live IMU frame — the same record-21 buffer the historical diff --git a/test/gen5_v22_test.dart b/test/gen5_v22_test.dart new file mode 100644 index 0000000..2bd134c --- /dev/null +++ b/test/gen5_v22_test.dart @@ -0,0 +1,645 @@ +// gen5 (WHOOP 5) revision-22 research/diagnostic record tests. +// +// The five hex bodies below are REAL 176-byte R22 inner packets, one per +// emitted tag, with their timestamp fields synthesized. Every cross-check +// value beside them (the twin R18's accel float bytes, its state word and +// its flags byte) comes from the R18 record with the SAME +// (record_index, unix) — the join key every R22 record carries. +// +// The synthetic cases fill every byte with a poison pattern first and then +// write only the offsets the layout assigns. That encodes the stale-bytes +// rule ("unwritten offsets retain the previous packet's content") as a test: +// if a decoder read one byte it should not, the poison would show up in a +// typed field. + +import 'dart:math' as math; +import 'dart:typed_data'; +import 'package:test/test.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +Uint8List hex(String s) { + final clean = s.replaceAll(RegExp(r'\s'), ''); + 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; +} + +/// tag 1 — a real capture record; the timestamp field is synthesized. +final Uint8List realTag1 = hex( + '2f16806fc34b0101a5186ab81e0100ffcf030059027dffdffbe7fa58fa45fbbb' + 'fce0fd55feabfe83fe13ff48032c043d046c0525048f048f055f0610051a03fa' + '0041fe0080000000000000000000000000000000000000000000000000000000' + '0000000000000000000000000000000000000000000021f408e0ebe23d71dda9' + '3d0a03433f663a263f5007010c020c0100000000000000000000000000000000' + '00000000000000000000000000000000', +); + +/// tag 2 — a real capture record from a band CONFIGURED for variant 3 that +/// emitted tag 2 (the 3→2 fallback, visible on the wire); the timestamp +/// field is synthesized. +final Uint8List realTag2 = hex( + '2f16806ffa4c0102a5186a701d0200ffff070000000000000000000000000000' + '0000000000000000000000000000000000000000000000000000000000000000' + '0000000080000000000000000000000000000000000000000000000000000000' + '00000000000000000000000000000000000000000005000000b81c623eae57b8' + 'bea4443f3f52fc103ff057010c020c0000000000000000000000000000000000' + '00000000000000000000000000000000', +); + +/// tag 3 — a real capture record (natural sleep); the timestamp field is +/// synthesized. +final Uint8List realTag3 = hex( + '2f1680c368500103a5186aa3100300ced10100460044004d0044004d00440040' + '00dbfe66fd6a00bd009600008000000000000000000000000000000000000000' + '00ad00ffff070000000000000000000000000000000000000000000000000000' + '80000000000000000000000000000000000000000080e080e02e41809ad63bf6' + '6880bd5c3fa0bea48c70bf50020107f257000000000000000000000000000000' + '00000000000000000000000000000000', +); + +/// tag 5 — a real capture record; both timestamp fields are synthesized, +/// preserving the wire fact that the embedded PIP ring record is stamped +/// 39 s earlier than the carrier. +final Uint8List realTag5 = hex( + '2f16805c054d013fa5186a7a54050018a5186ac2550100615b00004201bd02a9' + 'fe8803a10040ffbefdd5faccfcf0fb7bfc04fee9fe33ffb6fda0fe6bfd03fe62' + 'ffdcff5dff71fef3fd99fc50a9063f6003000000000000000000000000000000' + '0000000000000000000000000000000000000000000060c509a89a033e852f75' + 'bf3dfadf3e7ba4203e6003dd00020c00000000000000000000000000000000dd' + '00000000000000000000000000000000', +); + +/// tag 6 — a real capture record; the timestamp field is synthesized. +final Uint8List realTag6 = hex( + '2f1680a1e24c0106a5186a701d0600000000f301e301290243021302db014802' + '81010b02d900c1016d0290023902230235025d024202470261026f0271024102' + '47026d02b108050744070508a507a6077908b50600079809a507e707ed075508' + 'af07a60728086c081d08d507b8075d072a07a007ca073e0d240e2b0ed90dfa0d' + '1a0e950eb20e4d0b510f260ec20de70dda0d150e040e700d7b0dab0dcf0ec70d' + 'ea0df70d020ee60d0000000064000000', +); + +/// A 176-byte R22 inner whose every body byte is poison, so any accessor that +/// reads an offset its tag does not assign shows it. +Uint8List poisonBody(int tag) { + final inner = Uint8List(kGen5V22InnerLen); + for (int i = 0; i < inner.length; i++) { + inner[i] = 0xA5; + } + inner[0] = 0x2F; + inner[1] = 22; + inner[2] = 0x80; // bit 7 set: 25 Hz optical front end + final v = ByteData.sublistView(inner); + v.setUint32(3, 4242, Endian.little); + v.setUint32(7, 1780000000, Endian.little); + v.setUint16(11, 1234, Endian.little); + inner[13] = tag; + inner[14] = 0x00; + return inner; +} + +/// Write the tags-1/2/3/4 metadata block at [base]. +void writeMetaBlock( + Uint8List inner, { + required int base, + required int flagsSnapshot, + required double accelDeltaG, + required double f1, + required double f2, + required double f3, + required int stateWord, + required int primaryFlags, +}) { + final v = ByteData.sublistView(inner); + inner[base + 1] = flagsSnapshot; + v.setFloat32(base + 4, accelDeltaG, Endian.little); + v.setFloat32(base + 8, f1, Endian.little); + v.setFloat32(base + 12, f2, Endian.little); + v.setFloat32(base + 16, f3, Endian.little); + v.setUint16(base + 20, stateWord, Endian.little); + inner[base + 26] = primaryFlags; +} + +void writeWindow( + Uint8List inner, { + required int start, + required int firstSample, + required List deltas, +}) { + final v = ByteData.sublistView(inner); + v.setInt32(start, firstSample, Endian.little); + for (int i = 0; i < deltas.length; i++) { + v.setInt16(start + 4 + 2 * i, deltas[i], Endian.little); + } +} + +void main() { + group('Gen5V22Decoder — registration and gating', () { + test('is registered in kGen5HistoricalDecoders', () { + expect( + kGen5HistoricalDecoders.map((d) => d.name), + contains('gen5_v22'), + ); + }); + + test('parseGen5Historical dispatches revision 22', () { + expect(parseGen5Historical(realTag1), isA()); + }); + + test('the length gate is EXACT (176), not a floor', () { + const dec = Gen5V22Decoder(); + expect(dec.matches(realTag1), isTrue); + for (final len in [kGen5V22InnerLen - 1, kGen5V22InnerLen + 1]) { + final wrong = Uint8List(len); + wrong[0] = 0x2F; + wrong[1] = 22; + expect(dec.matches(wrong), isFalse, reason: 'len=$len'); + expect(dec.decode(wrong), isNull, reason: 'len=$len'); + expect(parseGen5Historical(wrong), isNull, reason: 'len=$len'); + } + }); + + test('a 176-byte record of another revision is not claimed', () { + final other = Uint8List.fromList(realTag1); + other[1] = 20; + expect(const Gen5V22Decoder().matches(other), isFalse); + }); + + test('an unknown tag still decodes to header + tag + rawBody', () { + final inner = poisonBody(7); + final r = parseGen5Historical(inner) as Gen5ResearchRecord; + expect(r.tag, 7); + expect(r.hasKnownLayout, isFalse); + expect(r.rawBody, hasLength(kGen5V22InnerLen - 13)); // 163-byte body + expect(r.rawBody[0], 7); // body 0 is the tag itself + expect(r.opticalWindows, isEmpty); + expect(r.accelDeltaG, isNull); + expect(r.channelStateWord, isNull); + expect(r.primaryFlagsByte, isNull); + expect(r.primaryFlagsBit8Raw, isNull); + expect(r.flagsSnapshotByte, isNull); + expect(r.unnamedMetadataFloats, isEmpty); + expect(r.pipRecordUnix, isNull); + expect(r.accelRawX, isEmpty); + expect(r.accelTailRaw, isEmpty); + expect(r.extendedMetricsRaw, isEmpty); + }); + + test('the shared header decodes like every other gen5 record kind', () { + final r = parseGen5Historical(realTag1) as Gen5ResearchRecord; + expect(r.histVersion, 22); + expect(r.flags, 0x80); + expect(r.ppgSampleRateHz, 25); // flags bit 7 — every checked record is 25 Hz + expect(r.recordIndex, 21742447); + expect(r.unix, 1780000001); + expect(r.tsSubsec, 0x1EB8); + }); + }); + + group('tag 1 — real capture', () { + late Gen5ResearchRecord r; + setUp(() => r = parseGen5Historical(realTag1) as Gen5ResearchRecord); + + test('one 49-slot optical window at inner[15]', () { + expect(r.tag, 1); + expect(r.opticalWindows, hasLength(1)); + final w = r.opticalWindows.single; + expect(w.innerOffset, 15); + expect(w.firstSampleAdc, 0x0003CFFF); // 249855 + expect(w.firstSampleAdcInRange, isTrue); + expect(w.deltas, hasLength(49)); + expect(w.deltas[0], 601); + expect(w.deltas[24], -32768); // the padding marker + expect(w.deltas.sublist(25).every((d) => d == 0), isTrue); + }); + + test('the reconstruction reports where it stops being trustworthy', () { + final rec = r.opticalWindows.single.reconstructWindow(); + expect(rec.samples, hasLength(50)); + expect(rec.samples.first, 249855); + // A -32768 delta at index 24 poisons sample 25 and everything after it: + // 25 usable samples, which is the 25 Hz mode. Nothing in the body says + // so — the marker is the only signal, and it is ambiguous by design. + expect(rec.firstAmbiguousSampleIndex, 25); + expect(rec.trustedSampleCount, 25); + expect(rec.hasSaturatedDelta, isTrue); + expect(rec.outOfRangeSampleIndices, isEmpty); + }); + + test('the metadata block mirrors the paired R18 record', () { + // The twin R18: inner[33:37] = e0ebe23d, + // inner[67:69] = 1872, inner[73] = 1. + expect(r.accelDeltaG, closeTo(0.11080145835876465, 1e-12)); + expect(r.channelStateWord, 1872); + expect(r.primaryFlagsByte, 1); + expect(r.primaryFlagsBit8Raw, 1); + expect(r.flagsSnapshotByte, 0x21); + expect(r.flagsSnapshotByte! & 3, 1); // only these two bits are proven + expect(r.unnamedMetadataFloats, hasLength(3)); + expect(r.unnamedMetadataFloats[0], closeTo(0.082938, 1e-5)); + }); + + test('tag 1 writes nothing in the tag-2/4 extension region', () { + expect(r.extendedMetricsRaw, isEmpty); + expect(r.accelRawX, isEmpty); + expect(r.pipRecordUnix, isNull); + expect(r.rawBody, hasLength(163)); + }); + }); + + group('tag 2 — real capture (a variant-3 writer that fell back)', () { + late Gen5ResearchRecord r; + setUp(() => r = parseGen5Historical(realTag2) as Gen5ResearchRecord); + + test('a clipped-flat window is detectable, not silently "valid"', () { + expect(r.tag, 2); + final w = r.opticalWindows.single; + expect(w.firstSampleAdc, 524287); // the +2^19-1 clip code + expect(w.isClippedFlat, isTrue); + // It reconstructs to a perfectly flat, perfectly plausible line — which + // is exactly why clipping has to be detected separately. + final rec = w.reconstructWindow(); + expect(rec.outOfRangeSampleIndices, isEmpty); + expect(rec.samples.take(25).every((s) => s == 524287), isTrue); + }); + + test('isClippedFlat treats +32767 as an in-band terminator like -32768', + () { + // Both i16 rails end the usable band; a clipped-flat window whose + // in-band zeros run into the POSITIVE rail is exactly as empty as one + // ending on the negative rail. + const positive = Gen5ResearchOpticalWindow( + innerOffset: 15, + firstSampleAdc: 524287, + deltas: [0, 0, 0, 32767, 12, -9], + ); + expect(positive.isClippedFlat, isTrue); + const negative = Gen5ResearchOpticalWindow( + innerOffset: 15, + firstSampleAdc: 524287, + deltas: [0, 0, 0, -32768, 12, -9], + ); + expect(negative.isClippedFlat, isTrue); + const realSignal = Gen5ResearchOpticalWindow( + innerOffset: 15, + firstSampleAdc: 524287, + deltas: [0, 5, 0, -32768], + ); + expect(realSignal.isClippedFlat, isFalse, + reason: 'a nonzero in-band delta is signal, not clip'); + }); + + test('the metadata block mirrors the paired R18 record', () { + expect(r.accelDeltaG, closeTo(0.22081267833709717, 1e-12)); + expect(r.channelStateWord, 22512); + expect(r.primaryFlagsByte, 0); + expect(r.flagsSnapshotByte, 0x00); + }); + + test('the located-but-unsplit extension region is exposed raw', () { + expect(r.extendedMetricsRaw, hasLength(11)); // inner[144:155] + }); + }); + + group('tag 3 — real capture (two optical windows, metadata shifted +2)', () { + late Gen5ResearchRecord r; + setUp(() => r = parseGen5Historical(realTag3) as Gen5ResearchRecord); + + test('two 24-slot windows at inner[15] and inner[67]', () { + expect(r.tag, 3); + expect(r.opticalWindows, hasLength(2)); + final a = r.opticalWindows[0]; + final b = r.opticalWindows[1]; + expect(a.innerOffset, 15); + expect(a.firstSampleAdc, 0x0001D1CE); // 119246 + expect(a.deltas, hasLength(24)); + expect(a.deltas[0], 70); + expect(a.deltas[12], -32768); + expect(a.reconstructWindow().trustedSampleCount, 13); + expect(b.innerOffset, 67); + expect(b.firstSampleAdc, 524287); + expect(b.isClippedFlat, isTrue); + expect(b.deltas[12], -32768); + }); + + test('the metadata block sits at inner[119], +2 from tags 1/2/4', () { + // The twin R18: inner[33:37] = 809ad63b, + // inner[67:69] = 592, inner[73] = 32 (&3 == 0). + expect(r.accelDeltaG, closeTo(0.006549179553985596, 1e-12)); + expect(r.channelStateWord, 592); + expect(r.primaryFlagsByte, 0); + expect(r.flagsSnapshotByte, 0xE0); + expect(r.flagsSnapshotByte! & 3, 0); + expect(r.extendedMetricsRaw, isEmpty); + }); + }); + + group('tag 5 — real capture (embedded PIP ring record)', () { + late Gen5ResearchRecord r; + setUp(() => r = parseGen5Historical(realTag5) as Gen5ResearchRecord); + + test('the ring record carries its OWN timestamp, 39 s behind the carrier', + () { + expect(r.tag, 5); + expect(r.unix, 1780000063); + expect(r.pipRecordUnix, 1780000024); + expect(r.unix - r.pipRecordUnix!, 39); + }); + + test('one 24-slot window at inner[23], no saturation in this record', () { + final w = r.opticalWindows.single; + expect(w.innerOffset, 23); + expect(w.firstSampleAdc, 0x00005B61); // 23393 + expect(w.deltas, hasLength(24)); + expect(w.deltas.contains(-32768), isFalse); + expect(w.reconstructWindow().hasSaturatedDelta, isFalse); + expect(w.reconstructWindow().samples, hasLength(25)); + }); + + test('its mirrors match the R18 of the RING second, not the carrier', () { + // The R18 of the embedded record's second: inner[33:37] = 50a9063f, + // inner[67:69] = 864, inner[73] & 3 = 0. The carrier's own R18 reads + // 0.10741591453552246 / 864 / 1 — a different accel value, which is what + // makes this a real check rather than a tautology. + expect(r.accelDeltaG, closeTo(0.5260210037231445, 1e-12)); + expect(r.channelStateWord, 864); + expect(r.primaryFlagsByte, 0); + }); + + test('the stale tail is NOT decoded as tag-1/2/4 metadata', () { + // inner[83:176] of this body is byte-identical to the PREVIOUS R22 + // packet (the stale-bytes rule, visible on the wire). The + // bytes at inner[121:125] therefore hold the previous packet's accel + // float, 0.1284..., and a decoder that read the shared metadata block + // here would report that as this record's. + final stale = ByteData.sublistView(realTag5).getFloat32(121, Endian.little); + expect(stale, isNot(closeTo(r.accelDeltaG!, 1e-6))); + expect(r.flagsSnapshotByte, isNull); // tag 5 has no such byte + expect(r.unnamedMetadataFloats, isEmpty); + expect(r.extendedMetricsRaw, isEmpty); + // The bytes are still all there for a later analysis. + expect(r.rawBody, hasLength(163)); + expect(r.rawBody[121 - 13], realTag5[121]); + }); + }); + + group('tag 6 — real capture (25 x i16 acceleration per axis)', () { + late Gen5ResearchRecord r; + setUp(() => r = parseGen5Historical(realTag6) as Gen5ResearchRecord); + + test('three 25-sample axes at inner[18] / [68] / [118]', () { + expect(r.tag, 6); + expect(r.accelRawX, hasLength(25)); + expect(r.accelRawY, hasLength(25)); + expect(r.accelRawZ, hasLength(25)); + expect(r.accelRawX.first, 499); + expect(r.accelRawX.last, 621); + expect(r.accelRawY.first, 2225); + expect(r.accelRawZ.first, 3390); + expect(r.accelRawZ.last, 3558); + }); + + test('the 4096 LSB/g scale puts a resting wrist at 1 g', () { + expect(r.accelXg.first, closeTo(499 / 4096.0, 1e-12)); + var total = 0.0; + for (int i = 0; i < 25; i++) { + final x = r.accelXg[i]; + final y = r.accelYg[i]; + final z = r.accelZg[i]; + final mag = math.sqrt(x * x + y * y + z * z); + expect(mag, closeTo(1.0, 0.25), reason: 'sample $i'); + total += mag; + } + // Over the whole 20-record capture the mean is 1.0406 g; this record's + // own mean is the same order. Anything else would mean the axes are not + // where this decoder puts them, or the scale is not 4096 LSB/g. + expect(total / 25, closeTo(1.0, 0.1)); + }); + + test('tag 6 carries no optical window and no R18 mirror', () { + expect(r.opticalWindows, isEmpty); + expect(r.accelDeltaG, isNull); + expect(r.channelStateWord, isNull); + expect(r.primaryFlagsByte, isNull); + expect(r.flagsSnapshotByte, isNull); + expect(r.unnamedMetadataFloats, isEmpty); + }); + + test('the eight tail bytes are raw — the doc\'s counters are unconfirmed', + () { + expect(r.accelTailRaw, hasLength(8)); // inner[168:176] + expect(r.accelTailRaw, [0, 0, 0, 0, 0x64, 0, 0, 0]); + }); + }); + + group('synthetic bodies — only the assigned offsets are read', () { + test('tag 1 over a poisoned body', () { + final inner = poisonBody(1); + writeWindow( + inner, + start: 15, + firstSample: -12345, + deltas: List.generate(49, (i) => i < 24 ? i - 12 : 0) + ..[24] = -32768, + ); + writeMetaBlock( + inner, + base: 117, + flagsSnapshot: 0x61, + accelDeltaG: 0.25, + f1: -0.5, + f2: 0.75, + f3: 1.5, + stateWord: 0xBEEF, + primaryFlags: 1, + ); + final r = parseGen5Historical(inner) as Gen5ResearchRecord; + expect(r.opticalWindows.single.firstSampleAdc, -12345); + expect(r.opticalWindows.single.deltas[24], -32768); + expect(r.accelDeltaG, 0.25); + expect(r.unnamedMetadataFloats, [-0.5, 0.75, 1.5]); + expect(r.channelStateWord, 0xBEEF); + expect(r.primaryFlagsByte, 1); + expect(r.flagsSnapshotByte, 0x61); + // Nothing leaked from the poisoned, unassigned bytes. + expect(r.extendedMetricsRaw, isEmpty); + expect(r.accelRawX, isEmpty); + expect(r.pipRecordUnix, isNull); + // …but the poison is preserved verbatim in the raw body. + expect(r.rawBody[160 - 13], 0xA5); + }); + + test('tag 2 exposes the extension region without naming anything in it', + () { + final inner = poisonBody(2); + writeWindow(inner, start: 15, firstSample: 100, deltas: List.filled(49, 0)); + writeMetaBlock( + inner, + base: 117, + flagsSnapshot: 0, + accelDeltaG: 1.0, + f1: 0, + f2: 0, + f3: 0, + stateWord: 1, + primaryFlags: 0, + ); + final r = parseGen5Historical(inner) as Gen5ResearchRecord; + expect(r.extendedMetricsRaw, hasLength(11)); + expect(r.extendedMetricsRaw.every((b) => b == 0xA5), isTrue); + }); + + test('tag 4 shares the tag-1/2 layout and writes the extension region', + () { + // Tag 4 rides the shared tag-1/2/4 branch behind an `tag != 1` gate for + // the extension region; a regression narrowing that gate to tag 2 would + // pass every other test here. + final inner = poisonBody(4); + writeWindow( + inner, start: 15, firstSample: 300, deltas: List.filled(49, 0)); + writeMetaBlock( + inner, + base: 117, + flagsSnapshot: 0x10, + accelDeltaG: 0.5, + f1: 0, + f2: 0, + f3: 0, + stateWord: 1872, + primaryFlags: 1, + ); + final r = parseGen5Historical(inner) as Gen5ResearchRecord; + expect(r.opticalWindows.single.deltas, hasLength(49)); + expect(r.accelDeltaG, 0.5); + expect(r.channelStateWord, 1872); + expect(r.flagsSnapshotByte, 0x10); + expect(r.extendedMetricsRaw, hasLength(11)); + expect(r.pipRecordUnix, isNull); + expect(r.accelRawX, isEmpty); + }); + + test('tag 3 reads two windows and the +2 metadata base', () { + final inner = poisonBody(3); + writeWindow( + inner, + start: 15, + firstSample: 1000, + deltas: List.generate(24, (i) => i + 1), + ); + writeWindow( + inner, + start: 67, + firstSample: -2000, + deltas: List.generate(24, (i) => -(i + 1)), + ); + writeMetaBlock( + inner, + base: 119, + flagsSnapshot: 0x20, + accelDeltaG: 0.125, + f1: 1, + f2: 2, + f3: 3, + stateWord: 592, + primaryFlags: 0, + ); + final r = parseGen5Historical(inner) as Gen5ResearchRecord; + expect(r.opticalWindows.map((w) => w.firstSampleAdc), [1000, -2000]); + expect(r.opticalWindows[0].deltas.first, 1); + expect(r.opticalWindows[1].deltas.first, -1); + expect(r.accelDeltaG, 0.125); + expect(r.channelStateWord, 592); + expect(r.flagsSnapshotByte, 0x20); + expect(r.primaryFlagsByte, 0); + }); + + test('tag 5 reads the ring record, never the shared metadata block', () { + final inner = poisonBody(5); + final v = ByteData.sublistView(inner); + v.setUint32(15, 1780000024, Endian.little); + writeWindow( + inner, + start: 23, + firstSample: 4096, + deltas: List.filled(24, 7), + ); + v.setFloat32(75, 0.5, Endian.little); + v.setUint16(79, 864, Endian.little); + inner[81] = 1; + final r = parseGen5Historical(inner) as Gen5ResearchRecord; + expect(r.pipRecordUnix, 1780000024); + expect(r.opticalWindows.single.firstSampleAdc, 4096); + expect(r.opticalWindows.single.deltas, hasLength(24)); + expect(r.accelDeltaG, 0.5); + expect(r.channelStateWord, 864); + expect(r.primaryFlagsByte, 1); + expect(r.primaryFlagsBit8Raw, 1); + // inner[117:176] is 0xA5 poison here; nothing read it. + expect(r.flagsSnapshotByte, isNull); + expect(r.unnamedMetadataFloats, isEmpty); + }); + + test('tag 6 reads three axes and the raw tail', () { + final inner = poisonBody(6); + final v = ByteData.sublistView(inner); + for (int i = 0; i < 25; i++) { + v.setInt16(18 + 2 * i, 4096, Endian.little); // 1.0 g + v.setInt16(68 + 2 * i, -2048, Endian.little); // -0.5 g + v.setInt16(118 + 2 * i, 0, Endian.little); + } + for (int o = 168; o < kGen5V22InnerLen; o++) { + inner[o] = o - 168; + } + final r = parseGen5Historical(inner) as Gen5ResearchRecord; + expect(r.accelXg.every((g) => g == 1.0), isTrue); + expect(r.accelYg.every((g) => g == -0.5), isTrue); + expect(r.accelZg.every((g) => g == 0.0), isTrue); + expect(r.accelTailRaw, [0, 1, 2, 3, 4, 5, 6, 7]); + expect(r.opticalWindows, isEmpty); + }); + }); + + group('reconstruction guards carry over from v26', () { + test('an out-of-range reconstructed sample is reported', () { + final inner = poisonBody(1); + writeWindow( + inner, + start: 15, + firstSample: 524000, + deltas: List.filled(49, 1000), + ); + writeMetaBlock( + inner, + base: 117, + flagsSnapshot: 0, + accelDeltaG: 0, + f1: 0, + f2: 0, + f3: 0, + stateWord: 0, + primaryFlags: 0, + ); + final r = parseGen5Historical(inner) as Gen5ResearchRecord; + final rec = r.opticalWindows.single.reconstructWindow(); + expect(rec.divergenceProven, isTrue); + expect(rec.outOfRangeSampleIndices.first, 1); // 524000 + 1000 > 524287 + }); + + test('a non-finite metadata float abstains rather than fabricating', () { + final inner = poisonBody(1); + writeWindow(inner, start: 15, firstSample: 0, deltas: List.filled(49, 0)); + final v = ByteData.sublistView(inner); + v.setUint32(117 + 4, 0x7FC00000, Endian.little); // NaN + v.setUint32(117 + 8, 0x7F800000, Endian.little); // +Inf + v.setFloat32(117 + 12, 1.0, Endian.little); + v.setFloat32(117 + 16, 2.0, Endian.little); + v.setUint16(117 + 20, 0, Endian.little); + inner[117 + 26] = 0; + final r = parseGen5Historical(inner) as Gen5ResearchRecord; + expect(r.accelDeltaG, isNull); + expect(r.unnamedMetadataFloats, [null, 1.0, 2.0]); + }); + }); +} diff --git a/test/whoop_protocol_update_test.dart b/test/whoop_protocol_update_test.dart index 953d492..aeaa72f 100644 --- a/test/whoop_protocol_update_test.dart +++ b/test/whoop_protocol_update_test.dart @@ -29,10 +29,19 @@ void main() { ); }); - test('history result failure uses cmd 0x17 + failure byte', () { - final frame = parseFrame(buildHistoryResultFail(0x22))!; - expect(frame.valid, isTrue); - expect(frame.inner, [0x23, 0x22, 0x17, 0x00]); + test('history result failure: gen5 sends 00 00, gen4 keeps one byte', () { + // The two-byte failure payload is pinned on gen5 only; the gen4 form + // keeps its established single failure byte until a gen4 capture says + // otherwise. Trailing bytes are 4-byte inner alignment padding. + final g5 = parseFrame( + buildHistoryResultFail(0x22, profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + expect(g5.valid, isTrue); + expect(g5.inner.sublist(0, 5), [0x23, 0x22, 0x17, 0x00, 0x00]); + + final g4 = parseFrame(buildHistoryResultFail(0x22))!; + expect(g4.valid, isTrue); + expect(g4.inner.sublist(0, 4), [0x23, 0x22, 0x17, 0x00]); }); test('enter high-frequency sync uses revision 2 + little-endian u16s', () {