velxio/frontend/src/__tests__/i2c-complex-scenarios.test.ts

562 lines
19 KiB
TypeScript
Raw Normal View History

/**
* i2c-complex-scenarios.test.ts
*
* Complex multi-device / multi-board I2C scenarios driven by REAL
* Arduino sketches compiled on demand by arduino-cli — the exact
* production flow Velxio runs when a user clicks Run.
*
* Coverage matrix
* ---------------
* I2C scanner over a populated bus → multi-device discovery
* DS1307 + LCD-I2C clock combo → two devices on same bus,
* BCD decode + HD44780 print
* BMP280 read across a bridged peer → multi-board I2C, register
* reads through the bridge
* PCF8574 bidirectional I/O → read+write round-trip on
* the same device
*
* Each scenario uses the same `compileSketch()` helper as
* `i2c-real-firmware.test.ts` (subprocess invocation of arduino-cli,
* identical to backend/services/arduino_cli.py:compile()). Tests
* skip gracefully if arduino-cli or the required library is missing.
*/
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 { RP2040Simulator } from '../simulation/RP2040Simulator';
import { PinManager } from '../simulation/PinManager';
import {
I2CBusManager,
I2CMemoryDevice,
VirtualBMP280,
VirtualDS1307,
VirtualPCF8574,
} from '../simulation/I2CBusManager';
import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry';
import '../simulation/parts/ProtocolParts';
// ─── Availability gates (same shape as i2c-real-firmware.test.ts) ────────────
const ARDUINO_CLI_AVAILABLE = (() => {
const r = spawnSync('arduino-cli', ['version'], { encoding: 'utf-8' });
return r.error == null && r.status === 0;
})();
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 helper — mirrors backend/services/arduino_cli.py exactly ────────
function sketchSourcePath(name: string): string {
return resolve(
__dirname,
'../../../test/test_custom_chips/sketches',
name,
`${name}.ino`,
);
}
/**
* Compile a sketch. Returns the artifact contents — `.hex` text for
* AVR boards, base64 of `.bin` for RP2040. Velxio's frontend follows
* the same split (AVRSimulator.loadHex vs RP2040Simulator.loadBinary).
*/
function compileSketch(name: string, fqbn = 'arduino:avr:uno'): string {
const sourcePath = sketchSourcePath(name);
if (!existsSync(sourcePath)) {
throw new Error(`Sketch source missing: ${sourcePath}`);
}
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 ext = fqbn.startsWith('rp2040:') ? 'binb64' : 'hex';
const cachePath = join(
tmpdir(),
`velxio-${name}-${fqbn.replace(/[^a-z0-9]/gi, '_')}-${hash >>> 0}.${ext}`,
);
if (existsSync(cachePath)) {
return readFileSync(cachePath, 'utf-8');
}
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: 240_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}`,
);
}
let artifact: string | null = null;
if (ext === 'binb64') {
// RP2040: pick up the raw .bin and base64-encode it.
for (const candidate of [`${name}.ino.bin`, `${name}.bin`]) {
const p = join(buildDir, candidate);
if (existsSync(p)) {
artifact = readFileSync(p).toString('base64');
break;
}
}
} else {
for (const candidate of [`${name}.ino.hex`, `${name}.hex`, 'sketch.ino.hex']) {
const p = join(buildDir, candidate);
if (existsSync(p)) {
artifact = readFileSync(p, 'utf-8');
break;
}
}
}
if (!artifact) {
const files = readdirSync(buildDir, { recursive: true });
throw new Error(
`Could not locate ${ext} output in ${buildDir} (saw: ${files.join(', ')})`,
);
}
writeFileSync(cachePath, artifact);
return artifact;
}
// ─── Step-budget helper ──────────────────────────────────────────────────────
/**
* Drive a simulator's CPU until predicate() returns true or budget
* is exhausted. We check the predicate sparsely (every 1024 steps)
* because most predicates only flip at I2C transaction boundaries
* which are >100 cycles apart — checking on every step would just
* be overhead.
*/
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;
}
// ─── 1. I2C scanner over a populated bus ─────────────────────────────────────
describe.runIf(ARDUINO_CLI_AVAILABLE)(
'I2C scenario — scanner finds every registered device + NACKs the rest',
() => {
let HEX: string;
beforeAll(() => {
HEX = compileSketch('i2c_scanner_multi');
});
it('detects exactly the addresses that have a registered device', () => {
const sim = new AVRSimulator(new PinManager(), 'uno');
sim.loadHex(HEX);
// Register devices at 5 distinct addresses spanning the
// common I2C device-id range. The scanner walks 1..126,
// so a NACK on any non-registered address must NOT produce
// a Serial byte.
const REGISTERED = [0x10, 0x27, 0x3c, 0x68, 0x76];
for (const addr of REGISTERED) {
sim.addI2CDevice(new I2CMemoryDevice(addr));
}
const out: number[] = [];
sim.onSerialData = (ch) => out.push(ch.charCodeAt(0));
// Scanner walks 126 addresses × ~100 µs per attempt = ~13 ms
// plus Wire.begin overhead. 500 k cycles is generous.
runUntil(sim, 1_500_000, () => out[out.length - 1] === 0xff);
// Last byte is the 0xFF terminator; trim it off to get the
// discovered address list.
expect(out[out.length - 1]).toBe(0xff);
const discovered = out.slice(0, -1).sort((a, b) => a - b);
expect(discovered).toEqual(REGISTERED);
});
it('an empty bus produces zero discovered addresses', () => {
const sim = new AVRSimulator(new PinManager(), 'uno');
sim.loadHex(HEX);
// No devices registered.
const out: number[] = [];
sim.onSerialData = (ch) => out.push(ch.charCodeAt(0));
runUntil(sim, 1_500_000, () => out[out.length - 1] === 0xff);
expect(out).toEqual([0xff]);
});
it('finds devices that live on a bridged peer bus', () => {
const sim = new AVRSimulator(new PinManager(), 'uno');
sim.loadHex(HEX);
// Local devices on the Uno's own bus...
sim.addI2CDevice(new I2CMemoryDevice(0x10));
sim.addI2CDevice(new I2CMemoryDevice(0x27));
// ...plus devices on a peer board's bus reached via bridge.
const peerBus = new I2CBusManager({
completeStart() {},
completeStop() {},
completeConnect() {},
completeWrite() {},
completeRead() {},
});
peerBus.addDevice(new I2CMemoryDevice(0x3c));
peerBus.addDevice(new I2CMemoryDevice(0x76));
sim.i2cBus.attachBridge(peerBus);
peerBus.attachBridge(sim.i2cBus);
const out: number[] = [];
sim.onSerialData = (ch) => out.push(ch.charCodeAt(0));
runUntil(sim, 1_500_000, () => out[out.length - 1] === 0xff);
const discovered = out.slice(0, -1).sort((a, b) => a - b);
expect(discovered).toEqual([0x10, 0x27, 0x3c, 0x76]);
});
},
);
// ─── 2. DS1307 RTC + LCD-I2C clock combo ─────────────────────────────────────
describe.runIf(LIQUID_CRYSTAL_I2C_AVAILABLE)(
'I2C scenario — RTC + LCD-I2C clock (two devices, same bus)',
() => {
let HEX: string;
beforeAll(() => {
HEX = compileSketch('rtc_lcd_clock');
});
it('renders "Time:" + valid HH:MM:SS from a real DS1307 + LiquidCrystal_I2C combo', () => {
const sim = new AVRSimulator(new PinManager(), 'uno');
sim.loadHex(HEX);
// Device 1: virtual DS1307 returning host wall-clock time.
sim.addI2CDevice(new VirtualDS1307());
// Device 2: the LCD-I2C part (uses VirtualPCF8574 + HD44780
// decoder internally). Attach against a mock wokwi-lcd1602
// shaped element.
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',
);
// Init delay is ~1 s + write phase. Budget 30M cycles.
// Wait for the LAST seconds digit (row 1, col 7 = flat index
// 23) to land — that's the final character written by the
// sketch, after which DDRAM is fully populated.
runUntil(sim, 30_000_000, () => {
const c = el.characters as Uint8Array;
const lastSec = c[23];
return (
c[0] === 'T'.charCodeAt(0) &&
lastSec >= 0x30 &&
lastSec <= 0x39
);
});
const c = el.characters as Uint8Array;
const row0 = String.fromCharCode(...c.slice(0, 16));
const row1 = String.fromCharCode(...c.slice(16, 32));
expect(row0.startsWith('Time:')).toBe(true);
// row 1 should be "HH:MM:SS" padded with spaces.
const time = row1.slice(0, 8);
expect(time).toMatch(/^\d{2}:\d{2}:\d{2}$/);
// Sanity: hours/minutes/seconds are in valid ranges.
const [hh, mm, ss] = time.split(':').map((s) => parseInt(s, 10));
expect(hh).toBeGreaterThanOrEqual(0);
expect(hh).toBeLessThanOrEqual(23);
expect(mm).toBeGreaterThanOrEqual(0);
expect(mm).toBeLessThanOrEqual(59);
expect(ss).toBeGreaterThanOrEqual(0);
expect(ss).toBeLessThanOrEqual(59);
// Compare against host clock — should be within ~5 s
// (test runtime + I2C overhead).
const now = new Date();
const displayedSecondsOfDay = hh * 3600 + mm * 60 + ss;
const nowSecondsOfDay =
now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
const drift = Math.abs(displayedSecondsOfDay - nowSecondsOfDay);
// Allow for day rollover at midnight.
expect(Math.min(drift, 86400 - drift)).toBeLessThan(10);
detach();
});
},
);
// ─── 3. Multi-board BMP280 via bridge ────────────────────────────────────────
describe.runIf(ARDUINO_CLI_AVAILABLE)(
'I2C scenario — Uno reads BMP280 chip_id from a bridged peer bus',
() => {
let HEX: string;
beforeAll(() => {
HEX = compileSketch('bmp280_bridge_reader');
});
it('returns the BMP280 chip_id (0x58) and status (0x00) through the bridge', () => {
const sim = new AVRSimulator(new PinManager(), 'uno');
sim.loadHex(HEX);
// Peer board: just a bus + the BMP280. The Uno has no local
// 0x76 device; the only way these reads can succeed is via
// the cross-board bridge.
const peerBus = new I2CBusManager({
completeStart() {},
completeStop() {},
completeConnect() {},
completeWrite() {},
completeRead() {},
});
peerBus.addDevice(new VirtualBMP280(0x76));
sim.i2cBus.attachBridge(peerBus);
peerBus.attachBridge(sim.i2cBus);
const out: number[] = [];
sim.onSerialData = (ch) => out.push(ch.charCodeAt(0));
runUntil(sim, 2_000_000, () => out.length >= 2);
expect(out[0]).toBe(0x58); // BMP280 production chip_id
expect(out[1]).toBe(0x00); // status: measurement complete
});
it('returns NACK behaviour when the bridge is torn down mid-sketch', () => {
// Cold simulator — no devices anywhere, no bridge. The
// sketch should still complete (Wire.requestFrom NACKs and
// returns 0 bytes), and Serial output should be empty since
// `if (Wire.available())` gates the writes.
const sim = new AVRSimulator(new PinManager(), 'uno');
sim.loadHex(HEX);
const out: number[] = [];
sim.onSerialData = (ch) => out.push(ch.charCodeAt(0));
// Run long enough for setup() to complete entirely.
runUntil(sim, 2_000_000, () => false);
// No bytes should have been emitted. This proves the
// sketch's `if (Wire.available())` guards behave correctly
// — meaning the I2CBusManager genuinely NACKed when no
// device matched.
expect(out).toEqual([]);
});
},
);
// ─── 4. PCF8574 bidirectional I/O on a single device ─────────────────────────
describe.runIf(ARDUINO_CLI_AVAILABLE)(
'I2C scenario — PCF8574 bidirectional read+write round-trips',
() => {
let HEX: string;
beforeAll(() => {
HEX = compileSketch('pcf8574_bidirectional');
});
it('round-trips three different patterns through the same device', () => {
const sim = new AVRSimulator(new PinManager(), 'uno');
sim.loadHex(HEX);
const pcf = new VirtualPCF8574(0x27);
// Default portState = 0xFF (everything externally pulled
// high), so reads should mirror outputLatch.
sim.addI2CDevice(pcf);
const out: number[] = [];
sim.onSerialData = (ch) => out.push(ch.charCodeAt(0));
runUntil(sim, 1_500_000, () => out.length >= 3);
expect(out[0]).toBe(0xaa);
expect(out[1]).toBe(0x55);
expect(out[2]).toBe(0xff);
// After the last write the latch should hold 0xFF.
expect(pcf.outputLatch).toBe(0xff);
});
it('external input wins when output latch is HIGH (open-drain semantics)', () => {
const sim = new AVRSimulator(new PinManager(), 'uno');
sim.loadHex(HEX);
const pcf = new VirtualPCF8574(0x27);
// External inputs hold lower nibble LOW. After the sketch
// releases everything with 0xFF, the read-back should reflect
// those external lows.
pcf.portState = 0b11110000;
sim.addI2CDevice(pcf);
const out: number[] = [];
sim.onSerialData = (ch) => out.push(ch.charCodeAt(0));
runUntil(sim, 1_500_000, () => out.length >= 3);
// Round 1: write 0xAA (10101010), AND with portState (11110000)
// = 10100000 = 0xA0
// Round 2: write 0x55 (01010101), AND with 11110000
// = 01010000 = 0x50
// Round 3: write 0xFF, AND with 11110000 = 11110000 = 0xF0
expect(out[0]).toBe(0xa0);
expect(out[1]).toBe(0x50);
expect(out[2]).toBe(0xf0);
});
},
);
// ─── 5. Cross-architecture: Pi Pico master reads BMP280 via bridge ──────────
//
// The Pico runs a REAL compiled rp2040 binary (earlephilhower core)
// driven by rp2040js. Its Wire master TWI talks to a BMP280 that
// lives on a peer board's I2C bus through the cross-board bridge.
//
// This is the ultimate cross-architecture test: AVR firmware → no.
// Real ARM Cortex-M0+ firmware → yes. Proves the I2CBusManager +
// bridge work uniformly across both simulator backends.
const RP2040_CORE_AVAILABLE = (() => {
if (!ARDUINO_CLI_AVAILABLE) return false;
const r = spawnSync('arduino-cli', ['core', 'list'], { encoding: 'utf-8' });
return r.status === 0 && /rp2040:rp2040/.test(r.stdout);
})();
describe.runIf(RP2040_CORE_AVAILABLE)(
'I2C scenario — Pi Pico (real RP2040 firmware) reads BMP280 via bridge',
() => {
let BIN_B64: string;
// First-time RP2040 compile pulls in the picotool + GCC toolchain
// and takes ~60 s on a cold cache. Subsequent runs hit the
// in-tmpdir hash cache and complete in <50 ms.
beforeAll(() => {
BIN_B64 = compileSketch('pico_i2c_master_reader', 'rp2040:rp2040:rpipico');
}, 300_000);
it('master TWI on rp2040js reaches the BMP280 on a bridged peer bus', () => {
const pm = new PinManager();
const sim = new RP2040Simulator(pm);
sim.loadBinary(BIN_B64);
// Peer bus owns the BMP280.
const peerBus = new I2CBusManager({
completeStart() {},
completeStop() {},
completeConnect() {},
completeWrite() {},
completeRead() {},
});
peerBus.addDevice(new VirtualBMP280(0x76));
// Bridge symmetrically. Bus 0 is the Pico's I2C0 (default
// Wire on GP4/GP5).
const picoBus = sim.getI2CBus(0);
picoBus.attachBridge(peerBus);
peerBus.attachBridge(picoBus);
const out: number[] = [];
sim.onSerialData = (ch) => out.push(ch.charCodeAt(0));
// Step the Pico CPU. Earle's core init + Wire.begin +
// Serial1.begin + delay(100) + 4 transactions takes a fair
// chunk of cycles. 125 MHz × 200 ms = 25 M cycles is a
// generous ceiling for the happy path; we exit early once
// both expected bytes are received.
const maxCycles = 60_000_000;
let consumed = 0;
while (consumed < maxCycles && out.length < 2) {
consumed += sim.stepCycles(100_000);
}
expect(out[0]).toBe(0x58); // BMP280 chip_id
expect(out[1]).toBe(0x00); // status
});
},
);
// ─── Skip notices ────────────────────────────────────────────────────────────
describe.skipIf(ARDUINO_CLI_AVAILABLE)('I2C scenarios — skipped (no arduino-cli)', () => {
it('install arduino-cli to enable the complex scenarios', () => {
expect(true).toBe(true);
});
});
describe.skipIf(LIQUID_CRYSTAL_I2C_AVAILABLE || !ARDUINO_CLI_AVAILABLE)(
'I2C scenarios — LCD scenarios skipped (no LiquidCrystal_I2C)',
() => {
it('run: arduino-cli lib install "LiquidCrystal_I2C"', () => {
expect(true).toBe(true);
});
},
);
describe.skipIf(RP2040_CORE_AVAILABLE || !ARDUINO_CLI_AVAILABLE)(
'I2C scenarios — Pico scenarios skipped (no rp2040 core)',
() => {
it('run: arduino-cli core install rp2040:rp2040 (earlephilhower URL required)', () => {
expect(true).toBe(true);
});
},
);