""" 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)