Skip to main content

uart-proxy

PyPI PyPI Downloads Python License: MIT

A cross-platform UART log reader / controller for macOS and Windows 11 — think PuTTY / Minicom, but a small tool you own and can extend.

It reads and writes a UART, shows the log on two time axes at once (absolute wall-clock and relative elapsed time), records to files, can re-share the port over a network socket (with an auth code + role), and supports a plugin system for line-by-line pattern matching.

The serial engine is the uart-helper library (built on pyserial), installed from PyPI; this project layers the viewer, recorder, proxy, plugins, and UI on top of it.

See README.arch.md for the architecture diagrams and ROADMAP.md for the action items / status.


Features

# Requirement Status
1 Detect all UART ports; pick one for read & write ✅ uart-proxy ports, connect
2 Show local time and elapsed time; output output.log, output-timestamp.log, output-fulltimestamp.log ✅ recorder + dual time axis
3 Simple ASCII display (BBS / telnet style) ✅ --encoding latin-1, text view
4 Re-share the port via a socket proxy with auth ✅ --serve --auth CODE[:role]
5 Command-line driven ✅ uart-proxy …
6 Connect to a local UART or a remote socket ✅ connect / remote
7 Plugin architecture for pattern watching (grep-style) ✅ --grep, --plugin-dir, Plugin API

Beyond the original seven:

  • Exclusive port claim — nothing else on this machine can open the wire behind your back and silently steal half the bytes (--no-exclusive opts out).
  • Local PTY mirrors — deliberately share the one port with screen, minicom, pyserial or an AI agent: --proxy-dir DIR --proxy-count 2 gives 2 full-duplex mirrors (POSIX).
  • Background sessions — start / status / stop: keep the port, the recording and the proxy alive after the terminal closes (POSIX).
  • attach with replay — rejoin a background session and see what happened while nobody was watching, with the timestamps it really happened at.
  • Scripts and AI agents — tail / expect / send from a shell, a SessionClient for Python, and uart-proxy mcp, an MCP server through which an AI tool reads a session you share and, if you allow it, types into it.

Install

From PyPI:

pipx install uart-proxy

From source (development):

git clone https://github.com/changyy/py-uart-proxy.git
cd py-uart-proxy
python3 -m venv .venv && source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install -e ".[dev]"                                 # editable install + test deps
# uart-helper (the serial engine) and textual (the TUI) install automatically.

Verify:

uart-proxy --version
uart-proxy ports

Trying a working copy as the real command

The venv above only puts uart-proxy on your PATH while it is activated. To try your checkout the way users will run it — from any terminal, including start / attach across shells — install it with pipx straight from the directory:

pipx install --force ~/py-uart-proxy     # replaces any PyPI install of uart-proxy
uart-proxy --version                     # should print the version in _version.py

This is a copy, not a link: run it again after each change you want to try. (pipx install --force --editable ~/py-uart-proxy links instead, so edits take effect immediately.) To go back to the published release: pipx install --force uart-proxy.

Stop any background sessions (uart-proxy stop --all) before reinstalling, so a daemon still running the old code doesn't keep hold of the port.

Running the tests

python -m pytest -q                      # inside the venv above; no hardware needed —
                                         # the suite never opens a real port (conftest.py)
python examples/check_pty_mirrors.py     # PTY mirrors against a fake device (POSIX)
python examples/check_char_mode.py       # character mode against a real bash (POSIX)

A few behaviours can only be confirmed with a real adapter, because the pty driver ignores TIOCEXCL. For those, plug one in and run:

python examples/check_hardware.py /dev/tty.usbserial-110   # or no argument: pick from a list
python examples/check_hardware.py DEV --replug              # + unplug/replug steps
python examples/check_hardware.py DEV --loopback            # + data, TX jumpered to RX

It checks the exclusive claim (a second open really gets EBUSY), what a busy port reports, --profile matching, a session holding the port end to end, and — with --replug — following the adapter to a new name. It refuses to start if anything holds the port, asks before opening it (opening toggles DTR/RTS, which resets some boards), writes nothing unless --loopback, and keeps its sessions in a temporary UART_PROXY_HOME.


Usage

Before a push, the tests run by themselves — under Python 3.10, 3.11, 3.12 and 3.13, as CI does — once the repository's hooks are enabled: git config core.hooksPath .githooks (a failing test stops the push; git push --no-verify skips it). scripts/test-pythons.sh [VERSION…] runs the same by hand.

1. List ports

uart-proxy ports
uart-proxy ports --json

2. Open a local UART (read & write, with the TUI)

uart-proxy connect --port /dev/tty.usbserial-110 --baud 115200
uart-proxy connect --port COM3 --baud 115200          # Windows

In the TUI:

Key / action Effect
type + Enter send a line to the device (the input box is focused on start)
Ctrl+W copy the whole log to the clipboard as clean text
Ctrl+E toggle Select Mode — drag-select a range with the terminal
mouse wheel up scroll into history — auto-follow pauses
mouse wheel down to bottom auto-follow resumes
End jump to the bottom and resume following
Ctrl+T cycle timestamp display: none → relative → full
Ctrl+Y toggle hex view
Ctrl+K clear the log and reset the Ctrl+W copy range
Ctrl+Q quit — line mode only; in character mode Ctrl+Q is XON and goes to the device, so quit with Ctrl+] q
Ctrl+] command prefix — see below (Ctrl+] ? lists the commands)

