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

359 lines
12 KiB
TypeScript
Raw Normal View History

/**
* 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
});
});
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv) Closes the remaining gaps in cross-board I2C so any topology of supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3 connected to anything, etc.) works end-to-end with all I2C components including write-only sinks (SSD1306, PCF8574, LCD-I2C). Implementation (6 phases): 1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect walk the bridge graph with a visited Set so multi-hop chains (A↔B↔C with the device on C) resolve transparently. A new forwarder-device shim is installed at intermediate hops so the existing handleExternalWrite/Read/Stop machinery routes through without per-method visited tracking. 2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the global _proxiedAddrs Set with _proxiedByPeer Map so concurrent bridges to the same ESP32 (e.g. wired to both Uno and Pico) don't wipe each other's proxies on teardown. Interconnect's per-wire teardown calls clearProxiesForPeer(peerBus) instead of clearAllProxies. 3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer bus + its transitive bridges, so an ESP32 sees devices on boards two or more hops away. _peerDeviceLookup keeps a flat addr → device map for write-forwarding and resync. 4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval while any proxy is live, re-dumping each device with dumpRegisters() and pushing updateProxyI2c only when an XOR- stride hash changes. This keeps RTC time advancing visible to ESP32 firmware without flooding the WS pipe with static calibration dumps. Hash is primed during initial sync so the first tick doesn't push a redundant identical buffer. 5. **Write-forwarding ProxySlave → peer**: backend ProxySlave buffers write bytes during the transaction and emits a `proxy_i2c_complete` event on STOP / repeated-START. Frontend Esp32Bridge dispatches the event to a new onProxyI2cComplete callback; the shim replays the byte sequence on the actual peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware writes to peer SSD1306 actually repaint the OLED, peer PCF8574 latch updates, peer I2CMemoryDevice register mutations propagate. 6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no longer claims c3/xiao-c3/c3-supermini — they were already going through Esp32Bridge per the store's ESP32_RISCV_KINDS routing, but Interconnect was treating them as browser sims which broke proxy install. isEsp32Bridge now correctly includes c3 family + ESP32-S3 + Arduino Nano ESP32. Defensive: addBoard now disposes any existing shim's proxies before overwriting simulatorMap entry so test reruns don't leak timers. Tests: - 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts) - 11 cross-board scenarios + per-peer + write-forward + resync (i2c-esp32-multiboard-bridge.test.ts) - 1 real-firmware E2E for write-forward via QEMU (compile + load + observe proxy_i2c_complete arriving with the byte) - New sketch fixture: esp32_i2c_write_to_peer.ino Result: 90 test files / 1295 tests pass / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
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');
});
});