diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 281a240..077ca36 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,9 +62,13 @@ dart test Two things guard every change: - **`decode_parity_cases.json`** — 2934 cases checked against the frozen - TypeScript oracle in `ts/`. If you change a decoder and parity breaks, either - your change is wrong or the oracle needs regenerating — work out which, and - say so in the PR. + oracle. This file *is* the oracle now — it was generated from a TypeScript + reference implementation (`ts/`) that was later deleted once parity was + locked in (still recoverable from git history if you need to regenerate + cases from scratch), but nothing in `dart test` spawns node/tsc against it + any more. If you change a decoder and parity breaks, either your change is + wrong or the fixture needs regenerating — work out which, and say so in the + PR. - **`dart_header.json`** — 550 hand-checked R24 header cases. Both are tracked in-repo and run in CI. A third set replays diff --git a/README.md b/README.md index 5feda2b..a190362 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ Pure Dart, zero runtime deps. You hand it an already-unwrapped chunk of bytes from the band, it hands you back a record with named fields, or a decoded command/event. That's -the whole job. +the whole job. Covers WHOOP 4 (gen4) and WHOOP 5 (gen5), a generic Bluetooth Heart Rate +Service (0x180D) sensor, and the Oura ring's wire format. This isn't backend-side anymore — the app ([edge](https://github.com/OpenStrap/edge)) depends on this package directly and calls it on-device. There's no cloud, no upload, no @@ -30,6 +31,13 @@ server that ever sees your raw bytes. etc.) and the control-plane decoders (HELLO, events, command responses, metadata/sync markers). - `constants.dart` — the GATT UUIDs, opcode tables, event IDs. +- `band.dart` — the multi-generation ("multi-band") wire-format profile: WHOOP 5 (gen5 / + "fd4b") is WHOOP 4 (gen4 / "Harvard") in a different envelope, and this is what lets + framing/records/edge stay band-agnostic instead of forking per generation. +- `gen5_records.dart` — WHOOP 5 (gen5) historical record decoders: v18/v20/v21/v22/v26. +- `hrs.dart` — the Bluetooth SIG's generic Heart Rate Service (0x180D) as a pure + function, for any standard chest strap or optical armband, not one vendor's device. +- `oura.dart` — the Oura ring's wire format, as pure functions. ## The one record that matters most @@ -140,7 +148,7 @@ Pure Dart, no Flutter dependency: ```bash dart pub get -dart test # 71 tests, incl. the 2934-case TS-parity suite +dart test # full suite, incl. the 2934-case TS-parity suite ``` Run tests from the repo root — the parity fixture (`decode_parity_cases.json`) is @@ -155,7 +163,10 @@ an honest "not sure." If you're touching `records.dart`'s multi-version decode c check `FirmwareAwareR24Decoder` first — chances are your case fits the existing fallback shape rather than needing a new one. -Cross-checking against `_external/noop/` `bWanShiTong/reverse-engineering-whoop-post/` for facts/techniques is fine; copying its code is not. +Cross-checking against other WHOOP reverse-engineering write-ups (e.g. the `noop` +project, or bWanShiTong's `reverse-engineering-whoop-post`) for facts/techniques is +fine; copying their code is not. These are external projects, not paths inside this +repo — go find and clone them separately if you want to compare. ## Contributing diff --git a/SECURITY.md b/SECURITY.md index 7a19127..1879ae9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,43 +17,31 @@ company with an on-call rota: for anything that puts user data at risk. - Credit in the release notes if you want it. +## What this package actually is + +This repo is a pure-Dart library with zero runtime dependencies: bytes in, +decoded records/frames/commands out. It doesn't run on its own, ship an app, +talk to a network, or store anything — no database, no telemetry, no +Firebase. It's a dependency of [edge](https://github.com/OpenStrap/edge), +which is where the app, its distribution model, and its data-handling +questions live. + ## What's in scope -- Anything that discloses a user's health data off their device. -- Anything that lets a third party read, write to, or hijack the Bluetooth - session with someone's band. -- Local data-at-rest problems: the database, exports, the App Group container, - widget snapshots. -- The optional companion worker in - [backend](https://github.com/OpenStrap/backend): auth, the import endpoints, - the opt-in telemetry and health-upload paths. -- Anything that causes the app to send data anywhere the user did not agree to. +- A decoder that misparses bytes in a way that's exploitable, not just wrong + (buffer overreads, crashes on malformed input, anything an attacker could + use by controlling bytes the band or a proxy sends). +- Anything in this package's command builders that could be used to send a + command to a band the caller didn't ask for. ## What's out of scope - The band's own firmware. We don't ship it, can't patch it, and won't publish attacks against it. - WHOOP's own apps and services. Please report those to WHOOP. -- The fact that sideloaded builds are unsigned, or that a rooted/jailbroken - device can read app storage. Both are known properties of the distribution - model, documented in the README. +- App-level distribution and privacy questions (signing, sideloading, device + storage access, telemetry, health-data upload) — those belong to + [edge's SECURITY.md](https://github.com/OpenStrap/edge/blob/main/SECURITY.md) + and [PRIVACY.md](https://github.com/OpenStrap/edge/blob/main/PRIVACY.md), + not this repo. - Metric accuracy. Wrong numbers are bugs — open a normal issue. - -## Where your data actually is - -Worth knowing before you go looking: OpenStrap computes and stores your health -data on-device, and there's no account or server holding it. Two qualifications, -so the boundary is exact: - -- **Anonymous diagnostics** (Firebase crash/performance, never health data) are - **on by default in GitHub release builds** and absent from App Store / Play - Store builds. Switchable off in-app. -- **Health-data contribution** uploads the local database, but is opt-in, off by - default, and compiled out of store builds entirely. - -Everything else the companion worker does — legacy import, an update pointer — -is optional and carries no health data. See [edge's PRIVACY.md](https://github.com/OpenStrap/edge/blob/main/PRIVACY.md). - -That means the realistic attack surface is the phone, the Bluetooth link, and -the local database — not a cloud backend. Reports focused there are the most -useful. diff --git a/lib/src/commands.dart b/lib/src/commands.dart index dc06fb0..a5ebc35 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -14,6 +14,17 @@ enum WristSelection { final int value; } +/// LE-encode a WHOOP (whole-seconds, subseconds) timestamp pair as the 6 bytes +/// `[sec:u32le][subsec:u16le]` shared by SET_CLOCK and every SET_ALARM form. +List _leTimestamp(int sec, int subsec) => [ + sec & 0xff, + (sec >> 8) & 0xff, + (sec >> 16) & 0xff, + (sec >> 24) & 0xff, + subsec & 0xff, + (subsec >> 8) & 0xff, + ]; + /// Build a framed command packet: [type][seq][opcode][payload]. /// [profile] selects the generation's frame envelope (default gen4 = WHOOP 4). /// The inner bytes are identical across generations — command opcodes are @@ -135,12 +146,7 @@ Uint8List cmdSetClock(int seq, final sec = ms ~/ 1000; final subsec = ((ms % 1000) * 32768) ~/ 1000; // 0..32767, 1/32768 s units final payload = [ - sec & 0xff, - (sec >> 8) & 0xff, - (sec >> 16) & 0xff, - (sec >> 24) & 0xff, - subsec & 0xff, - (subsec >> 8) & 0xff, + ..._leTimestamp(sec, subsec), 0, 0, ]; @@ -365,12 +371,7 @@ Uint8List cmdSetAlarmSimple(int seq, DateTime when, final subsec = _alarmSubsec(when); final p = [ 0x01, - sec & 0xff, - (sec >> 8) & 0xff, - (sec >> 16) & 0xff, - (sec >> 24) & 0xff, - subsec & 0xff, - (subsec >> 8) & 0xff, + ..._leTimestamp(sec, subsec), ]; return buildCommand(seq, Cmd.setAlarmTime, p, profile); } @@ -422,12 +423,7 @@ List alarmRev1Payload(DateTime when, {int hapticMode = 0}) { final subsec = _alarmSubsec(when); return [ 0x01, - sec & 0xff, - (sec >> 8) & 0xff, - (sec >> 16) & 0xff, - (sec >> 24) & 0xff, - subsec & 0xff, - (subsec >> 8) & 0xff, + ..._leTimestamp(sec, subsec), hapticMode & 0xff, (hapticMode >> 8) & 0xff, ]; @@ -509,12 +505,7 @@ Uint8List cmdSetAlarm( final p = [ 0x04, slot & 0xff, - sec & 0xff, - (sec >> 8) & 0xff, - (sec >> 16) & 0xff, - (sec >> 24) & 0xff, - subsec & 0xff, - (subsec >> 8) & 0xff, + ..._leTimestamp(sec, subsec), ...pattern.map((b) => b & 0xff), if (profile.isGen5) crescendo, ]; @@ -652,12 +643,7 @@ Uint8List cmdSetClockGen5(int seq, {DateTime? now}) { final subsec = ((ms % 1000) * 32768) ~/ 1000; final payload = [ revision1, - sec & 0xff, - (sec >> 8) & 0xff, - (sec >> 16) & 0xff, - (sec >> 24) & 0xff, - subsec & 0xff, - (subsec >> 8) & 0xff, + ..._leTimestamp(sec, subsec), 0, 0, ]; diff --git a/lib/src/control.dart b/lib/src/control.dart index 482181d..bd75bf8 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -4,6 +4,7 @@ // NOT duplicated here: decodeFrame delegates the R24 branch to the now-native // Dart parseR24 (records.dart, Source 1). PURE Dart. +import 'dart:math' as math; import 'dart:typed_data'; import 'band.dart'; import 'constants.dart'; @@ -20,18 +21,10 @@ double f32(Uint8List b, int o) => _bd(b).getFloat32(o, Endian.little); double _round(double v, int decimals) { if (v.isNaN || v.isInfinite) return 0.0; - final p = _pow10(decimals); + final p = math.pow(10, decimals).toDouble(); return (v * p).roundToDouble() / p; } -double _pow10(int n) { - double p = 1; - for (int i = 0; i < n; i++) { - p *= 10; - } - return p; -} - String _hex(Uint8List b) { final sb = StringBuffer(); for (final x in b) { @@ -1414,8 +1407,46 @@ Decoded _decodeDataRecord(Uint8List inner, return Decoded('data_record', {'rec_type': inner.length > 1 ? inner[1] : -1}); } final recType = inner.length > 1 ? inner[1] : -1; + // Live R10 (HR + IMU) — surface HR for the live display. Checked before the + // generic small-packet branch below: a short/lite R10 record (parseR10Lite + // only requires 18 bytes) is still under that branch's 64-byte cutoff and + // would otherwise be swallowed there first, misread by the wrong offsets, + // and never reach this branch at all. + if (recType == Record.r10) { + final r = parseR10Lite(inner); + if (r != null) { + // rr_ms too: parseR10Lite already accepted these beats, and the short + // realtime-HR branch above emits them — dropping them here silently + // halved the beat supply of anything reading live R10 through + // decodeFrame rather than live.dart. + // + // Emit even when hr==0 — that's a legitimate off-wrist reading (see + // live.dart's `wristOn = hr > 0`), not an undecoded record. Falling + // through to 'data_record' below dropped the timestamp and wearing + // state for every wrist-off period. + return Decoded('realtime_hr', { + 'rec_type': recType, + 'ts_epoch': r.tsEpoch, + 'hr': r.hr, + 'rr_ms': r.rrIntervalsMs, + 'wearing': r.hr > 0, + }); + } + } // Compact realtime stream (small packet). if (inner.length < 64) { + if (recType == 2) { + final v2 = parseRealtimeHrV2(inner); + if (v2 != null) { + return Decoded('realtime_hr', { + 'rec_type': recType, + 'ts_epoch': v2.tsEpoch, + 'hr': v2.hrBpm, + 'wearing': !v2.isOffBody, + 'location': v2.locationRaw, + }); + } + } final hr = parseRealtimeHr(inner); if (hr != null) { return Decoded('realtime_hr', { @@ -1429,22 +1460,6 @@ Decoded _decodeDataRecord(Uint8List inner, } return Decoded('realtime_small', {'rec_type': recType}); } - // Live R10 (HR + IMU) — surface HR for the live display. - if (recType == Record.r10) { - final r = parseR10Lite(inner); - if (r != null && r.hr > 0) { - // rr_ms too: parseR10Lite already accepted these beats, and the short - // realtime-HR branch above emits them — dropping them here silently - // halved the beat supply of anything reading live R10 through - // decodeFrame rather than live.dart. - return Decoded('realtime_hr', { - 'rec_type': recType, - 'hr': r.hr, - 'rr_ms': r.rrIntervalsMs, - 'wearing': true, - }); - } - } // R24: delegate to the native Dart full-record decoder (Source 1). if (recType == Record.r24) { final r = parseR24(inner); diff --git a/lib/src/gen5_records.dart b/lib/src/gen5_records.dart index 8005dd2..66d105a 100644 --- a/lib/src/gen5_records.dart +++ b/lib/src/gen5_records.dart @@ -719,14 +719,14 @@ class Gen5OpticalBlock { /// LED A drive current in units of 10 µA. @ sharedMeta[1:3] u16. /// [ledACurrentMicroamps] is the same number in µA. - int get ledACurrentRaw => _u16(sharedMetaRaw, 1); + int get ledACurrentRaw => _view(sharedMetaRaw).getUint16(1, Endian.little); int get ledACurrentMicroamps => ledACurrentRaw * 10; /// Which driver output LED B is wired to. @ sharedMeta[3]. int get ledBDriverConnection => sharedMetaRaw[3]; /// LED B drive current in units of 10 µA. @ sharedMeta[4:6] u16. - int get ledBCurrentRaw => _u16(sharedMetaRaw, 4); + int get ledBCurrentRaw => _view(sharedMetaRaw).getUint16(4, Endian.little); int get ledBCurrentMicroamps => ledBCurrentRaw * 10; /// Which PHYSICAL photodiode (1..4) is routed into the TIA 1 path for this @@ -736,7 +736,8 @@ class Gen5OpticalBlock { /// TIA 1's ADC full-scale range, in µA. @ channel0Meta[1:5] u32 /// (descriptor relative 7). - int get channel0AdcRange => _u32(channel0MetaRaw, 1); + int get channel0AdcRange => + _view(channel0MetaRaw).getUint32(1, Endian.little); /// TIA 1's offset-current setting, raw wire value: **signed i16, 10 nA/LSB /// (0.01 µA/LSB)**. @ channel0Meta[5:7] = descriptor relative 11. @@ -747,7 +748,8 @@ class Gen5OpticalBlock { /// labels the unscaled nA value with a "µA" suffix — that is a logging bug, /// not an alternative unit. [tia1OffsetCurrentNanoamps] is the same number /// in nA. - int get tia1OffsetCurrentRaw => _i16(channel0MetaRaw, 5); + int get tia1OffsetCurrentRaw => + _view(channel0MetaRaw).getInt16(5, Endian.little); int get tia1OffsetCurrentNanoamps => tia1OffsetCurrentRaw * 10; @Deprecated( @@ -760,11 +762,13 @@ class Gen5OpticalBlock { /// TIA 2's, same three fields at the same relative offsets (descriptor /// relative 13/14/18). int get channel1Source => channel1MetaRaw[0]; - int get channel1AdcRange => _u32(channel1MetaRaw, 1); + int get channel1AdcRange => + _view(channel1MetaRaw).getUint32(1, Endian.little); /// TIA 2's offset-current setting — see [tia1OffsetCurrentRaw] for the unit /// and the quantization; @ channel1Meta[5:7] = descriptor relative 18. - int get tia2OffsetCurrentRaw => _i16(channel1MetaRaw, 5); + int get tia2OffsetCurrentRaw => + _view(channel1MetaRaw).getInt16(5, Endian.little); int get tia2OffsetCurrentNanoamps => tia2OffsetCurrentRaw * 10; @Deprecated( @@ -775,15 +779,6 @@ class Gen5OpticalBlock { int get channel1AdcOffset => tia2OffsetCurrentRaw & 0xFFFF; } -int _u16(Uint8List b, int i) => b[i] | (b[i + 1] << 8); -int _i16(Uint8List b, int i) { - final v = _u16(b, i); - return v >= 0x8000 ? v - 0x10000 : v; -} - -int _u32(Uint8List b, int i) => - b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24); - /// The raw optical deep buffer. Layout is confirmed: the body starts at inner /// 18, then 5 blocks of 422 bytes each; every block holds 50 sample-pair slots, /// photodiode slot A at `block + 21` and slot B at `block + 221`, and the diff --git a/lib/src/records.dart b/lib/src/records.dart index 34202e6..25162bf 100644 --- a/lib/src/records.dart +++ b/lib/src/records.dart @@ -3,6 +3,7 @@ // PURE Dart — dart:typed_data plus constants.dart (itself dependency-free), // so the packet-type values are not a second copy of the same magic numbers. +import 'dart:math' as math; import 'dart:typed_data'; import 'constants.dart'; @@ -210,7 +211,7 @@ double _jsRound(double v) { /// Non-finite input is returned unchanged — see [_jsRound]. double _round(double v, int decimals) { if (!v.isFinite) return v; - final p = _pow10(decimals); + final p = math.pow(10, decimals).toDouble(); return _jsRound(v * p) / p; } @@ -243,14 +244,6 @@ const int kMaxRrMs = 2500; final Set kKnownRecordVersions = Set.unmodifiable({..._hrOffsetByVersion.keys, 25}); -double _pow10(int n) { - double p = 1; - for (int i = 0; i < n; i++) { - p *= 10; - } - return p; -} - ByteData _view(Uint8List b) => b.buffer.asByteData(b.offsetInBytes, b.lengthInBytes); diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 26e7119..0000000 --- a/package-lock.json +++ /dev/null @@ -1,238 +0,0 @@ -{ - "name": "openstrap-protocol", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "openstrap-protocol", - "version": "1.0.0", - "license": "ISC", - "devDependencies": { - "@types/node": "^25.9.2", - "ts-node": "^10.9.2", - "typescript": "^6.0.3" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.9.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", - "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "dev": true, - "license": "MIT" - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index f2fee11..0000000 --- a/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "openstrap-protocol", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "keywords": [], - "author": "", - "license": "ISC", - "type": "commonjs", - "devDependencies": { - "@types/node": "^25.9.2", - "ts-node": "^10.9.2", - "typescript": "^6.0.3" - } -} diff --git a/test/whoop_protocol_update_test.dart b/test/whoop_protocol_update_test.dart index aeaa72f..896a0b4 100644 --- a/test/whoop_protocol_update_test.dart +++ b/test/whoop_protocol_update_test.dart @@ -581,4 +581,24 @@ void main() { expect(decoded.fields['rr_ms'], isEmpty); }); }); + + group('short R10 frames are not swallowed by the compact dispatch', () { + // A real R10 (0x2B) live frame is normally ~1920 bytes, but parseR10Lite + // only requires 18 — a short one still under the compact branch's 64-byte + // cutoff used to get routed there first and never reach the R10 branch. + test('a short R10 frame with hr==0 (off-wrist) still decodes as R10', () { + final b = Uint8List(20); + final bd = b.buffer.asByteData(); + b[0] = 0x2b; + b[1] = Record.r10; + bd.setUint32(7, 1780840486, Endian.little); // ts + b[17] = 0; // hr — off-wrist, not "undecoded" + + final decoded = decodeFrame(Frame(b, true, true)); + expect(decoded.kind, 'realtime_hr'); + expect(decoded.fields['ts_epoch'], 1780840486); + expect(decoded.fields['hr'], 0); + expect(decoded.fields['wearing'], isFalse); + }); + }); } diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index f5e15a1..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "CommonJS", - "lib": ["ES2020"], - "esModuleInterop": true, - "skipLibCheck": true, - "outDir": "./dist", - "rootDir": "./", - "strict": true, - "types": ["node"] - }, - "include": ["ts/**/*"] -}