From c3df484b4fe18faa419230bb03fc6b0919b4168c Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Fri, 13 Mar 2026 20:35:48 -0300 Subject: [PATCH] feat: Implement ESP32 emulation via QEMU and multi-board support for Raspberry Pi and Arduino - Added ESP32 emulation plan and architecture documentation. - Created `esp_qemu_manager.py` for managing ESP32 QEMU instances. - Modified backend API routes to support ESP32 firmware loading and GPIO handling. - Introduced `Esp32Bridge.ts` for frontend communication with ESP32 instances. - Refactored simulator store to support multiple boards, including Raspberry Pi and Arduino. - Created `RaspberryPi3Bridge.ts` for WebSocket communication between frontend and backend for Raspberry Pi. - Updated QEMU manager to handle multiple serial ports for Raspberry Pi GPIO communication. - Enhanced SimulatorCanvas to render multiple boards and manage wire routing between them. - Implemented board picker modal for selecting and adding boards to the canvas. - Updated editor to support multiple file groups per board. - Added migration logic for loading old project formats into the new multi-board structure. - Ensured backward compatibility with existing components and functionality. --- backend/app/api/routes/simulation.py | 27 ++ backend/app/services/arduino_cli.py | 34 ++ backend/app/services/esp_qemu_manager.py | 335 ++++++++++++++++++ frontend/src/App.tsx | 2 - .../src/components/components-wokwi/Esp32.tsx | 22 ++ .../components-wokwi/Esp32Element.ts | 144 ++++++++ .../src/components/editor/EditorToolbar.tsx | 5 +- .../components/simulator/BoardOnCanvas.tsx | 8 + .../components/simulator/BoardPickerModal.tsx | 9 +- frontend/src/simulation/Esp32Bridge.ts | 159 +++++++++ frontend/src/store/useSimulatorStore.ts | 117 +++--- frontend/src/types/board.ts | 13 +- frontend/src/utils/boardPinMapping.ts | 29 ++ 13 files changed, 850 insertions(+), 54 deletions(-) create mode 100644 backend/app/services/esp_qemu_manager.py create mode 100644 frontend/src/components/components-wokwi/Esp32.tsx create mode 100644 frontend/src/components/components-wokwi/Esp32Element.ts create mode 100644 frontend/src/simulation/Esp32Bridge.ts diff --git a/backend/app/api/routes/simulation.py b/backend/app/api/routes/simulation.py index 10cbb5f..7d26407 100644 --- a/backend/app/api/routes/simulation.py +++ b/backend/app/api/routes/simulation.py @@ -2,6 +2,7 @@ import json import logging from fastapi import APIRouter, WebSocket, WebSocketDisconnect from app.services.qemu_manager import qemu_manager +from app.services.esp_qemu_manager import esp_qemu_manager router = APIRouter() logger = logging.getLogger(__name__) @@ -67,10 +68,36 @@ async def simulation_websocket(websocket: WebSocket, client_id: str): state = msg_data.get('state', 0) qemu_manager.set_pin_state(client_id, pin, state) + # ── ESP32 messages ────────────────────────────────────────────── + elif msg_type == 'start_esp32': + board = msg_data.get('board', 'esp32') + firmware_b64 = msg_data.get('firmware_b64') + esp_qemu_manager.start_instance(client_id, board, qemu_callback, firmware_b64) + + elif msg_type == 'stop_esp32': + esp_qemu_manager.stop_instance(client_id) + + elif msg_type == 'load_firmware': + firmware_b64 = msg_data.get('firmware_b64', '') + if firmware_b64: + esp_qemu_manager.load_firmware(client_id, firmware_b64) + + elif msg_type == 'esp32_serial_input': + raw_bytes: list[int] = msg_data.get('bytes', []) + if raw_bytes: + await esp_qemu_manager.send_serial_bytes(client_id, bytes(raw_bytes)) + + elif msg_type == 'esp32_gpio_in': + pin = msg_data.get('pin', 0) + state = msg_data.get('state', 0) + esp_qemu_manager.set_pin_state(client_id, pin, state) + except WebSocketDisconnect: manager.disconnect(client_id) qemu_manager.stop_instance(client_id) + esp_qemu_manager.stop_instance(client_id) except Exception as e: logger.error('WebSocket error for %s: %s', client_id, e) manager.disconnect(client_id) qemu_manager.stop_instance(client_id) + esp_qemu_manager.stop_instance(client_id) diff --git a/backend/app/services/arduino_cli.py b/backend/app/services/arduino_cli.py index c6ea41c..f6d8125 100644 --- a/backend/app/services/arduino_cli.py +++ b/backend/app/services/arduino_cli.py @@ -205,6 +205,10 @@ class ArduinoCLIService: """Return True if the FQBN targets an RP2040/RP2350 board.""" return any(p in fqbn for p in ("rp2040", "rp2350", "mbed_rp2040", "mbed_rp2350")) + def _is_esp32_board(self, fqbn: str) -> bool: + """Return True if the FQBN targets an ESP32 family board.""" + return fqbn.startswith("esp32:") + async def compile(self, files: list[dict], board_fqbn: str = "arduino:avr:uno") -> dict: """ Compile Arduino sketch using arduino-cli. @@ -314,6 +318,36 @@ class ArduinoCLIService: "stdout": result.stdout, "stderr": result.stderr } + elif self._is_esp32_board(board_fqbn): + # ESP32 outputs a merged flash .bin file + # arduino-cli places it as sketch.ino.bin + bin_file = build_dir / "sketch.ino.bin" + # Some versions use a merged-flash variant + merged_file = build_dir / "sketch.ino.merged.bin" + target_file = merged_file if merged_file.exists() else (bin_file if bin_file.exists() else None) + + if target_file: + raw_bytes = target_file.read_bytes() + binary_b64 = base64.b64encode(raw_bytes).decode('ascii') + print(f"[ESP32] Binary file: {target_file.name}, size: {len(raw_bytes)} bytes") + print("=== ESP32 Compilation successful ===\n") + return { + "success": True, + "hex_content": None, + "binary_content": binary_b64, + "binary_type": "bin", + "stdout": result.stdout, + "stderr": result.stderr + } + else: + print(f"[ESP32] Binary file not found. Files: {list(build_dir.iterdir())}") + print("=== ESP32 Compilation failed: binary not found ===\n") + return { + "success": False, + "error": "ESP32 binary (.bin) not found after compilation", + "stdout": result.stdout, + "stderr": result.stderr + } else: # AVR outputs a .hex file (Intel HEX format) hex_file = build_dir / "sketch.ino.hex" diff --git a/backend/app/services/esp_qemu_manager.py b/backend/app/services/esp_qemu_manager.py new file mode 100644 index 0000000..d78c231 --- /dev/null +++ b/backend/app/services/esp_qemu_manager.py @@ -0,0 +1,335 @@ +""" +EspQemuManager — backend service for ESP32/ESP32-S3/ESP32-C3 emulation via QEMU. + +Architecture +------------ +Each ESP32 instance gets: + - qemu-system-xtensa (ESP32/ESP32-S3) or qemu-system-riscv32 (ESP32-C3) process + - UART0 → TCP socket on a dynamic port → user serial I/O + - GPIO chardev → TCP socket on a dynamic port → GPIO pin protocol + - Firmware loaded as a flash image (-drive if=mtd) + +GPIO protocol (chardev socket) +------------------------------- + QEMU → backend : "GPIO <0|1>\\n" + backend → QEMU : "SET <0|1>\\n" + +Board types and QEMU machines +------------------------------ + 'esp32' → qemu-system-xtensa -M esp32 + 'esp32-s3' → qemu-system-xtensa -M esp32s3 + 'esp32-c3' → qemu-system-riscv32 -M esp32c3 +""" + +import asyncio +import base64 +import logging +import os +import socket +import tempfile +from typing import Callable, Awaitable + +logger = logging.getLogger(__name__) + +# ── QEMU binary paths (configurable via env) ────────────────────────────────── +QEMU_XTENSA = os.environ.get('QEMU_ESP32_BINARY', 'qemu-system-xtensa') +QEMU_RISCV32 = os.environ.get('QEMU_RISCV32_BINARY', 'qemu-system-riscv32') + +# ── Machine names per board type ────────────────────────────────────────────── +_MACHINE: dict[str, tuple[str, str]] = { + 'esp32': (QEMU_XTENSA, 'esp32'), + 'esp32-s3': (QEMU_XTENSA, 'esp32s3'), + 'esp32-c3': (QEMU_RISCV32, 'esp32c3'), +} + +EventCallback = Callable[[str, dict], Awaitable[None]] + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('127.0.0.1', 0)) + return s.getsockname()[1] + + +class EspInstance: + """State for one running ESP32 board.""" + + def __init__(self, client_id: str, board_type: str, callback: EventCallback): + self.client_id = client_id + self.board_type = board_type # 'esp32' | 'esp32-s3' | 'esp32-c3' + self.callback = callback + + # Runtime state + self.process: asyncio.subprocess.Process | None = None + self.firmware_path: str | None = None # temp file, deleted on stop + self.serial_port: int = 0 # UART0 TCP port + self.gpio_port: int = 0 # GPIO chardev TCP port + self._serial_writer: asyncio.StreamWriter | None = None + self._gpio_writer: asyncio.StreamWriter | None = None + self._tasks: list[asyncio.Task] = [] + self.running: bool = False + + async def emit(self, event_type: str, data: dict) -> None: + try: + await self.callback(event_type, data) + except Exception as e: + logger.error('emit(%s): %s', event_type, e) + + +class EspQemuManager: + def __init__(self): + self._instances: dict[str, EspInstance] = {} + + # ── Public API ───────────────────────────────────────────────────────────── + + def start_instance(self, client_id: str, board_type: str, + callback: EventCallback, + firmware_b64: str | None = None) -> None: + if client_id in self._instances: + logger.warning('start_instance: %s already running', client_id) + return + if board_type not in _MACHINE: + logger.error('Unknown ESP32 board type: %s', board_type) + return + inst = EspInstance(client_id, board_type, callback) + self._instances[client_id] = inst + asyncio.create_task(self._boot(inst, firmware_b64)) + + def stop_instance(self, client_id: str) -> None: + inst = self._instances.pop(client_id, None) + if inst: + asyncio.create_task(self._shutdown(inst)) + + def load_firmware(self, client_id: str, firmware_b64: str) -> None: + """Hot-reload firmware into a running instance (stop + restart).""" + inst = self._instances.get(client_id) + if not inst: + logger.warning('load_firmware: no instance %s', client_id) + return + board_type = inst.board_type + callback = inst.callback + self.stop_instance(client_id) + # Re-start with new firmware after brief delay for cleanup + async def _restart() -> None: + await asyncio.sleep(0.5) + self.start_instance(client_id, board_type, callback, firmware_b64) + asyncio.create_task(_restart()) + + def set_pin_state(self, client_id: str, pin: int | str, state: int) -> None: + """Drive a GPIO pin from outside (e.g. connected Arduino output).""" + inst = self._instances.get(client_id) + if inst and inst._gpio_writer: + asyncio.create_task(self._send_gpio(inst, int(pin), bool(state))) + + async def send_serial_bytes(self, client_id: str, data: bytes) -> None: + inst = self._instances.get(client_id) + if inst and inst._serial_writer: + inst._serial_writer.write(data) + try: + await inst._serial_writer.drain() + except Exception as e: + logger.warning('send_serial_bytes drain: %s', e) + + # ── Boot sequence ────────────────────────────────────────────────────────── + + async def _boot(self, inst: EspInstance, firmware_b64: str | None) -> None: + # Write firmware to temp file if provided + firmware_path: str | None = None + if firmware_b64: + try: + firmware_bytes = base64.b64decode(firmware_b64) + tmp = tempfile.NamedTemporaryFile(suffix='.bin', delete=False) + tmp.write(firmware_bytes) + tmp.close() + firmware_path = tmp.name + inst.firmware_path = firmware_path + except Exception as e: + await inst.emit('error', {'message': f'Failed to decode firmware: {e}'}) + self._instances.pop(inst.client_id, None) + return + + qemu_bin, machine = _MACHINE[inst.board_type] + + # Allocate TCP ports + inst.serial_port = _find_free_port() + inst.gpio_port = _find_free_port() + + # Build QEMU command + cmd = [ + qemu_bin, + '-nographic', + '-machine', machine, + # UART0 → TCP (serial I/O) + '-serial', f'tcp:127.0.0.1:{inst.serial_port},server,nowait', + # GPIO chardev → TCP + '-chardev', f'socket,id=gpio0,host=127.0.0.1,port={inst.gpio_port},server,nowait', + ] + + if firmware_path: + cmd += ['-drive', f'file={firmware_path},if=mtd,format=raw'] + + logger.info('Launching ESP32 QEMU for %s: %s', inst.client_id, ' '.join(cmd)) + + try: + inst.process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.DEVNULL, + ) + except FileNotFoundError: + await inst.emit('error', {'message': f'{qemu_bin} not found in PATH'}) + self._instances.pop(inst.client_id, None) + return + + inst.running = True + await inst.emit('system', {'event': 'booting'}) + + # Give QEMU a moment to open its TCP sockets + await asyncio.sleep(1.0) + + inst._tasks.append(asyncio.create_task(self._connect_serial(inst))) + inst._tasks.append(asyncio.create_task(self._connect_gpio(inst))) + inst._tasks.append(asyncio.create_task(self._watch_stderr(inst))) + + # ── Serial (UART0) ───────────────────────────────────────────────────────── + + async def _connect_serial(self, inst: EspInstance) -> None: + for attempt in range(10): + try: + reader, writer = await asyncio.open_connection('127.0.0.1', inst.serial_port) + inst._serial_writer = writer + logger.info('%s: serial connected on port %d', inst.client_id, inst.serial_port) + await inst.emit('system', {'event': 'booted'}) + await self._read_serial(inst, reader) + return + except (ConnectionRefusedError, OSError): + await asyncio.sleep(1.0 * (attempt + 1)) + await inst.emit('error', {'message': 'Could not connect to QEMU serial port'}) + + async def _read_serial(self, inst: EspInstance, reader: asyncio.StreamReader) -> None: + buf = bytearray() + while inst.running: + try: + chunk = await asyncio.wait_for(reader.read(256), timeout=0.1) + if not chunk: + break + buf.extend(chunk) + text = buf.decode('utf-8', errors='replace') + buf.clear() + await inst.emit('serial_output', {'data': text}) + except asyncio.TimeoutError: + continue + except Exception as e: + logger.warning('%s serial read: %s', inst.client_id, e) + break + + # ── GPIO chardev ─────────────────────────────────────────────────────────── + + async def _connect_gpio(self, inst: EspInstance) -> None: + for attempt in range(10): + try: + reader, writer = await asyncio.open_connection('127.0.0.1', inst.gpio_port) + inst._gpio_writer = writer + logger.info('%s: GPIO chardev connected on port %d', inst.client_id, inst.gpio_port) + await self._read_gpio(inst, reader) + return + except (ConnectionRefusedError, OSError): + await asyncio.sleep(1.0 * (attempt + 1)) + logger.warning('%s: GPIO chardev connection failed', inst.client_id) + + async def _read_gpio(self, inst: EspInstance, reader: asyncio.StreamReader) -> None: + """Parse "GPIO \n" lines from the firmware GPIO bridge.""" + linebuf = b'' + while inst.running: + try: + chunk = await asyncio.wait_for(reader.read(256), timeout=0.1) + if not chunk: + break + linebuf += chunk + while b'\n' in linebuf: + line, linebuf = linebuf.split(b'\n', 1) + await self._handle_gpio_line(inst, line.decode('ascii', 'ignore').strip()) + except asyncio.TimeoutError: + continue + except Exception as e: + logger.warning('%s GPIO read: %s', inst.client_id, e) + break + + async def _handle_gpio_line(self, inst: EspInstance, line: str) -> None: + # Expected: "GPIO <0|1>" + parts = line.split() + if len(parts) == 3 and parts[0] == 'GPIO': + try: + pin = int(parts[1]) + state = int(parts[2]) + await inst.emit('gpio_change', {'pin': pin, 'state': state}) + except ValueError: + pass + + async def _send_gpio(self, inst: EspInstance, pin: int, state: bool) -> None: + if inst._gpio_writer: + msg = f'SET {pin} {1 if state else 0}\n'.encode() + inst._gpio_writer.write(msg) + try: + await inst._gpio_writer.drain() + except Exception as e: + logger.warning('%s GPIO send: %s', inst.client_id, e) + + # ── QEMU stderr watcher ──────────────────────────────────────────────────── + + async def _watch_stderr(self, inst: EspInstance) -> None: + if not inst.process or not inst.process.stderr: + return + try: + async for line in inst.process.stderr: + text = line.decode('utf-8', errors='replace').rstrip() + if text: + logger.debug('QEMU[%s] %s', inst.client_id, text) + except Exception: + pass + logger.info('QEMU[%s] process exited', inst.client_id) + inst.running = False + await inst.emit('system', {'event': 'exited'}) + + # ── Shutdown ─────────────────────────────────────────────────────────────── + + async def _shutdown(self, inst: EspInstance) -> None: + inst.running = False + + for task in inst._tasks: + task.cancel() + inst._tasks.clear() + + for writer_attr in ('_gpio_writer', '_serial_writer'): + writer: asyncio.StreamWriter | None = getattr(inst, writer_attr) + if writer: + try: + writer.close() + except Exception: + pass + setattr(inst, writer_attr, None) + + if inst.process: + try: + inst.process.terminate() + await asyncio.wait_for(inst.process.wait(), timeout=5.0) + except Exception: + try: + inst.process.kill() + except Exception: + pass + inst.process = None + + # Delete temp firmware file + if inst.firmware_path and os.path.exists(inst.firmware_path): + try: + os.unlink(inst.firmware_path) + except Exception: + pass + inst.firmware_path = None + + logger.info('EspInstance %s shut down', inst.client_id) + + +esp_qemu_manager = EspQemuManager() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5059811..c5d65aa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,7 +10,6 @@ import { UserProfilePage } from './pages/UserProfilePage'; import { ProjectPage } from './pages/ProjectPage'; import { ProjectByIdPage } from './pages/ProjectByIdPage'; import { AdminPage } from './pages/AdminPage'; -import { DocsPage } from './pages/DocsPage'; import { useAuthStore } from './store/useAuthStore'; import './App.css'; @@ -31,7 +30,6 @@ function App() { } /> } /> } /> - } /> } /> {/* Canonical project URL by ID */} } /> diff --git a/frontend/src/components/components-wokwi/Esp32.tsx b/frontend/src/components/components-wokwi/Esp32.tsx new file mode 100644 index 0000000..784618f --- /dev/null +++ b/frontend/src/components/components-wokwi/Esp32.tsx @@ -0,0 +1,22 @@ +import './Esp32Element'; + +interface Esp32Props { + id?: string; + x?: number; + y?: number; +} + +declare global { + namespace JSX { + interface IntrinsicElements { + 'wokwi-esp32': any; + } + } +} + +export const Esp32 = ({ id = 'esp32', x = 0, y = 0 }: Esp32Props) => ( + +); diff --git a/frontend/src/components/components-wokwi/Esp32Element.ts b/frontend/src/components/components-wokwi/Esp32Element.ts new file mode 100644 index 0000000..82062f0 --- /dev/null +++ b/frontend/src/components/components-wokwi/Esp32Element.ts @@ -0,0 +1,144 @@ +/** + * ESP32 DevKit-C Web Component + * + * Renders an ESP32 DevKit-C board (38 pins) as a custom HTML element. + * Pin layout follows the standard ESP32 DevKit-C pinout. + */ + +const ESP32_WIDTH = 280; +const ESP32_HEIGHT = 185; + +// ESP32 DevKit-C pinout (left column = GPIO order top-to-bottom, right column = same) +const ESP32_PINS = [ + // Left column (top to bottom) + { name: 'GPIO36', x: 2, y: 18 }, + { name: 'GPIO39', x: 2, y: 29 }, + { name: 'GPIO34', x: 2, y: 40 }, + { name: 'GPIO35', x: 2, y: 51 }, + { name: 'GPIO32', x: 2, y: 62 }, + { name: 'GPIO33', x: 2, y: 73 }, + { name: 'GPIO25', x: 2, y: 84 }, + { name: 'GPIO26', x: 2, y: 95 }, + { name: 'GPIO27', x: 2, y: 106 }, + { name: 'GPIO14', x: 2, y: 117 }, + { name: 'GPIO12', x: 2, y: 128 }, + { name: 'GND', x: 2, y: 139 }, + { name: 'GPIO13', x: 2, y: 150 }, + { name: 'GPIO9', x: 2, y: 161 }, + { name: 'GPIO10', x: 2, y: 172 }, + { name: 'GPIO11', x: 2, y: 183 }, + + // Right column (top to bottom) + { name: '3V3', x: 278, y: 18 }, + { name: 'EN', x: 278, y: 29 }, + { name: 'GPIO36', x: 278, y: 40 }, + { name: 'GPIO39', x: 278, y: 51 }, + { name: 'GPIO34', x: 278, y: 62 }, + { name: 'GPIO35', x: 278, y: 73 }, + { name: 'GPIO32', x: 278, y: 84 }, + { name: 'GPIO33', x: 278, y: 95 }, + { name: 'GPIO25', x: 278, y: 106 }, + { name: 'GPIO26', x: 278, y: 117 }, + { name: 'GPIO27', x: 278, y: 128 }, + { name: 'GND', x: 278, y: 139 }, + { name: 'GPIO13', x: 278, y: 150 }, + { name: 'GPIO15', x: 278, y: 161 }, + { name: 'GPIO2', x: 278, y: 172 }, + { name: 'GPIO0', x: 278, y: 183 }, + + // Bottom row + { name: 'GND', x: 30, y: 183 }, + { name: '5V', x: 50, y: 183 }, + { name: 'GPIO23', x: 70, y: 183 }, + { name: 'GPIO22', x: 90, y: 183 }, + { name: 'TX', x: 110, y: 183 }, + { name: 'RX', x: 130, y: 183 }, + { name: 'GPIO21', x: 150, y: 183 }, + { name: 'GND', x: 170, y: 183 }, + { name: 'GPIO19', x: 190, y: 183 }, + { name: 'GPIO18', x: 210, y: 183 }, + { name: 'GPIO5', x: 230, y: 183 }, + { name: 'GPIO17', x: 250, y: 183 }, + { name: 'GPIO16', x: 270, y: 183 }, + { name: 'GPIO4', x: 20, y: 183 }, +]; + +class Esp32Element extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.render(); + } + + get pinInfo() { + return ESP32_PINS; + } + + render() { + if (!this.shadowRoot) return; + + this.shadowRoot.innerHTML = ` + + + + + + + + + + + + ESP32 + WROOM-32 + + + + WiFi+BT + + + + PWR + + BT + + + + BOOT + + EN + + + ${ESP32_PINS.filter(p => p.x < 10).map((p, i) => ` + + `).join('')} + + + ${ESP32_PINS.filter(p => p.x > 270).map((p) => ` + + `).join('')} + + + + ESP32 DevKit-C + + + `; + } +} + +if (!customElements.get('wokwi-esp32')) { + customElements.define('wokwi-esp32', Esp32Element); +} diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 4489d21..faa8d60 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -101,7 +101,8 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi const handleRun = () => { if (activeBoardId) { const board = boards.find((b) => b.id === activeBoardId); - if (board?.boardKind === 'raspberry-pi-3' || board?.compiledProgram) { + const isQemuBoard = board?.boardKind === 'raspberry-pi-3' || board?.boardKind === 'esp32' || board?.boardKind === 'esp32-s3' || board?.boardKind === 'esp32-c3'; + if (isQemuBoard || board?.compiledProgram) { startBoard(activeBoardId); setMessage(null); return; @@ -190,7 +191,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi {/* Run */}