Skip to content

Commit 8c780ba

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 7f8347f commit 8c780ba

4 files changed

Lines changed: 121 additions & 2 deletions

File tree

canopen/objectdictionary/__init__.py

Lines changed: 8 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: Optional[str] = None
210210
self.subindices: dict[int, ODVariable] = {}
211211
self.names: dict[str, ODVariable] = {}
212+
#: Key-Value pairs not defined by the standard
213+
self.custom_options: dict[str, str] = {}
212214

213215
def __repr__(self) -> str:
214216
return f"<{type(self).__qualname__} {self.name!r} at {pretty_index(self.index)}>"
@@ -270,6 +272,8 @@ def __init__(self, name: str, index: int):
270272
self.storage_location: Optional[str] = None
271273
self.subindices: dict[int, ODVariable] = {}
272274
self.names: dict[str, ODVariable] = {}
275+
#: Key-Value pairs not defined by the standard
276+
self.custom_options: dict[str, str] = {}
273277

274278
def __repr__(self) -> str:
275279
return f"<{type(self).__qualname__} {self.name!r} at {pretty_index(self.index)}>"
@@ -290,6 +294,8 @@ def __getitem__(self, subindex: Union[int, str]) -> ODVariable:
290294
"bit_definitions", "storage_location"):
291295
if attr in template.__dict__:
292296
var.__dict__[attr] = template.__dict__[attr]
297+
if "custom_options" in template.__dict__:
298+
var.__dict__["custom_options"] = template.__dict__["custom_options"].copy()
293299
else:
294300
raise KeyError(f"Could not find subindex {pretty_index(None, subindex)}")
295301
return var
@@ -380,6 +386,8 @@ def __init__(self, name: str, index: int, subindex: int = 0):
380386
self.storage_location: Optional[str] = None
381387
#: Can this variable be mapped to a PDO
382388
self.pdo_mappable = False
389+
#: Key-Value pairs not defined by the standard
390+
self.custom_options: dict[str, str] = {}
383391

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

canopen/objectdictionary/eds.py

Lines changed: 35 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, object_type, 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
@@ -257,6 +260,25 @@ def _revert_variable(var_type, value):
257260
else:
258261
return f"0x{value:02X}"
259262

263+
_STANDARD_OPTIONS = {
264+
"ObjectType", "ParameterName", "DataType", "AccessType",
265+
"PDOMapping", "LowLimit", "HighLimit", "DefaultValue",
266+
"ParameterValue", "Factor", "Description", "Unit",
267+
"StorageLocation", "CompactSubObj",
268+
# CiA 306 fields parsed explicitly:
269+
"SubNumber",
270+
# ObjFlags and Denotation are intentionally absent: they are not yet
271+
# parsed by this codebase, so they flow through custom_options and
272+
# survive round-trips. Proper first-class support is tracked in #654.
273+
}
274+
275+
def _get_custom_options(eds, section):
276+
custom_options = {}
277+
for option, value in eds.items(section):
278+
if option not in _STANDARD_OPTIONS:
279+
custom_options[option] = value
280+
return custom_options
281+
260282

