1119 lines
42 KiB
TypeScript
1119 lines
42 KiB
TypeScript
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 0–1000 → volts 0–5
|
||
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 0–VCC (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 544–2400 µs, NOT the generic 1000–2000 µ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);
|