Skip to content

feat: decode unframed 96-byte realtime packet (raw SpO2 byte + stream CRC32) - #84

Merged
abdulsaheel merged 3 commits into
OpenStrap:mainfrom
BucciMobile:vibe/whoop-spo2-realtime96-7295e2
Nov 10, 2026
Merged

abdulsaheel merged 3 commits into
OpenStrap:mainfrom
BucciMobile:vibe/whoop-spo2-realtime96-7295e2

Conversation

@BucciMobile

@BucciMobile BucciMobile commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a decoder for the unframed 96-byte realtime sensor packet that WHOOP 4.0 delivers as raw BLE notifications on the data-stream characteristic (distinct from the framed 0xAA… packets handled by framing.dart).

  • lib/src/realtime96.dart: parseRealtime96 decodes sequence (@0), hr×100 (u16 LE @1), RR ms (u16 LE @3), SpO2 byte 5 as raw only, skin-temp byte 6 raw, and validates the CRC-32 trailer (@92).
  • crc32WhoopStream: reflected CRC-32 with xor-out 0xF43F44AC for this stream — deliberately different from the framed-packet crc32 (zlib xor-out 0xFFFFFFFF); two independent community sources agree on this xor-out.
  • Exported via openstrap_protocol.dart (Realtime96, parseRealtime96, crc32WhoopStream, kRealtime96PacketSize).

Honesty contract (why SpO2 stays raw)

Byte 5 is a single-source community reverse-engineering finding with only a 50–100 range gate, no ground truth; the original RE write-up says blood oxygen on this band is measured only during sleep. Therefore the decoder exposes spo2Raw exactly as received — no plausibility gate, no percentage getter, nothing user-facing. Values >128 pass through unmasked so captures can be correlated later.

Review-driven changes (what was reworked and why)

  • CodeRabbit: the CRC test was self-referential (fixture built with the same function under test) → added an independent known-answer vector: crc32WhoopStream("123456789") == 0xC0348275 (verified independently: standard CRC-32 of "123456789" is 0xCBF43926; xor-out difference yields 0xC0348275). Commit 0702595.

Tests

test/realtime96_test.dart: field decode, length gate (≠96 → null), corrupted payload → crcValid == false with raw fields intact, raw passthrough incl. 0xFF, stream-CRC vs framed-CRC distinction + known-answer vector.

Still to do (open items, explicit)

  1. Run the tests once for real DONE locally on Windows (Dart 3.11.4): dart analyze clean, dart test test/realtime96_test.dart → All tests passed (5/5).
  2. Wire-validate the CRC xor-out (0xF43F44AC) against a real 96-byte capture from an actual band — community-sourced so far; arithmetic is pinned by the known-answer vector, the wire fact is not.
  3. Resolve the characteristic-UUID discrepancy (61080004 per whoop-reader vs 61080005 per whoop-rs uuids.rs) from real notifications before anything consumes this stream in edge.
  4. Real-frame fixtures: CONTRIBUTING asks for test cases from real frames for new record types — current tests are synthetic (layout + known-answer CRC only); add a real captured 96-byte packet when available.
  5. No consumer wired yet: edge does not subscribe/decode this stream; any live use stays RAM-only per edge AGENTS.md invariant 14 and needs ground-truth validation (see the edge proposal PR) before anything user-facing.

Verification status (honest)

  • CodeRabbit review (on fork PR): pass, no open comments.
  • CI: no test job exists for protocol PRs; CodeRabbit pass only.
  • Local Windows run: dart pub get ok, dart analyze → No issues found!, dart test test/realtime96_test.dart → +5: All tests passed!.
  • Remaining caveat: the CRC xor-out still needs wire-validation against a real capture.

Summary by Sourcery

Add support for parsing unframed 96-byte WHOOP realtime sensor notifications while preserving unverified measurements as raw data.

