velxio/test/backend/e2e/test_esp32_spice_analog.mjs

358 lines
13 KiB
JavaScript

/**
* test_esp32_spice_analog.mjs
*
* Full end-to-end co-simulation test: ESP32 (QEMU via backend) + ngspice (WASM).
*
* What it tests:
* 1. Compile a minimal ESP32 sketch that reads analogRead(34) every 500 ms
* and prints "ADC34: raw=XXXX voltage=X.XXXV" via Serial.
* 2. Boot the ESP32 in QEMU via the backend WebSocket.
* 3. Run ngspice to solve a voltage divider circuit (R1=10k + R2=10k, Vcc=3.3V)
* producing V(mid) = 1.65 V.
* 4. Inject V(mid) into ESP32's ADC channel 6 (GPIO34) via `esp32_adc_set`.
* 5. Read Serial output and verify the ADC value matches the SPICE voltage
* within tolerance (12-bit ADC: 4096 counts over 3.3V → ±20 counts).
* 6. Update the circuit (R2=30k → V(mid)=2.475V), re-inject, and verify
* the ESP32 reads the new voltage.
*
* Run:
* cd test/backend/e2e && npm install && node test_esp32_spice_analog.mjs
*
* Prerequisites:
* - Backend running on http://localhost:8001
* - ESP32 Arduino core installed (`arduino-cli core install esp32:esp32`)
*/
import { Simulation } from 'eecircuit-engine';
// ─── 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-esp32-spice-${Date.now()}`;
const TIMEOUT_S = parseInt(
process.argv.find(a => a.startsWith('--timeout='))?.slice(10) ?? '90'
);
// ─── ESP32 ADC sketch ────────────────────────────────────────────────────────
const SKETCH = `// ESP32 ADC reader for SPICE co-simulation test
// Reads GPIO34 (ADC1_CH6) at 12-bit resolution
void setup() {
Serial.begin(115200);
analogReadResolution(12);
delay(500);
Serial.println("ESP32_ADC_READY");
}
void loop() {
int raw = analogRead(34);
float voltage = raw * 3.3 / 4095.0;
Serial.printf("ADC34: raw=%d voltage=%.3fV\\n", raw, voltage);
delay(500);
}`;
// ─── Logging ──────────────────────────────────────────────────────────────────
const T0 = Date.now();
const ts = () => `[+${((Date.now() - T0) / 1000).toFixed(3)}s]`;
const C = {
INFO: '\x1b[36m', OK: '\x1b[32m', ERROR: '\x1b[31m',
SERIAL: '\x1b[32m', SPICE: '\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 err = (...a) => log('ERROR', ...a);
const serial = (...a) => log('SERIAL', ...a);
const spice = (...a) => log('SPICE', ...a);
// ─── ngspice Engine (singleton) ──────────────────────────────────────────────
let sim = null;
async function bootNgspice() {
if (sim) return sim;
spice('Booting ngspice-WASM...');
sim = new Simulation();
await sim.start();
spice('ngspice ready');
return sim;
}
async function solveCircuit(r1, r2, vcc = 3.3) {
const engine = await bootNgspice();
const netlist = `Voltage divider R1=${r1} R2=${r2}
V1 vcc 0 DC ${vcc}
R1 vcc mid ${r1}
R2 mid 0 ${r2}
.op
.end`;
engine.setNetList(netlist);
const result = await engine.runSim();
const names = result.variableNames.map(n => n.toLowerCase());
const idx = names.indexOf('v(mid)');
if (idx < 0) throw new Error(`v(mid) not found in result: ${names}`);
const voltage = result.data[idx].values[0];
spice(`Solved: R1=${r1}, R2=${r2}, V(mid) = ${voltage.toFixed(4)}V`);
return voltage;
}
// ─── Step 1: Compile ──────────────────────────────────────────────────────────
async function compile() {
info('Compiling ESP32 ADC sketch...');
const res = await fetch(`${BACKEND}/api/compile/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
files: [{ name: 'sketch.ino', content: SKETCH }],
board_fqbn: 'esp32:esp32:esp32',
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Compilation HTTP ${res.status}: ${text.slice(0, 500)}`);
}
const body = await res.json();
if (!body.success) {
throw new Error(`Compilation error:\n${(body.error ?? body.stderr ?? 'unknown').slice(0, 500)}`);
}
const firmware_b64 = body.binary_content ?? body.firmware_b64;
if (!firmware_b64) throw new Error(`No firmware. Keys: ${Object.keys(body)}`);
ok(`Compiled -- ${Math.round(firmware_b64.length * 0.75 / 1024)} KB firmware`);
return firmware_b64;
}
// ─── Step 2: Run co-simulation ────────────────────────────────────────────────
function runCoSimulation(firmware_b64) {
return new Promise(async (resolve) => {
// Pre-solve two circuit configurations with ngspice
const v1 = await solveCircuit(10000, 10000, 3.3); // 1.65V
const v2 = await solveCircuit(10000, 30000, 3.3); // 2.475V
const wsUrl = `${WS_BASE}/api/simulation/ws/${SESSION}`;
info(`Connecting WebSocket -> ${wsUrl}`);
const ws = new WebSocket(wsUrl);
let serialLines = [];
let lineBuf = '';
let readyReceived = false;
let firstInjected = false;
let secondInjected = false;
let firstReadings = [];
let secondReadings = [];
let phase = 0; // 0=boot, 1=injected v1, 2=injected v2
const timer = setTimeout(() => {
info(`Timeout (${TIMEOUT_S}s)`);
ws.close();
resolve({
timedOut: true, firstReadings, secondReadings,
v1, v2, serialLines,
});
}, TIMEOUT_S * 1000);
ws.addEventListener('open', () => {
ok('WebSocket connected');
ws.send(JSON.stringify({
type: 'start_esp32',
data: {
board: 'esp32',
firmware_b64,
wifi_enabled: false,
},
}));
info('Sent start_esp32');
});
ws.addEventListener('message', ev => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
const { type, data } = msg;
if (type === 'serial_output') {
lineBuf += data?.data ?? '';
let nl;
while ((nl = lineBuf.indexOf('\n')) !== -1) {
const line = lineBuf.slice(0, nl).replace(/\r$/, '');
lineBuf = lineBuf.slice(nl + 1);
if (!line.trim()) continue;
serialLines.push(line);
serial(`UART: ${line}`);
// Detect ready signal
if (line.includes('ESP32_ADC_READY') && !readyReceived) {
readyReceived = true;
ok('ESP32 ADC ready -- injecting SPICE voltage #1');
// Inject v1 into ADC channel 6 (GPIO34)
const mv = Math.round(v1 * 1000);
ws.send(JSON.stringify({
type: 'esp32_adc_set',
data: { channel: 6, millivolts: mv },
}));
spice(`Injected V(mid) = ${v1.toFixed(3)}V (${mv} mV) into ADC CH6`);
phase = 1;
firstInjected = true;
}
// Parse ADC readings
const adcMatch = line.match(/ADC34:\s*raw=(\d+)\s+voltage=([\d.]+)V/);
if (adcMatch) {
const raw = parseInt(adcMatch[1]);
const vRead = parseFloat(adcMatch[2]);
if (phase === 1) {
firstReadings.push({ raw, voltage: vRead });
// After 3 readings at v1, switch to v2
if (firstReadings.length >= 3 && !secondInjected) {
info('3 readings at v1 collected -- injecting SPICE voltage #2');
const mv2 = Math.round(v2 * 1000);
ws.send(JSON.stringify({
type: 'esp32_adc_set',
data: { channel: 6, millivolts: mv2 },
}));
spice(`Injected V(mid) = ${v2.toFixed(3)}V (${mv2} mV) into ADC CH6`);
phase = 2;
secondInjected = true;
}
} else if (phase === 2) {
secondReadings.push({ raw, voltage: vRead });
if (secondReadings.length >= 3) {
clearTimeout(timer);
ws.close();
resolve({
timedOut: false, firstReadings, secondReadings,
v1, v2, serialLines,
});
}
}
}
}
return;
}
if (type === 'system') info(`system: ${JSON.stringify(data)}`);
if (type === 'error') err(`error: ${JSON.stringify(data)}`);
});
ws.addEventListener('close', () => {
clearTimeout(timer);
if (phase < 2) {
resolve({
timedOut: true, firstReadings, secondReadings,
v1, v2, serialLines,
});
}
});
ws.addEventListener('error', e => {
err(`WebSocket error: ${e.message ?? e}`);
});
});
}
// ─── Step 3: Validate results ────────────────────────────────────────────────
function validate(result) {
const { timedOut, firstReadings, secondReadings, v1, v2 } = result;
info('');
info('═══════════════════════════════════════════════════');
info(' Co-Simulation Results: ESP32 + ngspice');
info('═══════════════════════════════════════════════════');
// Expected ADC raw values (12-bit, 3.3V reference)
const expected1 = Math.round(v1 / 3.3 * 4095);
const expected2 = Math.round(v2 / 3.3 * 4095);
info(`Circuit 1: R1=10k, R2=10k -> V(mid)=${v1.toFixed(4)}V -> expected ADC=${expected1}`);
info(`Circuit 2: R1=10k, R2=30k -> V(mid)=${v2.toFixed(4)}V -> expected ADC=${expected2}`);
info('');
let pass = true;
if (timedOut) {
err('Test timed out before collecting enough readings');
pass = false;
}
// Check first batch
if (firstReadings.length < 1) {
err('No ADC readings received after first injection');
pass = false;
} else {
const avg1 = firstReadings.reduce((s, r) => s + r.raw, 0) / firstReadings.length;
info(`First batch: ${firstReadings.length} readings, avg raw=${avg1.toFixed(0)} (expected ${expected1})`);
// Tolerance: ±50 counts (generous for QEMU ADC emulation + timing)
if (Math.abs(avg1 - expected1) > 50) {
err(`First batch off by ${Math.abs(avg1 - expected1).toFixed(0)} counts (tolerance: 50)`);
pass = false;
} else {
ok(`First batch within tolerance`);
}
}
// Check second batch
if (secondReadings.length < 1) {
err('No ADC readings received after second injection');
pass = false;
} else {
const avg2 = secondReadings.reduce((s, r) => s + r.raw, 0) / secondReadings.length;
info(`Second batch: ${secondReadings.length} readings, avg raw=${avg2.toFixed(0)} (expected ${expected2})`);
if (Math.abs(avg2 - expected2) > 50) {
err(`Second batch off by ${Math.abs(avg2 - expected2).toFixed(0)} counts (tolerance: 50)`);
pass = false;
} else {
ok(`Second batch within tolerance`);
}
}
// Check that the two batches are DIFFERENT (proving the circuit change was detected)
if (firstReadings.length > 0 && secondReadings.length > 0) {
const avg1 = firstReadings.reduce((s, r) => s + r.raw, 0) / firstReadings.length;
const avg2 = secondReadings.reduce((s, r) => s + r.raw, 0) / secondReadings.length;
if (Math.abs(avg2 - avg1) < 100) {
err(`First and second batches too similar (delta=${Math.abs(avg2 - avg1).toFixed(0)}). Circuit change not detected.`);
pass = false;
} else {
ok(`Circuit change detected: delta=${Math.abs(avg2 - avg1).toFixed(0)} counts`);
}
}
info('');
if (pass) {
ok('ALL CHECKS PASSED -- ESP32 + ngspice co-simulation works!');
process.exit(0);
} else {
err('SOME CHECKS FAILED');
process.exit(1);
}
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
info('ESP32 + ngspice analog co-simulation E2E test');
info(`Backend: ${BACKEND}`);
info(`Timeout: ${TIMEOUT_S}s`);
info('');
try {
// Boot ngspice engine (async, ~400ms)
await bootNgspice();
// Compile sketch
const firmware = await compile();
// Run the co-simulation
const result = await runCoSimulation(firmware);
// Validate
validate(result);
} catch (e) {
err(`Fatal: ${e.message}`);
if (e.message?.includes('fetch')) {
err('Is the backend running? Start with: cd backend && uvicorn app.main:app --port 8001');
}
process.exit(1);
}
}
main();