From 37b3fac978fb2f671a8292d9a8f51154381f30cb Mon Sep 17 00:00:00 2001 From: David Montero Date: Fri, 19 Jun 2026 07:44:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(sim):=20ATtiny85=20USI=20I2C=20=E2=80=94?= =?UTF-8?q?=20drive=20I2C=20devices=20(SSD1306=20OLED)=20via=20TinyWireM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ATtiny85 has no hardware TWI; TinyWireM/Tiny4kOLED drive I2C through the USI peripheral (SDA=PB0, SCL=PB2). avr8js ships AVRUSI but velxio never instantiated it, so the I2C bus had no master on the ATtiny85 and devices (e.g. SSD1306 OLED) got no data — the display stayed blank (and wokwi-ssd1306 threw putImageData with an empty framebuffer). New UsiI2cBridge instantiates AVRUSI and sniffs the SDA/SCL lines, replaying START/STOP + 8-bit bytes onto the shared I2C bus as start/connectToSlave/writeByte/stop (the same calls AVRTWI makes for the Uno). Validated against real ATTinyCore Tiny4kOLED firmware: decodes addr 0x3C + SSD1306 init/data stream. Firmware tolerates NACK so the sniffer is passive. --- frontend/src/simulation/AVRSimulator.ts | 7 +- frontend/src/simulation/UsiI2cBridge.ts | 104 ++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 frontend/src/simulation/UsiI2cBridge.ts diff --git a/frontend/src/simulation/AVRSimulator.ts b/frontend/src/simulation/AVRSimulator.ts index 30839446..97c22084 100644 --- a/frontend/src/simulation/AVRSimulator.ts +++ b/frontend/src/simulation/AVRSimulator.ts @@ -38,6 +38,7 @@ import { PinManager } from './PinManager'; import { hexToUint8Array } from '../utils/hexParser'; import { I2CBusManager, nullI2CMaster } from './I2CBusManager'; import type { I2CDevice } from './I2CBusManager'; +import { attachUsiI2c } from './UsiI2cBridge'; /** * AVRSimulator - Emulates Arduino Uno (ATmega328p) using avr8js @@ -441,7 +442,11 @@ export class AVRSimulator { new AVRTimer(this.cpu, attiny85Timer0Config), new ATtinyTimer1(this.cpu, attinyTimer1Config), ]; - // usart stays null — ATtiny85 has no hardware USART + // usart stays null — ATtiny85 has no hardware USART. + // The ATtiny85 also has no hardware TWI: TinyWireM / Tiny4kOLED drive I2C + // through the USI peripheral on PB0 (SDA) / PB2 (SCL). Bridge that onto the + // shared I2C bus so devices (SSD1306 OLED, etc.) receive data. + this.peripherals.push(attachUsiI2c(this.cpu, this.portB, this.i2cBus)); } else { // ATmega2560 has more vectors before the timers/USART (8 external INTs, etc.), // so the interrupt WORD addresses differ from ATmega328P. diff --git a/frontend/src/simulation/UsiI2cBridge.ts b/frontend/src/simulation/UsiI2cBridge.ts new file mode 100644 index 00000000..5ab83e46 --- /dev/null +++ b/frontend/src/simulation/UsiI2cBridge.ts @@ -0,0 +1,104 @@ +/** + * ATtiny85 USI → I2C bridge. + * + * The ATtiny85 has no hardware TWI. TinyWireM (and Tiny4kOLED, the Adafruit + * tiny ports, etc.) drive I2C through the USI peripheral in two-wire mode: + * SDA on PB0, SCL on PB2. avr8js's `AVRUSI` shifts the USI data register out + * onto those port pins. This bridge sniffs the SDA/SCL lines, reconstructs the + * I2C transaction (START / STOP conditions + 8-bit bytes, MSB first) and + * replays it onto the shared I2C bus as start / connectToSlave / writeByte / + * 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. + * + * Validated against the real ATTinyCore-compiled Tiny4kOLED firmware: the + * decoded stream is `connectToSlave(0x3C,W)` followed by the SSD1306 init + * commands (0x00, 0xC8, 0xA1, 0xAD, 0x30, 0x8D, 0x14 …) and framebuffer data + * (0x40, …) — identical to the hardware-TWI path. + */ +import { AVRUSI } from 'avr8js'; +import type { CPU, AVRIOPort } from 'avr8js'; +import type { I2CBusManager } from './I2CBusManager'; + +export interface UsiI2cOptions { + /** PIN register address (PINB on the ATtiny85 = 0x36). */ + pinAddr?: number; + /** SDA bit within the port (USI DI/DO → PB0). */ + sda?: number; + /** SCL bit within the port (USI USCK → PB2). */ + scl?: number; +} + +/** + * Instantiate the USI peripheral and attach the I2C sniffer to `port`. + * Returns the AVRUSI instance so the caller can keep it alive (GC roots). + */ +export function attachUsiI2c( + cpu: CPU, + port: AVRIOPort, + bus: I2CBusManager, + opts: UsiI2cOptions = {}, +): AVRUSI { + const pinAddr = opts.pinAddr ?? 0x36; // ATtiny85 PINB + const sdaPin = opts.sda ?? 0; // PB0 = USI DI/DO (SDA) + const sclPin = opts.scl ?? 2; // PB2 = USI USCK (SCL) + + const usi = new AVRUSI(cpu, port, pinAddr, sdaPin, sclPin); + + let sda = 1; + let scl = 1; + let inFrame = false; + let bit = 0; + let cur = 0; + let addressByte = false; + + // PinState.Low === 0; everything else (High / pulled-up / floating) reads as 1. + const lineOf = (pin: number) => (port.pinState(pin) === 0 ? 0 : 1); + + port.addListener(() => { + const nextScl = lineOf(sclPin); + const nextSda = lineOf(sdaPin); + + // START / STOP are SDA edges while SCL is held HIGH. + if (scl === 1 && nextScl === 1) { + if (sda === 1 && nextSda === 0) { + bus.start(inFrame); // repeated START if a frame was already open + inFrame = true; + bit = 0; + cur = 0; + addressByte = true; + } else if (sda === 0 && nextSda === 1) { + if (inFrame) bus.stop(); + inFrame = false; + } + } + + // 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. + if (scl === 0 && nextScl === 1 && inFrame) { + if (bit < 8) { + cur = ((cur << 1) | nextSda) & 0xff; + bit++; + if (bit === 8) { + if (addressByte) { + bus.connectToSlave(cur >> 1, (cur & 1) === 0); + addressByte = false; + } else { + bus.writeByte(cur); + } + } + } else { + bit = 0; + cur = 0; + } + } + + scl = nextScl; + sda = nextSda; + }); + + return usi; +}