From f495a2ce3a3b15ad3b4038f8deb3fee100f66e0b Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Wed, 1 Apr 2026 22:41:52 -0300 Subject: [PATCH] feat: implement ESP32 QEMU backend manager and frontend simulation interface --- Dockerfile.standalone | 8 +++ .../esp-idf-template/main/CMakeLists.txt | 7 ++- .../esp-idf-template/sdkconfig.defaults | 13 +++++ backend/app/services/esp32_lib_manager.py | 4 +- backend/app/services/esp_qemu_manager.py | 11 +++- backend/app/services/espidf_compiler.py | 17 +++++- backend/app/services/wifi_status_parser.py | 7 ++- backend/tests/test_esp32_wifi.py | 4 +- backend/tests/test_esp32_wifi_webserver.py | 4 +- backend/tests/test_esp32c3_wifi.py | 4 +- frontend/public/components-metadata.json | 2 +- .../components/simulator/SerialMonitor.tsx | 52 ++++++++++++++++-- .../components/simulator/SimulatorCanvas.css | 14 +++++ .../components/simulator/SimulatorCanvas.tsx | 53 +++++++++++-------- frontend/src/simulation/Esp32Bridge.ts | 6 ++- 15 files changed, 165 insertions(+), 41 deletions(-) diff --git a/Dockerfile.standalone b/Dockerfile.standalone index 20a43f2..90444f0 100644 --- a/Dockerfile.standalone +++ b/Dockerfile.standalone @@ -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 diff --git a/backend/app/services/esp-idf-template/main/CMakeLists.txt b/backend/app/services/esp-idf-template/main/CMakeLists.txt index 801eb9f..95bcf9f 100644 --- a/backend/app/services/esp-idf-template/main/CMakeLists.txt +++ b/backend/app/services/esp-idf-template/main/CMakeLists.txt @@ -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 diff --git a/backend/app/services/esp-idf-template/sdkconfig.defaults b/backend/app/services/esp-idf-template/sdkconfig.defaults index d894be3..ace8c59 100644 --- a/backend/app/services/esp-idf-template/sdkconfig.defaults +++ b/backend/app/services/esp-idf-template/sdkconfig.defaults @@ -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 diff --git a/backend/app/services/esp32_lib_manager.py b/backend/app/services/esp32_lib_manager.py index ca34e60..b004af2 100644 --- a/backend/app/services/esp32_lib_manager.py +++ b/backend/app/services/esp32_lib_manager.py @@ -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 diff --git a/backend/app/services/esp_qemu_manager.py b/backend/app/services/esp_qemu_manager.py index 94c0b6a..bed48b1 100644 --- a/backend/app/services/esp_qemu_manager.py +++ b/backend/app/services/esp_qemu_manager.py @@ -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: diff --git a/backend/app/services/espidf_compiler.py b/backend/app/services/espidf_compiler.py index 6bd6cec..a093c0d 100644 --- a/backend/app/services/espidf_compiler.py +++ b/backend/app/services/espidf_compiler.py @@ -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') diff --git a/backend/app/services/wifi_status_parser.py b/backend/app/services/wifi_status_parser.py index db999d7..449eadf 100644 --- a/backend/app/services/wifi_status_parser.py +++ b/backend/app/services/wifi_status_parser.py @@ -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') diff --git a/backend/tests/test_esp32_wifi.py b/backend/tests/test_esp32_wifi.py index edd49fc..128aca5 100644 --- a/backend/tests/test_esp32_wifi.py +++ b/backend/tests/test_esp32_wifi.py @@ -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 diff --git a/backend/tests/test_esp32_wifi_webserver.py b/backend/tests/test_esp32_wifi_webserver.py index 7261f7d..e5e01da 100644 --- a/backend/tests/test_esp32_wifi_webserver.py +++ b/backend/tests/test_esp32_wifi_webserver.py @@ -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) diff --git a/backend/tests/test_esp32c3_wifi.py b/backend/tests/test_esp32c3_wifi.py index 102a133..4bcb2ab 100644 --- a/backend/tests/test_esp32c3_wifi.py +++ b/backend/tests/test_esp32c3_wifi.py @@ -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.""" diff --git a/frontend/public/components-metadata.json b/frontend/public/components-metadata.json index c57dc6e..95a14af 100644 --- a/frontend/public/components-metadata.json +++ b/frontend/public/components-metadata.json @@ -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", diff --git a/frontend/src/components/simulator/SerialMonitor.tsx b/frontend/src/components/simulator/SerialMonitor.tsx index e4db825..568cc9e 100644 --- a/frontend/src/components/simulator/SerialMonitor.tsx +++ b/frontend/src/components/simulator/SerialMonitor.tsx @@ -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 = { +const BOARD_SHORT_LABEL: Record = { 'arduino-uno': 'Uno', 'arduino-nano': 'Nano', 'arduino-mega': 'Mega', @@ -20,7 +21,7 @@ const BOARD_SHORT_LABEL: Record = { 'esp32-c3': 'ESP32-C3', }; -const BOARD_ICON: Record = { +const BOARD_ICON: Record = { 'arduino-uno': '⬤', 'arduino-nano': '▪', 'arduino-mega': '▬', @@ -31,7 +32,7 @@ const BOARD_ICON: Record = { 'esp32-c3': '⬡', }; -const BOARD_COLOR: Record = { +const BOARD_COLOR: Record = { 'arduino-uno': '#4fc3f7', 'arduino-nano': '#4fc3f7', 'arduino-mega': '#4fc3f7', @@ -173,8 +174,49 @@ export const SerialMonitor: React.FC = () => { {/* Output area */}
-        {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(
+                
+                  {m[0]} (Open IoT Gateway ↗)
+                
+              );
+              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')}
       
{/* Input row */} diff --git a/frontend/src/components/simulator/SimulatorCanvas.css b/frontend/src/components/simulator/SimulatorCanvas.css index 4e3af42..9d819ae 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.css +++ b/frontend/src/components/simulator/SimulatorCanvas.css @@ -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; } diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 4511848..4809380 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -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 = () => { {/* WiFi status indicator (ESP32 boards only) */} - {activeBoard && isEsp32Kind(activeBoard.boardKind) && activeBoard.wifiStatus && ( - - - - - - - - - )} + {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 ( + 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' + } + > + + + + + + + + ); + })()} {/* BLE status indicator (ESP32 boards only) */} {activeBoard && isEsp32Kind(activeBoard.boardKind) && activeBoard.bleStatus && ( diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index 8152f38..2c03b02 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -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;