Skip to main content

serial-junction

PyPI Python versions CI License: MIT

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.SerialException when 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 port is always reopened at the same path. It is never swapped for another device, even if one matches search_pattern.
  • With port=None, the device you were last connected to is preferred.
  • Data already received stays in the buffer.
  • While disconnected, is_open is False and write() raises.
  • After max_reconnect_attempts failed attempts (0, the default, means retry forever), the junction gives up: running becomes False, blocked readers return, and write() 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 with write_latest().

Platform notes

  • Linux: your user needs permission to open serial devices, usually by joining the dialout group (uucp on 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 COM port 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() raises serial.SerialException on failure instead of dropping the data silently.
  • Arguments after baudrate are keyword-only.
  • The wait between reconnection attempts is now reconnect_delay, no longer timeout.
  • detect_devices() returns an empty list instead of None when nothing matches.
  • read_until(max_bytes=...) now caps the returned data at max_bytes.
  • Log records come from the serial_junction.junction logger.

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)

Source distribution for serial-junction 0.2.0
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)

Table of built distributions (wheels) for serial-junction 0.2.0
File Interpreter ABI Platform Actions
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 log

Release 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 log

Security 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.

Select from 2 files

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 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.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files Full provenance

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