Add WHOOP 4.0 (Gen4) historical sync support#26
Conversation
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
left a comment
There was a problem hiding this comment.
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.
|
|
||
| /// 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] |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| let ackPayload: [UInt8] | ||
| if activeDeviceGeneration == .gen4 { | ||
| gen4HistoricalPageSeq += 1 | ||
| ackPayload = gen4PageRequestPayload(seq: gen4HistoricalPageSeq) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Do you need any feedback @jakobrmarrone ? |
|
@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: |
|
@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. |
|
@jakobrmarrone this is the missing piece — thank you. Reverse-engineering the cmd 34 → 22 → 23 sequence (and especially that Gen4 needs the single For context: my PR #19 implemented Gen4 framing + the V12/V24 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 Happy to coordinate so the sync + decode land together rather than conflicting. |
|
Tested your sync on a real WHOOP 4.0 (multi-day backlog) merged with the #19 metric decode — it works end to end:
One open problem — sync speed, and I think you'll know the answer fastest. On this band the backlog is ~27k pages ( The cmd 23 args carry Happy to test any variant on the hardware. The combined branch is |
|
@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. |
…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
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.