1use 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#[derive(Debug)]
38pub enum EdsError {
39 Io(io::Error),
41 MissingField {
43 section: String,
45 field: &'static str,
47 },
48 UnknownAccessType(String),
50 InvalidValue {
52 section: String,
54 value: String,
56 },
57 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#[derive(Debug, Clone)]
94pub struct ObjectDescription {
95 pub address: Address,
97 pub parameter_name: String,
99 pub data_type: DataType,
101 pub access: AccessType,
103 pub default_value: Value,
105 pub pdo_mappable: bool,
107}
108
109impl ObjectDescription {
110 pub fn entry(&self) -> Entry {
112 Entry {
113 value: self.default_value,
114 access: self.access,
115 }
116 }
117}
118
119#[derive(Debug, Clone)]
121pub struct Eds {
122 pub vendor_name: Option<String>,
124 pub product_name: Option<String>,
126 pub node_id: Option<u8>,
129 pub objects: Vec<ObjectDescription>,
131}
132
133impl Eds {
134 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 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 §ions {
153 let Some(address) = parse_object_section(name) else {
154 continue;
155 };
156 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 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 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Eds, EdsError> {
217 Self::parse(&fs::read_to_string(path)?)
218 }
219
220 pub fn get(&self, address: Address) -> Option<&ObjectDescription> {
222 self.objects.iter().find(|o| o.address == address)
223 }
224
225 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
239fn 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))) = (¤t, 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
265fn 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
288fn 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
307fn value_from_str(data_type: DataType, s: &str, node_id: u8) -> Option<Value> {
309 let s = s.trim();
310 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 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 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 _ => return None,
359 })
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 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 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 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 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}