Skip to content

WHOOP 5.0 raw HCI capture — where to submit? (docs reference #174, which doesn't exist) #103

Closed

Description

@digitalerdude

docs/WHOOP5_DEEP_DATA.md points to this issue as the tracking issue for the WHOOP 5/MG
deep-data (R22) unlock:

Tracking: #174

That issue number doesn't currently resolve (404) — the repo's highest issue/PR number
right now is #102. The doc also says:

Even better: a Bluetooth HCI capture of the official app syncing a night's history
shows the deep packets actually flowing and their layout. See the capture guide in
the wiki.

The wiki link (github.com/ryanbr/noop/wiki) redirects straight back to the repo home,
so there doesn't seem to be a public capture guide there either.

I have a WHOOP 5.0 strap and would like to help by providing a raw HCI/BTSnoop capture
of the official app doing a full history sync, so the type-0x2F deep records can get
decoded. Before I do that, could someone confirm:

  1. What the correct tracking issue number is (or should this issue become the new one?)
  2. The expected capture format/tooling (I was planning an iOS Bluetooth diagnostic
    profile capture → .pklg/btsnoop_hci.log, opened in Wireshark)
  3. Where to upload the capture file (attach here, or is there a preferred drop point?)

Happy to also fix the stale #174 reference and wiki link in
docs/WHOOP5_DEEP_DATA.md once the right number/link is confirmed.

Activity

  1. added a commit that references this issue on Jul 8, 2026
  2. ryanbr commented on Jul 8, 2026

    @ryanbr
    Owner

    Great catch, and thanks for offering to help — this is exactly the artifact the deep-data work needs.

    On the stale references: you're right. #174 was an issue number inherited from the original upstream repo (now deleted), so it 404s here, and there's no public wiki. I've just fixed docs/WHOOP5_DEEP_DATA.md on main — the #174 links now point to this issue (#103), which is the tracking issue for HCI captures + new deep-record layouts, and the "capture guide in the wiki" is replaced with the actual method. So no PR needed on your side for that, though further capture-guide additions are welcome.

    Your three questions:

    1. Tracking issue: WHOOP 5.0 raw HCI capture — where to submit? (docs reference #174, which doesn't exist) #103 — this one. It's now referenced from the doc.
    2. Tooling: your plan is exactly right — iOS PacketLogger via the Bluetooth diagnostic profile → .pklg (or Android Developer Options → Bluetooth HCI snoop log → btsnoop_hci.log), opened in Wireshark. That's the same iOS-HCI approach the judes.club write-up used.
    3. Upload: attach it here (NOOP is offline-only, so there's no private drop). One privacy note: an HCI log carries your strap's BLE MAC + the full sync traffic — no account credentials (BLE is device-local), but device identifiers are present, so it's worth filtering the capture to just the WHOOP peripheral in Wireshark before posting.

    What's most useful: capture the official app doing a full night's history sync (not just live HR). NOOP already decodes the R22 enable sequence and the known channels; the gap now is the un-decoded record layouts — the type-0x2F deep records and any 5/MG history record types NOOP still skips. A full-sync capture is where those show up. Thanks again!

  3. digitalerdude commented on Jul 10, 2026

    @digitalerdude
    Author

    Update on #174: it now exists but is an unrelated bug report (Today "Rest" ring stuck
    calibrating), closed as a duplicate — not the WHOOP5 deep-data tracking issue. So this
    issue (#103) seems to be the right place to continue.

    I have a WHOOP 5.0 and captured a full iOS Bluetooth HCI log (sysdiagnose →
    bluetoothd-hci-*.pklg, PacketLogger format) while syncing history overnight. Confirmed
    it contains the relevant strings (whoop_live_hr_in_adv_ind_pkt,
    whoop_live_2_hrm_devices, etc.).

    One device-name string in the larger file leaked a personal device name
    ("AirPods Pro" from an unrelated paired device) — I redacted it byte-for-byte
    with an equal-length placeholder so the binary framing/length fields stay valid; nothing
    else was touched. The smaller "latest" log only contained generic firmware/model
    strings (no personal info).

    Both files (zipped individually, they're .pklg/PacketLogger — open with Wireshark or
    Xcode's Bluetooth log viewer):

    https://github.com/digitalerdude/noop/releases/tag/whoop5-hci-capture-2026-07-10

    Let me know if a different format/location is preferred, or if there's a more complete
    capture (e.g. spanning a full night's get_data_range/send_historical_data exchange)
    that would be more useful.

  4. digitalerdude commented on Jul 10, 2026

    @digitalerdude
    Author

    I decoded the overnight capture I uploaded above end-to-end and have some concrete results — including one actionable config gap and a full reverse-engineer of the v18 "optical/tail" region.

    Method (reproducible): .pklg → HCI ACL → L2CAP reassembly → ATT → puffin-frame reassembly (mirroring WhoopProtocol.Reassembler), then decoded with the repo's own whoop-decode (release build). All ATT was on legacy CID 0x0004 (no EATT); the WHOOP data channel was ATT handle 0x09a3, live HR on standard 0x2A37.

    What the capture is

    A live R22 session, not a bulk history offload. 23,166 strap→app puffin frames, 100% framed + CRC-OK. Breakdown: 19,282 type-0x2F HISTORICAL_DATA (v18 ×18,602 @124 B + v26 PPG ×680 @88 B), plus CONSOLE_LOGS/METADATA/COMMAND_RESPONSE/EVENT. There is no large multi-KB type-47 record in here — the one 679 B "frame" is a CRC-bad misframe on a stray 0xAA. HR ground-truth checks out (v18 heart_rate over 18,602 records: min 0 / max 93 / mean 52 — a clean overnight resting curve). So for the big historical record layouts we'd still want a capture of the official app doing a real backfill (strap offline → reconnect); this one is pure live streaming.

    1) The R22 enable sequence — one flag missing from Whoop5Config

    The TX side captured the full official enable sequence: 0x73 key-exchange + 16× 0x78 SET_FF_VALUE, each flag written twice. The shipped Whoop5Config.enableR22Sequence (15 flags) matches items 1–15 byte-for-byte (names, order, values incl. the two '1's) — independent on-strap confirmation of what was previously only judes.club-sourced. The one difference: the app writes a 16th flag, enable_sig12 = '2', which the shipped sequence omits.

    Captured order:

    enable_r22_packets=2  enable_r22_v2_packets=2  enable_r22_v3_packets=2  enable_r22_v4_packets=1
    enable_r22_v5_packets=2  enable_r22_v6_packets=2  enable_r22_v8_packets=2  make_hrfm_visible=2
    disable_pip_r26_packets=2  wear_detect_bias=2  hr_ch_switching=2  ir_hw_switching=2
    enable_passive_strap_fit_gen5=1  enable_sig11_during_sleep=2  dorset_inhibit_wpt=2  enable_sig12=2
    

    Opened as #224 (Swift + Kotlin twin + both golden tests bumped 15→16). It diverges from the judes.club "byte-for-byte" note on purpose — real hardware writes a flag their builder doesn't list.

    2) v18 type-0x2F "optical/tail" region (offset 82–119) — reverse-engineered

    Characterised across all 18,602 v18 records and cross-checked on the two existing fixture devices. The 38-byte region is ~85% zero padding:

    offset finding
    83–103, 110–112, 117–119 constant 0x00 on every record
    104 constant 0x01 marker (5/5 fixtures, both devices)
    106–107 u16 LE analog optical/ADC baseline — wanders 35k–60k overnight (not a counter), 0 = off-wrist
    108, 109 paired channel (@108≈@109, equal 24% / ±2 80%). Rises monotonically with HR (~34 @ HR40 → ~58 @ HR80) and motion. 128 = per-channel invalid sentinel — seen off-wrist AND on a worn record with valid HR (dev2 HR=57 → 128/128), so it's a channel-validity flag, not an off-wrist flag
    113–116 the existing unknown_f32_113 (−5.3…0, +0.44 corr with HR — the strongest HR-linked byte in the region)

    Honest limit: single night, no SpO2/respiratory reference, so @106–109 can't be named to a WHOOP metric — I've kept them raw per the decoder's own rule. Notably the pipeline already reserves spo2_red/spo2_ir/resp_rate_raw output slots (unfilled on v18); @108/@109 are the obvious candidates, but naming them would need a strap that gives ground truth.

    Opened as #223: turns the opaque 38-byte region into honest annotated fields (optical_baseline_106, optical_amp_a/b) + documents the padding — Swift + Kotlin twin + shared decoder-oracle entries for 4 real frames, swift test green (254). And if anyone has a way to capture a full official-app history backfill, that's the missing piece for the large type-47 layouts.

  5. digitalerdude commented on Jul 11, 2026

    @digitalerdude
    Author

    History-backfill capture — the answer on "large type-47 records"

    Captured the official app doing a real backfill: strap kept off-sync ~15 h (yesterday 16:11 → this morning 07:30 local), then synced in one burst. Two .pklg (a rolling bluetoothd-hci-*.pklg + -latest), run through the same extractor + whoop-decode.

    This is unambiguously a history offload, not live streaming:

    • 38,304 type-0x2F records with a gapless record_index (main file: 29,901 v18 + 1,040 v26, record_index Δ = n−1; -latest continues at +1), unix timestamps spanning 2026-07-10 19:32 → 2026-07-11 05:49 UTC (~10.3 h) — past-timestamped records delivered in minutes.
    • TX = 516× HISTORICAL_DATA_RESULT (inner cmd 0x17/23) acks — i.e. exactly the acked-offload handshake NOOP already implements (whoop5HistoricalAckFrame walking the trim cursor).
    • Records are real body data: v18 HR 41–112, mean 60, all worn (no off-wrist gaps overnight).

    Headline: the 5/MG history offload uses the SAME small records — there are no large multi-KB type-47 records. Every historical record is a 124 B v18 or 88 B v26 frame; frames >200B = 0 in both files. So the "big historical record layouts" we were hunting for don't exist for this firmware — the historical layout is v18/v26, which NOOP already decodes, and the offload handshake NOOP already runs is the whole mechanism. Nothing missing on the decode side.

    Honest caveat: the .pklg is a rolling ~5 MB buffer, so the first ~5.5 h of the backlog (14:11 → 19:32 UTC) was overwritten before sysdiagnose. But the 8.3 h that survived is uniform — no large record appears anywhere among 30 k contiguous records — so the conclusion holds. (If a different firmware ever emits large records, they'd be interleaved and would show up here.)

    Bonus (cross-validation of #223): this third capture reconfirmed the @108/@109 optical pairing and the 128 per-channel-invalid sentinel, but the apparent HR correlation from the first capture did not reproduce (flat ~34 across HR here). I've softened the field note in #223 accordingly — the fields stay raw and unnamed.

    Net: for WHOOP 5/MG, decode + offload of the full history appears already covered by NOOP's v18/v26 path. The remaining unknowns are the write-path unlock (does NOOP's enable sequence start the deep stream on a bonded strap?) and naming the optical channels (needs a SpO2/resp reference) — both on my list.

  6. digitalerdude commented on Jul 11, 2026

    @digitalerdude
    Author

    Can SpO₂ / respiratory rate be pulled from the 5/MG stream? No — with ground truth

    Using the history-backfill capture (it covers a full night's sleep) plus the WHOOP app's own SpO₂ and respiratory-rate readings for that same night as ground truth (both normal-range), two independent checks:

    1. No stored value anywhere. Scanned every byte of the ~23 k per-second type-0x2F v18 records over the sleep window — and every byte of the summary frame types (0x24 / 0x30 / 0x31 / 0x32) — for a value tracking the app's SpO₂ or respiratory rate. Nothing matches with plausible variance (the only near-mean hits were high bytes of the gravity/accel float32s, sd ≫ a real SpO₂). Window sanity check: the sleep-window mean HR matched the app's resting HR exactly, so these are the right records.

    2. The v26 PPG is single-wavelength. The type-0x2F v26 optical buffer (24× i16 LE @ bytes [27:75]) is one AC-coupled channel, not interleaved red/IR:

    • lag-1 autocorrelation +0.79 > lag-2 +0.50 (interleaved red/IR would make lag-2 the larger);
    • even- vs odd-index samples share the same baseline and range (interleaving would separate them);
    • the 88-byte record has no room for a second 48-byte array, and no other frame type carries a second waveform (best non-v26 i16 run has acf +0.37 — not a clean PPG).

    Consequences

    • SpO₂ is not obtainable from the exposed stream: it's neither transmitted as a value nor computable, since SpO₂ needs the red/IR absorption ratio and only one wavelength is streamed.
    • Respiratory rate is also not transmitted, but is in principle derivable by DSP on the single PPG channel (amplitude/baseline modulation) or R-R RSA — unproven here (a quick 1 Hz-HR spectral estimate did not recover the app value; the 24 Hz v26 waveform is the right substrate). A future analytics-only experiment, now validatable against the app number.
    • Independently confirms optical_amp_a/optical_amp_b (@108/@109) are NOT SpO₂/perfusion (WhoopProtocol: annotate the v18 type-0x2F optical/tail region (#103) #223) — good that they were kept raw and unnamed.

    Net: the strap emits raw signals; SpO₂ / respiratory rate / scores are computed by WHOOP, not withheld as hidden raw data — so there's nothing here that a higher membership tier would "unlock" at the BLE level.

  7. digitalerdude commented on Jul 11, 2026

    @digitalerdude
    Author

    Respiratory rate from the v26 PPG — proof-of-concept works, needs multi-night validation

    Following up on the SpO2 finding above: SpO2 isn't derivable (single-wavelength PPG), but respiratory rate might be, since it's extractable from a single PPG channel via amplitude/baseline modulation (RIAV/RIIV) or R-R RSA — this doesn't need a second wavelength.

    Structure: type-0x2F v26 records arrive in 40 s bursts at 24 Hz (40 records × 24 i16 samples = 960 samples), not scattered singles. Confirmed on two independent captures — density and burst shape are near-identical whether the strap is live-streaming (no backlog) or delivering a forced history backlog: ~130 v26 records/hour, clean 40-record (=40s) bursts either way. So a live overnight capture (phone stays connected as normal, no need to force a backlog) is sufficient and actually gives slightly better temporal coverage since nothing falls off the rolling .pklg buffer.

    Method (PoC): per 40 s burst, compute the amplitude envelope (RIAV) and the 1 s moving-average baseline (RIIV), high-pass detrend both (removes <~0.08 Hz drift), take the spectral peak in the respiratory band 0.15–0.40 Hz (9–24 breaths/min) from whichever of the two has the cleaner peak (spectral prominence), then aggregate across the night's bursts weighted by that prominence.

    Validated against the WHOOP app's own nightly respiratory-rate reading (backfill-capture night):

    • Method sanity check: the PPG-derived pulse rate (spectral peak in the cardiac band) matched the night's actual resting HR almost exactly — confirms the extraction pipeline reads real physiology, not noise.
    • Prominence-weighted respiratory estimate: ~13–15 breaths/min depending on aggregation choice, vs. the app's ~15/min for that night — the highest-SNR bursts alone landed within a few tenths of a breath/min; the full-night aggregate was noisier (~10–12% off).

    Honest status: this is an n=1 proof-of-concept, not a validated feature — the aggregation is still sensitive to which bursts/parameters are used. Before this could become a StrandAnalytics estimator + test, it needs several more nights of (capture + the app's own respiratory-rate reading) to tune and quantify the real error. Documenting the method now so it isn't rediscovered from scratch; will follow up once more nights are collected.

  8. added 2 commits that reference this issue on Jul 11, 2026
  9. 93 remaining items

  10. added a commit that references this issue on Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions