Skip to main content

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}