Skip to content

Subsystem TCP Serial Bridge

OneSeventyFour edited this page May 14, 2026 · 1 revision

Subsystem: TCP-to-serial bridge

host/tcp_serial_bridge/tcp_serial_bridge.py is a small Python process that holds the dongle's USB serial port open and exposes it as a TCP service on port 9000. It runs outside Docker so it can see the host's USB devices.

Why it exists

Docker Desktop on macOS and Windows runs the engine in a Linux VM. That VM does not get easy passthrough access to the host's USB devices — there's no equivalent of --device /dev/ttyACM0 like Linux Docker has. To support all three operating systems with one architecture, the bridge runs natively on the host and the daemon (in the container) connects to it over TCP at host.docker.internal:9000.

On Linux you could in principle skip the bridge and pass the serial device through directly, but the bridge has zero downside, so the launcher scripts always start it.

What it does

    Container                        Host
   ┌──────────┐  TCP localhost:9000   ┌────────────────────┐    USB-CDC
   │  daemon  │ ◄───────────────────► │ tcp_serial_bridge  │ ◄────────► dongle
   └──────────┘                       └────────────────────┘
  • Two threads per connected TCP client: one pumps TCP → serial, the other pumps serial → TCP.
  • A single TCP server, listens on port 9000. The daemon is the only client (one connection, persistent).
  • serial_lock (a threading.Lock) serializes writes/closes between the two threads.

Auto-reconnect

The dongle reboots itself in two cases:

  • Hardware task watchdog firing (e.g. during a hostile OTA RF link).
  • Operator unplug/replug.

On macOS, /dev/tty.usbmodem* paths usually stay stable across a reboot — the bridge just needs to wait ~1–3 s for re-enumeration and reopen. The bridge does this automatically.

The reconnect logic:

  • Exponential-ish backoff from 1 s to 5 s (RECONNECT_BACKOFF_S, RECONNECT_BACKOFF_MAX_S).
  • Logs once per failure burst (not on every retry — would flood logs during a multi-second outage).
  • Both the read and write threads call _try_auto_reopen() when they see an error; the lock + idempotent close serialize concurrent attempts.

When the dongle comes back, you'll see one log line:

Serial auto-reconnected: /dev/tty.usbmodem01 after 4 failed attempt(s)

Special control messages

In addition to passing serial bytes, the bridge accepts a JSON control envelope from the TCP client:

config_serial

{ "type": "config_serial", "port": "/dev/tty.usbmodem01", "baud": 115200 }

Reconfigures the bridge to use a different port / baud. The bridge closes the existing port (if any) and opens the new one. Replies with:

{ "tcpstatus": true, "serial_config": { "port": "...", "baud": 115200 } }

or, on failure:

{ "type": "config_response", "error": "Could not configure port: ..." }

The daemon sends a config_serial immediately after connecting so it can pick up the port from systemcfg.json regardless of what the bridge was originally launched with.

get_status

{ "type": "get_status" }

Replies:

{ "type": "status_response", "connected": true, "config": { "port": "...", "baud": 115200 } }

(Used by the UI's "Restart dongle" button via the daemon.)

Performance details (read path)

A subtle but important implementation choice: the read thread polls serial_conn.in_waiting instead of calling serial.read(N) with a long timeout.

pyserial's read(N) on POSIX loops in select+os.read until either N bytes accumulate or the configured timeout expires. With timeout=1 and small inbound messages (e.g. an 80-byte OTA ack), a single read(8192) would hold the lock for nearly a full second while waiting for the buffer to fill. While the lock is held, the write thread can't push the next outbound command — that turned the OTA chunk loop into a ~2 Hz round-trip.

By polling in_waiting (1 ms idle poll) we hold the lock for microseconds per iteration and let the OTA path (and high-rate command bursts in general) run at the dongle's actual radio cadence — ~3 ms per round-trip with no failures, ~22 ms per round-trip after maxing out the nRF24's 5 retries.

Performance details (write path)

The write thread:

  • Reads from the TCP socket into a buffer.
  • Tries to interpret the buffer as a JSON control message (if it starts with {).
  • Otherwise writes the bytes straight to the serial port.

If the dongle is unreachable mid-write, the write throws and the buffer is dropped (rather than queued unbounded). The host-side OTA driver's retry logic re-sends whatever was lost. Same for any other command that gets lost — the daemon's protocol handler doesn't assume guaranteed delivery.

Bootstrap

The bridge has its own venv at host/tcp_serial_bridge/venv. The launcher scripts (start_prod.sh / start_prod.bat) create it and pip install pyserial on first run. To rebuild from scratch:

rm -rf host/tcp_serial_bridge/venv
host/start_prod.sh   # or start_prod.bat — will recreate the venv

Configuration

Default config in tcp_serial_bridge.py:

SERIAL_CONFIG = { 'port': '/dev/tty.usbmodem01', 'baud': 115200 }
TCP_HOST = '0.0.0.0'
TCP_PORT = 9000

The port is overridden the moment the daemon connects (via the config_serial control message, populated from systemcfg.json's system.dongle_port). So in practice you almost never edit the bridge's defaults — change the port in systemcfg.json instead.

Logs

The bridge logs to stdout. When run via start_prod.sh, that's the same terminal as the rest of the daemon. When run via start_prod.bat, it's its own window titled "BYH-Bridge".

A clean startup looks like:

Serial connected: /dev/tty.usbmodem01 at 115200 baud
TCP server listening on 0.0.0.0:9000
Accepted connection from ('192.168.65.2', 53114)
Client connected

A dongle reboot looks like:

serial_to_tcp: read error (read failed: device reports readiness to read but returned no data); will auto-reconnect
Serial auto-reconnect attempt 1 failed: ...
...
Serial auto-reconnected: /dev/tty.usbmodem01 after 4 failed attempt(s)

Clone this wiki locally