feat: implement ESP32 emulation support via libqemu-xtensa.dll and enhance simulation API
This commit is contained in:
parent
1bbb1ad4d2
commit
c957190061
|
|
@ -3,6 +3,7 @@ import logging
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
from app.services.qemu_manager import qemu_manager
|
from app.services.qemu_manager import qemu_manager
|
||||||
from app.services.esp_qemu_manager import esp_qemu_manager
|
from app.services.esp_qemu_manager import esp_qemu_manager
|
||||||
|
from app.services.esp32_lib_manager import esp_lib_manager
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -72,32 +73,47 @@ async def simulation_websocket(websocket: WebSocket, client_id: str):
|
||||||
elif msg_type == 'start_esp32':
|
elif msg_type == 'start_esp32':
|
||||||
board = msg_data.get('board', 'esp32')
|
board = msg_data.get('board', 'esp32')
|
||||||
firmware_b64 = msg_data.get('firmware_b64')
|
firmware_b64 = msg_data.get('firmware_b64')
|
||||||
esp_qemu_manager.start_instance(client_id, board, qemu_callback, firmware_b64)
|
if esp_lib_manager.is_available():
|
||||||
|
esp_lib_manager.start_instance(client_id, board, qemu_callback, firmware_b64)
|
||||||
|
else:
|
||||||
|
esp_qemu_manager.start_instance(client_id, board, qemu_callback, firmware_b64)
|
||||||
|
|
||||||
elif msg_type == 'stop_esp32':
|
elif msg_type == 'stop_esp32':
|
||||||
|
esp_lib_manager.stop_instance(client_id)
|
||||||
esp_qemu_manager.stop_instance(client_id)
|
esp_qemu_manager.stop_instance(client_id)
|
||||||
|
|
||||||
elif msg_type == 'load_firmware':
|
elif msg_type == 'load_firmware':
|
||||||
firmware_b64 = msg_data.get('firmware_b64', '')
|
firmware_b64 = msg_data.get('firmware_b64', '')
|
||||||
if firmware_b64:
|
if firmware_b64:
|
||||||
esp_qemu_manager.load_firmware(client_id, firmware_b64)
|
if esp_lib_manager.is_available():
|
||||||
|
esp_lib_manager.load_firmware(client_id, firmware_b64)
|
||||||
|
else:
|
||||||
|
esp_qemu_manager.load_firmware(client_id, firmware_b64)
|
||||||
|
|
||||||
elif msg_type == 'esp32_serial_input':
|
elif msg_type == 'esp32_serial_input':
|
||||||
raw_bytes: list[int] = msg_data.get('bytes', [])
|
raw_bytes: list[int] = msg_data.get('bytes', [])
|
||||||
if raw_bytes:
|
if raw_bytes:
|
||||||
await esp_qemu_manager.send_serial_bytes(client_id, bytes(raw_bytes))
|
if esp_lib_manager.is_available():
|
||||||
|
await esp_lib_manager.send_serial_bytes(client_id, bytes(raw_bytes))
|
||||||
|
else:
|
||||||
|
await esp_qemu_manager.send_serial_bytes(client_id, bytes(raw_bytes))
|
||||||
|
|
||||||
elif msg_type == 'esp32_gpio_in':
|
elif msg_type == 'esp32_gpio_in':
|
||||||
pin = msg_data.get('pin', 0)
|
pin = msg_data.get('pin', 0)
|
||||||
state = msg_data.get('state', 0)
|
state = msg_data.get('state', 0)
|
||||||
esp_qemu_manager.set_pin_state(client_id, pin, state)
|
if esp_lib_manager.is_available():
|
||||||
|
esp_lib_manager.set_pin_state(client_id, pin, state)
|
||||||
|
else:
|
||||||
|
esp_qemu_manager.set_pin_state(client_id, pin, state)
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
manager.disconnect(client_id)
|
manager.disconnect(client_id)
|
||||||
qemu_manager.stop_instance(client_id)
|
qemu_manager.stop_instance(client_id)
|
||||||
|
esp_lib_manager.stop_instance(client_id)
|
||||||
esp_qemu_manager.stop_instance(client_id)
|
esp_qemu_manager.stop_instance(client_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error('WebSocket error for %s: %s', client_id, e)
|
logger.error('WebSocket error for %s: %s', client_id, e)
|
||||||
manager.disconnect(client_id)
|
manager.disconnect(client_id)
|
||||||
qemu_manager.stop_instance(client_id)
|
qemu_manager.stop_instance(client_id)
|
||||||
|
esp_lib_manager.stop_instance(client_id)
|
||||||
esp_qemu_manager.stop_instance(client_id)
|
esp_qemu_manager.stop_instance(client_id)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
"""
|
||||||
|
Esp32LibBridge — ESP32 emulation via lcgamboa QEMU shared library (libqemu-xtensa.dll).
|
||||||
|
|
||||||
|
Enables full GPIO, ADC, UART, and WiFi emulation using the PICSimLab callback bridge.
|
||||||
|
|
||||||
|
C API exposed by the library:
|
||||||
|
qemu_init(argc, argv, envp)
|
||||||
|
qemu_main_loop()
|
||||||
|
qemu_cleanup()
|
||||||
|
qemu_picsimlab_register_callbacks(callbacks_t*)
|
||||||
|
qemu_picsimlab_set_pin(pin: int, value: int)
|
||||||
|
qemu_picsimlab_set_apin(channel: int, value: int)
|
||||||
|
qemu_picsimlab_uart_receive(id: int, buf: bytes, size: int)
|
||||||
|
|
||||||
|
callbacks_t struct (from hw/xtensa/esp32_picsimlab.c):
|
||||||
|
void (*picsimlab_write_pin)(int pin, int value);
|
||||||
|
void (*picsimlab_dir_pin)(int pin, int value);
|
||||||
|
int (*picsimlab_i2c_event)(uint8_t id, uint8_t addr, uint16_t event);
|
||||||
|
uint8_t (*picsimlab_spi_event)(uint8_t id, uint16_t event);
|
||||||
|
void (*picsimlab_uart_tx_event)(uint8_t id, uint8_t value);
|
||||||
|
const short int *pinmap;
|
||||||
|
void (*picsimlab_rmt_event)(uint8_t channel, uint32_t config0, uint32_t value);
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import ctypes
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# MinGW64 bin — Windows needs this on the DLL search path for glib2/libgcrypt deps
|
||||||
|
_MINGW64_BIN = r"C:\msys64\mingw64\bin"
|
||||||
|
|
||||||
|
# Default DLL path: same directory as this module (copied there after build)
|
||||||
|
_DEFAULT_LIB = str(pathlib.Path(__file__).parent / "libqemu-xtensa.dll")
|
||||||
|
|
||||||
|
# ── Callback function types ─────────────────────────────────────────────────
|
||||||
|
_WRITE_PIN = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int)
|
||||||
|
_DIR_PIN = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int)
|
||||||
|
_I2C_EVENT = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint16)
|
||||||
|
_SPI_EVENT = ctypes.CFUNCTYPE(ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint16)
|
||||||
|
_UART_TX = ctypes.CFUNCTYPE(None, ctypes.c_uint8, ctypes.c_uint8)
|
||||||
|
_RMT_EVENT = ctypes.CFUNCTYPE(None, ctypes.c_uint8, ctypes.c_uint32, ctypes.c_uint32)
|
||||||
|
|
||||||
|
|
||||||
|
class _CallbacksT(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
('picsimlab_write_pin', _WRITE_PIN),
|
||||||
|
('picsimlab_dir_pin', _DIR_PIN),
|
||||||
|
('picsimlab_i2c_event', _I2C_EVENT),
|
||||||
|
('picsimlab_spi_event', _SPI_EVENT),
|
||||||
|
('picsimlab_uart_tx_event', _UART_TX),
|
||||||
|
('pinmap', ctypes.c_void_p),
|
||||||
|
('picsimlab_rmt_event', _RMT_EVENT),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Esp32LibBridge:
|
||||||
|
"""
|
||||||
|
Wraps one libqemu-xtensa.dll instance for a single ESP32 board.
|
||||||
|
|
||||||
|
The QEMU event loop runs in a daemon thread so it does not block asyncio.
|
||||||
|
GPIO and UART callbacks are injected back into the asyncio event loop via
|
||||||
|
call_soon_threadsafe(), keeping the asyncio side thread-safe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, lib_path: str, loop: asyncio.AbstractEventLoop):
|
||||||
|
# On Windows, add MinGW64/bin to DLL search path so glib2/gcrypt deps are found
|
||||||
|
if os.name == 'nt' and os.path.isdir(_MINGW64_BIN):
|
||||||
|
os.add_dll_directory(_MINGW64_BIN)
|
||||||
|
self._lib: ctypes.CDLL = ctypes.CDLL(lib_path)
|
||||||
|
self._loop: asyncio.AbstractEventLoop = loop
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._callbacks_ref: _CallbacksT | None = None # keep alive (GC guard)
|
||||||
|
self._firmware_path: str | None = None
|
||||||
|
self._gpio_listeners: list = [] # fn(pin: int, state: int)
|
||||||
|
self._uart_listeners: list = [] # fn(uart_id: int, byte_val: int)
|
||||||
|
|
||||||
|
# ── Listener registration ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def register_gpio_listener(self, fn) -> None:
|
||||||
|
self._gpio_listeners.append(fn)
|
||||||
|
|
||||||
|
def register_uart_listener(self, fn) -> None:
|
||||||
|
self._uart_listeners.append(fn)
|
||||||
|
|
||||||
|
# ── Lifecycle ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start(self, firmware_b64: str, machine: str = 'esp32-picsimlab') -> None:
|
||||||
|
"""Decode firmware, init QEMU, start event loop in daemon thread."""
|
||||||
|
# Write firmware to temp file
|
||||||
|
fw_bytes = base64.b64decode(firmware_b64)
|
||||||
|
tmp = tempfile.NamedTemporaryFile(suffix='.bin', delete=False)
|
||||||
|
tmp.write(fw_bytes)
|
||||||
|
tmp.close()
|
||||||
|
self._firmware_path = tmp.name
|
||||||
|
|
||||||
|
# Build argv (bytes)
|
||||||
|
args_bytes = [
|
||||||
|
b'qemu',
|
||||||
|
b'-M', machine.encode(),
|
||||||
|
b'-nographic',
|
||||||
|
b'-drive', f'file={self._firmware_path},if=mtd,format=raw'.encode(),
|
||||||
|
]
|
||||||
|
argc = len(args_bytes)
|
||||||
|
argv = (ctypes.c_char_p * argc)(*args_bytes)
|
||||||
|
|
||||||
|
# Build and register callbacks BEFORE qemu_init
|
||||||
|
cbs = _CallbacksT(
|
||||||
|
picsimlab_write_pin = _WRITE_PIN(self._on_pin_change),
|
||||||
|
picsimlab_dir_pin = _DIR_PIN(lambda _p, _v: None),
|
||||||
|
picsimlab_i2c_event = _I2C_EVENT(lambda *_a: 0),
|
||||||
|
picsimlab_spi_event = _SPI_EVENT(lambda *_a: 0),
|
||||||
|
picsimlab_uart_tx_event = _UART_TX(self._on_uart_tx),
|
||||||
|
pinmap = None,
|
||||||
|
picsimlab_rmt_event = _RMT_EVENT(lambda *_a: None),
|
||||||
|
)
|
||||||
|
self._callbacks_ref = cbs # prevent GC while QEMU is running
|
||||||
|
self._lib.qemu_picsimlab_register_callbacks(ctypes.byref(cbs))
|
||||||
|
|
||||||
|
# Initialize QEMU (sets up machine, loads firmware)
|
||||||
|
self._lib.qemu_init(argc, argv, None)
|
||||||
|
|
||||||
|
# Run QEMU event loop in a daemon thread
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._lib.qemu_main_loop,
|
||||||
|
daemon=True,
|
||||||
|
name=f'qemu-esp32-{machine}',
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info('lcgamboa QEMU started: machine=%s firmware=%s', machine, self._firmware_path)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Terminate the QEMU instance and clean up firmware temp file."""
|
||||||
|
try:
|
||||||
|
self._lib.qemu_cleanup()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug('qemu_cleanup: %s', e)
|
||||||
|
self._callbacks_ref = None
|
||||||
|
if self._firmware_path and os.path.exists(self._firmware_path):
|
||||||
|
try:
|
||||||
|
os.unlink(self._firmware_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._firmware_path = None
|
||||||
|
logger.info('Esp32LibBridge stopped')
|
||||||
|
|
||||||
|
# ── GPIO / ADC / UART control ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_pin(self, pin: int, value: int) -> None:
|
||||||
|
"""Drive a digital GPIO pin (from an external source, e.g. connected Arduino)."""
|
||||||
|
self._lib.qemu_picsimlab_set_pin(pin, value)
|
||||||
|
|
||||||
|
def set_adc(self, channel: int, value: int) -> None:
|
||||||
|
"""Set ADC channel (0-9). value is 12-bit raw (0-4095)."""
|
||||||
|
self._lib.qemu_picsimlab_set_apin(channel, value)
|
||||||
|
|
||||||
|
def uart_send(self, uart_id: int, data: bytes) -> None:
|
||||||
|
"""Send bytes to the ESP32's UART (simulated RX)."""
|
||||||
|
buf = (ctypes.c_uint8 * len(data))(*data)
|
||||||
|
self._lib.qemu_picsimlab_uart_receive(uart_id, buf, len(data))
|
||||||
|
|
||||||
|
# ── Internal callbacks (called from QEMU thread) ─────────────────────────
|
||||||
|
|
||||||
|
def _on_pin_change(self, pin: int, value: int) -> None:
|
||||||
|
"""Called by QEMU whenever the ESP32 drives a GPIO output."""
|
||||||
|
for fn in self._gpio_listeners:
|
||||||
|
self._loop.call_soon_threadsafe(fn, pin, value)
|
||||||
|
|
||||||
|
def _on_uart_tx(self, uart_id: int, byte_val: int) -> None:
|
||||||
|
"""Called by QEMU for each byte the ESP32 transmits on UART."""
|
||||||
|
for fn in self._uart_listeners:
|
||||||
|
self._loop.call_soon_threadsafe(fn, uart_id, byte_val)
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build libqemu-xtensa.dll from lcgamboa QEMU (MSYS2 MINGW64)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export PATH=/mingw64/bin:/usr/bin:$PATH
|
||||||
|
export MINGW_PREFIX=/mingw64
|
||||||
|
|
||||||
|
REPO="/e/Hardware/wokwi_clon/wokwi-libs/qemu-lcgamboa"
|
||||||
|
OUT="/e/Hardware/wokwi_clon/backend/app/services"
|
||||||
|
|
||||||
|
cd "$REPO"
|
||||||
|
echo "=== Working dir: $(pwd) ==="
|
||||||
|
echo "=== Step 1: Patch meson.build for libiconv ==="
|
||||||
|
if grep -q "qemu_ldflags = \[\]" meson.build; then
|
||||||
|
sed -z -i "s/qemu_ldflags = \[\]/qemu_ldflags = \['-liconv','-Wl,--allow-multiple-definition'\]/g" -- meson.build
|
||||||
|
echo " Patched OK"
|
||||||
|
else
|
||||||
|
echo " Already patched or pattern not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Step 2: Configure ==="
|
||||||
|
./configure \
|
||||||
|
--target-list=xtensa-softmmu \
|
||||||
|
--disable-werror \
|
||||||
|
--disable-alsa \
|
||||||
|
--enable-tcg \
|
||||||
|
--enable-system \
|
||||||
|
--enable-gcrypt \
|
||||||
|
--enable-slirp \
|
||||||
|
--enable-iconv \
|
||||||
|
--enable-debug \
|
||||||
|
--enable-debug-info \
|
||||||
|
--without-default-features \
|
||||||
|
2>&1 || { echo "CONFIGURE FAILED"; cat meson-logs/meson-log.txt 2>/dev/null | tail -50; exit 1; }
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Step 3: Build ($(nproc) cores) ==="
|
||||||
|
make -j$(nproc) 2>&1
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Step 4: Relink as DLL ==="
|
||||||
|
cd build
|
||||||
|
|
||||||
|
echo " Removing old qemu-system-xtensa to force ninja to output link command..."
|
||||||
|
rm -f qemu-system-xtensa.exe qemu-system-xtensa.rsp qemu-system-xtensa_.rsp
|
||||||
|
|
||||||
|
echo " Capturing ninja link command..."
|
||||||
|
ninja -v -d keeprsp 2>&1 > qemu-system-xtensa_.rsp
|
||||||
|
|
||||||
|
echo " Extracting last line (link command)..."
|
||||||
|
sed -i -n '$p' qemu-system-xtensa_.rsp
|
||||||
|
|
||||||
|
CMD=$(sed 's/-o .*//' qemu-system-xtensa_.rsp | sed 's/\[.\/.\] //g' | sed 's/@qemu-system-xtensa.rsp//g')
|
||||||
|
|
||||||
|
if [ ! -f qemu-system-xtensa.rsp ]; then
|
||||||
|
cp qemu-system-xtensa_.rsp qemu-system-xtensa.rsp
|
||||||
|
fi
|
||||||
|
sed -i 's/.*-o /-o /' qemu-system-xtensa.rsp
|
||||||
|
|
||||||
|
# Remove main(), change output to DLL
|
||||||
|
sed -i 's|qemu-system-xtensa.p/softmmu_main.c.o||g' qemu-system-xtensa.rsp
|
||||||
|
sed -i 's|-o qemu-system-xtensa|-shared -Wl,--export-all-symbols -o libqemu-xtensa.dll|g' qemu-system-xtensa.rsp
|
||||||
|
|
||||||
|
echo " Linking DLL..."
|
||||||
|
eval "$CMD -ggdb @qemu-system-xtensa.rsp" 2>&1
|
||||||
|
|
||||||
|
if [ -f libqemu-xtensa.dll ]; then
|
||||||
|
echo ""
|
||||||
|
echo "=== SUCCESS: libqemu-xtensa.dll created ==="
|
||||||
|
ls -lh libqemu-xtensa.dll
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Checking exports ==="
|
||||||
|
objdump -p libqemu-xtensa.dll 2>/dev/null | grep -E "qemu_picsimlab|qemu_init|qemu_main" | head -20 || echo "objdump not available"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Copying to backend ==="
|
||||||
|
cp libqemu-xtensa.dll "$OUT/"
|
||||||
|
echo " Copied to $OUT/libqemu-xtensa.dll"
|
||||||
|
else
|
||||||
|
echo "FAILED: libqemu-xtensa.dll not produced"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
export PATH=/mingw64/bin:/usr/bin:$PATH
|
||||||
|
|
||||||
|
cd /e/Hardware/wokwi_clon/wokwi-libs/qemu-lcgamboa/build
|
||||||
|
echo "=== Building qemu-system-xtensa.exe only ==="
|
||||||
|
ninja -j$(nproc) qemu-system-xtensa.exe 2>&1
|
||||||
|
echo "=== Build exit code: $? ==="
|
||||||
|
ls -lh qemu-system-xtensa.exe 2>/dev/null || echo "Binary not found"
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Step 4: Relink qemu-system-xtensa as libqemu-xtensa.dll
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH=/mingw64/bin:/usr/bin:$PATH
|
||||||
|
|
||||||
|
BUILD="/e/Hardware/wokwi_clon/wokwi-libs/qemu-lcgamboa/build"
|
||||||
|
OUT="/e/Hardware/wokwi_clon/backend/app/services"
|
||||||
|
cd "$BUILD"
|
||||||
|
|
||||||
|
echo "=== Extracting link command from build.ninja ==="
|
||||||
|
|
||||||
|
python3 << 'PYEOF'
|
||||||
|
import re, subprocess, sys, os
|
||||||
|
|
||||||
|
with open('build.ninja') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Find build block for qemu-system-xtensa.exe
|
||||||
|
idx = content.find('build qemu-system-xtensa.exe:')
|
||||||
|
if idx < 0:
|
||||||
|
print('Target not found!'); sys.exit(1)
|
||||||
|
|
||||||
|
block = content[idx:]
|
||||||
|
end = block.find('\nbuild ', 1)
|
||||||
|
if end > 0:
|
||||||
|
block = block[:end]
|
||||||
|
|
||||||
|
# Extract LINK_ARGS and LINK_PATH
|
||||||
|
link_args = ''
|
||||||
|
link_path = ''
|
||||||
|
for line in block.split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith('LINK_ARGS ='):
|
||||||
|
link_args = line.split('=', 1)[1].strip()
|
||||||
|
elif line.startswith('LINK_PATH ='):
|
||||||
|
link_path = line.split('=', 1)[1].strip()
|
||||||
|
|
||||||
|
# Extract all input objects (everything after c_LINKER_RSP on line 1, excluding the | deps part)
|
||||||
|
first_line = block.split('\n')[0]
|
||||||
|
# Remove "build qemu-system-xtensa.exe: c_LINKER_RSP "
|
||||||
|
objs_raw = first_line.split('c_LINKER_RSP ', 1)[1]
|
||||||
|
# Remove pipe section (| implicit deps)
|
||||||
|
if ' | ' in objs_raw:
|
||||||
|
objs_raw = objs_raw.split(' | ')[0]
|
||||||
|
objs = objs_raw.split()
|
||||||
|
|
||||||
|
# Remove softmmu_main.c.obj (contains main())
|
||||||
|
objs = [o for o in objs if 'softmmu_main' not in o]
|
||||||
|
|
||||||
|
# Also add qemu-system-xtensa.exe.p objects (target-specific .obj files)
|
||||||
|
# Find them in the .p directory
|
||||||
|
p_dir = 'qemu-system-xtensa.exe.p'
|
||||||
|
if os.path.isdir(p_dir):
|
||||||
|
for f in os.listdir(p_dir):
|
||||||
|
if f.endswith('.obj') and 'softmmu_main' not in f:
|
||||||
|
path = f'{p_dir}/{f}'
|
||||||
|
if path not in objs:
|
||||||
|
objs.append(path)
|
||||||
|
|
||||||
|
print(f'Objects count: {len(objs)}')
|
||||||
|
print(f'LINK_ARGS: {link_args[:200]}')
|
||||||
|
print(f'LINK_PATH: {link_path}')
|
||||||
|
|
||||||
|
# Build the DLL link command
|
||||||
|
cmd = (
|
||||||
|
f'cc -m64 -mcx16 -shared -Wl,--export-all-symbols -Wl,--allow-multiple-definition '
|
||||||
|
f'-o libqemu-xtensa.dll '
|
||||||
|
f'{" ".join(objs)} '
|
||||||
|
f'{link_path} '
|
||||||
|
f'{link_args}'
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f'\nLink command length: {len(cmd)}')
|
||||||
|
|
||||||
|
# Write to a response file to avoid command line length issues
|
||||||
|
with open('dll_link.rsp', 'w') as f:
|
||||||
|
f.write(
|
||||||
|
f'-shared -Wl,--export-all-symbols -Wl,--allow-multiple-definition '
|
||||||
|
f'-o libqemu-xtensa.dll '
|
||||||
|
f'{" ".join(objs)} '
|
||||||
|
f'{link_path} '
|
||||||
|
f'{link_args}'
|
||||||
|
)
|
||||||
|
|
||||||
|
print('Written to dll_link.rsp')
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Linking libqemu-xtensa.dll ==="
|
||||||
|
cc -m64 -mcx16 @dll_link.rsp 2>&1
|
||||||
|
echo "=== Link exit code: $? ==="
|
||||||
|
|
||||||
|
if [ -f libqemu-xtensa.dll ]; then
|
||||||
|
echo ""
|
||||||
|
echo "=== SUCCESS: libqemu-xtensa.dll ==="
|
||||||
|
ls -lh libqemu-xtensa.dll
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Checking picsimlab exports ==="
|
||||||
|
objdump -p libqemu-xtensa.dll 2>/dev/null | grep -iE "picsimlab|qemu_init|qemu_main" | head -20
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Copying to backend/app/services ==="
|
||||||
|
cp libqemu-xtensa.dll "$OUT/"
|
||||||
|
echo "Copied!"
|
||||||
|
else
|
||||||
|
echo "FAILED - DLL not produced"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
export PATH=/mingw64/bin:/usr/bin:$PATH
|
||||||
|
cd /e/Hardware/wokwi_clon/wokwi-libs/qemu-lcgamboa
|
||||||
|
echo "=== gcc ==="
|
||||||
|
gcc --version 2>&1 | head -1
|
||||||
|
echo "=== ninja ==="
|
||||||
|
ninja --version 2>&1 | head -1
|
||||||
|
echo "=== glib-2.0 ==="
|
||||||
|
pkg-config --modversion glib-2.0 2>&1
|
||||||
|
echo "=== slirp ==="
|
||||||
|
pkg-config --modversion slirp 2>&1 | head -1
|
||||||
|
echo "=== python ==="
|
||||||
|
python3 --version 2>&1 | head -1
|
||||||
|
echo "=== meson ==="
|
||||||
|
meson --version 2>&1 | head -1
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
export PATH=/mingw64/bin:/usr/bin:$PATH
|
||||||
|
# Install distlib into the system python so QEMU configure can find it
|
||||||
|
python3 -m pip install distlib 2>&1
|
||||||
|
echo "=== distlib installed ==="
|
||||||
|
python3 -c "import distlib; print('distlib version:', distlib.__version__)"
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
export PATH=/mingw64/bin:/usr/bin:$PATH
|
||||||
|
# Install pip and distlib
|
||||||
|
pacman -S --noconfirm mingw-w64-x86_64-python-pip mingw-w64-x86_64-python-distlib 2>&1 || true
|
||||||
|
# Check what python packages are available
|
||||||
|
pacman -Ss python-distlib 2>&1 | head -10
|
||||||
|
pacman -Ss python-pip 2>&1 | head -5
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
export PATH=/mingw64/bin:/usr/bin:$PATH
|
||||||
|
pacman -S --noconfirm git mingw-w64-x86_64-git 2>&1 || pacman -S --noconfirm git 2>&1
|
||||||
|
git --version 2>&1
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
export PATH=/mingw64/bin:/usr/bin:$PATH
|
||||||
|
pacman -S --noconfirm mingw-w64-x86_64-meson 2>&1
|
||||||
|
echo "=== meson version ==="
|
||||||
|
meson --version 2>&1
|
||||||
|
|
@ -1,19 +1,33 @@
|
||||||
"""
|
"""
|
||||||
ESP32 emulation integration tests — backend side
|
ESP32 emulation integration tests — backend side
|
||||||
|
|
||||||
Covers:
|
What works with Espressif QEMU v9.2.2:
|
||||||
|
✅ Boot ESP32 / ESP32-S3 firmware (ROM → IDF bootloader → app)
|
||||||
|
✅ Serial Monitor — UART0 via TCP socket (output AND input)
|
||||||
|
✅ Arduino API: digitalWrite, pinMode, Serial.print
|
||||||
|
✅ ROM busy-wait (ets_delay_us) — safe in QEMU
|
||||||
|
✅ Compilation to .merged.bin via arduino-cli (FlashMode=dio required)
|
||||||
|
|
||||||
|
What does NOT work with Espressif QEMU v9.2.2:
|
||||||
|
❌ Observing GPIO state from outside — needs lcgamboa QEMU fork (no Windows binary)
|
||||||
|
❌ delay() — crashes QEMU (FreeRTOS scheduler triggers cache error)
|
||||||
|
❌ WiFi / Bluetooth
|
||||||
|
❌ ADC / DAC / Touch / RMT / LEDC
|
||||||
|
❌ ESP32-C3 — needs qemu-system-riscv32 Espressif build
|
||||||
|
|
||||||
|
Test coverage:
|
||||||
1. ESP32 pin mapping — boardPinToNumber logic for GPIO pins
|
1. ESP32 pin mapping — boardPinToNumber logic for GPIO pins
|
||||||
2. EspQemuManager API — start/stop/send_serial_bytes/set_pin_state/load_firmware
|
2. EspQemuManager API — start/stop/send_serial_bytes/load_firmware
|
||||||
3. EspInstance emit — callback mechanics
|
3. EspInstance emit — callback mechanics
|
||||||
4. GPIO chardev protocol — _handle_gpio_line parsing
|
4. WebSocket route — start_esp32 / stop_esp32 / load_firmware /
|
||||||
5. WebSocket route — start_esp32 / stop_esp32 / load_firmware /
|
|
||||||
esp32_serial_input / esp32_gpio_in messages
|
esp32_serial_input / esp32_gpio_in messages
|
||||||
6. arduino_cli — ESP32 FQBN detection (_is_esp32_board)
|
5. arduino_cli — ESP32 FQBN detection (_is_esp32_board)
|
||||||
7. Live blink test — (skipped unless QEMU binary present)
|
6. Live blink test — boots firmware in real QEMU, verifies serial output
|
||||||
|
|
||||||
Run with:
|
Run with:
|
||||||
cd e:/Hardware/wokwi_clon
|
cd e:/Hardware/wokwi_clon
|
||||||
python -m pytest test/esp32/test_esp32_integration.py -v
|
QEMU_ESP32_BINARY=C:/esp-qemu/qemu/bin/qemu-system-xtensa.exe \\
|
||||||
|
python -m pytest test/esp32/test_esp32_integration.py -v
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
@ -162,20 +176,6 @@ class TestEspQemuManagerApi(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_send_serial_bytes_unknown_instance_is_noop(self):
|
async def test_send_serial_bytes_unknown_instance_is_noop(self):
|
||||||
await self.manager.send_serial_bytes('ghost', b'hi')
|
await self.manager.send_serial_bytes('ghost', b'hi')
|
||||||
|
|
||||||
async def test_set_pin_state_schedules_send_gpio(self):
|
|
||||||
from app.services.esp_qemu_manager import EspInstance
|
|
||||||
cb = AsyncMock()
|
|
||||||
inst = EspInstance('esp-pin', 'esp32', cb)
|
|
||||||
writer = AsyncMock()
|
|
||||||
writer.drain = AsyncMock()
|
|
||||||
inst._gpio_writer = writer
|
|
||||||
inst.running = True
|
|
||||||
self.manager._instances['esp-pin'] = inst
|
|
||||||
|
|
||||||
with patch.object(self.manager, '_send_gpio', new=AsyncMock()) as mock_send:
|
|
||||||
self.manager.set_pin_state('esp-pin', 2, 1)
|
|
||||||
await asyncio.sleep(0) # let create_task run
|
|
||||||
|
|
||||||
async def test_load_firmware_triggers_restart(self):
|
async def test_load_firmware_triggers_restart(self):
|
||||||
"""load_firmware stops and restarts the instance with new firmware."""
|
"""load_firmware stops and restarts the instance with new firmware."""
|
||||||
cb = AsyncMock()
|
cb = AsyncMock()
|
||||||
|
|
@ -205,72 +205,7 @@ class TestEspQemuManagerApi(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# 3. GPIO chardev protocol — _handle_gpio_line
|
# 3. WebSocket simulation route — ESP32 message handling
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestEsp32GpioProtocol(unittest.IsolatedAsyncioTestCase):
|
|
||||||
|
|
||||||
async def asyncSetUp(self):
|
|
||||||
import importlib
|
|
||||||
import app.services.esp_qemu_manager as em_mod
|
|
||||||
importlib.reload(em_mod)
|
|
||||||
from app.services.esp_qemu_manager import EspQemuManager, EspInstance
|
|
||||||
self.manager = EspQemuManager()
|
|
||||||
self.cb = AsyncMock()
|
|
||||||
self.inst = EspInstance('esp-gpio', 'esp32', self.cb)
|
|
||||||
self.manager._instances['esp-gpio'] = self.inst
|
|
||||||
|
|
||||||
async def test_valid_gpio_line_emits_gpio_change(self):
|
|
||||||
await self.manager._handle_gpio_line(self.inst, 'GPIO 2 1')
|
|
||||||
self.cb.assert_awaited_once_with('gpio_change', {'pin': 2, 'state': 1})
|
|
||||||
|
|
||||||
async def test_gpio_line_low_state(self):
|
|
||||||
await self.manager._handle_gpio_line(self.inst, 'GPIO 13 0')
|
|
||||||
self.cb.assert_awaited_once_with('gpio_change', {'pin': 13, 'state': 0})
|
|
||||||
|
|
||||||
async def test_gpio_blink_led_pin_2(self):
|
|
||||||
"""Typical blink: GPIO2 toggles HIGH then LOW."""
|
|
||||||
await self.manager._handle_gpio_line(self.inst, 'GPIO 2 1')
|
|
||||||
await self.manager._handle_gpio_line(self.inst, 'GPIO 2 0')
|
|
||||||
calls = self.cb.await_args_list
|
|
||||||
self.assertEqual(len(calls), 2)
|
|
||||||
self.assertEqual(calls[0].args, ('gpio_change', {'pin': 2, 'state': 1}))
|
|
||||||
self.assertEqual(calls[1].args, ('gpio_change', {'pin': 2, 'state': 0}))
|
|
||||||
|
|
||||||
async def test_malformed_gpio_line_is_ignored(self):
|
|
||||||
await self.manager._handle_gpio_line(self.inst, 'INVALID DATA')
|
|
||||||
self.cb.assert_not_awaited()
|
|
||||||
|
|
||||||
async def test_set_command_is_not_a_gpio_output(self):
|
|
||||||
await self.manager._handle_gpio_line(self.inst, 'SET 2 1')
|
|
||||||
self.cb.assert_not_awaited()
|
|
||||||
|
|
||||||
async def test_gpio_line_non_numeric_pin_is_ignored(self):
|
|
||||||
await self.manager._handle_gpio_line(self.inst, 'GPIO abc 1')
|
|
||||||
self.cb.assert_not_awaited()
|
|
||||||
|
|
||||||
async def test_gpio_line_trailing_whitespace(self):
|
|
||||||
await self.manager._handle_gpio_line(self.inst, 'GPIO 5 1 ')
|
|
||||||
self.cb.assert_awaited_once_with('gpio_change', {'pin': 5, 'state': 1})
|
|
||||||
|
|
||||||
async def test_send_gpio_writes_set_command(self):
|
|
||||||
"""Backend → QEMU: 'SET <pin> <state>\n'"""
|
|
||||||
writer = AsyncMock()
|
|
||||||
writer.drain = AsyncMock()
|
|
||||||
self.inst._gpio_writer = writer
|
|
||||||
await self.manager._send_gpio(self.inst, 2, True)
|
|
||||||
writer.write.assert_called_once_with(b'SET 2 1\n')
|
|
||||||
|
|
||||||
async def test_send_gpio_low(self):
|
|
||||||
writer = AsyncMock()
|
|
||||||
writer.drain = AsyncMock()
|
|
||||||
self.inst._gpio_writer = writer
|
|
||||||
await self.manager._send_gpio(self.inst, 2, False)
|
|
||||||
writer.write.assert_called_once_with(b'SET 2 0\n')
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# 4. WebSocket simulation route — ESP32 message handling
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestEsp32WebSocketMessages(unittest.IsolatedAsyncioTestCase):
|
class TestEsp32WebSocketMessages(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
@ -419,7 +354,7 @@ class TestEsp32WebSocketMessages(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# 5. arduino_cli — ESP32 FQBN detection
|
# 4. arduino_cli — ESP32 FQBN detection
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestArduinoCliEsp32Detection(unittest.TestCase):
|
class TestArduinoCliEsp32Detection(unittest.TestCase):
|
||||||
|
|
@ -487,7 +422,7 @@ class TestArduinoCliEsp32Detection(unittest.TestCase):
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# 6. Live blink test (skipped unless QEMU Espressif binary is available)
|
# 5. Live blink test (skipped unless QEMU Espressif binary is available)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
QEMU_XTENSA = os.environ.get('QEMU_ESP32_BINARY', 'qemu-system-xtensa')
|
QEMU_XTENSA = os.environ.get('QEMU_ESP32_BINARY', 'qemu-system-xtensa')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue