Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
7 changes: 4 additions & 3 deletions BirdsEye/BirdsEye.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
7 changes: 7 additions & 0 deletions BirdsEye/bluetooth.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
17 changes: 17 additions & 0 deletions BirdsEye/bluetooth.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions BirdsEye/display_ui.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions BirdsEye/sim/stubs/module_stubs.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}

Expand Down
54 changes: 50 additions & 4 deletions BirdsEye/sim/wasm/test.html
Original file line number Diff line number Diff line change
Expand Up @@ -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).
-->
<html lang="en">
<head>
Expand Down Expand Up @@ -36,6 +43,9 @@ <h3>BirdsEye simulator — Phase 4 harness</h3>
<option value="1">1x</option><option value="2">2x</option>
<option value="5">5x</option><option value="10" selected>10x</option>
</select></label>
<label><input type="checkbox" id="fix"> GPS fix</label>
<label>mph <input type="number" id="mph" value="0" min="0" max="120"
style="width:4em"></label>
<input type="file" id="dovex" accept=".dovex">
</div>
<div id="state">loading…</div>
Expand Down Expand Up @@ -86,22 +96,44 @@ <h3>BirdsEye simulator — Phase 4 harness</h3>
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];
if (!file) return;
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);
Expand All @@ -122,9 +154,23 @@ <h3>BirdsEye simulator — Phase 4 harness</h3>
}));
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();
Expand Down
6 changes: 4 additions & 2 deletions BirdsEye/usb_msc.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();
68 changes: 47 additions & 21 deletions BirdsEye/usb_msc.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,34 +37,46 @@ 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
// cache so any later firmware FS read sees the host's changes. (In USB
// 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 --------------------------------------------------------
Expand Down Expand Up @@ -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);
}

Expand Down
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading