You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Fast historical sync: HCI capture of the official app's bulk-offload command #533
NOOP's historical sync is firmware-paced and much slower than the official WHOOP app, which offloads ~24 h of history in a couple of minutes vs NOOP's ~10×. The official app is clearly using a bulk / flash-page offload command that NOOP doesn't send.
An HCI / btsnoop capture of the official app doing a large historical sync would expose that command sequence and let NOOP close the gap for everyone.
How to capture (whichever you have) — capture on the phone, not an over-the-air sniffer, so the ATT traffic is plaintext:
Android: Developer Options → enable "Bluetooth HCI snoop log" → open the official WHOOP app → let it run a big sync → pull btsnoop_hci.log (adb bugreport or the vendor log path) → open in Wireshark.
iOS: Apple's PacketLogger ("Additional Tools for Xcode"), capturing from the iPhone with the Bluetooth logging profile installed, while the official app syncs.
To trigger it: let a large backlog build (skip a sync for a day or two), then sync — the bulk command fires when there's a lot to offload.
Share the capture, or just the decoded command frames if you'd rather. Happy to help analyze any capture — this is a general sync-speed win.
The capture request is well-founded and in scope — an on-phone HCI capture of the official app is exactly how the R22 flag table, the enable_sig12 correction (#522) and the Oura IBI layouts (#511) got pinned. Extracting the fact of a command sequence and reimplementing it is the established path here.
Before anyone burns a weekend on captures, though, it's worth flagging that the premise — "the official app is clearly using a bulk command NOOP doesn't send" — isn't established yet. There are at least three NOOP-side factors that could account for a large part of the gap, and they're much cheaper to rule out.
1. The connection-priority escalation is dead code
#477 shipped GATT connection-priority management fully implemented but dormant. setConnectionPriorityManagement is never called anywhere in production (grep finds only its own definition), so connectionPriorityEnabled stays false and refreshConnectionPriority() early-returns at every call site.
The hook is already sitting in exactly the right place — it was simply never switched on:
// enterBackfilling
refreshConnectionPriority() // #477: escalate to HIGH for the offload burst (faster sync). No-op unless enabled.
The source even notes "a follow-up wires it to a persisted Settings toggle" — that follow-up never landed. So every historical offload to date has run at the stack default. On Android CONNECTION_PRIORITY_HIGH is ~11.25 ms vs BALANCED ~30 ms.
#536 wires it up behind Settings → Strap → "Faster history sync (experimental)", default off. It escalates for the bounded offload burst only — deliberately not for live-HR, because realtimeArmed covers the whole overnight continuous-HRV window and would otherwise hold an ~11.25 ms interval for hours to carry a 1 Hz stream.
2. NOOP paces its own sync
BACKFILL_INTERVAL_MS = 900_000 — a 15-minute floor between periodic offloads
MAX_AUTO_CONTINUES = 6 — a hard per-connection cap on immediate re-kicks
A large backlog therefore drains in bursts of ≤6 passes, then waits 15 minutes. For the "let a day or two build up, then sync" scenario in the issue — exactly when the bulk command is said to fire — that pacing is a strong candidate for the wall-clock gap, with no firmware secret required.
3. MTU is already fine
247, negotiated before discovery. Not a factor.
On battery — and a tension worth naming
A shorter interval means ~2.7× more connection events, i.e. more radio wakeups on both the phone and the strap. Whether that costs strap battery depends entirely on which regime the offload is in:
Link-limited (the interval is the bottleneck): the same bytes move in less wall-clock. Same TX energy, shorter radio-on window, strap returns to idle sooner — roughly neutral, plausibly slightly better.
Firmware-limited (the strap emits records at its own pace regardless): no speedup at all, but ~2.7× the wakeups across the same-length transfer — a net increase in strap drain for zero benefit.
Which matters here, because this issue's own framing is that the sync is "firmware-paced." If that's literally true, #536 is precisely the case where it costs battery and gains nothing. Both claims can't be fully right — and that's the point: it can't be settled by reasoning, only by measuring.
(Two caveats either way: the peripheral gets a vote — requestConnectionPriority only asks the phone's stack to propose new parameters, and WHOOP's firmware may reject or pin its own, so the request could be a partial or total no-op. And strap battery % is coarse at 1%, so a single burst's delta may not even be visible; you'd want several syncs or a day-over-day comparison.)
The experiment is a discriminating test
The strap log already timestamps Backfill: session started / Backfill: session ended right next to Backfill: session persisted N rows, so records/sec is directly comparable from a before/after log — no new instrumentation needed.
Flip the #536 toggle, sync a genuine day-or-two backlog, and compare both duration and strap battery:
Faster, no real battery cost → a chunk of the gap was ours, cheaply fixed.
No speedup → that confirms the firmware-pacing premise, says the toggle should stay off, and makes the bulk-command hypothesis genuinely motivated — at which point the capture is clearly worth the effort, with much better priors about what to look for.
Either outcome is informative, which is why it's worth doing first.
One caution if the bulk command is pursued
It's a write to the strap, and an unknown or misidentified command in that space is precisely the hazard the BLE safety contract warns about — adjacent command IDs could be destructive (a flash erase/trim). Landing it would want the usual discipline: CRC-gate the responses, no hardcoded hex frames in app code (protocol facts live in the decoders/schema), a default-off experimental gate, and on-strap validation before it goes near anyone's history.
None of this argues against the capture — a decoded command sequence is valuable regardless, and it's the only way to settle the question properly. It's just worth knowing how much of the 10× might already be ours to fix.
Field data from #515 has landed that changes the picture here — including a correction to my own earlier comment, and to the two PRs I merged off the back of it.
A WHOOP 5.0 user's debug export captured a full historical drain. The offload bursts:
Each burst transfers in ~9 seconds (17:47:24 -> 17:47:33, ~1,200 rows).
The bursts are 15-20 minutes apart — that is BACKFILL_INTERVAL_MS = 900_000 (the 15-minute periodic floor), with MAX_AUTO_CONTINUES = 6 re-kicks in between (visible as the back-to-back 17:47:24 / :32 / :33 cluster before it falls back to the floor).
So the wall clock is roughly 95% waiting and 5% transferring. That reframes the whole issue: the gap is dominated by NOOP's own scheduler, not by link throughput or by a missing bulk command.
This undercuts the two levers I merged
#536 (HIGH connection priority) and #537 (LE 2M PHY) both speed up the transfer — i.e. the 9-second part. Even if they doubled throughput, they would shave seconds off a delay measured in hours. They optimise the 5%. They are still default-off experiments and might be worth a little on their own, but on this evidence they are not the answer to this issue, and I would not spend a capture-hunting weekend on the assumption that they are.
The real lever is the scheduler
MAX_AUTO_CONTINUES = 6 then a 15-minute floor. Auto-continue (#364) already exists precisely to drain a deep backlog back-to-back — it just gives up after 6 passes and falls back to the floor while the strap still has plenty banked. Raising the cap, or letting the floor collapse while the strap keeps reporting more history, is a pure scheduling change: no protocol work, no captures, no new BLE risk surface.
It also has a Swift twin (BackfillPolicy.periodicFloorSeconds / BLEManager.maxAutoContinues), so unlike the link levers it would fix iOS/macOS too.
And it may explain the official app's advantage
Worth considering that the official app's speed might not come from a bulk command at all — it may simply not sit out a 15-minute floor mid-backlog. If so, the "10x" is ours to fix in the scheduler, and no capture is needed to close it.
Why this matters more than sync speed
In #515 this delay made a user's sleep look wrong. Their night ended at 07:02; they opened NOOP, saw 1.5h of sleep, and reported a sleep-detection bug. The detector was fine — it later scored both segments of their split night correctly (96 min + 257 min). The data simply had not arrived yet, and was still landing at 18:57. They then tried to hand-fix the "wrong" night, which is how they hit two further bugs.
So this is not only a convenience issue: a slow drain surfaces as incorrect health data for hours after waking.
The capture is still worth having — a decoded command sequence is valuable regardless, and it is the only way to settle whether a bulk path exists. But the cheap, high-confidence win looks like the scheduler, and that is measurable today from the existing Backfill: session started / session ended / persisted N rows log lines.
Activity
ryanbr commentedon Jul 17, 2026
The capture request is well-founded and in scope — an on-phone HCI capture of the official app is exactly how the R22 flag table, the
enable_sig12correction (#522) and the Oura IBI layouts (#511) got pinned. Extracting the fact of a command sequence and reimplementing it is the established path here.Before anyone burns a weekend on captures, though, it's worth flagging that the premise — "the official app is clearly using a bulk command NOOP doesn't send" — isn't established yet. There are at least three NOOP-side factors that could account for a large part of the gap, and they're much cheaper to rule out.
1. The connection-priority escalation is dead code
#477 shipped GATT connection-priority management fully implemented but dormant.
setConnectionPriorityManagementis never called anywhere in production (grep finds only its own definition), soconnectionPriorityEnabledstaysfalseandrefreshConnectionPriority()early-returns at every call site.The hook is already sitting in exactly the right place — it was simply never switched on:
The source even notes "a follow-up wires it to a persisted Settings toggle" — that follow-up never landed. So every historical offload to date has run at the stack default. On Android
CONNECTION_PRIORITY_HIGHis ~11.25 ms vs BALANCED ~30 ms.#536 wires it up behind Settings → Strap → "Faster history sync (experimental)", default off. It escalates for the bounded offload burst only — deliberately not for live-HR, because
realtimeArmedcovers the whole overnight continuous-HRV window and would otherwise hold an ~11.25 ms interval for hours to carry a 1 Hz stream.2. NOOP paces its own sync
BACKFILL_INTERVAL_MS = 900_000— a 15-minute floor between periodic offloadsMAX_AUTO_CONTINUES = 6— a hard per-connection cap on immediate re-kicksA large backlog therefore drains in bursts of ≤6 passes, then waits 15 minutes. For the "let a day or two build up, then sync" scenario in the issue — exactly when the bulk command is said to fire — that pacing is a strong candidate for the wall-clock gap, with no firmware secret required.
3. MTU is already fine
247, negotiated before discovery. Not a factor.
On battery — and a tension worth naming
A shorter interval means ~2.7× more connection events, i.e. more radio wakeups on both the phone and the strap. Whether that costs strap battery depends entirely on which regime the offload is in:
Which matters here, because this issue's own framing is that the sync is "firmware-paced." If that's literally true, #536 is precisely the case where it costs battery and gains nothing. Both claims can't be fully right — and that's the point: it can't be settled by reasoning, only by measuring.
(Two caveats either way: the peripheral gets a vote —
requestConnectionPriorityonly asks the phone's stack to propose new parameters, and WHOOP's firmware may reject or pin its own, so the request could be a partial or total no-op. And strap battery % is coarse at 1%, so a single burst's delta may not even be visible; you'd want several syncs or a day-over-day comparison.)The experiment is a discriminating test
The strap log already timestamps
Backfill: session started/Backfill: session endedright next toBackfill: session persisted N rows, so records/sec is directly comparable from a before/after log — no new instrumentation needed.Flip the #536 toggle, sync a genuine day-or-two backlog, and compare both duration and strap battery:
Either outcome is informative, which is why it's worth doing first.
One caution if the bulk command is pursued
It's a write to the strap, and an unknown or misidentified command in that space is precisely the hazard the BLE safety contract warns about — adjacent command IDs could be destructive (a flash erase/trim). Landing it would want the usual discipline: CRC-gate the responses, no hardcoded hex frames in app code (protocol facts live in the decoders/schema), a default-off experimental gate, and on-strap validation before it goes near anyone's history.
None of this argues against the capture — a decoded command sequence is valuable regardless, and it's the only way to settle the question properly. It's just worth knowing how much of the 10× might already be ours to fix.
ble: faster history sync — experimental toggle for offload connection…
ble: experimental LE 2M PHY preference for the offload burst (#533)
ble: experimental LE 2M PHY preference for the offload burst (#533) (#…
ryanbr commentedon Jul 17, 2026
Field data from #515 has landed that changes the picture here — including a correction to my own earlier comment, and to the two PRs I merged off the back of it.
A WHOOP 5.0 user's debug export captured a full historical drain. The offload bursts:
Two things jump out:
17:47:24 -> 17:47:33, ~1,200 rows).BACKFILL_INTERVAL_MS = 900_000(the 15-minute periodic floor), withMAX_AUTO_CONTINUES = 6re-kicks in between (visible as the back-to-back17:47:24 / :32 / :33cluster before it falls back to the floor).So the wall clock is roughly 95% waiting and 5% transferring. That reframes the whole issue: the gap is dominated by NOOP's own scheduler, not by link throughput or by a missing bulk command.
This undercuts the two levers I merged
#536 (HIGH connection priority) and #537 (LE 2M PHY) both speed up the transfer — i.e. the 9-second part. Even if they doubled throughput, they would shave seconds off a delay measured in hours. They optimise the 5%. They are still default-off experiments and might be worth a little on their own, but on this evidence they are not the answer to this issue, and I would not spend a capture-hunting weekend on the assumption that they are.
The real lever is the scheduler
MAX_AUTO_CONTINUES = 6then a 15-minute floor. Auto-continue (#364) already exists precisely to drain a deep backlog back-to-back — it just gives up after 6 passes and falls back to the floor while the strap still has plenty banked. Raising the cap, or letting the floor collapse while the strap keeps reporting more history, is a pure scheduling change: no protocol work, no captures, no new BLE risk surface.It also has a Swift twin (
BackfillPolicy.periodicFloorSeconds/BLEManager.maxAutoContinues), so unlike the link levers it would fix iOS/macOS too.And it may explain the official app's advantage
Worth considering that the official app's speed might not come from a bulk command at all — it may simply not sit out a 15-minute floor mid-backlog. If so, the "10x" is ours to fix in the scheduler, and no capture is needed to close it.
Why this matters more than sync speed
In #515 this delay made a user's sleep look wrong. Their night ended at 07:02; they opened NOOP, saw 1.5h of sleep, and reported a sleep-detection bug. The detector was fine — it later scored both segments of their split night correctly (96 min + 257 min). The data simply had not arrived yet, and was still landing at 18:57. They then tried to hand-fix the "wrong" night, which is how they hit two further bugs.
So this is not only a convenience issue: a slow drain surfaces as incorrect health data for hours after waking.
The capture is still worth having — a decoded command sequence is valuable regardless, and it is the only way to settle whether a bulk path exists. But the cheap, high-confidence win looks like the scheduler, and that is measurable today from the existing
Backfill: session started/session ended/persisted N rowslog lines.Raise backfill auto-continue cap so a deep backlog drains in one conn…
Raise backfill auto-continue cap so a deep backlog drains in one conn…
Raise backfill auto-continue cap so a deep backlog drains in one conn…
ble: faster history sync — experimental toggle for offload connection…
ble: experimental LE 2M PHY preference for the offload burst (ryanbr#533
Raise backfill auto-continue cap so a deep backlog drains in one conn…
Report what each offload burst actually achieved (#1007) (#1027)
ble: faster history sync — experimental toggle for offload connection…
ble: experimental LE 2M PHY preference for the offload burst (ryanbr#533
Raise backfill auto-continue cap so a deep backlog drains in one conn…
Report what each offload burst actually achieved (ryanbr#1007) (ryanb…