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

989 lines
35 KiB
TypeScript
Raw Normal View History

import { PartSimulationRegistry } from './PartSimulationRegistry';
import type { AnySimulator } from './PartSimulationRegistry';
import { RP2040Simulator } from '../RP2040Simulator';
import { getADC, setAdcVoltage, emitPropertyChange } from './partUtils';
import { registerSensorUpdate, unregisterSensorUpdate } from '../SensorUpdateRegistry';
// ─── Helpers ────────────────────────────────────────────────────────────────
// ─── RGB LED (PWM-aware) ─────────────────────────────────────────────────────
/**
* RGB LED implementation supports both digital and PWM (analogWrite) output.
* Falls back to digital mode if no PWM is detected.
*/
PartSimulationRegistry.register('rgb-led', {
attachEvents: (element, avrSimulator, getArduinoPinHelper) => {
const pinManager = (avrSimulator as any).pinManager;
if (!pinManager) return () => {};
const el = element as any;
const unsubscribers: (() => void)[] = [];
const pinR = getArduinoPinHelper('R');
const pinG = getArduinoPinHelper('G');
const pinB = getArduinoPinHelper('B');
// Digital fallback
if (pinR !== null) {
unsubscribers.push(
pinManager.onPinChange(pinR, (_: number, state: boolean) => {
el.ledRed = state ? 255 : 0;
}),
);
}
if (pinG !== null) {
unsubscribers.push(
pinManager.onPinChange(pinG, (_: number, state: boolean) => {
el.ledGreen = state ? 255 : 0;
}),
);
}
if (pinB !== null) {
unsubscribers.push(
pinManager.onPinChange(pinB, (_: number, state: boolean) => {
el.ledBlue = state ? 255 : 0;
}),
);
}
// PWM override — when analogWrite() is used the OCR value supersedes digital
const pwmPins = [
{ pin: pinR, prop: 'ledRed' },
{ pin: pinG, prop: 'ledGreen' },
{ pin: pinB, prop: 'ledBlue' },
];
for (const { pin, prop } of pwmPins) {
if (pin !== null) {
unsubscribers.push(
pinManager.onPwmChange(pin, (_: number, dc: number) => {
el[prop] = Math.round(dc * 255);
}),
);
}
}
return () => unsubscribers.forEach((u) => u());
},
});
// ─── Potentiometer (rotary) ──────────────────────────────────────────────────
PartSimulationRegistry.register('potentiometer', {
attachEvents: (element, simulator, getArduinoPinHelper, componentId) => {
const pin = getArduinoPinHelper('SIG');
// Determine reference voltage based on board type
const isRP2040 = simulator instanceof RP2040Simulator;
const isESP32 = typeof (simulator as any).setAdcVoltage === 'function';
const refVoltage = isRP2040 || isESP32 ? 3.3 : 5.0;
const onInput = () => {
const rawStr = (element as any).value ?? '0';
const raw = parseInt(rawStr, 10);
if (pin !== null) {
const volts = (raw / 1023.0) * refVoltage;
setAdcVoltage(simulator, pin, volts);
}
// Mirror to store so the SPICE netlist re-solves (op-amp
// comparators, divider-driven circuits etc. depend on this).
emitPropertyChange(componentId, 'value', raw);
};
onInput();
element.addEventListener('input', onInput);
return () => element.removeEventListener('input', onInput);
},
});
// ─── Slide Potentiometer ─────────────────────────────────────────────────────
PartSimulationRegistry.register('slide-potentiometer', {
attachEvents: (element, avrSimulator, getArduinoPinHelper, componentId) => {
const arduinoPin = getArduinoPinHelper('SIG') ?? getArduinoPinHelper('OUT');
const el = element as any;
const isRP2040 = avrSimulator instanceof RP2040Simulator;
const isESP32 = typeof (avrSimulator as any).setAdcVoltage === 'function';
const refVoltage = isRP2040 || isESP32 ? 3.3 : 5.0;
const onInput = () => {
const min = Number(el.min ?? 0);
const max = Number(el.max ?? 1023);
const value = Number(el.value ?? 0);
const normalized = (value - min) / (max - min || 1);
if (arduinoPin !== null) {
const volts = normalized * refVoltage;
setAdcVoltage(avrSimulator, arduinoPin, volts);
}
emitPropertyChange(componentId, 'value', value);
};
onInput();
element.addEventListener('input', onInput);
return () => element.removeEventListener('input', onInput);
},
});
// ─── Photoresistor Sensor ────────────────────────────────────────────────────
/**
* Photoresistor sensor the wokwi element does not emit input events,
* so we simulate light level with a slider drawn via the component's
* luminance property when available, or simply set a mid-range voltage.
*
* The element exposes `ledDO` and `ledPower` for display only.
* We inject a static mid-range voltage on the AO pin so analogRead()
* returns a valid value. Users can modify the element's `value` attribute.
*/
PartSimulationRegistry.register('photoresistor-sensor', {
attachEvents: (element, avrSimulator, getArduinoPinHelper, componentId) => {
const pinAO = getArduinoPinHelper('AO') ?? getArduinoPinHelper('A0');
const pinDO = getArduinoPinHelper('DO') ?? getArduinoPinHelper('D0');
const pinManager = (avrSimulator as any).pinManager;
const unsubscribers: (() => void)[] = [];
// Inject initial mid-range voltage (simulate moderate light, ~500 lux)
if (pinAO !== null) {
setAdcVoltage(avrSimulator, pinAO, 2.5);
}
// Watch element's 'input' events in case the element supports it
const onInput = () => {
const val = (element as any).value;
if (val !== undefined) {
if (pinAO !== null) {
const volts = (val / 1023.0) * 5.0;
setAdcVoltage(avrSimulator, pinAO, volts);
}
// Mirror to store — maps the slider 0-1023 back to lux 0-1000
// so the SPICE photoresistor handler re-computes its R_ldr.
emitPropertyChange(componentId, 'lux', Math.round((val / 1023) * 1000));
}
};
element.addEventListener('input', onInput);
unsubscribers.push(() => element.removeEventListener('input', onInput));
// DO (digital output) — if connected, update element's LED indicator
if (pinDO !== null && pinManager) {
unsubscribers.push(
pinManager.onPinChange(pinDO, (_: number, state: boolean) => {
(element as any).ledDO = state;
}),
);
}
// SensorControlPanel: lux 01000 → volts 05
registerSensorUpdate(componentId, (values) => {
if ('lux' in values) {
if (pinAO !== null) {
setAdcVoltage(avrSimulator, pinAO, ((values.lux as number) / 1000) * 5.0);
}
emitPropertyChange(componentId, 'lux', values.lux);
}
});
return () => {
unsubscribers.forEach((u) => u());
unregisterSensorUpdate(componentId);
};
},
});
// ─── Analog Joystick ─────────────────────────────────────────────────────────
/**
* Analog Joystick two axes (xValue/yValue 0-1023) + button press
* Wokwi pins: VRX (X axis), VRY (Y axis), SW (button)
*/
PartSimulationRegistry.register('analog-joystick', {
attachEvents: (element, avrSimulator, getArduinoPinHelper, componentId) => {
// wokwi-analog-joystick uses VERT/HORZ/SEL pin names
const pinX =
getArduinoPinHelper('VERT') ?? getArduinoPinHelper('VRX') ?? getArduinoPinHelper('XOUT');
const pinY =
getArduinoPinHelper('HORZ') ?? getArduinoPinHelper('VRY') ?? getArduinoPinHelper('YOUT');
const pinSW = getArduinoPinHelper('SEL') ?? getArduinoPinHelper('SW');
const el = element as any;
// RP2040 uses 3.3V reference; AVR uses 5V
const vcc = avrSimulator instanceof RP2040Simulator ? 3.3 : 5.0;
const centerV = vcc / 2;
// Initialize to center position and button not pressed
if (pinX !== null) setAdcVoltage(avrSimulator, pinX, centerV);
if (pinY !== null) setAdcVoltage(avrSimulator, pinY, centerV);
if (pinSW !== null) avrSimulator.setPinState(pinSW, true); // HIGH = not pressed
const onMove = () => {
// xValue / yValue are 0-1023
if (pinX !== null) {
const vx = ((el.xValue ?? 512) / 1023.0) * vcc;
setAdcVoltage(avrSimulator, pinX, vx);
}
if (pinY !== null) {
const vy = ((el.yValue ?? 512) / 1023.0) * vcc;
setAdcVoltage(avrSimulator, pinY, vy);
}
};
const onPress = () => {
if (pinSW !== null) avrSimulator.setPinState(pinSW, false); // Active LOW
el.pressed = true;
};
const onRelease = () => {
if (pinSW !== null) avrSimulator.setPinState(pinSW, true);
el.pressed = false;
};
element.addEventListener('input', onMove);
element.addEventListener('joystick-move', onMove);
element.addEventListener('button-press', onPress);
element.addEventListener('button-release', onRelease);
// SensorControlPanel: xAxis/yAxis -512..512 → voltage 0VCC (center = VCC/2)
registerSensorUpdate(componentId, (values) => {
if ('xAxis' in values && pinX !== null) {
setAdcVoltage(avrSimulator, pinX, (((values.xAxis as number) + 512) / 1023) * vcc);
}
if ('yAxis' in values && pinY !== null) {
setAdcVoltage(avrSimulator, pinY, (((values.yAxis as number) + 512) / 1023) * vcc);
}
});
return () => {
element.removeEventListener('input', onMove);
element.removeEventListener('joystick-move', onMove);
element.removeEventListener('button-press', onPress);
element.removeEventListener('button-release', onRelease);
unregisterSensorUpdate(componentId);
};
},
});
// ─── Servo ───────────────────────────────────────────────────────────────────
/**
* Servo motor measures actual PWM pulse width from pin state changes.
*
* Standard RC servo protocol:
* - 50 Hz signal (20 ms period)
* - Pulse width 544 µs 0°, 1472 µs 90°, 2400 µs 180°
* (Arduino Servo.h uses 5442400 µs, NOT the generic 10002000 µs range)
*
* Approach: subscribe to the servo's PWM pin state changes, record the CPU
* cycle count at the rising edge, then compute pulse width on the falling edge.
* avr8js re-schedules Timer1 every 8 CPU cycles (prescaler=8), so each HIGH
* and LOW transition fires in a separate count() call with a distinct cpu.cycles
* value the measurement is cycle-accurate.
*
* Fallback: if no wire is connected (pinSIG === null), poll OCR1A/ICR1 registers
* via requestAnimationFrame (less accurate but still functional).
*/
PartSimulationRegistry.register('servo', {
attachEvents: (element, avrSimulator, getArduinoPinHelper) => {
const pinSIG =
getArduinoPinHelper('PWM') ?? getArduinoPinHelper('SIG') ?? getArduinoPinHelper('1');
const el = element as any;
// Arduino Servo.h actual pulse range (544µs = 0°, 2400µs = 180°)
const MIN_PULSE_US = 544;
const MAX_PULSE_US = 2400;
const CPU_HZ = 16_000_000;
// ── RP2040 path: measure GPIO pulse timing via onPinChangeWithTime ───────
// Arduino-Pico Servo library uses PIO (not hardware PWM) — PIO toggles GPIO
// directly, which fires gpio.addListener → onPinChangeWithTime with the
// accurate simulation time from SimulationClock.nanosCounter.
if (avrSimulator instanceof RP2040Simulator && pinSIG !== null) {
let riseTimeMs = -1;
// Self-calibrating pulse range: the PIO clock divider may not match
// exactly, producing pulses offset from the standard 544-2400µs range.
// Track the minimum observed pulse (= 0° reference) and map using the
// known standard spread (MAX_PULSE_US - MIN_PULSE_US = 1856µs).
let observedMin = Infinity;
const EXPECTED_SPREAD = MAX_PULSE_US - MIN_PULSE_US; // 1856
avrSimulator.onPinChangeWithTime = (pin, state, timeMs) => {
if (pin !== pinSIG) return;
if (state) {
riseTimeMs = timeMs;
} else if (riseTimeMs >= 0) {
const pulseUs = (timeMs - riseTimeMs) * 1000;
riseTimeMs = -1;
// Reject noise: only consider pulses in a reasonable servo range
if (pulseUs < 100 || pulseUs > 25000) return;
// Update calibration baseline
if (pulseUs < observedMin) observedMin = pulseUs;
// Try standard range first
if (pulseUs >= MIN_PULSE_US && pulseUs <= MAX_PULSE_US) {
const angle = Math.round(((pulseUs - MIN_PULSE_US) / EXPECTED_SPREAD) * 180);
el.angle = Math.max(0, Math.min(180, angle));
} else if (observedMin < Infinity) {
// Self-calibrated range: use observedMin as 0° reference
const rangeMax = observedMin + EXPECTED_SPREAD;
if (pulseUs >= observedMin - 50 && pulseUs <= rangeMax + 200) {
const angle = Math.round(((pulseUs - observedMin) / EXPECTED_SPREAD) * 180);
el.angle = Math.max(0, Math.min(180, angle));
}
}
}
};
return () => {
avrSimulator.onPinChangeWithTime = null;
};
}
// ── ESP32 path: subscribe to LEDC PWM duty updates via PinManager ──
// Esp32BridgeShim has pinManager but getCurrentCycles() returns -1
// (no local CPU cycle counter — QEMU runs on the backend).
if (pinSIG !== null && !(avrSimulator instanceof RP2040Simulator)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pinManager = (avrSimulator as any).pinManager as
| import('../PinManager').PinManager
| undefined;
const hasCpuCycles =
typeof (avrSimulator as any).getCurrentCycles === 'function' &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(avrSimulator as any).getCurrentCycles() >= 0;
if (pinManager && !hasCpuCycles) {
// ESP32 Servo.h uses 50Hz PWM with pulse 544-2400µs
// dutyCycle here is 0.0-1.0 (fraction of PWM period = 20ms)
// 544µs = 2.72%, 2400µs = 12.0%
const MIN_DC = MIN_PULSE_US / 20000; // 0.0272
const MAX_DC = MAX_PULSE_US / 20000; // 0.12
const unsubscribe = pinManager.onPwmChange(pinSIG, (_pin, dutyCycle) => {
if (dutyCycle < 0.01 || dutyCycle > 0.2) return; // ignore out-of-range
const angle = Math.round(((dutyCycle - MIN_DC) / (MAX_DC - MIN_DC)) * 180);
el.angle = Math.max(0, Math.min(180, angle));
});
return () => {
unsubscribe();
};
}
}
// ── AVR primary: cycle-accurate pulse width measurement ────────────
if (pinSIG !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pinManager = (avrSimulator as any).pinManager as
| import('../PinManager').PinManager
| undefined;
if (pinManager) {
let riseTime = -1; // cycle count at last rising edge
const getCycles = () =>
typeof (avrSimulator as any).getCurrentCycles === 'function'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
((avrSimulator as any).getCurrentCycles() as number)
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
(((avrSimulator as any).cpu?.cycles ?? 0) as number);
const clockHz =
typeof (avrSimulator as any).getClockHz === 'function'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
((avrSimulator as any).getClockHz() as number)
: CPU_HZ;
const unsubscribe = pinManager.onPinChange(pinSIG, (_pin, state) => {
if (state) {
riseTime = getCycles();
} else if (riseTime >= 0) {
const pulseCycles = getCycles() - riseTime;
const pulseUs = (pulseCycles / clockHz) * 1_000_000;
riseTime = -1;
if (pulseUs >= MIN_PULSE_US && pulseUs <= MAX_PULSE_US) {
const angle = Math.round(
((pulseUs - MIN_PULSE_US) / (MAX_PULSE_US - MIN_PULSE_US)) * 180,
);
el.angle = angle;
}
}
});
return () => {
unsubscribe();
};
}
}
// ── Fallback: poll OCR1A/ICR1 registers when no wire is connected ──
// OCR1A low byte = 0x88, high byte = 0x89
// ICR1L = 0x86, ICR1H = 0x87
const OCR1AL = 0x88;
const OCR1AH = 0x89;
const ICR1L = 0x86;
const ICR1H = 0x87;
const SERVO_PERIOD_US = 20000;
let rafId: number | null = null;
let lastOcr1a = -1;
const poll = () => {
if (!avrSimulator.isRunning()) {
rafId = requestAnimationFrame(poll);
return;
}
const cpu = (avrSimulator as any).cpu;
if (!cpu) {
rafId = requestAnimationFrame(poll);
return;
}
const ocr1a = cpu.data[OCR1AL] | (cpu.data[OCR1AH] << 8);
if (ocr1a !== lastOcr1a) {
lastOcr1a = ocr1a;
const icr1 = cpu.data[ICR1L] | (cpu.data[ICR1H] << 8);
let pulseUs: number;
if (icr1 > 0) {
pulseUs = (ocr1a / icr1) * SERVO_PERIOD_US;
} else {
// prescaler 8, 16MHz → 0.5µs per tick
pulseUs = ocr1a * 0.5;
}
const clamped = Math.max(MIN_PULSE_US, Math.min(MAX_PULSE_US, pulseUs));
const angle = Math.round(((clamped - MIN_PULSE_US) / (MAX_PULSE_US - MIN_PULSE_US)) * 180);
el.angle = angle;
}
rafId = requestAnimationFrame(poll);
};
rafId = requestAnimationFrame(poll);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
};
},
});
// ─── Buzzer ──────────────────────────────────────────────────────────────────
/**
* Buzzer uses Web Audio API to generate a tone.
*
* Reads OCR2A (Timer2 CTC mode) to determine frequency:
* f = F_CPU / (2 × prescaler × (OCR2A + 1))
*
* Prescaler detected from TCCR2B[2:0] bits.
* Activates when duty cycle > 0 (pin is driven HIGH).
*/
PartSimulationRegistry.register('buzzer', {
attachEvents: (element, avrSimulator, getArduinoPinHelper) => {
const pinSIG =
getArduinoPinHelper('1') ?? getArduinoPinHelper('+') ?? getArduinoPinHelper('POS');
const pinManager = (avrSimulator as any).pinManager;
let audioCtx: AudioContext | null = null;
let oscillator: OscillatorNode | null = null;
let gainNode: GainNode | null = null;
let isSounding = false;
const el = element as any;
// Timer2 register addresses
const OCR2A = 0xb3;
const TCCR2B = 0xb1;
const F_CPU = 16_000_000;
const prescalerTable: Record<number, number> = {
1: 1,
2: 8,
3: 32,
4: 64,
5: 128,
6: 256,
7: 1024,
};
function getFrequency(cpu: any): number {
const ocr2a = cpu.data[OCR2A] ?? 0;
const tccr2b = cpu.data[TCCR2B] ?? 0;
const csField = tccr2b & 0x07;
const prescaler = prescalerTable[csField] ?? 64;
// CTC mode: f = F_CPU / (2 × prescaler × (OCR2A + 1))
return F_CPU / (2 * prescaler * (ocr2a + 1));
}
function startTone(freq: number) {
if (!audioCtx) {
audioCtx = new AudioContext();
gainNode = audioCtx.createGain();
gainNode.gain.value = 0.1;
gainNode.connect(audioCtx.destination);
}
// Browser autoplay policy: AudioContext starts in 'suspended' state
// until a user gesture has occurred. Resume it here so sound plays.
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
if (oscillator) {
oscillator.frequency.setTargetAtTime(freq, audioCtx.currentTime, 0.01);
return;
}
oscillator = audioCtx.createOscillator();
oscillator.type = 'square';
oscillator.frequency.value = freq;
oscillator.connect(gainNode!);
oscillator.start();
isSounding = true;
if (el.playing !== undefined) el.playing = true;
}
function stopTone() {
if (oscillator) {
oscillator.stop();
oscillator.disconnect();
oscillator = null;
}
isSounding = false;
if (el.playing !== undefined) el.playing = false;
}
// Poll via PWM duty cycle on the buzzer pin
const unsubscribers: (() => void)[] = [];
if (pinSIG !== null && pinManager) {
unsubscribers.push(
pinManager.onPwmChange(pinSIG, (_: number, dc: number) => {
const cpu = (avrSimulator as any).cpu;
if (dc > 0) {
const freq = cpu ? getFrequency(cpu) : 440;
startTone(Math.max(20, Math.min(20000, freq)));
} else {
stopTone();
}
}),
);
// Also respond to digital HIGH/LOW (tone() toggles the pin)
unsubscribers.push(
pinManager.onPinChange(pinSIG, (_: number, state: boolean) => {
if (!isSounding && state) {
const cpu = (avrSimulator as any).cpu;
const freq = cpu ? getFrequency(cpu) : 440;
startTone(Math.max(20, Math.min(20000, freq)));
} else if (isSounding && !state) {
// Don't stop on every LOW — tone() generates a square wave
// We stop only when duty cycle drops to 0 via onPwmChange
}
}),
);
}
return () => {
stopTone();
if (audioCtx) {
audioCtx.close();
audioCtx = null;
}
unsubscribers.forEach((u) => u());
};
},
});
// ─── LCD 1602 / 2004 ─────────────────────────────────────────────────────────
function createLcdSimulation(cols: number, rows: number) {
return {
attachEvents: (
element: HTMLElement,
avrSimulator: AnySimulator,
getArduinoPinHelper: (pin: string) => number | null,
) => {
const el = element as any;
const ddram = new Uint8Array(128).fill(0x20);
let ddramAddress = 0;
let entryIncrement = true;
let displayOn = true;
let cursorOn = false;
let blinkOn = false;
let nibbleState: 'high' | 'low' = 'high';
let highNibble = 0;
let initialized = false;
let initCount = 0;
let rsState = false;
let eState = false;
let d4State = false;
let d5State = false;
let d6State = false;
let d7State = false;
const lineOffsets = rows >= 4 ? [0x00, 0x40, 0x14, 0x54] : [0x00, 0x40];
function ddramToLinear(addr: number): number {
for (let row = 0; row < rows; row++) {
const offset = lineOffsets[row];
if (addr >= offset && addr < offset + cols) {
return row * cols + (addr - offset);
}
}
return -1;
}
function refreshDisplay() {
if (!displayOn) {
el.characters = new Uint8Array(cols * rows).fill(0x20);
return;
}
const chars = new Uint8Array(cols * rows);
for (let row = 0; row < rows; row++) {
const offset = lineOffsets[row];
for (let col = 0; col < cols; col++) {
chars[row * cols + col] = ddram[offset + col];
}
}
el.characters = chars;
el.cursor = cursorOn;
el.blink = blinkOn;
const cursorLinear = ddramToLinear(ddramAddress);
if (cursorLinear >= 0) {
el.cursorX = cursorLinear % cols;
el.cursorY = Math.floor(cursorLinear / cols);
}
}
function processByte(rs: boolean, data: number) {
if (!rs) {
if (data & 0x80) {
ddramAddress = data & 0x7f;
} else if (data & 0x40) {
// CGRAM — not implemented
} else if (data & 0x20) {
initialized = true;
} else if (data & 0x10) {
const sc = (data >> 3) & 1;
const rl = (data >> 2) & 1;
if (!sc) {
ddramAddress = (ddramAddress + (rl ? 1 : -1)) & 0x7f;
}
} else if (data & 0x08) {
displayOn = !!(data & 0x04);
cursorOn = !!(data & 0x02);
blinkOn = !!(data & 0x01);
} else if (data & 0x04) {
entryIncrement = !!(data & 0x02);
} else if (data & 0x02) {
ddramAddress = 0;
} else if (data & 0x01) {
ddram.fill(0x20);
ddramAddress = 0;
}
} else {
ddram[ddramAddress & 0x7f] = data;
ddramAddress = entryIncrement ? (ddramAddress + 1) & 0x7f : (ddramAddress - 1) & 0x7f;
}
refreshDisplay();
}
function onEnableFallingEdge() {
const nibble =
(d4State ? 0x01 : 0) | (d5State ? 0x02 : 0) | (d6State ? 0x04 : 0) | (d7State ? 0x08 : 0);
if (!initialized) {
initCount++;
if (initCount >= 4) {
initialized = true;
nibbleState = 'high';
}
return;
}
if (nibbleState === 'high') {
highNibble = nibble << 4;
nibbleState = 'low';
} else {
processByte(rsState, highNibble | nibble);
nibbleState = 'high';
}
}
const pinRS = getArduinoPinHelper('RS');
const pinE = getArduinoPinHelper('E');
const pinD4 = getArduinoPinHelper('D4');
const pinD5 = getArduinoPinHelper('D5');
const pinD6 = getArduinoPinHelper('D6');
const pinD7 = getArduinoPinHelper('D7');
const pinManager = (avrSimulator as any).pinManager;
if (!pinManager) return () => {};
const unsubscribers: (() => void)[] = [];
if (pinRS !== null)
unsubscribers.push(
pinManager.onPinChange(pinRS, (_: number, s: boolean) => {
rsState = s;
}),
);
if (pinD4 !== null)
unsubscribers.push(
pinManager.onPinChange(pinD4, (_: number, s: boolean) => {
d4State = s;
}),
);
if (pinD5 !== null)
unsubscribers.push(
pinManager.onPinChange(pinD5, (_: number, s: boolean) => {
d5State = s;
}),
);
if (pinD6 !== null)
unsubscribers.push(
pinManager.onPinChange(pinD6, (_: number, s: boolean) => {
d6State = s;
}),
);
if (pinD7 !== null)
unsubscribers.push(
pinManager.onPinChange(pinD7, (_: number, s: boolean) => {
d7State = s;
}),
);
if (pinE !== null) {
unsubscribers.push(
pinManager.onPinChange(pinE, (_: number, s: boolean) => {
const wasHigh = eState;
eState = s;
if (wasHigh && !s) onEnableFallingEdge();
}),
);
}
refreshDisplay();
return () => {
unsubscribers.forEach((u) => u());
};
},
};
}
PartSimulationRegistry.register('lcd1602', createLcdSimulation(16, 2));
PartSimulationRegistry.register('lcd2004', createLcdSimulation(20, 4));
PartSimulationRegistry.register('lcd2002', createLcdSimulation(20, 2));
// ─── ILI9341 TFT Display (SPI) ───────────────────────────────────────────────
/**
* ILI9341 TFT display simulation via hardware SPI.
*
* Intercepts writes to SPDR (via AVRSPI) and decodes ILI9341 commands:
* - 0x2A CASET set column address window
* - 0x2B PASET set page (row) address window
* - 0x2C RAMWR stream RGB-565 pixel data
* - 0x01 SWRESET clear display
* - All others are silently accepted (init sequences, DISPON, MADCTL)
*
* DC/RS pin: LOW = command byte, HIGH = data bytes.
*/
const ili9341Simulation = {
refactor(spi): unify SPI bus interface across all simulators Previous fix added an ESP32-specific code path inside ili9341Simulation to subscribe to the QEMU worker's spi_event stream. That made the LCD work on ESP32-CAM but left the underlying issue unsolved: every other SPI part (custom chips, future SD-card emulators, the SSD168x ePaper already in the codebase) would also need its own per-board branching. The right shape: every simulator exposes a `.spi` member matching the SAME SpiBusLike interface, and SPI parts hook .spi.onByte without caring which board they're attached to. AVRSimulator already had this — now everything else does too. frontend/src/simulation/SpiBus.ts (new) Defines the contract — `onByte: (mosi) => void | null` plus optional `completeTransfer(miso)`. Documents the single-listener semantics that AVR has had since day one. frontend/src/store/useSimulatorStore.ts Esp32BridgeShim gets a lazy `.spi` getter that wraps bridge.onSpiByte (the per-byte WS event from the QEMU worker). completeTransfer is a no-op because the worker drives MISO via its own _spi_response global. Covers ESP32 (Xtensa), ESP32-S3, ESP32-CAM, ESP32-C3 — every kind that routes through Esp32Bridge. frontend/src/simulation/RP2040Simulator.ts Adds a lazy `.spi` getter that re-routes rp2040.spi[0].onTransmit through the adapter. Default loopback (the prior behaviour) is preserved when no part has accessed `.spi` yet — only consumers that opt in see their handler invoked. Covers Pico and Pico W. frontend/src/simulation/parts/ComplexParts.ts ili9341Simulation no longer has an ESP32 special case. Single code path: `simulator.spi.onByte = handler`. Works on AVR, RP2040, all ESP32 variants. Same pattern is now available to every future SPI part — ssd1306, sd-card, oled, etc. The Esp32Bridge.ts spi_event field-name fix from 6afa62e (msg.data.event instead of the non-existent msg.data.data) stays in place — that's what makes the per-byte stream actually arrive in the bridge. Verified: ILI9341 + ESP32-CAM gallery example renders the live webcam preview after a hard refresh. The same simulation code works on Arduino Uno + ILI9341 (the existing ili9341-test-sketch in example_zip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:36:29 +07:00
attachEvents: (element, simulator, getArduinoPinHelper) => {
const el = element as any;
refactor(spi): unify SPI bus interface across all simulators Previous fix added an ESP32-specific code path inside ili9341Simulation to subscribe to the QEMU worker's spi_event stream. That made the LCD work on ESP32-CAM but left the underlying issue unsolved: every other SPI part (custom chips, future SD-card emulators, the SSD168x ePaper already in the codebase) would also need its own per-board branching. The right shape: every simulator exposes a `.spi` member matching the SAME SpiBusLike interface, and SPI parts hook .spi.onByte without caring which board they're attached to. AVRSimulator already had this — now everything else does too. frontend/src/simulation/SpiBus.ts (new) Defines the contract — `onByte: (mosi) => void | null` plus optional `completeTransfer(miso)`. Documents the single-listener semantics that AVR has had since day one. frontend/src/store/useSimulatorStore.ts Esp32BridgeShim gets a lazy `.spi` getter that wraps bridge.onSpiByte (the per-byte WS event from the QEMU worker). completeTransfer is a no-op because the worker drives MISO via its own _spi_response global. Covers ESP32 (Xtensa), ESP32-S3, ESP32-CAM, ESP32-C3 — every kind that routes through Esp32Bridge. frontend/src/simulation/RP2040Simulator.ts Adds a lazy `.spi` getter that re-routes rp2040.spi[0].onTransmit through the adapter. Default loopback (the prior behaviour) is preserved when no part has accessed `.spi` yet — only consumers that opt in see their handler invoked. Covers Pico and Pico W. frontend/src/simulation/parts/ComplexParts.ts ili9341Simulation no longer has an ESP32 special case. Single code path: `simulator.spi.onByte = handler`. Works on AVR, RP2040, all ESP32 variants. Same pattern is now available to every future SPI part — ssd1306, sd-card, oled, etc. The Esp32Bridge.ts spi_event field-name fix from 6afa62e (msg.data.event instead of the non-existent msg.data.data) stays in place — that's what makes the per-byte stream actually arrive in the bridge. Verified: ILI9341 + ESP32-CAM gallery example renders the live webcam preview after a hard refresh. The same simulation code works on Arduino Uno + ILI9341 (the existing ili9341-test-sketch in example_zip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:36:29 +07:00
const pinManager = (simulator as any).pinManager;
// Generic .spi accessor — every simulator (AVR, RP2040, ESP32 family)
// exposes a SpiBusLike object via this name (see frontend/src/simulation/
// SpiBus.ts). Single-listener channel: assign to spi.onByte and
// chain any prior handler in our cleanup.
const spi = (simulator as any).spi as
| { onByte: ((mosi: number) => void) | null;
completeTransfer?: (miso: number) => void }
| undefined;
if (!pinManager || !spi) return () => {};
// ── Canvas setup ──────────────────────────────────────────────────
const SCREEN_W = 240;
const SCREEN_H = 320;
const initCanvas = (): CanvasRenderingContext2D | null => {
// el.canvas is the getter defined in ili9341-element.ts:
// get canvas() { return this.shadowRoot?.querySelector('canvas'); }
// The element already sets width=240 height=320 in its LitElement template.
const canvas = el.canvas as HTMLCanvasElement | null;
if (!canvas) return null;
return canvas.getContext('2d');
};
let ctx = initCanvas();
const onCanvasReady = () => {
ctx = initCanvas();
};
el.addEventListener('canvas-ready', onCanvasReady);
// ── Shared ImageData buffer ───────────────────────────────────────
// Accumulate pixels here; flush to canvas once per animation frame.
let imageData: ImageData | null = null;
const getOrCreateImageData = (): ImageData => {
if (!ctx) ctx = initCanvas();
if (!imageData && ctx) imageData = ctx.createImageData(SCREEN_W, SCREEN_H);
return imageData!;
};
let pendingFlush = false;
let rafId: number | null = null;
const scheduleFlush = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
if (pendingFlush && ctx && imageData) {
ctx.putImageData(imageData, 0, 0);
pendingFlush = false;
}
});
};
// ── ILI9341 state ─────────────────────────────────────────────────
let colStart = 0,
colEnd = SCREEN_W - 1;
let rowStart = 0,
rowEnd = SCREEN_H - 1;
let curX = 0,
curY = 0;
let currentCmd = -1;
let dataBytes: number[] = [];
let inRamWrite = false;
let pixelHiByte = 0;
let pixelByteCount = 0;
// ── DC pin tracking ───────────────────────────────────────────────
let dcState = false; // LOW = command, HIGH = data
const pinDC = getArduinoPinHelper('D/C');
const unsubscribers: (() => void)[] = [];
if (pinDC !== null) {
unsubscribers.push(
pinManager.onPinChange(pinDC, (_: number, s: boolean) => {
dcState = s;
}),
);
}
// ── Pixel writer ──────────────────────────────────────────────────
const writePixel = (hi: number, lo: number) => {
if (curX > colEnd || curY > rowEnd || curY >= SCREEN_H || curX >= SCREEN_W) return;
const id = getOrCreateImageData();
const color = (hi << 8) | lo;
const r = ((color >> 11) & 0x1f) * 8;
const g = ((color >> 5) & 0x3f) * 4;
const b = (color & 0x1f) * 8;
const idx = (curY * SCREEN_W + curX) * 4;
id.data[idx] = r;
id.data[idx + 1] = g;
id.data[idx + 2] = b;
id.data[idx + 3] = 255;
pendingFlush = true;
curX++;
if (curX > colEnd) {
curX = colStart;
curY++;
}
};
// ── Command / data processing ─────────────────────────────────────
const processCommand = (cmd: number) => {
currentCmd = cmd;
dataBytes = [];
inRamWrite = cmd === 0x2c;
pixelByteCount = 0;
if (cmd === 0x01) {
// SWRESET clear framebuffer
colStart = 0;
colEnd = SCREEN_W - 1;
rowStart = 0;
rowEnd = SCREEN_H - 1;
curX = 0;
curY = 0;
imageData = null;
if (ctx) ctx.clearRect(0, 0, SCREEN_W, SCREEN_H);
}
};
const processData = (value: number) => {
if (inRamWrite) {
// RGB-565: two bytes per pixel
if (pixelByteCount === 0) {
pixelHiByte = value;
pixelByteCount = 1;
} else {
writePixel(pixelHiByte, value);
scheduleFlush();
pixelByteCount = 0;
}
return;
}
dataBytes.push(value);
switch (currentCmd) {
case 0x2a: // CASET column address set
if (dataBytes.length === 2) colStart = (dataBytes[0] << 8) | dataBytes[1];
if (dataBytes.length === 4) {
colEnd = (dataBytes[2] << 8) | dataBytes[3];
curX = colStart;
}
break;
case 0x2b: // PASET page address set
if (dataBytes.length === 2) rowStart = (dataBytes[0] << 8) | dataBytes[1];
if (dataBytes.length === 4) {
rowEnd = (dataBytes[2] << 8) | dataBytes[3];
curY = rowStart;
}
break;
// All other commands (DISPON, MADCTL, COLMOD…) just buffer data
}
};
refactor(spi): unify SPI bus interface across all simulators Previous fix added an ESP32-specific code path inside ili9341Simulation to subscribe to the QEMU worker's spi_event stream. That made the LCD work on ESP32-CAM but left the underlying issue unsolved: every other SPI part (custom chips, future SD-card emulators, the SSD168x ePaper already in the codebase) would also need its own per-board branching. The right shape: every simulator exposes a `.spi` member matching the SAME SpiBusLike interface, and SPI parts hook .spi.onByte without caring which board they're attached to. AVRSimulator already had this — now everything else does too. frontend/src/simulation/SpiBus.ts (new) Defines the contract — `onByte: (mosi) => void | null` plus optional `completeTransfer(miso)`. Documents the single-listener semantics that AVR has had since day one. frontend/src/store/useSimulatorStore.ts Esp32BridgeShim gets a lazy `.spi` getter that wraps bridge.onSpiByte (the per-byte WS event from the QEMU worker). completeTransfer is a no-op because the worker drives MISO via its own _spi_response global. Covers ESP32 (Xtensa), ESP32-S3, ESP32-CAM, ESP32-C3 — every kind that routes through Esp32Bridge. frontend/src/simulation/RP2040Simulator.ts Adds a lazy `.spi` getter that re-routes rp2040.spi[0].onTransmit through the adapter. Default loopback (the prior behaviour) is preserved when no part has accessed `.spi` yet — only consumers that opt in see their handler invoked. Covers Pico and Pico W. frontend/src/simulation/parts/ComplexParts.ts ili9341Simulation no longer has an ESP32 special case. Single code path: `simulator.spi.onByte = handler`. Works on AVR, RP2040, all ESP32 variants. Same pattern is now available to every future SPI part — ssd1306, sd-card, oled, etc. The Esp32Bridge.ts spi_event field-name fix from 6afa62e (msg.data.event instead of the non-existent msg.data.data) stays in place — that's what makes the per-byte stream actually arrive in the bridge. Verified: ILI9341 + ESP32-CAM gallery example renders the live webcam preview after a hard refresh. The same simulation code works on Arduino Uno + ILI9341 (the existing ili9341-test-sketch in example_zip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:36:29 +07:00
// ── Intercept SPI (board-agnostic) ────────────────────────────────
// Single hook regardless of board kind: every simulator's `.spi`
// exposes the same shape — settable onByte handler + optional
// completeTransfer to drive MISO. AVR and RP2040 actually use
// completeTransfer; ESP32 ignores it (worker drives MISO via
// its own _spi_response global).
const prevOnByte = spi.onByte;
spi.onByte = (value: number) => {
if (!dcState) processCommand(value);
else processData(value);
// Idle-byte response — the typical ILI9341 driver writes only,
// so any value works. 0xff matches what the prior AVR path
// returned to keep behaviour stable.
spi.completeTransfer?.(0xff);
};
// ── Cleanup ───────────────────────────────────────────────────────
return () => {
refactor(spi): unify SPI bus interface across all simulators Previous fix added an ESP32-specific code path inside ili9341Simulation to subscribe to the QEMU worker's spi_event stream. That made the LCD work on ESP32-CAM but left the underlying issue unsolved: every other SPI part (custom chips, future SD-card emulators, the SSD168x ePaper already in the codebase) would also need its own per-board branching. The right shape: every simulator exposes a `.spi` member matching the SAME SpiBusLike interface, and SPI parts hook .spi.onByte without caring which board they're attached to. AVRSimulator already had this — now everything else does too. frontend/src/simulation/SpiBus.ts (new) Defines the contract — `onByte: (mosi) => void | null` plus optional `completeTransfer(miso)`. Documents the single-listener semantics that AVR has had since day one. frontend/src/store/useSimulatorStore.ts Esp32BridgeShim gets a lazy `.spi` getter that wraps bridge.onSpiByte (the per-byte WS event from the QEMU worker). completeTransfer is a no-op because the worker drives MISO via its own _spi_response global. Covers ESP32 (Xtensa), ESP32-S3, ESP32-CAM, ESP32-C3 — every kind that routes through Esp32Bridge. frontend/src/simulation/RP2040Simulator.ts Adds a lazy `.spi` getter that re-routes rp2040.spi[0].onTransmit through the adapter. Default loopback (the prior behaviour) is preserved when no part has accessed `.spi` yet — only consumers that opt in see their handler invoked. Covers Pico and Pico W. frontend/src/simulation/parts/ComplexParts.ts ili9341Simulation no longer has an ESP32 special case. Single code path: `simulator.spi.onByte = handler`. Works on AVR, RP2040, all ESP32 variants. Same pattern is now available to every future SPI part — ssd1306, sd-card, oled, etc. The Esp32Bridge.ts spi_event field-name fix from 6afa62e (msg.data.event instead of the non-existent msg.data.data) stays in place — that's what makes the per-byte stream actually arrive in the bridge. Verified: ILI9341 + ESP32-CAM gallery example renders the live webcam preview after a hard refresh. The same simulation code works on Arduino Uno + ILI9341 (the existing ili9341-test-sketch in example_zip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:36:29 +07:00
spi.onByte = prevOnByte;
if (rafId !== null) cancelAnimationFrame(rafId);
el.removeEventListener('canvas-ready', onCanvasReady);
unsubscribers.forEach((u) => u());
};
},
};
PartSimulationRegistry.register('ili9341', ili9341Simulation);
// board-ili9341-cap-touch (Wokwi type) maps to 'ili9341-cap-touch' metadataId — same SPI simulation
PartSimulationRegistry.register('ili9341-cap-touch', ili9341Simulation);