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,