641 lines
23 KiB
TypeScript
641 lines
23 KiB
TypeScript
/**
|
|
* i2c-esp32-multiboard-bridge.test.ts
|
|
*
|
|
* Cross-architecture I2C bridge test: a non-ESP32 board acts as
|
|
* master and reads from a device that is "attached" to an ESP32
|
|
* board on the canvas. The device's frontend-side virtual instance
|
|
* lives on the Esp32BridgeShim's I2CBusManager (mirror of what's
|
|
* registered server-side via `sim.registerSensor` for the real
|
|
* QEMU run). Interconnect bridges the two boards' buses when both
|
|
* SDA and SCL wires are present.
|
|
*
|
|
* Why this matters
|
|
* ----------------
|
|
* ESP32 emulation runs in backend QEMU — the I2CBusManager wired
|
|
* into the Esp32BridgeShim only exists on the frontend, but it is
|
|
* the only piece the Interconnect bridge mechanism can reach
|
|
* without a round-trip through the backend. Mirroring devices
|
|
* on this bus lets peer boards read sensors that physically sit
|
|
* on an ESP32 module without backend involvement.
|
|
*
|
|
* This test deliberately keeps the ESP32 SIDE bridged-only — no
|
|
* real ESP32 firmware running, no QEMU. That's a clean unit-level
|
|
* proof of the front-end wiring. The single-board ESP32 path
|
|
* (firmware + QEMU + WebSocket) is exercised separately by
|
|
* `i2c-esp32-real-firmware.test.ts`.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
const busFactories = vi.hoisted(() => {
|
|
return {
|
|
make: null as null | ((channels: number) => unknown[]),
|
|
};
|
|
});
|
|
|
|
vi.mock('../simulation/I2CBusManager', async () => {
|
|
const actual = await vi.importActual<typeof import('../simulation/I2CBusManager')>(
|
|
'../simulation/I2CBusManager',
|
|
);
|
|
busFactories.make = (channels: number) =>
|
|
Array.from(
|
|
{ length: channels },
|
|
() => new actual.I2CBusManager(actual.nullI2CMaster()),
|
|
) as unknown[];
|
|
return actual;
|
|
});
|
|
|
|
vi.mock('../simulation/AVRSimulator', () => ({
|
|
AVRSimulator: vi.fn(function (this: any) {
|
|
this.onSerialData = null;
|
|
this.start = vi.fn();
|
|
this.stop = vi.fn();
|
|
this.reset = vi.fn();
|
|
this.loadHex = vi.fn();
|
|
this.setPinState = vi.fn();
|
|
const [bus] = busFactories.make!(1) as any[];
|
|
this.i2cBus = bus;
|
|
this.getI2CBus = () => this.i2cBus;
|
|
this.addI2CDevice = (d: any) => this.i2cBus.addDevice(d);
|
|
this.removeI2CDevice = (a: number) => this.i2cBus.removeDevice(a);
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../simulation/RP2040Simulator', () => ({
|
|
RP2040Simulator: vi.fn(function (this: any) {
|
|
this.onSerialData = null;
|
|
this.start = vi.fn();
|
|
this.stop = vi.fn();
|
|
this.reset = vi.fn();
|
|
this.loadBinary = vi.fn();
|
|
this.setPinState = vi.fn();
|
|
this.attachPioPeripheral = vi.fn();
|
|
this.spi = { onByte: null, completeTransfer: vi.fn() };
|
|
const buses = busFactories.make!(2) as any[];
|
|
this.getI2CBus = (bus: 0 | 1 = 0) => buses[bus];
|
|
this.addI2CDevice = (d: any, bus: 0 | 1 = 0) => buses[bus].addDevice(d);
|
|
this.removeI2CDevice = (a: number, bus: 0 | 1 = 0) => buses[bus].removeDevice(a);
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../simulation/Esp32Bridge', () => ({
|
|
Esp32Bridge: vi.fn(function (this: any, _id: string) {
|
|
this.onSerialData = null;
|
|
this.onPinChange = null;
|
|
this.connect = vi.fn();
|
|
this.disconnect = vi.fn();
|
|
this.connected = true;
|
|
this.sendSerialBytes = vi.fn();
|
|
this.sendPinEvent = vi.fn();
|
|
this.sendSensorAttach = vi.fn();
|
|
this.sendSensorUpdate = vi.fn();
|
|
this.sendSensorDetach = vi.fn();
|
|
this.setAdc = vi.fn();
|
|
this.setAdcWaveform = vi.fn();
|
|
this.onI2cTransaction = null;
|
|
this.onI2cEvent = null;
|
|
this.onSpiByte = null;
|
|
this.onProxyI2cComplete = null;
|
|
// Spy-able proxy I2C wire calls so tests can verify per-peer
|
|
// registration / cleanup symmetry.
|
|
this.registerProxyI2c = vi.fn();
|
|
this.updateProxyI2c = vi.fn();
|
|
this.unregisterProxyI2c = vi.fn();
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../simulation/RiscVSimulator', () => ({
|
|
RiscVSimulator: vi.fn(function (this: any) {
|
|
this.onSerialData = null;
|
|
this.start = vi.fn();
|
|
this.stop = vi.fn();
|
|
this.setPinState = vi.fn();
|
|
}),
|
|
}));
|
|
vi.mock('../simulation/Esp32C3Simulator', () => ({
|
|
Esp32C3Simulator: vi.fn(function (this: any) {
|
|
this.onSerialData = null;
|
|
this.start = vi.fn();
|
|
this.stop = vi.fn();
|
|
this.setPinState = vi.fn();
|
|
}),
|
|
}));
|
|
vi.mock('../simulation/RaspberryPi3Bridge', () => ({
|
|
RaspberryPi3Bridge: vi.fn(function (this: any) {
|
|
this.onSerialData = null;
|
|
this.connect = vi.fn();
|
|
this.disconnect = vi.fn();
|
|
this.connected = true;
|
|
this.sendSerialBytes = vi.fn();
|
|
this.sendPinEvent = vi.fn();
|
|
}),
|
|
}));
|
|
vi.mock('../store/useOscilloscopeStore', () => ({
|
|
useOscilloscopeStore: {
|
|
getState: vi.fn().mockReturnValue({ channels: [], pushSample: vi.fn() }),
|
|
},
|
|
}));
|
|
vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => 1);
|
|
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
|
|
|
import { setWires, resetStore, clearAllPinManagerState } from './helpers/multiBoardSetup';
|
|
import { resetInterconnect } from '../simulation/Interconnect';
|
|
import {
|
|
useSimulatorStore,
|
|
getBoardSimulator,
|
|
getBoardPinManager,
|
|
} from '../store/useSimulatorStore';
|
|
import {
|
|
I2CMemoryDevice,
|
|
VirtualBMP280,
|
|
VirtualPCF8574,
|
|
} from '../simulation/I2CBusManager';
|
|
|
|
function fullReset() {
|
|
clearAllPinManagerState(useSimulatorStore, getBoardPinManager);
|
|
resetInterconnect();
|
|
resetStore(useSimulatorStore);
|
|
}
|
|
|
|
describe('Cross-architecture I2C — Uno master reads device attached to ESP32 via bridge', () => {
|
|
beforeEach(() => {
|
|
fullReset();
|
|
});
|
|
|
|
it('ESP32 shim exposes an I2CBusManager via getI2CBus()', () => {
|
|
const store = useSimulatorStore.getState();
|
|
const espId = store.addBoard('esp32', 200, 100);
|
|
const sim = getBoardSimulator(espId) as any;
|
|
expect(sim).toBeTruthy();
|
|
expect(typeof sim.getI2CBus).toBe('function');
|
|
const bus = sim.getI2CBus(0);
|
|
expect(bus).toBeTruthy();
|
|
expect(typeof bus.addDevice).toBe('function');
|
|
expect(typeof bus.attachBridge).toBe('function');
|
|
});
|
|
|
|
it('SDA+SCL wires between Uno A4/A5 and ESP32 GPIO21/22 install the bridge', () => {
|
|
const store = useSimulatorStore.getState();
|
|
const unoId = 'arduino-uno';
|
|
const espId = store.addBoard('esp32', 400, 100);
|
|
|
|
// Attach a BMP280 to the ESP32's frontend-side bus. In a real
|
|
// single-board ESP32 sim, ProtocolParts' bmp280 attach handler
|
|
// would do this when the user drops a BMP280 component near the
|
|
// ESP32.
|
|
const espSim = getBoardSimulator(espId) as any;
|
|
const bmp = new VirtualBMP280(0x76);
|
|
espSim.getI2CBus(0).addDevice(bmp);
|
|
|
|
// Uno A4 = D18 (SDA), Uno A5 = D19 (SCL). ESP32 GPIO 21 = SDA,
|
|
// GPIO 22 = SCL on default Wire bus.
|
|
setWires(useSimulatorStore, [
|
|
{ fromBoard: unoId, fromPin: 'A4', toBoard: espId, toPin: '21' },
|
|
{ fromBoard: unoId, fromPin: 'A5', toBoard: espId, toPin: '22' },
|
|
]);
|
|
|
|
// Now Uno master should reach the BMP280 through the bridge.
|
|
const unoSim = getBoardSimulator(unoId) as any;
|
|
const busU = unoSim.getI2CBus(0);
|
|
busU.start(false);
|
|
busU.connectToSlave(0x76, true);
|
|
busU.writeByte(0xd0); // chip_id register pointer
|
|
busU.stop();
|
|
busU.start(true);
|
|
busU.connectToSlave(0x76, false); // repeated start, read mode
|
|
busU.readByte(true);
|
|
busU.stop();
|
|
|
|
// The bus manager keeps a `twi` ref pointing at the master we
|
|
// passed in. Since we used the AVR mock master, we can't
|
|
// inspect the read result directly, but we can verify the
|
|
// device was reached — its internal regPointer should have
|
|
// advanced past 0xD0. The simplest check: re-read chip_id
|
|
// again through a fresh device-side method.
|
|
expect(bmp.address).toBe(0x76);
|
|
});
|
|
|
|
it('Uno master writes to a memory device attached to ESP32 via bridge', () => {
|
|
const store = useSimulatorStore.getState();
|
|
const unoId = 'arduino-uno';
|
|
const espId = store.addBoard('esp32', 400, 100);
|
|
|
|
const espSim = getBoardSimulator(espId) as any;
|
|
const memDev = new I2CMemoryDevice(0x42);
|
|
espSim.getI2CBus(0).addDevice(memDev);
|
|
|
|
setWires(useSimulatorStore, [
|
|
{ fromBoard: unoId, fromPin: 'A4', toBoard: espId, toPin: '21' },
|
|
{ fromBoard: unoId, fromPin: 'A5', toBoard: espId, toPin: '22' },
|
|
]);
|
|
|
|
const unoSim = getBoardSimulator(unoId) as any;
|
|
const busU = unoSim.getI2CBus(0);
|
|
busU.start(false);
|
|
busU.connectToSlave(0x42, true);
|
|
busU.writeByte(0x10); // register pointer
|
|
busU.writeByte(0xab); // data
|
|
busU.stop();
|
|
|
|
// The data byte should have landed on the memory device
|
|
// attached to the ESP32's bus, via the cross-architecture bridge.
|
|
expect(memDev.registers[0x10]).toBe(0xab);
|
|
});
|
|
|
|
it('only SDA wired (no SCL): no bridge installed, device unreachable', () => {
|
|
const store = useSimulatorStore.getState();
|
|
const unoId = 'arduino-uno';
|
|
const espId = store.addBoard('esp32', 400, 100);
|
|
|
|
const espSim = getBoardSimulator(espId) as any;
|
|
const memDev = new I2CMemoryDevice(0x42);
|
|
espSim.getI2CBus(0).addDevice(memDev);
|
|
|
|
// SCL missing
|
|
setWires(useSimulatorStore, [
|
|
{ fromBoard: unoId, fromPin: 'A4', toBoard: espId, toPin: '21' },
|
|
]);
|
|
|
|
const unoSim = getBoardSimulator(unoId) as any;
|
|
const busU = unoSim.getI2CBus(0);
|
|
busU.start(false);
|
|
busU.connectToSlave(0x42, true);
|
|
busU.writeByte(0xab);
|
|
busU.stop();
|
|
|
|
expect(memDev.registers[0x10]).toBe(0);
|
|
expect(espSim.getI2CBus(0).isHandlingExternal()).toBe(false);
|
|
});
|
|
|
|
it('removing one wire tears the bridge down', () => {
|
|
const store = useSimulatorStore.getState();
|
|
const unoId = 'arduino-uno';
|
|
const espId = store.addBoard('esp32', 400, 100);
|
|
|
|
const espSim = getBoardSimulator(espId) as any;
|
|
const memDev = new I2CMemoryDevice(0x42);
|
|
espSim.getI2CBus(0).addDevice(memDev);
|
|
|
|
setWires(useSimulatorStore, [
|
|
{ fromBoard: unoId, fromPin: 'A4', toBoard: espId, toPin: '21' },
|
|
{ fromBoard: unoId, fromPin: 'A5', toBoard: espId, toPin: '22' },
|
|
]);
|
|
|
|
// Sanity: bridge works.
|
|
const unoSim = getBoardSimulator(unoId) as any;
|
|
let busU = unoSim.getI2CBus(0);
|
|
busU.start(false);
|
|
busU.connectToSlave(0x42, true);
|
|
busU.writeByte(0x01);
|
|
busU.writeByte(0x77);
|
|
busU.stop();
|
|
expect(memDev.registers[0x01]).toBe(0x77);
|
|
|
|
// Drop SCL → bridge teardown.
|
|
setWires(useSimulatorStore, [
|
|
{ fromBoard: unoId, fromPin: 'A4', toBoard: espId, toPin: '21' },
|
|
]);
|
|
|
|
busU = unoSim.getI2CBus(0);
|
|
busU.start(false);
|
|
busU.connectToSlave(0x42, true);
|
|
busU.writeByte(0x02);
|
|
busU.writeByte(0x88);
|
|
busU.stop();
|
|
|
|
expect(memDev.registers[0x02]).toBe(0);
|
|
expect(memDev.registers[0x01]).toBe(0x77);
|
|
});
|
|
});
|
|
|
|
describe('Per-peer proxy ownership (multiple concurrent bridges to one ESP32)', () => {
|
|
beforeEach(() => {
|
|
fullReset();
|
|
});
|
|
|
|
it('tearing down one bridge leaves the other peer\'s proxies intact', () => {
|
|
const store = useSimulatorStore.getState();
|
|
const unoId = 'arduino-uno';
|
|
const picoId = store.addBoard('pi-pico-w', 200, 100);
|
|
const espId = store.addBoard('esp32', 400, 100);
|
|
|
|
// Two peer buses with one I2CMemoryDevice each at DIFFERENT addresses.
|
|
const unoSim = getBoardSimulator(unoId) as any;
|
|
const picoSim = getBoardSimulator(picoId) as any;
|
|
const espSim = getBoardSimulator(espId) as any;
|
|
const espBridge = (espSim as any).bridge ?? (espSim as any).getBridge?.();
|
|
|
|
unoSim.getI2CBus(0).addDevice(new I2CMemoryDevice(0x42));
|
|
picoSim.getI2CBus(0).addDevice(new I2CMemoryDevice(0x55));
|
|
|
|
// Wire BOTH peer buses to the SAME ESP32 I2C pins (21/22 = bus 0).
|
|
// In physical hardware a single I2C bus can have devices from
|
|
// multiple boards if they're all on the shared SDA/SCL. In
|
|
// velxio's Interconnect model this manifests as TWO distinct
|
|
// bridge routes (Uno↔ESP32 and Pico↔ESP32), each tracked
|
|
// separately in the ESP32 shim's `_proxiedByPeer` map.
|
|
setWires(useSimulatorStore, [
|
|
{ fromBoard: unoId, fromPin: 'A4', toBoard: espId, toPin: '21' },
|
|
{ fromBoard: unoId, fromPin: 'A5', toBoard: espId, toPin: '22' },
|
|
{ fromBoard: picoId, fromPin: 'GP4', toBoard: espId, toPin: '21' },
|
|
{ fromBoard: picoId, fromPin: 'GP5', toBoard: espId, toPin: '22' },
|
|
]);
|
|
|
|
// Capture which addresses were registered on the ESP32's backend.
|
|
const registeredAddrs = espBridge?.registerProxyI2c.mock.calls.map(
|
|
(c: any[]) => c[0],
|
|
) ?? [];
|
|
// Both peer addresses must have been pushed to QEMU.
|
|
expect(registeredAddrs).toContain(0x42);
|
|
expect(registeredAddrs).toContain(0x55);
|
|
|
|
// Drop ONLY the Uno↔ESP32 wires. Pico↔ESP32 stays. The
|
|
// Interconnect teardown fires for the disappearing wire pair,
|
|
// calling `clearProxiesForPeer(unoBus)` on the ESP32 shim. The
|
|
// Pico↔ESP32 bridge entry in i2cBridges remains in place — its
|
|
// proxies are NOT touched.
|
|
espBridge?.registerProxyI2c.mockClear();
|
|
espBridge?.unregisterProxyI2c.mockClear();
|
|
setWires(useSimulatorStore, [
|
|
{ fromBoard: picoId, fromPin: 'GP4', toBoard: espId, toPin: '21' },
|
|
{ fromBoard: picoId, fromPin: 'GP5', toBoard: espId, toPin: '22' },
|
|
]);
|
|
|
|
const unregistered = espBridge?.unregisterProxyI2c.mock.calls.map(
|
|
(c: any[]) => c[0],
|
|
) ?? [];
|
|
|
|
// Uno's 0x42 must have been unregistered exactly once.
|
|
expect(unregistered).toContain(0x42);
|
|
// Pico's 0x55 must NOT have been unregistered — the per-peer
|
|
// cleanup only touches addresses owned by the disappearing peer.
|
|
expect(unregistered).not.toContain(0x55);
|
|
});
|
|
|
|
it('clearAllProxies removes everything (full board stop)', () => {
|
|
const store = useSimulatorStore.getState();
|
|
const unoId = 'arduino-uno';
|
|
const espId = store.addBoard('esp32', 400, 100);
|
|
|
|
const unoSim = getBoardSimulator(unoId) as any;
|
|
const espSim = getBoardSimulator(espId) as any;
|
|
const espBridge = (espSim as any).bridge ?? (espSim as any).getBridge?.();
|
|
|
|
unoSim.getI2CBus(0).addDevice(new I2CMemoryDevice(0x10));
|
|
unoSim.getI2CBus(0).addDevice(new I2CMemoryDevice(0x11));
|
|
|
|
setWires(useSimulatorStore, [
|
|
{ fromBoard: unoId, fromPin: 'A4', toBoard: espId, toPin: '21' },
|
|
{ fromBoard: unoId, fromPin: 'A5', toBoard: espId, toPin: '22' },
|
|
]);
|
|
|
|
espBridge?.unregisterProxyI2c.mockClear();
|
|
espSim.clearAllProxies?.();
|
|
const unregistered = espBridge?.unregisterProxyI2c.mock.calls.map(
|
|
(c: any[]) => c[0],
|
|
) ?? [];
|
|
expect(unregistered).toContain(0x10);
|
|
expect(unregistered).toContain(0x11);
|
|
});
|
|
});
|
|
|
|
describe('Periodic resync (dynamic devices)', () => {
|
|
beforeEach(() => {
|
|
fullReset();
|
|
// The simulatorMap is module-level and survives store reset,
|
|
// which means devices added to a board's I2CBusManager in a
|
|
// previous test linger on the same bus. Clear each known sim's
|
|
// bus so each test starts from a fresh device set.
|
|
const uno = getBoardSimulator('arduino-uno') as any;
|
|
if (uno?.getI2CBus) {
|
|
const bus = uno.getI2CBus(0);
|
|
for (const dev of bus.listDevices()) bus.removeDevice(dev.address);
|
|
}
|
|
vi.useFakeTimers();
|
|
});
|
|
afterEach(() => {
|
|
// Drain pending fake-timer callbacks before swapping back to real
|
|
// timers — otherwise a running setInterval can leak across tests
|
|
// and reorder calls onto the next test's mocks.
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('pushes updateProxyI2c when a dynamic device\'s register dump changes', async () => {
|
|
const store = useSimulatorStore.getState();
|
|
const unoId = 'arduino-uno';
|
|
const espId = store.addBoard('esp32', 400, 100);
|
|
const unoSim = getBoardSimulator(unoId) as any;
|
|
const espSim = getBoardSimulator(espId) as any;
|
|
const espBridge = (espSim as any).bridge ?? (espSim as any).getBridge?.();
|
|
|
|
// A device whose dumpRegisters returns DIFFERENT bytes each
|
|
// time it's called — mimics RTC time progression.
|
|
let tick = 0;
|
|
const dynamicDevice: any = {
|
|
address: 0x68,
|
|
writeByte: () => true,
|
|
readByte: () => 0,
|
|
dumpRegisters: () => {
|
|
const buf = new Uint8Array(256);
|
|
buf[0] = (tick++ * 11) & 0xff; // ensures fresh hash each call
|
|
return buf;
|
|
},
|
|
};
|
|
unoSim.getI2CBus(0).addDevice(dynamicDevice);
|
|
|
|
setWires(useSimulatorStore, [
|
|
{ fromBoard: unoId, fromPin: 'A4', toBoard: espId, toPin: '21' },
|
|
{ fromBoard: unoId, fromPin: 'A5', toBoard: espId, toPin: '22' },
|
|
]);
|
|
|
|
espBridge?.updateProxyI2c.mockClear();
|
|
// Two resync ticks at 250 ms each.
|
|
await vi.advanceTimersByTimeAsync(700);
|
|
expect(espBridge?.updateProxyI2c).toHaveBeenCalled();
|
|
const addrsUpdated = espBridge?.updateProxyI2c.mock.calls.map(
|
|
(c: any[]) => c[0],
|
|
);
|
|
expect(addrsUpdated).toContain(0x68);
|
|
});
|
|
|
|
it('skips static-hash devices on resync (no redundant WS pushes)', async () => {
|
|
const store = useSimulatorStore.getState();
|
|
const unoId = 'arduino-uno';
|
|
const espId = store.addBoard('esp32', 400, 100);
|
|
const unoSim = getBoardSimulator(unoId) as any;
|
|
const espSim = getBoardSimulator(espId) as any;
|
|
const espBridge = (espSim as any).bridge ?? (espSim as any).getBridge?.();
|
|
|
|
// Device with fixed register dump (e.g. BMP280 calibration).
|
|
const STATIC = new Uint8Array(256);
|
|
STATIC[0xd0] = 0x58;
|
|
const staticDevice: any = {
|
|
address: 0x76,
|
|
writeByte: () => true,
|
|
readByte: () => 0,
|
|
dumpRegisters: () => new Uint8Array(STATIC),
|
|
};
|
|
unoSim.getI2CBus(0).addDevice(staticDevice);
|
|
|
|
setWires(useSimulatorStore, [
|
|
{ fromBoard: unoId, fromPin: 'A4', toBoard: espId, toPin: '21' },
|
|
{ fromBoard: unoId, fromPin: 'A5', toBoard: espId, toPin: '22' },
|
|
]);
|
|
|
|
espBridge?.updateProxyI2c.mockClear();
|
|
// Three resync ticks should all be skipped because hash is constant.
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
expect(espBridge?.updateProxyI2c).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('stops the resync timer when the last proxy is cleared', async () => {
|
|
const store = useSimulatorStore.getState();
|
|
const unoId = 'arduino-uno';
|
|
const espId = store.addBoard('esp32', 400, 100);
|
|
const unoSim = getBoardSimulator(unoId) as any;
|
|
const espSim = getBoardSimulator(espId) as any;
|
|
|
|
let tick = 0;
|
|
const dynamicDevice: any = {
|
|
address: 0x68,
|
|
writeByte: () => true,
|
|
readByte: () => 0,
|
|
dumpRegisters: () => {
|
|
const buf = new Uint8Array(256);
|
|
buf[0] = (tick++ * 13) & 0xff;
|
|
return buf;
|
|
},
|
|
};
|
|
unoSim.getI2CBus(0).addDevice(dynamicDevice);
|
|
|
|
setWires(useSimulatorStore, [
|
|
{ fromBoard: unoId, fromPin: 'A4', toBoard: espId, toPin: '21' },
|
|
{ fromBoard: unoId, fromPin: 'A5', toBoard: espId, toPin: '22' },
|
|
]);
|
|
|
|
// Disconnect all wires — should clear proxies and stop timer.
|
|
espSim.clearAllProxies?.();
|
|
expect(espSim['_resyncTimer']).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Write-forwarding ProxySlave → peer device', () => {
|
|
beforeEach(() => {
|
|
fullReset();
|
|
const uno = getBoardSimulator('arduino-uno') as any;
|
|
if (uno?.getI2CBus) {
|
|
const bus = uno.getI2CBus(0);
|
|
for (const dev of bus.listDevices()) bus.removeDevice(dev.address);
|
|
}
|
|
});
|
|
|
|
it('ESP32 write to peer PCF8574 updates outputLatch via proxy_i2c_complete', () => {
|
|
const store = useSimulatorStore.getState();
|
|
const unoId = 'arduino-uno';
|
|
const espId = store.addBoard('esp32', 400, 100);
|
|
const unoSim = getBoardSimulator(unoId) as any;
|
|
const espSim = getBoardSimulator(espId) as any;
|
|
const espBridge = (espSim as any).bridge;
|
|
|
|
const pcf = new VirtualPCF8574(0x27);
|
|
unoSim.getI2CBus(0).addDevice(pcf);
|
|
|
|
setWires(useSimulatorStore, [
|
|
{ fromBoard: unoId, fromPin: 'A4', toBoard: espId, toPin: '21' },
|
|
{ fromBoard: unoId, fromPin: 'A5', toBoard: espId, toPin: '22' },
|
|
]);
|
|
|
|
// Simulate the backend emitting a proxy_i2c_complete event as if
|
|
// the ESP32 firmware just wrote 0xAA to the PCF8574. In the real
|
|
// flow this comes over WebSocket from `ProxySlave._flush_write_transaction`.
|
|
expect(typeof espBridge.onProxyI2cComplete).toBe('function');
|
|
espBridge.onProxyI2cComplete(0x27, [0xaa]);
|
|
|
|
// The peer PCF8574's outputLatch should reflect the byte.
|
|
expect(pcf.outputLatch).toBe(0xaa);
|
|
});
|
|
|
|
it('ESP32 write to peer I2CMemoryDevice: pointer + data sequence reaches registers', () => {
|
|
const store = useSimulatorStore.getState();
|
|
const unoId = 'arduino-uno';
|
|
const espId = store.addBoard('esp32', 400, 100);
|
|
const unoSim = getBoardSimulator(unoId) as any;
|
|
const espSim = getBoardSimulator(espId) as any;
|
|
const espBridge = (espSim as any).bridge;
|
|
|
|
const mem = new I2CMemoryDevice(0x50);
|
|
unoSim.getI2CBus(0).addDevice(mem);
|
|
|
|
setWires(useSimulatorStore, [
|
|
{ fromBoard: unoId, fromPin: 'A4', toBoard: espId, toPin: '21' },
|
|
{ fromBoard: unoId, fromPin: 'A5', toBoard: espId, toPin: '22' },
|
|
]);
|
|
|
|
// ESP32 wrote: pointer=0x10, data=0xDE, data=0xAD.
|
|
espBridge.onProxyI2cComplete(0x50, [0x10, 0xde, 0xad]);
|
|
|
|
expect(mem.registers[0x10]).toBe(0xde);
|
|
expect(mem.registers[0x11]).toBe(0xad);
|
|
});
|
|
|
|
it('no peer device at address → write is silently dropped (no crash)', () => {
|
|
const store = useSimulatorStore.getState();
|
|
const unoId = 'arduino-uno';
|
|
const espId = store.addBoard('esp32', 400, 100);
|
|
const unoSim = getBoardSimulator(unoId) as any;
|
|
const espSim = getBoardSimulator(espId) as any;
|
|
const espBridge = (espSim as any).bridge;
|
|
|
|
// Wire bus but DON'T add any device — proxy_i2c_complete arrives
|
|
// for an unknown address (e.g. ESP32 firmware addressing a bus
|
|
// device that isn't physically there).
|
|
void unoSim;
|
|
setWires(useSimulatorStore, [
|
|
{ fromBoard: unoId, fromPin: 'A4', toBoard: espId, toPin: '21' },
|
|
{ fromBoard: unoId, fromPin: 'A5', toBoard: espId, toPin: '22' },
|
|
]);
|
|
|
|
expect(() => espBridge.onProxyI2cComplete(0x99, [0xff])).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('Transitive proxy sync (3-board chain into ESP32)', () => {
|
|
beforeEach(() => {
|
|
fullReset();
|
|
});
|
|
|
|
it('ESP32 ↔ Uno ↔ Pico: ESP32 sees Pico-attached device via BFS sync', () => {
|
|
const store = useSimulatorStore.getState();
|
|
const unoId = 'arduino-uno';
|
|
const picoId = store.addBoard('pi-pico-w', 200, 100);
|
|
const espId = store.addBoard('esp32', 400, 100);
|
|
|
|
// Topology: ESP32 — Uno — Pico, with the BMP280 sitting on Pico.
|
|
// BFS in syncProxyFromPeer must walk through Uno's bus and find
|
|
// the device on Pico, then register it as a proxy on ESP32.
|
|
const unoSim = getBoardSimulator(unoId) as any;
|
|
const picoSim = getBoardSimulator(picoId) as any;
|
|
const espSim = getBoardSimulator(espId) as any;
|
|
const espBridge = (espSim as any).bridge ?? (espSim as any).getBridge?.();
|
|
|
|
picoSim.getI2CBus(0).addDevice(new VirtualBMP280(0x76));
|
|
|
|
// Wire Uno ↔ Pico AND Uno ↔ ESP32. Two wire pairs, two bridges,
|
|
// ESP32's syncProxyFromPeer(unoBus) must BFS into Pico's bus.
|
|
setWires(useSimulatorStore, [
|
|
// Uno ↔ Pico
|
|
{ fromBoard: unoId, fromPin: 'A4', toBoard: picoId, toPin: 'GP4' },
|
|
{ fromBoard: unoId, fromPin: 'A5', toBoard: picoId, toPin: 'GP5' },
|
|
// Uno ↔ ESP32
|
|
{ fromBoard: unoId, fromPin: 'A4', toBoard: espId, toPin: '21' },
|
|
{ fromBoard: unoId, fromPin: 'A5', toBoard: espId, toPin: '22' },
|
|
]);
|
|
|
|
const registered = espBridge?.registerProxyI2c.mock.calls.map(
|
|
(c: any[]) => c[0],
|
|
) ?? [];
|
|
expect(registered).toContain(0x76);
|
|
});
|
|
});
|