2026-03-12 18:17:29 +07:00
|
|
|
import json
|
|
|
|
|
import logging
|
2026-04-01 06:53:56 +07:00
|
|
|
import socket
|
2026-03-12 18:17:29 +07:00
|
|
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
|
|
|
from app.services.qemu_manager import qemu_manager
|
2026-03-14 06:35:48 +07:00
|
|
|
from app.services.esp_qemu_manager import esp_qemu_manager
|
feat: STM32 (Blue Pill / Black Pill) QEMU emulation + Pro board gating
STM32 emulation (open-core, runs via libqemu-arm in the backend worker):
- backend: stm32_lib_manager + stm32_worker (GPIO, USART, I2C/SPI device models
reusing the ESP32 slaves, live sensor updates), arduino_cli STM32 branch,
start_stm32 simulation route.
- frontend: Stm32Bridge + Stm32BluePill(/BlackPill) web components (Wokwi SVGs),
board kinds, Interconnect/boardPinMapping/boardProtocols wiring, example
projects (blink, serial, I2C BMP280/MPU6050/DS1307/SSD1306/weather, 7-seg,
RGB, button, switch, stepper, cross-board interconnect).
- Raspberry Pi 4/5 board elements + thumbnails.
Pro board gating (generic OSS->Pro seam; entitlement logic lives in the overlay):
- lib/proBoardGate.ts: isProBoardKind (STM32 + every QEMU Raspberry Pi),
installBoardGateImpl/boardGateDecision, triggerProUpgradePrompt.
- PRO badge on those boards in the component picker; gate at the picker add +
the run backstop (startBoard).
- backend/app/services/board_access.py: server-side enforcement seam for the
simulation WebSocket; STM32/Pi unavailable -> Pro-framed message.
- desktop: generic QemuDownloadPrompt + Stm32QemuPrompt (download-behind-license,
mirrors the ESP32 prompt).
- .gitignore: never ship libqemu-* binaries in the public image.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 05:06:14 +07:00
|
|
|
from app.services.board_access import board_allowed, PRO_BOARD_MESSAGE
|
2026-03-14 12:32:48 +07:00
|
|
|
from app.services.esp32_lib_manager import esp_lib_manager
|
feat: STM32 (Blue Pill / Black Pill) QEMU emulation + Pro board gating
STM32 emulation (open-core, runs via libqemu-arm in the backend worker):
- backend: stm32_lib_manager + stm32_worker (GPIO, USART, I2C/SPI device models
reusing the ESP32 slaves, live sensor updates), arduino_cli STM32 branch,
start_stm32 simulation route.
- frontend: Stm32Bridge + Stm32BluePill(/BlackPill) web components (Wokwi SVGs),
board kinds, Interconnect/boardPinMapping/boardProtocols wiring, example
projects (blink, serial, I2C BMP280/MPU6050/DS1307/SSD1306/weather, 7-seg,
RGB, button, switch, stepper, cross-board interconnect).
- Raspberry Pi 4/5 board elements + thumbnails.
Pro board gating (generic OSS->Pro seam; entitlement logic lives in the overlay):
- lib/proBoardGate.ts: isProBoardKind (STM32 + every QEMU Raspberry Pi),
installBoardGateImpl/boardGateDecision, triggerProUpgradePrompt.
- PRO badge on those boards in the component picker; gate at the picker add +
the run backstop (startBoard).
- backend/app/services/board_access.py: server-side enforcement seam for the
simulation WebSocket; STM32/Pi unavailable -> Pro-framed message.
- desktop: generic QemuDownloadPrompt + Stm32QemuPrompt (download-behind-license,
mirrors the ESP32 prompt).
- .gitignore: never ship libqemu-* binaries in the public image.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 05:06:14 +07:00
|
|
|
from app.services.stm32_lib_manager import stm32_lib_manager
|
2026-04-29 12:33:59 +07:00
|
|
|
from app.services.picow_net_bridge import picow_net_manager
|
2026-03-12 18:17:29 +07:00
|
|
|
|
2026-04-01 06:53:56 +07:00
|
|
|
|
|
|
|
|
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]
|
|
|
|
|
|
2026-03-12 18:17:29 +07:00
|
|
|
router = APIRouter()
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
2026-03-12 18:17:29 +07:00
|
|
|
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):
|
2026-03-13 09:39:04 +07:00
|
|
|
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)
|
2026-03-12 18:17:29 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
manager = ConnectionManager()
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
|
|
|
|
@router.websocket('/ws/{client_id}')
|
2026-03-12 18:17:29 +07:00
|
|
|
async def simulation_websocket(websocket: WebSocket, client_id: str):
|
|
|
|
|
await manager.connect(websocket, client_id)
|
2026-03-13 09:39:04 +07:00
|
|
|
|
|
|
|
|
async def qemu_callback(event_type: str, data: dict) -> None:
|
2026-03-15 02:57:22 +07:00
|
|
|
if event_type == 'gpio_change':
|
2026-03-19 09:30:45 +07:00
|
|
|
logger.debug('[%s] gpio_change pin=%s state=%s', client_id, data.get('pin'), data.get('state'))
|
2026-03-15 02:57:22 +07:00
|
|
|
elif event_type == 'system':
|
2026-03-19 09:30:45 +07:00
|
|
|
logger.debug('[%s] system event: %s', client_id, data.get('event'))
|
2026-03-15 02:57:22 +07:00
|
|
|
elif event_type == 'error':
|
|
|
|
|
logger.error('[%s] error: %s', client_id, data.get('message'))
|
2026-03-17 12:28:08 +07:00
|
|
|
elif event_type == 'serial_output':
|
|
|
|
|
text = data.get('data', '')
|
2026-03-19 09:30:45 +07:00
|
|
|
logger.debug('[%s] serial_output uart=%s len=%d: %r', client_id, data.get('uart', 0), len(text), text[:80])
|
2026-03-13 09:39:04 +07:00
|
|
|
payload = json.dumps({'type': event_type, 'data': data})
|
2026-03-17 12:28:08 +07:00
|
|
|
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)
|
2026-03-13 09:39:04 +07:00
|
|
|
|
2026-03-14 22:05:35 +07:00
|
|
|
def _use_lib() -> bool:
|
|
|
|
|
return esp_lib_manager.is_available()
|
|
|
|
|
|
2026-03-12 18:17:29 +07:00
|
|
|
try:
|
|
|
|
|
while True:
|
2026-03-13 09:39:04 +07:00
|
|
|
raw = await websocket.receive_text()
|
|
|
|
|
message = json.loads(raw)
|
|
|
|
|
msg_type: str = message.get('type', '')
|
|
|
|
|
msg_data: dict = message.get('data', {})
|
|
|
|
|
|
2026-03-14 22:05:35 +07:00
|
|
|
# ── Raspberry Pi ─────────────────────────────────────────────
|
2026-03-13 09:39:04 +07:00
|
|
|
if msg_type == 'start_pi':
|
|
|
|
|
board = msg_data.get('board', 'raspberry-pi-3')
|
feat: STM32 (Blue Pill / Black Pill) QEMU emulation + Pro board gating
STM32 emulation (open-core, runs via libqemu-arm in the backend worker):
- backend: stm32_lib_manager + stm32_worker (GPIO, USART, I2C/SPI device models
reusing the ESP32 slaves, live sensor updates), arduino_cli STM32 branch,
start_stm32 simulation route.
- frontend: Stm32Bridge + Stm32BluePill(/BlackPill) web components (Wokwi SVGs),
board kinds, Interconnect/boardPinMapping/boardProtocols wiring, example
projects (blink, serial, I2C BMP280/MPU6050/DS1307/SSD1306/weather, 7-seg,
RGB, button, switch, stepper, cross-board interconnect).
- Raspberry Pi 4/5 board elements + thumbnails.
Pro board gating (generic OSS->Pro seam; entitlement logic lives in the overlay):
- lib/proBoardGate.ts: isProBoardKind (STM32 + every QEMU Raspberry Pi),
installBoardGateImpl/boardGateDecision, triggerProUpgradePrompt.
- PRO badge on those boards in the component picker; gate at the picker add +
the run backstop (startBoard).
- backend/app/services/board_access.py: server-side enforcement seam for the
simulation WebSocket; STM32/Pi unavailable -> Pro-framed message.
- desktop: generic QemuDownloadPrompt + Stm32QemuPrompt (download-behind-license,
mirrors the ESP32 prompt).
- .gitignore: never ship libqemu-* binaries in the public image.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 05:06:14 +07:00
|
|
|
if not await board_allowed(websocket, board):
|
|
|
|
|
await qemu_callback('error', {'message': PRO_BOARD_MESSAGE})
|
|
|
|
|
else:
|
|
|
|
|
qemu_manager.start_instance(client_id, board, qemu_callback)
|
2026-03-13 09:39:04 +07:00
|
|
|
|
|
|
|
|
elif msg_type == 'stop_pi':
|
2026-03-12 18:17:29 +07:00
|
|
|
qemu_manager.stop_instance(client_id)
|
2026-03-13 09:39:04 +07:00
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
2026-03-14 22:05:35 +07:00
|
|
|
elif msg_type in ('gpio_in', 'pin_change'):
|
2026-03-13 09:39:04 +07:00
|
|
|
pin = msg_data.get('pin', 0)
|
|
|
|
|
state = msg_data.get('state', 0)
|
2026-03-12 18:17:29 +07:00
|
|
|
qemu_manager.set_pin_state(client_id, pin, state)
|
2026-03-13 09:39:04 +07:00
|
|
|
|
2026-05-18 21:26:37 +07:00
|
|
|
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)
|
|
|
|
|
|
2026-03-14 22:05:35 +07:00
|
|
|
# ── ESP32 lifecycle ──────────────────────────────────────────
|
2026-03-14 06:35:48 +07:00
|
|
|
elif msg_type == 'start_esp32':
|
2026-03-14 22:05:35 +07:00
|
|
|
board = msg_data.get('board', 'esp32')
|
2026-03-14 06:35:48 +07:00
|
|
|
firmware_b64 = msg_data.get('firmware_b64')
|
2026-03-23 04:03:17 +07:00
|
|
|
sensors = msg_data.get('sensors', [])
|
2026-04-01 06:53:56 +07:00
|
|
|
wifi_enabled = bool(msg_data.get('wifi_enabled', False))
|
2026-03-15 02:57:22 +07:00
|
|
|
fw_size_kb = round(len(firmware_b64) * 0.75 / 1024) if firmware_b64 else 0
|
|
|
|
|
lib_available = _use_lib()
|
2026-04-01 06:53:56 +07:00
|
|
|
|
|
|
|
|
# Allocate a host port for WiFi hostfwd if WiFi is enabled
|
|
|
|
|
wifi_hostfwd_port = _find_free_port() if wifi_enabled else 0
|
|
|
|
|
|
|
|
|
|
logger.info('[%s] start_esp32 board=%s firmware=%dKB lib_available=%s sensors=%d wifi=%s hostfwd=%d',
|
|
|
|
|
client_id, board, fw_size_kb, lib_available, len(sensors),
|
|
|
|
|
wifi_enabled, wifi_hostfwd_port)
|
2026-03-15 02:57:22 +07:00
|
|
|
if lib_available:
|
2026-04-01 06:53:56 +07:00
|
|
|
await esp_lib_manager.start_instance(
|
|
|
|
|
client_id, board, qemu_callback, firmware_b64, sensors,
|
|
|
|
|
wifi_enabled=wifi_enabled, wifi_hostfwd_port=wifi_hostfwd_port)
|
2026-03-14 12:32:48 +07:00
|
|
|
else:
|
2026-03-15 02:57:22 +07:00
|
|
|
logger.warning('[%s] libqemu-xtensa not available — using subprocess fallback', client_id)
|
2026-04-01 06:53:56 +07:00
|
|
|
esp_qemu_manager.start_instance(
|
|
|
|
|
client_id, board, qemu_callback, firmware_b64,
|
|
|
|
|
wifi_enabled=wifi_enabled, wifi_hostfwd_port=wifi_hostfwd_port)
|
2026-03-14 06:35:48 +07:00
|
|
|
|
|
|
|
|
elif msg_type == 'stop_esp32':
|
2026-03-15 02:57:22 +07:00
|
|
|
await esp_lib_manager.stop_instance(client_id)
|
2026-03-14 06:35:48 +07:00
|
|
|
esp_qemu_manager.stop_instance(client_id)
|
|
|
|
|
|
|
|
|
|
elif msg_type == 'load_firmware':
|
|
|
|
|
firmware_b64 = msg_data.get('firmware_b64', '')
|
|
|
|
|
if firmware_b64:
|
2026-03-14 22:05:35 +07:00
|
|
|
if _use_lib():
|
2026-03-14 12:32:48 +07:00
|
|
|
esp_lib_manager.load_firmware(client_id, firmware_b64)
|
|
|
|
|
else:
|
|
|
|
|
esp_qemu_manager.load_firmware(client_id, firmware_b64)
|
2026-03-14 06:35:48 +07:00
|
|
|
|
feat: STM32 (Blue Pill / Black Pill) QEMU emulation + Pro board gating
STM32 emulation (open-core, runs via libqemu-arm in the backend worker):
- backend: stm32_lib_manager + stm32_worker (GPIO, USART, I2C/SPI device models
reusing the ESP32 slaves, live sensor updates), arduino_cli STM32 branch,
start_stm32 simulation route.
- frontend: Stm32Bridge + Stm32BluePill(/BlackPill) web components (Wokwi SVGs),
board kinds, Interconnect/boardPinMapping/boardProtocols wiring, example
projects (blink, serial, I2C BMP280/MPU6050/DS1307/SSD1306/weather, 7-seg,
RGB, button, switch, stepper, cross-board interconnect).
- Raspberry Pi 4/5 board elements + thumbnails.
Pro board gating (generic OSS->Pro seam; entitlement logic lives in the overlay):
- lib/proBoardGate.ts: isProBoardKind (STM32 + every QEMU Raspberry Pi),
installBoardGateImpl/boardGateDecision, triggerProUpgradePrompt.
- PRO badge on those boards in the component picker; gate at the picker add +
the run backstop (startBoard).
- backend/app/services/board_access.py: server-side enforcement seam for the
simulation WebSocket; STM32/Pi unavailable -> Pro-framed message.
- desktop: generic QemuDownloadPrompt + Stm32QemuPrompt (download-behind-license,
mirrors the ESP32 prompt).
- .gitignore: never ship libqemu-* binaries in the public image.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 05:06:14 +07:00
|
|
|
# ── 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)
|
|
|
|
|
|
2026-04-29 12:33:59 +07:00
|
|
|
# ── Pico W (CYW43439) WiFi bridge ────────────────────────────
|
|
|
|
|
# The chip-side gSPI emulator lives in the frontend; this side
|
|
|
|
|
# forwards Layer-2 Ethernet frames to/from the host network.
|
|
|
|
|
# Mirrors the ESP32 path deliberately — see
|
|
|
|
|
# backend/app/services/picow_net_bridge.py for design notes.
|
|
|
|
|
elif msg_type == 'start_picow':
|
|
|
|
|
wifi_enabled = bool(msg_data.get('wifi_enabled', False))
|
|
|
|
|
logger.info('[%s] start_picow wifi=%s', client_id, wifi_enabled)
|
|
|
|
|
await picow_net_manager.start_instance(
|
|
|
|
|
client_id, qemu_callback, wifi_enabled,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
elif msg_type == 'stop_picow':
|
|
|
|
|
await picow_net_manager.stop_instance(client_id)
|
|
|
|
|
|
|
|
|
|
elif msg_type == 'picow_packet_out':
|
|
|
|
|
ether_b64 = msg_data.get('ether_b64', '')
|
|
|
|
|
if ether_b64:
|
|
|
|
|
await picow_net_manager.deliver_packet_out(client_id, ether_b64)
|
|
|
|
|
|
2026-03-14 22:05:35 +07:00
|
|
|
# ── ESP32 serial (UART 0 / 1 / 2) ───────────────────────────
|
2026-03-14 06:35:48 +07:00
|
|
|
elif msg_type == 'esp32_serial_input':
|
2026-03-14 22:05:35 +07:00
|
|
|
raw_bytes = msg_data.get('bytes', [])
|
|
|
|
|
uart_id = int(msg_data.get('uart', 0))
|
2026-03-14 06:35:48 +07:00
|
|
|
if raw_bytes:
|
2026-03-14 22:05:35 +07:00
|
|
|
if _use_lib():
|
|
|
|
|
await esp_lib_manager.send_serial_bytes(
|
|
|
|
|
client_id, bytes(raw_bytes), uart_id
|
|
|
|
|
)
|
2026-03-14 12:32:48 +07:00
|
|
|
else:
|
2026-03-14 22:05:35 +07:00
|
|
|
await esp_qemu_manager.send_serial_bytes(
|
|
|
|
|
client_id, bytes(raw_bytes)
|
|
|
|
|
)
|
2026-03-14 06:35:48 +07:00
|
|
|
|
2026-03-14 22:05:35 +07:00
|
|
|
# ── ESP32 GPIO input (from connected component / button) ──────
|
2026-03-14 06:35:48 +07:00
|
|
|
elif msg_type == 'esp32_gpio_in':
|
|
|
|
|
pin = msg_data.get('pin', 0)
|
|
|
|
|
state = msg_data.get('state', 0)
|
2026-03-14 22:05:35 +07:00
|
|
|
if _use_lib():
|
2026-03-14 12:32:48 +07:00
|
|
|
esp_lib_manager.set_pin_state(client_id, pin, state)
|
|
|
|
|
else:
|
|
|
|
|
esp_qemu_manager.set_pin_state(client_id, pin, state)
|
2026-03-14 06:35:48 +07:00
|
|
|
|
2026-03-14 22:05:35 +07:00
|
|
|
# ── 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'])
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-21 12:17:30 +07:00
|
|
|
# ── 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
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-14 22:05:35 +07:00
|
|
|
# ── 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
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-23 04:03:17 +07:00
|
|
|
# ── ESP32 sensor protocol offloading (generic) ────────────────
|
|
|
|
|
elif msg_type == 'esp32_sensor_attach':
|
|
|
|
|
sensor_type = msg_data.get('sensor_type', '')
|
2026-03-23 01:17:44 +07:00
|
|
|
pin = int(msg_data.get('pin', 0))
|
|
|
|
|
if _use_lib():
|
2026-03-23 04:03:17 +07:00
|
|
|
esp_lib_manager.sensor_attach(client_id, sensor_type, pin, msg_data)
|
2026-03-23 01:17:44 +07:00
|
|
|
else:
|
2026-03-23 04:03:17 +07:00
|
|
|
esp_qemu_manager.sensor_attach(client_id, sensor_type, pin, msg_data)
|
2026-03-23 01:17:44 +07:00
|
|
|
|
2026-03-23 04:03:17 +07:00
|
|
|
elif msg_type == 'esp32_sensor_update':
|
2026-03-23 01:17:44 +07:00
|
|
|
pin = int(msg_data.get('pin', 0))
|
|
|
|
|
if _use_lib():
|
2026-03-23 04:03:17 +07:00
|
|
|
esp_lib_manager.sensor_update(client_id, pin, msg_data)
|
2026-03-23 01:17:44 +07:00
|
|
|
else:
|
2026-03-23 04:03:17 +07:00
|
|
|
esp_qemu_manager.sensor_update(client_id, pin, msg_data)
|
2026-03-23 01:17:44 +07:00
|
|
|
|
2026-03-23 04:03:17 +07:00
|
|
|
elif msg_type == 'esp32_sensor_detach':
|
2026-03-23 01:17:44 +07:00
|
|
|
pin = int(msg_data.get('pin', 0))
|
|
|
|
|
if _use_lib():
|
2026-03-23 04:03:17 +07:00
|
|
|
esp_lib_manager.sensor_detach(client_id, pin)
|
2026-03-23 01:17:44 +07:00
|
|
|
else:
|
2026-03-23 04:03:17 +07:00
|
|
|
esp_qemu_manager.sensor_detach(client_id, pin)
|
2026-03-23 01:17:44 +07:00
|
|
|
|
2026-05-13 02:55:15 +07:00
|
|
|
# ── 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)
|
|
|
|
|
|
feat: ESP32-CAM emulation with webcam frame bridge
First open-source end-to-end emulation of the AI-Thinker ESP32-CAM
in QEMU, paired with a browser webcam → firmware bridge so users can
develop camera sketches without hardware. Status: esp_camera_init()
returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly
match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by
the upstream driver. Final piece (cam_task accepting frames) is in
progress — descriptor walker fix landed in this commit.
Backend (Python/FastAPI):
- simulation.py: camera_attach/frame/detach WS handlers
- esp32_worker.py: ctypes binding to velxio_push_camera_frame +
feature-detection fallback for older DLLs
- esp32_lib_manager.py: forward camera commands to the worker stdin
- esp-idf-template/main/CMakeLists.txt: esp32-camera headers added
via add_prebuilt_library + REQUIRES driver (resolves i2c_master_*
symbols). LED_BUILTIN=2 fallback for sketches that hardcode it.
Frontend (React/TS):
- EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family)
added to isQemuBoard list — Run button now starts the QEMU bridge
for these boards instead of falling through to the AVR path
- useWebcamFrames.ts: getUserMedia → OffscreenCanvas →
toBlob('image/jpeg') → base64 → WS at ~10 fps
- CameraToggle.tsx: header button with status colors + frame counter
- SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards
- Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa
- useSimulatorStore.ts: diagnostic log on compileBoardProgram
- components-metadata.json: regen including esp32-cam component
Submodule pointer:
- wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on
davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32)
Investigation + tests in test/test-esp32-cam/:
- 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S
spec, build blueprint, blockers resolved, descriptor walker fix)
- 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip,
webcam_demo) + 8 live + WS regression tests
- README with the user-facing flow
.gitignore:
- libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated)
- wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path,
not part of this repo)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
|
|
|
# ── 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)
|
|
|
|
|
|
2026-03-14 22:05:35 +07:00
|
|
|
# ── 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})
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-12 18:17:29 +07:00
|
|
|
except WebSocketDisconnect:
|
2026-03-17 12:28:08 +07:00
|
|
|
# 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)
|
2026-03-14 22:05:35 +07:00
|
|
|
except Exception as exc:
|
|
|
|
|
logger.error('WebSocket error for %s: %s', client_id, exc)
|
2026-03-17 12:28:08 +07:00
|
|
|
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)
|