add tcp_serial_bridge.py

This commit is contained in:
Brian Harrison
2026-03-31 11:52:11 -04:00
parent 51d1aa917a
commit da446cb2e3

View File

@@ -0,0 +1,203 @@
"""
tcp_serial_bridge.py — Local TCP-to-serial bridge for bench testing TcpTransport.
Listens on a TCP port and, when a client connects, opens a serial port and
bridges bytes bidirectionally. This lets you test the SFM server's TCP
endpoint (?host=127.0.0.1&tcp_port=12345) against a locally-attached MiniMate
Plus without needing a field modem.
The bridge simulates an RV55 cellular modem in transparent TCP passthrough mode:
- No handshake bytes on connect
- Raw bytes forwarded in both directions
- One connection at a time (new connection closes any existing serial session)
Usage:
python bridges/tcp_serial_bridge.py --serial COM5 --tcp-port 12345
Then in another window:
python -m uvicorn sfm.server:app --port 8200
curl "http://localhost:8200/device/info?host=127.0.0.1&tcp_port=12345"
Or just hit http://localhost:8200/device/info?host=127.0.0.1&tcp_port=12345
in a browser.
Requirements:
pip install pyserial
"""
from __future__ import annotations
import argparse
import logging
import select
import socket
import sys
import threading
import time
try:
import serial # type: ignore
except ImportError:
print("pyserial required: pip install pyserial", file=sys.stderr)
sys.exit(1)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("tcp_serial_bridge")
# ── Constants ─────────────────────────────────────────────────────────────────
DEFAULT_BAUD = 38_400
DEFAULT_TCP_PORT = 12345
CHUNK = 256 # bytes per read call
SERIAL_TIMEOUT = 0.02 # serial read timeout (s) — non-blocking in practice
TCP_TIMEOUT = 0.02 # socket recv timeout (s)
BOOT_DELAY = 2.0 # seconds to wait after opening serial port before
# forwarding data — mirrors the unit's startup beep
# ── Bridge session ─────────────────────────────────────────────────────────────
def _pipe_tcp_to_serial(sock: socket.socket, ser: serial.Serial, stop: threading.Event) -> None:
"""Forward bytes from TCP socket → serial port."""
sock.settimeout(TCP_TIMEOUT)
while not stop.is_set():
try:
data = sock.recv(CHUNK)
if not data:
log.info("TCP peer closed connection")
stop.set()
break
log.debug("TCP→SER %d bytes: %s", len(data), data.hex())
ser.write(data)
except socket.timeout:
pass
except OSError as exc:
if not stop.is_set():
log.warning("TCP read error: %s", exc)
stop.set()
break
def _pipe_serial_to_tcp(sock: socket.socket, ser: serial.Serial, stop: threading.Event) -> None:
"""Forward bytes from serial port → TCP socket."""
while not stop.is_set():
try:
data = ser.read(CHUNK)
if data:
log.debug("SER→TCP %d bytes: %s", len(data), data.hex())
try:
sock.sendall(data)
except OSError as exc:
if not stop.is_set():
log.warning("TCP send error: %s", exc)
stop.set()
break
except serial.SerialException as exc:
if not stop.is_set():
log.warning("Serial read error: %s", exc)
stop.set()
break
def _run_session(conn: socket.socket, addr: tuple, serial_port: str, baud: int, boot_delay: float) -> None:
"""Handle one TCP client connection."""
peer = f"{addr[0]}:{addr[1]}"
log.info("Connection from %s", peer)
try:
ser = serial.Serial(
port = serial_port,
baudrate = baud,
bytesize = 8,
parity = "N",
stopbits = 1,
timeout = SERIAL_TIMEOUT,
)
except serial.SerialException as exc:
log.error("Cannot open serial port %s: %s", serial_port, exc)
conn.close()
return
log.info("Opened %s at %d baud — waiting %.1fs for unit boot", serial_port, baud, boot_delay)
ser.reset_input_buffer()
ser.reset_output_buffer()
if boot_delay > 0:
time.sleep(boot_delay)
ser.reset_input_buffer() # discard any boot noise
log.info("Bridge active: TCP %s%s", peer, serial_port)
stop = threading.Event()
t_tcp_to_ser = threading.Thread(
target=_pipe_tcp_to_serial, args=(conn, ser, stop), daemon=True
)
t_ser_to_tcp = threading.Thread(
target=_pipe_serial_to_tcp, args=(conn, ser, stop), daemon=True
)
t_tcp_to_ser.start()
t_ser_to_tcp.start()
stop.wait() # block until either thread sets the stop flag
log.info("Session ended, cleaning up")
try:
conn.close()
except OSError:
pass
try:
ser.close()
except OSError:
pass
t_tcp_to_ser.join(timeout=2.0)
t_ser_to_tcp.join(timeout=2.0)
log.info("Session with %s closed", peer)
# ── Server ────────────────────────────────────────────────────────────────────
def run_bridge(serial_port: str, baud: int, tcp_port: int, boot_delay: float) -> None:
"""Accept TCP connections forever and bridge each one to the serial port."""
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", tcp_port))
srv.listen(1)
log.info(
"Listening on TCP :%d — will bridge to %s at %d baud",
tcp_port, serial_port, baud,
)
log.info("Send test: curl 'http://localhost:8200/device/info?host=127.0.0.1&tcp_port=%d'", tcp_port)
try:
while True:
conn, addr = srv.accept()
# Handle one session at a time (synchronous) — matches modem behaviour
_run_session(conn, addr, serial_port, baud, boot_delay)
except KeyboardInterrupt:
log.info("Shutting down")
finally:
srv.close()
# ── Entry point ────────────────────────────────────────────────────────────────
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="TCP-to-serial bridge for bench testing TcpTransport")
ap.add_argument("--serial", default="COM5", help="Serial port (default: COM5)")
ap.add_argument("--baud", type=int, default=DEFAULT_BAUD, help="Baud rate (default: 38400)")
ap.add_argument("--tcp-port", type=int, default=DEFAULT_TCP_PORT, help="TCP listen port (default: 12345)")
ap.add_argument("--boot-delay", type=float, default=BOOT_DELAY,
help="Seconds to wait after opening serial before forwarding (default: 2.0). "
"Set to 0 if unit is already powered on.")
ap.add_argument("--debug", action="store_true", help="Show individual byte transfers")
args = ap.parse_args()
if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
run_bridge(args.serial, args.baud, args.tcp_port, args.boot_delay)