Skip to main content

canopen_rs/
node.rs

1//! A CANopen device node — object dictionary + SDO server + NMT state in one
2//! frame-driven type.
3//!
4//! [`Node`] bundles the pieces a device needs and, like the rest of the stack,
5//! is **sans-I/O**: hand each incoming CAN frame to [`Node::on_frame`] and
6//! transmit the [`TxFrame`] it returns (if any). It serves SDO requests against
7//! its object dictionary, tracks NMT state from node-control commands, and
8//! produces boot-up and heartbeat frames — the same logic on a host or an MCU.
9//!
10//! ```no_run
11//! # use canopen_rs::{Address, Entry, NodeId, ObjectDictionary, Value};
12//! # use canopen_rs::node::Node;
13//! # fn bus_recv() -> (u16, [u8; 8]) { (0, [0; 8]) }
14//! # fn bus_send(_cob: u16, _data: &[u8]) {}
15//! let mut od = ObjectDictionary::<16>::new();
16//! od.insert(Address::new(0x1000, 0), Entry::constant(Value::Unsigned32(0x0004_0192))).unwrap();
17//! let mut node = Node::new(NodeId::new(0x10).unwrap(), od);
18//!
19//! let boot = node.boot();            // enter pre-operational, announce boot-up
20//! bus_send(boot.cob_id, boot.data());
21//!
22//! loop {
23//!     let (cob_id, data) = bus_recv();
24//!     if let Some(tx) = node.on_frame(cob_id, &data) {
25//!         bus_send(tx.cob_id, tx.data());
26//!     }
27//! }
28//! ```
29
30use heapless::Vec;
31
32use crate::nmt::{self, NmtState, NmtStateMachine};
33use crate::object_dictionary::ObjectDictionary;
34use crate::pdo::{self, PdoMapping, TransmissionType};
35use crate::sdo::{self, SdoServer};
36use crate::types::NodeId;
37use crate::{Error, Result};
38
39/// The maximum number of transmit (or receive) PDOs a [`Node`] holds — the four
40/// of the predefined connection set.
41pub const MAX_PDOS: usize = 4;
42
43/// The maximum objects mapped into one PDO: a full eight-byte frame of
44/// one-byte objects.
45pub const MAX_PDO_MAPPING: usize = 8;
46
47/// A frame to transmit: an 11-bit COB-ID and up to eight data bytes.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct TxFrame {
50    /// The COB-ID to transmit on.
51    pub cob_id: u16,
52    data: [u8; 8],
53    len: u8,
54}
55
56impl TxFrame {
57    fn new(cob_id: u16, bytes: &[u8]) -> Self {
58        let len = bytes.len().min(8);
59        let mut data = [0u8; 8];
60        data[..len].copy_from_slice(&bytes[..len]);
61        Self {
62            cob_id,
63            data,
64            len: len as u8,
65        }
66    }
67
68    /// The frame's data bytes (its DLC-trimmed payload).
69    pub fn data(&self) -> &[u8] {
70        &self.data[..self.len as usize]
71    }
72}
73
74/// A configured receive PDO: the COB-ID it listens on and its object mapping.
75#[derive(Debug)]
76struct RpdoSlot {
77    cob_id: u16,
78    mapping: PdoMapping<MAX_PDO_MAPPING>,
79}
80
81/// A configured transmit PDO: its COB-ID, object mapping, and trigger type.
82#[derive(Debug)]
83struct TpdoSlot {
84    cob_id: u16,
85    mapping: PdoMapping<MAX_PDO_MAPPING>,
86    transmission: TransmissionType,
87}
88
89/// A CANopen device node: an object dictionary served over SDO, with NMT state,
90/// heartbeat/boot-up production, and PDO exchange.
91#[derive(Debug)]
92pub struct Node<const N: usize> {
93    node_id: NodeId,
94    od: ObjectDictionary<N>,
95    sdo: SdoServer,
96    nmt: NmtStateMachine,
97    rpdos: Vec<RpdoSlot, MAX_PDOS>,
98    tpdos: Vec<TpdoSlot, MAX_PDOS>,
99}
100
101impl<const N: usize> Node<N> {
102    /// Create a node with `node_id` serving `od`. It starts in
103    /// [`NmtState::Initialising`]; call [`Node::boot`] to go operational-ready.
104    pub fn new(node_id: NodeId, od: ObjectDictionary<N>) -> Self {
105        Self {
106            node_id,
107            od,
108            sdo: SdoServer::new(node_id),
109            nmt: NmtStateMachine::new(),
110            rpdos: Vec::new(),
111            tpdos: Vec::new(),
112        }
113    }
114
115    /// Configure a receive PDO: when a frame arrives on `cob_id` (while
116    /// operational), its bytes are unpacked into the mapped objects.
117    ///
118    /// Returns [`Error::MappingFull`] once [`MAX_PDOS`] receive PDOs are set.
119    pub fn add_rpdo(&mut self, cob_id: u16, mapping: PdoMapping<MAX_PDO_MAPPING>) -> Result<()> {
120        self.rpdos
121            .push(RpdoSlot { cob_id, mapping })
122            .map_err(|_| Error::MappingFull)
123    }
124
125    /// Configure a transmit PDO: [`Node::sync_tpdos`] packs and emits it on SYNC
126    /// (for synchronous types) and [`Node::tpdo`] emits it on demand.
127    ///
128    /// Returns [`Error::MappingFull`] once [`MAX_PDOS`] transmit PDOs are set.
129    pub fn add_tpdo(
130        &mut self,
131        cob_id: u16,
132        mapping: PdoMapping<MAX_PDO_MAPPING>,
133        transmission: TransmissionType,
134    ) -> Result<()> {
135        self.tpdos
136            .push(TpdoSlot {
137                cob_id,
138                mapping,
139                transmission,
140            })
141            .map_err(|_| Error::MappingFull)
142    }
143
144    /// This node's id.
145    pub fn node_id(&self) -> NodeId {
146        self.node_id
147    }
148
149    /// The current NMT state.
150    pub fn state(&self) -> NmtState {
151        self.nmt.state()
152    }
153
154    /// Borrow the object dictionary (e.g. to publish process data).
155    pub fn od(&self) -> &ObjectDictionary<N> {
156        &self.od
157    }
158
159    /// Mutably borrow the object dictionary.
160    pub fn od_mut(&mut self) -> &mut ObjectDictionary<N> {
161        &mut self.od
162    }
163
164    /// Finish initialisation: enter pre-operational and return the boot-up
165    /// frame to transmit (`0x700 + node`, data `0x00`).
166    pub fn boot(&mut self) -> TxFrame {
167        self.nmt.boot();
168        TxFrame::new(nmt::heartbeat_cob_id(self.node_id), &nmt::BOOTUP_FRAME)
169    }
170
171    /// The heartbeat frame for the current state. Transmit it on your heartbeat
172    /// timer (the producer heartbeat time lives in object `0x1017`).
173    pub fn heartbeat(&self) -> TxFrame {
174        TxFrame::new(
175            nmt::heartbeat_cob_id(self.node_id),
176            &nmt::encode_heartbeat(self.nmt.state()),
177        )
178    }
179
180    /// Process an incoming CAN frame, returning a response to transmit, if any.
181    ///
182    /// Handles NMT node-control (`0x000`) by advancing the state machine, and
183    /// SDO requests (`0x600 + node`) by serving the object dictionary. SDO is
184    /// answered only in pre-operational and operational states, per CiA 301.
185    /// Frames for other COB-IDs are ignored.
186    pub fn on_frame(&mut self, cob_id: u16, data: &[u8]) -> Option<TxFrame> {
187        if cob_id == nmt::NMT_COMMAND_COB_ID {
188            self.on_nmt(data);
189            None
190        } else if cob_id == self.sdo.request_cob_id() {
191            self.on_sdo(data)
192        } else {
193            self.on_rpdo(cob_id, data);
194            None
195        }
196    }
197
198    /// The synchronous transmit PDOs to send in response to a SYNC.
199    ///
200    /// Packs every configured TPDO with a synchronous transmission type from the
201    /// current object dictionary. Empty unless the node is operational — PDOs
202    /// are exchanged only in that state (CiA 301 §7.3.5).
203    pub fn sync_tpdos(&self) -> Vec<TxFrame, MAX_PDOS> {
204        let mut frames = Vec::new();
205        if self.nmt.state() != NmtState::Operational {
206            return frames;
207        }
208        for slot in &self.tpdos {
209            if is_synchronous(slot.transmission) {
210                if let Some(frame) = self.build_tpdo(slot) {
211                    // Capacity matches self.tpdos, so this never overflows.
212                    let _ = frames.push(frame);
213                }
214            }
215        }
216        frames
217    }
218
219    /// Emit transmit PDO `index` on demand (an event-driven transmission), or
220    /// `None` if there is no such PDO or the node is not operational.
221    pub fn tpdo(&self, index: usize) -> Option<TxFrame> {
222        if self.nmt.state() != NmtState::Operational {
223            return None;
224        }
225        self.build_tpdo(self.tpdos.get(index)?)
226    }
227
228    fn build_tpdo(&self, slot: &TpdoSlot) -> Option<TxFrame> {
229        if slot.mapping.is_empty() {
230            return None;
231        }
232        let mut buf = [0u8; 8];
233        let len = pdo::pack(&slot.mapping, &self.od, &mut buf).ok()?;
234        Some(TxFrame::new(slot.cob_id, &buf[..len]))
235    }
236
237    fn on_rpdo(&mut self, cob_id: u16, data: &[u8]) {
238        // PDOs are exchanged only in the operational state.
239        if self.nmt.state() != NmtState::Operational {
240            return;
241        }
242        if let Some(i) = self.rpdos.iter().position(|r| r.cob_id == cob_id) {
243            // Disjoint field borrows: `rpdos` (shared) and `od` (mutable).
244            let _ = pdo::unpack(&self.rpdos[i].mapping, &mut self.od, data);
245        }
246    }
247
248    fn on_nmt(&mut self, data: &[u8]) {
249        // An NMT node-control frame is [command specifier, target node].
250        if data.len() < 2 {
251            return;
252        }
253        if let Ok((command, target)) = nmt::decode_command(&[data[0], data[1]]) {
254            if target == NodeId::BROADCAST || target == self.node_id {
255                self.nmt.apply(command);
256            }
257        }
258    }
259
260    fn on_sdo(&mut self, data: &[u8]) -> Option<TxFrame> {
261        // SDO is inactive outside pre-operational / operational (CiA 301 §7.3).
262        if !matches!(
263            self.nmt.state(),
264            NmtState::PreOperational | NmtState::Operational
265        ) {
266            return None;
267        }
268        let mut payload: sdo::SdoPayload = [0u8; 8];
269        if data.len() > payload.len() {
270            return None;
271        }
272        payload[..data.len()].copy_from_slice(data);
273        let response = self.sdo.handle(&mut self.od, &payload)?;
274        Some(TxFrame::new(self.sdo.response_cob_id(), &response))
275    }
276}
277
278/// Whether a transmission type is SYNC-triggered (as opposed to event-driven).
279fn is_synchronous(transmission: TransmissionType) -> bool {
280    matches!(
281        transmission,
282        TransmissionType::SynchronousAcyclic | TransmissionType::SynchronousCyclic(_)
283    )
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::object_dictionary::{Address, Entry};
290    use crate::pdo::MappingEntry;
291    use crate::sdo::{encode_download_expedited, encode_upload_request};
292    use crate::{DataType, NmtCommand, Value};
293
294    fn start(n: &mut Node<8>) {
295        n.on_frame(
296            nmt::NMT_COMMAND_COB_ID,
297            &[NmtCommand::StartRemoteNode as u8, 0x10],
298        );
299    }
300
301    fn od() -> ObjectDictionary<8> {
302        let mut od = ObjectDictionary::new();
303        od.insert(
304            Address::new(0x1000, 0),
305            Entry::constant(Value::Unsigned32(0x192)),
306        )
307        .unwrap();
308        od.insert(Address::new(0x1017, 0), Entry::rw(Value::Unsigned16(1000)))
309            .unwrap();
310        od
311    }
312
313    fn node() -> Node<8> {
314        Node::new(NodeId::new(0x10).unwrap(), od())
315    }
316
317    #[test]
318    fn boots_from_init_to_preop_and_announces() {
319        let mut n = node();
320        assert_eq!(n.state(), NmtState::Initialising);
321        let boot = n.boot();
322        assert_eq!(n.state(), NmtState::PreOperational);
323        assert_eq!(boot.cob_id, 0x710); // 0x700 + node
324        assert_eq!(boot.data(), &[0x00]);
325    }
326
327    #[test]
328    fn heartbeat_reflects_state() {
329        let mut n = node();
330        n.boot();
331        assert_eq!(n.heartbeat().data(), &[0x7F]); // pre-operational
332        n.on_frame(
333            nmt::NMT_COMMAND_COB_ID,
334            &[NmtCommand::StartRemoteNode as u8, 0x10],
335        );
336        assert_eq!(n.state(), NmtState::Operational);
337        assert_eq!(n.heartbeat().data(), &[0x05]); // operational
338    }
339
340    #[test]
341    fn serves_sdo_read_when_preoperational() {
342        let mut n = node();
343        n.boot();
344        let req = encode_upload_request(Address::new(0x1000, 0));
345        let resp = n.on_frame(0x610, &req).expect("SDO response");
346        assert_eq!(resp.cob_id, 0x590); // 0x580 + node
347        let (_, value) = crate::sdo::decode_upload_expedited_response(
348            resp.data().try_into().unwrap(),
349            DataType::Unsigned32,
350        )
351        .unwrap();
352        assert_eq!(value, Value::Unsigned32(0x192));
353    }
354
355    #[test]
356    fn ignores_sdo_before_boot() {
357        let mut n = node(); // still Initialising
358        let req = encode_upload_request(Address::new(0x1000, 0));
359        assert!(n.on_frame(0x610, &req).is_none());
360    }
361
362    #[test]
363    fn ignores_sdo_when_stopped() {
364        let mut n = node();
365        n.boot();
366        n.on_frame(
367            nmt::NMT_COMMAND_COB_ID,
368            &[NmtCommand::StopRemoteNode as u8, 0x10],
369        );
370        assert_eq!(n.state(), NmtState::Stopped);
371        let req = encode_upload_request(Address::new(0x1000, 0));
372        assert!(n.on_frame(0x610, &req).is_none());
373    }
374
375    #[test]
376    fn nmt_command_for_other_node_is_ignored() {
377        let mut n = node();
378        n.boot();
379        // Start addressed to node 0x20, not us.
380        n.on_frame(
381            nmt::NMT_COMMAND_COB_ID,
382            &[NmtCommand::StartRemoteNode as u8, 0x20],
383        );
384        assert_eq!(n.state(), NmtState::PreOperational); // unchanged
385    }
386
387    #[test]
388    fn broadcast_nmt_applies() {
389        let mut n = node();
390        n.boot();
391        n.on_frame(
392            nmt::NMT_COMMAND_COB_ID,
393            &[NmtCommand::StartRemoteNode as u8, 0x00],
394        );
395        assert_eq!(n.state(), NmtState::Operational);
396    }
397
398    #[test]
399    fn serves_sdo_write_and_updates_od() {
400        let mut n = node();
401        n.boot();
402        let req =
403            encode_download_expedited(Address::new(0x1017, 0), &Value::Unsigned16(1234)).unwrap();
404        assert!(n.on_frame(0x610, &req).is_some());
405        assert_eq!(
406            n.od().read(Address::new(0x1017, 0)).unwrap(),
407            Value::Unsigned16(1234)
408        );
409    }
410
411    #[test]
412    fn ignores_unrelated_cob_id() {
413        let mut n = node();
414        n.boot();
415        assert!(n.on_frame(0x123, &[0; 8]).is_none());
416    }
417
418    // --- PDO ---------------------------------------------------------------
419    fn pdo_od() -> ObjectDictionary<8> {
420        let mut od = ObjectDictionary::new();
421        // TPDO source objects (readable) and an RPDO target (writable).
422        od.insert(
423            Address::new(0x6000, 1),
424            Entry::rw(Value::Unsigned16(0xBEEF)),
425        )
426        .unwrap();
427        od.insert(Address::new(0x6000, 2), Entry::rw(Value::Unsigned8(0x42)))
428            .unwrap();
429        od.insert(Address::new(0x6200, 1), Entry::rw(Value::Unsigned16(0)))
430            .unwrap();
431        od
432    }
433
434    fn mapping(entries: &[(u16, u8, u8)]) -> PdoMapping<MAX_PDO_MAPPING> {
435        let mut m = PdoMapping::new();
436        for &(index, sub, bits) in entries {
437            m.push(MappingEntry::new(index, sub, bits)).unwrap();
438        }
439        m
440    }
441
442    #[test]
443    fn tpdo_transmits_only_when_operational() {
444        let mut n = Node::new(NodeId::new(0x10).unwrap(), pdo_od());
445        n.add_tpdo(
446            0x18A,
447            mapping(&[(0x6000, 1, 16), (0x6000, 2, 8)]),
448            TransmissionType::SynchronousAcyclic,
449        )
450        .unwrap();
451        n.boot();
452
453        // Pre-operational: no PDO traffic.
454        assert!(n.sync_tpdos().is_empty());
455
456        start(&mut n);
457        let frames = n.sync_tpdos();
458        assert_eq!(frames.len(), 1);
459        assert_eq!(frames[0].cob_id, 0x18A);
460        // U16 0xBEEF little-endian then U8 0x42.
461        assert_eq!(frames[0].data(), &[0xEF, 0xBE, 0x42]);
462    }
463
464    #[test]
465    fn event_tpdo_by_index() {
466        let mut n = Node::new(NodeId::new(0x10).unwrap(), pdo_od());
467        // Event-driven type is not emitted by sync_tpdos, only by tpdo().
468        n.add_tpdo(
469            0x18A,
470            mapping(&[(0x6000, 2, 8)]),
471            TransmissionType::EventDrivenProfile,
472        )
473        .unwrap();
474        n.boot();
475        start(&mut n);
476        assert!(n.sync_tpdos().is_empty());
477        assert_eq!(n.tpdo(0).unwrap().data(), &[0x42]);
478        assert!(n.tpdo(1).is_none());
479    }
480
481    #[test]
482    fn rpdo_applies_only_when_operational() {
483        let mut n = Node::new(NodeId::new(0x10).unwrap(), pdo_od());
484        n.add_rpdo(0x20A, mapping(&[(0x6200, 1, 16)])).unwrap();
485        n.boot();
486
487        // Pre-operational: the RPDO is ignored.
488        assert!(n.on_frame(0x20A, &[0x34, 0x12]).is_none());
489        assert_eq!(
490            n.od().read(Address::new(0x6200, 1)).unwrap(),
491            Value::Unsigned16(0)
492        );
493
494        // Operational: the frame is unpacked into the object dictionary.
495        start(&mut n);
496        n.on_frame(0x20A, &[0x34, 0x12]);
497        assert_eq!(
498            n.od().read(Address::new(0x6200, 1)).unwrap(),
499            Value::Unsigned16(0x1234)
500        );
501    }
502
503    #[test]
504    fn pdo_capacity_is_enforced() {
505        let mut n = Node::new(NodeId::new(0x10).unwrap(), pdo_od());
506        for _ in 0..MAX_PDOS {
507            n.add_tpdo(
508                0x18A,
509                mapping(&[(0x6000, 2, 8)]),
510                TransmissionType::SynchronousAcyclic,
511            )
512            .unwrap();
513        }
514        assert_eq!(
515            n.add_tpdo(
516                0x18A,
517                mapping(&[(0x6000, 2, 8)]),
518                TransmissionType::SynchronousAcyclic
519            ),
520            Err(Error::MappingFull)
521        );
522    }
523}