Skip to main content

oms_modbus/
bus_timing.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//!
3//! Bus frame spacing — enforces minimum silence between Modbus frames.
4//
5// Critical for RS-485 multi-drop buses where sending too fast can crash
6// low-performance slave devices. RTU/ASCII spec requires ≥3.5 character
7// times of silence between frames. Users may also configure custom values
8// (50ms, 100ms, etc.) for particularly slow devices.
9
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::Duration;
12
13/// Tracks bus activity and enforces minimum frame spacing.
14///
15/// **Thread-safe**: uses `AtomicU64` for lock-free timestamp updates.
16/// Pass to [`crate::ClientOptions::with_bus_timing`] to activate.
17///
18/// # Frame spacing
19///
20/// 1. Every bus I/O event (read/write) calls [`touch`](BusTiming::touch)
21///    to record the last activity timestamp.
22/// 2. Before sending, [`wait_if_needed`](BusTiming::wait_if_needed) checks
23///    if enough time has elapsed. If not, it sleeps for the remaining gap.
24///
25/// # Examples
26///
27/// ```rust
28/// use std::time::Duration;
29/// use oms_modbus::BusTiming;
30///
31/// // RTU 3.5T @ 9600 baud ≈ 4.01ms
32/// let timing = BusTiming::rtu_35t(9600);
33///
34/// // Custom 100ms — for slow devices
35/// let timing = BusTiming::custom(Duration::from_millis(100));
36/// ```
37#[derive(Debug)]
38pub struct BusTiming {
39    /// Microsecond timestamp of the last bus event.
40    last_event_us: AtomicU64,
41    /// Minimum spacing between frames, in microseconds.
42    min_spacing_us: u64,
43}
44
45impl BusTiming {
46    /// Create with a custom minimum spacing.
47    ///
48    /// ```rust
49    /// use std::time::Duration;
50    /// use oms_modbus::BusTiming;
51    /// let timing = BusTiming::custom(Duration::from_millis(50));
52    /// ```
53    pub fn custom(min_spacing: Duration) -> Self {
54        let min_spacing_us = min_spacing.as_micros() as u64;
55        Self {
56            last_event_us: AtomicU64::new(0),
57            min_spacing_us,
58        }
59    }
60
61    /// RTU standard 3.5 character times at the given baud rate.
62    ///
63    /// Per *MODBUS over Serial Line Specification V1.02* §1.4:
64    /// - **≤ 19200 bps**: `3.5 × 11 bits / baud_rate` (the full formula).
65    /// - **> 19200 bps**: fixed minimum of **1.75 ms (1750 µs)**.
66    ///
67    /// Without the cap, 115200 baud would give only ~334 µs — too short for
68    /// real RS-485 transceivers and low-end MCU interrupt latency. Use
69    /// [`rtu_35t_raw`](Self::rtu_35t_raw) if you need the uncapped formula.
70    ///
71    /// | Baud   | 3.5T spacing |
72    /// |--------|-------------|
73    /// | 9600   | ~4.01 ms    |
74    /// | 19200  | ~2.01 ms    |
75    /// | 38400  | 1.75 ms     |
76    /// | 115200 | 1.75 ms     |
77    /// | 921600 | 1.75 ms     |
78    pub fn rtu_35t(baud_rate: u32) -> Self {
79        let min_spacing_us = if baud_rate > 19200 {
80            1750 // spec-mandated fixed interval for high baud rates
81        } else {
82            (3_500_000u64 * 11) / baud_rate.max(1) as u64
83        };
84        Self {
85            last_event_us: AtomicU64::new(0),
86            min_spacing_us,
87        }
88    }
89
90    /// Raw 3.5 character-time formula at any baud rate — **no 1.75 ms cap**.
91    ///
92    /// Unlike [`rtu_35t`](Self::rtu_35t), this always computes the raw formula,
93    /// even at high baud rates where the Modbus spec recommends a fixed
94    /// minimum of 1.75 ms. Baud rate 0 is silently clamped to 1.
95    /// Use this only if you understand the implications for your specific hardware.
96    ///
97    /// ```rust
98    /// use oms_modbus::BusTiming;
99    /// // 115200 baud → ~334 µs (raw formula, no cap)
100    /// let timing = BusTiming::rtu_35t_raw(115200);
101    /// assert_eq!(timing.min_spacing().as_micros(), 334);
102    /// ```
103    pub fn rtu_35t_raw(baud_rate: u32) -> Self {
104        let min_spacing_us = (3_500_000u64 * 11) / baud_rate.max(1) as u64;
105        Self {
106            last_event_us: AtomicU64::new(0),
107            min_spacing_us,
108        }
109    }
110
111    /// ASCII standard 3.5 character times — same timing as RTU.
112    pub fn ascii_35t(baud_rate: u32) -> Self {
113        Self::rtu_35t(baud_rate)
114    }
115
116    /// Record bus activity at the current instant.
117    ///
118    /// Call on every I/O read/write to keep the timestamp fresh.
119    /// Uses a monotonic, syscall-free clock for microsecond timestamps.
120    #[inline]
121    pub fn touch(&self) {
122        // Use max(1) so the stored value is never 0, which is reserved
123        // for "no previous activity" in wait_if_needed. Without this,
124        // touch + immediate wait within the same microsecond would hit
125        // the first-call fast path and skip the frame spacing check.
126        let now = now_micros().max(1);
127        self.last_event_us.store(now, Ordering::Relaxed);
128    }
129
130    /// Wait until the minimum spacing has elapsed since the last [`touch`](Self::touch).
131    ///
132    /// Returns immediately (no sleep) if:
133    /// - Enough time has already passed
134    /// - No previous activity was recorded (first call)
135    pub async fn wait_if_needed(&self) {
136        let last = self.last_event_us.load(Ordering::Relaxed);
137        if last == 0 {
138            return; // first call — no history
139        }
140        let now = now_micros();
141        let elapsed = now.saturating_sub(last);
142        if elapsed < self.min_spacing_us {
143            let remaining_us = self.min_spacing_us - elapsed;
144            tokio::time::sleep(Duration::from_micros(remaining_us)).await;
145        }
146    }
147
148    /// Current minimum spacing.
149    pub fn min_spacing(&self) -> Duration {
150        Duration::from_micros(self.min_spacing_us)
151    }
152}
153
154/// Monotonic timestamp in microseconds based on `std::time::Instant`.
155///
156/// Uses `std::time::Instant` (not `tokio::time::Instant`) because bus timing
157/// measures real hardware intervals — it should not be affected by
158/// `tokio::time::pause()` in tests. Also works in non-Tokio contexts
159/// (sync tests, pure-thread environments).
160fn now_micros() -> u64 {
161    static BASE: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
162    let base = BASE.get_or_init(std::time::Instant::now);
163    base.elapsed().as_micros() as u64
164}