The status bar shows the connection state (● live / ○ waiting), the port and baud (e.g. … @ 115200 8N1), the elapsed clock, byte counts, the input mode, and whether the view is following the tail (follow / paused ▲) — or, in character mode, the size of the emulated screen (screen 135×34).

Character mode, and the Ctrl+] prefix

Line mode is comfortable for typing commands, but it cannot express anything a shell needs right now: ^C to interrupt a runaway command, ^D for EOF, Tab to complete, ↑ for history. Character mode sends every keystroke as you press it, which is what a serial terminal normally does:

Ctrl+] c        switch character ⇄ line mode
uart-proxy connect --port … --input char     # or start in it

Switching mode switches the view with it, because a shell echoes into a screen, not a log — and a log can only ever append a finished line:

line mode character mode
you type a command, Enter sends it every keystroke, as you press it
you see the timestamped log, line by line the device's screen, emulated
good for reading output, copying, timestamps logging in, Tab completion, vi, htop

Both are live the whole time, so Ctrl+] c never loses anything: the screen already holds what arrived while you were reading the log, and the log holds every line that scrolled off the screen. The screen has no scrollback of its own — that is what the log is, one keystroke away.

Two things a serial line cannot do, so neither does this:

  • Your own keystrokes are not drawn locally. What you see is the device echoing them back, which is why a password prompt correctly shows nothing.
  • The device is never told the window size — RS-232 has no SIGWINCH, and screen over serial has the same limitation. A full-screen program paints to whatever size the far end assumed; the status bar reports ours, so stty rows 34 cols 135 over there is never a guess.

In character mode almost every key belongs to the device — including Ctrl+C, which no longer quits — so exactly one key is reserved as a command prefix:

Keys Effect
Ctrl+] ? list the commands
Ctrl+] d detach — leave a background session running (see §7)
Ctrl+] q quit
Ctrl+] c switch character ⇄ line mode (character mode hides the input box, whose rows go to the device's screen)
Ctrl+] t y k w e timestamps · hex · clear · copy · select mode
Ctrl+] / search — show only the log lines containing a pattern (smart case), live; an empty search brings the full log back. Line mode only
Ctrl+] i session info — proxy address, auth code(s), attach name, mirrors, logs (a toast only: never written to the log or sent to proxy clients)
(k in character mode clears the screen; the log behind it is kept)
Ctrl+] Ctrl+] send a literal Ctrl+] to the device

Why Ctrl+]? It is telnet's escape, picked there for this exact problem. The obvious alternatives are worse for a serial console, because what's usually on the far end is a shell: screen's Ctrl+A is readline's line-start and tmux's Ctrl+B is back-one-character, so both get hit by accident all day. Ctrl+] means nothing to readline, and nothing to flow control either (unlike Ctrl+Q, which is XON). Change it with --prefix ctrl+a if you disagree — and since <prefix> <prefix> always sends the literal byte, the choice is never a dead end.

The familiar Ctrl+T / Ctrl+Y / Ctrl+K / Ctrl+W / Ctrl+E still work directly in line mode, where they can't conflict with anything. In character mode they stand down so the device gets them, and stay reachable via the prefix.

Copying log text

Two ways, depending on what you need:

