Skip to main content

canopen_host/
eds.rs

1//! EDS / DCF parsing (CiA 306) — INI-style device description files.
2//!
3//! An Electronic Data Sheet describes a device's object dictionary in an
4//! INI-like text format: a `[<index>]` section per object and
5//! `[<index>sub<subindex>]` sections per subindex, each carrying fields such
6//! as `ParameterName`, `DataType`, `AccessType`, and `DefaultValue`. Indices
7//! and subindices are hexadecimal. A Device Configuration File (DCF) shares
8//! the grammar, adding concrete `ParameterValue` fields.
9//!
10//! [`Eds::parse`](crate::eds::Eds::parse) turns that text into a list of typed
11//! [`ObjectDescription`](crate::eds::ObjectDescription)s, and
12//! [`Eds::object_dictionary`](crate::eds::Eds::object_dictionary) builds a
13//! ready-to-use core [`ObjectDictionary`](canopen_rs::ObjectDictionary) from
14//! them — the same dictionary type an embedded node runs, populated here from a
15//! file.
16//!
17//! ## Supported subset
18//!
19//! * The numeric basic data types (see [`DataType`](canopen_rs::DataType));
20//!   `VISIBLE_STRING`,
21//!   `OCTET_STRING`, and `DOMAIN` objects are skipped, as the core does not
22//!   yet model variable-length values.
23//! * `DefaultValue` / `ParameterValue` as decimal or `0x…` hex, including the
24//!   `$NODEID` substitution used for COB-IDs (e.g. `$NODEID+0x200`), resolved
25//!   against the file's `[DeviceComissioning] NodeID` when present.
26
27use std::collections::BTreeMap;
28use std::fmt;
29use std::fs;
30use std::io;
31use std::path::Path;
32
33use canopen_rs::datatypes::{ByteString, DataType, Value, MAX_STRING_LEN};
34use canopen_rs::object_dictionary::{AccessType, Address, Entry, ObjectDictionary};
35
36/// An error encountered while parsing an EDS/DCF file.
37#[derive(Debug)]
38pub enum EdsError {
39    /// The file could not be read.
40    Io(io::Error),
41    /// An object section was missing a required field.
42    MissingField {
43        /// The `[section]` the field was expected in.
44        section: String,
45        /// The absent field name.
46        field: &'static str,
47    },
48    /// An `AccessType` value was not one of `ro`/`wo`/`rw`/`rwr`/`rww`/`const`.
49    UnknownAccessType(String),
50    /// A `DefaultValue`/`ParameterValue` could not be parsed for its type.
51    InvalidValue {
52        /// The `[section]` the value came from.
53        section: String,
54        /// The offending text.
55        value: String,
56    },
57    /// More objects than the target [`ObjectDictionary`] capacity.
58    TooManyObjects,
59}
60
61impl fmt::Display for EdsError {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            EdsError::Io(e) => write!(f, "reading EDS file: {e}"),
65            EdsError::MissingField { section, field } => {
66                write!(f, "section [{section}] is missing required field `{field}`")
67            }
68            EdsError::UnknownAccessType(s) => write!(f, "unknown AccessType `{s}`"),
69            EdsError::InvalidValue { section, value } => {
70                write!(f, "section [{section}] has an invalid value `{value}`")
71            }
72            EdsError::TooManyObjects => f.write_str("more objects than dictionary capacity"),
73        }
74    }
75}
76
77impl std::error::Error for EdsError {
78    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
79        match self {
80            EdsError::Io(e) => Some(e),
81            _ => None,
82        }
83    }
84}
85
86impl From<io::Error> for EdsError {
87    fn from(e: io::Error) -> Self {
88        EdsError::Io(e)
89    }
90}
91
92/// A single object dictionary entry described by the EDS.
93#[derive(Debug, Clone)]
94pub struct ObjectDescription {
95    /// The `(index, subindex)` address.
96    pub address: Address,
97    /// The human-readable `ParameterName`.
98    pub parameter_name: String,
99    /// The object's data type.
100    pub data_type: DataType,
101    /// The declared access rights.
102    pub access: AccessType,
103    /// The default (EDS) or configured (DCF) value.
104    pub default_value: Value,
105    /// Whether the object may be mapped into a PDO.
106    pub pdo_mappable: bool,
107}
108
109impl ObjectDescription {
110    /// The core [`Entry`] this description builds: its value and access rights.
111    pub fn entry(&self) -> Entry {
112        Entry {
113            value: self.default_value,
114            access: self.access,
115        }
116    }
117}
118
119/// A parsed Electronic Data Sheet (or Device Configuration File).
120#[derive(Debug, Clone)]
121pub struct Eds {
122    /// `[DeviceInfo] VendorName`, if present.
123    pub vendor_name: Option<String>,
124    /// `[DeviceInfo] ProductName`, if present.
125    pub product_name: Option<String>,
126    /// `[DeviceComissioning] NodeID`, if present — also used to resolve
127    /// `$NODEID` expressions in default values.
128    pub node_id: Option<u8>,
129    /// The described objects, ordered by address.
130    pub objects: Vec<ObjectDescription>,
131}
132
133impl Eds {
134    /// Parse EDS/DCF text.
135    pub fn parse(text: &str) -> Result<Eds, EdsError> {
136        let sections = parse_ini(text);
137
138        let device = sections.get("deviceinfo");
139        let vendor_name = device.and_then(|s| s.get("vendorname")).cloned();
140        let product_name = device.and_then(|s| s.get("productname")).cloned();
141
142        // "DeviceComissioning" is the CiA 306 spelling (sic); accept both.
143        let node_id = sections
144            .get("devicecomissioning")
145            .or_else(|| sections.get("devicecommissioning"))
146            .and_then(|s| s.get("nodeid"))
147            .and_then(|v| eval_int_expr(v, 0))
148            .and_then(|n| u8::try_from(n).ok());
149        let node_for_expr = node_id.unwrap_or(0);
150
151        let mut objects = Vec::new();
152        for (name, fields) in &sections {
153            let Some(address) = parse_object_section(name) else {
154                continue;
155            };
156            // Only sections that carry a DataType describe a stored value; a
157            // RECORD/ARRAY parent section (its subindices hold the values) has
158            // none and is skipped.
159            let Some(dt_raw) = fields.get("datatype") else {
160                continue;
161            };
162            let Some(dt_index) = eval_int_expr(dt_raw, node_for_expr) else {
163                return Err(EdsError::InvalidValue {
164                    section: name.clone(),
165                    value: dt_raw.clone(),
166                });
167            };
168            // Skip unmodelled types (strings, DOMAIN) rather than failing the
169            // whole file.
170            let Some(data_type) = u16::try_from(dt_index).ok().and_then(DataType::from_index)
171            else {
172                continue;
173            };
174
175            let access = match fields.get("accesstype") {
176                Some(a) => access_from_str(a)?,
177                None => AccessType::Ro,
178            };
179
180            let raw_value = fields
181                .get("parametervalue")
182                .or_else(|| fields.get("defaultvalue"))
183                .map(String::as_str)
184                .unwrap_or("");
185            let default_value =
186                value_from_str(data_type, raw_value, node_for_expr).ok_or_else(|| {
187                    EdsError::InvalidValue {
188                        section: name.clone(),
189                        value: raw_value.to_string(),
190                    }
191                })?;
192
193            objects.push(ObjectDescription {
194                address,
195                parameter_name: fields.get("parametername").cloned().unwrap_or_default(),
196                data_type,
197                access,
198                default_value,
199                pdo_mappable: fields
200                    .get("pdomapping")
201                    .map(|v| v.trim() != "0")
202                    .unwrap_or(false),
203            });
204        }
205
206        objects.sort_by_key(|o| o.address);
207        Ok(Eds {
208            vendor_name,
209            product_name,
210            node_id,
211            objects,
212        })
213    }
214
215    /// Read and parse an EDS/DCF file from `path`.
216    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Eds, EdsError> {
217        Self::parse(&fs::read_to_string(path)?)
218    }
219
220    /// The described object at `address`, if any.
221    pub fn get(&self, address: Address) -> Option<&ObjectDescription> {
222        self.objects.iter().find(|o| o.address == address)
223    }
224
225    /// Build a core [`ObjectDictionary`] of capacity `N` from the described
226    /// objects.
227    ///
228    /// Returns [`EdsError::TooManyObjects`] if there are more objects than `N`.
229    pub fn object_dictionary<const N: usize>(&self) -> Result<ObjectDictionary<N>, EdsError> {
230        let mut od = ObjectDictionary::new();
231        for obj in &self.objects {
232            od.insert(obj.address, obj.entry())
233                .map_err(|_| EdsError::TooManyObjects)?;
234        }
235        Ok(od)
236    }
237}
238
239// --- INI + value helpers ---------------------------------------------------
240
241/// Parse INI text into `section -> (key -> value)`, with section names and
242/// keys lower-cased for case-insensitive lookup and values kept verbatim.
243fn parse_ini(text: &str) -> BTreeMap<String, BTreeMap<String, String>> {
244    let mut sections = BTreeMap::new();
245    let mut current: Option<String> = None;
246    for line in text.lines() {
247        let line = line.trim();
248        if line.is_empty() || line.starts_with(';') {
249            continue;
250        }
251        if let Some(inner) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
252            let name = inner.trim().to_ascii_lowercase();
253            sections.entry(name.clone()).or_insert_with(BTreeMap::new);
254            current = Some(name);
255        } else if let (Some(section), Some((key, value))) = (&current, line.split_once('=')) {
256            sections
257                .get_mut(section)
258                .expect("current section was inserted")
259                .insert(key.trim().to_ascii_lowercase(), value.trim().to_string());
260        }
261    }
262    sections
263}
264
265/// Parse an object/subindex section name (`"1018"` or `"1018sub2"`) into an
266/// [`Address`]; `None` for a non-object section such as `"deviceinfo"`.
267fn parse_object_section(name: &str) -> Option<Address> {
268    if let Some((index, subindex)) = name.split_once("sub") {
269        Some(Address::new(
270            u16::from_str_radix(index, 16).ok()?,
271            u8::from_str_radix(subindex, 16).ok()?,
272        ))
273    } else {
274        Some(Address::new(u16::from_str_radix(name, 16).ok()?, 0))
275    }
276}
277
278fn access_from_str(s: &str) -> Result<AccessType, EdsError> {
279    match s.trim().to_ascii_lowercase().as_str() {
280        "ro" => Ok(AccessType::Ro),
281        "wo" => Ok(AccessType::Wo),
282        "rw" | "rwr" | "rww" => Ok(AccessType::Rw),
283        "const" => Ok(AccessType::Const),
284        other => Err(EdsError::UnknownAccessType(other.to_string())),
285    }
286}
287
288/// Evaluate a sum of `$NODEID` and integer literal terms, e.g. `"$NODEID+0x200"`
289/// or `"1280+$NODEID"`. Literals may be decimal or `0x…` hex.
290fn eval_int_expr(s: &str, node_id: u8) -> Option<i128> {
291    let mut sum: i128 = 0;
292    for term in s.split('+') {
293        let term = term.trim();
294        if term.is_empty() {
295            continue;
296        } else if term.eq_ignore_ascii_case("$nodeid") {
297            sum += node_id as i128;
298        } else if let Some(hex) = term.strip_prefix("0x").or_else(|| term.strip_prefix("0X")) {
299            sum += i128::from_str_radix(hex, 16).ok()?;
300        } else {
301            sum += term.parse::<i128>().ok()?;
302        }
303    }
304    Some(sum)
305}
306
307/// Parse a default/configured value string into a typed [`Value`].
308fn value_from_str(data_type: DataType, s: &str, node_id: u8) -> Option<Value> {
309    let s = s.trim();
310    // Variable-length types take the literal as their content (VISIBLE_STRING
311    // is text; OCTET_STRING / DOMAIN keep the raw bytes), truncated to the
312    // buffer capacity.
313    if data_type.is_variable() {
314        let bytes = s.as_bytes();
315        let end = bytes.len().min(MAX_STRING_LEN);
316        let bs = ByteString::from_bytes(&bytes[..end]).ok()?;
317        return Some(match data_type {
318            DataType::OctetString => Value::OctetString(bs),
319            DataType::Domain => Value::Domain(bs),
320            _ => Value::VisibleString(bs),
321        });
322    }
323    // Floating-point types parse the literal directly.
324    match data_type {
325        DataType::Real32 => {
326            return Some(Value::Real32(if s.is_empty() {
327                0.0
328            } else {
329                s.parse().ok()?
330            }))
331        }
332        DataType::Real64 => {
333            return Some(Value::Real64(if s.is_empty() {
334                0.0
335            } else {
336                s.parse().ok()?
337            }))
338        }
339        _ => {}
340    }
341    // Integer types evaluate a (possibly `$NODEID`-relative) expression.
342    let n = if s.is_empty() {
343        0
344    } else {
345        eval_int_expr(s, node_id)?
346    };
347    Some(match data_type {
348        DataType::Boolean => Value::Boolean(n != 0),
349        DataType::Integer8 => Value::Integer8(n as i8),
350        DataType::Integer16 => Value::Integer16(n as i16),
351        DataType::Integer32 => Value::Integer32(n as i32),
352        DataType::Unsigned8 => Value::Unsigned8(n as u8),
353        DataType::Unsigned16 => Value::Unsigned16(n as u16),
354        DataType::Unsigned32 => Value::Unsigned32(n as u32),
355        DataType::Integer64 => Value::Integer64(n as i64),
356        DataType::Unsigned64 => Value::Unsigned64(n as u64),
357        // Reals handled above; `#[non_exhaustive]` requires this catch-all.
358        _ => return None,
359    })
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    // A compact but representative EDS: device info, node id, a VAR, a RECORD
367    // with subindices, a $NODEID COB-ID, a hex value, a float, and a skipped
368    // string object.
369    const SAMPLE: &str = "\
370[DeviceInfo]
371VendorName=Acme Robotics
372ProductName=Widget 3000
373
374[DeviceComissioning]
375NodeID=0x10
376
377[MandatoryObjects]
378SupportedObjects=2
3791=0x1000
3802=0x1018
381
382[1000]
383ParameterName=Device type
384ObjectType=0x7
385DataType=0x0007
386AccessType=ro
387DefaultValue=0x00040192
388PDOMapping=0
389
390[1008]
391ParameterName=Device name
392ObjectType=0x7
393DataType=0x0009
394AccessType=const
395DefaultValue=Widget
396
397[1017]
398ParameterName=Producer heartbeat time
399ObjectType=0x7
400DataType=0x0006
401AccessType=rw
402DefaultValue=1000
403PDOMapping=1
404
405[1018]
406ParameterName=Identity object
407ObjectType=0x9
408SubNumber=2
409
410[1018sub0]
411ParameterName=Highest sub-index supported
412DataType=0x0005
413AccessType=const
414DefaultValue=1
415
416[1018sub1]
417ParameterName=Vendor-ID
418DataType=0x0007
419AccessType=ro
420DefaultValue=0x000000AB
421
422[1400sub1]
423ParameterName=COB-ID used by RPDO
424DataType=0x0007
425AccessType=rw
426DefaultValue=$NODEID+0x200
427
428[6000]
429ParameterName=Scale factor
430ObjectType=0x7
431DataType=0x0008
432AccessType=rw
433DefaultValue=1.5
434";
435
436    fn sample() -> Eds {
437        Eds::parse(SAMPLE).unwrap()
438    }
439
440    #[test]
441    fn reads_device_metadata_and_node_id() {
442        let eds = sample();
443        assert_eq!(eds.vendor_name.as_deref(), Some("Acme Robotics"));
444        assert_eq!(eds.product_name.as_deref(), Some("Widget 3000"));
445        assert_eq!(eds.node_id, Some(0x10));
446    }
447
448    #[test]
449    fn parses_var_with_hex_default() {
450        let obj = sample().get(Address::new(0x1000, 0)).unwrap().clone();
451        assert_eq!(obj.parameter_name, "Device type");
452        assert_eq!(obj.data_type, DataType::Unsigned32);
453        assert_eq!(obj.access, AccessType::Ro);
454        assert_eq!(obj.default_value, Value::Unsigned32(0x0004_0192));
455    }
456
457    #[test]
458    fn parses_record_subindices() {
459        let eds = sample();
460        assert_eq!(
461            eds.get(Address::new(0x1018, 0)).unwrap().default_value,
462            Value::Unsigned8(1)
463        );
464        assert_eq!(
465            eds.get(Address::new(0x1018, 1)).unwrap().default_value,
466            Value::Unsigned32(0x0000_00AB)
467        );
468    }
469
470    #[test]
471    fn resolves_nodeid_expression() {
472        // $NODEID (0x10) + 0x200 = 0x210.
473        let obj = sample().get(Address::new(0x1400, 1)).unwrap().clone();
474        assert_eq!(obj.default_value, Value::Unsigned32(0x210));
475    }
476
477    #[test]
478    fn parses_float_and_pdo_flag() {
479        let eds = sample();
480        assert_eq!(
481            eds.get(Address::new(0x6000, 0)).unwrap().default_value,
482            Value::Real32(1.5)
483        );
484        assert!(eds.get(Address::new(0x1017, 0)).unwrap().pdo_mappable);
485        assert!(!eds.get(Address::new(0x1000, 0)).unwrap().pdo_mappable);
486    }
487
488    #[test]
489    fn parses_visible_string_object() {
490        // 0x1008 Device name is VISIBLE_STRING (0x09), default "Widget".
491        let obj = sample().get(Address::new(0x1008, 0)).unwrap().clone();
492        assert_eq!(obj.data_type, DataType::VisibleString);
493        match obj.default_value {
494            Value::VisibleString(bs) => assert_eq!(bs.as_str(), Some("Widget")),
495            other => panic!("expected VISIBLE_STRING, got {other:?}"),
496        }
497    }
498
499    #[test]
500    fn builds_object_dictionary() {
501        let eds = sample();
502        let od = eds.object_dictionary::<16>().unwrap();
503        assert_eq!(
504            od.read(Address::new(0x1000, 0)).unwrap(),
505            Value::Unsigned32(0x0004_0192)
506        );
507        assert_eq!(
508            od.read(Address::new(0x1017, 0)).unwrap(),
509            Value::Unsigned16(1000)
510        );
511    }
512
513    #[test]
514    fn object_dictionary_capacity_is_enforced() {
515        // The sample has more than two modelled objects.
516        assert!(matches!(
517            sample().object_dictionary::<2>(),
518            Err(EdsError::TooManyObjects)
519        ));
520    }
521
522    #[test]
523    fn unknown_access_type_errors() {
524        let text = "[2000]\nDataType=0x0005\nAccessType=bogus\n";
525        assert!(matches!(
526            Eds::parse(text),
527            Err(EdsError::UnknownAccessType(_))
528        ));
529    }
530}