diff --git a/lib/src/control.dart b/lib/src/control.dart index 7fb19e0..ae2aff7 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -246,6 +246,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, @@ -269,8 +276,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,11 +1528,15 @@ Decoded _decodeDataRecord(Uint8List inner, if (inner.length < 64 && !historical) { 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': _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 f7f0f6a..4ff6174 100644 --- a/test/whoop_protocol_update_test.dart +++ b/test/whoop_protocol_update_test.dart @@ -616,6 +616,40 @@ 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; + b[19] = 2; + 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); + + // 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]); + }); + // 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