/** * i2c-real-firmware.test.ts * * Full-fidelity end-to-end I2C tests. The flow mirrors Velxio * production exactly: * * [.ino source on disk] * │ * ▼ spawnSync('arduino-cli', ['compile', …]) * [arduino-cli compile] ← same call the FastAPI backend * │ makes in services/arduino_cli.py * ▼ * [intel-hex string] * │ * ▼ sim.loadHex(hex) * [AVRSimulator + real avr8js CPU + real AVRTWI peripheral] * │ * ▼ sim.step() loop * [real I2CBusManager] * │ * ▼ * [registered virtual device | bridged peer bus | HD44780 decoder] * * The only thing skipped relative to production is the FastAPI HTTP * transport between frontend and backend, which is plain JSON * serialisation of the hex string — no I2C logic lives there. * * Fixtures * -------- * The sketches live under `velxio/test/test_custom_chips/sketches/` * as committed .ino source — the editor content the user would type. * Compiled hex files are produced on-demand and cached in the OS * tmpdir so subsequent test runs reuse them. * * Requirements * ------------ * - arduino-cli on PATH * - arduino:avr core installed * - LiquidCrystal_I2C library installed (for the LCD tests) * * If any of the above is missing the affected tests skip with a * clear note instead of failing — matches the convention in * pong-emulation.test.ts and mega-emulation.test.ts. */ import { describe, it, expect, beforeAll } from 'vitest'; import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { AVRSimulator } from '../simulation/AVRSimulator'; import { PinManager } from '../simulation/PinManager'; import { I2CBusManager, I2CMemoryDevice, VirtualPCF8574, } from '../simulation/I2CBusManager'; import { HD44780Decoder } from '../simulation/HD44780Decoder'; import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry'; import '../simulation/parts/ProtocolParts'; // ─── Availability gates ────────────────────────────────────────────────────── const ARDUINO_CLI_AVAILABLE = (() => { const r = spawnSync('arduino-cli', ['version'], { encoding: 'utf-8' }); return r.error == null && r.status === 0; })(); /** * Cheap probe: is a library installed and usable? We compile a * tiny shim that imports the library header — much faster than * parsing `arduino-cli lib list` output and immune to install * paths / sketchbook customisations. */ function hasLibrary(header: string): boolean { if (!ARDUINO_CLI_AVAILABLE) return false; const dir = mkdtempSync(join(tmpdir(), 'velxio-libprobe-')); const sketchDir = join(dir, 'probe'); mkdirSync(sketchDir); writeFileSync( join(sketchDir, 'probe.ino'), `#include <${header}>\nvoid setup(){}\nvoid loop(){}\n`, ); const r = spawnSync( 'arduino-cli', ['compile', '--fqbn', 'arduino:avr:uno', sketchDir], { encoding: 'utf-8', timeout: 60_000 }, ); return r.status === 0; } const LIQUID_CRYSTAL_I2C_AVAILABLE = ARDUINO_CLI_AVAILABLE ? hasLibrary('LiquidCrystal_I2C.h') : false; // ─── Compile-on-demand helper (mirrors backend/arduino_cli.py) ─────────────── /** * Resolve the path to one of the committed test sketches. */ function sketchSourcePath(name: string): string { return resolve( __dirname, '../../../test/test_custom_chips/sketches', name, `${name}.ino`, ); } /** * Compile a sketch with arduino-cli (subprocess), exactly like the * Velxio FastAPI backend does in services/arduino_cli.py:compile(). * Caches the produced hex per (sketch, fqbn, source mtime) so repeated * test runs against unchanged source are instant. * * Throws if arduino-cli is unavailable or compilation fails — the * caller is expected to gate on `ARDUINO_CLI_AVAILABLE` first. */ function compileSketch(name: string, fqbn = 'arduino:avr:uno'): string { const sourcePath = sketchSourcePath(name); if (!existsSync(sourcePath)) { throw new Error(`Sketch source missing: ${sourcePath}`); } // Cache key — invalidate when the .ino content changes. const source = readFileSync(sourcePath, 'utf-8'); let hash = 0; for (let i = 0; i < source.length; i++) { hash = (hash * 31 + source.charCodeAt(i)) | 0; } const hexCache = join( tmpdir(), `velxio-${name}-${fqbn.replace(/[^a-z0-9]/gi, '_')}-${hash >>> 0}.hex`, ); if (existsSync(hexCache)) { return readFileSync(hexCache, 'utf-8'); } // Stage the sketch in a temp dir — arduino-cli requires the // directory name to match the sketch name (so name/name.ino). const work = mkdtempSync(join(tmpdir(), `velxio-${name}-`)); const sketchDir = join(work, name); mkdirSync(sketchDir); writeFileSync(join(sketchDir, `${name}.ino`), source); const buildDir = join(work, 'build'); mkdirSync(buildDir); const result = spawnSync( 'arduino-cli', [ 'compile', '--fqbn', fqbn, '--output-dir', buildDir, '--build-path', buildDir, sketchDir, ], { encoding: 'utf-8', timeout: 180_000 }, ); if (result.error) { throw new Error(`arduino-cli not available: ${result.error.message}`); } if (result.status !== 0) { throw new Error( `arduino-cli compile failed (exit ${result.status}):\n${result.stderr}\n${result.stdout}`, ); } // Find the produced hex. arduino-cli emits ".ino.hex". let hex: string | null = null; for (const candidate of [ `${name}.ino.hex`, `${name}.hex`, 'sketch.ino.hex', ]) { const p = join(buildDir, candidate); if (existsSync(p)) { hex = readFileSync(p, 'utf-8'); break; } } if (!hex) { const files = readdirSync(buildDir, { recursive: true }); throw new Error( `Could not locate .hex output in ${buildDir} (saw: ${files.join(', ')})`, ); } writeFileSync(hexCache, hex); return hex; } // ─── Runtime helpers ───────────────────────────────────────────────────────── const EEPROM_STEP_BUDGET = 1_000_000; // Wire setup() takes ~200k cycles /** * LiquidCrystal_I2C v2.0.0 init contains delay(50) + delay(1000) + * 4 × delayMicroseconds(4500) BEFORE the first useful write. That's * ~1.07 s = ~17 M CPU cycles at 16 MHz. Plus the print() phase. * 30 M keeps us comfortably above that with margin for library bumps. */ const LCD_STEP_BUDGET = 30_000_000; function runUntil(sim: AVRSimulator, budget: number, predicate: () => boolean): number { for (let i = 0; i < budget; i++) { sim.step(); if ((i & 0x3ff) === 0 && predicate()) return i + 1; } return budget; } // ─── EEPROM-demo tests (compile + load + run) ──────────────────────────────── describe.runIf(ARDUINO_CLI_AVAILABLE)( 'I2C E2E — Wire master at 0x50 (full Velxio compile flow)', () => { let HEX: string; beforeAll(() => { HEX = compileSketch('i2c_eeprom_demo'); }); it('single board: master writes 0xAA..0xDD into a locally-registered device', () => { const sim = new AVRSimulator(new PinManager(), 'uno'); sim.loadHex(HEX); const eeprom = new I2CMemoryDevice(0x50); sim.addI2CDevice(eeprom); const steps = runUntil( sim, EEPROM_STEP_BUDGET, () => eeprom.registers[0] === 0xaa && eeprom.registers[1] === 0xbb && eeprom.registers[2] === 0xcc && eeprom.registers[3] === 0xdd, ); expect(steps).toBeLessThan(EEPROM_STEP_BUDGET); expect(eeprom.registers[0]).toBe(0xaa); expect(eeprom.registers[1]).toBe(0xbb); expect(eeprom.registers[2]).toBe(0xcc); expect(eeprom.registers[3]).toBe(0xdd); }); it('single board: master reads back via Wire.requestFrom and echoes via Serial', () => { const sim = new AVRSimulator(new PinManager(), 'uno'); sim.loadHex(HEX); sim.addI2CDevice(new I2CMemoryDevice(0x50)); const serialOut: number[] = []; sim.onSerialData = (ch) => serialOut.push(ch.charCodeAt(0)); runUntil(sim, EEPROM_STEP_BUDGET, () => serialOut.length >= 4); expect(serialOut.slice(0, 4)).toEqual([0xaa, 0xbb, 0xcc, 0xdd]); }); it('multi-board bridge: master on board A reaches device on board B', () => { const simA = new AVRSimulator(new PinManager(), 'uno'); simA.loadHex(HEX); // Peer "board" — only its I2CBusManager + device matter for // the slave-side path. A real RP2040Simulator or AVRSimulator // would equally work; we use a bare bus to keep the test fast // (no second CPU to step). const peerBus = new I2CBusManager({ completeStart() {}, completeStop() {}, completeConnect() {}, completeWrite() {}, completeRead() {}, }); const eeprom = new I2CMemoryDevice(0x50); peerBus.addDevice(eeprom); simA.i2cBus.attachBridge(peerBus); peerBus.attachBridge(simA.i2cBus); const serialOut: number[] = []; simA.onSerialData = (ch) => serialOut.push(ch.charCodeAt(0)); runUntil(simA, EEPROM_STEP_BUDGET, () => serialOut.length >= 4); // Writes landed on the PEER bus's device (not the master's, // which has no local 0x50). expect(eeprom.registers[0]).toBe(0xaa); expect(eeprom.registers[1]).toBe(0xbb); expect(eeprom.registers[2]).toBe(0xcc); expect(eeprom.registers[3]).toBe(0xdd); // Reads also flowed back through the bridge. expect(serialOut.slice(0, 4)).toEqual([0xaa, 0xbb, 0xcc, 0xdd]); }); }, ); // ─── LCD-I2C tests (need the LiquidCrystal_I2C library installed) ──────────── describe.runIf(LIQUID_CRYSTAL_I2C_AVAILABLE)( 'I2C E2E — LiquidCrystal_I2C → HD44780 decoder (full Velxio compile flow)', () => { let HEX: string; beforeAll(() => { HEX = compileSketch('lcd_i2c_hello'); }); it('renders "Hello" on row 0 and "World" on row 1', () => { const sim = new AVRSimulator(new PinManager(), 'uno'); sim.loadHex(HEX); const pcf = new VirtualPCF8574(0x27); const decoder = new HD44780Decoder({ cols: 16, rows: 2 }); const bytesSeen: number[] = []; pcf.onWrite = (v) => { bytesSeen.push(v); decoder.feedPCF8574Byte(v); }; sim.addI2CDevice(pcf); runUntil(sim, LCD_STEP_BUDGET, () => { const c = decoder.snapshot().characters; return c[0] === 'H'.charCodeAt(0) && c[20] === 'd'.charCodeAt(0); }); expect(bytesSeen.length).toBeGreaterThan(20); const snap = decoder.snapshot(); expect(String.fromCharCode(...snap.characters.slice(0, 5))).toBe('Hello'); expect(String.fromCharCode(...snap.characters.slice(16, 21))).toBe('World'); expect(snap.backlight).toBe(true); expect(snap.displayOn).toBe(true); }); it('end-to-end via the lcd1602-i2c part: element.characters reflects the print', () => { const sim = new AVRSimulator(new PinManager(), 'uno'); sim.loadHex(HEX); // Mock wokwi-lcd1602 element exactly as DynamicComponent // would render and pass to the part. const el: any = { pins: 'full', characters: new Uint8Array(32), backlight: true, i2cAddress: '0x27', addEventListener: () => {}, removeEventListener: () => {}, }; const detach = PartSimulationRegistry.get('lcd1602-i2c')!.attachEvents!( el as HTMLElement, sim as unknown as object as any, () => null, 'lcd-1', ); expect(el.pins).toBe('i2c'); runUntil(sim, LCD_STEP_BUDGET, () => { const c = el.characters as Uint8Array; return c[0] === 'H'.charCodeAt(0) && c[20] === 'd'.charCodeAt(0); }); const c = el.characters as Uint8Array; expect(String.fromCharCode(...c.slice(0, 5))).toBe('Hello'); expect(String.fromCharCode(...c.slice(16, 21))).toBe('World'); detach(); }); }, ); // ─── Skip notices when prerequisites are missing ───────────────────────────── describe.skipIf(ARDUINO_CLI_AVAILABLE)( 'I2C E2E — skipped because arduino-cli is not on PATH', () => { it('install arduino-cli to enable the full-fidelity I2C tests', () => { expect(true).toBe(true); }); }, ); describe.skipIf(LIQUID_CRYSTAL_I2C_AVAILABLE || !ARDUINO_CLI_AVAILABLE)( 'I2C E2E — LCD tests skipped because LiquidCrystal_I2C library is missing', () => { it('run: arduino-cli lib install "LiquidCrystal_I2C"', () => { expect(true).toBe(true); }); }, );