velxio/test/multi_board_esp32/test_dual_esp32_serial.py

148 lines
5.2 KiB
Python
Raw Normal View History

feat(multi-board): add wire-aware cross-board interconnect router Fixes the user-reported bug where two RPi Pico W boards wired GP0↔GP1 running SerialPassthrough don't communicate. Replaces the broken broadcast-style cross-board logic in addBoard (only routed AVR↔Pi3B, ignored wires entirely, no RP2040↔anything path) with a wire-aware Interconnect singleton. Architecture: digital pin transitions are the lowest-common-denominator abstraction. Each simulator's hardware peripherals (UART/I2C/SPI) and bit-banging libraries (SoftwareSerial, software I2C) decode the transitions naturally — propagate the pin and the protocols come for free. For cross-process boards (ESP32 backend QEMU, Pi3B QEMU) a byte-level shortcut is additionally enabled on hardware-UART pin pairs to handle high-baud links over WebSocket latency. Implementation: - New simulation/Interconnect.ts singleton subscribes to wire/board changes via the Zustand store. Handlers per tier: browser-sim → pinManager.onPinChange, ESP32 → Esp32Bridge.sendPinEvent, Pi3B → bridge.sendPinEvent. Re-entrancy guard via per-(board,pin) Set. - New utils/boardProtocols.ts classifies pins (uart-tx, i2c-sda, etc.) per board kind, used as optimization hint for the byte shortcut. - types/wire.ts: added signalType field, exports WireSignalType / WireColorMap (fixes a pre-existing TS import error in wireColors). - Deleted the bridgeMap/simulatorMap broadcast forEach blocks in addBoard. Initial board + future boards register with Interconnect via setInterconnectRuntime + store subscription. - PinManager.resetPinStates() helper for test isolation. Tests (16 new files, 96 tests, all passing): - Per-pair × per-protocol matrix: dual-arduino-digital, dual-pico-digital, arduino-pico-digital, triple-pico-digital-chain, dual-arduino-hw-uart, dual-arduino-software-serial, arduino-pico-mixed-uart, arduino-esp32-uart, dual-esp32-uart, pi3-pico-uart, arduino-pico-i2c, arduino-arduino-spi, interconnect-routing, dual-arduino-multi-protocol (UART+I2C+SPI+ digital + concurrent), dual-pico-multi-protocol (UART0+UART1 alt+ I2C0+I2C1+SPI0+digital + 3-Pico star topology) - Updated dual-pico-serial-passthrough to assert correct behaviour - Backend test/multi_board_esp32/test_dual_esp32_serial.py for two real QEMU instances (skip-graceful when lcgamboa lib absent) Verified: 1107/1107 tests pass, zero regressions, vite build OK. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:47:28 +07:00
"""
test_dual_esp32_serial.py
=========================
Backend integration test: two ESP32 QEMU instances, both running the
SerialPassthrough sketch on UART2 (GPIO16/17), bridged via the same
Interconnect logic the frontend uses.
Architecture
------------
[host] USB Serial> [ESP32-A QEMU] <UART2/GPIO16/17> [ESP32-B QEMU] USB Serial> [host]
Each ESP32 instance runs a copy of `serial_passthrough.ino`. The
Python harness:
1. Spins up two `Esp32LibBridge` instances (one per emulated ESP32),
each loaded with the same merged firmware.
2. Wires their `on_serial_data` / `uart_send` (UART2) so that a byte
emitted by A on Serial2 gets fed into B's Serial2 RX, and vice
versa. This mirrors what `frontend/src/simulation/Interconnect.ts`
does in the browser.
3. Sends "PING-A\\n" on A's USB Serial; expects to read it back on
B's USB Serial output.
Skips
-----
This test is heavy (~510 min, two QEMU/lcgamboa libs + arduino-cli
ESP32 toolchain). It auto-skips when:
- the lcgamboa ESP32 emulator DLL is missing (the typical CI shape),
- or no `serial_passthrough.ino.merged.bin` is present alongside.
Run locally:
cd <repo>
python -m pytest test/multi_board_esp32/test_dual_esp32_serial.py -v
"""
import asyncio
import os
import pathlib
import sys
import time
import unittest
_REPO = pathlib.Path(__file__).parent.parent.parent
_BACKEND = _REPO / "backend"
sys.path.insert(0, str(_BACKEND))
# Firmware compiled from the .ino in this folder. Build with:
# arduino-cli compile --fqbn esp32:esp32:esp32 \
# --output-dir test/multi_board_esp32/out \
# test/multi_board_esp32/sketches/serial_passthrough
# esptool.py --chip esp32 merge_bin --fill-flash-size 4MB \
# -o test/multi_board_esp32/serial_passthrough.merged.bin \
# --flash_mode dio --flash_size 4MB \
# 0x1000 test/multi_board_esp32/out/serial_passthrough.ino.bootloader.bin \
# 0x8000 test/multi_board_esp32/out/serial_passthrough.ino.partitions.bin \
# 0x10000 test/multi_board_esp32/out/serial_passthrough.ino.bin
_FW_PATH = pathlib.Path(__file__).parent / "serial_passthrough.merged.bin"
try:
from app.services.esp32_lib_bridge import Esp32LibBridge # type: ignore
from app.services.esp32_lib_manager import LIB_PATH # type: ignore
_DLL_AVAILABLE = bool(LIB_PATH) and os.path.isfile(LIB_PATH)
except Exception:
_DLL_AVAILABLE = False
Esp32LibBridge = None # type: ignore
_FW_AVAILABLE = _FW_PATH.is_file()
_SKIP = (
not _DLL_AVAILABLE
or not _FW_AVAILABLE
or os.environ.get("SKIP_LIB_INTEGRATION", "") == "1"
)
@unittest.skipIf(_SKIP, "ESP32 lcgamboa lib or firmware not available")
class DualEsp32SerialTest(unittest.IsolatedAsyncioTestCase):
"""Two ESP32 QEMU instances exchange bytes over UART2."""
async def asyncSetUp(self):
self.bridge_a = Esp32LibBridge("esp-a")
self.bridge_b = Esp32LibBridge("esp-b")
self.usb_serial_a: list[str] = []
self.usb_serial_b: list[str] = []
# Wire UART0 (USB Serial) sinks for the host
self.bridge_a.on_serial_data = lambda ch, uart=0: self._on_uart(
self.usb_serial_a, self.bridge_b, ch, uart
)
self.bridge_b.on_serial_data = lambda ch, uart=0: self._on_uart(
self.usb_serial_b, self.bridge_a, ch, uart
)
await self.bridge_a.start(_FW_PATH)
await self.bridge_b.start(_FW_PATH)
# Give both ESP32s a chance to boot and print READY
await asyncio.sleep(2.0)
async def asyncTearDown(self):
await self.bridge_a.stop()
await self.bridge_b.stop()
def _on_uart(self, sink: list[str], peer, ch: str, uart: int) -> None:
"""Mirror of the frontend Interconnect's UART byte-shortcut.
UART0 = USB Serial goes to the host transcript.
UART2 = GPIO16/17 = the cross-board wire forward to peer's UART2 RX.
"""
if uart == 0:
sink.append(ch)
elif uart == 2:
# Wire-routing: A.UART2 TX → B.UART2 RX
peer.uart_send(ord(ch), uart=2)
async def test_pi_a_sees_byte_from_b(self):
# Send via A's USB Serial — sketch forwards to A.Serial2 → wire → B.Serial2
# → sketch on B forwards to B's USB Serial. Host reads it on bridge_b.
line = "HELLO_FROM_A\n"
for ch in line:
self.bridge_a.uart_send(ord(ch), uart=0)
# Wait up to 5 s for the round-trip
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if "HELLO_FROM_A" in "".join(self.usb_serial_b):
break
await asyncio.sleep(0.05)
self.assertIn("HELLO_FROM_A", "".join(self.usb_serial_b))
async def test_pi_b_sees_byte_from_a(self):
line = "PING_FROM_B\n"
for ch in line:
self.bridge_b.uart_send(ord(ch), uart=0)
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if "PING_FROM_B" in "".join(self.usb_serial_a):
break
await asyncio.sleep(0.05)
self.assertIn("PING_FROM_B", "".join(self.usb_serial_a))
if __name__ == "__main__":
unittest.main(verbosity=2)