├── .github └── workflows │ ├── pythonpackage.yml │ └── pythonpublish.yml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── canopen ├── __init__.py ├── emcy.py ├── lss.py ├── network.py ├── nmt.py ├── node │ ├── __init__.py │ ├── base.py │ ├── local.py │ └── remote.py ├── objectdictionary │ ├── __init__.py │ ├── datatypes.py │ ├── eds.py │ └── epf.py ├── pdo │ ├── __init__.py │ └── base.py ├── profiles │ ├── __init__.py │ ├── p402.py │ └── tools │ │ └── test_p402_states.py ├── py.typed ├── sdo │ ├── __init__.py │ ├── base.py │ ├── client.py │ ├── constants.py │ ├── exceptions.py │ └── server.py ├── sync.py ├── timestamp.py ├── utils.py └── variable.py ├── codecov.yml ├── doc ├── Makefile ├── conf.py ├── emcy.rst ├── index.rst ├── integration.rst ├── lss.rst ├── network.rst ├── nmt.rst ├── od.rst ├── pdo.rst ├── profiles.rst ├── requirements.txt ├── sdo.rst ├── sync.rst └── timestamp.rst ├── examples ├── eds │ └── e35.eds └── simple_ds402_node.py ├── makedeb ├── pyproject.toml ├── requirements-dev.txt ├── setup.py └── test ├── __init__.py ├── datatypes.eds ├── sample.eds ├── test_eds.py ├── test_emcy.py ├── test_local.py ├── test_network.py ├── test_nmt.py ├── test_node.py ├── test_od.py ├── test_pdo.py ├── test_sdo.py ├── test_sync.py ├── test_time.py ├── test_utils.py └── util.py /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: 9 | - 'master' 10 | paths-ignore: 11 | - 'README.rst' 12 | - 'LICENSE.txt' 13 | pull_request: 14 | branches: 15 | - 'master' 16 | paths-ignore: 17 | - 'README.rst' 18 | - 'LICENSE.txt' 19 | 20 | jobs: 21 | build: 22 | 23 | runs-on: ubuntu-latest 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | python-version: ['3.x'] 28 | features: ['', '[db_export]'] 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | - name: Set up Python ${{ matrix.python-version }} 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | cache: 'pip' 37 | cache-dependency-path: | 38 | 'pyproject.toml' 39 | 'requirements-dev.txt' 40 | - name: Install dependencies 41 | run: python3 -m pip install -e '.${{ matrix.features }}' -r requirements-dev.txt 42 | - name: Test with pytest 43 | run: pytest -v --cov=canopen --cov-report=xml --cov-branch 44 | - name: Upload coverage reports to Codecov 45 | uses: codecov/codecov-action@v5 46 | with: 47 | token: ${{ secrets.CODECOV_TOKEN }} 48 | 49 | docs: 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v4 53 | - uses: actions/setup-python@v5 54 | with: 55 | python-version: 3.12 56 | cache: 'pip' 57 | cache-dependency-path: | 58 | 'pyproject.toml' 59 | 'doc/requirements.txt' 60 | - name: Install dependencies 61 | run: python3 -m pip install -r doc/requirements.txt -e . 62 | - name: Build docs 63 | run: make -C doc html 64 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # https://github.com/github/gitignore/blob/da00310ccba9de9a988cc973ef5238ad2c1460e9/Python.gitignore 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | build-deb/ 29 | _version.py 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *,cover 50 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | 59 | # Sphinx documentation 60 | doc/_build/ 61 | 62 | # PyBuilder 63 | target/ 64 | 65 | # IDEs 66 | .vscode/ 67 | 68 | *.dbc 69 | 70 | \.project 71 | 72 | \.pydevproject 73 | 74 | *.kdev4 75 | *.kate-swp 76 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-22.04 9 | tools: 10 | python: "3" 11 | 12 | # Build documentation in the docs/ directory with Sphinx 13 | sphinx: 14 | configuration: doc/conf.py 15 | 16 | # If using Sphinx, optionally build your docs in additional formats such as PDF 17 | # formats: 18 | # - pdf 19 | 20 | # Optionally declare the Python requirements required to build your docs 21 | python: 22 | install: 23 | - method: pip 24 | path: . 25 | - requirements: doc/requirements.txt 26 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Christian Sandberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include test/sample.eds 2 | include LICENSE.txt 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | CANopen for Python 2 | ================== 3 | 4 | A Python implementation of the CANopen_ standard. 5 | The aim of the project is to support the most common parts of the CiA 301 6 | standard in a simple Pythonic interface. It is mainly targeted for testing and 7 | automation tasks rather than a standard compliant master implementation. 8 | 9 | The library supports Python 3.8 or newer. 10 | 11 | 12 | Features 13 | -------- 14 | 15 | The library is mainly meant to be used as a master. 16 | 17 | * NMT master 18 | * SDO client 19 | * PDO producer/consumer 20 | * SYNC producer 21 | * EMCY consumer 22 | * TIME producer 23 | * LSS master 24 | * Object Dictionary from EDS 25 | * 402 profile support 26 | 27 | Incomplete support for creating slave nodes also exists. 28 | 29 | * SDO server 30 | * PDO producer/consumer 31 | * NMT slave 32 | * EMCY producer 33 | * Object Dictionary from EDS 34 | 35 | 36 | Installation 37 | ------------ 38 | 39 | Install from PyPI_ using :program:`pip`:: 40 | 41 | $ pip install canopen 42 | 43 | Install from latest ``master`` on GitHub:: 44 | 45 | $ pip install https://github.com/canopen-python/canopen/archive/master.zip 46 | 47 | If you want to be able to change the code while using it, clone it then install 48 | it in `develop mode`_:: 49 | 50 | $ git clone https://github.com/canopen-python/canopen.git 51 | $ cd canopen 52 | $ pip install -e . 53 | 54 | Unit tests can be run using the pytest_ framework:: 55 | 56 | $ pip install -r requirements-dev.txt 57 | $ pytest -v 58 | 59 | You can also use :mod:`unittest` standard library module:: 60 | 61 | $ python3 -m unittest discover test -v 62 | 63 | Documentation 64 | ------------- 65 | 66 | Documentation can be found on Read the Docs: 67 | 68 | http://canopen.readthedocs.io/en/latest/ 69 | 70 | It can also be generated from a local clone using Sphinx_:: 71 | 72 | $ pip install -r doc/requirements.txt 73 | $ make -C doc html 74 | 75 | 76 | Hardware support 77 | ---------------- 78 | 79 | This library supports multiple hardware and drivers through the python-can_ package. 80 | See `the list of supported devices `_. 81 | 82 | It is also possible to integrate this library with a custom backend. 83 | 84 | 85 | Quick start 86 | ----------- 87 | 88 | Here are some quick examples of what you can do: 89 | 90 | The PDOs can be access by three forms: 91 | 92 | **1st:** :code:`node.tpdo[n]` or :code:`node.rpdo[n]` 93 | 94 | **2nd:** :code:`node.pdo.tx[n]` or :code:`node.pdo.rx[n]` 95 | 96 | **3rd:** :code:`node.pdo[0x1A00]` or :code:`node.pdo[0x1600]` 97 | 98 | The :code:`n` is the PDO index (normally 1 to 4). The second form of access is for backward compatibility. 99 | 100 | .. code-block:: python 101 | 102 | import canopen 103 | 104 | # Start with creating a network representing one CAN bus 105 | network = canopen.Network() 106 | 107 | # Add some nodes with corresponding Object Dictionaries 108 | node = canopen.RemoteNode(6, '/path/to/object_dictionary.eds') 109 | network.add_node(node) 110 | 111 | # Connect to the CAN bus 112 | # Arguments are passed to python-can's can.Bus() constructor 113 | # (see https://python-can.readthedocs.io/en/latest/bus.html). 114 | network.connect() 115 | # network.connect(interface='socketcan', channel='can0') 116 | # network.connect(interface='kvaser', channel=0, bitrate=250000) 117 | # network.connect(interface='pcan', channel='PCAN_USBBUS1', bitrate=250000) 118 | # network.connect(interface='ixxat', channel=0, bitrate=250000) 119 | # network.connect(interface='vector', app_name='CANalyzer', channel=0, bitrate=250000) 120 | # network.connect(interface='nican', channel='CAN0', bitrate=250000) 121 | 122 | # Read a variable using SDO 123 | device_name = node.sdo['Manufacturer device name'].raw 124 | vendor_id = node.sdo[0x1018][1].raw 125 | 126 | # Write a variable using SDO 127 | node.sdo['Producer heartbeat time'].raw = 1000 128 | 129 | # Read PDO configuration from node 130 | node.tpdo.read() 131 | node.rpdo.read() 132 | # Re-map TPDO[1] 133 | node.tpdo[1].clear() 134 | node.tpdo[1].add_variable('Statusword') 135 | node.tpdo[1].add_variable('Velocity actual value') 136 | node.tpdo[1].add_variable('Some group', 'Some subindex') 137 | node.tpdo[1].trans_type = 254 138 | node.tpdo[1].event_timer = 10 139 | node.tpdo[1].enabled = True 140 | # Save new PDO configuration to node 141 | node.tpdo[1].save() 142 | 143 | # Transmit SYNC every 100 ms 144 | network.sync.start(0.1) 145 | 146 | # Change state to operational (NMT start) 147 | node.nmt.state = 'OPERATIONAL' 148 | 149 | # Read a value from TPDO[1] 150 | node.tpdo[1].wait_for_reception() 151 | speed = node.tpdo[1]['Velocity actual value'].phys 152 | val = node.tpdo['Some group.Some subindex'].raw 153 | 154 | # Disconnect from CAN bus 155 | network.sync.stop() 156 | network.disconnect() 157 | 158 | 159 | Debugging 160 | --------- 161 | 162 | If you need to see what's going on in better detail, you can increase the 163 | logging_ level: 164 | 165 | .. code-block:: python 166 | 167 | import logging 168 | logging.basicConfig(level=logging.DEBUG) 169 | 170 | 171 | .. _PyPI: https://pypi.org/project/canopen/ 172 | .. _CANopen: https://www.can-cia.org/canopen/ 173 | .. _python-can: https://python-can.readthedocs.org/en/stable/ 174 | .. _Sphinx: http://www.sphinx-doc.org/ 175 | .. _develop mode: https://packaging.python.org/distributing/#working-in-development-mode 176 | .. _logging: https://docs.python.org/3/library/logging.html 177 | .. _pytest: https://docs.pytest.org/ 178 | -------------------------------------------------------------------------------- /canopen/__init__.py: -------------------------------------------------------------------------------- 1 | from canopen.network import Network, NodeScanner 2 | from canopen.node import LocalNode, RemoteNode 3 | from canopen.objectdictionary import ( 4 | ObjectDictionary, 5 | ObjectDictionaryError, 6 | export_od, 7 | import_od, 8 | ) 9 | from canopen.profiles.p402 import BaseNode402 10 | from canopen.sdo import SdoAbortedError, SdoCommunicationError 11 | 12 | try: 13 | from canopen._version import version as __version__ 14 | except ImportError: 15 | # package is not installed 16 | __version__ = "unknown" 17 | 18 | __all__ = [ 19 | "Network", 20 | "NodeScanner", 21 | "RemoteNode", 22 | "LocalNode", 23 | "SdoCommunicationError", 24 | "SdoAbortedError", 25 | "import_od", 26 | "export_od", 27 | "ObjectDictionary", 28 | "ObjectDictionaryError", 29 | "BaseNode402", 30 | ] 31 | __pypi_url__ = "https://pypi.org/project/canopen/" 32 | 33 | Node = RemoteNode 34 | -------------------------------------------------------------------------------- /canopen/emcy.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import struct 3 | import threading 4 | import time 5 | from typing import Callable, List, Optional 6 | 7 | import canopen.network 8 | 9 | 10 | # Error code, error register, vendor specific data 11 | EMCY_STRUCT = struct.Struct(" "EmcyError": 59 | """Wait for a new EMCY to arrive. 60 | 61 | :param emcy_code: EMCY code to wait for 62 | :param timeout: Max time in seconds to wait 63 | 64 | :return: The EMCY exception object or None if timeout 65 | """ 66 | end_time = time.time() + timeout 67 | while True: 68 | with self.emcy_received: 69 | prev_log_size = len(self.log) 70 | self.emcy_received.wait(timeout) 71 | if len(self.log) == prev_log_size: 72 | # Resumed due to timeout 73 | return None 74 | # Get last logged EMCY 75 | emcy = self.log[-1] 76 | logger.info("Got %s", emcy) 77 | if time.time() > end_time: 78 | # No valid EMCY received on time 79 | return None 80 | if emcy_code is None or emcy.code == emcy_code: 81 | # This is the one we're interested in 82 | return emcy 83 | 84 | 85 | class EmcyProducer: 86 | 87 | def __init__(self, cob_id: int): 88 | self.network: canopen.network.Network = canopen.network._UNINITIALIZED_NETWORK 89 | self.cob_id = cob_id 90 | 91 | def send(self, code: int, register: int = 0, data: bytes = b""): 92 | payload = EMCY_STRUCT.pack(code, register, data) 93 | self.network.send_message(self.cob_id, payload) 94 | 95 | def reset(self, register: int = 0, data: bytes = b""): 96 | payload = EMCY_STRUCT.pack(0, register, data) 97 | self.network.send_message(self.cob_id, payload) 98 | 99 | 100 | class EmcyError(Exception): 101 | """EMCY exception.""" 102 | 103 | DESCRIPTIONS = [ 104 | # Code Mask Description 105 | (0x0000, 0xFF00, "Error Reset / No Error"), 106 | (0x1000, 0xFF00, "Generic Error"), 107 | (0x2000, 0xF000, "Current"), 108 | (0x3000, 0xF000, "Voltage"), 109 | (0x4000, 0xF000, "Temperature"), 110 | (0x5000, 0xFF00, "Device Hardware"), 111 | (0x6000, 0xF000, "Device Software"), 112 | (0x7000, 0xFF00, "Additional Modules"), 113 | (0x8000, 0xF000, "Monitoring"), 114 | (0x9000, 0xFF00, "External Error"), 115 | (0xF000, 0xFF00, "Additional Functions"), 116 | (0xFF00, 0xFF00, "Device Specific") 117 | ] 118 | 119 | def __init__(self, code: int, register: int, data: bytes, timestamp: float): 120 | #: EMCY code 121 | self.code = code 122 | #: Error register 123 | self.register = register 124 | #: Vendor specific data 125 | self.data = data 126 | #: Timestamp of message 127 | self.timestamp = timestamp 128 | 129 | def get_desc(self) -> str: 130 | for code, mask, description in self.DESCRIPTIONS: 131 | if self.code & mask == code: 132 | return description 133 | return "" 134 | 135 | def __str__(self): 136 | text = f"Code 0x{self.code:04X}" 137 | description = self.get_desc() 138 | if description: 139 | text = text + ", " + description 140 | return text 141 | -------------------------------------------------------------------------------- /canopen/lss.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import queue 3 | import struct 4 | import time 5 | 6 | import canopen.network 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | # Command Specifier (CS) 12 | CS_SWITCH_STATE_GLOBAL = 0x04 13 | CS_CONFIGURE_NODE_ID = 0x11 14 | CS_CONFIGURE_BIT_TIMING = 0x13 15 | CS_ACTIVATE_BIT_TIMING = 0x15 16 | CS_STORE_CONFIGURATION = 0x17 17 | CS_SWITCH_STATE_SELECTIVE_VENDOR_ID = 0x40 18 | CS_SWITCH_STATE_SELECTIVE_PRODUCT_CODE = 0x41 19 | CS_SWITCH_STATE_SELECTIVE_REVISION_NUMBER = 0x42 20 | CS_SWITCH_STATE_SELECTIVE_SERIAL_NUMBER = 0x43 21 | CS_SWITCH_STATE_SELECTIVE_RESPONSE = 0x44 22 | CS_IDENTIFY_REMOTE_SLAVE_VENDOR_ID = 0x46 # m -> s 23 | CS_IDENTIFY_REMOTE_SLAVE_PRODUCT_CODE = 0x47 # m -> s 24 | CS_IDENTIFY_REMOTE_SLAVE_REVISION_NUMBER_LOW = 0x48 # m -> s 25 | CS_IDENTIFY_REMOTE_SLAVE_REVISION_NUMBER_HIGH = 0x49 # m -> s 26 | CS_IDENTIFY_REMOTE_SLAVE_SERIAL_NUMBER_LOW = 0x4A # m -> s 27 | CS_IDENTIFY_REMOTE_SLAVE_SERIAL_NUMBER_HIGH = 0x4B # m -> s 28 | CS_IDENTIFY_NON_CONFIGURED_REMOTE_SLAVE = 0x4C # m -> s 29 | CS_IDENTIFY_SLAVE = 0x4F # s -> m 30 | CS_IDENTIFY_NON_CONFIGURED_SLAVE = 0x50 # s -> m 31 | CS_FAST_SCAN = 0x51 # m -> s 32 | CS_INQUIRE_VENDOR_ID = 0x5A 33 | CS_INQUIRE_PRODUCT_CODE = 0x5B 34 | CS_INQUIRE_REVISION_NUMBER = 0x5C 35 | CS_INQUIRE_SERIAL_NUMBER = 0x5D 36 | CS_INQUIRE_NODE_ID = 0x5E 37 | 38 | # obsolete 39 | SWITCH_MODE_GLOBAL = 0x04 40 | CONFIGURE_NODE_ID = 0x11 41 | CONFIGURE_BIT_TIMING = 0x13 42 | STORE_CONFIGURATION = 0x17 43 | INQUIRE_NODE_ID = 0x5E 44 | 45 | ERROR_NONE = 0 46 | ERROR_INADMISSIBLE = 1 47 | 48 | ERROR_STORE_NONE = 0 49 | ERROR_STORE_NOT_SUPPORTED = 1 50 | ERROR_STORE_ACCESS_PROBLEM = 2 51 | 52 | ERROR_VENDOR_SPECIFIC = 0xff 53 | 54 | ListMessageNeedResponse = [ 55 | CS_CONFIGURE_NODE_ID, 56 | CS_CONFIGURE_BIT_TIMING, 57 | CS_STORE_CONFIGURATION, 58 | CS_SWITCH_STATE_SELECTIVE_SERIAL_NUMBER, 59 | CS_FAST_SCAN, 60 | CS_INQUIRE_VENDOR_ID, 61 | CS_INQUIRE_PRODUCT_CODE, 62 | CS_INQUIRE_REVISION_NUMBER, 63 | CS_INQUIRE_SERIAL_NUMBER, 64 | CS_INQUIRE_NODE_ID, 65 | ] 66 | 67 | 68 | class LssMaster: 69 | """The Master of Layer Setting Services""" 70 | 71 | LSS_TX_COBID = 0x7E5 72 | LSS_RX_COBID = 0x7E4 73 | 74 | WAITING_STATE = 0x00 75 | CONFIGURATION_STATE = 0x01 76 | 77 | # obsolete 78 | NORMAL_MODE = 0x00 79 | CONFIGURATION_MODE = 0x01 80 | 81 | #: Max time in seconds to wait for response from server 82 | RESPONSE_TIMEOUT = 0.5 83 | 84 | def __init__(self) -> None: 85 | self.network: canopen.network.Network = canopen.network._UNINITIALIZED_NETWORK 86 | self._node_id = 0 87 | self._data = None 88 | self.responses = queue.Queue() 89 | 90 | def send_switch_state_global(self, mode): 91 | """switch mode to CONFIGURATION_STATE or WAITING_STATE 92 | in the all slaves on CAN bus. 93 | There is no reply for this request 94 | 95 | :param int mode: 96 | CONFIGURATION_STATE or WAITING_STATE 97 | """ 98 | # LSS messages are always a full 8 bytes long. 99 | # Unused bytes are reserved and should be initialized with 0. 100 | message = bytearray(8) 101 | 102 | message[0] = CS_SWITCH_STATE_GLOBAL 103 | message[1] = mode 104 | self.__send_command(message) 105 | 106 | def send_switch_mode_global(self, mode): 107 | """obsolete""" 108 | self.send_switch_state_global(mode) 109 | 110 | def send_switch_state_selective(self, 111 | vendorId, productCode, revisionNumber, serialNumber): 112 | """switch mode from WAITING_STATE to CONFIGURATION_STATE 113 | only if 128bits LSS address matches with the arguments. 114 | It sends 4 messages for each argument. 115 | Then wait the response from the slave. 116 | There will be no response if there is no matching slave 117 | 118 | :param int vendorId: 119 | object index 0x1018 subindex 1 120 | :param int productCode: 121 | object index 0x1018 subindex 2 122 | :param int revisionNumber: 123 | object index 0x1018 subindex 3 124 | :param int serialNumber: 125 | object index 0x1018 subindex 4 126 | 127 | :return: 128 | True if any slave responds. 129 | False if there is no response. 130 | :rtype: bool 131 | """ 132 | 133 | self.__send_lss_address(CS_SWITCH_STATE_SELECTIVE_VENDOR_ID, vendorId) 134 | self.__send_lss_address(CS_SWITCH_STATE_SELECTIVE_PRODUCT_CODE, productCode) 135 | self.__send_lss_address(CS_SWITCH_STATE_SELECTIVE_REVISION_NUMBER, revisionNumber) 136 | response = self.__send_lss_address(CS_SWITCH_STATE_SELECTIVE_SERIAL_NUMBER, serialNumber) 137 | 138 | cs = struct.unpack_from(" 0: 264 | lss_bit_check -= 1 265 | 266 | if not self.__send_fast_scan_message(lss_id[lss_sub], lss_bit_check, lss_sub, lss_next): 267 | lss_id[lss_sub] |= 1< str: 82 | """Attribute to get or set node's state as a string. 83 | 84 | Can be one of: 85 | 86 | - 'INITIALISING' 87 | - 'PRE-OPERATIONAL' 88 | - 'STOPPED' 89 | - 'OPERATIONAL' 90 | - 'SLEEP' 91 | - 'STANDBY' 92 | - 'RESET' 93 | - 'RESET COMMUNICATION' 94 | """ 95 | try: 96 | return NMT_STATES[self._state] 97 | except KeyError: 98 | return f"UNKNOWN STATE '{self._state}'" 99 | 100 | @state.setter 101 | def state(self, new_state: str): 102 | if new_state in NMT_COMMANDS: 103 | code = NMT_COMMANDS[new_state] 104 | else: 105 | raise ValueError("'%s' is an invalid state. Must be one of %s." % 106 | (new_state, ", ".join(NMT_COMMANDS))) 107 | 108 | self.send_command(code) 109 | 110 | 111 | class NmtMaster(NmtBase): 112 | 113 | def __init__(self, node_id: int): 114 | super(NmtMaster, self).__init__(node_id) 115 | self._state_received = None 116 | self._node_guarding_producer: Optional[PeriodicMessageTask] = None 117 | #: Timestamp of last heartbeat message 118 | self.timestamp: Optional[float] = None 119 | self.state_update = threading.Condition() 120 | self._callbacks = [] 121 | 122 | def on_heartbeat(self, can_id, data, timestamp): 123 | with self.state_update: 124 | self.timestamp = timestamp 125 | new_state, = struct.unpack_from("B", data) 126 | # Mask out toggle bit 127 | new_state &= 0x7F 128 | logger.debug("Received heartbeat can-id %d, state is %d", can_id, new_state) 129 | for callback in self._callbacks: 130 | callback(new_state) 131 | if new_state == 0: 132 | # Boot-up, will go to PRE-OPERATIONAL automatically 133 | self._state = 127 134 | else: 135 | self._state = new_state 136 | self._state_received = new_state 137 | self.state_update.notify_all() 138 | 139 | def send_command(self, code: int): 140 | """Send an NMT command code to the node. 141 | 142 | :param code: 143 | NMT command code. 144 | """ 145 | super(NmtMaster, self).send_command(code) 146 | logger.info( 147 | "Sending NMT command 0x%X to node %d", code, self.id) 148 | self.network.send_message(0, [code, self.id]) 149 | 150 | def wait_for_heartbeat(self, timeout: float = 10): 151 | """Wait until a heartbeat message is received.""" 152 | with self.state_update: 153 | self._state_received = None 154 | self.state_update.wait(timeout) 155 | if self._state_received is None: 156 | raise NmtError("No boot-up or heartbeat received") 157 | return self.state 158 | 159 | def wait_for_bootup(self, timeout: float = 10) -> None: 160 | """Wait until a boot-up message is received.""" 161 | end_time = time.time() + timeout 162 | while True: 163 | now = time.time() 164 | with self.state_update: 165 | self._state_received = None 166 | self.state_update.wait(end_time - now + 0.1) 167 | if now > end_time: 168 | raise NmtError("Timeout waiting for boot-up message") 169 | if self._state_received == 0: 170 | break 171 | 172 | def add_heartbeat_callback(self, callback: Callable[[int], None]): 173 | """Add function to be called on heartbeat reception. 174 | 175 | :param callback: 176 | Function that should accept an NMT state as only argument. 177 | """ 178 | self._callbacks.append(callback) 179 | 180 | # Compatibility with previous typo 181 | add_hearbeat_callback = add_heartbeat_callback 182 | 183 | def start_node_guarding(self, period: float): 184 | """Starts the node guarding mechanism. 185 | 186 | :param period: 187 | Period (in seconds) at which the node guarding should be advertised to the slave node. 188 | """ 189 | if self._node_guarding_producer : self.stop_node_guarding() 190 | self._node_guarding_producer = self.network.send_periodic(0x700 + self.id, None, period, True) 191 | 192 | def stop_node_guarding(self): 193 | """Stops the node guarding mechanism.""" 194 | if self._node_guarding_producer is not None: 195 | self._node_guarding_producer.stop() 196 | self._node_guarding_producer = None 197 | 198 | 199 | class NmtSlave(NmtBase): 200 | """ 201 | Handles the NMT state and handles heartbeat NMT service. 202 | """ 203 | 204 | def __init__(self, node_id: int, local_node): 205 | super(NmtSlave, self).__init__(node_id) 206 | self._send_task: Optional[PeriodicMessageTask] = None 207 | self._heartbeat_time_ms = 0 208 | self._local_node = local_node 209 | 210 | def on_command(self, can_id, data, timestamp): 211 | super(NmtSlave, self).on_command(can_id, data, timestamp) 212 | self.update_heartbeat() 213 | 214 | def send_command(self, code: int) -> None: 215 | """Send an NMT command code to the node. 216 | 217 | :param code: 218 | NMT command code. 219 | """ 220 | old_state = self._state 221 | super(NmtSlave, self).send_command(code) 222 | 223 | if self._state == 0: 224 | logger.info("Sending boot-up message") 225 | self.network.send_message(0x700 + self.id, [0]) 226 | 227 | # The heartbeat service should start on the transition 228 | # between INITIALIZING and PRE-OPERATIONAL state 229 | if old_state == 0 and self._state == 127: 230 | heartbeat_time_ms = self._local_node.sdo[0x1017].raw 231 | self.start_heartbeat(heartbeat_time_ms) 232 | else: 233 | self.update_heartbeat() 234 | 235 | def on_write(self, index, data, **kwargs): 236 | if index == 0x1017: 237 | heartbeat_time, = struct.unpack_from(" 0: 254 | logger.info("Start the heartbeat timer, interval is %d ms", self._heartbeat_time_ms) 255 | self._send_task = self.network.send_periodic( 256 | 0x700 + self.id, [self._state], heartbeat_time_ms / 1000.0) 257 | 258 | def stop_heartbeat(self): 259 | """Stop the heartbeat service.""" 260 | if self._send_task is not None: 261 | logger.info("Stop the heartbeat timer") 262 | self._send_task.stop() 263 | self._send_task = None 264 | 265 | def update_heartbeat(self): 266 | if self._send_task is not None: 267 | self._send_task.update([self._state]) 268 | 269 | 270 | class NmtError(Exception): 271 | """Some NMT operation failed.""" 272 | -------------------------------------------------------------------------------- /canopen/node/__init__.py: -------------------------------------------------------------------------------- 1 | from canopen.node.local import LocalNode 2 | from canopen.node.remote import RemoteNode 3 | -------------------------------------------------------------------------------- /canopen/node/base.py: -------------------------------------------------------------------------------- 1 | from typing import TextIO, Union 2 | 3 | import canopen.network 4 | from canopen.objectdictionary import ObjectDictionary, import_od 5 | 6 | 7 | class BaseNode: 8 | """A CANopen node. 9 | 10 | :param node_id: 11 | Node ID (set to None or 0 if specified by object dictionary) 12 | :param object_dictionary: 13 | Object dictionary as either a path to a file, an ``ObjectDictionary`` 14 | or a file like object. 15 | """ 16 | 17 | def __init__( 18 | self, 19 | node_id: int, 20 | object_dictionary: Union[ObjectDictionary, str, TextIO], 21 | ): 22 | self.network: canopen.network.Network = canopen.network._UNINITIALIZED_NETWORK 23 | 24 | if not isinstance(object_dictionary, ObjectDictionary): 25 | object_dictionary = import_od(object_dictionary, node_id) 26 | self.object_dictionary = object_dictionary 27 | 28 | self.id = node_id or self.object_dictionary.node_id 29 | 30 | def has_network(self) -> bool: 31 | """Check whether the node has been associated to a network.""" 32 | return not isinstance(self.network, canopen.network._UninitializedNetwork) 33 | -------------------------------------------------------------------------------- /canopen/node/local.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import Dict, Union 5 | 6 | import canopen.network 7 | from canopen import objectdictionary 8 | from canopen.emcy import EmcyProducer 9 | from canopen.nmt import NmtSlave 10 | from canopen.node.base import BaseNode 11 | from canopen.objectdictionary import ObjectDictionary 12 | from canopen.pdo import PDO, RPDO, TPDO 13 | from canopen.sdo import SdoAbortedError, SdoServer 14 | 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class LocalNode(BaseNode): 20 | 21 | def __init__( 22 | self, 23 | node_id: int, 24 | object_dictionary: Union[ObjectDictionary, str], 25 | ): 26 | super(LocalNode, self).__init__(node_id, object_dictionary) 27 | 28 | self.data_store: Dict[int, Dict[int, bytes]] = {} 29 | self._read_callbacks = [] 30 | self._write_callbacks = [] 31 | 32 | self.sdo = SdoServer(0x600 + self.id, 0x580 + self.id, self) 33 | self.tpdo = TPDO(self) 34 | self.rpdo = RPDO(self) 35 | self.pdo = PDO(self, self.rpdo, self.tpdo) 36 | self.nmt = NmtSlave(self.id, self) 37 | # Let self.nmt handle writes for 0x1017 38 | self.add_write_callback(self.nmt.on_write) 39 | self.emcy = EmcyProducer(0x80 + self.id) 40 | 41 | def associate_network(self, network: canopen.network.Network): 42 | if self.has_network(): 43 | raise RuntimeError("Node is already associated with a network") 44 | self.network = network 45 | self.sdo.network = network 46 | self.tpdo.network = network 47 | self.rpdo.network = network 48 | self.nmt.network = network 49 | self.emcy.network = network 50 | network.subscribe(self.sdo.rx_cobid, self.sdo.on_request) 51 | network.subscribe(0, self.nmt.on_command) 52 | 53 | def remove_network(self) -> None: 54 | if not self.has_network(): 55 | return 56 | self.network.unsubscribe(self.sdo.rx_cobid, self.sdo.on_request) 57 | self.network.unsubscribe(0, self.nmt.on_command) 58 | self.network = canopen.network._UNINITIALIZED_NETWORK 59 | self.sdo.network = canopen.network._UNINITIALIZED_NETWORK 60 | self.tpdo.network = canopen.network._UNINITIALIZED_NETWORK 61 | self.rpdo.network = canopen.network._UNINITIALIZED_NETWORK 62 | self.nmt.network = canopen.network._UNINITIALIZED_NETWORK 63 | self.emcy.network = canopen.network._UNINITIALIZED_NETWORK 64 | 65 | def add_read_callback(self, callback): 66 | self._read_callbacks.append(callback) 67 | 68 | def add_write_callback(self, callback): 69 | self._write_callbacks.append(callback) 70 | 71 | def get_data( 72 | self, index: int, subindex: int, check_readable: bool = False 73 | ) -> bytes: 74 | obj = self._find_object(index, subindex) 75 | 76 | if check_readable and not obj.readable: 77 | raise SdoAbortedError(0x06010001) 78 | 79 | # Try callback 80 | for callback in self._read_callbacks: 81 | result = callback(index=index, subindex=subindex, od=obj) 82 | if result is not None: 83 | return obj.encode_raw(result) 84 | 85 | # Try stored data 86 | try: 87 | return self.data_store[index][subindex] 88 | except KeyError: 89 | # Try ParameterValue in EDS 90 | if obj.value is not None: 91 | return obj.encode_raw(obj.value) 92 | # Try default value 93 | if obj.default is not None: 94 | return obj.encode_raw(obj.default) 95 | 96 | # Resource not available 97 | logger.info("Resource unavailable for 0x%04X:%02X", index, subindex) 98 | raise SdoAbortedError(0x060A0023) 99 | 100 | def set_data( 101 | self, 102 | index: int, 103 | subindex: int, 104 | data: bytes, 105 | check_writable: bool = False, 106 | ) -> None: 107 | obj = self._find_object(index, subindex) 108 | 109 | if check_writable and not obj.writable: 110 | raise SdoAbortedError(0x06010002) 111 | 112 | # Check length matches type (length of od variable is in bits) 113 | if obj.data_type in objectdictionary.NUMBER_TYPES and ( 114 | not 8 * len(data) == len(obj) 115 | ): 116 | raise SdoAbortedError(0x06070010) 117 | 118 | # Try callbacks 119 | for callback in self._write_callbacks: 120 | callback(index=index, subindex=subindex, od=obj, data=data) 121 | 122 | # Store data 123 | self.data_store.setdefault(index, {}) 124 | self.data_store[index][subindex] = bytes(data) 125 | 126 | def _find_object(self, index, subindex): 127 | if index not in self.object_dictionary: 128 | # Index does not exist 129 | raise SdoAbortedError(0x06020000) 130 | obj = self.object_dictionary[index] 131 | if not isinstance(obj, objectdictionary.ODVariable): 132 | # Group or array 133 | if subindex not in obj: 134 | # Subindex does not exist 135 | raise SdoAbortedError(0x06090011) 136 | obj = obj[subindex] 137 | return obj 138 | -------------------------------------------------------------------------------- /canopen/node/remote.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TextIO, Union 5 | 6 | import canopen.network 7 | from canopen.emcy import EmcyConsumer 8 | from canopen.nmt import NmtMaster 9 | from canopen.node.base import BaseNode 10 | from canopen.objectdictionary import ODArray, ODRecord, ODVariable, ObjectDictionary 11 | from canopen.pdo import PDO, RPDO, TPDO 12 | from canopen.sdo import SdoAbortedError, SdoClient, SdoCommunicationError 13 | 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class RemoteNode(BaseNode): 19 | """A CANopen remote node. 20 | 21 | :param node_id: 22 | Node ID (set to None or 0 if specified by object dictionary) 23 | :param object_dictionary: 24 | Object dictionary as either a path to a file, an ``ObjectDictionary`` 25 | or a file like object. 26 | :param load_od: 27 | Enable the Object Dictionary to be sent through SDO's to the remote 28 | node at startup. 29 | """ 30 | 31 | def __init__( 32 | self, 33 | node_id: int, 34 | object_dictionary: Union[ObjectDictionary, str, TextIO], 35 | load_od: bool = False, 36 | ): 37 | super(RemoteNode, self).__init__(node_id, object_dictionary) 38 | 39 | #: Enable WORKAROUND for reversed PDO mapping entries 40 | self.curtis_hack = False 41 | 42 | self.sdo_channels = [] 43 | self.sdo = self.add_sdo(0x600 + self.id, 0x580 + self.id) 44 | self.tpdo = TPDO(self) 45 | self.rpdo = RPDO(self) 46 | self.pdo = PDO(self, self.rpdo, self.tpdo) 47 | self.nmt = NmtMaster(self.id) 48 | self.emcy = EmcyConsumer() 49 | 50 | if load_od: 51 | self.load_configuration() 52 | 53 | def associate_network(self, network: canopen.network.Network): 54 | if self.has_network(): 55 | raise RuntimeError("Node is already associated with a network") 56 | self.network = network 57 | self.sdo.network = network 58 | self.pdo.network = network 59 | self.tpdo.network = network 60 | self.rpdo.network = network 61 | self.nmt.network = network 62 | for sdo in self.sdo_channels: 63 | network.subscribe(sdo.tx_cobid, sdo.on_response) 64 | network.subscribe(0x700 + self.id, self.nmt.on_heartbeat) 65 | network.subscribe(0x80 + self.id, self.emcy.on_emcy) 66 | network.subscribe(0, self.nmt.on_command) 67 | 68 | def remove_network(self) -> None: 69 | if not self.has_network(): 70 | return 71 | for sdo in self.sdo_channels: 72 | self.network.unsubscribe(sdo.tx_cobid, sdo.on_response) 73 | self.network.unsubscribe(0x700 + self.id, self.nmt.on_heartbeat) 74 | self.network.unsubscribe(0x80 + self.id, self.emcy.on_emcy) 75 | self.network.unsubscribe(0, self.nmt.on_command) 76 | self.network = canopen.network._UNINITIALIZED_NETWORK 77 | self.sdo.network = canopen.network._UNINITIALIZED_NETWORK 78 | self.pdo.network = canopen.network._UNINITIALIZED_NETWORK 79 | self.tpdo.network = canopen.network._UNINITIALIZED_NETWORK 80 | self.rpdo.network = canopen.network._UNINITIALIZED_NETWORK 81 | self.nmt.network = canopen.network._UNINITIALIZED_NETWORK 82 | 83 | def add_sdo(self, rx_cobid, tx_cobid): 84 | """Add an additional SDO channel. 85 | 86 | The SDO client will be added to :attr:`sdo_channels`. 87 | 88 | :param int rx_cobid: 89 | COB-ID that the server receives on 90 | :param int tx_cobid: 91 | COB-ID that the server responds with 92 | 93 | :return: The SDO client created 94 | :rtype: canopen.sdo.SdoClient 95 | """ 96 | client = SdoClient(rx_cobid, tx_cobid, self.object_dictionary) 97 | self.sdo_channels.append(client) 98 | if self.has_network(): 99 | self.network.subscribe(client.tx_cobid, client.on_response) 100 | return client 101 | 102 | def store(self, subindex=1): 103 | """Store parameters in non-volatile memory. 104 | 105 | :param int subindex: 106 | 1 = All parameters\n 107 | 2 = Communication related parameters\n 108 | 3 = Application related parameters\n 109 | 4 - 127 = Manufacturer specific 110 | """ 111 | self.sdo.download(0x1010, subindex, b"save") 112 | 113 | def restore(self, subindex=1): 114 | """Restore default parameters. 115 | 116 | :param int subindex: 117 | 1 = All parameters\n 118 | 2 = Communication related parameters\n 119 | 3 = Application related parameters\n 120 | 4 - 127 = Manufacturer specific 121 | """ 122 | self.sdo.download(0x1011, subindex, b"load") 123 | 124 | def __load_configuration_helper(self, index, subindex, name, value): 125 | """Helper function to send SDOs to the remote node 126 | :param index: Object index 127 | :param subindex: Object sub-index (if it does not exist e should be None) 128 | :param name: Object name 129 | :param value: Value to set in the object 130 | """ 131 | try: 132 | if subindex is not None: 133 | logger.info('SDO [0x%04X][0x%02X]: %s: %#06x', 134 | index, subindex, name, value) 135 | self.sdo[index][subindex].raw = value 136 | else: 137 | self.sdo[index].raw = value 138 | logger.info('SDO [0x%04X]: %s: %#06x', 139 | index, name, value) 140 | except SdoCommunicationError as e: 141 | logger.warning(str(e)) 142 | except SdoAbortedError as e: 143 | # WORKAROUND for broken implementations: the SDO is set but the error 144 | # "Attempt to write a read-only object" is raised any way. 145 | if e.code != 0x06010002: 146 | # Abort codes other than "Attempt to write a read-only object" 147 | # should still be reported. 148 | logger.warning('[ERROR SETTING object 0x%04X:%02X] %s', 149 | index, subindex, e) 150 | raise 151 | 152 | def load_configuration(self) -> None: 153 | """Load the configuration of the node from the Object Dictionary. 154 | 155 | Iterate through all objects in the Object Dictionary and download the 156 | values to the remote node via SDO. 157 | To avoid PDO mapping conflicts, PDO-related objects are handled through 158 | the methods :meth:`canopen.pdo.PdoBase.read` and 159 | :meth:`canopen.pdo.PdoBase.save`. 160 | 161 | """ 162 | # First apply PDO configuration from object dictionary 163 | self.pdo.read(from_od=True) 164 | self.pdo.save() 165 | 166 | # Now apply all other records in object dictionary 167 | for obj in self.object_dictionary.values(): 168 | if 0x1400 <= obj.index < 0x1c00: 169 | # Ignore PDO related objects 170 | continue 171 | if isinstance(obj, ODRecord) or isinstance(obj, ODArray): 172 | for subobj in obj.values(): 173 | if isinstance(subobj, ODVariable) and subobj.writable and (subobj.value is not None): 174 | self.__load_configuration_helper(subobj.index, subobj.subindex, subobj.name, subobj.value) 175 | elif isinstance(obj, ODVariable) and obj.writable and (obj.value is not None): 176 | self.__load_configuration_helper(obj.index, None, obj.name, obj.value) 177 | -------------------------------------------------------------------------------- /canopen/objectdictionary/datatypes.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | 4 | BOOLEAN = 0x1 5 | INTEGER8 = 0x2 6 | INTEGER16 = 0x3 7 | INTEGER32 = 0x4 8 | UNSIGNED8 = 0x5 9 | UNSIGNED16 = 0x6 10 | UNSIGNED32 = 0x7 11 | REAL32 = 0x8 12 | VISIBLE_STRING = 0x9 13 | OCTET_STRING = 0xA 14 | UNICODE_STRING = 0xB 15 | TIME_OF_DAY = 0xC 16 | TIME_DIFFERENCE = 0xD 17 | DOMAIN = 0xF 18 | INTEGER24 = 0x10 19 | REAL64 = 0x11 20 | INTEGER40 = 0x12 21 | INTEGER48 = 0x13 22 | INTEGER56 = 0x14 23 | INTEGER64 = 0x15 24 | UNSIGNED24 = 0x16 25 | UNSIGNED40 = 0x18 26 | UNSIGNED48 = 0x19 27 | UNSIGNED56 = 0x1A 28 | UNSIGNED64 = 0x1B 29 | PDO_COMMUNICATION_PARAMETER = 0x20 30 | PDO_MAPPING = 0x21 31 | SDO_PARAMETER = 0x22 32 | IDENTITY = 0x23 33 | 34 | SIGNED_TYPES = ( 35 | INTEGER8, 36 | INTEGER16, 37 | INTEGER24, 38 | INTEGER32, 39 | INTEGER40, 40 | INTEGER48, 41 | INTEGER56, 42 | INTEGER64, 43 | ) 44 | UNSIGNED_TYPES = ( 45 | UNSIGNED8, 46 | UNSIGNED16, 47 | UNSIGNED24, 48 | UNSIGNED32, 49 | UNSIGNED40, 50 | UNSIGNED48, 51 | UNSIGNED56, 52 | UNSIGNED64, 53 | ) 54 | INTEGER_TYPES = SIGNED_TYPES + UNSIGNED_TYPES 55 | FLOAT_TYPES = (REAL32, REAL64) 56 | NUMBER_TYPES = INTEGER_TYPES + FLOAT_TYPES 57 | DATA_TYPES = (VISIBLE_STRING, OCTET_STRING, UNICODE_STRING, DOMAIN) 58 | 59 | 60 | class UnsignedN(struct.Struct): 61 | """Packing and unpacking unsigned integers of arbitrary width, like struct.Struct. 62 | 63 | The width must be a multiple of 8 and must be between 8 and 64. 64 | """ 65 | 66 | def __init__(self, width: int): 67 | self.width = width 68 | if width % 8 != 0: 69 | raise ValueError("Width must be a multiple of 8") 70 | if width <= 0 or width > 64: 71 | raise ValueError("Invalid width for UnsignedN") 72 | elif width <= 8: 73 | fmt = "B" 74 | elif width <= 16: 75 | fmt = " int: 90 | return self.width // 8 91 | 92 | 93 | class IntegerN(struct.Struct): 94 | """Packing and unpacking integers of arbitrary width, like struct.Struct. 95 | 96 | The width must be a multiple of 8 and must be between 8 and 64. 97 | """ 98 | 99 | def __init__(self, width: int): 100 | self.width = width 101 | if width % 8 != 0: 102 | raise ValueError("Width must be a multiple of 8") 103 | if width <= 0 or width > 64: 104 | raise ValueError("Invalid width for IntegerN") 105 | elif width <= 8: 106 | fmt = "b" 107 | elif width <= 16: 108 | fmt = " 0 118 | return super().unpack( 119 | buffer + (b'\xff' if neg else b'\x00') * (super().size - self.size) 120 | ) 121 | 122 | def pack(self, *v): 123 | return super().pack(*v)[:self.size] 124 | 125 | @property 126 | def size(self) -> int: 127 | return self.width // 8 128 | -------------------------------------------------------------------------------- /canopen/objectdictionary/epf.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import xml.etree.ElementTree as etree 3 | 4 | from canopen import objectdictionary 5 | from canopen.objectdictionary import ObjectDictionary 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | DATA_TYPES = { 11 | "BOOLEAN": objectdictionary.BOOLEAN, 12 | "INTEGER8": objectdictionary.INTEGER8, 13 | "INTEGER16": objectdictionary.INTEGER16, 14 | "INTEGER32": objectdictionary.INTEGER32, 15 | "UNSIGNED8": objectdictionary.UNSIGNED8, 16 | "UNSIGNED16": objectdictionary.UNSIGNED16, 17 | "UNSIGNED32": objectdictionary.UNSIGNED32, 18 | "REAL32": objectdictionary.REAL32, 19 | "VISIBLE_STRING": objectdictionary.VISIBLE_STRING, 20 | "DOMAIN": objectdictionary.DOMAIN 21 | } 22 | 23 | 24 | def import_epf(epf): 25 | """Import an EPF file. 26 | 27 | :param epf: 28 | Either a path to an EPF-file, a file-like object, or an instance of 29 | :class:`xml.etree.ElementTree.Element`. 30 | 31 | :returns: 32 | The Object Dictionary. 33 | :rtype: canopen.ObjectDictionary 34 | """ 35 | od = ObjectDictionary() 36 | if etree.iselement(epf): 37 | tree = epf 38 | else: 39 | tree = etree.parse(epf).getroot() 40 | 41 | # Find and set default bitrate 42 | can_config = tree.find("Configuration/CANopen") 43 | if can_config is not None: 44 | bitrate = can_config.get("BitRate", "250") 45 | bitrate = bitrate.replace("U", "") 46 | od.bitrate = int(bitrate) * 1000 47 | 48 | # Parse Object Dictionary 49 | for group_tree in tree.iterfind("Dictionary/Parameters/Group"): 50 | name = group_tree.get("SymbolName") 51 | parameters = group_tree.findall("Parameter") 52 | index = int(parameters[0].get("Index"), 0) 53 | 54 | if len(parameters) == 1: 55 | # Simple variable 56 | var = build_variable(parameters[0]) 57 | # Use top level index name instead 58 | var.name = name 59 | od.add_object(var) 60 | elif len(parameters) == 2 and parameters[1].get("ObjectType") == "ARRAY": 61 | # Array 62 | arr = objectdictionary.ODArray(name, index) 63 | for par_tree in parameters: 64 | var = build_variable(par_tree) 65 | arr.add_member(var) 66 | description = group_tree.find("Description") 67 | if description is not None: 68 | arr.description = description.text 69 | od.add_object(arr) 70 | else: 71 | # Complex record 72 | record = objectdictionary.ODRecord(name, index) 73 | for par_tree in parameters: 74 | var = build_variable(par_tree) 75 | record.add_member(var) 76 | description = group_tree.find("Description") 77 | if description is not None: 78 | record.description = description.text 79 | od.add_object(record) 80 | 81 | return od 82 | 83 | 84 | def build_variable(par_tree): 85 | index = int(par_tree.get("Index"), 0) 86 | subindex = int(par_tree.get("SubIndex")) 87 | name = par_tree.get("SymbolName") 88 | data_type = par_tree.get("DataType") 89 | 90 | par = objectdictionary.ODVariable(name, index, subindex) 91 | factor = par_tree.get("Factor", "1") 92 | par.factor = int(factor) if factor.isdigit() else float(factor) 93 | unit = par_tree.get("Unit") 94 | if unit and unit != "-": 95 | par.unit = unit 96 | description = par_tree.find("Description") 97 | if description is not None: 98 | par.description = description.text 99 | if data_type in DATA_TYPES: 100 | par.data_type = DATA_TYPES[data_type] 101 | else: 102 | logger.warning("Don't know how to handle data type %s", data_type) 103 | par.access_type = par_tree.get("AccessType", "rw") 104 | try: 105 | par.min = int(par_tree.get("MinimumValue")) 106 | except (ValueError, TypeError): 107 | pass 108 | try: 109 | par.max = int(par_tree.get("MaximumValue")) 110 | except (ValueError, TypeError): 111 | pass 112 | try: 113 | par.default = int(par_tree.get("DefaultValue")) 114 | except (ValueError, TypeError): 115 | pass 116 | 117 | # Find value descriptions 118 | for value_field_def in par_tree.iterfind("ValueFieldDefs/ValueFieldDef"): 119 | value = int(value_field_def.get("Value"), 0) 120 | desc = value_field_def.get("Description") 121 | par.add_value_description(value, desc) 122 | 123 | # Find bit field descriptions 124 | for bits_tree in par_tree.iterfind("BitFieldDefs/BitFieldDef"): 125 | name = bits_tree.get("Name") 126 | bits = [int(bit) for bit in bits_tree.get("Bit").split(",")] 127 | par.add_bit_definition(name, bits) 128 | 129 | return par 130 | -------------------------------------------------------------------------------- /canopen/pdo/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from canopen import node 4 | from canopen.pdo.base import PdoBase, PdoMap, PdoMaps, PdoVariable 5 | 6 | 7 | __all__ = [ 8 | "PdoBase", 9 | "PdoMap", 10 | "PdoMaps", 11 | "PdoVariable", 12 | "PDO", 13 | "RPDO", 14 | "TPDO", 15 | ] 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class PDO(PdoBase): 21 | """PDO Class for backwards compatibility. 22 | 23 | :param rpdo: RPDO object holding the Receive PDO mappings 24 | :param tpdo: TPDO object holding the Transmit PDO mappings 25 | """ 26 | 27 | def __init__(self, node, rpdo, tpdo): 28 | super(PDO, self).__init__(node) 29 | self.rx = rpdo.map 30 | self.tx = tpdo.map 31 | 32 | self.map = {} 33 | # the object 0x1A00 equals to key '1' so we remove 1 from the key 34 | for key, value in self.rx.items(): 35 | self.map[0x1A00 + (key - 1)] = value 36 | for key, value in self.tx.items(): 37 | self.map[0x1600 + (key - 1)] = value 38 | 39 | 40 | class RPDO(PdoBase): 41 | """Receive PDO to transfer data from somewhere to the represented node. 42 | 43 | Properties 0x1400 to 0x1403 | Mapping 0x1600 to 0x1603. 44 | :param object node: Parent node for this object. 45 | """ 46 | 47 | def __init__(self, node): 48 | super(RPDO, self).__init__(node) 49 | self.map = PdoMaps(0x1400, 0x1600, self, 0x200) 50 | logger.debug('RPDO Map as %d', len(self.map)) 51 | 52 | def stop(self): 53 | """Stop transmission of all RPDOs. 54 | 55 | :raise TypeError: Exception is thrown if the node associated with the PDO does not 56 | support this function. 57 | """ 58 | if isinstance(self.node, node.RemoteNode): 59 | for pdo in self.map.values(): 60 | pdo.stop() 61 | else: 62 | raise TypeError('The node type does not support this function.') 63 | 64 | 65 | class TPDO(PdoBase): 66 | """Transmit PDO to broadcast data from the represented node to the network. 67 | 68 | Properties 0x1800 to 0x1803 | Mapping 0x1A00 to 0x1A03. 69 | :param object node: Parent node for this object. 70 | """ 71 | 72 | def __init__(self, node): 73 | super(TPDO, self).__init__(node) 74 | self.map = PdoMaps(0x1800, 0x1A00, self, 0x180) 75 | logger.debug('TPDO Map as %d', len(self.map)) 76 | 77 | def stop(self): 78 | """Stop transmission of all TPDOs. 79 | 80 | :raise TypeError: Exception is thrown if the node associated with the PDO does not 81 | support this function. 82 | """ 83 | if isinstance(self.node, node.LocalNode): 84 | for pdo in self.map.values(): 85 | pdo.stop() 86 | else: 87 | raise TypeError('The node type does not support this function.') 88 | 89 | 90 | # Compatibility 91 | Variable = PdoVariable 92 | -------------------------------------------------------------------------------- /canopen/profiles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canopen-python/canopen/e840449fbf9ddf810d14899279cb72e61a2447f0/canopen/profiles/__init__.py -------------------------------------------------------------------------------- /canopen/profiles/tools/test_p402_states.py: -------------------------------------------------------------------------------- 1 | """Verification script to diagnose automatic state transitions. 2 | 3 | This is meant to be run for verifying changes to the DS402 power state 4 | machine code. For each target state, it just lists the next 5 | intermediate state which would be set automatically, depending on the 6 | assumed current state. 7 | """ 8 | 9 | from canopen.objectdictionary import ObjectDictionary 10 | from canopen.profiles.p402 import BaseNode402, State402 11 | 12 | 13 | if __name__ == '__main__': 14 | n = BaseNode402(1, ObjectDictionary()) 15 | 16 | for target_state in State402.SW_MASK: 17 | print('\n--- Target =', target_state, '---') 18 | for from_state in State402.SW_MASK: 19 | if target_state == from_state: 20 | continue 21 | if (from_state, target_state) in State402.TRANSITIONTABLE: 22 | print(f'direct:\t{from_state} -> {target_state}') 23 | else: 24 | next_state = State402.next_state_indirect(from_state) 25 | if not next_state: 26 | print(f'FAIL:\t{from_state} -> {next_state}') 27 | else: 28 | print(f'\t{from_state} -> {next_state} ...') 29 | 30 | try: 31 | while from_state != target_state: 32 | n.tpdo_values[0x6041] = State402.SW_MASK[from_state][1] 33 | next_state = n._next_state(target_state) 34 | print(f'\t\t-> {next_state}') 35 | from_state = next_state 36 | except ValueError: 37 | print('\t\t-> disallowed!') 38 | -------------------------------------------------------------------------------- /canopen/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canopen-python/canopen/e840449fbf9ddf810d14899279cb72e61a2447f0/canopen/py.typed -------------------------------------------------------------------------------- /canopen/sdo/__init__.py: -------------------------------------------------------------------------------- 1 | from canopen.sdo.base import SdoArray, SdoRecord, SdoVariable 2 | from canopen.sdo.client import SdoClient 3 | from canopen.sdo.exceptions import SdoAbortedError, SdoCommunicationError 4 | from canopen.sdo.server import SdoServer 5 | 6 | # Compatibility 7 | from canopen.sdo.base import Array, Record, Variable 8 | -------------------------------------------------------------------------------- /canopen/sdo/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import binascii 4 | from collections.abc import Mapping 5 | from typing import Iterator, Optional, Union 6 | 7 | import canopen.network 8 | from canopen import objectdictionary 9 | from canopen import variable 10 | from canopen.utils import pretty_index 11 | 12 | 13 | class CrcXmodem: 14 | """Mimics CrcXmodem from crccheck.""" 15 | 16 | def __init__(self): 17 | self._value = 0 18 | 19 | def process(self, data): 20 | self._value = binascii.crc_hqx(data, self._value) 21 | 22 | def final(self): 23 | return self._value 24 | 25 | 26 | class SdoBase(Mapping): 27 | 28 | #: The CRC algorithm used for block transfers 29 | crc_cls = CrcXmodem 30 | 31 | def __init__( 32 | self, 33 | rx_cobid: int, 34 | tx_cobid: int, 35 | od: objectdictionary.ObjectDictionary, 36 | ): 37 | """ 38 | :param rx_cobid: 39 | COB-ID that the server receives on (usually 0x600 + node ID) 40 | :param tx_cobid: 41 | COB-ID that the server responds with (usually 0x580 + node ID) 42 | :param od: 43 | Object Dictionary to use for communication 44 | """ 45 | self.rx_cobid = rx_cobid 46 | self.tx_cobid = tx_cobid 47 | self.network: canopen.network.Network = canopen.network._UNINITIALIZED_NETWORK 48 | self.od = od 49 | 50 | def __getitem__( 51 | self, index: Union[str, int] 52 | ) -> Union[SdoVariable, SdoArray, SdoRecord]: 53 | entry = self.od[index] 54 | if isinstance(entry, objectdictionary.ODVariable): 55 | return SdoVariable(self, entry) 56 | elif isinstance(entry, objectdictionary.ODArray): 57 | return SdoArray(self, entry) 58 | elif isinstance(entry, objectdictionary.ODRecord): 59 | return SdoRecord(self, entry) 60 | 61 | def __iter__(self) -> Iterator[int]: 62 | return iter(self.od) 63 | 64 | def __len__(self) -> int: 65 | return len(self.od) 66 | 67 | def __contains__(self, key: Union[int, str]) -> bool: 68 | return key in self.od 69 | 70 | def get_variable( 71 | self, index: Union[int, str], subindex: int = 0 72 | ) -> Optional[SdoVariable]: 73 | """Get the variable object at specified index (and subindex if applicable). 74 | 75 | :return: SdoVariable if found, else `None` 76 | """ 77 | obj = self.get(index) 78 | if isinstance(obj, SdoVariable): 79 | return obj 80 | elif isinstance(obj, (SdoRecord, SdoArray)): 81 | return obj.get(subindex) 82 | 83 | def upload(self, index: int, subindex: int) -> bytes: 84 | raise NotImplementedError() 85 | 86 | def download( 87 | self, 88 | index: int, 89 | subindex: int, 90 | data: bytes, 91 | force_segment: bool = False, 92 | ) -> None: 93 | raise NotImplementedError() 94 | 95 | 96 | class SdoRecord(Mapping): 97 | 98 | def __init__(self, sdo_node: SdoBase, od: objectdictionary.ODRecord): 99 | self.sdo_node = sdo_node 100 | self.od = od 101 | 102 | def __repr__(self) -> str: 103 | return f"<{type(self).__qualname__} {self.od.name!r} at {pretty_index(self.od.index)}>" 104 | 105 | def __getitem__(self, subindex: Union[int, str]) -> SdoVariable: 106 | return SdoVariable(self.sdo_node, self.od[subindex]) 107 | 108 | def __iter__(self) -> Iterator[int]: 109 | # Skip the "highest subindex" entry, which is not part of the data 110 | return filter(None, iter(self.od)) 111 | 112 | def __len__(self) -> int: 113 | # Skip the "highest subindex" entry, which is not part of the data 114 | return len(self.od) - int(0 in self.od) 115 | 116 | def __contains__(self, subindex: Union[int, str]) -> bool: 117 | return subindex in self.od 118 | 119 | 120 | class SdoArray(Mapping): 121 | 122 | def __init__(self, sdo_node: SdoBase, od: objectdictionary.ODArray): 123 | self.sdo_node = sdo_node 124 | self.od = od 125 | 126 | def __repr__(self) -> str: 127 | return f"<{type(self).__qualname__} {self.od.name!r} at {pretty_index(self.od.index)}>" 128 | 129 | def __getitem__(self, subindex: Union[int, str]) -> SdoVariable: 130 | return SdoVariable(self.sdo_node, self.od[subindex]) 131 | 132 | def __iter__(self) -> Iterator[int]: 133 | # Skip the "highest subindex" entry, which is not part of the data 134 | return iter(range(1, len(self) + 1)) 135 | 136 | def __len__(self) -> int: 137 | return self[0].raw 138 | 139 | def __contains__(self, subindex: int) -> bool: 140 | return 0 <= subindex <= len(self) 141 | 142 | 143 | class SdoVariable(variable.Variable): 144 | """Access object dictionary variable values using SDO protocol.""" 145 | 146 | def __init__(self, sdo_node: SdoBase, od: objectdictionary.ODVariable): 147 | self.sdo_node = sdo_node 148 | variable.Variable.__init__(self, od) 149 | 150 | def get_data(self) -> bytes: 151 | return self.sdo_node.upload(self.od.index, self.od.subindex) 152 | 153 | def set_data(self, data: bytes): 154 | force_segment = self.od.data_type == objectdictionary.DOMAIN 155 | self.sdo_node.download(self.od.index, self.od.subindex, data, force_segment) 156 | 157 | @property 158 | def writable(self) -> bool: 159 | return self.od.writable 160 | 161 | @property 162 | def readable(self) -> bool: 163 | return self.od.readable 164 | 165 | def open(self, mode="rb", encoding="ascii", buffering=1024, size=None, 166 | block_transfer=False, request_crc_support=True): 167 | """Open the data stream as a file like object. 168 | 169 | :param str mode: 170 | ========= ========================================================== 171 | Character Meaning 172 | --------- ---------------------------------------------------------- 173 | 'r' open for reading (default) 174 | 'w' open for writing 175 | 'b' binary mode (default) 176 | 't' text mode 177 | ========= ========================================================== 178 | :param str encoding: 179 | The str name of the encoding used to decode or encode the file. 180 | This will only be used in text mode. 181 | :param int buffering: 182 | An optional integer used to set the buffering policy. Pass 0 to 183 | switch buffering off (only allowed in binary mode), 1 to select line 184 | buffering (only usable in text mode), and an integer > 1 to indicate 185 | the size in bytes of a fixed-size chunk buffer. 186 | :param int size: 187 | Size of data to that will be transmitted. 188 | :param bool block_transfer: 189 | If block transfer should be used. 190 | :param bool request_crc_support: 191 | If crc calculation should be requested when using block transfer 192 | 193 | :returns: 194 | A file like object. 195 | """ 196 | return self.sdo_node.open(self.od.index, self.od.subindex, mode, 197 | encoding, buffering, size, block_transfer, request_crc_support=request_crc_support) 198 | 199 | 200 | # For compatibility 201 | Record = SdoRecord 202 | Array = SdoArray 203 | Variable = SdoVariable 204 | -------------------------------------------------------------------------------- /canopen/sdo/constants.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | # Command, index, subindex 4 | SDO_STRUCT = struct.Struct("> 2) & 0x3) 138 | else: 139 | size = 4 140 | self._node.set_data(index, subindex, request[4:4 + size], check_writable=True) 141 | else: 142 | logger.info("Initiating segmented download for 0x%04X:%02X", index, subindex) 143 | if command & SIZE_SPECIFIED: 144 | size, = struct.unpack_from("> 1) & 0x7) 157 | self._buffer.extend(request[1:last_byte]) 158 | 159 | if command & NO_MORE_DATA: 160 | self._node.set_data(self._index, 161 | self._subindex, 162 | self._buffer, 163 | check_writable=True) 164 | 165 | res_command = RESPONSE_SEGMENT_DOWNLOAD 166 | # Add toggle bit 167 | res_command |= self._toggle 168 | # Toggle bit for next message 169 | self._toggle ^= TOGGLE_BIT 170 | 171 | response = bytearray(8) 172 | response[0] = res_command 173 | self.send_response(response) 174 | 175 | def send_response(self, response): 176 | self.network.send_message(self.tx_cobid, response) 177 | 178 | def abort(self, abort_code=0x08000000): 179 | """Abort current transfer.""" 180 | data = struct.pack(" bytes: 186 | """May be called to make a read operation without an Object Dictionary. 187 | 188 | :param index: 189 | Index of object to read. 190 | :param subindex: 191 | Sub-index of object to read. 192 | 193 | :return: A data object. 194 | 195 | :raises canopen.SdoAbortedError: 196 | When node responds with an error. 197 | """ 198 | return self._node.get_data(index, subindex) 199 | 200 | def download( 201 | self, 202 | index: int, 203 | subindex: int, 204 | data: bytes, 205 | force_segment: bool = False, 206 | ): 207 | """May be called to make a write operation without an Object Dictionary. 208 | 209 | :param index: 210 | Index of object to write. 211 | :param subindex: 212 | Sub-index of object to write. 213 | :param data: 214 | Data to be written. 215 | 216 | :raises canopen.SdoAbortedError: 217 | When node responds with an error. 218 | """ 219 | return self._node.set_data(index, subindex, data) 220 | -------------------------------------------------------------------------------- /canopen/sync.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class SyncProducer: 5 | """Transmits a SYNC message periodically.""" 6 | 7 | #: COB-ID of the SYNC message 8 | cob_id = 0x80 9 | 10 | def __init__(self, network): 11 | self.network = network 12 | self.period: Optional[float] = None 13 | self._task = None 14 | 15 | def transmit(self, count: Optional[int] = None): 16 | """Send out a SYNC message once. 17 | 18 | :param count: 19 | Counter to add in message. 20 | """ 21 | data = [count] if count is not None else [] 22 | self.network.send_message(self.cob_id, data) 23 | 24 | def start(self, period: Optional[float] = None): 25 | """Start periodic transmission of SYNC message in a background thread. 26 | 27 | :param period: 28 | Period of SYNC message in seconds. 29 | """ 30 | if period is not None: 31 | self.period = period 32 | 33 | if not self.period: 34 | raise ValueError("A valid transmission period has not been given") 35 | 36 | self._task = self.network.send_periodic(self.cob_id, [], self.period) 37 | 38 | def stop(self): 39 | """Stop periodic transmission of SYNC message.""" 40 | if self._task is not None: 41 | self._task.stop() 42 | -------------------------------------------------------------------------------- /canopen/timestamp.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import time 3 | from typing import Optional 4 | 5 | 6 | # 1 Jan 1984 7 | OFFSET = 441763200 8 | 9 | ONE_DAY = 60 * 60 * 24 10 | 11 | TIME_OF_DAY_STRUCT = struct.Struct(" str: 28 | subindex = self.subindex if isinstance(self.od.parent, 29 | (objectdictionary.ODRecord, objectdictionary.ODArray) 30 | ) else None 31 | return f"<{type(self).__qualname__} {self.name!r} at {pretty_index(self.index, subindex)}>" 32 | 33 | def get_data(self) -> bytes: 34 | raise NotImplementedError("Variable is not readable") 35 | 36 | def set_data(self, data: bytes): 37 | raise NotImplementedError("Variable is not writable") 38 | 39 | @property 40 | def data(self) -> bytes: 41 | """Byte representation of the object as :class:`bytes`.""" 42 | return self.get_data() 43 | 44 | @data.setter 45 | def data(self, data: bytes): 46 | self.set_data(data) 47 | 48 | @property 49 | def raw(self) -> Union[int, bool, float, str, bytes]: 50 | """Raw representation of the object. 51 | 52 | This table lists the translations between object dictionary data types 53 | and Python native data types. 54 | 55 | +---------------------------+----------------------------+ 56 | | Data type | Python type | 57 | +===========================+============================+ 58 | | BOOLEAN | :class:`bool` | 59 | +---------------------------+----------------------------+ 60 | | UNSIGNEDxx | :class:`int` | 61 | +---------------------------+----------------------------+ 62 | | INTEGERxx | :class:`int` | 63 | +---------------------------+----------------------------+ 64 | | REALxx | :class:`float` | 65 | +---------------------------+----------------------------+ 66 | | VISIBLE_STRING | :class:`str` | 67 | +---------------------------+----------------------------+ 68 | | UNICODE_STRING | :class:`str` | 69 | +---------------------------+----------------------------+ 70 | | OCTET_STRING | :class:`bytes` | 71 | +---------------------------+----------------------------+ 72 | | DOMAIN | :class:`bytes` | 73 | +---------------------------+----------------------------+ 74 | 75 | Data types that this library does not handle yet must be read and 76 | written as :class:`bytes`. 77 | """ 78 | value = self.od.decode_raw(self.data) 79 | text = f"Value of {self.name!r} ({pretty_index(self.index, self.subindex)}) is {value!r}" 80 | if value in self.od.value_descriptions: 81 | text += f" ({self.od.value_descriptions[value]})" 82 | logger.debug(text) 83 | return value 84 | 85 | @raw.setter 86 | def raw(self, value: Union[int, bool, float, str, bytes]): 87 | logger.debug("Writing %r (0x%04X:%02X) = %r", 88 | self.name, self.index, 89 | self.subindex, value) 90 | self.data = self.od.encode_raw(value) 91 | 92 | @property 93 | def phys(self) -> Union[int, bool, float, str, bytes]: 94 | """Physical value scaled with some factor (defaults to 1). 95 | 96 | On object dictionaries that support specifying a factor, this can be 97 | either a :class:`float` or an :class:`int`. 98 | Non integers will be passed as is. 99 | """ 100 | value = self.od.decode_phys(self.raw) 101 | if self.od.unit: 102 | logger.debug("Physical value is %s %s", value, self.od.unit) 103 | return value 104 | 105 | @phys.setter 106 | def phys(self, value: Union[int, bool, float, str, bytes]): 107 | self.raw = self.od.encode_phys(value) 108 | 109 | @property 110 | def desc(self) -> str: 111 | """Converts to and from a description of the value as a string.""" 112 | value = self.od.decode_desc(self.raw) 113 | logger.debug("Description is '%s'", value) 114 | return value 115 | 116 | @desc.setter 117 | def desc(self, desc: str): 118 | self.raw = self.od.encode_desc(desc) 119 | 120 | @property 121 | def bits(self) -> "Bits": 122 | """Access bits using integers, slices, or bit descriptions.""" 123 | return Bits(self) 124 | 125 | def read(self, fmt: str = "raw") -> Union[int, bool, float, str, bytes]: 126 | """Alternative way of reading using a function instead of attributes. 127 | 128 | May be useful for asynchronous reading. 129 | 130 | :param str fmt: 131 | How to return the value 132 | - 'raw' 133 | - 'phys' 134 | - 'desc' 135 | 136 | :returns: 137 | The value of the variable. 138 | """ 139 | if fmt == "raw": 140 | return self.raw 141 | elif fmt == "phys": 142 | return self.phys 143 | elif fmt == "desc": 144 | return self.desc 145 | 146 | def write( 147 | self, value: Union[int, bool, float, str, bytes], fmt: str = "raw" 148 | ) -> None: 149 | """Alternative way of writing using a function instead of attributes. 150 | 151 | May be useful for asynchronous writing. 152 | 153 | :param str fmt: 154 | How to write the value 155 | - 'raw' 156 | - 'phys' 157 | - 'desc' 158 | """ 159 | if fmt == "raw": 160 | self.raw = value 161 | elif fmt == "phys": 162 | self.phys = value 163 | elif fmt == "desc": 164 | self.desc = value 165 | 166 | 167 | class Bits(Mapping): 168 | 169 | def __init__(self, variable: Variable): 170 | self.variable = variable 171 | self.read() 172 | 173 | @staticmethod 174 | def _get_bits(key): 175 | if isinstance(key, slice): 176 | bits = range(key.start, key.stop, key.step) 177 | elif isinstance(key, int): 178 | bits = [key] 179 | else: 180 | bits = key 181 | return bits 182 | 183 | def __getitem__(self, key) -> int: 184 | return self.variable.od.decode_bits(self.raw, self._get_bits(key)) 185 | 186 | def __setitem__(self, key, value: int): 187 | self.raw = self.variable.od.encode_bits( 188 | self.raw, self._get_bits(key), value) 189 | self.write() 190 | 191 | def __iter__(self): 192 | return iter(self.variable.od.bit_definitions) 193 | 194 | def __len__(self): 195 | return len(self.variable.od.bit_definitions) 196 | 197 | def read(self): 198 | self.raw = self.variable.raw 199 | 200 | def write(self): 201 | self.variable.raw = self.raw 202 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "*/test/*" 3 | 4 | comment: 5 | require_changes: true 6 | layout: "reach, diff, flags, files" 7 | behavior: default 8 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # If extensions (or modules to document with autodoc) are in another directory, 7 | # add these directories to sys.path here. If the directory is relative to the 8 | # documentation root, use os.path.abspath to make it absolute, like shown here. 9 | import os 10 | import sys 11 | from importlib import metadata 12 | 13 | sys.path.insert(0, os.path.abspath('..')) 14 | 15 | 16 | # -- Project information ----------------------------------------------------- 17 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 18 | 19 | project = 'canopen' 20 | project_copyright = '2016, Christian Sandberg' 21 | author = 'Christian Sandberg' 22 | # The full version, including alpha/beta/rc tags. 23 | release = metadata.version('canopen') 24 | # The short X.Y version. 25 | version = '.'.join(release.split('.')[:2]) 26 | 27 | # -- General configuration --------------------------------------------------- 28 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 29 | 30 | extensions = [ 31 | 'sphinx.ext.autodoc', 32 | 'sphinx.ext.intersphinx', 33 | 'sphinx.ext.viewcode', 34 | 'sphinx_autodoc_typehints', 35 | ] 36 | 37 | templates_path = ['_templates'] 38 | root_doc = 'index' 39 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 40 | 41 | language = 'en' 42 | 43 | # -- Options for HTML output ------------------------------------------------- 44 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 45 | 46 | html_theme = 'furo' 47 | 48 | # -- Options for HTML help output -------------------------------------------- 49 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-help-output 50 | 51 | htmlhelp_basename = 'canopendoc' 52 | 53 | # -- Options for LaTeX output ------------------------------------------------ 54 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-latex-output 55 | 56 | latex_documents = [ 57 | (root_doc, 'canopen.tex', 'canopen Documentation', 58 | 'Christian Sandberg', 'manual'), 59 | ] 60 | 61 | # -- Options for manual page output ------------------------------------------ 62 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-manual-page-output 63 | 64 | man_pages = [ 65 | (root_doc, 'canopen', 'canopen Documentation', 66 | [author], 1) 67 | ] 68 | 69 | # -- Options for Texinfo output ---------------------------------------------- 70 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-texinfo-output 71 | 72 | texinfo_documents = [ 73 | (root_doc, 'canopen', 'canopen Documentation', 74 | author, 'canopen', 'One line description of project.', 75 | 'Miscellaneous'), 76 | ] 77 | 78 | # -- Options for autodoc extension ------------------------------------------- 79 | # https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#configuration 80 | 81 | autoclass_content = 'both' 82 | 83 | # -- Options for intersphinx extension --------------------------------------- 84 | # https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration 85 | 86 | intersphinx_mapping = { 87 | 'python': ('https://docs.python.org/3/', None), 88 | 'can': ('https://python-can.readthedocs.io/en/stable/', None), 89 | } 90 | -------------------------------------------------------------------------------- /doc/emcy.rst: -------------------------------------------------------------------------------- 1 | Emergency Object (EMCY) 2 | ======================= 3 | 4 | Emergency messages are triggered by the occurrence of a device internal fatal 5 | error situation and are transmitted from the concerned application device to the 6 | other devices with high priority. This makes them suitable for interrupt type 7 | error alerts. An Emergency Telegram may be sent only once per 'error event', 8 | i.e. the emergency messages must not be repeated. As long as no new errors occur 9 | on a device no further emergency message must be sent. 10 | By means of CANopen Communication Profile defined emergency error codes, 11 | the error register and device specific additional information are specified in 12 | the device profiles. 13 | 14 | 15 | Examples 16 | -------- 17 | 18 | To list the currently active emergencies for a particular node, one can use the 19 | ``.active`` attribute which is a list of :class:`canopen.emcy.EmcyError` 20 | objects:: 21 | 22 | active_codes = [emcy.code for emcy in node.emcy.active] 23 | all_codes = [emcy.code for emcy in node.emcy.log] 24 | 25 | The :class:`canopen.emcy.EmcyError` objects are actually exceptions so that they 26 | can be easily raised if that's what you want:: 27 | 28 | if node.emcy.active: 29 | raise node.emcy.active[-1] 30 | 31 | 32 | API 33 | --- 34 | 35 | .. autoclass:: canopen.emcy.EmcyConsumer 36 | :members: 37 | 38 | .. autoexception:: canopen.emcy.EmcyError 39 | :members: 40 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | CANopen for Python 2 | ================== 3 | 4 | This package provides support for interacting with a network of CANopen_ nodes. 5 | 6 | .. note:: 7 | 8 | Most of the documentation here is directly stolen from the 9 | CANopen_ Wikipedia page. 10 | 11 | This documentation is a work in progress. 12 | Feedback and revisions are most welcome! 13 | 14 | CANopen is a communication protocol and device profile specification for 15 | embedded systems used in automation. In terms of the OSI model, CANopen 16 | implements the layers above and including the network layer. 17 | The CANopen standard consists of an addressing scheme, several small 18 | communication protocols and an application layer defined by a device profile. 19 | The communication protocols have support for network management, device 20 | monitoring and communication between nodes, including a simple transport layer 21 | for message segmentation/desegmentation. 22 | 23 | Easiest way to install is to use pip_:: 24 | 25 | $ pip install canopen 26 | 27 | 28 | .. toctree:: 29 | :maxdepth: 1 30 | 31 | network 32 | od 33 | nmt 34 | sdo 35 | pdo 36 | sync 37 | emcy 38 | timestamp 39 | lss 40 | integration 41 | profiles 42 | 43 | 44 | .. _CANopen: https://en.wikipedia.org/wiki/CANopen 45 | .. _pip: https://pip.pypa.io/en/stable/ 46 | -------------------------------------------------------------------------------- /doc/integration.rst: -------------------------------------------------------------------------------- 1 | Integration with existing code 2 | ============================== 3 | 4 | Sometimes you need to use this library together with some existing code base 5 | or you have CAN drivers not supported by python-can. This chapter will cover 6 | some use cases. 7 | 8 | 9 | Re-using a bus 10 | -------------- 11 | 12 | If you need to interact with the CAN-bus outside of this library too and you 13 | want to use the same python-can Bus instance, you need to tell the Network 14 | which Bus to use and also add the :class:`canopen.network.MessageListener` 15 | to your existing :class:`can.Notifier`. 16 | 17 | Here is a short example:: 18 | 19 | import canopen 20 | import can 21 | 22 | # A Bus instance created outside 23 | bus = can.interface.Bus() 24 | 25 | network = canopen.Network() 26 | # Associate the bus with the network 27 | network.bus = bus 28 | 29 | # Add your list of can.Listener with the network's 30 | listeners = [can.Printer()] + network.listeners 31 | # Start the notifier 32 | notifier = can.Notifier(bus, listeners, 0.5) 33 | 34 | 35 | Using a custom backend 36 | ---------------------- 37 | 38 | If the python-can package does not have support for your CAN interface then you 39 | need to create a sub-class of :class:`canopen.Network` and provide your own 40 | means of sending messages. You also need to feed incoming messages in a 41 | background thread to :meth:`canopen.Network.notify`. 42 | 43 | Here is an example:: 44 | 45 | import canopen 46 | 47 | class CustomNetwork(canopen.Network): 48 | 49 | def connect(self, *args, **kwargs): 50 | # Optionally use this to start communication with CAN 51 | pass 52 | 53 | def disconnect(self): 54 | # Optionally use this to stop communincation 55 | pass 56 | 57 | def send_message(self, can_id, data, remote=False): 58 | # Send the message with the 11-bit can_id and data which might be 59 | # a bytearray or list of integers. 60 | # if remote is True then it should be sent as an RTR. 61 | pass 62 | 63 | 64 | network = CustomNetwork() 65 | 66 | # Should be done in a thread but here we notify the network for 67 | # demonstration purposes only 68 | network.notify(0x701, bytearray([0x05]), time.time()) 69 | -------------------------------------------------------------------------------- /doc/lss.rst: -------------------------------------------------------------------------------- 1 | Layer Setting Services (LSS) 2 | ================================ 3 | 4 | The LSS protocol is used to change the node id and baud rate 5 | of the target CANOpen device (slave). To change these values, configuration state should be set 6 | first by master. Then modify the node id and the baud rate. 7 | There are two options to switch from waiting state to configuration state. 8 | One is to switch all the slave at once, the other way is to switch only one slave. 9 | The former can be used to set baud rate for all the slaves. 10 | The latter can be used to change node id one by one. 11 | 12 | Once you finished the setting, the values should be saved to non-volatile memory. 13 | Finally, you can switch to LSS waiting state. 14 | 15 | .. note:: 16 | Some method and constance names are changed:: 17 | 18 | send_switch_mode_global() ==> send_switch_state_global() 19 | network.lss.CONFIGURATION_MODE ==> network.lss.CONFIGURATION_STATE 20 | network.lss.NORMAL_MODE ==> network.lss.WAITING_STATE 21 | 22 | You can still use the old name, but please use the new names. 23 | 24 | 25 | .. note:: 26 | Fastscan is supported from v0.8.0. 27 | LSS identify slave service is not implemented. 28 | 29 | Examples 30 | -------- 31 | 32 | Switch all the slave into CONFIGURATION state. There is no response for the message. :: 33 | 34 | network.lss.send_switch_state_global(network.lss.CONFIGURATION_STATE) 35 | 36 | 37 | Or, you can call this method with 4 IDs if you want to switch only one slave:: 38 | 39 | vendorId = 0x00000022 40 | productCode = 0x12345678 41 | revisionNumber = 0x0000555 42 | serialNumber = 0x00abcdef 43 | ret_bool = network.lss.send_switch_state_selective(vendorId, productCode, 44 | revisionNumber, serialNumber) 45 | 46 | Or, you can run fastscan procedure :: 47 | 48 | ret_bool, lss_id_list = network.lss.fast_scan() 49 | 50 | Once one of sensors goes to CONFIGURATION state, you can read the current node id of the LSS slave:: 51 | 52 | node_id = network.lss.inquire_node_id() 53 | 54 | Change the node id and baud rate:: 55 | 56 | network.lss.configure_node_id(node_id+1) 57 | network.lss.configure_bit_timing(2) 58 | 59 | This is the table for converting the argument index of bit timing into baud rate. 60 | 61 | ==== =============== 62 | idx Baud rate 63 | ==== =============== 64 | 0 1 MBit/sec 65 | 1 800 kBit/sec 66 | 2 500 kBit/sec 67 | 3 250 kBit/sec 68 | 4 125 kBit/sec 69 | 5 100 kBit/sec 70 | 6 50 kBit/sec 71 | 7 20 kBit/sec 72 | 8 10 kBit/sec 73 | ==== =============== 74 | 75 | Save the configuration:: 76 | 77 | network.lss.store_configuration() 78 | 79 | Finally, you can switch the state of the slave(s) from CONFIGURATION state to WAITING state:: 80 | 81 | network.lss.send_switch_state_global(network.lss.WAITING_STATE) 82 | 83 | 84 | API 85 | --- 86 | 87 | .. autoclass:: canopen.lss.LssMaster 88 | :members: 89 | 90 | 91 | .. autoclass:: canopen.lss.LssError 92 | :show-inheritance: 93 | :members: 94 | 95 | .. _python-can: https://python-can.readthedocs.org/en/stable/ 96 | -------------------------------------------------------------------------------- /doc/network.rst: -------------------------------------------------------------------------------- 1 | Network and nodes 2 | ================= 3 | 4 | The :class:`canopen.Network` represents a collection of nodes connected to the 5 | same CAN bus. This handles the sending and receiving of messages and dispatches 6 | messages to the nodes it knows about. 7 | 8 | Each node is represented using the :class:`canopen.RemoteNode` or 9 | :class:`canopen.LocalNode` class. It is usually associated with an 10 | object dictionary and each service has its own attribute owned by this node. 11 | 12 | 13 | Examples 14 | -------- 15 | 16 | Create one network per CAN bus:: 17 | 18 | import canopen 19 | 20 | network = canopen.Network() 21 | 22 | By default this library uses python-can_ for the actual communication. 23 | See its documentation for specifics on how to configure your specific interface. 24 | 25 | Call the :meth:`~canopen.Network.connect` method to start the communication, optionally providing 26 | arguments passed to a the :class:`can.BusABC` constructor:: 27 | 28 | network.connect(channel='can0', interface='socketcan') 29 | # network.connect(interface='kvaser', channel=0, bitrate=250000) 30 | # network.connect(interface='pcan', channel='PCAN_USBBUS1', bitrate=250000) 31 | # network.connect(interface='ixxat', channel=0, bitrate=250000) 32 | # network.connect(interface='nican', channel='CAN0', bitrate=250000) 33 | 34 | Add nodes to the network using the :meth:`~canopen.Network.add_node` method:: 35 | 36 | node = network.add_node(6, '/path/to/object_dictionary.eds') 37 | 38 | local_node = canopen.LocalNode(1, '/path/to/master_dictionary.eds') 39 | network.add_node(local_node) 40 | 41 | Nodes can also be accessed using the ``Network`` object as a Python dictionary:: 42 | 43 | for node_id in network: 44 | print(network[node_id]) 45 | 46 | To automatically detect which nodes are present on the network, there is the 47 | :attr:`~canopen.Network.scanner` attribute available for this purpose:: 48 | 49 | # This will attempt to read an SDO from nodes 1 - 127 50 | network.scanner.search() 51 | # We may need to wait a short while here to allow all nodes to respond 52 | time.sleep(0.05) 53 | for node_id in network.scanner.nodes: 54 | print(f"Found node {node_id}!") 55 | 56 | Finally, make sure to disconnect after you are done:: 57 | 58 | network.disconnect() 59 | 60 | 61 | API 62 | --- 63 | 64 | .. autoclass:: canopen.Network 65 | :members: 66 | 67 | .. py:attribute:: nmt 68 | 69 | The broadcast :class:`canopen.nmt.NmtMaster` which will affect all nodes. 70 | 71 | .. py:attribute:: sync 72 | 73 | The :class:`canopen.sync.SyncProducer` for this network. 74 | 75 | .. py:attribute:: time 76 | 77 | The :class:`canopen.timestamp.TimeProducer` for this network. 78 | 79 | .. describe:: network[node_id] 80 | 81 | Return the :class:`canopen.RemoteNode` or :class:`canopen.LocalNode` for 82 | the specified node ID. 83 | 84 | .. describe:: iter(network) 85 | 86 | Return an iterator over the handled node IDs. 87 | 88 | .. describe:: node_id in network 89 | 90 | Return ``True`` if the node ID exists is handled by this network. 91 | 92 | .. describe:: del network[node_id] 93 | 94 | Delete the node ID from the network. 95 | 96 | .. method:: values() 97 | 98 | Return a list of :class:`canopen.RemoteNode` or :class:`canopen.LocalNode` 99 | handled by this network. 100 | 101 | 102 | .. autoclass:: canopen.RemoteNode 103 | :members: 104 | 105 | .. py:attribute:: id 106 | 107 | The node id (1 - 127). Changing this after initializing the object 108 | will not have any effect. 109 | 110 | .. py:attribute:: sdo 111 | 112 | The :class:`canopen.sdo.SdoClient` associated with the node. 113 | 114 | .. py:attribute:: sdo_channels 115 | 116 | List of available SDO channels (added with :meth:`add_sdo`). 117 | 118 | .. py:attribute:: tpdo 119 | 120 | The :class:`canopen.pdo.PdoBase` for TPDO associated with the node. 121 | 122 | .. py:attribute:: rpdo 123 | 124 | The :class:`canopen.pdo.PdoBase` for RPDO associated with the node. 125 | 126 | .. py:attribute:: nmt 127 | 128 | The :class:`canopen.nmt.NmtMaster` associated with the node. 129 | 130 | .. py:attribute:: emcy 131 | 132 | The :class:`canopen.emcy.EmcyConsumer` associated with the node. 133 | 134 | .. py:attribute:: object_dictionary 135 | 136 | The :class:`canopen.ObjectDictionary` associated with the node 137 | 138 | .. py:attribute:: network 139 | 140 | The :class:`canopen.Network` owning the node 141 | 142 | 143 | .. autoclass:: canopen.LocalNode 144 | :members: 145 | 146 | .. py:attribute:: id 147 | 148 | The node id (1 - 127). Changing this after initializing the object 149 | will not have any effect. 150 | 151 | .. py:attribute:: sdo 152 | 153 | The :class:`canopen.sdo.SdoServer` associated with the node. 154 | 155 | .. py:attribute:: object_dictionary 156 | 157 | The :class:`canopen.ObjectDictionary` associated with the node 158 | 159 | .. py:attribute:: network 160 | 161 | The :class:`canopen.Network` owning the node 162 | 163 | 164 | .. autoclass:: canopen.network.MessageListener 165 | :show-inheritance: 166 | :members: 167 | 168 | 169 | .. autoclass:: canopen.network.NodeScanner 170 | :members: 171 | 172 | 173 | .. autoclass:: canopen.network.PeriodicMessageTask 174 | :members: 175 | 176 | 177 | .. _python-can: https://python-can.readthedocs.org/en/stable/ 178 | -------------------------------------------------------------------------------- /doc/nmt.rst: -------------------------------------------------------------------------------- 1 | Network management (NMT) 2 | ======================== 3 | 4 | The NMT protocols are used to issue state machine change commands 5 | (e.g. to start and stop the devices), detect remote device bootups and 6 | error conditions. 7 | 8 | The Module control protocol is used by the NMT master to change the state of 9 | the devices. The CAN-frame COB-ID of this protocol is always 0, meaning that it 10 | has a function code 0 and node ID 0, which means that every node in the network 11 | will process this message. The actual node ID, to which the command is meant to, 12 | is given in the data part of the message (at the second byte). This can also be 13 | 0, meaning that all the devices on the bus should go to the indicated state. 14 | 15 | The Heartbeat protocol is used to monitor the nodes in the network and verify 16 | that they are alive. A heartbeat producer (usually a slave device) periodically 17 | sends a message with the binary function code of 1110 and its node ID 18 | (COB-ID = 0x700 + node ID). The data part of the frame contains a byte 19 | indicating the node status. The heartbeat consumer reads these messages. 20 | 21 | CANopen devices are required to make the transition from the state Initializing 22 | to Pre-operational automatically during bootup. When this transition is made, 23 | a single heartbeat message is sent to the bus. This is the bootup protocol. 24 | 25 | 26 | Examples 27 | -------- 28 | 29 | Access the NMT functionality using the :attr:`canopen.Node.nmt` attribute. 30 | Changing state can be done using the :attr:`~canopen.nmt.NmtMaster.state` 31 | attribute:: 32 | 33 | node.nmt.state = 'OPERATIONAL' 34 | # Same as sending NMT start 35 | node.nmt.send_command(0x1) 36 | 37 | You can also change state of all nodes simulaneously as a broadcast message:: 38 | 39 | network.nmt.state = 'OPERATIONAL' 40 | 41 | If the node transmits heartbeat messages, the 42 | :attr:`~canopen.nmt.NmtMaster.state` attribute gets automatically updated with 43 | current state:: 44 | 45 | # Send NMT start to all nodes 46 | network.send_message(0x0, [0x1, 0]) 47 | node.nmt.wait_for_heartbeat() 48 | assert node.nmt.state == 'OPERATIONAL' 49 | 50 | 51 | API 52 | --- 53 | 54 | .. autoclass:: canopen.nmt.NmtMaster 55 | :members: 56 | 57 | .. autoexception:: canopen.nmt.NmtError 58 | :members: 59 | -------------------------------------------------------------------------------- /doc/od.rst: -------------------------------------------------------------------------------- 1 | Object Dictionary 2 | ================= 3 | 4 | CANopen devices must have an object dictionary, which is used for configuration 5 | and communication with the device. 6 | An entry in the object dictionary is defined by: 7 | 8 | * Index, the 16-bit address of the object in the dictionary 9 | * Object type, such as an array, record, or simple variable 10 | * Name, a string describing the entry 11 | * Type, gives the datatype of the variable 12 | (or the datatype of all variables of an array) 13 | * Attribute, which gives information on the access rights for this entry, 14 | this can be read/write (rw), read-only (ro) or write-only (wo) 15 | 16 | The basic datatypes for object dictionary values such as booleans, integers and 17 | floats are defined in the standard, as well as composite datatypes such as 18 | strings, arrays and records. The composite datatypes can be subindexed with an 19 | 8-bit index; the value in subindex 0 of an array or record indicates the number 20 | of elements in the data structure, and is of type UNSIGNED8. 21 | 22 | 23 | Supported formats 24 | ----------------- 25 | 26 | The currently supported file formats for specifying a node's object dictionary 27 | are: 28 | 29 | * EDS (standardized INI-file like format) 30 | * DCF (same as EDS with bitrate and node ID specified) 31 | * EPF (proprietary XML-format used by Inmotion Technologies) 32 | 33 | 34 | Examples 35 | -------- 36 | 37 | The object dictionary file is normally provided when creating a node. 38 | Here is an example where the entire object dictionary gets printed out:: 39 | 40 | node = network.add_node(6, 'od.eds') 41 | for obj in node.object_dictionary.values(): 42 | print(f'0x{obj.index:X}: {obj.name}') 43 | if isinstance(obj, canopen.objectdictionary.ODRecord): 44 | for subobj in obj.values(): 45 | print(f' {subobj.subindex}: {subobj.name}') 46 | 47 | You can access the objects using either index/subindex or names:: 48 | 49 | device_name_obj = node.object_dictionary['ManufacturerDeviceName'] 50 | vendor_id_obj = node.object_dictionary[0x1018][1] 51 | actual_speed = node.object_dictionary['ApplicationStatus.ActualSpeed'] 52 | command_all = node.object_dictionary['ApplicationCommands.CommandAll'] 53 | 54 | API 55 | --- 56 | 57 | .. autofunction:: canopen.export_od 58 | 59 | .. autofunction:: canopen.import_od 60 | 61 | .. autoclass:: canopen.ObjectDictionary 62 | :members: 63 | 64 | .. describe:: od[index] 65 | 66 | Return the object for the specified index (as int) or name 67 | (as string). 68 | 69 | .. describe:: iter(od) 70 | 71 | Return an iterator over the indexes from the object dictionary. 72 | 73 | .. describe:: index in od 74 | 75 | Return ``True`` if the index (as int) or name (as string) exists in 76 | the object dictionary. 77 | 78 | .. describe:: len(od) 79 | 80 | Return the number of objects in the object dictionary. 81 | 82 | .. method:: values() 83 | 84 | Return a list of objects (records, arrays and variables). 85 | 86 | 87 | .. autoclass:: canopen.objectdictionary.ODVariable 88 | :members: 89 | 90 | .. describe:: len(var) 91 | 92 | Return the length of the variable data type in number of bits. 93 | 94 | .. describe:: var == other 95 | 96 | Return ``True`` if the variables have the same index and subindex. 97 | 98 | 99 | .. autoclass:: canopen.objectdictionary.ODRecord 100 | :members: 101 | 102 | .. describe:: record[subindex] 103 | 104 | Return the :class:`~canopen.objectdictionary.ODVariable` for the specified 105 | subindex (as int) or name (as string). 106 | 107 | .. describe:: iter(record) 108 | 109 | Return an iterator over the subindexes from the record. 110 | 111 | .. describe:: subindex in record 112 | 113 | Return ``True`` if the subindex (as int) or name (as string) exists in 114 | the record. 115 | 116 | .. describe:: len(record) 117 | 118 | Return the number of subindexes in the record. 119 | 120 | .. describe:: record == other 121 | 122 | Return ``True`` if the records have the same index. 123 | 124 | .. method:: values() 125 | 126 | Return a list of :class:`~canopen.objectdictionary.ODVariable` in the record. 127 | 128 | 129 | .. autoclass:: canopen.objectdictionary.ODArray 130 | :members: 131 | 132 | .. describe:: array[subindex] 133 | 134 | Return the :class:`~canopen.objectdictionary.ODVariable` for the specified 135 | subindex (as int) or name (as string). 136 | This will work for all subindexes between 1 and 255. If the requested 137 | subindex has not been specified in the object dictionary, it will be 138 | created dynamically from the first subindex and suffixing the name with 139 | an underscore + the subindex in hex format. 140 | 141 | 142 | .. autoexception:: canopen.ObjectDictionaryError 143 | :members: 144 | 145 | 146 | Constants 147 | ~~~~~~~~~ 148 | 149 | .. py:data:: canopen.objectdictionary.UNSIGNED8 150 | .. py:data:: canopen.objectdictionary.UNSIGNED16 151 | .. py:data:: canopen.objectdictionary.UNSIGNED32 152 | .. py:data:: canopen.objectdictionary.UNSIGNED64 153 | 154 | .. py:data:: canopen.objectdictionary.INTEGER8 155 | .. py:data:: canopen.objectdictionary.INTEGER16 156 | .. py:data:: canopen.objectdictionary.INTEGER32 157 | .. py:data:: canopen.objectdictionary.INTEGER64 158 | 159 | .. py:data:: canopen.objectdictionary.BOOLEAN 160 | 161 | .. py:data:: canopen.objectdictionary.REAL32 162 | .. py:data:: canopen.objectdictionary.REAL64 163 | 164 | .. py:data:: canopen.objectdictionary.VISIBLE_STRING 165 | .. py:data:: canopen.objectdictionary.OCTET_STRING 166 | .. py:data:: canopen.objectdictionary.UNICODE_STRING 167 | .. py:data:: canopen.objectdictionary.DOMAIN 168 | 169 | 170 | .. py:data:: canopen.objectdictionary.SIGNED_TYPES 171 | .. py:data:: canopen.objectdictionary.UNSIGNED_TYPES 172 | .. py:data:: canopen.objectdictionary.INTEGER_TYPES 173 | .. py:data:: canopen.objectdictionary.FLOAT_TYPES 174 | .. py:data:: canopen.objectdictionary.NUMBER_TYPES 175 | .. py:data:: canopen.objectdictionary.DATA_TYPES 176 | -------------------------------------------------------------------------------- /doc/pdo.rst: -------------------------------------------------------------------------------- 1 | Process Data Object (PDO) 2 | ========================= 3 | 4 | The Process Data Object protocol is used to process real time data among various 5 | nodes. You can transfer up to 8 bytes (64 bits) of data per one PDO either from 6 | or to the device. One PDO can contain multiple object dictionary entries and the 7 | objects within one PDO are configurable using the mapping and parameter object 8 | dictionary entries. 9 | 10 | There are two kinds of PDOs: transmit and receive PDOs (TPDO and RPDO). 11 | The former is for data coming from the device and the latter is for data going 12 | to the device; that is, with RPDO you can send data to the device and with TPDO 13 | you can read data from the device. In the pre-defined connection set there are 14 | identifiers for four (4) TPDOs and four (4) RPDOs available. 15 | With configuration 512 PDOs are possible. 16 | 17 | PDOs can be sent synchronously or asynchronously. Synchronous PDOs are sent 18 | after the SYNC message whereas asynchronous messages are sent after internal 19 | or external trigger. For example, you can make a request to a device to transmit 20 | TPDO that contains data you need by sending an empty TPDO with the RTR flag 21 | (if the device is configured to accept TPDO requests). 22 | 23 | With RPDOs you can, for example, start two devices simultaneously. 24 | You only need to map the same RPDO into two or more different devices and make 25 | sure those RPDOs are mapped with the same COB-ID. 26 | 27 | 28 | Examples 29 | -------- 30 | 31 | A :class:`canopen.RemoteNode` has :class:`canopen.RemoteNode.rpdo` and 32 | :class:`canopen.RemoteNode.tpdo` attributes that can be used to interact 33 | with the node using PDOs. These can be subindexed to specify which map to use (first map 34 | starts at 1, not 0):: 35 | 36 | # Read current PDO configuration 37 | node.tpdo.read() 38 | node.rpdo.read() 39 | 40 | # Do some changes to TPDO4 and RPDO4 41 | node.tpdo[4].clear() 42 | node.tpdo[4].add_variable('Application Status', 'Status All') 43 | node.tpdo[4].add_variable('Application Status', 'Actual Speed') 44 | node.tpdo[4].trans_type = 254 45 | node.tpdo[4].event_timer = 10 46 | node.tpdo[4].enabled = True 47 | 48 | node.rpdo[4].clear() 49 | node.rpdo[4].add_variable('Application Commands', 'Command All') 50 | node.rpdo[4].add_variable('Application Commands', 'Command Speed') 51 | node.rpdo[4].enabled = True 52 | 53 | # Save new configuration (node must be in pre-operational) 54 | node.nmt.state = 'PRE-OPERATIONAL' 55 | node.tpdo.save() 56 | node.rpdo.save() 57 | 58 | # Start RPDO4 with an interval of 100 ms 59 | node.rpdo[4]['Application Commands.Command Speed'].phys = 1000 60 | node.rpdo[4].start(0.1) 61 | node.nmt.state = 'OPERATIONAL' 62 | 63 | # Read 50 values of speed and save to a file 64 | with open('output.txt', 'w') as f: 65 | for i in range(50): 66 | node.tpdo[4].wait_for_reception() 67 | speed = node.tpdo['Application Status.Actual Speed'].phys 68 | f.write(f'{speed}\n') 69 | 70 | # Using a callback to asynchronously receive values 71 | # Do not do any blocking operations here! 72 | def print_speed(message): 73 | print(f'{message.name} received') 74 | for var in message: 75 | print(f'{var.name} = {var.raw}') 76 | 77 | node.tpdo[4].add_callback(print_speed) 78 | time.sleep(5) 79 | 80 | # Stop transmission of RxPDO 81 | node.rpdo[4].stop() 82 | 83 | 84 | API 85 | --- 86 | 87 | .. autoclass:: canopen.pdo.PdoBase 88 | :members: 89 | 90 | .. describe:: pdo[no] 91 | 92 | Return the :class:`canopen.pdo.PdoMap` for the specified map number. 93 | First map starts at 1. 94 | 95 | .. describe:: iter(pdo) 96 | 97 | Return an iterator of the available map numbers. 98 | 99 | .. describe:: len(pdo) 100 | 101 | Return the number of supported maps. 102 | 103 | 104 | .. autoclass:: canopen.pdo.PdoMap 105 | :members: 106 | 107 | .. describe:: map[name] 108 | 109 | Return the :class:`canopen.pdo.PdoVariable` for the variable specified as 110 | ``"Group.Variable"`` or ``"Variable"`` or as a position starting at 0. 111 | 112 | .. describe:: iter(map) 113 | 114 | Return an iterator of the :class:`canopen.pdo.PdoVariable` entries in the map. 115 | 116 | .. describe:: len(map) 117 | 118 | Return the number of variables in the map. 119 | 120 | 121 | .. autoclass:: canopen.pdo.PdoVariable 122 | :members: 123 | :inherited-members: 124 | 125 | .. py:attribute:: od 126 | 127 | The :class:`canopen.objectdictionary.ODVariable` associated with this object. 128 | -------------------------------------------------------------------------------- /doc/profiles.rst: -------------------------------------------------------------------------------- 1 | Device profiles 2 | ================ 3 | 4 | On top of the standard CANopen functionality which includes the DS301 5 | application layer there can be additional profiles specifically for certain 6 | applications. 7 | 8 | CiA 402 CANopen device profile for motion controllers and drives 9 | ---------------------------------------------------------------- 10 | 11 | This device profile has a control state machine for controlling the behaviour 12 | of the drive. Therefore one needs to instantiate a node with the 13 | :class:`BaseNode402` class 14 | 15 | Create a node with BaseNode402:: 16 | 17 | import canopen 18 | from canopen.profiles.p402 import BaseNode402 19 | 20 | some_node = BaseNode402(3, 'someprofile.eds') 21 | network = canopen.Network() 22 | network.add_node(some_node) 23 | 24 | The Power State Machine 25 | ```````````````````````` 26 | 27 | The :class:`PowerStateMachine` class provides the means of controlling the 28 | states of this state machine. The static method `on_PDO1_callback()` is added 29 | to the TPDO1 callback. 30 | 31 | State changes can be controlled by writing a specific value to register 32 | 0x6040, which is called the "Controlword". 33 | The current status can be read from the device by reading the register 34 | 0x6041, which is called the "Statusword". 35 | Changes in state can only be done in the 'OPERATIONAL' state of the NmtMaster 36 | 37 | PDOs with the Controlword and Statusword mapped need to be set up correctly, 38 | which is the default configuration of most DS402-compatible drives. To make 39 | them accessible to the state machine implementation, run the the 40 | `BaseNode402.setup_402_state_machine()` method. Note that this setup routine 41 | will read the current PDO configuration by default, causing some SDO traffic. 42 | That works only in the 'OPERATIONAL' or 'PRE-OPERATIONAL' states of the 43 | :class:`NmtMaster`:: 44 | 45 | # run the setup routine for TPDO1 and it's callback 46 | some_node.setup_402_state_machine() 47 | 48 | Write Controlword and read Statusword:: 49 | 50 | # command to go to 'READY TO SWITCH ON' from 'NOT READY TO SWITCH ON' or 'SWITCHED ON' 51 | some_node.sdo[0x6040].raw = 0x06 52 | 53 | # Read the state of the Statusword 54 | some_node.sdo[0x6041].raw 55 | 56 | During operation the state can change to states which cannot be commanded by the 57 | Controlword, for example a 'FAULT' state. Therefore the :class:`BaseNode402` 58 | class (in similarity to :class:`NmtMaster`) automatically monitors state changes 59 | of the Statusword which is sent by TPDO. The available callback on that TPDO 60 | will then extract the information and mirror the state change in the 61 | :attr:`BaseNode402.state` attribute. 62 | 63 | Similar to the :class:`NmtMaster` class, the states of the :class:`BaseNode402` 64 | class :attr:`.state` attribute can be read and set (command) by a string:: 65 | 66 | # command a state (an SDO message will be called) 67 | some_node.state = 'SWITCHED ON' 68 | # read the current state 69 | some_node.state 70 | 71 | Available states: 72 | 73 | - 'NOT READY TO SWITCH ON' 74 | - 'SWITCH ON DISABLED' 75 | - 'READY TO SWITCH ON' 76 | - 'SWITCHED ON' 77 | - 'OPERATION ENABLED' 78 | - 'FAULT' 79 | - 'FAULT REACTION ACTIVE' 80 | - 'QUICK STOP ACTIVE' 81 | 82 | Available commands 83 | 84 | - 'SWITCH ON DISABLED' 85 | - 'DISABLE VOLTAGE' 86 | - 'READY TO SWITCH ON' 87 | - 'SWITCHED ON' 88 | - 'OPERATION ENABLED' 89 | - 'QUICK STOP ACTIVE' 90 | 91 | 92 | API 93 | ``` 94 | 95 | .. autoclass:: canopen.profiles.p402.BaseNode402 96 | :members: 97 | -------------------------------------------------------------------------------- /doc/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx~=7.3 2 | sphinx-autodoc-typehints~=2.2 3 | furo~=2024.5 4 | -------------------------------------------------------------------------------- /doc/sdo.rst: -------------------------------------------------------------------------------- 1 | Service Data Object (SDO) 2 | ========================= 3 | 4 | The SDO protocol is used for setting and for reading values from the 5 | object dictionary of a remote device. The device whose object dictionary is 6 | accessed is the SDO server and the device accessing the remote device is the SDO 7 | client. The communication is always initiated by the SDO client. In CANopen 8 | terminology, communication is viewed from the SDO server, so that a read from an 9 | object dictionary results in an SDO upload and a write to a dictionary entry is 10 | an SDO download. 11 | 12 | Because the object dictionary values can be larger than the eight bytes limit of 13 | a CAN frame, the SDO protocol implements segmentation and desegmentation of 14 | longer messages. Actually, there are two of these protocols: SDO download/upload 15 | and SDO Block download/upload. The SDO block transfer is a newer addition to 16 | standard, which allows large amounts of data to be transferred with slightly 17 | less protocol overhead. 18 | 19 | The COB-IDs of the respective SDO transfer messages from client to server and 20 | server to client can be set in the object dictionary. Up to 128 SDO servers can 21 | be set up in the object dictionary at addresses 0x1200 - 0x127F. Similarly, the 22 | SDO client connections of the device can be configured with variables at 23 | 0x1280 - 0x12FF. However the pre-defined connection set defines an SDO channel 24 | which can be used even just after bootup (in the Pre-operational state) to 25 | configure the device. The COB-IDs of this channel are 0x600 + node ID for 26 | receiving and 0x580 + node ID for transmitting. 27 | 28 | 29 | Examples 30 | -------- 31 | 32 | SDO objects can be accessed using the ``.sdo`` member which works like a Python 33 | dictionary. Indexes can be identified by either name or number. 34 | There are two ways to idenity subindexes, either by using the index and subindex 35 | as separate arguments or by using a combined syntax using a dot. 36 | The code below only creates objects, no messages are sent or received yet:: 37 | 38 | # Complex records 39 | command_all = node.sdo['ApplicationCommands']['CommandAll'] 40 | command_all = node.sdo['ApplicationCommands.CommandAll'] 41 | actual_speed = node.sdo['ApplicationStatus']['ActualSpeed'] 42 | control_mode = node.sdo['ApplicationSetupParameters']['RequestedControlMode'] 43 | 44 | # Simple variables 45 | device_type = node.sdo[0x1000] 46 | 47 | # Arrays 48 | error_log = node.sdo[0x1003] 49 | 50 | To actually read or write the variables, use the ``.raw``, ``.phys``, ``.desc``, 51 | or ``.bits`` attributes:: 52 | 53 | print(f"The device type is 0x{device_type.raw:X}") 54 | 55 | # Using value descriptions instead of integers (if supported by OD) 56 | control_mode.desc = 'Speed Mode' 57 | 58 | # Set individual bit 59 | command_all.bits[3] = 1 60 | 61 | # Read and write physical values scaled by a factor (if supported by OD) 62 | print(f"The actual speed is {actual_speed.phys} rpm") 63 | 64 | # Iterate over arrays or records 65 | for error in error_log.values(): 66 | print(f"Error 0x{error.raw:X} was found in the log") 67 | 68 | It is also possible to read and write to variables that are not in the Object 69 | Dictionary, but only using raw bytes:: 70 | 71 | device_type_data = node.sdo.upload(0x1000, 0) 72 | node.sdo.download(0x1017, 0, b'\x00\x00') 73 | 74 | Variables can be opened as readable or writable file objects which can be useful 75 | when dealing with large amounts of data:: 76 | 77 | # Open the Store EDS variable as a file like object 78 | with node.sdo[0x1021].open('r', encoding='ascii') as infile, 79 | open('out.eds', 'w', encoding='ascii') as outfile: 80 | 81 | # Iteratively read lines from node and write to file 82 | outfile.writelines(infile) 83 | 84 | Most APIs accepting file objects should also be able to accept this. 85 | 86 | Block transfer can be used to effectively transfer large amounts of data if the 87 | server supports it. This is done through the file object interface:: 88 | 89 | FIRMWARE_PATH = '/path/to/firmware.bin' 90 | FILESIZE = os.path.getsize(FIRMWARE_PATH) 91 | 92 | with open(FIRMWARE_PATH, 'rb') as infile, 93 | node.sdo['Firmware'].open('wb', size=FILESIZE, block_transfer=True) as outfile: 94 | 95 | # Iteratively transfer data without having to read all into memory 96 | while True: 97 | data = infile.read(1024) 98 | if not data: 99 | break 100 | outfile.write(data) 101 | 102 | .. warning:: 103 | Block transfer is still in experimental stage! 104 | 105 | 106 | API 107 | --- 108 | 109 | .. autoclass:: canopen.sdo.SdoClient 110 | :members: 111 | 112 | .. py:attribute:: od 113 | 114 | The :class:`canopen.ObjectDictionary` associated with this object. 115 | 116 | .. describe:: c[index] 117 | 118 | Return the SDO object for the specified index (as int) or name 119 | (as string). 120 | 121 | .. describe:: iter(c) 122 | 123 | Return an iterator over the indexes from the object dictionary. 124 | 125 | .. describe:: index in c 126 | 127 | Return ``True`` if the index (as int) or name (as string) exists in 128 | the object dictionary. 129 | 130 | .. describe:: len(c) 131 | 132 | Return the number of indexes in the object dictionary. 133 | 134 | .. method:: values() 135 | 136 | Return a list of objects (records, arrays and variables). 137 | 138 | 139 | .. autoclass:: canopen.sdo.SdoServer 140 | :members: 141 | 142 | .. py:attribute:: od 143 | 144 | The :class:`canopen.ObjectDictionary` associated with this object. 145 | 146 | .. describe:: c[index] 147 | 148 | Return the SDO object for the specified index (as int) or name 149 | (as string). 150 | 151 | .. describe:: iter(c) 152 | 153 | Return an iterator over the indexes from the object dictionary. 154 | 155 | .. describe:: index in c 156 | 157 | Return ``True`` if the index (as int) or name (as string) exists in 158 | the object dictionary. 159 | 160 | .. describe:: len(c) 161 | 162 | Return the number of indexes in the object dictionary. 163 | 164 | .. method:: values() 165 | 166 | Return a list of objects (records, arrays and variables). 167 | 168 | 169 | .. autoclass:: canopen.sdo.SdoVariable 170 | :members: 171 | :inherited-members: 172 | 173 | .. py:attribute:: od 174 | 175 | The :class:`canopen.objectdictionary.ODVariable` associated with this object. 176 | 177 | 178 | .. autoclass:: canopen.sdo.SdoRecord 179 | :members: 180 | 181 | .. py:attribute:: od 182 | 183 | The :class:`canopen.objectdictionary.ODRecord` associated with this object. 184 | 185 | .. describe:: record[subindex] 186 | 187 | Return the :class:`canopen.sdo.SdoVariable` for the specified subindex 188 | (as int) or name (as string). 189 | 190 | .. describe:: iter(record) 191 | 192 | Return an iterator over the subindexes from the record. Only those with 193 | a matching object dictionary entry are considered. The "highest 194 | subindex" entry is officially not part of the data and thus skipped in 195 | the yielded values. 196 | 197 | .. describe:: subindex in record 198 | 199 | Return ``True`` if the subindex (as int) or name (as string) exists in 200 | the record. 201 | 202 | .. describe:: len(record) 203 | 204 | Return the number of subindexes in the record, not counting the "highest 205 | subindex" entry itself. Only those with a matching object dictionary 206 | entry are considered. 207 | 208 | .. method:: values() 209 | 210 | Return a list of :class:`canopen.sdo.SdoVariable` in the record. 211 | 212 | 213 | .. autoclass:: canopen.sdo.SdoArray 214 | :members: 215 | 216 | .. py:attribute:: od 217 | 218 | The :class:`canopen.objectdictionary.ODArray` associated with this object. 219 | 220 | .. describe:: array[subindex] 221 | 222 | Return the :class:`canopen.sdo.SdoVariable` for the specified subindex 223 | (as int) or name (as string). 224 | 225 | .. describe:: iter(array) 226 | 227 | Return an iterator over the subindexes from the array. 228 | This will make an SDO read operation on subindex 0 in order to get the 229 | actual length of the array. This "highest subindex" entry is officially 230 | not part of the data and thus skipped in the yielded values. 231 | 232 | .. describe:: subindex in array 233 | 234 | Return ``True`` if the subindex (as int) or name (as string) exists in 235 | the array. 236 | This will make an SDO read operation on subindex 0 in order to get the 237 | actual length of the array. 238 | 239 | .. describe:: len(array) 240 | 241 | Return the length of the array, not counting the "highest subindex" entry 242 | itself. 243 | This will make an SDO read operation on subindex 0. 244 | 245 | .. method:: values() 246 | 247 | Return a list of :class:`canopen.sdo.SdoVariable` in the array. 248 | This will make an SDO read operation on subindex 0 in order to get the 249 | actual length of the array. 250 | 251 | 252 | .. autoexception:: canopen.SdoAbortedError 253 | :show-inheritance: 254 | :members: 255 | 256 | .. autoexception:: canopen.SdoCommunicationError 257 | :show-inheritance: 258 | :members: 259 | -------------------------------------------------------------------------------- /doc/sync.rst: -------------------------------------------------------------------------------- 1 | Synchronization Object (SYNC) 2 | ============================= 3 | 4 | The Sync-Producer provides the synchronization-signal for the Sync-Consumer. 5 | When the Sync-Consumer receive the signal they start carrying out their 6 | synchronous tasks. 7 | 8 | In general, the fixing of the transmission time of synchronous PDO messages 9 | coupled with the periodicity of transmission of the Sync Object guarantees that 10 | sensor devices may arrange to sample process variables and that actuator devices 11 | may apply their actuation in a coordinated fashion. 12 | 13 | The identifier of the Sync Object is available at index 1005h. 14 | 15 | 16 | Examples 17 | -------- 18 | 19 | Use the :attr:`canopen.Network.sync` attribute to start and stop the SYNC 20 | message:: 21 | 22 | # Transmit every 10 ms 23 | network.sync.start(0.01) 24 | 25 | network.sync.stop() 26 | 27 | 28 | API 29 | --- 30 | 31 | .. autoclass:: canopen.sync.SyncProducer 32 | :members: 33 | -------------------------------------------------------------------------------- /doc/timestamp.rst: -------------------------------------------------------------------------------- 1 | Time Stamp Object (TIME) 2 | ======================== 3 | 4 | Usually the Time-Stamp object represents an absolute time in milliseconds after 5 | midnight and the number of days since January 1, 1984. This is a bit sequence of 6 | length 48 (6 bytes). 7 | 8 | 9 | API 10 | --- 11 | 12 | .. autoclass:: canopen.timestamp.TimeProducer 13 | :members: 14 | -------------------------------------------------------------------------------- /examples/simple_ds402_node.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import traceback 5 | 6 | import canopen 7 | 8 | 9 | try: 10 | 11 | # Start with creating a network representing one CAN bus 12 | network = canopen.Network() 13 | 14 | # Connect to the CAN bus 15 | network.connect(interface='kvaser', channel=0, bitrate=1000000) 16 | 17 | network.check() 18 | 19 | # Add some nodes with corresponding Object Dictionaries 20 | node = canopen.BaseNode402(35, 'eds/e35.eds') 21 | network.add_node(node) 22 | # network.add_node(34, 'eds/example34.eds') 23 | # node = network[34] 24 | 25 | # Reset network 26 | node.nmt.state = 'RESET COMMUNICATION' 27 | #node.nmt.state = 'RESET' 28 | node.nmt.wait_for_bootup(15) 29 | 30 | print(f'node state 1) = {node.nmt.state}') 31 | 32 | # Iterate over arrays or records 33 | error_log = node.sdo[0x1003] 34 | for error in error_log.values(): 35 | print(f"Error {error.raw} was found in the log") 36 | 37 | for node_id in network: 38 | print(network[node_id]) 39 | 40 | print(f'node state 2) = {node.nmt.state}') 41 | 42 | # Read a variable using SDO 43 | 44 | node.sdo[0x1006].raw = 1 45 | node.sdo[0x100c].raw = 100 46 | node.sdo[0x100d].raw = 3 47 | node.sdo[0x1014].raw = 163 48 | node.sdo[0x1003][0].raw = 0 49 | 50 | # Transmit SYNC every 100 ms 51 | network.sync.start(0.1) 52 | 53 | node.load_configuration() 54 | 55 | print(f'node state 3) = {node.nmt.state}') 56 | 57 | node.setup_402_state_machine() 58 | 59 | device_name = node.sdo[0x1008].raw 60 | vendor_id = node.sdo[0x1018][1].raw 61 | 62 | print(device_name) 63 | print(vendor_id) 64 | 65 | node.state = 'SWITCH ON DISABLED' 66 | 67 | print(f'node state 4) = {node.nmt.state}') 68 | 69 | # Read PDO configuration from node 70 | node.tpdo.read() 71 | # Re-map TxPDO1 72 | node.tpdo[1].clear() 73 | node.tpdo[1].add_variable('Statusword') 74 | node.tpdo[1].add_variable('Velocity actual value') 75 | node.tpdo[1].trans_type = 1 76 | node.tpdo[1].event_timer = 0 77 | node.tpdo[1].enabled = True 78 | # Save new PDO configuration to node 79 | node.tpdo.save() 80 | 81 | # publish the a value to the control word (in this case reset the fault at the motors) 82 | 83 | node.rpdo.read() 84 | node.rpdo[1]['Controlword'].raw = 0x80 85 | node.rpdo[1].transmit() 86 | node.rpdo[1]['Controlword'].raw = 0x81 87 | node.rpdo[1].transmit() 88 | 89 | node.state = 'READY TO SWITCH ON' 90 | node.state = 'SWITCHED ON' 91 | 92 | node.rpdo.export('database.dbc') 93 | 94 | # ----------------------------------------------------------------------------------------- 95 | 96 | print('Node booted up') 97 | 98 | timeout = time.time() + 15 99 | node.state = 'READY TO SWITCH ON' 100 | while node.state != 'READY TO SWITCH ON': 101 | if time.time() > timeout: 102 | raise Exception('Timeout when trying to change state') 103 | time.sleep(0.001) 104 | 105 | timeout = time.time() + 15 106 | node.state = 'SWITCHED ON' 107 | while node.state != 'SWITCHED ON': 108 | if time.time() > timeout: 109 | raise Exception('Timeout when trying to change state') 110 | time.sleep(0.001) 111 | 112 | timeout = time.time() + 15 113 | node.state = 'OPERATION ENABLED' 114 | while node.state != 'OPERATION ENABLED': 115 | if time.time() > timeout: 116 | raise Exception('Timeout when trying to change state') 117 | time.sleep(0.001) 118 | 119 | print(f'Node Status {node.powerstate_402.state}') 120 | 121 | # ----------------------------------------------------------------------------------------- 122 | node.nmt.start_node_guarding(0.01) 123 | while True: 124 | try: 125 | network.check() 126 | except Exception: 127 | break 128 | 129 | # Read a value from TxPDO1 130 | node.tpdo[1].wait_for_reception() 131 | speed = node.tpdo[1]['Velocity actual value'].phys 132 | 133 | # Read the state of the Statusword 134 | statusword = node.sdo[0x6041].raw 135 | 136 | print(f'statusword: {statusword}') 137 | print(f'VEL: {speed}') 138 | 139 | time.sleep(0.01) 140 | 141 | except KeyboardInterrupt: 142 | pass 143 | except Exception as e: 144 | exc_type, exc_obj, exc_tb = sys.exc_info() 145 | fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] 146 | print(exc_type, fname, exc_tb.tb_lineno) 147 | traceback.print_exc() 148 | finally: 149 | # Disconnect from CAN bus 150 | print('going to exit... stopping...') 151 | if network: 152 | 153 | for node_id in network: 154 | node = network[node_id] 155 | node.nmt.state = 'PRE-OPERATIONAL' 156 | node.nmt.stop_node_guarding() 157 | network.sync.stop() 158 | network.disconnect() 159 | 160 | -------------------------------------------------------------------------------- /makedeb: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | py=python3 4 | name='canopen' 5 | pkgname=$py-$name 6 | description="CANopen stack implementation" 7 | 8 | version=`git tag |grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' |sort | tail -1 ` 9 | maintainer=`git log -1 --pretty=format:'%an <%ae>'` 10 | arch=all 11 | 12 | echo version: $version 13 | echo maintainer: $maintainer 14 | 15 | cd $(dirname $0) 16 | package_dir=$PWD/build-deb/${pkgname}_$version-1_all 17 | fakeroot=$package_dir 18 | 19 | mkdir -p $fakeroot 20 | 21 | $py setup.py bdist_wheel >setup_py.log 22 | 23 | mkdir -p $fakeroot/usr/lib/$py/dist-packages/ 24 | unzip dist/*.whl -d $fakeroot/usr/lib/python3/dist-packages/ 25 | 26 | # deploy extra files 27 | #cp -r install/* $fakeroot/ 28 | 29 | mkdir $package_dir/DEBIAN 30 | 31 | cat > $package_dir/DEBIAN/control <=69", "wheel", "setuptools_scm>=8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "canopen" 7 | authors = [ 8 | {name = "Christian Sandberg", email = "christiansandberg@me.com"}, 9 | {name = "André Colomb", email = "src@andre.colomb.de"}, 10 | {name = "André Filipe Silva", email = "afsilva.work@gmail.com"}, 11 | ] 12 | description = "CANopen stack implementation" 13 | readme = "README.rst" 14 | requires-python = ">=3.8" 15 | license = {file = "LICENSE.txt"} 16 | classifiers = [ 17 | "Development Status :: 5 - Production/Stable", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Intended Audience :: Developers", 22 | "Topic :: Scientific/Engineering", 23 | ] 24 | dependencies = [ 25 | "python-can >= 3.0.0", 26 | ] 27 | dynamic = ["version"] 28 | 29 | [project.optional-dependencies] 30 | db_export = [ 31 | "canmatrix ~= 1.0", 32 | ] 33 | 34 | [project.urls] 35 | documentation = "https://canopen.readthedocs.io/en/stable/" 36 | repository = "https://github.com/canopen-python/canopen" 37 | 38 | [tool.setuptools] 39 | packages = ["canopen"] 40 | 41 | [tool.setuptools_scm] 42 | version_file = "canopen/_version.py" 43 | 44 | [tool.pytest.ini_options] 45 | testpaths = [ 46 | "test", 47 | ] 48 | filterwarnings = [ 49 | "ignore::DeprecationWarning", 50 | ] 51 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest~=8.3 2 | pytest-cov~=5.0 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canopen-python/canopen/e840449fbf9ddf810d14899279cb72e61a2447f0/test/__init__.py -------------------------------------------------------------------------------- /test/datatypes.eds: -------------------------------------------------------------------------------- 1 | [FileInfo] 2 | FileName=datatypes.eds 3 | FileVersion=1 4 | FileRevision=1 5 | EDSVersion=4.0 6 | Description=OD implementing the CANOpen datatype catalog 7 | CreationTime=07:31PM 8 | CreationDate=05-24-2024 9 | CreatedBy=objdictgen 10 | ModificationTime=07:31PM 11 | ModificationDate=05-24-2024 12 | ModifiedBy=objdictgen 13 | 14 | [DeviceInfo] 15 | VendorName=objdictgen 16 | VendorNumber=0x00000000 17 | ProductName=Alltypes 18 | ProductNumber=0x00000000 19 | RevisionNumber=0x00000000 20 | BaudRate_10=1 21 | BaudRate_20=1 22 | BaudRate_50=1 23 | BaudRate_125=1 24 | BaudRate_250=1 25 | BaudRate_500=1 26 | BaudRate_800=1 27 | BaudRate_1000=1 28 | SimpleBootUpMaster=1 29 | SimpleBootUpSlave=0 30 | Granularity=8 31 | DynamicChannelsSupported=0 32 | CompactPDO=0 33 | GroupMessaging=0 34 | NrOfRXPDO=0 35 | NrOfTXPDO=0 36 | LSS_Supported=0 37 | 38 | [DummyUsage] 39 | Dummy0001=0 40 | Dummy0002=1 41 | Dummy0003=1 42 | Dummy0004=1 43 | Dummy0005=1 44 | Dummy0006=1 45 | Dummy0007=1 46 | 47 | [Comments] 48 | Lines=0 49 | 50 | [MandatoryObjects] 51 | SupportedObjects=1 52 | 1=0x1018 53 | 54 | [1018] 55 | ParameterName=Identity 56 | ObjectType=0x9 57 | SubNumber=5 58 | 59 | [1018sub0] 60 | ParameterName=Number of Entries 61 | ObjectType=0x7 62 | DataType=0x0005 63 | AccessType=ro 64 | DefaultValue=4 65 | PDOMapping=0 66 | 67 | [1018sub1] 68 | ParameterName=Vendor ID 69 | ObjectType=0x7 70 | DataType=0x0007 71 | AccessType=ro 72 | DefaultValue=0 73 | PDOMapping=0 74 | 75 | [1018sub2] 76 | ParameterName=Product Code 77 | ObjectType=0x7 78 | DataType=0x0007 79 | AccessType=ro 80 | DefaultValue=0 81 | PDOMapping=0 82 | 83 | [1018sub3] 84 | ParameterName=Revision Number 85 | ObjectType=0x7 86 | DataType=0x0007 87 | AccessType=ro 88 | DefaultValue=0 89 | PDOMapping=0 90 | 91 | [1018sub4] 92 | ParameterName=Serial Number 93 | ObjectType=0x7 94 | DataType=0x0007 95 | AccessType=ro 96 | DefaultValue=0 97 | PDOMapping=0 98 | 99 | [OptionalObjects] 100 | SupportedObjects=0 101 | 102 | [ManufacturerObjects] 103 | SupportedObjects=23 104 | 1=0x2001 105 | 2=0x2002 106 | 3=0x2003 107 | 4=0x2004 108 | 5=0x2005 109 | 6=0x2006 110 | 7=0x2007 111 | 8=0x2008 112 | 9=0x2009 113 | 10=0x200A 114 | 11=0x200B 115 | 12=0x200F 116 | 13=0x2010 117 | 14=0x2011 118 | 15=0x2012 119 | 16=0x2013 120 | 17=0x2014 121 | 18=0x2015 122 | 19=0x2016 123 | 20=0x2018 124 | 21=0x2019 125 | 22=0x201A 126 | 23=0x201B 127 | 128 | [2001] 129 | ParameterName=BOOLEAN 130 | ObjectType=0x7 131 | DataType=0x0001 132 | AccessType=rw 133 | DefaultValue=0 134 | PDOMapping=1 135 | 136 | [2002] 137 | ParameterName=INTEGER8 138 | ObjectType=0x7 139 | DataType=0x0002 140 | AccessType=rw 141 | DefaultValue=12 142 | PDOMapping=1 143 | 144 | [2003] 145 | ParameterName=INTEGER16 146 | ObjectType=0x7 147 | DataType=0x0003 148 | AccessType=rw 149 | DefaultValue=34 150 | PDOMapping=1 151 | 152 | [2004] 153 | ParameterName=INTEGER32 154 | ObjectType=0x7 155 | DataType=0x0004 156 | AccessType=rw 157 | DefaultValue=45 158 | PDOMapping=1 159 | 160 | [2005] 161 | ParameterName=UNSIGNED8 162 | ObjectType=0x7 163 | DataType=0x0005 164 | AccessType=rw 165 | DefaultValue=56 166 | PDOMapping=1 167 | 168 | [2006] 169 | ParameterName=UNSIGNED16 170 | ObjectType=0x7 171 | DataType=0x0006 172 | AccessType=rw 173 | DefaultValue=8198 174 | PDOMapping=1 175 | 176 | [2007] 177 | ParameterName=UNSIGNED32 178 | ObjectType=0x7 179 | DataType=0x0007 180 | AccessType=rw 181 | DefaultValue=537337864 182 | PDOMapping=1 183 | 184 | [2008] 185 | ParameterName=REAL32 186 | ObjectType=0x7 187 | DataType=0x0008 188 | AccessType=rw 189 | DefaultValue=1.2 190 | PDOMapping=1 191 | 192 | [2009] 193 | ParameterName=VISIBLE_STRING 194 | ObjectType=0x7 195 | DataType=0x0009 196 | AccessType=rw 197 | DefaultValue=ABCD 198 | PDOMapping=1 199 | 200 | [200A] 201 | ParameterName=OCTET_STRING 202 | ObjectType=0x7 203 | DataType=0x000A 204 | AccessType=rw 205 | DefaultValue=ABCD 206 | PDOMapping=1 207 | 208 | [200B] 209 | ParameterName=UNICODE_STRING 210 | ObjectType=0x7 211 | DataType=0x000B 212 | AccessType=rw 213 | DefaultValue=abc✓ 214 | PDOMapping=1 215 | 216 | [200F] 217 | ParameterName=DOMAIN 218 | ObjectType=0x7 219 | DataType=0x000F 220 | AccessType=rw 221 | DefaultValue=@ABCD 222 | PDOMapping=1 223 | 224 | [2010] 225 | ParameterName=INTEGER24 226 | ObjectType=0x7 227 | DataType=0x0010 228 | AccessType=rw 229 | DefaultValue=-1 230 | PDOMapping=1 231 | 232 | [2011] 233 | ParameterName=REAL64 234 | ObjectType=0x7 235 | DataType=0x0011 236 | AccessType=rw 237 | DefaultValue=1.6 238 | PDOMapping=1 239 | 240 | [2012] 241 | ParameterName=INTEGER40 242 | ObjectType=0x7 243 | DataType=0x0012 244 | AccessType=rw 245 | DefaultValue=-40 246 | PDOMapping=1 247 | 248 | [2013] 249 | ParameterName=INTEGER48 250 | ObjectType=0x7 251 | DataType=0x0013 252 | AccessType=rw 253 | DefaultValue=-48 254 | PDOMapping=1 255 | 256 | [2014] 257 | ParameterName=INTEGER56 258 | ObjectType=0x7 259 | DataType=0x0014 260 | AccessType=rw 261 | DefaultValue=-56 262 | PDOMapping=1 263 | 264 | [2015] 265 | ParameterName=INTEGER64 266 | ObjectType=0x7 267 | DataType=0x0015 268 | AccessType=rw 269 | DefaultValue=-64 270 | PDOMapping=1 271 | 272 | [2016] 273 | ParameterName=UNSIGNED24 274 | ObjectType=0x7 275 | DataType=0x0016 276 | AccessType=rw 277 | DefaultValue=24 278 | PDOMapping=1 279 | 280 | [2018] 281 | ParameterName=UNSIGNED40 282 | ObjectType=0x7 283 | DataType=0x0018 284 | AccessType=rw 285 | DefaultValue=40 286 | PDOMapping=1 287 | 288 | [2019] 289 | ParameterName=UNSIGNED48 290 | ObjectType=0x7 291 | DataType=0x0019 292 | AccessType=rw 293 | DefaultValue=48 294 | PDOMapping=1 295 | 296 | [201A] 297 | ParameterName=UNSIGNED56 298 | ObjectType=0x7 299 | DataType=0x001A 300 | AccessType=rw 301 | DefaultValue=56 302 | PDOMapping=1 303 | 304 | [201B] 305 | ParameterName=UNSIGNED64 306 | ObjectType=0x7 307 | DataType=0x001B 308 | AccessType=rw 309 | DefaultValue=64 310 | PDOMapping=1 311 | -------------------------------------------------------------------------------- /test/test_emcy.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | import unittest 4 | from contextlib import contextmanager 5 | 6 | import can 7 | 8 | import canopen 9 | from canopen.emcy import EmcyError 10 | 11 | 12 | TIMEOUT = 0.1 13 | 14 | 15 | class TestEmcy(unittest.TestCase): 16 | def setUp(self): 17 | self.emcy = canopen.emcy.EmcyConsumer() 18 | 19 | def check_error(self, err, code, reg, data, ts): 20 | self.assertIsInstance(err, EmcyError) 21 | self.assertIsInstance(err, Exception) 22 | self.assertEqual(err.code, code) 23 | self.assertEqual(err.register, reg) 24 | self.assertEqual(err.data, data) 25 | self.assertAlmostEqual(err.timestamp, ts) 26 | 27 | def test_emcy_consumer_on_emcy(self): 28 | # Make sure multiple callbacks receive the same information. 29 | acc1 = [] 30 | acc2 = [] 31 | self.emcy.add_callback(lambda err: acc1.append(err)) 32 | self.emcy.add_callback(lambda err: acc2.append(err)) 33 | 34 | # Dispatch an EMCY datagram. 35 | self.emcy.on_emcy(0x81, b'\x01\x20\x02\x00\x01\x02\x03\x04', 1000) 36 | 37 | self.assertEqual(len(self.emcy.log), 1) 38 | self.assertEqual(len(self.emcy.active), 1) 39 | 40 | error = self.emcy.log[0] 41 | self.assertEqual(self.emcy.active[0], error) 42 | for err in error, acc1[0], acc2[0]: 43 | self.check_error( 44 | error, code=0x2001, reg=0x02, 45 | data=bytes([0, 1, 2, 3, 4]), ts=1000, 46 | ) 47 | 48 | # Dispatch a new EMCY datagram. 49 | self.emcy.on_emcy(0x81, b'\x10\x90\x01\x04\x03\x02\x01\x00', 2000) 50 | self.assertEqual(len(self.emcy.log), 2) 51 | self.assertEqual(len(self.emcy.active), 2) 52 | 53 | error = self.emcy.log[1] 54 | self.assertEqual(self.emcy.active[1], error) 55 | for err in error, acc1[1], acc2[1]: 56 | self.check_error( 57 | error, code=0x9010, reg=0x01, 58 | data=bytes([4, 3, 2, 1, 0]), ts=2000, 59 | ) 60 | 61 | # Dispatch an EMCY reset. 62 | self.emcy.on_emcy(0x81, b'\x00\x00\x00\x00\x00\x00\x00\x00', 2000) 63 | self.assertEqual(len(self.emcy.log), 3) 64 | self.assertEqual(len(self.emcy.active), 0) 65 | 66 | def test_emcy_consumer_reset(self): 67 | self.emcy.on_emcy(0x81, b'\x01\x20\x02\x00\x01\x02\x03\x04', 1000) 68 | self.emcy.on_emcy(0x81, b'\x10\x90\x01\x04\x03\x02\x01\x00', 2000) 69 | self.assertEqual(len(self.emcy.log), 2) 70 | self.assertEqual(len(self.emcy.active), 2) 71 | 72 | self.emcy.reset() 73 | self.assertEqual(len(self.emcy.log), 0) 74 | self.assertEqual(len(self.emcy.active), 0) 75 | 76 | def test_emcy_consumer_wait(self): 77 | PAUSE = TIMEOUT / 2 78 | 79 | def push_err(): 80 | self.emcy.on_emcy(0x81, b'\x01\x20\x01\x01\x02\x03\x04\x05', 100) 81 | 82 | def check_err(err): 83 | self.assertIsNotNone(err) 84 | self.check_error( 85 | err, code=0x2001, reg=1, 86 | data=bytes([1, 2, 3, 4, 5]), ts=100, 87 | ) 88 | 89 | @contextmanager 90 | def timer(func): 91 | t = threading.Timer(PAUSE, func) 92 | try: 93 | yield t 94 | finally: 95 | t.join(TIMEOUT) 96 | 97 | # Check unfiltered wait, on timeout. 98 | self.assertIsNone(self.emcy.wait(timeout=TIMEOUT)) 99 | 100 | # Check unfiltered wait, on success. 101 | with timer(push_err) as t: 102 | with self.assertLogs(level=logging.INFO): 103 | t.start() 104 | err = self.emcy.wait(timeout=TIMEOUT) 105 | check_err(err) 106 | 107 | # Check filtered wait, on success. 108 | with timer(push_err) as t: 109 | with self.assertLogs(level=logging.INFO): 110 | t.start() 111 | err = self.emcy.wait(0x2001, TIMEOUT) 112 | check_err(err) 113 | 114 | # Check filtered wait, on timeout. 115 | with timer(push_err) as t: 116 | t.start() 117 | self.assertIsNone(self.emcy.wait(0x9000, TIMEOUT)) 118 | 119 | def push_reset(): 120 | self.emcy.on_emcy(0x81, b'\x00\x00\x00\x00\x00\x00\x00\x00', 100) 121 | 122 | with timer(push_reset) as t: 123 | t.start() 124 | self.assertIsNone(self.emcy.wait(0x9000, TIMEOUT)) 125 | 126 | 127 | class TestEmcyError(unittest.TestCase): 128 | def test_emcy_error(self): 129 | error = EmcyError(0x2001, 0x02, b'\x00\x01\x02\x03\x04', 1000) 130 | self.assertEqual(error.code, 0x2001) 131 | self.assertEqual(error.data, b'\x00\x01\x02\x03\x04') 132 | self.assertEqual(error.register, 2) 133 | self.assertEqual(error.timestamp, 1000) 134 | 135 | def test_emcy_str(self): 136 | def check(code, expected): 137 | err = EmcyError(code, 1, b'', 1000) 138 | actual = str(err) 139 | self.assertEqual(actual, expected) 140 | 141 | check(0x2001, "Code 0x2001, Current") 142 | check(0x3abc, "Code 0x3ABC, Voltage") 143 | check(0x0234, "Code 0x0234") 144 | check(0xbeef, "Code 0xBEEF") 145 | 146 | def test_emcy_get_desc(self): 147 | def check(code, expected): 148 | err = EmcyError(code, 1, b'', 1000) 149 | actual = err.get_desc() 150 | self.assertEqual(actual, expected) 151 | 152 | check(0x0000, "Error Reset / No Error") 153 | check(0x00ff, "Error Reset / No Error") 154 | check(0x0100, "") 155 | check(0x1000, "Generic Error") 156 | check(0x10ff, "Generic Error") 157 | check(0x1100, "") 158 | check(0x2000, "Current") 159 | check(0x2fff, "Current") 160 | check(0x3000, "Voltage") 161 | check(0x3fff, "Voltage") 162 | check(0x4000, "Temperature") 163 | check(0x4fff, "Temperature") 164 | check(0x5000, "Device Hardware") 165 | check(0x50ff, "Device Hardware") 166 | check(0x5100, "") 167 | check(0x6000, "Device Software") 168 | check(0x6fff, "Device Software") 169 | check(0x7000, "Additional Modules") 170 | check(0x70ff, "Additional Modules") 171 | check(0x7100, "") 172 | check(0x8000, "Monitoring") 173 | check(0x8fff, "Monitoring") 174 | check(0x9000, "External Error") 175 | check(0x90ff, "External Error") 176 | check(0x9100, "") 177 | check(0xf000, "Additional Functions") 178 | check(0xf0ff, "Additional Functions") 179 | check(0xf100, "") 180 | check(0xff00, "Device Specific") 181 | check(0xffff, "Device Specific") 182 | 183 | 184 | class TestEmcyProducer(unittest.TestCase): 185 | def setUp(self): 186 | self.txbus = can.Bus(interface="virtual") 187 | self.rxbus = can.Bus(interface="virtual") 188 | self.net = canopen.Network(self.txbus) 189 | self.net.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 190 | self.net.connect() 191 | self.emcy = canopen.emcy.EmcyProducer(0x80 + 1) 192 | self.emcy.network = self.net 193 | 194 | def tearDown(self): 195 | self.net.disconnect() 196 | self.txbus.shutdown() 197 | self.rxbus.shutdown() 198 | 199 | def check_response(self, expected): 200 | msg = self.rxbus.recv(TIMEOUT) 201 | self.assertIsNotNone(msg) 202 | actual = msg.data 203 | self.assertEqual(actual, expected) 204 | 205 | def test_emcy_producer_send(self): 206 | def check(*args, res): 207 | self.emcy.send(*args) 208 | self.check_response(res) 209 | 210 | check(0x2001, res=b'\x01\x20\x00\x00\x00\x00\x00\x00') 211 | check(0x2001, 0x2, res=b'\x01\x20\x02\x00\x00\x00\x00\x00') 212 | check(0x2001, 0x2, b'\x2a', res=b'\x01\x20\x02\x2a\x00\x00\x00\x00') 213 | 214 | def test_emcy_producer_reset(self): 215 | def check(*args, res): 216 | self.emcy.reset(*args) 217 | self.check_response(res) 218 | 219 | check(res=b'\x00\x00\x00\x00\x00\x00\x00\x00') 220 | check(3, res=b'\x00\x00\x03\x00\x00\x00\x00\x00') 221 | check(3, b"\xaa\xbb", res=b'\x00\x00\x03\xaa\xbb\x00\x00\x00') 222 | 223 | 224 | if __name__ == "__main__": 225 | unittest.main() 226 | -------------------------------------------------------------------------------- /test/test_local.py: -------------------------------------------------------------------------------- 1 | import time 2 | import unittest 3 | 4 | import canopen 5 | 6 | from .util import SAMPLE_EDS 7 | 8 | 9 | class TestSDO(unittest.TestCase): 10 | """ 11 | Test SDO client and server against each other. 12 | """ 13 | 14 | @classmethod 15 | def setUpClass(cls): 16 | cls.network1 = canopen.Network() 17 | cls.network1.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 18 | cls.network1.connect("test", interface="virtual") 19 | cls.remote_node = cls.network1.add_node(2, SAMPLE_EDS) 20 | 21 | cls.network2 = canopen.Network() 22 | cls.network2.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 23 | cls.network2.connect("test", interface="virtual") 24 | cls.local_node = cls.network2.create_node(2, SAMPLE_EDS) 25 | 26 | cls.remote_node2 = cls.network1.add_node(3, SAMPLE_EDS) 27 | 28 | cls.local_node2 = cls.network2.create_node(3, SAMPLE_EDS) 29 | 30 | @classmethod 31 | def tearDownClass(cls): 32 | cls.network1.disconnect() 33 | cls.network2.disconnect() 34 | 35 | def test_expedited_upload(self): 36 | self.local_node.sdo[0x1400][1].raw = 0x99 37 | vendor_id = self.remote_node.sdo[0x1400][1].raw 38 | self.assertEqual(vendor_id, 0x99) 39 | 40 | def test_block_upload_switch_to_expedite_upload(self): 41 | with self.assertRaises(canopen.SdoCommunicationError) as context: 42 | with self.remote_node.sdo[0x1008].open('r', block_transfer=True) as fp: 43 | pass 44 | # We get this since the sdo client don't support the switch 45 | # from block upload to expedite upload 46 | self.assertEqual("Unexpected response 0x41", str(context.exception)) 47 | 48 | def test_block_download_not_supported(self): 49 | data = b"TEST DEVICE" 50 | with self.assertRaises(canopen.SdoAbortedError) as context: 51 | with self.remote_node.sdo[0x1008].open('wb', 52 | size=len(data), 53 | block_transfer=True) as fp: 54 | pass 55 | self.assertEqual(context.exception.code, 0x05040001) 56 | 57 | def test_expedited_upload_default_value_visible_string(self): 58 | device_name = self.remote_node.sdo["Manufacturer device name"].raw 59 | self.assertEqual(device_name, "TEST DEVICE") 60 | 61 | def test_expedited_upload_default_value_real(self): 62 | sampling_rate = self.remote_node.sdo["Sensor Sampling Rate (Hz)"].raw 63 | self.assertAlmostEqual(sampling_rate, 5.2, places=2) 64 | 65 | def test_segmented_upload(self): 66 | self.local_node.sdo["Manufacturer device name"].raw = "Some cool device" 67 | device_name = self.remote_node.sdo["Manufacturer device name"].data 68 | self.assertEqual(device_name, b"Some cool device") 69 | 70 | def test_expedited_download(self): 71 | self.remote_node.sdo[0x2004].raw = 0xfeff 72 | value = self.local_node.sdo[0x2004].raw 73 | self.assertEqual(value, 0xfeff) 74 | 75 | def test_expedited_download_wrong_datatype(self): 76 | # Try to write 32 bit in integer16 type 77 | with self.assertRaises(canopen.SdoAbortedError) as error: 78 | self.remote_node.sdo.download(0x2001, 0x0, bytes([10, 10, 10, 10])) 79 | self.assertEqual(error.exception.code, 0x06070010) 80 | # Try to write normal 16 bit word, should be ok 81 | self.remote_node.sdo.download(0x2001, 0x0, bytes([10, 10])) 82 | value = self.remote_node.sdo.upload(0x2001, 0x0) 83 | self.assertEqual(value, bytes([10, 10])) 84 | 85 | def test_segmented_download(self): 86 | self.remote_node.sdo[0x2000].raw = "Another cool device" 87 | value = self.local_node.sdo[0x2000].data 88 | self.assertEqual(value, b"Another cool device") 89 | 90 | def test_slave_send_heartbeat(self): 91 | # Setting the heartbeat time should trigger heartbeating 92 | # to start 93 | self.remote_node.sdo["Producer heartbeat time"].raw = 100 94 | state = self.remote_node.nmt.wait_for_heartbeat() 95 | self.local_node.nmt.stop_heartbeat() 96 | # The NMT master will change the state INITIALISING (0) 97 | # to PRE-OPERATIONAL (127) 98 | self.assertEqual(state, 'PRE-OPERATIONAL') 99 | 100 | def test_nmt_state_initializing_to_preoper(self): 101 | # Initialize the heartbeat timer 102 | self.local_node.sdo["Producer heartbeat time"].raw = 100 103 | self.local_node.nmt.stop_heartbeat() 104 | # This transition shall start the heartbeating 105 | self.local_node.nmt.state = 'INITIALISING' 106 | self.local_node.nmt.state = 'PRE-OPERATIONAL' 107 | state = self.remote_node.nmt.wait_for_heartbeat() 108 | self.local_node.nmt.stop_heartbeat() 109 | self.assertEqual(state, 'PRE-OPERATIONAL') 110 | 111 | def test_receive_abort_request(self): 112 | self.remote_node.sdo.abort(0x05040003) 113 | # Line below is just so that we are sure the client have received the abort 114 | # before we do the check 115 | time.sleep(0.1) 116 | self.assertEqual(self.local_node.sdo.last_received_error, 0x05040003) 117 | 118 | def test_start_remote_node(self): 119 | self.remote_node.nmt.state = 'OPERATIONAL' 120 | # Line below is just so that we are sure the client have received the command 121 | # before we do the check 122 | time.sleep(0.1) 123 | slave_state = self.local_node.nmt.state 124 | self.assertEqual(slave_state, 'OPERATIONAL') 125 | 126 | def test_two_nodes_on_the_bus(self): 127 | self.local_node.sdo["Manufacturer device name"].raw = "Some cool device" 128 | device_name = self.remote_node.sdo["Manufacturer device name"].data 129 | self.assertEqual(device_name, b"Some cool device") 130 | 131 | self.local_node2.sdo["Manufacturer device name"].raw = "Some cool device2" 132 | device_name = self.remote_node2.sdo["Manufacturer device name"].data 133 | self.assertEqual(device_name, b"Some cool device2") 134 | 135 | def test_abort(self): 136 | with self.assertRaises(canopen.SdoAbortedError) as cm: 137 | _ = self.remote_node.sdo.upload(0x1234, 0) 138 | # Should be Object does not exist 139 | self.assertEqual(cm.exception.code, 0x06020000) 140 | 141 | with self.assertRaises(canopen.SdoAbortedError) as cm: 142 | _ = self.remote_node.sdo.upload(0x1018, 100) 143 | # Should be Subindex does not exist 144 | self.assertEqual(cm.exception.code, 0x06090011) 145 | 146 | with self.assertRaises(canopen.SdoAbortedError) as cm: 147 | _ = self.remote_node.sdo[0x1001].data 148 | # Should be Resource not available 149 | self.assertEqual(cm.exception.code, 0x060A0023) 150 | 151 | def _some_read_callback(self, **kwargs): 152 | self._kwargs = kwargs 153 | if kwargs["index"] == 0x1003: 154 | return 0x0201 155 | 156 | def _some_write_callback(self, **kwargs): 157 | self._kwargs = kwargs 158 | 159 | def test_callbacks(self): 160 | self.local_node.add_read_callback(self._some_read_callback) 161 | self.local_node.add_write_callback(self._some_write_callback) 162 | 163 | data = self.remote_node.sdo.upload(0x1003, 5) 164 | self.assertEqual(data, b"\x01\x02\x00\x00") 165 | self.assertEqual(self._kwargs["index"], 0x1003) 166 | self.assertEqual(self._kwargs["subindex"], 5) 167 | 168 | self.remote_node.sdo.download(0x1017, 0, b"\x03\x04") 169 | self.assertEqual(self._kwargs["index"], 0x1017) 170 | self.assertEqual(self._kwargs["subindex"], 0) 171 | self.assertEqual(self._kwargs["data"], b"\x03\x04") 172 | 173 | 174 | class TestPDO(unittest.TestCase): 175 | """ 176 | Test PDO slave. 177 | """ 178 | 179 | @classmethod 180 | def setUpClass(cls): 181 | cls.network1 = canopen.Network() 182 | cls.network1.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 183 | cls.network1.connect("test", interface="virtual") 184 | cls.remote_node = cls.network1.add_node(2, SAMPLE_EDS) 185 | 186 | cls.network2 = canopen.Network() 187 | cls.network2.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 188 | cls.network2.connect("test", interface="virtual") 189 | cls.local_node = cls.network2.create_node(2, SAMPLE_EDS) 190 | 191 | @classmethod 192 | def tearDownClass(cls): 193 | cls.network1.disconnect() 194 | cls.network2.disconnect() 195 | 196 | def test_read(self): 197 | # TODO: Do some more checks here. Currently it only tests that they 198 | # can be called without raising an error. 199 | self.remote_node.pdo.read() 200 | self.local_node.pdo.read() 201 | 202 | def test_save(self): 203 | # TODO: Do some more checks here. Currently it only tests that they 204 | # can be called without raising an error. 205 | self.remote_node.pdo.save() 206 | self.local_node.pdo.save() 207 | 208 | 209 | if __name__ == "__main__": 210 | unittest.main() 211 | -------------------------------------------------------------------------------- /test/test_network.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | import unittest 4 | 5 | import can 6 | 7 | import canopen 8 | 9 | from .util import SAMPLE_EDS 10 | 11 | 12 | class TestNetwork(unittest.TestCase): 13 | 14 | def setUp(self): 15 | self.network = canopen.Network() 16 | self.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 17 | 18 | def test_network_add_node(self): 19 | # Add using str. 20 | with self.assertLogs(): 21 | node = self.network.add_node(2, SAMPLE_EDS) 22 | self.assertEqual(self.network[2], node) 23 | self.assertEqual(node.id, 2) 24 | self.assertIsInstance(node, canopen.RemoteNode) 25 | 26 | # Add using OD. 27 | node = self.network.add_node(3, self.network[2].object_dictionary) 28 | self.assertEqual(self.network[3], node) 29 | self.assertEqual(node.id, 3) 30 | self.assertIsInstance(node, canopen.RemoteNode) 31 | 32 | # Add using RemoteNode. 33 | with self.assertLogs(): 34 | node = canopen.RemoteNode(4, SAMPLE_EDS) 35 | self.network.add_node(node) 36 | self.assertEqual(self.network[4], node) 37 | self.assertEqual(node.id, 4) 38 | self.assertIsInstance(node, canopen.RemoteNode) 39 | 40 | # Add using LocalNode. 41 | with self.assertLogs(): 42 | node = canopen.LocalNode(5, SAMPLE_EDS) 43 | self.network.add_node(node) 44 | self.assertEqual(self.network[5], node) 45 | self.assertEqual(node.id, 5) 46 | self.assertIsInstance(node, canopen.LocalNode) 47 | 48 | # Verify that we've got the correct number of nodes. 49 | self.assertEqual(len(self.network), 4) 50 | 51 | def test_network_add_node_upload_eds(self): 52 | # Will err because we're not connected to a real network. 53 | with self.assertLogs(level=logging.ERROR): 54 | self.network.add_node(2, SAMPLE_EDS, upload_eds=True) 55 | 56 | def test_network_create_node(self): 57 | with self.assertLogs(): 58 | self.network.create_node(2, SAMPLE_EDS) 59 | self.network.create_node(3, SAMPLE_EDS) 60 | node = canopen.RemoteNode(4, SAMPLE_EDS) 61 | self.network.create_node(node) 62 | self.assertIsInstance(self.network[2], canopen.LocalNode) 63 | self.assertIsInstance(self.network[3], canopen.LocalNode) 64 | self.assertIsInstance(self.network[4], canopen.RemoteNode) 65 | 66 | def test_network_check(self): 67 | self.network.connect(interface="virtual") 68 | 69 | def cleanup(): 70 | # We must clear the fake exception installed below, since 71 | # .disconnect() implicitly calls .check() during test tear down. 72 | self.network.notifier.exception = None 73 | self.network.disconnect() 74 | 75 | self.addCleanup(cleanup) 76 | self.assertIsNone(self.network.check()) 77 | 78 | class Custom(Exception): 79 | pass 80 | 81 | self.network.notifier.exception = Custom("fake") 82 | with self.assertRaisesRegex(Custom, "fake"): 83 | with self.assertLogs(level=logging.ERROR): 84 | self.network.check() 85 | with self.assertRaisesRegex(Custom, "fake"): 86 | with self.assertLogs(level=logging.ERROR): 87 | self.network.disconnect() 88 | 89 | def test_network_notify(self): 90 | with self.assertLogs(): 91 | self.network.add_node(2, SAMPLE_EDS) 92 | node = self.network[2] 93 | self.network.notify(0x82, b'\x01\x20\x02\x00\x01\x02\x03\x04', 1473418396.0) 94 | self.assertEqual(len(node.emcy.active), 1) 95 | self.network.notify(0x702, b'\x05', 1473418396.0) 96 | self.assertEqual(node.nmt.state, 'OPERATIONAL') 97 | self.assertListEqual(self.network.scanner.nodes, [2]) 98 | 99 | def test_network_send_message(self): 100 | bus = can.interface.Bus(interface="virtual") 101 | self.addCleanup(bus.shutdown) 102 | 103 | self.network.connect(interface="virtual") 104 | self.addCleanup(self.network.disconnect) 105 | 106 | # Send standard ID 107 | self.network.send_message(0x123, [1, 2, 3, 4, 5, 6, 7, 8]) 108 | msg = bus.recv(1) 109 | self.assertIsNotNone(msg) 110 | self.assertEqual(msg.arbitration_id, 0x123) 111 | self.assertFalse(msg.is_extended_id) 112 | self.assertSequenceEqual(msg.data, [1, 2, 3, 4, 5, 6, 7, 8]) 113 | 114 | # Send extended ID 115 | self.network.send_message(0x12345, []) 116 | msg = bus.recv(1) 117 | self.assertIsNotNone(msg) 118 | self.assertEqual(msg.arbitration_id, 0x12345) 119 | self.assertTrue(msg.is_extended_id) 120 | 121 | def test_network_subscribe_unsubscribe(self): 122 | N_HOOKS = 3 123 | accumulators = [] * N_HOOKS 124 | 125 | self.network.connect(interface="virtual", receive_own_messages=True) 126 | self.addCleanup(self.network.disconnect) 127 | 128 | for i in range(N_HOOKS): 129 | accumulators.append([]) 130 | def hook(*args, i=i): 131 | accumulators[i].append(args) 132 | self.network.subscribe(i, hook) 133 | 134 | self.network.notify(0, bytes([1, 2, 3]), 1000) 135 | self.network.notify(1, bytes([2, 3, 4]), 1001) 136 | self.network.notify(1, bytes([3, 4, 5]), 1002) 137 | self.network.notify(2, bytes([4, 5, 6]), 1003) 138 | 139 | self.assertEqual(accumulators[0], [(0, bytes([1, 2, 3]), 1000)]) 140 | self.assertEqual(accumulators[1], [ 141 | (1, bytes([2, 3, 4]), 1001), 142 | (1, bytes([3, 4, 5]), 1002), 143 | ]) 144 | self.assertEqual(accumulators[2], [(2, bytes([4, 5, 6]), 1003)]) 145 | 146 | self.network.unsubscribe(0) 147 | self.network.notify(0, bytes([7, 7, 7]), 1004) 148 | # Verify that no new data was added to the accumulator. 149 | self.assertEqual(accumulators[0], [(0, bytes([1, 2, 3]), 1000)]) 150 | 151 | def test_network_subscribe_multiple(self): 152 | N_HOOKS = 3 153 | self.network.connect(interface="virtual", receive_own_messages=True) 154 | self.addCleanup(self.network.disconnect) 155 | 156 | accumulators = [] 157 | hooks = [] 158 | for i in range(N_HOOKS): 159 | accumulators.append([]) 160 | def hook(*args, i=i): 161 | accumulators[i].append(args) 162 | hooks.append(hook) 163 | self.network.subscribe(0x20, hook) 164 | 165 | self.network.notify(0xaa, bytes([1, 1, 1]), 2000) 166 | self.network.notify(0x20, bytes([2, 3, 4]), 2001) 167 | self.network.notify(0xbb, bytes([2, 2, 2]), 2002) 168 | self.network.notify(0x20, bytes([3, 4, 5]), 2003) 169 | self.network.notify(0xcc, bytes([3, 3, 3]), 2004) 170 | 171 | BATCH1 = [ 172 | (0x20, bytes([2, 3, 4]), 2001), 173 | (0x20, bytes([3, 4, 5]), 2003), 174 | ] 175 | for n, acc in enumerate(accumulators): 176 | with self.subTest(hook=n): 177 | self.assertEqual(acc, BATCH1) 178 | 179 | # Unsubscribe the second hook; dispatch a new message. 180 | self.network.unsubscribe(0x20, hooks[1]) 181 | 182 | BATCH2 = 0x20, bytes([4, 5, 6]), 2005 183 | self.network.notify(*BATCH2) 184 | self.assertEqual(accumulators[0], BATCH1 + [BATCH2]) 185 | self.assertEqual(accumulators[1], BATCH1) 186 | self.assertEqual(accumulators[2], BATCH1 + [BATCH2]) 187 | 188 | # Unsubscribe the first hook; dispatch yet another message. 189 | self.network.unsubscribe(0x20, hooks[0]) 190 | 191 | BATCH3 = 0x20, bytes([5, 6, 7]), 2006 192 | self.network.notify(*BATCH3) 193 | self.assertEqual(accumulators[0], BATCH1 + [BATCH2]) 194 | self.assertEqual(accumulators[1], BATCH1) 195 | self.assertEqual(accumulators[2], BATCH1 + [BATCH2] + [BATCH3]) 196 | 197 | # Unsubscribe the rest (only one remaining); dispatch a new message. 198 | self.network.unsubscribe(0x20) 199 | self.network.notify(0x20, bytes([7, 7, 7]), 2007) 200 | self.assertEqual(accumulators[0], BATCH1 + [BATCH2]) 201 | self.assertEqual(accumulators[1], BATCH1) 202 | self.assertEqual(accumulators[2], BATCH1 + [BATCH2] + [BATCH3]) 203 | 204 | def test_network_context_manager(self): 205 | with self.network.connect(interface="virtual"): 206 | pass 207 | with self.assertRaisesRegex(RuntimeError, "Not connected"): 208 | self.network.send_message(0, []) 209 | 210 | def test_network_item_access(self): 211 | with self.assertLogs(): 212 | self.network.add_node(2, SAMPLE_EDS) 213 | self.network.add_node(3, SAMPLE_EDS) 214 | self.assertEqual([2, 3], [node for node in self.network]) 215 | 216 | # Check __delitem__. 217 | del self.network[2] 218 | self.assertEqual([3], [node for node in self.network]) 219 | with self.assertRaises(KeyError): 220 | del self.network[2] 221 | 222 | # Check __setitem__. 223 | old = self.network[3] 224 | with self.assertLogs(): 225 | new = canopen.Node(3, SAMPLE_EDS) 226 | self.network[3] = new 227 | 228 | # Check __getitem__. 229 | self.assertNotEqual(self.network[3], old) 230 | self.assertEqual([3], [node for node in self.network]) 231 | 232 | def test_network_send_periodic(self): 233 | DATA1 = bytes([1, 2, 3]) 234 | DATA2 = bytes([4, 5, 6]) 235 | COB_ID = 0x123 236 | PERIOD = 0.01 237 | TIMEOUT = PERIOD * 10 238 | self.network.connect(interface="virtual") 239 | self.addCleanup(self.network.disconnect) 240 | 241 | bus = can.Bus(interface="virtual") 242 | self.addCleanup(bus.shutdown) 243 | 244 | acc = [] 245 | 246 | task = self.network.send_periodic(COB_ID, DATA1, PERIOD) 247 | self.addCleanup(task.stop) 248 | 249 | def wait_for_periodicity(): 250 | # Check if periodicity is established; flakiness has been observed 251 | # on macOS. 252 | end_time = time.time() + TIMEOUT 253 | while time.time() < end_time: 254 | if msg := bus.recv(PERIOD): 255 | acc.append(msg) 256 | if len(acc) >= 2: 257 | first, last = acc[-2:] 258 | delta = last.timestamp - first.timestamp 259 | if round(delta, ndigits=2) == PERIOD: 260 | return 261 | self.fail("Timed out") 262 | 263 | # Wait for frames to arrive; then check the result. 264 | wait_for_periodicity() 265 | self.assertTrue(all([v.data == DATA1 for v in acc])) 266 | 267 | # Update task data, which may implicitly restart the timer. 268 | # Wait for frames to arrive; then check the result. 269 | task.update(DATA2) 270 | acc.clear() 271 | wait_for_periodicity() 272 | # Find the first message with new data, and verify that all subsequent 273 | # messages also carry the new payload. 274 | data = [v.data for v in acc] 275 | self.assertIn(DATA2, data) 276 | idx = data.index(DATA2) 277 | self.assertTrue(all([v.data == DATA2 for v in acc[idx:]])) 278 | 279 | # Stop the task. 280 | task.stop() 281 | # A message may have been in flight when we stopped the timer, 282 | # so allow a single failure. 283 | bus = self.network.bus 284 | msg = bus.recv(PERIOD) 285 | if msg is not None: 286 | self.assertIsNone(bus.recv(PERIOD)) 287 | 288 | 289 | class TestScanner(unittest.TestCase): 290 | TIMEOUT = 0.1 291 | 292 | def setUp(self): 293 | self.scanner = canopen.network.NodeScanner() 294 | 295 | def test_scanner_on_message_received(self): 296 | # Emergency frames should be recognized. 297 | self.scanner.on_message_received(0x081) 298 | # Heartbeats should be recognized. 299 | self.scanner.on_message_received(0x703) 300 | # Tx PDOs should be recognized, but not Rx PDOs. 301 | self.scanner.on_message_received(0x185) 302 | self.scanner.on_message_received(0x206) 303 | self.scanner.on_message_received(0x287) 304 | self.scanner.on_message_received(0x308) 305 | self.scanner.on_message_received(0x389) 306 | self.scanner.on_message_received(0x40a) 307 | self.scanner.on_message_received(0x48b) 308 | self.scanner.on_message_received(0x50c) 309 | # SDO responses from .search() should be recognized, 310 | # but not SDO requests. 311 | self.scanner.on_message_received(0x58d) 312 | self.scanner.on_message_received(0x50e) 313 | self.assertListEqual(self.scanner.nodes, [1, 3, 5, 7, 9, 11, 13]) 314 | 315 | def test_scanner_reset(self): 316 | self.scanner.nodes = [1, 2, 3] # Mock scan. 317 | self.scanner.reset() 318 | self.assertListEqual(self.scanner.nodes, []) 319 | 320 | def test_scanner_search_no_network(self): 321 | with self.assertRaisesRegex(RuntimeError, "Network is required"): 322 | self.scanner.search() 323 | 324 | def test_scanner_search(self): 325 | rxbus = can.Bus(interface="virtual") 326 | self.addCleanup(rxbus.shutdown) 327 | 328 | txbus = can.Bus(interface="virtual") 329 | self.addCleanup(txbus.shutdown) 330 | 331 | net = canopen.Network(txbus) 332 | net.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 333 | net.connect() 334 | self.addCleanup(net.disconnect) 335 | 336 | self.scanner.network = net 337 | self.scanner.search() 338 | 339 | payload = bytes([64, 0, 16, 0, 0, 0, 0, 0]) 340 | acc = [rxbus.recv(self.TIMEOUT) for _ in range(127)] 341 | for node_id, msg in enumerate(acc, start=1): 342 | with self.subTest(node_id=node_id): 343 | self.assertIsNotNone(msg) 344 | self.assertEqual(msg.arbitration_id, 0x600 + node_id) 345 | self.assertEqual(msg.data, payload) 346 | # Check that no spurious packets were sent. 347 | self.assertIsNone(rxbus.recv(self.TIMEOUT)) 348 | 349 | def test_scanner_search_limit(self): 350 | bus = can.Bus(interface="virtual", receive_own_messages=True) 351 | net = canopen.Network(bus) 352 | net.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 353 | net.connect() 354 | self.addCleanup(net.disconnect) 355 | 356 | self.scanner.network = net 357 | self.scanner.search(limit=1) 358 | 359 | msg = bus.recv(self.TIMEOUT) 360 | self.assertIsNotNone(msg) 361 | self.assertEqual(msg.arbitration_id, 0x601) 362 | # Check that no spurious packets were sent. 363 | self.assertIsNone(bus.recv(self.TIMEOUT)) 364 | 365 | 366 | if __name__ == "__main__": 367 | unittest.main() 368 | -------------------------------------------------------------------------------- /test/test_nmt.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | import unittest 4 | 5 | import can 6 | 7 | import canopen 8 | from canopen.nmt import COMMAND_TO_STATE, NMT_COMMANDS, NMT_STATES, NmtError 9 | 10 | from .util import SAMPLE_EDS 11 | 12 | 13 | class TestNmtBase(unittest.TestCase): 14 | def setUp(self): 15 | node_id = 2 16 | self.node_id = node_id 17 | self.nmt = canopen.nmt.NmtBase(node_id) 18 | 19 | def test_send_command(self): 20 | dataset = ( 21 | "OPERATIONAL", 22 | "PRE-OPERATIONAL", 23 | "SLEEP", 24 | "STANDBY", 25 | "STOPPED", 26 | ) 27 | for cmd in dataset: 28 | with self.subTest(cmd=cmd): 29 | code = NMT_COMMANDS[cmd] 30 | self.nmt.send_command(code) 31 | expected = NMT_STATES[COMMAND_TO_STATE[code]] 32 | self.assertEqual(self.nmt.state, expected) 33 | 34 | def test_state_getset(self): 35 | for state in NMT_STATES.values(): 36 | with self.subTest(state=state): 37 | self.nmt.state = state 38 | self.assertEqual(self.nmt.state, state) 39 | 40 | def test_state_set_invalid(self): 41 | with self.assertRaisesRegex(ValueError, "INVALID"): 42 | self.nmt.state = "INVALID" 43 | 44 | 45 | class TestNmtMaster(unittest.TestCase): 46 | NODE_ID = 2 47 | PERIOD = 0.01 48 | TIMEOUT = PERIOD * 10 49 | 50 | def setUp(self): 51 | net = canopen.Network() 52 | net.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 53 | net.connect(interface="virtual") 54 | with self.assertLogs(): 55 | node = net.add_node(self.NODE_ID, SAMPLE_EDS) 56 | 57 | self.bus = can.Bus(interface="virtual") 58 | self.net = net 59 | self.node = node 60 | 61 | def tearDown(self): 62 | self.net.disconnect() 63 | self.bus.shutdown() 64 | 65 | def dispatch_heartbeat(self, code): 66 | cob_id = 0x700 + self.NODE_ID 67 | hb = can.Message(arbitration_id=cob_id, data=[code]) 68 | self.bus.send(hb) 69 | 70 | def test_nmt_master_no_heartbeat(self): 71 | with self.assertRaisesRegex(NmtError, "heartbeat"): 72 | self.node.nmt.wait_for_heartbeat(self.TIMEOUT) 73 | with self.assertRaisesRegex(NmtError, "boot-up"): 74 | self.node.nmt.wait_for_bootup(self.TIMEOUT) 75 | 76 | def test_nmt_master_on_heartbeat(self): 77 | # Skip the special INITIALISING case. 78 | for code in [st for st in NMT_STATES if st != 0]: 79 | with self.subTest(code=code): 80 | t = threading.Timer(0.01, self.dispatch_heartbeat, args=(code,)) 81 | t.start() 82 | self.addCleanup(t.join) 83 | actual = self.node.nmt.wait_for_heartbeat(0.1) 84 | expected = NMT_STATES[code] 85 | self.assertEqual(actual, expected) 86 | 87 | def test_nmt_master_wait_for_bootup(self): 88 | t = threading.Timer(0.01, self.dispatch_heartbeat, args=(0x00,)) 89 | t.start() 90 | self.addCleanup(t.join) 91 | self.node.nmt.wait_for_bootup(self.TIMEOUT) 92 | self.assertEqual(self.node.nmt.state, "PRE-OPERATIONAL") 93 | 94 | def test_nmt_master_on_heartbeat_initialising(self): 95 | t = threading.Timer(0.01, self.dispatch_heartbeat, args=(0x00,)) 96 | t.start() 97 | self.addCleanup(t.join) 98 | state = self.node.nmt.wait_for_heartbeat(self.TIMEOUT) 99 | self.assertEqual(state, "PRE-OPERATIONAL") 100 | 101 | def test_nmt_master_on_heartbeat_unknown_state(self): 102 | t = threading.Timer(0.01, self.dispatch_heartbeat, args=(0xcb,)) 103 | t.start() 104 | self.addCleanup(t.join) 105 | state = self.node.nmt.wait_for_heartbeat(self.TIMEOUT) 106 | # Expect the high bit to be masked out, and a formatted string to 107 | # be returned. 108 | self.assertEqual(state, "UNKNOWN STATE '75'") 109 | 110 | def test_nmt_master_add_heartbeat_callback(self): 111 | event = threading.Event() 112 | state = None 113 | def hook(st): 114 | nonlocal state 115 | state = st 116 | event.set() 117 | self.node.nmt.add_heartbeat_callback(hook) 118 | 119 | self.dispatch_heartbeat(0x7f) 120 | self.assertTrue(event.wait(self.TIMEOUT)) 121 | self.assertEqual(state, 127) 122 | 123 | def test_nmt_master_node_guarding(self): 124 | self.node.nmt.start_node_guarding(self.PERIOD) 125 | msg = self.bus.recv(self.TIMEOUT) 126 | self.assertIsNotNone(msg) 127 | self.assertEqual(msg.arbitration_id, 0x700 + self.NODE_ID) 128 | self.assertEqual(msg.dlc, 0) 129 | 130 | self.node.nmt.stop_node_guarding() 131 | # A message may have been in flight when we stopped the timer, 132 | # so allow a single failure. 133 | msg = self.bus.recv(self.TIMEOUT) 134 | if msg is not None: 135 | self.assertIsNone(self.bus.recv(self.TIMEOUT)) 136 | 137 | 138 | class TestNmtSlave(unittest.TestCase): 139 | def setUp(self): 140 | self.network1 = canopen.Network() 141 | self.network1.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 142 | self.network1.connect("test", interface="virtual") 143 | with self.assertLogs(): 144 | self.remote_node = self.network1.add_node(2, SAMPLE_EDS) 145 | 146 | self.network2 = canopen.Network() 147 | self.network2.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 148 | self.network2.connect("test", interface="virtual") 149 | with self.assertLogs(): 150 | self.local_node = self.network2.create_node(2, SAMPLE_EDS) 151 | self.remote_node2 = self.network1.add_node(3, SAMPLE_EDS) 152 | self.local_node2 = self.network2.create_node(3, SAMPLE_EDS) 153 | 154 | def tearDown(self): 155 | self.network1.disconnect() 156 | self.network2.disconnect() 157 | 158 | def test_start_two_remote_nodes(self): 159 | self.remote_node.nmt.state = "OPERATIONAL" 160 | # Line below is just so that we are sure the client have received the command 161 | # before we do the check 162 | time.sleep(0.1) 163 | slave_state = self.local_node.nmt.state 164 | self.assertEqual(slave_state, "OPERATIONAL") 165 | 166 | self.remote_node2.nmt.state = "OPERATIONAL" 167 | # Line below is just so that we are sure the client have received the command 168 | # before we do the check 169 | time.sleep(0.1) 170 | slave_state = self.local_node2.nmt.state 171 | self.assertEqual(slave_state, "OPERATIONAL") 172 | 173 | def test_stop_two_remote_nodes_using_broadcast(self): 174 | # This is a NMT broadcast "Stop remote node" 175 | # ie. set the node in STOPPED state 176 | self.network1.send_message(0, [2, 0]) 177 | 178 | # Line below is just so that we are sure the slaves have received the command 179 | # before we do the check 180 | time.sleep(0.1) 181 | slave_state = self.local_node.nmt.state 182 | self.assertEqual(slave_state, "STOPPED") 183 | slave_state = self.local_node2.nmt.state 184 | self.assertEqual(slave_state, "STOPPED") 185 | 186 | def test_heartbeat(self): 187 | self.assertEqual(self.remote_node.nmt.state, "INITIALISING") 188 | self.assertEqual(self.local_node.nmt.state, "INITIALISING") 189 | self.local_node.nmt.state = "OPERATIONAL" 190 | self.local_node.sdo[0x1017].raw = 100 191 | time.sleep(0.2) 192 | self.assertEqual(self.remote_node.nmt.state, "OPERATIONAL") 193 | 194 | self.local_node.nmt.stop_heartbeat() 195 | 196 | 197 | if __name__ == "__main__": 198 | unittest.main() 199 | -------------------------------------------------------------------------------- /test/test_node.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import canopen 4 | 5 | 6 | def count_subscribers(network: canopen.Network) -> int: 7 | """Count the number of subscribers in the network.""" 8 | return sum(len(n) for n in network.subscribers.values()) 9 | 10 | 11 | class TestLocalNode(unittest.TestCase): 12 | 13 | @classmethod 14 | def setUpClass(cls): 15 | cls.network = canopen.Network() 16 | cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 17 | cls.network.connect(interface="virtual") 18 | 19 | cls.node = canopen.LocalNode(2, canopen.objectdictionary.ObjectDictionary()) 20 | 21 | @classmethod 22 | def tearDownClass(cls): 23 | cls.network.disconnect() 24 | 25 | def test_associate_network(self): 26 | # Need to store the number of subscribers before associating because the 27 | # network implementation automatically adds subscribers to the list 28 | n_subscribers = count_subscribers(self.network) 29 | 30 | # Associating the network with the local node 31 | self.node.associate_network(self.network) 32 | self.assertIs(self.node.network, self.network) 33 | self.assertIs(self.node.sdo.network, self.network) 34 | self.assertIs(self.node.tpdo.network, self.network) 35 | self.assertIs(self.node.rpdo.network, self.network) 36 | self.assertIs(self.node.nmt.network, self.network) 37 | self.assertIs(self.node.emcy.network, self.network) 38 | 39 | # Test that its not possible to associate the network multiple times 40 | with self.assertRaises(RuntimeError) as cm: 41 | self.node.associate_network(self.network) 42 | self.assertIn("already associated with a network", str(cm.exception)) 43 | 44 | # Test removal of the network. The count of subscribers should 45 | # be the same as before the association 46 | self.node.remove_network() 47 | uninitalized = canopen.network._UNINITIALIZED_NETWORK 48 | self.assertIs(self.node.network, uninitalized) 49 | self.assertIs(self.node.sdo.network, uninitalized) 50 | self.assertIs(self.node.tpdo.network, uninitalized) 51 | self.assertIs(self.node.rpdo.network, uninitalized) 52 | self.assertIs(self.node.nmt.network, uninitalized) 53 | self.assertIs(self.node.emcy.network, uninitalized) 54 | self.assertEqual(count_subscribers(self.network), n_subscribers) 55 | 56 | # Test that its possible to deassociate the network multiple times 57 | self.node.remove_network() 58 | 59 | 60 | class TestRemoteNode(unittest.TestCase): 61 | 62 | @classmethod 63 | def setUpClass(cls): 64 | cls.network = canopen.Network() 65 | cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 66 | cls.network.connect(interface="virtual") 67 | 68 | cls.node = canopen.RemoteNode(2, canopen.objectdictionary.ObjectDictionary()) 69 | 70 | @classmethod 71 | def tearDownClass(cls): 72 | cls.network.disconnect() 73 | 74 | def test_associate_network(self): 75 | # Need to store the number of subscribers before associating because the 76 | # network implementation automatically adds subscribers to the list 77 | n_subscribers = count_subscribers(self.network) 78 | 79 | # Associating the network with the local node 80 | self.node.associate_network(self.network) 81 | self.assertIs(self.node.network, self.network) 82 | self.assertIs(self.node.sdo.network, self.network) 83 | self.assertIs(self.node.tpdo.network, self.network) 84 | self.assertIs(self.node.rpdo.network, self.network) 85 | self.assertIs(self.node.nmt.network, self.network) 86 | 87 | # Test that its not possible to associate the network multiple times 88 | with self.assertRaises(RuntimeError) as cm: 89 | self.node.associate_network(self.network) 90 | self.assertIn("already associated with a network", str(cm.exception)) 91 | 92 | # Test removal of the network. The count of subscribers should 93 | # be the same as before the association 94 | self.node.remove_network() 95 | uninitalized = canopen.network._UNINITIALIZED_NETWORK 96 | self.assertIs(self.node.network, uninitalized) 97 | self.assertIs(self.node.sdo.network, uninitalized) 98 | self.assertIs(self.node.tpdo.network, uninitalized) 99 | self.assertIs(self.node.rpdo.network, uninitalized) 100 | self.assertIs(self.node.nmt.network, uninitalized) 101 | self.assertEqual(count_subscribers(self.network), n_subscribers) 102 | 103 | # Test that its possible to deassociate the network multiple times 104 | self.node.remove_network() 105 | -------------------------------------------------------------------------------- /test/test_od.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from canopen import objectdictionary as od 4 | 5 | 6 | class TestDataConversions(unittest.TestCase): 7 | 8 | def test_boolean(self): 9 | var = od.ODVariable("Test BOOLEAN", 0x1000) 10 | var.data_type = od.BOOLEAN 11 | self.assertEqual(var.decode_raw(b"\x01"), True) 12 | self.assertEqual(var.decode_raw(b"\x00"), False) 13 | self.assertEqual(var.encode_raw(True), b"\x01") 14 | self.assertEqual(var.encode_raw(False), b"\x00") 15 | 16 | def test_unsigned8(self): 17 | var = od.ODVariable("Test UNSIGNED8", 0x1000) 18 | var.data_type = od.UNSIGNED8 19 | self.assertEqual(var.decode_raw(b"\xff"), 255) 20 | self.assertEqual(var.encode_raw(254), b"\xfe") 21 | 22 | def test_unsigned16(self): 23 | var = od.ODVariable("Test UNSIGNED16", 0x1000) 24 | var.data_type = od.UNSIGNED16 25 | self.assertEqual(var.decode_raw(b"\xfe\xff"), 65534) 26 | self.assertEqual(var.encode_raw(65534), b"\xfe\xff") 27 | 28 | def test_unsigned24(self): 29 | var = od.ODVariable("Test UNSIGNED24", 0x1000) 30 | var.data_type = od.UNSIGNED24 31 | self.assertEqual(var.decode_raw(b"\xfd\xfe\xff"), 16776957) 32 | self.assertEqual(var.encode_raw(16776957), b"\xfd\xfe\xff") 33 | 34 | def test_unsigned32(self): 35 | var = od.ODVariable("Test UNSIGNED32", 0x1000) 36 | var.data_type = od.UNSIGNED32 37 | self.assertEqual(var.decode_raw(b"\xfc\xfd\xfe\xff"), 4294901244) 38 | self.assertEqual(var.encode_raw(4294901244), b"\xfc\xfd\xfe\xff") 39 | 40 | def test_unsigned40(self): 41 | var = od.ODVariable("Test UNSIGNED40", 0x1000) 42 | var.data_type = od.UNSIGNED40 43 | self.assertEqual(var.decode_raw(b"\xfb\xfc\xfd\xfe\xff"), 0xfffefdfcfb) 44 | self.assertEqual(var.encode_raw(0xfffefdfcfb), b"\xfb\xfc\xfd\xfe\xff") 45 | 46 | def test_unsigned48(self): 47 | var = od.ODVariable("Test UNSIGNED48", 0x1000) 48 | var.data_type = od.UNSIGNED48 49 | self.assertEqual(var.decode_raw(b"\xfa\xfb\xfc\xfd\xfe\xff"), 0xfffefdfcfbfa) 50 | self.assertEqual(var.encode_raw(0xfffefdfcfbfa), b"\xfa\xfb\xfc\xfd\xfe\xff") 51 | 52 | def test_unsigned56(self): 53 | var = od.ODVariable("Test UNSIGNED56", 0x1000) 54 | var.data_type = od.UNSIGNED56 55 | self.assertEqual(var.decode_raw(b"\xf9\xfa\xfb\xfc\xfd\xfe\xff"), 0xfffefdfcfbfaf9) 56 | self.assertEqual(var.encode_raw(0xfffefdfcfbfaf9), b"\xf9\xfa\xfb\xfc\xfd\xfe\xff") 57 | 58 | def test_unsigned64(self): 59 | var = od.ODVariable("Test UNSIGNED64", 0x1000) 60 | var.data_type = od.UNSIGNED64 61 | self.assertEqual(var.decode_raw(b"\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff"), 0xfffefdfcfbfaf9f8) 62 | self.assertEqual(var.encode_raw(0xfffefdfcfbfaf9f8), b"\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff") 63 | 64 | def test_integer8(self): 65 | var = od.ODVariable("Test INTEGER8", 0x1000) 66 | var.data_type = od.INTEGER8 67 | self.assertEqual(var.decode_raw(b"\xff"), -1) 68 | self.assertEqual(var.decode_raw(b"\x7f"), 127) 69 | self.assertEqual(var.encode_raw(-2), b"\xfe") 70 | self.assertEqual(var.encode_raw(127), b"\x7f") 71 | 72 | def test_integer16(self): 73 | var = od.ODVariable("Test INTEGER16", 0x1000) 74 | var.data_type = od.INTEGER16 75 | self.assertEqual(var.decode_raw(b"\xfe\xff"), -2) 76 | self.assertEqual(var.decode_raw(b"\x01\x00"), 1) 77 | self.assertEqual(var.encode_raw(-2), b"\xfe\xff") 78 | self.assertEqual(var.encode_raw(1), b"\x01\x00") 79 | 80 | def test_integer24(self): 81 | var = od.ODVariable("Test INTEGER24", 0x1000) 82 | var.data_type = od.INTEGER24 83 | self.assertEqual(var.decode_raw(b"\xfe\xff\xff"), -2) 84 | self.assertEqual(var.decode_raw(b"\x01\x00\x00"), 1) 85 | self.assertEqual(var.encode_raw(-2), b"\xfe\xff\xff") 86 | self.assertEqual(var.encode_raw(1), b"\x01\x00\x00") 87 | 88 | def test_integer32(self): 89 | var = od.ODVariable("Test INTEGER32", 0x1000) 90 | var.data_type = od.INTEGER32 91 | self.assertEqual(var.decode_raw(b"\xfe\xff\xff\xff"), -2) 92 | self.assertEqual(var.decode_raw(b"\x01\x00\x00\x00"), 1) 93 | self.assertEqual(var.encode_raw(1), b"\x01\x00\x00\x00") 94 | self.assertEqual(var.encode_raw(-2), b"\xfe\xff\xff\xff") 95 | 96 | def test_integer40(self): 97 | var = od.ODVariable("Test INTEGER40", 0x1000) 98 | var.data_type = od.INTEGER40 99 | self.assertEqual(var.decode_raw(b"\xfe\xff\xff\xff\xff"), -2) 100 | self.assertEqual(var.decode_raw(b"\x01\x00\x00\x00\x00"), 1) 101 | self.assertEqual(var.encode_raw(-2), b"\xfe\xff\xff\xff\xff") 102 | self.assertEqual(var.encode_raw(1), b"\x01\x00\x00\x00\x00") 103 | 104 | def test_integer48(self): 105 | var = od.ODVariable("Test INTEGER48", 0x1000) 106 | var.data_type = od.INTEGER48 107 | self.assertEqual(var.decode_raw(b"\xfe\xff\xff\xff\xff\xff"), -2) 108 | self.assertEqual(var.decode_raw(b"\x01\x00\x00\x00\x00\x00"), 1) 109 | self.assertEqual(var.encode_raw(-2), b"\xfe\xff\xff\xff\xff\xff") 110 | self.assertEqual(var.encode_raw(1), b"\x01\x00\x00\x00\x00\x00") 111 | 112 | def test_integer56(self): 113 | var = od.ODVariable("Test INTEGER56", 0x1000) 114 | var.data_type = od.INTEGER56 115 | self.assertEqual(var.decode_raw(b"\xfe\xff\xff\xff\xff\xff\xff"), -2) 116 | self.assertEqual(var.decode_raw(b"\x01\x00\x00\x00\x00\x00\x00"), 1) 117 | self.assertEqual(var.encode_raw(-2), b"\xfe\xff\xff\xff\xff\xff\xff") 118 | self.assertEqual(var.encode_raw(1), b"\x01\x00\x00\x00\x00\x00\x00") 119 | 120 | def test_integer64(self): 121 | var = od.ODVariable("Test INTEGER64", 0x1000) 122 | var.data_type = od.INTEGER64 123 | self.assertEqual(var.decode_raw(b"\xfe\xff\xff\xff\xff\xff\xff\xff"), -2) 124 | self.assertEqual(var.decode_raw(b"\x01\x00\x00\x00\x00\x00\x00\x00"), 1) 125 | self.assertEqual(var.encode_raw(-2), b"\xfe\xff\xff\xff\xff\xff\xff\xff") 126 | self.assertEqual(var.encode_raw(1), b"\x01\x00\x00\x00\x00\x00\x00\x00") 127 | 128 | def test_real32(self): 129 | var = od.ODVariable("Test REAL32", 0x1000) 130 | var.data_type = od.REAL32 131 | # Select values that are exaclty representable in decimal notation 132 | self.assertEqual(var.decode_raw(b"\x00\x00\x00\x00"), 0.) 133 | self.assertEqual(var.decode_raw(b"\x00\x00\x60\x40"), 3.5) 134 | self.assertEqual(var.decode_raw(b"\x00\x20\x7a\xc4"), -1000.5) 135 | 136 | def test_real64(self): 137 | var = od.ODVariable("Test REAL64", 0x1000) 138 | var.data_type = od.REAL64 139 | # Select values that are exaclty representable in decimal notation 140 | self.assertEqual(var.decode_raw(b"\x00\x00\x00\x00\x00\x00\x00\x00"), 0.) 141 | self.assertEqual(var.decode_raw(b"\x00\x00\x00\x00\x00\x4a\x93\x40"), 1234.5) 142 | self.assertEqual(var.decode_raw(b"\x06\x81\x95\x43\x0b\x42\x8f\xc0"), -1000.2555) 143 | 144 | def test_visible_string(self): 145 | var = od.ODVariable("Test VISIBLE_STRING", 0x1000) 146 | var.data_type = od.VISIBLE_STRING 147 | self.assertEqual(var.decode_raw(b"abcdefg"), "abcdefg") 148 | self.assertEqual(var.decode_raw(b"zero terminated\x00"), "zero terminated") 149 | self.assertEqual(var.encode_raw("testing"), b"testing") 150 | 151 | def test_unicode_string(self): 152 | var = od.ODVariable("Test UNICODE_STRING", 0x1000) 153 | var.data_type = od.UNICODE_STRING 154 | self.assertEqual(var.decode_raw(b"\x61\x00\x62\x00\x63\x00"), "abc") 155 | self.assertEqual(var.decode_raw(b"\x61\x00\x62\x00\x63\x00\x00\x00"), "abc") # Zero terminated 156 | self.assertEqual(var.encode_raw("abc"), b"\x61\x00\x62\x00\x63\x00") 157 | self.assertEqual(var.decode_raw(b"\x60\x3f\x7d\x59"), "\u3f60\u597d") # Chinese "Nǐ hǎo", hello 158 | self.assertEqual(var.encode_raw("\u3f60\u597d"), b"\x60\x3f\x7d\x59") # Chinese "Nǐ hǎo", hello 159 | 160 | def test_octet_string(self): 161 | var = od.ODVariable("Test OCTET_STRING", 0x1000) 162 | var.data_type = od.OCTET_STRING 163 | self.assertEqual(var.decode_raw(b"abcdefg"), b"abcdefg") 164 | self.assertEqual(var.decode_raw(b"zero terminated\x00"), b"zero terminated\x00") 165 | self.assertEqual(var.encode_raw(b"testing"), b"testing") 166 | 167 | def test_domain(self): 168 | var = od.ODVariable("Test DOMAIN", 0x1000) 169 | var.data_type = od.DOMAIN 170 | self.assertEqual(var.decode_raw(b"abcdefg"), b"abcdefg") 171 | self.assertEqual(var.decode_raw(b"zero terminated\x00"), b"zero terminated\x00") 172 | self.assertEqual(var.encode_raw(b"testing"), b"testing") 173 | 174 | 175 | class TestAlternativeRepresentations(unittest.TestCase): 176 | 177 | def test_phys(self): 178 | var = od.ODVariable("Test INTEGER16", 0x1000) 179 | var.data_type = od.INTEGER16 180 | var.factor = 0.1 181 | 182 | self.assertAlmostEqual(var.decode_phys(128), 12.8) 183 | self.assertEqual(var.encode_phys(-0.1), -1) 184 | 185 | def test_desc(self): 186 | var = od.ODVariable("Test UNSIGNED8", 0x1000) 187 | var.data_type = od.UNSIGNED8 188 | var.add_value_description(0, "Value 0") 189 | var.add_value_description(1, "Value 1") 190 | var.add_value_description(3, "Value 3") 191 | 192 | self.assertEqual(var.decode_desc(0), "Value 0") 193 | self.assertEqual(var.decode_desc(3), "Value 3") 194 | self.assertEqual(var.encode_desc("Value 1"), 1) 195 | 196 | def test_bits(self): 197 | var = od.ODVariable("Test UNSIGNED8", 0x1000) 198 | var.data_type = od.UNSIGNED8 199 | var.add_bit_definition("BIT 0", [0]) 200 | var.add_bit_definition("BIT 2 and 3", [2, 3]) 201 | 202 | self.assertEqual(var.decode_bits(1, "BIT 0"), 1) 203 | self.assertEqual(var.decode_bits(1, [1]), 0) 204 | self.assertEqual(var.decode_bits(0xf, [0, 1, 2, 3]), 15) 205 | self.assertEqual(var.decode_bits(8, "BIT 2 and 3"), 2) 206 | self.assertEqual(var.encode_bits(0xf, [1], 0), 0xd) 207 | self.assertEqual(var.encode_bits(0, "BIT 0", 1), 1) 208 | 209 | 210 | class TestObjectDictionary(unittest.TestCase): 211 | 212 | def test_add_variable(self): 213 | test_od = od.ObjectDictionary() 214 | var = od.ODVariable("Test Variable", 0x1000) 215 | test_od.add_object(var) 216 | self.assertEqual(test_od["Test Variable"], var) 217 | self.assertEqual(test_od[0x1000], var) 218 | 219 | def test_add_record(self): 220 | test_od = od.ObjectDictionary() 221 | record = od.ODRecord("Test Record", 0x1001) 222 | var = od.ODVariable("Test Subindex", 0x1001, 1) 223 | record.add_member(var) 224 | test_od.add_object(record) 225 | self.assertEqual(test_od["Test Record"], record) 226 | self.assertEqual(test_od[0x1001], record) 227 | self.assertEqual(test_od["Test Record"]["Test Subindex"], var) 228 | 229 | def test_add_array(self): 230 | test_od = od.ObjectDictionary() 231 | array = od.ODArray("Test Array", 0x1002) 232 | array.add_member(od.ODVariable("Last subindex", 0x1002, 0)) 233 | test_od.add_object(array) 234 | self.assertEqual(test_od["Test Array"], array) 235 | self.assertEqual(test_od[0x1002], array) 236 | 237 | def test_get_item_dot(self): 238 | test_od = od.ObjectDictionary() 239 | array = od.ODArray("Test Array", 0x1000) 240 | last_subindex = od.ODVariable("Last subindex", 0x1000, 0) 241 | last_subindex.data_type = od.UNSIGNED8 242 | member1 = od.ODVariable("Test Variable", 0x1000, 1) 243 | member2 = od.ODVariable("Test Variable 2", 0x1000, 2) 244 | array.add_member(last_subindex) 245 | array.add_member(member1) 246 | array.add_member(member2) 247 | test_od.add_object(array) 248 | self.assertEqual(test_od["Test Array.Last subindex"], last_subindex) 249 | self.assertEqual(test_od["Test Array.Test Variable"], member1) 250 | self.assertEqual(test_od["Test Array.Test Variable 2"], member2) 251 | 252 | class TestArray(unittest.TestCase): 253 | 254 | def test_subindexes(self): 255 | array = od.ODArray("Test Array", 0x1000) 256 | last_subindex = od.ODVariable("Last subindex", 0x1000, 0) 257 | last_subindex.data_type = od.UNSIGNED8 258 | array.add_member(last_subindex) 259 | array.add_member(od.ODVariable("Test Variable", 0x1000, 1)) 260 | array.add_member(od.ODVariable("Test Variable 2", 0x1000, 2)) 261 | self.assertEqual(array[0].name, "Last subindex") 262 | self.assertEqual(array[1].name, "Test Variable") 263 | self.assertEqual(array[2].name, "Test Variable 2") 264 | self.assertEqual(array[3].name, "Test Variable_3") 265 | 266 | 267 | if __name__ == "__main__": 268 | unittest.main() 269 | -------------------------------------------------------------------------------- /test/test_pdo.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import canopen 4 | 5 | from .util import SAMPLE_EDS, tmp_file 6 | 7 | 8 | class TestPDO(unittest.TestCase): 9 | def setUp(self): 10 | node = canopen.Node(1, SAMPLE_EDS) 11 | pdo = node.pdo.tx[1] 12 | pdo.add_variable('INTEGER16 value') # 0x2001 13 | pdo.add_variable('UNSIGNED8 value', length=4) # 0x2002 14 | pdo.add_variable('INTEGER8 value', length=4) # 0x2003 15 | pdo.add_variable('INTEGER32 value') # 0x2004 16 | pdo.add_variable('BOOLEAN value', length=1) # 0x2005 17 | pdo.add_variable('BOOLEAN value 2', length=1) # 0x2006 18 | 19 | # Write some values 20 | pdo['INTEGER16 value'].raw = -3 21 | pdo['UNSIGNED8 value'].raw = 0xf 22 | pdo['INTEGER8 value'].raw = -2 23 | pdo['INTEGER32 value'].raw = 0x01020304 24 | pdo['BOOLEAN value'].raw = False 25 | pdo['BOOLEAN value 2'].raw = True 26 | 27 | self.pdo = pdo 28 | self.node = node 29 | 30 | def test_pdo_map_bit_mapping(self): 31 | self.assertEqual(self.pdo.data, b'\xfd\xff\xef\x04\x03\x02\x01\x02') 32 | 33 | def test_pdo_map_getitem(self): 34 | pdo = self.pdo 35 | self.assertEqual(pdo['INTEGER16 value'].raw, -3) 36 | self.assertEqual(pdo['UNSIGNED8 value'].raw, 0xf) 37 | self.assertEqual(pdo['INTEGER8 value'].raw, -2) 38 | self.assertEqual(pdo['INTEGER32 value'].raw, 0x01020304) 39 | self.assertEqual(pdo['BOOLEAN value'].raw, False) 40 | self.assertEqual(pdo['BOOLEAN value 2'].raw, True) 41 | 42 | def test_pdo_getitem(self): 43 | node = self.node 44 | self.assertEqual(node.tpdo[1]['INTEGER16 value'].raw, -3) 45 | self.assertEqual(node.tpdo[1]['UNSIGNED8 value'].raw, 0xf) 46 | self.assertEqual(node.tpdo[1]['INTEGER8 value'].raw, -2) 47 | self.assertEqual(node.tpdo[1]['INTEGER32 value'].raw, 0x01020304) 48 | self.assertEqual(node.tpdo['INTEGER32 value'].raw, 0x01020304) 49 | self.assertEqual(node.tpdo[1]['BOOLEAN value'].raw, False) 50 | self.assertEqual(node.tpdo[1]['BOOLEAN value 2'].raw, True) 51 | 52 | # Test different types of access 53 | self.assertEqual(node.pdo[0x1600]['INTEGER16 value'].raw, -3) 54 | self.assertEqual(node.pdo['INTEGER16 value'].raw, -3) 55 | self.assertEqual(node.pdo.tx[1]['INTEGER16 value'].raw, -3) 56 | self.assertEqual(node.pdo[0x2001].raw, -3) 57 | self.assertEqual(node.tpdo[0x2001].raw, -3) 58 | self.assertEqual(node.pdo[0x2002].raw, 0xf) 59 | self.assertEqual(node.pdo['0x2002'].raw, 0xf) 60 | self.assertEqual(node.tpdo[0x2002].raw, 0xf) 61 | self.assertEqual(node.pdo[0x1600][0x2002].raw, 0xf) 62 | 63 | def test_pdo_save(self): 64 | self.node.tpdo.save() 65 | self.node.rpdo.save() 66 | 67 | def test_pdo_export(self): 68 | try: 69 | import canmatrix 70 | except ImportError: 71 | raise unittest.SkipTest("The PDO export API requires canmatrix") 72 | 73 | for pdo in "tpdo", "rpdo": 74 | with tmp_file(suffix=".csv") as tmp: 75 | fn = tmp.name 76 | with self.subTest(filename=fn, pdo=pdo): 77 | getattr(self.node, pdo).export(fn) 78 | with open(fn) as csv: 79 | header = csv.readline() 80 | self.assertIn("ID", header) 81 | self.assertIn("Frame Name", header) 82 | 83 | 84 | if __name__ == "__main__": 85 | unittest.main() 86 | -------------------------------------------------------------------------------- /test/test_sync.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import unittest 3 | 4 | import can 5 | 6 | import canopen 7 | 8 | 9 | PERIOD = 0.01 10 | TIMEOUT = PERIOD * 10 11 | 12 | 13 | class TestSync(unittest.TestCase): 14 | def setUp(self): 15 | self.net = canopen.Network() 16 | self.net.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 17 | self.net.connect(interface="virtual") 18 | self.sync = canopen.sync.SyncProducer(self.net) 19 | self.rxbus = can.Bus(interface="virtual") 20 | 21 | def tearDown(self): 22 | self.net.disconnect() 23 | self.rxbus.shutdown() 24 | 25 | def test_sync_producer_transmit(self): 26 | self.sync.transmit() 27 | msg = self.rxbus.recv(TIMEOUT) 28 | self.assertIsNotNone(msg) 29 | self.assertEqual(msg.arbitration_id, 0x80) 30 | self.assertEqual(msg.dlc, 0) 31 | 32 | def test_sync_producer_transmit_count(self): 33 | self.sync.transmit(2) 34 | msg = self.rxbus.recv(TIMEOUT) 35 | self.assertIsNotNone(msg) 36 | self.assertEqual(msg.arbitration_id, 0x80) 37 | self.assertEqual(msg.dlc, 1) 38 | self.assertEqual(msg.data, b"\x02") 39 | 40 | def test_sync_producer_start_invalid_period(self): 41 | with self.assertRaises(ValueError): 42 | self.sync.start(0) 43 | 44 | def test_sync_producer_start(self): 45 | self.sync.start(PERIOD) 46 | self.addCleanup(self.sync.stop) 47 | 48 | acc = [] 49 | condition = threading.Condition() 50 | 51 | def hook(id_, data, ts): 52 | item = id_, data, ts 53 | acc.append(item) 54 | condition.notify() 55 | 56 | def periodicity(): 57 | # Check if periodicity has been established. 58 | if len(acc) > 2: 59 | delta = acc[-1][2] - acc[-2][2] 60 | return round(delta, ndigits=1) == PERIOD 61 | 62 | # Sample messages. 63 | with condition: 64 | condition.wait_for(periodicity, TIMEOUT) 65 | for msg in acc: 66 | self.assertIsNotNone(msg) 67 | self.assertEqual(msg[0], 0x80) 68 | self.assertEqual(msg[1], b"") 69 | 70 | self.sync.stop() 71 | # A message may have been in flight when we stopped the timer, 72 | # so allow a single failure. 73 | msg = self.rxbus.recv(TIMEOUT) 74 | if msg is not None: 75 | self.assertIsNone(self.net.bus.recv(TIMEOUT)) 76 | 77 | 78 | if __name__ == "__main__": 79 | unittest.main() 80 | -------------------------------------------------------------------------------- /test/test_time.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import canopen 4 | 5 | 6 | class TestTime(unittest.TestCase): 7 | 8 | def test_time_producer(self): 9 | network = canopen.Network() 10 | network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 11 | network.connect(interface="virtual", receive_own_messages=True) 12 | producer = canopen.timestamp.TimeProducer(network) 13 | producer.transmit(1486236238) 14 | msg = network.bus.recv(1) 15 | network.disconnect() 16 | self.assertEqual(msg.arbitration_id, 0x100) 17 | self.assertEqual(msg.dlc, 6) 18 | self.assertEqual(msg.data, b"\xb0\xa4\x29\x04\x31\x43") 19 | 20 | 21 | if __name__ == "__main__": 22 | unittest.main() 23 | -------------------------------------------------------------------------------- /test/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from canopen.utils import pretty_index 4 | 5 | 6 | class TestUtils(unittest.TestCase): 7 | 8 | def test_pretty_index(self): 9 | self.assertEqual(pretty_index(0x12ab), "0x12AB") 10 | self.assertEqual(pretty_index(0x12ab, 0xcd), "0x12AB:CD") 11 | self.assertEqual(pretty_index(0x12ab, ""), "0x12AB") 12 | self.assertEqual(pretty_index("test"), "'test'") 13 | self.assertEqual(pretty_index("test", 0xcd), "'test':CD") 14 | self.assertEqual(pretty_index(None), "") 15 | self.assertEqual(pretty_index(""), "") 16 | self.assertEqual(pretty_index("", ""), "") 17 | self.assertEqual(pretty_index(None, 0xab), "0xAB") 18 | 19 | 20 | if __name__ == "__main__": 21 | unittest.main() 22 | -------------------------------------------------------------------------------- /test/util.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | import tempfile 4 | 5 | 6 | DATATYPES_EDS = os.path.join(os.path.dirname(__file__), "datatypes.eds") 7 | SAMPLE_EDS = os.path.join(os.path.dirname(__file__), "sample.eds") 8 | 9 | 10 | @contextlib.contextmanager 11 | def tmp_file(*args, **kwds): 12 | with tempfile.NamedTemporaryFile(*args, **kwds) as tmp: 13 | tmp.close() 14 | yield tmp 15 | --------------------------------------------------------------------------------