Skip to content

History offload costs 41 min (5/MG) to 2.4 h (4.0) of radio per day; the official app does 24 h in 1–3 min #1007

Closed

Description

@ryanbr

What

NOOP spends roughly 2.4 hours of active BLE streaming per day pulling the strap's history. The official app moves the same 24 hours in 1–3 minutes. That is 48–144× more radio airtime for identical data, and it is very likely the dominant term in #1005's battery report.

The measurement

Already in docs/BLE_REVERSE_ENGINEERING.md, from real hardware:

  • The strap streams type-47 records at a steady ~10 records/s. The records are 1 Hz, so that is ~10× real-time — a full day of history takes ~40 min, a night ~30 min.
  • History is re-offloaded about every 15 min while connected, mirroring the official app's cadence.

The duty cycle follows from the data rate, not the sync frequency: moving 86,400 records a day at 10/s costs 8,640 s ≈ 2.4 h of radio per day, about a 10% duty cycle, no matter how often the sync runs.

Radio time per 24 h of history Duty cycle
Official app 1–3 min 0.07–0.21%
NOOP ~2.4 h ~10%

Why this is not a tuning problem

Both obvious levers were tested on a real worn WHOOP 4 and neither moved the rate:

  • ATT MTU 23 → 247 (a 104-byte type-47 frame drops from 6 notification packets to 1) — no change.
  • Connection interval 50 ms → 7.5 ms (the Linux equivalent of CONNECTION_PRIORITY_HIGH) — no change.

The rate is rock-steady across the whole drain, which is the signature of firmware-side pacing of the per-record HISTORY stream. The existing doc already draws the conclusion: matching the official app "would require a different bulk / flash-page transfer command, not link tuning, and that command is not yet reverse-engineered."

So this is a protocol gap, not an optimisation. Until that command is found, every alternative — connection priority, MTU, sync cadence, batching — is a rounding error against 2.4 h/day of airtime.

What would close it

An HCI capture of the official app performing a full history sync is the artefact that would settle it. docs/WHOOP5_DEEP_DATA.md already asks for one for a related reason. What matters here is the command that initiates the bulk transfer and the framing of what comes back — a flash-page or block-range read rather than the per-record stream NOOP uses today.

Clean-room rules apply as everywhere else: a re-derived offset or command number with attribution, not decompiled implementation.

Scope

