Skip to content

Serial Bridge Tool

The Serial Bridge is a PC-side development and debugging utility that creates a transparent, bidirectional data channel between two serial/USB ports. Its primary use case is connecting two FluidNC (or compatible CNC firmware) devices simultaneously, allowing engineers to observe, relay, and inject G-code traffic during integration testing without deploying firmware changes.

It is one of several communication client tools in the project; see tools_communication_clients_bt_clients.md for Bluetooth equivalents and tools_communication_clients_websocket.md for WebSocket-based testing.


  1. Module Purpose
  2. Architecture Overview
  3. Component Reference
  4. Threading Model
  5. Data Flow
  6. Keyboard Input & Command Processing
  7. Configuration & CLI
  8. Platform Considerations
  9. Serial Line Settings
  10. Usage Examples
  11. Relationship to Firmware

AspectDetail
Filetools/bridge/serial_bridge.py
LanguagePython 3
External dependencypyserial
Primary useBidirectional relay between two CNC-attached serial ports on a developer PC
Secondary useManual G-code injection into a live device without a separate terminal emulator

The tool is intentionally a standalone script with no dependency on the firmware build system. It is invoked directly by a developer during hardware bring-up, integration testing, or protocol debugging.


Device B (e.g. Pendant)

Device A (e.g. FluidNC)

Developer PC

SerialBridge Process

target=1

target=2

read / write

write

read / write

write

USB cable

USB cable

Keyboard Input Thread

Bridge Thread

Port1 → Port2

Bridge Thread

Port2 → Port1

queue1to2

queue2to1

keyboard_queue_port1

keyboard_queue_port2

ser1

(pyserial)

ser2

(pyserial)

USB-Serial

(CH340 / CP210x...)

USB-Serial

(CH340 / CP210x...)

The bridge runs three concurrent threads inside a single Python process. All inter-thread coordination uses queue.Queue objects — no shared mutable state is accessed without the queue’s implicit synchronisation.


Central orchestrator. Instantiated once by main() and driven by start().

MemberTypePurpose
port1_name / port2_namestrOS port identifiers (COM3, /dev/ttyUSB0, …)
baudrateintBaud rate applied to both ports (default 115200)
timeoutfloatPer-read timeout in seconds (default 0.1)
keyboard_inputboolWhether to spawn the keyboard thread
runningboolShared stop flag; set to False to gracefully terminate all threads
ser1 / ser2serial.Serialpyserial handles for the two ports
queue1to2 / queue2to1QueueCarry data between bridge threads
keyboard_queue_port1 / keyboard_queue_port2QueueCommands typed by the user, routed to the appropriate port
target_portint (1 or 2)Which port receives keyboard-injected commands
command_historylist[str]Accumulates sent commands for session reference
MethodResponsibility
connect()Opens both serial ports with 8N1, no flow control. Returns False on failure.
disconnect()Closes both ports; safe to call on a partially-connected state.
bridge_data(source, dest, ..., keyboard_queue)Main loop of one bridge thread; reads from source, writes to dest, drains the matching keyboard queue.
keyboard_input_thread()Reads user input, dispatches to process_command(). Cross-platform implementation.
process_command(command)Routes special keywords (help, exit, switch, status, home, unlock, reset) or passes raw text to send_command().
send_command(command, target_port)Appends \n, encodes to UTF-8 bytes, and puts into the appropriate keyboard queue.
start()Spawns threads and blocks until running becomes False.

Entry point. Parses CLI arguments using argparse, displays a configuration banner, and calls bridge.start().

--list

no ports

ports given

python serial_bridge.py

--list or

no ports given?

list_serial_ports

and exit 0

list_serial_ports

and exit 1

Print banner

Instantiate SerialBridge

bridge.start

Process exits


Uses serial.tools.list_ports.comports() to enumerate available ports and print device name, description, manufacturer, and serial number. Returns a list[str] of device paths.


Thread: keyboardThread: Port2→Port1Thread: Port1→Port2main threadThread: keyboardThread: Port2→Port1Thread: Port1→Port2main threadloop[Running]start() [daemon=True]start() [daemon=True]start() [daemon=True]sleep loop while runningread ser1 (in_waiting)parse line-by-linewrite to ser2drain keyboard_queue_port1 → ser1read ser2 (in_waiting)parse line-by-linewrite to ser1drain keyboard_queue_port2 → ser2read stdinprocess_command()keyboard_queue_port1.put(cmd)keyboard_queue_port2.put(cmd)KeyboardInterrupt / running=Falsedisconnect()

All three threads are daemon threads. If the main thread exits for any reason (e.g. unhandled exception), the OS tears down the daemon threads automatically — no explicit join() is needed.


yes

no

yes

no

ser1.read(in_waiting)

Extend bytearray buffer

'\

' in buffer?

Slice complete line

ser2.write(line)

ser2.flush()

Decode & print

[Port1→Port2]

buffer > 100 bytes?

ser2.write(buffer)

clear buffer

sleep 1 ms

next iteration

Design note — line-by-line buffering. FluidNC responds to and sends G-code line-by-line (LF-terminated). The bridge accumulates bytes until a newline is found before forwarding, which keeps log output coherent. The 100-byte overflow guard prevents the buffer from growing without bound if a device sends a very long line without a trailing newline.

put(bytes)

get_nowait()

keyboard thread

keyboard_queue_portN

bridge_data loop

(source=portN)

source.write(cmd)

source.flush()

print [Keyboard→PortN]

