Skip to content

plan 0008: get BLE download throughput off the floor - #144

Merged
TheAngryRaven merged 1 commit into
BETAfrom
claude/bluetooth-download-speed-iwclyc
Aug 17, 2026
Merged

plan 0008: get BLE download throughput off the floor#144
TheAngryRaven merged 1 commit into
BETAfrom
claude/bluetooth-download-speed-iwclyc

Conversation

@TheAngryRaven

@TheAngryRaven TheAngryRaven commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Summary

A 3.3 MB session downloading at 28.8 KB/s on an iPad, against ~130 KB/s remembered from an earlier bench run.

First suspect was the SD card, since the normal clock is a deliberately slow 2 MHz for ignition EMI. That turned out to be fineBLE_SETUP() still bumps to 8 MHz and BLE_STOP() still reverts it. But sdSetSpiClock() silently falls back to 2 MHz when the fast re-init fails and nothing anywhere surfaced which clock a session actually got, so "is it at 8?" was unanswerable from the device. That silence is fixed here too.

Reading the rest of the transfer path turned up three real ceilings:

1. Data Length Extension was requested but never verified. configPrphConn(247, ...) sets the ATT MTU; the link-layer PDU is separate and defaults to 27 bytes, which splits every 244-byte notify into ten packets — roughly a 3–4× tax. The connect callback does call requestDataLengthUpdate(), but it fires one line behind requestPHY(2M), and the SoftDevice runs one link-layer control procedure at a time — a collision returns NRF_ERROR_BUSY into a return value nobody reads. Nothing retried, and nothing ever called getDataLength() to notice. Centrals that initiate DLE themselves (Chrome desktop/Android) paper over it entirely, which is very plausibly why the bench run was fast. bleTuneLink() now reads back what actually negotiated at +500 ms and re-asks with nothing else in flight.

2. A connection-interval request iOS is required to reject. setConnInterval(6, 12) asks for 7.5–15 ms; Apple's accessory rules set a 15 ms floor, so iOS rejects it and keeps its own pick — commonly 30 ms, half the connection events. The preference is deliberately left alone (desktop/Android honour it and must not be slowed); instead an adaptive second ask fires only when the measured interval is slower than 15 ms.

3. The SD read sat in the radio's critical path. Each chunk was read off SdFat immediately before being notified, so every packet waited on a disk read — and 244 bytes isn't a sector, so the card ran one single-block command per 512 bytes. Chunks now stream from a compacting 4 KB read-ahead filled by one aligned multi-sector read. This also structurally removes the old seekCur() rewind-on-failed-notify hazard: with the bytes in RAM there is no file position to unwind.

Two things beyond the reported problem:

  • A mid-file SdFat read error used to report DONE — it fell into the same "no bytes" branch as end-of-file, handing the app a truncated session file with a clean status. It now sends ERROR.
  • The transfer page reports what it got: live KB/s plus the SD clock actually in force, the negotiated link-layer PDU, and the ATT payload — e.g. 118KB/s 8M 251 244. A 27 in the third slot means DLE didn't take. The whole reason this took a full session to spot is that the old page showed a percentage and nothing else.

Rationale in full: docs/plans/0008-ble-download-throughput.md.

Which lever actually dominated is still a bench measurement — the KB/s readout on the next download is what settles it. Best guess is DLE.

Type of change

  • Bug fix (no user-visible behavior change beyond the fix)
  • New feature / behavior
  • Refactor (no behavior change)
  • Tests only
  • CI / tooling / docs
  • Breaking change (track files, log format, BLE protocol, or a removed mode)

Wire protocol is unchanged — same SIZE: / chunks / DONE flow, and chunk size was already MTU-dependent. The one addition is ERROR on the status characteristic if the card fails mid-read, where a bogus DONE used to go.

