velxio/frontend/src/__tests__/i2c-esp32-real-firmware.tes...

546 lines
19 KiB
TypeScript
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.

/**
* i2c-esp32-real-firmware.test.ts
*
* Full-fidelity ESP32 I2C end-to-end test. Unlike AVR/RP2040 which
* run entirely in JS (avr8js / rp2040js), ESP32 emulation lives in
* the backend FastAPI process which calls into Espressif QEMU via
* the lcgamboa `libqemu-xtensa.dll` shared library. So a faithful
* test must:
*
* 1. Connect to a running backend FastAPI instance over HTTP.
* 2. Submit the sketch source to `/api/compile/start` which routes
* to `espidf_compiler.py` (when ESP-IDF is installed) and
* builds a QEMU-compatible 4 MB merged flash image.
* 3. Poll `/api/compile/status/{job_id}` until done.
* 4. Open a WebSocket to `/api/simulation/ws/{client_id}` —
* exactly what `Esp32Bridge` does in production.
* 5. Send `start_esp32` with the firmware_b64; the backend boots
* QEMU under the libqemu DLL via `esp_lib_manager`.
* 6. Listen for `i2c_event` / `i2c_transaction` messages and
* verify the sketch's I2C traffic arrives intact at the
* frontend bridge.
*
* Prerequisites (auto-skipped when missing — same convention as the
* pong/mega/ili9341 emulation suites that came before):
* - Backend reachable at `${VELXIO_BACKEND}/api` (default
* http://127.0.0.1:8001/api).
* - The backend has ESP-IDF wired up (espidf_compiler.available
* returns true server-side) AND libqemu-xtensa with ROM files
* beside it. Verified indirectly by the compile call
* succeeding and a `system: booted` event appearing.
*
* To run locally:
*
* cd backend && .venv/Scripts/uvicorn app.main:app --port 8001
* # in another shell:
* cd frontend && npx vitest run src/__tests__/i2c-esp32-real-firmware.test.ts
*
* The first run compiles the sketch (~75 s with cold ESP-IDF
* cache); subsequent runs hit the OS tmpdir hex cache and complete
* in a few seconds.
*/
import { describe, it, expect } from 'vitest';
import { spawnSync } from 'node:child_process';
import {
existsSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
// ─── Configuration ───────────────────────────────────────────────────────────
const BACKEND_URL =
process.env.VELXIO_BACKEND ?? 'http://127.0.0.1:8001';
const FQBN = 'esp32:esp32:esp32';
const SKETCH_PATH = resolve(
__dirname,
'../../../test/test_custom_chips/sketches/esp32_i2c_writer/esp32_i2c_writer.ino',
);
// ─── Availability probe (must be synchronous so describe.runIf works) ────────
const SKETCH_AVAILABLE = existsSync(SKETCH_PATH);
const BACKEND_AVAILABLE = (() => {
// node 22's fetch can't be awaited at the top level synchronously,
// but a 2 s curl probe is fast enough and works on every dev box.
// Root path always returns the version banner JSON when alive.
const r = spawnSync(
'curl',
['-s', '-m', '2', '-o', '-', `${BACKEND_URL}/`],
{ encoding: 'utf-8' },
);
return r.status === 0 && (r.stdout ?? '').includes('Arduino Emulator API');
})();
// ─── Compile-via-backend helper ──────────────────────────────────────────────
//
// Mirrors what the frontend does in `services/compilation.ts`:
// POST the files + board_fqbn, get back binary_content (b64 of the
// merged 4 MB flash image). Routes to espidf_compiler on the
// backend when ESP-IDF is available — that's the only path that
// produces a QEMU-bootable image.
interface CompileResult {
success: boolean;
binary_content?: string;
error?: string;
}
async function compileViaBackend(source: string): Promise<CompileResult> {
// Cache by source + fqbn hash so repeat runs are instant.
let hash = 0;
for (let i = 0; i < source.length; i++) {
hash = (hash * 31 + source.charCodeAt(i)) | 0;
}
const cachePath = join(
tmpdir(),
`velxio-esp32-fw-${FQBN.replace(/[^a-z0-9]/gi, '_')}-${hash >>> 0}.b64`,
);
if (existsSync(cachePath)) {
return { success: true, binary_content: readFileSync(cachePath, 'utf-8') };
}
// Submit the async compile job.
const startRes = await fetch(`${BACKEND_URL}/api/compile/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
files: [{ name: 'sketch.ino', content: source }],
board_fqbn: FQBN,
}),
redirect: 'follow',
});
if (!startRes.ok) {
return { success: false, error: `compile/start ${startRes.status}` };
}
const { job_id } = (await startRes.json()) as { job_id: string };
// Poll the status endpoint. ESP-IDF cold build takes ~75 s.
const deadline = Date.now() + 600_000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 5000));
const sr = await fetch(`${BACKEND_URL}/api/compile/status/${job_id}`);
if (!sr.ok) continue;
const s = (await sr.json()) as {
state: string;
result?: { success?: boolean; binary_content?: string; error?: string };
};
if (s.state === 'done') {
const b64 = s.result?.binary_content;
if (b64) writeFileSync(cachePath, b64);
return { success: true, binary_content: b64 };
}
if (s.state === 'error') {
return { success: false, error: s.result?.error ?? 'unknown' };
}
}
return { success: false, error: 'compile timeout' };
}
// ─── WebSocket helper — same protocol as Esp32Bridge ─────────────────────────
interface WsEvent {
type: string;
data?: unknown;
}
interface RunResult {
i2cEvents: Array<{ bus?: number; addr: number; event?: number; data?: number | number[] }>;
serial: string[];
systemEvents: string[];
errors: string[];
}
async function runFirmware(
firmwareB64: string,
timeoutMs: number,
stopWhen?: (r: RunResult) => boolean,
options?: {
/**
* Proxy I2C devices to install on the backend ProxySlave registry
* once the worker reports `booted`. Each entry mirrors the
* register dump of a peer board's virtual device so the ESP32
* firmware can read it synchronously inside QEMU.
*/
proxyI2c?: Array<{ addr: number; registers: Uint8Array }>;
},
): Promise<RunResult> {
const url = BACKEND_URL.replace(/^http/, 'ws') + `/api/simulation/ws/test-${Date.now()}`;
const ws = new WebSocket(url);
const result: RunResult = {
i2cEvents: [],
serial: [],
systemEvents: [],
errors: [],
};
await new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve();
ws.onerror = (e: any) => reject(new Error(`ws open failed: ${e?.message ?? e}`));
setTimeout(() => reject(new Error('ws open timeout')), 5000);
});
ws.send(
JSON.stringify({
type: 'start_esp32',
data: { board: 'esp32', firmware_b64: firmwareB64 },
}),
);
function sendProxies(): void {
const entries = options?.proxyI2c ?? [];
for (const e of entries) {
const regs_b64 = Buffer.from(e.registers).toString('base64');
ws.send(
JSON.stringify({
type: 'esp32_proxy_i2c_register',
data: { addr: e.addr & 0x7f, regs_b64 },
}),
);
}
}
await new Promise<void>((resolve) => {
const deadline = Date.now() + timeoutMs;
let proxiesSent = false;
const tick = setInterval(() => {
if (Date.now() >= deadline || (stopWhen && stopWhen(result))) {
clearInterval(tick);
try {
ws.send(JSON.stringify({ type: 'stop_esp32' }));
} catch {
/* ignore */
}
ws.close();
resolve();
}
}, 100);
ws.onmessage = (ev) => {
let msg: WsEvent;
try {
msg = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString());
} catch {
return;
}
switch (msg.type) {
case 'i2c_event':
case 'i2c_transaction':
result.i2cEvents.push(msg.data as any);
break;
case 'serial_output': {
const line = (msg.data as { data?: string })?.data ?? '';
if (line) result.serial.push(line);
break;
}
case 'system': {
const event = (msg.data as { event?: string })?.event;
if (event) result.systemEvents.push(event);
// Once the worker is booted, push any proxy I2C registrations
// so they land before the firmware's setup() does Wire.begin().
if (event === 'booted' && !proxiesSent) {
proxiesSent = true;
sendProxies();
}
break;
}
case 'error':
result.errors.push(JSON.stringify(msg.data));
break;
}
};
ws.onclose = () => {
clearInterval(tick);
resolve();
};
});
return result;
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('ESP32 I2C — backend + WebSocket + QEMU end-to-end', () => {
it.runIf(SKETCH_AVAILABLE && BACKEND_AVAILABLE)(
'compiles ESP32 sketch with ESP-IDF via backend',
async () => {
const source = readFileSync(SKETCH_PATH, 'utf-8');
const r = await compileViaBackend(source);
if (!r.success) {
// Backend may not have ESP-IDF wired up; fail loudly with
// the actual error so the user knows what to fix.
throw new Error(`compile failed: ${r.error}`);
}
expect(r.binary_content).toBeTruthy();
// Merged flash image is around 370 KB for a minimal sketch.
expect(r.binary_content!.length).toBeGreaterThan(50_000);
},
900_000,
);
it.runIf(SKETCH_AVAILABLE && BACKEND_AVAILABLE)(
'boots in QEMU and emits I2C events for Wire.beginTransmission(0x27)',
async () => {
const source = readFileSync(SKETCH_PATH, 'utf-8');
const compiled = await compileViaBackend(source);
expect(compiled.success).toBe(true);
// Run until BOTH at least 6 I2C events targeting 0x27 AND
// the "DONE" serial marker arrived. The sketch loops 5×
// (3 events each = 15 total) and then prints "DONE".
// Closing on the I2C count alone is racy — the serial
// flush may lag the I2C events by a few hundred ms.
const result = await runFirmware(compiled.binary_content!, 25_000, (r) => {
const i2cOk = r.i2cEvents.filter((e) => e.addr === 0x27).length >= 6;
const doneOk = r.serial.join('').toUpperCase().includes('DONE');
return i2cOk && doneOk;
});
// System lifecycle observed
expect(result.systemEvents).toContain('booting');
// booted is fired by lib_manager after the worker confirms QEMU started
expect(result.systemEvents).toContain('booted');
// No backend errors
expect(result.errors).toEqual([]);
// I2C activity on 0x27 from real Wire.h calls
const writes = result.i2cEvents.filter((e) => e.addr === 0x27);
expect(writes.length).toBeGreaterThan(0);
// Serial confirms setup() ran to completion
const serial = result.serial.join('').toUpperCase();
expect(serial).toContain('DONE');
},
900_000,
);
it.runIf(SKETCH_AVAILABLE && BACKEND_AVAILABLE)(
'system event sequence reflects boot → run → reboot/stop',
async () => {
const source = readFileSync(SKETCH_PATH, 'utf-8');
const compiled = await compileViaBackend(source);
const result = await runFirmware(compiled.binary_content!, 15_000);
expect(result.systemEvents[0]).toBe('booting');
expect(result.systemEvents).toContain('booted');
},
900_000,
);
});
// ─── Reverse direction: ESP32 master reads a peer board's I2C device ─────────
//
// Tests the cross-board proxy mechanism end-to-end:
// 1. A peer board (e.g. Uno) has a virtual I2CMemoryDevice with a known
// register dump at address 0x50.
// 2. Interconnect would normally install a ProxySlave on the ESP32's
// backend via the SDA+SCL bridge. In this isolated test we simulate
// Interconnect's behaviour by passing `proxyI2c` to runFirmware,
// which the test harness pushes to the worker right after `booted`.
// 3. The ESP32 firmware does Wire.requestFrom(0x50, 4) and echoes the
// bytes via Serial. Verifying the echo confirms the proxy responded
// with the right snapshot.
const READER_SKETCH_PATH = resolve(
__dirname,
'../../../test/test_custom_chips/sketches/esp32_i2c_reader/esp32_i2c_reader.ino',
);
const READER_SKETCH_AVAILABLE = existsSync(READER_SKETCH_PATH);
describe('ESP32 I2C — reverse-direction proxy (ESP32 master reads peer device)', () => {
it.runIf(READER_SKETCH_AVAILABLE && BACKEND_AVAILABLE)(
'reads 0xDE/0xAD/0xBE/0xEF from a ProxySlave-mirrored peer device',
async () => {
const source = readFileSync(READER_SKETCH_PATH, 'utf-8');
// Reuse compileViaBackend cache infra — different sketch, fresh hash.
const compiled = await (async () => {
// Inline minimal version of compileViaBackend so we don't have to
// refactor — same protocol calls, different sketch path.
let hash = 0;
for (let i = 0; i < source.length; i++) {
hash = (hash * 31 + source.charCodeAt(i)) | 0;
}
const cachePath = join(
tmpdir(),
`velxio-esp32-fw-${FQBN.replace(/[^a-z0-9]/gi, '_')}-${hash >>> 0}.b64`,
);
if (existsSync(cachePath)) {
return { success: true, binary_content: readFileSync(cachePath, 'utf-8') };
}
const r = await compileViaBackend(source);
return r;
})();
expect(compiled.success).toBe(true);
// Pre-load a 256-byte register snapshot — the I2CMemoryDevice on
// the peer board has registers[0..3] = {0xDE, 0xAD, 0xBE, 0xEF}.
const regs = new Uint8Array(256);
regs[0] = 0xde;
regs[1] = 0xad;
regs[2] = 0xbe;
regs[3] = 0xef;
const result = await runFirmware(
compiled.binary_content!,
30_000,
(r) => r.serial.join('').includes('DONE'),
{ proxyI2c: [{ addr: 0x50, registers: regs }] },
);
expect(result.errors).toEqual([]);
const ser = result.serial.join('');
expect(ser).toContain('BYTE[0]=0xDE');
expect(ser).toContain('BYTE[1]=0xAD');
expect(ser).toContain('BYTE[2]=0xBE');
expect(ser).toContain('BYTE[3]=0xEF');
expect(ser).toContain('DONE');
},
900_000,
);
it.runIf(READER_SKETCH_AVAILABLE && BACKEND_AVAILABLE)(
'no proxy registered → firmware reads garbage / NACK (control case)',
async () => {
const source = readFileSync(READER_SKETCH_PATH, 'utf-8');
const compiled = await compileViaBackend(source);
expect(compiled.success).toBe(true);
const result = await runFirmware(
compiled.binary_content!,
25_000,
(r) => r.serial.join('').includes('DONE'),
// No proxyI2c → no slave at 0x50 → firmware sees NACK / 0xFF.
);
const ser = result.serial.join('');
// Either the firmware printed garbage 0xFFs (NACK convention) or
// skipped the prints because Wire.available() returned 0. Both
// are valid "no proxy" outcomes — the key assertion is that we
// do NOT see the expected 0xDE/0xAD/0xBE/0xEF pattern.
expect(ser).not.toContain('BYTE[0]=0xDE');
expect(ser).not.toContain('BYTE[1]=0xAD');
},
900_000,
);
});
// ─── Phase 5: write-forwarding via proxy_i2c_complete event ─────────────────
const WRITER_TO_PEER_SKETCH_PATH = resolve(
__dirname,
'../../../test/test_custom_chips/sketches/esp32_i2c_write_to_peer/esp32_i2c_write_to_peer.ino',
);
const WRITER_TO_PEER_SKETCH_AVAILABLE = existsSync(WRITER_TO_PEER_SKETCH_PATH);
describe('ESP32 I2C — write-forwarding from QEMU back to frontend peer device', () => {
it.runIf(WRITER_TO_PEER_SKETCH_AVAILABLE && BACKEND_AVAILABLE)(
'firmware Wire.write(0xAA) to 0x27 reaches the proxy_i2c_complete handler',
async () => {
const source = readFileSync(WRITER_TO_PEER_SKETCH_PATH, 'utf-8');
const compiled = await compileViaBackend(source);
expect(compiled.success).toBe(true);
// We register a ProxySlave at 0x27 and watch for the
// proxy_i2c_complete event arriving when the firmware emits its
// STOP. The data array should include the byte the firmware
// wrote (0xAA).
const regs = new Uint8Array(256);
const url =
BACKEND_URL.replace(/^http/, 'ws') +
`/api/simulation/ws/test-${Date.now()}`;
const ws = new WebSocket(url);
const proxyCompletes: Array<{ addr: number; data: number[] }> = [];
let booted = false;
await new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve();
ws.onerror = (e: any) => reject(new Error(`ws: ${e?.message ?? e}`));
setTimeout(() => reject(new Error('ws open timeout')), 5000);
});
ws.send(
JSON.stringify({
type: 'start_esp32',
data: { board: 'esp32', firmware_b64: compiled.binary_content! },
}),
);
await new Promise<void>((resolve) => {
const deadline = Date.now() + 30_000;
const tick = setInterval(() => {
const done = proxyCompletes.some(
(p) => p.addr === 0x27 && p.data.includes(0xaa),
);
if (done || Date.now() >= deadline) {
clearInterval(tick);
try {
ws.send(JSON.stringify({ type: 'stop_esp32' }));
} catch {
/* ignore */
}
ws.close();
resolve();
}
}, 200);
ws.onmessage = (ev) => {
let msg: any;
try {
msg = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString());
} catch {
return;
}
if (msg.type === 'system' && msg.data?.event === 'booted' && !booted) {
booted = true;
const regs_b64 = Buffer.from(regs).toString('base64');
ws.send(
JSON.stringify({
type: 'esp32_proxy_i2c_register',
data: { addr: 0x27, regs_b64 },
}),
);
} else if (msg.type === 'proxy_i2c_complete') {
proxyCompletes.push({
addr: msg.data.addr,
data: msg.data.data,
});
}
};
ws.onclose = () => {
clearInterval(tick);
resolve();
};
});
const writes = proxyCompletes.filter((p) => p.addr === 0x27);
expect(writes.length).toBeGreaterThan(0);
expect(writes.some((w) => w.data.includes(0xaa))).toBe(true);
},
900_000,
);
});
describe.skipIf(BACKEND_AVAILABLE)(
'ESP32 I2C — skipped (backend not reachable at ' + BACKEND_URL + ')',
() => {
it('start backend: cd backend && .venv/Scripts/uvicorn app.main:app --port 8001', () => {
expect(true).toBe(true);
});
},
);
describe.skipIf(SKETCH_AVAILABLE)(
'ESP32 I2C — skipped (sketch fixture missing)',
() => {
it('expected ' + SKETCH_PATH, () => {
expect(true).toBe(true);
});
},
);