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.
pull/47/head
David Montero Crespo 2026-03-13 20:35:48 -03:00
parent 8122d5d80a
commit c3df484b4f
13 changed files with 850 additions and 54 deletions

View File

@ -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)

View File

@ -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"

View File

@ -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 <pin> <0|1>\\n"
backend QEMU : "SET <pin> <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 <pin> <state>\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 <pin> <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()

View File

@ -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() {
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/docs" element={<DocsPage />} />
<Route path="/docs/:section" element={<DocsPage />} />
{/* Canonical project URL by ID */}
<Route path="/project/:id" element={<ProjectByIdPage />} />

View File

@ -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) => (
<wokwi-esp32
id={id}
style={{ position: 'absolute', left: `${x}px`, top: `${y}px` }}
/>
);

View File

@ -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 = `
<style>
:host { display: inline-block; }
svg { display: block; }
</style>
<svg
width="${ESP32_WIDTH}"
height="${ESP32_HEIGHT}"
viewBox="0 0 ${ESP32_WIDTH} ${ESP32_HEIGHT}"
xmlns="http://www.w3.org/2000/svg"
>
<!-- PCB body -->
<rect x="10" y="5" width="${ESP32_WIDTH - 20}" height="${ESP32_HEIGHT - 15}"
rx="4" fill="#1a6b3e" stroke="#0d4a2a" stroke-width="1.5"/>
<!-- USB connector -->
<rect x="115" y="0" width="50" height="12" rx="2" fill="#888"/>
<rect x="119" y="2" width="42" height="8" rx="1" fill="#555"/>
<!-- ESP32 chip -->
<rect x="95" y="55" width="90" height="60" rx="4" fill="#222" stroke="#444" stroke-width="1"/>
<text x="140" y="86" text-anchor="middle" fill="#aaa" font-size="9" font-family="monospace">ESP32</text>
<text x="140" y="97" text-anchor="middle" fill="#666" font-size="7" font-family="monospace">WROOM-32</text>
<!-- WiFi/BT chip -->
<rect x="100" y="20" width="80" height="30" rx="2" fill="#2a2a2a" stroke="#555" stroke-width="0.5"/>
<text x="140" y="38" text-anchor="middle" fill="#888" font-size="7" font-family="monospace">WiFi+BT</text>
<!-- LED indicators -->
<circle cx="60" cy="25" r="4" fill="#f00" opacity="0.8"/>
<text x="60" y="40" text-anchor="middle" fill="#aaa" font-size="6" font-family="monospace">PWR</text>
<circle cx="78" cy="25" r="4" fill="#00f" opacity="0.6"/>
<text x="78" y="40" text-anchor="middle" fill="#aaa" font-size="6" font-family="monospace">BT</text>
<!-- BOOT and EN buttons -->
<rect x="210" y="145" width="20" height="8" rx="2" fill="#444"/>
<text x="220" y="162" text-anchor="middle" fill="#aaa" font-size="5" font-family="monospace">BOOT</text>
<rect x="210" y="125" width="20" height="8" rx="2" fill="#444"/>
<text x="220" y="122" text-anchor="middle" fill="#aaa" font-size="5" font-family="monospace">EN</text>
<!-- Left header pins -->
${ESP32_PINS.filter(p => p.x < 10).map((p, i) => `
<rect x="3" y="${p.y - 3}" width="8" height="6" fill="#c8a000" rx="0.5"/>
`).join('')}
<!-- Right header pins -->
${ESP32_PINS.filter(p => p.x > 270).map((p) => `
<rect x="${ESP32_WIDTH - 11}" y="${p.y - 3}" width="8" height="6" fill="#c8a000" rx="0.5"/>
`).join('')}
<!-- Board label -->
<text x="140" y="${ESP32_HEIGHT - 4}" text-anchor="middle" fill="#5a9" font-size="7" font-family="monospace">
ESP32 DevKit-C
</text>
</svg>
`;
}
}
if (!customElements.get('wokwi-esp32')) {
customElements.define('wokwi-esp32', Esp32Element);
}

View File

@ -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 */}
<button
onClick={handleRun}
disabled={running || (activeBoard?.boardKind !== 'raspberry-pi-3' && !compiledHex && !activeBoard?.compiledProgram)}
disabled={running || (!['raspberry-pi-3','esp32','esp32-s3','esp32-c3'].includes(activeBoard?.boardKind ?? '') && !compiledHex && !activeBoard?.compiledProgram)}
className="tb-btn tb-btn-run"
title="Run"
>

View File

@ -5,6 +5,7 @@ import { ArduinoNano } from '../components-wokwi/ArduinoNano';
import { ArduinoMega } from '../components-wokwi/ArduinoMega';
import { NanoRP2040 } from '../components-wokwi/NanoRP2040';
import { RaspberryPi3 } from '../components-wokwi/RaspberryPi3';
import { Esp32 } from '../components-wokwi/Esp32';
import { PinOverlay } from './PinOverlay';
// Board visual dimensions (width × height) for the drag-overlay sizing
@ -14,6 +15,9 @@ const BOARD_SIZE: Record<string, { w: number; h: number }> = {
'arduino-mega': { w: 530, h: 195 },
'raspberry-pi-pico': { w: 280, h: 180 },
'raspberry-pi-3': { w: 250, h: 160 },
'esp32': { w: 280, h: 185 },
'esp32-s3': { w: 280, h: 185 },
'esp32-c3': { w: 260, h: 175 },
};
interface BoardOnCanvasProps {
@ -46,6 +50,10 @@ export const BoardOnCanvas = ({
return <NanoRP2040 id={id} x={x} y={y} ledBuiltIn={led13} />;
case 'raspberry-pi-3':
return <RaspberryPi3 id={id} x={x} y={y} />;
case 'esp32':
case 'esp32-s3':
case 'esp32-c3':
return <Esp32 id={id} x={x} y={y} />;
}
})();

View File

