feat: implement ESP32 QEMU backend manager and frontend simulation interface

pull/90/head^2
David Montero Crespo 2026-04-01 22:41:52 -03:00
parent 69aaa070f2
commit f495a2ce3a
15 changed files with 165 additions and 41 deletions

View File

@ -46,6 +46,11 @@ RUN ./install.sh esp32,esp32c3
RUN rm -rf .git docs examples \
&& find /root/.espressif -name '*.tar.*' -delete 2>/dev/null || true
# Install Arduino-as-component for full Arduino API support in ESP-IDF builds
RUN git clone --branch 2.0.17 --depth=1 --recursive --shallow-submodules \
https://github.com/espressif/arduino-esp32.git /opt/arduino-esp32 \
&& rm -rf /opt/arduino-esp32/.git
# ---- Stage 1: Build frontend and wokwi-libs ----
FROM node:20 AS frontend-builder
@ -157,8 +162,11 @@ ENV QEMU_RISCV32_LIB=/app/lib/libqemu-riscv32.so
COPY --from=espidf-builder /opt/esp-idf /opt/esp-idf
COPY --from=espidf-builder /root/.espressif /root/.espressif
COPY --from=espidf-builder /opt/arduino-esp32 /opt/arduino-esp32
ENV IDF_PATH=/opt/esp-idf
ENV IDF_TOOLS_PATH=/root/.espressif
ENV ARDUINO_ESP32_PATH=/opt/arduino-esp32
# Install ESP-IDF Python dependencies using the final image's Python
# The requirements.txt has version constraints required by ESP-IDF 4.4.x

View File

@ -1,9 +1,12 @@
# If Arduino component is available, use C++ main and link Arduino
# If Arduino component is available, use C++ main and link Arduino.
# The component name matches the directory basename of ARDUINO_ESP32_PATH
# (e.g. "arduino-esp32" when cloned from GitHub).
if(DEFINED ENV{ARDUINO_ESP32_PATH})
get_filename_component(_arduino_comp_name $ENV{ARDUINO_ESP32_PATH} NAME)
idf_component_register(
SRCS "main.cpp"
INCLUDE_DIRS "."
REQUIRES arduino
REQUIRES ${_arduino_comp_name}
)
else()
# Pure ESP-IDF mode: main.c #includes sketch_translated.c

View File

@ -28,3 +28,16 @@ CONFIG_FREERTOS_HZ=1000
# Console
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
# Arduino-as-component: we provide our own app_main (main.cpp)
CONFIG_AUTOSTART_ARDUINO=n
# Increase main task stack for Arduino sketches
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
# Enable C++ exceptions (some Arduino libraries require it)
CONFIG_COMPILER_CXX_EXCEPTIONS=y
# Bluetooth for BLE examples
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y

View File

@ -95,7 +95,9 @@ class _UartBuffer:
"""Add one byte. Returns decoded string when a flush occurs, else None."""
with self._lock:
self._buf.append(byte_val)
if byte_val == ord('\n') or len(self._buf) >= self.flush_size:
# Flush on newline, carriage return, period, or max size
# This ensures progress dots '...' don't buffer endlessly.
if byte_val in (ord('\n'), ord('\r'), ord('.')) or len(self._buf) >= self.flush_size:
text = self._buf.decode('utf-8', errors='replace')
self._buf.clear()
return text

View File

@ -29,6 +29,7 @@ import socket
import tempfile
import time
from typing import Callable, Awaitable
from app.services.wifi_status_parser import parse_serial_text
logger = logging.getLogger(__name__)
@ -212,7 +213,7 @@ class EspQemuManager:
nic_model = 'esp32c3_wifi' if 'c3' in machine else 'esp32_wifi'
nic_arg = f'user,model={nic_model},net=192.168.4.0/24'
if wifi_hostfwd_port:
nic_arg += f',hostfwd=tcp::{wifi_hostfwd_port}-192.168.4.2:80'
nic_arg += f',hostfwd=tcp::{wifi_hostfwd_port}-192.168.4.15:80'
cmd += ['-nic', nic_arg]
logger.info('Launching ESP32 QEMU for %s: %s', inst.client_id, ' '.join(cmd))
@ -263,7 +264,13 @@ class EspQemuManager:
buf.extend(chunk)
text = buf.decode('utf-8', errors='replace')
buf.clear()
await inst.emit('serial_output', {'data': text})
asyncio.create_task(inst.emit('serial_output', {'data': text}))
# Parse WiFi/BLE status from serial output
wifi_evts, ble_evts = parse_serial_text(text)
for we in wifi_evts:
asyncio.create_task(inst.emit('wifi_status', dict(we)))
for be in ble_evts:
asyncio.create_task(inst.emit('ble_status', dict(be)))
except asyncio.TimeoutError:
continue
except Exception as e:

