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
parent
8122d5d80a
commit
c3df484b4f
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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` }}
|
||||
/>
|
||||
);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (GPIO0–GPIO39).
|
||||
* 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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue