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
parent
a99a9d512f
commit
b0b3a8763d
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
* NOT gate: A → Y
|
||||
*/
|
||||
|
||||
import { PartSimulationRegistry, PartSimulationLogic } from './PartSimulationRegistry';
|
||||
import { PartSimulationRegistry } from './PartSimulationRegistry';
|
||||
import type { PartSimulationLogic } from './PartSimulationRegistry';
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue