Skip to content

Add WHOOP 4.0 (Gen4) historical sync support#26

Open
jakobrmarrone wants to merge 5 commits into
b-nnett:mainfrom
jakobrmarrone:gen4-historical-sync
Open

Add WHOOP 4.0 (Gen4) historical sync support#26
jakobrmarrone wants to merge 5 commits into
b-nnett:mainfrom
jakobrmarrone:gen4-historical-sync

Conversation

@jakobrmarrone

Copy link
Copy Markdown

Implements the cmd 34 → 22 → 23 state machine reverse-engineered from a PacketLogger capture of the official iOS app. Gen4 uses a different framing (4-byte header + CRC-8), single-byte args on the setup commands, and a non-Gen5 success code on cmd 22; each is documented at the call site and in docs/gen4-historical-sync.md.

Sync completion now fires an onHistoricalSyncCompleted callback that AppShellView wires to HealthDataStore.runPacketInputs, so the metric extract pipeline runs automatically after a successful burst.

Implements the cmd 34 → 22 → 23 state machine reverse-engineered from a
PacketLogger capture of the official iOS app. Gen4 uses a different
framing (4-byte header + CRC-8), single-byte args on the setup commands,
and a non-Gen5 success code on cmd 22; each is documented at the call
site and in docs/gen4-historical-sync.md.

Sync completion now fires an onHistoricalSyncCompleted callback that
AppShellView wires to HealthDataStore.runPacketInputs, so the metric
extract pipeline runs automatically after a successful burst.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@tigercraft4 tigercraft4 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.

Strong protocol work — the Gen4 framing and state machine are accurate and honestly documented. Four issues to address before merge, plus a process blocker. Inline comments below in priority order.

Comment thread GooseSwift/AppShellView.swift Outdated

@tigercraft4 tigercraft4 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.

Remaining inline comments.

Comment thread GooseSwift/GooseBLEClient.swift

