Repository navigation
feat: decode unframed 96-byte realtime packet (raw SpO2 byte + stream CRC32) #84
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
abdulsaheel
merged 3 commits into
OpenStrap:main
from
BucciMobile:vibe/whoop-spo2-realtime96-7295e2
Nov 10, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
1810465
feat: decode unframed 96-byte realtime packet (raw spo2 byte + stream…
mistral-vibe 0702595
test: add independent known-answer vector for crc32WhoopStream (0xC03…
mistral-vibe c896c9a
realtime96: neutral doc wording, normalise the input once for fields …
abdulsaheel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| // 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 (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 | ||
| // [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 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<int> 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] — claimed SpO2 byte. | ||
| /// | ||
| /// 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 | ||
| /// 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<int> bytes) { | ||
| if (bytes.length != kRealtime96PacketSize) return null; | ||
| // 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: b[0], | ||
| hrTimes100: view.getUint16(1, Endian.little), | ||
| rrMsRaw: view.getUint16(3, Endian.little), | ||
| spo2Raw: b[5], | ||
| skinTempRaw: b[6], | ||
| crcValid: crc32WhoopStream(Uint8List.sublistView(b, 0, 92)) == crcReceived, | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| // Tests for the unframed 96-byte realtime packet decoder. The packets are | ||
| // 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'; | ||
|
|
||
| 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('a plain List<int> 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<int>.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<int>.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); | ||
| }); | ||
| }); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium · Valid packets fail CRC validation
When a real band capture uses a CRC xor-out other than
0xF43F44AC,crc32WhoopStreamapplies this xor-out to every packet checksum, so if the band uses a different wire xor-out,parseRealtime96marks every intact packetcrcValid == falseand callers that rely on this flag reject the data.Verify the xor-out against a real 96-byte capture and update the implementation and fixture if it differs.
Prompt for AI agents