/** * 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 * - 0x40–0x7F 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 0–6. */ 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) * 0x3B–0x40 ACCEL XYZ = (0, 0, +1g = 0x4000) — device sitting flat * 0x41–0x42 TEMP_OUT = ~25°C * 0x43–0x48 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) // 0x43–0x48 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): * 0–9 → 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 = { '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 0x00–0x06, * 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` (0–255) 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), });