canopen_rs/nmt.rs
1//! Network Management (NMT) — the node state machine (CiA 301 §7.3).
2//!
3//! NMT governs a node's lifecycle. A node powers up in *Initialisation*,
4//! emits its boot-up message, and autonomously enters *Pre-operational*.
5//! From there an NMT master moves it between *Operational* and *Stopped* with
6//! node-control commands broadcast on COB-ID `0x000`. Each node reports its
7//! current state to the network through the error-control (heartbeat)
8//! producer on `0x700 + node`.
9//!
10//! ```text
11//! ┌──────────────── reset ◄────────────┐
12//! ▼ │
13//! ┌───────────────┐ boot ┌──────────────────┴─┐
14//! │ Initialising ├────────►│ Pre-operational │
15//! └───────────────┘ └──┬──────────────▲──┘
16//! start(0x01)│ │enter-preop(0x80)
17//! ▼ │
18//! ┌─────┴──────────────┴──┐
19//! ┌──────────►│ Operational │
20//! start(0x01)│ └─────────┬─────────────┘
21//! │ stop(0x02)
22//! ┌─────┴─────┐ ▼
23//! │ Stopped │◄──────────────┘
24//! └───────────┘
25//! ```
26//!
27//! Like the SDO codec, the functions here encode and decode raw CAN *data
28//! fields* only; selecting the COB-ID and moving the frame is the transport's
29//! job. [`NmtStateMachine`] is the node-side state logic, with no I/O.
30//!
31//! ```
32//! use canopen_rs::nmt::encode_command;
33//! use canopen_rs::{NmtCommand, NmtState, NmtStateMachine, NodeId};
34//!
35//! // Master side: command node 5 to start (enter operational).
36//! let frame = encode_command(NmtCommand::StartRemoteNode, NodeId::new(5).unwrap());
37//! assert_eq!(frame, [0x01, 0x05]); // [command specifier, target node]
38//!
39//! // Node side: the guarded state machine.
40//! let mut sm = NmtStateMachine::new();
41//! assert_eq!(sm.state(), NmtState::Initialising);
42//! sm.boot(); // -> pre-operational
43//! assert_eq!(sm.apply(NmtCommand::StartRemoteNode), NmtState::Operational);
44//! assert_eq!(sm.apply(NmtCommand::ResetNode), NmtState::Initialising);
45//! ```
46
47use crate::types::NodeId;
48use crate::{Error, Result};
49
50/// COB-ID of the NMT node-control channel (master → nodes): `0x000`.
51///
52/// This is the highest-priority id on the bus; a single frame can address one
53/// node or, with target `0`, every node at once.
54pub const NMT_COMMAND_COB_ID: u16 = 0x000;
55
56/// COB-ID base for the error-control (heartbeat / boot-up) channel:
57/// `0x700 + node`.
58pub const HEARTBEAT_COB_BASE: u16 = 0x700;
59
60/// The COB-ID of a node's heartbeat / boot-up producer channel.
61pub const fn heartbeat_cob_id(node: NodeId) -> u16 {
62 HEARTBEAT_COB_BASE + node.raw() as u16
63}
64
65/// A node's NMT state.
66///
67/// The discriminant is the value carried in a heartbeat frame (CiA 301
68/// §7.2.8.3). [`NmtState::Initialising`] is reported as `0` — the value of a
69/// boot-up message, sent once as the node leaves initialisation.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum NmtState {
72 /// Initialising (boot-up); reported as `0`.
73 Initialising = 0x00,
74 /// Stopped: only NMT and error-control are active.
75 Stopped = 0x04,
76 /// Operational: all communication objects (incl. PDOs) are active.
77 Operational = 0x05,
78 /// Pre-operational: SDOs active, PDOs inactive.
79 PreOperational = 0x7F,
80}
81
82impl NmtState {
83 /// Decode a heartbeat state byte, ignoring the reserved toggle bit (7).
84 ///
85 /// Returns [`Error::UnknownState`] for an unrecognised value.
86 pub const fn from_wire(byte: u8) -> Result<Self> {
87 Ok(match byte & 0x7F {
88 0x00 => NmtState::Initialising,
89 0x04 => NmtState::Stopped,
90 0x05 => NmtState::Operational,
91 0x7F => NmtState::PreOperational,
92 _ => return Err(Error::UnknownState),
93 })
94 }
95}
96
97/// An NMT node-control command from the master (CiA 301 §7.2.8.2).
98///
99/// The discriminant is the command specifier carried in byte 0 of an
100/// `0x000` frame.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum NmtCommand {
103 /// Start remote node → *Operational*.
104 StartRemoteNode = 0x01,
105 /// Stop remote node → *Stopped*.
106 StopRemoteNode = 0x02,
107 /// Enter pre-operational → *Pre-operational*.
108 EnterPreOperational = 0x80,
109 /// Reset node → re-enter *Initialising* (application + communication).
110 ResetNode = 0x81,
111 /// Reset communication → re-enter *Initialising* (communication only).
112 ResetCommunication = 0x82,
113}
114
115impl NmtCommand {
116 /// Decode a command specifier byte.
117 ///
118 /// Returns [`Error::UnexpectedCommand`] for an unrecognised specifier.
119 pub const fn from_byte(byte: u8) -> Result<Self> {
120 Ok(match byte {
121 0x01 => NmtCommand::StartRemoteNode,
122 0x02 => NmtCommand::StopRemoteNode,
123 0x80 => NmtCommand::EnterPreOperational,
124 0x81 => NmtCommand::ResetNode,
125 0x82 => NmtCommand::ResetCommunication,
126 _ => return Err(Error::UnexpectedCommand),
127 })
128 }
129}
130
131/// The 2-byte data field of an NMT node-control frame.
132pub type NmtCommandFrame = [u8; 2];
133
134/// The 1-byte data field of a heartbeat / boot-up frame.
135pub type HeartbeatFrame = [u8; 1];
136
137/// The boot-up message data field (`0x00`), emitted once when a node leaves
138/// initialisation and enters pre-operational.
139pub const BOOTUP_FRAME: HeartbeatFrame = [0x00];
140
141/// Encode an NMT node-control frame for `target`.
142///
143/// Pass [`NodeId::BROADCAST`] to address every node on the bus at once.
144pub fn encode_command(command: NmtCommand, target: NodeId) -> NmtCommandFrame {
145 [command as u8, target.raw()]
146}
147
148/// Decode an NMT node-control frame into `(command, target)`.
149///
150/// A target byte of `0` decodes to [`NodeId::BROADCAST`]; any other value is a
151/// checked device id (`1..=127`), else [`Error::InvalidNodeId`].
152pub fn decode_command(frame: &NmtCommandFrame) -> Result<(NmtCommand, NodeId)> {
153 let command = NmtCommand::from_byte(frame[0])?;
154 let target = if frame[1] == 0 {
155 NodeId::BROADCAST
156 } else {
157 NodeId::new(frame[1])?
158 };
159 Ok((command, target))
160}
161
162/// Encode a heartbeat frame reporting `state`.
163pub fn encode_heartbeat(state: NmtState) -> HeartbeatFrame {
164 [state as u8]
165}
166
167/// Decode a heartbeat frame into the reported [`NmtState`].
168pub fn decode_heartbeat(frame: &HeartbeatFrame) -> Result<NmtState> {
169 NmtState::from_wire(frame[0])
170}
171
172/// The node-side NMT state machine (CiA 301 §7.3.2).
173///
174/// Holds only the current state and applies the guarded transitions of the
175/// diagram above. It performs no I/O: feed it decoded [`NmtCommand`]s and read
176/// back the resulting [`NmtState`] to drive heartbeat production and to gate
177/// which services (e.g. PDOs) are active.
178#[derive(Debug)]
179pub struct NmtStateMachine {
180 state: NmtState,
181}
182
183impl Default for NmtStateMachine {
184 fn default() -> Self {
185 Self::new()
186 }
187}
188
189impl NmtStateMachine {
190 /// A freshly powered node, in [`NmtState::Initialising`].
191 pub const fn new() -> Self {
192 Self {
193 state: NmtState::Initialising,
194 }
195 }
196
197 /// The current state.
198 pub const fn state(&self) -> NmtState {
199 self.state
200 }
201
202 /// Complete initialisation: the node enters pre-operational (CiA 301
203 /// §7.3.2.1). The caller emits [`BOOTUP_FRAME`] alongside this transition.
204 ///
205 /// A no-op unless currently initialising.
206 pub fn boot(&mut self) -> NmtState {
207 if let NmtState::Initialising = self.state {
208 self.state = NmtState::PreOperational;
209 }
210 self.state
211 }
212
213 /// Apply an NMT master command, returning the resulting state.
214 ///
215 /// Transitions are guarded: a reset returns to *Initialising* from any
216 /// state, while start/stop/enter-pre-operational are ignored during
217 /// initialisation (the node is not yet a network participant) and
218 /// otherwise move between the operational states. Repeating a command that
219 /// matches the current state is a harmless no-op.
220 pub fn apply(&mut self, command: NmtCommand) -> NmtState {
221 self.state = match (self.state, command) {
222 // Reset re-enters initialisation from any state.
223 (_, NmtCommand::ResetNode | NmtCommand::ResetCommunication) => NmtState::Initialising,
224 // During initialisation the node ignores operational commands; it
225 // leaves this state only via `boot`.
226 (NmtState::Initialising, _) => NmtState::Initialising,
227 (_, NmtCommand::StartRemoteNode) => NmtState::Operational,
228 (_, NmtCommand::StopRemoteNode) => NmtState::Stopped,
229 (_, NmtCommand::EnterPreOperational) => NmtState::PreOperational,
230 };
231 self.state
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 // --- COB-IDs -----------------------------------------------------------
240 #[test]
241 fn heartbeat_cob_id_follows_convention() {
242 assert_eq!(heartbeat_cob_id(NodeId::new(0x05).unwrap()), 0x705);
243 assert_eq!(NMT_COMMAND_COB_ID, 0x000);
244 }
245
246 // --- Command codec: known-good frames ----------------------------------
247 // NMT frames are [command specifier, target node]. Target 0 = all nodes.
248 #[test]
249 fn start_broadcast_matches_known_frame() {
250 assert_eq!(
251 encode_command(NmtCommand::StartRemoteNode, NodeId::BROADCAST),
252 [0x01, 0x00]
253 );
254 }
255
256 #[test]
257 fn start_single_node_matches_known_frame() {
258 let f = encode_command(NmtCommand::StartRemoteNode, NodeId::new(5).unwrap());
259 assert_eq!(f, [0x01, 0x05]);
260 }
261
262 #[test]
263 fn stop_single_node_matches_known_frame() {
264 let f = encode_command(NmtCommand::StopRemoteNode, NodeId::new(5).unwrap());
265 assert_eq!(f, [0x02, 0x05]);
266 }
267
268 #[test]
269 fn reset_commands_match_known_frames() {
270 let node = NodeId::new(0x7F).unwrap();
271 assert_eq!(
272 encode_command(NmtCommand::EnterPreOperational, node),
273 [0x80, 0x7F]
274 );
275 assert_eq!(encode_command(NmtCommand::ResetNode, node), [0x81, 0x7F]);
276 assert_eq!(
277 encode_command(NmtCommand::ResetCommunication, node),
278 [0x82, 0x7F]
279 );
280 }
281
282 #[test]
283 fn command_frame_roundtrips() {
284 let (cmd, target) = decode_command(&[0x01, 0x05]).unwrap();
285 assert_eq!(cmd, NmtCommand::StartRemoteNode);
286 assert_eq!(target, NodeId::new(5).unwrap());
287
288 let (cmd, target) = decode_command(&[0x82, 0x00]).unwrap();
289 assert_eq!(cmd, NmtCommand::ResetCommunication);
290 assert_eq!(target, NodeId::BROADCAST);
291 }
292
293 #[test]
294 fn decode_rejects_unknown_command() {
295 assert_eq!(decode_command(&[0x7E, 0x05]), Err(Error::UnexpectedCommand));
296 }
297
298 // --- Heartbeat / boot-up codec: known-good frames ----------------------
299 #[test]
300 fn heartbeat_states_match_known_frames() {
301 assert_eq!(encode_heartbeat(NmtState::Operational), [0x05]);
302 assert_eq!(encode_heartbeat(NmtState::Stopped), [0x04]);
303 assert_eq!(encode_heartbeat(NmtState::PreOperational), [0x7F]);
304 assert_eq!(BOOTUP_FRAME, [0x00]);
305 }
306
307 #[test]
308 fn heartbeat_decode_ignores_toggle_bit() {
309 // Bit 7 is reserved (toggle in node guarding); decoding must mask it.
310 assert_eq!(decode_heartbeat(&[0x05]).unwrap(), NmtState::Operational);
311 assert_eq!(decode_heartbeat(&[0x85]).unwrap(), NmtState::Operational);
312 }
313
314 #[test]
315 fn heartbeat_decode_rejects_unknown_state() {
316 assert_eq!(decode_heartbeat(&[0x42]), Err(Error::UnknownState));
317 }
318
319 // --- State machine -----------------------------------------------------
320 #[test]
321 fn boots_from_init_to_preoperational() {
322 let mut sm = NmtStateMachine::new();
323 assert_eq!(sm.state(), NmtState::Initialising);
324 assert_eq!(sm.boot(), NmtState::PreOperational);
325 }
326
327 #[test]
328 fn ignores_operational_commands_while_initialising() {
329 let mut sm = NmtStateMachine::new();
330 assert_eq!(
331 sm.apply(NmtCommand::StartRemoteNode),
332 NmtState::Initialising
333 );
334 }
335
336 #[test]
337 fn full_operational_lifecycle() {
338 let mut sm = NmtStateMachine::new();
339 sm.boot();
340 assert_eq!(sm.apply(NmtCommand::StartRemoteNode), NmtState::Operational);
341 assert_eq!(
342 sm.apply(NmtCommand::EnterPreOperational),
343 NmtState::PreOperational
344 );
345 assert_eq!(sm.apply(NmtCommand::StopRemoteNode), NmtState::Stopped);
346 assert_eq!(sm.apply(NmtCommand::StartRemoteNode), NmtState::Operational);
347 }
348
349 #[test]
350 fn reset_returns_to_initialising_from_any_state() {
351 let mut sm = NmtStateMachine::new();
352 sm.boot();
353 sm.apply(NmtCommand::StartRemoteNode);
354 assert_eq!(sm.apply(NmtCommand::ResetNode), NmtState::Initialising);
355 // And a fresh boot works again.
356 assert_eq!(sm.boot(), NmtState::PreOperational);
357
358 sm.apply(NmtCommand::StartRemoteNode);
359 assert_eq!(
360 sm.apply(NmtCommand::ResetCommunication),
361 NmtState::Initialising
362 );
363 }
364
365 #[test]
366 fn repeated_command_is_idempotent() {
367 let mut sm = NmtStateMachine::new();
368 sm.boot();
369 sm.apply(NmtCommand::StartRemoteNode);
370 assert_eq!(sm.apply(NmtCommand::StartRemoteNode), NmtState::Operational);
371 }
372}