How it was verified

  • Host unit tests pass — 450/450 (ctest --test-dir tests/build)
  • clang-tidy clean (new unit added to the job's file list)
  • Compiles for the XIAO nRF52840 Sense — no Arduino toolchain in this environment, left to CI. The sim TU (which compiles the real display_pages.ino / sd_functions.ino) builds clean, and all 6 sim tests pass with golden hashes unchanged, but bluetooth.ino is excluded from the sim so the compile-sketch job is the first real check on it.
  • Tested on real hardware — not yet, this is the point of the PR. Worth watching on the transfer page: the KB/s figure, and whether the third number is 251 (DLE landed) or 27 (still not).

New ble_stream unit is host-tested including a model transfer that streams a file through the real buffer discipline and compares it byte-for-byte, across sizes chosen around the buffer boundary (0, 1, 243, 244, 4095, 4096, 4097, 100000) and the MTUs a real central hands us (244, 182, 20). An off-by-one there corrupts a downloaded session, so it is checked away from the radio.

Checklist

  • CHANGELOG.md updated under [Unreleased]
  • ARCHITECTURE.md / CLAUDE.md updated (subsystem 6 + constants table + file map)
  • New testable logic has a matching test in tests/
  • Branch is focused — one commit, all of it the download path

Renumbered 0007 → 0008: the plan number 0007 is claimed by another branch that hasn't merged yet, so it isn't visible in docs/plans/ on BETA.

@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown

Coverage — host-testable units

📂 Overall coverage

Metric Coverage
Lines 🟢 1494/1517 (98.5%)
Functions 🟢 160/160 (100.0%)
Branches 🟢 1105/1228 (90.0%)

📄 File coverage

File Lines Functions Branches
BirdsEye/ble_stream.cpp 🟢 34/34 (100.0%) 🟢 8/8 (100.0%) 🟡 17/20 (85.0%)
BirdsEye/camera_fsm.cpp 🟢 238/246 (96.7%) 🟢 20/20 (100.0%) 🟡 142/160 (88.8%)
BirdsEye/course_creator.cpp 🟢 209/217 (96.3%) 🟢 21/21 (100.0%) 🟡 114/131 (87.0%)
BirdsEye/course_prune.cpp 🟢 37/37 (100.0%) 🟢 5/5 (100.0%) 🟢 47/50 (94.0%)
BirdsEye/crc32.cpp 🟢 30/30 (100.0%) 🟢 4/4 (100.0%) 🟢 24/24 (100.0%)
BirdsEye/crossing_pattern.cpp 🟢 15/15 (100.0%) 🟢 1/1 (100.0%) 🟢 12/12 (100.0%)
BirdsEye/dovex_header.cpp 🟢 106/107 (99.1%) 🟢 7/7 (100.0%) 🔴 62/88 (70.5%)
BirdsEye/filename_validator.cpp 🟢 14/14 (100.0%) 🟢 1/1 (100.0%) 🟢 30/30 (100.0%)
BirdsEye/gps_stats.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 8/8 (100.0%)
BirdsEye/gps_status_page.cpp 🟢 29/29 (100.0%) 🟢 4/4 (100.0%) 🟢 28/28 (100.0%)
BirdsEye/gps_time.cpp 🟢 45/45 (100.0%) 🟢 6/6 (100.0%) 🟢 30/32 (93.8%)
BirdsEye/gps_validation.cpp 🟢 24/24 (100.0%) 🟢 2/2 (100.0%) 🟢 66/66 (100.0%)
BirdsEye/haversine.cpp 🟢 8/8 (100.0%) 🟢 1/1 (100.0%) ⚫ 0/0 (0.0%)
BirdsEye/idle_policy.cpp 🟢 17/17 (100.0%) 🟢 2/2 (100.0%) 🟢 14/14 (100.0%)
BirdsEye/insta360_protocol.cpp 🟢 140/140 (100.0%) 🟢 16/16 (100.0%) 🟡 86/98 (87.8%)
BirdsEye/lap_format.cpp 🟢 18/18 (100.0%) 🟢 1/1 (100.0%) 🟢 9/9 (100.0%)
BirdsEye/led_animations.cpp 🟢 76/76 (100.0%) 🟢 5/5 (100.0%) 🟢 43/46 (93.5%)
BirdsEye/led_frame.cpp 🟢 21/21 (100.0%) 🟢 7/7 (100.0%) 🟢 6/6 (100.0%)
BirdsEye/led_modes.cpp 🟢 64/65 (98.5%) 🟢 5/5 (100.0%) 🟢 48/50 (96.0%)
BirdsEye/sat_bars.cpp 🟢 33/33 (100.0%) 🟢 2/2 (100.0%) 🟢 51/54 (94.4%)
BirdsEye/sd_access_policy.cpp 🟢 9/9 (100.0%) 🟢 3/3 (100.0%) 🟢 18/18 (100.0%)
BirdsEye/sd_format_page.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 25/26 (96.2%)
BirdsEye/sector_purple.cpp 🟢 51/51 (100.0%) 🟢 2/2 (100.0%) 🟡 42/50 (84.0%)
BirdsEye/sensoregg_protocol.cpp 🟢 44/45 (97.8%) 🟢 7/7 (100.0%) 🟢 33/34 (97.1%)
BirdsEye/sprint_select.cpp 🟢 25/25 (100.0%) 🟢 4/4 (100.0%) 🟢 46/48 (95.8%)
BirdsEye/tach_filter.cpp 🟢 27/27 (100.0%) 🟢 6/6 (100.0%) 🟢 17/18 (94.4%)
BirdsEye/track_json.cpp 🟢 116/120 (96.7%) 🟢 12/12 (100.0%) 🟡 67/88 (76.1%)
BirdsEye/wake_cause.cpp 🟢 14/14 (100.0%) 🟢 2/2 (100.0%) 🟢 20/20 (100.0%)

A 3.3 MB session downloading at 28.8 KB/s on an iPad, against ~130 KB/s
remembered from a bench run. The SD transfer-clock bump (2 -> 8 MHz while
parked) turned out to still be wired up correctly, so the regression was
elsewhere. Three ceilings, all fixed:

- Data Length Extension was requested on connect but never verified. The
  SoftDevice runs one link-layer control procedure at a time, so the ask
  fired immediately behind requestPHY(2M) can return NRF_ERROR_BUSY into a
  return value nobody reads -- and nothing ever called getDataLength() to
  find out. An un-extended link splits every 244-byte notify into ten
  27-byte packets. bleTuneLink() now reads back what negotiated at +500 ms
  and re-asks with nothing else in flight.

- The 7.5 ms connection-interval preference is one Apple centrals are
  required to reject (15 ms floor), leaving iOS on its own choice, commonly
  30 ms. The preference stays as-is so desktop/Android are not slowed; a
  second, Apple-compliant request is made only when the measured interval
  is slower than 15 ms.

- Every chunk was read straight off SdFat before being notified, putting a
  disk read in the radio's critical path and driving the card in 244-byte
  pieces rather than whole sectors. Chunks now stream from a compacting
  4 KB read-ahead filled by one aligned multi-sector read.

The read-ahead index math lives in a new host-tested ble_stream unit,
including a model transfer that reassembles a file and compares it
byte-for-byte -- an off-by-one here corrupts a downloaded session.

Also in the download path: a mid-file SdFat read error used to land in the
same branch as end-of-file and report DONE, handing the app a truncated
session with a clean status. It now reports ERROR.

And so the next regression is visible without a rebuild, the transfer page
shows live KB/s plus the SD clock actually in force, the negotiated
link-layer PDU and the ATT payload. sdActiveSpiHz() backs the SD figure
with the clock SD.begin() accepted -- the 8 MHz bump falls back to 2 MHz
silently, which nothing surfaced before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6mSm54pTqmg1eSgD3az2
@TheAngryRaven
TheAngryRaven force-pushed the claude/bluetooth-download-speed-iwclyc branch from f2038cf to 5c66db6 Compare August 17, 2026 00:08
@TheAngryRaven TheAngryRaven changed the title plan 0007: get BLE download throughput off the floor plan 0008: get BLE download throughput off the floor Aug 17, 2026
@TheAngryRaven
TheAngryRaven merged commit a63cdba into BETA Aug 17, 2026
8 checks passed
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.

2 participants