Small Rust Modbus library with typed request/response PDUs and a reusable Modbus TCP transport.
- Typed
ModbusRequestandModbusResponseenums for the common Modbus function codes - Request serialization and response parsing for:
- read coils
- read discrete inputs
- read holding registers
- read input registers
- write single coil
- write single register
- write multiple coils
- write multiple registers
- Modbus TCP transport with:
- two-phase
ModbusTcpSocket→ModbusTcpConnectionconstruction - eager, fallible first TCP connect followed by lazy reconnect after transport failures
- reusable actor-owned connections with bounded request-channel backpressure
- configurable in-flight window for slow gateway-backed buses
- retry of transient I/O failures and gateway-busy exception responses
- per-phase timeouts for connect, write, queue wait, and on-wire response wait
- request/response validation for transaction ID, protocol ID, unit ID, and echoed payloads
- two-phase
- Support for talking to multiple unit IDs through one Modbus TCP connection handle
- Optional CLI example behind the
clifeature
- Rust 1.85 or newer (MSRV), matching the crate's Rust 2024 edition.
Add the crate to your project:
[dependencies]
ferrobus = "0.1.0"Create a typed request and send it over Modbus TCP:
use ferrobus::tcp::ModbusTcpConnection;
use ferrobus::{ModbusRequest, ModbusResponse};
#[tokio::main]
async fn main() -> Result<(), ferrobus::ModbusError> {
let connection = ModbusTcpConnection::connect("127.0.0.1", 502, 1).await?;
let response = connection
.send_message(&ModbusRequest::ReadHoldingRegisters {
starting_address: 100,
quantity: 2,
})
.await?;
match response {
ModbusResponse::ReadHoldingRegisters { registers } => {
println!("registers: {registers:?}");
}
other => {
println!("unexpected response: {other:?}");
}
}
Ok(())
}The TCP client accepts host names or numeric IP addresses. ModbusTcpConnection::connect(...)
is async and fallible: it opens the first TCP socket eagerly, then the live actor handle retries
short-lived I/O failures and reconnects lazily after transport teardown. Internally, cloned
handles send commands over a bounded channel to one actor task that owns the socket and MBAP codec;
this provides backpressure under burst load. Cloned handles, including those returned by
with_unit_id, may issue requests concurrently; responses are matched by MBAP transaction ID.
Writes are serialized by the actor and
a cancellation in one caller cannot interrupt a partially written Modbus TCP frame.
Use send_message_with_unit_id or with_unit_id(...) when talking to multiple devices behind one
Modbus TCP gateway.
ModbusTcpConnection::connect(...) uses sensible defaults for connect, write, and response timeouts.
The write timeout covers both sending the frame and flushing the socket. If you need custom limits,
configure a ModbusTcpSocket before calling connect().await.
use std::time::Duration;
use ferrobus::tcp::{ModbusTcpFlowControl, ModbusTcpRetry, ModbusTcpSocket, ModbusTcpTimeouts};
let connection = ModbusTcpSocket::new("localhost", 502, 1)
.with_timeouts(ModbusTcpTimeouts {
connect_timeout: Duration::from_secs(2),
write_timeout: Duration::from_secs(2),
response_timeout: Duration::from_secs(2),
})
.with_flow_control(ModbusTcpFlowControl::serial_gateway())
.with_retry(Some(ModbusTcpRetry {
initial_delay: Duration::from_millis(100),
max_elapsed: Duration::from_secs(1),
..ModbusTcpRetry::default()
}))
.connect()
.await?;Flow control is actor-owned and validated when ModbusTcpSocket::connect is awaited:
ModbusTcpFlowControl::max_in_flight caps requests on the wire, and max_queue_depth is the
bounded backlog that applies backpressure to callers. Invalid flow-control settings return
ModbusError::ValidationError from socket connect. Use
ModbusTcpFlowControl::serial_gateway() for RTU gateways that drain one serial bus sequentially.
Queue wait is bounded by queue_timeout; the response_timeout clock starts only after the actor
successfully writes the frame to the socket. A single response timeout fails that transaction,
quarantines its transaction ID to avoid late-response aliasing, and keeps the TCP socket open.
By default, transient TCP connect/write/read/queue failures and gateway-busy exception responses
(0x05, 0x06, 0x0A, 0x0B) are retried with exponential backoff starting at 500 ms,
multiplied by 1.5, with jitter, and bounded only by max_elapsed (2 s). Set
max_times to cap the number of retries as well. To disable same-call retry, pass
with_retry(None); the first transient error is returned to the caller for that call, but the
connection state is still invalidated and the next call reconnects lazily.
Transport and protocol failures are reported with cloneable ModbusError values, including:
- connect, write, and read errors/timeouts
- I/O error variants carry
Arc<std::io::Error>; pattern matching still lets callers bind the error and callerr.kind()throughArcderef.
- I/O error variants carry
- malformed or invalid responses
- Modbus exception responses
- transaction ID, protocol ID, and unit ID mismatches
- request/response mismatches
- request validation errors
- invalid retry configuration as
ValidationError(not retried)
The repository includes a modbus_cli example for interactive Modbus TCP reads and writes across
all supported function codes.
Enable the cli feature when building or running it so the extra CLI-only dependencies are not
pulled into library-only builds.
Show the CLI help:
cargo run --features cli --example modbus_cli -- --helpThe CLI is organized as nested read/write subcommands:
modbus_cli [OPTIONS] read <coils|discrete|holding|input> <ADDRESS> <QUANTITY>
modbus_cli [OPTIONS] write <coil|register|coils|registers> <ADDRESS> <VALUE...>
Examples:
cargo run --features cli --example modbus_cli -- read coils 0 8
cargo run --features cli --example modbus_cli -- --address 192.168.1.10 read holding 100 4
cargo run --features cli --example modbus_cli -- write coil 12 on
cargo run --features cli --example modbus_cli -- --output hex write register 200 4660
cargo run --features cli --example modbus_cli -- write coils 16 1 0 1 1
cargo run --features cli --example modbus_cli -- write registers 300 10 20 30
cargo run --features cli --example modbus_cli -- -a 192.168.0.89 write register 5004 6000The CLI accepts:
- coil values as
1,0,true,false,on, oroff - register output in
decimalorhex - global connection options such as
--address,--port,--unit-id, and--transaction-id