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

744 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { PartSimulationRegistry } from './PartSimulationRegistry';
import type { AnySimulator } from './PartSimulationRegistry';
import { RP2040Simulator } from '../RP2040Simulator';
import { getADC, setAdcVoltage } from './partUtils';
// ─── 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) => {
const pin = getArduinoPinHelper('SIG');
console.log(`[Potentiometer] attachEvents called, SIG pin resolved to: ${pin}`);
if (pin === null) {
console.warn('[Potentiometer] No SIG pin found — skipping ADC attachment');
return () => { };
}
// Determine reference voltage based on board type
const isRP2040 = simulator instanceof RP2040Simulator;
const refVoltage = isRP2040 ? 3.3 : 5.0;
console.log(`[Potentiometer] Board type: ${isRP2040 ? 'RP2040' : 'AVR'}, refV: ${refVoltage}`);
const onInput = () => {
const raw = parseInt((element as any).value || '0', 10);
const volts = (raw / 1023.0) * refVoltage;
console.log(`[Potentiometer] pin=${pin}, raw=${raw}, volts=${volts.toFixed(3)}`);
if (!setAdcVoltage(simulator, pin, volts)) {
console.warn(`[Potentiometer] ADC not available for pin ${pin}`);
}
};
// Fire once on attach to set initial value
onInput();
element.addEventListener('input', onInput);
return () => element.removeEventListener('input', onInput);
},
});
// ─── Slide Potentiometer ─────────────────────────────────────────────────────
PartSimulationRegistry.register('slide-potentiometer', {
attachEvents: (element, avrSimulator, getArduinoPinHelper) => {
const arduinoPin = getArduinoPinHelper('SIG') ?? getArduinoPinHelper('OUT');
if (arduinoPin === null) return () => { };
const el = element as any;
const isRP2040 = avrSimulator instanceof RP2040Simulator;
const refVoltage = isRP2040 ? 3.3 : 5.0;
const onInput = () => {
const min = el.min ?? 0;
const max = el.max ?? 1023;
const value = el.value ?? 0;
const normalized = (value - min) / (max - min || 1);
const volts = normalized * refVoltage;
setAdcVoltage(avrSimulator, arduinoPin, volts);
};
// Fire once on attach to set initial 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) => {
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)
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 && pinAO !== null) {
const volts = (val / 1023.0) * 5.0;
setAdcVoltage(avrSimulator, pinAO, volts);
}
};
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;
}));
}
return () => unsubscribers.forEach(u => u());
},
});
// ─── 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) => {
const pinX = getArduinoPinHelper('VRX') ?? getArduinoPinHelper('XOUT');
const pinY = getArduinoPinHelper('VRY') ?? getArduinoPinHelper('YOUT');
const pinSW = getArduinoPinHelper('SW');
const el = element as any;
// Center position is mid-range (~2.5V)
if (pinX !== null) setAdcVoltage(avrSimulator, pinX, 2.5);
if (pinY !== null) setAdcVoltage(avrSimulator, pinY, 2.5);
const onMove = () => {
// xValue / yValue are 0-1023
if (pinX !== null) {
const vx = ((el.xValue ?? 512) / 1023.0) * 5.0;
setAdcVoltage(avrSimulator, pinX, vx);
}
if (pinY !== null) {
const vy = ((el.yValue ?? 512) / 1023.0) * 5.0;
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);
return () => {
element.removeEventListener('input', onMove);
element.removeEventListener('joystick-move', onMove);
element.removeEventListener('button-press', onPress);
element.removeEventListener('button-release', onRelease);
};
},
});
// ─── Servo ───────────────────────────────────────────────────────────────────
/**
* Servo motor — reads OCR1A and ICR1 to calculate pulse width and angle.
*
* Standard RC servo protocol:
* - 50 Hz signal (20 ms period)
* - Pulse width 1 ms → 0°, 1.5 ms → 90°, 2 ms → 180°
*
* With Timer1, prescaler=8, F_CPU=16MHz:
* - ICR1 = 20000 for 50Hz
* - OCR1A = 1000 → 0°, 1500 → 90°, 2000 → 180°
*
* We poll these registers every animation frame via a requestAnimationFrame loop.
*/
PartSimulationRegistry.register('servo', {
attachEvents: (element, avrSimulator, getArduinoPinHelper) => {
const pinSIG = getArduinoPinHelper('PWM') ?? getArduinoPinHelper('SIG') ?? getArduinoPinHelper('1');
const el = element as any;
// OCR1A low byte = 0x88, OCR1A high byte = 0x89
// ICR1L = 0x86, ICR1H = 0x87
const OCR1AL = 0x88;
const OCR1AH = 0x89;
const ICR1L = 0x86;
const ICR1H = 0x87;
let rafId: number | null = null;
let lastOcr1a = -1;
const poll = () => {
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);
// Calculate pulse width in microseconds
// prescaler 8, F_CPU 16MHz → 1 tick = 0.5µs
// pulse_us = ocr1a * 0.5
// But also handle prescaler 64 (1 tick = 4µs) and default ICR1 detection
let pulseUs: number;
if (icr1 > 0) {
// Proportional to ICR1 period (assume 20ms period)
pulseUs = 1000 + (ocr1a / icr1) * 1000;
} else {
// Fallback: prescaler 8
pulseUs = ocr1a * 0.5;
}
// Clamp to 1000-2000µs and map to 0-180°
const clamped = Math.max(1000, Math.min(2000, pulseUs));
const angle = Math.round(((clamped - 1000) / 1000) * 180);
el.angle = angle;
}
// Also support PWM duty cycle approach via PinManager
if (pinSIG !== null) {
const pinManager = (avrSimulator as any).pinManager;
// Only override angle if cpu-based approach doesn't work
// (ICR1 = 0 means Timer1 not configured as servo)
const icr1 = cpu.data[ICR1L] | (cpu.data[ICR1H] << 8);
if (icr1 === 0 && pinManager) {
const dc = pinManager.getPwmValue(pinSIG);
if (dc > 0) {
el.angle = Math.round(dc * 180);
}
}
}
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);
}
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();
console.log(`[LCD] ${cols}x${rows} simulation initialized`);
return () => {
unsubscribers.forEach(u => u());
};
},
};
}
PartSimulationRegistry.register('lcd1602', createLcdSimulation(16, 2));
PartSimulationRegistry.register('lcd2004', createLcdSimulation(20, 4));
// ─── 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.
*/
PartSimulationRegistry.register('ili9341', {
attachEvents: (element, avrSimulator, getArduinoPinHelper) => {
const el = element as any;
const pinManager = (avrSimulator as any).pinManager;
const spi = (avrSimulator as any).spi;
if (!pinManager || !spi) {
console.warn('[ILI9341] pinManager or SPI peripheral not available');
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
}
};
// ── Intercept SPI ─────────────────────────────────────────────────
const prevOnByte = spi.onByte.bind(spi);
spi.onByte = (value: number) => {
if (!dcState) {
processCommand(value);
} else {
processData(value);
}
spi.completeTransfer(0xFF); // Unblock CPU immediately
};
console.log(`[ILI9341] SPI simulation ready. DC→pin${pinDC}`);
// ── Cleanup ───────────────────────────────────────────────────────
return () => {
spi.onByte = prevOnByte;
if (rafId !== null) cancelAnimationFrame(rafId);
el.removeEventListener('canvas-ready', onCanvasReady);
unsubscribers.forEach(u => u());
};
},
});