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::{DataType, Value};
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    // Floating-point types parse the literal directly.
311    match data_type {
312        DataType::Real32 => {
313            return Some(Value::Real32(if s.is_empty() {
314                0.0
315            } else {
316                s.parse().ok()?
317            }))
318        }
319        DataType::Real64 => {
320            return Some(Value::Real64(if s.is_empty() {
321                0.0
322            } else {
323                s.parse().ok()?
324            }))
325        }
326        _ => {}
327    }
328    // Integer types evaluate a (possibly `$NODEID`-relative) expression.
329    let n = if s.is_empty() {
330        0
331    } else {
332        eval_int_expr(s, node_id)?
333    };
334    Some(match data_type {
335        DataType::Boolean => Value::Boolean(n != 0),
336        DataType::Integer8 => Value::Integer8(n as i8),
337        DataType::Integer16 => Value::Integer16(n as i16),
338        DataType::Integer32 => Value::Integer32(n as i32),
339        DataType::Unsigned8 => Value::Unsigned8(n as u8),
340        DataType::Unsigned16 => Value::Unsigned16(n as u16),
341        DataType::Unsigned32 => Value::Unsigned32(n as u32),
342        DataType::Integer64 => Value::Integer64(n as i64),
343        DataType::Unsigned64 => Value::Unsigned64(n as u64),
344        // Reals handled above; `#[non_exhaustive]` requires this catch-all.
345        _ => return None,
346    })
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    // A compact but representative EDS: device info, node id, a VAR, a RECORD
354    // with subindices, a $NODEID COB-ID, a hex value, a float, and a skipped
355    // string object.
356    const SAMPLE: &str = "\
357[DeviceInfo]
358VendorName=Acme Robotics
359ProductName=Widget 3000
360
361[DeviceComissioning]
362NodeID=0x10
363
364[MandatoryObjects]
365SupportedObjects=2
3661=0x1000
3672=0x1018
368
369[1000]
370ParameterName=Device type
371ObjectType=0x7
372DataType=0x0007
373AccessType=ro
374DefaultValue=0x00040192
375PDOMapping=0
376
377[1008]
378ParameterName=Device name
379ObjectType=0x7
380DataType=0x0009
381AccessType=const
382DefaultValue=Widget
383
384[1017]
385ParameterName=Producer heartbeat time
386ObjectType=0x7
387DataType=0x0006
388AccessType=rw
389DefaultValue=1000
390PDOMapping=1
391
392[1018]
393ParameterName=Identity object
394ObjectType=0x9
395SubNumber=2
396
397[1018sub0]
398ParameterName=Highest sub-index supported
399DataType=0x0005
400AccessType=const
401DefaultValue=1
402
403[1018sub1]
404ParameterName=Vendor-ID
405DataType=0x0007
406AccessType=ro
407DefaultValue=0x000000AB
408
409[1400sub1]
410ParameterName=COB-ID used by RPDO
411DataType=0x0007
412AccessType=rw
413DefaultValue=$NODEID+0x200
414
415[6000]
416ParameterName=Scale factor
417ObjectType=0x7
418DataType=0x0008
419AccessType=rw
420DefaultValue=1.5
421";
422
423    fn sample() -> Eds {
424        Eds::parse(SAMPLE).unwrap()
425    }
426
427    #[test]
428    fn reads_device_metadata_and_node_id() {
429        let eds = sample();
430        assert_eq!(eds.vendor_name.as_deref(), Some("Acme Robotics"));
431        assert_eq!(eds.product_name.as_deref(), Some("Widget 3000"));
432        assert_eq!(eds.node_id, Some(0x10));
433    }
434
435    #[test]
436    fn parses_var_with_hex_default() {
437        let obj = sample().get(Address::new(0x1000, 0)).unwrap().clone();
438        assert_eq!(obj.parameter_name, "Device type");
439        assert_eq!(obj.data_type, DataType::Unsigned32);
440        assert_eq!(obj.access, AccessType::Ro);
441        assert_eq!(obj.default_value, Value::Unsigned32(0x0004_0192));
442    }
443
444    #[test]
445    fn parses_record_subindices() {
446        let eds = sample();
447        assert_eq!(
448            eds.get(Address::new(0x1018, 0)).unwrap().default_value,
449            Value::Unsigned8(1)
450        );
451        assert_eq!(
452            eds.get(Address::new(0x1018, 1)).unwrap().default_value,
453            Value::Unsigned32(0x0000_00AB)
454        );
455    }
456
457    #[test]
458    fn resolves_nodeid_expression() {
459        // $NODEID (0x10) + 0x200 = 0x210.
460        let obj = sample().get(Address::new(0x1400, 1)).unwrap().clone();
461        assert_eq!(obj.default_value, Value::Unsigned32(0x210));
462    }
463
464    #[test]
465    fn parses_float_and_pdo_flag() {
466        let eds = sample();
467        assert_eq!(
468            eds.get(Address::new(0x6000, 0)).unwrap().default_value,
469            Value::Real32(1.5)
470        );
471        assert!(eds.get(Address::new(0x1017, 0)).unwrap().pdo_mappable);
472        assert!(!eds.get(Address::new(0x1000, 0)).unwrap().pdo_mappable);
473    }
474
475    #[test]
476    fn skips_unmodelled_string_object() {
477        // 0x1008 Device name is VISIBLE_STRING (0x09) — not represented yet.
478        assert!(sample().get(Address::new(0x1008, 0)).is_none());
479    }
480
481    #[test]
482    fn builds_object_dictionary() {
483        let eds = sample();
484        let od = eds.object_dictionary::<16>().unwrap();
485        assert_eq!(
486            od.read(Address::new(0x1000, 0)).unwrap(),
487            Value::Unsigned32(0x0004_0192)
488        );
489        assert_eq!(
490            od.read(Address::new(0x1017, 0)).unwrap(),
491            Value::Unsigned16(1000)
492        );
493    }
494
495    #[test]
496    fn object_dictionary_capacity_is_enforced() {
497        // The sample has more than two modelled objects.
498        assert!(matches!(
499            sample().object_dictionary::<2>(),
500            Err(EdsError::TooManyObjects)
501        ));
502    }
503
504    #[test]
505    fn unknown_access_type_errors() {
506        let text = "[2000]\nDataType=0x0005\nAccessType=bogus\n";
507        assert!(matches!(
508            Eds::parse(text),
509            Err(EdsError::UnknownAccessType(_))
510        ));
511    }
512}