Refactor ESP32 library management and add regression tests for issue #129

- Update esp32_lib_manager.py to dynamically set library extensions based on the platform (Linux, Windows, macOS).
- Update submodule references for qemu-lcgamboa and wokwi-elements.
- Add documentation on ESP32 Arduino runtime crashes related to cache disable during WiFi/BT initialization.
- Introduce regression tests for the ESP32-CAM blink issue, ensuring the user sketch matches the reported problem.
- Implement an IRAM-safe blink sketch to confirm the regression is due to the Arduino runtime.
- Create a comprehensive test suite to cover various layers of the simulation and compilation process.
This commit is contained in:
David Montero Crespo 2026-04-30 23:49:46 -03:00
parent 8aec6ed278
commit d346e89b92
11 changed files with 980 additions and 5 deletions

View File

@ -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()

5
.gitignore vendored
View File

@ -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

View File

@ -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 ──────────────────────────────────────────────────────────────

View File

@ -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 ''

View File

@ -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.

View File

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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/<id>, 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/<id>` 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)

@ -1 +1 @@
Subproject commit 81f87f158e954fa68969425226a7c08dac200ead
Subproject commit 8bf910d780047d4836d97718e83851d2b931bf95

@ -1 +1 @@
Subproject commit 3c8178e6eccd94f89a197182954a4bf9d3e6a76b
Subproject commit 53cec973e269ab4e611081951a8735780533a197