From ad1d93af16cc419e8ecf847489f5cdd06d498ea8 Mon Sep 17 00:00:00 2001 From: Nikos Skalkotos Date: Tue, 9 Mar 2021 19:16:05 +0200 Subject: [PATCH 01/43] Respect rtr_allowed in Pdo.Map.Save() When disabling or enabling a PDO by changing the "COB-ID used" sub-index we need to respect the value in the 30th bit. If a device does not support remote frames, an attempt to write the field will trigger an SBO abort reply. --- canopen/pdo/base.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/canopen/pdo/base.py b/canopen/pdo/base.py index 4fca9ffc..f0aea015 100644 --- a/canopen/pdo/base.py +++ b/canopen/pdo/base.py @@ -338,7 +338,7 @@ def save(self): """Save PDO configuration for this map using SDO.""" logger.info("Setting COB-ID 0x%X and temporarily disabling PDO", self.cob_id) - self.com_record[1].raw = self.cob_id | PDO_NOT_VALID + self.com_record[1].raw = self.cob_id | PDO_NOT_VALID | (RTR_NOT_ALLOWED if not self.rtr_allowed else 0x0) if self.trans_type is not None: logger.info("Setting transmission type to %d", self.trans_type) self.com_record[2].raw = self.trans_type @@ -388,7 +388,8 @@ def save(self): if self.enabled: logger.info("Enabling PDO") - self.com_record[1].raw = self.cob_id + self.com_record[1].raw = self.cob_id | (RTR_NOT_ALLOWED if not self.rtr_allowed else 0x0) + self.pdo_node.network.subscribe(self.cob_id, self.on_message) def clear(self): From f48a9425ebe36613f9aeda020043c56ca1258bbb Mon Sep 17 00:00:00 2001 From: ventussolus Date: Tue, 13 Apr 2021 20:59:56 -0500 Subject: [PATCH 02/43] Shutdown an already running periodic PDO before starting a new one --- canopen/pdo/base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/canopen/pdo/base.py b/canopen/pdo/base.py index f0aea015..97006b01 100644 --- a/canopen/pdo/base.py +++ b/canopen/pdo/base.py @@ -441,6 +441,10 @@ def start(self, period=None): :param float period: Transmission period in seconds """ + # Stop an already running transmission if we have one, otherwise we + # overwrite the reference and can lose our handle to shut it down + self.stop() + if period is not None: self.period = period From 287c1b56d888bfd7fdc7b3da3306e929ceb13f03 Mon Sep 17 00:00:00 2001 From: Christian Sandberg Date: Wed, 14 Apr 2021 08:13:36 +0200 Subject: [PATCH 03/43] Bump min supported Python version to 3.6 --- .github/workflows/pythonpackage.yml | 2 +- README.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 4089ee1e..3efb3261 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.5, '3.x'] + python-version: [3.6, '3.x'] steps: - uses: actions/checkout@v2 diff --git a/README.rst b/README.rst index 4a1fd4b1..c3c840d9 100644 --- a/README.rst +++ b/README.rst @@ -6,7 +6,7 @@ The aim of the project is to support the most common parts of the CiA 301 standard in a simple Pythonic interface. It is mainly targeted for testing and automation tasks rather than a standard compliant master implementation. -The library supports Python 3.4+. +The library supports Python 3.6+. Features From 7883bb3383b69525ae1dbe5af65c8f5f7bdbcff0 Mon Sep 17 00:00:00 2001 From: Janis Ita Date: Thu, 3 Jun 2021 17:18:50 +0200 Subject: [PATCH 04/43] added SDO Abort if a timeout occurs when sending a SDO request --- canopen/sdo/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/canopen/sdo/client.py b/canopen/sdo/client.py index 44159c49..0118d0ea 100644 --- a/canopen/sdo/client.py +++ b/canopen/sdo/client.py @@ -86,6 +86,7 @@ def request_response(self, sdo_request): except SdoCommunicationError as e: retries_left -= 1 if not retries_left: + self.abort(0x5040000) raise logger.warning(str(e)) From 1f288694b4587f36ab1b4e7d758d97dabb6c4031 Mon Sep 17 00:00:00 2001 From: Janis Ita Date: Fri, 4 Jun 2021 13:11:58 +0200 Subject: [PATCH 05/43] added argument for block transfer to enable/disable CRC Support --- canopen/sdo/base.py | 4 ++-- canopen/sdo/client.py | 18 +++++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/canopen/sdo/base.py b/canopen/sdo/base.py index 8f1fcb18..9cc3cf6f 100644 --- a/canopen/sdo/base.py +++ b/canopen/sdo/base.py @@ -112,7 +112,7 @@ def set_data(self, data): self.sdo_node.download(self.od.index, self.od.subindex, data, force_segment) def open(self, mode="rb", encoding="ascii", buffering=1024, size=None, - block_transfer=False): + block_transfer=False, request_crc_support=True): """Open the data stream as a file like object. :param str mode: @@ -141,4 +141,4 @@ def open(self, mode="rb", encoding="ascii", buffering=1024, size=None, A file like object. """ return self.sdo_node.open(self.od.index, self.od.subindex, mode, - encoding, buffering, size, block_transfer) + encoding, buffering, size, block_transfer, request_crc_support=request_crc_support) diff --git a/canopen/sdo/client.py b/canopen/sdo/client.py index 44159c49..672fb8ae 100644 --- a/canopen/sdo/client.py +++ b/canopen/sdo/client.py @@ -155,7 +155,7 @@ def download(self, index, subindex, data, force_segment=False): fp.close() def open(self, index, subindex=0, mode="rb", encoding="ascii", - buffering=1024, size=None, block_transfer=False, force_segment=False): + buffering=1024, size=None, block_transfer=False, force_segment=False, request_crc_support=True): """Open the data stream as a file like object. :param int index: @@ -192,7 +192,7 @@ def open(self, index, subindex=0, mode="rb", encoding="ascii", buffer_size = buffering if buffering > 1 else io.DEFAULT_BUFFER_SIZE if "r" in mode: if block_transfer: - raw_stream = BlockUploadStream(self, index, subindex) + raw_stream = BlockUploadStream(self, index, subindex, request_crc_support=request_crc_support) else: raw_stream = ReadableStream(self, index, subindex) if buffering: @@ -201,7 +201,7 @@ def open(self, index, subindex=0, mode="rb", encoding="ascii", return raw_stream if "w" in mode: if block_transfer: - raw_stream = BlockDownloadStream(self, index, subindex, size) + raw_stream = BlockDownloadStream(self, index, subindex, size, request_crc_support=request_crc_support) else: raw_stream = WritableStream(self, index, subindex, size, force_segment) if buffering: @@ -446,7 +446,7 @@ class BlockUploadStream(io.RawIOBase): crc_supported = False - def __init__(self, sdo_client, index, subindex=0): + def __init__(self, sdo_client, index, subindex=0, request_crc_support=True): """ :param canopen.sdo.SdoClient sdo_client: The SDO client to use for reading. @@ -466,7 +466,9 @@ def __init__(self, sdo_client, index, subindex=0): sdo_client.rx_cobid - 0x600) # Initiate Block Upload request = bytearray(8) - command = REQUEST_BLOCK_UPLOAD | INITIATE_BLOCK_TRANSFER | CRC_SUPPORTED + command = REQUEST_BLOCK_UPLOAD | INITIATE_BLOCK_TRANSFER + if request_crc_support: + command |= CRC_SUPPORTED struct.pack_into(" Date: Wed, 9 Jun 2021 07:26:07 +0200 Subject: [PATCH 06/43] added new arguments to docstrings --- canopen/sdo/base.py | 2 ++ canopen/sdo/client.py | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/canopen/sdo/base.py b/canopen/sdo/base.py index 9cc3cf6f..9018dc23 100644 --- a/canopen/sdo/base.py +++ b/canopen/sdo/base.py @@ -136,6 +136,8 @@ def open(self, mode="rb", encoding="ascii", buffering=1024, size=None, Size of data to that will be transmitted. :param bool block_transfer: If block transfer should be used. + :param bool request_crc_support: + If crc calculation should be requested when using block transfer :returns: A file like object. diff --git a/canopen/sdo/client.py b/canopen/sdo/client.py index 672fb8ae..ec993c2a 100644 --- a/canopen/sdo/client.py +++ b/canopen/sdo/client.py @@ -185,7 +185,9 @@ def open(self, index, subindex=0, mode="rb", encoding="ascii", If block transfer should be used. :param bool force_segment: Force use of segmented download regardless of data size. - + :param bool request_crc_support: + If crc calculation should be requested when using block transfer + :returns: A file like object. """ @@ -454,6 +456,8 @@ def __init__(self, sdo_client, index, subindex=0, request_crc_support=True): Object dictionary index to read from. :param int subindex: Object dictionary sub-index to read from. + :param bool request_crc_support: + If crc calculation should be requested when using block transfer """ self._done = False self.sdo_client = sdo_client @@ -608,6 +612,8 @@ def __init__(self, sdo_client, index, subindex=0, size=None, request_crc_support Object dictionary sub-index to read from. :param int size: Size of data in number of bytes if known in advance. + :param bool request_crc_support: + If crc calculation should be requested when using block transfer """ self.sdo_client = sdo_client self.size = size From 9b7fa31a2d1e61612b8053db8c910a02c086a015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCnther=20Jena?= Date: Wed, 9 Jun 2021 15:40:44 +0200 Subject: [PATCH 07/43] Check writeable for SDO upload --- canopen/sdo/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/canopen/sdo/server.py b/canopen/sdo/server.py index 894f48ef..f6d02dad 100644 --- a/canopen/sdo/server.py +++ b/canopen/sdo/server.py @@ -210,4 +210,4 @@ def download(self, index, subindex, data, force_segment=False): :raises canopen.SdoAbortedError: When node responds with an error. """ - return self._node.set_data(index, subindex, data) + return self._node.set_data(index, subindex, data, check_writable=True) From 47051d5877eed773db2b019e560205979ded65f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCnther=20Jena?= Date: Wed, 9 Jun 2021 21:35:23 +0200 Subject: [PATCH 08/43] implement feedback from Christian --- canopen/sdo/server.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/canopen/sdo/server.py b/canopen/sdo/server.py index f6d02dad..be4d934e 100644 --- a/canopen/sdo/server.py +++ b/canopen/sdo/server.py @@ -123,7 +123,6 @@ def block_download(self, data): self.abort(0x05040001) def init_download(self, request): - # TODO: Check if writable command, index, subindex = SDO_STRUCT.unpack_from(request) self._index = index self._subindex = subindex @@ -136,7 +135,7 @@ def init_download(self, request): size = 4 - ((command >> 2) & 0x3) else: size = 4 - self.download(index, subindex, request[4:4 + size]) + self._node.set_data(index, subindex, request[4:4 + size], check_writable=True) else: logger.info("Initiating segmented download for 0x%X:%d", index, subindex) if command & SIZE_SPECIFIED: @@ -210,4 +209,4 @@ def download(self, index, subindex, data, force_segment=False): :raises canopen.SdoAbortedError: When node responds with an error. """ - return self._node.set_data(index, subindex, data, check_writable=True) + return self._node.set_data(index, subindex, data) From d2276b20b6aa6e5c07e3ee766e9ecc3b653320b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCnther=20Jena?= Date: Wed, 9 Jun 2021 22:02:01 +0200 Subject: [PATCH 09/43] insert TODO comment --- canopen/sdo/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/canopen/sdo/server.py b/canopen/sdo/server.py index be4d934e..746f43a3 100644 --- a/canopen/sdo/server.py +++ b/canopen/sdo/server.py @@ -123,6 +123,7 @@ def block_download(self, data): self.abort(0x05040001) def init_download(self, request): + # TODO: Check if writable (now would fail on end of segmented downloads) command, index, subindex = SDO_STRUCT.unpack_from(request) self._index = index self._subindex = subindex From 657062aeb6634e5be33c3766777feb67e098822d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Mon, 2 Aug 2021 13:51:45 +0200 Subject: [PATCH 10/43] DS402 code cleanup (#245) * ds402: Define additional operation mode constants. Add the missing CYCLIC_SYNCHRONOUS_... modes to the mapping dicts, including the supported mode bit flags. * ds402: Use monotonic clock for timeouts. The value returned from time.time() can go backwards between multiple calls, so time.monotonic() is the recommended way for checking timeouts. * ds402: Make timeout values configurable. Move all timing-related magic numbers to class-level constants and use those for timeout calculations and sleep() calls. That allows the library user to override the defaults on a per-node basis if needed, by just setting the same attribute on the object. And it's cleaner code without magic numbers. * ds402: Code cleanup: whitespace and indentation. * ds402: Clean up list formatting and style. Consistently format constant definitions, especially constant mappings: * Write Controlword commands with for hex digits (16 bits). * Same for Supported Drive Modes flags (only 16 of 32 bits). * Move colon directly after key in dicts and align values consistently. Fixes lots of flake8 errors. * Always include trailing comma in multi-line sequences. * Use tuples instead of lists for constant definitions. They don't need to be mutable. * ds402: Use tuple unpacking for simplified bitmask code. Apply the same pattern whenever the Statusword is checked for a specific bit pattern. --- canopen/profiles/p402.py | 219 +++++++++++++++++++++------------------ 1 file changed, 118 insertions(+), 101 deletions(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index f4dedb4f..d3d75b17 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -6,53 +6,54 @@ logger = logging.getLogger(__name__) + class State402(object): # Controlword (0x6040) commands - CW_OPERATION_ENABLED = 0x0F - CW_SHUTDOWN = 0x06 - CW_SWITCH_ON = 0x07 - CW_QUICK_STOP = 0x02 - CW_DISABLE_VOLTAGE = 0x00 - CW_SWITCH_ON_DISABLED = 0x80 + CW_OPERATION_ENABLED = 0x000F + CW_SHUTDOWN = 0x0006 + CW_SWITCH_ON = 0x0007 + CW_QUICK_STOP = 0x0002 + CW_DISABLE_VOLTAGE = 0x0000 + CW_SWITCH_ON_DISABLED = 0x0080 CW_CODE_COMMANDS = { - CW_SWITCH_ON_DISABLED : 'SWITCH ON DISABLED', - CW_DISABLE_VOLTAGE : 'DISABLE VOLTAGE', - CW_SHUTDOWN : 'READY TO SWITCH ON', - CW_SWITCH_ON : 'SWITCHED ON', - CW_OPERATION_ENABLED : 'OPERATION ENABLED', - CW_QUICK_STOP : 'QUICK STOP ACTIVE' + CW_SWITCH_ON_DISABLED: 'SWITCH ON DISABLED', + CW_DISABLE_VOLTAGE: 'DISABLE VOLTAGE', + CW_SHUTDOWN: 'READY TO SWITCH ON', + CW_SWITCH_ON: 'SWITCHED ON', + CW_OPERATION_ENABLED: 'OPERATION ENABLED', + CW_QUICK_STOP: 'QUICK STOP ACTIVE', } CW_COMMANDS_CODE = { - 'SWITCH ON DISABLED' : CW_SWITCH_ON_DISABLED, - 'DISABLE VOLTAGE' : CW_DISABLE_VOLTAGE, - 'READY TO SWITCH ON' : CW_SHUTDOWN, - 'SWITCHED ON' : CW_SWITCH_ON, - 'OPERATION ENABLED' : CW_OPERATION_ENABLED, - 'QUICK STOP ACTIVE' : CW_QUICK_STOP + 'SWITCH ON DISABLED': CW_SWITCH_ON_DISABLED, + 'DISABLE VOLTAGE': CW_DISABLE_VOLTAGE, + 'READY TO SWITCH ON': CW_SHUTDOWN, + 'SWITCHED ON': CW_SWITCH_ON, + 'OPERATION ENABLED': CW_OPERATION_ENABLED, + 'QUICK STOP ACTIVE': CW_QUICK_STOP, } # Statusword 0x6041 bitmask and values in the list in the dictionary value SW_MASK = { - 'NOT READY TO SWITCH ON': [0x4F, 0x00], - 'SWITCH ON DISABLED' : [0x4F, 0x40], - 'READY TO SWITCH ON' : [0x6F, 0x21], - 'SWITCHED ON' : [0x6F, 0x23], - 'OPERATION ENABLED' : [0x6F, 0x27], - 'FAULT' : [0x4F, 0x08], - 'FAULT REACTION ACTIVE' : [0x4F, 0x0F], - 'QUICK STOP ACTIVE' : [0x6F, 0x07] + 'NOT READY TO SWITCH ON': (0x4F, 0x00), + 'SWITCH ON DISABLED': (0x4F, 0x40), + 'READY TO SWITCH ON': (0x6F, 0x21), + 'SWITCHED ON': (0x6F, 0x23), + 'OPERATION ENABLED': (0x6F, 0x27), + 'FAULT': (0x4F, 0x08), + 'FAULT REACTION ACTIVE': (0x4F, 0x0F), + 'QUICK STOP ACTIVE': (0x6F, 0x07), } # Transition path to get to the 'OPERATION ENABLED' state NEXTSTATE2ENABLE = { - ('START') : 'NOT READY TO SWITCH ON', - ('FAULT', 'NOT READY TO SWITCH ON') : 'SWITCH ON DISABLED', - ('SWITCH ON DISABLED') : 'READY TO SWITCH ON', - ('READY TO SWITCH ON') : 'SWITCHED ON', - ('SWITCHED ON', 'QUICK STOP ACTIVE', 'OPERATION ENABLED') : 'OPERATION ENABLED', - ('FAULT REACTION ACTIVE') : 'FAULT' + ('START'): 'NOT READY TO SWITCH ON', + ('FAULT', 'NOT READY TO SWITCH ON'): 'SWITCH ON DISABLED', + ('SWITCH ON DISABLED'): 'READY TO SWITCH ON', + ('READY TO SWITCH ON'): 'SWITCHED ON', + ('SWITCHED ON', 'QUICK STOP ACTIVE', 'OPERATION ENABLED'): 'OPERATION ENABLED', + ('FAULT REACTION ACTIVE'): 'FAULT' } # Tansition table from the DS402 State Machine @@ -110,35 +111,45 @@ class OperationMode(object): OPEN_LOOP_VECTOR_MODE = -2 CODE2NAME = { - NO_MODE : 'NO MODE', - PROFILED_POSITION : 'PROFILED POSITION', - VELOCITY : 'VELOCITY', - PROFILED_VELOCITY : 'PROFILED VELOCITY', - PROFILED_TORQUE : 'PROFILED TORQUE', - HOMING : 'HOMING', - INTERPOLATED_POSITION : 'INTERPOLATED POSITION' + NO_MODE: 'NO MODE', + PROFILED_POSITION: 'PROFILED POSITION', + VELOCITY: 'VELOCITY', + PROFILED_VELOCITY: 'PROFILED VELOCITY', + PROFILED_TORQUE: 'PROFILED TORQUE', + HOMING: 'HOMING', + INTERPOLATED_POSITION: 'INTERPOLATED POSITION', + CYCLIC_SYNCHRONOUS_POSITION: 'CYCLIC SYNCHRONOUS POSITION', + CYCLIC_SYNCHRONOUS_VELOCITY: 'CYCLIC SYNCHRONOUS VELOCITY', + CYCLIC_SYNCHRONOUS_TORQUE: 'CYCLIC SYNCHRONOUS TORQUE', } NAME2CODE = { - 'NO MODE' : NO_MODE, - 'PROFILED POSITION' : PROFILED_POSITION, - 'VELOCITY' : VELOCITY, - 'PROFILED VELOCITY' : PROFILED_VELOCITY, - 'PROFILED TORQUE' : PROFILED_TORQUE, - 'HOMING' : HOMING, - 'INTERPOLATED POSITION' : INTERPOLATED_POSITION + 'NO MODE': NO_MODE, + 'PROFILED POSITION': PROFILED_POSITION, + 'VELOCITY': VELOCITY, + 'PROFILED VELOCITY': PROFILED_VELOCITY, + 'PROFILED TORQUE': PROFILED_TORQUE, + 'HOMING': HOMING, + 'INTERPOLATED POSITION': INTERPOLATED_POSITION, + 'CYCLIC SYNCHRONOUS POSITION': CYCLIC_SYNCHRONOUS_POSITION, + 'CYCLIC SYNCHRONOUS VELOCITY': CYCLIC_SYNCHRONOUS_VELOCITY, + 'CYCLIC SYNCHRONOUS TORQUE': CYCLIC_SYNCHRONOUS_TORQUE, } SUPPORTED = { - 'NO MODE' : 0x0, - 'PROFILED POSITION' : 0x1, - 'VELOCITY' : 0x2, - 'PROFILED VELOCITY' : 0x4, - 'PROFILED TORQUE' : 0x8, - 'HOMING' : 0x20, - 'INTERPOLATED POSITION' : 0x40 + 'NO MODE': 0x0000, + 'PROFILED POSITION': 0x0001, + 'VELOCITY': 0x0002, + 'PROFILED VELOCITY': 0x0004, + 'PROFILED TORQUE': 0x0008, + 'HOMING': 0x0020, + 'INTERPOLATED POSITION': 0x0040, + 'CYCLIC SYNCHRONOUS POSITION': 0x0080, + 'CYCLIC SYNCHRONOUS VELOCITY': 0x0100, + 'CYCLIC SYNCHRONOUS TORQUE': 0x0200, } + class Homing(object): CW_START = 0x10 CW_HALT = 0x100 @@ -154,23 +165,23 @@ class Homing(object): HM_NO_HOMING_OPERATION = 0 HM_ON_THE_NEGATIVE_LIMIT_SWITCH_AND_INDEX_PULSE = 1 HM_ON_THE_POSITIVE_LIMIT_SWITCH_AND_INDEX_PULSE = 2 - HM_ON_THE_POSITIVE_HOME_SWITCH_AND_INDEX_PULSE = [3, 4] - HM_ON_THE_NEGATIVE_HOME_SWITCH_AND_INDEX_PULSE = [5, 6] + HM_ON_THE_POSITIVE_HOME_SWITCH_AND_INDEX_PULSE = (3, 4) + HM_ON_THE_NEGATIVE_HOME_SWITCH_AND_INDEX_PULSE = (5, 6) HM_ON_THE_NEGATIVE_LIMIT_SWITCH = 17 HM_ON_THE_POSITIVE_LIMIT_SWITCH = 18 - HM_ON_THE_POSITIVE_HOME_SWITCH = [19, 20] - HM_ON_THE_NEGATIVE_HOME_SWITCH = [21, 22] + HM_ON_THE_POSITIVE_HOME_SWITCH = (19, 20) + HM_ON_THE_NEGATIVE_HOME_SWITCH = (21, 22) HM_ON_NEGATIVE_INDEX_PULSE = 33 HM_ON_POSITIVE_INDEX_PULSE = 34 HM_ON_CURRENT_POSITION = 35 STATES = { - 'IN PROGRESS' : [0x3400, 0x0000], - 'INTERRUPTED' : [0x3400, 0x0400], - 'ATTAINED' : [0x3400, 0x1000], - 'TARGET REACHED' : [0x3400, 0x1400], - 'ERROR VELOCITY IS NOT ZERO' : [0x3400, 0x2000], - 'ERROR VELOCITY IS ZERO' : [0x3400, 0x2400] + 'IN PROGRESS': (0x3400, 0x0000), + 'INTERRUPTED': (0x3400, 0x0400), + 'ATTAINED': (0x3400, 0x1000), + 'TARGET REACHED': (0x3400, 0x1400), + 'ERROR VELOCITY IS NOT ZERO': (0x3400, 0x2000), + 'ERROR VELOCITY IS ZERO': (0x3400, 0x2400), } @@ -185,10 +196,17 @@ class BaseNode402(RemoteNode): :type object_dictionary: :class:`str`, :class:`canopen.ObjectDictionary` """ + TIMEOUT_RESET_FAULT = 0.4 # seconds + TIMEOUT_SWITCH_OP_MODE = 0.5 # seconds + TIMEOUT_SWITCH_STATE_FINAL = 0.8 # seconds + TIMEOUT_SWITCH_STATE_SINGLE = 0.4 # seconds + INTERVAL_CHECK_STATE = 0.01 # seconds + TIMEOUT_HOMING_DEFAULT = 30 # seconds + def __init__(self, node_id, object_dictionary): super(BaseNode402, self).__init__(node_id, object_dictionary) - self.tpdo_values = dict() # { index: TPDO_value } - self.rpdo_pointers = dict() # { index: RPDO_pointer } + self.tpdo_values = dict() # { index: TPDO_value } + self.rpdo_pointers = dict() # { index: RPDO_pointer } def setup_402_state_machine(self): """Configure the state machine by searching for a TPDO that has the @@ -204,7 +222,7 @@ def setup_402_state_machine(self): self.state = 'SWITCH ON DISABLED' # Why change state? def setup_pdos(self): - self.pdo.read() # TPDO and RPDO configurations + self.pdo.read() # TPDO and RPDO configurations self._init_tpdo_values() self._init_rpdo_pointers() @@ -218,7 +236,7 @@ def _init_tpdo_values(self): self.tpdo_values[obj.index] = 0 def _init_rpdo_pointers(self): - # If RPDOs have overlapping indecies, rpdo_pointers will point to + # If RPDOs have overlapping indecies, rpdo_pointers will point to # the first RPDO that has that index configured. for rpdo in self.rpdo.values(): if rpdo.enabled: @@ -228,13 +246,13 @@ def _init_rpdo_pointers(self): self.rpdo_pointers[obj.index] = obj def _check_controlword_configured(self): - if 0x6040 not in self.rpdo_pointers: # Controlword + if 0x6040 not in self.rpdo_pointers: # Controlword logger.warning( "Controlword not configured in node {0}'s PDOs. Using SDOs can cause slow performance.".format( self.id)) def _check_statusword_configured(self): - if 0x6041 not in self.tpdo_values: # Statusword + if 0x6041 not in self.tpdo_values: # Statusword raise ValueError( "Statusword not configured in node {0}'s PDOs. Using SDOs can cause slow performance.".format( self.id)) @@ -245,18 +263,18 @@ def reset_from_fault(self): if self.state == 'FAULT': # Resets the Fault Reset bit (rising edge 0 -> 1) self.controlword = State402.CW_DISABLE_VOLTAGE - timeout = time.time() + 0.4 # 400 ms - + timeout = time.monotonic() + self.TIMEOUT_RESET_FAULT while self.is_faulted(): - if time.time() > timeout: + if time.monotonic() > timeout: break - time.sleep(0.01) # 10 ms + time.sleep(self.INTERVAL_CHECK_STATE) self.state = 'OPERATION ENABLED' - + def is_faulted(self): - return self.statusword & State402.SW_MASK['FAULT'][0] == State402.SW_MASK['FAULT'][1] + bitmask, bits = State402.SW_MASK['FAULT'] + return self.statusword & bitmask == bits - def homing(self, timeout=30, set_new_home=True): + def homing(self, timeout=TIMEOUT_HOMING_DEFAULT, set_new_home=True): """Function to execute the configured Homing Method on the node :param int timeout: Timeout value (default: 30) :param bool set_new_home: Defines if the node should set the home offset @@ -271,23 +289,24 @@ def homing(self, timeout=30, set_new_home=True): self.state = 'OPERATION ENABLED' homingstatus = 'IN PROGRESS' self.controlword = State402.CW_OPERATION_ENABLED | Homing.CW_START - t = time.time() + timeout + t = time.monotonic() + timeout try: while homingstatus not in ('TARGET REACHED', 'ATTAINED'): for key, value in Homing.STATES.items(): - # check if the value after applying the bitmask (value[0]) - # corresponds with the value[1] to determine the current status - bitmaskvalue = self.statusword & value[0] - if bitmaskvalue == value[1]: + # check if the Statusword after applying the bitmask + # corresponds with the needed bits to determine the current status + bitmask, bits = value + if self.statusword & bitmask == bits: homingstatus = key - if homingstatus in ('INTERRUPTED', 'ERROR VELOCITY IS NOT ZERO', 'ERROR VELOCITY IS ZERO'): - raise RuntimeError ('Unable to home. Reason: {0}'.format(homingstatus)) - time.sleep(0.001) - if time.time() > t: + if homingstatus in ('INTERRUPTED', 'ERROR VELOCITY IS NOT ZERO', + 'ERROR VELOCITY IS ZERO'): + raise RuntimeError('Unable to home. Reason: {0}'.format(homingstatus)) + time.sleep(self.INTERVAL_CHECK_STATE) + if time.monotonic() > t: raise RuntimeError('Unable to home, timeout reached') if set_new_home: actual_position = self.sdo[0x6063].raw - self.sdo[0x607C].raw = actual_position # home offset (0x607C) + self.sdo[0x607C].raw = actual_position # Home Offset logger.info('Homing offset set to {0}'.format(actual_position)) logger.info('Homing mode carried out successfully.') return True @@ -334,16 +353,16 @@ def op_mode(self, mode): start_state = self.state if self.state == 'OPERATION ENABLED': - self.state = 'SWITCHED ON' + self.state = 'SWITCHED ON' # ensure the node does not move with an old value self._clear_target_values() # Shouldn't this happen before it's switched on? # operation mode self.sdo[0x6060].raw = OperationMode.NAME2CODE[mode] - timeout = time.time() + 0.5 # 500 ms + timeout = time.monotonic() + self.TIMEOUT_SWITCH_OP_MODE while self.op_mode != mode: - if time.time() > timeout: + if time.monotonic() > timeout: raise RuntimeError( "Timeout setting node {0}'s new mode of operation to {1}.".format( self.id, mode)) @@ -354,7 +373,7 @@ def op_mode(self, mode): logger.warning('{0}'.format(str(e))) finally: self.state = start_state # why? - logger.info('Set node {n} operation mode to {m}.'.format(n=self.id , m=mode)) + logger.info('Set node {n} operation mode to {m}.'.format(n=self.id, m=mode)) return False def _clear_target_values(self): @@ -421,10 +440,8 @@ def state(self): - 'QUICK STOP ACTIVE' """ for state, mask_val_pair in State402.SW_MASK.items(): - mask = mask_val_pair[0] - state_value = mask_val_pair[1] - masked_value = self.statusword & mask - if masked_value == state_value: + bitmask, bits = mask_val_pair + if self.statusword & bitmask == bits: return state return 'UNKNOWN' @@ -442,14 +459,14 @@ def state(self, target_state): :raise RuntimeError: Occurs when the time defined to change the state is reached :raise ValueError: Occurs when trying to execute a ilegal transition in the sate machine """ - timeout = time.time() + 0.8 # 800 ms + timeout = time.monotonic() + self.TIMEOUT_SWITCH_STATE_FINAL while self.state != target_state: next_state = self._next_state(target_state) if self._change_state(next_state): - continue - if time.time() > timeout: + continue + if time.monotonic() > timeout: raise RuntimeError('Timeout when trying to change state') - time.sleep(0.01) # 10 ms + time.sleep(self.INTERVAL_CHECK_STATE) def _next_state(self, target_state): if target_state == 'OPERATION ENABLED': @@ -463,9 +480,9 @@ def _change_state(self, target_state): except KeyError: raise ValueError( 'Illegal state transition from {f} to {t}'.format(f=self.state, t=target_state)) - timeout = time.time() + 0.4 # 400 ms + timeout = time.monotonic() + self.TIMEOUT_SWITCH_STATE_SINGLE while self.state != target_state: - if time.time() > timeout: + if time.monotonic() > timeout: return False - time.sleep(0.01) # 10 ms + time.sleep(self.INTERVAL_CHECK_STATE) return True From 509f5533e8a1ffeb6bd9bdbf41ffc81f63b75224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Mon, 2 Aug 2021 13:53:48 +0200 Subject: [PATCH 11/43] DS402: Cache supported operation modes (#247) * ds402: Cache supported operation modes. Requesting the same immutable OD object on each change of operation mode takes quite some time to process. Introduce a local cache member variable in order to avoid requesting more than once. * ds402: Log message about cache operation mode support. --- canopen/profiles/p402.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index d3d75b17..c5f8eb3a 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -388,8 +388,13 @@ def is_op_mode_supported(self, mode): :return: If the operation mode is supported :rtype: bool """ - mode_support = self.sdo[0x6502].raw & OperationMode.SUPPORTED[mode] - return mode_support == OperationMode.SUPPORTED[mode] + if not hasattr(self, '_op_mode_support'): + # Cache value only on first lookup, this object should never change. + self._op_mode_support = self.sdo[0x6502].raw + logger.info('Caching node {n} supported operation modes 0x{m:04X}'.format( + n=self.id, m=self._op_mode_support)) + bits = OperationMode.SUPPORTED[mode] + return self._op_mode_support & bits == bits def on_TPDOs_update_callback(self, mapobject): """This function receives a map object. From 179022cd870118f8f9ae714b9bc465af98758aec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Mon, 2 Aug 2021 14:38:47 +0200 Subject: [PATCH 12/43] ds402: Support checking the homing status. (#248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a new method BaseNode402.is_homed() which will switch to the corresponding operation mode HOMING and then examine the Statusword for the homing status bits. This allows to skip the homing procedure in case the drive controller still knows its position, but e.g. the Python application was restarted. Co-authored-by: André Filipe Silva --- canopen/profiles/p402.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index c5f8eb3a..dd3ac9eb 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -274,6 +274,21 @@ def is_faulted(self): bitmask, bits = State402.SW_MASK['FAULT'] return self.statusword & bitmask == bits + def is_homed(self, restore_op_mode=False): + """Switch to homing mode and determine its status.""" + previous_op_mode = self.op_mode + if previous_op_mode != 'HOMING': + logger.info('Switch to HOMING from %s', previous_op_mode) + self.op_mode = 'HOMING' + homingstatus = None + for key, value in Homing.STATES.items(): + bitmask, bits = value + if self.statusword & bitmask == bits: + homingstatus = key + if restore_op_mode: + self.op_mode = previous_op_mode + return homingstatus in ('TARGET REACHED', 'ATTAINED') + def homing(self, timeout=TIMEOUT_HOMING_DEFAULT, set_new_home=True): """Function to execute the configured Homing Method on the node :param int timeout: Timeout value (default: 30) From 1f456a469f4708790fb62f0e4fc02d2e76d77e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Fri, 6 Aug 2021 14:32:19 +0200 Subject: [PATCH 13/43] Separate subscription from the PDO's read() and save() methods. Normally one needs to call read() or save() on a single pdo (or the node's whole PDO collection) in order to receive any such objects from the network. That however requires quite a few SDO exchanges to make sure the node's PDO configuration matches the parameters configured in the object. For applications where the PDO configuration is stored persistently in the node (e.g. device EEPROM), doing this SDO exchange can be skipped entirely if the application programmer takes care to mirror the same configuration in the python-canopen objects. Another use case is reconnecting to a node for which the same python-canopen script previously ran and the PDO configuration is still known to be valid. Factor out a new method subscribe() from read() and save() to offer doing only that last part via the public API. Adapt the log message and make sure it is logged in read() as well. --- canopen/pdo/base.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/canopen/pdo/base.py b/canopen/pdo/base.py index 97006b01..f5f65192 100644 --- a/canopen/pdo/base.py +++ b/canopen/pdo/base.py @@ -59,6 +59,17 @@ def save(self): for pdo_map in self.map.values(): pdo_map.save() + def subscribe(self): + """Register the node's PDOs for reception on the network. + + This normally happens when the PDO configuration is read from + or saved to the node. Use this method to avoid the SDO flood + associated with read() or save(), if the local PDO setup is + known to match what's stored on the node. + """ + for pdo_map in self.map.values(): + pdo_map.subscribe() + def export(self, filename): """Export current configuration to a database file. @@ -331,8 +342,7 @@ def read(self): if index and size: self.add_variable(index, subindex, size) - if self.enabled: - self.pdo_node.network.subscribe(self.cob_id, self.on_message) + self.subscribe() def save(self): """Save PDO configuration for this map using SDO.""" @@ -387,9 +397,19 @@ def save(self): self._update_data_size() if self.enabled: - logger.info("Enabling PDO") self.com_record[1].raw = self.cob_id | (RTR_NOT_ALLOWED if not self.rtr_allowed else 0x0) + self.subscribe() + + def subscribe(self): + """Register the PDO for reception on the network. + This normally happens when the PDO configuration is read from + or saved to the node. Use this method to avoid the SDO flood + associated with read() or save(), if the local PDO setup is + known to match what's stored on the node. + """ + if self.enabled: + logger.info("Subscribing to enabled PDO 0x%X on the network", self.cob_id) self.pdo_node.network.subscribe(self.cob_id, self.on_message) def clear(self): From e3c0432e834505439019b6fd37c1fc93c731d816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Fri, 6 Aug 2021 14:55:55 +0200 Subject: [PATCH 14/43] Fix some docstrings on the PDO classes. Follow the standard docstring conventions and rephrase the description of RPDO and TPDO classes which is really long for a single summary line. No more docstring errors are reported by the flake8 checker on this file. --- canopen/pdo/__init__.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/canopen/pdo/__init__.py b/canopen/pdo/__init__.py index 5d8a3ba4..a08b3ccc 100644 --- a/canopen/pdo/__init__.py +++ b/canopen/pdo/__init__.py @@ -8,7 +8,8 @@ class PDO(PdoBase): - """PDO Class for backwards compatibility + """PDO Class for backwards compatibility. + :param rpdo: RPDO object holding the Receive PDO mappings :param tpdo: TPDO object holding the Transmit PDO mappings """ @@ -27,9 +28,11 @@ def __init__(self, node, rpdo, tpdo): class RPDO(PdoBase): - """PDO specialization for the Receive PDO enabling the transfer of data from the master to the node. + """Receive PDO to transfer data from somewhere to the represented node. + Properties 0x1400 to 0x1403 | Mapping 0x1600 to 0x1603. - :param object node: Parent node for this object.""" + :param object node: Parent node for this object. + """ def __init__(self, node): super(RPDO, self).__init__(node) @@ -38,8 +41,10 @@ def __init__(self, node): def stop(self): """Stop transmission of all RPDOs. + :raise TypeError: Exception is thrown if the node associated with the PDO does not - support this function""" + support this function. + """ if isinstance(self.node, canopen.RemoteNode): for pdo in self.map.values(): pdo.stop() @@ -48,8 +53,11 @@ def stop(self): class TPDO(PdoBase): - """PDO specialization for the Transmit PDO enabling the transfer of data from the node to the master. - Properties 0x1800 to 0x1803 | Mapping 0x1A00 to 0x1A03.""" + """Transmit PDO to broadcast data from the represented node to the network. + + Properties 0x1800 to 0x1803 | Mapping 0x1A00 to 0x1A03. + :param object node: Parent node for this object. + """ def __init__(self, node): super(TPDO, self).__init__(node) @@ -58,8 +66,10 @@ def __init__(self, node): def stop(self): """Stop transmission of all TPDOs. + :raise TypeError: Exception is thrown if the node associated with the PDO does not - support this function""" + support this function. + """ if isinstance(canopen.LocalNode, self.node): for pdo in self.map.values(): pdo.stop() From 7ff48b152ff2472e13ea27fe3059a37424f7149d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Wed, 11 Aug 2021 09:47:30 +0200 Subject: [PATCH 15/43] p402: Remove return value from property setter. Such a return value cannot be used sensibly, because assignments to the property don't have a return value in Python. --- canopen/profiles/p402.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index dd3ac9eb..195ca035 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -343,8 +343,6 @@ def op_mode(self): def op_mode(self, mode): """Function to define the operation mode of the node :param string mode: Mode to define. - :return: Return if the operation mode was set with success or not - :rtype: bool The modes can be: - 'NO MODE' @@ -381,7 +379,6 @@ def op_mode(self, mode): raise RuntimeError( "Timeout setting node {0}'s new mode of operation to {1}.".format( self.id, mode)) - return True except SdoCommunicationError as e: logger.warning('[SDO communication error] Cause: {0}'.format(str(e))) except (RuntimeError, ValueError) as e: @@ -389,7 +386,6 @@ def op_mode(self, mode): finally: self.state = start_state # why? logger.info('Set node {n} operation mode to {m}.'.format(n=self.id, m=mode)) - return False def _clear_target_values(self): # [target velocity, target position, target torque] From 475d22ceaf85d9e18ab0b7a8769070d19ecfafe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Wed, 11 Aug 2021 10:53:20 +0200 Subject: [PATCH 16/43] doc: Include generated API documentation for the BaseNode402 class. --- doc/profiles.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/doc/profiles.rst b/doc/profiles.rst index dac3e85d..1047c7f9 100644 --- a/doc/profiles.rst +++ b/doc/profiles.rst @@ -85,3 +85,10 @@ Available commands - 'SWITCHED ON' - 'OPERATION ENABLED' - 'QUICK STOP ACTIVE' + + +API +``` + +.. autoclass:: canopen.profiles.p402.BaseNode402 + :members: From 4263f85b3b8c7cddc3b09128cf8bbc1189945018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Wed, 11 Aug 2021 12:44:05 +0200 Subject: [PATCH 17/43] DS402: Clean up docstrings. Follow PEP-257 regarding overall formatting. Fix typos. Fix incorrect usage of Sphinx directives (:return: does not take an inline type as :param: does). Standardize on :raises: directive. Fix wrapping to eliminate Sphinx warnings. Add missing parameter and return documentation for is_homed(). Move all property documentation to the getter functions, where Python expects it and where it is not ignored by Sphinx. Fix incorrectly documented return types. Improve some descriptions that were unclear (to me). --- canopen/profiles/p402.py | 120 +++++++++++++++++++++------------------ 1 file changed, 66 insertions(+), 54 deletions(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index 195ca035..923e0331 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -87,9 +87,11 @@ class State402(object): @staticmethod def next_state_for_enabling(_from): - """Returns the next state needed for reach the state Operation Enabled - :param string target: Target state - :return string: Next target to chagne + """Return the next state needed for reach the state Operation Enabled. + + :param string target: Target state. + :return: Next target to change. + :rtype: str """ for cond, next_state in State402.NEXTSTATE2ENABLE.items(): if _from in cond: @@ -209,10 +211,10 @@ def __init__(self, node_id, object_dictionary): self.rpdo_pointers = dict() # { index: RPDO_pointer } def setup_402_state_machine(self): - """Configure the state machine by searching for a TPDO that has the - StatusWord mapped. - :raise ValueError: If the the node can't find a Statusword configured - in the any of the TPDOs + """Configure the state machine by searching for a TPDO that has the StatusWord mapped. + + :raises ValueError: + If the the node can't find a Statusword configured in the any of the TPDOs. """ self.nmt.state = 'PRE-OPERATIONAL' # Why is this necessary? self.setup_pdos() @@ -258,8 +260,7 @@ def _check_statusword_configured(self): self.id)) def reset_from_fault(self): - """Reset node from fault and set it to Operation Enable state - """ + """Reset node from fault and set it to Operation Enable state.""" if self.state == 'FAULT': # Resets the Fault Reset bit (rising edge 0 -> 1) self.controlword = State402.CW_DISABLE_VOLTAGE @@ -275,7 +276,12 @@ def is_faulted(self): return self.statusword & bitmask == bits def is_homed(self, restore_op_mode=False): - """Switch to homing mode and determine its status.""" + """Switch to homing mode and determine its status. + + :param bool restore_op_mode: Switch back to the previous operation mode when done. + :return: If the status indicates successful homing. + :rtype: bool + """ previous_op_mode = self.op_mode if previous_op_mode != 'HOMING': logger.info('Switch to HOMING from %s', previous_op_mode) @@ -290,11 +296,13 @@ def is_homed(self, restore_op_mode=False): return homingstatus in ('TARGET REACHED', 'ATTAINED') def homing(self, timeout=TIMEOUT_HOMING_DEFAULT, set_new_home=True): - """Function to execute the configured Homing Method on the node - :param int timeout: Timeout value (default: 30) - :param bool set_new_home: Defines if the node should set the home offset - object (0x607C) to the current position after the homing procedure (default: true) - :return: If the homing was complete with success + """Execute the configured Homing method on the node. + + :param int timeout: Timeout value (default: 30). + :param bool set_new_home: + Defines if the node should set the home offset object (0x607C) to the current + position after the homing procedure (default: true). + :return: If the homing was complete with success. :rtype: bool """ previus_op_mode = self.op_mode @@ -333,18 +341,11 @@ def homing(self, timeout=TIMEOUT_HOMING_DEFAULT, set_new_home=True): @property def op_mode(self): - """ - :return: Return the operation mode stored in the object 0x6061 through SDO - :rtype: int - """ - return OperationMode.CODE2NAME[self.sdo[0x6061].raw] + """The node's Operation Mode stored in the object 0x6061. - @op_mode.setter - def op_mode(self, mode): - """Function to define the operation mode of the node - :param string mode: Mode to define. + Uses SDO to access the current value. The modes are passed as one of the + following strings: - The modes can be: - 'NO MODE' - 'PROFILED POSITION' - 'VELOCITY' @@ -357,7 +358,14 @@ def op_mode(self, mode): - 'CYCLIC SYNCHRONOUS TORQUE' - 'OPEN LOOP SCALAR MODE' - 'OPEN LOOP VECTOR MODE' + + :raises TypeError: When setting a mode not advertised as supported by the node. + :raises RuntimeError: If the switch is not confirmed within the configured timeout. """ + return OperationMode.CODE2NAME[self.sdo[0x6061].raw] + + @op_mode.setter + def op_mode(self, mode): try: if not self.is_op_mode_supported(mode): raise TypeError( @@ -394,9 +402,13 @@ def _clear_target_values(self): self.sdo[target_index].raw = 0 def is_op_mode_supported(self, mode): - """Function to check if the operation mode is supported by the node - :param int mode: Operation mode - :return: If the operation mode is supported + """Check if the operation mode is supported by the node. + + The object listing the supported modes is retrieved once using SDO, then cached + for later checks. + + :param str mode: Same format as the :attr:`op_mode` property. + :return: If the operation mode is supported. :rtype: bool """ if not hasattr(self, '_op_mode_support'): @@ -408,18 +420,20 @@ def is_op_mode_supported(self, mode): return self._op_mode_support & bits == bits def on_TPDOs_update_callback(self, mapobject): - """This function receives a map object. - this map object is then used for changing the - :param mapobject: :class: `canopen.objectdictionary.Variable` + """Cache updated values from a TPDO received from this node. + + :param mapobject: The received PDO message. + :type mapobject: canopen.pdo.Map """ for obj in mapobject: self.tpdo_values[obj.index] = obj.raw @property def statusword(self): - """Returns the last read value of the Statusword (0x6041) from the device. - If the the object 0x6041 is not configured in any TPDO it will fallback to the SDO mechanism - and try to tget the value. + """Return the last read value of the Statusword (0x6041) from the device. + + If the the object 0x6041 is not configured in any TPDO it will fall back to the + SDO mechanism and try to get the value. """ try: return self.tpdo_values[0x6041] @@ -429,13 +443,15 @@ def statusword(self): @property def controlword(self): + """Send a state change command using PDO or SDO. + + :param int value: Controlword value to set. + :raises RuntimeError: Read access to the controlword is not intended. + """ raise RuntimeError('The Controlword is write-only.') @controlword.setter def controlword(self, value): - """Send the state using PDO or SDO objects. - :param int value: State value to send in the message - """ if 0x6040 in self.rpdo_pointers: self.rpdo_pointers[0x6040].raw = value self.rpdo_pointers[0x6040].pdo_parent.transmit() @@ -444,16 +460,24 @@ def controlword(self, value): @property def state(self): - """Attribute to get or set node's state as a string for the DS402 State Machine. - States of the node can be one of: - - 'NOT READY TO SWITCH ON' + """Manipulate current state of the DS402 State Machine on the node. + + Uses the last received Statusword value for read access, and manipulates the + :attr:`controlword` for changing states. The states are passed as one of the + following strings: + + - 'NOT READY TO SWITCH ON' (cannot be switched to deliberately) - 'SWITCH ON DISABLED' - 'READY TO SWITCH ON' - 'SWITCHED ON' - 'OPERATION ENABLED' - - 'FAULT' - - 'FAULT REACTION ACTIVE' + - 'FAULT' (cannot be switched to deliberately) + - 'FAULT REACTION ACTIVE' (cannot be switched to deliberately) - 'QUICK STOP ACTIVE' + - 'DISABLE VOLTAGE' (only as a command when writing) + + :raises RuntimeError: If the switch is not confirmed within the configured timeout. + :raises ValueError: Trying to execute a illegal transition in the state machine. """ for state, mask_val_pair in State402.SW_MASK.items(): bitmask, bits = mask_val_pair @@ -463,18 +487,6 @@ def state(self): @state.setter def state(self, target_state): - """ Defines the state for the DS402 state machine - States to switch to can be one of: - - 'SWITCH ON DISABLED' - - 'DISABLE VOLTAGE' - - 'READY TO SWITCH ON' - - 'SWITCHED ON' - - 'OPERATION ENABLED' - - 'QUICK STOP ACTIVE' - :param string target_state: Target state - :raise RuntimeError: Occurs when the time defined to change the state is reached - :raise ValueError: Occurs when trying to execute a ilegal transition in the sate machine - """ timeout = time.monotonic() + self.TIMEOUT_SWITCH_STATE_FINAL while self.state != target_state: next_state = self._next_state(target_state) From 282b2e32a42e18d3c009baf5032ca33d9a4e9482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Thu, 12 Aug 2021 09:42:55 +0200 Subject: [PATCH 18/43] Fix typo. --- canopen/profiles/p402.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index 923e0331..f822e1b9 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -432,8 +432,8 @@ def on_TPDOs_update_callback(self, mapobject): def statusword(self): """Return the last read value of the Statusword (0x6041) from the device. - If the the object 0x6041 is not configured in any TPDO it will fall back to the - SDO mechanism and try to get the value. + If the object 0x6041 is not configured in any TPDO it will fall back to the SDO + mechanism and try to get the value. """ try: return self.tpdo_values[0x6041] From 00041724a9a35e95d72f77eb39ab98ede22d5e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Thu, 12 Aug 2021 09:48:49 +0200 Subject: [PATCH 19/43] Fix wrong type name for parameter. "string" is not a Python type, but "str" is. --- canopen/profiles/p402.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index f822e1b9..c5d0d504 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -89,7 +89,7 @@ class State402(object): def next_state_for_enabling(_from): """Return the next state needed for reach the state Operation Enabled. - :param string target: Target state. + :param str target: Target state. :return: Next target to change. :rtype: str """ From 4e4bc90f78965124fa0e45ff2d2ba83d3706eb1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Thu, 12 Aug 2021 10:56:21 +0200 Subject: [PATCH 20/43] Fix typo. --- canopen/profiles/p402.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index c5d0d504..f5233ea4 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -214,7 +214,7 @@ def setup_402_state_machine(self): """Configure the state machine by searching for a TPDO that has the StatusWord mapped. :raises ValueError: - If the the node can't find a Statusword configured in the any of the TPDOs. + If the the node can't find a Statusword configured in any of the TPDOs. """ self.nmt.state = 'PRE-OPERATIONAL' # Why is this necessary? self.setup_pdos() From 4b95870ebf0a435ceed38fd4778abcb0305c3eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Mon, 16 Aug 2021 18:49:19 +0200 Subject: [PATCH 21/43] ds402: Skip explicit change to SWITCHED ON state. (#249) The BaseNode402.homing() method tries to enter state SWITCHED ON upon entry. That's unnecessary, the application should handle these transitions. But more importantly, it actually fails in many cases, namely if the previous state is SWITCH ON DISABLED, the default power-up state of most devices. There is an automatic way to reach OPERATION ENABLED over multiple intermediate steps, but the library does not know how to reach SWITCHED ON from any other state than OPERATION ENABLED or READY TO SWITCH ON. In addition, setting the op_mode property will already change to SWITCHED ON only if coming from OPERATION ENABLED (which is usually a good idea to avoid unexpected movement). Note that switching the operation mode to HOMING is actually safe in any power state. --- canopen/profiles/p402.py | 1 - 1 file changed, 1 deletion(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index f5233ea4..b0fc090f 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -306,7 +306,6 @@ def homing(self, timeout=TIMEOUT_HOMING_DEFAULT, set_new_home=True): :rtype: bool """ previus_op_mode = self.op_mode - self.state = 'SWITCHED ON' self.op_mode = 'HOMING' # The homing process will initialize at operation enabled self.state = 'OPERATION ENABLED' From 8e9ccd665053d99a17bbd84597f64d8a12257e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Mon, 16 Aug 2021 18:51:41 +0200 Subject: [PATCH 22/43] ds402: Remove set_new_home functionality from BaseNode402.homing(). (#250) The homing() method will try to manipulate the Home Offset (0x607C) parameter by default. That's not the way the parameter is intended to work. After a successful homing procedure, the drive should set the Actual Position (0x6063) to the Home Offset (0x607C) by itself. By default that is zero, so the selected reference switch flank will mark the new zero position. The library's default behavior here is backwards, and can only work with absolute position encoders. The whole point of homing is to find a physical reference and align the logical coordinate system to it. Trying to determine the desired offset from the value which an unreferenced encoder had at the physical reference point actually destroys that logical alignment. The functionality of set_new_home=True is trivial to do from the application, so remove it completely from homing(). --- canopen/profiles/p402.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index b0fc090f..804ec576 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -295,13 +295,10 @@ def is_homed(self, restore_op_mode=False): self.op_mode = previous_op_mode return homingstatus in ('TARGET REACHED', 'ATTAINED') - def homing(self, timeout=TIMEOUT_HOMING_DEFAULT, set_new_home=True): + def homing(self, timeout=TIMEOUT_HOMING_DEFAULT): """Execute the configured Homing method on the node. :param int timeout: Timeout value (default: 30). - :param bool set_new_home: - Defines if the node should set the home offset object (0x607C) to the current - position after the homing procedure (default: true). :return: If the homing was complete with success. :rtype: bool """ @@ -326,10 +323,6 @@ def homing(self, timeout=TIMEOUT_HOMING_DEFAULT, set_new_home=True): time.sleep(self.INTERVAL_CHECK_STATE) if time.monotonic() > t: raise RuntimeError('Unable to home, timeout reached') - if set_new_home: - actual_position = self.sdo[0x6063].raw - self.sdo[0x607C].raw = actual_position # Home Offset - logger.info('Homing offset set to {0}'.format(actual_position)) logger.info('Homing mode carried out successfully.') return True except RuntimeError as e: From 8523a686479a8a294a97e067fc3444ac869792e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Mon, 16 Aug 2021 18:54:51 +0200 Subject: [PATCH 23/43] DS402: Minimize side-effects of operation mode switching (ref #244) (#251) * ds402: Keep target values on operation mode change. As the comment already stated, clearing the target values should possibly happen before switching to the OPERATION ENABLED state, to avoid unexpected movements. So doing that when actually leaving that state is mostly useless. Some legitimate use cases even require switching the operation mode while in OPERATION ENABLED, e.g. switching between Profile Position and Profile Velocity. This change does not allow that, but at the very least will avoid the need to reset target values again. * ds402: Keep power state on operation mode change. Some legitimate use cases require switching the operation mode while in OPERATION ENABLED, e.g. switching between Profile Position and Profile Velocity. If an application or specific controller needs the transition from OPERATION ENABLED to SWITCHED ON during operation mode changes, that should be handled outside this library, and is easy enough to do. On the other hand, having it inside the op_mode setter prevents the above mentioned use-case. * ds402: Improve logging in op_mode setter. Do not generate a log message about the changed operation mode when it actually failed. Use a consistent style for the TypeError message formatting. --- canopen/profiles/p402.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index 804ec576..34763a21 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -361,31 +361,20 @@ def op_mode(self, mode): try: if not self.is_op_mode_supported(mode): raise TypeError( - 'Operation mode {0} not suppported on node {1}.'.format(mode, self.id)) - - start_state = self.state - - if self.state == 'OPERATION ENABLED': - self.state = 'SWITCHED ON' - # ensure the node does not move with an old value - self._clear_target_values() # Shouldn't this happen before it's switched on? - + 'Operation mode {m} not suppported on node {n}.'.format(n=self.id, m=mode)) # operation mode self.sdo[0x6060].raw = OperationMode.NAME2CODE[mode] - timeout = time.monotonic() + self.TIMEOUT_SWITCH_OP_MODE while self.op_mode != mode: if time.monotonic() > timeout: raise RuntimeError( "Timeout setting node {0}'s new mode of operation to {1}.".format( self.id, mode)) + logger.info('Set node {n} operation mode to {m}.'.format(n=self.id, m=mode)) except SdoCommunicationError as e: logger.warning('[SDO communication error] Cause: {0}'.format(str(e))) except (RuntimeError, ValueError) as e: logger.warning('{0}'.format(str(e))) - finally: - self.state = start_state # why? - logger.info('Set node {n} operation mode to {m}.'.format(n=self.id, m=mode)) def _clear_target_values(self): # [target velocity, target position, target torque] From ebad5da3285a2386be4030f2f58e2d0837a6e3da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Mon, 16 Aug 2021 19:07:19 +0200 Subject: [PATCH 24/43] DS402: Increase delay to check status on homing start. (#252) * ds402: Increase delay to check status on homing start. The Statusword is examined immediately after setting the Controlword command to start homing. That is very likely to fail because of the round-trip time until the Statusword is actually updated from a TPDO. To work around that, the delay between each check of the Statusword should be moved before the actual comparison, and its default value increased. Introduce a new constant TIMEOUT_CHECK_HOMING to configure that with a default value of 100 ms. This replaces the previously used INTERVAL_CHECK_STATE which is only 10 ms by default. An even better solution would be to wait for the Statusword to be updated by a received PDO, but that would be much more complex. * Apply interval to is_homed() method as well. Same problem as in the homing() method, PDO updates of the Statusword need at least one SYNC / PDO cycle duration. * Factor out common _homing_status() method. Move the common code from is_homed() and homing() to a method for internal use. Add a comment why the delay is necessary and how it should possibly be replaced by an RPDO reception check. Should the latter be implemented, there will be only one place to change it. --- canopen/profiles/p402.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index 34763a21..bb84ec8d 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -204,6 +204,7 @@ class BaseNode402(RemoteNode): TIMEOUT_SWITCH_STATE_SINGLE = 0.4 # seconds INTERVAL_CHECK_STATE = 0.01 # seconds TIMEOUT_HOMING_DEFAULT = 30 # seconds + INTERVAL_CHECK_HOMING = 0.1 # seconds def __init__(self, node_id, object_dictionary): super(BaseNode402, self).__init__(node_id, object_dictionary) @@ -275,6 +276,18 @@ def is_faulted(self): bitmask, bits = State402.SW_MASK['FAULT'] return self.statusword & bitmask == bits + def _homing_status(self): + """Interpret the current Statusword bits as homing state string.""" + # Wait to make sure an RPDO was received. Should better check for reception + # instead of this hard-coded delay, but at least it can be configured per node. + time.sleep(self.INTERVAL_CHECK_HOMING) + status = None + for key, value in Homing.STATES.items(): + bitmask, bits = value + if self.statusword & bitmask == bits: + status = key + return status + def is_homed(self, restore_op_mode=False): """Switch to homing mode and determine its status. @@ -286,11 +299,7 @@ def is_homed(self, restore_op_mode=False): if previous_op_mode != 'HOMING': logger.info('Switch to HOMING from %s', previous_op_mode) self.op_mode = 'HOMING' - homingstatus = None - for key, value in Homing.STATES.items(): - bitmask, bits = value - if self.statusword & bitmask == bits: - homingstatus = key + homingstatus = self._homing_status() if restore_op_mode: self.op_mode = previous_op_mode return homingstatus in ('TARGET REACHED', 'ATTAINED') @@ -311,16 +320,10 @@ def homing(self, timeout=TIMEOUT_HOMING_DEFAULT): t = time.monotonic() + timeout try: while homingstatus not in ('TARGET REACHED', 'ATTAINED'): - for key, value in Homing.STATES.items(): - # check if the Statusword after applying the bitmask - # corresponds with the needed bits to determine the current status - bitmask, bits = value - if self.statusword & bitmask == bits: - homingstatus = key + homingstatus = self._homing_status() if homingstatus in ('INTERRUPTED', 'ERROR VELOCITY IS NOT ZERO', 'ERROR VELOCITY IS ZERO'): raise RuntimeError('Unable to home. Reason: {0}'.format(homingstatus)) - time.sleep(self.INTERVAL_CHECK_STATE) if time.monotonic() > t: raise RuntimeError('Unable to home, timeout reached') logger.info('Homing mode carried out successfully.') From 1a0ebd72be4311291654c8bc5172763e339102a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= Date: Mon, 16 Aug 2021 18:16:03 +0100 Subject: [PATCH 25/43] Revert "DS402: Increase delay to check status on homing start. (#252)" This reverts commit ebad5da3285a2386be4030f2f58e2d0837a6e3da. Will merge instead the PR #257 with cumulative changes --- canopen/profiles/p402.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index bb84ec8d..34763a21 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -204,7 +204,6 @@ class BaseNode402(RemoteNode): TIMEOUT_SWITCH_STATE_SINGLE = 0.4 # seconds INTERVAL_CHECK_STATE = 0.01 # seconds TIMEOUT_HOMING_DEFAULT = 30 # seconds - INTERVAL_CHECK_HOMING = 0.1 # seconds def __init__(self, node_id, object_dictionary): super(BaseNode402, self).__init__(node_id, object_dictionary) @@ -276,18 +275,6 @@ def is_faulted(self): bitmask, bits = State402.SW_MASK['FAULT'] return self.statusword & bitmask == bits - def _homing_status(self): - """Interpret the current Statusword bits as homing state string.""" - # Wait to make sure an RPDO was received. Should better check for reception - # instead of this hard-coded delay, but at least it can be configured per node. - time.sleep(self.INTERVAL_CHECK_HOMING) - status = None - for key, value in Homing.STATES.items(): - bitmask, bits = value - if self.statusword & bitmask == bits: - status = key - return status - def is_homed(self, restore_op_mode=False): """Switch to homing mode and determine its status. @@ -299,7 +286,11 @@ def is_homed(self, restore_op_mode=False): if previous_op_mode != 'HOMING': logger.info('Switch to HOMING from %s', previous_op_mode) self.op_mode = 'HOMING' - homingstatus = self._homing_status() + homingstatus = None + for key, value in Homing.STATES.items(): + bitmask, bits = value + if self.statusword & bitmask == bits: + homingstatus = key if restore_op_mode: self.op_mode = previous_op_mode return homingstatus in ('TARGET REACHED', 'ATTAINED') @@ -320,10 +311,16 @@ def homing(self, timeout=TIMEOUT_HOMING_DEFAULT): t = time.monotonic() + timeout try: while homingstatus not in ('TARGET REACHED', 'ATTAINED'): - homingstatus = self._homing_status() + for key, value in Homing.STATES.items(): + # check if the Statusword after applying the bitmask + # corresponds with the needed bits to determine the current status + bitmask, bits = value + if self.statusword & bitmask == bits: + homingstatus = key if homingstatus in ('INTERRUPTED', 'ERROR VELOCITY IS NOT ZERO', 'ERROR VELOCITY IS ZERO'): raise RuntimeError('Unable to home. Reason: {0}'.format(homingstatus)) + time.sleep(self.INTERVAL_CHECK_STATE) if time.monotonic() > t: raise RuntimeError('Unable to home, timeout reached') logger.info('Homing mode carried out successfully.') From c161ab3931a6312ce27bffc96b79f4a70386464a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Mon, 16 Aug 2021 19:27:58 +0200 Subject: [PATCH 26/43] DS402: Allow handling the operation mode via PDO. (#257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * pdo: Document relation between start() and the period attribute. * pdo: Add a property to check if period updates are transmitted. * p402: Skip sending the controlword RPDO if configured with a period. Transmitting the RPDO only makes sense if the value takes effect immediately. If there is already a cyclic task configured, or the transmission type configuration requires a SYNC to be sent for the change to apply, there is no sense in sending the PDO automatically in the controlword setter. * p402: Use RPDO to set the operation mode if possible. Fall back to the previous behavior using SDO if the relevant object 0x6060 is not mapped to an RPDO. * p402: Use TPDO to get the operation mode if possible. Fall back to the previous behavior using SDO if the relevant object 0x6061 is not mapped to a TPDO. The property getter still blocks until an up-to-date value was received, by waiting for the respective TPDO up to a configurable timeout of 0.2 seconds by default. If the TPDO does not look like it will be transmitted regularly (from its is_periodic property), the method will not block and just return the last received TPDO's value. A lookup cache tpdo_pointers is added to keep track of the needed pdo.Map instance, analog to the rpdo_pointers. * p402: Improve documentation on PDO tracking dicts. Consistently use empty dict literal for initialization. Provide more useful comments about the expected contents. * p402: Check PDO configuration for the Operation Mode objects. Switching operation modes for several drives simultaneously must be done via PDO. There is still a fallback mechanism via SDO, but a warning should be issued when that is about to be used. Co-authored-by: André Filipe Silva --- canopen/pdo/base.py | 25 +++++++++++++++++++++++-- canopen/profiles/p402.py | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/canopen/pdo/base.py b/canopen/pdo/base.py index f5f65192..63a0053e 100644 --- a/canopen/pdo/base.py +++ b/canopen/pdo/base.py @@ -186,7 +186,8 @@ def __init__(self, pdo_node, com_record, map_array): self.data = bytearray() #: Timestamp of last received message self.timestamp = None - #: Period of receive message transmission in seconds + #: Period of receive message transmission in seconds. + #: Set explicitly or using the :meth:`start()` method. self.period = None self.callbacks = [] self.receive_condition = threading.Condition() @@ -273,6 +274,23 @@ def name(self): node_id = self.cob_id & 0x7F return "%sPDO%d_node%d" % (direction, map_id, node_id) + @property + def is_periodic(self): + """Indicate whether PDO updates will be transferred regularly. + + If some external mechanism is used to transmit the PDO regularly, its cycle time + should be written to the :attr:`period` member for this property to work. + """ + if self.period is not None: + # Configured from start() or externally + return True + elif self.trans_type is not None and self.trans_type <= 0xF0: + # TPDOs will be transmitted on SYNC, RPDOs need a SYNC to apply, so + # assume that the SYNC service is active. + return True + # Unknown transmission type, assume non-periodic + return False + def on_message(self, can_id, data, timestamp): is_transmitting = self._task is not None if can_id == self.cob_id and not is_transmitting: @@ -459,7 +477,10 @@ def transmit(self): def start(self, period=None): """Start periodic transmission of message in a background thread. - :param float period: Transmission period in seconds + :param float period: + Transmission period in seconds. Can be omitted if :attr:`period` has been set + on the object before. + :raises ValueError: When neither the argument nor the :attr:`period` is given. """ # Stop an already running transmission if we have one, otherwise we # overwrite the reference and can lose our handle to shut it down diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index 34763a21..044d45c4 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -202,13 +202,15 @@ class BaseNode402(RemoteNode): TIMEOUT_SWITCH_OP_MODE = 0.5 # seconds TIMEOUT_SWITCH_STATE_FINAL = 0.8 # seconds TIMEOUT_SWITCH_STATE_SINGLE = 0.4 # seconds + TIMEOUT_CHECK_TPDO = 0.2 # seconds INTERVAL_CHECK_STATE = 0.01 # seconds TIMEOUT_HOMING_DEFAULT = 30 # seconds def __init__(self, node_id, object_dictionary): super(BaseNode402, self).__init__(node_id, object_dictionary) - self.tpdo_values = dict() # { index: TPDO_value } - self.rpdo_pointers = dict() # { index: RPDO_pointer } + self.tpdo_values = {} # { index: value from last received TPDO } + self.tpdo_pointers = {} # { index: pdo.Map instance } + self.rpdo_pointers = {} # { index: pdo.Map instance } def setup_402_state_machine(self): """Configure the state machine by searching for a TPDO that has the StatusWord mapped. @@ -220,6 +222,7 @@ def setup_402_state_machine(self): self.setup_pdos() self._check_controlword_configured() self._check_statusword_configured() + self._check_op_mode_configured() self.nmt.state = 'OPERATIONAL' self.state = 'SWITCH ON DISABLED' # Why change state? @@ -236,6 +239,7 @@ def _init_tpdo_values(self): logger.debug('Configured TPDO: {0}'.format(obj.index)) if obj.index not in self.tpdo_values: self.tpdo_values[obj.index] = 0 + self.tpdo_pointers[obj.index] = obj def _init_rpdo_pointers(self): # If RPDOs have overlapping indecies, rpdo_pointers will point to @@ -259,6 +263,16 @@ def _check_statusword_configured(self): "Statusword not configured in node {0}'s PDOs. Using SDOs can cause slow performance.".format( self.id)) + def _check_op_mode_configured(self): + if 0x6060 not in self.rpdo_pointers: # Operation Mode + logger.warning( + "Operation Mode not configured in node {0}'s PDOs. Using SDOs can cause slow performance.".format( + self.id)) + if 0x6061 not in self.tpdo_values: # Operation Mode Display + logger.warning( + "Operation Mode Display not configured in node {0}'s PDOs. Using SDOs can cause slow performance.".format( + self.id)) + def reset_from_fault(self): """Reset node from fault and set it to Operation Enable state.""" if self.state == 'FAULT': @@ -335,7 +349,7 @@ def homing(self, timeout=TIMEOUT_HOMING_DEFAULT): def op_mode(self): """The node's Operation Mode stored in the object 0x6061. - Uses SDO to access the current value. The modes are passed as one of the + Uses SDO or PDO to access the current value. The modes are passed as one of the following strings: - 'NO MODE' @@ -354,7 +368,18 @@ def op_mode(self): :raises TypeError: When setting a mode not advertised as supported by the node. :raises RuntimeError: If the switch is not confirmed within the configured timeout. """ - return OperationMode.CODE2NAME[self.sdo[0x6061].raw] + try: + pdo = self.tpdo_pointers[0x6061].pdo_parent + if pdo.is_periodic: + timestamp = pdo.wait_for_reception(timeout=self.TIMEOUT_CHECK_TPDO) + if timestamp is None: + raise RuntimeError("Timeout getting node {0}'s mode of operation.".format( + self.id)) + code = self.tpdo_values[0x6061] + except KeyError: + logger.warning('The object 0x6061 is not a configured TPDO, fallback to SDO') + code = self.sdo[0x6061].raw + return OperationMode.CODE2NAME[code] @op_mode.setter def op_mode(self, mode): @@ -435,7 +460,9 @@ def controlword(self): def controlword(self, value): if 0x6040 in self.rpdo_pointers: self.rpdo_pointers[0x6040].raw = value - self.rpdo_pointers[0x6040].pdo_parent.transmit() + pdo = self.rpdo_pointers[0x6040].pdo_parent + if not pdo.is_periodic: + pdo.transmit() else: self.sdo[0x6040].raw = value From e5355f4911e58d6e673f256faeae805c2c2f4466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Mon, 16 Aug 2021 19:33:15 +0200 Subject: [PATCH 27/43] p402: Make HOMING_TIMEOUT_DEFAULT configurable per instance. (#258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the HOMING_TIMEOUT_DEFAULT class attribute is overridden as an object attribute, the argument default value definition in homing() still uses the old value from the class attribute. Check the argument and apply the default value at runtime to pick up the updated default timeout if the argument is omitted. Co-authored-by: André Filipe Silva --- canopen/profiles/p402.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index 044d45c4..bf8b0791 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -316,6 +316,8 @@ def homing(self, timeout=TIMEOUT_HOMING_DEFAULT): :return: If the homing was complete with success. :rtype: bool """ + if timeout is None: + timeout = self.TIMEOUT_HOMING_DEFAULT previus_op_mode = self.op_mode self.op_mode = 'HOMING' # The homing process will initialize at operation enabled @@ -335,7 +337,7 @@ def homing(self, timeout=TIMEOUT_HOMING_DEFAULT): 'ERROR VELOCITY IS ZERO'): raise RuntimeError('Unable to home. Reason: {0}'.format(homingstatus)) time.sleep(self.INTERVAL_CHECK_STATE) - if time.monotonic() > t: + if timeout and time.monotonic() > t: raise RuntimeError('Unable to home, timeout reached') logger.info('Homing mode carried out successfully.') return True From 7c5e4ceaecfeea0193777e9c4655c2e77b5c2f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Mon, 16 Aug 2021 19:36:10 +0200 Subject: [PATCH 28/43] DS402: Simplify setup procedure. (#259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * p402: Do not switch any states during setup_402_state_machine(). Reading the PDO configuration is possible in OPERATIONAL or PRE-OPERATIONAL states, so switching that is unnecessary. The application should be responsible to handle such transitions, and the library function should be usable without disturbing the application logic. Changing the DS402 state machine to SWITCH ON DISABLED is also not necessary. The drive may be in whatever state from a previous usage, and then the change to SWITCH ON DISABLED may even trigger an exception because there is no way to reach it directly. So this transition should also be the application's responsibility. * p402: Check NMT state before reading PDO configuration. SDOs are allowed in all but the STOPPED state. That would lead to a timeout and an SdoCommunicationError exception. Checking the NMT state here raises an exception without a timeout involved. * p402: Make reading the PDO configuration optional during setup. If the application already configured the PDOs and called .save() on the pdo.Maps object, there is no sense in reading everything back again in the setup_pdos() method. Provide an optional argument to disable that behavior. A call to subscribe to the PDOs from the network is added because that side-effect of pdo.read() is necessary for the TPDO callback to work. * p402: Allow skipping PDO upload from setup_402_state_machine(). Add an optional argument which is simply passed down to setup_pdos() to choose whether reading the PDO configuration is necessary. * Fix DS402 documentation to match the implementation. Besides the changes regarding setup_402_state_machine(), there were numerous errors where the documentation talks about nonexistent or differently named attributes. Also fix the description regaring what the method actually does. It won't configure the TPDO1 to contain the Statusword, but only check the PDO configuration for Statusword and Controlword presence. Co-authored-by: André Filipe Silva --- canopen/profiles/p402.py | 25 +++++++++++++++++-------- doc/profiles.rst | 30 ++++++++++++++++-------------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index bf8b0791..f12e6176 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -212,22 +212,31 @@ def __init__(self, node_id, object_dictionary): self.tpdo_pointers = {} # { index: pdo.Map instance } self.rpdo_pointers = {} # { index: pdo.Map instance } - def setup_402_state_machine(self): + def setup_402_state_machine(self, read_pdos=True): """Configure the state machine by searching for a TPDO that has the StatusWord mapped. + :param bool read_pdos: Upload current PDO configuration from node. :raises ValueError: If the the node can't find a Statusword configured in any of the TPDOs. """ - self.nmt.state = 'PRE-OPERATIONAL' # Why is this necessary? - self.setup_pdos() + self.setup_pdos(read_pdos) self._check_controlword_configured() self._check_statusword_configured() - self._check_op_mode_configured() - self.nmt.state = 'OPERATIONAL' - self.state = 'SWITCH ON DISABLED' # Why change state? - def setup_pdos(self): - self.pdo.read() # TPDO and RPDO configurations + def setup_pdos(self, upload=True): + """Find the relevant PDO configuration to handle the state machine. + + :param bool upload: + Retrieve up-to-date configuration via SDO. If False, the node's mappings must + already be configured in the object, matching the drive's settings. + :raises AssertionError: + When the node's NMT state disallows SDOs for reading the PDO configuration. + """ + if upload: + assert self.nmt.state in 'PRE-OPERATIONAL', 'OPERATIONAL' + self.pdo.read() # TPDO and RPDO configurations + else: + self.pdo.subscribe() # Get notified on reception, usually a side-effect of read() self._init_tpdo_values() self._init_rpdo_pointers() diff --git a/doc/profiles.rst b/doc/profiles.rst index 1047c7f9..1ef5ab58 100644 --- a/doc/profiles.rst +++ b/doc/profiles.rst @@ -34,10 +34,13 @@ The current status can be read from the device by reading the register 0x6041, which is called the "Statusword". Changes in state can only be done in the 'OPERATIONAL' state of the NmtMaster -TPDO1 needs to be set up correctly. For this, run the the -`BaseNode402.setup_402_state_machine()` method. Note that this setup -routine will change only TPDO1 and automatically go to the 'OPERATIONAL' state -of the NmtMaster:: +PDOs with the Controlword and Statusword mapped need to be set up correctly, +which is the default configuration of most DS402-compatible drives. To make +them accessible to the state machine implementation, run the the +`BaseNode402.setup_402_state_machine()` method. Note that this setup routine +will read the current PDO configuration by default, causing some SDO traffic. +That works only in the 'OPERATIONAL' or 'PRE-OPERATIONAL' states of the +:class:`NmtMaster`:: # run the setup routine for TPDO1 and it's callback some_node.setup_402_state_machine() @@ -50,21 +53,20 @@ Write Controlword and read Statusword:: # Read the state of the Statusword some_node.sdo[0x6041].raw -During operation the state can change to states which cannot be commanded -by the Controlword, for example a 'FAULT' state. -Therefore the :class:`PowerStateMachine` class (in similarity to the :class:`NmtMaster` -class) automatically monitors state changes of the Statusword which is sent -by TPDO1. The available callback on thet TPDO1 will then extract the -information and mirror the state change in the :attr:`BaseNode402.powerstate_402` -attribute. +During operation the state can change to states which cannot be commanded by the +Controlword, for example a 'FAULT' state. Therefore the :class:`BaseNode402` +class (in similarity to :class:`NmtMaster`) automatically monitors state changes +of the Statusword which is sent by TPDO. The available callback on that TPDO +will then extract the information and mirror the state change in the +:attr:`BaseNode402.state` attribute. Similar to the :class:`NmtMaster` class, the states of the :class:`BaseNode402` -class :attr:`._state` attribute can be read and set (command) by a string:: +class :attr:`.state` attribute can be read and set (command) by a string:: # command a state (an SDO message will be called) - some_node.powerstate_402.state = 'SWITCHED ON' + some_node.state = 'SWITCHED ON' # read the current state - some_node.powerstate_402.state = 'SWITCHED ON' + some_node.state = 'SWITCHED ON' Available states: From b2ef201b4a53820de38ba79cf093379d6f997169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Mon, 16 Aug 2021 19:37:24 +0200 Subject: [PATCH 29/43] Small change in NMT log message and FIXME comment for DS402. (#260) * nmt: Include node ID in state change message. These log messages can get rather confusing with more nodes involved. * p402: Add FIXME about the messed up logic in reset_from_fault(). That method does not work as intended. Until a fix is ready, at least explain what goes wrong in a code comment. --- canopen/nmt.py | 4 ++-- canopen/profiles/p402.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/canopen/nmt.py b/canopen/nmt.py index 60a7f758..f7a5d305 100644 --- a/canopen/nmt.py +++ b/canopen/nmt.py @@ -68,8 +68,8 @@ def send_command(self, code): """ if code in COMMAND_TO_STATE: new_state = COMMAND_TO_STATE[code] - logger.info("Changing NMT state from %s to %s", - NMT_STATES[self._state], NMT_STATES[new_state]) + logger.info("Changing NMT state on node %d from %s to %s", + self.id, NMT_STATES[self._state], NMT_STATES[new_state]) self._state = new_state @property diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index f12e6176..faa5f25e 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -287,6 +287,8 @@ def reset_from_fault(self): if self.state == 'FAULT': # Resets the Fault Reset bit (rising edge 0 -> 1) self.controlword = State402.CW_DISABLE_VOLTAGE + # FIXME! The rising edge happens with the transitions toward OPERATION + # ENABLED below, but until then the loop will always reach the timeout! timeout = time.monotonic() + self.TIMEOUT_RESET_FAULT while self.is_faulted(): if time.monotonic() > timeout: From 7462d0bf06f77b7b50bc81d90a2babfd5e21065b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Mon, 16 Aug 2021 19:41:56 +0200 Subject: [PATCH 30/43] DS402: Restore operation mode after homing only on explicit request. (#262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ds402: Remove set_new_home functionality from BaseNode402.homing(). The homing() method will try to manipulate the Home Offset (0x607C) parameter by default. That's not the way the parameter is intended to work. After a successful homing procedure, the drive should set the Actual Position (0x6063) to the Home Offset (0x607C) by itself. By default that is zero, so the selected reference switch flank will mark the new zero position. The library's default behavior here is backwards, and can only work with absolute position encoders. The whole point of homing is to find a physical reference and align the logical coordinate system to it. Trying to determine the desired offset from the value which an unreferenced encoder had at the physical reference point actually destroys that logical alignment. The functionality of set_new_home=True is trivial to do from the application, so remove it completely from homing(). * ds402: Restore operation mode after homing only on explicit request. Add a new parameter restore_op_mode which defaults to False, and skip changing back to the previous mode unless it is explicitly enabled by passing True. Note that most applications will decide on the needed mode after homing and therefore do not need this behavior, hence the new default. Co-authored-by: André Filipe Silva --- canopen/profiles/p402.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index faa5f25e..2c17149a 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -320,16 +320,18 @@ def is_homed(self, restore_op_mode=False): self.op_mode = previous_op_mode return homingstatus in ('TARGET REACHED', 'ATTAINED') - def homing(self, timeout=TIMEOUT_HOMING_DEFAULT): + def homing(self, timeout=TIMEOUT_HOMING_DEFAULT, restore_op_mode=False): """Execute the configured Homing method on the node. :param int timeout: Timeout value (default: 30). + :param bool restore_op_mode: + Switch back to the previous operation mode after homing (default: no). :return: If the homing was complete with success. :rtype: bool """ - if timeout is None: - timeout = self.TIMEOUT_HOMING_DEFAULT - previus_op_mode = self.op_mode + if restore_op_mode: + previous_op_mode = self.op_mode + self.state = 'SWITCHED ON' self.op_mode = 'HOMING' # The homing process will initialize at operation enabled self.state = 'OPERATION ENABLED' @@ -355,7 +357,8 @@ def homing(self, timeout=TIMEOUT_HOMING_DEFAULT): except RuntimeError as e: logger.info(str(e)) finally: - self.op_mode = previus_op_mode + if restore_op_mode: + self.op_mode = previous_op_mode return False @property From 0d2b4b622e0ff1a17c2cc9b8d1ea2de6c93425c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Mon, 16 Aug 2021 20:00:58 +0200 Subject: [PATCH 31/43] DS402: Replace delay loops with waiting for TPDO reception. (#263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ds402: Increase delay to check status on homing start. The Statusword is examined immediately after setting the Controlword command to start homing. That is very likely to fail because of the round-trip time until the Statusword is actually updated from a TPDO. To work around that, the delay between each check of the Statusword should be moved before the actual comparison, and its default value increased. Introduce a new constant TIMEOUT_CHECK_HOMING to configure that with a default value of 100 ms. This replaces the previously used INTERVAL_CHECK_STATE which is only 10 ms by default. An even better solution would be to wait for the Statusword to be updated by a received PDO, but that would be much more complex. * Apply interval to is_homed() method as well. Same problem as in the homing() method, PDO updates of the Statusword need at least one SYNC / PDO cycle duration. * Factor out common _homing_status() method. Move the common code from is_homed() and homing() to a method for internal use. Add a comment why the delay is necessary and how it should possibly be replaced by an RPDO reception check. Should the latter be implemented, there will be only one place to change it. * p402: Add blocking method check_statusword(). In contrast to the property getter, this method will not simply return the cached last value, but actually wait for an updated TPDO reception. If no TPDO is configured, or it is not expected periodically, the usual statusword getter takes over and returns either the last received value or queries via SDO as a fallback. The timeout for receiving a TPDO can be configured individually per call, defaulting to 0.2 seconds (TIMEOUT_CHECK_TPDO). * p402: Wait for TPDO instead of timed statusword checking. Several methods loop around waiting for some expected statusword value, usually calling time.sleep() with the INTERVAL_CHECK_STATE or INTERVAL_CHECK_HOMING constants. Replace that with a call to check_statusword(), which will only block until the TPDO is received. This allows for possibly shorter delays, because the delay can be cut short when the TPDO arrives in between. But rate limiting the checking loops is still achieved through the blocking behavior of check_statusword(). If no TPDO is configured, the statusword will fall back to checking via SDO, which will also result in periodic checks, but only rate limited by the SDO round-trip time. Anyway this is not a recommended mode of operation, as the warning in _check_statusword_configured() points out. * p402: Fix wrong reports of INTERRUPTED homing procedure. While the statusword checks work properly now, there is no delay anymore to make sure the controlword RPDO which starts the homing procedure is already received before we start checking the homing status. So the first reading might easily return INTERRUPTED, meaning that the controlword change has just not been applied yet. Add one extra call for check_statusword() to wait for one extra cycle in case of periodic transmissions. In effect, the success checking logic starts looking at the second received statusword after setting the controlword. Co-authored-by: André Filipe Silva --- canopen/profiles/p402.py | 58 ++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index 2c17149a..87d8948c 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -293,13 +293,24 @@ def reset_from_fault(self): while self.is_faulted(): if time.monotonic() > timeout: break - time.sleep(self.INTERVAL_CHECK_STATE) + self.check_statusword() self.state = 'OPERATION ENABLED' def is_faulted(self): bitmask, bits = State402.SW_MASK['FAULT'] return self.statusword & bitmask == bits + def _homing_status(self): + """Interpret the current Statusword bits as homing state string.""" + # Wait to make sure a TPDO was received + self.check_statusword() + status = None + for key, value in Homing.STATES.items(): + bitmask, bits = value + if self.statusword & bitmask == bits: + status = key + return status + def is_homed(self, restore_op_mode=False): """Switch to homing mode and determine its status. @@ -310,12 +321,8 @@ def is_homed(self, restore_op_mode=False): previous_op_mode = self.op_mode if previous_op_mode != 'HOMING': logger.info('Switch to HOMING from %s', previous_op_mode) - self.op_mode = 'HOMING' - homingstatus = None - for key, value in Homing.STATES.items(): - bitmask, bits = value - if self.statusword & bitmask == bits: - homingstatus = key + self.op_mode = 'HOMING' # blocks until confirmed + homingstatus = self._homing_status() if restore_op_mode: self.op_mode = previous_op_mode return homingstatus in ('TARGET REACHED', 'ATTAINED') @@ -335,17 +342,14 @@ def homing(self, timeout=TIMEOUT_HOMING_DEFAULT, restore_op_mode=False): self.op_mode = 'HOMING' # The homing process will initialize at operation enabled self.state = 'OPERATION ENABLED' - homingstatus = 'IN PROGRESS' - self.controlword = State402.CW_OPERATION_ENABLED | Homing.CW_START + homingstatus = 'UNKNOWN' + self.controlword = State402.CW_OPERATION_ENABLED | Homing.CW_START # does not block + # Wait for one extra cycle, to make sure the controlword was received + self.check_statusword() t = time.monotonic() + timeout try: while homingstatus not in ('TARGET REACHED', 'ATTAINED'): - for key, value in Homing.STATES.items(): - # check if the Statusword after applying the bitmask - # corresponds with the needed bits to determine the current status - bitmask, bits = value - if self.statusword & bitmask == bits: - homingstatus = key + homingstatus = self._homing_status() if homingstatus in ('INTERRUPTED', 'ERROR VELOCITY IS NOT ZERO', 'ERROR VELOCITY IS ZERO'): raise RuntimeError('Unable to home. Reason: {0}'.format(homingstatus)) @@ -463,6 +467,26 @@ def statusword(self): logger.warning('The object 0x6041 is not a configured TPDO, fallback to SDO') return self.sdo[0x6041].raw + def check_statusword(self, timeout=None): + """Report an up-to-date reading of the statusword (0x6041) from the device. + + If the TPDO with the statusword is configured as periodic, this method blocks + until one was received. Otherwise, it uses the SDO fallback of the ``statusword`` + property. + + :param timeout: Maximum time in seconds to wait for TPDO reception. + :raises RuntimeError: Occurs when the given timeout expires without a TPDO. + :return: Updated value of the ``statusword`` property. + :rtype: int + """ + if 0x6041 in self.tpdo_pointers: + pdo = self.tpdo_pointers[0x6041].pdo_parent + if pdo.is_periodic: + timestamp = pdo.wait_for_reception(timeout or self.TIMEOUT_CHECK_TPDO) + if timestamp is None: + raise RuntimeError('Timeout waiting for updated statusword') + return self.statusword + @property def controlword(self): """Send a state change command using PDO or SDO. @@ -518,7 +542,7 @@ def state(self, target_state): continue if time.monotonic() > timeout: raise RuntimeError('Timeout when trying to change state') - time.sleep(self.INTERVAL_CHECK_STATE) + self.check_statusword() def _next_state(self, target_state): if target_state == 'OPERATION ENABLED': @@ -536,5 +560,5 @@ def _change_state(self, target_state): while self.state != target_state: if time.monotonic() > timeout: return False - time.sleep(self.INTERVAL_CHECK_STATE) + self.check_statusword() return True From 33f7af33230bc49f5756c561f9b6800accc95895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Tue, 17 Aug 2021 09:50:31 +0200 Subject: [PATCH 32/43] DS402: Fix merge errors. (#265) * ds402: Skip explicit change to SWITCHED ON state. The BaseNode402.homing() method tries to enter state SWITCHED ON upon entry. That's unnecessary, the application should handle these transitions. But more importantly, it actually fails in many cases, namely if the previous state is SWITCH ON DISABLED, the default power-up state of most devices. There is an automatic way to reach OPERATION ENABLED over multiple intermediate steps, but the library does not know how to reach SWITCHED ON from any other state than OPERATION ENABLED or READY TO SWITCH ON. In addition, setting the op_mode property will already change to SWITCHED ON only if coming from OPERATION ENABLED (which is usually a good idea to avoid unexpected movement). Note that switching the operation mode to HOMING is actually safe in any power state. * p402: Make HOMING_TIMEOUT_DEFAULT configurable per instance. When the HOMING_TIMEOUT_DEFAULT class attribute is overridden as an object attribute, the argument default value definition in homing() still uses the old value from the class attribute. Check the argument and apply the default value at runtime to pick up the updated default timeout if the argument is omitted. * ds402: Increase delay to check status on homing start. The Statusword is examined immediately after setting the Controlword command to start homing. That is very likely to fail because of the round-trip time until the Statusword is actually updated from a TPDO. To work around that, the delay between each check of the Statusword should be moved before the actual comparison, and its default value increased. Introduce a new constant TIMEOUT_CHECK_HOMING to configure that with a default value of 100 ms. This replaces the previously used INTERVAL_CHECK_STATE which is only 10 ms by default. An even better solution would be to wait for the Statusword to be updated by a received PDO, but that would be much more complex. * p402: Wait for TPDO instead of timed statusword checking. Several methods loop around waiting for some expected statusword value, usually calling time.sleep() with the INTERVAL_CHECK_STATE or INTERVAL_CHECK_HOMING constants. Replace that with a call to check_statusword(), which will only block until the TPDO is received. This allows for possibly shorter delays, because the delay can be cut short when the TPDO arrives in between. But rate limiting the checking loops is still achieved through the blocking behavior of check_statusword(). If no TPDO is configured, the statusword will fall back to checking via SDO, which will also result in periodic checks, but only rate limited by the SDO round-trip time. Anyway this is not a recommended mode of operation, as the warning in _check_statusword_configured() points out. * p402: Use RPDO to set the operation mode if possible. Fall back to the previous behavior using SDO if the relevant object 0x6060 is not mapped to an RPDO. * p402: Check PDO configuration for the Operation Mode objects. Switching operation modes for several drives simultaneously must be done via PDO. There is still a fallback mechanism via SDO, but a warning should be issued when that is about to be used. --- canopen/profiles/p402.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index 87d8948c..f4fc0874 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -203,7 +203,6 @@ class BaseNode402(RemoteNode): TIMEOUT_SWITCH_STATE_FINAL = 0.8 # seconds TIMEOUT_SWITCH_STATE_SINGLE = 0.4 # seconds TIMEOUT_CHECK_TPDO = 0.2 # seconds - INTERVAL_CHECK_STATE = 0.01 # seconds TIMEOUT_HOMING_DEFAULT = 30 # seconds def __init__(self, node_id, object_dictionary): @@ -222,6 +221,7 @@ def setup_402_state_machine(self, read_pdos=True): self.setup_pdos(read_pdos) self._check_controlword_configured() self._check_statusword_configured() + self._check_op_mode_configured() def setup_pdos(self, upload=True): """Find the relevant PDO configuration to handle the state machine. @@ -327,18 +327,19 @@ def is_homed(self, restore_op_mode=False): self.op_mode = previous_op_mode return homingstatus in ('TARGET REACHED', 'ATTAINED') - def homing(self, timeout=TIMEOUT_HOMING_DEFAULT, restore_op_mode=False): + def homing(self, timeout=None, restore_op_mode=False): """Execute the configured Homing method on the node. - :param int timeout: Timeout value (default: 30). + :param int timeout: Timeout value (default: 30, zero to disable). :param bool restore_op_mode: Switch back to the previous operation mode after homing (default: no). :return: If the homing was complete with success. :rtype: bool """ + if timeout is None: + timeout = self.TIMEOUT_HOMING_DEFAULT if restore_op_mode: previous_op_mode = self.op_mode - self.state = 'SWITCHED ON' self.op_mode = 'HOMING' # The homing process will initialize at operation enabled self.state = 'OPERATION ENABLED' @@ -353,7 +354,6 @@ def homing(self, timeout=TIMEOUT_HOMING_DEFAULT, restore_op_mode=False): if homingstatus in ('INTERRUPTED', 'ERROR VELOCITY IS NOT ZERO', 'ERROR VELOCITY IS ZERO'): raise RuntimeError('Unable to home. Reason: {0}'.format(homingstatus)) - time.sleep(self.INTERVAL_CHECK_STATE) if timeout and time.monotonic() > t: raise RuntimeError('Unable to home, timeout reached') logger.info('Homing mode carried out successfully.') @@ -407,8 +407,14 @@ def op_mode(self, mode): if not self.is_op_mode_supported(mode): raise TypeError( 'Operation mode {m} not suppported on node {n}.'.format(n=self.id, m=mode)) - # operation mode - self.sdo[0x6060].raw = OperationMode.NAME2CODE[mode] + # Update operation mode in RPDO if possible, fall back to SDO + if 0x6060 in self.rpdo_pointers: + self.rpdo_pointers[0x6060].raw = OperationMode.NAME2CODE[mode] + pdo = self.rpdo_pointers[0x6060].pdo_parent + if not pdo.is_periodic: + pdo.transmit() + else: + self.sdo[0x6060].raw = OperationMode.NAME2CODE[mode] timeout = time.monotonic() + self.TIMEOUT_SWITCH_OP_MODE while self.op_mode != mode: if time.monotonic() > timeout: From 5202d1e31128c35218b5bf5b2167a390fa67dd91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Mon, 13 Sep 2021 12:32:20 +0200 Subject: [PATCH 33/43] DS402: Fix logic errors in the power state machine and generalize it. (#264) * p402: Forbid transitions to states which only the drive can enter. The NOT READY TO SWITCH ON and FAULT REACTION ACTIVE states can only be reached if the drive encounters an error or is still busy with its self-testing. Raise an exception in case these states are requested via the property. In extension, only the drive can trigger a transition to the FAULT state, so that is never valid as an end goal requested by the user. * p402: Add a test script to check DS402 State Machine transitions. Go through all possible target states and display where the next_state_for_enabling() function would lead to based on the original state. Mark transitions which can happen directly because they are in the TRANSITIONTABLE. * p402: Extend test script to check the actual implementation. Mock up a BaseNode402 object and compare the _next_state() behavior to the simple lookups from the underlying transition tables. * p402: Simplify check for intermediate states. Don't special case the OPERATION ENABLED state, as the mechanism formulated for it is actually usable for almost all states. Instead, check whether there is a direct transition and return that immediately before consulting next_state_for_enabling(). * p402: Rename NEXTSTATE2ENABLE to generalize it. The goal is to provide automatic transition paths for any state, not only OPERATION ENABLED. Reflect that in the naming, without changing the actual logic yet. * p402: Adjust automatic state transition paths. As there is a direct transition from QUICK STOP ACTIVE to OPERATION ENABLED, remove that from the transition paths. Instead go through the SWITCH ON DISABLED state, closing the cycle to make it work for anything between SWITCH ON DISABLED and OPERATION ENABLED. Also remove the self-reference OPERATION ENABLED to itself, which is useless. The whole state changing code will only be called if the target state and the current state do not match. * p402: Remove two illegal transitions from the state table. Transitions 7 and 10 are duplicated and certainly wrong in the quickstop context. The only transition toward QUICK STOP ACTIVE is from OPERATION ENABLED. * Move test_p402_states script to a subdirectory and add a docstring. --- canopen/profiles/p402.py | 35 ++++++++++++-------- canopen/profiles/tools/test_p402_states.py | 37 ++++++++++++++++++++++ 2 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 canopen/profiles/tools/test_p402_states.py diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index f4fc0874..a3a97160 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -46,14 +46,14 @@ class State402(object): 'QUICK STOP ACTIVE': (0x6F, 0x07), } - # Transition path to get to the 'OPERATION ENABLED' state - NEXTSTATE2ENABLE = { + # Transition path to reach and state without a direct transition + NEXTSTATE2ANY = { ('START'): 'NOT READY TO SWITCH ON', - ('FAULT', 'NOT READY TO SWITCH ON'): 'SWITCH ON DISABLED', + ('FAULT', 'NOT READY TO SWITCH ON', 'QUICK STOP ACTIVE'): 'SWITCH ON DISABLED', ('SWITCH ON DISABLED'): 'READY TO SWITCH ON', ('READY TO SWITCH ON'): 'SWITCHED ON', - ('SWITCHED ON', 'QUICK STOP ACTIVE', 'OPERATION ENABLED'): 'OPERATION ENABLED', - ('FAULT REACTION ACTIVE'): 'FAULT' + ('SWITCHED ON'): 'OPERATION ENABLED', + ('FAULT REACTION ACTIVE'): 'FAULT', } # Tansition table from the DS402 State Machine @@ -78,22 +78,25 @@ class State402(object): ('SWITCHED ON', 'OPERATION ENABLED'): CW_OPERATION_ENABLED, # transition 4 ('QUICK STOP ACTIVE', 'OPERATION ENABLED'): CW_OPERATION_ENABLED, # transition 16 # quickstop --------------------------------------------------------------------------- - ('READY TO SWITCH ON', 'QUICK STOP ACTIVE'): CW_QUICK_STOP, # transition 7 - ('SWITCHED ON', 'QUICK STOP ACTIVE'): CW_QUICK_STOP, # transition 10 ('OPERATION ENABLED', 'QUICK STOP ACTIVE'): CW_QUICK_STOP, # transition 11 # fault ------------------------------------------------------------------------------- ('FAULT', 'SWITCH ON DISABLED'): CW_SWITCH_ON_DISABLED, # transition 15 } @staticmethod - def next_state_for_enabling(_from): - """Return the next state needed for reach the state Operation Enabled. + def next_state_indirect(_from): + """Return the next state needed to reach any state indirectly. + + The chosen path always points toward the OPERATION ENABLED state, except when + coming from QUICK STOP ACTIVE. In that case, it will cycle through SWITCH ON + DISABLED first, as there would have been a direct transition if the opposite was + desired. :param str target: Target state. :return: Next target to change. :rtype: str """ - for cond, next_state in State402.NEXTSTATE2ENABLE.items(): + for cond, next_state in State402.NEXTSTATE2ANY.items(): if _from in cond: return next_state @@ -551,10 +554,16 @@ def state(self, target_state): self.check_statusword() def _next_state(self, target_state): - if target_state == 'OPERATION ENABLED': - return State402.next_state_for_enabling(self.state) - else: + if target_state in ('NOT READY TO SWITCH ON', + 'FAULT REACTION ACTIVE', + 'FAULT'): + raise ValueError( + 'Target state {} cannot be entered programmatically'.format(target_state)) + from_state = self.state + if (from_state, target_state) in State402.TRANSITIONTABLE: return target_state + else: + return State402.next_state_indirect(from_state) def _change_state(self, target_state): try: diff --git a/canopen/profiles/tools/test_p402_states.py b/canopen/profiles/tools/test_p402_states.py new file mode 100644 index 00000000..39f085f5 --- /dev/null +++ b/canopen/profiles/tools/test_p402_states.py @@ -0,0 +1,37 @@ +"""Verification script to diagnose automatic state transitions. + +This is meant to be run for verifying changes to the DS402 power state +machine code. For each target state, it just lists the next +intermediate state which would be set automatically, depending on the +assumed current state. +""" + +from canopen.objectdictionary import ObjectDictionary +from canopen.profiles.p402 import State402, BaseNode402 + + +if __name__ == '__main__': + n = BaseNode402(1, ObjectDictionary()) + + for target_state in State402.SW_MASK: + print('\n--- Target =', target_state, '---') + for from_state in State402.SW_MASK: + if target_state == from_state: + continue + if (from_state, target_state) in State402.TRANSITIONTABLE: + print('direct:\t{} -> {}'.format(from_state, target_state)) + else: + next_state = State402.next_state_indirect(from_state) + if not next_state: + print('FAIL:\t{} -> {}'.format(from_state, next_state)) + else: + print('\t{} -> {} ...'.format(from_state, next_state)) + + try: + while from_state != target_state: + n.tpdo_values[0x6041] = State402.SW_MASK[from_state][1] + next_state = n._next_state(target_state) + print('\t\t-> {}'.format(next_state)) + from_state = next_state + except ValueError: + print('\t\t-> disallowed!') From ff8b5ca9593862bde260f97299243b626238e7fa Mon Sep 17 00:00:00 2001 From: Kristian Sloth Lauszus Date: Mon, 13 Sep 2021 12:36:39 +0200 Subject: [PATCH 34/43] Do not raise an exception in "_check_statusword_configured" just log it as a warning (#268) --- canopen/profiles/p402.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index a3a97160..b8b9254a 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -271,7 +271,7 @@ def _check_controlword_configured(self): def _check_statusword_configured(self): if 0x6041 not in self.tpdo_values: # Statusword - raise ValueError( + logger.warning( "Statusword not configured in node {0}'s PDOs. Using SDOs can cause slow performance.".format( self.id)) From cacc4193a528c9057b29895ae90293f4f3e7074e Mon Sep 17 00:00:00 2001 From: Christian Sandberg Date: Sat, 2 Oct 2021 22:23:13 +0200 Subject: [PATCH 35/43] Add type annotations --- canopen/emcy.py | 26 +++--- canopen/network.py | 99 ++++++++++++--------- canopen/nmt.py | 39 +++++---- canopen/node/base.py | 10 ++- canopen/node/local.py | 22 +++-- canopen/node/remote.py | 15 +++- canopen/objectdictionary/__init__.py | 126 ++++++++++++++------------- canopen/pdo/base.py | 93 ++++++++++---------- canopen/sdo/base.py | 62 ++++++++----- canopen/sdo/client.py | 23 +++-- canopen/sdo/exceptions.py | 2 +- canopen/sdo/server.py | 23 +++-- canopen/sync.py | 13 +-- canopen/timestamp.py | 3 +- canopen/variable.py | 37 ++++---- doc/conf.py | 1 + 16 files changed, 342 insertions(+), 252 deletions(-) diff --git a/canopen/emcy.py b/canopen/emcy.py index afb52dde..8964262e 100644 --- a/canopen/emcy.py +++ b/canopen/emcy.py @@ -2,6 +2,7 @@ import logging import threading import time +from typing import Callable, List, Optional # Error code, error register, vendor specific data EMCY_STRUCT = struct.Struct(" "EmcyError": """Wait for a new EMCY to arrive. - :param int emcy_code: EMCY code to wait for - :param float timeout: Max time in seconds to wait + :param emcy_code: EMCY code to wait for + :param timeout: Max time in seconds to wait :return: The EMCY exception object or None if timeout - :rtype: canopen.emcy.EmcyError """ end_time = time.time() + timeout while True: @@ -79,15 +81,15 @@ def wait(self, emcy_code=None, timeout=10): class EmcyProducer(object): - def __init__(self, cob_id): + def __init__(self, cob_id: int): self.network = None self.cob_id = cob_id - def send(self, code, register=0, data=b""): + def send(self, code: int, register: int = 0, data: bytes = b""): payload = EMCY_STRUCT.pack(code, register, data) self.network.send_message(self.cob_id, payload) - def reset(self, register=0, data=b""): + def reset(self, register: int = 0, data: bytes = b""): payload = EMCY_STRUCT.pack(0, register, data) self.network.send_message(self.cob_id, payload) @@ -111,7 +113,7 @@ class EmcyError(Exception): (0xFF00, 0xFF00, "Device Specific") ] - def __init__(self, code, register, data, timestamp): + def __init__(self, code: int, register: int, data: bytes, timestamp: float): #: EMCY code self.code = code #: Error register @@ -121,7 +123,7 @@ def __init__(self, code, register, data, timestamp): #: Timestamp of message self.timestamp = timestamp - def get_desc(self): + def get_desc(self) -> str: for code, mask, description in self.DESCRIPTIONS: if self.code & mask == code: return description diff --git a/canopen/network.py b/canopen/network.py index 70f5c361..04acda18 100644 --- a/canopen/network.py +++ b/canopen/network.py @@ -4,7 +4,7 @@ from collections import MutableMapping import logging import threading -import struct +from typing import Callable, Dict, Iterable, List, Optional, Union try: import can @@ -22,9 +22,12 @@ from .nmt import NmtMaster from .lss import LssMaster from .objectdictionary.eds import import_from_node +from .objectdictionary import ObjectDictionary logger = logging.getLogger(__name__) +Callback = Callable[[int, bytearray, float], None] + class Network(MutableMapping): """Representation of one CAN bus containing one or more nodes.""" @@ -43,8 +46,8 @@ def __init__(self, bus=None): #: Includes at least MessageListener. self.listeners = [MessageListener(self)] self.notifier = None - self.nodes = {} - self.subscribers = {} + self.nodes: Dict[int, Union[RemoteNode, LocalNode]] = {} + self.subscribers: Dict[int, List[Callback]] = {} self.send_lock = threading.Lock() self.sync = SyncProducer(self) self.time = TimeProducer(self) @@ -55,10 +58,10 @@ def __init__(self, bus=None): self.lss.network = self self.subscribe(self.lss.LSS_RX_COBID, self.lss.on_message_received) - def subscribe(self, can_id, callback): + def subscribe(self, can_id: int, callback: Callback) -> None: """Listen for messages with a specific CAN ID. - :param int can_id: + :param can_id: The CAN ID to listen for. :param callback: Function to call when message is received. @@ -67,7 +70,7 @@ def subscribe(self, can_id, callback): if callback not in self.subscribers[can_id]: self.subscribers[can_id].append(callback) - def unsubscribe(self, can_id, callback=None): + def unsubscribe(self, can_id, callback=None) -> None: """Stop listening for message. :param int can_id: @@ -81,7 +84,7 @@ def unsubscribe(self, can_id, callback=None): else: self.subscribers[can_id].remove(callback) - def connect(self, *args, **kwargs): + def connect(self, *args, **kwargs) -> "Network": """Connect to CAN bus using python-can. Arguments are passed directly to :class:`can.BusABC`. Typically these @@ -111,7 +114,7 @@ def connect(self, *args, **kwargs): self.notifier = can.Notifier(self.bus, self.listeners, 1) return self - def disconnect(self): + def disconnect(self) -> None: """Disconnect from the CAN bus. Must be overridden in a subclass if a custom interface is used. @@ -132,7 +135,12 @@ def __enter__(self): def __exit__(self, type, value, traceback): self.disconnect() - def add_node(self, node, object_dictionary=None, upload_eds=False): + def add_node( + self, + node: Union[int, RemoteNode, LocalNode], + object_dictionary: Union[str, ObjectDictionary, None] = None, + upload_eds: bool = False, + ) -> RemoteNode: """Add a remote node to the network. :param node: @@ -142,12 +150,11 @@ def add_node(self, node, object_dictionary=None, upload_eds=False): Can be either a string for specifying the path to an Object Dictionary file or a :class:`canopen.ObjectDictionary` object. - :param bool upload_eds: + :param upload_eds: Set ``True`` if EDS file should be uploaded from 0x1021. :return: The Node object that was added. - :rtype: canopen.RemoteNode """ if isinstance(node, int): if upload_eds: @@ -157,7 +164,11 @@ def add_node(self, node, object_dictionary=None, upload_eds=False): self[node.id] = node return node - def create_node(self, node, object_dictionary=None): + def create_node( + self, + node: int, + object_dictionary: Union[str, ObjectDictionary, None] = None, + ) -> LocalNode: """Create a local node in the network. :param node: @@ -169,14 +180,13 @@ def create_node(self, node, object_dictionary=None): :return: The Node object that was added. - :rtype: canopen.LocalNode """ if isinstance(node, int): node = LocalNode(node, object_dictionary) self[node.id] = node return node - def send_message(self, can_id, data, remote=False): + def send_message(self, can_id: int, data: bytes, remote: bool = False) -> None: """Send a raw CAN message to the network. This method may be overridden in a subclass if you need to integrate @@ -203,35 +213,36 @@ def send_message(self, can_id, data, remote=False): self.bus.send(msg) self.check() - def send_periodic(self, can_id, data, period, remote=False): + def send_periodic( + self, can_id: int, data: bytes, period: float, remote: bool = False + ) -> "PeriodicMessageTask": """Start sending a message periodically. - :param int can_id: + :param can_id: CAN-ID of the message :param data: Data to be transmitted (anything that can be converted to bytes) - :param float period: + :param period: Seconds between each message - :param bool remote: + :param remote: indicates if the message frame is a remote request to the slave node :return: An task object with a ``.stop()`` method to stop the transmission - :rtype: canopen.network.PeriodicMessageTask """ return PeriodicMessageTask(can_id, data, period, self.bus, remote) - def notify(self, can_id, data, timestamp): + def notify(self, can_id: int, data: bytearray, timestamp: float) -> None: """Feed incoming message to this library. If a custom interface is used, this function must be called for each message read from the CAN bus. - :param int can_id: + :param can_id: CAN-ID of the message - :param bytearray data: + :param data: Data part of the message (0 - 8 bytes) - :param float timestamp: + :param timestamp: Timestamp of the message, preferably as a Unix timestamp """ if can_id in self.subscribers: @@ -240,7 +251,7 @@ def notify(self, can_id, data, timestamp): callback(can_id, data, timestamp) self.scanner.on_message_received(can_id) - def check(self): + def check(self) -> None: """Check that no fatal error has occurred in the receiving thread. If an exception caused the thread to terminate, that exception will be @@ -252,22 +263,22 @@ def check(self): logger.error("An error has caused receiving of messages to stop") raise exc - def __getitem__(self, node_id): + def __getitem__(self, node_id: int) -> Union[RemoteNode, LocalNode]: return self.nodes[node_id] - def __setitem__(self, node_id, node): + def __setitem__(self, node_id: int, node: Union[RemoteNode, LocalNode]): assert node_id == node.id self.nodes[node_id] = node node.associate_network(self) - def __delitem__(self, node_id): + def __delitem__(self, node_id: int): self.nodes[node_id].remove_network() del self.nodes[node_id] - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(self.nodes) - def __len__(self): + def __len__(self) -> int: return len(self.nodes) @@ -277,13 +288,20 @@ class PeriodicMessageTask(object): CyclicSendTask """ - def __init__(self, can_id, data, period, bus, remote=False): - """ - :param int can_id: + def __init__( + self, + can_id: int, + data: bytes, + period: float, + bus, + remote: bool = False, + ): + """ + :param can_id: CAN-ID of the message :param data: Data to be transmitted (anything that can be converted to bytes) - :param float period: + :param period: Seconds between each message :param can.BusABC bus: python-can bus to use for transmission @@ -303,7 +321,7 @@ def stop(self): """Stop transmission""" self._task.stop() - def update(self, data): + def update(self, data: bytes) -> None: """Update data of message :param data: @@ -323,11 +341,11 @@ def update(self, data): class MessageListener(Listener): """Listens for messages on CAN bus and feeds them to a Network instance. - :param canopen.Network network: + :param network: The network to notify on new messages. """ - def __init__(self, network): + def __init__(self, network: Network): self.network = network def on_message_received(self, msg): @@ -359,12 +377,12 @@ class NodeScanner(object): SERVICES = (0x700, 0x580, 0x180, 0x280, 0x380, 0x480, 0x80) - def __init__(self, network=None): + def __init__(self, network: Optional[Network] = None): self.network = network #: A :class:`list` of nodes discovered - self.nodes = [] + self.nodes: List[int] = [] - def on_message_received(self, can_id): + def on_message_received(self, can_id: int): service = can_id & 0x780 node_id = can_id & 0x7F if node_id not in self.nodes and node_id != 0 and service in self.SERVICES: @@ -374,11 +392,10 @@ def reset(self): """Clear list of found nodes.""" self.nodes = [] - def search(self, limit=127): + def search(self, limit: int = 127) -> None: """Search for nodes by sending SDO requests to all node IDs.""" if self.network is None: raise RuntimeError("A Network is required to do active scanning") sdo_req = b"\x40\x00\x10\x00\x00\x00\x00\x00" for node_id in range(1, limit + 1): self.network.send_message(0x600 + node_id, sdo_req) - diff --git a/canopen/nmt.py b/canopen/nmt.py index f7a5d305..09963de0 100644 --- a/canopen/nmt.py +++ b/canopen/nmt.py @@ -2,6 +2,7 @@ import logging import struct import time +from typing import Callable, Optional from .network import CanError @@ -44,7 +45,7 @@ class NmtBase(object): the current state using the heartbeat protocol. """ - def __init__(self, node_id): + def __init__(self, node_id: int): self.id = node_id self.network = None self._state = 0 @@ -60,10 +61,10 @@ def on_command(self, can_id, data, timestamp): NMT_STATES[new_state], NMT_STATES[self._state]) self._state = new_state - def send_command(self, code): + def send_command(self, code: int): """Send an NMT command code to the node. - :param int code: + :param code: NMT command code. """ if code in COMMAND_TO_STATE: @@ -73,7 +74,7 @@ def send_command(self, code): self._state = new_state @property - def state(self): + def state(self) -> str: """Attribute to get or set node's state as a string. Can be one of: @@ -93,7 +94,7 @@ def state(self): return self._state @state.setter - def state(self, new_state): + def state(self, new_state: str): if new_state in NMT_COMMANDS: code = NMT_COMMANDS[new_state] else: @@ -105,12 +106,12 @@ def state(self, new_state): class NmtMaster(NmtBase): - def __init__(self, node_id): + def __init__(self, node_id: int): super(NmtMaster, self).__init__(node_id) self._state_received = None self._node_guarding_producer = None #: Timestamp of last heartbeat message - self.timestamp = None + self.timestamp: Optional[float] = None self.state_update = threading.Condition() self._callbacks = [] @@ -131,10 +132,10 @@ def on_heartbeat(self, can_id, data, timestamp): self._state_received = new_state self.state_update.notify_all() - def send_command(self, code): + def send_command(self, code: int): """Send an NMT command code to the node. - :param int code: + :param code: NMT command code. """ super(NmtMaster, self).send_command(code) @@ -142,7 +143,7 @@ def send_command(self, code): "Sending NMT command 0x%X to node %d", code, self.id) self.network.send_message(0, [code, self.id]) - def wait_for_heartbeat(self, timeout=10): + def wait_for_heartbeat(self, timeout: float = 10): """Wait until a heartbeat message is received.""" with self.state_update: self._state_received = None @@ -151,7 +152,7 @@ def wait_for_heartbeat(self, timeout=10): raise NmtError("No boot-up or heartbeat received") return self.state - def wait_for_bootup(self, timeout=10): + def wait_for_bootup(self, timeout: float = 10) -> None: """Wait until a boot-up message is received.""" end_time = time.time() + timeout while True: @@ -164,7 +165,7 @@ def wait_for_bootup(self, timeout=10): if self._state_received == 0: break - def add_hearbeat_callback(self, callback): + def add_hearbeat_callback(self, callback: Callable[[int], None]): """Add function to be called on heartbeat reception. :param callback: @@ -172,10 +173,10 @@ def add_hearbeat_callback(self, callback): """ self._callbacks.append(callback) - def start_node_guarding(self, period): + def start_node_guarding(self, period: float): """Starts the node guarding mechanism. - :param float period: + :param period: Period (in seconds) at which the node guarding should be advertised to the slave node. """ if self._node_guarding_producer : self.stop_node_guarding() @@ -193,7 +194,7 @@ class NmtSlave(NmtBase): Handles the NMT state and handles heartbeat NMT service. """ - def __init__(self, node_id, local_node): + def __init__(self, node_id: int, local_node): super(NmtSlave, self).__init__(node_id) self._send_task = None self._heartbeat_time_ms = 0 @@ -203,10 +204,10 @@ def on_command(self, can_id, data, timestamp): super(NmtSlave, self).on_command(can_id, data, timestamp) self.update_heartbeat() - def send_command(self, code): + def send_command(self, code: int) -> None: """Send an NMT command code to the node. - :param int code: + :param code: NMT command code. """ old_state = self._state @@ -232,10 +233,10 @@ def on_write(self, index, data, **kwargs): else: self.start_heartbeat(hearbeat_time) - def start_heartbeat(self, heartbeat_time_ms): + def start_heartbeat(self, heartbeat_time_ms: int): """Start the hearbeat service. - :param int hearbeat_time + :param hearbeat_time The heartbeat time in ms. If the heartbeat time is 0 the heartbeating will not start. """ diff --git a/canopen/node/base.py b/canopen/node/base.py index 9df02e43..d87e5517 100644 --- a/canopen/node/base.py +++ b/canopen/node/base.py @@ -1,18 +1,22 @@ +from typing import TextIO, Union from .. import objectdictionary class BaseNode(object): """A CANopen node. - :param int node_id: + :param node_id: Node ID (set to None or 0 if specified by object dictionary) :param object_dictionary: Object dictionary as either a path to a file, an ``ObjectDictionary`` or a file like object. - :type object_dictionary: :class:`str`, :class:`canopen.ObjectDictionary` """ - def __init__(self, node_id, object_dictionary): + def __init__( + self, + node_id: int, + object_dictionary: Union[objectdictionary.ObjectDictionary, str, TextIO], + ): self.network = None if not isinstance(object_dictionary, diff --git a/canopen/node/local.py b/canopen/node/local.py index 8eee9420..ecce2dff 100644 --- a/canopen/node/local.py +++ b/canopen/node/local.py @@ -1,5 +1,5 @@ import logging -import struct +from typing import Dict, Union from .base import BaseNode from ..sdo import SdoServer, SdoAbortedError @@ -13,10 +13,14 @@ class LocalNode(BaseNode): - def __init__(self, node_id, object_dictionary): + def __init__( + self, + node_id: int, + object_dictionary: Union[objectdictionary.ObjectDictionary, str], + ): super(LocalNode, self).__init__(node_id, object_dictionary) - self.data_store = {} + self.data_store: Dict[int, Dict[int, bytes]] = {} self._read_callbacks = [] self._write_callbacks = [] @@ -55,7 +59,9 @@ def add_read_callback(self, callback): def add_write_callback(self, callback): self._write_callbacks.append(callback) - def get_data(self, index, subindex, check_readable=False): + def get_data( + self, index: int, subindex: int, check_readable: bool = False + ) -> bytes: obj = self._find_object(index, subindex) if check_readable and not obj.readable: @@ -82,7 +88,13 @@ def get_data(self, index, subindex, check_readable=False): logger.info("Resource unavailable for 0x%X:%d", index, subindex) raise SdoAbortedError(0x060A0023) - def set_data(self, index, subindex, data, check_writable=False): + def set_data( + self, + index: int, + subindex: int, + data: bytes, + check_writable: bool = False, + ) -> None: obj = self._find_object(index, subindex) if check_writable and not obj.writable: diff --git a/canopen/node/remote.py b/canopen/node/remote.py index 5a531868..864ffeb3 100644 --- a/canopen/node/remote.py +++ b/canopen/node/remote.py @@ -1,4 +1,5 @@ import logging +from typing import Union, TextIO from ..sdo import SdoClient from ..nmt import NmtMaster @@ -9,24 +10,30 @@ import canopen +from canopen import objectdictionary + logger = logging.getLogger(__name__) class RemoteNode(BaseNode): """A CANopen remote node. - :param int node_id: + :param node_id: Node ID (set to None or 0 if specified by object dictionary) :param object_dictionary: Object dictionary as either a path to a file, an ``ObjectDictionary`` or a file like object. - :param bool load_od: + :param load_od: Enable the Object Dictionary to be sent trough SDO's to the remote node at startup. - :type object_dictionary: :class:`str`, :class:`canopen.ObjectDictionary` """ - def __init__(self, node_id, object_dictionary, load_od=False): + def __init__( + self, + node_id: int, + object_dictionary: Union[objectdictionary.ObjectDictionary, str, TextIO], + load_od: bool = False, + ): super(RemoteNode, self).__init__(node_id, object_dictionary) #: Enable WORKAROUND for reversed PDO mapping entries diff --git a/canopen/objectdictionary/__init__.py b/canopen/objectdictionary/__init__.py index 25a2f37a..b66f8fc3 100644 --- a/canopen/objectdictionary/__init__.py +++ b/canopen/objectdictionary/__init__.py @@ -2,6 +2,7 @@ Object Dictionary module """ import struct +from typing import Dict, Iterable, List, Optional, TextIO, Union try: from collections.abc import MutableMapping, Mapping except ImportError: @@ -13,7 +14,10 @@ logger = logging.getLogger(__name__) -def import_od(source, node_id=None): +def import_od( + source: Union[str, TextIO, None], + node_id: Optional[int] = None, +) -> "ObjectDictionary": """Parse an EDS, DCF, or EPF file. :param source: @@ -21,7 +25,6 @@ def import_od(source, node_id=None): :return: An Object Dictionary instance. - :rtype: canopen.ObjectDictionary """ if source is None: return ObjectDictionary() @@ -52,11 +55,13 @@ def __init__(self): self.indices = {} self.names = {} #: Default bitrate if specified by file - self.bitrate = None + self.bitrate: Optional[int] = None #: Node ID if specified by file - self.node_id = None + self.node_id: Optional[int] = None - def __getitem__(self, index): + def __getitem__( + self, index: Union[int, str] + ) -> Union["Array", "Record", "Variable"]: """Get object from object dictionary by name or index.""" item = self.names.get(index) or self.indices.get(index) if item is None: @@ -64,25 +69,27 @@ def __getitem__(self, index): raise KeyError("%s was not found in Object Dictionary" % name) return item - def __setitem__(self, index, obj): + def __setitem__( + self, index: Union[int, str], obj: Union["Array", "Record", "Variable"] + ): assert index == obj.index or index == obj.name self.add_object(obj) - def __delitem__(self, index): + def __delitem__(self, index: Union[int, str]): obj = self[index] del self.indices[obj.index] del self.names[obj.name] - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(sorted(self.indices)) - def __len__(self): + def __len__(self) -> int: return len(self.indices) - def __contains__(self, index): + def __contains__(self, index: Union[int, str]): return index in self.names or index in self.indices - def add_object(self, obj): + def add_object(self, obj: Union["Array", "Record", "Variable"]) -> None: """Add object to the object dictionary. :param obj: @@ -95,11 +102,12 @@ def add_object(self, obj): self.indices[obj.index] = obj self.names[obj.name] = obj - def get_variable(self, index, subindex=0): + def get_variable( + self, index: Union[int, str], subindex: int = 0 + ) -> Optional["Variable"]: """Get the variable object at specified index (and subindex if applicable). :return: Variable if found, else `None` - :rtype: canopen.objectdictionary.Variable """ obj = self.get(index) if isinstance(obj, Variable): @@ -116,9 +124,9 @@ class Record(MutableMapping): #: Description for the whole record description = "" - def __init__(self, name, index): + def __init__(self, name: str, index: int): #: The :class:`~canopen.ObjectDictionary` owning the record. - self.parent = None + self.parent: Optional[ObjectDictionary] = None #: 16-bit address of the record self.index = index #: Name of record @@ -128,34 +136,34 @@ def __init__(self, name, index): self.subindices = {} self.names = {} - def __getitem__(self, subindex): + def __getitem__(self, subindex: Union[int, str]) -> "Variable": item = self.names.get(subindex) or self.subindices.get(subindex) if item is None: raise KeyError("Subindex %s was not found" % subindex) return item - def __setitem__(self, subindex, var): + def __setitem__(self, subindex: Union[int, str], var: "Variable"): assert subindex == var.subindex self.add_member(var) - def __delitem__(self, subindex): + def __delitem__(self, subindex: Union[int, str]): var = self[subindex] del self.subindices[var.subindex] del self.names[var.name] - def __len__(self): + def __len__(self) -> int: return len(self.subindices) - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(sorted(self.subindices)) - def __contains__(self, subindex): + def __contains__(self, subindex: Union[int, str]) -> bool: return subindex in self.names or subindex in self.subindices - def __eq__(self, other): + def __eq__(self, other: "Record") -> bool: return self.index == other.index - def add_member(self, variable): + def add_member(self, variable: "Variable") -> None: """Adds a :class:`~canopen.objectdictionary.Variable` to the record.""" variable.parent = self self.subindices[variable.subindex] = variable @@ -172,7 +180,7 @@ class Array(Mapping): #: Description for the whole array description = "" - def __init__(self, name, index): + def __init__(self, name: str, index: int): #: The :class:`~canopen.ObjectDictionary` owning the record. self.parent = None #: 16-bit address of the array @@ -184,7 +192,7 @@ def __init__(self, name, index): self.subindices = {} self.names = {} - def __getitem__(self, subindex): + def __getitem__(self, subindex: Union[int, str]) -> "Variable": var = self.names.get(subindex) or self.subindices.get(subindex) if var is not None: # This subindex is defined @@ -204,16 +212,16 @@ def __getitem__(self, subindex): raise KeyError("Could not find subindex %r" % subindex) return var - def __len__(self): + def __len__(self) -> int: return len(self.subindices) - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(sorted(self.subindices)) - def __eq__(self, other): + def __eq__(self, other: "Array") -> bool: return self.index == other.index - def add_member(self, variable): + def add_member(self, variable: "Variable") -> None: """Adds a :class:`~canopen.objectdictionary.Variable` to the record.""" variable.parent = self self.subindices[variable.subindex] = variable @@ -237,7 +245,7 @@ class Variable(object): REAL64: struct.Struct(" bool: return (self.index == other.index and self.subindex == other.subindex) - def __len__(self): + def __len__(self) -> int: if self.data_type in self.STRUCT_TYPES: return self.STRUCT_TYPES[self.data_type].size * 8 else: return 8 @property - def writable(self): + def writable(self) -> bool: return "w" in self.access_type @property - def readable(self): + def readable(self) -> bool: return "r" in self.access_type or self.access_type == "const" - def add_value_description(self, value, descr): + def add_value_description(self, value: int, descr: str) -> None: """Associate a value with a string description. - :param int value: Value to describe - :param str desc: Description of value + :param value: Value to describe + :param desc: Description of value """ self.value_descriptions[value] = descr - def add_bit_definition(self, name, bits): + def add_bit_definition(self, name: str, bits: List[int]) -> None: """Associate bit(s) with a string description. - :param str name: Name of bit(s) - :param list bits: List of bits as integers + :param name: Name of bit(s) + :param bits: List of bits as integers """ self.bit_definitions[name] = bits - def decode_raw(self, data): + def decode_raw(self, data: bytes) -> Union[int, float, str, bytes, bytearray]: if self.data_type == VISIBLE_STRING: return data.rstrip(b"\x00").decode("ascii", errors="ignore") elif self.data_type == UNICODE_STRING: @@ -324,7 +332,7 @@ def decode_raw(self, data): # Just return the data as is return data - def encode_raw(self, value): + def encode_raw(self, value: Union[int, float, str, bytes, bytearray]) -> bytes: if isinstance(value, (bytes, bytearray)): return value elif self.data_type == VISIBLE_STRING: @@ -355,18 +363,18 @@ def encode_raw(self, value): "Do not know how to encode %r to data type %Xh" % ( value, self.data_type)) - def decode_phys(self, value): + def decode_phys(self, value: int) -> Union[int, bool, float, str, bytes]: if self.data_type in INTEGER_TYPES: value *= self.factor return value - def encode_phys(self, value): + def encode_phys(self, value: Union[int, bool, float, str, bytes]) -> int: if self.data_type in INTEGER_TYPES: value /= self.factor value = int(round(value)) return value - def decode_desc(self, value): + def decode_desc(self, value: int) -> str: if not self.value_descriptions: raise ObjectDictionaryError("No value descriptions exist") elif value not in self.value_descriptions: @@ -375,7 +383,7 @@ def decode_desc(self, value): else: return self.value_descriptions[value] - def encode_desc(self, desc): + def encode_desc(self, desc: str) -> int: if not self.value_descriptions: raise ObjectDictionaryError("No value descriptions exist") else: @@ -386,7 +394,7 @@ def encode_desc(self, desc): error_text = "No value corresponds to '%s'. Valid values are: %s" raise ValueError(error_text % (desc, valid_values)) - def decode_bits(self, value, bits): + def decode_bits(self, value: int, bits: List[int]) -> int: try: bits = self.bit_definitions[bits] except (TypeError, KeyError): @@ -396,7 +404,7 @@ def decode_bits(self, value, bits): mask |= 1 << bit return (value & mask) >> min(bits) - def encode_bits(self, original_value, bits, bit_value): + def encode_bits(self, original_value: int, bits: List[int], bit_value: int): try: bits = self.bit_definitions[bits] except (TypeError, KeyError): diff --git a/canopen/pdo/base.py b/canopen/pdo/base.py index 63a0053e..c11eeaae 100644 --- a/canopen/pdo/base.py +++ b/canopen/pdo/base.py @@ -1,5 +1,6 @@ import threading import math +from typing import Callable, Dict, Iterable, List, Optional, Union try: from collections.abc import Mapping except ImportError: @@ -127,14 +128,14 @@ def stop(self): class Maps(Mapping): """A collection of transmit or receive maps.""" - def __init__(self, com_offset, map_offset, pdo_node, cob_base=None): + def __init__(self, com_offset, map_offset, pdo_node: PdoBase, cob_base=None): """ :param com_offset: :param map_offset: :param pdo_node: :param cob_base: """ - self.maps = {} + self.maps: Dict[int, "Map"] = {} for map_no in range(512): if com_offset + map_no in pdo_node.node.object_dictionary: new_map = Map( @@ -146,13 +147,13 @@ def __init__(self, com_offset, map_offset, pdo_node, cob_base=None): new_map.predefined_cob_id = cob_base + map_no * 0x100 + pdo_node.node.id self.maps[map_no + 1] = new_map - def __getitem__(self, key): + def __getitem__(self, key: int) -> "Map": return self.maps[key] - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(self.maps) - def __len__(self): + def __len__(self) -> int: return len(self.maps) @@ -164,34 +165,34 @@ def __init__(self, pdo_node, com_record, map_array): self.com_record = com_record self.map_array = map_array #: If this map is valid - self.enabled = False + self.enabled: bool = False #: COB-ID for this PDO - self.cob_id = None + self.cob_id: Optional[int] = None #: Default COB-ID if this PDO is part of the pre-defined connection set - self.predefined_cob_id = None + self.predefined_cob_id: Optional[int] = None #: Is the remote transmit request (RTR) allowed for this PDO - self.rtr_allowed = True + self.rtr_allowed: bool = True #: Transmission type (0-255) - self.trans_type = None + self.trans_type: Optional[int] = None #: Inhibit Time (optional) (in 100us) - self.inhibit_time = None + self.inhibit_time: Optional[int] = None #: Event timer (optional) (in ms) - self.event_timer = None + self.event_timer: Optional[int] = None #: Ignores SYNC objects up to this SYNC counter value (optional) - self.sync_start_value = None + self.sync_start_value: Optional[int] = None #: List of variables mapped to this PDO - self.map = [] - self.length = 0 + self.map: List["Variable"] = [] + self.length: int = 0 #: Current message data self.data = bytearray() #: Timestamp of last received message - self.timestamp = None + self.timestamp: Optional[float] = None #: Period of receive message transmission in seconds. #: Set explicitly or using the :meth:`start()` method. - self.period = None + self.period: Optional[float] = None self.callbacks = [] self.receive_condition = threading.Condition() - self.is_received = False + self.is_received: bool = False self._task = None def __getitem_by_index(self, value): @@ -214,7 +215,7 @@ def __getitem_by_name(self, value): raise KeyError('{0} not found in map. Valid entries are {1}'.format( value, ', '.join(valid_values))) - def __getitem__(self, key): + def __getitem__(self, key: Union[int, str]) -> "Variable": var = None if isinstance(key, int): # there is a maximum available of 8 slots per PDO map @@ -229,10 +230,10 @@ def __getitem__(self, key): var = self.__getitem_by_name(key) return var - def __iter__(self): + def __iter__(self) -> Iterable["Variable"]: return iter(self.map) - def __len__(self): + def __len__(self) -> int: return len(self.map) def _get_variable(self, index, subindex): @@ -257,7 +258,7 @@ def _update_data_size(self): self.data = bytearray(int(math.ceil(self.length / 8.0))) @property - def name(self): + def name(self) -> str: """A descriptive name of the PDO. Examples: @@ -275,7 +276,7 @@ def name(self): return "%sPDO%d_node%d" % (direction, map_id, node_id) @property - def is_periodic(self): + def is_periodic(self) -> bool: """Indicate whether PDO updates will be transferred regularly. If some external mechanism is used to transmit the PDO regularly, its cycle time @@ -304,7 +305,7 @@ def on_message(self, can_id, data, timestamp): for callback in self.callbacks: callback(self) - def add_callback(self, callback): + def add_callback(self, callback: Callable[["Map"], None]) -> None: """Add a callback which will be called on receive. :param callback: @@ -313,7 +314,7 @@ def add_callback(self, callback): """ self.callbacks.append(callback) - def read(self): + def read(self) -> None: """Read PDO configuration for this map using SDO.""" cob_id = self.com_record[1].raw self.cob_id = cob_id & 0x1FFFFFFF @@ -362,7 +363,7 @@ def read(self): self.subscribe() - def save(self): + def save(self) -> None: """Save PDO configuration for this map using SDO.""" logger.info("Setting COB-ID 0x%X and temporarily disabling PDO", self.cob_id) @@ -418,7 +419,7 @@ def save(self): self.com_record[1].raw = self.cob_id | (RTR_NOT_ALLOWED if not self.rtr_allowed else 0x0) self.subscribe() - def subscribe(self): + def subscribe(self) -> None: """Register the PDO for reception on the network. This normally happens when the PDO configuration is read from @@ -430,21 +431,23 @@ def subscribe(self): logger.info("Subscribing to enabled PDO 0x%X on the network", self.cob_id) self.pdo_node.network.subscribe(self.cob_id, self.on_message) - def clear(self): + def clear(self) -> None: """Clear all variables from this map.""" self.map = [] self.length = 0 - def add_variable(self, index, subindex=0, length=None): + def add_variable( + self, + index: Union[str, int], + subindex: Union[str, int] = 0, + length: Optional[int] = None, + ) -> "Variable": """Add a variable from object dictionary as the next entry. :param index: Index of variable as name or number :param subindex: Sub-index of variable as name or number - :param int length: Size of data in number of bits - :type index: :class:`str` or :class:`int` - :type subindex: :class:`str` or :class:`int` + :param length: Size of data in number of bits :return: Variable that was added - :rtype: canopen.pdo.Variable """ try: var = self._get_variable(index, subindex) @@ -470,14 +473,14 @@ def add_variable(self, index, subindex=0, length=None): logger.warning("Max size of PDO exceeded (%d > 64)", self.length) return var - def transmit(self): + def transmit(self) -> None: """Transmit the message once.""" self.pdo_node.network.send_message(self.cob_id, self.data) - def start(self, period=None): + def start(self, period: Optional[float] = None) -> None: """Start periodic transmission of message in a background thread. - :param float period: + :param period: Transmission period in seconds. Can be omitted if :attr:`period` has been set on the object before. :raises ValueError: When neither the argument nor the :attr:`period` is given. @@ -496,30 +499,29 @@ def start(self, period=None): self._task = self.pdo_node.network.send_periodic( self.cob_id, self.data, self.period) - def stop(self): + def stop(self) -> None: """Stop transmission.""" if self._task is not None: self._task.stop() self._task = None - def update(self): + def update(self) -> None: """Update periodic message with new data.""" if self._task is not None: self._task.update(self.data) - def remote_request(self): + def remote_request(self) -> None: """Send a remote request for the transmit PDO. Silently ignore if not allowed. """ if self.enabled and self.rtr_allowed: self.pdo_node.network.send_message(self.cob_id, None, remote=True) - def wait_for_reception(self, timeout=10): + def wait_for_reception(self, timeout: float = 10) -> float: """Wait for the next transmit PDO. :param float timeout: Max time to wait in seconds. :return: Timestamp of message received or None if timeout. - :rtype: float """ with self.receive_condition: self.is_received = False @@ -530,7 +532,7 @@ def wait_for_reception(self, timeout=10): class Variable(variable.Variable): """One object dictionary variable mapped to a PDO.""" - def __init__(self, od): + def __init__(self, od: objectdictionary.Variable): #: PDO object that is associated with this Variable Object self.pdo_parent = None #: Location of variable in the message in bits @@ -538,11 +540,10 @@ def __init__(self, od): self.length = len(od) variable.Variable.__init__(self, od) - def get_data(self): + def get_data(self) -> bytes: """Reads the PDO variable from the last received message. :return: Variable value as :class:`bytes`. - :rtype: bytes """ byte_offset, bit_offset = divmod(self.offset, 8) @@ -566,10 +567,10 @@ def get_data(self): return data - def set_data(self, data): + def set_data(self, data: bytes): """Set for the given variable the PDO data. - :param bytes data: Value for the PDO variable in the PDO message as :class:`bytes`. + :param data: Value for the PDO variable in the PDO message. """ byte_offset, bit_offset = divmod(self.offset, 8) logger.debug("Updating %s to %s in %s", diff --git a/canopen/sdo/base.py b/canopen/sdo/base.py index 9018dc23..3c3d0bbe 100644 --- a/canopen/sdo/base.py +++ b/canopen/sdo/base.py @@ -1,4 +1,5 @@ import binascii +from typing import Iterable, Union try: from collections.abc import Mapping except ImportError: @@ -26,13 +27,18 @@ class SdoBase(Mapping): #: The CRC algorithm used for block transfers crc_cls = CrcXmodem - def __init__(self, rx_cobid, tx_cobid, od): + def __init__( + self, + rx_cobid: int, + tx_cobid: int, + od: objectdictionary.ObjectDictionary, + ): """ - :param int rx_cobid: + :param rx_cobid: COB-ID that the server receives on (usually 0x600 + node ID) - :param int tx_cobid: + :param tx_cobid: COB-ID that the server responds with (usually 0x580 + node ID) - :param canopen.ObjectDictionary od: + :param od: Object Dictionary to use for communication """ self.rx_cobid = rx_cobid @@ -40,7 +46,9 @@ def __init__(self, rx_cobid, tx_cobid, od): self.network = None self.od = od - def __getitem__(self, index): + def __getitem__( + self, index: Union[str, int] + ) -> Union["Variable", "Array", "Record"]: entry = self.od[index] if isinstance(entry, objectdictionary.Variable): return Variable(self, entry) @@ -49,65 +57,77 @@ def __getitem__(self, index): elif isinstance(entry, objectdictionary.Record): return Record(self, entry) - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(self.od) - def __len__(self): + def __len__(self) -> int: return len(self.od) - def __contains__(self, key): + def __contains__(self, key: Union[int, str]) -> bool: return key in self.od + def upload(self, index: int, subindex: int) -> bytes: + raise NotImplementedError() + + def download( + self, + index: int, + subindex: int, + data: bytes, + force_segment: bool = False, + ) -> None: + raise NotImplementedError() + class Record(Mapping): - def __init__(self, sdo_node, od): + def __init__(self, sdo_node: SdoBase, od: objectdictionary.ObjectDictionary): self.sdo_node = sdo_node self.od = od - def __getitem__(self, subindex): + def __getitem__(self, subindex: Union[int, str]) -> "Variable": return Variable(self.sdo_node, self.od[subindex]) - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(self.od) - def __len__(self): + def __len__(self) -> int: return len(self.od) - def __contains__(self, subindex): + def __contains__(self, subindex: Union[int, str]) -> bool: return subindex in self.od class Array(Mapping): - def __init__(self, sdo_node, od): + def __init__(self, sdo_node: SdoBase, od: objectdictionary.ObjectDictionary): self.sdo_node = sdo_node self.od = od - def __getitem__(self, subindex): + def __getitem__(self, subindex: Union[int, str]) -> "Variable": return Variable(self.sdo_node, self.od[subindex]) - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(range(1, len(self) + 1)) - def __len__(self): + def __len__(self) -> int: return self[0].raw - def __contains__(self, subindex): + def __contains__(self, subindex: int) -> bool: return 0 <= subindex <= len(self) class Variable(variable.Variable): """Access object dictionary variable values using SDO protocol.""" - def __init__(self, sdo_node, od): + def __init__(self, sdo_node: SdoBase, od: objectdictionary.ObjectDictionary): self.sdo_node = sdo_node variable.Variable.__init__(self, od) - def get_data(self): + def get_data(self) -> bytes: return self.sdo_node.upload(self.od.index, self.od.subindex) - def set_data(self, data): + def set_data(self, data: bytes): force_segment = self.od.data_type == objectdictionary.DOMAIN self.sdo_node.download(self.od.index, self.od.subindex, data, force_segment) diff --git a/canopen/sdo/client.py b/canopen/sdo/client.py index 86a36365..0ed083e4 100644 --- a/canopen/sdo/client.py +++ b/canopen/sdo/client.py @@ -99,16 +99,15 @@ def abort(self, abort_code=0x08000000): self.send_request(request) logger.error("Transfer aborted by client with code 0x{:08X}".format(abort_code)) - def upload(self, index, subindex): + def upload(self, index: int, subindex: int) -> bytes: """May be called to make a read operation without an Object Dictionary. - :param int index: + :param index: Index of object to read. - :param int subindex: + :param subindex: Sub-index of object to read. :return: A data object. - :rtype: bytes :raises canopen.SdoCommunicationError: On unexpected response or timeout. @@ -133,16 +132,22 @@ def upload(self, index, subindex): data = data[0:size] return data - def download(self, index, subindex, data, force_segment=False): + def download( + self, + index: int, + subindex: int, + data: bytes, + force_segment: bool = False, + ) -> None: """May be called to make a write operation without an Object Dictionary. - :param int index: + :param index: Index of object to write. - :param int subindex: + :param subindex: Sub-index of object to write. - :param bytes data: + :param data: Data to be written. - :param bool force_segment: + :param force_segment: Force use of segmented transfer regardless of data size. :raises canopen.SdoCommunicationError: diff --git a/canopen/sdo/exceptions.py b/canopen/sdo/exceptions.py index 2276273a..515b4086 100644 --- a/canopen/sdo/exceptions.py +++ b/canopen/sdo/exceptions.py @@ -44,7 +44,7 @@ class SdoAbortedError(SdoError): 0x08000024: "No data available", } - def __init__(self, code): + def __init__(self, code: int): #: Abort code self.code = code diff --git a/canopen/sdo/server.py b/canopen/sdo/server.py index 746f43a3..7986e1fa 100644 --- a/canopen/sdo/server.py +++ b/canopen/sdo/server.py @@ -181,30 +181,35 @@ def abort(self, abort_code=0x08000000): self.send_response(data) # logger.error("Transfer aborted with code 0x{:08X}".format(abort_code)) - def upload(self, index, subindex): + def upload(self, index: int, subindex: int) -> bytes: """May be called to make a read operation without an Object Dictionary. - :param int index: + :param index: Index of object to read. - :param int subindex: + :param subindex: Sub-index of object to read. :return: A data object. - :rtype: bytes :raises canopen.SdoAbortedError: When node responds with an error. """ return self._node.get_data(index, subindex) - def download(self, index, subindex, data, force_segment=False): - """May be called to make a write operation without an Object Dictionary. + def download( + self, + index: int, + subindex: int, + data: bytes, + force_segment: bool = False, + ): + """May be called to make a write operation without an Object Dictionary. - :param int index: + :param index: Index of object to write. - :param int subindex: + :param subindex: Sub-index of object to write. - :param bytes data: + :param data: Data to be written. :raises canopen.SdoAbortedError: diff --git a/canopen/sync.py b/canopen/sync.py index 3619cfff..32248279 100644 --- a/canopen/sync.py +++ b/canopen/sync.py @@ -1,5 +1,8 @@ +from typing import Optional + + class SyncProducer(object): """Transmits a SYNC message periodically.""" @@ -8,22 +11,22 @@ class SyncProducer(object): def __init__(self, network): self.network = network - self.period = None + self.period: Optional[float] = None self._task = None - def transmit(self, count=None): + def transmit(self, count: Optional[int] = None): """Send out a SYNC message once. - :param int count: + :param count: Counter to add in message. """ data = [count] if count is not None else [] self.network.send_message(self.cob_id, data) - def start(self, period=None): + def start(self, period: Optional[float] = None): """Start periodic transmission of SYNC message in a background thread. - :param float period: + :param period: Period of SYNC message in seconds. """ if period is not None: diff --git a/canopen/timestamp.py b/canopen/timestamp.py index 8215affc..e96f7576 100644 --- a/canopen/timestamp.py +++ b/canopen/timestamp.py @@ -1,5 +1,6 @@ import time import struct +from typing import Optional # 1 Jan 1984 OFFSET = 441763200 @@ -18,7 +19,7 @@ class TimeProducer(object): def __init__(self, network): self.network = network - def transmit(self, timestamp=None): + def transmit(self, timestamp: Optional[float] = None): """Send out the TIME message once. :param float timestamp: diff --git a/canopen/variable.py b/canopen/variable.py index edb977c3..2357d162 100644 --- a/canopen/variable.py +++ b/canopen/variable.py @@ -1,4 +1,5 @@ import logging +from typing import Union try: from collections.abc import Mapping except ImportError: @@ -11,7 +12,7 @@ class Variable(object): - def __init__(self, od): + def __init__(self, od: objectdictionary.Variable): self.od = od #: Description of this variable from Object Dictionary, overridable self.name = od.name @@ -24,23 +25,23 @@ def __init__(self, od): #: Holds a local, overridable copy of the Object Subindex self.subindex = od.subindex - def get_data(self): + def get_data(self) -> bytes: raise NotImplementedError("Variable is not readable") - def set_data(self, data): + def set_data(self, data: bytes): raise NotImplementedError("Variable is not writable") @property - def data(self): + def data(self) -> bytes: """Byte representation of the object as :class:`bytes`.""" return self.get_data() @data.setter - def data(self, data): + def data(self, data: bytes): self.set_data(data) @property - def raw(self): + def raw(self) -> Union[int, bool, float, str, bytes]: """Raw representation of the object. This table lists the translations between object dictionary data types @@ -81,14 +82,14 @@ def raw(self): return value @raw.setter - def raw(self, value): + def raw(self, value: Union[int, bool, float, str, bytes]): logger.debug("Writing %s (0x%X:%d) = %r", self.name, self.index, self.subindex, value) self.data = self.od.encode_raw(value) @property - def phys(self): + def phys(self) -> Union[int, bool, float, str, bytes]: """Physical value scaled with some factor (defaults to 1). On object dictionaries that support specifying a factor, this can be @@ -101,26 +102,26 @@ def phys(self): return value @phys.setter - def phys(self, value): + def phys(self, value: Union[int, bool, float, str, bytes]): self.raw = self.od.encode_phys(value) @property - def desc(self): + def desc(self) -> str: """Converts to and from a description of the value as a string.""" value = self.od.decode_desc(self.raw) logger.debug("Description is '%s'", value) return value @desc.setter - def desc(self, desc): + def desc(self, desc: str): self.raw = self.od.encode_desc(desc) @property - def bits(self): + def bits(self) -> "Bits": """Access bits using integers, slices, or bit descriptions.""" return Bits(self) - def read(self, fmt="raw"): + def read(self, fmt: str = "raw") -> Union[int, bool, float, str, bytes]: """Alternative way of reading using a function instead of attributes. May be useful for asynchronous reading. @@ -141,7 +142,9 @@ def read(self, fmt="raw"): elif fmt == "desc": return self.desc - def write(self, value, fmt="raw"): + def write( + self, value: Union[int, bool, float, str, bytes], fmt: str = "raw" + ) -> None: """Alternative way of writing using a function instead of attributes. May be useful for asynchronous writing. @@ -162,7 +165,7 @@ def write(self, value, fmt="raw"): class Bits(Mapping): - def __init__(self, variable): + def __init__(self, variable: Variable): self.variable = variable self.read() @@ -176,10 +179,10 @@ def _get_bits(key): bits = key return bits - def __getitem__(self, key): + def __getitem__(self, key) -> int: return self.variable.od.decode_bits(self.raw, self._get_bits(key)) - def __setitem__(self, key, value): + def __setitem__(self, key, value: int): self.raw = self.variable.od.encode_bits( self.raw, self._get_bits(key), value) self.write() diff --git a/doc/conf.py b/doc/conf.py index 2a1bd192..3f865400 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -33,6 +33,7 @@ extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', + 'sphinx_autodoc_typehints', 'sphinx.ext.viewcode', ] From c46228f9cf2d2661166d68e7175f3e8b99064194 Mon Sep 17 00:00:00 2001 From: Christian Sandberg Date: Sun, 10 Oct 2021 20:12:30 +0200 Subject: [PATCH 36/43] Use PEP 517 for packaging --- .gitignore | 1 + canopen/__init__.py | 10 ++++------ pyproject.toml | 6 ++++++ setup.cfg | 23 +++++++++++++++++++++++ setup.py | 34 ++-------------------------------- 5 files changed, 36 insertions(+), 38 deletions(-) create mode 100644 pyproject.toml create mode 100644 setup.cfg diff --git a/.gitignore b/.gitignore index fddae0e9..89ebba91 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ var/ .installed.cfg *.egg build-deb/ +_version.py # PyInstaller # Usually these files are written by a python script from a template diff --git a/canopen/__init__.py b/canopen/__init__.py index 2fd09927..69ec15bf 100644 --- a/canopen/__init__.py +++ b/canopen/__init__.py @@ -1,16 +1,14 @@ -from pkg_resources import get_distribution, DistributionNotFound from .network import Network, NodeScanner from .node import RemoteNode, LocalNode from .sdo import SdoCommunicationError, SdoAbortedError from .objectdictionary import import_od, ObjectDictionary, ObjectDictionaryError from .profiles.p402 import BaseNode402 - -Node = RemoteNode - try: - __version__ = get_distribution(__name__).version -except DistributionNotFound: + from ._version import version as __version__ +except ImportError: # package is not installed __version__ = "unknown" +Node = RemoteNode + __pypi_url__ = "https://pypi.org/project/canopen/" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..403805fb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[build-system] +requires = ["setuptools>=45", "wheel", "setuptools_scm>=6.2"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +write_to = "canopen/_version.py" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..ca6253b5 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,23 @@ +[metadata] +name = canopen +description = CANopen stack implementation +long_description = file: README.rst +project_urls = + Documentation = http://canopen.readthedocs.io/en/stable/ + Source Code = https://github.com/christiansandberg/canopen +author = Christian Sandberg +author_email = christiansandberg@me.com +classifier = + Development Status :: 5 - Production/Stable + License :: OSI Approved :: MIT License + Operating System :: OS Independent + Programming Language :: Python :: 3 :: Only + Intended Audience :: Developers + Topic :: Scientific/Engineering + +[options] +packages = find: +python_requires = >=3.6 +install_requires = + python-can >= 3.0.0 +include_package_data = True diff --git a/setup.py b/setup.py index eb965308..60684932 100644 --- a/setup.py +++ b/setup.py @@ -1,33 +1,3 @@ -from setuptools import setup, find_packages +from setuptools import setup -description = open("README.rst").read() -# Change links to stable documentation -description = description.replace("/latest/", "/stable/") - -setup( - name="canopen", - url="https://github.com/christiansandberg/canopen", - use_scm_version=True, - packages=find_packages(), - author="Christian Sandberg", - author_email="christiansandberg@me.com", - description="CANopen stack implementation", - keywords="CAN CANopen", - long_description=description, - license="MIT", - platforms=["any"], - classifiers=[ - "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Intended Audience :: Developers", - "Topic :: Scientific/Engineering" - ], - install_requires=["python-can>=3.0.0"], - extras_require={ - "db_export": ["canmatrix"] - }, - setup_requires=["setuptools_scm"], - include_package_data=True -) +setup() From 0f13cfb7c2c0bfbabf364bc20c3057eab9537332 Mon Sep 17 00:00:00 2001 From: David van Rijn Date: Tue, 12 Oct 2021 19:48:08 +0200 Subject: [PATCH 37/43] Export Eds or Dcf (#254) --- .gitignore | 3 + canopen/__init__.py | 2 +- canopen/objectdictionary/__init__.py | 60 +++++++ canopen/objectdictionary/eds.py | 252 ++++++++++++++++++++++++++- makedeb | 14 +- test/sample.eds | 7 +- test/test_eds.py | 74 +++++++- 7 files changed, 399 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 89ebba91..e8507cc0 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ target/ \.project \.pydevproject + +*.kdev4 +*.kate-swp diff --git a/canopen/__init__.py b/canopen/__init__.py index 69ec15bf..a63ecb83 100644 --- a/canopen/__init__.py +++ b/canopen/__init__.py @@ -1,7 +1,7 @@ from .network import Network, NodeScanner from .node import RemoteNode, LocalNode from .sdo import SdoCommunicationError, SdoAbortedError -from .objectdictionary import import_od, ObjectDictionary, ObjectDictionaryError +from .objectdictionary import import_od, export_od, ObjectDictionary, ObjectDictionaryError from .profiles.p402 import BaseNode402 try: from ._version import version as __version__ diff --git a/canopen/objectdictionary/__init__.py b/canopen/objectdictionary/__init__.py index b66f8fc3..2a2f9e92 100644 --- a/canopen/objectdictionary/__init__.py +++ b/canopen/objectdictionary/__init__.py @@ -14,6 +14,41 @@ logger = logging.getLogger(__name__) +def export_od(od, dest:Union[str,TextIO,None]=None, doc_type:Optional[str]=None): + """ Export :class: ObjectDictionary to a file. + + :param od: + :class: ObjectDictionary object to be exported + :param dest: + export destination. filename, or file-like object or None. + if None, the document is returned as string + :param doc_type: type of document to export. + If a filename is given for dest, this default to the file extension. + Otherwise, this defaults to "eds" + :rtype: str or None + """ + + doctypes = {"eds", "dcf"} + if type(dest) is str: + if doc_type is None: + for t in doctypes: + if dest.endswith(f".{t}"): + doc_type = t + break + + if doc_type is None: + doc_type = "eds" + dest = open(dest, 'w') + assert doc_type in doctypes + + if doc_type == "eds": + from . import eds + return eds.export_eds(od, dest) + elif doc_type == "dcf": + from . import eds + return eds.export_dcf(od, dest) + + def import_od( source: Union[str, TextIO, None], node_id: Optional[int] = None, @@ -54,10 +89,13 @@ class ObjectDictionary(MutableMapping): def __init__(self): self.indices = {} self.names = {} + self.comments = "" #: Default bitrate if specified by file self.bitrate: Optional[int] = None #: Node ID if specified by file self.node_id: Optional[int] = None + #: Some information about the device + self.device_information = DeviceInformation() def __getitem__( self, index: Union[int, str] @@ -280,6 +318,9 @@ def __init__(self, name: str, index: int, subindex: int = 0): self.bit_definitions: Dict[str, List[int]] = {} #: Storage location of index self.storage_location = None + #: Can this variable be mapped to a PDO + self.pdo_mappable = False + def __eq__(self, other: "Variable") -> bool: return (self.index == other.index and @@ -418,5 +459,24 @@ def encode_bits(self, original_value: int, bits: List[int], bit_value: int): return temp +class DeviceInformation: + def __init__(self): + self.allowed_baudrates = set() + self.vendor_name:Optional[str] = None + self.vendor_number:Optional[int] = None + self.product_name:Optional[str] = None + self.product_number:Optional[int] = None + self.revision_number:Optional[int] = None + self.order_code:Optional[str] = None + self.simple_boot_up_master:Optional[bool] = None + self.simple_boot_up_slave:Optional[bool] = None + self.granularity:Optional[int] = None + self.dynamic_channels_supported:Optional[bool] = None + self.group_messaging:Optional[bool] = None + self.nr_of_RXPDO:Optional[bool] = None + self.nr_of_TXPDO:Optional[bool] = None + self.LSS_supported:Optional[bool] = None + + class ObjectDictionaryError(Exception): """Unsupported operation with the current Object Dictionary.""" diff --git a/canopen/objectdictionary/eds.py b/canopen/objectdictionary/eds.py index 81c38adc..10f7599d 100644 --- a/canopen/objectdictionary/eds.py +++ b/canopen/objectdictionary/eds.py @@ -11,6 +11,7 @@ logger = logging.getLogger(__name__) +# Object type. Don't confuse with Data type DOMAIN = 2 VAR = 7 ARR = 8 @@ -19,6 +20,7 @@ def import_eds(source, node_id): eds = RawConfigParser() + eds.optionxform = str if hasattr(source, "read"): fp = source else: @@ -31,6 +33,57 @@ def import_eds(source, node_id): eds.readfp(fp) fp.close() od = objectdictionary.ObjectDictionary() + + if eds.has_section("FileInfo"): + od.__edsFileInfo = { + opt: eds.get("FileInfo", opt) + for opt in eds.options("FileInfo") + } + + if eds.has_section("Comments"): + linecount = eds.getint("Comments", "Lines") + od.comments = '\n'.join([ + eds.get("Comments", "Line%i" % line) + for line in range(1, linecount+1) + ]) + + if not eds.has_section("DeviceInfo"): + logger.warn("eds file does not have a DeviceInfo section. This section is mandatory") + else: + for rate in [10, 20, 50, 125, 250, 500, 800, 1000]: + baudPossible = int( + eds.get("DeviceInfo", "Baudrate_%i" % rate, fallback='0'), 0) + if baudPossible != 0: + od.device_information.allowed_baudrates.add(rate*1000) + + for t, eprop, odprop in [ + (str, "VendorName", "vendor_name"), + (int, "VendorNumber", "vendor_number"), + (str, "ProductName", "product_name"), + (int, "ProductNumber", "product_number"), + (int, "RevisionNumber", "revision_number"), + (str, "OrderCode", "order_code"), + (bool, "SimpleBootUpMaster", "simple_boot_up_master"), + (bool, "SimpleBootUpSlave", "simple_boot_up_slave"), + (bool, "Granularity", "granularity"), + (bool, "DynamicChannelsSupported", "dynamic_channels_supported"), + (bool, "GroupMessaging", "group_messaging"), + (int, "NrOfRXPDO", "nr_of_RXPDO"), + (int, "NrOfTXPDO", "nr_of_TXPDO"), + (bool, "LSS_Supported", "LSS_supported"), + ]: + try: + if t in (int, bool): + setattr(od.device_information, odprop, + t(int(eds.get("DeviceInfo", eprop), 0)) + ) + elif t is str: + setattr(od.device_information, odprop, + eds.get("DeviceInfo", eprop) + ) + except NoOptionError: + pass + if eds.has_section("DeviceComissioning"): od.bitrate = int(eds.get("DeviceComissioning", "Baudrate")) * 1000 od.node_id = int(eds.get("DeviceComissioning", "NodeID"), 0) @@ -146,13 +199,26 @@ def _convert_variable(node_id, var_type, value): return float(value) else: # COB-ID can contain '$NODEID+' so replace this with node_id before converting - value = value.replace(" ","").upper() + value = value.replace(" ", "").upper() if '$NODEID' in value and node_id is not None: return int(re.sub(r'\+?\$NODEID\+?', '', value), 0) + node_id else: return int(value, 0) +def _revert_variable(var_type, value): + if value is None: + return None + if var_type in (objectdictionary.OCTET_STRING, objectdictionary.DOMAIN): + return bytes.hex(value) + elif var_type in (objectdictionary.VISIBLE_STRING, objectdictionary.UNICODE_STRING): + return value + elif var_type in objectdictionary.FLOAT_TYPES: + return value + else: + return "0x%02X" % value + + def build_variable(eds, section, node_id, index, subindex=0): """Creates a object dictionary entry. :param eds: String stream of the eds file @@ -181,6 +247,8 @@ def build_variable(eds, section, node_id, index, subindex=0): # Assume DOMAIN to force application to interpret the byte data var.data_type = objectdictionary.DOMAIN + var.pdo_mappable = bool(int(eds.get(section, "PDOMapping", fallback="0"), 0)) + if eds.has_option(section, "LowLimit"): try: var.min = int(eds.get(section, "LowLimit"), 0) @@ -193,11 +261,13 @@ def build_variable(eds, section, node_id, index, subindex=0): pass if eds.has_option(section, "DefaultValue"): try: + var.default_raw = eds.get(section, "DefaultValue") var.default = _convert_variable(node_id, var.data_type, eds.get(section, "DefaultValue")) except ValueError: pass if eds.has_option(section, "ParameterValue"): try: + var.value_raw = eds.get(section, "ParameterValue") var.value = _convert_variable(node_id, var.data_type, eds.get(section, "ParameterValue")) except ValueError: pass @@ -211,3 +281,183 @@ def copy_variable(eds, section, subindex, src_var): var.name = name var.subindex = subindex return var + + +def export_dcf(od, dest=None, fileInfo={}): + return export_eds(od, dest, fileInfo, True) + + +def export_eds(od, dest=None, file_info={}, device_commisioning=False): + def export_object(obj, eds): + if type(obj) is objectdictionary.Variable: + return export_variable(obj, eds) + if type(obj) is objectdictionary.Record: + return export_record(obj, eds) + if type(obj) is objectdictionary.Array: + return export_array(obj, eds) + + def export_common(var, eds, section): + eds.add_section(section) + eds.set(section, "ParameterName", var.name) + if var.storage_location: + eds.set(section, "StorageLocation", var.storage_location) + + def export_variable(var, eds): + if type(var.parent) is objectdictionary.ObjectDictionary: + # top level variable + section = "%04X" % var.index + else: + # nested variable + section = "%04Xsub%X" % (var.index, var.subindex) + + export_common(var, eds, section) + eds.set(section, "ObjectType", "0x%X" % VAR) + if var.data_type: + eds.set(section, "DataType", "0x%04X" % var.data_type) + if var.access_type: + eds.set(section, "AccessType", var.access_type) + + if getattr(var, 'default_raw', None) is not None: + eds.set(section, "DefaultValue", var.default_raw) + elif getattr(var, 'default', None) is not None: + eds.set(section, "DefaultValue", _revert_variable( + var.data_type, var.default)) + + if device_commisioning: + if getattr(var, 'value_raw', None) is not None: + eds.set(section, "ParameterValue", var.value_raw) + elif getattr(var, 'value', None) is not None: + eds.set(section, "ParameterValue", + _revert_variable(var.data_type, var.default)) + + eds.set(section, "DataType", "0x%04X" % var.data_type) + eds.set(section, "PDOMapping", hex(var.pdo_mappable)) + + if getattr(var, 'min', None) is not None: + eds.set(section, "LowLimit", var.min) + if getattr(var, 'max', None) is not None: + eds.set(section, "HighLimit", var.max) + + def export_record(var, eds): + section = "%04X" % var.index + export_common(var, eds, section) + eds.set(section, "SubNumber", "0x%X" % len(var.subindices)) + ot = RECORD if type(var) is objectdictionary.Record else ARR + eds.set(section, "ObjectType", "0x%X" % ot) + for i in var: + export_variable(var[i], eds) + + export_array = export_record + + eds = RawConfigParser() + # both disables lowercasing, and allows int keys + eds.optionxform = str + + from datetime import datetime as dt + defmtime = dt.utcnow() + + try: + # only if eds was loaded by us + origFileInfo = od.__edsFileInfo + except AttributeError: + origFileInfo = { + # just set some defaults + "CreationDate": defmtime.strftime("%m-%d-%Y"), + "CreationTime": defmtime.strftime("%I:%m%p"), + "EdsVersion": 4.2, + } + + file_info.setdefault("ModificationDate", defmtime.strftime("%m-%d-%Y")) + file_info.setdefault("ModificationTime", defmtime.strftime("%I:%m%p")) + for k, v in origFileInfo.items(): + file_info.setdefault(k, v) + + eds.add_section("FileInfo") + for k, v in file_info.items(): + eds.set("FileInfo", k, v) + + eds.add_section("DeviceInfo") + for eprop, odprop in [ + ("VendorName", "vendor_name"), + ("VendorNumber", "vendor_number"), + ("ProductName", "product_name"), + ("ProductNumber", "product_number"), + ("RevisionNumber", "revision_number"), + ("OrderCode", "order_code"), + ("SimpleBootUpMaster", "simple_boot_up_master"), + ("SimpleBootUpSlave", "simple_boot_up_slave"), + ("Granularity", "granularity"), + ("DynamicChannelsSupported", "dynamic_channels_supported"), + ("GroupMessaging", "group_messaging"), + ("NrOfRXPDO", "nr_of_RXPDO"), + ("NrOfTXPDO", "nr_of_TXPDO"), + ("LSS_Supported", "LSS_supported"), + ]: + val = getattr(od.device_information, odprop, None) + if type(val) is None: + continue + elif type(val) is str: + eds.set("DeviceInfo", eprop, val) + elif type(val) in (int, bool): + eds.set("DeviceInfo", eprop, int(val)) + + # we are also adding out of spec baudrates here. + for rate in od.device_information.allowed_baudrates.union( + {10e3, 20e3, 50e3, 125e3, 250e3, 500e3, 800e3, 1000e3}): + eds.set( + "DeviceInfo", "Baudrate_%i" % (rate/1000), + int(rate in od.device_information.allowed_baudrates)) + + if device_commisioning and (od.bitrate or od.node_id): + eds.add_section("DeviceComissioning") + if od.bitrate: + eds.set("DeviceComissioning", "Baudrate", int(od.bitrate / 1000)) + if od.node_id: + eds.set("DeviceComissioning", "NodeID", int(od.node_id)) + + eds.add_section("Comments") + i = 0 + for line in od.comments.splitlines(): + i += 1 + eds.set("Comments", "Line%i" % i, line) + eds.set("Comments", "Lines", i) + + eds.add_section("DummyUsage") + for i in range(1, 8): + key = "Dummy%04d" % i + eds.set("DummyUsage", key, 1 if (key in od) else 0) + + def mandatory_indices(x): + return x in {0x1000, 0x1001, 0x1018} + + def manufacturer_idices(x): + return x in range(0x2000, 0x6000) + + def optional_indices(x): + return all(( + x > 0x1001, + not mandatory_indices(x), + not manufacturer_idices(x), + )) + + supported_mantatory_indices = list(filter(mandatory_indices, od)) + supported_optional_indices = list(filter(optional_indices, od)) + supported_manufacturer_indices = list(filter(manufacturer_idices, od)) + + def add_list(section, list): + eds.add_section(section) + eds.set(section, "SupportedObjects", len(list)) + for i in range(0, len(list)): + eds.set(section, (i + 1), "0x%04X" % list[i]) + for index in list: + export_object(od[index], eds) + + add_list("MandatoryObjects", supported_mantatory_indices) + add_list("OptionalObjects", supported_optional_indices) + add_list("ManufacturerObjects", supported_manufacturer_indices) + + if not dest: + import sys + dest = sys.stdout + + eds.write(dest, False) diff --git a/makedeb b/makedeb index c7da516d..591ffc11 100755 --- a/makedeb +++ b/makedeb @@ -1,11 +1,10 @@ #!/bin/sh py=python3 -name=canopen -pkgname=$py-canopen +name='canopen' +pkgname=$py-$name description="CANopen stack implementation" - version=`git tag |grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' |sort | tail -1 ` maintainer=`git log -1 --pretty=format:'%an <%ae>'` arch=all @@ -19,14 +18,13 @@ fakeroot=$package_dir mkdir -p $fakeroot -$py setup.py bdist >setup_py.log - -tar -f dist/*.tar.* -C $fakeroot -x +$py setup.py bdist_wheel >setup_py.log mkdir -p $fakeroot/usr/lib/$py/dist-packages/ -mv -f $(find $fakeroot -name $name -type d) $fakeroot/usr/lib/python3/dist-packages/ +unzip dist/*.whl -d $fakeroot/usr/lib/python3/dist-packages/ -cp -r $name.egg-info $fakeroot/usr/lib/python3/dist-packages/$name-$version.egg-info +# deploy extra files +#cp -r install/* $fakeroot/ mkdir $package_dir/DEBIAN diff --git a/test/sample.eds b/test/sample.eds index 11c9c404..b01a9ee5 100644 --- a/test/sample.eds +++ b/test/sample.eds @@ -20,7 +20,7 @@ BaudRate_500=1 BaudRate_800=0 BaudRate_1000=1 SimpleBootUpMaster=0 -SimpleBootUpSlave=0 +SimpleBootUpSlave=1 Granularity=8 DynamicChannelsSupported=0 CompactPDO=0 @@ -46,7 +46,10 @@ Dummy0006=0 Dummy0007=0 [Comments] -Lines=0 +Lines=3 +Line1=|-------------| +Line2=| Don't panic | +Line3=|-------------| [MandatoryObjects] SupportedObjects=3 diff --git a/test/test_eds.py b/test/test_eds.py index a308d729..6df65285 100644 --- a/test/test_eds.py +++ b/test/test_eds.py @@ -4,7 +4,6 @@ EDS_PATH = os.path.join(os.path.dirname(__file__), 'sample.eds') - class TestEDS(unittest.TestCase): def setUp(self): @@ -90,3 +89,76 @@ def test_dummy_variable(self): def test_dummy_variable_undefined(self): with self.assertRaises(KeyError): var_undef = self.od['Dummy0001'] + + def test_comments(self): + self.assertEqual(self.od.comments, +""" +|-------------| +| Don't panic | +|-------------| +""".strip() + ) + + + def test_export_eds(self): + import tempfile + for doctype in {"eds", "dcf"}: + with tempfile.NamedTemporaryFile(suffix="."+doctype, mode="w+") as tempeds: + print("exporting %s to " % doctype + tempeds.name) + canopen.export_od(self.od, tempeds, doc_type=doctype) + tempeds.flush() + exported_od = canopen.import_od(tempeds.name) + + for index in exported_od: + self.assertIn(exported_od[index].name, self.od) + self.assertIn(index , self.od) + + for index in self.od: + if index < 0x0008: + # ignore dummies + continue + self.assertIn(self.od[index].name, exported_od) + self.assertIn(index , exported_od) + + actual_object = exported_od[index] + expected_object = self.od[index] + self.assertEqual(type(actual_object), type(expected_object)) + self.assertEqual(actual_object.name, expected_object.name) + + if type(actual_object) is canopen.objectdictionary.Variable: + expected_vars = [expected_object] + actual_vars = [actual_object ] + else : + expected_vars = [expected_object[idx] for idx in expected_object] + actual_vars = [actual_object [idx] for idx in actual_object] + + for prop in [ + "allowed_baudrates", + "vendor_name", + "vendor_number", + "product_name", + "product_number", + "revision_number", + "order_code", + "simple_boot_up_master", + "simple_boot_up_slave", + "granularity", + "dynamic_channels_supported", + "group_messaging", + "nr_of_RXPDO", + "nr_of_TXPDO", + "LSS_supported", + ]: + self.assertEqual(getattr(self.od.device_information, prop), getattr(exported_od.device_information, prop), f"prop {prop!r} mismatch on DeviceInfo") + + + for evar,avar in zip(expected_vars,actual_vars): + self. assertEqual(getattr(avar, "data_type" , None) , getattr(evar,"data_type" ,None) , " mismatch on %04X:%X"%(evar.index, evar.subindex)) + self. assertEqual(getattr(avar, "default_raw", None) , getattr(evar,"default_raw",None) , " mismatch on %04X:%X"%(evar.index, evar.subindex)) + self. assertEqual(getattr(avar, "min" , None) , getattr(evar,"min" ,None) , " mismatch on %04X:%X"%(evar.index, evar.subindex)) + self. assertEqual(getattr(avar, "max" , None) , getattr(evar,"max" ,None) , " mismatch on %04X:%X"%(evar.index, evar.subindex)) + if doctype == "dcf": + self.assertEqual(getattr(avar, "value" , None) , getattr(evar,"value" ,None) , " mismatch on %04X:%X"%(evar.index, evar.subindex)) + + self.assertEqual(self.od.comments, exported_od.comments) + From 1ea906d3fb6ecc73b25505015c07717d6b9069bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Wed, 10 Nov 2021 16:41:09 +0100 Subject: [PATCH 38/43] p402: Fix missing fallback behavior in check_statusword(). (#277) The docstring for BaseNode402.check_statusword() promises it will fall back to SDO if the TPDO is not configured as periodic. However, that actually only happens when there is no TPDO for the Statusword at all. Fix that small detail, as the code using this function relies on getting an up-to-date value and on the implicit throttling caused by the SDO round-trip time. --- canopen/profiles/p402.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index b8b9254a..12ccdd3b 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -477,9 +477,9 @@ def statusword(self): return self.sdo[0x6041].raw def check_statusword(self, timeout=None): - """Report an up-to-date reading of the statusword (0x6041) from the device. + """Report an up-to-date reading of the Statusword (0x6041) from the device. - If the TPDO with the statusword is configured as periodic, this method blocks + If the TPDO with the Statusword is configured as periodic, this method blocks until one was received. Otherwise, it uses the SDO fallback of the ``statusword`` property. @@ -494,6 +494,8 @@ def check_statusword(self, timeout=None): timestamp = pdo.wait_for_reception(timeout or self.TIMEOUT_CHECK_TPDO) if timestamp is None: raise RuntimeError('Timeout waiting for updated statusword') + else: + return self.sdo[0x6041].raw return self.statusword @property From 99bb97b9dd37b6b96acdf711cc7a713f1f782851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Colomb?= Date: Wed, 10 Nov 2021 16:42:34 +0100 Subject: [PATCH 39/43] eds: Fix exception in comment parsing with hexadecimal line count. (#279) Inside an EDS file's [Comments] section, the Lines attribute may be expressed as a hexadecimal number prefixed with 0x. The ConfigParser.getint() method however does not support such a format. Switch to the default int() constructor with a base=0 argument to enable auto-detection of this condition. The same is done for basically every other numeric value in the EDS importer. --- canopen/objectdictionary/eds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/canopen/objectdictionary/eds.py b/canopen/objectdictionary/eds.py index 10f7599d..fa1933af 100644 --- a/canopen/objectdictionary/eds.py +++ b/canopen/objectdictionary/eds.py @@ -41,7 +41,7 @@ def import_eds(source, node_id): } if eds.has_section("Comments"): - linecount = eds.getint("Comments", "Lines") + linecount = int(eds.get("Comments", "Lines"), 0) od.comments = '\n'.join([ eds.get("Comments", "Line%i" % line) for line in range(1, linecount+1) From 52131ea168ee989bc7a98031ccc1612cbd5e652d Mon Sep 17 00:00:00 2001 From: Christian Sandberg Date: Sat, 20 Nov 2021 13:17:24 +0100 Subject: [PATCH 40/43] GH: Update to PEP 517 build process --- .github/workflows/pythonpublish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index d5f3859d..420d032a 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -21,11 +21,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine + pip install setuptools wheel build twine - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | - python setup.py sdist bdist_wheel + python -m build twine upload dist/* From a83a6b83b1589f493b2205042ae114053d26f61e Mon Sep 17 00:00:00 2001 From: Henrik Brix Andersen Date: Wed, 1 Dec 2021 20:40:44 +0100 Subject: [PATCH 41/43] Add instructions for running the unit tests. (#280) --- README.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.rst b/README.rst index c3c840d9..bdf66701 100644 --- a/README.rst +++ b/README.rst @@ -51,6 +51,10 @@ it in `develop mode`_:: $ cd canopen $ pip install -e . +Unit tests can be run using the pytest_ framework:: + + $ pip install pytest + $ pytest -v Documentation ------------- @@ -165,3 +169,4 @@ logging_ level: .. _Sphinx: http://www.sphinx-doc.org/ .. _develop mode: https://packaging.python.org/distributing/#working-in-development-mode .. _logging: https://docs.python.org/3/library/logging.html +.. _pytest: https://docs.pytest.org/ From 89bc634e352124bf48e1fdaba7c49253af389f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valmantas=20Palik=C5=A1a?= Date: Wed, 22 Dec 2021 00:13:55 +0200 Subject: [PATCH 42/43] Fix canmatrix export (#287) Broken with canmatrix >= 0.9. --- canopen/pdo/base.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/canopen/pdo/base.py b/canopen/pdo/base.py index c11eeaae..1685de62 100644 --- a/canopen/pdo/base.py +++ b/canopen/pdo/base.py @@ -88,8 +88,7 @@ def export(self, filename): if pdo_map.cob_id is None: continue frame = canmatrix.Frame(pdo_map.name, - Id=pdo_map.cob_id, - extended=0) + arbitration_id=pdo_map.cob_id) for var in pdo_map.map: is_signed = var.od.data_type in objectdictionary.SIGNED_TYPES is_float = var.od.data_type in objectdictionary.FLOAT_TYPES @@ -103,8 +102,8 @@ def export(self, filename): name = name.replace(" ", "_") name = name.replace(".", "_") signal = canmatrix.Signal(name, - startBit=var.offset, - signalSize=var.length, + start_bit=var.offset, + size=var.length, is_signed=is_signed, is_float=is_float, factor=var.od.factor, @@ -113,9 +112,9 @@ def export(self, filename): unit=var.od.unit) for value, desc in var.od.value_descriptions.items(): signal.addValues(value, desc) - frame.addSignal(signal) - frame.calcDLC() - db.frames.addFrame(frame) + frame.add_signal(signal) + frame.calc_dlc() + db.add_frame(frame) formats.dumpp({"": db}, filename) return db From a142fb583f236e0ea3c561af32f72ab1c423b95b Mon Sep 17 00:00:00 2001 From: Henrik Brix Andersen Date: Thu, 6 Jan 2022 16:12:24 +0100 Subject: [PATCH 43/43] Add 'relative' property for COB-ID vars relative to the node-ID (#281) --- canopen/objectdictionary/__init__.py | 2 ++ canopen/objectdictionary/eds.py | 2 ++ test/test_eds.py | 6 ++++++ 3 files changed, 10 insertions(+) diff --git a/canopen/objectdictionary/__init__.py b/canopen/objectdictionary/__init__.py index 2a2f9e92..f900f71c 100644 --- a/canopen/objectdictionary/__init__.py +++ b/canopen/objectdictionary/__init__.py @@ -304,6 +304,8 @@ def __init__(self, name: str, index: int, subindex: int = 0): self.max: Optional[int] = None #: Default value at start-up self.default: Optional[int] = None + #: Is the default value relative to the node-ID (only applies to COB-IDs) + self.relative = False #: The value of this variable stored in the object dictionary self.value: Optional[int] = None #: Data type according to the standard as an :class:`int` diff --git a/canopen/objectdictionary/eds.py b/canopen/objectdictionary/eds.py index fa1933af..afd94159 100644 --- a/canopen/objectdictionary/eds.py +++ b/canopen/objectdictionary/eds.py @@ -262,6 +262,8 @@ def build_variable(eds, section, node_id, index, subindex=0): if eds.has_option(section, "DefaultValue"): try: var.default_raw = eds.get(section, "DefaultValue") + if '$NODEID' in var.default_raw: + var.relative = True var.default = _convert_variable(node_id, var.data_type, eds.get(section, "DefaultValue")) except ValueError: pass diff --git a/test/test_eds.py b/test/test_eds.py index 6df65285..e5f6c89e 100644 --- a/test/test_eds.py +++ b/test/test_eds.py @@ -26,6 +26,12 @@ def test_variable(self): self.assertEqual(var.data_type, canopen.objectdictionary.UNSIGNED16) self.assertEqual(var.access_type, 'rw') self.assertEqual(var.default, 0) + self.assertFalse(var.relative) + + def test_relative_variable(self): + var = self.od['Receive PDO 0 Communication Parameter']['COB-ID use by RPDO 1'] + self.assertTrue(var.relative) + self.assertEqual(var.default, 512 + self.od.node_id) def test_record(self): record = self.od['Identity object']