autocore-std 3.3.25

Standard library for AutoCore control programs - shared memory, IPC, and logging utilities
Documentation
use std::time::{Duration, Instant};

/// Default timeout period for heartbeat monitoring.
const DEFAULT_PERIOD: Duration = Duration::from_secs(7);

/// Heartbeat Monitor (FB_Heartbeat)
///
/// Monitors a counter that is incremented by a remote source (typically a
/// server or PLC). If the counter stops changing for longer than the
/// configured timeout, the connection is considered lost.
///
/// # Behavior
///
/// - `ok` is `false` until the first change in `beat_count` is observed
/// - Each time `beat_count` differs from the previous call, the internal
///   watchdog timer resets and `ok` becomes `true`
/// - If `beat_count` remains unchanged for longer than `period`, `ok`
///   becomes `false`
///
/// # Example
///
/// ```
/// use autocore_std::fb::Heartbeat;
/// use std::time::Duration;
///
/// let mut hb = Heartbeat::new();
/// let timeout = Duration::from_millis(100);
///
/// // First call — no previous value, ok is false
/// hb.call(0, timeout);
/// assert_eq!(hb.ok, false);
///
/// // Beat count changes — connection confirmed
/// hb.call(1, timeout);
/// assert_eq!(hb.ok, true);
///
/// // Beat count keeps changing — still ok
/// hb.call(2, timeout);
/// assert_eq!(hb.ok, true);
///
/// // Beat count stalls...
/// hb.call(2, timeout);
/// assert_eq!(hb.ok, true); // Within timeout
///
/// std::thread::sleep(Duration::from_millis(110));
/// hb.call(2, timeout);
/// assert_eq!(hb.ok, false); // Timed out — connection lost
///
/// // Beat count resumes — connection restored
/// hb.call(3, timeout);
/// assert_eq!(hb.ok, true);
/// ```
///
/// # Timing Diagram
///
/// ```text
/// beat_count: 0  1  2  3  3  3  3  3  3  4  5
///                                  ↑ timed out
///         ok: F  T  T  T  T  T  T  F  F  T  T
/// ```
///
/// # Use Cases
///
/// - Monitoring a PLC ↔ server communication link
/// - Detecting a stalled remote process
/// - Watchdog supervision of a periodic counter
#[derive(Debug, Clone)]
pub struct Heartbeat {
    /// Output: `true` when the heartbeat is alive (counter changing within
    /// the timeout period). `false` on first scan or after a timeout.
    pub ok: bool,

    last_beat_count: i64,
    last_change: Option<Instant>,
    first_scan: bool,
}

impl Heartbeat {
    /// Creates a new heartbeat monitor.
    ///
    /// The monitor starts on its first scan — `ok` will be `false` until
    /// the beat count changes for the first time.
    ///
    /// # Example
    ///
    /// ```
    /// use autocore_std::fb::Heartbeat;
    ///
    /// let hb = Heartbeat::new();
    /// assert_eq!(hb.ok, false);
    /// ```
    pub fn new() -> Self {
        Self {
            ok: false,
            last_beat_count: 0,
            last_change: None,
            first_scan: true,
        }
    }

    /// Executes one scan cycle of the heartbeat monitor.
    ///
    /// Call this once per control cycle with the current beat count from the
    /// remote source.
    ///
    /// # Arguments
    ///
    /// * `beat_count` - The latest heartbeat counter value from the remote
    ///   source. Any `i64` value; only *changes* matter.
    /// * `period` - Maximum allowed time between counter changes. If the
    ///   counter remains static for longer than this, `ok` becomes `false`.
    ///   A typical default is 7 seconds.
    ///
    /// # Example
    ///
    /// ```
    /// use autocore_std::fb::Heartbeat;
    /// use std::time::Duration;
    ///
    /// let mut hb = Heartbeat::new();
    ///
    /// // Call cyclically in your control loop
    /// # let remote_counter: i64 = 0;
    /// hb.call(remote_counter, Duration::from_secs(7));
    /// if !hb.ok {
    ///     // Handle connection loss
    /// }
    /// ```
    pub fn call(&mut self, beat_count: i64, period: Duration) {
        if self.first_scan {
            self.ok = false;
            self.last_beat_count = beat_count;
            self.first_scan = false;
            return;
        }

        if beat_count != self.last_beat_count {
            self.last_beat_count = beat_count;
            self.last_change = Some(Instant::now());
            self.ok = true;
        } else if let Some(last) = self.last_change {
            if last.elapsed() >= period {
                self.ok = false;
            }
        }
        // If last_change is None and beat hasn't changed, ok stays false
        // (no change has ever been observed)
    }

