Skip to content

Commit 4430483

Browse files
committed
eds: Add first-class support for CiA 306 ObjFlags and Denotation
- ODVariable, ODRecord and ODArray gain obj_flags: int = 0 - ODVariable, ODRecord and ODArray gain denotation: str = "" (Denotation also added to container types per reviewer request) - New _get_obj_flags() helper reads and validates ObjFlags integer - ObjFlags parsed for VAR/DOMAIN (build_variable), ARRAY and RECORD - Denotation parsed for all object types - ObjFlags exported when non-zero; Denotation exported in DCF mode only - __repr__ shows flags=0xN when obj_flags is non-zero (all three types) Closes review comment by acolomb: denotation now applied to all object types; obj_flags visible in string representation.
1 parent 1d6bd8d commit 4430483

4 files changed

Lines changed: 156 additions & 3 deletions

File tree

canopen/objectdictionary/__init__.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,16 @@ def __init__(self, name: str, index: int):
208208
self.name = name
209209
#: Storage location of index
210210
self.storage_location: Optional[str] = None
211+
#: CiA 306 ObjFlags bitfield
212+
self.obj_flags: int = 0
213+
#: CiA 306 Denotation string (DCF only)
214+
self.denotation: str = ""
211215
self.subindices: dict[int, ODVariable] = {}
212216
self.names: dict[str, ODVariable] = {}
213217

214218
def __repr__(self) -> str:
215-
return f"<{type(self).__qualname__} {self.name!r} at {pretty_index(self.index)}>"
219+
flags = f" flags=0x{self.obj_flags:X}" if self.obj_flags else ""
220+
return f"<{type(self).__qualname__} {self.name!r} at {pretty_index(self.index)}{flags}>"
216221

217222
def __getitem__(self, subindex: Union[int, str]) -> ODVariable:
218223
item = self.names.get(subindex) or self.subindices.get(subindex)
@@ -269,11 +274,16 @@ def __init__(self, name: str, index: int):
269274
self.name = name
270275
#: Storage location of index
271276
self.storage_location: Optional[str] = None
277+
#: CiA 306 ObjFlags bitfield
278+
self.obj_flags: int = 0
279+
#: CiA 306 Denotation string (DCF only)
280+
self.denotation: str = ""
272281
self.subindices: dict[int, ODVariable] = {}
273282
self.names: dict[str, ODVariable] = {}
274283

275284
def __repr__(self) -> str:
276-
return f"<{type(self).__qualname__} {self.name!r} at {pretty_index(self.index)}>"
285+
flags = f" flags=0x{self.obj_flags:X}" if self.obj_flags else ""
286+
return f"<{type(self).__qualname__} {self.name!r} at {pretty_index(self.index)}{flags}>"
277287

278288
def __getitem__(self, subindex: Union[int, str]) -> ODVariable:
279289
var = self.names.get(subindex) or self.subindices.get(subindex)
@@ -379,12 +389,17 @@ def __init__(self, name: str, index: int, subindex: int = 0):
379389
self.bit_definitions: dict[str, list[int]] = {}
380390
#: Storage location of index
381391
self.storage_location: Optional[str] = None
392+
#: CiA 306 ObjFlags bitfield
393+
self.obj_flags: int = 0
394+
#: CiA 306 Denotation string (DCF only)
395+
self.denotation: str = ""
382396
#: Can this variable be mapped to a PDO
383397
self.pdo_mappable = False
384398

385399
def __repr__(self) -> str:
386400
subindex = self.subindex if isinstance(self.parent, (ODRecord, ODArray)) else None
387-
return f"<{type(self).__qualname__} {self.qualname!r} at {pretty_index(self.index, subindex)}>"
401+
flags = f" flags=0x{self.obj_flags:X}" if self.obj_flags else ""
402+
return f"<{type(self).__qualname__} {self.qualname!r} at {pretty_index(self.index, subindex)}{flags}>"
388403

389404
@property
390405
def qualname(self) -> str:

