anymodbus
Async-native Modbus RTU client for Python, built on AnyIO and anyserial.
Overview
anymodbus is a small, opinionated Modbus client (RTU and ASCII serial framing) built on AnyIO and anyserial. It is intentionally protocol-only and narrow in scope. It does not ship servers (beyond the mock slaves for tests in anymodbus.testing), TCP transport (planned for v0.4), or device-specific drivers — pymodbus is the right choice if you need any of those, and the two libraries can coexist in one project.
The cases anymodbus is built for:
- AnyIO-native. Same code runs under
asyncio,uvloop, ortrio.pymodbusis asyncio-only. - Tx-side 3.5-char inter-frame gap. Enforced before sending.
pymodbusrelies on its concurrency lock plus OS scheduling and does not enforce a pre-tx idle gap. - Idempotent-only retries by default. Reads (FC 1-4) retry on transient transport errors; writes (FC 5/6/15/16) do not, unless you opt in. Protects against silent double-writes when a successful write's response is lost in transit.
- Strict typing.
mypy strict = truepluspyright typeCheckingMode = "strict".pymodbususes partial-strict mypy andstandardpyright. - Required
baudrateandparity. No defaults — mismatched parity silently drops every frame, so making it explicit at the call site is worth the small ergonomic cost. - Transport-agnostic. Takes any
anyio.abc.ByteStream, defaults to ananyserial.SerialPort. TCP support is planned in v0.4 with the sameBusAPI.
See docs/migration-from-pymodbus.md for an honest comparison.
Requirements
- Python 3.13 or 3.14
anyio >= 4.13anyserial >= 0.1.1
Installation
uv add anymodbus
# or
pip install anymodbus
Optional extras:
uv add "anymodbus[trio]" # trio runtime
Usage
Async, one slave
import anyio
from anymodbus import open_modbus_rtu
async def main() -> None:
async with await open_modbus_rtu("/dev/ttyUSB0", baudrate=19_200, parity="even") as bus:
slave = bus.slave(address=1)
regs = await slave.read_holding_registers(0x0040, count=2)
print(regs)
anyio.run(main)
Sync
from anymodbus.sync import open_modbus_rtu
with open_modbus_rtu("/dev/ttyUSB0", baudrate=19_200, parity="even") as bus:
slave = bus.slave(1)
regs = slave.read_holding_registers(0, count=4, timeout=1.0)
Reading a 32-bit float across two registers
Word order varies by device — the Modbus spec doesn't standardize multi-register layout. The default is HIGH_LOW × big-endian-within-word, equivalent to struct.pack(">f", ...); pass word_order="low_high" for devices that store the LSW first.
from anymodbus import WordOrder
async with await open_modbus_rtu("/dev/ttyUSB0", baudrate=19_200, parity="even") as bus:
slave = bus.slave(address=1)
high_low_value = await slave.read_float(0x0040) # default
low_high_value = await slave.read_float(0x0044, word_order=WordOrder.LOW_HIGH)
Wrapping an existing serial port (with RS-485)
from anyserial import open_serial_port, SerialConfig, RS485Config, Parity
from anymodbus import Bus
port = await open_serial_port(
"/dev/ttyUSB0",
SerialConfig(
baudrate=19_200,
parity=Parity.EVEN,
rs485=RS485Config(enabled=True, rts_on_send=True, rts_after_send=False),
),
)
async with Bus(port) as bus:
regs = await bus.slave(1).read_holding_registers(0, count=4)
Modbus ASCII framing and a diagnostic loopback probe
from anymodbus import Framing, RegisterSource, open_modbus_ascii
# Classic 7E1 ASCII wire (data_bits=7); 8 also works.
async with await open_modbus_ascii(
"/dev/ttyUSB0", baudrate=19_200, parity="even", data_bits=7
) as bus:
slave = bus.slave(30)
# Cheap, side-effect-free liveness probe (FC08 sub-0 loopback):
assert await slave.diagnostic_loopback(b"\xab\xcd") == b"\xab\xcd"
# Read a measurement published as input registers (FC04):
o2 = await slave.read_float(0, source=RegisterSource.INPUT)
# Caller-owned stream (e.g. a port shared across modes): pick framing explicitly.
from anymodbus import Bus
bus = Bus(my_byte_stream, framing=Framing.ASCII)
Concurrent fan-out across multiple buses
import anyio
from anymodbus import open_modbus_rtu
async def poll_one(path: str, results: dict[str, tuple[int, ...]]) -> None:
async with await open_modbus_rtu(path, baudrate=19_200, parity="even") as bus:
results[path] = await bus.slave(1).read_holding_registers(0, count=4)
async def main() -> None:
paths = ["/dev/ttyUSB0", "/dev/ttyUSB1", "/dev/ttyUSB2"]
results: dict[str, tuple[int, ...]] = {}
async with anyio.create_task_group() as tg:
for p in paths:
tg.start_soon(poll_one, p, results)
for path, regs in results.items():
print(path, regs)
anyio.run(main)
Testing without hardware
from anymodbus.testing import client_slave_pair
async with client_slave_pair(slave_address=1) as (bus, mock):
mock.holding_registers[0:4] = [10, 20, 30, 40]
regs = await bus.slave(1).read_holding_registers(0, count=4)
assert regs == (10, 20, 30, 40)
Documentation
Full documentation will live at https://anymodbus.graysonbellamy.dev/. Starting points:
- Quickstart
- Configuration
- RTU framing
- Decoders & word order
- Exceptions
- Troubleshooting
- Migration from pymodbus
Contributing
Issues and PRs are welcome. To get a local checkout running:
git clone https://github.com/GraysonBellamy/anymodbus
cd anymodbus
uv sync --all-extras
uv run pre-commit install
Before opening a PR:
uv run pytest
uv run ruff check
uv run ruff format --check
uv run mypy
uv run pyright
Hardware-dependent tests are opt-in via pytest -m hardware with ANYMODBUS_TEST_PORT and ANYMODBUS_TEST_SLAVE_ADDRESS set.
License
MIT. See LICENSE.
Metadata
Release files for anymodbus 0.3.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 | |
|---|---|---|---|
| anymodbus-0.3.0.tar.gz | 145.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| anymodbus-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 229.1 kB
Release files / anymodbus-0.3.0.tar.gz
| Download URL | anymodbus-0.3.0.tar.gz |
|---|---|
| Size | 145.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6c353ca829940f8eb01c5bdfc8d1f641dc72586e0aa946288fef58f0458d4285
|
|
BLAKE2b-256 checksum How to use checksums |
065881590747514e9c171683a25f577b9e7186bdcd6aba148dae1352df20c20f
|
| 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 Sep 28, 2026.
Transparency logRelease files / anymodbus-0.3.0-py3-none-any.whl
| Download URL | anymodbus-0.3.0-py3-none-any.whl |
|---|---|
| Size | 83.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
aafdebf5fa21d83a071612b9438ee85eb85ede65b0973d0fd8e873a0f189aa16
|
|
BLAKE2b-256 checksum How to use checksums |
56646211e1f13e74b36b02b9a9e4795cd59fac16e2c4ab24dd77f56c6acb2cb3
|
| 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 Sep 28, 2026.
Transparency log