Modbus client state machine for Rust — poll-driven, no_std compatible, zero heap allocation.
mbus-client is a helper crate for modbus-rs.
Ready-made transport implementations are available in:
- mbus-network for TCP
- mbus-serial for Serial RTU/ASCII
These crates target standard desktop platforms (Windows, Linux, and macOS).
- Poll-driven — no threads, no blocking I/O
- no_std compatible — runs on embedded MCUs
- Transport agnostic — TCP, Serial RTU, Serial ASCII
- All standard FCs — coils, registers, discrete inputs, FIFO, file records, diagnostics
- Configurable retry — exponential/linear/fixed backoff with optional jitter
use mbus_client::{
app::{CoilResponse, RequestErrorNotifier},
services::{ClientServices, coil::Coils},
};
use mbus_core::{
data_unit::common::MAX_ADU_FRAME_LEN,
errors::MbusError,
transport::{ModbusConfig, ModbusTcpConfig, TimeKeeper, Transport, TransportType, UnitIdOrSlaveAddr},
};
use heapless::Vec;
struct App;
impl RequestErrorNotifier for App {
fn request_failed(&mut self, _: u16, _: UnitIdOrSlaveAddr, _: MbusError) {}
}
impl CoilResponse for App {
fn read_coils_response(&mut self, _: u16, _: UnitIdOrSlaveAddr, _: &Coils) {}
fn read_single_coil_response(&mut self, _: u16, _: UnitIdOrSlaveAddr, _: u16, _: bool) {}
fn write_single_coil_response(&mut self, _: u16, _: UnitIdOrSlaveAddr, _: u16, _: bool) {}
fn write_multiple_coils_response(&mut self, _: u16, _: UnitIdOrSlaveAddr, _: u16, _: u16) {}
}
impl TimeKeeper for App {
fn current_millis(&self) -> u64 { 0 }
}
struct MockTransport;
impl Transport for MockTransport {
type Error = MbusError;
const TRANSPORT_TYPE: TransportType = TransportType::StdTcp;
const SUPPORTS_BROADCAST_WRITES: bool = false;
fn connect(&mut self, _: &ModbusConfig) -> Result<(), Self::Error> { Ok(()) }
fn disconnect(&mut self) -> Result<(), Self::Error> { Ok(()) }
fn send(&mut self, _: &[u8]) -> Result<(), Self::Error> { Ok(()) }
fn recv(&mut self) -> Result<Vec<u8, MAX_ADU_FRAME_LEN>, Self::Error> { Ok(Vec::new()) }
fn is_connected(&self) -> bool { true }
}
fn main() -> Result<(), MbusError> {
let config = ModbusConfig::Tcp(ModbusTcpConfig::new("192.168.1.10", 502)?);
let mut client = ClientServices::<_, _, 4>::new(MockTransport, App, config)?;
client.connect()?;
client
.coils()
.read_multiple_coils(1, UnitIdOrSlaveAddr::new(1)?, 0, 16)?;
loop {
client.poll(); // Drive state machine
break;
}
Ok(())
}| Topic | Link |
|---|---|
| Quick Start | documentation/client/quick_start.md |
| Building Apps | documentation/client/building_applications.md |
| Feature Flags | documentation/client/feature_flags.md |
| Architecture | documentation/client/architecture.md |
| Crate | Purpose |
|---|---|
modbus-rs |
Top-level convenience crate |
mbus-core |
Shared protocol types |
mbus-async |
Tokio async facade |
mbus-network |
TCP transport |
mbus-serial |
Serial RTU/ASCII transport |
- Runtime-safe path:
ClientServices::new(...)validates serialN == 1. - Compile-time-safe path:
ClientServices::new_serial(...)enforcesN == 1. - Recommended type alias:
SerialClientServices<TRANSPORT, APP>.
This crate uses selective compilation so you only build required protocol services.
Available features:
coilsregistersdiscrete-inputsfifofile-recorddiagnosticsserial-ascii(forwards tombus-core/serial-asciito enable ASCII-sized ADU buffers)traffic(enables raw TX/RX frame callbacks viaTrafficNotifier)logging(enables low-priority internal state-machine diagnostics via thelogfacade)
Default behavior:
defaultenables all service features above.
Feature forwarding:
- Each feature forwards to the equivalent model feature in
mbus-core.
Example (minimal feature set):
[dependencies]
mbus-client = { version = "0.15.0", default-features = false, features = ["coils"] }When traffic is enabled, apps can implement TrafficNotifier to observe raw ADU frames:
#[allow(unexpected_cfgs)]
#[cfg(feature = "traffic")]
use mbus_client::app::{TrafficDirection, TrafficNotifier};
use mbus_core::transport::UnitIdOrSlaveAddr;
#[allow(unexpected_cfgs)]
#[cfg(feature = "traffic")]
struct App;
#[allow(unexpected_cfgs)]
#[cfg(feature = "traffic")]
impl TrafficNotifier for App {
fn on_tx_frame(
&mut self,
txn_id: u16,
unit_id_slave_addr: UnitIdOrSlaveAddr,
frame_bytes: &[u8],
) {
println!(
"[{:?}] txn={} unit={} bytes={:02X?}",
TrafficDirection::Tx,
txn_id,
unit_id_slave_addr.get(),
frame_bytes
);
}
fn on_rx_frame(
&mut self,
txn_id: u16,
unit_id_slave_addr: UnitIdOrSlaveAddr,
frame_bytes: &[u8],
) {
println!(
"[{:?}] txn={} unit={} bytes={:02X?}",
TrafficDirection::Rx,
txn_id,
unit_id_slave_addr.get(),
frame_bytes
);
}
fn on_tx_error(
&mut self,
txn_id: u16,
unit_id_slave_addr: UnitIdOrSlaveAddr,
error: mbus_core::errors::MbusError,
frame_bytes: &[u8],
) {
println!(
"[{:?}] txn={} unit={} error={error:?} bytes={:02X?}",
TrafficDirection::Tx,
txn_id,
unit_id_slave_addr.get(),
frame_bytes
);
}
fn on_rx_error(
&mut self,
txn_id: u16,
unit_id_slave_addr: UnitIdOrSlaveAddr,
error: mbus_core::errors::MbusError,
frame_bytes: &[u8],
) {
println!(
"[{:?}] txn={} unit={} error={error:?} bytes={:02X?}",
TrafficDirection::Rx,
txn_id,
unit_id_slave_addr.get(),
frame_bytes
);
}
}mbus-client can emit low-priority internal diagnostics through the log facade when the
logging feature is enabled.
These logs are intentionally limited to debug and trace so applications can filter them
without treating normal control-flow events as warnings or errors.
Examples of logged events:
- frame parse/resynchronization
- response dispatch matching
- timeout scans and retry scheduling
- retry send failures
- pending-request flush during connection loss or reconnect
Typical filtering example:
RUST_LOG=mbus_client=trace cargo run -p modbus-rs --example modbus_rs_client_tcp_logging --no-default-features --features network-tcp,client,loggingTypical flow:
- Implement required callback traits in your app type.
- Provide a
Transportimplementation (custom,mbus-network, ormbus-serial). - Build a
ModbusConfig. - Construct
ClientServices. - Issue requests.
- Call
poll()periodically to process responses and timeouts.
use modbus_rs::{
ClientServices, MAX_ADU_FRAME_LEN, MbusError, ModbusConfig, ModbusTcpConfig,
RequestErrorNotifier, TimeKeeper, Transport, TransportType, UnitIdOrSlaveAddr,
};
#[allow(unexpected_cfgs)]
#[cfg(feature = "coils")]
use modbus_rs::{CoilResponse, Coils};
use heapless::Vec;
struct MockTransport;
impl Transport for MockTransport {
type Error = MbusError;
const TRANSPORT_TYPE: TransportType = TransportType::StdTcp;
const SUPPORTS_BROADCAST_WRITES: bool = false;
fn connect(&mut self, _: &ModbusConfig) -> Result<(), Self::Error> { Ok(()) }
fn disconnect(&mut self) -> Result<(), Self::Error> { Ok(()) }
fn send(&mut self, _: &[u8]) -> Result<(), Self::Error> { Ok(()) }
fn recv(&mut self) -> Result<Vec<u8, MAX_ADU_FRAME_LEN>, Self::Error> { Ok(Vec::new()) }
fn is_connected(&self) -> bool { true }
}
struct App;
impl RequestErrorNotifier for App {
fn request_failed(&mut self, _: u16, _: UnitIdOrSlaveAddr, _: MbusError) {}
}
#[allow(unexpected_cfgs)]
#[cfg(feature = "coils")]
impl CoilResponse for App {
fn read_coils_response(&mut self, _: u16, _: UnitIdOrSlaveAddr, _: &Coils) {}
fn read_single_coil_response(&mut self, _: u16, _: UnitIdOrSlaveAddr, _: u16, _: bool) {}
fn write_single_coil_response(&mut self, _: u16, _: UnitIdOrSlaveAddr, _: u16, _: bool) {}
fn write_multiple_coils_response(&mut self, _: u16, _: UnitIdOrSlaveAddr, _: u16, _: u16) {}
}
impl TimeKeeper for App {
fn current_millis(&self) -> u64 { 0 }
}
#[allow(unexpected_cfgs)]
#[cfg(feature = "traffic")]
impl modbus_rs::TrafficNotifier for App {}
fn main() -> Result<(), MbusError> {
let transport = MockTransport;
let app = App;
let config = ModbusConfig::Tcp(ModbusTcpConfig::new("127.0.0.1", 502)?);
let mut client = ClientServices::<_, _, 4>::new(transport, app, config)?;
client.connect()?;
#[allow(unexpected_cfgs)]
#[cfg(feature = "coils")]
client.coils().read_multiple_coils(1, UnitIdOrSlaveAddr::new(1)?, 0, 8)?;
#[allow(unexpected_cfgs)]
#[cfg(feature = "coils")]
client.with_coils(|coils| {
coils.read_single_coil(2, UnitIdOrSlaveAddr::new(1)?, 0)?;
coils.write_single_coil(3, UnitIdOrSlaveAddr::new(1)?, 0, true)?;
Ok::<(), MbusError>(())
})?;
while client.has_pending_requests() {
client.poll();
}
Ok(())
}ClientServices now supports feature facades so request APIs can be grouped by domain:
client.coils()client.registers()client.discrete_inputs()client.diagnostic()client.fifo()client.file_records()
For grouped request submission in a single scoped borrow, use batch helpers:
client.with_coils(...)client.with_registers(...)client.with_discrete_inputs(...)client.with_diagnostic(...)client.with_fifo(...)client.with_file_records(...)
From workspace root:
# default services
cargo check -p mbus-client
# only coils service
cargo check -p mbus-client --no-default-features --features coils
# registers + discrete inputs only
cargo check -p mbus-client --no-default-features --features registers,discrete-inputs- This crate is
no_stdfriendly and usesheaplessinternally. - Service and callback traits are conditionally compiled by feature flags.
- Use exact feature names with hyphens:
discrete-inputsfile-record
This crate is licensed under GPL-3.0.
Commercial licenses are also available for proprietary use; contact [email protected].
This is an independent Rust implementation of the Modbus specification and is not affiliated with the Modbus Organization.
For questions or support:
- Name: Raghava Ch
- Email: [email protected]