    /// Creates a new heartbeat monitor with the default 7-second period.
    ///
    /// This is a convenience alias; the period is still passed per-call
    /// via [`call()`](Self::call), but this documents the standard default.
    ///
    /// # Example
    ///
    /// ```
    /// use autocore_std::fb::Heartbeat;
    /// use std::time::Duration;
    ///
    /// let mut hb = Heartbeat::with_defaults();
    /// // Equivalent to Heartbeat::new(), period is passed to call()
    /// hb.call(0, Duration::from_secs(7));
    /// ```
    pub fn with_defaults() -> Self {
        Self::new()
    }

    /// The default timeout period (7 seconds).
    ///
    /// Provided as a constant for convenience so callers don't have to
    /// hard-code the value.
    ///
    /// # Example
    ///
    /// ```
    /// use autocore_std::fb::Heartbeat;
    ///
    /// let mut hb = Heartbeat::new();
    /// hb.call(1, Heartbeat::DEFAULT_PERIOD);
    /// ```
    pub const DEFAULT_PERIOD: Duration = DEFAULT_PERIOD;
}

impl Default for Heartbeat {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_first_scan_not_ok() {
        let mut hb = Heartbeat::new();
        hb.call(0, DEFAULT_PERIOD);
        assert_eq!(hb.ok, false);
    }

    #[test]
    fn test_change_sets_ok() {
        let mut hb = Heartbeat::new();
        hb.call(0, DEFAULT_PERIOD);
        assert_eq!(hb.ok, false);

        hb.call(1, DEFAULT_PERIOD);
        assert_eq!(hb.ok, true);
    }

    #[test]
    fn test_unchanged_within_period_stays_ok() {
        let mut hb = Heartbeat::new();
        let period = Duration::from_millis(100);

        hb.call(0, period);
        hb.call(1, period); // Change → ok
        assert!(hb.ok);

        // Same value, but within timeout
        std::thread::sleep(Duration::from_millis(30));
        hb.call(1, period);
        assert!(hb.ok);
    }

    #[test]
    fn test_timeout_clears_ok() {
        let mut hb = Heartbeat::new();
        let period = Duration::from_millis(50);

        hb.call(0, period);
        hb.call(1, period);
        assert!(hb.ok);

        // Stall beyond the timeout
        std::thread::sleep(Duration::from_millis(60));
        hb.call(1, period);
        assert_eq!(hb.ok, false);
    }

    #[test]
    fn test_recovery_after_timeout() {
        let mut hb = Heartbeat::new();
        let period = Duration::from_millis(50);

        hb.call(0, period);
        hb.call(1, period);
        assert!(hb.ok);

        // Timeout
        std::thread::sleep(Duration::from_millis(60));
        hb.call(1, period);
        assert_eq!(hb.ok, false);

        // Counter resumes
        hb.call(2, period);
        assert!(hb.ok);
    }

    #[test]
    fn test_no_change_ever_stays_not_ok() {
        let mut hb = Heartbeat::new();
        let period = Duration::from_millis(50);

        // Same value every scan
        hb.call(42, period);
        assert_eq!(hb.ok, false);

        hb.call(42, period);
        assert_eq!(hb.ok, false);

        std::thread::sleep(Duration::from_millis(60));
        hb.call(42, period);
        assert_eq!(hb.ok, false);
    }

    #[test]
    fn test_negative_values() {
        let mut hb = Heartbeat::new();
        let period = Duration::from_millis(100);

        hb.call(-5, period);
        assert_eq!(hb.ok, false);

        hb.call(-4, period);
        assert!(hb.ok);

        hb.call(-3, period);
        assert!(hb.ok);
    }

    #[test]
    fn test_large_jump() {
        let mut hb = Heartbeat::new();
        let period = Duration::from_millis(100);

        hb.call(0, period);
        hb.call(1_000_000, period);
        assert!(hb.ok);
    }

    #[test]
    fn test_default_trait() {
        let hb = Heartbeat::default();
        assert_eq!(hb.ok, false);
    }

    #[test]
    fn test_default_period_constant() {
        assert_eq!(Heartbeat::DEFAULT_PERIOD, Duration::from_secs(7));
    }
}