Metadata-Version: 2.3
Name: ok-serial
Version: 0.7
Summary: Serial port library (PySerial wrapper) with improved semantics
Author: Dan Egnor
Author-email: Dan Egnor <egnor@ofb.net>
Requires-Dist: click~=8.3
Requires-Dist: natsort~=8.4
Requires-Dist: ok-logging-setup~=0.17
Requires-Dist: psutil~=7.2
Requires-Dist: pyserial~=3.5
Requires-Python: >=3.11, <4
Project-URL: Homepage, https://github.com/egnor/ok-py-serial#readme
Project-URL: Repository, https://github.com/egnor/ok-py-serial.git
Description-Content-Type: text/markdown

# OK serial I/O for Python &nbsp; 🔌〡〇〡〇〡🐍

A Python serial port library (based on [PySerial](https://www.pyserial.com/)) with improved port discovery and I/O semantics. [(API reference)](https://egnor.github.io/ok-py-serial/)

Think twice before using this library! Consider something more established:

- [good old PySerial](https://www.pyserial.com/) - the implementation under `ok-serial`, established, widely used
- [pyserial-asyncio](https://github.com/pyserial/pyserial-asyncio) - official and "proper" [asyncio](https://docs.python.org/3/library/asyncio.html) support for PySerial
- [pyserial-asyncio-fast](https://github.com/home-assistant-libs/pyserial-asyncio-fast) - pyserial-asyncio fork designed for faster writes
- [aioserial](https://github.com/mrjohannchang/aioserial.py) - alternative asyncio wrapper designed for ease of use
- bonus recommendation: [tio](https://github.com/tio/tio) - not a library, not Python, but a great serial terminal utility

Also see my own [ok-serial-terminal](https://github.com/egnor/ok-serial-terminal#readme), a terminal program based on this library.

## Purpose

Since 2001, [PySerial](https://www.pyserial.com/) has been the workhorse [serial port](https://en.wikipedia.org/wiki/Serial_port) / [UART](https://en.wikipedia.org/wiki/Universal_asynchronous_receiver-transmitter) library for Python. It runs on lots of platforms and abstracts gnarly system details. But, some issues keep coming up:

- USB serial ports get temporary names like `/dev/ttyACM3` or `COM9`. PySerial's [`serial.tools.list_ports.grep(...)`](https://pythonhosted.org/pyserial/tools.html#serial.tools.list_ports.grep), OS-level `/dev/serial/by-id` paths, and [custom udev rules](https://dev.to/enbis/how-udev-rules-can-help-us-to-recognize-a-usb-to-serial-device-over-dev-tty-interface-pbk) are helpful but clunky.

- Nonblocking or concurrent PySerial I/O is [tricky](https://github.com/pyserial/pyserial/issues/772) and often [broken](https://github.com/pyserial/pyserial/issues/281) [entirely](https://github.com/pyserial/pyserial/issues/280).

- PySerial has small buffers; overruns lose data and/or block unexpectedly.

- PySerial doesn't lock ports by default, and only supports one advisory locking method. Bad things happen when multiple programs try to use the same port.

The `ok-serial` library uses PySerial internally but has a revised interface:

- Ports are referenced by [match strings](#port-matching) with wildcard support (eg. `RP2040` or `2e43:0226`) or, for by arbitrary `PortInfo -> bool` callables.

- I/O operations are thread safe and can be blocking, non-blocking, timeout-based, or async. Blocking operations can be cleanly interrupted. The semantics of concurrent access, partial reads/writes, interruption, I/O errors, closure, etc. are all well defined.

- I/O buffers are limited only by memory; writes never block. (A blocking drain is available.)

- Several [port locking modes](#sharing-modes) are supported, with exclusive locking by default. _All_ of [`/var/lock/LCK..*` files](https://refspecs.linuxfoundation.org/FHS_3.0/fhs/ch05s09.html), [`flock(...)`](https://linux.die.net/man/2/flock) (like PySerial), and [`TIOCEXCL`](https://man7.org/linux/man-pages/man2/TIOCEXCL.2const.html) are used (if available).

- [`SerialConnectionMonitor`](https://egnor.github.io/ok-py-serial/ok_serial.html#SerialConnectionMonitor) is an automatic reconnection helper for graceful handling of pluggable devices.

## Installation

```bash
pip install ok-serial
```

(or `uv add ok-serial`, etc.)

## Usage

Here is a minimal example:

```
import ok_serial

conn = ok_serial.SerialConnection(match="MyDevice", baud=115200)
conn.write(b"Hello Device!")
while (data := conn.read_sync(timeout=5)):
    print("Received data:", data)
print("...5 seconds elapsed with no data")
```

(Note that `"MyDevice"` is a [port match expression](#port-matching).)

API elements worth knowing include:

- [`SerialConnection`](https://egnor.github.io/ok-py-serial/ok_serial.html#SerialConnection) - establish a connection to a specific port and perform I/O
- [`scan_serial_ports`](https://egnor.github.io/ok-py-serial/ok_serial.html#scan_serial_ports) - get all ports on the system, with descriptive attributes
- [`SerialConnectionMonitor`](https://egnor.github.io/ok-py-serial/ok_serial.html#SerialConnectionMonitor) - scan and connect to a port with automatic error retry

I/O methods come in different flavors:

- `*_sync` methods (eg. [`read_sync`](https://egnor.github.io/ok-py-serial/ok_serial.html#SerialConnection.read_sync)) block, accept `timeout=...`, and can raise [exceptions](https://egnor.github.io/ok-py-serial/ok_serial.html#SerialIoException)
- `*_async` methods (eg. [`read_async`](https://egnor.github.io/ok-py-serial/ok_serial.html#SerialConnection.read_async)) return an [`await`-able coroutine](https://docs.python.org/3/reference/expressions.html#await) for [asyncio](https://docs.python.org/3/howto/a-conceptual-overview-of-asyncio.html)
  - Use [`asyncio.timeout`](https://docs.python.org/3/library/asyncio-task.html#timeouts) to add a timeout
  - Errors are reported via the coroutine (`await` will raise)
- Other methods (eg. [`write`](https://egnor.github.io/ok-py-serial/ok_serial.html#SerialConnection.write)) are non-blocking.

See the [full API reference docs](https://egnor.github.io/ok-py-serial/) for interface details.

Unless called out in the docs, all methods and functions are thread-safe: any method may be called from any thread at any time, and `*_async` methods may be awaited from any event loop in any thread. Any error or closure on a connection interrupts all operations on that connection.

## Serial port attributes

Serial ports have metadata attributes like descriptive text, USB vendor/product ID, serial number and the like. These are captured as key/value pairs in [`PortInfo.attr`](https://egnor.github.io/ok-py-serial/ok_serial.html#PortInfo.attr) and returned by [`scan_serial_ports`](https://egnor.github.io/ok-py-serial/ok_serial.html#scan_serial_ports).

Attributes [come from PySerial](https://pyserial.readthedocs.io/en/latest/tools.html#serial.tools.list_ports.ListPortInfo) and are platform dependent but typically include:

- `device` - system device name, eg. `/dev/ttyUSB1` or `COM3`
- `description` - human readable text, eg. `Arduino Uno`
- `manufacturer` - USB device manufacturer name, eg. `
- `vid_pid` - USB vendor and product ID, eg. `0403:6001`
- `serial_number` - USB device serial, eg. `DF62585783553434`
- `location` - system bus attachment path, eg. `3-2.1:1.0`

To see all the attributes, install `ok-serial` and run `okserial -v`:

```text
Port: /dev/ttyACM3 Kq2p 3:12s
  device=/dev/ttyACM3
  name=ttyACM3
  description='Feather RP2040 RFM - Pico Serial'
  hwid='USB VID:PID=239A:812D SER=DF62585783553434 LOCATION=3-2.1:1.0'
  vid=9114
  pid=33069
  serial_number=DF62585783553434
  location=3-2.1:1.0
  manufacturer=Adafruit
  product='Feather RP2040 RFM'
  interface='Pico Serial'
  usb_device_path=/sys/devices/pci0000:00/0000:00:14.0/usb3/3-2/3-2.1
  device_path=/sys/devices/pci0000:00/0000:00:14.0/usb3/3-2/3-2.1/3-2.1:1.0
  subsystem=usb
  usb_interface_path=/sys/devices/pci0000:00/0000:00:14.0/usb3/3-2/3-2.1/3-2.1:1.0
  tid=Kq2p
  time=2026-08-04T12:38:39.800
  vid_pid=239a:812d
...
```

## Port matching

`SerialConnection(match=...)` and `SerialConnectionMonitor(...)` take either a **match string** or a **predicate callable** (`PortInfo -> bool`).

A match string is split on whitespace into glob tokens. Each token must appear as a whole-word case-insensitive glob (with `*` and `?` wildcards) in some attribute value:

- `Pico` - some attribute contains the word `pico` (any case)
- `RP2040 DF625*` - some attribute contains `rp2040` AND some attribute
  contains a word starting with `df625`
- `2e8a:0005` - matches the canonical `vid_pid` form (lowercase hex)
- `ttyS1` - DOES match `/dev/ttyS1`, does NOT match `/dev/ttyS10`

Word boundaries treat any non-alphanumeric character (`/`, `:`, `_`, etc.) as a separator, so partial USB IDs and device-path fragments work naturally.

For anything more elaborate (substring matching across attribute boundaries, regex, negation, etc.), do your own filtering, or pass a callable:

```python
ok_serial.SerialConnectionMonitor(
    match=lambda p: p.attr.get("manufacturer") == "Adafruit"
    and p.attr.get("serial_number", "").startswith("DF625"),
)
```

## Sharing modes

When opening a port, [`SerialConnection`](https://egnor.github.io/ok-py-serial/ok_serial.html#SerialConnection.__init__) offers a choice of [sharing modes](https://egnor.github.io/ok-py-serial/ok_serial.html#SerialConnectionOptions.sharing):

- `oblivious` (not recommended) - Checks no locks and holds no locks. The port may be opened concurrently, leading to corruption.
- `polite` - Checks for locks before opening the port, but holds no locks while running. Abandons the port if another _process_ is detected using it.
- `exclusive` (the default) - Checks for locks before opening the port, and holds locks to guard against other uses of the port.
- `stomp` (use with care!) - _Any other process using the port is killed_, if possible; locks are held, if possible; the port is opened regardless.

Sharing modes are limited by OS capabilities, process permissions, and the conventions of port usage coordination. Best efforts are taken but your mileage may vary.

## Command line utility

Installing the `ok-serial` package installs the `okserial` utility, which lists serial ports:

```text
$ okserial
🔎 Finding serial ports...
✅ 2 serial ports found
/dev/ttyACM0 ZdvG usb 0424:494c 'USB2 Controller Hub - UART Bridge' 1d+04:18:11s
/dev/ttyACM3 Kq2p usb 239a:812d 'Feather RP2040 RFM' DF62585783553434 3:12s
```

Each line includes the device name, [tio](https://github.com/tio/tio)-compatible topology ID, and whichever of the subsystem, USB vendor/product ID, description, and serial number are known, along with the age of the port.

Run `okserial -v` print extra detail; see `okserial --help` for more options.

For an interactive terminal, see [ok-serial-terminal](https://github.com/egnor/ok-serial-terminal#readme).

## Socat for testing and profit

On Unix-ish systems, [socat](http://www.dest-unreach.org/socat/) is handy for connecting serial-port apps (using `ok-serial` or otherwise) to other endpoints (Unix programs, TCP sockets, other serial-port apps, etc). Install it with your favorite package manager (eg. `sudo apt install socat`), and run something like this in one window:

```sh
socat pty,raw,echo=0,link=socat.tmp exec:$SHELL,pty,stderr,setsid,ctty
```

The first socat argument `pty,raw,echo=0,link=socat.tmp` allocates a pseudoterminal (pty) that looks like a serial port, and creates a `./socat.tmp` symlink. The `,raw,echo=0` suppresses default pty echo behavior to avoid the shell looping on its own output.

The second socat argument starts a shell on its own pty, but this could be any socat endpoint (`exec:cat`, `tcp:localhost:8000`, another `pty,...`, etc).

Socat will shuffle data between the two. Try this Python in another window in the same directory:

```python
import ok_serial
import time
with ok_serial.SerialConnection(port="socat.run.tmp") as conn:
    conn.write(b"echo Hello World\n")
    time.sleep(0.5)  # let shell respond
    print(conn.read_sync())
```

You should see the `echo Hello World` echoed, then `Hello World`, then the next shell prompt. (Use [`ok-serial-terminal`](https://github.com/egnor/ok-serial-terminal#readme) to connect and operate the shell interactively.)
