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::lss::{self, LssAddress, LssSlave};
33use crate::nmt::{self, NmtState, NmtStateMachine};
34use crate::object_dictionary::ObjectDictionary;
35use crate::pdo::{self, PdoMapping, TransmissionType};
36use crate::sdo::{self, SdoServer};
37use crate::types::NodeId;
38use crate::{Error, Result};
39
40/// The maximum number of transmit (or receive) PDOs a [`Node`] holds — the four
41/// of the predefined connection set.
42pub const MAX_PDOS: usize = 4;
43
44/// The maximum objects mapped into one PDO: a full eight-byte frame of
45/// one-byte objects.
46pub const MAX_PDO_MAPPING: usize = 8;
47
48/// A frame to transmit: an 11-bit COB-ID and up to eight data bytes.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct TxFrame {
51    /// The COB-ID to transmit on.
52    pub cob_id: u16,
53    data: [u8; 8],
54    len: u8,
55}
56
57impl TxFrame {
58    fn new(cob_id: u16, bytes: &[u8]) -> Self {
59        let len = bytes.len().min(8);
60        let mut data = [0u8; 8];
61        data[..len].copy_from_slice(&bytes[..len]);
62        Self {
63            cob_id,
64            data,
65            len: len as u8,
66        }
67    }
68
69    /// The frame's data bytes (its DLC-trimmed payload).
70    pub fn data(&self) -> &[u8] {
71        &self.data[..self.len as usize]
72    }
73}
74
75/// A configured receive PDO: the COB-ID it listens on and its object mapping.
76#[derive(Debug)]
77struct RpdoSlot {
78    cob_id: u16,
79    mapping: PdoMapping<MAX_PDO_MAPPING>,
80}
81
82/// A configured transmit PDO: its COB-ID, object mapping, and trigger type.
83#[derive(Debug)]
84struct TpdoSlot {
85    cob_id: u16,
86    mapping: PdoMapping<MAX_PDO_MAPPING>,
87    transmission: TransmissionType,
88}
89
90/// A CANopen device node: an object dictionary served over SDO, with NMT state,
91/// heartbeat/boot-up production, and PDO exchange.
92#[derive(Debug)]
93pub struct Node<const N: usize> {
94    node_id: NodeId,
95    od: ObjectDictionary<N>,
96    sdo: SdoServer,
97    nmt: NmtStateMachine,
98    rpdos: Vec<RpdoSlot, MAX_PDOS>,
99    tpdos: Vec<TpdoSlot, MAX_PDOS>,
100    lss: Option<LssSlave>,
101}
102
103impl<const N: usize> Node<N> {
104    /// Create a node with `node_id` serving `od`. It starts in
105    /// [`NmtState::Initialising`]; call [`Node::boot`] to go operational-ready.
106    pub fn new(node_id: NodeId, od: ObjectDictionary<N>) -> Self {
107        Self {
108            node_id,
109            od,
110            sdo: SdoServer::new(node_id),
111            nmt: NmtStateMachine::new(),
112            rpdos: Vec::new(),
113            tpdos: Vec::new(),
114            lss: None,
115        }
116    }
117
118    /// Enable LSS with this node's 128-bit identity ([`LssAddress`], object
119    /// `0x1018`). The node then answers LSS master requests on `0x7E5`, letting
120    /// a master (re)assign its node-id over the bus.
121    ///
122    /// A node awaiting an LSS-assigned id should be left in
123    /// [`NmtState::Initialising`] (do not call [`Node::boot`]) so it serves only
124    /// LSS; after the id is assigned, call [`Node::apply_lss_node_id`] then boot.
125    pub fn enable_lss(&mut self, address: LssAddress) {
126        self.lss = Some(LssSlave::new(address, self.node_id.raw()));
127    }
128
129    /// Change the node-id, rebuilding the SDO server for the new COB-IDs. Call
130    /// on the reset that follows an LSS reconfiguration.
131    pub fn set_node_id(&mut self, node_id: NodeId) {
132        self.node_id = node_id;
133        self.sdo = SdoServer::new(node_id);
134    }
135
136    /// Adopt a node-id assigned over LSS: if the LSS slave holds a valid pending
137    /// id, apply it (rebuilding the SDO server) and return it. Call this on the
138    /// node's reset after an LSS configuration.
139    pub fn apply_lss_node_id(&mut self) -> Option<NodeId> {
140        let pending = self.lss.as_ref()?.pending_node_id();
141        let node_id = NodeId::new(pending).ok()?;
142        self.set_node_id(node_id);
143        if let Some(lss) = &mut self.lss {
144            lss.adopt_pending();
145        }
146        Some(node_id)
147    }
148
149    /// The LSS slave, if LSS is enabled (e.g. to read its pending node-id).
150    pub fn lss(&self) -> Option<&LssSlave> {
151        self.lss.as_ref()
152    }
153
154    /// Configure a receive PDO: when a frame arrives on `cob_id` (while
155    /// operational), its bytes are unpacked into the mapped objects.
156    ///
157    /// Returns [`Error::MappingFull`] once [`MAX_PDOS`] receive PDOs are set.
158    pub fn add_rpdo(&mut self, cob_id: u16, mapping: PdoMapping<MAX_PDO_MAPPING>) -> Result<()> {
159        self.rpdos
160            .push(RpdoSlot { cob_id, mapping })
161            .map_err(|_| Error::MappingFull)
162    }
163
164    /// Configure a transmit PDO: [`Node::sync_tpdos`] packs and emits it on SYNC
165    /// (for synchronous types) and [`Node::tpdo`] emits it on demand.
166    ///
167    /// Returns [`Error::MappingFull`] once [`MAX_PDOS`] transmit PDOs are set.
168    pub fn add_tpdo(
169        &mut self,
170        cob_id: u16,
171        mapping: PdoMapping<MAX_PDO_MAPPING>,
172        transmission: TransmissionType,
173    ) -> Result<()> {
174        self.tpdos
175            .push(TpdoSlot {
176                cob_id,
177                mapping,
178                transmission,
179            })
180            .map_err(|_| Error::MappingFull)
181    }
182
183    /// This node's id.
184    pub fn node_id(&self) -> NodeId {
185        self.node_id
186    }
187
188    /// The current NMT state.
189    pub fn state(&self) -> NmtState {
190        self.nmt.state()
191    }
192
193    /// Borrow the object dictionary (e.g. to publish process data).
194    pub fn od(&self) -> &ObjectDictionary<N> {
195        &self.od
196    }
197
198    /// Mutably borrow the object dictionary.
199    pub fn od_mut(&mut self) -> &mut ObjectDictionary<N> {
200        &mut self.od
201    }
202
203    /// Finish initialisation: enter pre-operational and return the boot-up
204    /// frame to transmit (`0x700 + node`, data `0x00`).
205    pub fn boot(&mut self) -> TxFrame {
206        self.nmt.boot();
207        TxFrame::new(nmt::heartbeat_cob_id(self.node_id), &nmt::BOOTUP_FRAME)
208    }
209
210    /// The heartbeat frame for the current state. Transmit it on your heartbeat
211    /// timer (the producer heartbeat time lives in object `0x1017`).
212    pub fn heartbeat(&self) -> TxFrame {
213        TxFrame::new(
214            nmt::heartbeat_cob_id(self.node_id),
215            &nmt::encode_heartbeat(self.nmt.state()),
216        )
217    }
218
219    /// Process an incoming CAN frame, returning a response to transmit, if any.
220    ///
221    /// Handles NMT node-control (`0x000`), SDO requests (`0x600 + node`), LSS
222    /// master requests (`0x7E5`, when enabled), and received PDOs. SDO is served
223    /// only in pre-operational and operational states, and PDOs only in
224    /// operational, per CiA 301; LSS is served regardless of NMT state. Frames
225    /// for other COB-IDs are ignored.
226    pub fn on_frame(&mut self, cob_id: u16, data: &[u8]) -> Option<TxFrame> {
227        if cob_id == nmt::NMT_COMMAND_COB_ID {
228            self.on_nmt(data);
229            None
230        } else if cob_id == lss::LSS_MASTER_COB_ID {
231            self.on_lss(data)
232        } else if cob_id == self.sdo.request_cob_id() {
233            self.on_sdo(data)
234        } else {
235            self.on_rpdo(cob_id, data);
236            None
237        }
238    }
239
240    fn on_lss(&mut self, data: &[u8]) -> Option<TxFrame> {
241        let lss = self.lss.as_mut()?;
242        if data.len() > 8 {
243            return None;
244        }
245        let mut frame: lss::LssFrame = [0u8; 8];
246        frame[..data.len()].copy_from_slice(data);
247        lss.handle(&frame)
248            .map(|resp| TxFrame::new(lss::LSS_SLAVE_COB_ID, &resp))
249    }
250
251    /// The synchronous transmit PDOs to send in response to a SYNC.
252    ///
253    /// Packs every configured TPDO with a synchronous transmission type from the
254    /// current object dictionary. Empty unless the node is operational — PDOs
255    /// are exchanged only in that state (CiA 301 §7.3.5).
256    pub fn sync_tpdos(&self) -> Vec<TxFrame, MAX_PDOS> {
257        let mut frames = Vec::new();
258        if self.nmt.state() != NmtState::Operational {
259            return frames;
260        }
261        for slot in &self.tpdos {
262            if is_synchronous(slot.transmission) {
263                if let Some(frame) = self.build_tpdo(slot) {
264                    // Capacity matches self.tpdos, so this never overflows.
265                    let _ = frames.push(frame);
266                }
267            }
268        }
269        frames
270    }
271
272    /// Emit transmit PDO `index` on demand (an event-driven transmission), or
273    /// `None` if there is no such PDO or the node is not operational.
274    pub fn tpdo(&self, index: usize) -> Option<TxFrame> {
275        if self.nmt.state() != NmtState::Operational {
276            return None;
277        }
278        self.build_tpdo(self.tpdos.get(index)?)
279    }
280
281    fn build_tpdo(&self, slot: &TpdoSlot) -> Option<TxFrame> {
282        if slot.mapping.is_empty() {
283            return None;
284        }
285        let mut buf = [0u8; 8];
286        let len = pdo::pack(&slot.mapping, &self.od, &mut buf).ok()?;
287        Some(TxFrame::new(slot.cob_id, &buf[..len]))
288    }
289
290    fn on_rpdo(&mut self, cob_id: u16, data: &[u8]) {
291        // PDOs are exchanged only in the operational state.
292        if self.nmt.state() != NmtState::Operational {
293            return;
294        }
295        if let Some(i) = self.rpdos.iter().position(|r| r.cob_id == cob_id) {
296            // Disjoint field borrows: `rpdos` (shared) and `od` (mutable).
297            let _ = pdo::unpack(&self.rpdos[i].mapping, &mut self.od, data);
298        }
299    }
300
301    fn on_nmt(&mut self, data: &[u8]) {
302        // An NMT node-control frame is [command specifier, target node].
303        if data.len() < 2 {
304            return;
305        }
306        if let Ok((command, target)) = nmt::decode_command(&[data[0], data[1]]) {
307            if target == NodeId::BROADCAST || target == self.node_id {
308                self.nmt.apply(command);
309            }
310        }
311    }
312
313    fn on_sdo(&mut self, data: &[u8]) -> Option<TxFrame> {
314        // SDO is inactive outside pre-operational / operational (CiA 301 §7.3).
315        if !matches!(
316            self.nmt.state(),
317            NmtState::PreOperational | NmtState::Operational
318        ) {
319            return None;
320        }
321        let mut payload: sdo::SdoPayload = [0u8; 8];
322        if data.len() > payload.len() {
323            return None;
324        }
325        payload[..data.len()].copy_from_slice(data);
326        let response = self.sdo.handle(&mut self.od, &payload)?;
327        Some(TxFrame::new(self.sdo.response_cob_id(), &response))
328    }
329}
330
331/// Whether a transmission type is SYNC-triggered (as opposed to event-driven).
332fn is_synchronous(transmission: TransmissionType) -> bool {
333    matches!(
334        transmission,
335        TransmissionType::SynchronousAcyclic | TransmissionType::SynchronousCyclic(_)
336    )
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::object_dictionary::{Address, Entry};
343    use crate::pdo::MappingEntry;
344    use crate::sdo::{encode_download_expedited, encode_upload_request};
345    use crate::{DataType, NmtCommand, Value};
346
347    fn start(n: &mut Node<8>) {
348        n.on_frame(
349            nmt::NMT_COMMAND_COB_ID,
350            &[NmtCommand::StartRemoteNode as u8, 0x10],
351        );
352    }
353
354    fn od() -> ObjectDictionary<8> {
355        let mut od = ObjectDictionary::new();
356        od.insert(
357            Address::new(0x1000, 0),
358            Entry::constant(Value::Unsigned32(0x192)),
359        )
360        .unwrap();
361        od.insert(Address::new(0x1017, 0), Entry::rw(Value::Unsigned16(1000)))
362            .unwrap();
363        od
364    }
365
366    fn node() -> Node<8> {
367        Node::new(NodeId::new(0x10).unwrap(), od())
368    }
369
370    #[test]
371    fn boots_from_init_to_preop_and_announces() {
372        let mut n = node();
373        assert_eq!(n.state(), NmtState::Initialising);
374        let boot = n.boot();
375        assert_eq!(n.state(), NmtState::PreOperational);
376        assert_eq!(boot.cob_id, 0x710); // 0x700 + node
377        assert_eq!(boot.data(), &[0x00]);
378    }
379
380    #[test]
381    fn heartbeat_reflects_state() {
382        let mut n = node();
383        n.boot();
384        assert_eq!(n.heartbeat().data(), &[0x7F]); // pre-operational
385        n.on_frame(
386            nmt::NMT_COMMAND_COB_ID,
387            &[NmtCommand::StartRemoteNode as u8, 0x10],
388        );
389        assert_eq!(n.state(), NmtState::Operational);
390        assert_eq!(n.heartbeat().data(), &[0x05]); // operational
391    }
392
393    #[test]
394    fn serves_sdo_read_when_preoperational() {
395        let mut n = node();
396        n.boot();
397        let req = encode_upload_request(Address::new(0x1000, 0));
398        let resp = n.on_frame(0x610, &req).expect("SDO response");
399        assert_eq!(resp.cob_id, 0x590); // 0x580 + node
400        let (_, value) = crate::sdo::decode_upload_expedited_response(
401            resp.data().try_into().unwrap(),
402            DataType::Unsigned32,
403        )
404        .unwrap();
405        assert_eq!(value, Value::Unsigned32(0x192));
406    }
407
408    #[test]
409    fn ignores_sdo_before_boot() {
410        let mut n = node(); // still Initialising
411        let req = encode_upload_request(Address::new(0x1000, 0));
412        assert!(n.on_frame(0x610, &req).is_none());
413    }
414
415    #[test]
416    fn ignores_sdo_when_stopped() {
417        let mut n = node();
418        n.boot();
419        n.on_frame(
420            nmt::NMT_COMMAND_COB_ID,
421            &[NmtCommand::StopRemoteNode as u8, 0x10],
422        );
423        assert_eq!(n.state(), NmtState::Stopped);
424        let req = encode_upload_request(Address::new(0x1000, 0));
425        assert!(n.on_frame(0x610, &req).is_none());
426    }
427
428    #[test]
429    fn nmt_command_for_other_node_is_ignored() {
430        let mut n = node();
431        n.boot();
432        // Start addressed to node 0x20, not us.
433        n.on_frame(
434            nmt::NMT_COMMAND_COB_ID,
435            &[NmtCommand::StartRemoteNode as u8, 0x20],
436        );
437        assert_eq!(n.state(), NmtState::PreOperational); // unchanged
438    }
439
440    #[test]
441    fn broadcast_nmt_applies() {
442        let mut n = node();
443        n.boot();
444        n.on_frame(
445            nmt::NMT_COMMAND_COB_ID,
446            &[NmtCommand::StartRemoteNode as u8, 0x00],
447        );
448        assert_eq!(n.state(), NmtState::Operational);
449    }
450
451    #[test]
452    fn serves_sdo_write_and_updates_od() {
453        let mut n = node();
454        n.boot();
455        let req =
456            encode_download_expedited(Address::new(0x1017, 0), &Value::Unsigned16(1234)).unwrap();
457        assert!(n.on_frame(0x610, &req).is_some());
458        assert_eq!(
459            n.od().read(Address::new(0x1017, 0)).unwrap(),
460            Value::Unsigned16(1234)
461        );
462    }
463
464    #[test]
465    fn ignores_unrelated_cob_id() {
466        let mut n = node();
467        n.boot();
468        assert!(n.on_frame(0x123, &[0; 8]).is_none());
469    }
470
471    // --- PDO ---------------------------------------------------------------
472    fn pdo_od() -> ObjectDictionary<8> {
473        let mut od = ObjectDictionary::new();
474        // TPDO source objects (readable) and an RPDO target (writable).
475        od.insert(
476            Address::new(0x6000, 1),
477            Entry::rw(Value::Unsigned16(0xBEEF)),
478        )
479        .unwrap();
480        od.insert(Address::new(0x6000, 2), Entry::rw(Value::Unsigned8(0x42)))
481            .unwrap();
482        od.insert(Address::new(0x6200, 1), Entry::rw(Value::Unsigned16(0)))
483            .unwrap();
484        od
485    }
486
487    fn mapping(entries: &[(u16, u8, u8)]) -> PdoMapping<MAX_PDO_MAPPING> {
488        let mut m = PdoMapping::new();
489        for &(index, sub, bits) in entries {
490            m.push(MappingEntry::new(index, sub, bits)).unwrap();
491        }
492        m
493    }
494
495    #[test]
496    fn tpdo_transmits_only_when_operational() {
497        let mut n = Node::new(NodeId::new(0x10).unwrap(), pdo_od());
498        n.add_tpdo(
499            0x18A,
500            mapping(&[(0x6000, 1, 16), (0x6000, 2, 8)]),
501            TransmissionType::SynchronousAcyclic,
502        )
503        .unwrap();
504        n.boot();
505
506        // Pre-operational: no PDO traffic.
507        assert!(n.sync_tpdos().is_empty());
508
509        start(&mut n);
510        let frames = n.sync_tpdos();
511        assert_eq!(frames.len(), 1);
512        assert_eq!(frames[0].cob_id, 0x18A);
513        // U16 0xBEEF little-endian then U8 0x42.
514        assert_eq!(frames[0].data(), &[0xEF, 0xBE, 0x42]);
515    }
516
517    #[test]
518    fn event_tpdo_by_index() {
519        let mut n = Node::new(NodeId::new(0x10).unwrap(), pdo_od());
520        // Event-driven type is not emitted by sync_tpdos, only by tpdo().
521        n.add_tpdo(
522            0x18A,
523            mapping(&[(0x6000, 2, 8)]),
524            TransmissionType::EventDrivenProfile,
525        )
526        .unwrap();
527        n.boot();
528        start(&mut n);
529        assert!(n.sync_tpdos().is_empty());
530        assert_eq!(n.tpdo(0).unwrap().data(), &[0x42]);
531        assert!(n.tpdo(1).is_none());
532    }
533
534    #[test]
535    fn rpdo_applies_only_when_operational() {
536        let mut n = Node::new(NodeId::new(0x10).unwrap(), pdo_od());
537        n.add_rpdo(0x20A, mapping(&[(0x6200, 1, 16)])).unwrap();
538        n.boot();
539
540        // Pre-operational: the RPDO is ignored.
541        assert!(n.on_frame(0x20A, &[0x34, 0x12]).is_none());
542        assert_eq!(
543            n.od().read(Address::new(0x6200, 1)).unwrap(),
544            Value::Unsigned16(0)
545        );
546
547        // Operational: the frame is unpacked into the object dictionary.
548        start(&mut n);
549        n.on_frame(0x20A, &[0x34, 0x12]);
550        assert_eq!(
551            n.od().read(Address::new(0x6200, 1)).unwrap(),
552            Value::Unsigned16(0x1234)
553        );
554    }
555
556    #[test]
557    fn pdo_capacity_is_enforced() {
558        let mut n = Node::new(NodeId::new(0x10).unwrap(), pdo_od());
559        for _ in 0..MAX_PDOS {
560            n.add_tpdo(
561                0x18A,
562                mapping(&[(0x6000, 2, 8)]),
563                TransmissionType::SynchronousAcyclic,
564            )
565            .unwrap();
566        }
567        assert_eq!(
568            n.add_tpdo(
569                0x18A,
570                mapping(&[(0x6000, 2, 8)]),
571                TransmissionType::SynchronousAcyclic
572            ),
573            Err(Error::MappingFull)
574        );
575    }
576
577    // --- LSS ---------------------------------------------------------------
578    use crate::lss::{self, encode_configure_node_id, encode_switch_global, LssAddress, LssState};
579
580    fn lss_address() -> LssAddress {
581        LssAddress {
582            vendor_id: 0x1F,
583            product_code: 0x2A,
584            revision_number: 1,
585            serial_number: 0x99,
586        }
587    }
588
589    #[test]
590    fn routes_lss_frames_when_enabled() {
591        let mut n = node();
592        n.enable_lss(lss_address());
593        // Switch into configuration via LSS (COB-ID 0x7E5), no response.
594        assert!(n
595            .on_frame(lss::LSS_MASTER_COB_ID, &encode_switch_global(true))
596            .is_none());
597        assert_eq!(n.lss().unwrap().state(), LssState::Configuration);
598    }
599
600    #[test]
601    fn lss_frames_ignored_when_disabled() {
602        let mut n = node(); // LSS not enabled
603        assert!(n
604            .on_frame(lss::LSS_MASTER_COB_ID, &encode_switch_global(true))
605            .is_none());
606        assert!(n.lss().is_none());
607    }
608
609    #[test]
610    fn lss_assigns_node_id_and_moves_sdo_cob_id() {
611        // A node that comes up unconfigured: leave it in Initialising and serve
612        // only LSS until a master assigns an id.
613        let mut n = Node::new(NodeId::new(1).unwrap(), od());
614        n.enable_lss(lss_address());
615        assert_eq!(n.node_id(), NodeId::new(1).unwrap());
616
617        // Master: switch to configuration, then assign node-id 0x20.
618        n.on_frame(lss::LSS_MASTER_COB_ID, &encode_switch_global(true));
619        let resp = n
620            .on_frame(lss::LSS_MASTER_COB_ID, &encode_configure_node_id(0x20))
621            .expect("configure response");
622        assert_eq!(resp.cob_id, lss::LSS_SLAVE_COB_ID);
623        assert_eq!(&resp.data()[..2], &[0x11, 0x00]); // configure success
624
625        // On the node's reset, adopt the assigned id — SDO COB-ID moves.
626        assert_eq!(n.apply_lss_node_id(), Some(NodeId::new(0x20).unwrap()));
627        assert_eq!(n.node_id(), NodeId::new(0x20).unwrap());
628
629        n.boot();
630        let req = encode_upload_request(Address::new(0x1000, 0));
631        assert!(n.on_frame(0x601, &req).is_none()); // old COB-ID no longer served
632        assert!(n.on_frame(0x620, &req).is_some()); // new COB-ID (0x600 + 0x20)
633    }
634}