V1 feat: Enhance ESP32 emulation support and logging

- Added detailed logging for GPIO changes, system events, and errors in simulation websocket.
- Improved ESP32 firmware handling by merging individual binaries into a single 4MB flash image.
- Updated ESP32 bridge to handle serial output and GPIO changes with appropriate logging.
- Introduced integration test for ESP32 emulation, covering compilation, WebSocket connection, and event handling.
- Enhanced examples to include ESP32 projects and updated the examples gallery to reflect new board types.
- Refactored simulator store to manage ESP32 bridge and simulator instances more effectively.
- Updated requirements to include esptool for ESP32 firmware management.
pull/47/head
David Montero Crespo 2026-03-14 16:57:22 -03:00
parent a99a9d512f
commit b0b3a8763d
18 changed files with 543 additions and 73 deletions

View File

@ -34,6 +34,12 @@ async def simulation_websocket(websocket: WebSocket, client_id: str):
await manager.connect(websocket, client_id)
async def qemu_callback(event_type: str, data: dict) -> None:
if event_type == 'gpio_change':
logger.info('[%s] gpio_change pin=%s state=%s', client_id, data.get('pin'), data.get('state'))
elif event_type == 'system':
logger.info('[%s] system event: %s', client_id, data.get('event'))
elif event_type == 'error':
logger.error('[%s] error: %s', client_id, data.get('message'))
payload = json.dumps({'type': event_type, 'data': data})
await manager.send(client_id, payload)
@ -69,13 +75,18 @@ async def simulation_websocket(websocket: WebSocket, client_id: str):
elif msg_type == 'start_esp32':
board = msg_data.get('board', 'esp32')
firmware_b64 = msg_data.get('firmware_b64')
if _use_lib():
esp_lib_manager.start_instance(client_id, board, qemu_callback, firmware_b64)
fw_size_kb = round(len(firmware_b64) * 0.75 / 1024) if firmware_b64 else 0
lib_available = _use_lib()
logger.info('[%s] start_esp32 board=%s firmware=%dKB lib_available=%s',
client_id, board, fw_size_kb, lib_available)
if lib_available:
await esp_lib_manager.start_instance(client_id, board, qemu_callback, firmware_b64)
else:
logger.warning('[%s] libqemu-xtensa not available — using subprocess fallback', client_id)
esp_qemu_manager.start_instance(client_id, board, qemu_callback, firmware_b64)
elif msg_type == 'stop_esp32':
esp_lib_manager.stop_instance(client_id)
await esp_lib_manager.stop_instance(client_id)
esp_qemu_manager.stop_instance(client_id)
elif msg_type == 'load_firmware':
@ -168,11 +179,11 @@ async def simulation_websocket(websocket: WebSocket, client_id: str):
except WebSocketDisconnect:
manager.disconnect(client_id)
qemu_manager.stop_instance(client_id)
esp_lib_manager.stop_instance(client_id)
await esp_lib_manager.stop_instance(client_id)
esp_qemu_manager.stop_instance(client_id)
except Exception as exc:
logger.error('WebSocket error for %s: %s', client_id, exc)
manager.disconnect(client_id)
qemu_manager.stop_instance(client_id)
esp_lib_manager.stop_instance(client_id)
await esp_lib_manager.stop_instance(client_id)
esp_qemu_manager.stop_instance(client_id)

View File

@ -1,5 +1,8 @@
import logging
from contextlib import asynccontextmanager
logging.basicConfig(level=logging.INFO, format='%(levelname)s %(name)s: %(message)s')
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text

View File

