+ {/* ESP32 crash notification */}
+ {esp32CrashBoardId && (
+
diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts
index 8c3335d..4e4541e 100644
--- a/frontend/src/simulation/Esp32Bridge.ts
+++ b/frontend/src/simulation/Esp32Bridge.ts
@@ -9,12 +9,20 @@
* { type: 'start_esp32', data: { board: BoardKind, firmware_b64?: string } }
* { type: 'stop_esp32' }
* { type: 'load_firmware', data: { firmware_b64: string } }
- * { type: 'esp32_serial_input', data: { bytes: number[] } }
+ * { type: 'esp32_serial_input', data: { bytes: number[], uart?: number } }
* { type: 'esp32_gpio_in', data: { pin: number, state: 0 | 1 } }
+ * { type: 'esp32_adc_set', data: { channel: number, millivolts: number } }
+ * { type: 'esp32_i2c_response', data: { addr: number, response: number } }
+ * { type: 'esp32_spi_response', data: { response: number } }
*
* Backend → Frontend
- * { type: 'serial_output', data: { data: string } }
+ * { type: 'serial_output', data: { data: string, uart?: number } }
* { type: 'gpio_change', data: { pin: number, state: 0 | 1 } }
+ * { type: 'gpio_dir', data: { pin: number, dir: 0 | 1 } }
+ * { type: 'ledc_update', data: { channel: number, duty: number, duty_pct: number } }
+ * { type: 'ws2812_update', data: { channel: number, pixels: [number, number, number][] } }
+ * { type: 'i2c_event', data: { addr: number, data: number } }
+ * { type: 'spi_event', data: { data: number } }
* { type: 'system', data: { event: string, ... } }
* { type: 'error', data: { message: string } }
*/
@@ -24,17 +32,26 @@ import type { BoardKind } from '../types/board';
const API_BASE = (): string =>
(import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8001/api';
+export interface Ws2812Pixel { r: number; g: number; b: number }
+export interface LedcUpdate { channel: number; duty: number; duty_pct: number }
+
export class Esp32Bridge {
readonly boardId: string;
readonly boardKind: BoardKind;
// Callbacks wired up by useSimulatorStore
- onSerialData: ((char: string) => void) | null = null;
- onPinChange: ((gpioPin: number, state: boolean) => void) | null = null;
- onConnected: (() => void) | null = null;
- onDisconnected: (() => void) | null = null;
- onError: ((msg: string) => void) | null = null;
- onSystemEvent: ((event: string, data: Record) => void) | null = null;
+ onSerialData: ((char: string, uart?: number) => void) | null = null;
+ onPinChange: ((gpioPin: number, state: boolean) => void) | null = null;
+ onPinDir: ((gpioPin: number, dir: 0 | 1) => void) | null = null;
+ onLedcUpdate: ((update: LedcUpdate) => void) | null = null;
+ onWs2812Update: ((channel: number, pixels: Ws2812Pixel[]) => void) | null = null;
+ onI2cEvent: ((addr: number, data: number) => void) | null = null;
+ onSpiEvent: ((data: number) => void) | null = null;
+ onConnected: (() => void) | null = null;
+ onDisconnected: (() => void) | null = null;
+ onError: ((msg: string) => void) | null = null;
+ onSystemEvent: ((event: string, data: Record) => void) | null = null;
+ onCrash: ((data: Record) => void) | null = null;
private socket: WebSocket | null = null;
private _connected = false;
@@ -63,7 +80,6 @@ export class Esp32Bridge {
socket.onopen = () => {
this._connected = true;
this.onConnected?.();
- // Boot the ESP32 via QEMU, optionally with pre-loaded firmware
this._send({
type: 'start_esp32',
data: {
@@ -84,8 +100,9 @@ export class Esp32Bridge {
switch (msg.type) {
case 'serial_output': {
const text = (msg.data.data as string) ?? '';
+ const uart = msg.data.uart as number | undefined;
if (this.onSerialData) {
- for (const ch of text) this.onSerialData(ch);
+ for (const ch of text) this.onSerialData(ch, uart);
}
break;
}
@@ -95,9 +112,42 @@ export class Esp32Bridge {
this.onPinChange?.(pin, state);
break;
}
- case 'system':
- this.onSystemEvent?.(msg.data.event as string, msg.data);
+ case 'gpio_dir': {
+ const pin = msg.data.pin as number;
+ const dir = msg.data.dir as 0 | 1;
+ this.onPinDir?.(pin, dir);
break;
+ }
+ case 'ledc_update': {
+ this.onLedcUpdate?.(msg.data as unknown as LedcUpdate);
+ break;
+ }
+ case 'ws2812_update': {
+ const channel = msg.data.channel as number;
+ const raw = msg.data.pixels as [number, number, number][];
+ const pixels: Ws2812Pixel[] = raw.map(([r, g, b]) => ({ r, g, b }));
+ this.onWs2812Update?.(channel, pixels);
+ break;
+ }
+ case 'i2c_event': {
+ const addr = msg.data.addr as number;
+ const data = msg.data.data as number;
+ this.onI2cEvent?.(addr, data);
+ break;
+ }
+ case 'spi_event': {
+ const data = msg.data.data as number;
+ this.onSpiEvent?.(data);
+ break;
+ }
+ case 'system': {
+ const evt = msg.data.event as string;
+ if (evt === 'crash') {
+ this.onCrash?.(msg.data);
+ }
+ this.onSystemEvent?.(evt, msg.data);
+ break;
+ }
case 'error':
this.onError?.(msg.data.message as string);
break;
@@ -135,15 +185,15 @@ export class Esp32Bridge {
}
}
- /** Send a byte to the ESP32 UART0 */
- sendSerialByte(byte: number): void {
- this._send({ type: 'esp32_serial_input', data: { bytes: [byte] } });
+ /** Send a byte to the ESP32 UART0 (or UART1/2) */
+ sendSerialByte(byte: number, uart = 0): void {
+ this._send({ type: 'esp32_serial_input', data: { bytes: [byte], uart } });
}
/** Send multiple bytes at once */
- sendSerialBytes(bytes: number[]): void {
+ sendSerialBytes(bytes: number[], uart = 0): void {
if (bytes.length === 0) return;
- this._send({ type: 'esp32_serial_input', data: { bytes } });
+ this._send({ type: 'esp32_serial_input', data: { bytes, uart } });
}
/** Drive a GPIO pin from an external source (e.g. connected Arduino) */
@@ -151,6 +201,21 @@ export class Esp32Bridge {
this._send({ type: 'esp32_gpio_in', data: { pin: gpioPin, state: state ? 1 : 0 } });
}
+ /** Set an ADC channel voltage (millivolts, 0–3300) */
+ setAdc(channel: number, millivolts: number): void {
+ this._send({ type: 'esp32_adc_set', data: { channel, millivolts } });
+ }
+
+ /** Configure the byte an I2C device at addr returns */
+ setI2cResponse(addr: number, response: number): void {
+ this._send({ type: 'esp32_i2c_response', data: { addr, response } });
+ }
+
+ /** Configure the MISO byte returned during an SPI transaction */
+ setSpiResponse(response: number): void {
+ this._send({ type: 'esp32_spi_response', data: { response } });
+ }
+
private _send(payload: unknown): void {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(payload));
diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts
index 1da99ec..72d693f 100644
--- a/frontend/src/store/useSimulatorStore.ts
+++ b/frontend/src/store/useSimulatorStore.ts
@@ -105,6 +105,10 @@ interface SimulatorState {
disconnectRemoteSimulator: () => void;
sendRemotePinEvent: (pin: string, state: number) => void;
+ // ── ESP32 crash notification ─────────────────────────────────────────────
+ esp32CrashBoardId: string | null;
+ dismissEsp32Crash: () => void;
+
// ── Components ──────────────────────────────────────────────────────────
components: Component[];
addComponent: (component: Component) => void;
@@ -251,8 +255,31 @@ export const useSimulatorStore = create((set, get) => {
} else if (isEsp32Kind(boardKind)) {
const bridge = new Esp32Bridge(id, boardKind);
bridge.onSerialData = serialCallback;
- bridge.onPinChange = (_gpioPin, _state) => {
- // Cross-board routing handled in SimulatorCanvas
+ bridge.onPinChange = (gpioPin, state) => {
+ const boardPm = pinManagerMap.get(id);
+ if (boardPm) boardPm.triggerPinChange(gpioPin, state);
+ };
+ bridge.onCrash = () => {
+ set({ esp32CrashBoardId: id });
+ };
+ bridge.onLedcUpdate = (update) => {
+ // Route LEDC duty cycles to PinManager as PWM.
+ // LEDC channel N drives a GPIO; the mapping is firmware-defined.
+ const boardPm = pinManagerMap.get(id);
+ if (boardPm && typeof boardPm.updatePwm === 'function') {
+ boardPm.updatePwm(update.channel, update.duty_pct);
+ }
+ };
+ bridge.onWs2812Update = (channel, pixels) => {
+ // Forward WS2812 pixel data to any DOM element with id=`ws2812-{id}-{channel}`
+ // (set by NeoPixel components rendered in SimulatorCanvas).
+ // We fire a custom event that NeoPixel components can listen to.
+ const eventTarget = document.getElementById(`ws2812-${id}-${channel}`);
+ if (eventTarget) {
+ eventTarget.dispatchEvent(
+ new CustomEvent('ws2812-pixels', { detail: { pixels } })
+ );
+ }
};
esp32BridgeMap.set(id, bridge);
} else {
@@ -487,6 +514,9 @@ export const useSimulatorStore = create((set, get) => {
remoteConnected: false,
remoteSocket: null,
+ esp32CrashBoardId: null,
+ dismissEsp32Crash: () => set({ esp32CrashBoardId: null }),
+
setBoardType: (type: BoardType) => {
const { activeBoardId, running, stopSimulation } = get();
if (running) stopSimulation();
diff --git a/test_dll_minimal.py b/test_dll_minimal.py
new file mode 100644
index 0000000..d517893
--- /dev/null
+++ b/test_dll_minimal.py
@@ -0,0 +1,78 @@
+"""Minimal test: start lcgamboa QEMU DLL with blink firmware and collect output."""
+import ctypes, os, sys, threading, time, pathlib
+
+MINGW = r"C:\msys64\mingw64\bin"
+DLL = r"E:\Hardware\wokwi_clon\backend\app\services\libqemu-xtensa.dll"
+FW = r"E:\Hardware\wokwi_clon\test\esp32-emulator\binaries\esp32_blink.ino.merged.bin"
+
+sys.path.insert(0, r"E:\Hardware\wokwi_clon\backend")
+from app.services.esp32_lib_bridge import (
+ _WRITE_PIN, _DIR_PIN, _I2C_EVENT, _SPI_EVENT, _UART_TX, _RMT_EVENT,
+ _CallbacksT, _PINMAP,
+)
+
+print("=== Loading DLL ===")
+os.add_dll_directory(MINGW)
+lib = ctypes.CDLL(DLL)
+print(" OK")
+
+uart_buf = bytearray()
+gpio_events = []
+
+def on_write_pin(pin, value):
+ gpio_events.append((pin, value))
+ print(f" [GPIO] pin={pin} value={value}")
+
+def on_dir_pin(pin, dir_):
+ pass
+
+def on_uart_tx(uart_id, byte_val):
+ uart_buf.append(byte_val)
+ if byte_val == ord('\n'):
+ line = uart_buf.decode("utf-8", errors="replace").rstrip()
+ uart_buf.clear()
+ print(f" [UART] {line}")
+
+cb_write = _WRITE_PIN(on_write_pin)
+cb_dir = _DIR_PIN(on_dir_pin)
+cb_i2c = _I2C_EVENT(lambda *a: 0)
+cb_spi = _SPI_EVENT(lambda *a: 0)
+cb_uart = _UART_TX(on_uart_tx)
+cb_rmt = _RMT_EVENT(lambda *a: None)
+
+cbs = _CallbacksT(
+ picsimlab_write_pin = cb_write,
+ picsimlab_dir_pin = cb_dir,
+ picsimlab_i2c_event = cb_i2c,
+ picsimlab_spi_event = cb_spi,
+ picsimlab_uart_tx_event = cb_uart,
+ pinmap = ctypes.cast(_PINMAP, ctypes.c_void_p).value,
+ picsimlab_rmt_event = cb_rmt,
+)
+_keep_alive = (cbs, cb_write, cb_dir, cb_i2c, cb_spi, cb_uart, cb_rmt)
+
+print("=== Registering callbacks ===")
+lib.qemu_picsimlab_register_callbacks(ctypes.byref(cbs))
+
+fw_bytes = FW.encode()
+args = [b"qemu", b"-M", b"esp32-picsimlab", b"-nographic",
+ b"-drive", b"file=" + fw_bytes + b",if=mtd,format=raw"]
+argc = len(args)
+argv = (ctypes.c_char_p * argc)(*args)
+
+print("=== Calling qemu_init ===")
+lib.qemu_init(argc, argv, None)
+print("=== qemu_init returned, starting main_loop thread ===")
+
+t = threading.Thread(target=lib.qemu_main_loop, daemon=True, name="qemu-test")
+t.start()
+
+print("=== Waiting 20s for output ===")
+time.sleep(20)
+
+print(f"\n=== Results ===")
+print(f"UART bytes: {len(uart_buf)} buffered, output so far:")
+print(f"GPIO events: {len(gpio_events)}")
+for ev in gpio_events[:20]:
+ print(f" pin={ev[0]} value={ev[1]}")
+print(f"Thread alive: {t.is_alive()}")