New Features:

  • Add decoding and public exports for unframed 96-byte WHOOP realtime sensor packets, including raw sensor fields and CRC status.

Enhancements:

  • Introduce the stream-specific CRC-32 implementation with an independently verified known-answer vector and keep uncertain SpO2 and temperature values uncalibrated and raw.

Tests:

  • Add coverage for field decoding, exact packet lengths, CRC failures, raw SpO2 passthrough, byte normalization, and distinction from the framed-packet CRC.

Summary by CodeRabbit

  • New Features
    • Added support for decoding unframed 96-byte realtime sensor packets, including sequence, heart rate, RR interval, SpO₂, and skin-temperature readings.
    • Decoded readings include an indicator of whether the packet’s CRC check passed. Packets with an invalid CRC can still return decoded values.
    • Added support for calculating the stream-specific CRC-32 checksum.
    • Inputs that are not exactly 96 bytes are rejected. SpO₂ and skin-temperature values are provided in raw form, without calibrated measurements.

mistral-vibe and others added 2 commits November 5, 2026 21:31
… CRC32)

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 <BucciMobile@users.noreply.github.com>
…48275)

Co-authored-by: BucciMobile <BucciMobile@users.noreply.github.com>
@sourcery-ai

sourcery-ai Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Data structures

Define the packet layout and the public raw-field structure that decoded realtime packets expose.

  • lib/src/realtime96.dart L1-17, L52-93

API

Expose the realtime decoder and stream CRC publicly, and implement the callable CRC and packet-parsing interfaces.

  • lib/openstrap_protocol.dart L102-105
  • lib/src/realtime96.dart L24-37, L94-111

Implementation

Normalize decoder inputs and build the lookup table used by the stream-specific CRC implementation.

  • lib/src/realtime96.dart L18-23, L38-51

Tests

Exercise raw field decoding, length and CRC handling, passthrough behavior, and an independent CRC known-answer vector.

  • test/realtime96_test.dart L1-85

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Explain Change →

No actionable comments were generated in the recent review.

Walkthrough

The package now exports an API for parsing unframed 96-byte realtime sensor packets. The parser exposes raw sensor fields and reports CRC validity. It returns null when the input length is not 96 bytes.

Changes: Realtime packet API

Layer / File(s) Summary
Packet decoding and public API
lib/src/realtime96.dart, lib/openstrap_protocol.dart, test/realtime96_test.dart
Adds the packet model, a stream-specific CRC-32 function, and a parser that returns raw fields with a CRC-validity flag. Exports the API and tests packet lengths, decoded fields, CRC behavior, and raw SpO₂ values.

Estimated code review effort: 2 (Simple) | ~15 minutes

Change: Feature

Suggested reviewers: abdulsaheel

Merge Risk: Minimal · up to c896c

The decoder is mergeable after normal checks. Its CRC assumption should be validated against a real packet when one becomes available.

Pre-merge checks: Passed 5

Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly summarizes the main change: decoding unframed 96-byte realtime packets and exposing the raw SpO₂ byte and stream CRC-32.
Docstring Coverage Passed No eligible functions found in the changed files. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentat…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

No eligible functions found in the changed files. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 0 functions across 0 files. (3 skipped: 3 unsupported.)

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Recent review info

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 436fad4f-f9db-4e81-8117-8110ee369f98
Commits

Reviewing files that changed from the base of the PR and between b4fde6a and c896c9a.

Files selected for processing: 3

  • lib/openstrap_protocol.dart
  • lib/src/realtime96.dart
  • test/realtime96_test.dart

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="lib/src/realtime96.dart" line_range="35" />
<code_context>
+  for (final b in data) {
+    crc = _crc32Table[(crc ^ b) & 0xFF] ^ (crc >>> 8);
+  }
+  return (crc ^ 0xF43F44AC) & 0xFFFFFFFF;
+}
+
</code_context>
<issue_to_address>
**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.
</issue_to_address>

### Comment 2
<location path="lib/src/realtime96.dart" line_range="103-106" />
<code_context>
+  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,
</code_context>
<issue_to_address>
**Decoded fields disagree with packet bytes**

When a caller passes a 96-element `List<int>` containing values outside 0–255, `view` and `crc32WhoopStream` use the octet-normalized copy, but `sequence`, `spo2Raw` and `skinTempRaw` read the original list. The parser can accept a packet whose fields disagree, exposing values such as 256 or -1 to callers.

Normalize the input once and use the normalized bytes for all field reads and CRC validation.
</issue_to_address>

Sourcery assessment

Approval pending. 2 findings to address first.

Blocking findings: lib/src/realtime96.dart:35, lib/src/realtime96.dart:106


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread lib/src/realtime96.dart
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.

Comment thread lib/src/realtime96.dart Resolved

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional comments: 2

lib/src/realtime96.dart-25-25: 1

25-25: Trivial · Maintainability & Code Quality · Low value

Unverified finding
Verification ran but could not confirm this finding. It is shown for review, not as a verified issue.

Fix the CRC doc comment: the polynomial and bit order are inconsistent.

The comment names the polynomial as 0x04C11DB7 and also calls the CRC reflected. The implementation uses the reflected constant 0xEDB88320 (Line 45), which is the reflected form of 0x04C11DB7. The two are consistent, but the comment can mislead a reader. Say that the polynomial is 0x04C11DB7 and that the code uses its reflected form 0xEDB88320.

I checked the known-answer vector by hand. CRC-32 of "123456789" is 0xCBF43926. Its pre-final-XOR register is 0x340BC6D9. XOR with 0xF43F44AC gives 0xC0348275, which matches the test.

lib/src/realtime96.dart-108-108: 1

108-108: Trivial · Performance & Scalability · Low value

Unverified finding
Verification ran but could not confirm this finding. It is shown for review, not as a verified issue.

Avoid the extra copy in the CRC check.

bytes.sublist(0, 92) allocates a new list for every packet. Packets arrive as BLE notifications, so this runs at the stream rate. Give crc32WhoopStream an optional end parameter, or pass a Uint8List.sublistView. This removes the allocation.

Review info

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 0e213991-c3da-468f-91db-e9779df38cbb
Commits

Reviewing files that changed from the base of the PR and between b4fde6a and 0702595.

Files selected for processing: 3

  • lib/openstrap_protocol.dart
  • lib/src/realtime96.dart
  • test/realtime96_test.dart

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

@BucciMobile

Copy link
Copy Markdown
Contributor Author

@coderabbitai Full Review

@coderabbitai

coderabbitai Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional comments: 1

lib/src/realtime96.dart-25-25: 1

25-25: Trivial · Maintainability & Code Quality · Low value

Unverified finding
Verification ran but could not confirm this finding. It is shown for review, not as a verified issue.

Correct the CRC polynomial description in the doc comment.

The comment says "poly 0x04C11DB7". The table at Line 45 uses 0xEDB88320. This is the bit-reversed form of the same polynomial, which is what a reflected CRC-32 requires. The comment is not wrong, but it is easy to misread. Add the reflected constant so the doc matches the code.

📝 Proposed fix

-/// Reflected CRC-32 (poly 0x04C11DB7, init 0xFFFFFFFF) with xor-out
+/// Reflected CRC-32 (poly 0x04C11DB7, reflected form 0xEDB88320, init 0xFFFFFFFF) with xor-out
Review info

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 970e6300-fa4b-4c16-9279-f7d32550c09d
Commits

Reviewing files that changed from the base of the PR and between b4fde6a and 0702595.

Files selected for processing: 3

  • lib/openstrap_protocol.dart
  • lib/src/realtime96.dart
  • test/realtime96_test.dart

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

@abdulsaheel
abdulsaheel merged commit 1ae61af into OpenStrap:main Nov 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants