Passive Bus Monitoring with WireTap: A Hardware Engineer's Guide

How oms-modbus WireTap implements passive bus observation at the I/O boundary, why microsecond timestamps matter for industrial diagnostics, and what the captured data reveals about your RS-485 network.

OrangeHorse Engineering Team 7 min read

Most Modbus debugging follows the same frustrating pattern: your sensor reports a CRC error, but you cannot see the bytes that caused it. You unplug the sensor, connect a USB-to-RS485 converter, fire up a serial monitor, and the error vanishes โ€” because the act of observing changed the bus. This is the observer effect in industrial form.

oms-modbus ships with a WireTap trait that solves this problem at the protocol level. It is a passive bus observer โ€” not a separate sniffer process, not a serial driver hack, but a first-class feature of a Modbus library that records every frame, direction, and CRC mismatch with wall-clock timestamps. Here is how it works and why the microsecond matters.

What WireTap Actually Observes

A standard Modbus client is an active participant. It sends a request, waits for a response, decodes the payload, and returns a result. Everything between "request sent" and "response received" is opaque โ€” the library does not record the raw bytes, the inter-frame gap, or whether the CRC matched.

WireTap sits at a different layer. SniffIo, the transport wrapper, copies every byte that passes through poll_read and poll_write to the tap's three hooks. The tap sees four things for every I/O event:

FieldExampleWhy It Matters
Direction[TX] or [RX]Distinguishes master requests from slave responses on a half-duplex bus
Timestamp2026-08-11T14:32:17.004221ISO 8601 with microsecond precision โ€” correlates frames with oscilloscope traces
Raw bytes[01 03 00 00 00 01 84 0A]The complete ADU including CRC, exactly as it appeared on the wire
Error contextRawError(bytes, "I/O error: ...")Partial data that arrived before the read failed

The key word is passive. WireTap never injects bytes, never modifies frames, never asserts control lines. The bus does not know it is being watched. This means you can use it on a production RS-485 bus without affecting communication between a PLC and its sensors.

How It Differs from a Serial Sniffer

A serial port sniffer like RealTerm or a logic analyzer captures raw bytes. That is useful, but it is only half the picture. You see hex dumps with no Modbus semantics โ€” no way to distinguish a Read Holding Registers request from a Read Coils request without manually decoding each byte. And you cannot correlate the sniffer output with what your application actually sent and received.

WireTap combines raw capture with application-level awareness. The same library that runs your Modbus transactions in production is the one that records them during diagnostics. You get:

  1. Raw bytes with CRC โ€” for verifying CRC calculations against your own implementation
  2. Direction markers โ€” [TX] for requests, [RX] for responses, [ERR] for partial reads on error โ€” for tracing multi-drop bus conversations
  3. Microsecond timestamps โ€” for measuring inter-frame gaps and response latency
  4. Byte counts โ€” each record shows 8B, 9B, etc., so oversized or truncated frames stand out immediately

A logic analyzer gives you (1) and (3). A Modbus client gives you the application context. WireTap gives you all four in one place, correlated with the exact Modbus calls your application made.

The Timestamp Story

Why microsecond precision? Because Modbus timing problems happen at the microsecond scale. The 3.5-character silent interval at 115200 baud is about 275 microseconds. A sensor that consistently responds 50 microseconds late will pass most tests but fail on a loaded bus with 20 devices polling at once.

now_micros() uses a hybrid clock: Instant::now() for monotonic delta from a one-time SystemTime::now() anchor captured at first call. One syscall at init, zero thereafter โ€” at Modbus rates (โ‰ค1000 exchanges/sec), the overhead is invisible. The timestamps are formatted as ISO 8601 strings by format_timestamp(), making them directly comparable to oscilloscope traces and SCADA event logs. If you capture a CRC error at 14:32:17.004221, you can correlate it to a ground-loop transient your oscilloscope triggered on at the same moment.

Attaching WireTap

WireTap is a trait โ€” not a standalone struct. You attach any type that implements it to a Modbus client through ClientOptions:

use oms_modbus::*;
use std::sync::Arc;
use std::time::Duration;

// BusCapture is the built-in WireTap implementation โ€” records + statistics
let cap = Arc::new(BusCapture::unbounded());

let opts = ClientOptions::default()
    .with_timeout(Duration::from_secs(3))
    .with_tap(cap.clone());

// Works with any transport: rtu, tcp, ascii
// Real RS-485: let port = tokio_serial::new("/dev/ttyUSB0", 9600).open_native_async()?;
let (port, _server) = tokio::io::duplex(1024); // in-memory for this demo
let client = rtu::with_options(port, opts);

// Normal Modbus calls โ€” WireTap records everything in the background
let regs = client.read_holding_registers(1, 0, 3).await?;
client.write_single_register(1, 10, 42).await?;

// Drain captured records
let packets = cap.drain();
for pkt in &packets {
    println!("{pkt}");
    // [TX] 2026-08-05T14:32:15.123456    8B  [01 03 00 00 00 03 05 CB]
    // [RX] 2026-08-05T14:32:15.170982    11B  [01 03 06 00 64 00 C8 01 2C D1 0E]
}

