OMS Modbus
⚠️ Preview Release — v0.2.0 is a preview. The API may change based on user feedback. We welcome issues, PRs, and suggestions.
High-performance, transport-generic Modbus library in pure Rust.
Complete Master (client) and Slave (server) for TCP, RTU, and ASCII —
all through a single unified API. 11 function codes. Generic transport
(any AsyncRead + AsyncWrite — serial, TCP, Unix sockets, pipes).
Passive WireTap bus monitor with microsecond timestamps. Configurable
TCP MBAP framing for non-standard devices. Auto-reconnect with USB
serial resilience. Spec-compliant RS-485 bus timing.
Async-first. Zero panics. One API, every transport.
Features
| Category | Details |
|---|---|
| Protocols | TCP (MBAP), RTU (CRC-16), ASCII (LRC). Full client + server for each. |
| Generic Transport | RTU and ASCII work over any AsyncRead + AsyncWrite — serial ports, TCP streams, Unix sockets, pipes, in-memory channels. One RtuClient / AsciiClient for all. |
| TCP MBAP Config | Full control over MBAP framing via TcpConfig: transaction ID (fixed / auto-increment), unit ID placement (header-only / PDU body), length calculation (standard / PDU-only). Gateway mode preset for TCP-to-RTU bridges. |
| Bus Monitor | Passive WireTap at the I/O boundary — like a hardware logic analyzer. Microsecond ISO 8601 timestamps (hybrid Instant + SystemTime, one syscall at init). Memory ring buffer, channel-based async dispatch, file recorder, custom backends via RecordSink. |
| Reconnect | Built-in for all transports. TCP: address-based. RTU/ASCII: factory closure for USB serial resilience, auto re-wraps in SniffIo. Fixed-interval (industrial-predictable). |
| Bus Timing | Enforces ≥3.5T silence between RTU/ASCII frames. Per Modbus Serial Spec V1.02 §1.4 (>19200 baud → fixed 1.75 ms). Raw-formula mode for custom hardware. Server-side timing support. |
| Custom Service | Implement the Service trait to bridge real hardware, transform values, enforce access control, or simulate devices. See dynamic_server example. |
| Function Codes | FC01–06, FC08, FC15–16, FC22–23 — all 11 with write+readback verification. Diagnostic sub-functions 0x00, 0x0A–0x0E. |
| Slave Store | RwLock<Vec<u16>> — O(1) indexed access. 125 consecutive registers = single lock + memcpy. |
| Error Handling | Structured ModbusError — short Display, full detail(), machine-readable label(). No string parsing. |
| Panic-Free | Zero .unwrap() in production code. Poison-safe mutexes. |
| Zero-Copy | Bytes-based PDU encoding. TCP codec uses reference-counted buffers. |
| Lean | tokio + tokio-serial + tokio-util + bytes + futures-util + async-trait + thiserror + log. 8 runtime deps, no transient dependencies. |
Installation
[]
= "0.2"
No feature flags — everything included. TCP, RTU, ASCII, server, capture, monitoring, and timing are all available by default. One dependency, all three protocols.
Quick Start
use ;
// ── Start a TCP server on a random port ──────────────────────
let addr = new;
let server = bind.await.unwrap;
let bind_addr = server.local_addr.unwrap;
let store = new;
spawn;
sleep.await;
// ── Connect and exchange data ─────────────────────────────────
let client = connect_with_timeout
.await
.unwrap;
let regs = client.read_holding_registers.await.unwrap;
assert_eq!;
client.write_single_register.await.unwrap;
Usage
Client (Master)
All function codes work the same way across TCP, RTU, and ASCII:
// TCP
let addr = "192.168.1.10:502".parse.unwrap;
let client = connect_with_timeout
.await
.unwrap;
// RTU — serial port
// let port = tokio_serial::SerialStream::open(&config)?;
// let client = rtu::RtuClient::with_timeout(port, Duration::from_secs(3));
// RTU/ASCII with monitoring, timing, reconnect — via ClientOptions
// let opts = ClientOptions::default()
// .with_timeout(Duration::from_secs(3))
// .with_tap(BusCapture::unbounded());
// let client = rtu::with_options(port, opts);
// ── Standard function codes ──────────────────────────────────────
client.read_coils.await.unwrap;
client.read_discrete_inputs.await.unwrap;
client.read_holding_registers.await.unwrap;
client.read_input_registers.await.unwrap;
client.write_single_coil.await.unwrap;
client.write_single_register.await.unwrap;
client
.write_multiple_coils
.await
.unwrap;
client
.write_multiple_registers
.await
.unwrap;
// ── Advanced ─────────────────────────────────────────────────────
client.mask_write_register.await.unwrap;
let _old = client
.read_write_multiple_registers
.await
.unwrap;
let = client.diagnostic.await.unwrap;
// ── Share via Arc ────────────────────────────────────────────────
let _shared: = new;
Server (Slave)
let store = new;
store.write_coil;
// TCP
let addr = "127.0.0.1:0".parse.unwrap;
let server = bind.await.unwrap;
let _bind_addr = server.local_addr.unwrap;
// server.serve_forever(store).await?; // would block forever
// RTU/ASCII with bus timing — uses in-memory duplex for testing
let = duplex;
let rtu_server = new
.with_bus_timing;
spawn;
Implement the Service trait for custom backends (hardware bridge, access control, value transformation) — see examples/custom_service.rs.
Bus Monitoring
WireTap is a passive observer attached to the transport. Implement it for custom backends, or use the built-in recorders:
let = duplex;
// ── In-memory recording ──────────────────────────────────────────
let cap = new; // memory buffer + stats
let _ring = bounded; // bounded ring buffer
let opts = default
.with_timeout
.with_tap;
let _client = with_options;
// client.read_holding_registers(1, 0, 5).await?;
// println!("requests: {} errors: {}", cap.count_requests(), cap.count_errors());
// for record in cap.drain() {
// println!("{record}"); // [TX] 2026-08-05T14:32:15.123456 8B [01 03 ...]
// }
// ── File recording (async, non-blocking) ─────────────────────────
let recorder = new.unwrap;
let = spawn;
let _opts = default.with_tap;
// let client = rtu::with_options(port, opts);
// ... traffic is written to traffic.log in real time ...
// ── Custom backend (network, database, dashboard) ────────────────
;
let = spawn;
Timestamps are ISO 8601 (2026-08-05T14:32:15.123456) — real wall-clock time via a hybrid Instant + SystemTime clock. One syscall at init, zero thereafter.
Auto-Reconnect
// TCP — address-based (built-in)
let addr = "127.0.0.1:502".parse.unwrap;
let _client = connect
.await
.unwrap
.with_reconnect; // 0 = infinite
// RTU/ASCII — factory-based (USB serial resilience)
// let client = rtu::RtuClient::with_timeout(port, timeout)
// .with_reconnect(3, Duration::from_millis(100), || {
// tokio_serial::SerialStream::open(&config) // reopen after disconnect
// });
// With ClientOptions + with_reconnect_on (SniffIo re-wrapped automatically)
let = duplex;
let opts = default
.with_timeout
.with_tap;
let _client = with_options.with_reconnect_on;
max_retries = 0→ infinite retry- Fixed interval (no exponential backoff — industrial environments require predictable timing)
- Protocol errors (CRC mismatch, exception responses) are not retried
- Only transport errors (timeout, connection, serial) trigger reconnect
Bus Timing
let = duplex;
// RTU 3.5T at 9600 baud (~4 ms)
let _timing = rtu_35t;
// Per spec: >19200 baud → fixed 1.75 ms minimum
let _timing = rtu_35t; // → 1.75 ms, not ~334 µs
// Raw formula (no cap) — for custom hardware
let _timing = rtu_35t_raw; // → ~334 µs
// Custom spacing for slow devices
let _timing = custom;
let opts = default
.with_timeout
.with_bus_timing;
let _client = with_options;
Error Handling
// Simulate a client call returning an error for demonstration
let result: = Err;
match result
Advanced TCP Configuration
TcpConfig gives full control over MBAP framing for non-standard devices:
use ;
let addr = "127.0.0.1:502".parse.unwrap;
// Gateway mode — auto TID + UID in PDU body (RTU-over-TCP gateways)
let _client = connect
.await
.unwrap
.with_gateway_mode;
// Full custom config
let _client = connect.await.unwrap.with_config;
Three configuration axes:
| Axis | Values | Default |
|---|---|---|
tid |
Fixed(u16) / Auto |
Fixed(0) |
unit_id_in_body |
PDU body starts with UID | false |
length_mode |
Standard (1+PDU) / PduOnly |
Standard |
Transport Flexibility
All protocol clients are generic over T: AsyncRead + AsyncWrite.
The same RtuClient or AsciiClient works with any byte stream:
use TcpStream;
// RTU over TCP — RTU frames on a TCP connection
let tcp = connect.await.unwrap;
let _rtu = with_timeout;
// let regs = _rtu.read_holding_registers(1, 0, 5).await?;
// ASCII over TCP — ASCII frames on a TCP connection
let tcp = connect.await.unwrap;
let _ascii = with_timeout;
// In-memory channel — perfect for testing (zero hardware)
let = duplex;
let _rtu = with_timeout;
// With capture — uses SniffIo internally
let = duplex;
let opts = default
.with_timeout
.with_tap;
let _rtu = with_options; // capture works anywhere
Transport Matrix:
| Transport | RTU | ASCII | TCP (MBAP) | SniffIo |
|---|---|---|---|---|
| Serial port | ✅ | ✅ | — | ✅ |
| TCP stream | ✅ | ✅ | ✅ | ✅ |
| DuplexStream | ✅ | ✅ | — | ✅ |
| Unix socket | ✅ | ✅ | — | ✅ |
| Custom I/O | ✅ | ✅ | — | ✅ |
See examples/generic_transport.rs and examples/tcp_client_gateway.rs.
Testing
# All tests (440+ passed, zero failures)
# Specific suites
# Hardware tests (require COM20↔COM21 virtual serial pair)
# No hardware? All core tests run with zero setup.
Benchmarks
Representative results from a developer workstation (Intel Core i7-6800K @ 3.40 GHz, Windows 10, DDR4 32 GB, Rust 1.97). Run cargo bench on your target hardware for numbers relevant to your deployment.
| Benchmark | Time |
|---|---|
| CRC-16 (6-byte RTU frame) | 16 ns |
| CRC-16 (256 bytes) | 668 ns |
| PDU decode (ReadHolding) | 39 ns |
| PDU encode (ReadHolding) | 241 ns |
| PDU encode (123 registers) | 1.6 µs |
| PDU encode response (125 registers) | 1.6 µs |
| RTU codec encode (6 bytes) | 112 ns |
| RTU codec decode (8-byte frame) | 243 ns |
| ASCII codec encode (6 bytes) | 177 ns |
| ASCII codec decode (19-byte frame) | 446 ns |
Run on your own hardware — cargo bench produces detailed HTML reports under
target/criterion/.
Examples
# TCP (no hardware needed)
# RTU / ASCII (no hardware needed — in-memory duplex)
# Monitoring
# Advanced
See examples/ for the full list.
Architecture
src/
├── frame.rs PDU encode/decode (Request, Response, Exception)
├── error.rs ModbusError — structured, label + detail + Display
├── client.rs ModbusClient trait — single required method: call()
├── server.rs Service trait + ServerHook + HookedService
├── slave_store.rs In-memory register/coil store (RwLock)
├── bus_timing.rs Minimum frame spacing for RS-485 buses
├── capture.rs BusCapture — WireTap impl with recording + stats
├── options.rs ClientOptions — unified builder for all transports
├── intercept.rs PacketRecord, PacketData, TrafficStats
├── wire_tap.rs WireTap trait — passive bus observer (3 hooks)
├── reconnect.rs ReconnectConfig + is_retryable()
├── codec.rs CRC-16 + LRC + frame encoding
├── transport/
│ ├── tcp.rs TcpClient + TcpServer + TcpConfig (MBAP)
│ ├── rtu.rs RtuClient + RtuServer (generic transport)
│ ├── ascii.rs AsciiClient + AsciiServer (generic transport)
│ ├── send_recv.rs Shared drain/reconnect/process helpers
│ └── sniff_io.rs SniffIo<T> — WireTap attachment + BusTiming
└── monitor/
├── mod.rs Module declarations
├── channel.rs ChannelRecorder + RecordSink trait
├── ring_buffer.rs RingBufferCapture (bounded/unbounded)
└── file_recorder.rs FileRecorder (ISO 8601, non-blocking)
Data Flow
┌──────────┐ Request ┌──────────────┐ Raw Bytes ┌──────────┐
│ Client │ ──────────────► │ send_recv │ ──────────────► │ SniffIo │
│ (Master) │ │ (protocol) │ │ (proxy) │
│ │ ◄────────────── │ │ ◄────────────── │ │
└──────────┘ Response └──────────────┘ Raw Bytes └────┬─────┘
│
┌──────────────────────┤
│ WireTap (passive) │
│ on_write / on_read │
│ on_error │
└──────────┬───────────┘
│
┌────────────┴───────────┐
│ BusCapture / │
│ ChannelRecorder / │
│ FileRecorder │
└────────────────────────┘
┌──────────┐ Request ┌──────────────┐
│ Server │ ◄────────────── │ Service │──► SlaveStore
│ (Slave) │ ──────────────► │ (trait) │ (default)
└──────────┘ Response └──────────────┘
Core principle: WireTap attaches to SniffIo<T> at the physical transport boundary.
Neither the client nor the server participates in capture — it's a pure passive observer.
Why OMS Modbus
OMS Modbus was built to support a next-generation industrial diagnostic IDE with requirements not commonly addressed by Modbus libraries:
- All three protocols in one API — TCP, RTU, and ASCII share the same client and server patterns. Switch transports without rewriting application logic.
- Passive bus monitoring —
WireTapcaptures raw bytes at the I/O boundary with microsecond timestamps, like a hardware logic analyzer. Neither the client nor the server participates in capture. - Spec-compliant bus timing — the Modbus Serial Spec mandates a fixed 1.75 ms
silent interval above 19200 baud.
BusTimingimplements this explicitly, protecting low-performance RS-485 devices from frame collisions. - Panic-free production paths — zero
.unwrap()in library code. Poison-safe mutexes, checked arithmetic, validated PDU sizes. - Generic transport — RTU and ASCII work over any
AsyncRead + AsyncWrite, enabling RTU-over-TCP, in-memory testing, and custom I/O backends.
Contact & Links
OrangeHorse Electronic Technology Co., Ltd. — industrial communication and diagnostic solutions.
| Channel | Link |
|---|---|
| 🌐 Website | orangehorsetech.com |
| OrangeHorseTech | |
| github@orangehorsetech.com |
License
Licensed under either of MIT License or Apache License 2.0, at your option.