@ -335,11 +335,35 @@ class ArduinoCLIService:
"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"
# ESP32 outputs individual .bin files that must be merged into a
# single 4MB flash image for QEMU lcgamboa to boot correctly.
bin_file = build_dir / "sketch.ino.bin"
bootloader_file = build_dir / "sketch.ino.bootloader.bin"
partitions_file = build_dir / "sketch.ino.partitions.bin"
merged_file = build_dir / "sketch.ino.merged.bin"
print(f"[ESP32] Build dir contents: {[f.name for f in build_dir.iterdir()]}")
# Merge individual .bin files into a single 4MB flash image in pure Python.
# ESP32 default flash layout: 0x1000 bootloader | 0x8000 partitions | 0x10000 app
# QEMU lcgamboa requires exactly 2/4/8/16 MB flash — raw app binary won't boot.
if not merged_file.exists() and bin_file.exists() and bootloader_file.exists() and partitions_file.exists():
print("[ESP32] Merging binaries into 4MB flash image (pure Python)...")
try:
FLASH_SIZE = 4 * 1024 * 1024 # 4 MB
flash = bytearray(b'\xff' * FLASH_SIZE)
for offset, path in [
(0x1000, bootloader_file),
(0x8000, partitions_file),
(0x10000, bin_file),
]:
data = path.read_bytes()
flash[offset:offset + len(data)] = data
merged_file.write_bytes(bytes(flash))
print(f"[ESP32] Merged image: {merged_file.stat().st_size} bytes")
except Exception as e:
print(f"[ESP32] Merge failed: {e} — falling back to raw app binary")
target_file = merged_file if merged_file.exists() else (bin_file if bin_file.exists() else None)
if target_file:

View File

@ -115,6 +115,7 @@ class Esp32LibBridge:
self._thread: threading.Thread | None = None
self._callbacks_ref: _CallbacksT | None = None # GC guard
self._firmware_path: str | None = None
self._stopped: bool = False # set on stop(); silences callbacks
# ── Listener/handler lists ────────────────────────────────────────
self._gpio_listeners: list = [] # fn(gpio_num: int, value: int)
@ -217,12 +218,28 @@ class Esp32LibBridge:
logger.info('lcgamboa QEMU started: machine=%s firmware=%s', machine, self._firmware_path)
def stop(self) -> None:
"""Terminate the QEMU instance and clean up."""
"""
Terminate the QEMU instance and block until the thread exits (5 s).
qemu_cleanup() is called here to request QEMU shutdown; the assertion
it raises on some platforms is non-fatal (glib prints "Bail out!" but
does not abort the process on Windows). We swallow all exceptions.
This method is intentionally synchronous/blocking so that callers can
run it in a thread-pool executor and await it from async code without
stalling the asyncio event loop.
"""
self._stopped = True
self._callbacks_ref = None # allow GC of ctypes callbacks early
try:
self._lib.qemu_cleanup()
except Exception as exc:
logger.debug('qemu_cleanup: %s', exc)
self._callbacks_ref = None
logger.debug('qemu_cleanup exception (expected): %s', exc)
# Wait for QEMU thread so the DLL global state is clean before re-init
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5.0)
if self._thread.is_alive():
logger.warning('QEMU thread still alive after 5 s — proceeding anyway')
if self._firmware_path and os.path.exists(self._firmware_path):
try:
os.unlink(self._firmware_path)
@ -314,12 +331,16 @@ class Esp32LibBridge:
def _on_pin_change(self, slot: int, value: int) -> None:
"""GPIO output changed — translate slot→GPIO, dispatch to async listeners."""
if self._stopped:
return
gpio = self._slot_to_gpio(slot)
for fn in self._gpio_listeners:
self._loop.call_soon_threadsafe(fn, gpio, value)
def _on_dir_change(self, slot: int, direction: int) -> None:
"""GPIO direction changed (0=input, 1=output)."""
if self._stopped:
return
gpio = self._slot_to_gpio(slot)
self._gpio_dir[gpio] = direction
for fn in self._dir_listeners:
@ -357,10 +378,14 @@ class Esp32LibBridge:
def _on_uart_tx(self, uart_id: int, byte_val: int) -> None:
"""UART TX byte transmitted by ESP32 firmware."""
if self._stopped:
return
for fn in self._uart_listeners:
self._loop.call_soon_threadsafe(fn, uart_id, byte_val)
def _on_rmt_event(self, channel: int, config0: int, value: int) -> None:
"""RMT pulse event — used for NeoPixel/WS2812, IR remotes, etc."""
if self._stopped:
return
for fn in self._rmt_listeners:
self._loop.call_soon_threadsafe(fn, channel, config0, value)

View File

