A complete Modbus protocol stack written in Rust, with Python bindings for client and server workflows. The workspace covers TCP, RTU, RTU-over-TCP, TLS, pipelined async clients, pluggable servers, TCP-RTU gatewaying, connection pooling, YAML-driven simulation, Docker packaging, benchmarks, and a diagnostic CLI.
- 3 transports — Modbus/TCP, RTU (serial + RTU-over-TCP), TLS 1.3 (mutual auth)
- Pipelined async client — 16-slot transaction ring with background reader task
- Pluggable server — async
DataStoretrait for custom register backends - TCP-RTU gateway — transparent protocol translation bridge
- Connection pooling — two-pool architecture with health checks and backoff
- YAML simulator — device profiles with scenario-driven register behavior
- CLI tool — read/write/server/shell/dashboard/discover commands with JSON output
- Python bindings — CPython 3.14/3.14t wheels with typed async/sync clients and server stores
- Spec conformance — Rust conformance tests plus Python wheel, stub, and typing gates
no_stdfoundation — types and codec crates work without allocator- Zero
unsafe—#![forbid(unsafe_code)]on all crates - Zero clippy warnings, fast
devCI and broader release-gate CI
[dependencies]
rusty-modbus = "0.1"
tokio = { version = "1", features = ["full"] }use rusty_modbus::client::{ModbusClient, ClientConfig};
use rusty_modbus::types::UnitId;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = ClientConfig {
timeout: Duration::from_secs(5),
..ClientConfig::default()
};
let client = ModbusClient::connect("127.0.0.1:502".parse()?, config).await?;
// Read 10 holding registers starting at address 0
let registers = client.read_holding_registers(UnitId(1), 0, 10).await?;
for (i, &val) in registers.iter().enumerate() {
println!("Register {i:>3}: {val:#06X} ({val})");
}
client.shutdown().await;
Ok(())
}The rusty-modbus facade crate enables the tcp client feature by default.
Enable server, rtu, tls, pool, gateway, or full when those modules
are needed.
The Python package is built from crates/rusty-modbus-python with PyO3 and
maturin. It requires Python 3.14 or newer and is validated against both standard
CPython 3.14 and free-threaded 3.14t.
scripts/ci-python.shThat command builds a fresh wheel, installs it into an isolated uv environment, runs pytest, runs stubtest, and runs pyright type checks.
import asyncio
import rusty_modbus
async def main() -> None:
client = await rusty_modbus.ModbusClient.connect("127.0.0.1:502")
try:
registers = await client.read_holding_registers(1, 0, 10)
print(registers)
finally:
await client.shutdown()
asyncio.run(main())For non-async scripts, use rusty_modbus.SyncModbusClient. For server tests or
simulators, use rusty_modbus.ModbusServer.start() with either
rusty_modbus.InMemoryStore or a Python object matching the DataStore
protocols described in docs/api.md.
# From a source checkout
cargo run -p rusty-modbus-cli -- --help
# After a tagged release, download the prebuilt modbus binary from GitHub Releases.
modbus --help
# Read holding registers
modbus read -H 192.168.1.100 holding 0 10
# Write a single register
modbus write -H 192.168.1.100 register 0 0x1234
# Interactive shell
modbus shell -H 192.168.1.100
# Discover devices on a subnet
modbus discover --range 192.168.1.0/24
# Run an in-memory Modbus/TCP server
modbus server --listen 0.0.0.0:5502 --holding 0=0x1234
# JSON output (for scripting)
modbus read -H 192.168.1.100 holding 0 10 --format json
# Structured diagnostics stay on stderr; command output stays on stdout
modbus --log-filter rusty_modbus_client=debug --log-format json \
read -H 192.168.1.100 holding 0 10 --format json
# Write diagnostics to a file instead of stderr
modbus --log-filter info --log-file modbus.log discover --range 192.168.1.0/24The Docker image packages the modbus CLI. By default it runs an in-memory
server on port 5502 as a non-root user; override the command to run client,
shell, dashboard, or discovery modes.
# Build the Alpine runtime image
scripts/docker-build.sh
# Build the distroless runtime image
RUSTY_MODBUS_DOCKER_TARGET=distroless \
RUSTY_MODBUS_DOCKER_TAG=rusty-modbus:distroless \
scripts/docker-build.sh
# Run the default server
docker run --rm -p 5502:5502 rusty-modbus:local
# Run client commands from the same image
docker run --rm rusty-modbus:local \
--host host.docker.internal --port 5502 --unit-id 1 read hr 0 1
# Run the interactive shell
docker run --rm -it rusty-modbus:local \
--host host.docker.internal --port 5502 --unit-id 1 shell
# Docker-only e2e smoke: server container + client containers
scripts/docker-smoke.sh
# Build and run the Alpine benchmark target
scripts/docker-bench.sh --duration 5 --clients 1 --in-flight 8 --json
# Build and run the distroless benchmark target
RUSTY_MODBUS_DOCKER_TARGET=benchmark-distroless \
scripts/docker-bench.sh --duration 5 --clients 1 --in-flight 8 --json
# Full local Docker check: Alpine smoke, distroless smoke, benchmark smoke
scripts/docker-ci.sh
# Comparable local + Docker benchmark matrix
scripts/bench-suite.sh allcrates/
rusty-modbus-types/ Enums, newtypes, constants (no_std, zerocopy)
rusty-modbus-codec/ Sans-IO encode/decode (no_std)
rusty-modbus-frame/ Tokio codecs, CRC-16, owned Bytes types
rusty-modbus-tcp/ Transport traits + TCP implementation
rusty-modbus-rtu/ Serial + RTU-over-TCP transport
rusty-modbus-tls/ TLS 1.3 transport (rustls + ring)
rusty-modbus-pool/ Two-pool connection pooling
rusty-modbus-client/ Pipelined async client
rusty-modbus-server/ Pluggable DataStore server
rusty-modbus-gateway/ TCP <-> RTU bridge
rusty-modbus-sim/ YAML simulator + device profiles
rusty-modbus-cli/ CLI binary (read/write/server/shell/dashboard/discover)
rusty-modbus/ Facade crate with feature flags
rusty-modbus-conformance/ Spec compliance test suite
rusty-modbus-python/ PyO3 bindings, excluded from the Rust workspace
benchmarks/ Criterion benchmarks + stress-test binary
The rusty-modbus facade crate re-exports subcrates behind feature flags:
| Feature | Default | Pulls In |
|---|---|---|
tcp |
yes | rusty-modbus-tcp, rusty-modbus-client |
rtu |
no | rusty-modbus-rtu |
rtu-tcp |
no | alias for rtu |
tls |
no | rusty-modbus-tls (rustls + ring) |
server |
no | rusty-modbus-server |
gateway |
no | rusty-modbus-gateway + rtu |
pool |
no | rusty-modbus-pool |
full |
no | all of the above |
The client exposes 14 typed function codes. The server dispatches all 19
standard codes: the built-in InMemoryStore serves 17, and the DataStore
trait exposes hooks for the remaining two.
| Function Code | Name | Client | Server |
|---|---|---|---|
| 0x01 | Read Coils | yes | yes |
| 0x02 | Read Discrete Inputs | yes | yes |
| 0x03 | Read Holding Registers | yes | yes |
| 0x04 | Read Input Registers | yes | yes |
| 0x05 | Write Single Coil | yes | yes |
| 0x06 | Write Single Register | yes | yes |
| 0x07 | Read Exception Status | no | yes |
| 0x08 | Diagnostics | no | yes |
| 0x0B | Get Comm Event Counter | no | hook † |
| 0x0C | Get Comm Event Log | no | hook † |
| 0x0F | Write Multiple Coils | yes | yes |
| 0x10 | Write Multiple Registers | yes | yes |
| 0x11 | Report Server ID | no | yes |
| 0x14 | Read File Record | yes | yes |
| 0x15 | Write File Record | yes | yes |
| 0x16 | Mask Write Register | yes | yes |
| 0x17 | Read/Write Multiple Registers | yes | yes |
| 0x18 | Read FIFO Queue | yes | yes |
| 0x2B/0x0E | Read Device Identification (MEI) | yes | yes |
† Dispatched to a DataStore method; the built-in InMemoryStore returns
IllegalFunction. Implement the trait method to serve device-specific counters.
The serial-line diagnostics codes (0x07/0x08/0x0B/0x0C/0x11) are accepted over
Modbus/TCP through DataStore methods with conformant defaults — override them
for device-specific behavior.
- docs/api.md summarizes the current Rust and Python public API surfaces, including feature flags, server stores, typing contracts, and the release branch model.
- Rust crates are documented with rustdoc and are configured for docs.rs with all facade features enabled once the 0.1.0 crates are published.
- Python typing is shipped through
py.typedandrusty_modbus.pyi, withstubtest,pyright --verifytypes, and a strict pyright public-contract test in CI.
# Build entire workspace
cargo build --workspace
# Run the workspace test gate with nextest + doctests
scripts/test-local.sh
# Run the Python binding wheel, pytest, stubtest, and pyright gate
scripts/ci-python.sh
# Lint (must be zero warnings)
cargo clippy --workspace -- -D warnings
# Install local hooks: fmt on commit, rust-analyzer + clippy on push
bash scripts/install-hooks.sh
# Check facade with all features
cargo check -p rusty-modbus --features full --examples
# License/advisory checks
cargo deny check
# Fast benchmark smoke: codec microbenches + single-connection pipelined TCP
scripts/bench-local.sh smoke
# Focused benchmark runs
scripts/bench-local.sh codec --quick --noplot
scripts/bench-local.sh tcp-pipelined --quick --noplot
scripts/bench-local.sh stress --duration 10 --clients 1 --in-flight 8 --operation mixed --json
# Docker benchmark target
scripts/docker-bench.sh --duration 5 --clients 1 --in-flight 8 --json
# Docker image validation
scripts/docker-ci.sh
# Comparable stress matrix: local release + Alpine Docker + distroless Docker
scripts/bench-suite.sh all
# Local-only / Docker-only matrices
scripts/bench-suite.sh local
scripts/bench-suite.sh docker
# Full benchmark suite
scripts/bench-local.sh all --quick --noplotSee benchmarks.md for the current local stress-test baseline and follow-up benchmark targets.
Release flow:
- Work lands on
devthrough ordinary PRs. - Releases are cut by opening a
dev->mainPR and passingrelease.yml. - A
v0.1.0tag onmaintriggerspublish.yml, which publishes crates in dependency order, publishes Python distributions to PyPI, and builds CLI release binaries.
Minimum Rust version: 1.95 (pinned in rust-toolchain.toml)
MIT