velxio/test/multi_board_esp32/test_dual_esp32_serial.py

148 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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