2026-03-04 23:36:33 +07:00
|
|
|
|
import { PartSimulationRegistry } from './PartSimulationRegistry';
|
2026-03-05 05:28:40 +07:00
|
|
|
|
import type { AnySimulator } from './PartSimulationRegistry';
|
2026-03-06 07:07:03 +07:00
|
|
|
|
import { RP2040Simulator } from '../RP2040Simulator';
|
2026-04-21 20:56:20 +07:00
|
|
|
|
import { getADC, setAdcVoltage, emitPropertyChange } from './partUtils';
|
2026-03-19 11:52:46 +07:00
|
|
|
|
import { registerSensorUpdate, unregisterSensorUpdate } from '../SensorUpdateRegistry';
|
2026-03-04 23:36:33 +07:00
|
|
|
|
|
2026-03-05 04:27:14 +07:00
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
// ─── RGB LED (PWM-aware) ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-03-04 23:36:33 +07:00
|
|
|
|
/**
|
2026-03-05 04:27:14 +07:00
|
|
|
|
* RGB LED implementation — supports both digital and PWM (analogWrite) output.
|
|
|
|
|
|
* Falls back to digital mode if no PWM is detected.
|
2026-03-04 23:36:33 +07:00
|
|
|
|
*/
|
|
|
|
|
|
PartSimulationRegistry.register('rgb-led', {
|
2026-05-15 23:25:40 +07:00
|
|
|
|
attachEvents: (element, avrSimulator, getArduinoPinHelper, _componentId, getPinResolver) => {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const pinManager = (avrSimulator as any).pinManager;
|
|
|
|
|
|
if (!pinManager) return () => {};
|
|
|
|
|
|
|
|
|
|
|
|
const el = element as any;
|
|
|
|
|
|
const unsubscribers: (() => void)[] = [];
|
2026-05-15 23:25:40 +07:00
|
|
|
|
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' },
|
|
|
|
|
|
];
|
2026-04-22 02:45:45 +07:00
|
|
|
|
|
2026-05-15 23:25:40 +07:00
|
|
|
|
// 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 });
|
2026-04-22 02:45:45 +07:00
|
|
|
|
}
|
2026-05-15 23:25:40 +07:00
|
|
|
|
|
|
|
|
|
|
// PWM override — analogWrite() value supersedes digital state.
|
|
|
|
|
|
for (const { pin, prop } of pwmPins) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
unsubscribers.push(
|
2026-05-15 23:25:40 +07:00
|
|
|
|
pinManager.onPwmChange(pin, (_: number, dc: number) => {
|
|
|
|
|
|
el[prop] = Math.round(dc * 255);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return () => unsubscribers.forEach((u) => u());
|
|
|
|
|
|
},
|
2026-03-04 23:36:33 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-05 04:27:14 +07:00
|
|
|
|
// ─── Potentiometer (rotary) ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
PartSimulationRegistry.register('potentiometer', {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
2026-03-05 04:27:14 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
onInput();
|
2026-03-06 07:07:03 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
element.addEventListener('input', onInput);
|
|
|
|
|
|
return () => element.removeEventListener('input', onInput);
|
|
|
|
|
|
},
|
2026-03-05 04:27:14 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Slide Potentiometer ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
PartSimulationRegistry.register('slide-potentiometer', {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
2026-03-05 04:27:14 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
onInput();
|
2026-03-06 07:07:03 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
element.addEventListener('input', onInput);
|
|
|
|
|
|
return () => element.removeEventListener('input', onInput);
|
|
|
|
|
|
},
|
2026-03-05 04:27:14 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Photoresistor Sensor ────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-03-04 23:36:33 +07:00
|
|
|
|
/**
|
2026-03-05 04:27:14 +07:00
|
|
|
|
* 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.
|
2026-03-04 23:36:33 +07:00
|
|
|
|
*/
|
2026-03-05 04:27:14 +07:00
|
|
|
|
PartSimulationRegistry.register('photoresistor-sensor', {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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) {
|
2026-03-05 04:27:14 +07:00
|
|
|
|
if (pinAO !== null) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const volts = (val / 1023.0) * 5.0;
|
|
|
|
|
|
setAdcVoltage(avrSimulator, pinAO, volts);
|
2026-03-05 04:27:14 +07:00
|
|
|
|
}
|
2026-04-22 02:45:45 +07:00
|
|
|
|
// 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);
|
2026-03-05 04:27:14 +07:00
|
|
|
|
}
|
2026-04-22 02:45:45 +07:00
|
|
|
|
emitPropertyChange(componentId, 'lux', values.lux);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-03-05 04:27:14 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
return () => {
|
|
|
|
|
|
unsubscribers.forEach((u) => u());
|
|
|
|
|
|
unregisterSensorUpdate(componentId);
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
2026-03-05 04:27:14 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 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', {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-05-22 23:57:11 +07:00
|
|
|
|
// 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;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const centerV = vcc / 2;
|
2026-05-22 23:57:11 +07:00
|
|
|
|
const dirToVolts = (d: number) => ((Math.max(-1, Math.min(1, d)) + 1) / 2) * vcc;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
|
|
|
|
|
|
// 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) {
|
2026-05-22 23:57:11 +07:00
|
|
|
|
setAdcVoltage(avrSimulator, pinX, dirToVolts(Number(el.xValue ?? 0)));
|
2026-04-22 02:45:45 +07:00
|
|
|
|
}
|
|
|
|
|
|
if (pinY !== null) {
|
2026-05-22 23:57:11 +07:00
|
|
|
|
setAdcVoltage(avrSimulator, pinY, dirToVolts(Number(el.yValue ?? 0)));
|
2026-04-22 02:45:45 +07:00
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-03-05 04:27:14 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
2026-03-19 11:52:46 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
2026-03-05 04:27:14 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Servo ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-21 03:11:12 +07:00
|
|
|
|
* Servo motor — measures actual PWM pulse width from pin state changes.
|
2026-03-05 04:27:14 +07:00
|
|
|
|
*
|
|
|
|
|
|
* Standard RC servo protocol:
|
|
|
|
|
|
* - 50 Hz signal (20 ms period)
|
2026-03-21 03:11:12 +07:00
|
|
|
|
* - 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)
|
2026-03-05 04:27:14 +07:00
|
|
|
|
*
|
2026-03-21 03:11:12 +07:00
|
|
|
|
* 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.
|
2026-03-05 04:27:14 +07:00
|
|
|
|
*
|
2026-03-21 03:11:12 +07:00
|
|
|
|
* Fallback: if no wire is connected (pinSIG === null), poll OCR1A/ICR1 registers
|
|
|
|
|
|
* via requestAnimationFrame (less accurate but still functional).
|
2026-03-05 04:27:14 +07:00
|
|
|
|
*/
|
|
|
|
|
|
PartSimulationRegistry.register('servo', {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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));
|
2026-03-23 04:03:17 +07:00
|
|
|
|
}
|
2026-04-22 02:45:45 +07:00
|
|
|
|
}
|
2026-03-23 04:03:17 +07:00
|
|
|
|
}
|
2026-04-22 02:45:45 +07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-04-22 03:14:27 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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
|
2026-04-22 03:14:27 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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);
|
2026-04-22 03:14:27 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
2026-03-21 03:11:12 +07:00
|
|
|
|
}
|
2026-04-22 02:45:45 +07:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-03-05 04:27:14 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-05 04:27:14 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-21 03:11:12 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-05 04:27:14 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
rafId = requestAnimationFrame(poll);
|
|
|
|
|
|
};
|
2026-03-04 23:36:33 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
rafId = requestAnimationFrame(poll);
|
2026-03-04 23:36:33 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
return () => {
|
|
|
|
|
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
2026-03-04 23:36:33 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-05 04:27:14 +07:00
|
|
|
|
// ─── Buzzer ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-03-04 23:36:33 +07:00
|
|
|
|
/**
|
2026-03-05 04:27:14 +07:00
|
|
|
|
* 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).
|
2026-03-04 23:36:33 +07:00
|
|
|
|
*/
|
2026-03-05 04:27:14 +07:00
|
|
|
|
PartSimulationRegistry.register('buzzer', {
|
2026-05-15 23:25:40 +07:00
|
|
|
|
attachEvents: (element, avrSimulator, getArduinoPinHelper, _componentId, getPinResolver) => {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const pinSIG =
|
|
|
|
|
|
getArduinoPinHelper('1') ?? getArduinoPinHelper('+') ?? getArduinoPinHelper('POS');
|
|
|
|
|
|
const pinManager = (avrSimulator as any).pinManager;
|
2026-05-15 23:25:40 +07:00
|
|
|
|
// 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;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
};
|
2026-03-05 04:27:14 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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 {
|
2026-03-05 04:27:14 +07:00
|
|
|
|
stopTone();
|
2026-04-22 02:45:45 +07:00
|
|
|
|
}
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-05-15 23:25:40 +07:00
|
|
|
|
// 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)));
|
|
|
|
|
|
}
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-04-22 02:45:45 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
stopTone();
|
|
|
|
|
|
if (audioCtx) {
|
|
|
|
|
|
audioCtx.close();
|
|
|
|
|
|
audioCtx = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
unsubscribers.forEach((u) => u());
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
2026-03-05 04:27:14 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── LCD 1602 / 2004 ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-03-04 23:36:33 +07:00
|
|
|
|
function createLcdSimulation(cols: number, rows: number) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-04 23:36:33 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
2026-03-04 23:36:33 +07:00
|
|
|
|
}
|
2026-04-22 02:45:45 +07:00
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
2026-03-04 23:36:33 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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());
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
2026-03-04 23:36:33 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
PartSimulationRegistry.register('lcd1602', createLcdSimulation(16, 2));
|
|
|
|
|
|
PartSimulationRegistry.register('lcd2004', createLcdSimulation(20, 4));
|
2026-03-09 12:31:04 +07:00
|
|
|
|
PartSimulationRegistry.register('lcd2002', createLcdSimulation(20, 2));
|
2026-03-05 11:52:15 +07:00
|
|
|
|
|
|
|
|
|
|
// ─── 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
|
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders
The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently
ignored every command except CASET/PASET/RAMWR/SWRESET. The block
comment even bragged about it ("All others are silently accepted —
init sequences, DISPON, MADCTL…").
That's fine for portrait sketches, but every landscape demo —
including the new Pico Doom raycaster — calls tft.setRotation(1) or
setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36
with the MV (row/column exchange) bit set, then issues CASET windows
with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's
bounds check `curX > colEnd` would let curX reach 319, but the
buffer write `id.data[(curY*240 + curX)*4]` would land in a slot
that belongs to a different row — and worse, the SCREEN_W=240
ceiling silently truncated everything past column 239. Net result:
black screen for any rotated sketch.
Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates.
At pixel-write time, remap (curX, curY) → physical (px, py) using the
MV/MX/MY bits, then write into the still-physical 240×320 imageData.
SWRESET resets MADCTL back to portrait defaults (matches the
datasheet's reset semantics).
MADCTL bit Mask Meaning
D7 MY 0x80 row mirror
D6 MX 0x40 column mirror
D5 MV 0x20 swap X/Y (landscape)
Verified by rebuilding (vite OSS+pro). The fix is data-flow only —
no API change, no new dependency. Pico Doom should now actually
render its title screen + raycast frames in /examples on the
raspberry-pi-pico board.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
|
|
|
|
* - 0x36 MADCTL – memory access control (rotation MV / MX / MY bits)
|
2026-03-05 11:52:15 +07:00
|
|
|
|
* - 0x01 SWRESET – clear display
|
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders
The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently
ignored every command except CASET/PASET/RAMWR/SWRESET. The block
comment even bragged about it ("All others are silently accepted —
init sequences, DISPON, MADCTL…").
That's fine for portrait sketches, but every landscape demo —
including the new Pico Doom raycaster — calls tft.setRotation(1) or
setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36
with the MV (row/column exchange) bit set, then issues CASET windows
with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's
bounds check `curX > colEnd` would let curX reach 319, but the
buffer write `id.data[(curY*240 + curX)*4]` would land in a slot
that belongs to a different row — and worse, the SCREEN_W=240
ceiling silently truncated everything past column 239. Net result:
black screen for any rotated sketch.
Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates.
At pixel-write time, remap (curX, curY) → physical (px, py) using the
MV/MX/MY bits, then write into the still-physical 240×320 imageData.
SWRESET resets MADCTL back to portrait defaults (matches the
datasheet's reset semantics).
MADCTL bit Mask Meaning
D7 MY 0x80 row mirror
D6 MX 0x40 column mirror
D5 MV 0x20 swap X/Y (landscape)
Verified by rebuilding (vite OSS+pro). The fix is data-flow only —
no API change, no new dependency. Pico Doom should now actually
render its title screen + raycast frames in /examples on the
raspberry-pi-pico board.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
|
|
|
|
* - 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.
|
2026-03-05 11:52:15 +07:00
|
|
|
|
*
|
|
|
|
|
|
* DC/RS pin: LOW = command byte, HIGH = data bytes.
|
|
|
|
|
|
*/
|
2026-03-09 12:31:04 +07:00
|
|
|
|
const ili9341Simulation = {
|
refactor(spi): unify SPI bus interface across all simulators
Previous fix added an ESP32-specific code path inside ili9341Simulation
to subscribe to the QEMU worker's spi_event stream. That made the LCD
work on ESP32-CAM but left the underlying issue unsolved: every other
SPI part (custom chips, future SD-card emulators, the SSD168x ePaper
already in the codebase) would also need its own per-board branching.
The right shape: every simulator exposes a `.spi` member matching the
SAME SpiBusLike interface, and SPI parts hook .spi.onByte without
caring which board they're attached to. AVRSimulator already had
this — now everything else does too.
frontend/src/simulation/SpiBus.ts (new)
Defines the contract — `onByte: (mosi) => void | null` plus
optional `completeTransfer(miso)`. Documents the single-listener
semantics that AVR has had since day one.
frontend/src/store/useSimulatorStore.ts
Esp32BridgeShim gets a lazy `.spi` getter that wraps
bridge.onSpiByte (the per-byte WS event from the QEMU worker).
completeTransfer is a no-op because the worker drives MISO via
its own _spi_response global. Covers ESP32 (Xtensa), ESP32-S3,
ESP32-CAM, ESP32-C3 — every kind that routes through Esp32Bridge.
frontend/src/simulation/RP2040Simulator.ts
Adds a lazy `.spi` getter that re-routes rp2040.spi[0].onTransmit
through the adapter. Default loopback (the prior behaviour) is
preserved when no part has accessed `.spi` yet — only consumers
that opt in see their handler invoked. Covers Pico and Pico W.
frontend/src/simulation/parts/ComplexParts.ts
ili9341Simulation no longer has an ESP32 special case. Single
code path: `simulator.spi.onByte = handler`. Works on AVR,
RP2040, all ESP32 variants. Same pattern is now available to
every future SPI part — ssd1306, sd-card, oled, etc.
The Esp32Bridge.ts spi_event field-name fix from 6afa62e (msg.data.event
instead of the non-existent msg.data.data) stays in place — that's what
makes the per-byte stream actually arrive in the bridge.
Verified: ILI9341 + ESP32-CAM gallery example renders the live webcam
preview after a hard refresh. The same simulation code works on Arduino
Uno + ILI9341 (the existing ili9341-test-sketch in example_zip).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:36:29 +07:00
|
|
|
|
attachEvents: (element, simulator, getArduinoPinHelper) => {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const el = element as any;
|
refactor(spi): unify SPI bus interface across all simulators
Previous fix added an ESP32-specific code path inside ili9341Simulation
to subscribe to the QEMU worker's spi_event stream. That made the LCD
work on ESP32-CAM but left the underlying issue unsolved: every other
SPI part (custom chips, future SD-card emulators, the SSD168x ePaper
already in the codebase) would also need its own per-board branching.
The right shape: every simulator exposes a `.spi` member matching the
SAME SpiBusLike interface, and SPI parts hook .spi.onByte without
caring which board they're attached to. AVRSimulator already had
this — now everything else does too.
frontend/src/simulation/SpiBus.ts (new)
Defines the contract — `onByte: (mosi) => void | null` plus
optional `completeTransfer(miso)`. Documents the single-listener
semantics that AVR has had since day one.
frontend/src/store/useSimulatorStore.ts
Esp32BridgeShim gets a lazy `.spi` getter that wraps
bridge.onSpiByte (the per-byte WS event from the QEMU worker).
completeTransfer is a no-op because the worker drives MISO via
its own _spi_response global. Covers ESP32 (Xtensa), ESP32-S3,
ESP32-CAM, ESP32-C3 — every kind that routes through Esp32Bridge.
frontend/src/simulation/RP2040Simulator.ts
Adds a lazy `.spi` getter that re-routes rp2040.spi[0].onTransmit
through the adapter. Default loopback (the prior behaviour) is
preserved when no part has accessed `.spi` yet — only consumers
that opt in see their handler invoked. Covers Pico and Pico W.
frontend/src/simulation/parts/ComplexParts.ts
ili9341Simulation no longer has an ESP32 special case. Single
code path: `simulator.spi.onByte = handler`. Works on AVR,
RP2040, all ESP32 variants. Same pattern is now available to
every future SPI part — ssd1306, sd-card, oled, etc.
The Esp32Bridge.ts spi_event field-name fix from 6afa62e (msg.data.event
instead of the non-existent msg.data.data) stays in place — that's what
makes the per-byte stream actually arrive in the bridge.
Verified: ILI9341 + ESP32-CAM gallery example renders the live webcam
preview after a hard refresh. The same simulation code works on Arduino
Uno + ILI9341 (the existing ili9341-test-sketch in example_zip).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:36:29 +07:00
|
|
|
|
const pinManager = (simulator as any).pinManager;
|
|
|
|
|
|
// Generic .spi accessor — every simulator (AVR, RP2040, ESP32 family)
|
|
|
|
|
|
// exposes a SpiBusLike object via this name (see frontend/src/simulation/
|
|
|
|
|
|
// SpiBus.ts). Single-listener channel: assign to spi.onByte and
|
|
|
|
|
|
// chain any prior handler in our cleanup.
|
|
|
|
|
|
const spi = (simulator as any).spi as
|
|
|
|
|
|
| { onByte: ((mosi: number) => void) | null;
|
|
|
|
|
|
completeTransfer?: (miso: number) => void }
|
|
|
|
|
|
| undefined;
|
|
|
|
|
|
|
|
|
|
|
|
if (!pinManager || !spi) return () => {};
|
2026-04-22 02:45:45 +07:00
|
|
|
|
|
|
|
|
|
|
// ── 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');
|
|
|
|
|
|
};
|
2026-03-05 11:52:15 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
let ctx = initCanvas();
|
2026-03-05 11:52:15 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const onCanvasReady = () => {
|
|
|
|
|
|
ctx = initCanvas();
|
|
|
|
|
|
};
|
|
|
|
|
|
el.addEventListener('canvas-ready', onCanvasReady);
|
2026-03-05 11:52:15 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
// ── Shared ImageData buffer ───────────────────────────────────────
|
|
|
|
|
|
// Accumulate pixels here; flush to canvas once per animation frame.
|
|
|
|
|
|
let imageData: ImageData | null = null;
|
2026-03-05 11:52:15 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const getOrCreateImageData = (): ImageData => {
|
|
|
|
|
|
if (!ctx) ctx = initCanvas();
|
|
|
|
|
|
if (!imageData && ctx) imageData = ctx.createImageData(SCREEN_W, SCREEN_H);
|
|
|
|
|
|
return imageData!;
|
|
|
|
|
|
};
|
2026-03-05 11:52:15 +07:00
|
|
|
|
|
2026-05-16 10:48:15 +07:00
|
|
|
|
// 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.
|
2026-04-22 02:45:45 +07:00
|
|
|
|
let pendingFlush = false;
|
2026-05-16 10:48:15 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-03-05 11:52:15 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const scheduleFlush = () => {
|
2026-05-16 10:48:15 +07:00
|
|
|
|
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);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
};
|
2026-03-05 11:52:15 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
// ── 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;
|
|
|
|
|
|
|
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders
The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently
ignored every command except CASET/PASET/RAMWR/SWRESET. The block
comment even bragged about it ("All others are silently accepted —
init sequences, DISPON, MADCTL…").
That's fine for portrait sketches, but every landscape demo —
including the new Pico Doom raycaster — calls tft.setRotation(1) or
setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36
with the MV (row/column exchange) bit set, then issues CASET windows
with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's
bounds check `curX > colEnd` would let curX reach 319, but the
buffer write `id.data[(curY*240 + curX)*4]` would land in a slot
that belongs to a different row — and worse, the SCREEN_W=240
ceiling silently truncated everything past column 239. Net result:
black screen for any rotated sketch.
Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates.
At pixel-write time, remap (curX, curY) → physical (px, py) using the
MV/MX/MY bits, then write into the still-physical 240×320 imageData.
SWRESET resets MADCTL back to portrait defaults (matches the
datasheet's reset semantics).
MADCTL bit Mask Meaning
D7 MY 0x80 row mirror
D6 MX 0x40 column mirror
D5 MV 0x20 swap X/Y (landscape)
Verified by rebuilding (vite OSS+pro). The fix is data-flow only —
no API change, no new dependency. Pico Doom should now actually
render its title screen + raycast frames in /examples on the
raspberry-pi-pico board.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
|
|
|
|
// ── 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
|
|
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
// ── 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 ──────────────────────────────────────────────────
|
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders
The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently
ignored every command except CASET/PASET/RAMWR/SWRESET. The block
comment even bragged about it ("All others are silently accepted —
init sequences, DISPON, MADCTL…").
That's fine for portrait sketches, but every landscape demo —
including the new Pico Doom raycaster — calls tft.setRotation(1) or
setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36
with the MV (row/column exchange) bit set, then issues CASET windows
with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's
bounds check `curX > colEnd` would let curX reach 319, but the
buffer write `id.data[(curY*240 + curX)*4]` would land in a slot
that belongs to a different row — and worse, the SCREEN_W=240
ceiling silently truncated everything past column 239. Net result:
black screen for any rotated sketch.
Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates.
At pixel-write time, remap (curX, curY) → physical (px, py) using the
MV/MX/MY bits, then write into the still-physical 240×320 imageData.
SWRESET resets MADCTL back to portrait defaults (matches the
datasheet's reset semantics).
MADCTL bit Mask Meaning
D7 MY 0x80 row mirror
D6 MX 0x40 column mirror
D5 MV 0x20 swap X/Y (landscape)
Verified by rebuilding (vite OSS+pro). The fix is data-flow only —
no API change, no new dependency. Pico Doom should now actually
render its title screen + raycast frames in /examples on the
raspberry-pi-pico board.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
|
|
|
|
// 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).
|
2026-05-15 11:32:16 +07:00
|
|
|
|
//
|
|
|
|
|
|
// 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.
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const writePixel = (hi: number, lo: number) => {
|
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders
The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently
ignored every command except CASET/PASET/RAMWR/SWRESET. The block
comment even bragged about it ("All others are silently accepted —
init sequences, DISPON, MADCTL…").
That's fine for portrait sketches, but every landscape demo —
including the new Pico Doom raycaster — calls tft.setRotation(1) or
setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36
with the MV (row/column exchange) bit set, then issues CASET windows
with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's
bounds check `curX > colEnd` would let curX reach 319, but the
buffer write `id.data[(curY*240 + curX)*4]` would land in a slot
that belongs to a different row — and worse, the SCREEN_W=240
ceiling silently truncated everything past column 239. Net result:
black screen for any rotated sketch.
Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates.
At pixel-write time, remap (curX, curY) → physical (px, py) using the
MV/MX/MY bits, then write into the still-physical 240×320 imageData.
SWRESET resets MADCTL back to portrait defaults (matches the
datasheet's reset semantics).
MADCTL bit Mask Meaning
D7 MY 0x80 row mirror
D6 MX 0x40 column mirror
D5 MV 0x20 swap X/Y (landscape)
Verified by rebuilding (vite OSS+pro). The fix is data-flow only —
no API change, no new dependency. Pico Doom should now actually
render its title screen + raycast frames in /examples on the
raspberry-pi-pico board.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
|
|
|
|
if (curX > colEnd || curY > rowEnd) return;
|
|
|
|
|
|
|
2026-05-15 11:32:16 +07:00
|
|
|
|
// 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)
|
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders
The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently
ignored every command except CASET/PASET/RAMWR/SWRESET. The block
comment even bragged about it ("All others are silently accepted —
init sequences, DISPON, MADCTL…").
That's fine for portrait sketches, but every landscape demo —
including the new Pico Doom raycaster — calls tft.setRotation(1) or
setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36
with the MV (row/column exchange) bit set, then issues CASET windows
with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's
bounds check `curX > colEnd` would let curX reach 319, but the
buffer write `id.data[(curY*240 + curX)*4]` would land in a slot
that belongs to a different row — and worse, the SCREEN_W=240
ceiling silently truncated everything past column 239. Net result:
black screen for any rotated sketch.
Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates.
At pixel-write time, remap (curX, curY) → physical (px, py) using the
MV/MX/MY bits, then write into the still-physical 240×320 imageData.
SWRESET resets MADCTL back to portrait defaults (matches the
datasheet's reset semantics).
MADCTL bit Mask Meaning
D7 MY 0x80 row mirror
D6 MX 0x40 column mirror
D5 MV 0x20 swap X/Y (landscape)
Verified by rebuilding (vite OSS+pro). The fix is data-flow only —
no API change, no new dependency. Pico Doom should now actually
render its title screen + raycast frames in /examples on the
raspberry-pi-pico board.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
|
|
|
|
physX = curY;
|
2026-05-15 11:32:16 +07:00
|
|
|
|
physY = (SCREEN_H - 1) - curX;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Landscape rotation 3: m = MX | MY | MV | BGR. (239 - curY, curX)
|
|
|
|
|
|
physX = (SCREEN_W - 1) - curY;
|
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders
The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently
ignored every command except CASET/PASET/RAMWR/SWRESET. The block
comment even bragged about it ("All others are silently accepted —
init sequences, DISPON, MADCTL…").
That's fine for portrait sketches, but every landscape demo —
including the new Pico Doom raycaster — calls tft.setRotation(1) or
setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36
with the MV (row/column exchange) bit set, then issues CASET windows
with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's
bounds check `curX > colEnd` would let curX reach 319, but the
buffer write `id.data[(curY*240 + curX)*4]` would land in a slot
that belongs to a different row — and worse, the SCREEN_W=240
ceiling silently truncated everything past column 239. Net result:
black screen for any rotated sketch.
Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates.
At pixel-write time, remap (curX, curY) → physical (px, py) using the
MV/MX/MY bits, then write into the still-physical 240×320 imageData.
SWRESET resets MADCTL back to portrait defaults (matches the
datasheet's reset semantics).
MADCTL bit Mask Meaning
D7 MY 0x80 row mirror
D6 MX 0x40 column mirror
D5 MV 0x20 swap X/Y (landscape)
Verified by rebuilding (vite OSS+pro). The fix is data-flow only —
no API change, no new dependency. Pico Doom should now actually
render its title screen + raycast frames in /examples on the
raspberry-pi-pico board.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
|
|
|
|
physY = curX;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (physX < 0 || physX >= SCREEN_W || physY < 0 || physY >= SCREEN_H) {
|
|
|
|
|
|
curX++;
|
|
|
|
|
|
if (curX > colEnd) {
|
|
|
|
|
|
curX = colStart;
|
|
|
|
|
|
curY++;
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-22 02:45:45 +07:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders
The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently
ignored every command except CASET/PASET/RAMWR/SWRESET. The block
comment even bragged about it ("All others are silently accepted —
init sequences, DISPON, MADCTL…").
That's fine for portrait sketches, but every landscape demo —
including the new Pico Doom raycaster — calls tft.setRotation(1) or
setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36
with the MV (row/column exchange) bit set, then issues CASET windows
with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's
bounds check `curX > colEnd` would let curX reach 319, but the
buffer write `id.data[(curY*240 + curX)*4]` would land in a slot
that belongs to a different row — and worse, the SCREEN_W=240
ceiling silently truncated everything past column 239. Net result:
black screen for any rotated sketch.
Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates.
At pixel-write time, remap (curX, curY) → physical (px, py) using the
MV/MX/MY bits, then write into the still-physical 240×320 imageData.
SWRESET resets MADCTL back to portrait defaults (matches the
datasheet's reset semantics).
MADCTL bit Mask Meaning
D7 MY 0x80 row mirror
D6 MX 0x40 column mirror
D5 MV 0x20 swap X/Y (landscape)
Verified by rebuilding (vite OSS+pro). The fix is data-flow only —
no API change, no new dependency. Pico Doom should now actually
render its title screen + raycast frames in /examples on the
raspberry-pi-pico board.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
|
|
|
|
const idx = (physY * SCREEN_W + physX) * 4;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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++;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-03-05 11:52:15 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
// ── Command / data processing ─────────────────────────────────────
|
|
|
|
|
|
const processCommand = (cmd: number) => {
|
|
|
|
|
|
currentCmd = cmd;
|
|
|
|
|
|
dataBytes = [];
|
|
|
|
|
|
inRamWrite = cmd === 0x2c;
|
|
|
|
|
|
pixelByteCount = 0;
|
|
|
|
|
|
|
|
|
|
|
|
if (cmd === 0x01) {
|
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders
The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently
ignored every command except CASET/PASET/RAMWR/SWRESET. The block
comment even bragged about it ("All others are silently accepted —
init sequences, DISPON, MADCTL…").
That's fine for portrait sketches, but every landscape demo —
including the new Pico Doom raycaster — calls tft.setRotation(1) or
setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36
with the MV (row/column exchange) bit set, then issues CASET windows
with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's
bounds check `curX > colEnd` would let curX reach 319, but the
buffer write `id.data[(curY*240 + curX)*4]` would land in a slot
that belongs to a different row — and worse, the SCREEN_W=240
ceiling silently truncated everything past column 239. Net result:
black screen for any rotated sketch.
Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates.
At pixel-write time, remap (curX, curY) → physical (px, py) using the
MV/MX/MY bits, then write into the still-physical 240×320 imageData.
SWRESET resets MADCTL back to portrait defaults (matches the
datasheet's reset semantics).
MADCTL bit Mask Meaning
D7 MY 0x80 row mirror
D6 MX 0x40 column mirror
D5 MV 0x20 swap X/Y (landscape)
Verified by rebuilding (vite OSS+pro). The fix is data-flow only —
no API change, no new dependency. Pico Doom should now actually
render its title screen + raycast frames in /examples on the
raspberry-pi-pico board.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
|
|
|
|
// SWRESET – clear framebuffer + reset MADCTL to defaults
|
2026-04-22 02:45:45 +07:00
|
|
|
|
colStart = 0;
|
|
|
|
|
|
colEnd = SCREEN_W - 1;
|
|
|
|
|
|
rowStart = 0;
|
|
|
|
|
|
rowEnd = SCREEN_H - 1;
|
|
|
|
|
|
curX = 0;
|
|
|
|
|
|
curY = 0;
|
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders
The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently
ignored every command except CASET/PASET/RAMWR/SWRESET. The block
comment even bragged about it ("All others are silently accepted —
init sequences, DISPON, MADCTL…").
That's fine for portrait sketches, but every landscape demo —
including the new Pico Doom raycaster — calls tft.setRotation(1) or
setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36
with the MV (row/column exchange) bit set, then issues CASET windows
with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's
bounds check `curX > colEnd` would let curX reach 319, but the
buffer write `id.data[(curY*240 + curX)*4]` would land in a slot
that belongs to a different row — and worse, the SCREEN_W=240
ceiling silently truncated everything past column 239. Net result:
black screen for any rotated sketch.
Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates.
At pixel-write time, remap (curX, curY) → physical (px, py) using the
MV/MX/MY bits, then write into the still-physical 240×320 imageData.
SWRESET resets MADCTL back to portrait defaults (matches the
datasheet's reset semantics).
MADCTL bit Mask Meaning
D7 MY 0x80 row mirror
D6 MX 0x40 column mirror
D5 MV 0x20 swap X/Y (landscape)
Verified by rebuilding (vite OSS+pro). The fix is data-flow only —
no API change, no new dependency. Pico Doom should now actually
render its title screen + raycast frames in /examples on the
raspberry-pi-pico board.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
|
|
|
|
madMV = false;
|
|
|
|
|
|
madMX = false;
|
|
|
|
|
|
madMY = false;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
imageData = null;
|
|
|
|
|
|
if (ctx) ctx.clearRect(0, 0, SCREEN_W, SCREEN_H);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-03-05 11:52:15 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
fix(ili9341): handle MADCTL so landscape (setRotation 1/3) renders
The ILI9341 emulator hardcoded SCREEN_W=240 SCREEN_H=320 and silently
ignored every command except CASET/PASET/RAMWR/SWRESET. The block
comment even bragged about it ("All others are silently accepted —
init sequences, DISPON, MADCTL…").
That's fine for portrait sketches, but every landscape demo —
including the new Pico Doom raycaster — calls tft.setRotation(1) or
setRotation(3). Adafruit_ILI9341 translates those into MADCTL 0x36
with the MV (row/column exchange) bit set, then issues CASET windows
with X∈[0..319] and PASET windows with Y∈[0..239]. The emulator's
bounds check `curX > colEnd` would let curX reach 319, but the
buffer write `id.data[(curY*240 + curX)*4]` would land in a slot
that belongs to a different row — and worse, the SCREEN_W=240
ceiling silently truncated everything past column 239. Net result:
black screen for any rotated sketch.
Fix: parse MADCTL (0x36) and treat CASET/PASET as LOGICAL coordinates.
At pixel-write time, remap (curX, curY) → physical (px, py) using the
MV/MX/MY bits, then write into the still-physical 240×320 imageData.
SWRESET resets MADCTL back to portrait defaults (matches the
datasheet's reset semantics).
MADCTL bit Mask Meaning
D7 MY 0x80 row mirror
D6 MX 0x40 column mirror
D5 MV 0x20 swap X/Y (landscape)
Verified by rebuilding (vite OSS+pro). The fix is data-flow only —
no API change, no new dependency. Pico Doom should now actually
render its title screen + raycast frames in /examples on the
raspberry-pi-pico board.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:03 +07:00
|
|
|
|
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
|
2026-04-22 02:45:45 +07:00
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-03-05 11:52:15 +07:00
|
|
|
|
|
refactor(spi): unify SPI bus interface across all simulators
Previous fix added an ESP32-specific code path inside ili9341Simulation
to subscribe to the QEMU worker's spi_event stream. That made the LCD
work on ESP32-CAM but left the underlying issue unsolved: every other
SPI part (custom chips, future SD-card emulators, the SSD168x ePaper
already in the codebase) would also need its own per-board branching.
The right shape: every simulator exposes a `.spi` member matching the
SAME SpiBusLike interface, and SPI parts hook .spi.onByte without
caring which board they're attached to. AVRSimulator already had
this — now everything else does too.
frontend/src/simulation/SpiBus.ts (new)
Defines the contract — `onByte: (mosi) => void | null` plus
optional `completeTransfer(miso)`. Documents the single-listener
semantics that AVR has had since day one.
frontend/src/store/useSimulatorStore.ts
Esp32BridgeShim gets a lazy `.spi` getter that wraps
bridge.onSpiByte (the per-byte WS event from the QEMU worker).
completeTransfer is a no-op because the worker drives MISO via
its own _spi_response global. Covers ESP32 (Xtensa), ESP32-S3,
ESP32-CAM, ESP32-C3 — every kind that routes through Esp32Bridge.
frontend/src/simulation/RP2040Simulator.ts
Adds a lazy `.spi` getter that re-routes rp2040.spi[0].onTransmit
through the adapter. Default loopback (the prior behaviour) is
preserved when no part has accessed `.spi` yet — only consumers
that opt in see their handler invoked. Covers Pico and Pico W.
frontend/src/simulation/parts/ComplexParts.ts
ili9341Simulation no longer has an ESP32 special case. Single
code path: `simulator.spi.onByte = handler`. Works on AVR,
RP2040, all ESP32 variants. Same pattern is now available to
every future SPI part — ssd1306, sd-card, oled, etc.
The Esp32Bridge.ts spi_event field-name fix from 6afa62e (msg.data.event
instead of the non-existent msg.data.data) stays in place — that's what
makes the per-byte stream actually arrive in the bridge.
Verified: ILI9341 + ESP32-CAM gallery example renders the live webcam
preview after a hard refresh. The same simulation code works on Arduino
Uno + ILI9341 (the existing ili9341-test-sketch in example_zip).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:36:29 +07:00
|
|
|
|
// ── Intercept SPI (board-agnostic) ────────────────────────────────
|
|
|
|
|
|
// Single hook regardless of board kind: every simulator's `.spi`
|
|
|
|
|
|
// exposes the same shape — settable onByte handler + optional
|
|
|
|
|
|
// completeTransfer to drive MISO. AVR and RP2040 actually use
|
|
|
|
|
|
// completeTransfer; ESP32 ignores it (worker drives MISO via
|
|
|
|
|
|
// its own _spi_response global).
|
|
|
|
|
|
const prevOnByte = spi.onByte;
|
|
|
|
|
|
spi.onByte = (value: number) => {
|
|
|
|
|
|
if (!dcState) processCommand(value);
|
|
|
|
|
|
else processData(value);
|
|
|
|
|
|
// Idle-byte response — the typical ILI9341 driver writes only,
|
|
|
|
|
|
// so any value works. 0xff matches what the prior AVR path
|
|
|
|
|
|
// returned to keep behaviour stable.
|
|
|
|
|
|
spi.completeTransfer?.(0xff);
|
|
|
|
|
|
};
|
2026-03-05 11:52:15 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
// ── Cleanup ───────────────────────────────────────────────────────
|
|
|
|
|
|
return () => {
|
refactor(spi): unify SPI bus interface across all simulators
Previous fix added an ESP32-specific code path inside ili9341Simulation
to subscribe to the QEMU worker's spi_event stream. That made the LCD
work on ESP32-CAM but left the underlying issue unsolved: every other
SPI part (custom chips, future SD-card emulators, the SSD168x ePaper
already in the codebase) would also need its own per-board branching.
The right shape: every simulator exposes a `.spi` member matching the
SAME SpiBusLike interface, and SPI parts hook .spi.onByte without
caring which board they're attached to. AVRSimulator already had
this — now everything else does too.
frontend/src/simulation/SpiBus.ts (new)
Defines the contract — `onByte: (mosi) => void | null` plus
optional `completeTransfer(miso)`. Documents the single-listener
semantics that AVR has had since day one.
frontend/src/store/useSimulatorStore.ts
Esp32BridgeShim gets a lazy `.spi` getter that wraps
bridge.onSpiByte (the per-byte WS event from the QEMU worker).
completeTransfer is a no-op because the worker drives MISO via
its own _spi_response global. Covers ESP32 (Xtensa), ESP32-S3,
ESP32-CAM, ESP32-C3 — every kind that routes through Esp32Bridge.
frontend/src/simulation/RP2040Simulator.ts
Adds a lazy `.spi` getter that re-routes rp2040.spi[0].onTransmit
through the adapter. Default loopback (the prior behaviour) is
preserved when no part has accessed `.spi` yet — only consumers
that opt in see their handler invoked. Covers Pico and Pico W.
frontend/src/simulation/parts/ComplexParts.ts
ili9341Simulation no longer has an ESP32 special case. Single
code path: `simulator.spi.onByte = handler`. Works on AVR,
RP2040, all ESP32 variants. Same pattern is now available to
every future SPI part — ssd1306, sd-card, oled, etc.
The Esp32Bridge.ts spi_event field-name fix from 6afa62e (msg.data.event
instead of the non-existent msg.data.data) stays in place — that's what
makes the per-byte stream actually arrive in the bridge.
Verified: ILI9341 + ESP32-CAM gallery example renders the live webcam
preview after a hard refresh. The same simulation code works on Arduino
Uno + ILI9341 (the existing ili9341-test-sketch in example_zip).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:36:29 +07:00
|
|
|
|
spi.onByte = prevOnByte;
|
2026-05-16 10:48:15 +07:00
|
|
|
|
if (idleTimerId !== null) clearTimeout(idleTimerId);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
el.removeEventListener('canvas-ready', onCanvasReady);
|
|
|
|
|
|
unsubscribers.forEach((u) => u());
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
2026-03-09 12:31:04 +07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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);
|