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

562 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
});
},
);