Skip to content

Commit e138419

Browse files
committed
Merge remote-tracking branch 'origin/master' into fix/mypy-type-fixes
2 parents a802794 + 7f8347f commit e138419

13 files changed

Lines changed: 237 additions & 28 deletions

File tree

canopen/network.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ def connect(self, *args, **kwargs) -> Network:
107107
if self.bus is None:
108108
self.bus = can.Bus(*args, **kwargs)
109109
logger.info("Connected to '%s'", self.bus.channel_info)
110-
self.notifier = can.Notifier(self.bus, self.listeners, self.NOTIFIER_CYCLE)
110+
if self.notifier is None:
111+
self.notifier = can.Notifier(self.bus, self.listeners, self.NOTIFIER_CYCLE)
111112
return self
112113

113114
def disconnect(self) -> None:
@@ -123,7 +124,11 @@ def disconnect(self) -> None:
123124
if self.bus is not None:
124125
self.bus.shutdown()
125126
self.bus = None
126-
self.check()
127+
try:
128+
self.check()
129+
finally:
130+
# Release notifier after check
131+
self.notifier = None
127132

128133
def __enter__(self):
129134
return self

canopen/node/base.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class BaseNode:
88
"""A CANopen node.
99
1010
:param node_id:
11-
Node ID (set to None or 0 if specified by object dictionary)
11+
Node ID (set to 0 if specified by object dictionary)
1212
:param object_dictionary:
1313
Object dictionary as either a path to a file, an ``ObjectDictionary``
1414
or a file like object.
@@ -25,7 +25,9 @@ def __init__(
2525
object_dictionary = import_od(object_dictionary, node_id)
2626
self.object_dictionary = object_dictionary
2727

28-
self.id = node_id or self.object_dictionary.node_id
28+
self.id = node_id or object_dictionary.node_id or 0
29+
if not 1 <= self.id <= 127:
30+
raise ValueError(f"No valid Node ID provided, {self.id} not in range 1..127")
2931

3032
def has_network(self) -> bool:
3133
"""Check whether the node has been associated to a network."""

canopen/node/local.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@
1818

1919

2020
class LocalNode(BaseNode):
21+
"""Local CANopen node implementing essential communication services.
22+
23+
This does not provide a full-fledged communication logic stack, but needs
24+
additional application logic to wire up the various services, such as
25+
triggering PDO transmissions according to their communication parameters.
26+
27+
Notable exceptions are a local data store for SDO server access, and using
28+
the Heartbeat Producer Time parameter to control Heartbeat transmission.
29+
30+
:param node_id:
31+
Node ID (set to 0 if specified by object dictionary)
32+
:param object_dictionary:
33+
Object dictionary as either a path to a file, an ``ObjectDictionary``
34+
or a file like object.
35+
"""
2136

2237
def __init__(
2338
self,

canopen/node/remote.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class RemoteNode(BaseNode):
1919
"""A CANopen remote node.
2020
2121
:param node_id:
22-
Node ID (set to None or 0 if specified by object dictionary)
22+
Node ID (set to 0 if specified by object dictionary)
2323
:param object_dictionary:
2424
Object dictionary as either a path to a file, an ``ObjectDictionary``
2525
or a file like object.

canopen/objectdictionary/__init__.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ def __init__(self, name: str, index: int):
207207
#: Name of record
208208
self.name = name
209209
#: Storage location of index
210-
self.storage_location = None
210+
self.storage_location: Optional[str] = None
211211
self.subindices: dict[int, ODVariable] = {}
212212
self.names: dict[str, ODVariable] = {}
213213

@@ -268,7 +268,7 @@ def __init__(self, name: str, index: int):
268268
#: Name of array
269269
self.name = name
270270
#: Storage location of index
271-
self.storage_location = None
271+
self.storage_location: Optional[str] = None
272272
self.subindices: dict[int, ODVariable] = {}
273273
self.names: dict[str, ODVariable] = {}
274274

@@ -369,14 +369,16 @@ def __init__(self, name: str, index: int, subindex: int = 0):
369369
self.data_type: Optional[int] = None
370370
#: Access type, should be "rw", "ro", "wo", or "const"
371371
self.access_type: str = "rw"
372+
#: The variable represents a DOMAIN ObjectType
373+
self.is_domain: bool = False
372374
#: Description of variable
373375
self.description: str = ""
374376
#: Dictionary of value descriptions
375377
self.value_descriptions: dict[int, str] = {}
376378
#: Dictionary of bitfield definitions
377379
self.bit_definitions: dict[str, list[int]] = {}
378380
#: Storage location of index
379-
self.storage_location = None
381+
self.storage_location: Optional[str] = None
380382
#: Can this variable be mapped to a PDO
381383
self.pdo_mappable = False
382384

