From fba1a7c1ec024c8b6a2c49ddd3e154f476be72f0 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:20:40 +0530 Subject: [PATCH] wire up gen5 framing on the last unwired command builders cmdLinkValid, cmdGetHello, cmdGetHelloModern, cmdReportVersionInfo, cmdToggleHr, cmdSendR10R11, cmdEnableOptical had no [profile] param at all, so a gen5 caller always got gen4's crc8 header - unparseable on a real gen5 strap. same bug already fixed on cmdGetBattery, cmdSetClock, cmdEnterHighFreqSync etc, just missed these. also folded the gen5 payload branch (empty body vs gen4's [0x00]) into cmdGetDataRange and cmdSendHistorical directly instead of keeping separate Gen5 twins, since nothing called the twins anywhere - deleted cmdGetDataRangeGen5/cmdSendHistoricalGen5. nothing in edge/lib calls any of these yet so this is latent, not a live fix. semantics on cmdSendR10R11/cmdEnableOptical stay unresolved per their existing doc comments - this only fixes framing. --- lib/openstrap_protocol.dart | 2 - lib/src/commands.dart | 56 ++++++++++++++-------------- test/doc_conformance_test.dart | 34 ++++++++++++++++- test/whoop_protocol_update_test.dart | 9 +++++ 4 files changed, 71 insertions(+), 30 deletions(-) diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index b8edb0a..4fb0baf 100644 --- a/lib/openstrap_protocol.dart +++ b/lib/openstrap_protocol.dart @@ -138,8 +138,6 @@ export 'src/commands.dart' cmdDisableAlarm, kDefaultAlarmHaptics, gen5ClientHello, - cmdGetDataRangeGen5, - cmdSendHistoricalGen5, cmdSetClockGen5, cmdGetClockGen5, cmdBuzzGen5Maverick, diff --git a/lib/src/commands.dart b/lib/src/commands.dart index 256ceb5..f0427d8 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -93,18 +93,25 @@ final List initPackets = [ ]; // ── Convenience builders for live ops ────────────────────────────────────── -Uint8List cmdLinkValid(int seq) => - buildCommand(seq, Cmd.linkValid, const [0x00]); +Uint8List cmdLinkValid(int seq, {BandProfile profile = BandProfile.gen4}) => + buildCommand(seq, Cmd.linkValid, const [0x00], profile); Uint8List cmdGetBattery(int seq, {BandProfile profile = BandProfile.gen4}) => buildCommand(seq, Cmd.getBatteryLevel, const [], profile); -Uint8List cmdGetHello(int seq) => - buildCommand(seq, Cmd.getHelloHarvard, const [0x00]); -Uint8List cmdGetHelloModern(int seq) => - buildCommand(seq, Cmd.getHello, const [0x01]); +Uint8List cmdGetHello(int seq, {BandProfile profile = BandProfile.gen4}) => + buildCommand(seq, Cmd.getHelloHarvard, const [0x00], profile); +Uint8List cmdGetHelloModern(int seq, {BandProfile profile = BandProfile.gen4}) => + buildCommand(seq, Cmd.getHello, const [0x01], profile); Uint8List cmdAbortHistorical(int seq, {BandProfile profile = BandProfile.gen4}) => buildCommand(seq, Cmd.abortHistoricalTransmits, const [0x00], profile); -Uint8List cmdSendHistorical(int seq) => - buildCommand(seq, Cmd.sendHistoricalData, const [0x00]); + +/// SEND_HISTORICAL_DATA (0x16) — starts the flash drain. +/// +/// gen4 wants a single `[0x00]` byte; gen5 wants an EMPTY body (a non-empty +/// body there was rejected outright, see `cmdGetDataRangeGen5`'s old note — +/// folded into this builder since nothing ever called the gen4 shape on gen5). +Uint8List cmdSendHistorical(int seq, {BandProfile profile = BandProfile.gen4}) => + buildCommand(seq, Cmd.sendHistoricalData, + profile.isGen5 ? const [] : const [0x00], profile); /// Read the strap RTC (GET_CLOCK = 0x0B = 11) with an EMPTY body. /// /// Shared across generations — hardware-verified on WHOOP 5: opcode 11 with @@ -153,10 +160,14 @@ Uint8List cmdSetClock(int seq, return buildCommand(seq, Cmd.setClock, payload, profile); } -Uint8List cmdGetDataRange(int seq) => - buildCommand(seq, Cmd.getDataRange, const [0x00]); -Uint8List cmdReportVersionInfo(int seq) => - buildCommand(seq, Cmd.reportVersionInfo, const []); +/// GET_DATA_RANGE (0x22) — shared opcode, envelope + payload differ by +/// profile: gen4 takes a `[0x00]` body, gen5 expects an EMPTY payload (see +/// control.dart's dual-profile decoder for this opcode). +Uint8List cmdGetDataRange(int seq, {BandProfile profile = BandProfile.gen4}) => + buildCommand(seq, Cmd.getDataRange, + profile.isGen5 ? const [] : const [0x00], profile); +Uint8List cmdReportVersionInfo(int seq, {BandProfile profile = BandProfile.gen4}) => + buildCommand(seq, Cmd.reportVersionInfo, const [], profile); // Both of these used to send an EMPTY body. gen5 reads the missing first byte // as revision 0 and rejects the command outright, so neither ever returned @@ -226,8 +237,8 @@ Uint8List cmdSelectWrist(int seq, WristSelection selection, // That puts [cmdEnableOptical] (0x6B) next to the 0x99 persistent-save family // rather than next to a live stream. Unconfirmed for gen4, so the opcodes are // left pointed where they are — only the description is corrected. -Uint8List cmdToggleHr(int seq, bool on) => - buildCommand(seq, Cmd.toggleRealtimeHr, [on ? 0x01 : 0x00]); +Uint8List cmdToggleHr(int seq, bool on, {BandProfile profile = BandProfile.gen4}) => + buildCommand(seq, Cmd.toggleRealtimeHr, [on ? 0x01 : 0x00], profile); /// Toggle the realtime raw (R10/R11) stream (SEND_R10_R11_REALTIME = 0x3F). /// @@ -239,8 +250,8 @@ Uint8List cmdToggleHr(int seq, bool on) => /// ⚠ That is INVERTED on gen5, which does not implement 0x3F at all: this /// command is silently ignored there, and 0x51/0x52 ([cmdRawDataStart] / /// [cmdRawDataStop]) are the realtime-raw start and stop instead. -Uint8List cmdSendR10R11(int seq, bool on) => - buildCommand(seq, Cmd.sendR10R11Realtime, [on ? 0x01 : 0x00]); +Uint8List cmdSendR10R11(int seq, bool on, {BandProfile profile = BandProfile.gen4}) => + buildCommand(seq, Cmd.sendR10R11Realtime, [on ? 0x01 : 0x00], profile); /// Toggle the IMU data stream (IMU_SET_DATA_STREAM = 0x6A). /// @@ -258,8 +269,8 @@ Uint8List cmdToggleImu(int seq, bool on, : [on ? 0x01 : 0x00], profile, ); -Uint8List cmdEnableOptical(int seq, bool on) => - buildCommand(seq, Cmd.enableOpticalData, [revision1, on ? 0x01 : 0x00]); +Uint8List cmdEnableOptical(int seq, bool on, {BandProfile profile = BandProfile.gen4}) => + buildCommand(seq, Cmd.enableOpticalData, [revision1, on ? 0x01 : 0x00], profile); /// Play a haptic waveform effect (RUN_HAPTICS_PATTERN = 0x4F). /// @@ -602,15 +613,6 @@ Uint8List cmdDisableAlarm(int seq, Uint8List gen5ClientHello({int seq = 1}) => buildCommand(seq, Cmd.getHello, const [0x01], BandProfile.gen5); -/// gen5 GET_DATA_RANGE (0x22) with the EMPTY payload gen5 expects. -Uint8List cmdGetDataRangeGen5(int seq) => - buildCommand(seq, Cmd.getDataRange, const [], BandProfile.gen5); - -/// gen5 SEND_HISTORICAL_DATA (0x16) with the EMPTY payload gen5 expects — the -/// command that starts the flash drain. -Uint8List cmdSendHistoricalGen5(int seq) => - buildCommand(seq, Cmd.sendHistoricalData, const [], BandProfile.gen5); - // ── gen5 clock (SET_CLOCK_MAVERICK=146 / GET_CLOCK_GEN5=147) ─────────────── // // gen5 replaces gen4's SET_CLOCK(0x0A)/GET_CLOCK(0x0B) with distinct opcode diff --git a/test/doc_conformance_test.dart b/test/doc_conformance_test.dart index 388c2bc..234ebc6 100644 --- a/test/doc_conformance_test.dart +++ b/test/doc_conformance_test.dart @@ -105,12 +105,44 @@ void main() { // 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)!; + final r = parseFrame( + cmdGetDataRange(1, profile: BandProfile.gen5), 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('SEND_HISTORICAL_DATA(16) empty body on gen5, [0x00] on gen4', () { + final gen5 = parseFrame( + cmdSendHistorical(1, profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + expect(gen5.inner[2], 22); + expect(gen5.inner.length, 4); + final gen4 = parseFrame(cmdSendHistorical(1))!; + expect(gen4.inner.sublist(2), [22, 0x00]); + }); + + // These six used to have no [profile] parameter at all, so a caller could + // not ask for gen5 framing — the frame was gen4-shaped (crc8 header) + // regardless, which a real gen5 strap cannot parse. Assert each now frames + // correctly under BOTH profiles, with gen4 output unchanged. + test('previously-unwired builders now frame correctly on gen5', () { + for (final profile in [BandProfile.gen4, BandProfile.gen5]) { + for (final f in [ + cmdLinkValid(1, profile: profile), + cmdGetHello(1, profile: profile), + cmdGetHelloModern(1, profile: profile), + cmdReportVersionInfo(1, profile: profile), + cmdToggleHr(1, true, profile: profile), + cmdSendR10R11(1, true, profile: profile), + cmdEnableOptical(1, true, profile: profile), + ]) { + expect(parseFrame(f, profile: profile), isNotNull, + reason: 'must parse under the profile it was built for'); + } + } + }); + test('toggles — 3 bare bool; 106/107 rev+bool; labrador ops', () { final hr = parseFrame(cmdToggleHr(1, true), profile: BandProfile.gen4)!; expect(hr.inner.sublist(2, 4), [3, 0x01], reason: 'opcode 3 takes bare 01'); diff --git a/test/whoop_protocol_update_test.dart b/test/whoop_protocol_update_test.dart index aafba3e..f7f0f6a 100644 --- a/test/whoop_protocol_update_test.dart +++ b/test/whoop_protocol_update_test.dart @@ -117,6 +117,15 @@ void main() { expect(frame.inner, [0x23, 0x06, 0x22, 0x00]); }); + test('cmdGetDataRange(profile: gen5) frames with the gen5 envelope', () { + final frame = parseFrame( + cmdGetDataRange(0x07, profile: BandProfile.gen5), + profile: BandProfile.gen5)!; + expect(frame.valid, isTrue); + // gen5 body is empty — inner is [type][seq][opcode] padded to /4. + expect(frame.inner, [0x23, 0x07, 0x22, 0x00]); + }); + test('cmdSetClock builds the WHOOP-exact 8-byte sec+subsec payload', () { // Fixed instant: sec = 0x12345678, millis = 500. // subsec = 500 * 32768 ~/ 1000 = 16384 = 0x4000 (u16 LE, then 2 zero pad).