/// Gen4 frame layout: [0xaa, len_lo, len_hi, crc8(len_bytes), payload..., crc32 x4]
private static func buildGen4CommandFrame(sequence: UInt8, command: UInt8, data: [UInt8]) -> Data {
var payload: [UInt8] = [GooseBLEClient.V5PacketType.command, sequence, command]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Gen4 payload padding is unverified.

buildV5CommandFrame and Rust's build_v5_payload_frame both pad to 4-byte boundary. buildGen4CommandFrame does no padding. The Rust core has no Gen4 builder (only a parser), so Swift is the sole producer of Gen4 command frames.

Please confirm from the PacketLogger capture whether the official app padded Gen4 frames, and add a comment:

  • Unpadded: // Gen4 frames are intentionally unpadded — confirmed from capture
  • Padded: add 4-byte alignment matching buildV5CommandFrame

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Pushed in fec758e. I verified this using the PacketLogger capture: the official iOS app emits cmd 120 with a 65-byte args field, which is not a multiple of, so padding can't be required. Our unpadded frames also round-trip cleanly with the strap. Noted both in a docstring above buildGen4CommandFrame.

@tigercraft4 tigercraft4 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.

Overflow and conflict notes.

let ackPayload: [UInt8]
if activeDeviceGeneration == .gen4 {
gen4HistoricalPageSeq += 1
ackPayload = gen4PageRequestPayload(seq: gen4HistoricalPageSeq)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

gen4HistoricalPageSeq overflow operator inconsistency.

The cmd-34 response site uses lastSynced &+ 1 (wrapping, correct for UInt32). This site uses plain += 1 which traps on overflow rather than wrapping.

Unify to &+= 1 everywhere gen4HistoricalPageSeq is incremented.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Pushed in 933ef93. Both increment sites now use wrapping (&+ 1 and &+= 1).

// bypass the Gen5 result-code logic and immediately advance to cmd 23.
// gen4HistoricalPageSeq was set by the preceding cmd 34 response.
if activeDeviceGeneration == .gen4 && pending.kind == .sendHistoricalData {
historicalCommandTimeoutWorkItem?.cancel()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Process: conflict with PR #19.

PR #19 (feat/whoop-4.0-gen4-support) is still open and implements Gen4 framing using a CommandGeneration enum. Merging both creates two parallel generation enums and duplicated frame-building logic.

This PR's WhoopGeneration is the cleaner abstraction and aligns with Rust's DeviceType naming. Recommend settling on one canonical enum before merging either — the non-Gen4 fixes from #19 can be cherry-picked separately once the abstraction is settled.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Acknowledged. Happy to coordinate with #19's author or rebase this PR onto whatever lands first. Will leave it to you / the maintainers to decide which enum to canonize.

Capture healthStore weakly in the model.onHistoricalSyncCompleted
closure and clear the callback in onDisappear so the app-lifetime
GooseAppModel does not pin the view-owned HealthDataStore.

Threading was already correct: the BLE callback hops to @mainactor
before invoking handleHistoricalSyncProgress, so runPacketInputs()
on the @mainactor HealthDataStore is called from the right context.
Noted at the call site so future readers see the contract.
The property is read and written exclusively on the main thread:
every CoreBluetooth delegate path bounces through
dispatchCoreBluetoothDelegateToMainIfNeeded before touching it, and
UI / @mainactor callers are already on main. State that invariant in a
doc comment so future readers do not have to derive it from call sites.
Document evidence at the buildGen4CommandFrame site so the divergence
from buildV5CommandFrame (which pads to 4-byte boundary) reads as
intentional.

Evidence: the official-app PacketLogger capture emits cmd 120 with a
65-byte args field (not a multiple of 4), and our unpadded frames
round-trip cleanly with the strap.
The cmd-34 response site already wraps via &+. The historyEnd site
was using plain += which traps on UInt32 overflow. Align to &+= for
consistency; page_seq values in the wild are nowhere near UInt32
max, so this is consistency/defensiveness rather than a live bug.
@BowgartField

Copy link
Copy Markdown

Do you need any feedback @jakobrmarrone ?
I will test on my whoop v4

@jakobrmarrone

Copy link
Copy Markdown
Author

@BowgartField That would be great. But, you should be wary that my PR only implements history sync (as of now) and will not show any metrics other than HR on the goose health dashboard. If you download go to the More tab --> Device --> Advanced and press sync. You should then see the output from your 4.0. However, I haven't decoded the messages.

@BowgartField

Copy link
Copy Markdown

@BowgartField That would be great. But, you should be wary that my PR only implements history sync (as of now) and will not show any metrics other than HR on the goose health dashboard. If you download go to the More tab --> Device --> Advanced and press sync. You should then see the output from your 4.0. However, I haven't decoded the messages.

Don't know if the sync is working getting this error:
GET_DATA_RANGE/SEND_HISTORICAL_DATA produced no historical packet bodies after 3 attempts; transfer metadata was received but no historical packet bodies arrived. Last idle reason:
▎ historical_metadata_idle.

@jakobrmarrone

Copy link
Copy Markdown
Author

@BowgartField Are you connected to the Whoop app? The error occurs for me if I'm connected to Whoop app and Goose at the same time.

@po-sc

po-sc commented Jun 7, 2026

Copy link
Copy Markdown

@jakobrmarrone this is the missing piece — thank you. Reverse-engineering the cmd 34 → 22 → 23 sequence (and especially that Gen4 needs the single 0x00 arg byte and that cmd 22's 0x02 is a success ack, not Gen5 PENDING) from a PacketLogger capture of the official app is exactly the rigor this needed.

For context: my PR #19 implemented Gen4 framing + the V12/V24 normal_history DSP decode (HRV RR intervals, respiratory, skin temp) and the recovery-metric pipeline, but my historical-sync path skipped cmd 34 and sent cmd 22 with an empty body — which, per your findings, the strap silently drops. That's why a multi-day backlog never came off the band in my testing; the decode was ready but nothing fed it.

I've put your sync branch and my decode together on an integration branch (jakob's cmd 34→22→23 + the V24 metric decode + segment-aware RMSSD + a body_hex storage-compaction fix), and it builds cleanly — so a synced backlog now also surfaces HRV / respiratory / resting HR / strain. One small note for anyone combining the two: the Rust parse_device_type / expected_device_type only accepted "GEN_4", but the Gen4 client sends "GEN4", so that string needs adding in bridge.rs + capture_correlation/import/fixtures.rs.

Happy to coordinate so the sync + decode land together rather than conflicting.

@po-sc

po-sc commented Jun 7, 2026

Copy link
Copy Markdown

Tested your sync on a real WHOOP 4.0 (multi-day backlog) merged with the #19 metric decode — it works end to end:

  • cmd 34 → 22 → 23 runs exactly as documented; GET_DATA_RANGE returns last_synced and the strap streams pages (HISTORICAL_DATA 0 → 382 → growing on a fresh install). Confirmed your note that the Swift packets=0 per page is fine — the Rust pipeline reassembles them.
  • Feeding the synced frames through the V12/V24 decode gives real metrics: HRV pass=true, 134 RR, RMSSD 52.8 ms; respiratory candidates from every frame. So a synced backlog now surfaces HRV / respiratory / resting HR / strain.

One open problem — sync speed, and I think you'll know the answer fastest. On this band the backlog is ~27k pages (page_current≈61458, last_synced≈34274), and it syncs at ~15 pages/min → ~30 h for the full catch-up. Timing per page: cmd 23 → HistoryStart → (≈3 s) → HistoryEnd → next cmd 23. Each page is ~9 small frames (~860 B), i.e. ~290 B/s — far below BLE throughput, so it looks latency-bound, not bandwidth-bound.

The cmd 23 args carry page_count = 16, but handleHistoryEnd advances gen4HistoricalPageSeq += 1 and immediately queues the next cmd 23 — so effectively one page per round-trip. Question: in your PacketLogger capture of the official app, does a single cmd 23 with page_count=16 actually stream ~16 pages back-to-back (and the app advances by 16), or does the strap genuinely send one page per request? If it can burst, not re-issuing cmd 23 on every HistoryEnd (advancing by the count received instead) could be ~16× faster. Also: does the official app sync newest-first so recent recovery shows quickly, or strictly oldest-first like here?

Happy to test any variant on the hardware. The combined branch is po-sc/goose@integrate/gen4-sync-plus-metrics if useful.

@jakobrmarrone

Copy link
Copy Markdown
Author

@po-sc That's a great question and thanks for doing that - I'll have to check it out a bit later. I only tried syncing data minutes behind not days. Glad you tried this out. I'll dig into it sometime this week.

tigercraft4 referenced this pull request in tigercraft4/goose Jun 9, 2026
…te machine)

- WhoopGeneration enum: detect Gen4/Gen5 from command characteristic UUID prefix;
  Gen4 uses 4-byte header (CRC-8) vs Gen5 8-byte; gen4/gen5 hello frames differ
- activeDeviceGeneration set in processDiscoveredCharacteristics; resets to .gen5
  on connection state reset
- Gen4 sync state machine: cmd 34 (GET_DATA_RANGE) → cmd 22 (SEND_HISTORICAL_DATA)
  → cmd 23 (HISTORICAL_DATA_RESULT) page-by-page, driven by gen4HistoricalPageSeq
- Gen4 cmd 22 response uses 0x02 success code (not Gen5 PENDING) — short-circuit
  bypasses result-code logic and immediately queues first cmd 23
- Gen4 cmd 34 response: parse payload[10..14] for last_synced, set next page seq
- Gen4 historyEnd: increment page seq counter, queue next cmd 23 (not derived
  from historyEnd metadata body like Gen5)
- Gen4 retry skip: trust historyCompleteReceived instead of packet count
  (K24 fragments not counted by per-notification deframer)
- onHistoricalSyncCompleted callback: AppShellView wires to healthStore.runPacketInputs()
  so metric extraction fires automatically after historical sync completes
- Drop bytes < 32 now log at .debug; ≥ 32 remain .warn (Gen4 ATT fragmentation noise)
- docs/gen4-historical-sync.md: wire protocol, state machine, implementation map,
  known limitations, success log example, reverse-engineering approach
- docs/protocol-reverse-engineering.md: complete BLE protocol reference for Gen4/Gen5
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.

4 participants