Whole log — Ctrl+W (recommended, always clean). Copies the entire in-memory log to the clipboard as plain text — no border, no padding, no colour codes. It uses the OS-native clipboard (pbcopy on macOS, clip on Windows, xclip/wl-copy on Linux), so it works even in macOS Terminal.app (which doesn't support the OSC-52 escape that many TUIs rely on). Best when you want to grab the log and paste it into a ticket/chat.

The copy range is everything since the last clear. Press Ctrl+K to clear the display and reset that range, let the lines you care about accumulate, then Ctrl+W to copy just that range.

A specific range — Ctrl+E (Select Mode). While the app is live it captures the mouse for scrolling, so the terminal's own click-drag selection is off. Press Ctrl+E to:

  • freeze the view (incoming data won't scroll it away), and
  • hand the mouse back to your terminal, so you can drag-select a range and copy with your terminal's copy (⌘C / Ctrl+C / right-click).

Press Ctrl+E again to resume live scrolling. The log has no border, so the selection won't pick up frame characters, and macOS terminals trim trailing spaces on copy. (If you still see padding, use Ctrl+W for a guaranteed-clean copy.)

Auto-reconnect / wait for device

If the port isn't there yet (or you haven't plugged the adapter in), connect no longer fails — it shows ○ waiting and attaches automatically as soon as the device appears. If the device is unplugged mid-session it shows reconnecting and re-attaches when it returns. Disable with --no-reconnect; tune the retry period with --reconnect-interval SECONDS.

A USB adapter replugged can come back under another name (usbserial-110 → usbserial-120). connect recognises it by its identity — VID, PID and serial number, or failing a serial the USB socket it is in — and follows it, with a notice saying where it went. It never guesses between two identical adapters it can't tell apart, and a port with no USB identity (a built-in UART) is waited for on its own path as before.

Baud rate

--baud defaults to 115200, so it is optional. The effective baud (and framing) is always visible in the status bar, e.g. … @ 115200 8N1.

Choosing the port

Leave out --port in a terminal and connect shows the ports to pick from (r rescans after you plug something in, Esc cancels); a port one of your sessions already holds is marked, with a pointer to attach. Without a terminal — a script, a pipe, --no-tui — it lists them and exits instead of waiting for a keypress. start never asks: give it --port, or a profile.

Device profiles (--profile)

uart_helper profiles work here too — TOML files in ./uart-helper.d/ or ~/.config/uart-helper/:

# ~/.config/uart-helper/lab.toml
[defaults]
baudrate = 9600
parity = "E"
rtscts = true

[[rules]]
vid = "067b"
pid = "23a3"
uart-proxy connect --profile lab          # finds the matching port, 9600 8E1
uart-proxy connect --profile lab --baud 115200   # a flag still wins
uart-proxy start --profile lab            # background, same rules

[defaults] fills in baud / bytesize / parity / stopbits / flow control wherever no flag is given; [[rules]] pick the port when --port is left out — one match is used, several are offered to choose from (or listed, where nobody can choose), none is an error. --profile also takes a path to a .toml file.

Line ending on Enter (--eol)

Pressing Enter appends a line ending, default cr (\r) — the convention for Unix consoles (same as PuTTY/minicom/screen). Using crlf against such a console sends two line-ends, which the device sees as two Enters (e.g. the login prompt prints twice). Change it if your device needs something else:

uart-proxy connect --port … --eol cr     # default: \r  (Unix console, login prompts)
uart-proxy connect --port … --eol crlf   # \r\n (some modems / AT firmwares)
uart-proxy connect --port … --eol lf      # \n
uart-proxy connect --port … --eol none    # send exactly what you typed

Network ports: console servers, ser2net, QEMU

A UART exposed as a TCP port — by a lab console server (Moxa, Digi, Lantronix, Cisco), ser2net, a Wi-Fi–serial bridge, or an emulator — can be the device itself, and then timestamps, logs, grep, search, mirrors, the proxy and background sessions all work on it:

uart-proxy connect --port socket://10.0.0.5:4001      # raw TCP
uart-proxy connect --port rfc2217://10.0.0.5:7001     # Telnet + RFC 2217
uart-proxy start   --port rfc2217://console1:7001 --name router
Scheme What it is Serial settings (--baud, parity, …)
socket:// raw TCP — ser2net raw, qemu -serial tcp::4444,server, most Wi-Fi bridges the server's — ours are not sent
rfc2217:// Telnet with RFC 2217 COM-port control — ser2net telnet/rfc2217, most console servers applied to the far port, so it behaves like a local one
  • If the server isn't up yet, or drops the connection (a console server rebooting, ser2net restarted), it waits and reconnects like an unplugged adapter does.
  • What belongs to a local device doesn't apply: there is no exclusive claim to take — the server decides who else may connect, and says so when connected — no busy-port hint, and no following a replug.
  • Mirrors and background sessions are named after the URL (socket-10.0.0.5-4001-0).
  • Plain telnet has its own scheme, telnet:// — below.

Over SSH: ssh://

Anything you reach with ssh -tt can be the device too — an SSH-based console server, a Raspberry Pi with the adapter plugged into it, a BBS:

uart-proxy connect --port ssh://admin@console1                    # a console server
uart-proxy connect --port ssh://pi@lab \
    --ssh-command "picocom -b 115200 /dev/ttyUSB0"                  # a UART on another machine
uart-proxy connect --port ssh://bbsu@ptt.cc --term-size 80x24       # a BBS (UTF-8)
  • It runs your own OpenSSH client in a pty, so keys, known_hosts, the agent, ~/.ssh/config and ProxyJump all behave exactly as for ssh. A host-key question or a password prompt appears on screen: answer it there.
  • It starts in character mode (--input line to override): a shell or a BBS reads keys, not lines, and full-screen programs need the terminal view.
  • Unlike a serial line, SSH carries the window size, and by default (--term-size auto) the far end gets exactly the rows and columns the terminal view has — your window minus uart-proxy's header, status bar and footer — and follows it when you resize. With --no-tui it gets your terminal's own size, likewise followed. --term-size 80x24 fixes it instead: what a BBS expects.
  • --ssh-option OPT passes -o OPT (e.g. BatchMode=yes for a background session, which has nobody to type a password — use a key there).
  • If ssh exits it is run again, like a replugged adapter — for a BBS that means a fresh login, not the screen you left.
  • Encoding is UTF-8; a Big5 BBS account is not supported. For PTT, use bbsu.
  • A full-screen program's log files are full of cursor-movement codes: the terminal view is how to read it; output.log is still the complete record.
  • POSIX only (it needs a pty).

Over telnet: telnet://

uart-proxy connect --port telnet://ptt.cc --term-size 80x24    # a BBS
uart-proxy connect --port telnet://10.0.0.1                    # a router's CLI

The option negotiation a telnet server starts with is answered, not shown: it may echo and suppress go-ahead (character at a time, what a BBS or shell expects), gets our terminal type (XTERM-256COLOR) and our window size (NAWS) — which, as with ssh://, follows the terminal view unless --term-size fixes it. Everything else is refused. Like ssh://, it starts in character mode; the port defaults to 23.

Without hardware at hand, an emulator is a fake device that really boots:

qemu-system-arm … -serial tcp::4444,server,nowait
uart-proxy connect --port socket://127.0.0.1:4444

3. Time axes & log files

Every line carries both axes. The display can show either:

relative:  00:00:10.0000  < device output here
full:      2026-06-12 08:40:20 | 00:00:10.0000  < device output here

Recording writes three files:

output.log                 raw RX bytes, exactly as received
output-timestamp.log       [00:00:10.0000] line          (elapsed only)
output-fulltimestamp.log   [2026-06-12 08:40:20 | 00:00:10.0000] line
output-timing.log          when each run of bytes in output.log arrived: epoch, elapsed, bytes (for replay)

Where they go: by default each run gets its own folder so nothing is ever clobbered:

~/.uart-proxy/sessions/<YYYYmmdd-HHMMSS>/output*.log

The two timestamped files open with a # banner and close with the window, so a log read months later still says what it is:

# uart-proxy 1.20260930.… · /dev/tty.usbserial-110 @ 115200 8N1
# encoding utf-8 · eol cr · started 2026-09-30 11:33:26 +08:00 (elapsed 0 = this instant)
[00:00:01.2034] U-Boot 2024.01 …
# ended · 2026-09-30 11:33:26 ~ 2026-09-30 12:10:02 (2196s) · 00:00:00.0000 ~ 00:36:36.0412

output.log gets no banner: it stays exactly the bytes the device sent.

The path is printed at startup and shown live in the TUI status bar (rec→…). Override with --output-dir DIR (use --output-dir . for the current directory), rename the files with --log-base NAME, disable with --no-log, or append instead of overwrite with --log-append.

A session left running for days can be split into parts, and capped:

uart-proxy start --port … --log-rotate-mb 100 --log-keep-parts 20   # ≤ ~2 GB

At 100 MB all three files move aside together as output.001.log, output-timestamp.001.log, … and fresh ones continue, at the end of a line; each part repeats the banner and names the part before it. --log-keep-parts deletes the oldest beyond N — retention only prunes finished sessions, so this is what bounds one that never finishes. Both are off by default.

Playing a session back

A full-screen program — a BBS, vi, a boot menu — leaves a log of cursor movements nobody can read. replay plays the recording back through a terminal emulator, at the pace it arrived, so it reads as it looked:

uart-proxy replay                         # the newest recorded session
uart-proxy replay ~/.uart-proxy/sessions/20260930-113326 --term-size 80x24
uart-proxy replay output.003.log --speed 8
uart-proxy replay DIR --at 03:12:40       # start at that moment
uart-proxy replay DIR --no-tui            # into this terminal, like scriptreplay

Space pauses, ←/→ seek 5 s, +/- change speed, Home/End jump, g goes to a time, q quits; the status bar shows the position, the speed and the wall-clock time of that moment.

Debugging with it: grep the timestamped log for the moment something went wrong, then play from just before it —

grep -n PANIC ~/.uart-proxy/sessions/20260930-*/output-fulltimestamp.log
#   …/output-fulltimestamp.log:4812:[2026-09-30 03:12:41 | 00:47:15.2033] PANIC …
uart-proxy replay ~/.uart-proxy/sessions/20260930-022526 --at 03:12:30

--at (and g) take +00:47:15 (session elapsed, as in the timestamped logs), 03:12:30 (a time of day) or 2026-09-30 03:12:30. Silences longer than --max-idle (2 s) are cut short. It works from output.log and output-timing.log; a recording made before timing was kept plays all at once.

Retention (auto-cleanup of the session store)

The default store is pruned automatically on each run along two axes:

  • age — sessions older than 30 days are deleted;
  • total size — if the store still exceeds 500 MB, the oldest sessions are deleted (logrotate-style) until it fits.

Either can be changed per-run or made permanent. 0 disables an axis. The in-progress session is never deleted.

# per-run override
uart-proxy connect --port … --max-age-days 14 --max-total-mb 1000

# inspect / prune manually
uart-proxy sessions                 # list sessions + current policy
uart-proxy sessions --prune         # apply the policy now
uart-proxy sessions --json

Permanent defaults live in ~/.uart-proxy/config.toml:

[retention]
max_age_days = 30      # 0 = keep forever
max_total_mb = 500     # 0 = no size cap

Precedence: CLI flag > config file > built-in default.

The same file can hold fixed proxy codes, so a restart doesn't mean sending everyone a new one — and the code stays off the command line, where ps would show it to other users:

[proxy]
auth = ["fixedcode", "look:readonly"]

It is ignored (with a note, falling back to a generated code) unless the file is private — chmod 600 ~/.uart-proxy/config.toml — and needs Python 3.11+.

4. BBS / telnet style ASCII

uart-proxy connect --port /dev/ttyUSB0 --encoding latin-1 --eol cr

5. Share the port over the network (socket proxy)

On the machine with the UART:

uart-proxy connect --port /dev/ttyUSB0 --serve \
    --auth 123456 \              # full access (read + write)
    --auth 000000:readonly       # read-only (e.g. for a mobile viewer)

From another machine:

uart-proxy remote --host 192.168.1.10 --port 9600 --auth 123456

Leave out --auth and a random code is generated for this run and printed — there is no fixed default, because --serve listens on every interface unless you pass --listen 127.0.0.1, and says so when it starts:

No --auth given; generated code 71baffb7cbe50ef7 (full access).
Proxy listening on 0.0.0.0:9600
  (every interface — reachable from the network; --listen 127.0.0.1 keeps it on this machine)

Two more knobs for sharing:

  • --echo-tx shows every client each line typed into the device, by anyone (not keystroke by keystroke, and not back to whoever typed it). Off by default, because a password typed at a login prompt is a line too.
  • --max-clients N (default 16) caps connections served at once; the next one is told the server is full and keeps retrying. 0 removes the cap.

A client refused by the server — wrong code, or refused for guessing — stops rather than retrying, so a server restarted with a new code can't get its old clients banned. Guessing is rate-limited per address: 10 failed attempts within a minute and that address is refused for 10 minutes (with a notice in the session), even with the right code. Other addresses are unaffected, and it clears on its own — no restart needed. The code travels in plain text — see below for networks you don't trust.

Over a network you don't trust: an SSH tunnel

Why. The proxy has no TLS yet: the auth code and every byte of the console — including passwords you type — cross the network in the clear. Fine on a LAN you trust; not across an office network, public Wi-Fi or the internet.

How. Nothing in uart-proxy changes. You make it listen on loopback only, so nothing outside that machine can reach it, and let SSH — which you already trust for logging in — carry the connection, encrypted and authenticated:

your machine                                lab machine (UART attached)
────────────                                ───────────────────────────
uart-proxy remote                           uart-proxy, listening on 127.0.0.1:9600
  │ connects to 127.0.0.1:9600                          ▲
  ▼                                                     │
ssh client ═════ encrypted SSH connection (port 22) ══▶ sshd
                 (anyone in between sees only SSH)

1. On the lab machine — serve on loopback, and get the code:

uart-proxy start --port /dev/ttyUSB0     # background; loopback is its default
# or in a terminal (connect --serve defaults to every interface, so say so):
uart-proxy connect --port /dev/ttyUSB0 --serve --listen 127.0.0.1

uart-proxy status --show-auth            # the auth code for step 2

2. On your machine — open the tunnel, then connect to your own loopback:

ssh -N -L 9600:127.0.0.1:9600 you@lab    # leave it running (or add &)
uart-proxy remote --host 127.0.0.1 --port 9600 --auth <code>

-L 9600:127.0.0.1:9600 reads "my port 9600 → what lab sees as 127.0.0.1:9600"; -N opens the tunnel without a remote shell. remote talks to 127.0.0.1 on your machine, and SSH delivers it to the lab.

Other setups

Situation Command
Port 9600 is already taken on your machine ssh -N -L 19600:127.0.0.1:9600 you@lab, then remote --port 19600
The lab is behind NAT and can't be reached, but can reach you on the lab: ssh -N -R 9600:127.0.0.1:9600 you@your-machine; connect to 127.0.0.1:9600 on yours
Only reachable through a jump host ssh -N -J you@bastion -L 9600:127.0.0.1:9600 you@lab

Leaving it running

  • If the network drops, the tunnel drops; remote keeps retrying and picks up again when the tunnel is back. Only a refused code makes it stop.
  • To rebuild the tunnel itself automatically, use autossh: autossh -M 0 -N -o ServerAliveInterval=15 -L 9600:127.0.0.1:9600 you@lab
  • Local tools can ride the same tunnel: add --proxy-dir to remote, and screen /tmp/uart-proxy/127.0.0.1-9600-0 talks to the lab's device as if it were plugged in here.

Pitfalls

  • Loopback only, or the tunnel protects nothing. connect --serve listens on 0.0.0.0 unless told otherwise, and then anyone can skip the tunnel and connect to lab:9600 directly. Check from another machine: connecting to lab:9600 should fail.
  • Everyone through the tunnel is 127.0.0.1 to the rate limit, so one person guessing a code wrong refuses all tunnelled clients for 10 minutes. Copy the code from status --show-auth rather than typing it from memory.

Attaching to a uart_helper-owned port (integration apps)

If another app already owns the serial port via uart-helper, uart-proxy can't open it (UART is exclusive). Instead, have that app expose a loopback-TCP broker speaking this same protocol, and attach with remote — no uart-proxy changes needed.

A drop-in, dependency-free broker (stdlib + uart_helper, portable to Windows & macOS — loopback TCP, not a Unix socket file) lives at examples/uart_helper_broker.py. It has two modes:

Embedded / tee mode — your app keeps owning the UART (it reads & uses the data) and just tees a copy to uart-proxy. This avoids two readers on one port:

from uart_helper import UARTDevice, PortIdentity, UARTConfig
from uart_helper_broker import UartHelperBroker   # or uart_helper.broker

dev = UARTDevice(PortIdentity(device="COM3"), UARTConfig(baudrate=115200))
dev.open()
broker = UartHelperBroker(host="127.0.0.1", port=9600,
                          auth={"123456": "full", "000000": "readonly"},
                          on_tx=lambda b: dev.write(b),   # client → device
                          source="my-app COM3")
broker.start()
while running:
    data = dev.read(...).data
    if data:
        my_app_consume(data)        # your app uses the data
        broker.publish_rx(data)     # …and tees it to uart-proxy

Owned mode — a standalone bridge where the broker opens the port itself:

python examples/uart_helper_broker.py --port COM3 --baud 115200 \
    --auth 123456 --auth 000000:readonly

Either way, attach from anywhere with the unmodified client:

uart-proxy remote --host 127.0.0.1 --port 9600 --auth 123456

The wire protocol is specified in PROTOCOL.md.

A read-only client (--auth 000000) can watch the stream but cannot send.

Why sharing needs a proxy — a UART delivers each byte once. There is no OS-level "multiple readers" for a raw serial port: whoever reads a byte first consumes it. On Windows a COM port is exclusive-open, so a second program simply gets "access denied". On POSIX a second open is not refused by default — both processes succeed and then split the stream between them at random, with nothing reporting the problem.

Measured on macOS 15 (open(2) on a USB-serial node, no claim taken): a second open of the same node succeeds. What is refused is the other node of the pair — holding /dev/tty.X makes /dev/cu.X return EBUSY and vice-versa (the classic dialin/callout interlock). Tools that claim the line — screen does, despite its reputation; minicom also writes a lock file — are protected; a plain pyserial script or cat is not, in either direction.

So uart-proxy claims the port itself with TIOCEXCL, which closes the same-node hole: with it, both nodes return Resource busy to everyone else (verified). Sharing then happens on purpose, one of two ways: over the network with --serve (below), or with other tools on this machine via PTY mirrors (next section). Use --no-exclusive for the old free-for-all.

You don't have to take that on trust — the claim is best-effort, so connect says which way it went as soon as it is connected:

* exclusive: claimed /dev/tty.usbserial-110 (TIOCEXCL) — another program
  opening it now gets EBUSY

…or COULD NOT claim … if the ioctl was refused, or not claimed (--no-exclusive) if you asked for that.

Note pyserial's own exclusive=True is not this: it takes an advisory flock, which only stops other programs that also flock.

The other way round — you are the one refused — connect says who has the port and how to get at it instead of just Resource busy. The likeliest holder is a background session you forgot about, and then the way in is one command:

* port busy: /dev/tty.usbserial-110 is held by background session
  'usbserial-110' (pid 50073) — join it with 'uart-proxy attach usbserial-110',
  or open one of its mirrors in /tmp/uart-proxy; free it with
  'uart-proxy stop usbserial-110'

Any other holder is named from lsof (screen (pid 777)) where it can see it. start goes further and refuses outright to launch a second background session on a port one already holds.

6. Share the port with local tools (PTY mirrors)

--serve is for other machines. To share with other programs on this box — screen, minicom, a pyserial script, an AI agent — expose PTY mirrors:

uart-proxy connect --port /dev/cu.usbserial-110 --baud 115200 \
    --proxy-dir /tmp/uart-proxy --proxy-count 2
Sharing /dev/cu.usbserial-110 via 2 PTY mirror(s), tx-merge=line:
  /tmp/uart-proxy/usbserial-110-0 -> /dev/ttys004
  /tmp/uart-proxy/usbserial-110-1 -> /dev/ttys005

Each mirror is a real, full-duplex serial device — --proxy-count 2 gives you 2 readers and 2 writers. Anything that opens a serial port can attach:

screen /tmp/uart-proxy/usbserial-110-1        # a human watching & typing
python -c "import serial; s=serial.Serial('/tmp/uart-proxy/usbserial-110-0')"
  • Everyone sees everything the device says — RX is broadcast to every mirror.
  • Anyone may type, and every byte crosses immediately (--tx-merge raw, the default). A mirror behaves like the port it stands in for: ^C interrupts the running command now, tab completion completes, arrow keys reach the shell's history, a single-key y/n prompt answers.
  • --tx-merge line is the opt-in alternative: your bytes are held until you finish the line, then the whole line goes out in one write, so two writers can never turn reboot + whoami into rebwhooot. That costs every interactive behaviour above, so reach for it when several unattended writers share the wire and a mangled command would be worse than a laggy one. ^C ^D ^Z ^\ are still sent immediately even here — a signal delivered late isn't slow, it interrupts the wrong thing.
  • A mirror shows device output only, not what another mirror typed. Real consoles echo, so you still see the other person's command come back — and staying transparent is what lets an unmodified screen work.
  • A mirror shows you the present, not a backlog. Output that arrived while nobody was attached is discarded after --proxy-max-lag seconds (default 5), so the tool you attach next sees what the device says from now on. This matters most for programs: reading minutes-old output as the current state is worse than not seeing it at all. The full record is in the log files.
  • A client that stops reading gets its backlog dropped (past 1 MiB) rather than stalling everyone else. A client that is merely slow loses nothing — the rule is "no progress", not "old bytes".
  • Symlinks are cleaned up on exit, including SIGTERM; a stale one from a kill -9 is replaced on the next start.

Give an exact path instead of a directory with --proxy PATH (repeatable).

No adapter to hand? examples/check_pty_mirrors.py runs the whole thing against a PTY pair standing in for the device, and reports each guarantee separately — useful both as a smoke test and as a readable demonstration of what sharing actually looks like:

$ python examples/check_pty_mirrors.py
============ --tx-merge raw ============
  ✓ 2 mirrors announced and symlinked
  ✓ device RX is broadcast to every mirror
  ✓ a mirror's TX reaches the device
  ✓ a single byte crosses without waiting for Enter
  ✓ ^C reaches the device immediately
  ✓ the device's reply is visible to both mirrors
  ✓ SIGTERM removes the symlinks
============ --tx-merge line ===========
  ✓ a partial line is held back
  ✓ both commands arrive intact (line-atomic merge)
  ✓ ^C is not held by line-merge
  …

What this can't do. A UART is one unframed byte stream, so nothing at this layer can tell you which reply belongs to which writer. Line-atomic merge keeps each command intact; correlating responses is up to you. If you need real per-client request/response, use the socket proxy protocol instead.

POSIX only — Windows has no pty. Use --serve there.

A remote stream can be mirrored the same way — remote … --proxy-dir (and attach … --proxy-dir) gives local PTYs fed from a proxy elsewhere, named <host>-<port>-0 (<session>-attach-0 for attach, so they never collide with the daemon's own). With a read-only code, what is typed into them is refused, and said so.

7. Run it in the background (start / status / stop)

A serial session is long-lived — you want it to survive closing the terminal, and to be able to walk away and come back:

uart-proxy start --port /dev/cu.usbserial-110 --name router \
    --proxy-dir /tmp/uart-proxy
Started 'router' — /dev/cu.usbserial-110 @ 115200
  proxy    127.0.0.1:9600 (auth in ~/.uart-proxy/daemons/router.json)
  logs     ~/.uart-proxy/sessions/20260731-095337
  mirrors  /tmp/uart-proxy
  stop it  uart-proxy stop router

It keeps the port, keeps recording and keeps serving with nobody watching.

uart-proxy status              # what's running, and how long since it last heard anything
uart-proxy stop router         # ordered shutdown (--all, --force)
NAME                 PID      UP   QUIET  PORT
router             68510      2h      3s  /dev/cu.usbserial-110 @ 115200

QUIET is time since the last recorded byte — usually the thing you actually want to know about a session you left running.

While it runs you can reach it two ways, and both work at the same time:

uart-proxy attach router               # no code needed — see below
uart-proxy status --show-auth          # or look the code up, for `remote` elsewhere
screen /tmp/uart-proxy/router-1        # or just attach to a mirror
  • A daemon always serves the proxy — one you can't reach is useless — bound to 127.0.0.1 unless --listen says otherwise, with a generated auth code in its 0600 state file.
  • --name names the session and its mirrors (router-0, router-1), so one word is the handle for the whole thing. Without it, both default to the device stem.
  • connect stays foreground, single process, no daemon. Detaching is explicit, because a leftover daemon holding the port (it claims it exclusively — see above) is a confusing thing to inflict on the simple case.
  • connect --serve is registered too, marked (foreground) in status, so its code — generated, and long gone off the top of the terminal — can be looked up with status --show-auth, and attach works on it from another terminal. It is unregistered when it exits. (status hides codes unless asked: its output gets pasted around.)
  • start fails if the daemon fails — it waits for the child to report that it's serving. An absent device isn't a failure, though: waiting for it to be plugged in is normal.
  • kill -9 on a daemon leaves its state file and mirror symlinks behind; the next status, stop or start clears them.

Attaching back to it

uart-proxy attach            # the only running session
uart-proxy attach router     # by name

You get what you missed first, dimmed, with the timestamps from when it actually happened — then the live tail:

── replayed 4 lines · 2026-07-31 19:09:52 → 2026-07-31 19:09:53 (1.1s) ──
[2026-07-31 19:09:52 | 00:00:01.5229] [boot 0] initialising subsystem 0
[2026-07-31 19:09:53 | 00:00:02.5873] [boot 3] initialising subsystem 3
── live ──
[2026-07-31 19:09:55 | 00:00:04.9557] [live] this arrived AFTER attaching
  • Host, port and auth code come from the session's state file — nothing to type.
  • The elapsed column is the daemon's clock, for replayed and live lines alike, so it matches the daemon's own log files instead of restarting at zero when you attach.
  • --replay-lines N sets how much history (default 2000, 0 for live only).
  • Several clients may attach at once, each with its own view; the daemon keeps the port either way.
  • Replay is display-only — the daemon already recorded those lines and already ran your --grep rules on them, so an attaching client doesn't do it again.
  • uart-proxy remote --replay-lines N gets the same thing from a remote server.

Leaving an attached client (Ctrl+Q, or just closing the terminal) leaves the daemon running — that is the useful half of detach. A proper <prefix> d keystroke arrives with the Ctrl-] command prefix; see ROADMAP.md.

8. Plugins (pattern watching)

Quick grep:

uart-proxy connect --port /dev/ttyUSB0 --grep ERROR --grep "panic.*" --grep-ignore-case

Load your own plugins:

uart-proxy connect --port /dev/ttyUSB0 --plugin-dir ./plugins

A plugin is a Plugin subclass — override on_line to react to patterns and optionally write back to the device. See plugins/example_alert_plugin.py.

9. Scripts and AI agents (tail / expect / send, mcp)

A session that serves the proxy — connect --serve, a started one, or an app sharing one of its tabs — can be driven without a terminal. These are clients of the session, never a second owner of the port: whoever has it open keeps seeing everything sent and everything the device answers.

uart-proxy tail usbserial-110 -n 20                       # the last lines, stamped
uart-proxy send usbserial-110 "uname -a" --expect Linux   # send, wait for the reply
uart-proxy expect usbserial-110 "login:" --timeout 60 && echo booted

The name is the one status lists (the only session running needs none), or reach a server directly with --host --port --auth. expect and send --expect exit 1 when the text does not come in time.

From Python, the same through SessionClient:

from uart_proxy.client import SessionClient

with SessionClient.from_registry("usbserial-110", want_full=True, client_name="my test") as dev:
    mark = dev.cursor
    dev.send_text("uname -a")
    hit = dev.expect(r"Linux", timeout=5, since=mark)

For an AI tool, uart-proxy mcp is a Model Context Protocol server on stdio. Its tools: list_sessions, session_status, tail, read_new (only what the agent has not seen) and wait_for; with --allow-send, also send_text and send_hex — and only to a session shared with a full-access code. In Claude Desktop's claude_desktop_config.json:

{
  "mcpServers": {
    "uart": { "command": "uart-proxy", "args": ["mcp"] }
  }
}

or with Claude Code: claude mcp add uart -- uart-proxy mcp.

The agent always knows whether the device is really there: session_status gives a verdict — ok, degraded (just reconnected, or connecting) or down (the adapter is gone, or the session is no longer shared) — with advice a person can act on ("re-plug the USB-serial adapter", "share the tab again"). Results from a session that is not well say so first; wait_for_device waits for it to come back after the person re-plugs it; and a change of the verdict is sent as an MCP notification. Add --allow-send to the arguments to let the agent type. The agent connects under the name uart-proxy mcp (<its name>), which the session's owner sees.

Headless (no TUI)

Add --no-tui to stream to stdout instead — handy for a server box that only needs to serve the proxy and write logs:

uart-proxy connect --port /dev/ttyUSB0 --serve --auth 123456 --no-tui

Project layout

py-uart-proxy/
  pyproject.toml
  README.md            ← you are here
  README.arch.md       ← architecture diagrams
  ROADMAP.md           ← action items / status
  src/uart_proxy/
    core/    timestamp · events · bus · line_assembler · recorder · session
             retention · pty_proxy (local PTY mirrors) · daemon (background
             sessions: state files, detach, liveness) · replay (history for attach)
             port_busy (who holds a busy port, and the way in instead)
    io/      source (ABC) · uart_source · socket_source
    proxy/   protocol (JSON-lines + auth/roles) · server
    plugins/ base · manager · builtin/grep
    ui/      tui (Textual) · headless
    cli.py
  plugins/   example user plugin
  examples/  uart_helper_broker.py · check_pty_mirrors.py
  tests/

Requirements

  • Python 3.10+
  • pyserial ≥ 3.5
  • textual ≥ 0.60 (core dependency, powers the TUI; --no-tui runs without using it)
  • uart-helper ≥ 1.0 (from PyPI)

License

MIT © Yuan-Yi Chang

Metadata

Release files for uart-proxy 1.20261004.1093657

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for uart-proxy 1.20261004.1093657
File Size Uploaded Provenance
uart_proxy-1.20261004.1093657.tar.gz 309.2 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for uart-proxy 1.20261004.1093657
File Interpreter ABI Platform
uart_proxy-1.20261004.1093657-py3-none-any.whl Python 3 none any Details

Total release size: 467.8 kB

Release files / uart_proxy-1.20261004.1093657.tar.gz

Download URL uart_proxy-1.20261004.1093657.tar.gz
Size 309.2 kB
Tags Source
SHA-256 checksum
How to use checksums
65d58bb349a96d59af098f122f0d8086ffec776781f1034ea6e95a77c7cb5c1a
BLAKE2b-256 checksum
How to use checksums
5670faade3a86ecc9f9f94ec1632973e92a8821898174de52a700c68dc81592f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.

Transparency log

Release files / uart_proxy-1.20261004.1093657-py3-none-any.whl

Download URL uart_proxy-1.20261004.1093657-py3-none-any.whl
Size 158.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ff1a571433408d7127646f0f41e1ac722b0432153ed64f372eda2fceb1685526
BLAKE2b-256 checksum
How to use checksums
8136c9622694c71f00847ed9b1c879df5cb45d3beb80f9ba83ba5a26c8a137ff
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.

Transparency log

Security for uart-proxy 1.20261004.1093657

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for uart_proxy-1.20261004.1093657.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page