@ -186,18 +186,19 @@ class EspLibManager:
# ── Public API ────────────────────────────────────────────────────────
def start_instance(
async def start_instance(
self,
client_id: str,
board_type: str,
callback: EventCallback,
firmware_b64: str | None = None,
) -> None:
# If an instance already exists, stop it first and wait for it to clean up
if client_id in self._instances:
logger.warning('start_instance: %s already running', client_id)
return
logger.info('start_instance: %s already running — stopping old instance first', client_id)
await self.stop_instance(client_id)
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
bridge = Esp32LibBridge(LIB_PATH, loop)
state = _InstanceState(bridge, callback, board_type)
self._instances[client_id] = state
@ -295,21 +296,24 @@ class EspLibManager:
bridge.register_spi_handler(_spi_sync)
bridge.register_rmt_listener(_async_wrap(_on_rmt))
asyncio.ensure_future(callback('system', {'event': 'booting'}))
await callback('system', {'event': 'booting'})
machine = _MACHINE.get(board_type, 'esp32-picsimlab')
if firmware_b64:
try:
bridge.start(firmware_b64, machine)
asyncio.ensure_future(callback('system', {'event': 'booted'}))
# bridge.start() blocks for up to 30 s waiting for qemu_init —
# run it in a thread-pool executor so the asyncio event loop
# stays responsive during QEMU startup.
await loop.run_in_executor(None, bridge.start, firmware_b64, machine)
await callback('system', {'event': 'booted'})
except Exception as exc:
logger.error('start_instance %s: bridge.start failed: %s', client_id, exc)
self._instances.pop(client_id, None)
asyncio.ensure_future(callback('error', {'message': str(exc)}))
await callback('error', {'message': str(exc)})
else:
logger.info('start_instance %s: no firmware, waiting for load_firmware()', client_id)
def stop_instance(self, client_id: str) -> None:
async def stop_instance(self, client_id: str) -> None:
state = self._instances.pop(client_id, None)
if not state:
return
@ -320,7 +324,10 @@ class EspLibManager:
state.callback('serial_output', {'data': remaining, 'uart': buf.uart_id})
)
try:
state.bridge.stop()
# bridge.stop() calls qemu_cleanup() + thread.join(5 s) — blocking.
# Run in executor so we don't stall the asyncio event loop.
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, state.bridge.stop)
except Exception as exc:
logger.warning('stop_instance %s: %s', client_id, exc)
@ -332,11 +339,11 @@ class EspLibManager:
return
board_type = state.board_type
callback = state.callback
self.stop_instance(client_id)
async def _restart() -> None:
await asyncio.sleep(0.3)
self.start_instance(client_id, board_type, callback, firmware_b64)
await self.stop_instance(client_id)
await asyncio.sleep(0.1)
await self.start_instance(client_id, board_type, callback, firmware_b64)
asyncio.create_task(_restart())

View File

@ -12,3 +12,4 @@ httpx==0.27.0
authlib==1.3.1
email-validator==2.2.0
mcp>=1.0.0
esptool>=4.7.0

View File

