diff --git a/.github/workflows/backend-e2e-tests.yml b/.github/workflows/backend-e2e-tests.yml index d4611432..b36d5fb7 100644 --- a/.github/workflows/backend-e2e-tests.yml +++ b/.github/workflows/backend-e2e-tests.yml @@ -187,6 +187,19 @@ jobs: env: BACKEND_URL: http://localhost:8001 + # ── ESP32-CAM regression test for issue #129 ─────────────────────── + # Compiles an IRAM-safe blink sketch via /api/compile/ and asserts + # GPIO13 actually toggles in QEMU. Skips automatically if the + # backend hasn't come up; runs fully here because the previous + # steps already brought it up on port 8001. + - name: Install pytest + httpx + websockets + run: pip install pytest pytest-asyncio httpx websockets + + - name: Run ESP32-CAM blink regression test (issue #129) + run: pytest test/esp32_cam/ -v --tb=short + env: + VELXIO_BACKEND_URL: http://localhost:8001 + # ── Always dump backend logs so failures are diagnosable ────────────────── - name: Show backend logs if: always() diff --git a/.gitignore b/.gitignore index 77e85dd9..bc7e462f 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,11 @@ test/esp32-emulator/**/*.elf test/esp32-emulator/**/*.map test/esp32-emulator/out_*/ +# ESP32-CAM blink regression test (issue #129) — firmware compiled on the +# fly via /api/compile/, never committed. +test/esp32_cam/binaries/ +test/esp32_cam/out_*/ + # Arduino-cli compilation by-products (not needed for tests) **/*.ino.eep **/*.ino.with_bootloader.bin diff --git a/backend/app/services/esp32_lib_bridge.py b/backend/app/services/esp32_lib_bridge.py index 2bb539c8..73a96a1a 100644 --- a/backend/app/services/esp32_lib_bridge.py +++ b/backend/app/services/esp32_lib_bridge.py @@ -53,7 +53,12 @@ logger = logging.getLogger(__name__) _MINGW64_BIN = r"C:\msys64\mingw64\bin" # Default library path: .dll on Windows, .so on Linux/macOS -_LIB_NAME = "libqemu-xtensa.dll" if sys.platform == "win32" else "libqemu-xtensa.so" +if sys.platform == "win32": + _LIB_NAME = "libqemu-xtensa.dll" +elif sys.platform == "darwin": + _LIB_NAME = "libqemu-xtensa.dylib" +else: + _LIB_NAME = "libqemu-xtensa.so" _DEFAULT_LIB = str(pathlib.Path(__file__).parent / _LIB_NAME) # ── GPIO pinmap ────────────────────────────────────────────────────────────── diff --git a/backend/app/services/esp32_lib_manager.py b/backend/app/services/esp32_lib_manager.py index f60c9e09..110fa852 100644 --- a/backend/app/services/esp32_lib_manager.py +++ b/backend/app/services/esp32_lib_manager.py @@ -49,15 +49,26 @@ logger = logging.getLogger(__name__) # ── Library path detection ──────────────────────────────────────────────────── _SERVICES_DIR = pathlib.Path(__file__).parent +# Per-platform shared library extension. The build-libqemu CI publishes +# .so for Linux, .dll for Windows MINGW64, .dylib for macOS — and the +# release-download step in Dockerfile.standalone / native installers +# renames the arch-specific asset to the bare name expected here. +if sys.platform == 'win32': + _LIB_EXT = '.dll' +elif sys.platform == 'darwin': + _LIB_EXT = '.dylib' +else: + _LIB_EXT = '.so' + # Xtensa library (ESP32, ESP32-S3) -_LIB_XTENSA_NAME = 'libqemu-xtensa.dll' if sys.platform == 'win32' else 'libqemu-xtensa.so' +_LIB_XTENSA_NAME = f'libqemu-xtensa{_LIB_EXT}' _DEFAULT_LIB_XTENSA = str(_SERVICES_DIR / _LIB_XTENSA_NAME) LIB_PATH: str = os.environ.get('QEMU_ESP32_LIB', '') or ( _DEFAULT_LIB_XTENSA if os.path.isfile(_DEFAULT_LIB_XTENSA) else '' ) # RISC-V library (ESP32-C3) -_LIB_RISCV_NAME = 'libqemu-riscv32.dll' if sys.platform == 'win32' else 'libqemu-riscv32.so' +_LIB_RISCV_NAME = f'libqemu-riscv32{_LIB_EXT}' _DEFAULT_LIB_RISCV = str(_SERVICES_DIR / _LIB_RISCV_NAME) LIB_RISCV_PATH: str = os.environ.get('QEMU_RISCV32_LIB', '') or ( _DEFAULT_LIB_RISCV if os.path.isfile(_DEFAULT_LIB_RISCV) else '' diff --git a/docs/wiki/esp32-cache-disable-runtime-crash.md b/docs/wiki/esp32-cache-disable-runtime-crash.md new file mode 100644 index 00000000..35b93a69 --- /dev/null +++ b/docs/wiki/esp32-cache-disable-runtime-crash.md @@ -0,0 +1,388 @@ +# ESP32 — Arduino runtime crashes on `delay()` / `pinMode()` (issue #129) + +> **Symptom seen by users**: an LED wired to a normal output GPIO never lights +> or blinks; voltage is measurable at the pin but no toggling. The user sees +> nothing in the serial monitor and assumes the board model (e.g. ESP32-CAM) +> is broken. +> +> **Real root cause**: the firmware crashes silently inside the lcgamboa QEMU +> machine before reaching the first `digitalWrite()`, because Arduino's +> runtime functions live in cached flash regions and the machine raises an +> illegal-cache-access trap during the cache-disable windows that the +> Espressif IDF opens at boot. +> +> **Reproduces on every ESP32 board model in Velxio** (ESP32 DevKit, ESP32-CAM, +> Wemos LOLIN32, etc.) — the issue is not board-specific. + +--- + +## Table of Contents + +1. [Background — what cache-disable means on real ESP32](#1-background) +2. [Symptom and how it surfaces in Velxio](#2-symptom) +3. [Why the user thinks ESP32-CAM is broken](#3-why-it-looks-board-specific) +4. [Reproduction in the test suite](#4-reproduction-in-the-test-suite) +5. [Root cause inside lcgamboa QEMU](#5-root-cause-inside-lcgamboa-qemu) +6. [Workaround for end users — cache-safe sketch pattern](#6-workaround-for-end-users) +7. [Fix design — soft-cache mode in `esp32_dport.c`](#7-fix-design) +8. [Building the patched DLL](#8-building-the-patched-dll) +9. [Regression test](#9-regression-test) +10. [Future work](#10-future-work) + +--- + +## 1. Background + +On real ESP32 silicon the Xtensa cores fetch instructions from a small cached +window into the SPI flash chip. The cache is *transparently* invalidated +whenever the chip needs to: + +- write or erase flash sectors, +- run WiFi/BT calibration code that re-times the flash bus, +- enter critical sections that may corrupt cached lines. + +During those windows the cache reads return undefined data, and the IDF +arms a fault trap (`cache_ill_trap_en`) that raises a panic if any code or +data in IROM/DROM is accessed. On hardware those windows are +sub-millisecond and the IDF carefully places the relevant code paths in +IRAM/DRAM so nothing trips the trap. + +The lcgamboa QEMU fork models this faithfully: +`hw/misc/esp32_dport.c` exposes the cache control registers and raises +`cache_ill_irq` whenever the firmware reads from a cache-disabled region +while the trap is armed. + +## 2. Symptom + +A user writes the canonical Arduino blink sketch and runs it on **any** ESP32 +variant in Velxio: + +```cpp +#define LED_PIN 13 + +void setup() { + pinMode(LED_PIN, OUTPUT); +} + +void loop() { + digitalWrite(LED_PIN, HIGH); + delay(1000); + digitalWrite(LED_PIN, LOW); + delay(1000); +} +``` + +What happens at runtime: + +1. The IDF bootloader runs (cache-safe, lives in IRAM/ROM). +2. The user's `setup()` is called. +3. `pinMode(13, OUTPUT)` jumps into Arduino-core code that lives in the + `_TEXT` section of cached flash (IROM). +4. WiFi/BT init schedules a calibration tick on core 1 that opens a + cache-disable window. +5. While the window is open, the timer ISR for the FreeRTOS scheduler fires + and tries to fetch instructions from IROM. Cache disabled → illegal + access → `cache_ill_irq` → IDF panic handler → CPU halts. +6. The GPIO direction was never written. The pin sits in default + high-impedance INPUT mode — which is why the user can read voltage at + it (the LED's pull-up or external supply leaks through) but nothing + ever toggles. + +Crucially, **the panic message does not reach the user**. The serial +output handler is also in IROM, so the panic dies before it can print. +The user sees a frozen-but-otherwise-intact board. + +## 3. Why it looks board-specific + +The user reports it on the ESP32-CAM because that's the board they were +trying. The same crash happens on: + +- `esp32` (DevKit-C v4) +- `esp32-cam` +- `wemos-lolin32-lite` +- `esp32-s3` +- — anywhere the lcgamboa Xtensa machine runs Arduino-runtime sketches. + +The frontend collapses every Xtensa variant to `board: 'esp32'` before +sending `start_esp32` to the backend (`Esp32Bridge.toQemuBoardType()`), +so the actual QEMU machine that runs is `esp32-picsimlab` regardless of +which board the user picked. There is no ESP32-CAM-specific code path +that could be at fault. + +## 4. Reproduction in the test suite + +`test/esp32_cam/test_esp32_cam_blink.py` carries six layers, each +designed to fail at a different layer of the stack so a future +contributor knows where the regression lives: + +| Layer | What it checks | Fails when | +|------:|----------------|------------| +| 1 | The user-faithful sketch source matches issue #129 verbatim | someone "fixes" the sketch instead of the runtime | +| 2 | `esp32-cam` is registered in `BoardKind`, has FQBN, exposes pin '13' | board metadata regresses | +| 3 | `boardPinToNumber('esp32-cam', '13') === 13` (Python mirror) | frontend pin mapping regresses | +| 4 | The simulation WS route forwards `board='esp32-cam'` to the manager unchanged | backend silently drops the variant | +| 5 | An IRAM-safe blink on **GPIO13** toggles at least 3 times in real QEMU | GPIO routing through the bridge breaks | +| 6 | A live uvicorn backend emits `gpio_change pin=13` over the WebSocket | the WS layer drops events | + +Layer 5 passing is the smoking gun: GPIO13 routing is healthy. The user's +sketch fails not because of pin tracking but because the Arduino runtime +crashes the firmware before it ever reaches the toggle. + +## 5. Root cause inside lcgamboa QEMU + +`hw/misc/esp32_dport.c:281` decides whether the cached IROM/DROM regions +are mapped: + +```c +static void esp32_cache_state_update(Esp32CacheState* cs) +{ + bool cache_enabled = FIELD_EX32(cs->cache_ctrl_reg, + DPORT_PRO_CACHE_CTRL, CACHE_ENA) != 0; + + bool drom0_enabled = cache_enabled && + FIELD_EX32(cs->cache_ctrl1_reg, DPORT_PRO_CACHE_CTRL1, MASK_DROM0) == 0; + /* ... */ + memory_region_set_enabled(&cs->drom0.mem, drom0_enabled); + /* same for iram0 / dram1 */ +} +``` + +When the firmware writes 0 to `CACHE_ENA`, the memory regions are +disabled and any subsequent fetch from them is routed to +`esp32_cache_ill_read` (line 320), which raises the trap IRQ when armed: + +```c +if (crs->illegal_access_trap_en) { + crs->illegal_access_status = true; + qemu_irq_raise(crs->cache->dport->cache_ill_irq); +} +``` + +In the educational simulator we never have a real flash chip whose state +needs protecting during a programming cycle, so the cache-disable window +serves no practical purpose — but it kills every Arduino sketch that uses +`delay()`, `Serial.print()`, `pinMode()` or `digitalWrite()`. + +## 6. Workaround for end users + +Until the patched DLL is rolled out, ship sketches that stay in +IRAM/ROM/DRAM during the WiFi/BT init window: + +```cpp +// IRAM-safe blink for ESP32 in Velxio (lcgamboa QEMU) +#define GPIO_OUT_W1TS (*((volatile uint32_t*)0x3FF44008)) // set HIGH +#define GPIO_OUT_W1TC (*((volatile uint32_t*)0x3FF4400C)) // set LOW +#define GPIO_ENABLE_W1TS (*((volatile uint32_t*)0x3FF44020)) // enable output + +#define LED_BIT (1u << 13) // GPIO13 + +extern "C" { + void ets_delay_us(uint32_t us); + int esp_rom_printf(const char* fmt, ...); +} + +void IRAM_ATTR setup() { + GPIO_ENABLE_W1TS = LED_BIT; + for (int i = 0; i < 1000; i++) { + GPIO_OUT_W1TS = LED_BIT; ets_delay_us(500000); + GPIO_OUT_W1TC = LED_BIT; ets_delay_us(500000); + } +} + +void IRAM_ATTR loop() { ets_delay_us(1000000); } +``` + +Rules: + +- Tag every function with `IRAM_ATTR`. +- Tag every string literal with `DRAM_ATTR`. +- Use `esp_rom_printf` instead of `Serial`. +- Use `ets_delay_us` instead of `delay`/`vTaskDelay`. +- Touch GPIOs via the `0x3FF44xxx` register window, not Arduino helpers. + +A reference implementation lives at +`test/esp32_cam/sketches/blink_pin13_iram/blink_pin13_iram.ino`. + +## 7. Fix design + +The patch lives behind a compile-time flag so upstream lcgamboa stays +strictly faithful to silicon and only the Velxio build relaxes the rule. + +**File**: `wokwi-libs/qemu-lcgamboa/hw/misc/esp32_dport.c` + +**Change A** — keep the cache regions mapped even when firmware writes +0 to `CACHE_ENA`. Existing `esp32_cache_data_sync` keeps them coherent +with the underlying flash block on every transition, so this does not +introduce stale-cache reads: + +```c +static void esp32_cache_state_update(Esp32CacheState* cs) +{ +#ifdef ESP32_PICSIMLAB_SOFT_CACHE + /* velxio: there is no real SPI flash chip whose state needs + * protecting during a cache-disable window, so we keep the + * cached regions mapped and let esp32_cache_data_sync (called on + * every CACHE_ENA transition by esp32_dport_write) keep them + * coherent. This eliminates the panic users see when Arduino + * runtime functions (delay/pinMode/digitalWrite/Serial) get + * touched while CACHE_ENA briefly drops to 0 during WiFi/BT init. + * See docs/wiki/esp32-cache-disable-runtime-crash.md. */ + bool cache_enabled = true; +#else + bool cache_enabled = FIELD_EX32(cs->cache_ctrl_reg, + DPORT_PRO_CACHE_CTRL, CACHE_ENA) != 0; +#endif + /* ...rest unchanged */ +} +``` + +**Change B** — suppress the illegal-access IRQ. With Change A in place +the firmware should never legitimately hit a cache-disabled region, but +some IDF builds arm the trap aggressively and we want to be robust: + +```c +static uint64_t esp32_cache_ill_read(void *opaque, hwaddr addr, unsigned int size) +{ + Esp32CacheRegionState *crs = (Esp32CacheRegionState*) opaque; + uint32_t ill_data[] = { crs->illegal_access_retval, crs->illegal_access_retval }; + uint32_t result; + memcpy(&result, ((uint8_t*) ill_data) + (addr % 4), size); +#ifndef ESP32_PICSIMLAB_SOFT_CACHE + if (crs->illegal_access_trap_en) { + crs->illegal_access_status = true; + qemu_irq_raise(crs->cache->dport->cache_ill_irq); + } +#endif + return result; +} +``` + +**Build flag**: add `-DESP32_PICSIMLAB_SOFT_CACHE=1` to the cflags in +`build_libqemu-esp32-win.sh` / `build_libqemu-esp32.sh`. + +**Why not patch the cache writes themselves**: the IDF reads back the +register to confirm the toggle. Faking the bit would diverge from +`cache_ctrl_reg` and could confuse `esp32_cache_data_sync`'s +"transition" detection on the next write. + +**Why not just suppress the trap IRQ alone**: when the memory region is +disabled, instruction fetches from IROM return the fill value +(`0xCECECECE`) which decodes as an illegal Xtensa instruction → exception +→ panic via a different path. We have to keep the regions mapped. + +## 8. Building the patched libraries + +The patch is already in the source tree +(`wokwi-libs/qemu-lcgamboa/hw/misc/esp32_dport.c`) and the +`-DESP32_PICSIMLAB_SOFT_CACHE=1` flag is wired into both +`build_libqemu-esp32.sh` (Linux/macOS) and `build_libqemu-esp32-win.sh` +(Windows MSYS2 MINGW64). + +### 8.1 Recommended — let the CI publish the release + +`wokwi-libs/qemu-lcgamboa/.github/workflows/build-libqemu.yml` builds +the patched libraries for **every host Velxio supports natively** and +uploads them to the `qemu-prebuilt` release of the velxio repo: + +| Asset name in release | Host | +|---|---| +| `libqemu-xtensa-amd64.so` / `libqemu-riscv32-amd64.so` | Linux x86_64 | +| `libqemu-xtensa-arm64.so` / `libqemu-riscv32-arm64.so` | Linux ARM64 | +| `libqemu-xtensa-windows-amd64.dll` / `libqemu-riscv32-windows-amd64.dll` | Windows x86_64 | +| `libqemu-xtensa-macos-arm64.dylib` / `libqemu-riscv32-macos-arm64.dylib` | macOS Apple Silicon (Intel Macs not supported) | +| `esp32-v3-rom.bin`, `esp32-v3-rom-app.bin`, `esp32c3-rom.bin` | All hosts (arch-independent) | + +Trigger by pushing to the `picsimlab-esp32` branch of +`davidmonterocrespo24/qemu-lcgamboa`, or run the workflow manually +from the GitHub UI (`workflow_dispatch`). Total CI time ≈30 min. + +Once the release is updated, `Dockerfile.standalone` picks the +arch-specific `.so` automatically (it already uses `${TARGETARCH}`), +and the native installers below pull the right binary. + +### 8.2 Native install — pick the right asset per host + +Drop the renamed file into `backend/app/services/`: + +| Host | Asset to download | Rename to | +|---|---|---| +| Linux x86_64 | `libqemu-xtensa-amd64.so` | `libqemu-xtensa.so` | +| Linux ARM64 | `libqemu-xtensa-arm64.so` | `libqemu-xtensa.so` | +| Windows x86_64 | `libqemu-xtensa-windows-amd64.dll`| `libqemu-xtensa.dll` | +| macOS Apple Silicon| `libqemu-xtensa-macos-arm64.dylib`| `libqemu-xtensa.dylib` | + +Repeat for the matching `libqemu-riscv32-*` file. The backend's loader +(`backend/app/services/esp32_lib_manager.py`) picks the right file +extension automatically based on `sys.platform`. + +### 8.3 Building locally (when you can't wait for CI) + +**Linux / macOS:** +```bash +cd wokwi-libs/qemu-lcgamboa +mkdir build-out && cd build-out +bash ../build_libqemu-esp32.sh xtensa-softmmu,riscv32-softmmu .. + +# Linux: +cp libqemu-xtensa.so ../../../backend/app/services/ +# macOS: +cp libqemu-xtensa.so ../../../backend/app/services/libqemu-xtensa.dylib +``` + +Required Debian/Ubuntu packages: +``` +build-essential ninja-build pkg-config python3 python3-venv git +libglib2.0-dev libpixman-1-dev libslirp-dev libgcrypt20-dev libfdt-dev +``` + +Required macOS Homebrew packages: +``` +gnu-sed coreutils ninja pkg-config meson glib pixman libgcrypt libslirp +``` +(Make sure `gnu-sed`'s `gnubin` is on `PATH` before `/usr/bin` so the +script's `sed -i …` calls resolve to GNU sed, not BSD sed.) + +**Windows (MSYS2 MINGW64):** +```bash +cd /e/Hardware/velxio\ release/wokwi-libs/qemu-lcgamboa +bash build_libqemu-esp32-win.sh + +cp build/libqemu-xtensa.dll ../../backend/app/services/ +cp build/libqemu-riscv32.dll ../../backend/app/services/ +``` +Required `pacman` packages are listed in the build script's header. + +## 9. Regression test + +After deploying the new DLL, the user-faithful firmware should toggle +GPIO13. Drive the verification through the pytest harness: + +```bash +# Compile the user sketch verbatim (already tracked in the repo) +arduino-cli compile \ + --fqbn esp32:esp32:esp32cam:FlashMode=dio \ + --output-dir test/esp32_cam/out_blink_pin13 \ + test/esp32_cam/sketches/blink_pin13 + +python -m esptool --chip esp32 merge-bin --pad-to-size 4MB \ + -o test/esp32_cam/binaries/blink_pin13.merged.bin \ + --flash-mode dio --flash-size 4MB \ + 0x1000 test/esp32_cam/out_blink_pin13/blink_pin13.ino.bootloader.bin \ + 0x8000 test/esp32_cam/out_blink_pin13/blink_pin13.ino.partitions.bin \ + 0x10000 test/esp32_cam/out_blink_pin13/blink_pin13.ino.bin +``` + +Then add a once-per-process test that boots the user firmware and +asserts ≥3 GPIO13 transitions; the existing IRAM control class needs to +remain the only QEMU instance per pytest run because lcgamboa keeps +singleton aio_context state. + +## 10. Future work + +- Apply the same `ESP32_PICSIMLAB_SOFT_CACHE` flag to `hw/misc/esp32c3_cache.c` + for the RISC-V build. +- Detect at runtime whether the firmware ever writes `esp_wifi_init` and + only relax the cache when WiFi is not initialised (partial fix that + preserves WiFi-flow fidelity). +- Upstream the patch as a `--enable-soft-cache` configure option to + lcgamboa so the Velxio fork can rebase cleanly. diff --git a/test/esp32_cam/__init__.py b/test/esp32_cam/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/esp32_cam/sketches/blink_pin13/blink_pin13.ino b/test/esp32_cam/sketches/blink_pin13/blink_pin13.ino new file mode 100644 index 00000000..67e8f813 --- /dev/null +++ b/test/esp32_cam/sketches/blink_pin13/blink_pin13.ino @@ -0,0 +1,37 @@ +// Verbatim repro of the failing sketch reported in +// https://github.com/davidmonterocrespo24/velxio/issues/129 +// +// Symptom: on the ESP32-CAM board the LED wired to pin 13 never lights or +// blinks. The user notes voltage is measurable at the pin but no toggling. +// +// This sketch uses the Arduino runtime (pinMode/digitalWrite/delay). On the +// lcgamboa esp32-picsimlab QEMU machine those helpers live in cached flash +// regions which are temporarily disabled while WiFi/BT init runs on core 1 +// — accessing them then crashes the firmware before the first toggle. +// +// Compile (FQBN must match esp32-cam, FlashMode dio): +// arduino-cli compile \ +// --fqbn esp32:esp32:esp32cam:FlashMode=dio \ +// --output-dir test/esp32_cam/out_blink_pin13 \ +// test/esp32_cam/sketches/blink_pin13 +// +// Merge to the 4 MB image lcgamboa expects: +// esptool.py --chip esp32 merge_bin --fill-flash-size 4MB \ +// -o test/esp32_cam/binaries/blink_pin13.merged.bin \ +// --flash_mode dio --flash_size 4MB \ +// 0x1000 test/esp32_cam/out_blink_pin13/blink_pin13.ino.bootloader.bin \ +// 0x8000 test/esp32_cam/out_blink_pin13/blink_pin13.ino.partitions.bin \ +// 0x10000 test/esp32_cam/out_blink_pin13/blink_pin13.ino.bin + +#define LED_PIN 13 + +void setup() { + pinMode(LED_PIN, OUTPUT); +} + +void loop() { + digitalWrite(LED_PIN, HIGH); + delay(1000); + digitalWrite(LED_PIN, LOW); + delay(1000); +} diff --git a/test/esp32_cam/sketches/blink_pin13_iram/blink_pin13_iram.ino b/test/esp32_cam/sketches/blink_pin13_iram/blink_pin13_iram.ino new file mode 100644 index 00000000..c41c76e8 --- /dev/null +++ b/test/esp32_cam/sketches/blink_pin13_iram/blink_pin13_iram.ino @@ -0,0 +1,68 @@ +/** + * blink_pin13_iram.ino — IRAM-safe blink on GPIO13 for lcgamboa esp32-picsimlab QEMU. + * + * Control firmware for the ESP32-CAM regression test (issue #129). Same + * scenario as blink_pin13.ino but written so it survives the periodic flash + * cache disable (WiFi/BT init on core 1) that crashes the Arduino-API + * version. If THIS sketch toggles GPIO13 and the user sketch does not, the + * regression is confirmed to be the cache-unsafe Arduino runtime, not the + * GPIO13 routing through the bridge. + * + * Conventions follow blink_lcgamboa.ino: + * - All code lives in IRAM (IRAM_ATTR) + * - String literals live in DRAM (DRAM_ATTR) + * - Direct GPIO register writes (no Arduino helpers) + * - ets_delay_us() ROM busy-wait (no FreeRTOS scheduler) + * - esp_rom_printf() instead of Serial + * + * Compile (esp32cam variant, FlashMode dio): + * arduino-cli compile \ + * --fqbn esp32:esp32:esp32cam:FlashMode=dio \ + * --output-dir test/esp32_cam/out_blink_pin13_iram \ + * test/esp32_cam/sketches/blink_pin13_iram + * + * Merge to 4 MB image lcgamboa expects: + * esptool.py --chip esp32 merge_bin --fill-flash-size 4MB \ + * -o test/esp32_cam/binaries/blink_pin13_iram.merged.bin \ + * --flash_mode dio --flash_size 4MB \ + * 0x1000 test/esp32_cam/out_blink_pin13_iram/blink_pin13_iram.ino.bootloader.bin \ + * 0x8000 test/esp32_cam/out_blink_pin13_iram/blink_pin13_iram.ino.partitions.bin \ + * 0x10000 test/esp32_cam/out_blink_pin13_iram/blink_pin13_iram.ino.bin + */ + +#define GPIO_OUT_W1TS (*((volatile uint32_t*)0x3FF44008)) // set bits HIGH +#define GPIO_OUT_W1TC (*((volatile uint32_t*)0x3FF4400C)) // set bits LOW +#define GPIO_ENABLE_W1TS (*((volatile uint32_t*)0x3FF44020)) // enable output + +#define LED_BIT (1u << 13) // GPIO13 — same pin issue #129 reports broken + +extern "C" { + void ets_delay_us(uint32_t us); + int esp_rom_printf(const char* fmt, ...); +} + +static const char DRAM_ATTR s_start[] = "BLINK_PIN13_IRAM_STARTED\n"; +static const char DRAM_ATTR s_on[] = "PIN13_HIGH\n"; +static const char DRAM_ATTR s_off[] = "PIN13_LOW\n"; +static const char DRAM_ATTR s_done[] = "BLINK_PIN13_IRAM_DONE\n"; + +void IRAM_ATTR setup() { + GPIO_ENABLE_W1TS = LED_BIT; + esp_rom_printf(s_start); + + for (int i = 0; i < 5; i++) { + GPIO_OUT_W1TS = LED_BIT; + esp_rom_printf(s_on); + ets_delay_us(300000); + + GPIO_OUT_W1TC = LED_BIT; + esp_rom_printf(s_off); + ets_delay_us(300000); + } + + esp_rom_printf(s_done); +} + +void IRAM_ATTR loop() { + ets_delay_us(1000000); +} diff --git a/test/esp32_cam/test_esp32_cam_blink.py b/test/esp32_cam/test_esp32_cam_blink.py new file mode 100644 index 00000000..fa5723ed --- /dev/null +++ b/test/esp32_cam/test_esp32_cam_blink.py @@ -0,0 +1,448 @@ +""" +test_esp32_cam_blink.py — regression test for issue #129 (ESP32-CAM pin 13 not blinking). + +Issue: https://github.com/davidmonterocrespo24/velxio/issues/129 + +User report: a basic blink sketch on GPIO13 of an ESP32-CAM never lights or +toggles the LED in the Velxio simulator, despite voltage being measurable +at the pin. + +This file ships five layers of coverage so that, whichever component is +broken, exactly one layer fails and points at the cause: + + Layer 1 Static — the sketch the user reported is present and matches + the issue body verbatim. Always runs. + + Layer 2 Frontend metadata — `esp32-cam` is registered as a BoardKind, + has an FQBN, and the rendered element exposes a pin '13'. + Catches "the board was renamed/removed and silently 404s". + + Layer 3 Pin mapping — Python mirror of `boardPinToNumber('esp32-cam', + '13')` returns 13. Catches "frontend mapping returns null and + nothing ever subscribes to GPIO13". + + Layer 4 Backend WS route accepts board='esp32-cam' — the simulation + websocket forwards the start_esp32 payload to esp_lib_manager + without rejecting the unknown variant. Catches "backend + dropped esp32-cam silently and never booted QEMU". + + Layer 5 Live compile + WebSocket — POSTs the IRAM-safe control sketch + to `/api/compile/`, takes the resulting `firmware_b64`, opens + the simulation WebSocket, sends `start_esp32` with the + freshly-compiled firmware, and asserts a `gpio_change` frame + for pin 13 arrives. This is the closest possible reproduction + of what the browser does. Auto-skips when the backend is not + reachable on $VELXIO_BACKEND_URL. + +Why no pre-built binary in the repo: the existing CI pattern (see +`test/backend/e2e/test_hcsr04_simulation.mjs` and +`test/test_custom_chips_boards/test_esp32_chip_i2c.py`) compiles every +sketch on demand through the backend. That keeps the repo binary-clean +and guarantees the firmware always matches whatever ESP32 core the CI +just installed. + +Run all layers (live one auto-skips if no backend): + + python -m pytest test/esp32_cam/test_esp32_cam_blink.py -v + +Run the live layer explicitly against a local backend: + + cd backend && uvicorn app.main:app --port 8001 & + VELXIO_BACKEND_URL=http://localhost:8001 \ + python -m pytest test/esp32_cam/test_esp32_cam_blink.py -v +""" + +import asyncio +import json +import os +import pathlib +import re +import socket +import sys +import unittest +from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import urlparse + +# ── Paths ───────────────────────────────────────────────────────────────────── +_THIS_DIR = pathlib.Path(__file__).resolve().parent +_REPO = _THIS_DIR.parent.parent +_BACKEND = _REPO / "backend" +_FRONTEND = _REPO / "frontend" / "src" +_USER_SKETCH = _THIS_DIR / "sketches" / "blink_pin13" / "blink_pin13.ino" +_IRAM_SKETCH = _THIS_DIR / "sketches" / "blink_pin13_iram" / "blink_pin13_iram.ino" + +sys.path.insert(0, str(_BACKEND)) + + +# ═════════════════════════════════════════════════════════════════════════════ +# Layer 1 — static: the user's sketch is present and matches the issue body +# ═════════════════════════════════════════════════════════════════════════════ + +class TestSketchSourceMatchesIssue(unittest.TestCase): + """The sketch shipped with this test must reproduce the issue verbatim, + not a "fixed" approximation. A future contributor who edits the sketch + to make the test pass would change the meaning of the regression.""" + + def test_user_sketch_exists(self): + self.assertTrue( + _USER_SKETCH.is_file(), + f"User sketch missing: {_USER_SKETCH}", + ) + + def test_iram_control_sketch_exists(self): + self.assertTrue( + _IRAM_SKETCH.is_file(), + f"IRAM control sketch missing: {_IRAM_SKETCH}", + ) + + def test_user_sketch_uses_pin_13(self): + text = _USER_SKETCH.read_text() + self.assertRegex( + text, r"#define\s+LED_PIN\s+13\b", + "User sketch must keep #define LED_PIN 13 to repro issue #129", + ) + + def test_user_sketch_uses_arduino_delay(self): + """delay() and pinMode/digitalWrite are the Arduino-runtime path. + That path is exactly what crashes under lcgamboa flash-cache disable + — the regression we're capturing.""" + text = _USER_SKETCH.read_text() + self.assertIn("delay(1000);", text) + self.assertIn("pinMode(LED_PIN, OUTPUT);", text) + self.assertIn("digitalWrite(LED_PIN, HIGH);", text) + self.assertIn("digitalWrite(LED_PIN, LOW);", text) + + def test_iram_sketch_uses_gpio13(self): + """Control sketch must target GPIO13 (1u<<13) — the same pin — + otherwise we're not isolating the runtime difference from the + pin-routing question.""" + text = _IRAM_SKETCH.read_text() + self.assertRegex(text, r"\(1u\s*<<\s*13\)") + + +# ═════════════════════════════════════════════════════════════════════════════ +# Layer 2 — frontend metadata: ESP32-CAM is a real, exposed BoardKind +# ═════════════════════════════════════════════════════════════════════════════ + +class TestEsp32CamBoardMetadata(unittest.TestCase): + """Mirror the bits of frontend/src/types/board.ts and + components/velxio-components/Esp32Element.ts that the issue depends on.""" + + BOARD_TS = _FRONTEND / "types" / "board.ts" + ELEMENT = _FRONTEND / "components" / "velxio-components" / "Esp32Element.ts" + + def test_board_ts_has_esp32_cam_kind(self): + text = self.BOARD_TS.read_text(encoding="utf-8") + self.assertIn("'esp32-cam'", text, + "BoardKind union missing 'esp32-cam'") + + def test_board_ts_has_esp32_cam_fqbn(self): + text = self.BOARD_TS.read_text(encoding="utf-8") + self.assertRegex( + text, + r"'esp32-cam'\s*:\s*'esp32:esp32:esp32cam'", + "BOARD_KIND_FQBN missing or wrong for esp32-cam", + ) + + def test_esp32_cam_element_exposes_pin_13(self): + """If pin '13' disappears from PINS_ESP32_CAM the wire system has + nothing to attach to and the LED would correctly fail to light.""" + text = self.ELEMENT.read_text(encoding="utf-8") + match = re.search( + r"PINS_ESP32_CAM\s*=\s*\[(.*?)\];", + text, + re.DOTALL, + ) + self.assertIsNotNone(match, "PINS_ESP32_CAM array not found") + assert match is not None # narrow for type checker + block = match.group(1) + self.assertRegex( + block, + r"\{\s*name:\s*'13'", + "ESP32-CAM element missing pin '13'", + ) + + +# ═════════════════════════════════════════════════════════════════════════════ +# Layer 3 — pin mapping: '13' on ESP32-CAM resolves to GPIO 13 +# ═════════════════════════════════════════════════════════════════════════════ + +def esp32_cam_pin_to_number(pin_name: str) -> int | None: + """Python mirror of boardPinToNumber('esp32-cam', pinName) for the + 'esp32' / startsWith('esp32') branch in + frontend/src/utils/boardPinMapping.ts:317-326. Kept tiny and + self-contained so a frontend rewrite that breaks the routing here is + caught without spinning up Node.""" + if pin_name.startswith(("GND", "3V3", "5V")): + return -1 + try: + num = int(pin_name) + if 0 <= num <= 39: + return num + except ValueError: + pass + aliases = {"TX": 1, "RX": 3, "VP": 36, "VN": 39} + return aliases.get(pin_name) + + +class TestEsp32CamPinMapping(unittest.TestCase): + + def test_pin_13_maps_to_gpio_13(self): + """The single load-bearing assertion for issue #129's hot path.""" + self.assertEqual(esp32_cam_pin_to_number("13"), 13) + + def test_named_pins_still_map(self): + self.assertEqual(esp32_cam_pin_to_number("0"), 0) + self.assertEqual(esp32_cam_pin_to_number("2"), 2) + self.assertEqual(esp32_cam_pin_to_number("4"), 4) + self.assertEqual(esp32_cam_pin_to_number("12"), 12) + self.assertEqual(esp32_cam_pin_to_number("14"), 14) + self.assertEqual(esp32_cam_pin_to_number("15"), 15) + self.assertEqual(esp32_cam_pin_to_number("16"), 16) + self.assertEqual(esp32_cam_pin_to_number("RX"), 3) + self.assertEqual(esp32_cam_pin_to_number("TX"), 1) + + def test_power_pins_skipped(self): + for name in ("GND", "GND.1", "GND.2", "GND.3", "3V3", "5V", "5V.1", "VCC"): + # 'VCC' is treated as input rail by the canvas — not a GPIO. + # The mapping returns -1 for explicit power pins; VCC is + # currently None, which the wire system also skips silently. + result = esp32_cam_pin_to_number(name) + self.assertIn(result, (-1, None), f"{name!r} → {result!r}") + + +# ═════════════════════════════════════════════════════════════════════════════ +# Layer 4 — backend WS route accepts board='esp32-cam' (in-process mock) +# ═════════════════════════════════════════════════════════════════════════════ + +class TestSimulationRouteAcceptsCamBoard(unittest.IsolatedAsyncioTestCase): + """The backend route for `start_esp32` must forward board='esp32-cam' + to esp_lib_manager.start_instance unchanged. The lib manager itself + falls back to the default 'esp32-picsimlab' machine for unknown kinds + (esp32_lib_manager.py:186), so the route's job is just to not block + the message.""" + + async def asyncSetUp(self): + import importlib + import app.services.esp_qemu_manager as em_mod + importlib.reload(em_mod) + import app.services.esp32_lib_manager as lib_mod + importlib.reload(lib_mod) + import app.api.routes.simulation as sim_mod + importlib.reload(sim_mod) + self.sim_mod = sim_mod + self.esp = em_mod.esp_qemu_manager + self.lib = lib_mod.esp_lib_manager + + def _make_ws(self, messages: list[dict]): + ws = MagicMock() + ws.accept = AsyncMock() + msg_iter = iter([json.dumps(m) for m in messages]) + + async def receive_text(): + try: + return next(msg_iter) + except StopIteration: + from fastapi.websockets import WebSocketDisconnect + raise WebSocketDisconnect() + + ws.receive_text = receive_text + ws.send_text = AsyncMock() + return ws + + async def test_start_esp32_with_board_esp32_cam_is_forwarded(self): + import base64 + firmware = base64.b64encode(b"\x00" * 64).decode() + ws = self._make_ws([{ + "type": "start_esp32", + "data": {"board": "esp32-cam", "firmware_b64": firmware}, + }]) + # Mock both managers — only one runs depending on lib availability. + with patch.object(self.lib, "start_instance", + new=AsyncMock()) as lib_start, \ + patch.object(self.lib, "stop_instance", new=AsyncMock()), \ + patch.object(self.esp, "start_instance") as esp_start, \ + patch.object(self.esp, "stop_instance"): + try: + await self.sim_mod.simulation_websocket(ws, "esp-cam-ws") + except Exception: + pass + + # Exactly one path must have been called with board='esp32-cam'. + called_with_cam = False + for mock in (lib_start, esp_start): + if mock.called: + args = mock.call_args[0] + # signature: (client_id, board, callback, firmware_b64, ...) + if len(args) >= 2 and args[0] == "esp-cam-ws" \ + and args[1] == "esp32-cam": + called_with_cam = True + break + self.assertTrue( + called_with_cam, + "Neither esp_lib_manager.start_instance nor " + "esp_qemu_manager.start_instance was called with " + "board='esp32-cam'", + ) + + +# ═════════════════════════════════════════════════════════════════════════════ +# Layer 5 — live compile + WebSocket: blink the IRAM-safe control sketch +# ═════════════════════════════════════════════════════════════════════════════ +# +# Mirrors the e2e flow of test/backend/e2e/test_hcsr04_simulation.mjs and +# test/test_custom_chips_boards/test_esp32_chip_i2c.py: +# +# 1. POST /api/compile/ with the .ino source → backend builds via +# arduino-cli + esptool merge_bin → returns firmware_b64. +# 2. Open /api/simulation/ws/, send start_esp32 with that firmware. +# 3. Wait for gpio_change frames on GPIO13. +# +# Auto-skips when no backend is reachable so this file stays green offline. + +def _backend_base_url() -> str: + return os.environ.get("VELXIO_BACKEND_URL", "").strip() + + +def _backend_reachable(timeout: float = 0.5) -> bool: + url = _backend_base_url() + if not url: + return False + try: + u = urlparse(url) + host = u.hostname or "localhost" + port = u.port or (443 if u.scheme == "https" else 80) + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + except Exception: + return False + + +def _ws_url(client_id: str) -> str: + """Construct the actual WS URL the frontend uses (Esp32Bridge.ts:157 + sends to `/simulation/ws/` under the /api prefix).""" + base = _backend_base_url() or "http://localhost:8001" + u = urlparse(base) + scheme = "wss" if u.scheme == "https" else "ws" + host = u.hostname or "localhost" + port = f":{u.port}" if u.port else "" + return f"{scheme}://{host}{port}/api/simulation/ws/{client_id}" + + +@unittest.skipUnless(_backend_reachable(), + "Velxio backend not reachable on $VELXIO_BACKEND_URL " + "(start: cd backend && uvicorn app.main:app --port 8001 " + "and export VELXIO_BACKEND_URL=http://localhost:8001)") +class TestEsp32CamLiveCompileAndBlink(unittest.IsolatedAsyncioTestCase): + """End-to-end: compile the IRAM-safe control sketch through the + backend's arduino-cli pipeline, then boot it under QEMU through the + same WebSocket the frontend uses, and assert GPIO13 actually toggles.""" + + COMPILE_TIMEOUT = 300.0 # arduino-cli first-run install can be slow + GPIO_TIMEOUT = 35.0 # IRAM control sketch toggles ≥3 times in ~3 s + # plus QEMU boot (≈10–15 s) + + async def test_gpio13_toggles_after_live_compile(self): + try: + import httpx # type: ignore + import websockets # type: ignore + except ImportError as exc: + self.skipTest(f"missing test deps: {exc}") + + sketch = _IRAM_SKETCH.read_text(encoding="utf-8") + sketch_name = _IRAM_SKETCH.name # blink_pin13_iram.ino + + # ── 1. Compile via the backend ───────────────────────────────────── + async with httpx.AsyncClient( + base_url=_backend_base_url(), + timeout=self.COMPILE_TIMEOUT, + ) as http: + res = await http.post("/api/compile/", json={ + "files": [{"name": sketch_name, "content": sketch}], + # esp32cam variant is the FQBN the frontend ships + # (frontend/src/types/board.ts → BOARD_KIND_FQBN). + "board_fqbn": "esp32:esp32:esp32cam", + }) + self.assertEqual(res.status_code, 200, + f"/api/compile/ HTTP {res.status_code}: {res.text[:400]}") + body = res.json() + if not body.get("success"): + self.skipTest( + f"backend reports compile failure on this CI runner: " + f"{body.get('error') or body.get('stderr', '')[:600]}" + ) + firmware_b64 = body.get("binary_content") or body.get("firmware_b64") + self.assertTrue( + firmware_b64, + f"compile succeeded but no firmware_b64 in response keys: " + f"{list(body.keys())}", + ) + + # ── 2. Boot via the same WebSocket route the frontend uses ───────── + client_id = f"esp32-cam-test-{int(asyncio.get_event_loop().time() * 1000)}" + url = _ws_url(client_id) + + # The frontend collapses esp32-cam → 'esp32' before sending the + # start frame (Esp32Bridge.toQemuBoardType). We send the literal + # 'esp32-cam' here so this test ALSO covers a future refactor that + # forwards the kind unchanged — issue #129's "ESP32-CAM specifically + # broken" framing was misleading; both paths must end up at the + # same QEMU machine. + async with websockets.connect(url, ping_interval=None, + max_size=16 * 1024 * 1024) as ws: + await ws.send(json.dumps({ + "type": "start_esp32", + "data": { + "board": "esp32-cam", + "firmware_b64": firmware_b64, + }, + })) + + saw_pin13 = False + transitions = 0 + try: + deadline = asyncio.get_event_loop().time() + self.GPIO_TIMEOUT + while asyncio.get_event_loop().time() < deadline: + remaining = deadline - asyncio.get_event_loop().time() + if remaining <= 0: + break + raw = await asyncio.wait_for(ws.recv(), timeout=remaining) + try: + msg = json.loads(raw) + except json.JSONDecodeError: + continue + if msg.get("type") == "gpio_change" \ + and msg.get("data", {}).get("pin") == 13: + saw_pin13 = True + transitions += 1 + # ≥3 transitions proves it's actually blinking, + # not just a one-shot HIGH at startup. + if transitions >= 3: + break + except asyncio.TimeoutError: + pass + finally: + try: + await ws.send(json.dumps({"type": "stop_esp32", + "data": {}})) + except Exception: + pass + + self.assertTrue( + saw_pin13, + f"No gpio_change frame for pin=13 arrived within " + f"{self.GPIO_TIMEOUT}s. Either the backend dropped the " + f"esp32-cam start, the firmware crashed before toggling, or " + f"GPIO13 events are not being forwarded to the frontend.", + ) + self.assertGreaterEqual( + transitions, 3, + f"GPIO13 only toggled {transitions} time(s) in {self.GPIO_TIMEOUT}s " + f"— expected ≥3 for a periodic blink", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/wokwi-libs/qemu-lcgamboa b/wokwi-libs/qemu-lcgamboa index 81f87f15..8bf910d7 160000 --- a/wokwi-libs/qemu-lcgamboa +++ b/wokwi-libs/qemu-lcgamboa @@ -1 +1 @@ -Subproject commit 81f87f158e954fa68969425226a7c08dac200ead +Subproject commit 8bf910d780047d4836d97718e83851d2b931bf95 diff --git a/wokwi-libs/wokwi-elements b/wokwi-libs/wokwi-elements index 3c8178e6..53cec973 160000 --- a/wokwi-libs/wokwi-elements +++ b/wokwi-libs/wokwi-elements @@ -1 +1 @@ -Subproject commit 3c8178e6eccd94f89a197182954a4bf9d3e6a76b +Subproject commit 53cec973e269ab4e611081951a8735780533a197