@@ -496,7 +498,8 @@ def decode_phys(self, value: Union[int, bool, float, str, bytes]) -> Union[int,
496498
def encode_phys(self, value: Union[int, bool, float, str, bytes]) -> int:
497499
if self.data_type in INTEGER_TYPES:
498500
assert isinstance(value, (int, float))
499-
value = int(round(value / self.factor))
501+
if self.factor != 1:
502+
value = round(value / self.factor)
500503
return value # type: ignore[return-value]
501504

502505
def decode_desc(self, value: int) -> str:

canopen/objectdictionary/eds.py

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def import_eds(source, node_id):
4141
od = ObjectDictionary()
4242

4343
if eds.has_section("FileInfo"):
44-
od.__edsFileInfo = {
44+
od.__edsFileInfo = { # type: ignore[attr-defined] # custom addition
4545
opt: eds.get("FileInfo", opt)
4646
for opt in eds.options("FileInfo")
4747
}
@@ -50,7 +50,7 @@ def import_eds(source, node_id):
5050
linecount = int(eds.get("Comments", "Lines"), 0)
5151
od.comments = '\n'.join([
5252
eds.get("Comments", f"Line{line}")
53-
for line in range(1, linecount+1)
53+
for line in range(1, linecount + 1)
5454
])
5555

5656
if not eds.has_section("DeviceInfo"):
@@ -129,15 +129,15 @@ def import_eds(source, node_id):
129129
storage_location = None
130130

131131
if object_type in (objectcodes.VAR, objectcodes.DOMAIN):
132-
var = build_variable(eds, section, node_id, index)
132+
var = build_variable(eds, section, node_id, object_type, index)
133133
od.add_object(var)
134134
elif object_type == objectcodes.ARRAY and eds.has_option(section, "CompactSubObj"):
135135
arr = ODArray(name, index)
136136
last_subindex = ODVariable(
137137
"Number of entries", index, 0)
138138
last_subindex.data_type = datatypes.UNSIGNED8
139139
arr.add_member(last_subindex)
140-
arr.add_member(build_variable(eds, section, node_id, index, 1))
140+
arr.add_member(build_variable(eds, section, node_id, object_type, index, 1))
141141
arr.storage_location = storage_location
142142
od.add_object(arr)
143143
elif object_type == objectcodes.ARRAY:
@@ -158,7 +158,11 @@ def import_eds(source, node_id):
158158
subindex = int(match.group(2), 16)
159159
entry = od[index]
160160
if isinstance(entry, (ODRecord, ODArray)):
161-
var = build_variable(eds, section, node_id, index, subindex)
161+
try:
162+
object_type = int(eds.get(section, "ObjectType"), 0)
163+
except NoOptionError:
164+
object_type = objectcodes.VAR
165+
var = build_variable(eds, section, node_id, object_type, index, subindex)
162166
entry.add_member(var)
163167

164168
# Match [index]Name
@@ -210,7 +214,9 @@ def _calc_bit_length(data_type):
210214
elif data_type == datatypes.INTEGER64:
211215
return 64
212216
else:
213-
raise ValueError(f"Invalid data_type '{data_type}', expecting a signed integer data_type.")
217+
raise ValueError(
218+
f"Invalid data_type '{data_type}', expecting a signed integer data_type."
219+
)
214220

215221

216222
def _signed_int_from_hex(hex_str, bit_length):
@@ -252,13 +258,22 @@ def _revert_variable(var_type, value):
252258
return f"0x{value:02X}"
253259

254260

255-
def build_variable(eds, section, node_id, index, subindex=0):
256-
"""Creates a object dictionary entry.
261+
def build_variable(
262+
eds: RawConfigParser,
263+
section: str,
264+
node_id: int,
265+
object_type: int,
266+
index: int,
267+
subindex: int = 0
268+
) -> ODVariable:
269+
"""Create a object dictionary entry.
270+
257271
:param eds: String stream of the eds file
258272
:param section:
259273
:param node_id: Node ID
260274
:param index: Index of the CANOpen object
261-
:param subindex: Subindex of the CANOpen object (if presente, else 0)
275+
:param subindex: Subindex of the CANOpen object (if present, else 0)
276+
:param is_domain: variable represents a DOMAIN ObjectType (if present, else False)
262277
"""
263278
name = eds.get(section, "ParameterName")
264279
var = ODVariable(name, index, subindex)
@@ -268,15 +283,19 @@ def build_variable(eds, section, node_id, index, subindex=0):
268283
var.storage_location = None
269284
var.data_type = int(eds.get(section, "DataType"), 0)
270285
var.access_type = eds.get(section, "AccessType").lower()
286+
var.is_domain = object_type == objectcodes.DOMAIN
271287
if var.data_type > 0x1B:
272-
# The object dictionary editor from CANFestival creates an optional object if min max values are used
273-
# This optional object is then placed in the eds under the section [A0] (start point, iterates for more)
274-
# The eds.get function gives us 0x00A0 now convert to String without hex representation and upper case
275-
# The sub2 part is then the section where the type parameter stands
288+
# The object dictionary editor from CANFestival creates an optional object if min max
289+
# values are used. This optional object is then placed in the eds under the section
290+
# [A0] (start point, iterates for more). The eds.get function gives us 0x00A0 now
291+
# convert to String without hex representation and upper case. The sub2 part is then
292+
# the section where the type parameter stands.
276293
try:
277294
var.data_type = int(eds.get(f"{var.data_type:X}sub1", "DefaultValue"), 0)
278295
except NoSectionError:
279-
logger.warning("%s has an unknown or unsupported data type (0x%X)", name, var.data_type)
296+
logger.warning(
297+
"%s has an unknown or unsupported data type (0x%X)", name, var.data_type
298+
)
280299
# Assume DOMAIN to force application to interpret the byte data
281300
var.data_type = datatypes.DOMAIN
282301

@@ -305,16 +324,17 @@ def build_variable(eds, section, node_id, index, subindex=0):
305324
var.default_raw = eds.get(section, "DefaultValue")
306325
if '$NODEID' in var.default_raw:
307326
var.relative = True
308-
var.default = _convert_variable(node_id, var.data_type, eds.get(section, "DefaultValue"))
327+
var.default = _convert_variable(node_id, var.data_type, var.default_raw)
309328
except ValueError:
310329
pass
311330
if eds.has_option(section, "ParameterValue"):
312331
try:
313332
var.value_raw = eds.get(section, "ParameterValue")
314-
var.value = _convert_variable(node_id, var.data_type, eds.get(section, "ParameterValue"))
333+
var.value = _convert_variable(node_id, var.data_type, var.value_raw)
315334
except ValueError:
316335
pass
317-
# Factor, Description and Unit are not standard according to the CANopen specifications, but they are implemented in the python canopen package, so we can at least try to use them
336+
# Factor, Description and Unit are not standard according to the CANopen specifications, but
337+
# they are implemented in the python canopen package, so we can at least try to use them
318338
if eds.has_option(section, "Factor"):
319339
try:
320340
var.factor = float(eds.get(section, "Factor"))
@@ -370,7 +390,8 @@ def export_variable(var, eds):
370390
section = f"{var.index:04X}sub{var.subindex:X}"
371391

372392
export_common(var, eds, section)
373-
eds.set(section, "ObjectType", f"0x{objectcodes.VAR:X}")
393+
object_type = objectcodes.DOMAIN if var.is_domain else objectcodes.VAR
394+
eds.set(section, "ObjectType", f"0x{object_type:X}")
374395
if var.data_type:
375396
eds.set(section, "DataType", f"0x{var.data_type:04X}")
376397
if var.access_type:

doc/lss.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ Or, you can run fastscan procedure ::
4747

4848
ret_bool, lss_id_list = network.lss.fast_scan()
4949

50+
.. note::
51+
Before you run fastscan, you have to make sure the devices are in non-configured
52+
or unconfigured state.
53+
5054
Once one of sensors goes to CONFIGURATION state, you can read the current node id of the LSS slave::
5155

5256
node_id = network.lss.inquire_node_id()

pyproject.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,14 @@ exclude = [
5656
"^test*",
5757
"^setup.py*",
5858
]
59+
60+
[tool.ruff]
61+
line-length = 96
62+
[tool.ruff.lint.isort]
63+
case-sensitive = true
64+
order-by-type = false
65+
lines-after-imports = 2
66+
67+
[tool.black]
68+
line-length = 96
69+
skip-string-normalization = true

test/sample.eds

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,3 +1017,30 @@ PDOMapping=0x0
10171017
Factor=ERROR
10181018
Description=
10191019
Unit=
1020+
1021+
[3063]
1022+
ParameterName=DOMAIN object
1023+
ObjectType=0x2
1024+
DataType=0x0007
1025+
AccessType=rw
1026+
PDOMapping=0
1027+
1028+
[3064]
1029+
ParameterName=Record with DOMAIN sub-object
1030+
SubNumber=0x2
1031+
ObjectType=0x9
1032+
1033+
[3064sub0]
1034+
ParameterName=Highest subindex
1035+
ObjectType=0x7
1036+
DataType=0x0005
1037+
AccessType=ro
1038+
DefaultValue=0x01
1039+
PDOMapping=0
1040+
1041+
[3064sub1]
1042+
ParameterName=DOMAIN sub-object
1043+
ObjectType=0x2
1044+
DataType=0x0007
1045+
AccessType=rw
1046+
PDOMapping=0

test/test_eds.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def test_variable(self):
111111
self.assertEqual(var.name, 'Producer heartbeat time')
112112
self.assertEqual(var.data_type, canopen.objectdictionary.UNSIGNED16)
113113
self.assertEqual(var.access_type, 'rw')
114+
self.assertFalse(var.is_domain)
114115
self.assertEqual(var.default, 0)
115116
self.assertFalse(var.relative)
116117

@@ -132,6 +133,7 @@ def test_record(self):
132133
self.assertEqual(var.subindex, 1)
133134
self.assertEqual(var.data_type, canopen.objectdictionary.UNSIGNED32)
134135
self.assertEqual(var.access_type, 'ro')
136+
self.assertFalse(var.is_domain)
135137

136138
def test_record_with_limits(self):
137139
int8 = self.od[0x3020]
@@ -166,6 +168,7 @@ def test_array_compact_subobj(self):
166168
self.assertEqual(var.subindex, 5)
167169
self.assertEqual(var.data_type, canopen.objectdictionary.UNSIGNED32)
168170
self.assertEqual(var.access_type, 'ro')
171+
self.assertFalse(var.is_domain)
169172

170173
def test_explicit_name_subobj(self):
171174
name = self.od[0x3004].name
@@ -197,6 +200,7 @@ def test_dummy_variable(self):
197200
self.assertEqual(var.name, 'Dummy0003')
198201
self.assertEqual(var.data_type, canopen.objectdictionary.INTEGER16)
199202
self.assertEqual(var.access_type, 'const')
203+
self.assertFalse(var.is_domain)
200204
self.assertEqual(len(var), 16)
201205

202206
def test_dummy_variable_undefined(self):
@@ -213,6 +217,39 @@ def test_reading_factor(self):
213217
self.assertEqual(var2.factor, 1)
214218
self.assertEqual(var2.unit, '')
215219

220+
def test_read_domain_object(self):
221+
var = self.od[0x3063]
222+
self.assertIsInstance(var, canopen.objectdictionary.ODVariable)
223+
self.assertEqual(var.index, 0x3063)
224+
self.assertEqual(var.subindex, 0)
225+
self.assertEqual(var.name, 'DOMAIN object')
226+
self.assertEqual(var.data_type, canopen.objectdictionary.UNSIGNED32)
227+
self.assertEqual(var.access_type, 'rw')
228+
self.assertTrue(var.is_domain)
229+
230+
def test_read_domain_subobject(self):
231+
record = self.od[0x3064]
232+
var = record[1]
233+
self.assertIsInstance(var, canopen.objectdictionary.ODVariable)
234+
self.assertEqual(var.index, 0x3064)
235+
self.assertEqual(var.subindex, 1)
236+
self.assertEqual(var.name, 'DOMAIN sub-object')
237+
self.assertEqual(var.data_type, canopen.objectdictionary.UNSIGNED32)
238+
self.assertEqual(var.access_type, 'rw')
239+
self.assertTrue(var.is_domain)
240+
241+
def test_roundtrip_domain_objects(self):
242+
# ObjectType==DOMAIN survive an EDS export/import round-trip
243+
import io
244+
with io.StringIO() as dest:
245+
canopen.export_od(self.od, dest, 'eds')
246+
dest.name = 'mock.eds'
247+
dest.seek(0)
248+
od2 = canopen.import_od(dest)
249+
self.assertFalse(od2['Producer heartbeat time'].is_domain)
250+
self.assertFalse(od2['Identity object']['Vendor-ID'].is_domain)
251+
self.assertTrue(od2[0x3063].is_domain)
252+
self.assertTrue(od2[0x3064][1].is_domain)
216253

217254

218255
def test_comments(self):

0 commit comments

Comments
 (0)