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:
parent
8aec6ed278
commit
d346e89b92
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 ''
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue