Skip to content

Commit 064b334

Browse files
bizfscFedericoSpada
andcommitted
eds: Support custom_options for unknown EDS fields
When importing an EDS/DCF file, any key that is not part of the CiA 306 standard field set is now collected into a custom_options dict on the object. This allows applications to round-trip vendor-specific metadata without losing it. Co-authored-by: FedericoSpada <FedericoSpada@users.noreply.github.com>
1 parent 4e789fe commit 064b334

4 files changed

Lines changed: 113 additions & 2 deletions

File tree

canopen/objectdictionary/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ def __init__(self, name: str, index: int):
209209
self.storage_location = None
210210
self.subindices = {}
211211
self.names = {}
212+
#: Key-Value pairs not defined by the standard
213+
self.custom_options = {}
212214

213215
def __repr__(self) -> str:
214216
return f"<{type(self).__qualname__} {self.name!r} at {pretty_index(self.index)}>"
@@ -268,6 +270,8 @@ def __init__(self, name: str, index: int):
268270
self.storage_location = None
269271
self.subindices = {}
270272
self.names = {}
273+
#: Key-Value pairs not defined by the standard
274+
self.custom_options = {}
271275

272276
def __repr__(self) -> str:
273277
return f"<{type(self).__qualname__} {self.name!r} at {pretty_index(self.index)}>"
@@ -374,6 +378,8 @@ def __init__(self, name: str, index: int, subindex: int = 0):
374378
self.storage_location = None
375379
#: Can this variable be mapped to a PDO
376380
self.pdo_mappable = False
381+
#: Key-Value pairs not defined by the standard
382+
self.custom_options = {}
377383

378384
def __repr__(self) -> str:
379385
subindex = self.subindex if isinstance(self.parent, (ODRecord, ODArray)) else None

canopen/objectdictionary/eds.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
logger = logging.getLogger(__name__)
2424

25+
2526
def import_eds(source, node_id):
2627
eds = RawConfigParser(inline_comment_prefixes=(';',))
2728
eds.optionxform = str
@@ -133,20 +134,22 @@ def import_eds(source, node_id):
133134
od.add_object(var)
134135
elif object_type == objectcodes.ARRAY and eds.has_option(section, "CompactSubObj"):
135136
arr = ODArray(name, index)
136-
last_subindex = ODVariable(
137-
"Number of entries", index, 0)
137+
last_subindex = ODVariable("Number of entries", index, 0)
138138
last_subindex.data_type = datatypes.UNSIGNED8
139139
arr.add_member(last_subindex)
140140
arr.add_member(build_variable(eds, section, node_id, index, 1))
141141
arr.storage_location = storage_location
142+
arr.custom_options = _get_custom_options(eds, section)
142143
od.add_object(arr)
143144
elif object_type == objectcodes.ARRAY:
144145
arr = ODArray(name, index)
145146
arr.storage_location = storage_location
147+
arr.custom_options = _get_custom_options(eds, section)
146148
od.add_object(arr)
147149
elif object_type == objectcodes.RECORD:
148150
record = ODRecord(name, index)
149151
record.storage_location = storage_location
152+
record.custom_options = _get_custom_options(eds, section)
150153
od.add_object(record)
151154

152155
continue
@@ -251,6 +254,24 @@ def _revert_variable(var_type, value):
251254
else:
252255
return f"0x{value:02X}"
253256

257+
_STANDARD_OPTIONS = {
258+
"ObjectType", "ParameterName", "DataType", "AccessType",
259+
"PDOMapping", "LowLimit", "HighLimit", "DefaultValue",
260+
"ParameterValue", "Factor", "Description", "Unit",
261+
"StorageLocation", "CompactSubObj",
262+
# CiA 306 fields parsed explicitly:
263+
"SubNumber",
264+
# ObjFlags and Denotation are intentionally absent here so they are
265+
# preserved via custom_options until proper support is added in #654.
266+
}
267+
268+
def _get_custom_options(eds, section):
269+
custom_options = {}
270+
for option, value in eds.items(section):
271+
if option not in _STANDARD_OPTIONS:
272+
custom_options[option] = value
273+
return custom_options
274+
254275

