velxio/backend/app/services/esp32_lib_manager.py

161 lines
6.1 KiB
Python

"""
EspLibManager — ESP32 emulation via lcgamboa libqemu-xtensa.dll.
Exposes the same public API as EspQemuManager so simulation.py can
transparently switch between the two backends:
- DLL available → full GPIO + ADC + UART + WiFi (this module)
- DLL missing → serial-only via subprocess (esp_qemu_manager.py)
Activation: set environment variable QEMU_ESP32_LIB to the DLL path.
"""
import asyncio
import logging
import os
from typing import Callable, Awaitable
from .esp32_lib_bridge import Esp32LibBridge, _DEFAULT_LIB
logger = logging.getLogger(__name__)
# Path to libqemu-xtensa.dll — env var takes priority, then auto-detect beside this module
LIB_PATH: str = os.environ.get('QEMU_ESP32_LIB', '') or (
_DEFAULT_LIB if os.path.isfile(_DEFAULT_LIB) else ''
)
EventCallback = Callable[[str, dict], Awaitable[None]]
# lcgamboa machine names (esp32-picsimlab has the GPIO callback bridge)
_MACHINE: dict[str, str] = {
'esp32': 'esp32-picsimlab',
'esp32-s3': 'esp32s3-picsimlab',
'esp32-c3': 'esp32c3-picsimlab',
}
class _InstanceState:
"""Tracks one running ESP32 instance."""
def __init__(self, bridge: Esp32LibBridge, callback: EventCallback, board_type: str):
self.bridge = bridge
self.callback = callback
self.board_type = board_type
class EspLibManager:
"""
Manager for ESP32 emulation via libqemu-xtensa.dll.
Uses Esp32LibBridge to load the lcgamboa QEMU shared library.
GPIO, ADC, UART, and WiFi events are delivered via the callback
function registered at start_instance().
"""
def __init__(self):
self._instances: dict[str, _InstanceState] = {}
# ── Availability check ───────────────────────────────────────────────────
@staticmethod
def is_available() -> bool:
"""Return True if the DLL path is configured and the file exists."""
return bool(LIB_PATH) and os.path.isfile(LIB_PATH)
# ── Public API (mirrors EspQemuManager) ─────────────────────────────────
def start_instance(
self,
client_id: str,
board_type: str,
callback: EventCallback,
firmware_b64: str | None = None,
) -> None:
if client_id in self._instances:
logger.warning('start_instance: %s already running', client_id)
return
loop = asyncio.get_event_loop()
bridge = Esp32LibBridge(LIB_PATH, loop)
# ── GPIO listener → emit gpio_change events ──────────────────────────
async def _on_gpio(pin: int, state: int) -> None:
await callback('gpio_change', {'pin': pin, 'state': state})
# ── UART listener → accumulate bytes, emit serial_output ─────────────
uart_buf: bytearray = bytearray()
async def _on_uart(uart_id: int, byte_val: int) -> None:
if uart_id == 0:
uart_buf.append(byte_val)
# Flush on newline or if buffer gets large
if byte_val == ord('\n') or len(uart_buf) >= 256:
text = uart_buf.decode('utf-8', errors='replace')
uart_buf.clear()
await callback('serial_output', {'data': text})
bridge.register_gpio_listener(
lambda p, s: loop.call_soon_threadsafe(
lambda: asyncio.ensure_future(_on_gpio(p, s), loop=loop)
)
)
bridge.register_uart_listener(
lambda i, b: loop.call_soon_threadsafe(
lambda: asyncio.ensure_future(_on_uart(i, b), loop=loop)
)
)
machine = _MACHINE.get(board_type, 'esp32-picsimlab')
state = _InstanceState(bridge, callback, board_type)
self._instances[client_id] = state
asyncio.ensure_future(callback('system', {'event': 'booting'}))
if firmware_b64:
try:
bridge.start(firmware_b64, machine)
asyncio.ensure_future(callback('system', {'event': 'booted'}))
except Exception as e:
logger.error('start_instance %s: bridge.start failed: %s', client_id, e)
self._instances.pop(client_id, None)
asyncio.ensure_future(callback('error', {'message': str(e)}))
else:
# No firmware yet — instance registered, waiting for load_firmware()
logger.info('start_instance %s: no firmware, waiting for load_firmware()', client_id)
def stop_instance(self, client_id: str) -> None:
state = self._instances.pop(client_id, None)
if state:
try:
state.bridge.stop()
except Exception as e:
logger.warning('stop_instance %s: %s', client_id, e)
def load_firmware(self, client_id: str, firmware_b64: str) -> None:
"""Hot-reload firmware: stop current bridge, start fresh with new firmware."""
state = self._instances.get(client_id)
if not state:
logger.warning('load_firmware: no instance %s', client_id)
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)
asyncio.create_task(_restart())
def set_pin_state(self, client_id: str, pin: int | str, state: int) -> None:
"""Drive a GPIO pin from an external board (e.g. Arduino output → ESP32 input)."""
inst = self._instances.get(client_id)
if inst:
inst.bridge.set_pin(int(pin), state)
async def send_serial_bytes(self, client_id: str, data: bytes) -> None:
"""Send bytes to the ESP32 UART0 RX (user serial input)."""
inst = self._instances.get(client_id)
if inst:
inst.bridge.uart_send(0, data)
esp_lib_manager = EspLibManager()