562 lines
19 KiB
TypeScript
562 lines
19 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
);
|