449 lines
20 KiB
Python
449 lines
20 KiB
Python
"""
|
||
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)
|