261283
def build_variable(
262284
eds: RawConfigParser,
@@ -350,6 +372,8 @@ def build_variable(
350372
var.unit = eds.get(section, "Unit")
351373
except ValueError:
352374
pass
375+
376+
var.custom_options = _get_custom_options(eds, section)
353377
return var
354378

355379

@@ -359,6 +383,8 @@ def copy_variable(eds, section, subindex, src_var):
359383
# It is only the name and subindex that varies
360384
var.name = name
361385
var.subindex = subindex
386+
# Give the copy its own custom_options dict to avoid shared-state mutations
387+
var.custom_options = src_var.custom_options.copy()
362388
return var
363389

364390

@@ -425,12 +451,19 @@ def export_variable(var, eds):
425451
if getattr(var, 'unit', '') != '':
426452
eds.set(section, "Unit", var.unit)
427453

454+
for option, value in var.custom_options.items():
455+
if option not in _STANDARD_OPTIONS:
456+
eds.set(section, option, str(value))
457+
428458
def export_record(var, eds):
429459
section = f"{var.index:04X}"
430460
export_common(var, eds, section)
431461
eds.set(section, "SubNumber", f"0x{len(var.subindices):X}")
432462
ot = objectcodes.RECORD if isinstance(var, ODRecord) else objectcodes.ARRAY
433463
eds.set(section, "ObjectType", f"0x{ot:X}")
464+
for option, value in var.custom_options.items():
465+
if option not in _STANDARD_OPTIONS:
466+
eds.set(section, option, str(value))
434467
for i in var:
435468
export_variable(var[i], eds)
436469

test/sample.eds

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,6 +1018,36 @@ Factor=ERROR
10181018
Description=
10191019
Unit=
10201020

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
1050+
10211051
[3063]
10221052
ParameterName=DOMAIN object
10231053
ObjectType=0x2

test/test_eds.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,54 @@ def test_roundtrip_domain_objects(self):
251251
self.assertTrue(od2[0x3063].is_domain)
252252
self.assertTrue(od2[0x3064][1].is_domain)
253253

254+
def test_reading_custom_options(self):
255+
# custom options (unknown EDS keys) are collected in custom_options dict
256+
var = self.od[0x3061]
257+
self.assertIsInstance(var, canopen.objectdictionary.ODVariable)
258+
self.assertEqual(var.custom_options, {'Category': 'Motor', 'Offset': '100'})
259+
260+
def test_custom_options_standard_keys_excluded(self):
261+
# Standard CiA 306 keys must NOT appear in custom_options
262+
var = self.od[0x3061]
263+
for key in ('ParameterName', 'ObjectType', 'DataType', 'AccessType', 'PDOMapping'):
264+
self.assertNotIn(key, var.custom_options,
265+
f"Standard key {key!r} must not be in custom_options")
266+
267+
def test_custom_options_empty_for_standard_object(self):
268+
# Objects without extra keys must have an empty custom_options dict
269+
var = self.od['Producer heartbeat time']
270+
self.assertEqual(var.custom_options, {})
271+
272+
def test_custom_options_record(self):
273+
# custom_options is read for ODRecord container objects too
274+
record = self.od[0x3062]
275+
self.assertIsInstance(record, canopen.objectdictionary.ODRecord)
276+
self.assertEqual(record.custom_options, {'RecordTag': 'vendor_specific'})
277+
# sub-entries without extra keys have empty custom_options
278+
self.assertEqual(record[1].custom_options, {})
279+
280+
def test_roundtrip_custom_options(self):
281+
# custom_options survive an EDS export/import round-trip
282+
import io
283+
with io.StringIO() as dest:
284+
canopen.export_od(self.od, dest, 'eds')
285+
dest.name = 'mock.eds'
286+
dest.seek(0)
287+
od2 = canopen.import_od(dest)
288+
self.assertEqual(od2[0x3061].custom_options, {'Category': 'Motor', 'Offset': '100'})
289+
self.assertEqual(od2[0x3062].custom_options, {'RecordTag': 'vendor_specific'})
290+
291+
def test_roundtrip_custom_options_not_duplicated_as_standard(self):
292+
# After round-trip the re-imported object must not contain standard keys
293+
import io
294+
with io.StringIO() as dest:
295+
canopen.export_od(self.od, dest, 'eds')
296+
dest.name = 'mock.eds'
297+
dest.seek(0)
298+
od2 = canopen.import_od(dest)
299+
for key in ('ParameterName', 'ObjectType', 'DataType', 'AccessType', 'PDOMapping'):
300+
self.assertNotIn(key, od2[0x3061].custom_options)
301+
254302

255303
def test_comments(self):
256304
self.assertEqual(self.od.comments,

0 commit comments

Comments
 (0)