Skip to main content

oms_modbus/
intercept.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Packet data types for Modbus traffic capture.
3//!
4//! These types are produced by [`crate::WireTap`] implementations and consumed
5//! by recording backends (ring buffers, file loggers, network forwarders).
6//! They carry raw bytes with microsecond timestamps — no PDU decoding.
7
8use std::fmt;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::{Arc, Mutex};
11
12use crate::wire_tap::WireTap;
13
14// ── Traffic statistics ────────────────────────────────────────────────────
15
16/// Atomic request/response/error counter — cheap enough for production use.
17///
18/// Implements [`WireTap`] to count frames at the transport boundary:
19/// `on_write` → request, `on_read` → response, `on_error` → error.
20#[derive(Debug, Default, Clone)]
21pub struct TrafficStats {
22    requests: Arc<AtomicU64>,
23    responses: Arc<AtomicU64>,
24    errors: Arc<AtomicU64>,
25}
26
27impl TrafficStats {
28    /// Create a new zeroed traffic counter.
29    pub fn new() -> Self {
30        Self::default()
31    }
32    /// Number of write (request) events captured.
33    pub fn count_requests(&self) -> u64 {
34        self.requests.load(Ordering::Relaxed)
35    }
36    /// Number of read (response) events captured.
37    pub fn count_responses(&self) -> u64 {
38        self.responses.load(Ordering::Relaxed)
39    }
40    /// Number of read-error events captured.
41    pub fn count_errors(&self) -> u64 {
42        self.errors.load(Ordering::Relaxed)
43    }
44    /// Reset all counters to zero.
45    ///
46    /// Note: the three counters are reset individually — a concurrent reader
47    /// may observe a mix of pre- and post-reset values. Call this from a
48    /// quiescent point (no concurrent I/O) for a consistent snapshot.
49    pub fn reset(&self) {
50        self.requests.store(0, Ordering::Relaxed);
51        self.responses.store(0, Ordering::Relaxed);
52        self.errors.store(0, Ordering::Relaxed);
53    }
54}
55
56impl WireTap for TrafficStats {
57    fn on_write(&self, _bytes: &[u8], _ts: u64) {
58        self.requests.fetch_add(1, Ordering::Relaxed);
59    }
60    fn on_read(&self, _bytes: &[u8], _ts: u64) {
61        self.responses.fetch_add(1, Ordering::Relaxed);
62    }
63    fn on_error(&self, _bytes: &[u8], _error: &str, _ts: u64) {
64        self.errors.fetch_add(1, Ordering::Relaxed);
65    }
66}
67
68// ── Packet data ────────────────────────────────────────────────────────────
69
70/// Raw captured traffic — bytes that moved across the wire at a specific instant.
71#[derive(Debug, Clone)]
72#[non_exhaustive]
73pub enum PacketData {
74    /// Bytes successfully written to the transport.
75    RawTx(Vec<u8>),
76    /// Bytes successfully read from the transport.
77    RawRx(Vec<u8>),
78    /// Bytes received before a read error occurred, with the error message.
79    RawError(Vec<u8>, String),
80}
81
82/// One captured I/O event with timestamp.
83#[derive(Debug, Clone)]
84pub struct PacketRecord {
85    pub timestamp_us: u64,
86    pub data: PacketData,
87}
88
89impl fmt::Display for PacketRecord {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        let ts = format_timestamp(self.timestamp_us);
92        match &self.data {
93            PacketData::RawTx(bytes) => {
94                write!(f, "[TX] {ts}  {:>3}B  ", bytes.len())?;
95                write_hex(f, bytes)
96            }
97            PacketData::RawRx(bytes) => {
98                write!(f, "[RX] {ts}  {:>3}B  ", bytes.len())?;
99                write_hex(f, bytes)
100            }
101            PacketData::RawError(bytes, err) => {
102                write!(f, "[ERR] {ts}  {:>3}B  ", bytes.len())?;
103                write_hex(f, bytes)?;
104                write!(f, "  — {err}")
105            }
106        }
107    }
108}
109
110/// Format a microsecond Unix-epoch timestamp as ISO 8601.
111///
112/// Returns `YYYY-MM-DDTHH:MM:SS.uuuuuu` — 26 characters.
113/// Allocates a new `String` on each call.
114/// Used by `PacketRecord::Display` and `FileRecorder`.
115///
116/// # For RecordSink implementors
117///
118/// Import this function to format timestamps consistently with the built-in
119/// display and file backends:
120///
121/// ```ignore
122/// use oms_modbus::format_timestamp;
123/// impl RecordSink for MySink {
124///     fn on_packet(&mut self, record: PacketRecord) {
125///         let ts = format_timestamp(record.timestamp_us);
126///         println!("{ts}  {:?}", record.data);
127///     }
128/// }
129/// ```
130pub fn format_timestamp(timestamp_us: u64) -> String {
131    let secs = (timestamp_us / 1_000_000) as i64;
132    let us = timestamp_us % 1_000_000;
133
134    let days = secs / 86_400;
135    let time_of_day = secs % 86_400;
136
137    let hour = time_of_day / 3600;
138    let min = (time_of_day % 3600) / 60;
139    let sec = time_of_day % 60;
140
141    let (y, m, d) = days_to_ymd(days);
142
143    format!("{y:04}-{m:02}-{d:02}T{hour:02}:{min:02}:{sec:02}.{us:06}")
144}
145
146/// Format bytes as hex: `[01 03 00 00 00 02]`
147fn write_hex(f: &mut fmt::Formatter<'_>, bytes: &[u8]) -> fmt::Result {
148    write!(f, "[")?;
149    for (i, b) in bytes.iter().enumerate() {
150        if i > 0 {
151            write!(f, " ")?;
152        }
153        write!(f, "{b:02X}")?;
154    }
155    write!(f, "]")
156}
157
158/// Convert days since 1970-01-01 to (year, month, day).
159///
160/// Used internally by [`format_timestamp`]. Exposed as `pub(crate)` for
161/// [`FileRecorder`](crate::monitor::FileRecorder) which uses its own
162/// record formatting but shares this calendar conversion.
163///
164/// Based on Howard Hinnant's civil_from_days algorithm.
165pub(crate) fn days_to_ymd(mut z: i64) -> (i64, u32, u32) {
166    z += 719468;
167    let era = if z >= 0 { z } else { z - 146096 } / 146097;
168    let doe = (z - era * 146097) as u32;
169    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
170    let y = (yoe as i64) + era * 400;
171    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
172    let mp = (5 * doy + 2) / 153;
173    let d = doy - (153 * mp + 2) / 5 + 1;
174    let m = if mp < 10 { mp + 3 } else { mp - 9 };
175    let y = if m <= 2 { y + 1 } else { y };
176    (y, m, d)
177}
178
179// ── Unbounded in-memory capture ────────────────────────────────────────────
180
181/// Records all traffic in memory via [`WireTap`].
182///
183/// Use [`drain`](PacketCapture::drain) to retrieve and clear the buffer.
184/// For bounded capture with automatic eviction, see
185/// [`RingBufferCapture`](crate::monitor::RingBufferCapture).
186///
187/// Prefer [`BusCapture`](crate::BusCapture) for new code — it includes built-in
188/// statistics counters.
189pub struct PacketCapture {
190    records: Mutex<Vec<PacketRecord>>,
191}
192
193impl Default for PacketCapture {
194    fn default() -> Self {
195        Self {
196            records: Mutex::new(Vec::with_capacity(256)),
197        }
198    }
199}
200
201impl PacketCapture {
202    /// Create an empty packet capture buffer.
203    pub fn new() -> Self {
204        Self::default()
205    }
206
207    /// Retrieve and clear all captured records.
208    pub fn drain(&self) -> Vec<PacketRecord> {
209        std::mem::take(&mut *self.records.lock().unwrap_or_else(|e| e.into_inner()))
210    }
211    /// Number of recorded packets.
212    pub fn len(&self) -> usize {
213        self.records.lock().unwrap_or_else(|e| e.into_inner()).len()
214    }
215    /// Returns `true` if no packets have been recorded.
216    pub fn is_empty(&self) -> bool {
217        self.records
218            .lock()
219            .unwrap_or_else(|e| e.into_inner())
220            .is_empty()
221    }
222
223    fn record(&self, ts: u64, data: PacketData) {
224        self.records
225            .lock()
226            .unwrap_or_else(|e| e.into_inner())
227            .push(PacketRecord {
228                timestamp_us: ts,
229                data,
230            });
231    }
232}
233
234impl WireTap for PacketCapture {
235    fn on_write(&self, bytes: &[u8], ts: u64) {
236        self.record(ts, PacketData::RawTx(bytes.to_vec()));
237    }
238    fn on_read(&self, bytes: &[u8], ts: u64) {
239        self.record(ts, PacketData::RawRx(bytes.to_vec()));
240    }
241    fn on_error(&self, bytes: &[u8], error: &str, ts: u64) {
242        self.record(ts, PacketData::RawError(bytes.to_vec(), error.to_string()));
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    // ── days_to_ymd ────────────────────────────────────────────────────
251
252    #[test]
253    fn epoch_zero() {
254        let (y, m, d) = days_to_ymd(0);
255        assert_eq!((y, m, d), (1970, 1, 1));
256    }
257
258    #[test]
259    fn day_one() {
260        let (y, m, d) = days_to_ymd(1);
261        assert_eq!((y, m, d), (1970, 1, 2));
262    }
263
264    #[test]
265    fn day_364_before_year_roll() {
266        let (y, m, d) = days_to_ymd(364);
267        assert_eq!((y, m, d), (1970, 12, 31));
268    }
269
270    #[test]
271    fn day_365_year_roll() {
272        let (y, m, d) = days_to_ymd(365);
273        assert_eq!((y, m, d), (1971, 1, 1));
274    }
275
276    #[test]
277    fn year_2000_jan_1() {
278        // 1970-01-01 → 2000-01-01: 30 years (1970–1999)
279        // Leap years: 1972,76,80,84,88,92,96 = 7 of 30 years
280        // Days: 23×365 + 7×366 = 8395 + 2562 = 10957
281        let (y, m, d) = days_to_ymd(10957);
282        assert_eq!((y, m, d), (2000, 1, 1));
283    }
284
285    #[test]
286    fn feb_29_leap_year() {
287        // 1972-02-29: days from epoch =
288        //   1970(365) + 1971(365) + Jan-1972(31) + 28 days of Feb = 789
289        let (y, m, d) = days_to_ymd(789);
290        assert_eq!((y, m, d), (1972, 2, 29));
291    }
292
293    #[test]
294    fn year_2026_august() {
295        // 1970-2025: 56 years. 14 leap years (1972..2024).
296        // Full years: 42×365 + 14×366 = 15330 + 5124 = 20454
297        // 2026 before Aug 10: Jan(31)+Feb(28)+Mar(31)+Apr(30)+May(31)+Jun(30)+Jul(31)+9 = 221
298        // Total: 20454 + 221 = 20675
299        let (y, m, d) = days_to_ymd(20675);
300        assert_eq!((y, m, d), (2026, 8, 10));
301    }
302
303    #[test]
304    fn far_future_year_2100() {
305        // 2100 is NOT a leap year (divisible by 100 but not 400)
306        // 1970-2099: 130 years, 32 leap years
307        // Days: 98×365 + 32×366 = 35770 + 11712 = 47482
308        let (y, m, d) = days_to_ymd(47482);
309        assert_eq!((y, m, d), (2100, 1, 1));
310    }
311
312    #[test]
313    fn negative_days_before_epoch() {
314        let (y, m, d) = days_to_ymd(-1);
315        assert_eq!((y, m, d), (1969, 12, 31));
316    }
317}