velxio/test/test-esp32-cam/tests/test_camera_live.py

210 lines
7.8 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.

"""
Layer 5 — live compile + WebSocket end-to-end.
Mirrors test/esp32_cam/test_esp32_cam_blink.py's live layer:
1. POST the camera_init.ino sketch to /api/compile/.
2. Open the simulation WebSocket and send `start_esp32`.
3. Push a synthetic JPEG frame as a `camera_frame` message.
4. Wait for serial output reporting `got frame: N bytes` with N>0.
Auto-skips if no backend is reachable on $VELXIO_BACKEND_URL — same
behaviour as the blink layer so this file stays green offline.
Today, even with a backend running, the test will *expectedly* fail at
step 4 because the shim isn't shipped yet. That's the regression
contract: a green Layer 5 means the camera-emulation pipeline works
end-to-end. A red Layer 5 with the precise stage that fails (compile?
boot? frame delivery? serial echo?) tells the contributor exactly where
the implementation is incomplete.
Marked `@unittest.expectedFailure` until the shim ships. Once it does,
flip the decorator off in the same diff that lands the shim — that's
the obvious atomic change.
"""
from __future__ import annotations
import asyncio
import base64
import io
import json
import os
import pathlib
import socket
import unittest
from urllib.parse import urlparse
_THIS_DIR = pathlib.Path(__file__).resolve().parent
_TEST_ROOT = _THIS_DIR.parent
_SKETCH = _TEST_ROOT / "sketches" / "camera_init" / "camera_init.ino"
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:
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}"
def _make_test_jpeg() -> bytes:
"""Smallest possible valid JPEG so we can ship the test with no fixture
files. Generated once with PIL, embedded as base64 to keep the test
self-contained. Decodes to a 1×1 pure-white pixel — proves the
transport, not the visual fidelity (the live webcam path uses real
JPEGs from the canvas; this test only validates plumbing)."""
# 1x1 white JPEG, hand-crafted minimal payload (~125 bytes)
return base64.b64decode(
"/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////"
"////////////////////////////////////////////////////2wBDAf//////////////"
"//////////////////////////////////////////////////////////////////////8A"
"AEQgAAQABAwEiAAIRAQMRAf/EABQAAQAAAAAAAAAAAAAAAAAAAAr/xAAUEAEAAAAAAAAAAAAA"
"AAAAAAAAAP/EABQBAQAAAAAAAAAAAAAAAAAAAAD/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oA"
"DAMBAAIRAxEAPwA/Ahf//Z"
)
@unittest.skipUnless(
_backend_reachable(),
"Velxio backend not reachable on $VELXIO_BACKEND_URL. Start one with: "
"cd backend && uvicorn app.main:app --port 8001",
)
class TestCameraLiveCompileAndFrameRoundTrip(unittest.IsolatedAsyncioTestCase):
"""Compile the camera_init sketch, boot it under QEMU, push a JPEG
over the WebSocket, and assert serial output reports the right
`len`. Currently expected-failure until the firmware shim is in
place."""
COMPILE_TIMEOUT = 300.0
SERIAL_TIMEOUT = 30.0
@unittest.expectedFailure
async def test_camera_fb_get_returns_pushed_frame(self):
try:
import httpx # type: ignore
import websockets # type: ignore
except ImportError as exc:
self.skipTest(f"missing test deps: {exc}")
sketch = _SKETCH.read_text(encoding="utf-8")
sketch_name = _SKETCH.name
# 1. compile
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}],
"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"):
# Without the shim, even *compiling* the sketch may need
# the upstream library installed. Skip gracefully so this
# path doesn't masquerade as a regression.
self.skipTest(
f"backend cannot compile camera_init.ino on this runner "
f"(probably missing esp32-camera library or shim): "
f"{(body.get('error') or body.get('stderr', ''))[:600]}"
)
firmware_b64 = body.get("binary_content") or body.get("firmware_b64")
self.assertTrue(firmware_b64, "compile ok but firmware missing")
# 2. boot via WS
client_id = (
f"esp32-cam-frame-test-"
f"{int(asyncio.get_event_loop().time() * 1000)}"
)
url = _ws_url(client_id)
jpeg = _make_test_jpeg()
target_len = len(jpeg)
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},
}))
# 3. push a frame after a brief boot delay
await asyncio.sleep(2.0)
await ws.send(json.dumps({
"type": "esp32_camera_attach",
"data": {"board": "esp32-cam"},
}))
await ws.send(json.dumps({
"type": "camera_frame",
"data": {
"fmt": "jpeg", "w": 1, "h": 1,
"b64": base64.b64encode(jpeg).decode(),
},
}))
# 4. read serial until we see the size echoed back
saw_size = False
try:
deadline = (
asyncio.get_event_loop().time() + self.SERIAL_TIMEOUT
)
accumulated = io.StringIO()
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") == "serial_output":
accumulated.write(msg.get("data", {}).get("data", ""))
if f"got frame: {target_len} bytes" in accumulated.getvalue():
saw_size = True
break
except asyncio.TimeoutError:
pass
finally:
try:
await ws.send(json.dumps({"type": "stop_esp32", "data": {}}))
except Exception:
pass
self.assertTrue(
saw_size,
f"firmware did not echo the pushed frame size ({target_len} bytes) "
f"within {self.SERIAL_TIMEOUT}s — either the shim isn't installed "
f"yet (current expected state) or the WS->shim path is broken.",
)
if __name__ == "__main__":
unittest.main(verbosity=2)