├── canopen ├── py.typed ├── profiles │ ├── __init__.py │ └── tools │ │ └── test_p402_states.py ├── node │ ├── __init__.py │ ├── base.py │ ├── local.py │ └── remote.py ├── canopen.py ├── sdo │ ├── __init__.py │ ├── constants.py │ ├── exceptions.py │ ├── base.py │ └── server.py ├── utils.py ├── __init__.py ├── timestamp.py ├── sync.py ├── async_guard.py ├── pdo │ └── __init__.py ├── objectdictionary │ ├── datatypes.py │ └── epf.py ├── emcy.py ├── variable.py └── nmt.py ├── test ├── __init__.py ├── util.py ├── test_utils.py ├── test_time.py ├── test_sync.py ├── datatypes.eds ├── test_node.py ├── test_pdo.py ├── test_emcy.py ├── test_nmt.py └── test_od.py ├── setup.py ├── MANIFEST.in ├── requirements-dev.txt ├── doc ├── requirements.txt ├── timestamp.rst ├── Makefile ├── sync.rst ├── index.rst ├── emcy.rst ├── nmt.rst ├── integration.rst ├── lss.rst ├── conf.py ├── profiles.rst ├── pdo.rst ├── network.rst ├── od.rst └── sdo.rst ├── codecov.yml ├── .readthedocs.yaml ├── .github └── workflows │ ├── pr-linters.yaml │ ├── pythonpublish.yml │ ├── pythonpackage.yml │ └── publish-to-pypi.yml ├── makedeb ├── LICENSE.txt ├── .gitignore ├── examples ├── canopen_async.py └── simple_ds402_node.py ├── pyproject.toml └── README.rst /canopen/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /canopen/profiles/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include test/sample.eds 2 | include LICENSE.txt 3 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | mypy~=1.10 2 | pytest~=8.3 3 | pytest-cov~=5.0 4 | -------------------------------------------------------------------------------- /doc/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx~=7.3 2 | sphinx-autodoc-typehints~=2.2 3 | furo~=2024.5 4 | -------------------------------------------------------------------------------- /canopen/node/__init__.py: -------------------------------------------------------------------------------- 1 | from canopen_asyncio.node.local import LocalNode 2 | from canopen_asyncio.node.remote import RemoteNode 3 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "*/test/*" 3 | 4 | comment: 5 | require_changes: true 6 | layout: "reach, diff, flags, files" 7 | behavior: default 8 | -------------------------------------------------------------------------------- /canopen/canopen.py: -------------------------------------------------------------------------------- 1 | """ Canopen namespace adapter """ 2 | 3 | from canopen_asyncio import * 4 | from canopen_asyncio import network 5 | from canopen_asyncio import objectdictionary 6 | -------------------------------------------------------------------------------- /canopen/sdo/__init__.py: -------------------------------------------------------------------------------- 1 | from canopen_asyncio.sdo.base import SdoArray, SdoRecord, SdoVariable 2 | from canopen_asyncio.sdo.client import SdoClient 3 | from canopen_asyncio.sdo.exceptions import SdoAbortedError, SdoCommunicationError 4 | from canopen_asyncio.sdo.server import SdoServer 5 | 6 | # Compatibility 7 | from canopen_asyncio.sdo.base import Array, Record, Variable 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/workflows/pr-linters.yaml: -------------------------------------------------------------------------------- 1 | name: Run PR linters 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | permissions: 8 | contents: read 9 | pull-requests: read 10 | 11 | jobs: 12 | 13 | mypy: 14 | name: Run mypy static type checker (optional) 15 | runs-on: ubuntu-latest 16 | continue-on-error: true 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: 3.12 22 | cache: pip 23 | cache-dependency-path: | 24 | 'pyproject.toml' 25 | 'requirements-dev.txt' 26 | - run: pip install -r requirements-dev.txt -e . 27 | - name: Run mypy and report 28 | run: mypy --config-file pyproject.toml . 29 | -------------------------------------------------------------------------------- /canopen/utils.py: -------------------------------------------------------------------------------- 1 | """Additional utility functions for canopen.""" 2 | 3 | from typing import Optional, Union 4 | 5 | 6 | def pretty_index(index: Optional[Union[int, str]], 7 | sub: Optional[Union[int, str]] = None): 8 | """Format an index and subindex as a string.""" 9 | 10 | index_str = "" 11 | if isinstance(index, int): 12 | index_str = f"0x{index:04X}" 13 | elif index: 14 | index_str = f"{index!r}" 15 | 16 | sub_str = "" 17 | if isinstance(sub, int): 18 | # Need 0x prefix if index is not present 19 | sub_str = f"{'0x' if not index_str else ''}{sub:02X}" 20 | elif sub: 21 | sub_str = f"{sub!r}" 22 | 23 | return ":".join(s for s in (index_str, sub_str) if s) 24 | -------------------------------------------------------------------------------- /test/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from canopen_asyncio.utils import pretty_index 4 | 5 | 6 | class TestUtils(unittest.IsolatedAsyncioTestCase): 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /canopen/__init__.py: -------------------------------------------------------------------------------- 1 | from canopen_asyncio.network import Network, NodeScanner 2 | from canopen_asyncio.node import LocalNode, RemoteNode 3 | from canopen_asyncio.objectdictionary import ( 4 | ObjectDictionary, 5 | ObjectDictionaryError, 6 | export_od, 7 | import_od, 8 | ) 9 | from canopen_asyncio.profiles.p402 import BaseNode402 10 | from canopen_asyncio.sdo import SdoAbortedError, SdoCommunicationError 11 | 12 | try: 13 | from canopen_asyncio._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-asyncio/" 32 | 33 | Node = RemoteNode 34 | -------------------------------------------------------------------------------- /canopen/timestamp.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import struct 4 | import time 5 | from typing import Optional, TYPE_CHECKING 6 | 7 | if TYPE_CHECKING: 8 | from canopen_asyncio import canopen 9 | 10 | 11 | # 1 Jan 1984 12 | OFFSET = 441763200 13 | 14 | ONE_DAY = 60 * 60 * 24 15 | 16 | TIME_OF_DAY_STRUCT = struct.Struct("'` 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 < bool: 31 | """Check whether the node has been associated to a network.""" 32 | return not isinstance(self.network, canopen.network._UninitializedNetwork) 33 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /canopen/sync.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional, TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from canopen_asyncio import canopen 7 | 8 | 9 | class SyncProducer: 10 | """Transmits a SYNC message periodically.""" 11 | 12 | #: COB-ID of the SYNC message 13 | cob_id = 0x80 14 | 15 | def __init__(self, network: canopen.network.Network): 16 | self.network = network 17 | self.period: Optional[float] = None 18 | self._task = None 19 | 20 | def transmit(self, count: Optional[int] = None): 21 | """Send out a SYNC message once. 22 | 23 | :param count: 24 | Counter to add in message. 25 | """ 26 | data = [count] if count is not None else [] 27 | self.network.send_message(self.cob_id, data) 28 | 29 | def start(self, period: Optional[float] = None): 30 | """Start periodic transmission of SYNC message in a background thread. 31 | 32 | :param period: 33 | Period of SYNC message in seconds. 34 | """ 35 | if period is not None: 36 | self.period = period 37 | 38 | if not self.period: 39 | raise ValueError("A valid transmission period has not been given") 40 | 41 | self._task = self.network.send_periodic(self.cob_id, [], self.period) 42 | 43 | def stop(self): 44 | """Stop periodic transmission of SYNC message.""" 45 | if self._task is not None: 46 | self._task.stop() 47 | -------------------------------------------------------------------------------- /canopen/async_guard.py: -------------------------------------------------------------------------------- 1 | """ Utils for async """ 2 | import functools 3 | import logging 4 | import threading 5 | import traceback 6 | 7 | # NOTE: Global, but needed to be able to use ensure_not_async() in 8 | # decorator context. 9 | _ASYNC_SENTINELS: dict[int, bool] = {} 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def set_async_sentinel(enable: bool): 15 | """ Register a function to validate if async is running """ 16 | _ASYNC_SENTINELS[threading.get_ident()] = enable 17 | 18 | 19 | def ensure_not_async(fn): 20 | """ Decorator that will ensure that the function is not called if async 21 | is running. 22 | """ 23 | @functools.wraps(fn) 24 | def async_guard_wrap(*args, **kwargs): 25 | if _ASYNC_SENTINELS.get(threading.get_ident(), False): 26 | st = "".join(traceback.format_stack()) 27 | logger.debug("Traceback:\n%s", st.rstrip()) 28 | raise RuntimeError(f"Calling a blocking function, {fn.__qualname__}() in {fn.__code__.co_filename}:{fn.__code__.co_firstlineno}, while running async") 29 | return fn(*args, **kwargs) 30 | return async_guard_wrap 31 | 32 | 33 | class AllowBlocking: 34 | """ Context manager to pause async guard """ 35 | def __init__(self): 36 | self._enabled = _ASYNC_SENTINELS.get(threading.get_ident(), False) 37 | 38 | def __enter__(self): 39 | set_async_sentinel(False) 40 | return self 41 | 42 | def __exit__(self, exc_type, exc_value, traceback): 43 | set_async_sentinel(self._enabled) 44 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /examples/canopen_async.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import canopen 4 | 5 | # Set logging output 6 | logging.basicConfig(level=logging.INFO) 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | async def do_loop(network: canopen.Network, nodeid): 11 | 12 | # Create the node object and load the OD 13 | node: canopen.RemoteNode = await network.aadd_node(nodeid, 'eds/e35.eds') 14 | 15 | # Get the PDOs from the remote 16 | await node.tpdo.aread(from_od=False) 17 | await node.rpdo.aread(from_od=False) 18 | 19 | # Set the remote state 20 | node.nmt.state = 'OPERATIONAL' 21 | 22 | # Set SDO 23 | await node.sdo['something'].aset_raw(2) 24 | 25 | i = 0 26 | while True: 27 | i += 1 28 | 29 | # Wait for PDO 30 | t = await node.tpdo[1].await_for_reception(1) 31 | if not t: 32 | continue 33 | 34 | # Get TPDO value 35 | # PDO values are accessed non-synchronously using attributes 36 | state = node.tpdo[1]['state'].raw 37 | 38 | # If state send RPDO to remote 39 | if state == 5: 40 | 41 | await asyncio.sleep(0.2) 42 | 43 | # Set RPDO and transmit 44 | node.rpdo[1]['count'].phys = i 45 | node.rpdo[1].transmit() 46 | 47 | 48 | async def amain(): 49 | 50 | # Create the canopen network and connect it to the CAN bus 51 | loop = asyncio.get_running_loop() 52 | async with canopen.Network(loop=loop).connect( 53 | interface='virtual', bitrate=1000000, recieve_own_messages=True 54 | ) as network: 55 | 56 | # Start two instances and run them concurrently 57 | # NOTE: It is better to use asyncio.TaskGroup to manage tasks, but this 58 | # is not available before Python 3.11. 59 | await asyncio.gather( 60 | asyncio.create_task(do_loop(network, 20)), 61 | asyncio.create_task(do_loop(network, 21)), 62 | ) 63 | 64 | 65 | def main(): 66 | asyncio.run(amain()) 67 | 68 | if __name__ == '__main__': 69 | main() 70 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-vcs"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "canopen-asyncio" 7 | authors = [ 8 | # This fork is solely maintained by Svein Seldal, but the original 9 | # authors are listed here for reference. 10 | # {name = "Christian Sandberg", email = "christiansandberg@me.com"}, 11 | # {name = "André Colomb", email = "src@andre.colomb.de"}, 12 | # {name = "André Filipe Silva", email = "afsilva.work@gmail.com"}, 13 | {name = "Svein Seldal", email = "sveinse@seldal.com"}, 14 | ] 15 | description = "CANopen stack implementation for asyncio" 16 | readme = "README.rst" 17 | requires-python = ">=3.9" 18 | license = "MIT" 19 | license-files = ["LICENSE.txt"] 20 | classifiers = [ 21 | "Development Status :: 5 - Production/Stable", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python :: 3 :: Only", 24 | "Intended Audience :: Developers", 25 | "Topic :: Scientific/Engineering", 26 | ] 27 | dependencies = [ 28 | "python-can >= 3.0.0", 29 | ] 30 | dynamic = ["version"] 31 | 32 | [project.optional-dependencies] 33 | db_export = [ 34 | "canmatrix ~= 1.0", 35 | ] 36 | 37 | [project.urls] 38 | documentation = "https://canopen.readthedocs.io/en/stable/" 39 | repository = "https://github.com/sveinse/canopen-asyncio" 40 | 41 | [tool.hatch] 42 | version.source = "vcs" 43 | build.hooks.vcs.version-file = "canopen/_version.py" 44 | 45 | [tool.hatch.version.raw-options] 46 | local_scheme = "no-local-version" 47 | 48 | [tool.hatch.build.targets.wheel] 49 | packages = ["canopen"] 50 | 51 | [tool.hatch.build.targets.wheel.sources] 52 | "canopen" = "canopen_asyncio" 53 | 54 | [tool.hatch.build.targets.sdist] 55 | include = [ 56 | "canopen/*.py", 57 | "canopen/*/*.py", 58 | "canopen/*/*/*.py", 59 | "/test", 60 | "/doc", 61 | ] 62 | 63 | [tool.setuptools_scm] 64 | version_file = "canopen/_version.py" 65 | 66 | [tool.pytest.ini_options] 67 | testpaths = [ 68 | "test", 69 | ] 70 | filterwarnings = [ 71 | "ignore::DeprecationWarning", 72 | ] 73 | 74 | [tool.mypy] 75 | python_version = "3.9" 76 | exclude = [ 77 | "^examples*", 78 | "^test*", 79 | "^setup.py*", 80 | ] 81 | 82 | [tool.coverage.run] 83 | branch = true 84 | 85 | [tool.coverage.report] 86 | exclude_also = [ 87 | 'if TYPE_CHECKING:', 88 | ] 89 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | build: 10 | name: Build distribution 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | persist-credentials: false 17 | - name: Get history and tags for SCM versioning to work 18 | run: | 19 | git fetch --prune --unshallow 20 | git fetch --depth=1 origin +refs/tags/*:refs/tags/* 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: "3.x" 25 | - name: Install pypa/build 26 | run: >- 27 | python3 -m 28 | pip install 29 | build 30 | --user 31 | - name: Build a binary wheel and a source tarball 32 | run: python3 -m build 33 | - name: Store the distribution packages 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: python-package-distributions 37 | path: dist/ 38 | 39 | publish-to-pypi: 40 | name: >- 41 | Publish to PyPI 42 | # if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 43 | needs: 44 | - build 45 | runs-on: ubuntu-latest 46 | environment: 47 | name: pypi 48 | url: https://pypi.org/p/canopen-asyncio 49 | permissions: 50 | id-token: write 51 | 52 | steps: 53 | - name: Download all the dists 54 | uses: actions/download-artifact@v4 55 | with: 56 | name: python-package-distributions 57 | path: dist/ 58 | - name: Publish to PyPI 59 | uses: pypa/gh-action-pypi-publish@release/v1 60 | 61 | # publish-to-testpypi: 62 | # name: Publish to TestPyPI 63 | # needs: 64 | # - build 65 | # runs-on: ubuntu-latest 66 | 67 | # environment: 68 | # name: testpypi 69 | # url: https://test.pypi.org/p/canopen-asyncio 70 | 71 | # permissions: 72 | # id-token: write 73 | # steps: 74 | # - name: Download all the dists 75 | # uses: actions/download-artifact@v4 76 | # with: 77 | # name: python-package-distributions 78 | # path: dist/ 79 | # - name: Publish to TestPyPI 80 | # uses: pypa/gh-action-pypi-publish@release/v1 81 | # with: 82 | # repository-url: https://test.pypi.org/legacy/ 83 | -------------------------------------------------------------------------------- /test/test_time.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import struct 3 | import time 4 | import unittest 5 | from datetime import datetime 6 | from unittest.mock import patch 7 | 8 | import canopen_asyncio 9 | import canopen_asyncio as canopen 10 | import canopen_asyncio.timestamp 11 | 12 | 13 | class TestTime(unittest.IsolatedAsyncioTestCase): 14 | 15 | __test__ = False # This is a base class, tests should not be run directly. 16 | use_async: bool 17 | 18 | def setUp(self): 19 | self.loop = None 20 | if self.use_async: 21 | self.loop = asyncio.get_event_loop() 22 | 23 | async def test_epoch(self): 24 | """Verify that the epoch matches the standard definition.""" 25 | epoch = datetime.strptime( 26 | "1984-01-01 00:00:00 +0000", "%Y-%m-%d %H:%M:%S %z" 27 | ).timestamp() 28 | self.assertEqual(int(epoch), canopen.timestamp.OFFSET) 29 | 30 | async def test_time_producer(self): 31 | network = canopen.Network(loop=self.loop) 32 | self.addCleanup(network.disconnect) 33 | network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 34 | network.connect(interface="virtual", receive_own_messages=True) 35 | producer = canopen.timestamp.TimeProducer(network) 36 | 37 | # Provide a specific time to verify the proper encoding 38 | producer.transmit(1_927_999_438) # 2031-02-04T19:23:58+00:00 39 | msg = network.bus.recv(1) 40 | self.assertEqual(msg.arbitration_id, 0x100) 41 | self.assertEqual(msg.dlc, 6) 42 | self.assertEqual(msg.data, b"\xb0\xa4\x29\x04\x31\x43") 43 | 44 | # Test again with the current time as implicit timestamp 45 | current = time.time() 46 | with patch("canopen_asyncio.timestamp.time.time", return_value=current): 47 | current_from_epoch = current - canopen_asyncio.timestamp.OFFSET 48 | producer.transmit() 49 | msg = network.bus.recv(1) 50 | self.assertEqual(msg.arbitration_id, 0x100) 51 | self.assertEqual(msg.dlc, 6) 52 | ms, days = struct.unpack(" 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 | -------------------------------------------------------------------------------- /test/test_sync.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import unittest 3 | import asyncio 4 | 5 | import can 6 | 7 | import canopen_asyncio as canopen 8 | 9 | 10 | PERIOD = 0.01 11 | TIMEOUT = PERIOD * 10 12 | 13 | 14 | class TestSync(unittest.IsolatedAsyncioTestCase): 15 | 16 | __test__ = False # This is a base class, tests should not be run directly. 17 | use_async: bool 18 | 19 | def setUp(self): 20 | loop = None 21 | if self.use_async: 22 | loop = asyncio.get_event_loop() 23 | 24 | self.net = canopen.Network(loop=loop) 25 | self.net.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 26 | self.net.connect(interface="virtual") 27 | self.sync = canopen.sync.SyncProducer(self.net) 28 | self.rxbus = can.Bus(interface="virtual", loop=loop) 29 | 30 | def tearDown(self): 31 | self.net.disconnect() 32 | self.rxbus.shutdown() 33 | 34 | async def test_sync_producer_transmit(self): 35 | self.sync.transmit() 36 | msg = self.rxbus.recv(TIMEOUT) 37 | self.assertIsNotNone(msg) 38 | self.assertEqual(msg.arbitration_id, 0x80) 39 | self.assertEqual(msg.dlc, 0) 40 | 41 | async def test_sync_producer_transmit_count(self): 42 | self.sync.transmit(2) 43 | msg = self.rxbus.recv(TIMEOUT) 44 | self.assertIsNotNone(msg) 45 | self.assertEqual(msg.arbitration_id, 0x80) 46 | self.assertEqual(msg.dlc, 1) 47 | self.assertEqual(msg.data, b"\x02") 48 | 49 | async def test_sync_producer_start_invalid_period(self): 50 | with self.assertRaises(ValueError): 51 | self.sync.start(0) 52 | 53 | async def test_sync_producer_start(self): 54 | self.sync.start(PERIOD) 55 | self.addCleanup(self.sync.stop) 56 | 57 | acc = [] 58 | condition = threading.Condition() 59 | 60 | def hook(id_, data, ts): 61 | item = id_, data, ts 62 | acc.append(item) 63 | condition.notify() 64 | 65 | def periodicity(): 66 | # Check if periodicity has been established. 67 | if len(acc) > 2: 68 | delta = acc[-1][2] - acc[-2][2] 69 | return round(delta, ndigits=1) == PERIOD 70 | 71 | # Sample messages. 72 | with condition: 73 | condition.wait_for(periodicity, TIMEOUT) 74 | for msg in acc: 75 | self.assertIsNotNone(msg) 76 | self.assertEqual(msg[0], 0x80) 77 | self.assertEqual(msg[1], b"") 78 | 79 | self.sync.stop() 80 | # A message may have been in flight when we stopped the timer, 81 | # so allow a single failure. 82 | msg = self.rxbus.recv(TIMEOUT) 83 | if msg is not None: 84 | self.assertIsNone(self.net.bus.recv(TIMEOUT)) 85 | 86 | 87 | class TestSyncSync(TestSync): 88 | """ Test the functions in synchronous mode. """ 89 | __test__ = True 90 | use_async = False 91 | 92 | 93 | class TestSyncAsync(TestSync): 94 | """ Test the functions in asynchronous mode. """ 95 | __test__ = True 96 | use_async = True 97 | 98 | 99 | if __name__ == "__main__": 100 | unittest.main() 101 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /canopen/objectdictionary/epf.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import xml.etree.ElementTree as etree 3 | 4 | from canopen_asyncio import objectdictionary 5 | from canopen_asyncio.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /canopen/node/local.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import Dict, Union 5 | 6 | from canopen_asyncio import canopen 7 | from canopen_asyncio import objectdictionary 8 | from canopen_asyncio.emcy import EmcyProducer 9 | from canopen_asyncio.nmt import NmtSlave 10 | from canopen_asyncio.node.base import BaseNode 11 | from canopen_asyncio.objectdictionary import ObjectDictionary 12 | from canopen_asyncio.pdo import PDO, RPDO, TPDO 13 | from canopen_asyncio.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_node.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import asyncio 3 | 4 | import canopen_asyncio as canopen 5 | 6 | 7 | def count_subscribers(network: canopen.Network) -> int: 8 | """Count the number of subscribers in the network.""" 9 | return sum(len(n) for n in network.subscribers.values()) 10 | 11 | 12 | class TestLocalNode(unittest.IsolatedAsyncioTestCase): 13 | 14 | __test__ = False # This is a base class, tests should not be run directly. 15 | use_async: bool 16 | 17 | def setUp(self): 18 | loop = None 19 | if self.use_async: 20 | loop = asyncio.get_event_loop() 21 | 22 | self.network = canopen.Network(loop=loop) 23 | self.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 24 | self.network.connect(interface="virtual") 25 | 26 | self.node = canopen.LocalNode(2, canopen.objectdictionary.ObjectDictionary()) 27 | 28 | def tearDown(self): 29 | self.network.disconnect() 30 | 31 | async def test_associate_network(self): 32 | # Need to store the number of subscribers before associating because the 33 | # network implementation automatically adds subscribers to the list 34 | n_subscribers = count_subscribers(self.network) 35 | 36 | # Associating the network with the local node 37 | self.node.associate_network(self.network) 38 | self.assertIs(self.node.network, self.network) 39 | self.assertIs(self.node.sdo.network, self.network) 40 | self.assertIs(self.node.tpdo.network, self.network) 41 | self.assertIs(self.node.rpdo.network, self.network) 42 | self.assertIs(self.node.nmt.network, self.network) 43 | self.assertIs(self.node.emcy.network, self.network) 44 | 45 | # Test that its not possible to associate the network multiple times 46 | with self.assertRaises(RuntimeError) as cm: 47 | self.node.associate_network(self.network) 48 | self.assertIn("already associated with a network", str(cm.exception)) 49 | 50 | # Test removal of the network. The count of subscribers should 51 | # be the same as before the association 52 | self.node.remove_network() 53 | uninitalized = canopen.network._UNINITIALIZED_NETWORK 54 | self.assertIs(self.node.network, uninitalized) 55 | self.assertIs(self.node.sdo.network, uninitalized) 56 | self.assertIs(self.node.tpdo.network, uninitalized) 57 | self.assertIs(self.node.rpdo.network, uninitalized) 58 | self.assertIs(self.node.nmt.network, uninitalized) 59 | self.assertIs(self.node.emcy.network, uninitalized) 60 | self.assertEqual(count_subscribers(self.network), n_subscribers) 61 | 62 | # Test that its possible to deassociate the network multiple times 63 | self.node.remove_network() 64 | 65 | 66 | class TestLocalNodeSync(TestLocalNode): 67 | """ Run the tests in non-asynchronous mode. """ 68 | __test__ = True 69 | use_async = False 70 | 71 | 72 | class TestLocalNodeAsync(TestLocalNode): 73 | """ Run the tests in asynchronous mode. """ 74 | __test__ = True 75 | use_async = True 76 | 77 | 78 | class TestRemoteNode(unittest.IsolatedAsyncioTestCase): 79 | 80 | __test__ = False # This is a base class, tests should not be run directly. 81 | use_async: bool 82 | 83 | def setUp(self): 84 | loop = None 85 | if self.use_async: 86 | loop = asyncio.get_event_loop() 87 | 88 | self.network = canopen.Network(loop=loop) 89 | self.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 90 | self.network.connect(interface="virtual") 91 | 92 | self.node = canopen.RemoteNode(2, canopen.objectdictionary.ObjectDictionary()) 93 | 94 | def tearDown(self): 95 | self.network.disconnect() 96 | 97 | async def test_associate_network(self): 98 | # Need to store the number of subscribers before associating because the 99 | # network implementation automatically adds subscribers to the list 100 | n_subscribers = count_subscribers(self.network) 101 | 102 | # Associating the network with the local node 103 | self.node.associate_network(self.network) 104 | self.assertIs(self.node.network, self.network) 105 | self.assertIs(self.node.sdo.network, self.network) 106 | self.assertIs(self.node.tpdo.network, self.network) 107 | self.assertIs(self.node.rpdo.network, self.network) 108 | self.assertIs(self.node.nmt.network, self.network) 109 | self.assertIs(self.node.emcy.network, self.network) 110 | 111 | # Test that its not possible to associate the network multiple times 112 | with self.assertRaises(RuntimeError) as cm: 113 | self.node.associate_network(self.network) 114 | self.assertIn("already associated with a network", str(cm.exception)) 115 | 116 | # Test removal of the network. The count of subscribers should 117 | # be the same as before the association 118 | self.node.remove_network() 119 | uninitalized = canopen.network._UNINITIALIZED_NETWORK 120 | self.assertIs(self.node.network, uninitalized) 121 | self.assertIs(self.node.sdo.network, uninitalized) 122 | self.assertIs(self.node.tpdo.network, uninitalized) 123 | self.assertIs(self.node.rpdo.network, uninitalized) 124 | self.assertIs(self.node.nmt.network, uninitalized) 125 | self.assertIs(self.node.emcy.network, uninitalized) 126 | self.assertEqual(count_subscribers(self.network), n_subscribers) 127 | 128 | # Test that its possible to deassociate the network multiple times 129 | self.node.remove_network() 130 | 131 | 132 | class TestRemoteNodeSync(TestRemoteNode): 133 | """ Run the tests in non-asynchronous mode. """ 134 | __test__ = True 135 | use_async = False 136 | 137 | 138 | class TestRemoteNodeAsync(TestRemoteNode): 139 | """ Run the tests in asynchronous mode. """ 140 | __test__ = True 141 | use_async = True 142 | -------------------------------------------------------------------------------- /canopen/emcy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import asyncio 3 | import logging 4 | import struct 5 | import threading 6 | import time 7 | from typing import Callable, List, Optional 8 | 9 | from canopen_asyncio.async_guard import ensure_not_async 10 | from canopen_asyncio import canopen 11 | 12 | 13 | # Error code, error register, vendor specific data 14 | EMCY_STRUCT = struct.Struct(" "EmcyError": 67 | """Wait for a new EMCY to arrive. 68 | 69 | :param emcy_code: EMCY code to wait for 70 | :param timeout: Max time in seconds to wait 71 | 72 | :return: The EMCY exception object or None if timeout 73 | """ 74 | end_time = time.time() + timeout 75 | while True: 76 | # NOTE: Blocking lock 77 | with self.emcy_received: 78 | prev_log_size = len(self.log) 79 | # NOTE: Blocking call 80 | self.emcy_received.wait(timeout) 81 | if len(self.log) == prev_log_size: 82 | # Resumed due to timeout 83 | return None 84 | # Get last logged EMCY 85 | emcy = self.log[-1] 86 | logger.info("Got %s", emcy) 87 | if time.time() > end_time: 88 | # No valid EMCY received on time 89 | return None 90 | if emcy_code is None or emcy.code == emcy_code: 91 | # This is the one we're interested in 92 | return emcy 93 | 94 | async def async_wait( 95 | self, emcy_code: Optional[int] = None, timeout: float = 10 96 | ) -> EmcyError: 97 | """Wait for a new EMCY to arrive. 98 | 99 | :param emcy_code: EMCY code to wait for 100 | :param timeout: Max time in seconds to wait 101 | 102 | :return: The EMCY exception object or None if timeout 103 | """ 104 | return await asyncio.to_thread(self.wait, emcy_code, timeout) 105 | 106 | 107 | class EmcyProducer: 108 | 109 | def __init__(self, cob_id: int): 110 | self.network: canopen.network.Network = canopen.network._UNINITIALIZED_NETWORK 111 | self.cob_id = cob_id 112 | 113 | def send(self, code: int, register: int = 0, data: bytes = b""): 114 | payload = EMCY_STRUCT.pack(code, register, data) 115 | self.network.send_message(self.cob_id, payload) 116 | 117 | def reset(self, register: int = 0, data: bytes = b""): 118 | payload = EMCY_STRUCT.pack(0, register, data) 119 | self.network.send_message(self.cob_id, payload) 120 | 121 | 122 | class EmcyError(Exception): 123 | """EMCY exception.""" 124 | 125 | DESCRIPTIONS = [ 126 | # Code Mask Description 127 | (0x0000, 0xFF00, "Error Reset / No Error"), 128 | (0x1000, 0xFF00, "Generic Error"), 129 | (0x2000, 0xF000, "Current"), 130 | (0x3000, 0xF000, "Voltage"), 131 | (0x4000, 0xF000, "Temperature"), 132 | (0x5000, 0xFF00, "Device Hardware"), 133 | (0x6000, 0xF000, "Device Software"), 134 | (0x7000, 0xFF00, "Additional Modules"), 135 | (0x8000, 0xF000, "Monitoring"), 136 | (0x9000, 0xFF00, "External Error"), 137 | (0xF000, 0xFF00, "Additional Functions"), 138 | (0xFF00, 0xFF00, "Device Specific") 139 | ] 140 | 141 | def __init__(self, code: int, register: int, data: bytes, timestamp: float): 142 | #: EMCY code 143 | self.code = code 144 | #: Error register 145 | self.register = register 146 | #: Vendor specific data 147 | self.data = data 148 | #: Timestamp of message 149 | self.timestamp = timestamp 150 | 151 | def get_desc(self) -> str: 152 | for code, mask, description in self.DESCRIPTIONS: 153 | if self.code & mask == code: 154 | return description 155 | return "" 156 | 157 | def __str__(self): 158 | text = f"Code 0x{self.code:04X}" 159 | description = self.get_desc() 160 | if description: 161 | text = text + ", " + description 162 | return text 163 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /canopen/node/remote.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TextIO, Union 5 | 6 | from canopen_asyncio import canopen 7 | from canopen_asyncio.emcy import EmcyConsumer 8 | from canopen_asyncio.nmt import NmtMaster 9 | from canopen_asyncio.node.base import BaseNode 10 | from canopen_asyncio.objectdictionary import ODArray, ODRecord, ODVariable, ObjectDictionary 11 | from canopen_asyncio.pdo import PDO, RPDO, TPDO 12 | from canopen_asyncio.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 | self.emcy.network = network 63 | for sdo in self.sdo_channels: 64 | network.subscribe(sdo.tx_cobid, sdo.on_response) 65 | network.subscribe(0x700 + self.id, self.nmt.on_heartbeat) 66 | network.subscribe(0x80 + self.id, self.emcy.on_emcy) 67 | network.subscribe(0, self.nmt.on_command) 68 | 69 | def remove_network(self) -> None: 70 | if not self.has_network(): 71 | return 72 | for sdo in self.sdo_channels: 73 | self.network.unsubscribe(sdo.tx_cobid, sdo.on_response) 74 | self.network.unsubscribe(0x700 + self.id, self.nmt.on_heartbeat) 75 | self.network.unsubscribe(0x80 + self.id, self.emcy.on_emcy) 76 | self.network.unsubscribe(0, self.nmt.on_command) 77 | self.network = canopen.network._UNINITIALIZED_NETWORK 78 | self.sdo.network = canopen.network._UNINITIALIZED_NETWORK 79 | self.pdo.network = canopen.network._UNINITIALIZED_NETWORK 80 | self.tpdo.network = canopen.network._UNINITIALIZED_NETWORK 81 | self.rpdo.network = canopen.network._UNINITIALIZED_NETWORK 82 | self.nmt.network = canopen.network._UNINITIALIZED_NETWORK 83 | self.emcy.network = canopen.network._UNINITIALIZED_NETWORK 84 | 85 | def add_sdo(self, rx_cobid, tx_cobid): 86 | """Add an additional SDO channel. 87 | 88 | The SDO client will be added to :attr:`sdo_channels`. 89 | 90 | :param int rx_cobid: 91 | COB-ID that the server receives on 92 | :param int tx_cobid: 93 | COB-ID that the server responds with 94 | 95 | :return: The SDO client created 96 | :rtype: canopen.sdo.SdoClient 97 | """ 98 | client = SdoClient(rx_cobid, tx_cobid, self.object_dictionary) 99 | self.sdo_channels.append(client) 100 | if self.has_network(): 101 | self.network.subscribe(client.tx_cobid, client.on_response) 102 | return client 103 | 104 | def store(self, subindex=1): 105 | """Store parameters in non-volatile memory. 106 | 107 | :param int subindex: 108 | 1 = All parameters\n 109 | 2 = Communication related parameters\n 110 | 3 = Application related parameters\n 111 | 4 - 127 = Manufacturer specific 112 | """ 113 | self.sdo.download(0x1010, subindex, b"save") 114 | 115 | def restore(self, subindex=1): 116 | """Restore default parameters. 117 | 118 | :param int subindex: 119 | 1 = All parameters\n 120 | 2 = Communication related parameters\n 121 | 3 = Application related parameters\n 122 | 4 - 127 = Manufacturer specific 123 | """ 124 | self.sdo.download(0x1011, subindex, b"load") 125 | 126 | def __load_configuration_helper(self, index, subindex, name, value): 127 | """Helper function to send SDOs to the remote node 128 | :param index: Object index 129 | :param subindex: Object sub-index (if it does not exist e should be None) 130 | :param name: Object name 131 | :param value: Value to set in the object 132 | """ 133 | try: 134 | if subindex is not None: 135 | logger.info('SDO [0x%04X][0x%02X]: %s: %#06x', 136 | index, subindex, name, value) 137 | # NOTE: Blocking - protected in SdoClient 138 | self.sdo[index][subindex].raw = value 139 | else: 140 | # NOTE: Blocking - protected in SdoClient 141 | self.sdo[index].raw = value 142 | logger.info('SDO [0x%04X]: %s: %#06x', 143 | index, name, value) 144 | except SdoCommunicationError as e: 145 | logger.warning(str(e)) 146 | except SdoAbortedError as e: 147 | # WORKAROUND for broken implementations: the SDO is set but the error 148 | # "Attempt to write a read-only object" is raised any way. 149 | if e.code != 0x06010002: 150 | # Abort codes other than "Attempt to write a read-only object" 151 | # should still be reported. 152 | logger.warning('[ERROR SETTING object 0x%04X:%02X] %s', 153 | index, subindex, e) 154 | raise 155 | 156 | def load_configuration(self) -> None: 157 | """Load the configuration of the node from the Object Dictionary. 158 | 159 | Iterate through all objects in the Object Dictionary and download the 160 | values to the remote node via SDO. 161 | To avoid PDO mapping conflicts, PDO-related objects are handled through 162 | the methods :meth:`canopen.pdo.PdoBase.read` and 163 | :meth:`canopen.pdo.PdoBase.save`. 164 | 165 | """ 166 | # First apply PDO configuration from object dictionary 167 | self.pdo.read(from_od=True) 168 | self.pdo.save() 169 | 170 | # Now apply all other records in object dictionary 171 | for obj in self.object_dictionary.values(): 172 | if 0x1400 <= obj.index < 0x1c00: 173 | # Ignore PDO related objects 174 | continue 175 | if isinstance(obj, ODRecord) or isinstance(obj, ODArray): 176 | for subobj in obj.values(): 177 | if isinstance(subobj, ODVariable) and subobj.writable and (subobj.value is not None): 178 | self.__load_configuration_helper(subobj.index, subobj.subindex, subobj.name, subobj.value) 179 | elif isinstance(obj, ODVariable) and obj.writable and (obj.value is not None): 180 | self.__load_configuration_helper(obj.index, None, obj.name, obj.value) 181 | -------------------------------------------------------------------------------- /test/test_pdo.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import canopen_asyncio as canopen 4 | 5 | from .util import SAMPLE_EDS, tmp_file 6 | 7 | 8 | class TestPDO(unittest.IsolatedAsyncioTestCase): 9 | 10 | __test__ = False # This is a base class, tests should not be run directly. 11 | use_async: bool 12 | 13 | def setUp(self): 14 | node = canopen.LocalNode(1, SAMPLE_EDS) 15 | pdo = node.pdo.tx[1] 16 | pdo.add_variable('INTEGER16 value') # 0x2001 17 | pdo.add_variable('UNSIGNED8 value', length=4) # 0x2002 18 | pdo.add_variable('INTEGER8 value', length=4) # 0x2003 19 | pdo.add_variable('INTEGER32 value') # 0x2004 20 | pdo.add_variable('BOOLEAN value', length=1) # 0x2005 21 | pdo.add_variable('BOOLEAN value 2', length=1) # 0x2006 22 | 23 | # Write some values 24 | # Async: moved to set_values() method 25 | 26 | self.pdo = pdo 27 | self.node = node 28 | 29 | async def set_values(self): 30 | """Initialize the PDO with some valuues. 31 | 32 | Do this in a separate method in order to be abel to use the 33 | async and sync versions of the tests. 34 | """ 35 | node = self.node 36 | pdo = node.pdo.tx[1] 37 | if not self.use_async: 38 | # Write some values 39 | pdo['INTEGER16 value'].raw = -3 40 | pdo['UNSIGNED8 value'].raw = 0xf 41 | pdo['INTEGER8 value'].raw = -2 42 | pdo['INTEGER32 value'].raw = 0x01020304 43 | pdo['BOOLEAN value'].raw = False 44 | pdo['BOOLEAN value 2'].raw = True 45 | else: 46 | # Write some values (different from the synchronous values) 47 | await pdo['INTEGER16 value'].aset_raw(12) 48 | await pdo['UNSIGNED8 value'].aset_raw(0xe) 49 | await pdo['INTEGER8 value'].aset_raw(-4) 50 | await pdo['INTEGER32 value'].aset_raw(0x56789abc) 51 | await pdo['BOOLEAN value'].aset_raw(True) 52 | await pdo['BOOLEAN value 2'].aset_raw(False) 53 | 54 | async def test_pdo_map_bit_mapping(self): 55 | await self.set_values() 56 | if not self.use_async: 57 | self.assertEqual(self.pdo.data, b'\xfd\xff\xef\x04\x03\x02\x01\x02') 58 | else: 59 | self.assertEqual(self.pdo.data, b'\x0c\x00\xce\xbc\x9a\x78\x56\x01') 60 | 61 | async def test_pdo_map_getitem(self): 62 | await self.set_values() 63 | pdo = self.pdo 64 | if not self.use_async: 65 | self.assertEqual(pdo['INTEGER16 value'].raw, -3) 66 | self.assertEqual(pdo['UNSIGNED8 value'].raw, 0xf) 67 | self.assertEqual(pdo['INTEGER8 value'].raw, -2) 68 | self.assertEqual(pdo['INTEGER32 value'].raw, 0x01020304) 69 | self.assertEqual(pdo['BOOLEAN value'].raw, False) 70 | self.assertEqual(pdo['BOOLEAN value 2'].raw, True) 71 | else: 72 | self.assertEqual(await pdo['INTEGER16 value'].aget_raw(), 12) 73 | self.assertEqual(await pdo['UNSIGNED8 value'].aget_raw(), 0xe) 74 | self.assertEqual(await pdo['INTEGER8 value'].aget_raw(), -4) 75 | self.assertEqual(await pdo['INTEGER32 value'].aget_raw(), 0x56789abc) 76 | self.assertEqual(await pdo['BOOLEAN value'].aget_raw(), True) 77 | self.assertEqual(await pdo['BOOLEAN value 2'].aget_raw(), False) 78 | 79 | async def test_pdo_getitem(self): 80 | await self.set_values() 81 | node = self.node 82 | if not self.use_async: 83 | self.assertEqual(node.tpdo[1]['INTEGER16 value'].raw, -3) 84 | self.assertEqual(node.tpdo[1]['UNSIGNED8 value'].raw, 0xf) 85 | self.assertEqual(node.tpdo[1]['INTEGER8 value'].raw, -2) 86 | self.assertEqual(node.tpdo[1]['INTEGER32 value'].raw, 0x01020304) 87 | self.assertEqual(node.tpdo['INTEGER32 value'].raw, 0x01020304) 88 | self.assertEqual(node.tpdo[1]['BOOLEAN value'].raw, False) 89 | self.assertEqual(node.tpdo[1]['BOOLEAN value 2'].raw, True) 90 | 91 | # Test different types of access 92 | self.assertEqual(node.pdo[0x1600]['INTEGER16 value'].raw, -3) 93 | self.assertEqual(node.pdo['INTEGER16 value'].raw, -3) 94 | self.assertEqual(node.pdo.tx[1]['INTEGER16 value'].raw, -3) 95 | self.assertEqual(node.pdo[0x2001].raw, -3) 96 | self.assertEqual(node.tpdo[0x2001].raw, -3) 97 | self.assertEqual(node.pdo[0x2002].raw, 0xf) 98 | self.assertEqual(node.pdo['0x2002'].raw, 0xf) 99 | self.assertEqual(node.tpdo[0x2002].raw, 0xf) 100 | self.assertEqual(node.pdo[0x1600][0x2002].raw, 0xf) 101 | else: 102 | self.assertEqual(await node.tpdo[1]['INTEGER16 value'].aget_raw(), 12) 103 | self.assertEqual(await node.tpdo[1]['UNSIGNED8 value'].aget_raw(), 0xe) 104 | self.assertEqual(await node.tpdo[1]['INTEGER8 value'].aget_raw(), -4) 105 | self.assertEqual(await node.tpdo[1]['INTEGER32 value'].aget_raw(), 0x56789abc) 106 | self.assertEqual(await node.tpdo['INTEGER32 value'].aget_raw(), 0x56789abc) 107 | self.assertEqual(await node.tpdo[1]['BOOLEAN value'].aget_raw(), True) 108 | self.assertEqual(await node.tpdo[1]['BOOLEAN value 2'].aget_raw(), False) 109 | 110 | # Test different types of access 111 | self.assertEqual(await node.pdo[0x1600]['INTEGER16 value'].aget_raw(), 12) 112 | self.assertEqual(await node.pdo['INTEGER16 value'].aget_raw(), 12) 113 | self.assertEqual(await node.pdo.tx[1]['INTEGER16 value'].aget_raw(), 12) 114 | self.assertEqual(await node.pdo[0x2001].aget_raw(), 12) 115 | self.assertEqual(await node.tpdo[0x2001].aget_raw(), 12) 116 | self.assertEqual(await node.pdo[0x2002].aget_raw(), 0xe) 117 | self.assertEqual(await node.pdo['0x2002'].aget_raw(), 0xe) 118 | self.assertEqual(await node.tpdo[0x2002].aget_raw(), 0xe) 119 | self.assertEqual(await node.pdo[0x1600][0x2002].aget_raw(), 0xe) 120 | 121 | async def test_pdo_save(self): 122 | await self.set_values() 123 | if not self.use_async: 124 | self.node.tpdo.save() 125 | self.node.rpdo.save() 126 | else: 127 | await self.node.tpdo.asave() 128 | await self.node.rpdo.asave() 129 | 130 | async def test_pdo_save_skip_readonly(self): 131 | """Expect no exception when a record entry is not writable.""" 132 | await self.set_values() 133 | if not self.use_async: 134 | # Saving only happens with a defined COB ID and for specified parameters 135 | self.node.tpdo[1].cob_id = self.node.tpdo[1].predefined_cob_id 136 | self.node.tpdo[1].trans_type = 1 137 | self.node.tpdo[1].map_array[1].od.access_type = "r" 138 | self.node.tpdo[1].save() 139 | 140 | self.node.tpdo[2].cob_id = self.node.tpdo[2].predefined_cob_id 141 | self.node.tpdo[2].trans_type = 1 142 | self.node.tpdo[2].com_record[2].od.access_type = "r" 143 | self.node.tpdo[2].save() 144 | else: 145 | # Saving only happens with a defined COB ID and for specified parameters 146 | self.node.tpdo[1].cob_id = self.node.tpdo[1].predefined_cob_id 147 | self.node.tpdo[1].trans_type = 1 148 | self.node.tpdo[1].map_array[1].od.access_type = "r" 149 | await self.node.tpdo[1].asave() 150 | 151 | self.node.tpdo[2].cob_id = self.node.tpdo[2].predefined_cob_id 152 | self.node.tpdo[2].trans_type = 1 153 | self.node.tpdo[2].com_record[2].od.access_type = "r" 154 | await self.node.tpdo[2].asave() 155 | 156 | async def test_pdo_export(self): 157 | try: 158 | import canmatrix 159 | except ImportError: 160 | raise unittest.SkipTest("The PDO export API requires canmatrix") 161 | 162 | for pdo in "tpdo", "rpdo": 163 | with tmp_file(suffix=".csv") as tmp: 164 | fn = tmp.name 165 | with self.subTest(filename=fn, pdo=pdo): 166 | getattr(self.node, pdo).export(fn) 167 | with open(fn) as csv: 168 | header = csv.readline() 169 | self.assertIn("ID", header) 170 | self.assertIn("Frame Name", header) 171 | 172 | 173 | class TestPDOSync(TestPDO): 174 | """ Test the functions in synchronous mode. """ 175 | __test__ = True 176 | use_async = False 177 | 178 | 179 | class TestPDOAsync(TestPDO): 180 | """ Test the functions in asynchronous mode. """ 181 | __test__ = True 182 | use_async = True 183 | 184 | 185 | if __name__ == "__main__": 186 | unittest.main() 187 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | from canopen_asyncio import canopen 8 | from canopen_asyncio import objectdictionary 9 | from canopen_asyncio import variable 10 | from canopen_asyncio.async_guard import ensure_not_async 11 | from canopen_asyncio.utils import pretty_index 12 | 13 | 14 | class CrcXmodem: 15 | """Mimics CrcXmodem from crccheck.""" 16 | 17 | def __init__(self): 18 | self._value = 0 19 | 20 | def process(self, data): 21 | self._value = binascii.crc_hqx(data, self._value) 22 | 23 | def final(self): 24 | return self._value 25 | 26 | 27 | class SdoBase(Mapping): 28 | 29 | #: The CRC algorithm used for block transfers 30 | crc_cls = CrcXmodem 31 | 32 | def __init__( 33 | self, 34 | rx_cobid: int, 35 | tx_cobid: int, 36 | od: objectdictionary.ObjectDictionary, 37 | ): 38 | """ 39 | :param rx_cobid: 40 | COB-ID that the server receives on (usually 0x600 + node ID) 41 | :param tx_cobid: 42 | COB-ID that the server responds with (usually 0x580 + node ID) 43 | :param od: 44 | Object Dictionary to use for communication 45 | """ 46 | self.rx_cobid = rx_cobid 47 | self.tx_cobid = tx_cobid 48 | self.network: canopen.network.Network = canopen.network._UNINITIALIZED_NETWORK 49 | self.od = od 50 | 51 | def __getitem__( 52 | self, index: Union[str, int] 53 | ) -> Union[SdoVariable, SdoArray, SdoRecord]: 54 | entry = self.od[index] 55 | if isinstance(entry, objectdictionary.ODVariable): 56 | return SdoVariable(self, entry) 57 | elif isinstance(entry, objectdictionary.ODArray): 58 | return SdoArray(self, entry) 59 | elif isinstance(entry, objectdictionary.ODRecord): 60 | return SdoRecord(self, entry) 61 | 62 | def __iter__(self) -> Iterator[int]: 63 | return iter(self.od) 64 | 65 | def __len__(self) -> int: 66 | return len(self.od) 67 | 68 | def __contains__(self, key: Union[int, str]) -> bool: 69 | return key in self.od 70 | 71 | def get_variable( 72 | self, index: Union[int, str], subindex: int = 0 73 | ) -> Optional[SdoVariable]: 74 | """Get the variable object at specified index (and subindex if applicable). 75 | 76 | :return: SdoVariable if found, else `None` 77 | """ 78 | obj = self.get(index) 79 | if isinstance(obj, SdoVariable): 80 | return obj 81 | elif isinstance(obj, (SdoRecord, SdoArray)): 82 | return obj.get(subindex) 83 | 84 | def upload(self, index: int, subindex: int) -> bytes: 85 | raise NotImplementedError() 86 | 87 | async def aupload(self, index: int, subindex: int) -> bytes: 88 | raise NotImplementedError() 89 | 90 | def download( 91 | self, 92 | index: int, 93 | subindex: int, 94 | data: bytes, 95 | force_segment: bool = False, 96 | ) -> None: 97 | raise NotImplementedError() 98 | 99 | async def adownload( 100 | self, 101 | index: int, 102 | subindex: int, 103 | data: bytes, 104 | force_segment: bool = False, 105 | ) -> None: 106 | raise NotImplementedError() 107 | 108 | 109 | class SdoRecord(Mapping): 110 | 111 | def __init__(self, sdo_node: SdoBase, od: objectdictionary.ODRecord): 112 | self.sdo_node = sdo_node 113 | self.od = od 114 | 115 | def __repr__(self) -> str: 116 | return f"<{type(self).__qualname__} {self.od.name!r} at {pretty_index(self.od.index)}>" 117 | 118 | def __getitem__(self, subindex: Union[int, str]) -> SdoVariable: 119 | return SdoVariable(self.sdo_node, self.od[subindex]) 120 | 121 | def __iter__(self) -> Iterator[int]: 122 | # Skip the "highest subindex" entry, which is not part of the data 123 | return filter(None, iter(self.od)) 124 | 125 | async def aiter(self): 126 | for i in iter(self.od): 127 | yield i 128 | 129 | def __aiter__(self): 130 | return self.aiter() 131 | 132 | def __len__(self) -> int: 133 | # Skip the "highest subindex" entry, which is not part of the data 134 | return len(self.od) - int(0 in self.od) 135 | 136 | async def alen(self) -> int: 137 | return len(self.od) 138 | 139 | def __contains__(self, subindex: Union[int, str]) -> bool: 140 | return subindex in self.od 141 | 142 | 143 | class SdoArray(Mapping): 144 | 145 | def __init__(self, sdo_node: SdoBase, od: objectdictionary.ODArray): 146 | self.sdo_node = sdo_node 147 | self.od = od 148 | 149 | def __repr__(self) -> str: 150 | return f"<{type(self).__qualname__} {self.od.name!r} at {pretty_index(self.od.index)}>" 151 | 152 | def __getitem__(self, subindex: Union[int, str]) -> SdoVariable: 153 | return SdoVariable(self.sdo_node, self.od[subindex]) 154 | 155 | def __iter__(self) -> Iterator[int]: 156 | # Skip the "highest subindex" entry, which is not part of the data 157 | return iter(range(1, len(self) + 1)) 158 | 159 | async def aiter(self): 160 | for i in range(1, await self.alen() + 1): 161 | yield i 162 | 163 | def __aiter__(self): 164 | return self.aiter() 165 | 166 | def __len__(self) -> int: 167 | # NOTE: Blocking - protected in SdoClient 168 | return self[0].raw 169 | 170 | async def alen(self) -> int: 171 | return await self[0].aget_raw() # type: ignore[return-value] 172 | 173 | def __contains__(self, subindex: int) -> bool: 174 | return 0 <= subindex <= len(self) 175 | 176 | 177 | class SdoVariable(variable.Variable): 178 | """Access object dictionary variable values using SDO protocol.""" 179 | 180 | def __init__(self, sdo_node: SdoBase, od: objectdictionary.ODVariable): 181 | self.sdo_node = sdo_node 182 | variable.Variable.__init__(self, od) 183 | 184 | def __await__(self): 185 | return self.aget_raw().__await__() 186 | 187 | @ensure_not_async # NOTE: Safeguard for accidental async use 188 | def get_data(self) -> bytes: 189 | return self.sdo_node.upload(self.od.index, self.od.subindex) 190 | 191 | async def aget_data(self) -> bytes: 192 | return await self.sdo_node.aupload(self.od.index, self.od.subindex) 193 | 194 | @ensure_not_async # NOTE: Safeguard for accidental async use 195 | def set_data(self, data: bytes): 196 | force_segment = self.od.data_type == objectdictionary.DOMAIN 197 | self.sdo_node.download(self.od.index, self.od.subindex, data, force_segment) 198 | 199 | async def aset_data(self, data: bytes): 200 | force_segment = self.od.data_type == objectdictionary.DOMAIN 201 | await self.sdo_node.adownload(self.od.index, self.od.subindex, data, force_segment) 202 | 203 | @property 204 | def writable(self) -> bool: 205 | return self.od.writable 206 | 207 | @property 208 | def readable(self) -> bool: 209 | return self.od.readable 210 | 211 | @ensure_not_async # NOTE: Safeguard for accidental async use 212 | def open(self, mode="rb", encoding="ascii", buffering=1024, size=None, 213 | block_transfer=False, request_crc_support=True): 214 | """Open the data stream as a file like object. 215 | 216 | :param str mode: 217 | ========= ========================================================== 218 | Character Meaning 219 | --------- ---------------------------------------------------------- 220 | 'r' open for reading (default) 221 | 'w' open for writing 222 | 'b' binary mode (default) 223 | 't' text mode 224 | ========= ========================================================== 225 | :param str encoding: 226 | The str name of the encoding used to decode or encode the file. 227 | This will only be used in text mode. 228 | :param int buffering: 229 | An optional integer used to set the buffering policy. Pass 0 to 230 | switch buffering off (only allowed in binary mode), 1 to select line 231 | buffering (only usable in text mode), and an integer > 1 to indicate 232 | the size in bytes of a fixed-size chunk buffer. 233 | :param int size: 234 | Size of data to that will be transmitted. 235 | :param bool block_transfer: 236 | If block transfer should be used. 237 | :param bool request_crc_support: 238 | If crc calculation should be requested when using block transfer 239 | 240 | :returns: 241 | A file like object. 242 | """ 243 | return self.sdo_node.open(self.od.index, self.od.subindex, mode, 244 | encoding, buffering, size, block_transfer, request_crc_support=request_crc_support) 245 | 246 | async def aopen(self, mode="rb", encoding="ascii", buffering=1024, size=None, 247 | block_transfer=False, request_crc_support=True): 248 | """Open the data stream as a file like object. See open()""" 249 | return await self.sdo_node.aopen(self.od.index, self.od.subindex, mode, 250 | encoding, buffering, size, block_transfer, 251 | request_crc_support=request_crc_support) 252 | 253 | 254 | # For compatibility 255 | Record = SdoRecord 256 | Array = SdoArray 257 | Variable = SdoVariable 258 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | CANopen for Python, asyncio port 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.9 or newer. 10 | 11 | This library is the asyncio port of CANopen. It is a fork of the upstream 12 | canopen_ library, adding support for running in an asyncio environment. 13 | 14 | NOTE 15 | ----- 16 | 17 | There is ongoing work to merge this asyncio port back into the upstream 18 | canopen library. This is not yet complete, and this package was created to 19 | be able to use the asyncio port in the meantime. When the merge is 20 | complete, this package will be deprecated and the upstream library will 21 | support asyncio natively. 22 | 23 | See `canopen asyncio issue`_ and `canopen asyncio PR`_ for more information 24 | about the merge. 25 | 26 | 27 | Features 28 | -------- 29 | 30 | The library is mainly meant to be used as a master. 31 | 32 | * NMT master 33 | * SDO client 34 | * PDO producer/consumer 35 | * SYNC producer 36 | * EMCY consumer 37 | * TIME producer 38 | * LSS master 39 | * Object Dictionary from EDS 40 | * 402 profile support 41 | 42 | Incomplete support for creating slave nodes also exists. 43 | 44 | * SDO server 45 | * PDO producer/consumer 46 | * NMT slave 47 | * EMCY producer 48 | * Object Dictionary from EDS 49 | 50 | 51 | Installation 52 | ------------ 53 | 54 | Install from PyPI_ using ``pip``:: 55 | 56 | $ pip install canopen-asyncio 57 | 58 | Install from latest ``master`` on GitHub:: 59 | 60 | $ pip install https://github.com/sveinse/canopen-asyncio/archive/main.zip 61 | 62 | If you want to be able to change the code while using it, clone it then install 63 | it in `develop mode`_:: 64 | 65 | $ git clone https://github.com/sveinse/canopen-asyncio.git 66 | $ cd canopen-asyncio 67 | $ pip install -e . 68 | 69 | Unit tests can be run using the pytest_ framework:: 70 | 71 | $ pip install -r requirements-dev.txt 72 | $ pytest -v 73 | 74 | You can also use ``unittest`` standard library module:: 75 | 76 | $ python3 -m unittest discover test -v 77 | 78 | Documentation 79 | ------------- 80 | 81 | **NOTE:** The documentation is not yet updated for the asyncio port. These docs 82 | are for the upstream canopen library. 83 | 84 | Documentation can be found on Read the Docs: 85 | 86 | http://canopen.readthedocs.io/en/latest/ 87 | 88 | It can also be generated from a local clone using Sphinx_:: 89 | 90 | $ pip install -r doc/requirements.txt 91 | $ make -C doc html 92 | 93 | 94 | Hardware support 95 | ---------------- 96 | 97 | This library supports multiple hardware and drivers through the python-can_ package. 98 | See `the list of supported devices `_. 99 | 100 | It is also possible to integrate this library with a custom backend. 101 | 102 | 103 | Asyncio port 104 | ------------ 105 | 106 | To minimize the impact of the async changes, this port is designed to use the 107 | existing synchronous backend of the library. This means that the library 108 | uses :code:`asyncio.to_thread()` for many asynchronous operations. 109 | 110 | This port remains compatible with using it in a regular non-asyncio 111 | environment. This is selected with the `loop` parameter in the 112 | :code:`Network` constructor. If you pass a valid asyncio event loop, the 113 | library will run in async mode. If you pass `loop=None`, it will run in 114 | regular blocking mode. It cannot be used in both modes at the same time. 115 | 116 | 117 | Difference between async and non-async version 118 | ---------------------------------------------- 119 | 120 | This port have some differences with the upstream non-async version of canopen. 121 | 122 | * Minimum python version is 3.9, while the upstream version supports 3.8. 123 | 124 | * The :code:`Network` accepts additional parameters than upstream. It accepts 125 | :code:`loop` which selects the mode of operation. If :code:`None` it will 126 | run in blocking mode, otherwise it will run in async mode. It supports 127 | providing a custom CAN :code:`notifier` if the CAN bus will be shared by 128 | multiple protocols. 129 | 130 | * The :code:`Network` class can be (and should be) used in an async context 131 | manager. This will ensure the network will be automatically disconnected when 132 | exiting the context. See the example below. 133 | 134 | * Most async functions follow an "a" prefix naming scheme. 135 | E.g. the async variant for :code:`SdoClient.download()` is available 136 | as :code:`SdoClient.adownload()`. 137 | 138 | * Variables in the regular canopen library uses properties for getting and 139 | setting. This is replaced with awaitable methods in the async version. 140 | 141 | var = sdo['Variable'].raw # synchronous 142 | sdo['Variable'].raw = 12 # synchronous 143 | 144 | var = await sdo['Variable'].get_raw() # async 145 | await sdo['Variable'].set_raw(12) # async 146 | 147 | * Installed :code:`ensure_not_async()` sentinel guard in functions which 148 | prevents calling blocking functions in async context. It will raise the 149 | exception :code:`RuntimeError` "Calling a blocking function" when this 150 | happen. If this is encountered, it is likely that the code is not using the 151 | async variants of the library. 152 | 153 | * The mechanism for CAN bus callbacks have been changed. Callbacks might be 154 | async, which means they cannot be called immediately. This affects how 155 | error handling is done in the library. 156 | 157 | * The callbacks to the message handlers have been changed to be handled by 158 | :code:`Network.dispatch_callbacks()`. They are no longer called with any 159 | locks held, as this would not work with async. This affects: 160 | 161 | * :code:`PdoMaps.on_message` 162 | * :code:`EmcyConsumer.on_emcy` 163 | * :code:`NtmMaster.on_heartbaet` 164 | 165 | * SDO block upload and download is not yet supported in async mode. 166 | 167 | * :code:`ODVariable.__len__()` returns 64 bits instead of 8 bits to support 168 | truncated 24-bits integers, see #436 169 | 170 | * :code:`BaseNode402` does not work with async 171 | 172 | * :code:`LssMaster` does not work with async, except :code:`LssMaster.fast_scan()` 173 | 174 | * :code:`Bits` is not working in async 175 | 176 | 177 | Quick start 178 | ----------- 179 | 180 | Here are some quick examples of what you can do with the async port: 181 | 182 | .. code-block:: python 183 | 184 | import asyncio 185 | import canopen 186 | import can 187 | 188 | async def my_node(network, nodeid, od): 189 | 190 | # Create the node object and load the OD 191 | node = network.add_node(nodeid, od) 192 | 193 | # Read a variable using SDO 194 | device_name = await node.sdo['Manufacturer device name'].aget_raw() 195 | vendor_id = await node.sdo[0x1018][1].aget_raw() 196 | 197 | # Write a variable using SDO 198 | await node.sdo['Producer heartbeat time'].aset_raw(1000) 199 | 200 | # Read the PDOs from the remote 201 | await node.tpdo.aread() 202 | await node.rpdo.aread() 203 | 204 | # Set the module state 205 | node.nmt.state = 'OPERATIONAL' 206 | 207 | while True: 208 | 209 | # Wait for TPDO 1 210 | t = await node.tpdo[1].await_for_reception(1) 211 | if not t: 212 | continue 213 | 214 | # Get the TPDO 1 value 215 | speed = node.tpdo[1]['Velocity actual value'].phys 216 | val = node.tpdo['Some group.Some subindex'].raw 217 | 218 | # Sleep a little 219 | await asyncio.sleep(0.2) 220 | 221 | # Send RPDO 1 with some data 222 | node.rpdo[1]['Some variable'].phys = 42 223 | node.rpdo[1].transmit() 224 | 225 | async def main(): 226 | 227 | # Connect to the CAN bus 228 | # Arguments are passed to python-can's can.Bus() constructor 229 | # (see https://python-can.readthedocs.io/en/latest/bus.html). 230 | # Note the loop parameter to enable asyncio operation 231 | # 232 | # Connect alternative interfaces: 233 | # connect(interface='socketcan', channel='can0') 234 | # connect(interface='kvaser', channel=0, bitrate=250000) 235 | # connect(interface='pcan', channel='PCAN_USBBUS1', bitrate=250000) 236 | # connect(interface='ixxat', channel=0, bitrate=250000) 237 | # connect(interface='vector', app_name='CANalyzer', channel=0, bitrate=250000) 238 | # connect(interface='nican', channel='CAN0', bitrate=250000) 239 | loop = asyncio.get_running_loop() 240 | async with canopen.Network(loop=loop).connect( 241 | interface='pcan', bitrate=1000000) as network: 242 | 243 | # Create two independent tasks for two nodes 51 and 52 which will run concurrently 244 | task1 = asyncio.create_task(my_node(network, 51, '/path/to/object_dictionary.eds')) 245 | task2 = asyncio.create_task(my_node(network, 52, '/path/to/object_dictionary.eds')) 246 | 247 | # Wait for both to complete (which will never happen) 248 | await asyncio.gather((task1, task2)) 249 | 250 | asyncio.run(main()) 251 | 252 | 253 | Debugging 254 | --------- 255 | 256 | If you need to see what's going on in better detail, you can increase the 257 | logging_ level: 258 | 259 | .. code-block:: python 260 | 261 | import logging 262 | logging.basicConfig(level=logging.DEBUG) 263 | 264 | 265 | .. _PyPI: https://pypi.org/project/canopen-asyncio/ 266 | .. _canopen: https://pypi.org/project/canopen/ 267 | .. _CANopen standard: https://www.can-cia.org/can-knowledge 268 | .. _python-can: https://python-can.readthedocs.org/en/stable/ 269 | .. _Sphinx: http://www.sphinx-doc.org/ 270 | .. _develop mode: https://packaging.python.org/distributing/#working-in-development-mode 271 | .. _logging: https://docs.python.org/3/library/logging.html 272 | .. _pytest: https://docs.pytest.org/ 273 | .. _canopen asyncio issue: https://github.com/canopen-python/canopen/issues/272 274 | .. _canopen asyncio pr: https://github.com/canopen-python/canopen/pull/359 275 | -------------------------------------------------------------------------------- /test/test_emcy.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | import unittest 4 | import asyncio 5 | from contextlib import contextmanager 6 | 7 | import can 8 | 9 | import canopen_asyncio as canopen 10 | from canopen_asyncio.emcy import EmcyError, EmcyConsumer 11 | 12 | 13 | TIMEOUT = 0.1 14 | 15 | 16 | class TestEmcy(unittest.IsolatedAsyncioTestCase): 17 | 18 | __test__ = False # This is a base class, tests should not be run directly. 19 | use_async: bool 20 | 21 | def setUp(self): 22 | loop = None 23 | if self.use_async: 24 | loop = asyncio.get_event_loop() 25 | self.loop = loop 26 | 27 | self.net = canopen.Network(loop=loop) 28 | self.net.connect(interface="virtual") 29 | self.emcy = EmcyConsumer() 30 | self.emcy.network = self.net 31 | 32 | def tearDown(self): 33 | self.net.disconnect() 34 | 35 | def check_error(self, err, code, reg, data, ts): 36 | self.assertIsInstance(err, EmcyError) 37 | self.assertIsInstance(err, Exception) 38 | self.assertEqual(err.code, code) 39 | self.assertEqual(err.register, reg) 40 | self.assertEqual(err.data, data) 41 | self.assertAlmostEqual(err.timestamp, ts) 42 | 43 | async def dispatch_emcy(self, can_id, data, ts): 44 | # Dispatch an EMCY datagram. 45 | if self.use_async: 46 | await asyncio.to_thread( 47 | self.emcy.on_emcy, can_id, data, ts 48 | ) 49 | else: 50 | self.emcy.on_emcy(can_id, data, ts) 51 | 52 | async def test_emcy_consumer_on_emcy(self): 53 | # Make sure multiple callbacks receive the same information. 54 | acc1 = [] 55 | acc2 = [] 56 | self.emcy.add_callback(lambda err: acc1.append(err)) 57 | self.emcy.add_callback(lambda err: acc2.append(err)) 58 | 59 | # Dispatch an EMCY datagram. 60 | await self.dispatch_emcy(0x81, b'\x01\x20\x02\x00\x01\x02\x03\x04', 1000) 61 | 62 | self.assertEqual(len(self.emcy.log), 1) 63 | self.assertEqual(len(self.emcy.active), 1) 64 | 65 | error = self.emcy.log[0] 66 | self.assertEqual(self.emcy.active[0], error) 67 | for err in error, acc1[0], acc2[0]: 68 | self.check_error( 69 | error, code=0x2001, reg=0x02, 70 | data=bytes([0, 1, 2, 3, 4]), ts=1000, 71 | ) 72 | 73 | # Dispatch a new EMCY datagram. 74 | await self.dispatch_emcy(0x81, b'\x10\x90\x01\x04\x03\x02\x01\x00', 2000) 75 | self.assertEqual(len(self.emcy.log), 2) 76 | self.assertEqual(len(self.emcy.active), 2) 77 | 78 | error = self.emcy.log[1] 79 | self.assertEqual(self.emcy.active[1], error) 80 | for err in error, acc1[1], acc2[1]: 81 | self.check_error( 82 | error, code=0x9010, reg=0x01, 83 | data=bytes([4, 3, 2, 1, 0]), ts=2000, 84 | ) 85 | 86 | # Dispatch an EMCY reset. 87 | await self.dispatch_emcy(0x81, b'\x00\x00\x00\x00\x00\x00\x00\x00', 2000) 88 | self.assertEqual(len(self.emcy.log), 3) 89 | self.assertEqual(len(self.emcy.active), 0) 90 | 91 | async def test_emcy_consumer_reset(self): 92 | await self.dispatch_emcy(0x81, b'\x01\x20\x02\x00\x01\x02\x03\x04', 1000) 93 | await self.dispatch_emcy(0x81, b'\x10\x90\x01\x04\x03\x02\x01\x00', 2000) 94 | self.assertEqual(len(self.emcy.log), 2) 95 | self.assertEqual(len(self.emcy.active), 2) 96 | 97 | self.emcy.reset() 98 | self.assertEqual(len(self.emcy.log), 0) 99 | self.assertEqual(len(self.emcy.active), 0) 100 | 101 | async def test_emcy_consumer_wait(self): 102 | if self.use_async: 103 | self.skipTest("Not implemented for async") 104 | 105 | PAUSE = TIMEOUT / 2 106 | 107 | def push_err(): 108 | self.emcy.on_emcy(0x81, b'\x01\x20\x01\x01\x02\x03\x04\x05', 100) 109 | 110 | def check_err(err): 111 | self.assertIsNotNone(err) 112 | self.check_error( 113 | err, code=0x2001, reg=1, 114 | data=bytes([1, 2, 3, 4, 5]), ts=100, 115 | ) 116 | 117 | @contextmanager 118 | def timer(func): 119 | t = threading.Timer(PAUSE, func) 120 | try: 121 | yield t 122 | finally: 123 | t.join(TIMEOUT) 124 | 125 | # Check unfiltered wait, on timeout. 126 | if self.use_async: 127 | self.assertIsNone(await self.emcy.async_wait(timeout=TIMEOUT)) 128 | else: 129 | self.assertIsNone(self.emcy.wait(timeout=TIMEOUT)) 130 | 131 | # Check unfiltered wait, on success. 132 | with timer(push_err) as t: 133 | with self.assertLogs(level=logging.INFO): 134 | t.start() 135 | err = self.emcy.wait(timeout=TIMEOUT) 136 | check_err(err) 137 | 138 | # Check filtered wait, on success. 139 | with timer(push_err) as t: 140 | with self.assertLogs(level=logging.INFO): 141 | t.start() 142 | err = self.emcy.wait(0x2001, TIMEOUT) 143 | check_err(err) 144 | 145 | # Check filtered wait, on timeout. 146 | with timer(push_err) as t: 147 | t.start() 148 | self.assertIsNone(self.emcy.wait(0x9000, TIMEOUT)) 149 | 150 | def push_reset(): 151 | self.emcy.on_emcy(0x81, b'\x00\x00\x00\x00\x00\x00\x00\x00', 100) 152 | 153 | with timer(push_reset) as t: 154 | t.start() 155 | self.assertIsNone(self.emcy.wait(0x9000, TIMEOUT)) 156 | 157 | 158 | class TestEmcySync(TestEmcy): 159 | """ Run the tests in non-asynchronous mode. """ 160 | __test__ = True 161 | use_async = False 162 | 163 | 164 | class TestEmcyAsync(TestEmcy): 165 | """ Run the tests in asynchronous mode. """ 166 | __test__ = True 167 | use_async = True 168 | 169 | 170 | class TestEmcyError(unittest.TestCase): 171 | def test_emcy_error(self): 172 | error = EmcyError(0x2001, 0x02, b'\x00\x01\x02\x03\x04', 1000) 173 | self.assertEqual(error.code, 0x2001) 174 | self.assertEqual(error.data, b'\x00\x01\x02\x03\x04') 175 | self.assertEqual(error.register, 2) 176 | self.assertEqual(error.timestamp, 1000) 177 | 178 | def test_emcy_str(self): 179 | def check(code, expected): 180 | err = EmcyError(code, 1, b'', 1000) 181 | actual = str(err) 182 | self.assertEqual(actual, expected) 183 | 184 | check(0x2001, "Code 0x2001, Current") 185 | check(0x3abc, "Code 0x3ABC, Voltage") 186 | check(0x0234, "Code 0x0234") 187 | check(0xbeef, "Code 0xBEEF") 188 | 189 | def test_emcy_get_desc(self): 190 | def check(code, expected): 191 | err = EmcyError(code, 1, b'', 1000) 192 | actual = err.get_desc() 193 | self.assertEqual(actual, expected) 194 | 195 | check(0x0000, "Error Reset / No Error") 196 | check(0x00ff, "Error Reset / No Error") 197 | check(0x0100, "") 198 | check(0x1000, "Generic Error") 199 | check(0x10ff, "Generic Error") 200 | check(0x1100, "") 201 | check(0x2000, "Current") 202 | check(0x2fff, "Current") 203 | check(0x3000, "Voltage") 204 | check(0x3fff, "Voltage") 205 | check(0x4000, "Temperature") 206 | check(0x4fff, "Temperature") 207 | check(0x5000, "Device Hardware") 208 | check(0x50ff, "Device Hardware") 209 | check(0x5100, "") 210 | check(0x6000, "Device Software") 211 | check(0x6fff, "Device Software") 212 | check(0x7000, "Additional Modules") 213 | check(0x70ff, "Additional Modules") 214 | check(0x7100, "") 215 | check(0x8000, "Monitoring") 216 | check(0x8fff, "Monitoring") 217 | check(0x9000, "External Error") 218 | check(0x90ff, "External Error") 219 | check(0x9100, "") 220 | check(0xf000, "Additional Functions") 221 | check(0xf0ff, "Additional Functions") 222 | check(0xf100, "") 223 | check(0xff00, "Device Specific") 224 | check(0xffff, "Device Specific") 225 | 226 | 227 | class TestEmcyProducer(unittest.IsolatedAsyncioTestCase): 228 | 229 | __test__ = False # This is a base class, tests should not be run directly. 230 | use_async: bool 231 | 232 | def setUp(self): 233 | loop = None 234 | if self.use_async: 235 | loop = asyncio.get_event_loop() 236 | 237 | self.txbus = can.Bus(interface="virtual", loop=loop) 238 | self.rxbus = can.Bus(interface="virtual", loop=loop) 239 | self.net = canopen.Network(self.txbus, loop=loop) 240 | self.net.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 241 | self.net.connect() 242 | self.emcy = canopen.emcy.EmcyProducer(0x80 + 1) 243 | self.emcy.network = self.net 244 | 245 | def tearDown(self): 246 | self.net.disconnect() 247 | self.txbus.shutdown() 248 | self.rxbus.shutdown() 249 | 250 | def check_response(self, expected): 251 | msg = self.rxbus.recv(TIMEOUT) 252 | self.assertIsNotNone(msg) 253 | actual = msg.data 254 | self.assertEqual(actual, expected) 255 | 256 | async def test_emcy_producer_send(self): 257 | def check(*args, res): 258 | self.emcy.send(*args) 259 | self.check_response(res) 260 | 261 | check(0x2001, res=b'\x01\x20\x00\x00\x00\x00\x00\x00') 262 | check(0x2001, 0x2, res=b'\x01\x20\x02\x00\x00\x00\x00\x00') 263 | check(0x2001, 0x2, b'\x2a', res=b'\x01\x20\x02\x2a\x00\x00\x00\x00') 264 | 265 | async def test_emcy_producer_reset(self): 266 | def check(*args, res): 267 | self.emcy.reset(*args) 268 | self.check_response(res) 269 | 270 | check(res=b'\x00\x00\x00\x00\x00\x00\x00\x00') 271 | check(3, res=b'\x00\x00\x03\x00\x00\x00\x00\x00') 272 | check(3, b"\xaa\xbb", res=b'\x00\x00\x03\xaa\xbb\x00\x00\x00') 273 | 274 | 275 | class TestEmcyProducerSync(TestEmcyProducer): 276 | """ Run the tests in non-asynchronous mode. """ 277 | __test__ = True 278 | use_async = False 279 | 280 | 281 | class TestEmcyProducerAsync(TestEmcyProducer): 282 | """ Run the tests in asynchronous mode. """ 283 | __test__ = True 284 | use_async = True 285 | 286 | 287 | if __name__ == "__main__": 288 | unittest.main() 289 | -------------------------------------------------------------------------------- /canopen/sdo/server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from canopen_asyncio.async_guard import ensure_not_async 4 | from canopen_asyncio.sdo.base import SdoBase 5 | from canopen_asyncio.sdo.constants import * 6 | from canopen_asyncio.sdo.exceptions import * 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class SdoServer(SdoBase): 13 | """Creates an SDO server.""" 14 | 15 | def __init__(self, rx_cobid, tx_cobid, node): 16 | """ 17 | :param int rx_cobid: 18 | COB-ID that the server receives on (usually 0x600 + node ID) 19 | :param int tx_cobid: 20 | COB-ID that the server responds with (usually 0x580 + node ID) 21 | :param canopen.LocalNode od: 22 | Node object owning the server 23 | """ 24 | SdoBase.__init__(self, rx_cobid, tx_cobid, node.object_dictionary) 25 | self._node = node 26 | self._buffer = None 27 | self._toggle = 0 28 | self._index = None 29 | self._subindex = None 30 | self.last_received_error = 0x00000000 31 | 32 | # @callback # NOTE: called from another thread 33 | def on_request(self, can_id, data, timestamp): 34 | # FIXME: There is a lot of calls here, this must be checked for thread safe 35 | command, = struct.unpack_from("B", data, 0) 36 | ccs = command & 0xE0 37 | 38 | try: 39 | if ccs == REQUEST_UPLOAD: 40 | self.init_upload(data) 41 | elif ccs == REQUEST_SEGMENT_UPLOAD: 42 | self.segmented_upload(command) 43 | elif ccs == REQUEST_DOWNLOAD: 44 | self.init_download(data) 45 | elif ccs == REQUEST_SEGMENT_DOWNLOAD: 46 | self.segmented_download(command, data) 47 | elif ccs == REQUEST_BLOCK_UPLOAD: 48 | self.block_upload(data) 49 | elif ccs == REQUEST_BLOCK_DOWNLOAD: 50 | self.block_download(data) 51 | elif ccs == REQUEST_ABORTED: 52 | self.request_aborted(data) 53 | else: 54 | self.abort(ABORT_INVALID_COMMAND_SPECIFIER) 55 | except SdoAbortedError as exc: 56 | self.abort(exc.code) 57 | except KeyError as exc: 58 | self.abort(ABORT_NOT_IN_OD) 59 | except Exception as exc: 60 | self.abort() 61 | logger.exception(exc) 62 | 63 | def init_upload(self, request): 64 | _, index, subindex = SDO_STRUCT.unpack_from(request) 65 | self._index = index 66 | self._subindex = subindex 67 | res_command = RESPONSE_UPLOAD | SIZE_SPECIFIED 68 | response = bytearray(8) 69 | 70 | data = self._node.get_data(index, subindex, check_readable=True) 71 | size = len(data) 72 | if size == 0: 73 | logger.info("No content to upload for 0x%04X:%02X", index, subindex) 74 | self.abort(ABORT_NO_DATA_AVAILABLE) 75 | return 76 | elif size <= 4: 77 | logger.info("Expedited upload for 0x%04X:%02X", index, subindex) 78 | res_command |= EXPEDITED 79 | res_command |= (4 - size) << 2 80 | response[4:4 + size] = data 81 | else: 82 | logger.info("Initiating segmented upload for 0x%04X:%02X", index, subindex) 83 | struct.pack_into("> 2) & 0x3) 149 | else: 150 | size = 4 151 | self._node.set_data(index, subindex, request[4:4 + size], check_writable=True) 152 | else: 153 | logger.info("Initiating segmented download for 0x%04X:%02X", index, subindex) 154 | if command & SIZE_SPECIFIED: 155 | size, = struct.unpack_from("> 1) & 0x7) 168 | self._buffer.extend(request[1:last_byte]) 169 | 170 | if command & NO_MORE_DATA: 171 | self._node.set_data(self._index, 172 | self._subindex, 173 | self._buffer, 174 | check_writable=True) 175 | 176 | res_command = RESPONSE_SEGMENT_DOWNLOAD 177 | # Add toggle bit 178 | res_command |= self._toggle 179 | # Toggle bit for next message 180 | self._toggle ^= TOGGLE_BIT 181 | 182 | response = bytearray(8) 183 | response[0] = res_command 184 | self.send_response(response) 185 | 186 | def send_response(self, response): 187 | self.network.send_message(self.tx_cobid, response) 188 | 189 | def abort(self, abort_code=ABORT_GENERAL_ERROR): 190 | """Abort current transfer.""" 191 | data = struct.pack(" bytes: 198 | """May be called to make a read operation without an Object Dictionary. 199 | 200 | :param index: 201 | Index of object to read. 202 | :param subindex: 203 | Sub-index of object to read. 204 | 205 | :return: A data object. 206 | 207 | :raises canopen.SdoAbortedError: 208 | When node responds with an error. 209 | """ 210 | return self._node.get_data(index, subindex) 211 | 212 | async def aupload(self, index: int, subindex: int) -> bytes: 213 | """May be called to make a read operation without an Object Dictionary. 214 | 215 | :param index: 216 | Index of object to read. 217 | :param subindex: 218 | Sub-index of object to read. 219 | 220 | :return: A data object. 221 | 222 | :raises canopen.SdoAbortedError: 223 | When node responds with an error. 224 | """ 225 | return self._node.get_data(index, subindex) 226 | 227 | @ensure_not_async # NOTE: Safeguard for accidental async use 228 | def download( 229 | self, 230 | index: int, 231 | subindex: int, 232 | data: bytes, 233 | force_segment: bool = False, 234 | ): 235 | """May be called to make a write operation without an Object Dictionary. 236 | 237 | :param index: 238 | Index of object to write. 239 | :param subindex: 240 | Sub-index of object to write. 241 | :param data: 242 | Data to be written. 243 | 244 | :raises canopen.SdoAbortedError: 245 | When node responds with an error. 246 | """ 247 | return self._node.set_data(index, subindex, data) 248 | 249 | async def adownload( 250 | self, 251 | index: int, 252 | subindex: int, 253 | data: bytes, 254 | force_segment: bool = False, 255 | ): 256 | """May be called to make a write operation without an Object Dictionary. 257 | 258 | :param index: 259 | Index of object to write. 260 | :param subindex: 261 | Sub-index of object to write. 262 | :param data: 263 | Data to be written. 264 | 265 | :raises canopen.SdoAbortedError: 266 | When node responds with an error. 267 | """ 268 | return self._node.set_data(index, subindex, data) 269 | -------------------------------------------------------------------------------- /test/test_nmt.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | import unittest 4 | import asyncio 5 | 6 | import can 7 | 8 | import canopen_asyncio as canopen 9 | from canopen_asyncio.async_guard import AllowBlocking 10 | from canopen_asyncio.nmt import COMMAND_TO_STATE, NMT_COMMANDS, NMT_STATES, NmtError 11 | 12 | from .util import SAMPLE_EDS 13 | 14 | 15 | class TestNmtBase(unittest.TestCase): 16 | 17 | def setUp(self): 18 | node_id = 2 19 | self.node_id = node_id 20 | self.nmt = canopen.nmt.NmtBase(node_id) 21 | 22 | def test_send_command(self): 23 | dataset = ( 24 | "OPERATIONAL", 25 | "PRE-OPERATIONAL", 26 | "SLEEP", 27 | "STANDBY", 28 | "STOPPED", 29 | ) 30 | for cmd in dataset: 31 | with self.subTest(cmd=cmd): 32 | code = NMT_COMMANDS[cmd] 33 | self.nmt.send_command(code) 34 | expected = NMT_STATES[COMMAND_TO_STATE[code]] 35 | self.assertEqual(self.nmt.state, expected) 36 | 37 | def test_state_getset(self): 38 | for state in NMT_STATES.values(): 39 | with self.subTest(state=state): 40 | self.nmt.state = state 41 | self.assertEqual(self.nmt.state, state) 42 | 43 | def test_state_set_invalid(self): 44 | with self.assertRaisesRegex(ValueError, "INVALID"): 45 | self.nmt.state = "INVALID" 46 | 47 | 48 | class TestNmtMaster(unittest.IsolatedAsyncioTestCase): 49 | NODE_ID = 2 50 | PERIOD = 0.01 51 | TIMEOUT = PERIOD * 10 52 | 53 | __test__ = False # This is a base class, tests should not be run directly. 54 | use_async: bool 55 | 56 | def setUp(self): 57 | loop = None 58 | if self.use_async: 59 | loop = asyncio.get_event_loop() 60 | 61 | net = canopen.Network(loop=loop) 62 | net.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 63 | net.connect(interface="virtual") 64 | with self.assertLogs(): 65 | with AllowBlocking(): 66 | node = net.add_node(self.NODE_ID, SAMPLE_EDS) 67 | 68 | self.bus = can.Bus(interface="virtual", loop=loop) 69 | self.net = net 70 | self.node = node 71 | 72 | def tearDown(self): 73 | self.net.disconnect() 74 | self.bus.shutdown() 75 | 76 | def dispatch_heartbeat(self, code): 77 | cob_id = 0x700 + self.NODE_ID 78 | hb = can.Message(arbitration_id=cob_id, data=[code]) 79 | self.bus.send(hb) 80 | 81 | async def test_nmt_master_no_heartbeat(self): 82 | with self.assertRaisesRegex(NmtError, "heartbeat"): 83 | if self.use_async: 84 | await self.node.nmt.await_for_heartbeat(self.TIMEOUT) 85 | else: 86 | self.node.nmt.wait_for_heartbeat(self.TIMEOUT) 87 | with self.assertRaisesRegex(NmtError, "boot-up"): 88 | if self.use_async: 89 | await self.node.nmt.await_for_bootup(self.TIMEOUT) 90 | else: 91 | self.node.nmt.wait_for_bootup(self.TIMEOUT) 92 | 93 | async def test_nmt_master_on_heartbeat(self): 94 | # Skip the special INITIALISING case. 95 | for code in [st for st in NMT_STATES if st != 0]: 96 | with self.subTest(code=code): 97 | t = threading.Timer(0.01, self.dispatch_heartbeat, args=(code,)) 98 | t.start() 99 | self.addCleanup(t.join) 100 | if self.use_async: 101 | actual = await self.node.nmt.await_for_heartbeat(0.1) 102 | else: 103 | actual = self.node.nmt.wait_for_heartbeat(0.1) 104 | expected = NMT_STATES[code] 105 | self.assertEqual(actual, expected) 106 | 107 | async def test_nmt_master_wait_for_bootup(self): 108 | t = threading.Timer(0.01, self.dispatch_heartbeat, args=(0x00,)) 109 | t.start() 110 | self.addCleanup(t.join) 111 | if self.use_async: 112 | await self.node.nmt.await_for_bootup(self.TIMEOUT) 113 | else: 114 | self.node.nmt.wait_for_bootup(self.TIMEOUT) 115 | self.assertEqual(self.node.nmt.state, "PRE-OPERATIONAL") 116 | 117 | async def test_nmt_master_on_heartbeat_initialising(self): 118 | t = threading.Timer(0.01, self.dispatch_heartbeat, args=(0x00,)) 119 | t.start() 120 | self.addCleanup(t.join) 121 | if self.use_async: 122 | state = await self.node.nmt.await_for_heartbeat(self.TIMEOUT) 123 | else: 124 | state = self.node.nmt.wait_for_heartbeat(self.TIMEOUT) 125 | self.assertEqual(state, "PRE-OPERATIONAL") 126 | 127 | async def test_nmt_master_on_heartbeat_unknown_state(self): 128 | t = threading.Timer(0.01, self.dispatch_heartbeat, args=(0xcb,)) 129 | t.start() 130 | self.addCleanup(t.join) 131 | if self.use_async: 132 | state = await self.node.nmt.await_for_heartbeat(self.TIMEOUT) 133 | else: 134 | state = self.node.nmt.wait_for_heartbeat(self.TIMEOUT) 135 | # Expect the high bit to be masked out, and a formatted string to 136 | # be returned. 137 | self.assertEqual(state, "UNKNOWN STATE '75'") 138 | 139 | async def test_nmt_master_add_heartbeat_callback(self): 140 | event = threading.Event() 141 | state = None 142 | def hook(st): 143 | nonlocal state 144 | state = st 145 | event.set() 146 | self.node.nmt.add_heartbeat_callback(hook) 147 | 148 | self.dispatch_heartbeat(0x7f) 149 | if self.use_async: 150 | await asyncio.to_thread(event.wait, self.TIMEOUT) 151 | else: 152 | self.assertTrue(event.wait(self.TIMEOUT)) 153 | self.assertEqual(state, 127) 154 | 155 | async def test_nmt_master_node_guarding(self): 156 | self.node.nmt.start_node_guarding(self.PERIOD) 157 | msg = self.bus.recv(self.TIMEOUT) 158 | self.assertIsNotNone(msg) 159 | self.assertEqual(msg.arbitration_id, 0x700 + self.NODE_ID) 160 | self.assertEqual(msg.dlc, 0) 161 | 162 | self.node.nmt.stop_node_guarding() 163 | # A message may have been in flight when we stopped the timer, 164 | # so allow a single failure. 165 | msg = self.bus.recv(self.TIMEOUT) 166 | if msg is not None: 167 | self.assertIsNone(self.bus.recv(self.TIMEOUT)) 168 | 169 | 170 | class TestNmtMasterSync(TestNmtMaster): 171 | """ Run tests in non-asynchronous mode. """ 172 | __test__ = True 173 | use_async = False 174 | 175 | 176 | class TestNmtMasterAsync(TestNmtMaster): 177 | """ Run tests in asynchronous mode. """ 178 | __test__ = True 179 | use_async = True 180 | 181 | 182 | class TestNmtSlave(unittest.IsolatedAsyncioTestCase): 183 | 184 | __test__ = False # This is a base class, tests should not be run directly. 185 | use_async: bool 186 | 187 | def setUp(self): 188 | loop = None 189 | if self.use_async: 190 | loop = asyncio.get_event_loop() 191 | 192 | self.network1 = canopen.Network(loop=loop) 193 | self.network1.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 194 | self.network1.connect("test", interface="virtual") 195 | with self.assertLogs(): 196 | with AllowBlocking(): 197 | self.remote_node = self.network1.add_node(2, SAMPLE_EDS) 198 | 199 | self.network2 = canopen.Network(loop=loop) 200 | self.network2.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0 201 | self.network2.connect("test", interface="virtual") 202 | with self.assertLogs(): 203 | self.local_node = self.network2.create_node(2, SAMPLE_EDS) 204 | with AllowBlocking(): 205 | self.remote_node2 = self.network1.add_node(3, SAMPLE_EDS) 206 | self.local_node2 = self.network2.create_node(3, SAMPLE_EDS) 207 | 208 | def tearDown(self): 209 | self.network1.disconnect() 210 | self.network2.disconnect() 211 | 212 | async def test_start_two_remote_nodes(self): 213 | self.remote_node.nmt.state = "OPERATIONAL" 214 | # Line below is just so that we are sure the client have received the command 215 | # before we do the check 216 | if self.use_async: 217 | await asyncio.sleep(0.1) 218 | else: 219 | time.sleep(0.1) 220 | slave_state = self.local_node.nmt.state 221 | self.assertEqual(slave_state, "OPERATIONAL") 222 | 223 | self.remote_node2.nmt.state = "OPERATIONAL" 224 | # Line below is just so that we are sure the client have received the command 225 | # before we do the check 226 | if self.use_async: 227 | await asyncio.sleep(0.1) 228 | else: 229 | time.sleep(0.1) 230 | slave_state = self.local_node2.nmt.state 231 | self.assertEqual(slave_state, "OPERATIONAL") 232 | 233 | async def test_stop_two_remote_nodes_using_broadcast(self): 234 | # This is a NMT broadcast "Stop remote node" 235 | # ie. set the node in STOPPED state 236 | self.network1.send_message(0, [2, 0]) 237 | 238 | # Line below is just so that we are sure the slaves have received the command 239 | # before we do the check 240 | if self.use_async: 241 | await asyncio.sleep(0.1) 242 | else: 243 | time.sleep(0.1) 244 | slave_state = self.local_node.nmt.state 245 | self.assertEqual(slave_state, "STOPPED") 246 | slave_state = self.local_node2.nmt.state 247 | self.assertEqual(slave_state, "STOPPED") 248 | 249 | async def test_heartbeat(self): 250 | self.assertEqual(self.remote_node.nmt.state, "INITIALISING") 251 | self.assertEqual(self.local_node.nmt.state, "INITIALISING") 252 | self.local_node.nmt.state = "OPERATIONAL" 253 | if self.use_async: 254 | await self.local_node.sdo[0x1017].aset_raw(100) 255 | await asyncio.sleep(0.2) 256 | else: 257 | self.local_node.sdo[0x1017].raw = 100 258 | time.sleep(0.2) 259 | self.assertEqual(self.remote_node.nmt.state, "OPERATIONAL") 260 | 261 | self.local_node.nmt.stop_heartbeat() 262 | 263 | 264 | class TestNmtSlaveSync(TestNmtSlave): 265 | """ Run tests in non-asynchronous mode. """ 266 | __test__ = True 267 | use_async = False 268 | 269 | 270 | class TestNmtSlaveAsync(TestNmtSlave): 271 | """ Run tests in asynchronous mode. """ 272 | __test__ = True 273 | use_async = True 274 | 275 | 276 | if __name__ == "__main__": 277 | unittest.main() 278 | -------------------------------------------------------------------------------- /canopen/variable.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections.abc import Mapping 3 | from typing import Union 4 | 5 | from canopen_asyncio import objectdictionary 6 | from canopen_asyncio.async_guard import ensure_not_async 7 | from canopen_asyncio.utils import pretty_index 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class Variable: 14 | 15 | def __init__(self, od: objectdictionary.ODVariable): 16 | self.od = od 17 | #: Description of this variable from Object Dictionary, overridable 18 | self.name = od.name 19 | if isinstance(od.parent, (objectdictionary.ODRecord, 20 | objectdictionary.ODArray)): 21 | # Include the parent object's name for subentries 22 | self.name = od.parent.name + "." + od.name 23 | #: Holds a local, overridable copy of the Object Index 24 | self.index = od.index 25 | #: Holds a local, overridable copy of the Object Subindex 26 | self.subindex = od.subindex 27 | 28 | def __repr__(self) -> str: 29 | subindex = self.subindex if isinstance(self.od.parent, 30 | (objectdictionary.ODRecord, objectdictionary.ODArray) 31 | ) else None 32 | return f"<{type(self).__qualname__} {self.name!r} at {pretty_index(self.index, subindex)}>" 33 | 34 | def get_data(self) -> bytes: 35 | raise NotImplementedError("Variable is not readable") 36 | 37 | async def aget_data(self) -> bytes: 38 | raise NotImplementedError("Variable is not readable") 39 | 40 | def set_data(self, data: bytes): 41 | raise NotImplementedError("Variable is not writable") 42 | 43 | async def aset_data(self, data: bytes): 44 | raise NotImplementedError("Variable is not writable") 45 | 46 | @property 47 | def data(self) -> bytes: 48 | """Byte representation of the object as :class:`bytes`.""" 49 | return self.get_data() 50 | 51 | @data.setter 52 | def data(self, data: bytes): 53 | self.set_data(data) 54 | 55 | @property 56 | def raw(self) -> Union[int, bool, float, str, bytes]: 57 | """Raw representation of the object. 58 | 59 | This table lists the translations between object dictionary data types 60 | and Python native data types. 61 | 62 | +---------------------------+----------------------------+ 63 | | Data type | Python type | 64 | +===========================+============================+ 65 | | BOOLEAN | :class:`bool` | 66 | +---------------------------+----------------------------+ 67 | | UNSIGNEDxx | :class:`int` | 68 | +---------------------------+----------------------------+ 69 | | INTEGERxx | :class:`int` | 70 | +---------------------------+----------------------------+ 71 | | REALxx | :class:`float` | 72 | +---------------------------+----------------------------+ 73 | | VISIBLE_STRING | :class:`str` | 74 | +---------------------------+----------------------------+ 75 | | UNICODE_STRING | :class:`str` | 76 | +---------------------------+----------------------------+ 77 | | OCTET_STRING | :class:`bytes` | 78 | +---------------------------+----------------------------+ 79 | | DOMAIN | :class:`bytes` | 80 | +---------------------------+----------------------------+ 81 | 82 | Data types that this library does not handle yet must be read and 83 | written as :class:`bytes`. 84 | """ 85 | return self._get_raw(self.get_data()) 86 | 87 | async def aget_raw(self) -> Union[int, bool, float, str, bytes]: 88 | """Raw representation of the object, async variant""" 89 | return self._get_raw(await self.aget_data()) 90 | 91 | def _get_raw(self, data: bytes) -> Union[int, bool, float, str, bytes]: 92 | value = self.od.decode_raw(data) 93 | text = f"Value of {self.name!r} ({pretty_index(self.index, self.subindex)}) is {value!r}" 94 | if value in self.od.value_descriptions: 95 | text += f" ({self.od.value_descriptions[value]})" 96 | logger.debug(text) 97 | return value 98 | 99 | @raw.setter 100 | def raw(self, value: Union[int, bool, float, str, bytes]): 101 | self.set_data(self._set_raw(value)) 102 | 103 | async def aset_raw(self, value: Union[int, bool, float, str, bytes]): 104 | """Set the raw value of the object, async variant""" 105 | await self.aset_data(self._set_raw(value)) 106 | 107 | def _set_raw(self, value: Union[int, bool, float, str, bytes]): 108 | logger.debug("Writing %r (0x%04X:%02X) = %r", 109 | self.name, self.index, 110 | self.subindex, value) 111 | return self.od.encode_raw(value) 112 | 113 | @property 114 | def phys(self) -> Union[int, bool, float, str, bytes]: 115 | """Physical value scaled with some factor (defaults to 1). 116 | 117 | On object dictionaries that support specifying a factor, this can be 118 | either a :class:`float` or an :class:`int`. 119 | Non integers will be passed as is. 120 | """ 121 | return self._get_phys(self.raw) 122 | 123 | async def aget_phys(self) -> Union[int, bool, float, str, bytes]: 124 | """Physical value scaled with some factor (defaults to 1), async variant.""" 125 | return self._get_phys(await self.aget_raw()) 126 | 127 | def _get_phys(self, raw: Union[int, bool, float, str, bytes]): 128 | value = self.od.decode_phys(raw) 129 | if self.od.unit: 130 | logger.debug("Physical value is %s %s", value, self.od.unit) 131 | return value 132 | 133 | @phys.setter 134 | def phys(self, value: Union[int, bool, float, str, bytes]): 135 | self.raw = self.od.encode_phys(value) 136 | 137 | async def aset_phys(self, value: Union[int, bool, float, str, bytes]): 138 | """Set physical value scaled with some factor (defaults to 1). Async variant""" 139 | await self.aset_raw(self.od.encode_phys(value)) 140 | 141 | @property 142 | def desc(self) -> str: 143 | """Converts to and from a description of the value as a string.""" 144 | value = self.od.decode_desc(self.raw) 145 | logger.debug("Description is '%s'", value) 146 | return value 147 | 148 | async def aget_desc(self) -> str: 149 | """Converts to and from a description of the value as a string, async variant.""" 150 | value = self.od.decode_desc(await self.aget_raw()) 151 | logger.debug("Description is '%s'", value) 152 | return value 153 | 154 | @desc.setter 155 | def desc(self, desc: str): 156 | self.raw = self.od.encode_desc(desc) 157 | 158 | async def aset_desc(self, desc: str): 159 | """Set variable description, async variant.""" 160 | await self.aset_raw(self.od.encode_desc(desc)) 161 | 162 | @property 163 | def bits(self) -> "Bits": 164 | """Access bits using integers, slices, or bit descriptions.""" 165 | return Bits(self) 166 | 167 | def read(self, fmt: str = "raw") -> Union[int, bool, float, str, bytes]: 168 | """Alternative way of reading using a function instead of attributes. 169 | 170 | May be useful for asynchronous reading. 171 | 172 | :param str fmt: 173 | How to return the value 174 | - 'raw' 175 | - 'phys' 176 | - 'desc' 177 | 178 | :returns: 179 | The value of the variable. 180 | """ 181 | if fmt == "raw": 182 | return self.raw 183 | elif fmt == "phys": 184 | return self.phys 185 | elif fmt == "desc": 186 | return self.desc 187 | 188 | async def aread(self, fmt: str = "raw") -> Union[int, bool, float, str, bytes]: 189 | """Alternative way of reading using a function instead of attributes. Async variant.""" 190 | if fmt == "raw": 191 | return await self.aget_raw() 192 | elif fmt == "phys": 193 | return await self.aget_phys() 194 | elif fmt == "desc": 195 | return await self.aget_desc() 196 | raise ValueError(f"Unknown format '{fmt}'") 197 | 198 | def write( 199 | self, value: Union[int, bool, float, str, bytes], fmt: str = "raw" 200 | ) -> None: 201 | """Alternative way of writing using a function instead of attributes. 202 | 203 | May be useful for asynchronous writing. 204 | 205 | :param str fmt: 206 | How to write the value 207 | - 'raw' 208 | - 'phys' 209 | - 'desc' 210 | """ 211 | if fmt == "raw": 212 | self.raw = value 213 | elif fmt == "phys": 214 | self.phys = value 215 | elif fmt == "desc": 216 | self.desc = value 217 | 218 | async def awrite( 219 | self, value: Union[int, bool, float, str, bytes], fmt: str = "raw" 220 | ) -> None: 221 | """Alternative way of writing using a function instead of attributes. Async variant""" 222 | if fmt == "raw": 223 | await self.aset_raw(value) 224 | elif fmt == "phys": 225 | await self.aset_phys(value) 226 | elif fmt == "desc": 227 | await self.aset_desc(value) # type: ignore[arg-type] 228 | 229 | 230 | class Bits(Mapping): 231 | 232 | @ensure_not_async # NOTE: Safeguard for accidental async use 233 | def __init__(self, variable: Variable): 234 | self.variable = variable 235 | # FIXME: This is not compatible with async 236 | self.read() 237 | 238 | @staticmethod 239 | def _get_bits(key): 240 | if isinstance(key, slice): 241 | bits = range(key.start, key.stop, key.step) 242 | elif isinstance(key, int): 243 | bits = [key] 244 | else: 245 | bits = key 246 | return bits 247 | 248 | def __getitem__(self, key) -> int: 249 | return self.variable.od.decode_bits(self.raw, self._get_bits(key)) 250 | 251 | def __setitem__(self, key, value: int): 252 | self.raw = self.variable.od.encode_bits( 253 | self.raw, self._get_bits(key), value) 254 | self.write() 255 | 256 | def __iter__(self): 257 | return iter(self.variable.od.bit_definitions) 258 | 259 | def __len__(self): 260 | return len(self.variable.od.bit_definitions) 261 | 262 | def read(self): 263 | self.raw = self.variable.raw 264 | 265 | def write(self): 266 | self.variable.raw = self.raw 267 | 268 | async def aread(self): 269 | self.raw = await self.variable.aget_raw() 270 | 271 | async def awrite(self): 272 | await self.variable.aset_raw(self.raw) 273 | -------------------------------------------------------------------------------- /canopen/nmt.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import struct 4 | import threading 5 | import time 6 | from typing import Callable, Dict, Final, List, Optional, TYPE_CHECKING 7 | 8 | from canopen_asyncio.async_guard import ensure_not_async 9 | from canopen_asyncio import canopen 10 | 11 | if TYPE_CHECKING: 12 | from canopen.network import PeriodicMessageTask 13 | 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | NMT_STATES: Final[Dict[int, str]] = { 18 | 0: 'INITIALISING', 19 | 4: 'STOPPED', 20 | 5: 'OPERATIONAL', 21 | 80: 'SLEEP', 22 | 96: 'STANDBY', 23 | 127: 'PRE-OPERATIONAL' 24 | } 25 | 26 | NMT_COMMANDS: Final[Dict[str, int]] = { 27 | 'OPERATIONAL': 1, 28 | 'STOPPED': 2, 29 | 'SLEEP': 80, 30 | 'STANDBY': 96, 31 | 'PRE-OPERATIONAL': 128, 32 | 'INITIALISING': 129, 33 | 'RESET': 129, 34 | 'RESET COMMUNICATION': 130 35 | } 36 | 37 | COMMAND_TO_STATE: Final[Dict[int, int]] = { 38 | 1: 5, 39 | 2: 4, 40 | 80: 80, 41 | 96: 96, 42 | 128: 127, 43 | 129: 0, 44 | 130: 0 45 | } 46 | 47 | 48 | class NmtBase: 49 | """ 50 | Can set the state of the node it controls using NMT commands and monitor 51 | the current state using the heartbeat protocol. 52 | """ 53 | 54 | def __init__(self, node_id: int): 55 | self.id = node_id 56 | self.network: canopen.network.Network = canopen.network._UNINITIALIZED_NETWORK 57 | self._state = 0 58 | 59 | # @callback # NOTE: called from another thread 60 | def on_command(self, can_id, data, timestamp): 61 | cmd, node_id = struct.unpack_from("BB", data) 62 | if node_id in (self.id, 0): 63 | logger.info("Node %d received command %d", self.id, cmd) 64 | if cmd in COMMAND_TO_STATE: 65 | new_state = COMMAND_TO_STATE[cmd] 66 | if new_state != self._state: 67 | logger.info("New NMT state %s, old state %s", 68 | NMT_STATES[new_state], NMT_STATES[self._state]) 69 | # FIXME: Is this thread-safe? 70 | self._state = new_state 71 | 72 | def send_command(self, code: int): 73 | """Send an NMT command code to the node. 74 | 75 | :param code: 76 | NMT command code. 77 | """ 78 | if code in COMMAND_TO_STATE: 79 | new_state = COMMAND_TO_STATE[code] 80 | logger.info("Changing NMT state on node %d from %s to %s", 81 | self.id, NMT_STATES[self._state], NMT_STATES[new_state]) 82 | self._state = new_state 83 | 84 | @property 85 | def state(self) -> str: 86 | """Attribute to get or set node's state as a string. 87 | 88 | Can be one of: 89 | 90 | - 'INITIALISING' 91 | - 'PRE-OPERATIONAL' 92 | - 'STOPPED' 93 | - 'OPERATIONAL' 94 | - 'SLEEP' 95 | - 'STANDBY' 96 | - 'RESET' 97 | - 'RESET COMMUNICATION' 98 | """ 99 | try: 100 | return NMT_STATES[self._state] 101 | except KeyError: 102 | return f"UNKNOWN STATE '{self._state}'" 103 | 104 | @state.setter 105 | def state(self, new_state: str): 106 | if new_state in NMT_COMMANDS: 107 | code = NMT_COMMANDS[new_state] 108 | else: 109 | raise ValueError("'%s' is an invalid state. Must be one of %s." % 110 | (new_state, ", ".join(NMT_COMMANDS))) 111 | 112 | self.send_command(code) 113 | 114 | 115 | class NmtMaster(NmtBase): 116 | 117 | def __init__(self, node_id: int): 118 | super(NmtMaster, self).__init__(node_id) 119 | self._state_received = None 120 | self._node_guarding_producer: Optional[PeriodicMessageTask] = None 121 | #: Timestamp of last heartbeat message 122 | self.timestamp: Optional[float] = None 123 | self.state_update = threading.Condition() 124 | self._callbacks: List[Callable[[int], None]] = [] 125 | 126 | # @callback # NOTE: called from another thread 127 | @ensure_not_async # NOTE: Safeguard for accidental async use 128 | def on_heartbeat(self, can_id, data, timestamp): 129 | new_state, = struct.unpack_from("B", data) 130 | # Mask out toggle bit 131 | new_state &= 0x7F 132 | logger.debug("Received heartbeat can-id %d, state is %d", can_id, new_state) 133 | 134 | # NOTE: Blocking lock 135 | with self.state_update: 136 | self.timestamp = timestamp 137 | if new_state == 0: 138 | # Boot-up, will go to PRE-OPERATIONAL automatically 139 | self._state = 127 140 | else: 141 | self._state = new_state 142 | self._state_received = new_state 143 | self.state_update.notify_all() 144 | 145 | # Call all registered callbacks 146 | self.network.dispatch_callbacks(self._callbacks, new_state) 147 | 148 | def send_command(self, code: int): 149 | """Send an NMT command code to the node. 150 | 151 | :param code: 152 | NMT command code. 153 | """ 154 | super(NmtMaster, self).send_command(code) 155 | logger.info( 156 | "Sending NMT command 0x%X to node %d", code, self.id) 157 | self.network.send_message(0, [code, self.id]) 158 | 159 | @ensure_not_async # NOTE: Safeguard for accidental async use 160 | def wait_for_heartbeat(self, timeout: float = 10): 161 | """Wait until a heartbeat message is received.""" 162 | # NOTE: Blocking lock 163 | with self.state_update: 164 | self._state_received = None 165 | # NOTE: Blocking call 166 | self.state_update.wait(timeout) 167 | if self._state_received is None: 168 | raise NmtError("No boot-up or heartbeat received") 169 | return self.state 170 | 171 | async def await_for_heartbeat(self, timeout: float = 10): 172 | """Wait until a heartbeat message is received.""" 173 | return await asyncio.to_thread(self.wait_for_heartbeat, timeout) 174 | 175 | @ensure_not_async # NOTE: Safeguard for accidental async use 176 | def wait_for_bootup(self, timeout: float = 10) -> None: 177 | """Wait until a boot-up message is received.""" 178 | end_time = time.time() + timeout 179 | while True: 180 | now = time.time() 181 | # NOTE: Blocking lock 182 | with self.state_update: 183 | self._state_received = None 184 | # NOTE: Blocking call 185 | self.state_update.wait(end_time - now + 0.1) 186 | if now > end_time: 187 | raise NmtError("Timeout waiting for boot-up message") 188 | if self._state_received == 0: 189 | break 190 | 191 | async def await_for_bootup(self, timeout: float = 10) -> None: 192 | """Wait until a boot-up message is received.""" 193 | return await asyncio.to_thread(self.wait_for_bootup, timeout) 194 | 195 | def add_heartbeat_callback(self, callback: Callable[[int], None]): 196 | """Add function to be called on heartbeat reception. 197 | 198 | :param callback: 199 | Function that should accept an NMT state as only argument. 200 | """ 201 | self._callbacks.append(callback) 202 | 203 | # Compatibility with previous typo 204 | add_hearbeat_callback = add_heartbeat_callback 205 | 206 | def start_node_guarding(self, period: float): 207 | """Starts the node guarding mechanism. 208 | 209 | :param period: 210 | Period (in seconds) at which the node guarding should be advertised to the slave node. 211 | """ 212 | if self._node_guarding_producer: 213 | self.stop_node_guarding() 214 | self._node_guarding_producer = self.network.send_periodic(0x700 + self.id, None, period, True) 215 | 216 | def stop_node_guarding(self): 217 | """Stops the node guarding mechanism.""" 218 | if self._node_guarding_producer is not None: 219 | self._node_guarding_producer.stop() 220 | self._node_guarding_producer = None 221 | 222 | 223 | class NmtSlave(NmtBase): 224 | """ 225 | Handles the NMT state and handles heartbeat NMT service. 226 | """ 227 | 228 | def __init__(self, node_id: int, local_node): 229 | super(NmtSlave, self).__init__(node_id) 230 | self._send_task: Optional[PeriodicMessageTask] = None 231 | self._heartbeat_time_ms = 0 232 | self._local_node = local_node 233 | 234 | # @callback # NOTE: called from another thread 235 | def on_command(self, can_id, data, timestamp): 236 | super(NmtSlave, self).on_command(can_id, data, timestamp) 237 | self.update_heartbeat() 238 | 239 | def send_command(self, code: int) -> None: 240 | """Send an NMT command code to the node. 241 | 242 | :param code: 243 | NMT command code. 244 | """ 245 | old_state = self._state 246 | super(NmtSlave, self).send_command(code) 247 | 248 | if self._state == 0: 249 | logger.info("Sending boot-up message") 250 | self.network.send_message(0x700 + self.id, [0]) 251 | 252 | # The heartbeat service should start on the transition 253 | # between INITIALIZING and PRE-OPERATIONAL state 254 | if old_state == 0 and self._state == 127: 255 | # FIXME: Document why this was fixed 256 | if self._heartbeat_time_ms == 0: 257 | # NOTE: Blocking - protected in SdoClient 258 | heartbeat_time_ms = self._local_node.sdo[0x1017].raw 259 | else: 260 | heartbeat_time_ms = self._heartbeat_time_ms 261 | self.start_heartbeat(heartbeat_time_ms) 262 | else: 263 | self.update_heartbeat() 264 | 265 | def on_write(self, index, data, **kwargs): 266 | if index == 0x1017: 267 | heartbeat_time, = struct.unpack_from(" 0: 284 | logger.info("Start the heartbeat timer, interval is %d ms", self._heartbeat_time_ms) 285 | self._send_task = self.network.send_periodic( 286 | 0x700 + self.id, [self._state], heartbeat_time_ms / 1000.0) 287 | 288 | def stop_heartbeat(self): 289 | """Stop the heartbeat service.""" 290 | if self._send_task is not None: 291 | logger.info("Stop the heartbeat timer") 292 | self._send_task.stop() 293 | self._send_task = None 294 | 295 | # @callback # NOTE: Indirectly called from another thread via on_command 296 | def update_heartbeat(self): 297 | if self._send_task is not None: 298 | # FIXME: Make this thread-safe 299 | self._send_task.update([self._state]) 300 | 301 | 302 | class NmtError(Exception): 303 | """Some NMT operation failed.""" 304 | -------------------------------------------------------------------------------- /test/test_od.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from canopen_asyncio 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 | def test_get_item_index(self): 253 | test_od = od.ObjectDictionary() 254 | array = od.ODArray("Test Array", 0x1000) 255 | test_od.add_object(array) 256 | item = test_od[0x1000] 257 | self.assertIsInstance(item, od.ODArray) 258 | self.assertIs(item, array) 259 | item = test_od["Test Array"] 260 | self.assertIsInstance(item, od.ODArray) 261 | self.assertIs(item, array) 262 | 263 | 264 | class TestArray(unittest.TestCase): 265 | 266 | def test_subindexes(self): 267 | array = od.ODArray("Test Array", 0x1000) 268 | last_subindex = od.ODVariable("Last subindex", 0x1000, 0) 269 | last_subindex.data_type = od.UNSIGNED8 270 | array.add_member(last_subindex) 271 | array.add_member(od.ODVariable("Test Variable", 0x1000, 1)) 272 | array.add_member(od.ODVariable("Test Variable 2", 0x1000, 2)) 273 | self.assertEqual(array[0].name, "Last subindex") 274 | self.assertEqual(array[1].name, "Test Variable") 275 | self.assertEqual(array[2].name, "Test Variable 2") 276 | self.assertEqual(array[3].name, "Test Variable_3") 277 | 278 | 279 | if __name__ == "__main__": 280 | unittest.main() 281 | --------------------------------------------------------------------------------