diff --git a/.gitignore b/.gitignore
index 26265e0..73465a3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@
# Host unit-test build (also covered by tests/.gitignore)
build/
tests/build/
+# Sim wasm build (emcmake; CI uploads its dist/ as the artifact)
+build-wasm/
# Coverage artifacts generated by the coverage workflow / local runs
public-badges/
diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino
index aee6605..2abbe79 100644
--- a/BirdsEye/BirdsEye.ino
+++ b/BirdsEye/BirdsEye.ino
@@ -2321,11 +2321,12 @@ void loop() {
lastBatteryVoltage = getBatteryVoltage();
}
- // Minimal button check for exit
+ // Minimal button check for exit. Leaving transfer mode reboots (same
+ // as the phone-disconnect auto-reboot and the USB exit) so changed
+ // settings take effect and no session state leaks.
readButtons();
if (btn2->pressed) {
- BLE_STOP();
- switchToDisplayPage(PAGE_MAIN_MENU);
+ bleExitTransferMode(); // does not return (NVIC_SystemReset)
}
resetButtons();
diff --git a/BirdsEye/bluetooth.h b/BirdsEye/bluetooth.h
index d6372fd..023c70a 100644
--- a/BirdsEye/bluetooth.h
+++ b/BirdsEye/bluetooth.h
@@ -61,6 +61,13 @@ void BLE_SETUP();
// transfer file, release SD access, and drop bleOwner back to NONE.
void BLE_STOP();
+// Manual exit from the Bluetooth transfer page: BLE_STOP() then reboot
+// (NVIC_SystemReset), so a manual exit applies changed settings and clears
+// session state exactly like the phone-disconnect auto-reboot and the USB
+// mass-storage exit. Does not return on hardware; the SIM stub returns
+// after stopping the radio.
+void bleExitTransferMode();
+
// Force the Bluefruit connection LED off (autoConnLed disarm + park the
// pin high). Shared by BLE_STOP() and the shutdown quiesce.
void bleConnLedOff();
diff --git a/BirdsEye/bluetooth.ino b/BirdsEye/bluetooth.ino
index 92bc728..d7b2651 100644
--- a/BirdsEye/bluetooth.ino
+++ b/BirdsEye/bluetooth.ino
@@ -806,6 +806,23 @@ void BLE_STOP() {
debugln(F("BLE: Bluetooth stopped"));
}
+// Manual exit from the Bluetooth transfer page (the on-device Exit button).
+// Leaving transfer mode ALWAYS reboots, matching the phone-disconnect
+// auto-reboot and the USB mass-storage exit: a reboot is what guarantees
+// settings changed over BLE take effect and that no radio/advert/SD state
+// leaks from the transfer session into the next driving session. Before
+// this, only a peer disconnect rebooted — a manual exit dropped back to the
+// menu on the old settings. BLE_STOP() first so the teardown (file close,
+// SD release, OTA abort, advert stop) runs cleanly on the main loop before
+// the reset. Does not return on hardware; the SIM stub stops the radio and
+// returns so the sim's menu walk can continue.
+void bleExitTransferMode() {
+ BLE_STOP();
+ debugln(F("BLE: Transfer mode exited — rebooting..."));
+ delay(100); // let debug output flush (mirrors the disconnect auto-reboot)
+ NVIC_SystemReset();
+}
+
// Force the Bluefruit connection LED off and keep it off. Bluefruit drives
// LED_CONN (the XIAO's blue LED, active-low) whenever _led_conn is enabled —
// which is the library DEFAULT, so camera-owned advertising/links blink it
diff --git a/BirdsEye/display_ui.ino b/BirdsEye/display_ui.ino
index 8c98e2a..0f0dcd2 100644
--- a/BirdsEye/display_ui.ino
+++ b/BirdsEye/display_ui.ino
@@ -496,9 +496,15 @@ void handleMenuPageSelection() {
switchToDisplayPage(PAGE_MAIN_MENU);
}
} else if (currentPage == PAGE_BLUETOOTH) {
- // Exit button pressed - go back to main menu and disable bluetooth
+ // Exit button pressed — leaving transfer mode reboots the device (same
+ // as the phone-disconnect auto-reboot and the USB exit). On hardware
+ // this handler is normally unreachable anyway: bleActive parks loop()
+ // in its own branch, whose Exit check calls the same function. In the
+ // SIM the stub radio never sets bleActive, so THIS is the live path —
+ // the stub bleExitTransferMode() returns after stopping, and the page
+ // switch below keeps the sim's menu walk (golden fixtures) working.
debugln(F("Bluetooth: Exit selected"));
- BLE_STOP();
+ bleExitTransferMode(); // hardware: does not return (NVIC_SystemReset)
switchToDisplayPage(PAGE_MAIN_MENU);
} else if (currentPage == PAGE_COURSE_PRUNE) {
courseCreatorConfirmPrune(menuSelectionIndex == 1);
diff --git a/BirdsEye/sim/stubs/module_stubs.cpp b/BirdsEye/sim/stubs/module_stubs.cpp
index 8a7c623..c348562 100644
--- a/BirdsEye/sim/stubs/module_stubs.cpp
+++ b/BirdsEye/sim/stubs/module_stubs.cpp
@@ -41,6 +41,13 @@ void BLE_STOP() {
bleConnected = false;
}
+// On hardware this reboots and never returns; the sim just stops the stub
+// radio and returns so the Bluetooth page's Exit continues to the menu
+// (the golden walk exits this page mid-script).
+void bleExitTransferMode() {
+ BLE_STOP();
+}
+
void bleConnLedOff() {}
void bleShutdownQuiesce() {}
diff --git a/BirdsEye/sim/wasm/test.html b/BirdsEye/sim/wasm/test.html
index 7b04858..abb9245 100644
--- a/BirdsEye/sim/wasm/test.html
+++ b/BirdsEye/sim/wasm/test.html
@@ -9,6 +9,13 @@
real .dovex file: rows are injected at 25 Hz virtual time with the RPM
column driving the tach, exactly like the viewer's playback engine
will in Phase 5.
+
+ The "GPS fix" toggle injects a synthetic stationary fix (parked on the
+ preloaded OKC track's start line, deterministic timestamps) so the
+ flows that refuse to run without a fix + time lock are reachable —
+ most usefully the on-device course creator (main menu -> Create), whose
+ 3 s point-averaging hold needs a live 25 Hz fix stream. The mph field
+ sets the injected speed (10+ trips auto-race from a settled menu).
-->
@@ -36,6 +43,9 @@
BirdsEye simulator — Phase 4 harness
+
+
loading…
@@ -86,7 +96,9 @@
BirdsEye simulator — Phase 4 harness
if (e.key === 'ArrowRight') sim.buttonUp(2);
});
-// Optional dovex playback (Phase-5 semantics, minimally).
+// Optional dovex playback (Phase-5 semantics, minimally). Rows are >= 13
+// fields, not exactly 13: 4.0.0 logs append Temp1/Junction1/Temp2 columns
+// (16 fields), and the playback only reads the stable first 13.
let rows = null, rowIdx = 0, playClockMs = 0;
document.getElementById('dovex').addEventListener('change', async (e) => {
const file = e.target.files[0];
@@ -94,14 +106,34 @@
BirdsEye simulator — Phase 4 harness
const text = new TextDecoder().decode(
(await file.arrayBuffer()).slice(1024));
rows = text.split('\n').slice(1).map((l) => l.split(',')).filter(
- (f) => f.length === 13);
+ (f) => f.length >= 13);
rowIdx = 0;
+ document.getElementById('fix').checked = false; // dovex owns the GPS feed
await sim.reset();
sim.init();
playClockMs = Number(rows[0][0]) - 3000; // ~3 s no-fix pre-roll
console.log(`dovex loaded: ${rows.length} rows`);
});
+// Synthetic fix stream for the fix toggle: parked on the preloaded OKC
+// track's start/finish line (the sdfat_shim asset), so track detection
+// fires and the course creator's "Here" prompt has something to offer.
+// Timestamps run from a fixed epoch on the virtual clock — deterministic,
+// and the creator stamps N{YYMMDD}_{HHMM} names from it.
+const kOkcStartLat = 28.41271928, kOkcStartLon = -81.37965158;
+const kSynthEpochMs = 1785076320000; // 2026-08-03T14:32Z
+let synthClockMs = 0;
+
+function injectSynthFix() {
+ sim.injectPvt(JSON.stringify({
+ timestamp: kSynthEpochMs + synthClockMs, sats: 12, hdop: 0.8,
+ lat: kOkcStartLat, lng: kOkcStartLon,
+ speed_mph: Number(document.getElementById('mph').value) || 0,
+ altitude_m: 25, heading_deg: 0, h_acc_m: 1.2, fix: true,
+ accelX: 0, accelY: 0, accelZ: 1,
+ }));
+}
+
let prev = performance.now();
function frame(now) {
const speed = Number(document.getElementById('speed').value);
@@ -122,9 +154,23 @@
BirdsEye simulator — Phase 4 harness
}));
sim.setRpm(Number(f[9]));
}
+ sim.stepMillis(delta);
+ } else if (document.getElementById('fix').checked) {
+ // Synthetic fix: one PVT per <=40 ms step slice — the documented
+ // injection rate (25 Hz). A burst of injects before one big step
+ // would collapse into a single fix and starve the course creator's
+ // averaging hold (it needs >=8 fixes across its 3 s window).
+ let remaining = delta;
+ while (remaining > 0) {
+ const slice = Math.min(remaining, 40);
+ remaining -= slice;
+ synthClockMs += slice;
+ injectSynthFix();
+ sim.stepMillis(slice);
+ }
+ } else {
+ sim.stepMillis(delta);
}
-
- sim.stepMillis(delta);
blit();
const st = sim.getStateJson();
diff --git a/BirdsEye/usb_msc.h b/BirdsEye/usb_msc.h
index 6b0f1e3..3d47a5b 100644
--- a/BirdsEye/usb_msc.h
+++ b/BirdsEye/usb_msc.h
@@ -32,6 +32,8 @@ void USB_MSC_SETUP();
// the SD card is busy with another subsystem.
bool USB_MSC_ENABLE();
-// Leave USB mass-storage mode. Reboots the device (NVIC_SystemReset) so
-// the host drive drops and the firmware remounts a fresh filesystem.
+// Leave USB mass-storage mode. Detaches USB (stopping all further SCSI
+// traffic), drains any block callback still executing on the USBD task
+// (WDT-fed), syncs the card, then reboots (NVIC_SystemReset) so the host
+// drive drops and the firmware remounts a fresh filesystem.
void USB_MSC_DISABLE();
diff --git a/BirdsEye/usb_msc.ino b/BirdsEye/usb_msc.ino
index 86bd9eb..636c4c5 100644
--- a/BirdsEye/usb_msc.ino
+++ b/BirdsEye/usb_msc.ino
@@ -11,11 +11,16 @@ static Adafruit_USBD_MSC usb_msc;
bool usbMscActive = false;
-// millis() of the last host WRITE10 data phase serviced on the USBD task.
-// USB_MSC_DISABLE() waits for this to go quiet before it syncs + resets, so
-// a reset can't cut an in-flight writeSectors() and truncate a file / leave
-// the FAT inconsistent.
-static volatile uint32_t mscLastWriteMs = 0;
+// Block-callback activity tracking for the exit drain in USB_MSC_DISABLE().
+// mscIoInFlight is true while ANY block callback (read, write, or flush) is
+// executing on the USBD task; mscLastIoMs is stamped when one finishes. The
+// exit must wait for BOTH: a callback mid-writeSectors() can stall 100 ms–2 s
+// on SD garbage collection, so a time-window check alone (the old
+// mscLastWriteMs, stamped at write ENTRY and ignoring reads entirely) declared
+// the bus quiet while a callback was still on it — and the main loop's
+// syncDevice() + reset then raced the USBD task inside SdFat.
+static volatile bool mscIoInFlight = false;
+static volatile uint32_t mscLastIoMs = 0;
// --- TinyUSB block callbacks -------------------------------------------
// These run from the USBD task, NOT the main loop. The SD mutex
@@ -32,25 +37,34 @@ static volatile uint32_t mscLastWriteMs = 0;
// 3x like the rest of the SD code (ignition EMI can glitch a single op).
int32_t msc_read_cb(uint32_t lba, void* buffer, uint32_t bufsize) {
if (bufsize == 0 || (bufsize % 512) != 0) return -1;
+ mscIoInFlight = true;
+ int32_t result = -1;
uint32_t sectors = bufsize / 512;
for (uint8_t attempt = 0; attempt < 3; attempt++) {
if (SD.card()->readSectors(lba, (uint8_t*) buffer, sectors)) {
- return (int32_t) bufsize;
+ result = (int32_t) bufsize;
+ break;
}
}
- return -1;
+ mscLastIoMs = millis();
+ mscIoInFlight = false;
+ return result;
}
int32_t msc_write_cb(uint32_t lba, uint8_t* buffer, uint32_t bufsize) {
if (bufsize == 0 || (bufsize % 512) != 0) return -1;
- mscLastWriteMs = millis(); // mark the write window for the exit quiesce
+ mscIoInFlight = true;
+ int32_t result = -1;
uint32_t sectors = bufsize / 512;
for (uint8_t attempt = 0; attempt < 3; attempt++) {
if (SD.card()->writeSectors(lba, buffer, sectors)) {
- return (int32_t) bufsize;
+ result = (int32_t) bufsize;
+ break;
}
}
- return -1;
+ mscLastIoMs = millis();
+ mscIoInFlight = false;
+ return result;
}
// Host signalled it is done writing — flush the card and drop SdFat's
@@ -58,8 +72,11 @@ int32_t msc_write_cb(uint32_t lba, uint8_t* buffer, uint32_t bufsize) {
// mode the firmware doesn't touch the filesystem anyway; this is belt
// and suspenders, and matches the canonical Adafruit msc_sdfat example.)
void msc_flush_cb(void) {
+ mscIoInFlight = true;
SD.card()->syncDevice();
SD.cacheClear();
+ mscLastIoMs = millis();
+ mscIoInFlight = false;
}
// --- Public API --------------------------------------------------------
@@ -139,22 +156,31 @@ void USB_MSC_DISABLE() {
// files the host added/removed are picked up. Mirrors the BLE
// auto-reboot on disconnect.
debugln(F("USB MSC: exiting — rebooting to remount filesystem"));
- // Drop media-ready so the host sees the drive go away, and flush any
- // buffered writes to the card before we reset — the reboot is otherwise
- // a hard cut that would lose a not-yet-synced sector and risk leaving the
- // FAT inconsistent if the host hadn't already ejected.
+ // Drop media-ready so a still-attached host stops queueing new work, then
+ // cut the USB connection entirely. setUnitReady(false) alone only refuses
+ // NEW SCSI commands — an already-dispatched READ10/WRITE10 keeps calling
+ // the block callbacks on the USBD task, and the host keeps issuing more.
+ // Detaching is what actually stops the traffic, so the drain below only
+ // has to outlast the ONE callback that may still be executing. (Harmless
+ // on the cable-pulled path — the bus is already dead.)
usb_msc.setUnitReady(false);
+ TinyUSBDevice.detach();
- // Quiesce before we sync + reset. setUnitReady(false) only stops NEW SCSI
- // commands; a WRITE10 already dispatched keeps calling msc_write_cb on the
- // USBD task. Wait until no write has landed for a short window so the reset
- // can't cut an in-flight writeSectors() (truncated file / inconsistent
- // FAT). Bounded so a wedged host can't hang the exit.
+ // Drain before we sync + reset: wait until no block callback is executing
+ // AND none has finished for a short quiet window. Reads count too — a
+ // concurrent readSectors() wedges the shared SPI bus exactly like a write.
+ // Without this the main-loop syncDevice() raced a callback still inside
+ // SdFat, the exit hung on the wedged bus, and the ~4 s watchdog reset the
+ // device instead of the clean reboot (the "crash on USB exit" field bug).
+ // Bounded generously — SD garbage collection can stall a single
+ // writeSectors() for 100 ms–2 s — and WDT-fed so the wait itself can
+ // never trip the watchdog.
const uint32_t quietMs = 100;
- const uint32_t maxWaitMs = 1000;
+ const uint32_t maxWaitMs = 4000;
const uint32_t waitStart = millis();
while (millis() - waitStart < maxWaitMs) {
- if (millis() - mscLastWriteMs >= quietMs) break; // writes have gone quiet
+ wdtPet();
+ if (!mscIoInFlight && (millis() - mscLastIoMs >= quietMs)) break;
delay(5);
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c677ac..2116589 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,41 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
- **MINOR** — new features or device behavior that is backwards compatible.
- **PATCH** — bug fixes and internal changes with no user-visible behavior change.
+## [Unreleased]
+
+Slated to release as **4.0.1** (patch — bug fixes only) unless the scope
+changes before the cut.
+
+### Fixed
+- **Exiting USB transfer mode no longer risks a hang + watchdog reset.**
+ Leaving the USB drive page (Exit button or cable pull) could wedge the
+ device for ~4 seconds and come back via the watchdog instead of the clean
+ reboot: the exit path stopped accepting *new* host commands but a
+ read/write already in flight kept driving the SD card from the USB task
+ while the exit synced the card from the main loop — two tasks on the SPI
+ bus at once. The exit now detaches USB first (so host traffic actually
+ stops), waits out any callback still running — reads included, which were
+ never tracked before — and only then syncs and reboots. The wait is
+ watchdog-fed and sized to survive the SD card's own garbage-collection
+ stalls.
+- **Exiting Bluetooth transfer mode with the button now reboots the device.**
+ Only a phone disconnect triggered the auto-reboot; pressing Exit on the
+ device dropped back to the menu with any settings changed over Bluetooth
+ not yet applied (they only take effect on boot). Both ways out of transfer
+ mode — manual exit and peer disconnect — now reboot, matching how USB
+ transfer mode has always exited.
+- **Browser-sim harness plays 4.0.0 logs again.** The wasm test harness
+ filtered DOVEX rows to exactly 13 columns, so logs from 4.0.0 firmware
+ (16 columns after the `Temp1`/`Junction1`/`Temp2` additions) injected
+ nothing. It now accepts 13+ and reads the stable first 13.
+
+### Added
+- **The browser-sim harness can fake a GPS fix.** A "GPS fix" toggle (plus
+ an mph field) streams a deterministic synthetic 25 Hz fix parked on the
+ bundled OKC track's start line, so fix-gated flows — most usefully the
+ on-device course creator, including its 3 s point-averaging hold — can be
+ exercised in the simulator without loading a log file.
+
## [4.0.0] - 2026-08-10
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index 63abe76..d47398a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -86,7 +86,7 @@ All sketch sources live in `BirdsEye/` so the folder name matches the
| `gps_config.h` | GPS configuration constants (baud rate, nav rate, serial port) |
| `images.h` | PROGMEM bitmap data — the bird splash only; the crossing animation is generated (`crossing_pattern`) |
| `accelerometer.{h,ino}` | LSM6DS3 IMU init and g-force reads (onboard XIAO Sense) |
-| `bluetooth.{h,ino}` | BLE service (file listing, transfer, settings, track sync), auto-reboot on disconnect; shared peripheral BLE core init (+ Just-Works bonding) + `bleOwner` radio-ownership routing |
+| `bluetooth.{h,ino}` | BLE service (file listing, transfer, settings, track sync), reboot on leaving transfer mode (peer disconnect or manual Exit); shared peripheral BLE core init (+ Just-Works bonding) + `bleOwner` radio-ownership routing |
| `camera_ble.{h,ino}` | Insta360 X4 auto-record BLE glue: peripheral remote GATT (0xCE80), all control via ce82 button notifies, executes `camera_fsm` actions, deferred callback→loop pattern (see subsystem 13) |
| `firmware_ota.{h,ino}` | SD-staged firmware OTA: `FW*` BLE protocol, SD staging, CRC verify, self-flash apply (see subsystem 11) |
| `display_pages.{h,ino}` | All page rendering functions (`displayPage_*()`) |
@@ -157,7 +157,7 @@ handoff spec.
| `API.md` | Canonical WASM API contract (v1): artifact set, method surface, injectPvt schema, deltas from the handoff-spec draft (async `reset()` via module re-instantiation) |
| `wasm/bindings.cpp` | EMSCRIPTEN_KEEPALIVE exports over sim_host.h + getStateJson/getVersion/readFile/listFiles |
| `wasm/birdseye-sim.mjs` | Hand-written public ESM wrapper (stable import; async `reset()` re-instantiates the core module) |
-| `wasm/test.html` | Standalone browser harness: canvas blit (hash dirty-check), buttons, dovex file playback |
+| `wasm/test.html` | Standalone browser harness: canvas blit (hash dirty-check), buttons, dovex file playback (≥13 columns — 4.0.0 logs carry 16), synthetic GPS-fix toggle + mph field (parked on the OKC asset track, 40 ms inject/step interleave) so fix-gated flows like the course creator are reachable |
| `wasm/smoke.mjs` | Node smoke test the wasm CI job runs (boot→menu, state/version/VFS, determinism across instances, reset) |
| `CMakeLists.txt` | Native build; FetchContent pins: DovesLapTimer `BETA` (matches CI channel), SparkFun GNSS v3.1.9 (header-only use), ArduinoJson v6.21.5, ArxTypeTraits v0.3.2, Adafruit GFX 1.12.6 + SH110X 2.1.14 (real display stack) |
@@ -574,11 +574,20 @@ loop() ~250 Hz
raw image chunks to `fwReceiveChunk()` while `fwReceiving()`. The request
characteristic max length was raised from 64 to **244** so ~240-byte image
chunks fit. `BLUETOOTH_LOOP()` calls `FW_OTA_LOOP()` each iteration.
-- **Auto-reboot on BLE disconnect**: `bleDisconnectCallback()` flags a
- deferred teardown that `BLUETOOTH_LOOP()` runs on the main loop —
- `NVIC_SystemReset()` after a 100 ms delay so new settings take effect
- without a manual power cycle, plus `fwReset()` to abort any in-flight OTA
- and free the staging file + SD access. **Exception — OTA apply**: if an
+- **Leaving transfer mode always reboots** — both ways out:
+ - *Peer disconnect*: `bleDisconnectCallback()` flags a deferred teardown
+ that `BLUETOOTH_LOOP()` runs on the main loop — `NVIC_SystemReset()`
+ after a 100 ms delay so new settings take effect without a manual power
+ cycle, plus `fwReset()` to abort any in-flight OTA and free the staging
+ file + SD access.
+ - *Manual Exit* (`bleExitTransferMode()`): the parked-loop Exit button
+ runs `BLE_STOP()` then the same 100 ms-delay reboot. Before 4.0.1 a
+ manual exit dropped back to the menu without rebooting, so settings
+ written over BLE silently didn't apply until the next power cycle. The
+ SIM stub returns after stopping (no reboot) so the golden menu walk can
+ exit the Bluetooth page; `display_ui.ino`'s `PAGE_BLUETOOTH` handler is
+ that sim-only path (on hardware the `bleActive` parked branch owns the
+ button). **Exception — OTA apply**: if an
apply has been requested (`fwApplyRequested()`), the teardown skips *both*
the abort and the reboot. After `FWAPPLY` the web app disconnects on purpose
to let the device self-flash; rebooting here would discard the staged image
@@ -861,13 +870,18 @@ hardware needs no power switch. Wake = chip reset = fresh `setup()`.
page, and watches VBUS: if the cable is unplugged it calls
`USB_MSC_DISABLE()` so the SD lock and fast SPI clock can't leak past the
session.
-- **Exit = reboot**: `USB_MSC_DISABLE()` drops media-ready, **quiesces** (waits
- up to 1 s for the write block callback to go quiet — `setUnitReady(false)`
- only blocks *new* SCSI commands, so an in-flight `WRITE10` keeps calling
- `msc_write_cb` on the USBD task, and resetting mid-write would truncate a
- file / corrupt the FAT), `syncDevice()`s
- the card, then calls `NVIC_SystemReset()` (mirrors the BLE auto-reboot on
- disconnect). The reboot drops the MSC interface and remounts a clean
+- **Exit = reboot**: `USB_MSC_DISABLE()` drops media-ready, **detaches USB**
+ (`TinyUSBDevice.detach()` — `setUnitReady(false)` only blocks *new* SCSI
+ commands, so without the detach the host keeps issuing traffic and an
+ in-flight `READ10`/`WRITE10` keeps calling the block callbacks on the USBD
+ task), then **drains**: waits (WDT-fed, up to 4 s — SD garbage collection
+ can stall one `writeSectors()` 100 ms–2 s) until no block callback is
+ executing and none has finished for 100 ms, tracked by
+ `mscIoInFlight`/`mscLastIoMs` around all three callbacks — reads and flush
+ included. Only then `syncDevice()` + `NVIC_SystemReset()` (mirrors the BLE
+ transfer-mode exit reboot). The 4.0.0 exit tracked only write *entry*
+ times, so a callback still on the SPI bus raced the main-loop sync and the
+ wedge came back via the watchdog instead of the clean reset. The reboot drops the MSC interface and remounts a clean
filesystem, so host edits are picked up without any SdFat cache-coherency
dance. Triggered by the on-device Exit button or a cable unplug.
- **Mutex**: the whole session holds `SD_ACCESS_USB_MSC`, so logging,