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..d540285 --- /dev/null +++ b/lib/src/realtime96.dart @@ -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 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 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, + ); +} diff --git a/test/realtime96_test.dart b/test/realtime96_test.dart new file mode 100644 index 0000000..10260ae --- /dev/null +++ b/test/realtime96_test.dart @@ -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 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))); + // 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); + }); + }); +}