255276
def build_variable(eds, section, node_id, index, subindex=0):
256277
"""Creates a object dictionary entry.
@@ -330,6 +351,8 @@ def build_variable(eds, section, node_id, index, subindex=0):
330351
var.unit = eds.get(section, "Unit")
331352
except ValueError:
332353
pass
354+
355+
var.custom_options = _get_custom_options(eds, section)
333356
return var
334357

335358

@@ -404,12 +427,17 @@ def export_variable(var, eds):
404427
if getattr(var, 'unit', '') != '':
405428
eds.set(section, "Unit", var.unit)
406429

430+
for option, value in var.custom_options.items():
431+
eds.set(section, option, str(value))
432+
407433
def export_record(var, eds):
408434
section = f"{var.index:04X}"
409435
export_common(var, eds, section)
410436
eds.set(section, "SubNumber", f"0x{len(var.subindices):X}")
411437
ot = objectcodes.RECORD if isinstance(var, ODRecord) else objectcodes.ARRAY
412438
eds.set(section, "ObjectType", f"0x{ot:X}")
439+
for option, value in var.custom_options.items():
440+
eds.set(section, option, str(value))
413441
for i in var:
414442
export_variable(var[i], eds)
415443

test/sample.eds

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,3 +1017,33 @@ PDOMapping=0x0
10171017
Factor=ERROR
10181018
Description=
10191019
Unit=
1020+
1021+
[3061]
1022+
ParameterName=Object with custom options
1023+
ObjectType=0x7
1024+
DataType=0x0007
1025+
AccessType=rw
1026+
PDOMapping=0
1027+
Category=Motor
1028+
Offset=100
1029+
1030+
[3062]
1031+
ParameterName=Record with custom options
1032+
SubNumber=0x2
1033+
ObjectType=0x9
1034+
RecordTag=vendor_specific
1035+
1036+
[3062sub0]
1037+
ParameterName=Highest subindex
1038+
ObjectType=0x7
1039+
DataType=0x0005
1040+
AccessType=ro
1041+
DefaultValue=0x01
1042+
PDOMapping=0
1043+
1044+
[3062sub1]
1045+
ParameterName=Value
1046+
ObjectType=0x7
1047+
DataType=0x0007
1048+
AccessType=rw
1049+
PDOMapping=0

test/test_eds.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,53 @@ def test_reading_factor(self):
213213
self.assertEqual(var2.factor, 1)
214214
self.assertEqual(var2.unit, '')
215215

216+
def test_reading_custom_options(self):
217+
# custom options (unknown EDS keys) are collected in custom_options dict
218+
var = self.od[0x3061]
219+
self.assertIsInstance(var, canopen.objectdictionary.ODVariable)
220+
self.assertEqual(var.custom_options, {'Category': 'Motor', 'Offset': '100'})
221+
222+
def test_custom_options_standard_keys_excluded(self):
223+
# Standard CiA 306 keys must NOT appear in custom_options
224+
var = self.od[0x3061]
225+
for key in ('ParameterName', 'ObjectType', 'DataType', 'AccessType', 'PDOMapping'):
226+
self.assertNotIn(key, var.custom_options,
227+
f"Standard key {key!r} must not be in custom_options")
228+
229+
def test_custom_options_empty_for_standard_object(self):
230+
# Objects without extra keys must have an empty custom_options dict
231+
var = self.od['Producer heartbeat time']
232+
self.assertEqual(var.custom_options, {})
233+
234+
def test_custom_options_record(self):
235+
# custom_options is read for ODRecord container objects too
236+
record = self.od[0x3062]
237+
self.assertIsInstance(record, canopen.objectdictionary.ODRecord)
238+
self.assertEqual(record.custom_options, {'RecordTag': 'vendor_specific'})
239+
# sub-entries without extra keys have empty custom_options
240+
self.assertEqual(record[1].custom_options, {})
241+
242+
def test_roundtrip_custom_options(self):
243+
# custom_options survive an EDS export/import round-trip
244+
import io
245+
with io.StringIO() as dest:
246+
canopen.export_od(self.od, dest, 'eds')
247+
dest.name = 'mock.eds'
248+
dest.seek(0)
249+
od2 = canopen.import_od(dest)
250+
self.assertEqual(od2[0x3061].custom_options, {'Category': 'Motor', 'Offset': '100'})
251+
self.assertEqual(od2[0x3062].custom_options, {'RecordTag': 'vendor_specific'})
252+
253+
def test_roundtrip_custom_options_not_duplicated_as_standard(self):
254+
# After round-trip the re-imported object must not contain standard keys
255+
import io
256+
with io.StringIO() as dest:
257+
canopen.export_od(self.od, dest, 'eds')
258+
dest.name = 'mock.eds'
259+
dest.seek(0)
260+
od2 = canopen.import_od(dest)
261+
for key in ('ParameterName', 'ObjectType', 'DataType', 'AccessType', 'PDOMapping'):
262+
self.assertNotIn(key, od2[0x3061].custom_options)
216263

217264

218265
def test_comments(self):

0 commit comments

Comments
 (0)