feat: implement ESP32 QEMU backend manager and frontend simulation interface
parent
69aaa070f2
commit
f495a2ce3a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue