542 lines
19 KiB
Python
542 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""End-to-end QEMU tests for ESP32 example sketches reported by beta testers.
|
|
|
|
Each test:
|
|
1. Compiles a sketch via app.services.espidf_compiler.compile()
|
|
2. Launches esp32_worker.py with the resulting firmware
|
|
3. Captures serial output for N seconds
|
|
4. Asserts on expected substrings + verifies NO ANSI escape sequences
|
|
and NO `I (xxx) gpio:` info logs leak into the user's serial output
|
|
|
|
Skipped automatically when the ESP-IDF toolchain or libqemu shared
|
|
library are not available, so the suite is safe to run on Windows
|
|
dev boxes and only does real work in the Docker CI image.
|
|
|
|
Sketches are inlined verbatim from frontend/src/data/examples.ts and
|
|
frontend/src/data/examples-circuits.ts.
|
|
|
|
Run from the repo root:
|
|
pytest test/backend/integration/test_esp32_examples_qemu.py -v
|
|
|
|
Or directly:
|
|
python test/backend/integration/test_esp32_examples_qemu.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import io
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
from typing import cast
|
|
|
|
# ── Path setup ────────────────────────────────────────────────────────────────
|
|
BACKEND_DIR = Path(__file__).resolve().parents[3] / 'backend'
|
|
SERVICES_DIR = BACKEND_DIR / 'app' / 'services'
|
|
WORKER = SERVICES_DIR / 'esp32_worker.py'
|
|
|
|
# libqemu has different filenames per platform.
|
|
if sys.platform == 'win32':
|
|
LIB_XTENSA = SERVICES_DIR / 'libqemu-xtensa.dll'
|
|
elif sys.platform == 'darwin':
|
|
LIB_XTENSA = SERVICES_DIR / 'libqemu-xtensa.dylib'
|
|
else:
|
|
LIB_XTENSA = SERVICES_DIR / 'libqemu-xtensa.so'
|
|
|
|
if str(BACKEND_DIR) not in sys.path:
|
|
sys.path.insert(0, str(BACKEND_DIR))
|
|
|
|
from app.services.espidf_compiler import espidf_compiler # type: ignore[import-not-found] # noqa: E402
|
|
|
|
# ── Regex helpers — bug #1 verifications ─────────────────────────────────────
|
|
ANSI_RE = re.compile(rb'\x1b\[[0-9;]*m')
|
|
# Matches lines like "I (53306) gpio: GPIO[4]| InputEn: 1| ..." that the
|
|
# beta tester reported leaking into user output before the WARN log-level fix.
|
|
ESPIDF_INFO_RE = re.compile(rb'^I \(\d+\) (gpio|wifi|phy|wifi_init):', re.MULTILINE)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Sketches under test (verbatim from frontend/src/data/examples.ts)
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
SKETCH_DHT22 = r"""// ESP32 - DHT22 Temperature & Humidity Sensor
|
|
#include <DHT.h>
|
|
|
|
#define DHT_PIN 4
|
|
#define DHT_TYPE DHT22
|
|
|
|
DHT dht(DHT_PIN, DHT_TYPE);
|
|
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
dht.begin();
|
|
delay(2000);
|
|
Serial.println("ESP32 DHT22 ready!");
|
|
}
|
|
|
|
void loop() {
|
|
delay(2000);
|
|
float h = dht.readHumidity();
|
|
float t = dht.readTemperature();
|
|
if (isnan(h) || isnan(t)) {
|
|
Serial.println("DHT22: waiting for sensor...");
|
|
return;
|
|
}
|
|
Serial.printf("Temp: %.1f C Humidity: %.1f %%\n", t, h);
|
|
}
|
|
"""
|
|
|
|
SKETCH_SERVO_POT = r"""// ESP32 - Servo controlled by Potentiometer
|
|
#include <ESP32Servo.h>
|
|
|
|
#define SERVO_PIN 13
|
|
#define POT_PIN 34
|
|
|
|
Servo myServo;
|
|
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
myServo.attach(SERVO_PIN, 500, 2400);
|
|
Serial.println("ESP32 Servo + Pot control");
|
|
}
|
|
|
|
void loop() {
|
|
int raw = analogRead(POT_PIN);
|
|
int angle = map(raw, 0, 4095, 0, 180);
|
|
myServo.write(angle);
|
|
Serial.printf("Pot: %4d Angle: %3d deg\n", raw, angle);
|
|
delay(20);
|
|
}
|
|
"""
|
|
|
|
SKETCH_JOYSTICK = r"""// ESP32 - Analog Joystick
|
|
#define JOY_HORZ 35
|
|
#define JOY_VERT 34
|
|
#define JOY_BTN 15
|
|
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
pinMode(JOY_BTN, INPUT_PULLUP);
|
|
Serial.println("ESP32 Joystick ready");
|
|
}
|
|
|
|
void loop() {
|
|
int x = analogRead(JOY_HORZ);
|
|
int y = analogRead(JOY_VERT);
|
|
bool btn = (digitalRead(JOY_BTN) == LOW);
|
|
int xPct = map(x, 0, 4095, -100, 100);
|
|
int yPct = map(y, 0, 4095, -100, 100);
|
|
Serial.printf("X=%4d(%4d%%) Y=%4d(%4d%%) BTN=%s\n",
|
|
x, xPct, y, yPct, btn ? "PRESSED" : "---");
|
|
delay(100);
|
|
}
|
|
"""
|
|
|
|
SKETCH_DUAL_ADC = r"""// ESP32 dual ADC - 12-bit, 3.3V reference
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
analogReadResolution(12);
|
|
}
|
|
void loop() {
|
|
int ch1 = analogRead(34);
|
|
int ch2 = analogRead(35);
|
|
float v1 = ch1 * 3.3 / 4095.0;
|
|
float v2 = ch2 * 3.3 / 4095.0;
|
|
Serial.printf("CH1=%.3fV CH2=%.3fV\n", v1, v2);
|
|
delay(500);
|
|
}
|
|
"""
|
|
|
|
# Bug #3: uses arduino-esp32 3.x ledcAttach() — fails without velxio_compat.h shim.
|
|
SKETCH_LEDC_RGB = r"""// ESP32 LEDC PWM - RGB LED color cycling
|
|
#define R_PIN 16
|
|
#define G_PIN 17
|
|
#define B_PIN 18
|
|
void setup() {
|
|
ledcAttach(R_PIN, 5000, 8);
|
|
ledcAttach(G_PIN, 5000, 8);
|
|
ledcAttach(B_PIN, 5000, 8);
|
|
}
|
|
void loop() {
|
|
for(int h=0; h<360; h+=5) {
|
|
float r,g,b;
|
|
int i = h/60; float f = h/60.0-i;
|
|
switch(i%6) {
|
|
case 0: r=1; g=f; b=0; break;
|
|
case 1: r=1-f; g=1; b=0; break;
|
|
case 2: r=0; g=1; b=f; break;
|
|
case 3: r=0; g=1-f; b=1; break;
|
|
case 4: r=f; g=0; b=1; break;
|
|
case 5: r=1; g=0; b=1-f; break;
|
|
}
|
|
ledcWrite(R_PIN, (int)(r*255));
|
|
ledcWrite(G_PIN, (int)(g*255));
|
|
ledcWrite(B_PIN, (int)(b*255));
|
|
delay(30);
|
|
}
|
|
}
|
|
"""
|
|
|
|
SKETCH_WIFI_CONNECT = r"""#include <WiFi.h>
|
|
|
|
const char* ssid = "Velxio-GUEST";
|
|
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
delay(1000);
|
|
Serial.println("ESP32 WiFi Connection Demo");
|
|
Serial.println("==========================");
|
|
Serial.printf("Connecting to %s", ssid);
|
|
|
|
WiFi.begin(ssid, "", 6);
|
|
|
|
while (WiFi.status() != WL_CONNECTED) {
|
|
delay(500);
|
|
Serial.print(".");
|
|
}
|
|
|
|
Serial.println(" Connected!");
|
|
Serial.printf("IP Address: %s\n", WiFi.localIP().toString().c_str());
|
|
}
|
|
|
|
void loop() {
|
|
delay(5000);
|
|
}
|
|
"""
|
|
|
|
SKETCH_WIFI_SERVER = r"""#include <WiFi.h>
|
|
#include <WebServer.h>
|
|
|
|
const char* ssid = "Velxio-GUEST";
|
|
WebServer server(80);
|
|
|
|
void handleRoot() {
|
|
server.send(200, "text/plain", "Hello from ESP32!");
|
|
}
|
|
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
delay(1000);
|
|
Serial.println("ESP32 HTTP Server");
|
|
|
|
WiFi.begin(ssid, "", 6);
|
|
while (WiFi.status() != WL_CONNECTED) {
|
|
delay(500);
|
|
}
|
|
|
|
server.on("/", handleRoot);
|
|
server.begin();
|
|
|
|
Serial.printf("Server started at: http://%s/\n", WiFi.localIP().toString().c_str());
|
|
}
|
|
|
|
void loop() {
|
|
server.handleClient();
|
|
}
|
|
"""
|
|
|
|
# Bug #2: arduino-esp32 BLEDevice.h needs Bluedroid (was NimBLE-only before fix).
|
|
SKETCH_BLE_ADVERTISE = r"""#include <BLEDevice.h>
|
|
#include <BLEServer.h>
|
|
#include <BLEUtils.h>
|
|
#include <BLE2902.h>
|
|
|
|
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
|
|
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"
|
|
|
|
BLEServer* pServer = nullptr;
|
|
BLECharacteristic* pCharacteristic = nullptr;
|
|
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
delay(1000);
|
|
Serial.println("ESP32 BLE Advertise Demo");
|
|
|
|
BLEDevice::init("Velxio-ESP32");
|
|
pServer = BLEDevice::createServer();
|
|
BLEService *pService = pServer->createService(SERVICE_UUID);
|
|
pCharacteristic = pService->createCharacteristic(
|
|
CHARACTERISTIC_UUID,
|
|
BLECharacteristic::PROPERTY_READ |
|
|
BLECharacteristic::PROPERTY_WRITE |
|
|
BLECharacteristic::PROPERTY_NOTIFY
|
|
);
|
|
pCharacteristic->addDescriptor(new BLE2902());
|
|
pCharacteristic->setValue("Hello from Velxio!");
|
|
|
|
pService->start();
|
|
BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
|
|
pAdvertising->addServiceUUID(SERVICE_UUID);
|
|
pAdvertising->setScanResponse(true);
|
|
pAdvertising->start();
|
|
|
|
Serial.println("BLE advertising started!");
|
|
}
|
|
|
|
void loop() {
|
|
delay(2000);
|
|
}
|
|
"""
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Helpers
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
def _compile_via_espidf(sketch_code: str, fqbn: str = 'esp32:esp32:esp32') -> bytes:
|
|
"""Compile sketch through the production ESP-IDF compiler. Returns the
|
|
trimmed firmware bytes ready to hand to esp32_worker.py.
|
|
|
|
Raises AssertionError with stderr on compile failure so test diagnostics
|
|
are immediately visible.
|
|
"""
|
|
files = [{'name': 'sketch.ino', 'content': sketch_code}]
|
|
result = asyncio.run(espidf_compiler.compile(files, fqbn))
|
|
if not result.get('success'):
|
|
raise AssertionError(
|
|
f"Compile failed for fqbn={fqbn}:\n"
|
|
f" error: {result.get('error')}\n"
|
|
f" stderr: {(result.get('stderr') or '')[-2000:]}\n"
|
|
)
|
|
bin_b64 = result.get('binary_content')
|
|
if not bin_b64:
|
|
raise AssertionError(f"Compile reported success but no binary_content: {result}")
|
|
return base64.b64decode(bin_b64)
|
|
|
|
|
|
def _run_worker(firmware_bytes: bytes,
|
|
run_seconds: float = 8.0,
|
|
machine: str = 'esp32-picsimlab') -> dict:
|
|
"""Launch esp32_worker.py with firmware, collect events for `run_seconds`,
|
|
return aggregated dict. Mirrors the pattern in test_esp32c3_emulation.py.
|
|
|
|
Returns:
|
|
{
|
|
'serial_bytes': bytes, # concatenated UART0 output
|
|
'gpio_events': list[(pin, state)],
|
|
'sys_events': list[str],
|
|
'errors': list[str],
|
|
'booted': bool,
|
|
'all_events': list[dict],
|
|
}
|
|
"""
|
|
if not WORKER.exists():
|
|
raise unittest.SkipTest(f'Worker not found: {WORKER}')
|
|
if not LIB_XTENSA.exists():
|
|
raise unittest.SkipTest(f'libqemu not found: {LIB_XTENSA}')
|
|
|
|
cfg = {
|
|
'lib_path': str(LIB_XTENSA),
|
|
'firmware_b64': base64.b64encode(firmware_bytes).decode('ascii'),
|
|
'machine': machine,
|
|
}
|
|
|
|
proc = subprocess.Popen(
|
|
[sys.executable, str(WORKER)],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=False,
|
|
)
|
|
# PIPE was passed for all three streams, so they are non-None handles.
|
|
# Cast to satisfy the type checker (Optional narrowing doesn't flow
|
|
# through attribute access into closures).
|
|
p_stdin = cast(io.BufferedWriter, proc.stdin)
|
|
p_stdout = cast(io.BufferedReader, proc.stdout)
|
|
p_stderr = cast(io.BufferedReader, proc.stderr)
|
|
p_stdin.write((json.dumps(cfg) + '\n').encode('utf-8'))
|
|
p_stdin.flush()
|
|
|
|
serial_chunks: list[bytes] = []
|
|
gpio_events: list[tuple[int, int]] = []
|
|
sys_events: list[str] = []
|
|
errors: list[str] = []
|
|
all_events: list[dict] = []
|
|
|
|
def _read_stdout() -> None:
|
|
for raw_line in p_stdout:
|
|
line = raw_line.decode('utf-8', errors='replace').strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
evt = json.loads(line)
|
|
except Exception:
|
|
continue
|
|
all_events.append(evt)
|
|
t = evt.get('type')
|
|
if t == 'uart_tx':
|
|
# Single-byte event — append as a 1-byte chunk.
|
|
b = evt.get('byte')
|
|
if isinstance(b, int) and 0 <= b <= 255:
|
|
serial_chunks.append(bytes([b]))
|
|
elif t == 'serial_output':
|
|
data = evt.get('data')
|
|
if isinstance(data, str):
|
|
serial_chunks.append(data.encode('utf-8', errors='replace'))
|
|
elif t == 'gpio_change':
|
|
gpio_events.append((evt.get('pin'), evt.get('state')))
|
|
elif t == 'system':
|
|
sys_events.append(evt.get('event', ''))
|
|
elif t == 'error':
|
|
errors.append(evt.get('message', ''))
|
|
|
|
def _read_stderr() -> None:
|
|
for _ in p_stderr:
|
|
pass # discard; surface via errors[] if the worker emits a JSON error event
|
|
|
|
threading.Thread(target=_read_stdout, daemon=True).start()
|
|
threading.Thread(target=_read_stderr, daemon=True).start()
|
|
|
|
deadline = time.monotonic() + run_seconds
|
|
while time.monotonic() < deadline and proc.poll() is None:
|
|
time.sleep(0.2)
|
|
|
|
# Graceful stop, then force.
|
|
try:
|
|
p_stdin.write((json.dumps({'cmd': 'stop'}) + '\n').encode('utf-8'))
|
|
p_stdin.flush()
|
|
except Exception:
|
|
pass
|
|
time.sleep(0.5)
|
|
if proc.poll() is None:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
return {
|
|
'serial_bytes': b''.join(serial_chunks),
|
|
'gpio_events': gpio_events,
|
|
'sys_events': sys_events,
|
|
'errors': errors,
|
|
'booted': 'booted' in sys_events,
|
|
'all_events': all_events,
|
|
}
|
|
|
|
|
|
def _toolchain_available() -> bool:
|
|
return espidf_compiler.available and LIB_XTENSA.exists() and WORKER.exists()
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Bug #1 — Serial cleanliness across multiple sketches
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@unittest.skipUnless(_toolchain_available(),
|
|
'Requires libqemu-xtensa + ESP-IDF toolchain (Docker CI)')
|
|
class TestEsp32SerialCleanliness(unittest.TestCase):
|
|
"""The reporter saw raw `[0;32m` ANSI codes and `I (xxx) gpio:` info logs
|
|
mixed into the Serial.print output of these examples. After the sdkconfig
|
|
log-level + ANSI strip fixes, neither should leak.
|
|
|
|
Note: ANSI verification is on the BACKEND side (the worker emits raw
|
|
UART bytes — frontend strip is a separate concern, covered by the
|
|
SerialMonitor change in commit 3 of this series). The backend bug is
|
|
that ESP-IDF's logger tags lines with `I (xxx) gpio:` at INFO level
|
|
by default; we want only WARN+ leaking through.
|
|
"""
|
|
|
|
def _assert_clean_user_output(self, sketch: str, expected_substr: bytes,
|
|
run_seconds: float = 12.0):
|
|
fw = _compile_via_espidf(sketch)
|
|
out = _run_worker(fw, run_seconds=run_seconds)
|
|
self.assertTrue(out['booted'], f"firmware never booted; events={out['all_events'][:5]}")
|
|
self.assertFalse(
|
|
ESPIDF_INFO_RE.search(out['serial_bytes']),
|
|
f"ESP-IDF INFO logs leaked into user serial output:\n"
|
|
f"{out['serial_bytes'][:400]!r}"
|
|
)
|
|
self.assertIn(
|
|
expected_substr, out['serial_bytes'],
|
|
f"Expected {expected_substr!r} in user output but got:\n"
|
|
f"{out['serial_bytes'][:400]!r}"
|
|
)
|
|
|
|
def test_dht22_serial_is_clean(self):
|
|
self._assert_clean_user_output(SKETCH_DHT22, b'Temp:', run_seconds=14)
|
|
|
|
def test_servo_pot_serial_is_clean(self):
|
|
self._assert_clean_user_output(SKETCH_SERVO_POT, b'Pot:')
|
|
|
|
def test_joystick_serial_is_clean(self):
|
|
self._assert_clean_user_output(SKETCH_JOYSTICK, b'X=')
|
|
|
|
def test_dual_adc_serial_is_clean(self):
|
|
self._assert_clean_user_output(SKETCH_DUAL_ADC, b'CH1=')
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Bugs #2, #3 — Compile success regression coverage
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@unittest.skipUnless(_toolchain_available(),
|
|
'Requires libqemu-xtensa + ESP-IDF toolchain (Docker CI)')
|
|
class TestEsp32CompileSuccess(unittest.TestCase):
|
|
"""These sketches were reported as 'Fail to Compile' before commits 1
|
|
(Bluedroid) and 2 (LEDC compat shim).
|
|
"""
|
|
|
|
def test_ble_advertise_compiles(self):
|
|
# Bug #2 — sdkconfig now enables Bluedroid, BLEDevice.h must compile.
|
|
fw = _compile_via_espidf(SKETCH_BLE_ADVERTISE)
|
|
self.assertGreater(len(fw), 100_000,
|
|
f"BLE Advertise firmware looks suspiciously small: {len(fw)} bytes")
|
|
|
|
def test_ledc_rgb_compiles(self):
|
|
# Bug #3 — velxio_compat.h shim provides ledcAttach() on 2.0.17.
|
|
fw = _compile_via_espidf(SKETCH_LEDC_RGB)
|
|
self.assertGreater(len(fw), 100_000,
|
|
f"LEDC RGB firmware looks suspiciously small: {len(fw)} bytes")
|
|
|
|
def test_ledc_rgb_boots(self):
|
|
"""Stronger than compile: the firmware must reach setup() and run
|
|
the user loop without crashing. The shim's dynamic channel allocation
|
|
is exercised at runtime here.
|
|
"""
|
|
fw = _compile_via_espidf(SKETCH_LEDC_RGB)
|
|
out = _run_worker(fw, run_seconds=6)
|
|
self.assertTrue(out['booted'],
|
|
f"LEDC RGB never booted; events={out['all_events'][:5]}")
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Regression — WiFi sketches still associate after the sdkconfig changes
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@unittest.skipUnless(_toolchain_available(),
|
|
'Requires libqemu-xtensa + ESP-IDF toolchain (Docker CI)')
|
|
class TestEsp32WiFiSketches(unittest.TestCase):
|
|
"""The sdkconfig changes (log level + Bluedroid) shouldn't have broken
|
|
WiFi association. WiFi takes longer to come up — give it 25 s.
|
|
"""
|
|
|
|
def test_wifi_connect_reaches_got_ip(self):
|
|
fw = _compile_via_espidf(SKETCH_WIFI_CONNECT)
|
|
out = _run_worker(fw, run_seconds=25)
|
|
self.assertTrue(out['booted'])
|
|
self.assertIn(b'IP Address:', out['serial_bytes'],
|
|
f"Never saw IP Address line; output:\n{out['serial_bytes'][-800:]!r}")
|
|
# Bug #1 also applies to WiFi sketches — no info logs leaking.
|
|
self.assertFalse(ESPIDF_INFO_RE.search(out['serial_bytes']),
|
|
"WiFi sketch leaked ESP-IDF info logs")
|
|
|
|
def test_wifi_server_starts(self):
|
|
fw = _compile_via_espidf(SKETCH_WIFI_SERVER)
|
|
out = _run_worker(fw, run_seconds=25)
|
|
self.assertTrue(out['booted'])
|
|
self.assertIn(b'Server started', out['serial_bytes'],
|
|
f"Server never started; output:\n{out['serial_bytes'][-800:]!r}")
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main(verbosity=2)
|