serial-junction
One serial port, many threads. serial-junction lets any number of threads
in your program read from and write to the same serial port safely. It buffers
incoming bytes in the background and reconnects automatically when the device
drops. It is a software library built on pyserial,
not a hardware splitter.
from serial_junction import SerialJunction
with SerialJunction("/dev/ttyUSB0", 115200) as junction:
junction.write(b"*IDN?\r\n")
print(junction.readline(timeout=1.0))
Why serial-junction?
pyserial gives you a port, and sharing it between threads is left to you. Two
threads reading from one serial.Serial split the incoming bytes between them
unpredictably, concurrent writes can interleave, and when a USB adapter is
unplugged the port stays dead until you rebuild it yourself.
serial-junction puts a single background thread in charge of the port:
- Every byte lands in one buffer. Threads take whole lines, chunks or framed packets from it, waiting up to a timeout you choose.
- Any thread can write. Writes are serialized, and raise
serial.SerialExceptionwhen the device is gone instead of failing silently. write_latest()for control loops. Queue a command without blocking; if the link is busy or down, only the newest command is sent.- Automatic reconnection. When the device drops, the junction reopens it and your threads carry on.
- Packet framing for fixed-length binary protocols.
- Typed and tested end to end against real pseudo-terminals, including on free-threaded Python.
Installation
pip install serial-junction
Or with uv: uv add serial-junction.
Requires Python 3.10 or newer.
Usage
Opening a port
from serial_junction import SerialJunction
junction = SerialJunction("/dev/ttyUSB0", 115200) # or "COM3" on Windows
junction = SerialJunction(baudrate=115200) # first device matching "ACM|USB"
junction = SerialJunction(search_pattern="CP210", baudrate=115200)
The constructor blocks until the port opens. By default it keeps retrying until
a device appears; pass max_reconnect_attempts=3 to fail with
serial.SerialException instead. Use it as a context manager, or call
junction.stop() when you are done.
Reading
Received bytes collect in a buffer. Each read takes bytes out of it, so with several reader threads every byte goes to exactly one of them.
line = junction.readline(timeout=1.0) # b"OK" (terminator removed), or None after 1 s
line = junction.readline(b"\n") # custom terminator; returns at once
chunk = junction.read(64, timeout=0.5) # up to 64 bytes
everything = junction.read() # whatever is buffered, possibly b""
timeout |
Behaviour |
|---|---|
0 (default) |
Return immediately with what is buffered |
| seconds | Wait up to that long |
None |
Wait until data arrives or the junction stops |
To guard against a device that never sends a terminator, pass max_bytes:
junction.read_until(b"\n", max_bytes=256) returns a runaway line in 256-byte
pieces.
Writing
junction.write(b"MEASURE\r\n") # blocks until sent; a str is encoded as UTF-8
write() raises serial.SerialException if the device is disconnected or the
write fails. The data is not retried, so you decide whether to resend it.
For control loops, write_latest() queues a value and returns immediately. A
background thread sends it as soon as the port is free. If you queue a new value
before the old one is sent, the old one is dropped, so a slow or reconnecting
link never builds up a backlog of stale commands:
while running:
junction.write_latest(f"SET {controller.output():.3f}\n")
time.sleep(0.01)
Reconnection
When a read or write fails, the background thread closes the port and reopens
it every reconnect_delay seconds (default 1):
- An explicit
portis always reopened at the same path. It is never swapped for another device, even if one matchessearch_pattern. - With
port=None, the device you were last connected to is preferred. - Data already received stays in the buffer.
- While disconnected,
is_openisFalseandwrite()raises. - After
max_reconnect_attemptsfailed attempts (0, the default, means retry forever), the junction gives up:runningbecomesFalse, blocked readers return, andwrite()raises with the last connection error as its cause.
Binary packets
WindowedPacketReader extracts fixed-length packets framed by a start byte and
an end byte, skipping any noise in between:
from serial_junction import SerialJunction, WindowedPacketReader
junction = SerialJunction("/dev/ttyACM0", 115200)
reader = WindowedPacketReader(junction.read, window_size=10, start_byte=0xA5, end_byte=0x5A)
payload = reader.read_packet() # the 8 bytes between 0xA5 and 0x5A, or None after 1 s
Packets that arrive together, or are split across reads, are handled; bytes after a packet are kept for the next call.
Stopping
stop() (or close(), or leaving the with block) stops both background
threads, releases any thread blocked in a read or write, and closes the port. It
is safe to call more than once.
Logging
Connection events are logged to the serial_junction logger at INFO and ERROR
level. Enable DEBUG to log every chunk sent and received:
logging.getLogger("serial_junction").setLevel(logging.DEBUG)
API reference
SerialJunction(port=None, baudrate=9600, *, ...)
| Parameter | Default | Description |
|---|---|---|
port |
None |
Device path such as "/dev/ttyUSB0" or "COM3"; None auto-detects |
baudrate |
9600 |
Baud rate |
bytesize, parity, stopbits |
8N1 | As for serial.Serial |
max_reconnect_attempts |
0 |
Failed attempts before giving up; 0 retries forever |
reconnect_delay |
1.0 |
Seconds between connection attempts |
search_pattern |
r"ACM|USB" |
Regex matched against device paths and descriptions when auto-detecting |
timeout |
1 |
Read timeout of the background reader thread; rarely needs changing |
| Method or property | Description |
|---|---|
read(size=-1, timeout=0) |
Up to size bytes from the buffer (-1 for all) |
read_until(expected=b"\r\n", max_bytes=None, timeout=0) |
Data up to a delimiter, or None |
readline(terminator=b"\r\n", timeout=0) |
Shorthand for read_until |
write(data) |
Blocking write; raises serial.SerialException on failure |
write_latest(data) |
Non-blocking write that keeps only the newest value |
detect_devices() |
Device paths matching search_pattern |
stop() / close() |
Stop the background threads and close the port |
in_waiting |
Number of buffered bytes |
is_open |
Whether the port is currently connected |
running |
False once stopped or once reconnection has given up |
port |
The device currently in use |
WindowedPacketReader(read_callback, window_size=10, start_byte=0xA5, end_byte=0x5A, timeout=1.0, poll_interval=0.001)
| Parameter | Description |
|---|---|
read_callback |
Called with no arguments to fetch bytes, e.g. junction.read |
window_size |
Total packet length, including the start and end bytes |
start_byte, end_byte |
Framing byte values |
timeout |
Seconds read_packet() waits for a packet |
poll_interval |
Seconds to sleep when read_callback returns nothing |
read_packet() returns the payload between the framing bytes, or None on timeout.
Examples
line_protocol.py: one thread sends lines while another prints the replies.binary_packets.py: reads framed binary packets while streaming setpoints withwrite_latest().
Platform notes
- Linux: your user needs permission to open serial devices, usually by
joining the
dialoutgroup (uucpon Arch):sudo usermod -aG dialout $USER, then log in again. - macOS: open USB adapters through their
/dev/cu.*path, for example/dev/cu.usbserial-1410. - Windows: use
COMport names such as"COM3".
Migrating from threadsafe-serial
This package was previously python-threadsafe-serial. The old imports still
work but emit a DeprecationWarning:
| Before | After |
|---|---|
from threadsafe_serial import ThreadSafeSerial |
from serial_junction import SerialJunction |
threadsafe_serial.threadsafe_serial |
serial_junction.junction |
threadsafe_serial.packet_reader |
serial_junction.packet_reader |
Behaviour changes in this release:
write()raisesserial.SerialExceptionon failure instead of dropping the data silently.- Arguments after
baudrateare keyword-only. - The wait between reconnection attempts is now
reconnect_delay, no longertimeout. detect_devices()returns an empty list instead ofNonewhen nothing matches.read_until(max_bytes=...)now caps the returned data atmax_bytes.- Log records come from the
serial_junction.junctionlogger.
Development
uv sync
uv run pytest # tests; the pseudo-terminal tests are skipped on Windows
uv run ruff check && uv run ruff format --check
uv run mypy
License
MIT. See LICENSE.
Metadata
Release files for serial-junction 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| serial_junction-0.2.0.tar.gz | 26.2 kB | With provenance | Details for serial_junction-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| serial_junction-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for serial_junction-0.2.0-py3-none-any.whl |
Total release size: 40.5 kB
Release files / serial_junction-0.2.0.tar.gz
| Download URL | serial_junction-0.2.0.tar.gz |
|---|---|
| Size | 26.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
324e88831b28053c91584006b17e5494231eb4ce3535ed447c47df25c82f0eb8
|
|
BLAKE2b-256 checksum How to use checksums |
463ebba840e2aae9f2b27e3e32917fc1b3a6500e415d3b87b29833ca3bfc915f
|
| 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 27, 2026.
Transparency logRelease files / serial_junction-0.2.0-py3-none-any.whl
| Download URL | serial_junction-0.2.0-py3-none-any.whl |
|---|---|
| Size | 14.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
00bc9554c9746ada5aa93ae2a34bcf0f039ea0da724f238be7ed13e239da7981
|
|
BLAKE2b-256 checksum How to use checksums |
92c1bca75d7b61dddfbb4a857621d6616809b1d5d1f59cd751993e61f71f063d
|
| 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 27, 2026.
Transparency logSecurity for serial-junction 0.2.0
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
Both files in this release have been uploaded with provenance metadata.
Attestation for serial_junction-0.2.0.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 27, 2026.
Transparency logA 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.