feat(sim): ATtiny85 USI I2C — drive I2C devices (SSD1306 OLED) via TinyWireM

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.
This commit is contained in:
David Montero 2026-06-19 07:44:18 +02:00
parent 06f672f47f
commit 37b3fac978
2 changed files with 110 additions and 1 deletions

View File

@ -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.

View File

@ -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;
}