From 538c482af68a6341d7450630057e3fea71ebc79e Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:54:05 +0530 Subject: [PATCH] gate getBatteryPackInfo on gen5 cmd_status like clock/range already are was decoding a full-length reply body without checking status, so a gen5 failure/deferred reply with a stale but 30-byte body would still produce a confident BatteryPackInfoResponse. gen4 stays ungated since its status byte is unconfirmed, same reasoning as getClock/getDataRange. --- lib/src/control.dart | 25 ++++++++++++------- test/whoop_protocol_update_test.dart | 36 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/lib/src/control.dart b/lib/src/control.dart index 607a1bc..df4b94c 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -1009,14 +1009,23 @@ CmdResponse? parseCommandResponse(Uint8List inner, // 28-byte body [rev][attached][id ×6][name ×16][u16][type][status], again // starting at payload[2]. Every field was previously read two bytes early, // so type/status were reading the two halves of the unnamed u16. - dec['battery_pack_info'] = BatteryPackInfoResponse( - revision: payload[2], - attached: payload[3] == 1, - identifier: _macAddress(payload, 4), - name: _batteryPackName(payload), - batteryPackTypeRaw: payload[28], - statusRaw: payload[29], - ); + // + // Status-gated on gen5 ONLY, same convention as getClock/getDataRange + // above: this opcode is sent to both profiles (see commands.dart), gen5's + // status byte is confirmed so a failure reply's stale body is dropped, + // gen4's is unconfirmed so it stays ungated to avoid silently losing + // valid gen4 replies. + final statusOk = !profile.isGen5 || status == 1; + if (statusOk) { + dec['battery_pack_info'] = BatteryPackInfoResponse( + revision: payload[2], + attached: payload[3] == 1, + identifier: _macAddress(payload, 4), + name: _batteryPackName(payload), + batteryPackTypeRaw: payload[28], + statusRaw: payload[29], + ); + } } else if (op == Cmd.reportVersionInfo) { dec['version_info'] = { 'payload_len': payload.length, diff --git a/test/whoop_protocol_update_test.dart b/test/whoop_protocol_update_test.dart index 896a0b4..eb3ff13 100644 --- a/test/whoop_protocol_update_test.dart +++ b/test/whoop_protocol_update_test.dart @@ -206,6 +206,42 @@ void main() { expect(pack.statusRaw, 7); }); + test('0x97 battery pack info on gen5 decodes when cmd_status is ok', () { + final inner = hexToBytes( + '240297' + '0701' // echoed request seq, status = 1 (ok) + '0101' + '112233445566' + '50756666696e20426174746572790000' + '0000' + '0c' + '07', + ); + final resp = parseCommandResponse(inner, profile: BandProfile.gen5)!; + final pack = resp.decoded['battery_pack_info'] as BatteryPackInfoResponse; + expect(pack.identifier, '11:22:33:44:55:66'); + }); + + test( + '0x97 battery pack info on gen5 is dropped when cmd_status is not ok', + () { + // Same stale-but-full-length body as above, but cmd_status = 0 + // (failed) — a gen5 reply's body is unpopulated on failure, so these + // bytes must not be trusted. + final inner = hexToBytes( + '240297' + '0700' // echoed request seq, status = 0 (failed) + '0101' + '112233445566' + '50756666696e20426174746572790000' + '0000' + '0c' + '07', + ); + final resp = parseCommandResponse(inner, profile: BandProfile.gen5)!; + expect(resp.decoded.containsKey('battery_pack_info'), isFalse); + }); + test('0x07 version info is surfaced honestly as raw payload', () { final inner = hexToBytes('24010700112233445566778899aabbccddeeff0011'); final resp = parseCommandResponse(inner)!;