1518 lines
57 KiB
TypeScript
1518 lines
57 KiB
TypeScript
/**
|
||
* 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;
|
||
// 0=horizontal, 1=vertical, 2=page. SSD1306 power-on default is PAGE
|
||
// addressing (datasheet 10b). Adafruit_SSD1306 overrides it to horizontal
|
||
// via 0x20,0x00; page-mode drivers (Tiny4kOLED, U8g2 page buffer) rely on
|
||
// this default and never send 0x20 — so the default MUST be 2 or their
|
||
// setCursor (0xB0-0xB7 + 0x00-0x1F) renders garbled.
|
||
private memMode = 2;
|
||
|
||
// 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:
|
||
// Page-addressing-mode cursor commands (single-byte). Used by
|
||
// Tiny4kOLED / U8g2 page buffer / classic SSD1306 drivers whose
|
||
// setCursor() does NOT use the 0x21/0x22 column/page-range commands.
|
||
if (cmd >= 0xb0 && cmd <= 0xb7) {
|
||
// set page start address (B0..B7 → page 0..7)
|
||
this.page = cmd & 0x07;
|
||
} else if (cmd <= 0x0f) {
|
||
// set lower column nibble (0x00..0x0F)
|
||
this.col = (this.col & 0xf0) | (cmd & 0x0f);
|
||
} else if (cmd >= 0x10 && cmd <= 0x1f) {
|
||
// set higher column nibble (0x10..0x1F)
|
||
this.col = (this.col & 0x0f) | ((cmd & 0x0f) << 4);
|
||
} else 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.
|
||
* Called by the single `ssd1306` entry once the protocol has been resolved
|
||
* (auto-detected from the wiring, or read from an explicit `protocol` property).
|
||
*/
|
||
function attachSSD1306(
|
||
element: HTMLElement,
|
||
simulator: unknown,
|
||
getPin: (n: string) => number | null,
|
||
protocol: 'i2c' | 'spi',
|
||
i2cAddr = 0x3c,
|
||
): () => void {
|
||
if (protocol === 'spi') {
|
||
return attachSSD1306SPI(element, simulator, getPin);
|
||
}
|
||
const sim = simulator as any;
|
||
const device = new VirtualSSD1306(i2cAddr, element);
|
||
|
||
// The ESP32/STM32 bridge shims expose registerSensor (backend QEMU slave) +
|
||
// addI2CTransactionListener (framebuffer bytes streamed back) AND addI2CDevice
|
||
// (frontend bus for the cross-board Interconnect). AVR / RP2040 also carry a
|
||
// registerSensor() stub that returns false, so they enter this branch too —
|
||
// harmlessly: registerSensor no-ops, the absent addI2CTransactionListener is
|
||
// skipped, and the real attach happens via the addI2CDevice mirror below.
|
||
if (typeof sim.registerSensor === 'function') {
|
||
// ── ESP32 / STM32 (and AVR/RP2040 via the addI2CDevice mirror) ──────────
|
||
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 () => {};
|
||
}
|
||
|
||
/**
|
||
* Which wire protocol did the user build? A real SSD1306 breakout is ONE board
|
||
* that talks either I2C or SPI depending on how it is wired. The definitive
|
||
* SPI-only signal is chip-select (CS): I2C never uses it. (DC deliberately does
|
||
* NOT count — on the 8-pin module DC doubles as the I2C address-select/SA0 line,
|
||
* so many I2C circuits wire it too.) So CS wired to a GPIO => SPI, otherwise
|
||
* I2C. This mirrors the physical part — one component, no protocol switch to
|
||
* set, just wire it up.
|
||
*
|
||
* Pure wiring check: it deliberately does NOT read `simulator.spi`, whose getter
|
||
* on some boards (RP2040, and the ESP32/STM32 bridge shims) lazily re-routes the
|
||
* board SPI bus as a side effect and must not fire in I2C mode.
|
||
*/
|
||
function detectSSD1306Protocol(getPin: (n: string) => number | null): 'i2c' | 'spi' {
|
||
return getPin('CS') !== null ? 'spi' : 'i2c';
|
||
}
|
||
|
||
/**
|
||
* SSD1306 OLED — a single component that works on every board with an I2C or
|
||
* SPI bus (AVR, RP2040, ESP32, STM32). New projects just wire it up and the
|
||
* protocol is auto-detected from the wiring like the physical module; a
|
||
* `protocol` property, when present, pins it explicitly (projects migrated from
|
||
* the old ssd1306-i2c / ssd1306-spi entries carry it so their behaviour is
|
||
* preserved exactly). Consolidates the old three picker entries into one
|
||
* (issues #101 / #215).
|
||
*/
|
||
PartSimulationRegistry.register('ssd1306', {
|
||
attachEvents: (element, simulator, getPin, componentId) => {
|
||
const { components } = useSimulatorStore.getState();
|
||
const comp = components.find((c) => c.id === componentId);
|
||
const i2cAddr = parseI2cAddress(comp?.properties?.i2cAddress, 0x3c);
|
||
const explicit = comp?.properties?.protocol;
|
||
const protocol: 'i2c' | 'spi' =
|
||
explicit === 'i2c' || explicit === 'spi' ? explicit : detectSSD1306Protocol(getPin);
|
||
return attachSSD1306(element, simulator, getPin, protocol, i2cAddr);
|
||
},
|
||
});
|
||
|
||
// ─── 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<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 — generic SD-over-SPI device with a real backing store.
|
||
*
|
||
* Hooks the hardware SPI peripheral (simulator.spi) — works for AVR and RP2040
|
||
* (both expose the `.spi` adapter). ESP32 runs in QEMU and is a separate path.
|
||
*
|
||
* Implements the SD v2 / SDHC command set the SD.h / SdFat libraries use, so it
|
||
* works generically with any card configuration (not a one-card hack):
|
||
* - Init/info: CMD0, CMD8 (R7), CMD55+ACMD41, CMD58 (OCR, CCS=1 SDHC),
|
||
* CMD9 (CSD v2 reflecting SD_CARD_BYTES), CMD10 (CID), CMD13, CMD16.
|
||
* - Read: CMD17 (single), CMD18 (multiple, until CMD12) — served from store.
|
||
* - Write: CMD24 (single), CMD25 (multiple, until stop token 0xFD) — the data
|
||
* block that follows the command is captured and stored.
|
||
*
|
||
* Addressing: the card advertises SDHC (CCS=1), so CMD17/24 args are BLOCK
|
||
* indices (not byte offsets). Backing store is a sparse Map of 512-byte sectors.
|
||
*
|
||
* An optional pre-built FAT image can be injected via element.sdImageData (the
|
||
* file-upload / auto-copy feature lands files there). The response queue drains
|
||
* one byte per SPI transfer; idle line reads 0xFF.
|
||
*/
|
||
const SD_BLOCK_SIZE = 512;
|
||
// Fixed card capacity (mirrors Wokwi's "no size attribute" model). The backing
|
||
// store is SPARSE — only written/loaded blocks allocate — so the advertised
|
||
// capacity is free in RAM. Adjustable here; not exposed to the user.
|
||
const SD_CARD_BYTES = 64 * 1024 * 1024; // 64 MB
|
||
const SD_C_SIZE = Math.floor(SD_CARD_BYTES / (512 * 1024)) - 1; // CSD v2 C_SIZE
|
||
|
||
PartSimulationRegistry.register('microsd-card', {
|
||
attachEvents: (element, simulator, _getPin) => {
|
||
const spi = (simulator as any).spi;
|
||
if (!spi) return () => {};
|
||
const el = element as any;
|
||
|
||
// ── Backing store: sparse map of blockIndex -> 512-byte sector ──────────
|
||
const store = new Map<number, Uint8Array>();
|
||
const readBlock = (idx: number): Uint8Array =>
|
||
store.get(idx) ?? new Uint8Array(SD_BLOCK_SIZE); // unwritten = zeros
|
||
const writeBlock = (idx: number, data: ArrayLike<number>): void => {
|
||
const blk = new Uint8Array(SD_BLOCK_SIZE);
|
||
blk.set(Array.from(data).slice(0, SD_BLOCK_SIZE));
|
||
store.set(idx, blk);
|
||
};
|
||
|
||
// Optional pre-built FAT image (Phase 2 sets element.sdImageData). Loaded
|
||
// into the store block-by-block so the firmware can mount + read it.
|
||
(() => {
|
||
const raw = el.sdImageData;
|
||
if (!raw) return;
|
||
const bytes: Uint8Array | null =
|
||
raw instanceof Uint8Array ? raw
|
||
: raw instanceof ArrayBuffer ? new Uint8Array(raw)
|
||
: Array.isArray(raw) ? Uint8Array.from(raw)
|
||
: null;
|
||
if (!bytes) return;
|
||
for (let i = 0; i * SD_BLOCK_SIZE < bytes.length; i++) {
|
||
const slice = bytes.subarray(i * SD_BLOCK_SIZE, (i + 1) * SD_BLOCK_SIZE);
|
||
// Skip all-zero blocks so the store stays sparse (they read back as
|
||
// zeros anyway) — a multi-MB FAT image only allocates its used blocks.
|
||
if (slice.some((b) => b !== 0)) writeBlock(i, slice);
|
||
}
|
||
})();
|
||
|
||
// ── 16-byte CSD (v2.0, high-capacity) reflecting SD_CARD_BYTES ──────────
|
||
const buildCSD = (): number[] => [
|
||
0x40, 0x0e, 0x00, 0x32, 0x5b, 0x59, 0x00,
|
||
(SD_C_SIZE >> 16) & 0x3f, (SD_C_SIZE >> 8) & 0xff, SD_C_SIZE & 0xff,
|
||
0x7f, 0x80, 0x0a, 0x40, 0x00, 0x01,
|
||
];
|
||
// ── 16-byte CID (manufacturer info; values are cosmetic) ────────────────
|
||
const buildCID = (): number[] => [
|
||
0x01, 0x56, 0x58, 0x56, 0x45, 0x4c, 0x58, 0x53, // mfr, "VX", "VELXS"
|
||
0x10, 0x00, 0x00, 0x00, 0x01, 0x01, 0x60, 0x01,
|
||
];
|
||
|
||
// ── SD SPI protocol state machine ───────────────────────────────────────
|
||
const respQueue: number[] = [];
|
||
let cmdBuf: number[] = [];
|
||
let expectingAcmd = false;
|
||
// Phases: 'cmd' (idle/command), and the write data path after CMD24/25.
|
||
let phase: 'cmd' | 'wait-token' | 'recv-data' | 'recv-crc' = 'cmd';
|
||
let dataBuf: number[] = [];
|
||
let crcLeft = 0;
|
||
let writeAddr = 0;
|
||
let multiWrite = false;
|
||
// CMD18 continuous read: keep streaming blocks until CMD12.
|
||
let multiRead = false;
|
||
let readAddr = 0;
|
||
|
||
/** Queue a data block as the firmware reads it: token + 512 bytes + CRC. */
|
||
const pushDataBlock = (bytes: ArrayLike<number>): void => {
|
||
respQueue.push(0xfe); // start-block token
|
||
for (let i = 0; i < SD_BLOCK_SIZE; i++) respQueue.push((bytes as any)[i] ?? 0);
|
||
respQueue.push(0xff, 0xff); // CRC (ignored by SPI mode)
|
||
};
|
||
/** Queue R1 + a short (<=16 byte) data block (CSD/CID). */
|
||
const pushShortData = (bytes: number[]): void => {
|
||
respQueue.push(0x00, 0xfe, ...bytes, 0xff, 0xff);
|
||
};
|
||
|
||
const processCmd = (raw: number[]): void => {
|
||
const cmd = raw[0] & 0x3f;
|
||
const arg = ((raw[1] << 24) | (raw[2] << 16) | (raw[3] << 8) | raw[4]) >>> 0;
|
||
const isAcmd = expectingAcmd;
|
||
expectingAcmd = false;
|
||
|
||
if (isAcmd) {
|
||
if (cmd === 41) { respQueue.push(0x00); return; } // ACMD41 — ready
|
||
if (cmd === 13) { respQueue.push(0x00, 0x00); return; } // ACMD13 SD status (R2)
|
||
// fall through for other ACMDs
|
||
}
|
||
|
||
switch (cmd) {
|
||
case 0: respQueue.push(0x01); break; // GO_IDLE -> idle
|
||
case 8: respQueue.push(0x01, 0x00, 0x00, 0x01, 0xaa); break; // SEND_IF_COND (R7)
|
||
case 9: pushShortData(buildCSD()); break; // SEND_CSD
|
||
case 10: pushShortData(buildCID()); break; // SEND_CID
|
||
case 12: multiRead = false; respQueue.push(0x00, 0x00, 0xff); break; // STOP_TRANSMISSION
|
||
case 13: respQueue.push(0x00, 0x00); break; // SEND_STATUS (R2)
|
||
case 16: respQueue.push(0x00); break; // SET_BLOCKLEN (fixed 512)
|
||
// Standard-capacity (SDSC) byte addressing: CMD17/18/24/25 args are BYTE
|
||
// offsets (block*512), not block indices. The Arduino SD library uses
|
||
// this even when the card advertises SDHC, so we present SDSC (CMD58
|
||
// CCS=0) and translate `arg >> 9` -> block. SDSC covers up to 2 GB,
|
||
// plenty for our small card; every SD library supports it.
|
||
case 17: respQueue.push(0x00); pushDataBlock(readBlock(arg >> 9)); break; // READ_SINGLE
|
||
case 18: // READ_MULTIPLE — stream until CMD12
|
||
respQueue.push(0x00);
|
||
readAddr = arg >> 9; multiRead = true;
|
||
pushDataBlock(readBlock(readAddr)); readAddr++;
|
||
break;
|
||
case 24: // WRITE_SINGLE — data block follows
|
||
respQueue.push(0x00); writeAddr = arg >> 9; multiWrite = false; phase = 'wait-token';
|
||
break;
|
||
case 25: // WRITE_MULTIPLE — data blocks follow until stop token
|
||
respQueue.push(0x00); writeAddr = arg >> 9; multiWrite = true; phase = 'wait-token';
|
||
break;
|
||
case 55: respQueue.push(0x01); expectingAcmd = true; break; // APP_CMD prefix
|
||
case 58: respQueue.push(0x00, 0x80, 0xff, 0x80, 0x00); break; // READ_OCR (powered, CCS=0 SDSC)
|
||
default: respQueue.push(0x00); // accept unhandled commands
|
||
}
|
||
};
|
||
|
||
const prevOnByte = spi.onByte as ((b: number) => void) | null | undefined;
|
||
|
||
spi.onByte = (byte: number) => {
|
||
// Full-duplex: the MISO shifted out for THIS transfer was prepared by
|
||
// earlier bytes, so reply FIRST (from the queue as it stood before this
|
||
// byte), THEN consume this MOSI byte to prepare future MISO. This gives
|
||
// the 1-byte (Ncr) command->response latency real SD cards have — the
|
||
// host reads R1 on the 0xFF clocks it sends AFTER the 6 command bytes,
|
||
// not on the last command byte. Replying after processing broke SD.begin.
|
||
spi.completeTransfer?.(respQueue.length > 0 ? respQueue.shift()! : 0xff);
|
||
|
||
switch (phase) {
|
||
case 'cmd':
|
||
if (cmdBuf.length === 0 && (byte & 0xc0) === 0x40) {
|
||
cmdBuf = [byte]; // command start (bit7=0, bit6=1)
|
||
} else if (cmdBuf.length > 0) {
|
||
cmdBuf.push(byte);
|
||
if (cmdBuf.length === 6) { processCmd(cmdBuf); cmdBuf = []; }
|
||
} else if (multiRead && respQueue.length === 0) {
|
||
// Continuous read: refill the next block while the host clocks 0xFF.
|
||
pushDataBlock(readBlock(readAddr)); readAddr++;
|
||
}
|
||
break;
|
||
case 'wait-token':
|
||
if (byte === 0xfe || byte === 0xfc) { phase = 'recv-data'; dataBuf = []; }
|
||
else if (byte === 0xfd) { multiWrite = false; phase = 'cmd'; respQueue.push(0x00); }
|
||
// else 0xFF gap — keep waiting
|
||
break;
|
||
case 'recv-data':
|
||
dataBuf.push(byte);
|
||
if (dataBuf.length === SD_BLOCK_SIZE) { phase = 'recv-crc'; crcLeft = 2; }
|
||
break;
|
||
case 'recv-crc':
|
||
if (--crcLeft === 0) {
|
||
writeBlock(writeAddr, dataBuf);
|
||
writeAddr++;
|
||
respQueue.push(0x05); // data-response: accepted
|
||
phase = multiWrite ? 'wait-token' : 'cmd';
|
||
}
|
||
break;
|
||
}
|
||
};
|
||
|
||
return () => {
|
||
spi.onByte = prevOnByte ?? null;
|
||
respQueue.length = 0;
|
||
cmdBuf = [];
|
||
store.clear();
|
||
};
|
||
},
|
||
});
|
||
|
||
// ─── 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),
|
||
});
|