Skip to content

RS-485

anyserial supports kernel-level RS-485 half-duplex operation on Linux via the TIOCSRS485 ioctl. When enabled, the tty driver asserts RTS around each transmitted frame so the transceiver auto-switches between TX and RX — no user-space RTS toggling, no timing against the UART FIFO drain, no race with partial writes.

See DESIGN §19 for the full rationale.

Platform support

Kernel RS-485 is Linux-only. macOS has no equivalent ioctl (DarwinBackend rejects via UnsupportedPolicy); BSD has FreeBSD-only TIOCSRS485 support in some drivers but is out of scope. Manual RTS toggling (see below) is the portable fallback on both platforms.

Quick start

import anyio
from anyserial import RS485Config, SerialConfig, open_serial_port


async def main() -> None:
    config = SerialConfig(
        baudrate=115_200,
        rs485=RS485Config(
            enabled=True,
            rts_on_send=True,
            rts_after_send=False,
            delay_before_send=0.001,  # seconds
            delay_after_send=0.002,
            rx_during_tx=False,
        ),
    )
    async with await open_serial_port("/dev/ttyUSB0", config) as port:
        await port.send(b"\x01\x03\x00\x00\x00\x01\x84\x0a")  # Modbus-RTU read
        reply = await port.receive(256)
        print(reply.hex())


anyio.run(main)

RS485Config

Field Default Meaning
enabled True Master switch — set rs485=None on SerialConfig to skip the ioctl entirely.
rts_on_send True Logic level of RTS while transmitting. Most half-duplex transceivers want True.
rts_after_send False Level of RTS after transmission completes. Leave False for receive-after-send.
delay_before_send 0.0 Seconds to hold RTS before transmission starts (float; kernel converts to ms).
delay_after_send 0.0 Seconds to hold RTS after transmission ends.
rx_during_tx False Enable receive while transmitting. Needed only on full-duplex RS-485 wiring.

Delays round to milliseconds in the kernel struct; a driver may further round to its hardware granularity. The config delays are seconds (0.001 = one ms) to match anyio.sleep and the rest of the API.

Runtime enable / disable

configure() accepts a new SerialConfig with a different rs485 field. Switching rs485=None after RS-485 was previously enabled restores the struct serial_rs485 state the kernel reported before anyserial first wrote to it:

async with await open_serial_port("/dev/ttyUSB0", SerialConfig()) as port:
    # Plain UART mode.
    await port.send(b"AT\r\n")

    await port.configure(SerialConfig(rs485=RS485Config()))
    # Half-duplex RS-485 mode from here on.
    await port.send(b"\x01\x03...")

    await port.configure(SerialConfig())
    # Back to plain UART; the kernel RS-485 settings are restored.

close() performs the same restore step so the next process to open the device inherits the kernel default rather than anyserial's last write.

Driver support

TIOCSRS485 is a kernel ioctl, so whether it works depends on the tty driver, not on anyserial. Expect:

  • Supported: genuine FTDI chips on recent kernels, most industrial PCIe UART cards (8250_pci, exar, lpc_sch), some PL-series adapters, many vendor-specific drivers.
  • Unsupported: most consumer USB-serial dongles (CP210x, CH340, older FTDI clones). The driver returns ENOTTY or EINVAL.

The SerialCapabilities.rs485 field is Capability.SUPPORTED on Linux because the platform has the ioctl. The device answer is only knowable at apply time, which is why rs485 on SerialConfig routes through UnsupportedPolicy.

Handling unsupported drivers

The unsupported_policy field on SerialConfig controls what happens when TIOCSRS485 fails:

from anyserial import SerialConfig
from anyserial._types import UnsupportedPolicy

# Default: raise UnsupportedFeatureError.
SerialConfig(rs485=RS485Config(), unsupported_policy=UnsupportedPolicy.RAISE)

# Warn via warnings.warn(RuntimeWarning) and keep going without RS-485.
SerialConfig(rs485=RS485Config(), unsupported_policy=UnsupportedPolicy.WARN)

# Silently continue without RS-485.
SerialConfig(rs485=RS485Config(), unsupported_policy=UnsupportedPolicy.IGNORE)

WARN / IGNORE are useful for drivers whose TIOCSRS485 behaviour you cannot know at config-build time — the library will fall back to standard UART mode and your code can toggle RTS manually if the protocol demands it.

Manual RTS toggling

anyserial intentionally does not expose a "fake RS-485 via RTS in user space" shortcut in the core API. The timing is platform-dependent and non-equivalent to kernel RS-485: you have to wait for the UART FIFO to drain (not just the kernel output queue) before flipping RTS back to receive, or you'll clip the end of the frame.

If you need manual direction control, wire it yourself:

await port.set_control_lines(rts=True)
await port.send_buffer(frame)
await port.drain_exact()  # waits for the FIFO, not just the kernel queue
await port.set_control_lines(rts=False)

drain_exact calls tcdrain in a worker thread — it's the only portable way to wait for the hardware FIFO. Kernel RS-485 avoids this round-trip entirely, which is why it's preferred whenever the driver supports it.

Testing support on your device

import os
import contextlib
import anyio

from anyserial._linux.rs485 import read_rs485


def probe(path: str) -> bool:
    fd = os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
    try:
        try:
            read_rs485(fd)
            return True
        except OSError:
            return False
    finally:
        with contextlib.suppress(OSError):
            os.close(fd)


print(probe("/dev/ttyUSB0"))

The private _linux.rs485 module is stable-in-practice — it wraps kernel ABI that has not changed since Linux 4.14 — but it is not part of the public API and may move between minor versions.