import json import logging import socket from fastapi import APIRouter, WebSocket, WebSocketDisconnect from app.services.qemu_manager import qemu_manager from app.services.esp_qemu_manager import esp_qemu_manager from app.services.board_access import board_allowed, PRO_BOARD_MESSAGE from app.services.esp32_lib_manager import esp_lib_manager from app.services.stm32_lib_manager import stm32_lib_manager from app.core.hooks import dispatch_ws_sim_message def _find_free_port() -> int: """Allocate a free TCP port for WiFi hostfwd.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(('127.0.0.1', 0)) return s.getsockname()[1] router = APIRouter() logger = logging.getLogger(__name__) class ConnectionManager: def __init__(self): self.active_connections: dict[str, WebSocket] = {} async def connect(self, websocket: WebSocket, client_id: str): await websocket.accept() self.active_connections[client_id] = websocket def disconnect(self, client_id: str): self.active_connections.pop(client_id, None) async def send(self, client_id: str, message: str): ws = self.active_connections.get(client_id) if ws: await ws.send_text(message) manager = ConnectionManager() @router.websocket('/ws/{client_id}') 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.debug('[%s] gpio_change pin=%s state=%s', client_id, data.get('pin'), data.get('state')) elif event_type == 'system': logger.debug('[%s] system event: %s', client_id, data.get('event')) elif event_type == 'error': logger.error('[%s] error: %s', client_id, data.get('message')) elif event_type == 'serial_output': text = data.get('data', '') logger.debug('[%s] serial_output uart=%s len=%d: %r', client_id, data.get('uart', 0), len(text), text[:80]) payload = json.dumps({'type': event_type, 'data': data}) try: await manager.send(client_id, payload) except Exception as _send_exc: logger.debug('[%s] qemu_callback send failed (%s): %s', client_id, event_type, _send_exc) def _use_lib() -> bool: return esp_lib_manager.is_available() try: while True: raw = await websocket.receive_text() message = json.loads(raw) msg_type: str = message.get('type', '') msg_data: dict = message.get('data', {}) # ── Raspberry Pi ───────────────────────────────────────────── if msg_type == 'start_pi': board = msg_data.get('board', 'raspberry-pi-3') if not await board_allowed(websocket, board): await qemu_callback('error', {'message': PRO_BOARD_MESSAGE}) else: # msg_data carries whatever the client declared for this # session (an overlay may materialise extra drives from it). qemu_manager.start_instance(client_id, board, qemu_callback, msg_data) elif msg_type == 'stop_pi': qemu_manager.stop_instance(client_id) elif msg_type == 'serial_input': raw_bytes: list[int] = msg_data.get('bytes', []) if raw_bytes: await qemu_manager.send_serial_bytes(client_id, bytes(raw_bytes)) elif msg_type in ('gpio_in', 'pin_change'): pin = msg_data.get('pin', 0) state = msg_data.get('state', 0) qemu_manager.set_pin_state(client_id, pin, state) elif msg_type == 'pi_sensor_state': # Canvas-fed named values for overlay boards' built-in # sensors/buttons; the guest polls them via SENS requests. values = msg_data.get('values', {}) if isinstance(values, dict): qemu_manager.set_sensor_state(client_id, values) elif msg_type in ('pi_attach_slave', 'pi_detach_slave'): # Pluggable hook — pro overlay registers the actual handler # via qemu_manager.set_pi_slave_handler(). In the OSS image # the hook is unset and the message is silently dropped. handler = qemu_manager.get_pi_slave_handler() if handler is not None: action = 'attach' if msg_type == 'pi_attach_slave' else 'detach' try: await handler(client_id, action, msg_data) except Exception: logger.exception('[%s] %s handler crashed', client_id, msg_type) # ── ESP32 lifecycle ────────────────────────────────────────── elif msg_type == 'start_esp32': board = msg_data.get('board', 'esp32') firmware_b64 = msg_data.get('firmware_b64') sensors = msg_data.get('sensors', []) wifi_enabled = bool(msg_data.get('wifi_enabled', False)) sd_card = msg_data.get('sd_card') # {'image_b64': ...} when a microSD is wired fw_size_kb = round(len(firmware_b64) * 0.75 / 1024) if firmware_b64 else 0 lib_available = _use_lib() # Allocate a host port for WiFi hostfwd if WiFi is enabled wifi_hostfwd_port = _find_free_port() if wifi_enabled else 0 sd_kb = round(len(sd_card['image_b64']) * 0.75 / 1024) if sd_card and sd_card.get('image_b64') else 0 logger.info('[%s] start_esp32 board=%s firmware=%dKB lib_available=%s sensors=%d wifi=%s hostfwd=%d sd=%dKB', client_id, board, fw_size_kb, lib_available, len(sensors), wifi_enabled, wifi_hostfwd_port, sd_kb) if lib_available: await esp_lib_manager.start_instance( client_id, board, qemu_callback, firmware_b64, sensors, wifi_enabled=wifi_enabled, wifi_hostfwd_port=wifi_hostfwd_port, sd_card=sd_card) 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, wifi_enabled=wifi_enabled, wifi_hostfwd_port=wifi_hostfwd_port) elif msg_type == 'stop_esp32': await esp_lib_manager.stop_instance(client_id) esp_qemu_manager.stop_instance(client_id) elif msg_type == 'load_firmware': firmware_b64 = msg_data.get('firmware_b64', '') if firmware_b64: if _use_lib(): esp_lib_manager.load_firmware(client_id, firmware_b64) else: esp_qemu_manager.load_firmware(client_id, firmware_b64) # ── STM32 lifecycle (libqemu-arm via stm32_lib_manager) ────── elif msg_type == 'start_stm32': board = msg_data.get('board', 'stm32-bluepill') firmware_b64 = msg_data.get('firmware_b64') sensors = msg_data.get('sensors', []) fw_size_kb = round(len(firmware_b64) * 0.75 / 1024) if firmware_b64 else 0 lib_available = stm32_lib_manager.is_available() logger.info('[%s] start_stm32 board=%s firmware=%dKB lib_available=%s sensors=%d', client_id, board, fw_size_kb, lib_available, len(sensors)) if not await board_allowed(websocket, board): await qemu_callback('error', {'message': PRO_BOARD_MESSAGE}) elif lib_available: await stm32_lib_manager.start_instance( client_id, board, qemu_callback, firmware_b64, sensors) else: # No binary (OSS / self-hosted) — frame it as a Pro feature # rather than a raw "missing file" error. logger.warning('[%s] libqemu-arm not available', client_id) await qemu_callback('error', {'message': PRO_BOARD_MESSAGE}) elif msg_type == 'stop_stm32': await stm32_lib_manager.stop_instance(client_id) elif msg_type == 'stm32_load_firmware': firmware_b64 = msg_data.get('firmware_b64', '') if firmware_b64: stm32_lib_manager.load_firmware(client_id, firmware_b64) elif msg_type == 'stm32_gpio_in': pin = msg_data.get('pin', 0) state = msg_data.get('state', 0) stm32_lib_manager.set_pin_state(client_id, pin, state) elif msg_type == 'stm32_serial_input': raw_bytes: list[int] = msg_data.get('bytes', []) if raw_bytes: await stm32_lib_manager.send_serial_bytes( client_id, bytes(raw_bytes), msg_data.get('uart', 0)) elif msg_type == 'stm32_sensor_attach': sensor_type = msg_data.get('sensor_type', '') pin = int(msg_data.get('pin', 0)) stm32_lib_manager.sensor_attach(client_id, sensor_type, pin, msg_data) elif msg_type == 'stm32_sensor_update': pin = int(msg_data.get('pin', 0)) stm32_lib_manager.sensor_update(client_id, pin, msg_data) elif msg_type == 'stm32_sensor_detach': pin = int(msg_data.get('pin', 0)) stm32_lib_manager.sensor_detach(client_id, pin) # ── Pico W (CYW43439) WiFi bridge — overlay-provided ───────── # The chip-side gSPI emulator lives in the frontend; the userspace # network stack AND the paid-plan gate live in the velxio-prod # overlay (registered via register_ws_sim_handler). OSS has no # handler, so these messages are ignored and a Pico W has no WiFi. elif msg_type in ('start_picow', 'stop_picow', 'picow_packet_out'): await dispatch_ws_sim_message( websocket, client_id, msg_type, msg_data, qemu_callback, ) # ── ESP32 serial (UART 0 / 1 / 2) ─────────────────────────── elif msg_type == 'esp32_serial_input': raw_bytes = msg_data.get('bytes', []) uart_id = int(msg_data.get('uart', 0)) if raw_bytes: if _use_lib(): await esp_lib_manager.send_serial_bytes( client_id, bytes(raw_bytes), uart_id ) else: await esp_qemu_manager.send_serial_bytes( client_id, bytes(raw_bytes) ) # ── ESP32 GPIO input (from connected component / button) ────── elif msg_type == 'esp32_gpio_in': pin = msg_data.get('pin', 0) state = msg_data.get('state', 0) if _use_lib(): esp_lib_manager.set_pin_state(client_id, pin, state) else: esp_qemu_manager.set_pin_state(client_id, pin, state) # ── ESP32 ADC (analog input from potentiometer, sensor, etc.) ─ elif msg_type == 'esp32_adc_set': # Frontend sends {channel: int, millivolts: int} # or {channel: int, raw: int} for direct 12-bit value channel = int(msg_data.get('channel', 0)) if 'millivolts' in msg_data: if _use_lib(): esp_lib_manager.set_adc( client_id, channel, int(msg_data['millivolts']) ) elif 'raw' in msg_data: if _use_lib(): esp_lib_manager.set_adc_raw( client_id, channel, int(msg_data['raw']) ) # ── ESP32 ADC waveform LUT (periodic sampling for AC sources) ── # Frontend pushes a 12-bit sample array + period; QEMU interpolates # on every MMIO read using its virtual clock. This matches the # AVR/RP2040 per-read `onADCRead` hook so ADC samples see the # instantaneous SPICE waveform rather than a stale DC scalar. elif msg_type == 'esp32_adc_waveform': channel = int(msg_data.get('channel', 0)) samples_b64 = msg_data.get('samples_u12_b64', '') period_ns = int(msg_data.get('period_ns', 0)) if _use_lib() and hasattr(esp_lib_manager, 'set_adc_waveform'): esp_lib_manager.set_adc_waveform( client_id, channel, samples_b64, period_ns ) # ── ESP32 I2C device simulation ─────────────────────────────── elif msg_type == 'esp32_i2c_response': # Frontend configures what an I2C device at addr returns # {addr: int, response: int} addr = int(msg_data.get('addr', 0)) resp = int(msg_data.get('response', 0)) if _use_lib(): esp_lib_manager.set_i2c_response(client_id, addr, resp) # ── ESP32 SPI device simulation ─────────────────────────────── elif msg_type == 'esp32_spi_response': # {response: int} — byte to return as MISO resp = int(msg_data.get('response', 0xFF)) if _use_lib(): esp_lib_manager.set_spi_response(client_id, resp) # ── ESP32 UART 1 / 2 input ──────────────────────────────────── elif msg_type == 'esp32_uart1_input': raw_bytes = msg_data.get('bytes', []) if raw_bytes and _use_lib(): await esp_lib_manager.send_serial_bytes( client_id, bytes(raw_bytes), uart_id=1 ) elif msg_type == 'esp32_uart2_input': raw_bytes = msg_data.get('bytes', []) if raw_bytes and _use_lib(): await esp_lib_manager.send_serial_bytes( client_id, bytes(raw_bytes), uart_id=2 ) # ── ESP32 sensor protocol offloading (generic) ──────────────── elif msg_type == 'esp32_sensor_attach': sensor_type = msg_data.get('sensor_type', '') pin = int(msg_data.get('pin', 0)) if _use_lib(): esp_lib_manager.sensor_attach(client_id, sensor_type, pin, msg_data) else: esp_qemu_manager.sensor_attach(client_id, sensor_type, pin, msg_data) elif msg_type == 'esp32_sensor_update': pin = int(msg_data.get('pin', 0)) if _use_lib(): esp_lib_manager.sensor_update(client_id, pin, msg_data) else: esp_qemu_manager.sensor_update(client_id, pin, msg_data) elif msg_type == 'esp32_sensor_detach': pin = int(msg_data.get('pin', 0)) if _use_lib(): esp_lib_manager.sensor_detach(client_id, pin) else: esp_qemu_manager.sensor_detach(client_id, pin) # ── Cross-board I2C proxy: register a peer board's device on QEMU ── # Used when an ESP32 is wired to another board's I2C bus (Uno, Pico, # …) and that peer board has a virtual device the ESP32 firmware # should be able to read. The frontend snapshots the device's # register state and pushes it here; the worker installs a # ProxySlave at the address. elif msg_type == 'esp32_proxy_i2c_register': addr = int(msg_data.get('addr', 0)) & 0x7F regs_b64 = msg_data.get('regs_b64', '') if _use_lib(): esp_lib_manager.proxy_i2c_register(client_id, addr, regs_b64) elif msg_type == 'esp32_proxy_i2c_update': addr = int(msg_data.get('addr', 0)) & 0x7F regs_b64 = msg_data.get('regs_b64', '') if _use_lib(): esp_lib_manager.proxy_i2c_update(client_id, addr, regs_b64) elif msg_type == 'esp32_proxy_i2c_unregister': addr = int(msg_data.get('addr', 0)) & 0x7F if _use_lib(): esp_lib_manager.proxy_i2c_unregister(client_id, addr) # ── ESP32-CAM camera frame injection ─────────────────────────── # Browser pushes JPEGs from getUserMedia. Backend forwards to the # worker which writes them into the I²S camera peripheral. # See test/test-esp32-cam/autosearch/04_proposed_architecture.md elif msg_type == 'esp32_camera_attach': if _use_lib(): esp_lib_manager.camera_attach(client_id, msg_data) elif msg_type == 'esp32_camera_frame': if _use_lib(): esp_lib_manager.camera_frame( client_id, msg_data.get('b64', ''), fmt=msg_data.get('fmt', 'jpeg'), width=int(msg_data.get('w', 0)), height=int(msg_data.get('h', 0)), ) elif msg_type == 'esp32_camera_detach': if _use_lib(): esp_lib_manager.camera_detach(client_id) # ── ESP32 status query ──────────────────────────────────────── elif msg_type == 'esp32_status': if _use_lib(): status = esp_lib_manager.get_status(client_id) await manager.send( client_id, json.dumps({'type': 'esp32_status', 'data': status}) ) except WebSocketDisconnect: # Guard: only clean up if this coroutine still owns the connection for client_id. # A newer simulation_websocket may have already connected and replaced us. if manager.active_connections.get(client_id) is websocket: manager.disconnect(client_id) qemu_manager.stop_instance(client_id) await esp_lib_manager.stop_instance(client_id) esp_qemu_manager.stop_instance(client_id) else: logger.info('[%s] old WS session ended; newer session is active — skipping cleanup', client_id) except Exception as exc: logger.error('WebSocket error for %s: %s', client_id, exc) if manager.active_connections.get(client_id) is websocket: manager.disconnect(client_id) qemu_manager.stop_instance(client_id) await esp_lib_manager.stop_instance(client_id) esp_qemu_manager.stop_instance(client_id)