velxio/test/esp32_cam/test_esp32_cam_blink.py

449 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 (≈1015 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)