velxio/frontend/src/simulation/parts/ProtocolParts.ts

1403 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ProtocolParts.ts — Simulation for I2C, SPI, and custom-protocol components.
*
* Implements eight components that require specific communication stacks:
*
* ssd1306 — I2C OLED display (0x3C). Full command/data decoder.
* ds1307 — I2C Real-Time Clock (0x68). Returns browser system time.
* mpu6050 — I2C 6-axis IMU (0x68/0x69). Full register map simulation.
* dht22 — Single-wire temp/humidity. Drives DATA pin after start signal.
* hx711 — 2-wire load cell amplifier. Clocks out 24-bit ADC value.
* ir-receiver — NEC IR receiver. Click generates active-low pulse train.
* ir-remote — NEC IR remote. Button click dispatches ir-signal event.
* microsd-card — SPI SD card. Responds to CMD0/CMD8/ACMD41/CMD58 init.
*
* NOTE — timing-sensitive protocols (dht22, ir-receiver, ir-remote):
* Full µs-accuracy requires CPU-loop integration. These simulate protocol
* intent and work with polling-based Arduino code; hardware-interrupt-based
* libraries (e.g. IRremote) need the exact cycle counts not available here.
*/
import { PartSimulationRegistry } from './PartSimulationRegistry';
import { VirtualDS1307, VirtualBMP280, VirtualDS3231, VirtualPCF8574 } from '../I2CBusManager';
import type { I2CDevice } from '../I2CBusManager';
import { HD44780Decoder } from '../HD44780Decoder';
import { registerSensorUpdate, unregisterSensorUpdate } from '../SensorUpdateRegistry';
import { useSimulatorStore } from '../../store/useSimulatorStore';
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Remove a virtual I2C device from both AVR (i2cBus) and RP2040 simulators.
*/
function removeI2CDevice(simulator: any, address: number): void {
simulator.i2cBus?.removeDevice(address);
simulator.removeI2CDevice?.(address, 0);
simulator.removeI2CDevice?.(address, 1);
}
// ─── SSD1306 OLED ────────────────────────────────────────────────────────────
/**
* SSD1306Core — shared GDDRAM buffer, command decoder, and rendering logic.
*
* The SSD1306 command set is identical for I2C and SPI; only the transport
* differs. This core is used by both VirtualSSD1306 (I2C) and
* attachSSD1306SPI (SPI).
*
* Supported commands:
* - 0x20 Set Memory Addressing Mode (horizontal / vertical / page)
* - 0x21 Set Column Address
* - 0x22 Set Page Address
* - 0x400x7F Set Display Start Line
* - 0xAF Display ON / 0xAE Display OFF
* - All other parameterized commands are parsed but ignored.
*/
class SSD1306Core {
/** 1024-byte GDDRAM: 8 pages × 128 columns. Each byte = 8 vertical pixels. */
readonly buffer = new Uint8Array(128 * 8);
// GDDRAM cursor
private col = 0;
private page = 0;
private colStart = 0;
private colEnd = 127;
private pageStart = 0;
private pageEnd = 7;
private memMode = 0; // 0=horizontal, 1=vertical, 2=page
// Multi-byte command accumulation
private cmdBuf: number[] = [];
private cmdWant = 0;
/** How many parameter bytes does this command require? */
static cmdParams(cmd: number): number {
if (
cmd === 0x20 ||
cmd === 0x81 ||
cmd === 0x8d ||
cmd === 0xa8 ||
cmd === 0xd3 ||
cmd === 0xd5 ||
cmd === 0xd8 ||
cmd === 0xd9 ||
cmd === 0xda ||
cmd === 0xdb
)
return 1;
if (cmd === 0x21 || cmd === 0x22) return 2;
return 0;
}
/** Write a data byte to GDDRAM and advance cursor. */
writeData(value: number): void {
this.buffer[this.page * 128 + this.col] = value;
this.advanceCursor();
}
/** Feed a command or parameter byte. Multi-byte commands are accumulated. */
writeCommand(value: number): void {
if (this.cmdWant > 0) {
this.cmdBuf.push(value);
this.cmdWant--;
if (this.cmdWant === 0) this.applyCmd();
return;
}
this.cmdBuf = [value];
this.cmdWant = SSD1306Core.cmdParams(value);
if (this.cmdWant === 0) this.applyCmd();
}
private applyCmd(): void {
const [cmd, p1, p2] = this.cmdBuf;
switch (cmd) {
case 0x20:
this.memMode = p1 & 0x03;
break;
case 0x21:
this.colStart = p1 & 0x7f;
this.colEnd = p2 & 0x7f;
this.col = this.colStart;
break;
case 0x22:
this.pageStart = p1 & 0x07;
this.pageEnd = p2 & 0x07;
this.page = this.pageStart;
break;
default:
if (cmd >= 0x40 && cmd <= 0x7f) {
/* display start line — visual, skip */
}
break;
}
}
private advanceCursor(): void {
if (this.memMode === 0) {
// horizontal addressing
this.col++;
if (this.col > this.colEnd) {
this.col = this.colStart;
this.page++;
if (this.page > this.pageEnd) this.page = this.pageStart;
}
} else if (this.memMode === 1) {
// vertical addressing
this.page++;
if (this.page > this.pageEnd) {
this.page = this.pageStart;
this.col++;
if (this.col > this.colEnd) this.col = this.colStart;
}
} else {
// page addressing
this.col++;
if (this.col > this.colEnd) this.col = this.colStart;
}
}
/**
* Push the 1-bit GDDRAM buffer to the wokwi-ssd1306 web component.
*
* wokwi-ssd1306 API:
* - `element.imageData` — a 128×64 ImageData (RGBA, 4 bytes/pixel)
* - `element.redraw()` — flushes imageData to the internal canvas
*/
syncElement(element: HTMLElement): void {
const el = element as any;
if (!el) return;
let imgData: ImageData | undefined = el.imageData;
if (!imgData || imgData.width !== 128 || imgData.height !== 64) {
try {
imgData = new ImageData(128, 64);
} catch {
return;
}
}
const px = imgData.data;
for (let page = 0; page < 8; page++) {
for (let col = 0; col < 128; col++) {
const byte = this.buffer[page * 128 + col];
for (let bit = 0; bit < 8; bit++) {
const row = page * 8 + bit;
const lit = (byte >> bit) & 1;
const idx = (row * 128 + col) * 4;
px[idx] = lit ? 200 : 0; // R
px[idx + 1] = lit ? 230 : 0; // G
px[idx + 2] = lit ? 255 : 0; // B
px[idx + 3] = 255; // A
}
}
}
el.imageData = imgData;
if (typeof el.redraw === 'function') el.redraw();
}
}
/**
* VirtualSSD1306 — I2C wrapper around SSD1306Core.
*
* Handles the I2C control byte (0x00 = command stream, 0x40 = data stream)
* and delegates command/data writes to the shared core.
*/
class VirtualSSD1306 implements I2CDevice {
address: number;
private readonly core = new SSD1306Core();
private ctrlByte = true;
private isData = false;
constructor(
address: number,
private element: HTMLElement,
) {
this.address = address;
}
/** Expose core buffer for tests. */
get buffer(): Uint8Array {
return this.core.buffer;
}
writeByte(value: number): boolean {
if (this.ctrlByte) {
this.isData = (value & 0x40) !== 0;
this.ctrlByte = false;
return true;
}
if (this.isData) {
this.core.writeData(value);
} else {
this.core.writeCommand(value);
}
return true;
}
readByte(): number {
return 0xff;
}
stop(): void {
this.ctrlByte = true;
this.core.syncElement(this.element);
}
}
/**
* Attach SSD1306 in SPI mode — intercepts the AVR SPI bus.
*
* Follows the same pattern as ILI9341 (ComplexParts.ts): hook spi.onByte,
* track DC pin state via PinManager, and render GDDRAM to the element.
*/
function attachSSD1306SPI(
element: HTMLElement,
simulator: any,
getPin: (name: string) => number | null,
): () => void {
const pinManager = simulator.pinManager;
const spi = simulator.spi;
if (!pinManager || !spi) return () => {};
const core = new SSD1306Core();
let dcState = false;
const unsubs: (() => void)[] = [];
// Track DC pin (LOW = command, HIGH = data)
const pinDC = getPin('DC');
if (pinDC !== null) {
unsubs.push(
pinManager.onPinChange(pinDC, (_: number, s: boolean) => {
dcState = s;
}),
);
}
// Throttle rendering to ~60 fps
let dirty = false;
let rafId: number | null = null;
const scheduleSync = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
if (dirty) {
core.syncElement(element);
dirty = false;
}
});
};
// Hook AVR SPI bus (onByte + completeTransfer)
const prevOnByte = spi.onByte;
spi.onByte = (value: number) => {
if (!dcState) {
core.writeCommand(value);
} else {
core.writeData(value);
dirty = true;
scheduleSync();
}
spi.completeTransfer(0xff);
};
return () => {
spi.onByte = prevOnByte;
if (rafId !== null) cancelAnimationFrame(rafId);
unsubs.forEach((u) => u());
};
}
/**
* Internal: SSD1306 attach logic, parameterised over the wire protocol.
* Used by the three picker entries (the generic `ssd1306` plus the two
* dedicated `ssd1306-i2c` / `ssd1306-spi` shortcuts).
*/
function attachSSD1306(
element: HTMLElement,
simulator: unknown,
getPin: (n: string) => number | null,
protocol: 'i2c' | 'spi',
): () => void {
if (protocol === 'spi') {
return attachSSD1306SPI(element, simulator, getPin);
}
const sim = simulator as any;
const i2cAddr = 0x3c;
const device = new VirtualSSD1306(i2cAddr, element);
// Check ESP32 first — its shim exposes BOTH registerSensor (for the
// backend QEMU slave) AND addI2CDevice (for the frontend bus used by
// the Interconnect cross-board bridge). AVR / RP2040 only expose
// addI2CDevice, so registerSensor is the unambiguous ESP32 marker.
if (typeof sim.registerSensor === 'function') {
// ── ESP32 path ─────────────────────────────────────────────────────────
const virtualPin = 200 + i2cAddr;
sim.registerSensor('ssd1306', virtualPin, { addr: i2cAddr });
sim.addI2CTransactionListener?.(i2cAddr, (data: number[]) => {
data.forEach((b: number) => device.writeByte(b));
device.stop();
});
// Mirror on the frontend bus so peer boards reading across an
// I2C bridge can also reach the device.
sim.addI2CDevice?.(device);
return () => {
sim.unregisterSensor(virtualPin);
sim.removeI2CTransactionListener?.(i2cAddr);
sim.removeI2CDevice?.(i2cAddr, 0);
};
} else if (typeof sim.addI2CDevice === 'function') {
// ── AVR / RP2040 path ──────────────────────────────────────────────────
sim.addI2CDevice(device);
return () => removeI2CDevice(sim, device.address);
}
return () => {};
}
/**
* Generic `ssd1306` entry — reads the user-selectable `protocol`
* property (control: select, options: i2c | spi). Keeps backward
* compatibility for existing projects whose components carry this id.
*/
PartSimulationRegistry.register('ssd1306', {
attachEvents: (element, simulator, getPin, componentId) => {
const { components } = useSimulatorStore.getState();
const comp = components.find((c) => c.id === componentId);
const protocol = ((comp?.properties?.protocol as string) ?? 'i2c') as 'i2c' | 'spi';
return attachSSD1306(element, simulator, getPin, protocol);
},
});
/**
* Picker shortcut: "SSD1306 OLED (I2C)" — same web component, but the
* metadata defaults protocol to 'i2c' and the part skips the property
* lookup. Lets users find the I2C variant by name without having to
* discover the protocol property on the generic ssd1306 entry.
*/
PartSimulationRegistry.register('ssd1306-i2c', {
attachEvents: (element, simulator, getPin) =>
attachSSD1306(element, simulator, getPin, 'i2c'),
});
/** Picker shortcut: "SSD1306 OLED (SPI)" — counterpart to ssd1306-i2c. */
PartSimulationRegistry.register('ssd1306-spi', {
attachEvents: (element, simulator, getPin) =>
attachSSD1306(element, simulator, getPin, 'spi'),
});
// ─── DS1307 RTC ──────────────────────────────────────────────────────────────
/**
* DS1307 Real-Time Clock — uses the pre-built VirtualDS1307 from I2CBusManager.
* Returns the browser's current system time in BCD format for registers 06.
*/
PartSimulationRegistry.register('ds1307', {
attachEvents: (_element, simulator, _getPin) => {
const sim = simulator as any;
const rtc = new VirtualDS1307();
if (typeof sim.registerSensor === 'function') {
// ── ESP32 path: backend QEMU RTC slave + frontend bus mirror ────────
const virtualPin = 200 + 0x68;
sim.registerSensor('ds1307', virtualPin, { addr: 0x68 });
sim.addI2CDevice?.(rtc);
return () => {
sim.unregisterSensor(virtualPin);
sim.removeI2CDevice?.(rtc.address, 0);
};
} else if (typeof sim.addI2CDevice === 'function') {
// ── AVR / RP2040 path ──────────────────────────────────────────────────
sim.addI2CDevice(rtc);
return () => removeI2CDevice(sim, rtc.address);
}
return () => {};
},
});
// ─── MPU-6050 IMU ────────────────────────────────────────────────────────────
/**
* Virtual MPU-6050 — 6-axis IMU register simulation at I2C address 0x68.
*
* Pre-loaded registers:
* 0x75 WHO_AM_I = 0x68
* 0x6B PWR_MGMT_1 = 0x00 (already awake — no need to write 0 to wake)
* 0x3B0x40 ACCEL XYZ = (0, 0, +1g = 0x4000) — device sitting flat
* 0x410x42 TEMP_OUT = ~25°C
* 0x430x48 GYRO XYZ = 0 (stationary)
*
* The sketch can write to set register pointer, then read sequentially.
*/
class VirtualMPU6050 implements I2CDevice {
address: number;
registers = new Uint8Array(256);
private regPtr = 0;
private firstByte = true;
constructor(address: number) {
this.address = address;
// WHO_AM_I
this.registers[0x75] = 0x68;
// PWR_MGMT_1: device awake by default (0 = no sleep)
this.registers[0x6b] = 0x00;
// ACCEL: Z = +1g = +16384 (0x4000) at ±2g full-scale
this.registers[0x3b] = 0x00; // ACCEL_XOUT_H
this.registers[0x3c] = 0x00; // ACCEL_XOUT_L
this.registers[0x3d] = 0x00; // ACCEL_YOUT_H
this.registers[0x3e] = 0x00; // ACCEL_YOUT_L
this.registers[0x3f] = 0x40; // ACCEL_ZOUT_H (0x4000 = +16384 = +1g)
this.registers[0x40] = 0x00; // ACCEL_ZOUT_L
// TEMP: T(°C) = TEMP_OUT / 340.0 + 36.53
// → TEMP_OUT = (25 - 36.53) × 340 ≈ -3920 = 0xF190
const tempRaw = Math.round((25 - 36.53) * 340) & 0xffff;
this.registers[0x41] = (tempRaw >> 8) & 0xff;
this.registers[0x42] = tempRaw & 0xff;
// GYRO: all zero (stationary)
// 0x430x48 already 0 from Uint8Array initialization
}
writeByte(value: number): boolean {
if (this.firstByte) {
this.regPtr = value;
this.firstByte = false;
} else {
this.registers[this.regPtr] = value;
this.regPtr = (this.regPtr + 1) & 0xff;
}
return true;
}
readByte(): number {
const val = this.registers[this.regPtr];
this.regPtr = (this.regPtr + 1) & 0xff;
return val;
}
stop(): void {
this.firstByte = true;
}
}
PartSimulationRegistry.register('mpu6050', {
attachEvents: (element, simulator, _getPin, componentId) => {
const sim = simulator as any;
const el = element as any;
// Respect AD0 pin: `el.ad0 = true` → address 0x69, else 0x68
const addr = el.ad0 === true || el.ad0 === 'true' ? 0x69 : 0x68;
if (typeof sim.registerSensor === 'function') {
// ── ESP32 path: backend QEMU I2C slave + frontend bus mirror ────────
const virtualPin = 200 + addr;
const device = new VirtualMPU6050(addr);
sim.registerSensor('mpu6050', virtualPin, { addr });
sim.addI2CDevice?.(device);
const writeI16 = (regH: number, raw: number) => {
const v = Math.max(-32768, Math.min(32767, Math.round(raw))) & 0xffff;
device.registers[regH] = (v >> 8) & 0xff;
device.registers[regH + 1] = v & 0xff;
};
registerSensorUpdate(componentId, (values) => {
sim.updateSensor(virtualPin, values);
// Keep the frontend-side mirror in sync so peer-master bridge reads
// see fresh values too.
if ('accelX' in values) writeI16(0x3b, (values.accelX as number) * 16384);
if ('accelY' in values) writeI16(0x3d, (values.accelY as number) * 16384);
if ('accelZ' in values) writeI16(0x3f, (values.accelZ as number) * 16384);
if ('gyroX' in values) writeI16(0x43, (values.gyroX as number) * 131);
if ('gyroY' in values) writeI16(0x45, (values.gyroY as number) * 131);
if ('gyroZ' in values) writeI16(0x47, (values.gyroZ as number) * 131);
if ('temp' in values) writeI16(0x41, ((values.temp as number) - 36.53) * 340);
});
return () => {
sim.unregisterSensor(virtualPin);
sim.removeI2CDevice?.(addr, 0);
unregisterSensorUpdate(componentId);
};
} else if (typeof sim.addI2CDevice === 'function') {
// ── AVR / RP2040 path: virtual I2C device in JavaScript ──────────────
const device = new VirtualMPU6050(addr);
sim.addI2CDevice(device);
const writeI16 = (regH: number, raw: number) => {
const v = Math.max(-32768, Math.min(32767, Math.round(raw))) & 0xffff;
device.registers[regH] = (v >> 8) & 0xff;
device.registers[regH + 1] = v & 0xff;
};
registerSensorUpdate(componentId, (values) => {
if ('accelX' in values) writeI16(0x3b, (values.accelX as number) * 16384);
if ('accelY' in values) writeI16(0x3d, (values.accelY as number) * 16384);
if ('accelZ' in values) writeI16(0x3f, (values.accelZ as number) * 16384);
if ('gyroX' in values) writeI16(0x43, (values.gyroX as number) * 131);
if ('gyroY' in values) writeI16(0x45, (values.gyroY as number) * 131);
if ('gyroZ' in values) writeI16(0x47, (values.gyroZ as number) * 131);
if ('temp' in values) writeI16(0x41, ((values.temp as number) - 36.53) * 340);
});
return () => {
removeI2CDevice(sim, device.address);
unregisterSensorUpdate(componentId);
};
}
return () => {};
},
});
// ─── DHT22 Temperature / Humidity Sensor ─────────────────────────────────────
/**
* DHT22 (AM2302) — single-wire bidirectional protocol.
*
* Protocol summary:
* 1. MCU drives DATA LOW for ≥1 ms (start signal)
* 2. MCU releases DATA HIGH
* 3. DHT22 drives: 80 µs LOW → 80 µs HIGH (response)
* 4. DHT22 transmits 40 bits: each bit = 50 µs LOW + (26 µs=0 | 70 µs=1) HIGH
* 5. Data layout: [humidity_H, humidity_L, temp_H, temp_L, checksum]
* Humidity in 0.1%, Temperature in 0.1°C (MSB = sign for temp)
*
* TIMING NOTE:
* Full µs-accuracy requires injecting pin changes inside the CPU execution
* loop. This implementation drives DATA via setPinState() after detecting the
* start sequence. It works with simple polling-based DHT22 code. The standard
* Arduino DHT library uses pulseIn() counts; exact cycle-accuracy is not
* achievable without modifying the AVR execution loop.
*
* Default values: 50.0% humidity, 25.0°C temperature.
* These can be changed by setting element properties: `el.temperature`, `el.humidity`.
*/
function buildDHT22Payload(element: HTMLElement): Uint8Array {
const el = element as any;
const humidity = Math.round((el.humidity ?? 50.0) * 10); // tenths of %
const temperature = Math.round((el.temperature ?? 25.0) * 10); // tenths of °C
const h_H = (humidity >> 8) & 0xff;
const h_L = humidity & 0xff;
// Temperature sign bit is bit 15 of the 16-bit value
const rawTemp = temperature < 0 ? (-temperature & 0x7fff) | 0x8000 : temperature & 0x7fff;
const t_H = (rawTemp >> 8) & 0xff;
const t_L = rawTemp & 0xff;
const chk = (h_H + h_L + t_H + t_L) & 0xff;
return new Uint8Array([h_H, h_L, t_H, t_L, chk]);
}
/**
* Schedule the full DHT22 waveform on DATA using cycle-accurate pin changes.
*
* DHT22 protocol (after MCU releases DATA HIGH):
* - 80 µs LOW → 80 µs HIGH (response preamble)
* - 40 bits, each: 50 µs LOW + (26 µs HIGH = '0', 70 µs HIGH = '1')
* - Line released HIGH after last bit
*
* At 16 MHz: 1 µs = 16 cycles
* - 80 µs = 1280 cycles, 50 µs = 800 cycles, 26 µs = 416 cycles, 70 µs = 1120 cycles
*/
function scheduleDHT22Response(simulator: any, pin: number, element: HTMLElement): void {
if (typeof simulator.schedulePinChange !== 'function') {
// Fallback: synchronous drive (legacy / non-AVR simulators)
const payload = buildDHT22Payload(element);
simulator.setPinState(pin, false);
simulator.setPinState(pin, true);
for (const byte of payload) {
for (let b = 7; b >= 0; b--) {
const bit = (byte >> b) & 1;
simulator.setPinState(pin, false);
simulator.setPinState(pin, !!bit);
}
}
simulator.setPinState(pin, true);
return;
}
const payload = buildDHT22Payload(element);
const now = simulator.getCurrentCycles() as number;
// Scale timing by CPU clock — AVR runs at 16 MHz, RP2040 at 125 MHz.
const clockHz: number =
typeof simulator.getClockHz === 'function' ? simulator.getClockHz() : 16_000_000;
const us = (microseconds: number) => Math.round((microseconds * clockHz) / 1_000_000);
const RESPONSE_START = us(20); // DHT22 response start (~20 µs after MCU releases)
const LOW80 = us(80); // 80 µs LOW preamble
const HIGH80 = us(80); // 80 µs HIGH preamble
const LOW50 = us(50); // 50 µs LOW marker before each bit
const HIGH0 = us(26); // 26 µs HIGH → bit '0'
const HIGH1 = us(70); // 70 µs HIGH → bit '1'
let t = now + RESPONSE_START;
// Preamble: 80 µs LOW
simulator.schedulePinChange(pin, false, t);
t += LOW80;
// Preamble: 80 µs HIGH
simulator.schedulePinChange(pin, true, t);
t += HIGH80;
// 40 data bits, MSB first — schedule LOW then advance, schedule HIGH then advance
for (const byte of payload) {
for (let b = 7; b >= 0; b--) {
const bit = (byte >> b) & 1;
simulator.schedulePinChange(pin, false, t);
t += LOW50;
simulator.schedulePinChange(pin, true, t);
t += bit ? HIGH1 : HIGH0;
}
}
// Final release
simulator.schedulePinChange(pin, false, t);
t += LOW50;
simulator.schedulePinChange(pin, true, t);
}
PartSimulationRegistry.register('dht22', {
attachEvents: (element, simulator, getPin, componentId) => {
// wokwi-dht22 element uses 'SDA' as the data pin name (not 'DATA')
const pin = getPin('SDA') ?? getPin('DATA');
if (pin === null) return () => {};
// Ask the simulator if it handles sensor protocols natively (e.g. ESP32
// delegates to backend QEMU). If so, we only forward property updates.
const el = element as any;
const temperature = el.temperature ?? 25.0;
const humidity = el.humidity ?? 50.0;
const handledNatively =
typeof (simulator as any).registerSensor === 'function' &&
(simulator as any).registerSensor('dht22', pin, { temperature, humidity });
if (handledNatively) {
registerSensorUpdate(componentId, (values) => {
if ('temperature' in values) el.temperature = values.temperature as number;
if ('humidity' in values) el.humidity = values.humidity as number;
(simulator as any).updateSensor(pin, {
temperature: el.temperature ?? 25.0,
humidity: el.humidity ?? 50.0,
});
});
return () => {
(simulator as any).unregisterSensor(pin);
unregisterSensorUpdate(componentId);
};
}
let wasLow = false;
// Prevent DHT22's own scheduled pin changes from re-triggering the response.
// After the MCU releases DATA HIGH and we begin responding, we ignore all
// pin-change callbacks until the full waveform has been emitted.
// DHT22 response is ~5 ms; gate for ~12.5 ms scaled to the CPU clock.
const clockHz: number =
typeof (simulator as any).getClockHz === 'function'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
(simulator as any).getClockHz()
: 16_000_000;
const RESPONSE_GATE_CYCLES = Math.round((12_500 * clockHz) / 1_000_000);
let responseEndCycle = 0;
let responseEndTimeMs = 0; // time-based fallback for ESP32 (no cycle counter)
const getCycles = (): number =>
typeof (simulator as any).getCurrentCycles === 'function'
? ((simulator as any).getCurrentCycles() as number)
: -1;
const unsub = (simulator as any).pinManager.onPinChange(pin, (_: number, state: boolean) => {
// While DHT22 is driving the line, ignore our own scheduled changes.
const now = getCycles();
if (now >= 0 && now < responseEndCycle) return;
// Time-based fallback for ESP32 (no cycle counter available)
if (now < 0 && Date.now() < responseEndTimeMs) return;
if (!state) {
// MCU drove DATA LOW — start signal detected
wasLow = true;
return;
}
if (wasLow) {
// MCU released DATA HIGH — begin DHT22 response
wasLow = false;
const cur = getCycles();
responseEndCycle = cur >= 0 ? cur + RESPONSE_GATE_CYCLES : 0;
responseEndTimeMs = Date.now() + 20; // 20ms gate for non-cycle simulators
scheduleDHT22Response(simulator, pin, element);
}
});
// Idle state: DATA HIGH (pulled up)
simulator.setPinState(pin, true);
// SensorControlPanel: update temperature / humidity on the element
registerSensorUpdate(componentId, (values) => {
const el = element as any;
if ('temperature' in values) el.temperature = values.temperature as number;
if ('humidity' in values) el.humidity = values.humidity as number;
});
return () => {
unsub();
simulator.setPinState(pin, true);
unregisterSensorUpdate(componentId);
};
},
});
// ─── HX711 Load Cell Amplifier ────────────────────────────────────────────────
/**
* HX711 — 24-bit ADC for load cells.
*
* Protocol:
* - DOUT LOW = conversion ready
* - MCU reads 24 rising CLK edges → DOUT sends 24 bits MSB-first
* - 1 extra CLK pulse → gain 128 (channel A, default)
* - After 25th pulse falling edge: new conversion starts (DOUT → LOW after ~delay)
*
* Default weight: 100 g. Change via element.weight (grams).
* Raw ADC = weight × 1000 (signed 24-bit two's complement).
*
* Taring: Arduino sketches typically call tare() first, which reads the
* zero offset. This simulation always returns weight × 1000 as the raw value;
* after taring with 0 g the sketch will correctly read any non-zero value.
*/
PartSimulationRegistry.register('hx711', {
attachEvents: (element, simulator, getPin) => {
const pinSCK = getPin('SCK');
const pinDOUT = getPin('DOUT');
if (pinSCK === null || pinDOUT === null) return () => {};
let rawValue = rawFromWeight(element);
let bitCount = 0;
let finishing = false;
function rawFromWeight(el: HTMLElement): number {
const w = (el as any).weight ?? 100; // grams
const raw = Math.round(w * 1000); // 24-bit fixed-point
return Math.max(-8_388_608, Math.min(8_388_607, raw)) & 0xff_ffff;
}
// DOUT LOW = next conversion ready
simulator.setPinState(pinDOUT, false);
const unsub = (simulator as any).pinManager.onPinChange(
pinSCK,
(_: number, rising: boolean) => {
if (rising) {
// Rising edge: output the current bit (MSB first), then advance
if (bitCount < 24) {
const bit = (rawValue >> (23 - bitCount)) & 1;
simulator.setPinState(pinDOUT, bit === 1);
bitCount++;
} else {
// 25th pulse → gain select. DOUT driven HIGH (end of word)
simulator.setPinState(pinDOUT, true);
finishing = true;
}
} else {
// Falling edge after the 25th pulse → conversion complete
if (finishing) {
finishing = false;
bitCount = 0;
rawValue = rawFromWeight(element);
// DOUT LOW = new conversion ready (simulate ~10 ms conversion time)
setTimeout(() => simulator.setPinState(pinDOUT, false), 10);
}
}
},
);
return () => {
unsub();
simulator.setPinState(pinDOUT, true); // DOUT HIGH = device idle / power down
};
},
});
// ─── IR Receiver ─────────────────────────────────────────────────────────────
/**
* IR receiver (e.g. VS1838B) — responds to clicks by generating an NEC
* protocol pulse train on the DATA/OUT pin (active-low: LOW = IR burst).
*
* NEC frame on the demodulated output:
* 9 ms LOW + 4.5 ms HIGH (preamble)
* 8-bit address (MSB first) + 8-bit ~address
* 8-bit command (MSB first) + 8-bit ~command
* Final 562 µs LOW ("end burst")
*
* Default: address 0x00, command 0x45 (NEC remote "POWER" button equivalent).
* Change by setting `element.irAddress` and `element.irCommand`.
*
* TIMING: Each ms-level delay is implemented via setTimeout. This chains
* ~70 callbacks (35 bits × 2 edges each). Because the simulation runs in
* requestAnimationFrame batches (~16 ms), the timing will be stretched but
* the logical transitions are correct for polling-based IR decoders.
*/
function necBitSequence(address: number, command: number): number[] {
/* Returns interleaved [duration_ms, level, ...] pairs for NEC frame.
level: 1 = LINE HIGH (no IR / space), 0 = LINE LOW (IR burst / mark) */
const frames: number[] = [];
function push(duration: number, level: number) {
frames.push(duration, level);
}
// Preamble
push(9, 0); // 9 ms mark
push(4.5, 1); // 4.5 ms space
// Build 32 bits: addr, ~addr, cmd, ~cmd
const bytes = [address & 0xff, ~address & 0xff, command & 0xff, ~command & 0xff];
for (const byte of bytes) {
for (let b = 0; b < 8; b++) {
// LSB first for NEC
const bit = (byte >> b) & 1;
push(0.562, 0); // 562 µs mark (same for 0 and 1)
push(bit ? 1.687 : 0.562, 1); // space: 1687 µs=1, 562 µs=0
}
}
// Final burst
push(0.562, 0);
return frames;
}
function driveNECSequence(simulator: any, pin: number, address: number, command: number): void {
const frames = necBitSequence(address, command);
let i = 0;
function next(): void {
if (i >= frames.length) {
simulator.setPinState(pin, true); // idle HIGH
return;
}
const duration = frames[i++];
const level = frames[i++];
simulator.setPinState(pin, level === 1); // active-low: LOW=burst, HIGH=space
setTimeout(next, duration);
}
next();
}
PartSimulationRegistry.register('ir-receiver', {
attachEvents: (element, simulator, getPin) => {
const pin = getPin('OUT') ?? getPin('DATA');
if (pin === null) return () => {};
// Idle: pin HIGH (no IR)
simulator.setPinState(pin, true);
const onClick = () => {
const el = element as any;
const address = (el.irAddress ?? 0x00) & 0xff;
const command = (el.irCommand ?? 0x45) & 0xff;
driveNECSequence(simulator, pin, address, command);
};
element.addEventListener('click', onClick);
return () => {
element.removeEventListener('click', onClick);
simulator.setPinState(pin, true);
};
},
});
// ─── IR Remote ───────────────────────────────────────────────────────────────
/**
* IR remote control — each button click:
* 1. Fires an `ir-signal` CustomEvent on the element with {address, command}
* 2. Drives the IR output pin (if connected) with the NEC pulse sequence
*
* Button → command mapping (NEC standard SHARP-style remote):
* 09 → commands 0x16, 0x0C, 0x18, 0x5E, 0x08, 0x1C, 0x5A, 0x42, 0x52, 0x4A
* VOL+→0x40, VOL-→0x00, CH+→0x48, CH-→0x0D, POWER→0x45, MUTE→0x09
*
* The element should dispatch `button-press` events with `detail.key` naming
* the button (matches typical wokwi IR remote element events). We listen for
* both 'button-press' from the element model and 'click' as fallback.
*/
const IR_REMOTE_COMMANDS: Record<string, number> = {
'0': 0x16,
'1': 0x0c,
'2': 0x18,
'3': 0x5e,
'4': 0x08,
'5': 0x1c,
'6': 0x5a,
'7': 0x42,
'8': 0x52,
'9': 0x4a,
'vol+': 0x40,
'vol-': 0x00,
'ch+': 0x48,
'ch-': 0x0d,
power: 0x45,
mute: 0x09,
ok: 0x1b,
up: 0x46,
down: 0x15,
left: 0x44,
right: 0x43,
};
PartSimulationRegistry.register('ir-remote', {
attachEvents: (element, simulator, getPin) => {
const pin = getPin('IR') ?? getPin('OUT');
// Idle HIGH if pin connected
if (pin !== null) simulator.setPinState(pin, true);
const el = element as any;
const address = (el.irAddress ?? 0x00) & 0xff;
const onButtonPress = (e: Event) => {
const key = ((e as CustomEvent).detail?.key ?? '').toLowerCase();
const command = (IR_REMOTE_COMMANDS[key] ?? 0x45) & 0xff;
element.dispatchEvent(
new CustomEvent('ir-signal', {
bubbles: true,
detail: { address, command, key },
}),
);
if (pin !== null) driveNECSequence(simulator, pin, address, command);
};
const onClick = () => {
// Fallback for plain click — send POWER code
const command = 0x45;
element.dispatchEvent(
new CustomEvent('ir-signal', {
bubbles: true,
detail: { address, command, key: 'power' },
}),
);
if (pin !== null) driveNECSequence(simulator, pin, address, command);
};
element.addEventListener('button-press', onButtonPress);
element.addEventListener('click', onClick);
return () => {
element.removeEventListener('button-press', onButtonPress);
element.removeEventListener('click', onClick);
if (pin !== null) simulator.setPinState(pin, true);
};
},
});
// ─── MicroSD Card ─────────────────────────────────────────────────────────────
/**
* MicroSD card — SPI mode initialization handshake simulator.
*
* Hooks into the AVR's hardware SPI peripheral (simulator.spi.onTransmit).
* Implements the SD card v2 / SDHC initialization sequence:
*
* CMD0 (0x40) → R1 = 0x01 (idle)
* CMD8 (0x48) → R7 = 0x01, 0x00, 0x00, 0x01, 0xAA
* CMD55 (0x77) → R1 = 0x01 (prefix for ACMD)
* ACMD41 (0x69) → R1 = 0x00 (ready — skip lengthy poll loop)
* CMD58 (0x7A) → R3 = 0x00, 0x40, 0x00, 0x00, 0x00 (SDHC power-up OCR)
* CMD17 (0x51) → R1 = 0x00 + data token 0xFE + 512 bytes 0xFF + CRC
* CMD24 (0x58) → R1 = 0x00 + data response 0x05 (accepted)
*
* 0xFF bytes act as idle / clock-only bytes; the response queue is drained
* one byte per SPI transfer.
*
* NOTE: This hooks into AVR SPI only (simulator.spi). RP2040 SPI integration
* follows the same pattern but uses simulator.rp2040.spi[0].onTransmit.
*/
PartSimulationRegistry.register('microsd-card', {
attachEvents: (_element, simulator, _getPin) => {
const spi = (simulator as any).spi;
if (!spi) return () => {};
const respQueue: number[] = [];
let cmdBuf: number[] = [];
let expectingAcmd = false;
/** Resolve GPIO CS if wired — not strictly required since Arduino drives CS via GPIO */
function enqueueR1(r1: number): void {
respQueue.push(r1);
}
function enqueueR7(r1: number, v32: number): void {
respQueue.push(r1, (v32 >> 24) & 0xff, (v32 >> 16) & 0xff, (v32 >> 8) & 0xff, v32 & 0xff);
}
function processCmd(raw: number[]): void {
if (raw.length < 6) return;
const cmdIndex = raw[0] & 0x3f;
const isAcmd = expectingAcmd;
expectingAcmd = false;
if (isAcmd) {
// ACMD41: send init — respond ready
if (cmdIndex === 41) {
enqueueR1(0x00);
return;
}
}
switch (cmdIndex) {
case 0:
enqueueR1(0x01);
break;
case 8:
enqueueR7(0x01, 0x000001aa);
break;
case 55:
enqueueR1(0x01);
expectingAcmd = true;
break;
case 58:
enqueueR7(0x00, 0x40000000);
break; // SDHC OCR
case 17: // CMD17: read single block
respQueue.push(0x00); // R1 ok
respQueue.push(0xfe); // data token
for (let i = 0; i < 512; i++) respQueue.push(0xff); // empty block
respQueue.push(0xff, 0xff); // CRC (ignored)
break;
case 24: // CMD24: write single block
respQueue.push(0x00, 0x05); // R1 ok, data response accepted
break;
default:
enqueueR1(0x00); // respond OK for unhandled commands
}
}
const prevOnTransmit = spi.onTransmit as ((b: number) => void) | null | undefined;
spi.onTransmit = (byte: number) => {
if (byte & 0x40 && cmdBuf.length === 0) {
// New command — start accumulation
cmdBuf = [byte];
} else if (cmdBuf.length > 0 && cmdBuf.length < 6) {
cmdBuf.push(byte);
if (cmdBuf.length === 6) {
processCmd(cmdBuf);
cmdBuf = [];
}
}
// Drain response queue; idle reply is 0xFF
const reply = respQueue.length > 0 ? respQueue.shift()! : 0xff;
spi.completeTransmit(reply);
};
return () => {
spi.onTransmit = prevOnTransmit ?? null;
respQueue.length = 0;
cmdBuf = [];
};
},
});
// ─── BMP280 Barometric Pressure / Temperature Sensor ─────────────────────────
/**
* BMP280 — I2C barometric pressure + temperature sensor.
*
* Addresses:
* 0x76 (SDO pin pulled LOW, default)
* 0x77 (SDO pin pulled HIGH — set element.address = '0x77')
*
* The element may expose `temperature` (°C) and `pressure` (hPa) properties
* that are read on attach and forwarded to the virtual device.
*
* The virtual device uses the BMP280 datasheet calibration example to compute
* raw ADC values for any desired temperature/pressure combination, so Arduino
* sketches using Adafruit_BMP280 or Bosch's reference driver receive correct
* compensated readings.
*/
PartSimulationRegistry.register('bmp280', {
attachEvents: (element, simulator, _getPin, componentId) => {
const sim = simulator as any;
const el = element as any;
const addr = el.address === '0x77' || el.address === 0x77 ? 0x77 : 0x76;
const initTemp = el.temperature !== undefined ? parseFloat(el.temperature) : 25.0;
const initPressure = el.pressure !== undefined ? parseFloat(el.pressure) : 1013.25;
if (typeof sim.registerSensor === 'function') {
// ── ESP32 path: backend BMP280 slave + frontend bus mirror ──────────
const virtualPin = 200 + addr;
const dev = new VirtualBMP280(addr);
dev.temperatureC = initTemp;
dev.pressureHPa = initPressure;
sim.registerSensor('bmp280', virtualPin, { addr, temperature: initTemp, pressure: initPressure });
sim.addI2CDevice?.(dev);
registerSensorUpdate(componentId, (values) => {
sim.updateSensor(virtualPin, values);
if ('temperature' in values) dev.temperatureC = values.temperature as number;
if ('pressure' in values) dev.pressureHPa = values.pressure as number;
});
return () => {
sim.unregisterSensor(virtualPin);
sim.removeI2CDevice?.(addr, 0);
unregisterSensorUpdate(componentId);
};
} else if (typeof sim.addI2CDevice === 'function') {
// ── AVR / RP2040 path ──────────────────────────────────────────────────
const dev = new VirtualBMP280(addr);
dev.temperatureC = initTemp;
dev.pressureHPa = initPressure;
sim.addI2CDevice(dev);
registerSensorUpdate(componentId, (values) => {
if ('temperature' in values) dev.temperatureC = values.temperature as number;
if ('pressure' in values) dev.pressureHPa = values.pressure as number;
});
return () => {
removeI2CDevice(sim, dev.address);
unregisterSensorUpdate(componentId);
};
}
return () => {};
},
});
// ─── DS3231 Real-Time Clock ───────────────────────────────────────────────────
/**
* DS3231 — I2C RTC with on-chip temperature sensor (address 0x68).
*
* Returns the browser's current system time as BCD in registers 0x000x06,
* identical to DS1307 for the time registers. Additionally exposes:
* 0x0E Control register
* 0x0F Status register (OSF cleared)
* 0x11 Temperature MSB (integer °C, signed)
* 0x12 Temperature LSB (fractional, 0.25°C per bit in bits 7:6)
*
* Ambient temperature defaults to 25°C; override via `element.temperature`.
*/
PartSimulationRegistry.register('ds3231', {
attachEvents: (element, simulator, _getPin, componentId) => {
const sim = simulator as any;
const el = element as any;
const initTemp = el.temperature !== undefined ? parseFloat(el.temperature) : 25.0;
if (typeof sim.registerSensor === 'function') {
// ── ESP32 path: backend DS3231 slave + frontend bus mirror ──────────
const virtualPin = 200 + 0x68;
const dev = new VirtualDS3231();
dev.temperatureC = initTemp;
sim.registerSensor('ds3231', virtualPin, { addr: 0x68, temperature: initTemp });
sim.addI2CDevice?.(dev);
registerSensorUpdate(componentId, (values) => {
sim.updateSensor(virtualPin, values);
if ('temperature' in values) dev.temperatureC = values.temperature as number;
});
return () => {
sim.unregisterSensor(virtualPin);
sim.removeI2CDevice?.(dev.address, 0);
unregisterSensorUpdate(componentId);
};
} else if (typeof sim.addI2CDevice === 'function') {
// ── AVR / RP2040 path ──────────────────────────────────────────────────
const dev = new VirtualDS3231();
dev.temperatureC = initTemp;
sim.addI2CDevice(dev);
return () => removeI2CDevice(sim, dev.address);
}
return () => {};
},
});
// ─── PCF8574 I/O Expander ────────────────────────────────────────────────────
/**
* PCF8574 — I2C 8-bit quasi-bidirectional I/O expander.
*
* Default address: 0x27 (all three address pins HIGH — typical LCD backpack).
* Override with `element.i2cAddress` (e.g. '0x20', '0x3F').
*
* `element.portState` (0255) sets the external input state visible to the
* Arduino on a read. Defaults to 0xFF (all pins pulled high / floating input).
*
* Writes from the Arduino update `dev.outputLatch` and fire `dev.onWrite`
* which sets `element.value` so wokwi-LCD-I2C or similar elements can render.
*/
PartSimulationRegistry.register('pcf8574', {
attachEvents: (element, simulator, _getPin) => {
const sim = simulator as any;
const el = element as any;
// Parse address from element property (accepts '0x27', '39', or numeric)
let addr = 0x27;
if (el.i2cAddress !== undefined) {
const raw = String(el.i2cAddress).trim();
const parsed =
raw.startsWith('0x') || raw.startsWith('0X') ? parseInt(raw, 16) : parseInt(raw, 10);
if (!isNaN(parsed)) addr = parsed;
}
const dev = new VirtualPCF8574(addr);
if (el.portState !== undefined) dev.portState = Number(el.portState) & 0xff;
dev.onWrite = (value: number) => {
el.value = value;
};
if (typeof sim.registerSensor === 'function') {
// ── ESP32 path: backend slave + frontend bus mirror ─────────────────
const virtualPin = 200 + addr;
sim.registerSensor('pcf8574', virtualPin, { addr });
sim.addI2CTransactionListener?.(addr, (data: number[]) => {
if (data.length > 0) dev.writeByte(data[0]);
});
sim.addI2CDevice?.(dev);
return () => {
sim.unregisterSensor(virtualPin);
sim.removeI2CTransactionListener?.(addr);
sim.removeI2CDevice?.(addr, 0);
};
} else if (typeof sim.addI2CDevice === 'function') {
// ── AVR / RP2040 path ──────────────────────────────────────────────────
sim.addI2CDevice(dev);
return () => removeI2CDevice(sim, dev.address);
}
return () => {};
},
});
// ─── LCD1602 / LCD2004 with I2C backpack (PCF8574 + HD44780) ────────────────
/**
* Common parser for an I2C address property coming from a wokwi-element
* (the metadata exposes `i2cAddress` as a text control; users type
* "0x27", "39", or just the raw number).
*/
function parseI2cAddress(raw: unknown, fallback: number): number {
if (raw === undefined || raw === null) return fallback;
if (typeof raw === 'number' && !isNaN(raw)) return raw & 0x7f;
const s = String(raw).trim();
if (!s) return fallback;
const parsed = s.toLowerCase().startsWith('0x') ? parseInt(s, 16) : parseInt(s, 10);
return isNaN(parsed) ? fallback : parsed & 0x7f;
}
/**
* Build a part attach function for an LCD with an I2C backpack. The
* same logic applies to LCD1602 (16×2) and LCD2004 (20×4); only the
* geometry differs.
*
* On attach:
* 1. Force the underlying `wokwi-lcd1602` / `wokwi-lcd2004` element
* into I2C-pinout mode (`pins='i2c'`) so the user sees the
* correct 4-pin backpack header.
* 2. Pre-fill `characters` with spaces so the screen is clean before
* the sketch issues its first Clear command.
* 3. Create a `VirtualPCF8574` at the configured address.
* 4. Pipe `pcf.onWrite` → `HD44780Decoder.feedPCF8574Byte`.
* 5. Reflect the decoder's `characters` + `backlight` snapshots back
* onto the element's reactive properties.
*
* Works on AVR, RP2040, and the ESP32 backend (same trifurcation
* pattern as other I2C parts above).
*/
function makeI2cLcdAttach(cols: number, rows: number) {
return (
element: HTMLElement,
simulator: unknown,
_getPin: (name: string) => number | null,
): (() => void) => {
const sim = simulator as any;
const el = element as any;
const addr = parseI2cAddress(el.i2cAddress ?? el.address, 0x27);
// Switch the underlying LCD element to I2C pin mode + a clean
// characters buffer. The host wokwi element re-renders on
// attribute change.
try {
el.pins = 'i2c';
} catch {
/* read-only on some implementations — ignore */
}
const blankGrid = new Uint8Array(cols * rows).fill(0x20);
el.characters = blankGrid;
if (el.backlight === undefined) el.backlight = true;
const decoder = new HD44780Decoder({ cols, rows });
decoder.onCharsChange = (chars) => {
// wokwi-lcd1602 accepts both number[] and Uint8Array. Use Uint8Array
// so Lit's change detection sees a new reference.
el.characters = Uint8Array.from(chars);
};
decoder.onBacklightChange = (on) => {
el.backlight = on;
};
decoder.onCursorChange = (snap) => {
el.cursorX = snap.cursorCol;
el.cursorY = snap.cursorRow;
el.cursor = snap.cursorOn;
el.blink = snap.cursorBlink;
};
const pcf = new VirtualPCF8574(addr);
pcf.onWrite = (v: number) => decoder.feedPCF8574Byte(v);
if (typeof sim.registerSensor === 'function') {
// ── ESP32 path: backend QEMU PCF8574 slave forwards transactions
// back to us, and the same VirtualPCF8574 is also on the frontend
// bus so peer boards can reach it via the I2C bridge. ─────────
const virtualPin = 200 + addr;
sim.registerSensor('pcf8574', virtualPin, { addr });
sim.addI2CTransactionListener?.(addr, (data: number[]) => {
for (const b of data) decoder.feedPCF8574Byte(b);
});
sim.addI2CDevice?.(pcf);
return () => {
sim.unregisterSensor(virtualPin);
sim.removeI2CTransactionListener?.(addr);
sim.removeI2CDevice?.(addr, 0);
decoder.reset();
};
} else if (typeof sim.addI2CDevice === 'function') {
// ── AVR / RP2040 path ────────────────────────────────────────────
sim.addI2CDevice(pcf);
return () => {
removeI2CDevice(sim, pcf.address);
decoder.reset();
};
}
return () => decoder.reset();
};
}
/**
* LCD 16×2 with PCF8574 I2C backpack — the classic "I2C LCD" you buy
* in a single piece on AliExpress. Default address 0x27.
*/
PartSimulationRegistry.register('lcd1602-i2c', {
attachEvents: makeI2cLcdAttach(16, 2),
});
/**
* LCD 20×4 with PCF8574 I2C backpack. Same protocol; uses the 2004
* DDRAM row offsets (0x00, 0x40, 0x14, 0x54).
*/
PartSimulationRegistry.register('lcd2004-i2c', {
attachEvents: makeI2cLcdAttach(20, 4),
});