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}