/** * test_micropython_esp32.mjs * * End-to-end diagnostic test for MicroPython on ESP32 via QEMU simulation. * * What it tests: * 1. MicroPython firmware is downloaded, correctly placed at flash offset 0x1000, * and padded to 4 MB (required by the firmware's CONFIG_ESPTOOLPY_FLASHSIZE_4MB). * 2. QEMU boots without flash-size errors. * 3. MicroPython REPL prompt ">>>" appears in serial output. * 4. A simple Python snippet is injected via raw REPL (Ctrl+A / code / Ctrl+D) * and the expected output line is observed. * * Flash layout: * [0x0000–0x0FFF] = 0xFF (ROM bootloader reads its own code from chip, not flash) * [0x1000–... ] = MicroPython .bin (2nd-stage bootloader at file offset 0) * Padded to 4 MB (firmware header declares 4 MB flash) * * Run: * node test/backend/e2e/test_micropython_esp32.mjs [--timeout=120] [--backend=http://localhost:8001] * * Prerequisites: Backend running on http://localhost:8001 */ // ─── Config ─────────────────────────────────────────────────────────────────── const BACKEND = process.env.BACKEND_URL ?? process.argv.find(a => a.startsWith('--backend='))?.slice(10) ?? 'http://localhost:8001'; const WS_BASE = BACKEND.replace(/^https?:/, m => m === 'https:' ? 'wss:' : 'ws:'); const SESSION = `test-micropython-${Date.now()}`; const TIMEOUT_S = parseInt( process.argv.find(a => a.startsWith('--timeout='))?.slice(10) ?? '120' ); // MicroPython firmware for ESP32 (LX6 / Xtensa dual-core) // Flash command: esptool.py write_flash -z 0x1000 → offset = 0x1000 const FIRMWARE_URL = 'https://micropython.org/resources/firmware/ESP32_GENERIC-20230426-v1.20.0.bin'; const FLASH_OFFSET = 0x1000; // bytes — 2nd-stage bootloader must start here const FLASH_SIZE = 4 * 1024 * 1024; // 4 MB — matches firmware's built-in flash config // Python code injected once ">>>" is seen const INJECT_CODE = [ 'import sys', 'print("velxio_micropython_ok")', 'print("py_version:" + sys.version.split(" ")[0])', 'print("math_check:" + str(6 * 7))', ].join('\n'); // ─── Logging ────────────────────────────────────────────────────────────────── const T0 = Date.now(); const ts = () => `[+${((Date.now() - T0) / 1000).toFixed(3)}s]`; const C = { INFO: '\x1b[36m', WARN: '\x1b[33m', ERROR: '\x1b[31m', OK: '\x1b[32m', SERIAL: '\x1b[32m', DIAG: '\x1b[33m', RESET: '\x1b[0m', }; const log = (lvl, ...a) => console.log(`${C[lvl] ?? ''}${ts()} [${lvl}]${C.RESET}`, ...a); const info = (...a) => log('INFO', ...a); const ok = (...a) => log('OK', ...a); const warn = (...a) => log('WARN', ...a); const err = (...a) => log('ERROR', ...a); const serial = (...a) => log('SERIAL', ...a); const diag = (...a) => log('DIAG', ...a); // ─── Step 1: Download MicroPython firmware ──────────────────────────────────── async function downloadFirmware() { info(`Downloading MicroPython firmware from ${FIRMWARE_URL} ...`); const controller = new AbortController(); const dlTimeout = setTimeout(() => controller.abort(), 60_000); try { const res = await fetch(FIRMWARE_URL, { signal: controller.signal }); if (!res.ok) throw new Error(`HTTP ${res.status} from firmware URL`); const buf = await res.arrayBuffer(); clearTimeout(dlTimeout); const bytes = new Uint8Array(buf); ok(`Downloaded ${bytes.length} bytes (${(bytes.length / 1024).toFixed(1)} KB)`); return bytes; } catch (e) { clearTimeout(dlTimeout); throw new Error(`Firmware download failed: ${e.message}`); } } // ─── Step 2: Build 4 MB flash image ────────────────────────────────────────── function buildFlashImage(firmware) { if (firmware.length + FLASH_OFFSET > FLASH_SIZE) { throw new Error(`Firmware (${firmware.length} B) + offset (${FLASH_OFFSET} B) > 4 MB`); } const image = new Uint8Array(FLASH_SIZE).fill(0xFF); image.set(firmware, FLASH_OFFSET); info(`Flash image: 4 MB, firmware at offset 0x${FLASH_OFFSET.toString(16).padStart(4, '0')}`); // Sanity-check: first byte at offset must be 0xE9 (ESP32 image magic) const magic = image[FLASH_OFFSET]; if (magic !== 0xE9) { warn(`Unexpected magic byte at 0x${FLASH_OFFSET.toString(16)}: 0x${magic.toString(16)} (expected 0xE9)`); } else { ok(`Magic byte 0xE9 confirmed at flash offset 0x${FLASH_OFFSET.toString(16)}`); } return image; } // ─── Step 3: Base64-encode ──────────────────────────────────────────────────── function toBase64(bytes) { // Node.js Buffer is the fastest path; fall back to btoa for browser compat if (typeof Buffer !== 'undefined') { return Buffer.from(bytes).toString('base64'); } let binary = ''; for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary); } // ─── Step 4: Run simulation via WebSocket ──────────────────────────────────── function runSimulation(firmware_b64) { return new Promise((resolve) => { const wsUrl = `${WS_BASE}/api/simulation/ws/${SESSION}`; info(`Connecting WebSocket → ${wsUrl}`); const ws = new WebSocket(wsUrl); // ── Collected evidence ────────────────────────────────────────────── const serialLines = []; let replState = 'idle'; // idle → banner_seen → prompt_seen → raw_repl_entered let replReady = false; // true once ">>>" confirmed (for result reporting) let codeInjected = false; // true once code bytes sent let outputOk = false; // "velxio_micropython_ok" received let mathCheck = false; // "math_check:42" received let flashError = false; // flash-size warning seen let bootError = false; // OSError/_boot.py error seen let serialBuf = ''; const globalTimer = setTimeout(() => { info(`Global timeout (${TIMEOUT_S}s)`); ws.close(); resolve({ timedOut: true, serialLines, replReady, outputOk, mathCheck, flashError, bootError, codeInjected }); }, TIMEOUT_S * 1000); // ── 4-stage state machine (mirrors Esp32Bridge.ts) ────────────────── function sendCodeInRawRepl() { if (codeInjected) return; codeInjected = true; info('Stage 3: raw REPL confirmed → sending code in 64-byte chunks'); diag(`Code:\n${INJECT_CODE}`); const codeBytes = Array.from(new TextEncoder().encode(INJECT_CODE)); const CHUNK_SIZE = 64; const CHUNK_DELAY_MS = 150; let offset = 0; const sendChunk = () => { if (offset >= codeBytes.length) { setTimeout(() => { ws.send(JSON.stringify({ type: 'esp32_serial_input', data: { bytes: [0x04] } })); replState = 'done'; info('Ctrl+D sent — code executing'); }, 300); return; } const chunk = codeBytes.slice(offset, offset + CHUNK_SIZE); ws.send(JSON.stringify({ type: 'esp32_serial_input', data: { bytes: chunk } })); offset += CHUNK_SIZE; setTimeout(sendChunk, CHUNK_DELAY_MS); }; sendChunk(); } ws.addEventListener('open', () => { ok('WebSocket connected'); ws.send(JSON.stringify({ type: 'start_esp32', data: { board: 'esp32', firmware_b64, sensors: [], wifi_enabled: false, }, })); info('Sent start_esp32 with MicroPython firmware (4 MB flash image)'); }); ws.addEventListener('message', ev => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } const { type, data } = msg; if (type === 'serial_output') { const text = data?.data ?? ''; serialBuf += text; for (const ch of text) process.stdout.write(ch); // ── 4-stage state machine (mirrors Esp32Bridge.ts) ────────────── // Stage 1: "Type help()" banner → poke \r to flush ">>> " from UART FIFO if (replState === 'idle' && serialBuf.includes('Type "help()"')) { replState = 'banner_seen'; info('Stage 1: banner seen → poking UART with \\r'); setTimeout(() => { ws.send(JSON.stringify({ type: 'esp32_serial_input', data: { bytes: [0x0D] } })); }, 800); } // Stage 2: ">>>" visible → send Ctrl+A to enter raw REPL if (replState === 'banner_seen' && serialBuf.includes('>>>')) { replState = 'prompt_seen'; replReady = true; serialBuf = ''; ok('Stage 2: >>> seen → sending Ctrl+A'); setTimeout(() => { ws.send(JSON.stringify({ type: 'esp32_serial_input', data: { bytes: [0x01] } })); }, 200); } // Stage 3: "raw REPL" confirmation → now safe to send code if (replState === 'prompt_seen' && serialBuf.includes('raw REPL')) { replState = 'raw_repl_entered'; serialBuf = ''; setTimeout(sendCodeInRawRepl, 200); } // Split on newlines for line-by-line error/output analysis let nl; while ((nl = serialBuf.indexOf('\n')) !== -1) { const line = serialBuf.slice(0, nl).replace(/\r$/, ''); serialBuf = serialBuf.slice(nl + 1); if (!line.trim()) continue; serialLines.push(line); serial(`UART: ${line}`); if (line.includes('smaller than the size in the binary image header')) { flashError = true; warn('Flash size mismatch!'); } if (line.includes('OSError') && line.includes('FLASH_NOT_INITIALISED')) { bootError = true; warn('_boot.py OSError — VFS init failed'); } if (line.startsWith('MicroPython ')) { ok(`MicroPython booted: ${line}`); } if (line.includes('velxio_micropython_ok')) { outputOk = true; ok('Output marker "velxio_micropython_ok" received ✓'); } if (line.includes('math_check:42')) { mathCheck = true; ok('Math check "6*7=42" confirmed ✓'); } if (outputOk && mathCheck) { clearTimeout(globalTimer); ws.close(); resolve({ timedOut: false, serialLines, replReady, outputOk, mathCheck, flashError, bootError, codeInjected }); } } if (serialBuf.length > 8192) serialBuf = serialBuf.slice(-1024); return; } if (type === 'system') { info(`system: ${JSON.stringify(data)}`); return; } if (type === 'error') { err(`simulation error: ${JSON.stringify(data)}`); return; } }); ws.addEventListener('close', ev => { clearTimeout(globalTimer); info(`WebSocket closed (code=${ev.code})`); resolve({ timedOut: false, serialLines, replReady, outputOk, mathCheck, flashError, bootError, codeInjected }); }); ws.addEventListener('error', ev => { err('WebSocket error', ev.message ?? ''); }); }); } // ─── Main ───────────────────────────────────────────────────────────────────── async function main() { console.log('\n' + '='.repeat(60)); console.log(' MicroPython ESP32 QEMU Simulation — E2E Diagnostic Test'); console.log('='.repeat(60) + '\n'); info(`Backend: ${BACKEND}`); info(`Timeout: ${TIMEOUT_S}s`); let exitCode = 0; try { // 1. Download firmware const rawFirmware = await downloadFirmware(); // 2. Build flash image (firmware at 0x1000, padded to 4 MB) const flashImage = buildFlashImage(rawFirmware); // 3. Base64 encode const firmware_b64 = toBase64(flashImage); info(`Flash image base64: ${Math.round(firmware_b64.length / 1024)} KB`); // 4. Run simulation const result = await runSimulation(firmware_b64); // ── Report ────────────────────────────────────────────────────────── console.log('\n' + '─'.repeat(60)); console.log(' Results'); console.log('─'.repeat(60)); console.log(` Timed out: ${result.timedOut}`); console.log(` Flash error: ${result.flashError} (should be false after 4 MB fix)`); console.log(` Boot error: ${result.bootError} (should be false after 4 MB fix)`); console.log(` REPL appeared: ${result.replReady}`); console.log(` Code injected: ${result.codeInjected}`); console.log(` Output received: ${result.outputOk}`); console.log(` Math check (42): ${result.mathCheck}`); console.log(` Serial lines: ${result.serialLines.length}`); console.log('─'.repeat(60) + '\n'); // ── Assertions ────────────────────────────────────────────────────── const FAIL = (msg) => { err(`FAIL: ${msg}`); exitCode = 1; }; if (result.flashError) FAIL('Flash size mismatch — padToFlashSize must produce a 4 MB image for ESP32'); if (result.bootError) FAIL('_boot.py OSError — flash not initialised (likely flash size mismatch)'); if (!result.replReady) FAIL('MicroPython REPL prompt ">>>" never appeared'); if (!result.outputOk) FAIL('"velxio_micropython_ok" not found in serial output (code injection failed?)'); if (!result.mathCheck) FAIL('"math_check:42" not found (6*7 computation did not execute)'); if (exitCode === 0) { ok('ALL CHECKS PASSED ✓'); } } catch (e) { err(`Fatal: ${e.message}`); console.error(e); exitCode = 1; } process.exit(exitCode); } main();