diff --git a/backend/app/api/routes/simulation.py b/backend/app/api/routes/simulation.py index 739a08b..a2f0cab 100644 --- a/backend/app/api/routes/simulation.py +++ b/backend/app/api/routes/simulation.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index c046bd1..3e5ea23 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/services/arduino_cli.py b/backend/app/services/arduino_cli.py index 60edd7d..c09c11d 100644 --- a/backend/app/services/arduino_cli.py +++ b/backend/app/services/arduino_cli.py @@ -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: diff --git a/backend/app/services/esp32_lib_bridge.py b/backend/app/services/esp32_lib_bridge.py index d446de6..2bb539c 100644 --- a/backend/app/services/esp32_lib_bridge.py +++ b/backend/app/services/esp32_lib_bridge.py @@ -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) diff --git a/backend/app/services/esp32_lib_manager.py b/backend/app/services/esp32_lib_manager.py index 9690a42..c2b9885 100644 --- a/backend/app/services/esp32_lib_manager.py +++ b/backend/app/services/esp32_lib_manager.py @@ -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()) diff --git a/backend/requirements.txt b/backend/requirements.txt index cc9c282..f170e7c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/public/components-metadata.json b/frontend/public/components-metadata.json index 5ee7032..605712c 100644 --- a/frontend/public/components-metadata.json +++ b/frontend/public/components-metadata.json @@ -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", diff --git a/frontend/src/components/examples/ExamplesGallery.tsx b/frontend/src/components/examples/ExamplesGallery.tsx index b1a0d5b..9f129e7 100644 --- a/frontend/src/components/examples/ExamplesGallery.tsx +++ b/frontend/src/components/examples/ExamplesGallery.tsx @@ -218,6 +218,18 @@ export const ExamplesGallery: React.FC = ({ onLoadExample Pico )} + {example.boardType === 'esp32' && ( + + ESP32 + + )} diff --git a/frontend/src/components/simulator/PinOverlay.tsx b/frontend/src/components/simulator/PinOverlay.tsx index f18beed..478fb74 100644 --- a/frontend/src/components/simulator/PinOverlay.tsx +++ b/frontend/src/components/simulator/PinOverlay.tsx @@ -58,14 +58,14 @@ export const PinOverlay: React.FC = ({ 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 (
{ e.stopPropagation(); onPinClick(componentId, pin.name, componentX + wrapperOffsetX + pinX, componentY + wrapperOffsetY + pinY); diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index bc28af3..9c360c3 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -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) => ( - + ))} diff --git a/frontend/src/data/examples.ts b/frontend/src/data/examples.ts index 8d735db..887f52e 100644 --- a/frontend/src/data/examples.ts +++ b/frontend/src/data/examples.ts @@ -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 diff --git a/frontend/src/pages/ExamplesPage.tsx b/frontend/src/pages/ExamplesPage.tsx index 1d175b4..1ecdbb0 100644 --- a/frontend/src/pages/ExamplesPage.tsx +++ b/frontend/src/pages/ExamplesPage.tsx @@ -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, diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index 97878bc..c10336c 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -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'; diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index 4e4541e..f903adb 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -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'); }; } diff --git a/frontend/src/simulation/parts/LogicGateParts.ts b/frontend/src/simulation/parts/LogicGateParts.ts index 4a97cca..fc58b95 100644 --- a/frontend/src/simulation/parts/LogicGateParts.ts +++ b/frontend/src/simulation/parts/LogicGateParts.ts @@ -8,7 +8,8 @@ * NOT gate: A → Y */ -import { PartSimulationRegistry, PartSimulationLogic } from './PartSimulationRegistry'; +import { PartSimulationRegistry } from './PartSimulationRegistry'; +import type { PartSimulationLogic } from './PartSimulationRegistry'; // ─── Helper ─────────────────────────────────────────────────────────────────── diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 72d693f..72d9234 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -524,41 +524,82 @@ export const useSimulatorStore = create((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}`); }, diff --git a/test_esp32_emulation.py b/test_esp32_emulation.py new file mode 100644 index 0000000..bc42db7 --- /dev/null +++ b/test_esp32_emulation.py @@ -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() diff --git a/wokwi-libs/wokwi-boards b/wokwi-libs/wokwi-boards new file mode 160000 index 0000000..0cffc5b --- /dev/null +++ b/wokwi-libs/wokwi-boards @@ -0,0 +1 @@ +Subproject commit 0cffc5bdac4acfab9c2d0771bd6ef31a56c6d3c8