From 3f7ec0c9bd2ef24fb86c39f490b7386664eadc76 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 23:10:09 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20transfer-mode=20exits=20=E2=80=94=20?= =?UTF-8?q?drain=20USB=20block=20I/O=20before=20reset,=20reboot=20on=20man?= =?UTF-8?q?ual=20BLE=20exit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two 4.0.0 transfer-mode exit bugs, slated for 4.0.1: USB: USB_MSC_DISABLE() raced the USBD task. setUnitReady(false) only refuses NEW SCSI commands, and the exit quiesce tracked write-callback ENTRY times only — so an in-flight READ10/WRITE10 (reads were never tracked at all, and a writeSectors() can stall 100 ms–2 s on SD garbage collection) was still driving SdFat on the USBD task while the main loop ran syncDevice() on the same SPI bus. The wedge came back via the ~4 s watchdog instead of the clean reset. Now the exit detaches USB first (so host traffic actually stops), tracks in-flight state + completion time around all three block callbacks, and drains WDT-fed for up to 4 s before syncing and resetting. BLE: only a phone disconnect triggered the transfer auto-reboot; the on-device Exit button just BLE_STOP()'d back to the menu, so settings written over BLE silently didn't apply until the next power cycle. New bleExitTransferMode() (BLE_STOP + the same 100 ms-delay reset) makes both ways out of transfer mode reboot, matching USB. The SIM stub returns after stopping so the golden menu walk still exits the Bluetooth page. Both fixes are TinyUSB/Bluefruit-bound (no host-testable pure logic); goldens, boot soak, and lap oracles verified unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014rqEnPQUqhMj98sgkefJkz --- BirdsEye/BirdsEye.ino | 7 +-- BirdsEye/bluetooth.h | 7 +++ BirdsEye/bluetooth.ino | 17 ++++++++ BirdsEye/display_ui.ino | 10 ++++- BirdsEye/sim/stubs/module_stubs.cpp | 7 +++ BirdsEye/usb_msc.h | 6 ++- BirdsEye/usb_msc.ino | 68 ++++++++++++++++++++--------- CHANGELOG.md | 24 ++++++++++ CLAUDE.md | 40 +++++++++++------ 9 files changed, 145 insertions(+), 41 deletions(-) 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/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..7bcae5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,30 @@ 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. + ## [4.0.0] - 2026-08-10 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 63abe76..33dcfb5 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_*()`) | @@ -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, From baab46a690429115c26ee520c32abee7c32a188f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 23:23:19 +0000 Subject: [PATCH 2/2] sim: fix wasm harness for 16-column 4.0.0 logs + synthetic GPS-fix toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser test harness (wasm/test.html) had fallen behind the firmware: - dovex playback filtered rows to exactly 13 columns, so 4.0.0 logs (16 columns after Temp1/Junction1/Temp2) injected nothing. Accept >=13 and read the stable first 13. - Nothing could satisfy the course creator's fix + time-lock entry gate interactively. New "GPS fix" toggle + mph field: a deterministic synthetic 25 Hz fix parked on the bundled OKC track's start line, injected one PVT per <=40 ms step slice (a burst before one big step collapses into a single fix and starves the creator's >=8-fix averaging hold). Loading a dovex unchecks the toggle — playback owns the GPS feed. Verified against a fresh emsdk 3.1.61 wasm build (DovesLapTimer BETA, matching this PR's CI): node smoke passes, and a scripted walk using the harness's exact injection pattern reaches the creator through the OKC track prompt and commits a real 3 s point-averaging hold. Also ignore build-wasm/ (the emcmake build dir CI uses). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014rqEnPQUqhMj98sgkefJkz --- .gitignore | 2 ++ BirdsEye/sim/wasm/test.html | 54 ++++++++++++++++++++++++++++++++++--- CHANGELOG.md | 11 ++++++++ CLAUDE.md | 2 +- 4 files changed, 64 insertions(+), 5 deletions(-) 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/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/CHANGELOG.md b/CHANGELOG.md index 7bcae5d..2116589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,17 @@ changes before the cut. 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 diff --git a/CLAUDE.md b/CLAUDE.md index 33dcfb5..d47398a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) |