@ -8,6 +8,9 @@ const BOARD_DESCRIPTIONS: Record<BoardKind, string> = {
'arduino-mega': '8-bit AVR, 256KB flash, 54 digital I/O',
'raspberry-pi-pico': 'RP2040 dual-core Cortex-M0+',
'raspberry-pi-3': 'ARM64 Cortex-A53 quad-core, Linux/Python (QEMU)',
'esp32': 'Xtensa LX6 dual-core, WiFi+BT, 38 GPIO (QEMU)',
'esp32-s3': 'Xtensa LX7 dual-core, WiFi+BT, AI accel (QEMU)',
'esp32-c3': 'RISC-V single-core, WiFi+BLE, 22 GPIO (QEMU)',
};
const BOARD_ICON: Record<BoardKind, string> = {
@ -16,6 +19,9 @@ const BOARD_ICON: Record<BoardKind, string> = {
'arduino-mega': '▬',
'raspberry-pi-pico': '◆',
'raspberry-pi-3': '⬛',
'esp32': '⬡',
'esp32-s3': '⬡',
'esp32-c3': '⬡',
};
interface BoardPickerModalProps {
@ -26,6 +32,7 @@ interface BoardPickerModalProps {
const BOARDS: BoardKind[] = [
'arduino-uno', 'arduino-nano', 'arduino-mega', 'raspberry-pi-pico', 'raspberry-pi-3',
'esp32', 'esp32-s3', 'esp32-c3',
];
export const BoardPickerModal = ({ isOpen, onClose, onSelectBoard }: BoardPickerModalProps) => {
@ -63,7 +70,7 @@ export const BoardPickerModal = ({ isOpen, onClose, onSelectBoard }: BoardPicker
onMouseEnter={(e) => (e.currentTarget.style.background = '#3a3a3a')}
onMouseLeave={(e) => (e.currentTarget.style.background = '#2d2d2d')}
>
<span style={{ fontSize: 20, width: 28, textAlign: 'center', color: kind.startsWith('raspberry') ? '#c22' : '#4af' }}>
<span style={{ fontSize: 20, width: 28, textAlign: 'center', color: kind.startsWith('raspberry') ? '#c22' : kind.startsWith('esp') ? '#e8a020' : '#4af' }}>
{BOARD_ICON[kind]}
</span>
<div>

View File

@ -0,0 +1,159 @@
/**
* Esp32Bridge
*
* Manages the WebSocket connection from the frontend to the backend
* QEMU manager for one ESP32/ESP32-S3/ESP32-C3 board instance.
*
* Protocol (JSON frames):
* Frontend Backend
* { 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_gpio_in', data: { pin: number, state: 0 | 1 } }
*
* Backend Frontend
* { type: 'serial_output', data: { data: string } }
* { type: 'gpio_change', data: { pin: number, state: 0 | 1 } }
* { type: 'system', data: { event: string, ... } }
* { type: 'error', data: { message: string } }
*/
import type { BoardKind } from '../types/board';
const API_BASE = (): string =>
(import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8001/api';
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<string, unknown>) => void) | null = null;
private socket: WebSocket | null = null;
private _connected = false;
private _pendingFirmware: string | null = null;
constructor(boardId: string, boardKind: BoardKind) {
this.boardId = boardId;
this.boardKind = boardKind;
}
get connected(): boolean {
return this._connected;
}
connect(): void {
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) return;
const base = API_BASE();
const wsProtocol = base.startsWith('https') ? 'wss:' : 'ws:';
const wsUrl = base.replace(/^https?:/, wsProtocol)
+ `/simulation/ws/${encodeURIComponent(this.boardId)}`;
const socket = new WebSocket(wsUrl);
this.socket = socket;
socket.onopen = () => {
this._connected = true;
this.onConnected?.();
// Boot the ESP32 via QEMU, optionally with pre-loaded firmware
this._send({
type: 'start_esp32',
data: {
board: this.boardKind,
...(this._pendingFirmware ? { firmware_b64: this._pendingFirmware } : {}),
},
});
};
socket.onmessage = (event: MessageEvent) => {
let msg: { type: string; data: Record<string, unknown> };
try {
msg = JSON.parse(event.data as string);
} catch {
return;
}
switch (msg.type) {
case 'serial_output': {
const text = (msg.data.data as string) ?? '';
if (this.onSerialData) {
for (const ch of text) this.onSerialData(ch);
}
break;
}
case 'gpio_change': {
const pin = msg.data.pin as number;
const state = (msg.data.state as number) === 1;
this.onPinChange?.(pin, state);
break;
}
case 'system':
this.onSystemEvent?.(msg.data.event as string, msg.data);
break;
case 'error':
this.onError?.(msg.data.message as string);
break;
}
};
socket.onclose = () => {
this._connected = false;
this.socket = null;
this.onDisconnected?.();
};
socket.onerror = () => {
this.onError?.('WebSocket error');
};
}
disconnect(): void {
if (this.socket) {
this._send({ type: 'stop_esp32' });
this.socket.close();
this.socket = null;
}
this._connected = false;
}
/**
* Load a compiled firmware (base64-encoded .bin) into the running ESP32.
* If not yet connected, the firmware will be sent on next connect().
*/
loadFirmware(firmwareBase64: string): void {
this._pendingFirmware = firmwareBase64;
if (this._connected) {
this._send({ type: 'load_firmware', data: { firmware_b64: firmwareBase64 } });
}
}
/** Send a byte to the ESP32 UART0 */
sendSerialByte(byte: number): void {
this._send({ type: 'esp32_serial_input', data: { bytes: [byte] } });
}
/** Send multiple bytes at once */
sendSerialBytes(bytes: number[]): void {
if (bytes.length === 0) return;
this._send({ type: 'esp32_serial_input', data: { bytes } });
}
/** Drive a GPIO pin from an external source (e.g. connected Arduino) */
sendPinEvent(gpioPin: number, state: boolean): void {
this._send({ type: 'esp32_gpio_in', data: { pin: gpioPin, state: state ? 1 : 0 } });
}
private _send(payload: unknown): void {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(payload));
}
}
}

View File

