velxio/frontend/src/__tests__/i2c-multi-board-slave-gap.t...

359 lines
12 KiB
TypeScript

/**
* i2c-multi-board-slave-gap.test.ts
*
* Faithful reproduction of the multi-board I2C bug reported in
* issue #38 and the Discord thread ("multiple boards over I2C
* does not work").
*
* Why the existing I2C tests pass while production fails
* ------------------------------------------------------
* The existing arduino-pico-i2c / dual-*-multi-protocol tests
* mock both AVRSimulator and RP2040Simulator entirely
* (vi.mock) and only assert that PinManager transitions reach
* the other board's setPinState() spy. That proves wire
* fan-out but never exercises the actual I2C state machine.
*
* In production:
* - avr8js AVRTWI only emits events when the firmware programs
* the TWI registers (master operations). It does NOT sample
* SDA/SCL from GPIO to act as a slave.
* - rp2040js RPI2C is the same: master-only API (onConnect /
* onWriteByte / onReadByte fire when the CPU programs the
* controller, not from GPIO transitions).
* - I2CBusManager bridges TWI <-> JavaScript virtual devices
* living on the SAME simulator. There is no API to feed
* transactions from another board's bus, nor any code path
* in Interconnect that decodes SDA/SCL transitions into I2C
* events on the receiving side.
*
* This test uses the REAL I2CBusManager on each side. We drive
* the master bus the same way the master simulator's AVRTWI
* eventHandler calls into it once the compiled firmware runs
* (the I2CBusManager surface is exactly what avr8js's TWI fires
* — connectToSlave, writeByte, stop). We then check whether a
* device registered on the OTHER board's bus sees anything.
*
* It demonstrates concretely:
*
* 1. The master's local I2C path works (sanity).
* 2. A virtual I2C device on the slave board's bus, even with
* the same address as the master's target, observes
* nothing. The slave bus is fully isolated from the master
* bus.
* 3. There is no public API on I2CBusManager to inject an
* external master transaction — confirming the gap is
* architectural.
*
* Until the gap is closed (either by adding a slave-mode I2C
* decoder in Interconnect for SDA/SCL pin pairs, or by adding
* an `acceptExternalTransaction` entry on I2CBusManager so a
* cross-board router can push transactions in), the
* REPRODUCTION assertion will fail — and that failure IS the
* reproduction of the bug.
*/
import { describe, it, expect } from 'vitest';
import {
I2CBusManager,
I2CMemoryDevice,
} from '../simulation/I2CBusManager';
/**
* Minimal AVRTWI mock shaped exactly as I2CBusManager uses it.
* The real avr8js AVRTWI fires the SAME I2CBusManager method
* calls (start, connectToSlave, writeByte, stop) when the
* compiled firmware programs the TWI peripheral — so driving
* the bus directly via these methods is a faithful stand-in
* for the firmware path. This is the same pattern used by
* virtual-i2c-devices.test.ts.
*/
function makeTWI() {
const calls: string[] = [];
return {
calls,
set eventHandler(_handler: unknown) {
/* installed by I2CBusManager constructor */
},
completeStart() {
calls.push('start');
},
completeStop() {
calls.push('stop');
},
completeConnect(ack: boolean) {
calls.push(`connect:${ack}`);
},
completeWrite(ack: boolean) {
calls.push(`write:${ack}`);
},
completeRead(value: number) {
calls.push(`read:${value}`);
},
};
}
/**
* Simulate a full master-side transaction: START + SLA+W
* (connectToSlave with write=true) + one data byte + STOP.
*
* This is exactly what the AVRTWI eventHandler dispatches when
* Arduino Wire.beginTransmission(addr); Wire.write(byte);
* Wire.endTransmission() compiles down and runs.
*/
function driveMasterTransaction(
bus: I2CBusManager,
slaveAddr: number,
byte: number,
) {
bus.start(false);
bus.connectToSlave(slaveAddr, true);
bus.writeByte(byte);
bus.stop();
}
describe('I2C bug — multi-board slave gap (issue #38 / Discord)', () => {
it('master-local I2C device receives the transaction (sanity baseline)', () => {
// Single-board I2C works. This test guards against
// regressions in the part that is wired correctly so the
// negative result below cannot be blamed on a broken bus.
const masterTwi = makeTWI();
const masterBus = new I2CBusManager(masterTwi as any);
const memDevice = new I2CMemoryDevice(0x42);
masterBus.addDevice(memDevice);
// Use a transaction shape that exercises a register write:
// SLA+W, pointer byte 0x10, data byte 0xAB, STOP.
masterBus.start(false);
masterBus.connectToSlave(0x42, true);
masterBus.writeByte(0x10); // register pointer
masterBus.writeByte(0xab); // data byte to register 0x10
masterBus.stop();
expect(memDevice.registers[0x10]).toBe(0xab);
expect(masterTwi.calls).toContain('connect:true');
});
it('master finds an unknown address via attached bridge', () => {
// The FIX: when the master board has no local device at the
// requested address, the bus walks its attached bridges. A
// peer bus that has the device registered ACKs the
// connection. All subsequent writeByte / stop are routed
// to that peer's device.
const masterTwi = makeTWI();
const slaveTwi = makeTWI();
const masterBus = new I2CBusManager(masterTwi as any);
const slaveBus = new I2CBusManager(slaveTwi as any);
// Symmetric bridge. Interconnect installs this when both
// SDA and SCL of the two boards are wired together.
masterBus.attachBridge(slaveBus);
slaveBus.attachBridge(masterBus);
const slaveBytesObserved: number[] = [];
const slaveDevice = new I2CMemoryDevice(0x42);
const originalWriteByte = slaveDevice.writeByte.bind(slaveDevice);
slaveDevice.writeByte = (v: number) => {
slaveBytesObserved.push(v);
return originalWriteByte(v);
};
slaveBus.addDevice(slaveDevice);
driveMasterTransaction(masterBus, 0x42, 0xab);
// The slave's device must see the data byte.
expect(slaveBytesObserved).toContain(0xab);
// And the master TWI must have received the ACK chain.
expect(masterTwi.calls).toContain('connect:true');
expect(masterTwi.calls).toContain('write:true');
expect(masterTwi.calls).toContain('stop');
});
it('bridge is bidirectional — slave can also be master toward the peer', () => {
const aTwi = makeTWI();
const bTwi = makeTWI();
const aBus = new I2CBusManager(aTwi as any);
const bBus = new I2CBusManager(bTwi as any);
aBus.attachBridge(bBus);
bBus.attachBridge(aBus);
const aDevice = new I2CMemoryDevice(0x10);
const bDevice = new I2CMemoryDevice(0x20);
aBus.addDevice(aDevice);
bBus.addDevice(bDevice);
// B initiates: writes to A's 0x10 device.
driveMasterTransaction(bBus, 0x10, 0x77);
aDevice.writeByte(0x05); // probe-only — just to verify pointer state
expect(bTwi.calls).toContain('connect:true');
});
it('local device wins over a bridged device on the same address', () => {
// Determinism: if the user wires a sensor at 0x68 on both
// boards, each board's master talks to its OWN device.
const aTwi = makeTWI();
const bTwi = makeTWI();
const aBus = new I2CBusManager(aTwi as any);
const bBus = new I2CBusManager(bTwi as any);
aBus.attachBridge(bBus);
bBus.attachBridge(aBus);
const aDevice = new I2CMemoryDevice(0x68);
const bDevice = new I2CMemoryDevice(0x68);
aBus.addDevice(aDevice);
bBus.addDevice(bDevice);
// A writes pointer 0x05 then data 0xCC at 0x68. Should
// land on A's own device, not B's.
aBus.start(false);
aBus.connectToSlave(0x68, true);
aBus.writeByte(0x05);
aBus.writeByte(0xcc);
aBus.stop();
expect(aDevice.registers[0x05]).toBe(0xcc);
expect(bDevice.registers[0x05]).toBe(0);
});
it('detachBridge restores the previous isolated behaviour', () => {
const masterTwi = makeTWI();
const slaveTwi = makeTWI();
const masterBus = new I2CBusManager(masterTwi as any);
const slaveBus = new I2CBusManager(slaveTwi as any);
masterBus.attachBridge(slaveBus);
slaveBus.attachBridge(masterBus);
slaveBus.addDevice(new I2CMemoryDevice(0x42));
// Confirm bridge works:
driveMasterTransaction(masterBus, 0x42, 0xaa);
expect(masterTwi.calls).toContain('connect:true');
// Now detach the bridge (Interconnect calls this when the
// user removes an SDA or SCL wire).
masterBus.detachBridge(slaveBus);
slaveBus.detachBridge(masterBus);
masterTwi.calls.length = 0;
driveMasterTransaction(masterBus, 0x42, 0xbb);
expect(masterTwi.calls).toContain('connect:false'); // NACK — isolated again
});
});
describe('I2C multi-hop routing (3+ board chains)', () => {
it('A↔B↔C: master on A reaches device on C through B (BFS)', () => {
// Topology:
// A.bus ─── B.bus ─── C.bus
// │
// └── I2CMemoryDevice(0x42)
//
// Bridges installed bidirectionally between each adjacent pair,
// mimicking what Interconnect.updateI2CBridges does when the user
// wires SDA+SCL between three boards. Board A has no direct
// bridge to C, but the BFS walk through B's `getBridges()` reaches
// it.
const aTwi = makeTWI();
const bTwi = makeTWI();
const cTwi = makeTWI();
const aBus = new I2CBusManager(aTwi as any);
const bBus = new I2CBusManager(bTwi as any);
const cBus = new I2CBusManager(cTwi as any);
// A↔B
aBus.attachBridge(bBus);
bBus.attachBridge(aBus);
// B↔C
bBus.attachBridge(cBus);
cBus.attachBridge(bBus);
const memDevice = new I2CMemoryDevice(0x42);
cBus.addDevice(memDevice);
// A writes pointer 0x05, data 0xCC across two hops.
aBus.start(false);
aBus.connectToSlave(0x42, true);
aBus.writeByte(0x05);
aBus.writeByte(0xcc);
aBus.stop();
expect(aTwi.calls).toContain('connect:true');
expect(memDevice.registers[0x05]).toBe(0xcc);
});
it('A↔B↔C: read flows back through both hops', () => {
const aTwi = makeTWI();
const bTwi = makeTWI();
const cTwi = makeTWI();
const aBus = new I2CBusManager(aTwi as any);
const bBus = new I2CBusManager(bTwi as any);
const cBus = new I2CBusManager(cTwi as any);
aBus.attachBridge(bBus);
bBus.attachBridge(aBus);
bBus.attachBridge(cBus);
cBus.attachBridge(bBus);
const memDevice = new I2CMemoryDevice(0x42);
memDevice.registers[0x00] = 0xab;
memDevice.registers[0x01] = 0xcd;
cBus.addDevice(memDevice);
aBus.start(false);
aBus.connectToSlave(0x42, true);
aBus.writeByte(0x00); // pointer
aBus.start(true); // repeated start
aBus.connectToSlave(0x42, false); // switch to read
aBus.readByte(true);
aBus.readByte(false);
aBus.stop();
const reads = aTwi.calls.filter((c) => c.startsWith('read:'));
expect(reads).toContain('read:171'); // 0xAB
expect(reads).toContain('read:205'); // 0xCD
});
it('cycles in the bridge graph do not cause infinite recursion', () => {
// A↔B↔C and ALSO A↔C — forms a triangle. Visited Set must
// prevent the BFS from looping back through the long way around.
const aTwi = makeTWI();
const bTwi = makeTWI();
const cTwi = makeTWI();
const aBus = new I2CBusManager(aTwi as any);
const bBus = new I2CBusManager(bTwi as any);
const cBus = new I2CBusManager(cTwi as any);
aBus.attachBridge(bBus);
bBus.attachBridge(aBus);
bBus.attachBridge(cBus);
cBus.attachBridge(bBus);
aBus.attachBridge(cBus); // direct A↔C edge as well
cBus.attachBridge(aBus);
const memDevice = new I2CMemoryDevice(0x55);
cBus.addDevice(memDevice);
aBus.start(false);
aBus.connectToSlave(0x55, true);
aBus.writeByte(0x00);
aBus.writeByte(0x99);
aBus.stop();
expect(memDevice.registers[0x00]).toBe(0x99);
});
it('NACKs cleanly when the address exists nowhere in the graph', () => {
const aBus = new I2CBusManager(makeTWI() as any);
const bBus = new I2CBusManager(makeTWI() as any);
const cBus = new I2CBusManager(makeTWI() as any);
aBus.attachBridge(bBus);
bBus.attachBridge(aBus);
bBus.attachBridge(cBus);
cBus.attachBridge(bBus);
const aTwi = makeTWI();
const aBusInstrumented = new I2CBusManager(aTwi as any);
aBusInstrumented.attachBridge(bBus);
bBus.attachBridge(aBusInstrumented);
aBusInstrumented.connectToSlave(0x77, true);
expect(aTwi.calls).toContain('connect:false');
});
});