From 1810465b91cc47f429e2e7f2cb00da650f9c60a4 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 5 Nov 2026 21:31:52 +0000 Subject: [PATCH 1/3] feat: decode unframed 96-byte realtime packet (raw spo2 byte + stream CRC32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds parseRealtime96/crc32WhoopStream for the raw data-stream characteristic. SpO2 byte 5 is exposed raw only — unvalidated community finding, never published as a percentage. Co-authored-by: BucciMobile --- lib/openstrap_protocol.dart | 4 ++ lib/src/realtime96.dart | 110 ++++++++++++++++++++++++++++++++++++ test/realtime96_test.dart | 70 +++++++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 lib/src/realtime96.dart create mode 100644 test/realtime96_test.dart diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 0bf901b..8fab9d3 100644 --- a/lib/openstrap_protocol.dart +++ b/lib/openstrap_protocol.dart @@ -99,6 +99,10 @@ export 'src/live.dart' decodeRecord, decodeBatch; +// Unframed 96-byte realtime sensor packet (raw data-stream characteristic). +export 'src/realtime96.dart' + show Realtime96, parseRealtime96, crc32WhoopStream, kRealtime96PacketSize; + // Source 2 — CRC, constants, framing, commands. export 'src/crc.dart' show crc8, crc32, crc16Modbus; export 'src/constants.dart'; diff --git a/lib/src/realtime96.dart b/lib/src/realtime96.dart new file mode 100644 index 0000000..4d98830 --- /dev/null +++ b/lib/src/realtime96.dart @@ -0,0 +1,110 @@ +// realtime96.dart — the unframed 96-byte realtime sensor packet that the +// WHOOP 4 data-stream characteristic delivers as raw BLE notifications +// (unlike the framed 0xAA… packets, which go through framing.dart). +// +// Layout (community reverse engineering; HR/RR framing cross-checked against +// multiple independent sources, SpO2/temperature single-source and NOT +// validated against a reference device): +// [0] u8 sequence / packet type +// [1:3] u16LE heart rate * 100 +// [3:5] u16LE RR interval ms +// [5] u8 SpO2 byte — RAW ONLY, see [Realtime96.spo2Raw] +// [6] u8 skin temperature byte (offset +25 C claimed, unconfirmed) +// [7:92] candidate accel/PPG/ambient fields, meaning unconfirmed +// [92:96] u32LE CRC-32 over bytes [0:92), see [crc32WhoopStream] +// +// PURE Dart — dart:typed_data only. No I/O, no dependencies. + +import 'dart:typed_data'; + +/// Expected length of one realtime sensor packet, in bytes. +const int kRealtime96PacketSize = 96; + +/// CRC-32 over the first 92 bytes of a realtime packet. +/// +/// Reflected CRC-32 (poly 0x04C11DB7, init 0xFFFFFFFF) with xor-out +/// 0xF43F44AC — TWO independent community sources agree on this xor-out, and +/// it is deliberately NOT the same as the framed-packet [crc32] in crc.dart +/// (zlib xor-out 0xFFFFFFFF): the unframed data stream uses its own trailer +/// checksum, so the two must not be mixed. +int crc32WhoopStream(List data) { + int crc = 0xFFFFFFFF; + for (final b in data) { + crc = _crc32Table[(crc ^ b) & 0xFF] ^ (crc >>> 8); + } + return (crc ^ 0xF43F44AC) & 0xFFFFFFFF; +} + +final Uint32List _crc32Table = _makeTable(); + +Uint32List _makeTable() { + final table = Uint32List(256); + for (int n = 0; n < 256; n++) { + int c = n; + for (int k = 0; k < 8; k++) { + c = (c & 1) != 0 ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + } + table[n] = c; + } + return table; +} + +/// One decoded 96-byte realtime packet. Every field is RAW — this package +/// publishes no calibrated unit from this packet, including no SpO2 percent. +class Realtime96 { + /// Packet type / sequence counter, byte [0]. + final int sequence; + + /// Heart-rate field as delivered: bpm * 100, u16 LE at [1:3]. Raw, so a + /// mis-framed packet cannot masquerade as a plausible bpm. + final int hrTimes100; + + /// RR-interval field as delivered, u16 LE at [3:5], ms. Raw for the same + /// reason. + final int rrMsRaw; + + /// Byte [5] — community-reported SpO2 byte. + /// + /// RAW AND UNVALIDATED: the only evidence is single-source community + /// reverse engineering with a 50–100 range gate, and the original + /// reverse-engineering write-up says blood oxygen on this band is measured + /// only during sleep. The byte is exposed exactly as received so captures + /// can be correlated against a reference oximeter; it must NOT be + /// published to a user as a percentage. + final int spo2Raw; + + /// Byte [6] — skin-temperature byte, raw. The +25 C offset is claimed but + /// unconfirmed, so no Celsius getter exists. + final int skinTempRaw; + + /// Whether the trailing CRC-32 matched bytes [0:92]. A packet with + /// [crcValid] == false is mis-framed or corrupted: its fields are still + /// the bytes that arrived, but nothing downstream should trust them. + final bool crcValid; + + const Realtime96({ + required this.sequence, + required this.hrTimes100, + required this.rrMsRaw, + required this.spo2Raw, + required this.skinTempRaw, + required this.crcValid, + }); +} + +/// Decode one 96-byte realtime packet, or null when [bytes] is not exactly +/// [kRealtime96PacketSize] long (a short notification is a fragment, never a +/// shorter packet). +Realtime96? parseRealtime96(List bytes) { + if (bytes.length != kRealtime96PacketSize) return null; + final view = ByteData.sublistView(bytes is Uint8List ? bytes : Uint8List.fromList(bytes)); + final crcReceived = view.getUint32(92, Endian.little); + return Realtime96( + sequence: bytes[0], + hrTimes100: view.getUint16(1, Endian.little), + rrMsRaw: view.getUint16(3, Endian.little), + spo2Raw: bytes[5], + skinTempRaw: bytes[6], + crcValid: crc32WhoopStream(bytes.sublist(0, 92)) == crcReceived, + ); +} diff --git a/test/realtime96_test.dart b/test/realtime96_test.dart new file mode 100644 index 0000000..cf6b83f --- /dev/null +++ b/test/realtime96_test.dart @@ -0,0 +1,70 @@ +// Tests for the unframed 96-byte realtime packet decoder. The packets are +// synthetic — built from the community-documented layout with the CRC +// computed by the same crc32WhoopStream the decoder verifies — because no +// real capture of this stream is in the repo; the CRC xor-out (0xF43F44AC) is +// itself only community-sourced, so these tests pin the ARITHMETIC, not the +// wire fact. + +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +/// Build a 96-byte packet with a valid trailer CRC. +Uint8List buildPacket({ + int sequence = 1, + int hrTimes100 = 6200, + int rrMs = 968, + int spo2 = 97, + int skinTemp = 62, +}) { + final b = Uint8List(kRealtime96PacketSize); + b[0] = sequence; + final v = ByteData.sublistView(b); + v.setUint16(1, hrTimes100, Endian.little); + v.setUint16(3, rrMs, Endian.little); + b[5] = spo2; + b[6] = skinTemp; + v.setUint32(92, crc32WhoopStream(b.sublist(0, 92)), Endian.little); + return b; +} + +void main() { + group('parseRealtime96', () { + test('decodes all raw fields and validates the CRC', () { + final p = parseRealtime96(buildPacket())!; + expect(p.sequence, 1); + expect(p.hrTimes100, 6200); + expect(p.rrMsRaw, 968); + expect(p.spo2Raw, 97); + expect(p.skinTempRaw, 62); + expect(p.crcValid, isTrue); + }); + + test('rejects any length other than 96 — a fragment is not a packet', () { + expect(parseRealtime96(buildPacket().sublist(0, 95)), isNull); + final long = Uint8List(97); + expect(parseRealtime96(long), isNull); + expect(parseRealtime96(const []), isNull); + }); + + test('a corrupted payload byte fails the CRC but fields stay raw', () { + final b = buildPacket(); + b[5] = 42; // flip the spo2 byte AFTER the CRC was computed + final p = parseRealtime96(b)!; + expect(p.crcValid, isFalse); + expect(p.spo2Raw, 42); + }); + + test('spo2Raw is passthrough: even an out-of-range byte is exposed raw, never gated into a %', () { + final p = parseRealtime96(buildPacket(spo2: 0xFF))!; + expect(p.spo2Raw, 0xFF); + expect(p.crcValid, isTrue); + }); + + test('crc32WhoopStream differs from the framed-packet crc32 (different xor-out)', () { + final body = List.generate(92, (i) => i); + expect(crc32WhoopStream(body), isNot(crc32(body))); + }); + }); +} From 0702595cd20c64bec7bb811d21d25a7e4cbf8c13 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 00:56:57 +0000 Subject: [PATCH 2/3] test: add independent known-answer vector for crc32WhoopStream (0xC0348275) Co-authored-by: BucciMobile --- test/realtime96_test.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/realtime96_test.dart b/test/realtime96_test.dart index cf6b83f..c591ce7 100644 --- a/test/realtime96_test.dart +++ b/test/realtime96_test.dart @@ -65,6 +65,10 @@ void main() { test('crc32WhoopStream differs from the framed-packet crc32 (different xor-out)', () { final body = List.generate(92, (i) => i); expect(crc32WhoopStream(body), isNot(crc32(body))); + // Known-answer vector, computed independently of the implementation: + // standard CRC-32 of "123456789" is 0xCBF43926; with this stream's + // xor-out 0xF43F44AC it becomes 0xC0348275. + expect(crc32WhoopStream('123456789'.codeUnits), 0xC0348275); }); }); } From c896c9a9e7baeac22be76f82b4af2412abfcee16 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:35:32 +0530 Subject: [PATCH 3/3] realtime96: neutral doc wording, normalise the input once for fields and crc --- lib/src/realtime96.dart | 35 ++++++++++++++++++----------------- test/realtime96_test.dart | 21 ++++++++++++++++----- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/lib/src/realtime96.dart b/lib/src/realtime96.dart index 4d98830..d540285 100644 --- a/lib/src/realtime96.dart +++ b/lib/src/realtime96.dart @@ -2,9 +2,9 @@ // WHOOP 4 data-stream characteristic delivers as raw BLE notifications // (unlike the framed 0xAA… packets, which go through framing.dart). // -// Layout (community reverse engineering; HR/RR framing cross-checked against -// multiple independent sources, SpO2/temperature single-source and NOT -// validated against a reference device): +// Layout (HR/RR fields agree across more than one independent source; the +// SpO2 and temperature bytes are single-source and NOT validated against a +// reference device): // [0] u8 sequence / packet type // [1:3] u16LE heart rate * 100 // [3:5] u16LE RR interval ms @@ -23,8 +23,8 @@ const int kRealtime96PacketSize = 96; /// CRC-32 over the first 92 bytes of a realtime packet. /// /// Reflected CRC-32 (poly 0x04C11DB7, init 0xFFFFFFFF) with xor-out -/// 0xF43F44AC — TWO independent community sources agree on this xor-out, and -/// it is deliberately NOT the same as the framed-packet [crc32] in crc.dart +/// 0xF43F44AC — two independent sources agree on this xor-out, but it has not +/// been checked against a capture of ours. It is deliberately NOT the same as the framed-packet [crc32] in crc.dart /// (zlib xor-out 0xFFFFFFFF): the unframed data stream uses its own trailer /// checksum, so the two must not be mixed. int crc32WhoopStream(List data) { @@ -63,14 +63,13 @@ class Realtime96 { /// reason. final int rrMsRaw; - /// Byte [5] — community-reported SpO2 byte. + /// Byte [5] — claimed SpO2 byte. /// - /// RAW AND UNVALIDATED: the only evidence is single-source community - /// reverse engineering with a 50–100 range gate, and the original - /// reverse-engineering write-up says blood oxygen on this band is measured - /// only during sleep. The byte is exposed exactly as received so captures - /// can be correlated against a reference oximeter; it must NOT be - /// published to a user as a percentage. + /// RAW AND UNVALIDATED: the only evidence is a single source with a 50–100 + /// range gate, which also says blood oxygen on this band is measured only + /// during sleep. The byte is exposed exactly as received so captures can be + /// correlated against a reference oximeter; it must NOT be published to a + /// user as a percentage. final int spo2Raw; /// Byte [6] — skin-temperature byte, raw. The +25 C offset is claimed but @@ -97,14 +96,16 @@ class Realtime96 { /// shorter packet). Realtime96? parseRealtime96(List bytes) { if (bytes.length != kRealtime96PacketSize) return null; - final view = ByteData.sublistView(bytes is Uint8List ? bytes : Uint8List.fromList(bytes)); + // Normalise once: every field and the CRC read the same 0–255 bytes. + final b = bytes is Uint8List ? bytes : Uint8List.fromList(bytes); + final view = ByteData.sublistView(b); final crcReceived = view.getUint32(92, Endian.little); return Realtime96( - sequence: bytes[0], + sequence: b[0], hrTimes100: view.getUint16(1, Endian.little), rrMsRaw: view.getUint16(3, Endian.little), - spo2Raw: bytes[5], - skinTempRaw: bytes[6], - crcValid: crc32WhoopStream(bytes.sublist(0, 92)) == crcReceived, + spo2Raw: b[5], + skinTempRaw: b[6], + crcValid: crc32WhoopStream(Uint8List.sublistView(b, 0, 92)) == crcReceived, ); } diff --git a/test/realtime96_test.dart b/test/realtime96_test.dart index c591ce7..10260ae 100644 --- a/test/realtime96_test.dart +++ b/test/realtime96_test.dart @@ -1,9 +1,8 @@ // Tests for the unframed 96-byte realtime packet decoder. The packets are -// synthetic — built from the community-documented layout with the CRC -// computed by the same crc32WhoopStream the decoder verifies — because no -// real capture of this stream is in the repo; the CRC xor-out (0xF43F44AC) is -// itself only community-sourced, so these tests pin the ARITHMETIC, not the -// wire fact. +// synthetic: built from the documented layout with the CRC computed by the +// same crc32WhoopStream the decoder verifies, because no real capture of this +// stream is in the repo. The CRC xor-out (0xF43F44AC) is itself unverified, so +// these tests pin the ARITHMETIC, not the wire fact. import 'dart:typed_data'; @@ -62,6 +61,18 @@ void main() { expect(p.crcValid, isTrue); }); + test('a plain List reads the same bytes for fields and CRC', () { + // 256 + 97 is not a byte; it must land as 97 in the fields and the CRC + // alike, exactly as the same packet delivered as a Uint8List. + final good = buildPacket(); + final loose = List.from(good)..[5] = 256 + 97; + final a = parseRealtime96(loose)!; + final b = parseRealtime96(good)!; + expect(a.spo2Raw, b.spo2Raw); + expect(a.crcValid, b.crcValid); + expect(a.crcValid, isTrue); + }); + test('crc32WhoopStream differs from the framed-packet crc32 (different xor-out)', () { final body = List.generate(92, (i) => i); expect(crc32WhoopStream(body), isNot(crc32(body)));