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

1119 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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, _componentId, getPinResolver) => {
const pinManager = (avrSimulator as any).pinManager;
if (!pinManager) return () => {};
const el = element as any;
const unsubscribers: (() => void)[] = [];
const useResolver = typeof getPinResolver === 'function';
// Digital path: prefer PinResolver so each channel works when driven
// through an active device (e.g. a P-MOSFET high-side switch).
type Channel = { pinName: 'R' | 'G' | 'B'; prop: 'ledRed' | 'ledGreen' | 'ledBlue' };
const channels: Channel[] = [
{ pinName: 'R', prop: 'ledRed' },
{ pinName: 'G', prop: 'ledGreen' },
{ pinName: 'B', prop: 'ledBlue' },
];
// Track Arduino pin numbers for the PWM hook below — analogWrite()
// override still needs the integer pin number because PinResolver
// doesn't (yet) expose PWM duty.
const pwmPins: Array<{ pin: number; prop: Channel['prop'] }> = [];
for (const { pinName, prop } of channels) {
if (useResolver) {
const resolver = getPinResolver!(pinName);
if (resolver) {
el[prop] = resolver.getCurrentState() === 'HIGH' ? 255 : 0;
unsubscribers.push(
resolver.onChange((state) => {
el[prop] = state === 'HIGH' ? 255 : 0;
}),
);
}
} else {
const pin = getArduinoPinHelper(pinName);
if (pin !== null) {
unsubscribers.push(
pinManager.onPinChange(pin, (_: number, state: boolean) => {
el[prop] = state ? 255 : 0;
}),
);
}
}
// PWM hook still uses the raw pin number — duty cycle handling
// doesn't live in PinResolver yet.
const rawPin = getArduinoPinHelper(pinName);
if (rawPin !== null) pwmPins.push({ pin: rawPin, prop });
}
// PWM override — analogWrite() value supersedes digital state.
for (const { pin, prop } of pwmPins) {
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;
// wokwi-analog-joystick exposes xValue/yValue as DIRECTION (-1 / 0 / +1),
// not pot-style 0..1023. See @wokwi/elements analog-joystick-element.js:
// arrow-zone clicks call mousedown(e, dx, dy) where dx,dy ∈ {-1, 0, +1};
// mouseup snaps back to 0. Map that tri-state to an ADC voltage:
// -1 → 0 V | 0 → VCC/2 (center) | +1 → VCC
// AVR uses 5 V; everything else (RP2040, ESP32, ESP32-S3, …) runs at 3.3 V.
const isAvr = !(avrSimulator instanceof RP2040Simulator)
&& typeof (avrSimulator as any).setAdcVoltage !== 'function';
const vcc = isAvr ? 5.0 : 3.3;
const centerV = vcc / 2;
const dirToVolts = (d: number) => ((Math.max(-1, Math.min(1, d)) + 1) / 2) * vcc;
// 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 = () => {
if (pinX !== null) {
setAdcVoltage(avrSimulator, pinX, dirToVolts(Number(el.xValue ?? 0)));
}
if (pinY !== null) {
setAdcVoltage(avrSimulator, pinY, dirToVolts(Number(el.yValue ?? 0)));
}
};
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, _componentId, getPinResolver) => {
const pinSIG =
getArduinoPinHelper('1') ?? getArduinoPinHelper('+') ?? getArduinoPinHelper('POS');
const pinManager = (avrSimulator as any).pinManager;
// PWM tracking still needs the integer pin number; resolver doesn't
// expose duty. The HIGH/LOW path migrates to PinResolver below.
const useResolver = typeof getPinResolver === 'function';
const sigResolver = useResolver
? getPinResolver!('1') ?? getPinResolver!('+') ?? getPinResolver!('POS')
: null;
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).
// Prefer the resolver — a buzzer driven through a transistor sees
// the real collector voltage and threshold-converts via the board
// logic family.
if (sigResolver) {
unsubscribers.push(
sigResolver.onChange((state) => {
if (!isSounding && state === 'HIGH') {
const cpu = (avrSimulator as any).cpu;
const freq = cpu ? getFrequency(cpu) : 440;
startTone(Math.max(20, Math.min(20000, freq)));
}
// tone() produces a square wave — don't stop on every LOW;
// stop only when duty drops to 0 via onPwmChange.
}),
);
} else {
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)));
}
}),
);
}
}
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
* - 0x36 MADCTL memory access control (rotation MV / MX / MY bits)
* - 0x01 SWRESET clear display
* - All others are silently accepted (DISPON, COLMOD, …)
*
* Coordinates in CASET/PASET are LOGICAL — driver libraries (Adafruit_
* ILI9341 etc.) call `setRotation(1|3)` which emits MADCTL with MV set
* and then writes CASET in 0..319 / PASET in 0..239. The emulator keeps
* the underlying canvas at the panel's native 240×320 and remaps each
* pixel through MV/MX/MY at write time. Without this, every landscape
* sketch (rotation 1 or 3) used to render to nothing because the X
* bound check filtered out anything past column 239.
*
* DC/RS pin: LOW = command byte, HIGH = data bytes.
*/
const ili9341Simulation = {
attachEvents: (element, simulator, getArduinoPinHelper) => {
const el = element as any;
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!;
};
// Flush is debounced rather than rAF-pinned: TFT firmwares emit each
// frame as one long SPI burst that often takes >16 ms to drain
// (rp2040js is sub-realtime), so painting every rAF would snapshot
// the canvas mid-burst — the user would see only the pixels that
// happened to land before that tick. We instead wait for SPI silence
// (a real frame boundary), bounded by a hard cap so continuous-write
// sketches still update.
let pendingFlush = false;
let idleTimerId: number | null = null;
let firstWriteSinceFlush = 0;
const IDLE_FLUSH_MS = 16;
const MAX_FLUSH_INTERVAL_MS = 100;
const doFlush = () => {
if (idleTimerId !== null) {
clearTimeout(idleTimerId);
idleTimerId = null;
}
if (pendingFlush && ctx && imageData) {
ctx.putImageData(imageData, 0, 0);
pendingFlush = false;
firstWriteSinceFlush = 0;
}
};
const scheduleFlush = () => {
if (!pendingFlush) return;
const now = performance.now();
if (firstWriteSinceFlush === 0) firstWriteSinceFlush = now;
if (now - firstWriteSinceFlush >= MAX_FLUSH_INTERVAL_MS) {
doFlush();
return;
}
if (idleTimerId !== null) clearTimeout(idleTimerId);
idleTimerId = window.setTimeout(doFlush, IDLE_FLUSH_MS);
};
// ── 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;
// ── MADCTL state ──────────────────────────────────────────────────
// ILI9341 0x36 command bits we care about (datasheet §8.2.29). Set
// by setRotation() in every Adafruit-style driver; default is
// rotation 0 = all bits clear (portrait, no swap, no mirror).
let madMV = false; // row/column exchange — landscape orientation
let madMX = false; // column address mirror
let madMY = false; // row address mirror
// ── 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 ──────────────────────────────────────────────────
// curX / curY / col* / row* are LOGICAL coordinates — the values the
// driver thinks it's writing to. In rotation 0 logical = physical.
// In rotation 1/3 (MV set) the driver iterates X in 0..319 and Y in
// 0..239; we swap them at the last possible moment before touching
// the imageData buffer (which is always physically 240 wide × 320 tall).
//
// The mapping is rotation-specific because applying MX/MY/MV as three
// independent flags double-mirrors the output (we tried that in
// commit 6edc715 and the user saw "espejada" text). The four
// Adafruit_ILI9341 setRotation() values map cleanly to four explicit
// (curX, curY) → (physX, physY) formulae taken from the chip's
// datasheet section 8.2.29 (Memory Access Control):
//
// rot 0 M=0x48 (MX|BGR) : (curX, curY) [portrait]
// rot 1 M=0x28 (MV|BGR) : (curY, (319 - curX)) [landscape]
// rot 2 M=0x88 (MY|BGR) : ((239 - curX), (319 - curY)) [portrait flipped]
// rot 3 M=0xE8 (MX|MY|MV|BGR) : ((239 - curY), curX) [landscape flipped]
//
// The Adafruit driver computes the rotation register value, sends it
// once via MADCTL, then writes pixels in the rotated framebuffer's
// coordinate space — we mirror that on the receive side.
const writePixel = (hi: number, lo: number) => {
if (curX > colEnd || curY > rowEnd) return;
// Map logical → physical via the (MV, MX, MY) rotation signature.
let physX: number, physY: number;
if (!madMV) {
// Portrait (rotations 0 or 2)
physX = madMY ? (SCREEN_W - 1) - curX : curX;
physY = madMY ? (SCREEN_H - 1) - curY : curY;
} else if (!madMX && !madMY) {
// Landscape rotation 1: m = MV | BGR. (curY, 319 - curX)
physX = curY;
physY = (SCREEN_H - 1) - curX;
} else {
// Landscape rotation 3: m = MX | MY | MV | BGR. (239 - curY, curX)
physX = (SCREEN_W - 1) - curY;
physY = curX;
}
if (physX < 0 || physX >= SCREEN_W || physY < 0 || physY >= SCREEN_H) {
curX++;
if (curX > colEnd) {
curX = colStart;
curY++;
}
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 = (physY * SCREEN_W + physX) * 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 + reset MADCTL to defaults
colStart = 0;
colEnd = SCREEN_W - 1;
rowStart = 0;
rowEnd = SCREEN_H - 1;
curX = 0;
curY = 0;
madMV = false;
madMX = false;
madMY = false;
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;
case 0x36: // MADCTL memory access control (rotation / mirror)
if (dataBytes.length === 1) {
const m = dataBytes[0];
madMY = (m & 0x80) !== 0;
madMX = (m & 0x40) !== 0;
madMV = (m & 0x20) !== 0;
}
break;
// All other commands (DISPON, COLMOD…) just buffer data
}
};
// ── 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 () => {
spi.onByte = prevOnByte;
if (idleTimerId !== null) clearTimeout(idleTimerId);
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);