Monitor a Modbus RTU Bus Without Disrupting It — WireTap Quick Start

Attach oms-modbus WireTap to a Modbus RTU client and capture every frame — raw bytes, direction, microsecond timestamps — without the bus knowing you are watching.

OrangeHorse Engineering Team 7 min read

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:

ConstructorBehavior
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:

VariantMeaningMatch 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 errormatches!(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

SymptomLikely causeFix
client.read_*() times out immediatelyNo device at that slave addressVerify the slave ID and that the device is powered
All responses are errorsBaud rate mismatchTry 9600 → 19200 → 38400 → 57600 → 115200 via .with_bus_timing()
drain() returns emptyClient hasn't been used yet; no I/O occurredMake at least one Modbus call before draining
Permission denied on LinuxSerial port not in dialout groupsudo usermod -a -G dialout $USER, then log out and back in
cap.count_errors() > 0 but no visible error outputErrors are recorded silently in PacketData::RawErrorFilter 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.

modbus rs485 bus-monitoring WireTap rust tutorial