Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/openstrap_protocol.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
111 changes: 111 additions & 0 deletions lib/src/realtime96.dart
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;

Copy link
Copy Markdown

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, crc32WhoopStream applies this xor-out to every packet checksum, so if the band uses a different wire xor-out, parseRealtime96 marks every intact packet crcValid == false and 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
In `lib/src/realtime96.dart` at line 35:

**Valid packets fail CRC validation**

When a real band capture uses a CRC xor-out other than `0xF43F44AC`, `crc32WhoopStream` applies this xor-out to every packet checksum, so if the band uses a different wire xor-out, `parseRealtime96` marks every intact packet `crcValid == false` and 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.

}

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,
);
}
85 changes: 85 additions & 0 deletions test/realtime96_test.dart
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);
});
});
}
Loading