@ -1,6 +1,6 @@
{
"version": "1.0.0",
"generatedAt": "2026-03-14T15:29:57.121Z",
"generatedAt": "2026-03-14T18:11:39.708Z",
"components": [
{
"id": "arduino-mega",

View File

@ -218,6 +218,18 @@ export const ExamplesGallery: React.FC<ExamplesGalleryProps> = ({ onLoadExample
Pico
</span>
)}
{example.boardType === 'esp32' && (
<span className="example-board-badge" style={{
backgroundColor: '#e77d11',
color: '#fff',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '0.7rem',
fontWeight: 600,
}}>
ESP32
</span>
)}
</div>
</div>
</div>

View File

@ -58,14 +58,14 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
zIndex: 10, // Above wires (1) and components, below modals/dialogs (1000+)
}}
>
{pins.map((pin) => {
{pins.map((pin, index) => {
// Pin coordinates are already in CSS pixels
const pinX = pin.x;
const pinY = pin.y;
return (
<div
key={pin.name}
key={`${pin.name}-${index}`}
onClick={(e) => {
e.stopPropagation();
onPinClick(componentId, pin.name, componentX + wrapperOffsetX + pinX, componentY + wrapperOffsetY + pinY);

View File

@ -9,9 +9,11 @@ import { WireLayer } from './WireLayer';
import { BoardOnCanvas } from './BoardOnCanvas';
import { BoardPickerModal } from './BoardPickerModal';
import { PartSimulationRegistry } from '../../simulation/parts';
import { PinOverlay } from './PinOverlay';
import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping';
import type { ComponentMetadata } from '../../types/component-metadata';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_LABELS } from '../../types/board';
import { useOscilloscopeStore } from '../../store/useOscilloscopeStore';
import { useEditorStore } from '../../store/useEditorStore';
import './SimulatorCanvas.css';
@ -434,9 +436,20 @@ export const SimulatorCanvas = () => {
const otherEndpoint = isStartSelf ? wire.end : wire.start;
if (isBoardComponent(otherEndpoint.componentId)) {
const pin = boardPinToNumber(otherEndpoint.componentId, otherEndpoint.pinName);
// Use the board's actual boardKind (not just its instance ID) so that
// a board whose ID is 'arduino-uno' but whose kind is 'esp32' gets the
// correct GPIO mapping ('GPIO4' → 4, not null).
const boardInstance = boards.find(b => b.id === otherEndpoint.componentId);
const lookupKey = boardInstance ? boardInstance.boardKind : otherEndpoint.componentId;
const pin = boardPinToNumber(lookupKey, otherEndpoint.pinName);
console.log(
`[WirePin] component=${component.id} board=${otherEndpoint.componentId}` +
` kind=${lookupKey} pinName=${otherEndpoint.pinName} → gpioPin=${pin}`
);
if (pin !== null) {
subscribeComponentToPin(component, pin, selfEndpoint.pinName);
} else {
console.warn(`[WirePin] Could not resolve pin "${otherEndpoint.pinName}" on ${lookupKey}`);
}
}
});
@ -820,7 +833,7 @@ export const SimulatorCanvas = () => {
title="Active board"
>
{boards.map((b) => (
<option key={b.id} value={b.id}>{b.id}</option>
<option key={b.id} value={b.id}>{BOARD_KIND_LABELS[b.boardKind] ?? b.id}</option>
))}
</select>

View File

@ -11,7 +11,7 @@ export interface ExampleProject {
category: 'basics' | 'sensors' | 'displays' | 'communication' | 'games' | 'robotics';
difficulty: 'beginner' | 'intermediate' | 'advanced';
/** Target board — defaults to 'arduino-uno' if omitted */
boardType?: 'arduino-uno' | 'arduino-nano' | 'raspberry-pi-pico';
boardType?: 'arduino-uno' | 'arduino-nano' | 'raspberry-pi-pico' | 'esp32';
code: string;
components: Array<{
type: string;
@ -1957,6 +1957,80 @@ void loop() {
{ id: 'w-gpio', start: { componentId: 'nano-rp2040', pinName: 'D2' }, end: { componentId: 'led-gpio', pinName: 'A' }, color: '#00cc00' },
],
},
// ─── ESP32 Examples ───────────────────────────────────────────────────────
{
id: 'esp32-blink-led',
title: 'ESP32 Blink LED',
description: 'Blink the built-in LED on GPIO2 and an external red LED on GPIO4. Verifies ESP32 emulation is working.',
category: 'basics',
difficulty: 'beginner',
boardType: 'esp32',
code: `// ESP32 Blink LED
// Blinks the built-in LED (GPIO2) and an external LED (GPIO4)
// Requires arduino-esp32 2.0.17 (IDF 4.4.x) — see docs/ESP32_EMULATION.md
#define LED_BUILTIN_PIN 2 // Built-in blue LED on ESP32 DevKit
#define LED_EXT_PIN 4 // External red LED
void setup() {
Serial.begin(115200);
pinMode(LED_BUILTIN_PIN, OUTPUT);
pinMode(LED_EXT_PIN, OUTPUT);
Serial.println("ESP32 Blink ready!");
}
void loop() {
digitalWrite(LED_BUILTIN_PIN, HIGH);
digitalWrite(LED_EXT_PIN, HIGH);
Serial.println("LED ON");
delay(500);
digitalWrite(LED_BUILTIN_PIN, LOW);
digitalWrite(LED_EXT_PIN, LOW);
Serial.println("LED OFF");
delay(500);
}`,
components: [
{ type: 'wokwi-led', id: 'led-ext', x: 460, y: 190, properties: { color: 'red' } },
],
wires: [
// GPIO4 → LED anode (direct — subscription system needs board→component wire)
{ id: 'w-gpio4-led', start: { componentId: 'arduino-uno', pinName: 'GPIO4' }, end: { componentId: 'led-ext', pinName: 'A' }, color: '#e74c3c' },
// LED cathode → GND
{ id: 'w-gnd', start: { componentId: 'led-ext', pinName: 'C' }, end: { componentId: 'arduino-uno', pinName: 'GND' }, color: '#2c3e50' },
],
},
{
id: 'esp32-serial-echo',
title: 'ESP32 Serial Echo',
description: 'ESP32 reads from Serial and echoes back. Demonstrates multi-UART and Serial Monitor integration.',
category: 'communication',
difficulty: 'beginner',
boardType: 'esp32',
code: `// ESP32 Serial Echo
// Echoes anything received on Serial (UART0) back to the sender.
// Open the Serial Monitor, type something, and see it echoed back.
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("ESP32 Serial Echo ready!");
Serial.println("Type anything in the Serial Monitor...");
}
void loop() {
if (Serial.available()) {
String input = Serial.readStringUntil('\\n');
input.trim();
if (input.length() > 0) {
Serial.print("Echo: ");
Serial.println(input);
}
}
}`,
components: [],
wires: [],
},
];
// Get examples by category

View File

@ -10,12 +10,13 @@ import { ExamplesGallery } from '../components/examples/ExamplesGallery';
import { AppHeader } from '../components/layout/AppHeader';
import { useEditorStore } from '../store/useEditorStore';
import { useSimulatorStore } from '../store/useSimulatorStore';
import { isBoardComponent } from '../utils/boardPinMapping';
import type { ExampleProject } from '../data/examples';
export const ExamplesPage: React.FC = () => {
const navigate = useNavigate();
const { setCode } = useEditorStore();
const { setComponents, setWires, setBoardType } = useSimulatorStore();
const { setComponents, setWires, setBoardType, activeBoardId } = useSimulatorStore();
const handleLoadExample = (example: ExampleProject) => {
console.log('Loading example:', example.title);
@ -29,7 +30,10 @@ export const ExamplesPage: React.FC = () => {
// Filter out board components from examples (board is rendered separately in SimulatorCanvas)
const componentsWithoutBoard = example.components.filter(
(comp) => !comp.type.includes('arduino') && !comp.type.includes('pico')
(comp) =>
!comp.type.includes('arduino') &&
!comp.type.includes('pico') &&
!comp.type.includes('esp32')
);
// Load components into the simulator
@ -44,18 +48,23 @@ export const ExamplesPage: React.FC = () => {
}))
);
// Load wires (need to convert to full wire format with positions)
// For now, just set empty wires - wire positions will be calculated when components are loaded
// The active board's instance ID (DOM id of the board element).
// setBoardType changes boardKind but not the instance ID, so wires that
// reference any known board component ID must be remapped to this ID.
const boardInstanceId = activeBoardId ?? 'arduino-uno';
const remapBoardId = (id: string) => isBoardComponent(id) ? boardInstanceId : id;
// Load wires — positions are calculated by SimulatorCanvas after mount
const wiresWithPositions = example.wires.map((wire) => ({
id: wire.id,
start: {
componentId: wire.start.componentId,
componentId: remapBoardId(wire.start.componentId),
pinName: wire.start.pinName,
x: 0, // Will be calculated by SimulatorCanvas
x: 0,
y: 0,
},
end: {
componentId: wire.end.componentId,
componentId: remapBoardId(wire.end.componentId),
pinName: wire.end.pinName,
x: 0,
y: 0,

View File

@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/useAuthStore';
import { AppHeader } from '../components/layout/AppHeader';
import './LandingPage.css';
const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio';

View File

@ -79,6 +79,7 @@ export class Esp32Bridge {
socket.onopen = () => {
this._connected = true;
console.log(`[Esp32Bridge:${this.boardId}] WebSocket connected → sending start_esp32 (firmware: ${this._pendingFirmware ? `${Math.round(this._pendingFirmware.length * 0.75 / 1024)}KB` : 'none'})`);
this.onConnected?.();
this._send({
type: 'start_esp32',
@ -109,6 +110,7 @@ export class Esp32Bridge {
case 'gpio_change': {
const pin = msg.data.pin as number;
const state = (msg.data.state as number) === 1;
console.log(`[Esp32Bridge:${this.boardId}] gpio_change pin=${pin} state=${state ? 'HIGH' : 'LOW'}`);
this.onPinChange?.(pin, state);
break;
}
@ -142,6 +144,7 @@ export class Esp32Bridge {
}
case 'system': {
const evt = msg.data.event as string;
console.log(`[Esp32Bridge:${this.boardId}] system event: ${evt}`, msg.data);
if (evt === 'crash') {
this.onCrash?.(msg.data);
}
@ -149,18 +152,21 @@ export class Esp32Bridge {
break;
}
case 'error':
console.error(`[Esp32Bridge:${this.boardId}] error: ${msg.data.message as string}`);
this.onError?.(msg.data.message as string);
break;
}
};
socket.onclose = () => {
socket.onclose = (ev) => {
console.log(`[Esp32Bridge:${this.boardId}] WebSocket closed (code=${ev.code})`);
this._connected = false;
this.socket = null;
this.onDisconnected?.();
};
socket.onerror = () => {
socket.onerror = (ev) => {
console.error(`[Esp32Bridge:${this.boardId}] WebSocket error`, ev);
this.onError?.('WebSocket error');
};
}

View File

@ -8,7 +8,8 @@
* NOT gate: A Y
*/
import { PartSimulationRegistry, PartSimulationLogic } from './PartSimulationRegistry';
import { PartSimulationRegistry } from './PartSimulationRegistry';
import type { PartSimulationLogic } from './PartSimulationRegistry';
// ─── Helper ───────────────────────────────────────────────────────────────────

View File

@ -524,41 +524,82 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
const pm = getBoardPinManager(boardId) ?? legacyPinManager;
// Stop and remove old simulator
// Stop and remove old simulator / bridge
getBoardSimulator(boardId)?.stop();
simulatorMap.delete(boardId);
getEsp32Bridge(boardId)?.disconnect();
esp32BridgeMap.delete(boardId);
const sim = createSimulator(
type as BoardKind,
pm,
(ch) => set((s) => {
const boards = s.boards.map((b) =>
b.id === boardId ? { ...b, serialOutput: b.serialOutput + ch } : b
);
return { boards, serialOutput: s.serialOutput + ch };
}),
(baud) => set((s) => {
const boards = s.boards.map((b) =>
b.id === boardId ? { ...b, serialBaudRate: baud } : b
);
return { boards, serialBaudRate: baud };
}),
getOscilloscopeCallback(),
);
simulatorMap.set(boardId, sim);
const serialCallback = (ch: string) => set((s) => {
const boards = s.boards.map((b) =>
b.id === boardId ? { ...b, serialOutput: b.serialOutput + ch } : b
);
return { boards, serialOutput: s.serialOutput + ch };
});
set((s) => ({
boardType: type,
simulator: sim,
compiledHex: null,
serialOutput: '',
serialBaudRate: 0,
boards: s.boards.map((b) =>
b.id === boardId
? { ...b, boardKind: type as BoardKind, compiledProgram: null, serialOutput: '', serialBaudRate: 0 }
: b
),
}));
if (isEsp32Kind(type as BoardKind)) {
// ESP32: use bridge, not AVR simulator
const bridge = new Esp32Bridge(boardId, type as BoardKind);
bridge.onSerialData = serialCallback;
bridge.onPinChange = (gpioPin, state) => {
const boardPm = pinManagerMap.get(boardId);
if (boardPm) boardPm.triggerPinChange(gpioPin, state);
};
bridge.onCrash = () => { set({ esp32CrashBoardId: boardId }); };
bridge.onLedcUpdate = (update) => {
const boardPm = pinManagerMap.get(boardId);
if (boardPm && typeof boardPm.updatePwm === 'function') {
boardPm.updatePwm(update.channel, update.duty_pct);
}
};
bridge.onWs2812Update = (channel, pixels) => {
const eventTarget = document.getElementById(`ws2812-${boardId}-${channel}`);
if (eventTarget) {
eventTarget.dispatchEvent(new CustomEvent('ws2812-pixels', { detail: { pixels } }));
}
};
esp32BridgeMap.set(boardId, bridge);
set((s) => ({
boardType: type,
simulator: null,
compiledHex: null,
serialOutput: '',
serialBaudRate: 0,
boards: s.boards.map((b) =>
b.id === boardId
? { ...b, boardKind: type as BoardKind, compiledProgram: null, serialOutput: '', serialBaudRate: 0 }
: b
),
}));
} else {
const sim = createSimulator(
type as BoardKind,
pm,
serialCallback,
(baud) => set((s) => {
const boards = s.boards.map((b) =>
b.id === boardId ? { ...b, serialBaudRate: baud } : b
);
return { boards, serialBaudRate: baud };
}),
getOscilloscopeCallback(),
);
simulatorMap.set(boardId, sim);
set((s) => ({
boardType: type,
simulator: sim,
compiledHex: null,
serialOutput: '',
serialBaudRate: 0,
boards: s.boards.map((b) =>
b.id === boardId
? { ...b, boardKind: type as BoardKind, compiledProgram: null, serialOutput: '', serialBaudRate: 0 }
: b
),
}));
}
console.log(`Board switched to: ${type}`);
},

241
test_esp32_emulation.py Normal file
View File

@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
ESP32 Emulation Integration Test
=================================
Tests the full pipeline:
1. Compile ESP32 Blink sketch via HTTP POST /api/compile
2. Connect WebSocket to /api/simulation/ws/test-esp32
3. Send start_esp32 with the compiled 4MB firmware
4. Wait for system events (booting, booted) and gpio_change events
5. Report success/failure
Usage:
python test_esp32_emulation.py
python test_esp32_emulation.py --base http://localhost:8001
"""
import argparse
import asyncio
import io
import json
import sys
import time
# Force UTF-8 on Windows so checkmarks/symbols don't crash
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
import httpx
import websockets
BLINK_SKETCH = """\
// ESP32 Blink LED - Test Sketch
// Blinks GPIO4 at 500ms intervals, outputs status on Serial
#define LED_PIN 4
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
Serial.println("ESP32 Blink ready!");
}
void loop() {
digitalWrite(LED_PIN, HIGH);
Serial.println("LED ON");
delay(500);
digitalWrite(LED_PIN, LOW);
Serial.println("LED OFF");
delay(500);
}
"""
def print_section(title: str):
print(f"\n{'='*60}")
print(f" {title}")
print(f"{'='*60}")
async def run_test(base_url: str):
ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://")
# ── Step 1: Compile ───────────────────────────────────────────────────────
print_section("Step 1: Compile ESP32 Blink sketch")
async with httpx.AsyncClient(base_url=base_url, timeout=120.0) as client:
payload = {
"files": [{"name": "sketch.ino", "content": BLINK_SKETCH}],
"board_fqbn": "esp32:esp32:esp32",
}
print(f" POST {base_url}/api/compile/")
t0 = time.time()
resp = await client.post("/api/compile/", json=payload)
elapsed = time.time() - t0
print(f" Status: {resp.status_code} ({elapsed:.1f}s)")
if resp.status_code != 200:
print(f" FAIL: {resp.text}")
return False
data = resp.json()
if not data.get("success"):
print(f" FAIL: compilation failed")
print(f" stderr: {data.get('stderr', '')[:500]}")
return False
firmware_b64: str = data.get("binary_content", "")
fw_bytes = len(firmware_b64) * 3 // 4
print(f" OK — firmware {fw_bytes // 1024} KB base64-encoded")
if fw_bytes < 1024 * 1024:
print(f" WARN: firmware < 1 MB ({fw_bytes} bytes). "
f"QEMU needs a 4MB merged image. Expected ~4194304 bytes.")
print(f" This suggests the esptool merge step did not run.")
else:
print(f" OK — firmware size looks like a full flash image ✓")
# ── Step 2: WebSocket Simulation ─────────────────────────────────────────
print_section("Step 2: Connect WebSocket and start ESP32 emulation")
ws_endpoint = f"{ws_url}/api/simulation/ws/test-esp32"
print(f" Connecting to {ws_endpoint}")
results = {
"connected": False,
"booting": False,
"booted": False,
"serial_lines": [],
"gpio_changes": [],
"errors": [],
}
try:
async with websockets.connect(ws_endpoint, open_timeout=10) as ws:
results["connected"] = True
print(" WebSocket connected ✓")
# Send start_esp32 with firmware
msg = json.dumps({
"type": "start_esp32",
"data": {
"board": "esp32",
"firmware_b64": firmware_b64,
},
})
await ws.send(msg)
print(" Sent start_esp32 (firmware attached)")
# Listen for events for up to 20 seconds
deadline = time.time() + 20
print(" Waiting for events (up to 20s)...")
while time.time() < deadline:
remaining = deadline - time.time()
try:
raw = await asyncio.wait_for(ws.recv(), timeout=min(remaining, 2.0))
evt = json.loads(raw)
evt_type = evt.get("type", "")
evt_data = evt.get("data", {})
if evt_type == "system":
event_name = evt_data.get("event", "")
print(f" [system] {event_name}")
if event_name == "booting":
results["booting"] = True
elif event_name == "booted":
results["booted"] = True
elif event_name == "crash":
print(f" CRASH: {json.dumps(evt_data)}")
results["errors"].append(f"crash: {evt_data}")
elif evt_type == "serial_output":
text = evt_data.get("data", "")
sys.stdout.write(f" [serial] {text}")
sys.stdout.flush()
results["serial_lines"].append(text)
elif evt_type == "gpio_change":
pin = evt_data.get("pin")
state = evt_data.get("state")
label = "HIGH" if state == 1 else "LOW"
print(f" [gpio] pin={pin}{label}")
results["gpio_changes"].append((pin, state))
elif evt_type == "gpio_dir":
pin = evt_data.get("pin")
direction = "OUTPUT" if evt_data.get("dir") == 1 else "INPUT"
print(f" [gpio_dir] pin={pin}{direction}")
elif evt_type == "error":
msg_text = evt_data.get("message", "")
print(f" [error] {msg_text}")
results["errors"].append(msg_text)
# Stop early if we got at least 2 gpio toggles on pin 4
pin4_toggles = [(p, s) for p, s in results["gpio_changes"] if p == 4]
if len(pin4_toggles) >= 2:
print(f"\n Got {len(pin4_toggles)} GPIO4 toggles — stopping early ✓")
break
except asyncio.TimeoutError:
continue
except Exception as e:
print(f" WebSocket error: {e}")
results["errors"].append(str(e))
# ── Step 3: Report ────────────────────────────────────────────────────────
print_section("Test Results")
ok = True
checks = [
("WebSocket connected", results["connected"]),
("QEMU booting event", results["booting"]),
("QEMU booted event", results["booted"]),
("Serial output received", bool(results["serial_lines"])),
("GPIO4 toggled at least once", any(p == 4 for p, _ in results["gpio_changes"])),
("GPIO4 toggled HIGH+LOW", (
any(p == 4 and s == 1 for p, s in results["gpio_changes"]) and
any(p == 4 and s == 0 for p, s in results["gpio_changes"])
)),
]
for label, passed in checks:
icon = "" if passed else ""
print(f" {icon} {label}")
if not passed:
ok = False
if results["errors"]:
print(f"\n Errors encountered:")
for e in results["errors"]:
print(f" - {e}")
if results["gpio_changes"]:
print(f"\n GPIO changes recorded: {results['gpio_changes'][:10]}")
if results["serial_lines"]:
joined = "".join(results["serial_lines"])
print(f"\n Serial output (first 300 chars):\n {joined[:300]!r}")
print()
if ok:
print(" ALL CHECKS PASSED ✓ — ESP32 emulation is working end-to-end")
else:
print(" SOME CHECKS FAILED ✗ — see above for details")
print()
return ok
def main():
parser = argparse.ArgumentParser(description="ESP32 emulation integration test")
parser.add_argument("--base", default="http://localhost:8001",
help="Backend base URL (default: http://localhost:8001)")
args = parser.parse_args()
ok = asyncio.run(run_test(args.base))
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()

@ -0,0 +1 @@
Subproject commit 0cffc5bdac4acfab9c2d0771bd6ef31a56c6d3c8