BusCapture has three modes โ€” pick based on your memory constraints:

ModeConstructorUse case
Full recordingBusCapture::unbounded()Diagnostics โ€” see every frame, never evict
Ring bufferBusCapture::bounded(10_000)Production โ€” constant memory, oldest evicted
Stats onlyBusCapture::stats_only()Monitoring โ€” atomic counters, zero allocation

PacketRecord implements Display, producing a fixed-width format: direction tag, ISO 8601 timestamp, byte count, hex dump in brackets. Each record is self-contained and greppable.

Real-World Debugging: A Case Study

During testing of the OHTS1070 weather station, we observed intermittent CRC errors on a bus that looked perfect under an oscilloscope. We attached a BusCapture in unbounded mode and ran 10,000 read cycles over an hour.

// Real RS-485 (OHTS1070 on /dev/ttyUSB1):
//   let port = tokio_serial::new("/dev/ttyUSB1", 9600).open_native_async()?;
// Self-contained demo โ€” in-memory duplex:
let (port, _srv) = tokio::io::duplex(1024);

let cap = Arc::new(BusCapture::unbounded());
let opts = ClientOptions::default()
    .with_timeout(Duration::from_secs(3))
    .with_tap(cap.clone());
let client = rtu::with_options(port, opts);

for i in 0..10_000 {
    let result = client.read_holding_registers(1, 0, 2).await;
    if result.is_err() {
        // Drain only on error โ€” inspect the bad frame
        let recent = cap.drain();
        for pkt in recent.iter().rev().take(4) {
            eprintln!("{pkt}");
        }
    }
    tokio::time::sleep(Duration::from_millis(360)).await;
}

Draining on error gave us the last few frames without storing 10,000 records in memory. The captured data revealed the pattern: every 47th response from sensor address 0x02 had a valid CRC but arrived before the 3.5T silent interval had elapsed. The sensor was responding too quickly after receiving the master's request โ€” a firmware edge case triggered by a specific register read sequence.

WireTap caught it in 10 minutes. Finding the same bug with an oscilloscope would have meant manually triggering on dozens of frames, decoding each CRC by hand, and measuring the inter-frame gap with cursors. WireTap produced formatted timestamps on every frame, making the inter-frame gap a one-line subtraction.

Custom WireTap Implementations

The WireTap trait has three hooks, each with a default no-op. Implement only what you need:

use oms_modbus::*;
use std::sync::atomic::{AtomicU64, Ordering};

/// A tap that only logs errors โ€” successful frames are silent.
struct ErrorLogger {
    count: AtomicU64,
}

impl WireTap for ErrorLogger {
    fn on_write(&self, _bytes: &[u8], _ts: u64) {
        // Default no-op โ€” not logging successful requests
    }
    fn on_read(&self, _bytes: &[u8], _ts: u64) {
        // Default no-op โ€” not logging successful responses
    }
    fn on_error(&self, bytes: &[u8], error: &str, _ts: u64) {
        self.count.fetch_add(1, Ordering::Relaxed);
        eprintln!("WireTap error ({} bytes): {error}", bytes.len());
    }
}

Attach with Arc::new(ErrorLogger { count: AtomicU64::new(0) }) and pass to ClientOptions::with_tap(). The transport layer drives all hooks โ€” neither the client nor the server participates in capture.

For async dispatch, use the monitor module's ChannelRecorder + RecordSink trait, or FileRecorder for direct-to-disk logging. See the bus_monitor and custom_tap examples for complete working code.

When to Use WireTap

WireTap is not a replacement for an oscilloscope or a logic analyzer. It operates at the application layer, not the physical layer โ€” it sees bytes after the UART, not voltage levels on the wire. Use WireTap when:

  • You suspect a protocol-level issue โ€” bad CRC, wrong function code, unexpected register address
  • You need to correlate Modbus traffic with SCADA events โ€” WireTap's timestamps are ISO 8601, same format as your event log
  • You are integrating a new sensor and want to verify its register map without writing test scripts
  • You need to monitor a live bus without interrupting communication โ€” WireTap is invisible to both master and slave
  • You want production monitoring with zero per-frame overhead โ€” BusCapture::stats_only() adds only atomic counter increments

Use an oscilloscope when you suspect a physical-layer issue โ€” ground loops, termination resistors, signal reflection. WireTap and an oscilloscope are complementary tools for different layers of the OSI stack.


Read the bus_monitor example for the complete working code with TCP server, traffic generation, and packet analysis. For a step-by-step setup guide, see WireTap Quick Start. To understand the raw bytes you captured, see Decoding Modbus Frames by Hand. For real-world diagnostic stories, see Field Troubleshooting with WireTap. If you encounter a bus anomaly that WireTap cannot explain, open an issue on GitHub.

modbus rs485 bus-monitoring diagnostic WireTap IIoT rust