Activity

  1. ryanbr commented on Aug 1, 2026

    @ryanbr
    OwnerAuthor

    Correcting the headline figure, and adding the avenues that are actually reachable.

    The 5/MG number was 3.5x too pessimistic

    I quoted the WHOOP 4 rate (~10 records/s) as the general case. BLE_REVERSE_ENGINEERING.md also carries a hardware-verified WHOOP 5 measurement I did not use: "One 90 s capture pulled 3193 CRC-valid type-47 frames" — 35.5 records/s.

    Radio time per day of history Duty cycle
    WHOOP 4 144 min 10%
    WHOOP 5 41 min 2.8%
    Official app 1–3 min 0.07–0.21%

    Still 15–40x the official app, so the issue stands. But 41 minutes a day is a different claim from 2.4 hours, and #1005's reporter is on a 5.0.

    The measurement that should come before any fix

    That 35.5 rec/s came from whoop_capture.py, which acks almost immediately. The app does not. It persists the decoded rows, then the trim cursor, and only then acks — and the strap does not advance until acked, so per-chunk latency caps throughput directly.

    From the same capture: ~11.4 records per chunk, a chunk every ~320 ms.

    Nobody has compared the app's offload rate against the tool's. That single number decides what this issue even is:

    • App ≈ 35 rec/s → we are firmware-limited, and only a bulk transfer command helps.
    • App materially slower → we are limiting ourselves, and that part is fixable today with no protocol work.

    It is measurable from a strap log on any device, and it should come first.

    One concrete inefficiency, honestly sized

    PrefsTrimCursorStore.set does prefs.edit().putLong(...).commit() — a synchronous whole-file XML rewrite plus fsync, per chunk, inside the ack path. The comment is right that durability must precede the ack, so .apply() would be wrong.

    But the rows already land in a Room transaction. Folding the cursor into that transaction gives the same ordering with one durable write instead of two. At ~3 chunks/s this is worth single-digit percent, not a fix for this issue — and the Swift twin has the same shape via store.setCursor on UserDefaults, so it would be a both-platforms change.

    Avenues toward the real fix

    1. An HCI capture of the official app syncing a full night's history. The definitive artefact, and WHOOP5_DEEP_DATA.md item 4 already asks for one for a related reason. What matters is the command that initiates the bulk transfer and the framing of what comes back.
    2. Enumerate the strap's own device-config keys. Ask the strap to LIST its device-config keys before guessing any: a read-only 115/116 enumeration probe, with a derived key-name sweep as the fallback (#103) #898 is already in flight for this. If any key governs offload pacing or chunk size, it would show up in a list the strap itself provides rather than in guesses.
    3. Confirm the plain path's ceiling first. goose #24 shows a Gen5 band streaming type-47 history to a third-party app with no config write at all. Worth establishing what a clocked 5/MG already returns through the get_data_range/send_historical_data loop NOOP runs today, before assuming a write path is needed.

    I have deliberately not searched the command table and called a candidate likely: nothing in the 80 documented commands reads as a bulk or flash-page transfer, which is consistent with the doc's conclusion that it is simply not reverse-engineered yet.

    What will not work

    Link tuning. Already tested on real hardware and disproved: ATT MTU 23 → 247 and connection interval 50 ms → 7.5 ms both left the rate unmoved. The pacing is firmware-side.

  2. changed the title [-]History offload costs ~2.4 h of radio per day; the official app does 24 h in 1–3 min (bulk transfer command not yet found)[/-] [+]History offload costs 41 min (5/MG) to 2.4 h (4.0) of radio per day; the official app does 24 h in 1–3 min[/+] on Aug 1, 2026
  3. vishk23 commented on Aug 3, 2026

    @vishk23

    Independent 5/MG numbers from a ~29 h backlog drained today, plus two client-side costs that I think complicate the "purely firmware-paced" conclusion.

    Your 35.5 rec/s figure reproduces — but only at the top of the range

    I measured throughput as history-time covered per wall-second, decoding the per-record timestamp out of the frames themselves. Since the records are 1 Hz, N× real-time == N records/s and the units are directly comparable to yours.

    window rate
    first 23 min ~36× real-time
    middle stretch ~7.5×
    after restarts stopped ~16×

    The opening ~36× lands essentially on your hardware-verified 35.5 rec/s, which I take as independent confirmation of the ceiling on a 5/MG.

    But it is not rock-steady. Same strap, same connection, one continuous drain — 4.8× spread. Firmware pacing should not vary like that, and the variation tracks something visible in the log: sessions ending reason=timeout and immediately auto-continuing. The slow stretch had three session boundaries in a 2000-line tail; the fast stretch had zero and one long-running session (sessionRows 31,004 and climbing).

    So I would split the gap in two. 35 rec/s → 1-3 min is the protocol gap you describe. 7.5 rec/s → 35 rec/s is ours, and it is reachable without finding any new command.

    Client-side cost 1: the strap log re-serializes ~4 MB per line

    LiveState.append(log:) calls persistTail(log) on every appended line, and persistTail does Array(lines.suffix(2000)) + UserDefaults.standard.set(...). Its comment says "Synchronous and cheap (a single small array write)… the persisted blob stays a few hundred KB at most."

    That assumption breaks whenever a strap emits records that fail to decode. Backfiller hex-dumps up to 8 full frames per chunk (rejected.prefix(8)). My frames are 1584 B, so each dump is 3,168 characters, and 1,307 of my 2,000 tail lines were hex — a ~4 MB blob, rebuilt and re-serialized on every line, ~10 lines per 2-second chunk. redactPii() also runs a regex over each 3,168-char string.

    Not a regression — #91 landed in v1.69, #30/#136 in v1.92 — and it is dormant for any strap whose records decode. Which is probably why it has not surfaced before. Cheap fixes: debounce persistTail, cap the persisted line length, and skip the hex dump when the payload is all zeros.

    Client-side cost 2: half my frames carried no payload

    Of every 52 frames per chunk, exactly 26 were rejected, and all 1,307 I captured were byte-identically shaped:

    size              1584 B  (all 1307)
    nonzero fraction  0.015   (mean = min = max)
    nonzero offsets   0-20    (header: magic aa, seq, LE unix ts, +1s per frame)
                      1580-1583 (CRC32)
    offsets 21-1579   ZERO in every frame
    

    Structurally valid, correctly timestamped, and empty — 1,559 zero bytes each. If that generalises, half the airtime is carrying nothing.

    Strong caveat: this may be self-inflicted and specific to my strap. These records begin at a datable instant — the raw-archive table holds 499,624 rows starting 2026-07-27T17:06:58Z at 0.97/s, exactly one per second, and is empty before that. That is the same day I wrote config flags to the strap while probing #103/#891. So the likeliest reading is that a flag I set causes the strap to allocate a per-second buffer nothing fills. I have not yet cleared the flag to confirm, and I would not want this counted toward the general figure until someone reproduces it on a strap that has never been written to.

    Worth checking whether anyone else drains with ~50% rejects. If so it is not my flag, and it is half the offload budget.

    Method note

    The per-frame timestamp gives an exact progress figure without needing GET_DATA_RANGE — useful, because on my strap that reply was first absent and then returned an epoch-era value:

    recovered through  Aug 3 01:38:22
    window             Aug 2 11:43 -> Aug 3 15:56  (28.2 h)
    progress           49.3%  (13.9 h done, 14.3 h left)
    

    Happy to capture more on the 5/MG if a specific measurement would help.

  4. vishk23 commented on Aug 3, 2026

    @vishk23

    Correcting my own comment above: the rate variation is real, but my explanation for it does not hold up. Please do not act on the session-restart attribution.

    The measurements that stand are the absolute ones — frontier timestamps decoded from the frames, which are independent of anything else going on:

    • peak ~36x real-time, matching your hardware-verified 35.5 rec/s
    • lower sustained rates later in the same drain

    What does not stand is my claim that the slowdown tracks reason=timeout session restarts. Four confounds I should have accounted for:

    1. I was reading the app container repeatedly to take the measurements — roughly every 10-15 minutes across the drain. That read takes a usage assertion on the app and is documented in this project as able to relaunch or perturb it. My instrument was plausibly perturbing the thing it measured, during the window I then called slow.
    2. The app was force-quit and reopened mid-drain, with two manual syncs, as part of recovering a wedged strap.
    3. Thermal state unknown. Phone on cable, sustained BLE + CPU for about an hour. I have no data either way.
    4. The session-boundary count itself is biased. I counted boundaries in a fixed-length 2000-line rolling log tail. Those hex dumps consume the tail budget, so a faster drain emits more lines and the window covers less wall-time. "Zero session boundaries during the fast stretch" may be the tail having rolled past them, not fewer restarts. The correlation may be an artifact of the instrument.

    So my "35 -> 1-3 min is the protocol gap, 7.5 -> 35 is ours" split is not evidenced. The ceiling reproduces; whether the shortfall beneath it is client-side is open.

    The two code-level findings are unaffected, since neither depends on that timing data: persistTail re-serializing the tail on every appended line is a plain reading of LiveState.append(log:), and the 1584 B all-zero frame structure is a byte census over 1,307 captured frames.

    A clean throughput measurement would need no container reads during the drain, a known thermal state, and no app restarts — I can try to produce one on the 5/MG if it is useful.

  5. sempowa-hub commented on Aug 8, 2026

    @sempowa-hub

    A 5/MG data point for the "confirm separately" note

    The issue asks for the 5/MG pacing to be confirmed before quoting the ~10% duty cycle at a 5/MG user. Here is one measurement from a WHOOP 5.0, firmware 50.36.2.0, never paired with the official app (Bluetooth-only, computed namespace).

    Method — and its limits. This is not a record-rate counter. It is inferred at the app-log level: how far our own frontier advanced, divided by the wall-clock duration of the offload session that delivered it. Records are taken as 1 Hz, as in the issue. One connection, one sample.

    The window. The strap reconnected at 21:28:32 after ~10 minutes disconnected, so this is a genuine backlog drain rather than a steady-state trickle:

    21:18:40  frontier 1785266318
    21:28:32  Connected — discovering services
    21:28:34  Backfill: session started — historical offload requested
    21:28:40  Backfill: session ended — reason=HISTORY_COMPLETE
    21:28:40  frontier 1785266913
    

    Result. 595 s of history delivered in ~6 s of session, i.e. ~99 records/s, ~99x real-time — roughly 10x the ~10 rec/s measured on the 4.0.

    Extrapolated the same way the issue does, 86,400 records/day at ~99 rec/s is ~15 min of radio per day, ~1% duty cycle, versus the ~2.4 h / ~10% quoted for the 4.0. So the 10% figure does look like it should not be quoted at a 5/MG user, though the gap to the official app's 1–3 min remains large.

    Caveats. Single sample, single connection, one firmware. The frontier-based method is coarser than counting notifications, and a longer backlog might pace differently once the ack-gating has more to chew on. Happy to repeat it over a multi-day backlog if that would help, or to hand over the raw strap log.

    Separately, this session's chain stopped at 3/24 auto-continues because the strap reported HISTORY_COMPLETE and the trim stopped advancing — so on 5/MG the per-connection cap does not appear to be the binding constraint either; the gap between triggers is.

  6. sempowa-hub commented on Aug 9, 2026

    @sempowa-hub

    Correcting my own comment: retract the ~99x figure

    I posted a 5/MG measurement above without having read this thread's existing comments first. @vishk23 had already posted 5/MG numbers on 3 August, with a better-instrumented method, and my figure disagrees with theirs by roughly 3x. Theirs is the one to trust; please disregard my ~99x and the ~1% duty cycle derived from it.

    Why mine is wrong. We were computing the same quantity — history-time covered per wall-second — but the denominators are not comparable. @vishk23 decodes the per-record timestamp out of the frames. I took two log lines:

    21:28:34  Backfill: session started — historical offload requested
    21:28:40  Backfill: session ended — reason=HISTORY_COMPLETE
    

    and called that 6 s. That denominator has two defects I should have stated before quoting a number off it:

    1. It may not bracket the transfer. The link connected at 21:28:32, two seconds before the "session started" line, and I have no evidence the last frame lands before the "session ended" line rather than after. If the true window is ~16 s rather than 6 s, the result is ~37x — which is @vishk23's peak, and consistent with the hardware-verified 35.5 rec/s.
    2. One-second log resolution on a 6-second measurement is a ±17% error bar before any of the above.

    Separately, my numerator assumed the 595 s of frontier advance corresponds to 595 banked records. That holds only if the strap banked continuously at 1 Hz across the whole window, which I did not verify.

    What I still think stands, since none of it depends on the rate:

    • the device context: WHOOP 5.0, firmware 50.36.2.0, never paired with the official app;
    • the chain ended at 3/24 auto-continues via HISTORY_COMPLETE with the trim no longer advancing, so on this unit the per-connection cap was not the binding constraint;
    • the window followed a reconnect, so it was a real backlog drain rather than a steady-state trickle.

    Apologies for the noise. I still have the raw strap log for this session and can hand it over if a second data point at frame level would be useful — but next time I will instrument it the way @vishk23 did instead of inferring it from app logs.

  7. ryanbr commented on Aug 15, 2026

    @ryanbr
    OwnerAuthor

    Landed a small log-hygiene slice from this thread: #1341 (merged cacdf242).

    @vishk23's byte census found a strap emitting byte-identically empty rejected frames (valid header + CRC, all-zero payload) — ~4 MB of all-00 in the strap log, since the Backfiller hex-dumps up to 8 full rejected frames/chunk for firmware-mapping. An all-zero frame has no record layout to map, so #1341 adds a pure isEmptyRecordFrame (both platforms) and skips those frames' hex dump, keeping the WARNING … decoded to 0 rows count plus a compact N/M all-zero, hex skipped line so the occurrence is still visible.

    Scope — this does not close the issue. It's log/CPU hygiene only; it does not reduce the offload radio airtime (the strap still sends the frames). The core cost here is still the protocol gap — the per-record type-47 stream vs the official app's bulk transfer — which needs the un-RE'd bulk/flash-page command, and the definitive next artefact remains an HCI capture of the official app doing a full history sync.

    For the record on the other client-side items in the thread: the persistTail re-serialization is already fixed on main (batched via persistEveryNLines), and the trim-cursor fold into the Room transaction needs a schema migration, so it's a separate change rather than a quick win. Leaving this open as the protocol-gap tracker.

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