feat: implement ESP32 emulation support via libqemu-xtensa.dll and enhance simulation API

pull/47/head
David Montero Crespo 2026-03-14 02:32:48 -03:00
parent 1bbb1ad4d2
commit c957190061
12 changed files with 619 additions and 93 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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()

84
build_qemu_dll.sh Normal file
View File

@ -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

8
build_qemu_step3.sh Normal file
View File

@ -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"

109
build_qemu_step4.sh Normal file
View File

@ -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

15
check_tools.sh Normal file
View File

@ -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

6
fix_distlib.sh Normal file
View File

@ -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__)"

7
fix_distlib2.sh Normal file
View File

@ -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

4
install_git.sh Normal file
View File

@ -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

5
install_meson.sh Normal file
View File

@ -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

View File

@ -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')