feat: implement ESP32 emulation support via libqemu-xtensa.dll and enhance simulation API
parent
1bbb1ad4d2
commit
c957190061
|
|
@ -3,6 +3,7 @@ import logging
|
|||
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.esp32_lib_manager import esp_lib_manager
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -72,32 +73,47 @@ 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')
|
||||
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':
|
||||
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:
|
||||
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':
|
||||
raw_bytes: list[int] = msg_data.get('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':
|
||||
pin = msg_data.get('pin', 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:
|
||||
manager.disconnect(client_id)
|
||||
qemu_manager.stop_instance(client_id)
|
||||
esp_lib_manager.stop_instance(client_id)
|
||||
esp_qemu_manager.stop_instance(client_id)
|
||||
except Exception as e:
|
||||
logger.error('WebSocket error for %s: %s', client_id, e)
|
||||
manager.disconnect(client_id)
|
||||
qemu_manager.stop_instance(client_id)
|
||||
esp_lib_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
|
||||
|
||||
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
|
||||
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
|
||||
4. GPIO chardev protocol — _handle_gpio_line parsing
|
||||
5. WebSocket route — start_esp32 / stop_esp32 / load_firmware /
|
||||
4. WebSocket route — start_esp32 / stop_esp32 / load_firmware /
|
||||
esp32_serial_input / esp32_gpio_in messages
|
||||
6. arduino_cli — ESP32 FQBN detection (_is_esp32_board)
|
||||
7. Live blink test — (skipped unless QEMU binary present)
|
||||
5. arduino_cli — ESP32 FQBN detection (_is_esp32_board)
|
||||
6. Live blink test — boots firmware in real QEMU, verifies serial output
|
||||
|
||||
Run with:
|
||||
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
|
||||
|
|
@ -162,20 +176,6 @@ class TestEspQemuManagerApi(unittest.IsolatedAsyncioTestCase):
|
|||
async def test_send_serial_bytes_unknown_instance_is_noop(self):
|
||||
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):
|
||||
"""load_firmware stops and restarts the instance with new firmware."""
|
||||
cb = AsyncMock()
|
||||
|
|
@ -205,72 +205,7 @@ class TestEspQemuManagerApi(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 3. GPIO chardev protocol — _handle_gpio_line
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
# 3. WebSocket simulation route — ESP32 message handling
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
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):
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue