From be9010451708cb3164d3d03ef17281fa8469c0c2 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:59:25 +0530 Subject: [PATCH 1/3] decodeFrame: keep rr_ms and gate hr on rev-2 0x28 packets --- lib/src/control.dart | 6 +++++- test/whoop_protocol_update_test.dart | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/lib/src/control.dart b/lib/src/control.dart index ee414a2..8bfccec 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -1514,11 +1514,15 @@ Decoded _decodeDataRecord(Uint8List inner, if (inner.length < 64) { if (recType == 2) { final v2 = parseRealtimeHrV2(inner); - if (v2 != null) { + // rev 2 is every real 0x28 packet, and it keeps the compact layout's + // rr_count@9 + slots @10..16, so read the beats from there. hr 0 is a + // legit off-wrist reading; anything over 250 is not a bpm. + if (v2 != null && v2.hrBpm <= 250) { return Decoded('realtime_hr', { 'rec_type': recType, 'ts_epoch': v2.tsEpoch, 'hr': v2.hrBpm, + 'rr_ms': parseRealtimeHr(inner)?.rrMs ?? const [], 'wearing': !v2.isOffBody, 'location': v2.locationRaw, }); diff --git a/test/whoop_protocol_update_test.dart b/test/whoop_protocol_update_test.dart index f7f0f6a..bb1a4c7 100644 --- a/test/whoop_protocol_update_test.dart +++ b/test/whoop_protocol_update_test.dart @@ -616,6 +616,31 @@ void main() { expect(decoded.fields['wearing'], isTrue); }); + // real 0x28 packets all carry rev byte 2 and took the v2 path, which + // dropped the beats and let any hr byte through + test('rev-2 packet keeps rr_ms and gates hr', () { + Uint8List pkt(int hr) { + final b = Uint8List(20); + final bd = b.buffer.asByteData(); + b[0] = 0x28; + b[1] = 2; + bd.setUint32(2, 1780840486, Endian.little); + b[8] = hr; + b[9] = 1; + bd.setInt16(10, 850, Endian.little); + b[18] = 1; + return b; + } + + final ok = decodeFrame(Frame(pkt(70), true, true)); + expect(ok.kind, 'realtime_hr'); + expect(ok.fields['hr'], 70); + expect(ok.fields['rr_ms'], [850]); + expect(ok.fields['wearing'], isTrue); + + expect(decodeFrame(Frame(pkt(255), true, true)).kind, 'realtime_small'); + }); + // copilot review also caught a real one: a 9-byte packet (ts+hr, no // rr_count byte at all) would read inner[9] out of bounds and throw // instead of decoding. fixed to treat a missing rr_count byte as "no RR From d2dd8668760f96c757d8b887d1bfa1661b9ff352 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:06:45 +0530 Subject: [PATCH 2/3] rev-2 realtime hr: read rr slots without the hr gate so hr 0 keeps its beats --- lib/src/control.dart | 12 +++++++++--- test/whoop_protocol_update_test.dart | 2 ++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/src/control.dart b/lib/src/control.dart index 8bfccec..454d718 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -245,6 +245,13 @@ RealtimeHr? parseRealtimeHr(Uint8List inner) { final ts = u32(inner, 2); final hr = inner[8]; if (hr < 1 || hr > 250) return null; + final wearing = inner.length > 18 ? inner[18] == 1 : true; + return RealtimeHr(hr, hr.toDouble(), _realtimeRrSlots(inner), wearing, ts); +} + +// The compact 0x28 R-R slots, read independently of the HR byte so an hr-0 +// (off-wrist) packet keeps its declared beats like live.dart's realtimeRr. +List _realtimeRrSlots(Uint8List inner) { final rr = []; // a 9-byte packet has ts+hr but nothing past it - inner[9] (rr_count) would // be one byte out of bounds. no rr_count byte just means no RR intervals, @@ -268,8 +275,7 @@ RealtimeHr? parseRealtimeHr(Uint8List inner) { if (v >= kMinRrMs && v <= kMaxRrMs) rr.add(v); } } - final wearing = inner.length > 18 ? inner[18] == 1 : true; - return RealtimeHr(hr, hr.toDouble(), rr, wearing, ts); + return rr; } RealtimeHrV2? parseRealtimeHrV2(Uint8List body) { @@ -1522,7 +1528,7 @@ Decoded _decodeDataRecord(Uint8List inner, 'rec_type': recType, 'ts_epoch': v2.tsEpoch, 'hr': v2.hrBpm, - 'rr_ms': parseRealtimeHr(inner)?.rrMs ?? const [], + 'rr_ms': _realtimeRrSlots(inner), 'wearing': !v2.isOffBody, 'location': v2.locationRaw, }); diff --git a/test/whoop_protocol_update_test.dart b/test/whoop_protocol_update_test.dart index bb1a4c7..624a7dc 100644 --- a/test/whoop_protocol_update_test.dart +++ b/test/whoop_protocol_update_test.dart @@ -639,6 +639,8 @@ void main() { expect(ok.fields['wearing'], isTrue); expect(decodeFrame(Frame(pkt(255), true, true)).kind, 'realtime_small'); + // hr 0 is off-wrist but the declared beats still come through + expect(decodeFrame(Frame(pkt(0), true, true)).fields['rr_ms'], [850]); }); // copilot review also caught a real one: a 9-byte packet (ts+hr, no From f0785449e342ee30e0f12f88f640f99243a9caae Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:13:50 +0530 Subject: [PATCH 3/3] test: pin hr 250 to the rev-2 path --- test/whoop_protocol_update_test.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/whoop_protocol_update_test.dart b/test/whoop_protocol_update_test.dart index 624a7dc..4ff6174 100644 --- a/test/whoop_protocol_update_test.dart +++ b/test/whoop_protocol_update_test.dart @@ -629,6 +629,7 @@ void main() { b[9] = 1; bd.setInt16(10, 850, Endian.little); b[18] = 1; + b[19] = 2; return b; } @@ -638,6 +639,12 @@ void main() { expect(ok.fields['rr_ms'], [850]); expect(ok.fields['wearing'], isTrue); + // 250 is the top of the rev-2 path, not the fallback (which drops location) + final max = decodeFrame(Frame(pkt(250), true, true)); + expect(max.kind, 'realtime_hr'); + expect(max.fields['hr'], 250); + expect(max.fields['location'], 2); + expect(decodeFrame(Frame(pkt(255), true, true)).kind, 'realtime_small'); // hr 0 is off-wrist but the declared beats still come through expect(decodeFrame(Frame(pkt(0), true, true)).fields['rr_ms'], [850]);