Keyboard commands are always written to the source port of the thread that owns that keyboard queue, keeping the directionality unambiguous.


default target

type '2' / port2 / Ctrl+B / Ctrl+S / switch

type '1' / port1 / Ctrl+A / Ctrl+S / switch

Port1

Port2

InputSends to deviceNote
status? + \nFluidNC/grbl real-time status request
home$H + \nHoming cycle
unlock$X + \nAlarm unlock
reset0x18 (Ctrl+X)Soft reset — no newline appended
switch / 1 / 2—Changes target_port only
help—Prints command reference to stdout
exit—Sets running = False
(anything else)raw text + \nForwarded as a G-code command

win32

Unix/Mac

Platform?

msvcrt.kbhit()

msvcrt.getch()

select.select(stdin)

stdin.readline()

Character-by-character

line accumulation

Ctrl+A / Ctrl+B / Ctrl+S shortcuts

Line-at-a-time

full readline (100 ms select timeout)

process_command

The Windows path accumulates characters manually to support Ctrl-key detection and backspace handling. The Unix path uses select with a 100 ms timeout for a simpler readline approach. As a result, on Unix all port switching must be done with text commands (1, 2, switch) rather than Ctrl shortcuts.


ArgumentDefaultDescription
port1(required)First serial port path
port2(required)Second serial port path
-b / --baudrate115200Baud rate (must match device firmware setting)
-t / --timeout0.1Read timeout in seconds
--no-keyboardFalseDisables the keyboard input thread; pure relay mode
--listFalseEnumerate available ports and exit
Terminal window
pip install pyserial

No other runtime dependencies beyond the Python standard library.


FeatureWindowsLinux / macOS
Keyboard character capturemsvcrt.kbhit() + msvcrt.getch()select.select([sys.stdin], ...) + readline()
Ctrl+key shortcutsCtrl+A (Port1), Ctrl+B (Port2), Ctrl+S (switch)Not supported; use 1, 2, switch text commands
Port path formatCOM3, COM10, …/dev/ttyUSB0, /dev/ttyACM0, …
Terminal raw modeNot requiredNot required

Both ports are opened with identical fixed settings, matching the FluidNC / ESP32 UART default:

ParameterValue
Byte size8 bits
ParityNone
Stop bits1
XON/XOFF flow controlDisabled
RTS/CTS flow controlDisabled
DTR/DSR flow controlDisabled

These settings match the firmware-side serial transport configured in main/modules/serial/esp3d_serial_config.h. The baud rate is the only field that may need to be adjusted via -b to match a non-default firmware configuration.


Terminal window
python tools/bridge/serial_bridge.py --list
Terminal window
python tools/bridge/serial_bridge.py /dev/ttyUSB0 /dev/ttyUSB1
Terminal window
python tools/bridge/serial_bridge.py COM3 COM4
Terminal window
python tools/bridge/serial_bridge.py /dev/ttyUSB0 /dev/ttyUSB1 -b 250000

Silent bridge — no keyboard, suited for scripted piping

Section titled “Silent bridge — no keyboard, suited for scripted piping”
Terminal window
python tools/bridge/serial_bridge.py COM3 COM4 --no-keyboard
╔════════════════════════════════════════════╗
║ FluidNC Serial Bridge - Bidirectional ║
╚════════════════════════════════════════════╝
✓ Connected to /dev/ttyUSB0
✓ Connected to /dev/ttyUSB1
Bridge active!
G-code [Port1]> status <- sends '?' to Port1
[Port1→Port2] <Idle|MPos:0.000,0.000,0.000|FS:0,0>
G-code [Port1]> 2 <- switch target to Port2
-> Switched to Port2 (/dev/ttyUSB1)
G-code [Port2]> $I <- send info request to Port2
[Port2→Port1] [VER:3.7.8 FluidNC]
G-code [Port2]> exit
✓ Serial bridge closed.

The serial bridge operates entirely on the PC side — it has no build-time dependency on the ESP-IDF firmware. Its role in the development workflow is:

Developer PC

ESP32 Firmware (device)

USB / UART

loopback / second port

Bluetooth SPP/BLE

ESP3DSerialClient

main/modules/serial/

esp3d_serial_client.cpp

serial_bridge.py

(this module)

fw_simulator.py

(tools_firmware_simulator)

fluidnc_bt.py / fluidnc_ble.py

(tools_communication_clients_bt_clients)

  • ESP3DSerialClient (main/modules/serial/esp3d_serial_client.cpp) — the embedded counterpart that owns the UART on the ESP32 and runs esp3d_serial_rx_task in its own FreeRTOS task. The bridge connects to this device over USB.
  • fw_simulator.py (tools_firmware_simulator.md) — can be used as the second endpoint of the bridge instead of a physical device, enabling fully virtual integration tests with simulated FluidNC, grbl, or other firmware responses.
  • BT clients (tools_communication_clients_bt_clients.md) — alternative transport clients when testing over Bluetooth SPP (fluidnc_bt.py, grblhal_bt.py) or BLE (fluidnc_ble.py) instead of UART.
  • WebSocket test (tools_communication_clients_websocket.md) — used when the CNC transport is over WiFi TCP/WebSocket instead of serial.

Transport exclusivity reminder: In the default firmware build, serial (UART) is the CNC transport and WiFi serves the WebUI/remote interface. SOCKET_CLIENT_SERVICE and serial are mutually exclusive at the build level, enforced by cmake/sanity_check.cmake. Refer to docs/architecture/connection_management.md for the full connection model.