Async-native Modbus RTU client for Python, built on AnyIO and anyserial.
Project description
anymodbus
Async-native Modbus RTU client for Python, built on AnyIO and anyserial.
[!WARNING] Alpha. The v0.1 surface is implemented and tested but has not yet been exercised against a wide range of real hardware. Expect minor API tweaks before v1.0. See DESIGN.md for the full plan and CHANGELOG.md for the current state.
Overview
anymodbus is a small, opinionated Modbus RTU client built on AnyIO and anyserial. It is intentionally protocol-only and narrow in scope. It does not ship servers, ASCII transport, 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.3 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)
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://graysonbellamy.github.io/anymodbus/. 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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file anymodbus-0.1.1.tar.gz.
File metadata
- Download URL: anymodbus-0.1.1.tar.gz
- Upload date:
- Size: 95.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
771ae2212913f3aaefeaf236f8d06d77484d0c249a182621e3d2c6f982a99487
|
|
| MD5 |
69f669f231a67b6d710c58d9719e1a4d
|
|
| BLAKE2b-256 |
43812a241539dfb4327fce85edfe379f4ba0f272455c3b33f1a8cb8d6dbf2589
|
Provenance
The following attestation bundles were made for anymodbus-0.1.1.tar.gz:
Publisher:
publish.yml on GraysonBellamy/anymodbus
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
anymodbus-0.1.1.tar.gz -
Subject digest:
771ae2212913f3aaefeaf236f8d06d77484d0c249a182621e3d2c6f982a99487 - Sigstore transparency entry: 1390521648
- Sigstore integration time:
-
Permalink:
GraysonBellamy/anymodbus@6b0ea3b1abf3a269421ac4313817770ddd0ddd5e -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/GraysonBellamy
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@6b0ea3b1abf3a269421ac4313817770ddd0ddd5e -
Trigger Event:
release
-
Statement type:
File details
Details for the file anymodbus-0.1.1-py3-none-any.whl.
File metadata
- Download URL: anymodbus-0.1.1-py3-none-any.whl
- Upload date:
- Size: 55.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
24c57ef3cbc8c8f6b19c60ab7771219261d12f0f9a186aa00f0e99d4f96edc5b
|
|
| MD5 |
b8da246d4a5f8350368afb1175cd18b7
|
|
| BLAKE2b-256 |
b2ea32864d0b3f96197e9aa39d54793d3c4327958035f2119c00343f2f03ee69
|
Provenance
The following attestation bundles were made for anymodbus-0.1.1-py3-none-any.whl:
Publisher:
publish.yml on GraysonBellamy/anymodbus
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
anymodbus-0.1.1-py3-none-any.whl -
Subject digest:
24c57ef3cbc8c8f6b19c60ab7771219261d12f0f9a186aa00f0e99d4f96edc5b - Sigstore transparency entry: 1390521658
- Sigstore integration time:
-
Permalink:
GraysonBellamy/anymodbus@6b0ea3b1abf3a269421ac4313817770ddd0ddd5e -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/GraysonBellamy
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@6b0ea3b1abf3a269421ac4313817770ddd0ddd5e -
Trigger Event:
release
-
Statement type: