1use std::collections::BTreeMap;
10use std::time::{Duration, Instant};
11
12use canopen_rs::nmt::{decode_heartbeat, HEARTBEAT_COB_BASE};
13use canopen_rs::{NmtState, NodeId};
14
15#[derive(Debug, Clone, Copy)]
17pub struct NodeHealth {
18 pub state: NmtState,
20 pub last_seen: Instant,
22}
23
24#[derive(Debug)]
31pub struct HeartbeatMonitor {
32 nodes: BTreeMap<u8, NodeHealth>,
33 timeout: Duration,
34}
35
36impl HeartbeatMonitor {
37 pub fn new(timeout: Duration) -> Self {
40 Self {
41 nodes: BTreeMap::new(),
42 timeout,
43 }
44 }
45
46 pub fn on_frame(
51 &mut self,
52 cob_id: u16,
53 data: &[u8],
54 now: Instant,
55 ) -> Option<(NodeId, NmtState)> {
56 let raw = cob_id.checked_sub(HEARTBEAT_COB_BASE)?;
58 let node = NodeId::new(u8::try_from(raw).ok()?).ok()?;
59 let state = decode_heartbeat(&[*data.first()?]).ok()?;
60 self.nodes.insert(
61 node.raw(),
62 NodeHealth {
63 state,
64 last_seen: now,
65 },
66 );
67 Some((node, state))
68 }
69
70 pub fn state(&self, node: NodeId) -> Option<NmtState> {
72 self.nodes.get(&node.raw()).map(|h| h.state)
73 }
74
75 pub fn health(&self, node: NodeId) -> Option<NodeHealth> {
77 self.nodes.get(&node.raw()).copied()
78 }
79
80 pub fn is_alive(&self, node: NodeId, now: Instant) -> bool {
82 self.nodes
83 .get(&node.raw())
84 .is_some_and(|h| now.saturating_duration_since(h.last_seen) <= self.timeout)
85 }
86
87 pub fn timed_out(&self, now: Instant) -> impl Iterator<Item = NodeId> + '_ {
89 self.nodes.iter().filter_map(move |(&id, h)| {
90 (now.saturating_duration_since(h.last_seen) > self.timeout)
91 .then(|| NodeId::new(id).ok())
92 .flatten()
93 })
94 }
95
96 pub fn nodes(&self) -> impl Iterator<Item = (NodeId, NodeHealth)> + '_ {
98 self.nodes
99 .iter()
100 .filter_map(|(&id, &h)| NodeId::new(id).ok().map(|n| (n, h)))
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 fn node(id: u8) -> NodeId {
109 NodeId::new(id).unwrap()
110 }
111
112 #[test]
113 fn records_heartbeat_state() {
114 let mut m = HeartbeatMonitor::new(Duration::from_secs(1));
115 let t0 = Instant::now();
116 assert_eq!(
118 m.on_frame(0x705, &[0x05], t0),
119 Some((node(5), NmtState::Operational))
120 );
121 assert_eq!(m.state(node(5)), Some(NmtState::Operational));
122 }
123
124 #[test]
125 fn decodes_bootup_frame() {
126 let mut m = HeartbeatMonitor::new(Duration::from_secs(1));
127 assert_eq!(
129 m.on_frame(0x70A, &[0x00], Instant::now()),
130 Some((node(10), NmtState::Initialising))
131 );
132 }
133
134 #[test]
135 fn ignores_non_heartbeat_frames() {
136 let mut m = HeartbeatMonitor::new(Duration::from_secs(1));
137 let now = Instant::now();
138 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); }
142
143 #[test]
144 fn liveness_and_timeout() {
145 let mut m = HeartbeatMonitor::new(Duration::from_secs(1));
146 let t0 = Instant::now();
147 m.on_frame(0x705, &[0x05], t0);
148
149 assert!(m.is_alive(node(5), t0));
150 assert!(m.is_alive(node(5), t0 + Duration::from_millis(900)));
151 assert!(!m.is_alive(node(5), t0 + Duration::from_millis(1500)));
152
153 let timed_out: Vec<_> = m.timed_out(t0 + Duration::from_secs(2)).collect();
154 assert_eq!(timed_out, vec![node(5)]);
155 assert!(m.timed_out(t0).next().is_none());
156 }
157
158 #[test]
159 fn unknown_node_is_not_alive() {
160 let m = HeartbeatMonitor::new(Duration::from_secs(1));
161 assert!(!m.is_alive(node(9), Instant::now()));
162 assert_eq!(m.state(node(9)), None);
163 }
164}