View File

@ -57,9 +57,24 @@ class ESPIDFCompiler:
self.idf_path = candidate
break
# Auto-detect Arduino-as-component if not explicitly set
if self.idf_path and not self.has_arduino:
for candidate in [
r'C:\Espressif\components\arduino-esp32',
os.path.join(self.idf_path, '..', 'components', 'arduino-esp32'),
'/opt/arduino-esp32',
]:
if os.path.isdir(candidate):
self.arduino_path = os.path.abspath(candidate)
self.has_arduino = True
break
if self.idf_path:
logger.info(f'[espidf] IDF_PATH={self.idf_path}')
logger.info(f'[espidf] Arduino component: {"yes" if self.has_arduino else "no"}')
if self.has_arduino:
logger.info(f'[espidf] Arduino component: yes ({self.arduino_path})')
else:
logger.info('[espidf] Arduino component: no (pure ESP-IDF fallback)')
else:
logger.warning('[espidf] IDF_PATH not set — ESP-IDF compilation unavailable')

View File

@ -48,6 +48,8 @@ _RE_WIFI_DISCONNECT = re.compile(
)
# Also catch "WiFi.begin" style Arduino logs
_RE_WIFI_BEGIN = re.compile(r'wifi\s*:\s*new\s*:\s*([^,]+)', re.IGNORECASE)
_RE_WIFI_MODE_STA = re.compile(r'wifi\s*:\s*mode\s*:\s*sta', re.IGNORECASE)
_RE_WIFI_CONNECTING = re.compile(r'Connecting\s+to\s+WiFi', re.IGNORECASE)
# ── BLE patterns ─────────────────────────────────────────────────────────────
@ -69,9 +71,12 @@ def parse_wifi_line(line: str) -> WifiEvent | None:
m = _RE_WIFI_BEGIN.search(line)
return WifiEvent(status='connected', ssid=m.group(1).strip() if m else '')
if _RE_WIFI_STA_START.search(line):
if _RE_WIFI_STA_START.search(line) or _RE_WIFI_MODE_STA.search(line):
return WifiEvent(status='initializing')
if _RE_WIFI_CONNECTING.search(line):
return WifiEvent(status='connected', ssid='Velxio-GUEST')
if _RE_WIFI_DISCONNECT.search(line):
return WifiEvent(status='disconnected')

View File

@ -58,7 +58,7 @@ class TestEsp32WorkerWifiArgs(unittest.TestCase):
args = self._simulate_args(cfg)
nic_idx = args.index(b'-nic')
nic_val = args[nic_idx + 1].decode()
self.assertIn('hostfwd=tcp::12345-192.168.4.2:80', nic_val)
self.assertIn('hostfwd=tcp::12345-192.168.4.15:80', nic_val)
def test_hostfwd_absent_when_port_zero(self):
"""When wifi_hostfwd_port is 0, hostfwd should NOT appear."""
@ -88,7 +88,7 @@ class TestEsp32WorkerWifiArgs(unittest.TestCase):
nic_model = 'esp32c3_wifi' if 'c3' in machine else 'esp32_wifi'
nic_arg = f'user,model={nic_model},net=192.168.4.0/24'
if wifi_hostfwd_port:
nic_arg += f',hostfwd=tcp::{wifi_hostfwd_port}-192.168.4.2:80'
nic_arg += f',hostfwd=tcp::{wifi_hostfwd_port}-192.168.4.15:80'
args_list.extend([b'-nic', nic_arg.encode()])
return args_list

View File

@ -135,7 +135,7 @@ class TestQemuNicArgs(unittest.TestCase):
nic_model = 'esp32c3_wifi' if 'c3' in machine else 'esp32_wifi'
nic_arg = f'user,model={nic_model},net=192.168.4.0/24'
if hostfwd_port:
nic_arg += f',hostfwd=tcp::{hostfwd_port}-192.168.4.2:80'
nic_arg += f',hostfwd=tcp::{hostfwd_port}-192.168.4.15:80'
args.extend([b'-nic', nic_arg.encode()])
return args
@ -157,7 +157,7 @@ class TestQemuNicArgs(unittest.TestCase):
"""WebServer listens on port 80, hostfwd should route to it."""
args = self.build_args(wifi_enabled=True, hostfwd_port=54321)
nic_val = args[args.index(b'-nic') + 1].decode()
self.assertIn('hostfwd=tcp::54321-192.168.4.2:80', nic_val)
self.assertIn('hostfwd=tcp::54321-192.168.4.15:80', nic_val)
def test_no_hostfwd_when_port_zero(self):
args = self.build_args(wifi_enabled=True, hostfwd_port=0)

