velxio/test/test_micropython_i2c_minimal/test.mjs

335 lines
13 KiB
JavaScript

/**
* test_micropython_i2c_minimal.mjs — Phase 1 reproduction test
*
* Boots ESP32 MicroPython via velxio QEMU, registers an SSD1306 I2C slave
* at 0x3C, then runs the SMALLEST possible MicroPython program that
* touches `machine.I2C(0, ...).scan()` and writes one byte. The goal is
* to confirm in isolation (no SSD1306 driver, no helper libs) that the
* reboot reproduces, narrowing the bug to the QEMU I2C peripheral
* emulation itself (not the ssd1306.py driver or the example's main.py).
*
* EXPECTED FAIL (before the QEMU fix lands):
* - REPL boots, code injection succeeds
* - Right after `i2c = I2C(0, ...)` the chip reboots (system event=reboot)
* - WebSocket closes with code 1006
*
* EXPECTED PASS (after the fix):
* - `i2c.scan()` returns `[60]` (0x3C — the registered SSD1306 slave)
* - `i2c.writeto(0x3C, b'\\x00')` returns OK (no OSError)
* - `velxio_i2c_done` marker printed
* - No reboot, no premature WS close
*
* Heavily based on `test/backend/e2e/test_micropython_esp32.mjs` (same
* firmware download + 4 MB flash image + raw-REPL injection state machine).
*
* Run:
* node test/test_micropython_i2c_minimal/test.mjs [--timeout=60] [--backend=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-mp-i2c-${Date.now()}`;
const TIMEOUT_S = parseInt(
process.argv.find(a => a.startsWith('--timeout='))?.slice(10) ?? '60'
);
// Same MicroPython firmware as the frontend ships
const FIRMWARE_URL = 'https://micropython.org/resources/firmware/ESP32_GENERIC-20230426-v1.20.0.bin';
const FLASH_OFFSET = 0x1000;
const FLASH_SIZE = 4 * 1024 * 1024;
// Minimal I2C test — no ssd1306 driver, no helper libs. Just touches the
// hardware I2C peripheral the same way ssd1306.py does on its first
// write. Each step prints a tag so we can pinpoint which call rebooted.
const INJECT_CODE = [
'from machine import Pin, I2C',
'print("velxio_i2c_pre")', // marker before I2C touch
'i2c = I2C(0, scl=Pin(22), sda=Pin(21))',
'print("velxio_i2c_ctor_ok")', // ctor survived
'devs = i2c.scan()',
'print("velxio_i2c_scan_ok", devs)', // scan survived + result
'try:',
' i2c.writeto(0x3C, b"\\xA0")', // single byte write — same as ssd1306 init does first
' print("velxio_i2c_write_ok")', // write survived
'except OSError as e:',
' print("velxio_i2c_write_err", e)',
'print("velxio_i2c_done")',
].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[35m', 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);
// ─── Firmware fetch + 4MB flash image (copied from test_micropython_esp32.mjs) ─
async function downloadFirmware() {
info(`Downloading MicroPython firmware from ${FIRMWARE_URL} ...`);
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 60_000);
try {
const res = await fetch(FIRMWARE_URL, { signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const bytes = new Uint8Array(await res.arrayBuffer());
clearTimeout(t);
ok(`Downloaded ${bytes.length} bytes`);
return bytes;
} finally { clearTimeout(t); }
}
function buildFlashImage(firmware) {
const image = new Uint8Array(FLASH_SIZE).fill(0xFF);
image.set(firmware, FLASH_OFFSET);
if (image[FLASH_OFFSET] !== 0xE9) {
warn(`Unexpected magic at 0x${FLASH_OFFSET.toString(16)}: 0x${image[FLASH_OFFSET].toString(16)}`);
}
return image;
}
const toBase64 = (bytes) => Buffer.from(bytes).toString('base64');
// ─── Simulation ───────────────────────────────────────────────────────────────
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);
const markers = new Set();
let replState = 'idle';
let replReady = false;
let codeInjected = false;
let i2cScanResult = null;
let writeErr = null;
let serialBuf = '';
let systemReboot = false;
let wsCloseCode = null;
const systemEvents = [];
const globalTimer = setTimeout(() => {
info(`Global timeout (${TIMEOUT_S}s)`);
ws.close();
finish({ timedOut: true });
}, TIMEOUT_S * 1000);
function finish(extra = {}) {
clearTimeout(globalTimer);
resolve({
replReady, codeInjected,
markers: [...markers],
i2cScanResult, writeErr,
systemReboot, systemEvents, wsCloseCode,
...extra,
});
}
function sendCodeInRawRepl() {
if (codeInjected) return;
codeInjected = true;
info('Stage 3: raw REPL confirmed → sending code (64-byte chunks)');
const codeBytes = Array.from(new TextEncoder().encode(INJECT_CODE));
const CHUNK = 64, DELAY = 150;
let offset = 0;
const sendChunk = () => {
if (offset >= codeBytes.length) {
setTimeout(() => {
ws.send(JSON.stringify({ type: 'esp32_serial_input', data: { bytes: [0x04] } }));
info('Ctrl+D sent — code executing');
}, 300);
return;
}
const chunk = codeBytes.slice(offset, offset + CHUNK);
ws.send(JSON.stringify({ type: 'esp32_serial_input', data: { bytes: chunk } }));
offset += CHUNK;
setTimeout(sendChunk, DELAY);
};
sendChunk();
}
ws.addEventListener('open', () => {
ok('WebSocket connected');
ws.send(JSON.stringify({
type: 'start_esp32',
data: {
board: 'esp32',
firmware_b64,
// CRITICAL: register the SSD1306 slave at 0x3C so the worker
// ACKs the write. virtualPin = 200 + addr (per the ProtocolParts
// pattern in the frontend).
sensors: [{ sensor_type: 'ssd1306', pin: 200 + 0x3C, addr: 0x3C }],
wifi_enabled: false,
},
}));
info('Sent start_esp32 with sensors=[{ssd1306@0x3C}]');
});
ws.addEventListener('message', ev => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
const { type, data } = msg;
if (type === 'system') {
systemEvents.push(data);
info(`system: ${JSON.stringify(data)}`);
if (data?.event === 'reboot' || data?.status === 'reboot' || String(data).includes('reboot')) {
warn('!!! ESP32 REBOOTED — this is the bug we are chasing');
systemReboot = true;
}
return;
}
if (type === 'serial_output') {
const text = data?.data ?? '';
serialBuf += text;
for (const ch of text) process.stdout.write(ch);
// 4-stage state machine (same as the frontend Esp32Bridge)
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);
}
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);
}
if (replState === 'prompt_seen' && serialBuf.includes('raw REPL')) {
replState = 'raw_repl_entered';
serialBuf = '';
setTimeout(sendCodeInRawRepl, 200);
}
// Scan line-by-line for our injection markers
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;
// velxio_i2c_* markers tell us WHICH I2C call survived
if (line.includes('velxio_i2c_pre')) markers.add('pre');
if (line.includes('velxio_i2c_ctor_ok')) markers.add('ctor_ok');
if (line.includes('velxio_i2c_scan_ok')) {
markers.add('scan_ok');
const m = line.match(/velxio_i2c_scan_ok\s+(.+)/);
if (m) i2cScanResult = m[1].trim();
}
if (line.includes('velxio_i2c_write_ok')) markers.add('write_ok');
if (line.includes('velxio_i2c_write_err')) {
markers.add('write_err');
const m = line.match(/velxio_i2c_write_err\s+(.+)/);
if (m) writeErr = m[1].trim();
}
if (line.includes('velxio_i2c_done')) markers.add('done');
if (line.includes('Traceback')) warn(`TRACEBACK: ${line}`);
}
if (markers.has('done')) {
ok('Reached velxio_i2c_done — test complete');
ws.close();
finish();
}
if (serialBuf.length > 4096) serialBuf = serialBuf.slice(-512);
return;
}
if (type === 'error') {
err(`simulation error: ${JSON.stringify(data)}`);
return;
}
});
ws.addEventListener('close', ev => {
wsCloseCode = ev.code;
info(`WebSocket closed (code=${ev.code})`);
finish();
});
ws.addEventListener('error', ev => err('WebSocket error', ev.message ?? ''));
});
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
console.log('\n' + '='.repeat(70));
console.log(' Phase 1 — MicroPython I2C minimal reproduction');
console.log('='.repeat(70) + '\n');
info(`Backend: ${BACKEND}`);
info(`Timeout: ${TIMEOUT_S}s`);
let exitCode = 0;
try {
const fw = await downloadFirmware();
const image = buildFlashImage(fw);
const b64 = toBase64(image);
info(`Flash image: ${Math.round(b64.length / 1024)} KB base64`);
const r = await runSimulation(b64);
console.log('\n' + '─'.repeat(70));
console.log(' Results');
console.log('─'.repeat(70));
console.log(` REPL ready: ${r.replReady}`);
console.log(` Code injected: ${r.codeInjected}`);
console.log(` Markers reached: ${JSON.stringify(r.markers)}`);
console.log(` i2c.scan() result: ${r.i2cScanResult ?? '(never reached)'}`);
console.log(` i2c write error: ${r.writeErr ?? '(none)'}`);
console.log(` System reboot: ${r.systemReboot}`);
console.log(` WS close code: ${r.wsCloseCode ?? '(open)'}`);
console.log(` Timed out: ${r.timedOut ?? false}`);
console.log('─'.repeat(70) + '\n');
// Phase 1 diagnostic — we're not asserting PASS yet, we're collecting
// evidence of WHICH call rebooted. Use the marker set to localize.
const lastMarker = ['done','write_ok','write_err','scan_ok','ctor_ok','pre']
.find(m => r.markers.includes(m));
if (r.systemReboot) {
const where = lastMarker
? `AFTER reaching marker "${lastMarker}"`
: 'BEFORE any marker (very early — code injection may not have started)';
console.log(`Bug LOCATION: ESP32 rebooted ${where}.`);
console.log(' → If lastMarker = "scan_ok": reboot triggered by writeto()');
console.log(' → If lastMarker = "ctor_ok": reboot triggered by scan()');
console.log(' → If lastMarker = "pre": reboot triggered by I2C() constructor');
exitCode = 1;
} else if (!r.markers.includes('done')) {
warn('No reboot but test did not complete — likely a different bug. See serial output above.');
exitCode = 1;
} else {
ok(`I2C path complete. scan=${r.i2cScanResult}, writeErr=${r.writeErr ?? 'none'}`);
if (r.i2cScanResult === '[60]' && !r.writeErr) {
ok('Phase 1 PASSED — I2C hardware fully functional');
} else {
warn(`Phase 1 PARTIAL — scan returned ${r.i2cScanResult} (expected [60]), writeErr=${r.writeErr ?? 'none'}`);
exitCode = 1;
}
}
} catch (e) {
err(`Fatal: ${e.message}`);
console.error(e);
exitCode = 1;
}
process.exit(exitCode);
}
main();