canopen/objectdictionary/eds.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,14 +139,20 @@ def import_eds(source, node_id):
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.obj_flags = _get_obj_flags(eds, section)
143+
arr.denotation = eds.get(section, "Denotation") if eds.has_option(section, "Denotation") else ""
142144
od.add_object(arr)
143145
elif object_type == objectcodes.ARRAY:
144146
arr = ODArray(name, index)
145147
arr.storage_location = storage_location
148+
arr.obj_flags = _get_obj_flags(eds, section)
149+
arr.denotation = eds.get(section, "Denotation") if eds.has_option(section, "Denotation") else ""
146150
od.add_object(arr)
147151
elif object_type == objectcodes.RECORD:
148152
record = ODRecord(name, index)
149153
record.storage_location = storage_location
154+
record.obj_flags = _get_obj_flags(eds, section)
155+
record.denotation = eds.get(section, "Denotation") if eds.has_option(section, "Denotation") else ""
150156
od.add_object(record)
151157

152158
continue
@@ -258,6 +264,15 @@ def _revert_variable(var_type, value):
258264
return f"0x{value:02X}"
259265

260266

267+
def _get_obj_flags(eds, section):
268+
if eds.has_option(section, "ObjFlags"):
269+
try:
270+
return int(eds.get(section, "ObjFlags"), 0)
271+
except ValueError:
272+
pass
273+
return 0
274+
275+
261276
def build_variable(
262277
eds: RawConfigParser,
263278
section: str,
@@ -350,6 +365,9 @@ def build_variable(
350365
var.unit = eds.get(section, "Unit")
351366
except ValueError:
352367
pass
368+
var.obj_flags = _get_obj_flags(eds, section)
369+
if eds.has_option(section, "Denotation"):
370+
var.denotation = eds.get(section, "Denotation")
353371
return var
354372

355373

@@ -425,12 +443,21 @@ def export_variable(var, eds):
425443
if getattr(var, 'unit', '') != '':
426444
eds.set(section, "Unit", var.unit)
427445

446+
if getattr(var, 'obj_flags', 0) != 0:
447+
eds.set(section, "ObjFlags", f"0x{var.obj_flags:X}")
448+
if device_commisioning and getattr(var, 'denotation', '') != '':
449+
eds.set(section, "Denotation", var.denotation)
450+
428451
def export_record(var, eds):
429452
section = f"{var.index:04X}"
430453
export_common(var, eds, section)
431454
eds.set(section, "SubNumber", f"0x{len(var.subindices):X}")
432455
ot = objectcodes.RECORD if isinstance(var, ODRecord) else objectcodes.ARRAY
433456
eds.set(section, "ObjectType", f"0x{ot:X}")
457+
if getattr(var, 'obj_flags', 0) != 0:
458+
eds.set(section, "ObjFlags", f"0x{var.obj_flags:X}")
459+
if device_commisioning and getattr(var, 'denotation', '') != '':
460+
eds.set(section, "Denotation", var.denotation)
434461
for i in var:
435462
export_variable(var[i], eds)
436463

test/sample.eds

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,6 +1025,14 @@ DataType=0x0007
10251025
AccessType=rw
10261026
PDOMapping=0
10271027

1028+
[3060]
1029+
ParameterName=Object with ObjFlags
1030+
ObjectType=0x7
1031+
DataType=0x0007
1032+
AccessType=rw
1033+
PDOMapping=0
1034+
ObjFlags=0x1
1035+
10281036
[3064]
10291037
ParameterName=Record with DOMAIN sub-object
10301038
SubNumber=0x2
@@ -1044,3 +1052,24 @@ ObjectType=0x2
10441052
DataType=0x0007
10451053
AccessType=rw
10461054
PDOMapping=0
1055+
1056+
[3065]
1057+
ParameterName=Record with ObjFlags
1058+
ObjectType=0x9
1059+
ObjFlags=0x3
1060+
SubNumber=0x2
1061+
1062+
[3065sub0]
1063+
ParameterName=Highest sub-index supported
1064+
ObjectType=0x7
1065+
DataType=0x0005
1066+
AccessType=ro
1067+
DefaultValue=0x01
1068+
PDOMapping=0
1069+
1070+
[3065sub1]
1071+
ParameterName=Value
1072+
ObjectType=0x7
1073+
DataType=0x0007
1074+
AccessType=rw
1075+
PDOMapping=0

test/test_eds.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,88 @@ def verify_od(self, source, doctype):
396396

397397
self.assertEqual(self.od.comments, exported_od.comments)
398398

399+
def test_reading_obj_flags(self):
400+
var = self.od[0x3060]
401+
self.assertIsInstance(var, canopen.objectdictionary.ODVariable)
402+
self.assertEqual(var.obj_flags, 0x1)
403+
404+
def test_reading_obj_flags_default(self):
405+
"""Standard objects without ObjFlags must have obj_flags == 0."""
406+
var = self.od[0x1017] # Producer heartbeat time — no ObjFlags in sample.eds
407+
self.assertEqual(var.obj_flags, 0)
408+
409+
def test_reading_obj_flags_record(self):
410+
record = self.od[0x3065]
411+
self.assertIsInstance(record, canopen.objectdictionary.ODRecord)
412+
self.assertEqual(record.obj_flags, 0x3)
413+
414+
def test_roundtrip_obj_flags(self):
415+
import io
416+
with io.StringIO() as dest:
417+
canopen.export_od(self.od, dest, 'eds')
418+
dest.name = 'mock.eds'
419+
dest.seek(0)
420+
od2 = canopen.import_od(dest)
421+
self.assertEqual(od2[0x3060].obj_flags, 0x1)
422+
self.assertEqual(od2[0x1017].obj_flags, 0)
423+
424+
def test_roundtrip_obj_flags_record(self):
425+
import io
426+
with io.StringIO() as dest:
427+
canopen.export_od(self.od, dest, 'eds')
428+
dest.name = 'mock.eds'
429+
dest.seek(0)
430+
od2 = canopen.import_od(dest)
431+
self.assertEqual(od2[0x3065].obj_flags, 0x3)
432+
433+
def test_invalid_obj_flags_returns_zero(self):
434+
import configparser
435+
from canopen.objectdictionary.eds import _get_obj_flags
436+
eds = configparser.RawConfigParser()
437+
eds.optionxform = str
438+
eds.add_section("3060")
439+
eds.set("3060", "ObjFlags", "not_a_number")
440+
self.assertEqual(_get_obj_flags(eds, "3060"), 0)
441+
442+
def test_denotation_roundtrip_dcf(self):
443+
import io
444+
self.od[0x3060].denotation = 'FlaggedObject'
445+
with io.StringIO() as dest:
446+
canopen.export_od(self.od, dest, 'dcf')
447+
dest.name = 'mock.dcf'
448+
dest.seek(0)
449+
od2 = canopen.import_od(dest)
450+
self.assertEqual(od2[0x3060].denotation, 'FlaggedObject')
451+
452+
def test_denotation_not_exported_in_eds_mode(self):
453+
import io
454+
self.od[0x3060].denotation = 'ShouldNotAppear'
455+
with io.StringIO() as dest:
456+
canopen.export_od(self.od, dest, 'eds')
457+
dest.name = 'mock.eds'
458+
dest.seek(0)
459+
od2 = canopen.import_od(dest)
460+
self.assertEqual(od2[0x3060].denotation, '')
461+
462+
def test_obj_flags_in_repr(self):
463+
var = self.od[0x3060]
464+
self.assertIn("flags=0x1", repr(var))
465+
record = self.od[0x3065]
466+
self.assertIn("flags=0x3", repr(record))
467+
# zero flags must not clutter repr
468+
self.assertNotIn("flags", repr(self.od[0x1017]))
469+
470+
def test_denotation_record_roundtrip_dcf(self):
471+
"""Denotation on ODRecord/ODArray is preserved in DCF round-trip."""
472+
import io
473+
self.od[0x3065].denotation = 'RecordLabel'
474+
with io.StringIO() as dest:
475+
canopen.export_od(self.od, dest, 'dcf')
476+
dest.name = 'mock.dcf'
477+
dest.seek(0)
478+
od2 = canopen.import_od(dest)
479+
self.assertEqual(od2[0x3065].denotation, 'RecordLabel')
480+
399481

400482
if __name__ == "__main__":
401483
unittest.main()

0 commit comments

Comments
 (0)