@ -9,6 +9,8 @@ import type { BoardKind, BoardInstance } from '../types/board';
import { calculatePinPosition } from '../utils/pinPositionCalculator';
import { useOscilloscopeStore } from './useOscilloscopeStore';
import { RaspberryPi3Bridge } from '../simulation/RaspberryPi3Bridge';
import { Esp32Bridge } from '../simulation/Esp32Bridge';
import { useEditorStore } from './useEditorStore';
// ── Legacy type aliases (keep external consumers working) ──────────────────
export type BoardType = 'arduino-uno' | 'arduino-nano' | 'arduino-mega' | 'raspberry-pi-pico';
@ -34,10 +36,16 @@ export const ARDUINO_POSITION = DEFAULT_BOARD_POSITION;
const simulatorMap = new Map<string, AVRSimulator | RP2040Simulator>();
const pinManagerMap = new Map<string, PinManager>();
const bridgeMap = new Map<string, RaspberryPi3Bridge>();
const esp32BridgeMap = new Map<string, Esp32Bridge>();
export const getBoardSimulator = (id: string) => simulatorMap.get(id);
export const getBoardPinManager = (id: string) => pinManagerMap.get(id);
export const getBoardBridge = (id: string) => bridgeMap.get(id);
export const getEsp32Bridge = (id: string) => esp32BridgeMap.get(id);
function isEsp32Kind(kind: BoardKind): kind is 'esp32' | 'esp32-s3' | 'esp32-c3' {
return kind === 'esp32' || kind === 'esp32-s3' || kind === 'esp32-c3';
}
// ── Component type ────────────────────────────────────────────────────────
interface Component {
@ -223,19 +231,35 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
const pm = new PinManager();
pinManagerMap.set(id, pm);
if (boardKind !== 'raspberry-pi-3') {
const serialCallback = (ch: string) => {
set((s) => {
const boards = s.boards.map((b) =>
b.id === id ? { ...b, serialOutput: b.serialOutput + ch } : b
);
const isActive = s.activeBoardId === id;
return { boards, ...(isActive ? { serialOutput: s.serialOutput + ch } : {}) };
});
};
if (boardKind === 'raspberry-pi-3') {
const bridge = new RaspberryPi3Bridge(id);
bridge.onSerialData = serialCallback;
bridge.onPinChange = (_gpioPin, _state) => {
// Cross-board routing handled in SimulatorCanvas
};
bridgeMap.set(id, bridge);
} else if (isEsp32Kind(boardKind)) {
const bridge = new Esp32Bridge(id, boardKind);
bridge.onSerialData = serialCallback;
bridge.onPinChange = (_gpioPin, _state) => {
// Cross-board routing handled in SimulatorCanvas
};
esp32BridgeMap.set(id, bridge);
} else {
const sim = createSimulator(
boardKind,
pm,
(ch) => {
set((s) => {
const boards = s.boards.map((b) =>
b.id === id ? { ...b, serialOutput: b.serialOutput + ch } : b
);
const isActive = s.activeBoardId === id;
return { boards, ...(isActive ? { serialOutput: s.serialOutput + ch } : {}) };
});
},
serialCallback,
(baud) => {
set((s) => {
const boards = s.boards.map((b) =>
@ -248,21 +272,6 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
getOscilloscopeCallback(),
);
simulatorMap.set(id, sim);
} else {
const bridge = new RaspberryPi3Bridge(id);
bridge.onSerialData = (ch) => {
set((s) => {
const boards = s.boards.map((b) =>
b.id === id ? { ...b, serialOutput: b.serialOutput + ch } : b
);
const isActive = s.activeBoardId === id;
return { boards, ...(isActive ? { serialOutput: s.serialOutput + ch } : {}) };
});
};
bridge.onPinChange = (_gpioPin, _state) => {
// Cross-board routing handled in SimulatorCanvas
};
bridgeMap.set(id, bridge);
}
const newBoard: BoardInstance = {
@ -274,6 +283,8 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
};
set((s) => ({ boards: [...s.boards, newBoard] }));
// Create the editor file group for this board
useEditorStore.getState().createFileGroup(`group-${id}`);
return id;
},
@ -283,6 +294,8 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
pinManagerMap.delete(boardId);
const bridge = getBoardBridge(boardId);
if (bridge) { bridge.disconnect(); bridgeMap.delete(boardId); }
const esp32Bridge = getEsp32Bridge(boardId);
if (esp32Bridge) { esp32Bridge.disconnect(); esp32BridgeMap.delete(boardId); }
set((s) => {
const boards = s.boards.filter((b) => b.id !== boardId);
const activeBoardId = s.activeBoardId === boardId
@ -321,32 +334,40 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
serialOutput: board.serialOutput,
serialBaudRate: board.serialBaudRate,
serialMonitorOpen: board.serialMonitorOpen,
remoteConnected: bridgeMap.get(boardId)?.connected ?? false,
remoteConnected: (bridgeMap.get(boardId)?.connected ?? esp32BridgeMap.get(boardId)?.connected) ?? false,
remoteSocket: null,
});
// Switch the editor to this board's file group
useEditorStore.getState().setActiveGroup(board.activeFileGroupId);
},
compileBoardProgram: (boardId: string, program: string) => {
const board = get().boards.find((b) => b.id === boardId);
if (!board) return;
const sim = getBoardSimulator(boardId);
if (sim && board.boardKind !== 'raspberry-pi-3') {
try {
if (sim instanceof AVRSimulator) {
sim.loadHex(program);
sim.addI2CDevice(new VirtualDS1307());
sim.addI2CDevice(new VirtualTempSensor());
sim.addI2CDevice(new I2CMemoryDevice(0x50));
} else if (sim instanceof RP2040Simulator) {
sim.loadBinary(program);
sim.addI2CDevice(new VirtualDS1307() as RP2040I2CDevice);
sim.addI2CDevice(new VirtualTempSensor() as RP2040I2CDevice);
sim.addI2CDevice(new I2CMemoryDevice(0x50) as RP2040I2CDevice);
if (isEsp32Kind(board.boardKind)) {
// For ESP32: program is base64-encoded .bin — send to QEMU via bridge
const esp32Bridge = getEsp32Bridge(boardId);
if (esp32Bridge) esp32Bridge.loadFirmware(program);
} else {
const sim = getBoardSimulator(boardId);
if (sim && board.boardKind !== 'raspberry-pi-3') {
try {
if (sim instanceof AVRSimulator) {
sim.loadHex(program);
sim.addI2CDevice(new VirtualDS1307());
sim.addI2CDevice(new VirtualTempSensor());
sim.addI2CDevice(new I2CMemoryDevice(0x50));
} else if (sim instanceof RP2040Simulator) {
sim.loadBinary(program);
sim.addI2CDevice(new VirtualDS1307() as RP2040I2CDevice);
sim.addI2CDevice(new VirtualTempSensor() as RP2040I2CDevice);
sim.addI2CDevice(new I2CMemoryDevice(0x50) as RP2040I2CDevice);
}
} catch (err) {
console.error(`compileBoardProgram(${boardId}):`, err);
return;
}
} catch (err) {
console.error(`compileBoardProgram(${boardId}):`, err);
return;
}
}
@ -367,11 +388,11 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
if (!board) return;
if (board.boardKind === 'raspberry-pi-3') {
const bridge = getBoardBridge(boardId);
if (bridge) bridge.connect();
getBoardBridge(boardId)?.connect();
} else if (isEsp32Kind(board.boardKind)) {
getEsp32Bridge(boardId)?.connect();
} else {
const sim = getBoardSimulator(boardId);
if (sim) sim.start();
getBoardSimulator(boardId)?.start();
}
set((s) => {
@ -389,6 +410,8 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
if (board.boardKind === 'raspberry-pi-3') {
getBoardBridge(boardId)?.disconnect();
} else if (isEsp32Kind(board.boardKind)) {
getEsp32Bridge(boardId)?.disconnect();
} else {
getBoardSimulator(boardId)?.stop();
}
@ -406,7 +429,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
const board = get().boards.find((b) => b.id === boardId);
if (!board) return;
if (board.boardKind !== 'raspberry-pi-3') {
if (board.boardKind !== 'raspberry-pi-3' && !isEsp32Kind(board.boardKind)) {
const sim = getBoardSimulator(boardId);
if (sim) {
sim.reset();

View File

@ -3,7 +3,10 @@ export type BoardKind =
| 'arduino-nano'
| 'arduino-mega'
| 'raspberry-pi-pico' // RP2040, browser emulation
| 'raspberry-pi-3'; // QEMU ARM64, backend
| 'raspberry-pi-3' // QEMU ARM64, backend
| 'esp32' // Xtensa LX6, QEMU backend
| 'esp32-s3' // Xtensa LX7, QEMU backend
| 'esp32-c3'; // RISC-V, QEMU backend
export interface BoardInstance {
id: string; // unique in canvas, e.g. 'arduino-uno', 'raspberry-pi-3'
@ -24,6 +27,9 @@ export const BOARD_KIND_LABELS: Record<BoardKind, string> = {
'arduino-mega': 'Arduino Mega 2560',
'raspberry-pi-pico': 'Raspberry Pi Pico',
'raspberry-pi-3': 'Raspberry Pi 3B',
'esp32': 'ESP32 DevKit',
'esp32-s3': 'ESP32-S3 DevKit',
'esp32-c3': 'ESP32-C3 DevKit',
};
export const BOARD_KIND_FQBN: Record<BoardKind, string | null> = {
@ -31,5 +37,8 @@ export const BOARD_KIND_FQBN: Record<BoardKind, string | null> = {
'arduino-nano': 'arduino:avr:nano:cpu=atmega328',
'arduino-mega': 'arduino:avr:mega',
'raspberry-pi-pico': 'rp2040:rp2040:rpipico',
'raspberry-pi-3': null, // compiled/run by QEMU, no arduino-cli
'raspberry-pi-3': null, // compiled/run by QEMU, no arduino-cli
'esp32': 'esp32:esp32:esp32',
'esp32-s3': 'esp32:esp32:esp32s3',
'esp32-c3': 'esp32:esp32:esp32c3',
};

View File

@ -116,9 +116,30 @@ export const PI3_BCM_TO_PHYSICAL: Record<number, number> = Object.fromEntries(
.map(([physical, bcm]) => [bcm, Number(physical)])
);
/**
* ESP32 DevKit-C GPIO pin names GPIO numbers.
* Pin names are GPIO numbers directly (GPIO0GPIO39).
* Special aliases: TX=1, RX=3.
*/
const ESP32_PIN_MAP: Record<string, number> = {
'TX': 1, 'RX': 3,
'GPIO0': 0, 'GPIO1': 1, 'GPIO2': 2, 'GPIO3': 3,
'GPIO4': 4, 'GPIO5': 5, 'GPIO6': 6, 'GPIO7': 7,
'GPIO8': 8, 'GPIO9': 9, 'GPIO10': 10, 'GPIO11': 11,
'GPIO12': 12, 'GPIO13': 13, 'GPIO14': 14, 'GPIO15': 15,
'GPIO16': 16, 'GPIO17': 17, 'GPIO18': 18, 'GPIO19': 19,
'GPIO20': 20, 'GPIO21': 21, 'GPIO22': 22, 'GPIO23': 23,
'GPIO25': 25, 'GPIO26': 26, 'GPIO27': 27,
'GPIO32': 32, 'GPIO33': 33, 'GPIO34': 34, 'GPIO35': 35,
'GPIO36': 36, 'GPIO39': 39,
// ADC aliases
'VP': 36, 'VN': 39,
};
/** All known board component IDs in the simulator */
export const BOARD_COMPONENT_IDS = [
'arduino-uno', 'arduino-nano', 'arduino-mega', 'nano-rp2040', 'raspberry-pi-3',
'esp32', 'esp32-s3', 'esp32-c3',
];
/**
@ -173,5 +194,13 @@ export function boardPinToNumber(boardId: string, pinName: string): number | nul
return null;
}
// ESP32 / ESP32-S3 / ESP32-C3 — GPIO numbers used directly
if (boardId === 'esp32' || boardId.startsWith('esp32')) {
// Try bare number first ("13" → 13)
const num = parseInt(pinName, 10);
if (!isNaN(num) && num >= 0 && num <= 39) return num;
return ESP32_PIN_MAP[pinName] ?? null;
}
return null;
}