diff --git a/frontend/src/__tests__/usi-i2c-ack.test.ts b/frontend/src/__tests__/usi-i2c-ack.test.ts new file mode 100644 index 00000000..8ed2cee0 --- /dev/null +++ b/frontend/src/__tests__/usi-i2c-ack.test.ts @@ -0,0 +1,182 @@ +/** + * usi-i2c-ack.test.ts + * + * Regression tests for the ATtiny85 USI → I2C bridge's slave-side ACK + * (the blank-SSD1306-on-ATtiny85 regression). + * + * TinyWireM's USI_TWI_Master_Transfer strobes USICR with + * USIWM1|USICS1|USICLK|USITC (0x2B). In avr8js that is `clockSrc === 3`: + * DI is sampled at the top of the FALLING USITC write — one toggle AFTER + * the 9th rising edge. The bridge therefore has to hold SDA low from the + * moment the 8th bit completes until the falling edge that closes the ACK + * slot. Releasing on the rising edge makes the master read NACK and + * TinyWireM aborts after the address byte: the exact bug where the + * INPUT_PULLUP seed (eb3b04ef) stopped masking the missing ACK. + * + * Instead of compiling real firmware, the test replays TinyWireM's exact + * register sequence (taken from the ATTinyCore disassembly of + * USI_TWI_Master_Transfer / _Start / _Stop) against a real AVRSimulator. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { AVRSimulator } from '../simulation/AVRSimulator'; +import { PinManager } from '../simulation/PinManager'; +import type { I2CDevice } from '../simulation/I2CBusManager'; + +// ATtiny85 register addresses +const DDRB = 0x37; +const PORTB = 0x38; +const USICR = 0x2d; +const USISR = 0x2e; +const USIDR = 0x2f; + +const SDA = 1 << 0; // PB0 +const SCL = 1 << 2; // PB2 + +const USICR_STROBE = 0x2b; // USIWM1|USICS1|USICLK|USITC — TinyWireM's clock strobe +const USIOIF = 1 << 6; + +const EMPTY_HEX = ':00000001FF\n'; + +beforeEach(() => { + let counter = 0; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + cb(0); + return ++counter; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); +}); +afterEach(() => vi.unstubAllGlobals()); + +/** Mirror of TinyWireM's master ops, register-accurate. */ +class UsiMaster { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private cpu: any; + + constructor(sim: AVRSimulator) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.cpu = (sim as any).cpu; + } + + private write(addr: number, value: number): void { + this.cpu.writeData(addr, value); + } + private read(addr: number): number { + return this.cpu.data[addr]; + } + + init(): void { + this.write(PORTB, SDA); + this.write(PORTB, SDA | SCL); + this.write(DDRB, SCL); + this.write(DDRB, SCL | SDA); + this.write(USIDR, 0xff); + this.write(USICR, 0x28); // USIWM1|USICS1 — two-wire mode, hold + this.write(USISR, 0xf0); + } + + start(): void { + this.write(PORTB, this.read(PORTB) | SCL); // release SCL + this.write(PORTB, this.read(PORTB) & ~SDA); // SDA low while SCL high + this.write(PORTB, this.read(PORTB) & ~SCL); // SCL low + this.write(PORTB, this.read(PORTB) | SDA); // release SDA + } + + /** USI_TWI_Master_Transfer: returns USIDR after the shift completes. */ + private transfer(usisr: number): number { + this.write(USISR, usisr); + do { + this.write(USICR, USICR_STROBE); // positive SCL edge + this.write(USICR, USICR_STROBE); // negative SCL edge + } while (!(this.read(USISR) & USIOIF)); + const out = this.read(USIDR); + this.write(USIDR, 0xff); // release SDA + this.write(DDRB, this.read(DDRB) | SDA); // SDA back to output + return out; + } + + /** Send one byte; returns true when the slave ACKed it. */ + sendByte(value: number): boolean { + this.write(PORTB, this.read(PORTB) & ~SCL); + this.write(USIDR, value); + this.transfer(0xf0); // 8-bit transfer + this.write(DDRB, this.read(DDRB) & ~SDA); // SDA input for the ACK slot + const ack = this.transfer(0xfe); // 1-bit transfer + return (ack & 1) === 0; + } + + stop(): void { + this.write(PORTB, this.read(PORTB) & ~SDA); + this.write(PORTB, this.read(PORTB) | SCL); + this.write(PORTB, this.read(PORTB) | SDA); + } +} + +function makeSim() { + const sim = new AVRSimulator(new PinManager(), 'tiny85'); + sim.loadHex(EMPTY_HEX); + + const received: number[] = []; + let stops = 0; + const device: I2CDevice = { + address: 0x3c, + writeByte(v: number) { + received.push(v); + return true; + }, + readByte: () => 0xff, + stop() { + stops++; + }, + }; + // same call path ProtocolParts uses on AVR + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const simAny = sim as any; + if (typeof simAny.addI2CDevice === 'function') simAny.addI2CDevice(device); + else sim.i2cBus.addDevice(device); + + return { sim, received, getStops: () => stops }; +} + +describe('USI I2C slave-side ACK', () => { + it('ACKs a registered device through the whole write transaction', () => { + const { sim, received, getStops } = makeSim(); + const master = new UsiMaster(sim); + + master.init(); + master.start(); + expect(master.sendByte(0x78)).toBe(true); // 0x3C << 1 | W — address ACK + expect(master.sendByte(0x00)).toBe(true); // control byte + expect(master.sendByte(0xaf)).toBe(true); // command (display on) + master.stop(); + + expect(received).toEqual([0x00, 0xaf]); + expect(getStops()).toBe(1); + }); + + it('NACKs an address nobody owns, like real hardware', () => { + const { sim, received } = makeSim(); + const master = new UsiMaster(sim); + + master.init(); + master.start(); + expect(master.sendByte(0x42 << 1)).toBe(false); // no device at 0x42 + master.stop(); + expect(received).toEqual([]); + }); + + it('keeps ACKing across repeated transactions (Tiny4kOLED frame loop)', () => { + const { sim, received } = makeSim(); + const master = new UsiMaster(sim); + + master.init(); + for (let frame = 0; frame < 3; frame++) { + master.start(); + expect(master.sendByte(0x78)).toBe(true); + expect(master.sendByte(0x40)).toBe(true); // data stream control byte + expect(master.sendByte(frame)).toBe(true); + master.stop(); + } + expect(received).toEqual([0x40, 0, 0x40, 1, 0x40, 2]); + }); +}); diff --git a/frontend/src/simulation/UsiI2cBridge.ts b/frontend/src/simulation/UsiI2cBridge.ts index 5ab83e46..a056e756 100644 --- a/frontend/src/simulation/UsiI2cBridge.ts +++ b/frontend/src/simulation/UsiI2cBridge.ts @@ -10,9 +10,22 @@ * stop — exactly what `AVRTWI` does for the ATmega328P/Uno. I2C devices * (SSD1306 OLED, etc.) then receive data normally. * - * TinyWireM does not abort on NACK, so the bridge is a passive sniffer and - * never drives the ACK bit — which keeps it simple and avoids fighting the - * open-collector line. + * The bridge also plays the slave side of the ACK bit: when a registered + * device ACKs the address or a data byte, SDA's external input level is + * pulled low (`port.setPin`) for the whole ACK slot, exactly like the real + * SSD1306 would. Timing is the subtle part: TinyWireM strobes the USI with + * USICS1|USICLK (avr8js `clockSrc === 3`), which samples DI at the top of + * the FALLING USITC write — one toggle after the 9th rising edge. So the + * drive is asserted the moment the 8th bit completes and only released on + * the falling edge that closes the ACK slot; releasing on the rising edge + * hands the master a NACK and TinyWireM aborts the whole transmission + * (address goes out, zero data bytes follow — the blank-OLED symptom). + * Addresses nobody ACKs read back NACK, matching real hardware. + * + * `port.setPin` only rewrites the PIN register (no `writeGpio`), so neither + * this listener nor AVRUSI's START/STOP detector re-fires from the drive + * itself, and `pinState()` — what the sniffer reads — never consults the + * external input level, so the drive cannot fake bus edges either. * * Validated against the real ATTinyCore-compiled Tiny4kOLED firmware: the * decoded stream is `connectToSlave(0x3C,W)` followed by the SSD1306 init @@ -55,6 +68,34 @@ export function attachUsiI2c( let cur = 0; let addressByte = false; + // Slave-side ACK state. The bus reports each byte's ACK through its master + // callbacks (this bridge owns the master slot — on the ATtiny85 nothing + // else ever attaches, the AVRTWI takeover only happens on ATmega variants). + let lastAck = false; + let ackDriven = false; + let ackRelease = false; + const driveAck = () => { + ackDriven = true; + port.setPin(sdaPin, false); + }; + const releaseAck = () => { + ackRelease = false; + if (!ackDriven) return; + ackDriven = false; + port.setPin(sdaPin, true); + }; + bus.attachMaster({ + completeStart() {}, + completeStop() {}, + completeConnect(ack: boolean) { + lastAck = ack; + }, + completeWrite(ack: boolean) { + lastAck = ack; + }, + completeRead() {}, + }); + // PinState.Low === 0; everything else (High / pulled-up / floating) reads as 1. const lineOf = (pin: number) => (port.pinState(pin) === 0 ? 0 : 1); @@ -70,29 +111,44 @@ export function attachUsiI2c( bit = 0; cur = 0; addressByte = true; + releaseAck(); } else if (sda === 0 && nextSda === 1) { if (inFrame) bus.stop(); inFrame = false; + releaseAck(); } } - // Data bits are sampled on the SCL rising edge (MSB first). After 8 bits the - // 9th clock is the ACK slot, which the sniffer ignores. + // The falling edge that closes the ACK slot: the master has sampled the + // ACK at the top of this very toggle (clockSrc 3 shifts on the falling + // strobe), so the line can be handed back to the pull-up now. + if (scl === 1 && nextScl === 0 && ackRelease) { + releaseAck(); + } + + // Data bits are decoded on the SCL rising edge (MSB first). After 8 bits + // the 9th clock is the ACK slot; the ACK level was asserted when the byte + // completed and is scheduled for release on the slot's closing falling + // edge (see above). if (scl === 0 && nextScl === 1 && inFrame) { if (bit < 8) { cur = ((cur << 1) | nextSda) & 0xff; bit++; if (bit === 8) { + lastAck = false; if (addressByte) { bus.connectToSlave(cur >> 1, (cur & 1) === 0); addressByte = false; } else { bus.writeByte(cur); } + // completeConnect / completeWrite ran synchronously above. + if (lastAck) driveAck(); } } else { bit = 0; cur = 0; + if (ackDriven) ackRelease = true; } }