From 49ac3730685aba5638b6bb4caef13c8d6bde0b12 Mon Sep 17 00:00:00 2001 From: David Montero Date: Tue, 11 Aug 2026 05:19:26 +0200 Subject: [PATCH] fix(sim): drive the slave ACK in the USI I2C bridge (blank SSD1306 on ATtiny85) The bridge was a passive sniffer that never drove the ACK bit. That worked by accident: avr8js seeds a pin's external input value to 0, so when TinyWireM released SDA for the ACK slot the master read 0 (ACK) and kept transmitting. Commit eb3b04ef (seed INPUT_PULLUP pins to their resting level) now sets that external value HIGH at init, so every ACK slot read back NACK and TinyWireM aborted each transmission right after the address byte: 239 address frames, zero data bytes, zero STOPs, and a permanently blank SSD1306 on every ATtiny85 + Tiny4kOLED project. Fix: capture each byte's ACK through the bus master callbacks (the bridge owns the master slot on the ATtiny85) and hold SDA's external input low for the whole ACK slot. Timing matters: 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 released only on the falling edge that closes the slot. Unowned addresses now read back NACK like real hardware. Regression test replays TinyWireM's exact register sequence (from the ATTinyCore disassembly) against a real AVRSimulator. --- frontend/src/__tests__/usi-i2c-ack.test.ts | 182 +++++++++++++++++++++ frontend/src/simulation/UsiI2cBridge.ts | 66 +++++++- 2 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 frontend/src/__tests__/usi-i2c-ack.test.ts 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; } }