Most Modbus debugging goes like this: your sensor reports a CRC error. You unplug the sensor, wire in a USB-to-RS485 adapter, fire up a serial monitor — and the error disappears. The act of observing changed the bus.
oms-modbus ships with a feature called WireTap that watches the bus without the bus knowing it is there. You attach it to a normal Modbus client via ClientOptions::with_tap(). The client sends requests and reads responses as usual. WireTap records every byte that crosses the I/O boundary — direction, raw hex, microsecond timestamps — in a background task driven by the transport layer. It never injects bytes. It never modifies frames. It is invisible to every other device on the bus.
This tutorial gets you from cargo init to a running capture in 15 minutes.
Prerequisites
- Rust toolchain (install via rustup) — that is it. The demo is self-contained.
This tutorial uses tokio::io::duplex() to create an in-memory Modbus bus — no USB adapter, no sensor, no hardware at all. The code compiles and runs on any machine. If you have a real RS-485 bus and want to attach WireTap to it, see the real-hardware note below.
Step 1: Create the project
cargo new modbus-monitor
cd modbus-monitor
cargo add oms-modbus
cargo add tokio --features full
Two dependencies. WireTap lives in the core crate — no feature flags, no optional modules.
Step 2: Create the capture backend
BusCapture is the built-in WireTap implementation. It records every frame and maintains atomic counters. Three modes:
| Constructor | Behavior |
|---|---|
BusCapture::stats_only() | Counts only — zero memory allocation |
BusCapture::unbounded() | Records every frame, never drops |
BusCapture::bounded(1000) | Ring buffer, evicts oldest when full |
Start with unbounded — see everything, then tune for production:
use oms_modbus::*;
use std::sync::Arc;
use std::time::Duration;
let cap = Arc::new(BusCapture::unbounded());
Wrap in Arc because both the client and your analysis code need to hold a reference.
Step 3: Attach WireTap to a client
This is the key pattern. WireTap is not a standalone sniffer — it attaches to a Modbus client through ClientOptions:
// In-memory duplex stream — self-contained, no hardware needed for this demo
// Real RS-485: replace with tokio_serial::SerialStream::open("/dev/ttyUSB0", &config)?
let (transport, _server_side) = tokio::io::duplex(1024);
let opts = ClientOptions::default()
.with_timeout(Duration::from_secs(3))
.with_tap(cap.clone());
let client = rtu::with_options(transport, opts);
tokio::io::duplex(1024) creates an in-memory byte-stream pair — the simplest way to learn WireTap without hardware. For a real RS-485 bus, open a serial port with the tokio-serial crate and pass the handle to rtu::with_options(). The baud rate defaults to 9600 — override with .with_bus_timing(BusTiming::rtu_35t(19200)).
What happens under the hood: ClientOptions::with_tap() wraps the transport in SniffIo, a transparent layer that copies every byte read or written to the tap's on_write(), on_read(), and on_error() hooks. The client does not participate in this — the transport layer drives capture as a side effect of normal I/O.
Step 4: Make Modbus calls — WireTap records silently
// Read three holding registers from slave address 1
let regs = client.read_holding_registers(1, 0, 3).await?;
println!("Holding registers [0..2]: {regs:?}");
// Write a single register
client.write_single_register(1, 0, 7777).await?;
// Read coils
let coils = client.read_coils(1, 0, 4).await?;
println!("Coils [0..3]: {coils:?}");
Every call to read_holding_registers(), write_single_register(), and read_coils() produces two captured frames: one TX (the request) and one RX (the response). WireTap recorded them all without you writing a single line of capture logic.
Step 5: Drain and inspect
let packets = cap.drain();
println!("Captured {} frames\n", packets.len());
for pkt in &packets {
// PacketRecord implements Display — direction, timestamp, byte count, hex dump
println!("{pkt}");
}
Output looks like this:
[TX] 2026-08-12T14:32:17.004221 8B [01 03 00 00 00 03 05 CB]
[RX] 2026-08-12T14:32:17.051892 9B [01 03 06 00 2A 00 64 00 C8 E4 7A]
[TX] 2026-08-12T14:32:18.003441 8B [01 06 00 00 1E 61 89 D0]
[RX] 2026-08-12T14:32:18.048772 8B [01 06 00 00 1E 61 89 D0]
[TX] 2026-08-12T14:32:19.001112 8B [01 01 00 00 00 04 3D C9]
[RX] 2026-08-12T14:32:19.044556 6B [01 01 01 0A 90 47]
[TX]= request (master → bus),[RX]= response (slave → bus)- ISO 8601 timestamps with microsecond precision — comparable to oscilloscope traces
- Byte count before the hex dump for quick size checks
- Full hex including CRC — verify against your own CRC implementation
Step 6: Filter and analyze by direction
PacketRecord carries a PacketData enum with three variants:
| Variant | Meaning | Match pattern |
|---|---|---|
PacketData::RawTx(Vec<u8>) | Bytes transmitted (request) | matches!(p.data, PacketData::RawTx(_)) |
PacketData::RawRx(Vec<u8>) | Bytes received (response) | matches!(p.data, PacketData::RawRx(_)) |
PacketData::RawError(Vec<u8>, String) | Partial read before error | matches!(p.data, PacketData::RawError(..)) |
Count by direction:
let tx_count = packets.iter()
.filter(|p| matches!(p.data, PacketData::RawTx(_)))
.count();
let rx_count = packets.iter()
.filter(|p| matches!(p.data, PacketData::RawRx(_)))
.count();
let err_count = packets.iter()
.filter(|p| matches!(p.data, PacketData::RawError(..)))
.count();
println!("TX: {tx_count}, RX: {rx_count}, Errors: {err_count}");
Lightweight mode: statistics only
If you only need counts — not the raw bytes — use BusCapture::stats_only(). Atomic counters, zero allocation:
let cap = Arc::new(BusCapture::stats_only());
let (transport, _server) = tokio::io::duplex(1024);
let opts = ClientOptions::default().with_tap(cap.clone());
let client = rtu::with_options(transport, opts);
// ... run Modbus traffic ...
println!("Requests: {}", cap.count_requests());
println!("Responses: {}", cap.count_responses());
println!("Errors: {}", cap.count_errors());
No drain() needed. The counters are always live.
Custom WireTap: implement your own
The WireTap trait has three hooks, all with default no-op implementations. Implement only what you need:
use oms_modbus::*;
use std::sync::atomic::{AtomicU64, Ordering};
struct ErrorLogger {
count: AtomicU64,
}
impl WireTap for ErrorLogger {
fn on_error(&self, bytes: &[u8], error: &str, _timestamp_us: u64) {
self.count.fetch_add(1, Ordering::Relaxed);
eprintln!("WireTap error ({} bytes): {error}", bytes.len());
}
// on_write and on_read default to no-op — not logging successful frames
}
Attach it the same way: ClientOptions::default().with_tap(Arc::new(ErrorLogger { count: AtomicU64::new(0) })).
Complete program
use oms_modbus::*;
use std::sync::Arc;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Create an in-memory Modbus RTU bus (self-contained — no hardware needed)
let (client_stream, server_stream) = tokio::io::duplex(1024);
// 2. Start a virtual RTU device on the other end of the duplex
let store = Arc::new(SlaveStore::with_holding_registers(&[
(0, 100),
(1, 200),
(2, 300),
]));
let server = rtu::RtuServer::new(server_stream);
tokio::spawn(async move {
server.serve_forever(store).await.ok();
});
// 3. Create capture backend
let cap = Arc::new(BusCapture::unbounded());
// 4. Attach WireTap to the RTU client
let opts = ClientOptions::default()
.with_timeout(Duration::from_secs(3))
.with_tap(cap.clone());
let client = rtu::with_options(client_stream, opts);
// 5. Run Modbus traffic — WireTap records everything silently
println!("Reading registers from slave 1...\n");
let regs = client.read_holding_registers(1, 0, 3).await?;
println!("Holding registers [0..2]: {regs:?}\n");
// 6. Drain and print captured frames
let packets = cap.drain();
println!("═══ Capture ({:>3} frames) ═══", packets.len());
for pkt in &packets {
println!("{pkt}");
}
// 7. Quick stats
println!("\nRequests: {}, Responses: {}, Errors: {}",
cap.count_requests(), cap.count_responses(), cap.count_errors());
Ok(())
}
TCP monitoring
WireTap works identically over TCP. Replace the RTU client with a TCP client:
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), 502);
let cap = Arc::new(BusCapture::unbounded());
let opts = ClientOptions::default()
.with_timeout(Duration::from_secs(3))
.with_tap(cap.clone());
let client = tcp::with_options(addr, opts).await?;
// Same pattern — make Modbus calls, drain records
let regs = client.read_holding_registers(1, 0, 1).await?;
for pkt in cap.drain() {
println!("{pkt}");
}
The capture API is identical. Attach BusCapture via ClientOptions::with_tap(), run traffic, call drain().
Using real serial hardware
The demo above uses tokio::io::duplex() — self-contained, runs anywhere. To attach WireTap to a live RS-485 bus, open a real serial port and pass it to rtu::with_options():
// Open the serial port with tokio-serial (bundled with oms-modbus)
let port = tokio_serial::new("/dev/ttyUSB0", 9600)
.open_native_async()?;
let cap = Arc::new(BusCapture::unbounded());
let opts = ClientOptions::default()
.with_timeout(Duration::from_secs(3))
.with_tap(cap.clone())
.with_bus_timing(BusTiming::rtu_35t(9600));
let client = rtu::with_options(port, opts);
On Windows use "COM3" instead of "/dev/ttyUSB0". Match the baud rate to your bus — 9600, 19200, 38400, 57600, and 115200 are the most common rates on RS-485 networks.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
client.read_*() times out immediately | No device at that slave address | Verify the slave ID and that the device is powered |
| All responses are errors | Baud rate mismatch | Try 9600 → 19200 → 38400 → 57600 → 115200 via .with_bus_timing() |
drain() returns empty | Client hasn't been used yet; no I/O occurred | Make at least one Modbus call before draining |
Permission denied on Linux | Serial port not in dialout group | sudo usermod -a -G dialout $USER, then log out and back in |
cap.count_errors() > 0 but no visible error output | Errors are recorded silently in PacketData::RawError | Filter packets for RawError variant to inspect them |
What WireTap is not
WireTap operates at the application layer — it sees bytes after the UART, not voltage levels on the wire. It cannot detect ground loops, termination resistor problems, or signal reflection. For physical-layer issues, you still need an oscilloscope. WireTap and an oscilloscope are complementary tools for different layers of the OSI stack.
WireTap is part of oms-modbus v0.2.0+, a high-performance Modbus library in Rust built by OrangeHorse, a Shanghai-based IIoT sensor manufacturer. 440+ tests. MIT/Apache-2.0 licensed. Source on GitHub.