use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use canopen_rs::nmt::{decode_heartbeat, HEARTBEAT_COB_BASE};
use canopen_rs::{NmtState, NodeId};
#[derive(Debug, Clone, Copy)]
pub struct NodeHealth {
pub state: NmtState,
pub last_seen: Instant,
}
#[derive(Debug)]
pub struct HeartbeatMonitor {
nodes: BTreeMap<u8, NodeHealth>,
timeout: Duration,
}
impl HeartbeatMonitor {
pub fn new(timeout: Duration) -> Self {
Self {
nodes: BTreeMap::new(),
timeout,
}
}
pub fn on_frame(
&mut self,
cob_id: u16,
data: &[u8],
now: Instant,
) -> Option<(NodeId, NmtState)> {
let raw = cob_id.checked_sub(HEARTBEAT_COB_BASE)?;
let node = NodeId::new(u8::try_from(raw).ok()?).ok()?;
let state = decode_heartbeat(&[*data.first()?]).ok()?;
self.nodes.insert(
node.raw(),
NodeHealth {
state,
last_seen: now,
},
);
Some((node, state))
}
pub fn state(&self, node: NodeId) -> Option<NmtState> {
self.nodes.get(&node.raw()).map(|h| h.state)
}
pub fn health(&self, node: NodeId) -> Option<NodeHealth> {
self.nodes.get(&node.raw()).copied()
}
pub fn is_alive(&self, node: NodeId, now: Instant) -> bool {
self.nodes
.get(&node.raw())
.is_some_and(|h| now.saturating_duration_since(h.last_seen) <= self.timeout)
}
pub fn timed_out(&self, now: Instant) -> impl Iterator<Item = NodeId> + '_ {
self.nodes.iter().filter_map(move |(&id, h)| {
(now.saturating_duration_since(h.last_seen) > self.timeout)
.then(|| NodeId::new(id).ok())
.flatten()
})
}
pub fn nodes(&self) -> impl Iterator<Item = (NodeId, NodeHealth)> + '_ {
self.nodes
.iter()
.filter_map(|(&id, &h)| NodeId::new(id).ok().map(|n| (n, h)))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn node(id: u8) -> NodeId {
NodeId::new(id).unwrap()
}
#[test]
fn records_heartbeat_state() {
let mut m = HeartbeatMonitor::new(Duration::from_secs(1));
let t0 = Instant::now();
assert_eq!(
m.on_frame(0x705, &[0x05], t0),
Some((node(5), NmtState::Operational))
);
assert_eq!(m.state(node(5)), Some(NmtState::Operational));
}
#[test]
fn decodes_bootup_frame() {
let mut m = HeartbeatMonitor::new(Duration::from_secs(1));
assert_eq!(
m.on_frame(0x70A, &[0x00], Instant::now()),
Some((node(10), NmtState::Initialising))
);
}
#[test]
fn ignores_non_heartbeat_frames() {
let mut m = HeartbeatMonitor::new(Duration::from_secs(1));
let now = Instant::now();
assert_eq!(m.on_frame(0x585, &[0x43, 0, 0, 0], now), None); assert_eq!(m.on_frame(0x000, &[0x01, 0x05], now), None); assert_eq!(m.on_frame(0x700, &[0x00], now), None); }
#[test]
fn liveness_and_timeout() {
let mut m = HeartbeatMonitor::new(Duration::from_secs(1));
let t0 = Instant::now();
m.on_frame(0x705, &[0x05], t0);
assert!(m.is_alive(node(5), t0));
assert!(m.is_alive(node(5), t0 + Duration::from_millis(900)));
assert!(!m.is_alive(node(5), t0 + Duration::from_millis(1500)));
let timed_out: Vec<_> = m.timed_out(t0 + Duration::from_secs(2)).collect();
assert_eq!(timed_out, vec![node(5)]);
assert!(m.timed_out(t0).next().is_none());
}
#[test]
fn unknown_node_is_not_alive() {
let m = HeartbeatMonitor::new(Duration::from_secs(1));
assert!(!m.is_alive(node(9), Instant::now()));
assert_eq!(m.state(node(9)), None);
}
}