Skip to main content

autocore_std/fb/
heartbeat.rs

1use std::time::{Duration, Instant};
2
3/// Default timeout period for heartbeat monitoring.
4const DEFAULT_PERIOD: Duration = Duration::from_secs(7);
5
6/// Heartbeat Monitor (FB_Heartbeat)
7///
8/// Monitors a counter that is incremented by a remote source (typically a
9/// server or PLC). If the counter stops changing for longer than the
10/// configured timeout, the connection is considered lost.
11///
12/// # Behavior
13///
14/// - `ok` is `false` until the first change in `beat_count` is observed
15/// - Each time `beat_count` differs from the previous call, the internal
16///   watchdog timer resets and `ok` becomes `true`
17/// - If `beat_count` remains unchanged for longer than `period`, `ok`
18///   becomes `false`
19///
20/// # Example
21///
22/// ```
23/// use autocore_std::fb::Heartbeat;
24/// use std::time::Duration;
25///
26/// let mut hb = Heartbeat::new();
27/// let timeout = Duration::from_millis(100);
28///
29/// // First call — no previous value, ok is false
30/// hb.call(0, timeout);
31/// assert_eq!(hb.ok, false);
32///
33/// // Beat count changes — connection confirmed
34/// hb.call(1, timeout);
35/// assert_eq!(hb.ok, true);
36///
37/// // Beat count keeps changing — still ok
38/// hb.call(2, timeout);
39/// assert_eq!(hb.ok, true);
40///
41/// // Beat count stalls...
42/// hb.call(2, timeout);
43/// assert_eq!(hb.ok, true); // Within timeout
44///
45/// std::thread::sleep(Duration::from_millis(110));
46/// hb.call(2, timeout);
47/// assert_eq!(hb.ok, false); // Timed out — connection lost
48///
49/// // Beat count resumes — connection restored
50/// hb.call(3, timeout);
51/// assert_eq!(hb.ok, true);
52/// ```
53///
54/// # Timing Diagram
55///
56/// ```text
57/// beat_count: 0  1  2  3  3  3  3  3  3  4  5
58///                                  ↑ timed out
59///         ok: F  T  T  T  T  T  T  F  F  T  T
60/// ```
61///
62/// # Use Cases
63///
64/// - Monitoring a PLC ↔ server communication link
65/// - Detecting a stalled remote process
66/// - Watchdog supervision of a periodic counter
67#[derive(Debug, Clone)]
68pub struct Heartbeat {
69    /// Output: `true` when the heartbeat is alive (counter changing within
70    /// the timeout period). `false` on first scan or after a timeout.
71    pub ok: bool,
72
73    last_beat_count: i64,
74    last_change: Option<Instant>,
75    first_scan: bool,
76}
77
78impl Heartbeat {
79    /// Creates a new heartbeat monitor.
80    ///
81    /// The monitor starts on its first scan — `ok` will be `false` until
82    /// the beat count changes for the first time.
83    ///
84    /// # Example
85    ///
86    /// ```
87    /// use autocore_std::fb::Heartbeat;
88    ///
89    /// let hb = Heartbeat::new();
90    /// assert_eq!(hb.ok, false);
91    /// ```
92    pub fn new() -> Self {
93        Self {
94            ok: false,
95            last_beat_count: 0,
96            last_change: None,
97            first_scan: true,
98        }
99    }
100
101    /// Executes one scan cycle of the heartbeat monitor.
102    ///
103    /// Call this once per control cycle with the current beat count from the
104    /// remote source.
105    ///
106    /// # Arguments
107    ///
108    /// * `beat_count` - The latest heartbeat counter value from the remote
109    ///   source. Any `i64` value; only *changes* matter.
110    /// * `period` - Maximum allowed time between counter changes. If the
111    ///   counter remains static for longer than this, `ok` becomes `false`.
112    ///   A typical default is 7 seconds.
113    ///
114    /// # Example
115    ///
116    /// ```
117    /// use autocore_std::fb::Heartbeat;
118    /// use std::time::Duration;
119    ///
120    /// let mut hb = Heartbeat::new();
121    ///
122    /// // Call cyclically in your control loop
123    /// # let remote_counter: i64 = 0;
124    /// hb.call(remote_counter, Duration::from_secs(7));
125    /// if !hb.ok {
126    ///     // Handle connection loss
127    /// }
128    /// ```
129    pub fn call(&mut self, beat_count: i64, period: Duration) {
130        if self.first_scan {
131            self.ok = false;
132            self.last_beat_count = beat_count;
133            self.first_scan = false;
134            return;
135        }
136
137        if beat_count != self.last_beat_count {
138            self.last_beat_count = beat_count;
139            self.last_change = Some(Instant::now());
140            self.ok = true;
141        } else if let Some(last) = self.last_change {
142            if last.elapsed() >= period {
143                self.ok = false;
144            }
145        }
146        // If last_change is None and beat hasn't changed, ok stays false
147        // (no change has ever been observed)
148    }
149
150    /// Creates a new heartbeat monitor with the default 7-second period.
151    ///
152    /// This is a convenience alias; the period is still passed per-call
153    /// via [`call()`](Self::call), but this documents the standard default.
154    ///
155    /// # Example
156    ///
157    /// ```
158    /// use autocore_std::fb::Heartbeat;
159    /// use std::time::Duration;
160    ///
161    /// let mut hb = Heartbeat::with_defaults();
162    /// // Equivalent to Heartbeat::new(), period is passed to call()
163    /// hb.call(0, Duration::from_secs(7));
164    /// ```
165    pub fn with_defaults() -> Self {
166        Self::new()
167    }
168
169    /// The default timeout period (7 seconds).
170    ///
171    /// Provided as a constant for convenience so callers don't have to
172    /// hard-code the value.
173    ///
174    /// # Example
175    ///
176    /// ```
177    /// use autocore_std::fb::Heartbeat;
178    ///
179    /// let mut hb = Heartbeat::new();
180    /// hb.call(1, Heartbeat::DEFAULT_PERIOD);
181    /// ```
182    pub const DEFAULT_PERIOD: Duration = DEFAULT_PERIOD;
183}
184
185impl Default for Heartbeat {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_first_scan_not_ok() {
197        let mut hb = Heartbeat::new();
198        hb.call(0, DEFAULT_PERIOD);
199        assert_eq!(hb.ok, false);
200    }
201
202    #[test]
203    fn test_change_sets_ok() {
204        let mut hb = Heartbeat::new();
205        hb.call(0, DEFAULT_PERIOD);
206        assert_eq!(hb.ok, false);
207
208        hb.call(1, DEFAULT_PERIOD);
209        assert_eq!(hb.ok, true);
210    }
211
212    #[test]
213    fn test_unchanged_within_period_stays_ok() {
214        let mut hb = Heartbeat::new();
215        let period = Duration::from_millis(100);
216
217        hb.call(0, period);
218        hb.call(1, period); // Change → ok
219        assert!(hb.ok);
220
221        // Same value, but within timeout
222        std::thread::sleep(Duration::from_millis(30));
223        hb.call(1, period);
224        assert!(hb.ok);
225    }
226
227    #[test]
228    fn test_timeout_clears_ok() {
229        let mut hb = Heartbeat::new();
230        let period = Duration::from_millis(50);
231
232        hb.call(0, period);
233        hb.call(1, period);
234        assert!(hb.ok);
235
236        // Stall beyond the timeout
237        std::thread::sleep(Duration::from_millis(60));
238        hb.call(1, period);
239        assert_eq!(hb.ok, false);
240    }
241
242    #[test]
243    fn test_recovery_after_timeout() {
244        let mut hb = Heartbeat::new();
245        let period = Duration::from_millis(50);
246
247        hb.call(0, period);
248        hb.call(1, period);
249        assert!(hb.ok);
250
251        // Timeout
252        std::thread::sleep(Duration::from_millis(60));
253        hb.call(1, period);
254        assert_eq!(hb.ok, false);
255
256        // Counter resumes
257        hb.call(2, period);
258        assert!(hb.ok);
259    }
260
261    #[test]
262    fn test_no_change_ever_stays_not_ok() {
263        let mut hb = Heartbeat::new();
264        let period = Duration::from_millis(50);
265
266        // Same value every scan
267        hb.call(42, period);
268        assert_eq!(hb.ok, false);
269
270        hb.call(42, period);
271        assert_eq!(hb.ok, false);
272
273        std::thread::sleep(Duration::from_millis(60));
274        hb.call(42, period);
275        assert_eq!(hb.ok, false);
276    }
277
278    #[test]
279    fn test_negative_values() {
280        let mut hb = Heartbeat::new();
281        let period = Duration::from_millis(100);
282
283        hb.call(-5, period);
284        assert_eq!(hb.ok, false);
285
286        hb.call(-4, period);
287        assert!(hb.ok);
288
289        hb.call(-3, period);
290        assert!(hb.ok);
291    }
292
293    #[test]
294    fn test_large_jump() {
295        let mut hb = Heartbeat::new();
296        let period = Duration::from_millis(100);
297
298        hb.call(0, period);
299        hb.call(1_000_000, period);
300        assert!(hb.ok);
301    }
302
303    #[test]
304    fn test_default_trait() {
305        let hb = Heartbeat::default();
306        assert_eq!(hb.ok, false);
307    }
308
309    #[test]
310    fn test_default_period_constant() {
311        assert_eq!(Heartbeat::DEFAULT_PERIOD, Duration::from_secs(7));
312    }
313}