View File

@ -30,7 +30,7 @@ class TestEsp32C3QemuNicArgs(unittest.TestCase):
nic_model = 'esp32c3_wifi' if 'c3' in machine else 'esp32_wifi'
nic_arg = f'user,model={nic_model},net=192.168.4.0/24'
if hostfwd_port:
nic_arg += f',hostfwd=tcp::{hostfwd_port}-192.168.4.2:80'
nic_arg += f',hostfwd=tcp::{hostfwd_port}-192.168.4.15:80'
args.extend([b'-nic', nic_arg.encode()])
return args
@ -69,7 +69,7 @@ class TestEsp32C3QemuNicArgs(unittest.TestCase):
args = self._simulate_args(wifi_enabled=True, hostfwd_port=12345)
nic_idx = args.index(b'-nic')
nic_val = args[nic_idx + 1].decode()
self.assertIn('hostfwd=tcp::12345-192.168.4.2:80', nic_val)
self.assertIn('hostfwd=tcp::12345-192.168.4.15:80', nic_val)
def test_c3_hostfwd_absent_when_port_zero(self):
"""No hostfwd when port is 0."""

View File

@ -1,6 +1,6 @@
{
"version": "1.0.0",
"generatedAt": "2026-03-24T02:30:06.881Z",
"generatedAt": "2026-04-02T01:25:51.523Z",
"components": [
{
"id": "arduino-mega",

View File

@ -5,11 +5,12 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { getTabSessionId } from '../../simulation/Esp32Bridge';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_LABELS } from '../../types/board';
// Short labels for tabs
const BOARD_SHORT_LABEL: Record<BoardKind, string> = {
const BOARD_SHORT_LABEL: Record<string, string> = {
'arduino-uno': 'Uno',
'arduino-nano': 'Nano',
'arduino-mega': 'Mega',
@ -20,7 +21,7 @@ const BOARD_SHORT_LABEL: Record<BoardKind, string> = {
'esp32-c3': 'ESP32-C3',
};
const BOARD_ICON: Record<BoardKind, string> = {
const BOARD_ICON: Record<string, string> = {
'arduino-uno': '⬤',
'arduino-nano': '▪',
'arduino-mega': '▬',
@ -31,7 +32,7 @@ const BOARD_ICON: Record<BoardKind, string> = {
'esp32-c3': '⬡',
};
const BOARD_COLOR: Record<BoardKind, string> = {
const BOARD_COLOR: Record<string, string> = {
'arduino-uno': '#4fc3f7',
'arduino-nano': '#4fc3f7',
'arduino-mega': '#4fc3f7',
@ -173,8 +174,49 @@ export const SerialMonitor: React.FC = () => {
{/* Output area */}
<pre ref={outputRef} style={styles.output}>
{activeBoard?.serialOutput ||
(activeBoard?.running ? 'Waiting for serial data...\n' : 'Start simulation to see serial output.\n')}
{activeBoard?.serialOutput ? (() => {
const text = activeBoard.serialOutput;
const ipRegex = /http:\/\/192\.168\.4\.(\d+)(\/[^\s]*)?/g;
const matches = [...text.matchAll(ipRegex)];
if (matches.length > 0) {
const parts: (string | React.ReactNode)[] = [];
let lastIdx = 0;
const sessionId = getTabSessionId();
const backendBase = (import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8001/api';
matches.forEach((m, i) => {
const start = m.index!;
const end = start + m[0].length;
const path = m[2] || '/';
const clientId = `${sessionId}::${activeBoard.id}`;
const gatewayUrl = `${backendBase}/gateway/${clientId}${path}`;
parts.push(text.slice(lastIdx, start));
parts.push(
<a
key={i}
href={gatewayUrl}
target="_blank"
rel="noreferrer"
style={{
color: '#4fc3f7',
textDecoration: 'underline',
fontWeight: 'bold',
cursor: 'pointer'
}}
title="Click to open through IoT Gateway"
>
{m[0]} (Open IoT Gateway )
</a>
);
lastIdx = end;
});
parts.push(text.slice(lastIdx));
return parts;
}
return text;
})() : (activeBoard?.running ? 'Waiting for serial data...\n' : 'Start simulation to see serial output.\n')}
</pre>
{/* Input row */}

View File

@ -356,6 +356,20 @@
.canvas-wifi-got_ip { color: #22c55e; }
.canvas-wifi-disconnected { color: #6b7280; }
.canvas-wifi-clickable {
cursor: pointer !important;
transition: transform 0.2s, filter 0.2s;
}
.canvas-wifi-clickable:hover {
transform: scale(1.15);
filter: drop-shadow(0 0 5px currentColor);
}
.canvas-wifi-clickable:active {
transform: scale(0.95);
}
/* BLE status colours */
.canvas-ble-initialized { color: #3b82f6; }
.canvas-ble-advertising { color: #6366f1; }

View File

@ -8,6 +8,7 @@ import { SENSOR_CONTROLS } from '../../simulation/sensorControlConfig';
import { DynamicComponent, createComponentFromMetadata } from '../DynamicComponent';
import { ComponentRegistry } from '../../services/ComponentRegistry';
import { PinSelector } from './PinSelector';
import { getTabSessionId } from '../../simulation/Esp32Bridge';
import { WireLayer } from './WireLayer';
import type { SegmentHandle } from './WireLayer';
import { BoardOnCanvas } from './BoardOnCanvas';
@ -1276,27 +1277,37 @@ export const SimulatorCanvas = () => {
</button>
{/* WiFi status indicator (ESP32 boards only) */}
{activeBoard && isEsp32Kind(activeBoard.boardKind) && activeBoard.wifiStatus && (
<span
className={`canvas-wifi-badge canvas-wifi-${activeBoard.wifiStatus.status}`}
title={
activeBoard.wifiStatus.status === 'got_ip'
? `WiFi: ${activeBoard.wifiStatus.ssid ?? 'Velxio-GUEST'} — IP: ${activeBoard.wifiStatus.ip}`
: activeBoard.wifiStatus.status === 'connected'
? `WiFi: ${activeBoard.wifiStatus.ssid ?? 'Velxio-GUEST'} — Connecting...`
: activeBoard.wifiStatus.status === 'initializing'
? 'WiFi: Initializing...'
: 'WiFi: Disconnected'
}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
<circle cx="12" cy="20" r="1" />
</svg>
</span>
)}
{activeBoard && isEsp32Kind(activeBoard.boardKind) && activeBoard.wifiStatus && (() => {
const status = activeBoard.wifiStatus.status;
const hasIp = status === 'got_ip';
const sessionId = getTabSessionId();
const clientId = `${sessionId}::${activeBoard.id}`;
const backendBase = (import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8001/api';
const gatewayUrl = `${backendBase}/gateway/${clientId}/`;
return (
<span
className={`canvas-wifi-badge canvas-wifi-${status}${hasIp ? ' canvas-wifi-clickable' : ''}`}
onClick={() => hasIp && window.open(gatewayUrl, '_blank')}
title={
hasIp
? `WiFi: ${activeBoard.wifiStatus.ssid ?? 'Velxio-GUEST'} — IP: ${activeBoard.wifiStatus.ip}\nClick to open IoT Gateway ↗`
: status === 'connected'
? `WiFi: ${activeBoard.wifiStatus.ssid ?? 'Velxio-GUEST'} — Connecting...`
: status === 'initializing'
? 'WiFi: Initializing...'
: 'WiFi: Disconnected'
}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
<circle cx="12" cy="20" r="1" />
</svg>
</span>
);
})()}
{/* BLE status indicator (ESP32 boards only) */}
{activeBoard && isEsp32Kind(activeBoard.boardKind) && activeBoard.bleStatus && (

View File

@ -46,7 +46,7 @@ const API_BASE = (): string =>
(import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8001/api';
/** Returns a stable UUID for this browser tab (persists across reloads, resets on new tab). */
function getTabSessionId(): string {
export function getTabSessionId(): string {
// sessionStorage is not available in Node/test environments
if (typeof sessionStorage === 'undefined') return crypto.randomUUID();
const KEY = 'velxio-tab-id';
@ -100,6 +100,10 @@ export class Esp32Bridge {
return this._connected;
}
get clientId(): string {
return getTabSessionId() + '::' + this.boardId;
}
connect(): void {
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) return;