├── .github └── workflows │ └── test-and-release.yml ├── .gitignore ├── .gitmodules ├── .idea └── dictionaries │ ├── pavel.xml │ └── project.xml ├── .readthedocs.yml ├── .test_deps ├── .gitignore ├── README.md ├── ncat.exe ├── npcap-0.96.exe └── sonar-scanner-cli-5.0.1.3006-linux.zip ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── README.md ├── demo ├── README.md ├── custom_data_types │ └── sirius_cyber_corp │ │ ├── PerformLinearLeastSquaresFit.1.0.dsdl │ │ └── PointXY.1.0.dsdl ├── demo_app.py ├── launch.orc.yaml ├── plant.py ├── requirements.txt └── setup.py ├── docs ├── .gitignore ├── conf.py ├── figures │ ├── arch-non-redundant.svg │ ├── arch-redundant.svg │ ├── subject_synchronizer_monotonic_clustering.py │ └── subject_synchronizer_monotonic_clustering.svg ├── index.rst ├── pages │ ├── api.rst │ ├── architecture.rst │ ├── changelog.rst │ ├── demo.rst │ ├── dev.rst │ ├── faq.rst │ ├── installation.rst │ └── synth │ │ ├── application_module_summary.py │ │ ├── installation_option_matrix.py │ │ └── transport_summary.py ├── ref_fixer_hack.py ├── requirements.txt └── static │ ├── custom.css │ ├── favicon.ico │ └── opencyphal-favicon.svg ├── noxfile.py ├── pycyphal ├── __init__.py ├── _version.py ├── application │ ├── __init__.py │ ├── _node.py │ ├── _node_factory.py │ ├── _port_list_publisher.py │ ├── _register_server.py │ ├── _registry_factory.py │ ├── _transport_factory.py │ ├── diagnostic.py │ ├── file.py │ ├── heartbeat_publisher.py │ ├── node_tracker.py │ ├── plug_and_play.py │ └── register │ │ ├── __init__.py │ │ ├── _registry.py │ │ ├── _value.py │ │ └── backend │ │ ├── __init__.py │ │ ├── dynamic.py │ │ └── static.py ├── dsdl │ ├── __init__.py │ ├── _compiler.py │ ├── _import_hook.py │ └── _support_wrappers.py ├── presentation │ ├── __init__.py │ ├── _port │ │ ├── __init__.py │ │ ├── _base.py │ │ ├── _client.py │ │ ├── _error.py │ │ ├── _publisher.py │ │ ├── _server.py │ │ └── _subscriber.py │ ├── _presentation.py │ └── subscription_synchronizer │ │ ├── __init__.py │ │ ├── _common.py │ │ ├── monotonic_clustering.py │ │ └── transfer_id.py ├── py.typed ├── transport │ ├── __init__.py │ ├── _data_specifier.py │ ├── _error.py │ ├── _payload_metadata.py │ ├── _session.py │ ├── _timestamp.py │ ├── _tracer.py │ ├── _transfer.py │ ├── _transport.py │ ├── can │ │ ├── __init__.py │ │ ├── _can.py │ │ ├── _frame.py │ │ ├── _identifier.py │ │ ├── _input_dispatch_table.py │ │ ├── _session │ │ │ ├── __init__.py │ │ │ ├── _base.py │ │ │ ├── _input.py │ │ │ ├── _output.py │ │ │ ├── _transfer_reassembler.py │ │ │ └── _transfer_sender.py │ │ ├── _tracer.py │ │ └── media │ │ │ ├── __init__.py │ │ │ ├── _filter.py │ │ │ ├── _frame.py │ │ │ ├── _media.py │ │ │ ├── candump │ │ │ ├── __init__.py │ │ │ └── _candump.py │ │ │ ├── pythoncan │ │ │ ├── __init__.py │ │ │ └── _pythoncan.py │ │ │ ├── socketcan │ │ │ ├── __init__.py │ │ │ └── _socketcan.py │ │ │ └── socketcand │ │ │ ├── __init__.py │ │ │ └── _socketcand.py │ ├── commons │ │ ├── __init__.py │ │ ├── _refragment.py │ │ ├── crc │ │ │ ├── __init__.py │ │ │ ├── _base.py │ │ │ ├── _crc16_ccitt.py │ │ │ ├── _crc32c.py │ │ │ └── _crc64we.py │ │ └── high_overhead_transport │ │ │ ├── __init__.py │ │ │ ├── _alien_transfer_reassembler.py │ │ │ ├── _common.py │ │ │ ├── _frame.py │ │ │ ├── _transfer_reassembler.py │ │ │ └── _transfer_serializer.py │ ├── loopback │ │ ├── __init__.py │ │ ├── _input_session.py │ │ ├── _loopback.py │ │ ├── _output_session.py │ │ └── _tracer.py │ ├── redundant │ │ ├── __init__.py │ │ ├── _deduplicator │ │ │ ├── __init__.py │ │ │ ├── _base.py │ │ │ ├── _cyclic.py │ │ │ └── _monotonic.py │ │ ├── _error.py │ │ ├── _redundant_transport.py │ │ ├── _session │ │ │ ├── __init__.py │ │ │ ├── _base.py │ │ │ ├── _input.py │ │ │ └── _output.py │ │ └── _tracer.py │ ├── serial │ │ ├── __init__.py │ │ ├── _frame.py │ │ ├── _serial.py │ │ ├── _session │ │ │ ├── __init__.py │ │ │ ├── _base.py │ │ │ ├── _input.py │ │ │ └── _output.py │ │ ├── _stream_parser.py │ │ └── _tracer.py │ └── udp │ │ ├── __init__.py │ │ ├── _frame.py │ │ ├── _ip │ │ ├── __init__.py │ │ ├── _endpoint_mapping.py │ │ ├── _link_layer.py │ │ ├── _socket_factory.py │ │ └── _v4.py │ │ ├── _session │ │ ├── __init__.py │ │ ├── _input.py │ │ └── _output.py │ │ ├── _tracer.py │ │ └── _udp.py └── util │ ├── __init__.py │ ├── _broadcast.py │ ├── _introspect.py │ ├── _mark_last.py │ └── _repr.py ├── pyproject.toml ├── setup.cfg ├── setup.py ├── sonar-project.properties └── tests ├── __init__.py ├── application ├── __init__.py ├── diagnostic.py ├── file.py ├── node.py ├── node_tracker.py ├── plug_and_play.py └── transport_factory_candump.py ├── conftest.py ├── demo ├── __init__.py ├── _demo_app.py ├── _setup.py ├── _subprocess.py └── conftest.py ├── dsdl ├── __init__.py ├── _compiler.py ├── conftest.py └── test_dsdl_namespace │ ├── delimited │ ├── A.1.0.dsdl │ ├── A.1.1.dsdl │ ├── BDelimited.1.0.dsdl │ ├── BDelimited.1.1.dsdl │ ├── BSealed.1.0.dsdl │ ├── CFixed.1.0.dsdl │ ├── CFixed.1.1.dsdl │ ├── CVariable.1.0.dsdl │ └── CVariable.1.1.dsdl │ ├── if │ ├── B.1.0.dsdl │ ├── C.1.0.dsdl │ └── del.1.0.dsdl │ └── numpy │ ├── CombinatorialExplosion.0.1.dsdl │ ├── Complex.254.255.dsdl │ └── RGB888_3840x2748.0.1.dsdl ├── presentation ├── __init__.py ├── _pub_sub.py ├── _rpc.py ├── conftest.py └── subscription_synchronizer │ ├── __init__.py │ ├── monotonic_clustering.py │ └── transfer_id.py ├── transport ├── __init__.py ├── _primitives.py ├── can │ ├── __init__.py │ ├── _can.py │ └── media │ │ ├── __init__.py │ │ ├── _pythoncan.py │ │ ├── _socketcan.py │ │ ├── _socketcand.py │ │ └── mock │ │ ├── __init__.py │ │ └── _media.py ├── loopback │ ├── __init__.py │ └── _loopback.py ├── redundant │ ├── __init__.py │ ├── _redundant.py │ ├── _session_input.py │ └── _session_output.py ├── serial │ ├── __init__.py │ ├── _input_session.py │ ├── _output_session.py │ └── _serial.py └── udp │ ├── __init__.py │ ├── _input_session.py │ ├── _output_session.py │ ├── _udp.py │ └── ip │ ├── __init__.py │ ├── link_layer.py │ └── v4.py └── util ├── __init__.py └── import_error ├── __init__.py └── _subpackage └── __init__.py /.github/workflows/test-and-release.yml: -------------------------------------------------------------------------------- 1 | name: 'Test & Release' 2 | on: [ push, pull_request ] 3 | 4 | jobs: 5 | test: 6 | name: Test PyCyphal 7 | # Run on push OR on 3rd-party PR. 8 | # https://docs.github.com/en/webhooks/webhook-events-and-payloads?actionType=edited#pull_request 9 | if: (github.event_name == 'push') || github.event.pull_request.head.repo.fork 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | # We text the full matrix on GNU/Linux 14 | os: [ ubuntu-latest ] 15 | py: [ '3.10', '3.11', '3.12', '3.13' ] 16 | # On Windows, we select the configurations we test manually because we only have a few runners, 17 | # and because the infrastructure is hard to maintain using limited resources. 18 | include: 19 | - { os: win-pcap, py: '3.10' } 20 | - { os: win-pcap, py: '3.12' } 21 | runs-on: ${{ matrix.os }} 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 25 | FORCE_COLOR: 1 26 | steps: 27 | - uses: actions/checkout@v4 28 | with: 29 | submodules: recursive 30 | 31 | - uses: actions/setup-python@v5 32 | with: 33 | python-version: ${{ matrix.py }} 34 | 35 | - name: Configure environment -- GNU/Linux 36 | if: ${{ runner.os == 'Linux' }} 37 | run: | 38 | sudo apt-get --ignore-missing update || true 39 | sudo apt-get install -y linux-*-extra-$(uname -r) graphviz ncat 40 | 41 | # Configure socketcand 42 | sudo apt-get install -y meson libconfig-dev libsocketcan-dev 43 | git clone https://github.com/linux-can/socketcand.git 44 | cd socketcand 45 | meson setup -Dlibconfig=true --buildtype=release build 46 | meson compile -C build 47 | sudo meson install -C build 48 | 49 | # Collect diagnostics 50 | python --version 51 | ip link show 52 | 53 | - name: Configure environment -- Windows 54 | if: ${{ runner.os == 'Windows' }} 55 | run: | 56 | # Collect diagnostics 57 | python --version 58 | systeminfo 59 | route print 60 | ipconfig /all 61 | 62 | # Only one statement per step to ensure the error codes are not ignored by PowerShell. 63 | - run: python -m pip install --upgrade pip setuptools nox 64 | - run: nox --non-interactive --error-on-missing-interpreters --session test pristine --python ${{ matrix.py }} 65 | - run: nox --non-interactive --no-error-on-missing-interpreters --session demo check_style docs 66 | 67 | - uses: actions/upload-artifact@v4 68 | with: 69 | name: "${{matrix.os}}_py${{matrix.py}}" 70 | path: ".nox/**/*.log" 71 | include-hidden-files: true 72 | 73 | release: 74 | name: Release PyCyphal 75 | runs-on: ubuntu-latest 76 | if: > 77 | (github.event_name == 'push') && 78 | (contains(github.event.head_commit.message, '#release') || contains(github.ref, '/master')) 79 | needs: test 80 | steps: 81 | - name: Check out 82 | uses: actions/checkout@v4 83 | with: 84 | submodules: recursive 85 | 86 | - name: Create distribution wheel 87 | run: | 88 | python -m pip install --upgrade pip packaging setuptools wheel twine 89 | python setup.py sdist bdist_wheel 90 | 91 | - name: Get release version 92 | run: | 93 | cd pycyphal 94 | echo "pycyphal_version=$(python -c 'from _version import __version__; print(__version__)')" >> $GITHUB_ENV 95 | 96 | - name: Upload distribution 97 | run: | 98 | python -m twine upload dist/* 99 | env: 100 | TWINE_USERNAME: __token__ 101 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN_PYCYPHAL }} 102 | 103 | - name: Push version tag 104 | uses: mathieudutour/github-tag-action@v6.2 105 | with: 106 | github_token: ${{ secrets.GITHUB_TOKEN }} 107 | custom_tag: ${{ env.pycyphal_version }} 108 | tag_prefix: '' 109 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *.so 4 | .Python 5 | env/ 6 | build/ 7 | _build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | *.egg-info/ 19 | .installed.cfg 20 | *.egg 21 | pip-log.txt 22 | pip-delete-this-directory.txt 23 | 24 | htmlcov/ 25 | .nox/ 26 | .coverage 27 | .coverage.* 28 | .xml 29 | .cache 30 | .*_cache 31 | nosetests.xml 32 | coverage.xml 33 | *,cover 34 | .*mypy.json 35 | .python-version 36 | 37 | .project 38 | .pydevproject 39 | .DS_Store 40 | .settings/ 41 | **/.idea/* 42 | !**/.idea/dictionaries 43 | !**/.idea/dictionaries/* 44 | 45 | *.tmp 46 | *.log 47 | .*generated 48 | .*compiled 49 | *.cache 50 | *.db 51 | nunavut_support.py 52 | 53 | .scannerwork 54 | 55 | # MS stuff 56 | *.code-workspace 57 | .vscode 58 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "public_regulated_data_types_for_testing"] 2 | path = demo/public_regulated_data_types 3 | url = https://github.com/OpenCyphal/public_regulated_data_types 4 | -------------------------------------------------------------------------------- /.idea/dictionaries/project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | usbtingo 5 | 6 | 7 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 2 | 3 | version: 2 4 | 5 | build: 6 | os: ubuntu-22.04 7 | tools: 8 | python: "3.10" 9 | apt_packages: 10 | - graphviz 11 | 12 | sphinx: 13 | configuration: docs/conf.py 14 | fail_on_warning: true 15 | 16 | submodules: 17 | include: all 18 | recursive: true 19 | 20 | python: 21 | install: 22 | - requirements: docs/requirements.txt 23 | -------------------------------------------------------------------------------- /.test_deps/.gitignore: -------------------------------------------------------------------------------- 1 | # Unpacked archive 2 | sonar-scanner*/ 3 | !*.zip 4 | -------------------------------------------------------------------------------- /.test_deps/README.md: -------------------------------------------------------------------------------- 1 | # Test dependencies 2 | 3 | This directory contains external dependencies necessary for running the integration test suite 4 | that cannot be sourced from package managers. 5 | To see how these components are used, refer to the test scripts. 6 | 7 | Please keep this document in sync with the contents of this directory. 8 | 9 | ## Nmap project binaries 10 | 11 | ### Portable Ncat 12 | 13 | Ncat is needed for brokering TCP connections that emulate serial port connections. 14 | This is needed for testing the Cyphal/serial transport without having to access a physical serial port 15 | (which would be difficult to set up on a CI server). 16 | 17 | The binary comes with the following statement by its developers: 18 | 19 | > This is a portable (statically compiled) Win32 version of Ncat. 20 | > You should be able to take the ncat.exe and run it on other systems without having to also copy over 21 | > a bunch of DLLs, etc. 22 | > 23 | > More information on Ncat: http://nmap.org/ncat/ 24 | > 25 | > You can get the version number of this ncat by runnign "ncat --version". 26 | > We don't create a new Ncat portable for each Ncat release, 27 | > so you will have to compile your own if you want a newer version. 28 | > Instructions for doing so are available at: https://secwiki.org/w/Nmap/Ncat_Portable 29 | > 30 | > Ncat is distributed under the same free and open source license as Nmap. 31 | > See http://nmap.org/book/man-legal.html. 32 | 33 | 34 | ### Npcap installer 35 | 36 | Npcap is needed for testing the network sniffer of the Cyphal/UDP transport implementation on Windows. 37 | 38 | Npcap is distributed under the terms of Nmap Public Source License: https://nmap.org/npsl/. 39 | 40 | 41 | ## SonarQube scanner 42 | 43 | New versions can be obtained from https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/. 44 | -------------------------------------------------------------------------------- /.test_deps/ncat.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenCyphal/pycyphal/71eb3ae06b51820f0f4ec27e2f1edc48f73fad8e/.test_deps/ncat.exe -------------------------------------------------------------------------------- /.test_deps/npcap-0.96.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenCyphal/pycyphal/71eb3ae06b51820f0f4ec27e2f1edc48f73fad8e/.test_deps/npcap-0.96.exe -------------------------------------------------------------------------------- /.test_deps/sonar-scanner-cli-5.0.1.3006-linux.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenCyphal/pycyphal/71eb3ae06b51820f0f4ec27e2f1edc48f73fad8e/.test_deps/sonar-scanner-cli-5.0.1.3006-linux.zip -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 OpenCyphal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

Cyphal in Python

3 |
4 | 5 | [![Test and Release PyCyphal](https://github.com/OpenCyphal/pycyphal/actions/workflows/test-and-release.yml/badge.svg)](https://github.com/OpenCyphal/pycyphal/actions/workflows/test-and-release.yml) [![RTFD](https://readthedocs.org/projects/pycyphal/badge/)](https://pycyphal.readthedocs.io/) [![Coverage Status](https://coveralls.io/repos/github/OpenCyphal/pycyphal/badge.svg)](https://coveralls.io/github/OpenCyphal/pycyphal) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=PyCyphal&metric=alert_status)](https://sonarcloud.io/dashboard?id=PyCyphal) [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=PyCyphal&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=PyCyphal) [![PyPI - Version](https://img.shields.io/pypi/v/pycyphal.svg)](https://pypi.org/project/pycyphal/) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Forum](https://img.shields.io/discourse/https/forum.opencyphal.org/users.svg)](https://forum.opencyphal.org) 6 | 7 |
8 |
9 | 10 | PyCyphal is a full-featured implementation of the Cyphal protocol stack intended for non-embedded, user-facing applications such as GUI software, diagnostic tools, automation scripts, prototypes, and various R&D cases. 11 | 12 | PyCyphal aims to support all features and transport layers of Cyphal, be portable across all major platforms supporting Python, and be extensible to permit low-effort experimentation and testing of new protocol capabilities. 13 | 14 | It is designed to support **GNU/Linux**, **MS Windows**, and **macOS** as first-class target platforms. However, the library does not rely on any platform-specific capabilities, so it should be usable with other systems as well. 15 | 16 | [Cyphal](https://opencyphal.org) is an open technology for real-time intravehicular distributed computing and communication based on modern networking standards (Ethernet, CAN FD, etc.). 17 | 18 |

19 | 20 |

21 | 22 | **READ THE DOCS: [pycyphal.readthedocs.io](https://pycyphal.readthedocs.io/)** 23 | 24 | **Ask questions: [forum.opencyphal.org](https://forum.opencyphal.org/)** 25 | 26 | *See also: [**Yakut**](https://github.com/OpenCyphal/yakut) -- a CLI tool for diagnostics and management of Cyphal networks built on top of PyCyphal.* 27 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | PyCyphal demo application 2 | ========================= 3 | 4 | This directory contains the demo application. 5 | It is invoked and verified by the integration test suite along with the main library codebase. 6 | 7 | Please refer to the official library documentation for details about this demo. 8 | -------------------------------------------------------------------------------- /demo/custom_data_types/sirius_cyber_corp/PerformLinearLeastSquaresFit.1.0.dsdl: -------------------------------------------------------------------------------- 1 | # This service accepts a list of 2D point coordinates and returns the best-fit linear function coefficients. 2 | # If no solution exists, the returned coefficients are NaN. 3 | 4 | PointXY.1.0[<64] points 5 | 6 | @extent 1024 * 8 7 | 8 | --- 9 | 10 | float64 slope 11 | float64 y_intercept 12 | 13 | @extent 64 * 8 14 | -------------------------------------------------------------------------------- /demo/custom_data_types/sirius_cyber_corp/PointXY.1.0.dsdl: -------------------------------------------------------------------------------- 1 | float16 x 2 | float16 y 3 | @sealed 4 | -------------------------------------------------------------------------------- /demo/launch.orc.yaml: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S yakut --verbose orchestrate 2 | # Read the docs about the orc-file syntax: yakut orchestrate --help 3 | 4 | # Shared environment variables for all nodes/processes (can be overridden or selectively removed in local scopes). 5 | CYPHAL_PATH: "./public_regulated_data_types;./custom_data_types" 6 | PYCYPHAL_PATH: ".pycyphal_generated" # This one is optional; the default is "~/.pycyphal". 7 | 8 | # Shared registers for all nodes/processes (can be overridden or selectively removed in local scopes). 9 | # See the docs for pycyphal.application.make_node() to see which registers can be used here. 10 | uavcan: 11 | # Use Cyphal/UDP via localhost: 12 | udp.iface: 127.0.0.1 13 | # If you have Ncat or some other TCP broker, you can use Cyphal/serial tunneled over TCP (in a heterogeneous 14 | # redundant configuration with UDP or standalone). Ncat launch example: ncat --broker --listen --source-port 50905 15 | serial.iface: "" # socket://127.0.0.1:50905 16 | # It is recommended to explicitly assign unused transports to ensure that previously stored transport 17 | # configurations are not accidentally reused: 18 | can.iface: "" 19 | # Configure diagnostic publishing, too: 20 | diagnostic: 21 | severity: 2 22 | timestamp: true 23 | 24 | # Keys with "=" define imperatives rather than registers or environment variables. 25 | $=: 26 | - $=: 27 | # Wait a bit to let the diagnostic subscriber get ready (it is launched below). 28 | - sleep 6 29 | - # An empty statement is a join statement -- wait for the previously launched processes to exit before continuing. 30 | 31 | # Launch the demo app that implements the thermostat. 32 | - $=: python demo_app.py 33 | uavcan: 34 | node.id: 42 35 | sub.temperature_setpoint.id: 2345 36 | sub.temperature_measurement.id: 2346 37 | pub.heater_voltage.id: 2347 38 | srv.least_squares.id: 0xFFFF # We don't need this service. Disable by setting an invalid port-ID. 39 | thermostat: 40 | pid.gains: [0.1, 0, 0] 41 | 42 | # Launch the controlled plant simulator. 43 | - $=: python plant.py 44 | uavcan: 45 | node.id: 43 46 | sub.voltage.id: 2347 47 | pub.temperature.id: 2346 48 | model.environment.temperature: 300.0 # In UAVCAN everything follows SI, so this temperature is in kelvin. 49 | 50 | # Publish the setpoint a few times to show how the thermostat drives the plant to the correct temperature. 51 | # You can publish a different setpoint by running this command in a separate terminal to see how the system responds: 52 | # yakut pub 2345 "kelvin: 200" 53 | - $=: | 54 | yakut pub 2345:uavcan.si.unit.temperature.scalar 450 -N3 55 | uavcan.node.id: 100 56 | 57 | # Launch diagnostic subscribers to print messages in the terminal that runs the orchestrator. 58 | - yakut sub --with-metadata uavcan.diagnostic.record 2346:uavcan.si.sample.temperature.scalar 59 | 60 | # Exit automatically if STOP_AFTER is defined (frankly, this is just a testing aid, feel free to ignore). 61 | - ?=: test -n "$STOP_AFTER" 62 | $=: sleep $STOP_AFTER && exit 111 63 | -------------------------------------------------------------------------------- /demo/plant.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Distributed under CC0 1.0 Universal (CC0 1.0) Public Domain Dedication. 3 | """ 4 | This application simulates the plant controlled by the thermostat node: it takes a voltage command, 5 | runs a crude thermodynamics simulation, and publishes the temperature (i.e., one subscription, one publication). 6 | """ 7 | 8 | import time 9 | import asyncio 10 | import pycyphal # Importing PyCyphal will automatically install the import hook for DSDL compilation. 11 | 12 | # Import DSDLs after pycyphal import hook is installed. 13 | import uavcan.si.unit.voltage 14 | import uavcan.si.sample.temperature 15 | import uavcan.time 16 | from pycyphal.application.heartbeat_publisher import Health 17 | from pycyphal.application import make_node, NodeInfo, register 18 | 19 | 20 | UPDATE_PERIOD = 0.5 21 | 22 | heater_voltage = 0.0 23 | saturation = False 24 | 25 | 26 | def handle_command(msg: uavcan.si.unit.voltage.Scalar_1, _metadata: pycyphal.transport.TransferFrom) -> None: 27 | global heater_voltage, saturation 28 | if msg.volt < 0.0: 29 | heater_voltage = 0.0 30 | saturation = True 31 | elif msg.volt > 50.0: 32 | heater_voltage = 50.0 33 | saturation = True 34 | else: 35 | heater_voltage = msg.volt 36 | saturation = False 37 | 38 | 39 | async def main() -> None: 40 | with make_node(NodeInfo(name="org.opencyphal.pycyphal.demo.plant"), "plant.db") as node: 41 | # Expose internal states for diagnostics. 42 | node.registry["status.saturation"] = lambda: saturation # The register type will be deduced as "bit[1]". 43 | 44 | # Initialize values from the registry. The temperature is in kelvin because in UAVCAN everything follows SI. 45 | # Here, we specify the type explicitly as "real32[1]". If we pass a native float, it would be "real64[1]". 46 | temp_environment = float(node.registry.setdefault("model.environment.temperature", register.Real32([292.15]))) 47 | temp_plant = temp_environment 48 | 49 | # Set up the ports. 50 | pub_meas = node.make_publisher(uavcan.si.sample.temperature.Scalar_1, "temperature") 51 | pub_meas.priority = pycyphal.transport.Priority.HIGH 52 | sub_volt = node.make_subscriber(uavcan.si.unit.voltage.Scalar_1, "voltage") 53 | sub_volt.receive_in_background(handle_command) 54 | 55 | # Run the main loop forever. 56 | next_update_at = asyncio.get_running_loop().time() 57 | while True: 58 | # Publish new measurement and update node health. 59 | await pub_meas.publish( 60 | uavcan.si.sample.temperature.Scalar_1( 61 | timestamp=uavcan.time.SynchronizedTimestamp_1(microsecond=int(time.time() * 1e6)), 62 | kelvin=temp_plant, 63 | ) 64 | ) 65 | node.heartbeat_publisher.health = Health.ADVISORY if saturation else Health.NOMINAL 66 | 67 | # Sleep until the next iteration. 68 | next_update_at += UPDATE_PERIOD 69 | await asyncio.sleep(next_update_at - asyncio.get_running_loop().time()) 70 | 71 | # Update the simulation. 72 | temp_plant += heater_voltage * 0.1 * UPDATE_PERIOD # Energy input from the heater. 73 | temp_plant -= (temp_plant - temp_environment) * 0.05 * UPDATE_PERIOD # Dissipation. 74 | 75 | 76 | if __name__ == "__main__": 77 | try: 78 | asyncio.run(main()) 79 | except KeyboardInterrupt: 80 | pass 81 | -------------------------------------------------------------------------------- /demo/requirements.txt: -------------------------------------------------------------------------------- 1 | pycyphal[transport-can-pythoncan,transport-serial,transport-udp] 2 | -------------------------------------------------------------------------------- /demo/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Distributed under CC0 1.0 Universal (CC0 1.0) Public Domain Dedication. 3 | # type: ignore 4 | """ 5 | A simplified setup.py demo that shows how to distribute compiled DSDL definitions with Python packages. 6 | 7 | To use precompiled DSDL files in app, the compilation output directory must be included in path: 8 | compiled_dsdl_dir = pathlib.Path(__file__).resolve().parent / ".demo_dsdl_compiled" 9 | sys.path.insert(0, str(compiled_dsdl_dir)) 10 | """ 11 | 12 | import setuptools 13 | import logging 14 | import distutils.command.build_py 15 | from pathlib import Path 16 | 17 | NAME = "demo_app" 18 | 19 | 20 | # noinspection PyUnresolvedReferences 21 | class BuildPy(distutils.command.build_py.build_py): 22 | def run(self): 23 | import pycyphal 24 | 25 | pycyphal.dsdl.compile_all( 26 | [ 27 | "public_regulated_data_types/uavcan", # All Cyphal applications need the standard namespace, always. 28 | "custom_data_types/sirius_cyber_corp", 29 | # "public_regulated_data_types/reg", # Many applications also need the non-standard regulated DSDL. 30 | ], 31 | output_directory=Path(self.build_lib, NAME, ".demo_dsdl_compiled"), # Store in the build output archive. 32 | ) 33 | super().run() 34 | 35 | 36 | logging.basicConfig(level=logging.INFO, format="%(levelname)-3.3s %(name)s: %(message)s") 37 | 38 | setuptools.setup( 39 | name=NAME, 40 | py_modules=["demo_app"], 41 | cmdclass={"build_py": BuildPy}, 42 | ) 43 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /api/ 2 | -------------------------------------------------------------------------------- /docs/figures/subject_synchronizer_monotonic_clustering.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # This script generates a diagram illustrating the operation of the monotonic clustering synchronizer. 4 | # Pipe its output to "neato -T svg > result.svg" to obtain the diagram. 5 | # 6 | # We could run the script at every doc build but I don't want to make the doc build unnecessarily fragile, 7 | # and this is not expected to be updated frequently. 8 | # It is also possible to use an online tool like https://edotor.net. 9 | # 10 | # The reason we don't use hand-drawn diagrams is that they may not accurately reflect the behavior of the synchronizer. 11 | # 12 | # Copyright (c) 2022 OpenCyphal 13 | # This software is distributed under the terms of the MIT License. 14 | # Author: Pavel Kirienko 15 | 16 | from __future__ import annotations 17 | from typing import Any, Callable 18 | import random 19 | import asyncio 20 | from pycyphal.transport.loopback import LoopbackTransport 21 | from pycyphal.transport import TransferFrom 22 | from pycyphal.presentation import Presentation 23 | from pycyphal.presentation.subscription_synchronizer import get_timestamp_field 24 | from pycyphal.presentation.subscription_synchronizer.monotonic_clustering import MonotonicClusteringSynchronizer 25 | from uavcan.si.sample.mass import Scalar_1 26 | from uavcan.time import SynchronizedTimestamp_1 as Ts1 27 | 28 | 29 | async def main() -> None: 30 | print("digraph {") 31 | print("node[shape=circle,style=filled,fillcolor=black,fixedsize=1];") 32 | print("edge[arrowhead=none,penwidth=10,color=black];") 33 | 34 | pres = Presentation(LoopbackTransport(1234)) 35 | 36 | pub_a = pres.make_publisher(Scalar_1, 2000) 37 | pub_b = pres.make_publisher(Scalar_1, 2001) 38 | pub_c = pres.make_publisher(Scalar_1, 2002) 39 | 40 | f_key = get_timestamp_field 41 | 42 | pres.make_subscriber(pub_a.dtype, pub_a.port_id).receive_in_background(_make_graphviz_printer("red", 0, f_key)) 43 | pres.make_subscriber(pub_b.dtype, pub_b.port_id).receive_in_background(_make_graphviz_printer("green", 1, f_key)) 44 | pres.make_subscriber(pub_c.dtype, pub_c.port_id).receive_in_background(_make_graphviz_printer("blue", 2, f_key)) 45 | 46 | sub_a = pres.make_subscriber(pub_a.dtype, pub_a.port_id) 47 | sub_b = pres.make_subscriber(pub_b.dtype, pub_b.port_id) 48 | sub_c = pres.make_subscriber(pub_c.dtype, pub_c.port_id) 49 | 50 | synchronizer = MonotonicClusteringSynchronizer([sub_a, sub_b, sub_c], f_key, 0.5) 51 | 52 | def cb(a: Scalar_1, b: Scalar_1, c: Scalar_1) -> None: 53 | print(f'"{_represent("red", a)}"->"{_represent("green", b)}"->"{_represent("blue", c)}";') 54 | 55 | synchronizer.get_in_background(cb) 56 | 57 | reference = 0 58 | random_skew = (-0.2, -0.1, 0.0, +0.1, +0.2) 59 | 60 | def ts() -> Ts1: 61 | return Ts1(round(max(0.0, (reference + random.choice(random_skew))) * 1e6)) 62 | 63 | async def advance(step: int = 1) -> None: 64 | nonlocal reference 65 | reference += step 66 | await asyncio.sleep(0.1) 67 | 68 | for _ in range(6): 69 | await pub_a.publish(Scalar_1(ts(), reference)) 70 | await pub_b.publish(Scalar_1(ts(), reference)) 71 | await pub_c.publish(Scalar_1(ts(), reference)) 72 | await advance() 73 | 74 | for _ in range(10): 75 | if random.random() < 0.7: 76 | await pub_a.publish(Scalar_1(ts(), reference)) 77 | if random.random() < 0.7: 78 | await pub_b.publish(Scalar_1(ts(), reference)) 79 | if random.random() < 0.7: 80 | await pub_c.publish(Scalar_1(ts(), reference)) 81 | await advance() 82 | 83 | for _ in range(3): 84 | await pub_a.publish(Scalar_1(ts(), reference)) 85 | await pub_b.publish(Scalar_1(ts(), reference)) 86 | await pub_c.publish(Scalar_1(ts(), reference)) 87 | await advance(3) 88 | 89 | for i in range(22): 90 | await pub_a.publish(Scalar_1(ts(), reference)) 91 | if i % 3 == 0: 92 | await pub_b.publish(Scalar_1(ts(), reference)) 93 | if i % 2 == 0: 94 | await pub_c.publish(Scalar_1(ts(), reference)) 95 | await advance(1) 96 | 97 | pres.close() 98 | await asyncio.sleep(0.1) 99 | print("}") 100 | 101 | 102 | def _represent(color: str, msg: Any) -> str: 103 | return f"{color}{round(msg.timestamp.microsecond * 1e-6)}" 104 | 105 | 106 | def _make_graphviz_printer( 107 | color: str, 108 | y_pos: float, 109 | f_key: Callable[[Any], float], 110 | ) -> Callable[[Any, TransferFrom], None]: 111 | def cb(msg: Any, meta: TransferFrom) -> None: 112 | print(f'"{_represent(color, msg)}"[label="",fillcolor="{color}",pos="{f_key((msg, meta))},{y_pos}!"];') 113 | 114 | return cb 115 | 116 | 117 | if __name__ == "__main__": 118 | asyncio.run(main()) 119 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | PyCyphal documentation 2 | ====================== 3 | 4 | PyCyphal is a full-featured implementation of the `Cyphal protocol stack `_ in Python. 5 | PyCyphal aims to support all features and transport layers of UAVCAN, 6 | be portable across all major platforms supporting Python, and 7 | be extensible to permit low-effort experimentation and testing of new protocol capabilities. 8 | 9 | Start reading this documentation from the first chapter -- :ref:`architecture`. 10 | If you have questions, please bring them to the `support forum `_. 11 | 12 | 13 | Contents 14 | -------- 15 | 16 | .. toctree:: 17 | :maxdepth: 2 18 | 19 | pages/architecture 20 | pages/installation 21 | pages/api 22 | pages/demo 23 | pages/faq 24 | pages/changelog 25 | pages/dev 26 | 27 | 28 | Indices and tables 29 | ------------------ 30 | 31 | * :ref:`genindex` 32 | * :ref:`modindex` 33 | * :ref:`search` 34 | 35 | 36 | See also 37 | -------- 38 | 39 | Related projects built on top of PyCyphal: 40 | 41 | - `Yakut `_ --- 42 | a command-line interface utility for diagnostics and management of Cyphal networks. 43 | 44 | 45 | License 46 | ------- 47 | 48 | .. include:: ../LICENSE 49 | -------------------------------------------------------------------------------- /docs/pages/api.rst: -------------------------------------------------------------------------------- 1 | API reference 2 | ============= 3 | 4 | For a general library overview, read :ref:`architecture`. 5 | Navigation resources: 6 | 7 | * :ref:`genindex` 8 | * :ref:`modindex` 9 | * :ref:`search` 10 | 11 | pycyphal root module 12 | -------------------- 13 | 14 | .. automodule:: pycyphal 15 | :members: 16 | :undoc-members: 17 | :imported-members: 18 | :inherited-members: 19 | :show-inheritance: 20 | 21 | Submodules 22 | ---------- 23 | 24 | .. toctree:: 25 | :maxdepth: 3 26 | 27 | /api/pycyphal.dsdl 28 | /api/pycyphal.application 29 | /api/pycyphal.presentation 30 | /api/pycyphal.transport 31 | /api/pycyphal.util 32 | -------------------------------------------------------------------------------- /docs/pages/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: /../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/pages/dev.rst: -------------------------------------------------------------------------------- 1 | .. include:: /../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/pages/faq.rst: -------------------------------------------------------------------------------- 1 | Frequently asked questions 2 | ========================== 3 | 4 | What is Cyphal? 5 | Cyphal is an open technology for real-time intravehicular distributed computing and communication 6 | based on modern networking standards (Ethernet, CAN FD, etc.). 7 | It was created to address the challenge of on-board deterministic computing and data distribution 8 | in next-generation intelligent vehicles: manned and unmanned aircraft, spacecraft, robots, and cars. 9 | The project was once known as `UAVCAN `_. 10 | 11 | 12 | How can I deploy PyCyphal on my embedded system? 13 | PyCyphal is mostly designed for high-level user-facing software for R&D, diagnostic, and testing applications. 14 | We have Cyphal implementations in other programming languages that are built specifically for embedded systems; 15 | please find more info at `opencyphal.org `_. 16 | 17 | 18 | PyCyphal seems complex. Does that mean that Cyphal is a complex protocol? 19 | Cyphal is a very simple protocol. 20 | This particular implementation may appear convoluted because it is very generic and provides a very high-level API. 21 | For comparison, there is a minimal Cyphal-over-CAN implementation in C called ``libcanard`` 22 | that is only ~1k SLoC large. 23 | 24 | 25 | I am getting ``ModuleNotFoundError: No module named 'uavcan'``. Do I need to install additional packages? 26 | We no longer ship the public regulated DSDL definitions together with Cyphal implementations 27 | in order to simplify maintenance and integration; 28 | also, this underlines our commitment to make vendor-specific (or application-specific) 29 | data types first-class citizens in Cyphal. 30 | Please read the user documentation to learn how to generate Python packages from DSDL namespaces. 31 | 32 | 33 | Imports fail with ``AttributeError: module 'uavcan...' has no attribute '...'``. What am I doing wrong? 34 | Remove the legacy library: ``pip uninstall -y uavcan``. 35 | Read the :ref:`installation` guide for details. 36 | 37 | 38 | I am experiencing poor SLCAN read/write performance on Windows. What can I do? 39 | Increasing the process priority to REALTIME 40 | (available if the application has administrator privileges) will help. 41 | Without administrator privileges, the HIGH priority set by this code, 42 | will still help with delays in SLCAN performance. 43 | Here's an example:: 44 | 45 | if sys.platform.startswith("win"): 46 | import ctypes, psutil 47 | 48 | # Reconfigure the system timer to run at a higher resolution. This is desirable for the real-time tests. 49 | t = ctypes.c_ulong() 50 | ctypes.WinDLL("NTDLL.DLL").NtSetTimerResolution(5000, 1, ctypes.byref(t)) 51 | p = psutil.Process(os.getpid()) 52 | p.nice(psutil.REALTIME_PRIORITY_CLASS) 53 | elif sys.platform.startswith("linux"): 54 | p = psutil.Process(os.getpid()) 55 | p.nice(-20) 56 | -------------------------------------------------------------------------------- /docs/pages/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ============ 5 | 6 | Install the library from PyPI; the package name is ``pycyphal``. 7 | Specify the installation options (known as "package extras" in parseltongue) 8 | depending on which Cyphal transports and features you are planning to use. 9 | 10 | Installation options 11 | -------------------- 12 | 13 | Most of the installation options enable a particular transport or a particular media sublayer implementation 14 | for a transport. 15 | Those options are named uniformly following the pattern 16 | ``transport--``, for example: ``transport-can-pythoncan``. 17 | If there is no media sub-layer, or the media dependencies are shared, or there is a common 18 | installation option for all media types of the transport, the media part is omitted from the key; 19 | for example: ``transport-serial``. 20 | Installation options whose names do not begin with ``transport-`` enable other optional features. 21 | 22 | .. computron-injection:: 23 | :filename: synth/installation_option_matrix.py 24 | 25 | Use from source 26 | --------------- 27 | 28 | PyCyphal requires no unconventional installation steps and is usable directly in its source form. 29 | If installation from PyPI is considered undesirable, 30 | the library sources can be just directly embedded into the user's codebase 31 | (as a git submodule/subtree or copy-paste). 32 | 33 | When doing so, don't forget to let others know that you use PyCyphal (it's MIT-licensed), 34 | and make sure to include at least its core dependencies, which are: 35 | 36 | .. computron-injection:: 37 | 38 | import configparser, textwrap 39 | cp = configparser.ConfigParser() 40 | cp.read('../setup.cfg') 41 | print('.. code-block::\n') 42 | print(textwrap.indent(cp['options']['install_requires'].strip(), ' ')) 43 | -------------------------------------------------------------------------------- /docs/pages/synth/application_module_summary.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright (c) 2020 OpenCyphal 3 | # This software is distributed under the terms of the MIT License. 4 | # Author: Pavel Kirienko 5 | 6 | import types 7 | import pycyphal 8 | import pycyphal.application 9 | 10 | print(".. autosummary::") 11 | print(" :nosignatures:") 12 | print() 13 | 14 | # noinspection PyTypeChecker 15 | pycyphal.util.import_submodules(pycyphal.application) 16 | for name in dir(pycyphal.application): 17 | entity = getattr(pycyphal.application, name) 18 | if isinstance(entity, types.ModuleType) and not name.startswith("_"): 19 | print(f" {entity.__name__}") 20 | 21 | print() 22 | -------------------------------------------------------------------------------- /docs/pages/synth/installation_option_matrix.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright (c) 2019 OpenCyphal 3 | # This software is distributed under the terms of the MIT License. 4 | # Author: Pavel Kirienko 5 | 6 | import re 7 | import typing 8 | import textwrap 9 | import dataclasses 10 | import configparser 11 | import pycyphal 12 | 13 | HEADER_SUFFIX = "\n" + "." * 80 + "\n" 14 | 15 | cp = configparser.ConfigParser() 16 | cp.read("../setup.cfg") 17 | extras: typing.Dict[str, str] = dict(cp["options.extras_require"]) 18 | 19 | 20 | print("If you need full-featured library, use this and read no more::", end="\n\n") 21 | print(f' pip install \'pycyphal[{",".join(extras.keys())}]\'', end="\n\n") 22 | print("If you want to know what exactly you are installing, read on.", end="\n\n") 23 | 24 | 25 | @dataclasses.dataclass(frozen=True) 26 | class TransportOption: 27 | name: str 28 | class_name: str 29 | extras: typing.Dict[str, str] 30 | 31 | 32 | transport_options: typing.List[TransportOption] = [] 33 | 34 | # noinspection PyTypeChecker 35 | pycyphal.util.import_submodules(pycyphal.transport) 36 | for cls in pycyphal.util.iter_descendants(pycyphal.transport.Transport): 37 | transport_name = cls.__module__.split(".")[2] # pycyphal.transport.X 38 | relevant_extras: typing.Dict[str, str] = {} 39 | for k in list(extras.keys()): 40 | if k.startswith(f"transport-{transport_name}"): 41 | relevant_extras[k] = extras.pop(k) 42 | 43 | transport_module_name = re.sub(r"\._[_a-zA-Z0-9]*", "", cls.__module__) 44 | transport_class_name = transport_module_name + "." + cls.__name__ 45 | 46 | transport_options.append( 47 | TransportOption(name=transport_name, class_name=transport_class_name, extras=relevant_extras) 48 | ) 49 | 50 | for to in transport_options: 51 | print(f"{to.name} transport" + HEADER_SUFFIX) 52 | print(f"This transport is implemented by :class:`{to.class_name}`.") 53 | if to.extras: 54 | print("The following installation options are available:") 55 | print() 56 | for key, deps in to.extras.items(): 57 | print(f"{key}") 58 | print(" This option pulls the following dependencies::", end="\n\n") 59 | print(textwrap.indent(deps.strip(), " " * 6), end="\n\n") 60 | else: 61 | print("This transport has no installation dependencies.") 62 | print() 63 | 64 | other_extras: typing.Dict[str, str] = {} 65 | for k in list(extras.keys()): 66 | if not k.startswith(f"transport_"): 67 | other_extras[k] = extras.pop(k) 68 | 69 | if other_extras: 70 | print("Other installation options" + HEADER_SUFFIX) 71 | print("These installation options are not related to any transport.", end="\n\n") 72 | for key, deps in other_extras.items(): 73 | print(f"{key}") 74 | print(" This option pulls the following dependencies:", end="\n\n") 75 | print(" .. code-block::", end="\n\n") 76 | print(textwrap.indent(deps.strip(), " " * 6), end="\n\n") 77 | print() 78 | 79 | if extras: 80 | raise RuntimeError( 81 | f"No known transports to match the following installation options (typo?): " f"{list(extras.keys())}" 82 | ) 83 | -------------------------------------------------------------------------------- /docs/pages/synth/transport_summary.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright (c) 2019 OpenCyphal 3 | # This software is distributed under the terms of the MIT License. 4 | # Author: Pavel Kirienko 5 | 6 | import re 7 | import pycyphal 8 | 9 | print(".. autosummary::") 10 | print(" :nosignatures:") 11 | print() 12 | 13 | # noinspection PyTypeChecker 14 | pycyphal.util.import_submodules(pycyphal.transport) 15 | for cls in pycyphal.util.iter_descendants(pycyphal.transport.Transport): 16 | export_module_name = re.sub(r"\._[_a-zA-Z0-9]*", "", cls.__module__) 17 | print(f" {export_module_name}.{cls.__name__}") 18 | 19 | print() 20 | -------------------------------------------------------------------------------- /docs/ref_fixer_hack.py: -------------------------------------------------------------------------------- 1 | """ 2 | ======================================== THIS IS A DIRTY HACK ======================================== 3 | 4 | I've constructed this Sphinx extension as a quick and dirty "solution" to the problem of broken cross-linking. 5 | 6 | The problem is that Autodoc fails to realize that an entity, say, pycyphal.transport._session.InputSession is 7 | exposed to the user as pycyphal.transport.InputSession, and that the original name is not a part of the API 8 | and it shouldn't even be mentioned in the documentation at all. I've described this problem in this Sphinx issue 9 | at https://github.com/sphinx-doc/sphinx/issues/6574. Since the original name is not exported, Autodoc can't find 10 | it in the output and generates no link at all, requiring the user to search manually instead of just clicking on 11 | stuff. 12 | 13 | The hack is known to occasionally misbehave and produce incorrect links at the output, but hey, it's a hack. 14 | Someone should just fix Autodoc instead of relying on this long-term. Please. 15 | """ 16 | 17 | import re 18 | import os 19 | import typing 20 | 21 | import sphinx.application 22 | import sphinx.environment 23 | import sphinx.util.nodes 24 | import docutils.nodes 25 | 26 | 27 | _ACCEPTANCE_PATTERN = r".*([a-zA-Z][a-zA-Z0-9_]*\.)+_[a-zA-Z0-9_]*\..+" 28 | _REFTYPES = "class", "meth", "func" 29 | 30 | _replacements_made: typing.List[typing.Tuple[str, str]] = [] 31 | 32 | 33 | def missing_reference( 34 | app: sphinx.application.Sphinx, 35 | _env: sphinx.environment.BuildEnvironment, 36 | node: docutils.nodes.Element, 37 | contnode: docutils.nodes.Node, 38 | ) -> typing.Optional[docutils.nodes.Node]: 39 | old_reftarget = node["reftarget"] 40 | if node["reftype"] in _REFTYPES and re.match(_ACCEPTANCE_PATTERN, old_reftarget): 41 | new_reftarget = re.sub(r"\._[a-zA-Z0-9_]*", "", old_reftarget) 42 | if new_reftarget != old_reftarget: 43 | _replacements_made.append((old_reftarget, new_reftarget)) 44 | attrs = contnode.attributes if isinstance(contnode, docutils.nodes.Element) else {} 45 | try: 46 | old_refdoc = node["refdoc"] 47 | except KeyError: 48 | return None 49 | new_refdoc = old_refdoc.rsplit(os.path.sep, 1)[0] + os.path.sep + new_reftarget.rsplit(".", 1)[0] 50 | return sphinx.util.nodes.make_refnode( 51 | app.builder, 52 | old_refdoc, 53 | new_refdoc, 54 | node.get("refid", new_reftarget), 55 | docutils.nodes.literal(new_reftarget, new_reftarget, **attrs), 56 | new_reftarget, 57 | ) 58 | return None 59 | 60 | 61 | def doctree_resolved(_app: sphinx.application.Sphinx, doctree: docutils.nodes.document, _docname: str) -> None: 62 | def predicate(n: docutils.nodes.Node) -> bool: 63 | if isinstance(n, docutils.nodes.FixedTextElement): 64 | is_text_primitive = len(n.children) == 1 and isinstance(n.children[0], docutils.nodes.Text) 65 | if is_text_primitive: 66 | return is_text_primitive and re.match(_ACCEPTANCE_PATTERN, n.children[0].astext()) 67 | return False 68 | 69 | def substitute_once(text: str) -> str: 70 | out = re.sub(r"\._[a-zA-Z0-9_]*", "", text) 71 | _replacements_made.append((text, out)) 72 | return out 73 | 74 | # The objective here is to replace all references to hidden objects with their exported aliases. 75 | # For example: pycyphal.presentation._typed_session._publisher.Publisher --> pycyphal.presentation.Publisher 76 | for node in doctree.traverse(predicate): 77 | assert isinstance(node, docutils.nodes.FixedTextElement) 78 | node.children = [docutils.nodes.Text(substitute_once(node.children[0].astext()))] 79 | 80 | 81 | def setup(app: sphinx.application.Sphinx): 82 | app.connect("missing-reference", missing_reference) 83 | app.connect("doctree-resolved", doctree_resolved) 84 | return { 85 | "parallel_read_safe": True, 86 | } 87 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # These dependencies are only needed to build the docs. 2 | # There are a few pending issues with Sphinx (update when resolved): 3 | # - https://github.com/sphinx-doc/sphinx/issues/6574 4 | # - https://github.com/sphinx-doc/sphinx/issues/6607 5 | # This file is meant to be used from the project root directory. 6 | 7 | .[transport-can-pythoncan,transport-serial,transport-udp] 8 | sphinx ~= 7.2.6 9 | sphinx_rtd_theme ~= 2.0.0 10 | sphinx-computron ~= 1.0 11 | -------------------------------------------------------------------------------- /docs/static/custom.css: -------------------------------------------------------------------------------- 1 | /* Gray text is ugly. Text should be black. */ 2 | body { 3 | color: #000; 4 | } 5 | .wy-nav-content { 6 | max-width: unset; 7 | } 8 | h2 { 9 | border-bottom: 1px solid #ddd; 10 | padding-bottom: 0.2em; 11 | } 12 | 13 | /* Desktop optimization. */ 14 | @media (min-width: 1200px) { 15 | .rst-content .toctree-wrapper ul li { 16 | margin-left: 48px; 17 | } 18 | } 19 | 20 | .wy-table-responsive table td, 21 | .wy-table-responsive table th { 22 | white-space: normal !important; 23 | } 24 | 25 | .rst-content table.docutils { 26 | border: solid 1px #555; 27 | } 28 | .rst-content table.docutils td { 29 | border: solid 1px #555; 30 | } 31 | .rst-content table.docutils thead th { 32 | border: solid 1px #555 !important; 33 | } 34 | 35 | .rst-content li.toctree-l1 > a { 36 | font-weight: bold; 37 | } 38 | 39 | .rst-content dl { 40 | display: block !important; 41 | } 42 | 43 | .rst-content a { 44 | color: #1700b3; 45 | } 46 | .rst-content a:visited { 47 | color: #1700b3; 48 | } 49 | 50 | .rst-content code.literal, 51 | .rst-content tt.literal { 52 | color: #007E87; 53 | font-weight: bold; 54 | } 55 | 56 | .rst-content code.xref, 57 | .rst-content tt.xref { 58 | color: #1700b3; 59 | } 60 | 61 | /* This is needed to make transparent images have the same background color. 62 | * https://stackoverflow.com/questions/19616629/css-inherit-for-unknown-background-color-is-actually-transparent 63 | */ 64 | div, section, img { 65 | background-color: inherit; 66 | } 67 | -------------------------------------------------------------------------------- /docs/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenCyphal/pycyphal/71eb3ae06b51820f0f4ec27e2f1edc48f73fad8e/docs/static/favicon.ico -------------------------------------------------------------------------------- /pycyphal/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | r""" 6 | Submodule import policy 7 | +++++++++++++++++++++++ 8 | 9 | The following submodules are auto-imported when the root module ``pycyphal`` is imported: 10 | 11 | - :mod:`pycyphal.dsdl` 12 | - :mod:`pycyphal.transport`, but not concrete transport implementation submodules. 13 | - :mod:`pycyphal.presentation` 14 | - :mod:`pycyphal.util` 15 | 16 | Submodule :mod:`pycyphal.application` is not auto-imported because in order to have it imported 17 | the DSDL-generated package ``uavcan`` containing the standard data types must be compiled first. 18 | 19 | 20 | Log level override 21 | ++++++++++++++++++ 22 | 23 | The environment variable ``PYCYPHAL_LOGLEVEL`` can be set to one of the following values to override 24 | the library log level: 25 | 26 | - ``CRITICAL`` 27 | - ``FATAL`` 28 | - ``ERROR`` 29 | - ``WARNING`` 30 | - ``INFO`` 31 | - ``DEBUG`` 32 | """ 33 | 34 | import os as _os 35 | 36 | 37 | from ._version import __version__ as __version__ 38 | 39 | __version_info__ = tuple(map(int, __version__.split(".")[:3])) 40 | __author__ = "OpenCyphal" 41 | __copyright__ = "Copyright (c) 2019 OpenCyphal" 42 | __email__ = "consortium@opencyphal.org" 43 | __license__ = "MIT" 44 | 45 | 46 | CYPHAL_SPECIFICATION_VERSION = 1, 0 47 | """ 48 | Version of the Cyphal protocol implemented by this library, major and minor. 49 | The corresponding field in ``uavcan.node.GetInfo.Response`` is initialized from this value, 50 | see :func:`pycyphal.application.make_node`. 51 | """ 52 | 53 | 54 | _log_level_from_env = _os.environ.get("PYCYPHAL_LOGLEVEL") 55 | if _log_level_from_env is not None: 56 | import logging as _logging 57 | 58 | _logging.basicConfig( 59 | format="%(asctime)s %(process)5d %(levelname)-8s %(name)s: %(message)s", level=_log_level_from_env 60 | ) 61 | _logging.getLogger(__name__).setLevel(_log_level_from_env) 62 | _logging.getLogger(__name__).info("Log config from env var; level: %r", _log_level_from_env) 63 | 64 | 65 | # The sub-packages are imported in the order of their interdependency. 66 | # pylint: disable=wrong-import-order,consider-using-from-import,wrong-import-position 67 | from pycyphal import util as util # noqa 68 | from pycyphal import dsdl as dsdl # noqa 69 | from pycyphal import transport as transport # noqa 70 | from pycyphal import presentation as presentation # noqa 71 | -------------------------------------------------------------------------------- /pycyphal/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.23.0" 2 | -------------------------------------------------------------------------------- /pycyphal/application/_registry_factory.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import os 7 | from typing import Callable, Optional, Union, List, Dict 8 | from pathlib import Path 9 | import logging 10 | from . import register 11 | 12 | 13 | EnvironmentVariables = Union[Dict[str, bytes], Dict[str, str], Dict[bytes, bytes]] 14 | 15 | 16 | class SimpleRegistry(register.Registry): 17 | def __init__( 18 | self, 19 | register_file: Union[None, str, Path] = None, 20 | environment_variables: Optional[EnvironmentVariables] = None, 21 | ) -> None: 22 | from .register.backend.dynamic import DynamicBackend 23 | from .register.backend.static import StaticBackend 24 | 25 | self._backend_static = StaticBackend(register_file) 26 | self._backend_dynamic = DynamicBackend() 27 | 28 | if environment_variables is None: 29 | try: 30 | environment_variables = os.environb # type: ignore 31 | except AttributeError: # pragma: no cover 32 | environment_variables = os.environ # type: ignore 33 | 34 | assert environment_variables is not None 35 | self._environment_variables: Dict[str, bytes] = { 36 | (k if isinstance(k, str) else k.decode()): (v if isinstance(v, bytes) else v.encode()) 37 | for k, v in environment_variables.items() 38 | } 39 | super().__init__() 40 | 41 | self._update_from_environment_variables() 42 | 43 | @property 44 | def backends(self) -> List[register.backend.Backend]: 45 | return [self._backend_static, self._backend_dynamic] 46 | 47 | @property 48 | def environment_variables(self) -> Dict[str, bytes]: 49 | return self._environment_variables 50 | 51 | def _create_static(self, name: str, value: register.Value) -> None: 52 | _logger.debug("%r: Create static %r = %r", self, name, value) 53 | self._backend_static[name] = value 54 | 55 | def _create_dynamic( 56 | self, 57 | name: str, 58 | getter: Callable[[], register.Value], 59 | setter: Optional[Callable[[register.Value], None]], 60 | ) -> None: 61 | _logger.debug("%r: Create dynamic %r from getter=%r setter=%r", self, name, getter, setter) 62 | self._backend_dynamic[name] = getter if setter is None else (getter, setter) 63 | 64 | def _update_from_environment_variables(self) -> None: 65 | for name in self: 66 | env_val = self.environment_variables.get(register.get_environment_variable_name(name)) 67 | if env_val is not None: 68 | _logger.debug("Updating register %r from env: %r", name, env_val) 69 | reg_val = self[name] 70 | reg_val.assign_environment_variable(env_val) 71 | self[name] = reg_val 72 | 73 | 74 | def make_registry( 75 | register_file: Union[None, str, Path] = None, 76 | environment_variables: Optional[EnvironmentVariables] = None, 77 | ) -> register.Registry: 78 | """ 79 | Construct a new instance of :class:`pycyphal.application.register.Registry`. 80 | Complex applications with uncommon requirements may choose to implement Registry manually 81 | instead of using this factory. 82 | 83 | See also: standard RPC-service ``uavcan.register.Access``. 84 | 85 | :param register_file: 86 | Path to the registry file; or, in other words, the configuration file of this application/node. 87 | If not provided (default), the registers of this instance will be stored in-memory (volatile configuration). 88 | If path is provided but the file does not exist, it will be created automatically. 89 | See :attr:`Node.registry`. 90 | 91 | :param environment_variables: 92 | During initialization, all registers will be updated based on the environment variables passed here. 93 | This dict is used to initialize :attr:`pycyphal.application.register.Registry.environment_variables`. 94 | Registers that are created later using :meth:`pycyphal.application.register.Registry.setdefault` 95 | will use these values as well. 96 | 97 | If None (which is default), the value is initialized by copying :data:`os.environb`. 98 | Pass an empty dict here to disable environment variable processing. 99 | 100 | :raises: 101 | - :class:`pycyphal.application.register.ValueConversionError` if a register is found but its value 102 | cannot be converted to the correct type, or if the value of an environment variable for a register 103 | is invalid or incompatible with the register's type 104 | (e.g., an environment variable set to ``Hello world`` cannot be assigned to register of type ``real64[3]``). 105 | """ 106 | return SimpleRegistry(register_file, environment_variables) 107 | 108 | 109 | _logger = logging.getLogger(__name__) 110 | -------------------------------------------------------------------------------- /pycyphal/application/register/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | # pylint: disable=wrong-import-position 6 | 7 | """ 8 | Implementation of the Cyphal register interface as defined in the Cyphal Specification 9 | (section 5.3 *Application-layer functions*). 10 | """ 11 | 12 | import uavcan.primitive 13 | import uavcan.primitive.array 14 | 15 | # import X as Y is not an accepted form; see https://github.com/python/mypy/issues/11706 16 | Empty = uavcan.primitive.Empty_1 17 | String = uavcan.primitive.String_1 18 | Unstructured = uavcan.primitive.Unstructured_1 19 | Bit = uavcan.primitive.array.Bit_1 20 | Integer64 = uavcan.primitive.array.Integer64_1 21 | Integer32 = uavcan.primitive.array.Integer32_1 22 | Integer16 = uavcan.primitive.array.Integer16_1 23 | Integer8 = uavcan.primitive.array.Integer8_1 24 | Natural64 = uavcan.primitive.array.Natural64_1 25 | Natural32 = uavcan.primitive.array.Natural32_1 26 | Natural16 = uavcan.primitive.array.Natural16_1 27 | Natural8 = uavcan.primitive.array.Natural8_1 28 | Real64 = uavcan.primitive.array.Real64_1 29 | Real32 = uavcan.primitive.array.Real32_1 30 | Real16 = uavcan.primitive.array.Real16_1 31 | 32 | from ._value import Value as Value 33 | from ._value import ValueProxy as ValueProxy 34 | from ._value import RelaxedValue as RelaxedValue 35 | from ._value import ValueConversionError as ValueConversionError 36 | 37 | from . import backend as backend 38 | 39 | from ._registry import Registry as Registry 40 | from ._registry import ValueProxyWithFlags as ValueProxyWithFlags 41 | from ._registry import MissingRegisterError as MissingRegisterError 42 | 43 | 44 | def get_environment_variable_name(register_name: str) -> str: 45 | """ 46 | Convert the name of the register to the name of the environment variable that assigns it. 47 | 48 | >>> get_environment_variable_name("m.motor.inductance_dq") 49 | 'M__MOTOR__INDUCTANCE_DQ' 50 | """ 51 | return register_name.upper().replace(".", "__") 52 | -------------------------------------------------------------------------------- /pycyphal/application/register/backend/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import sys 7 | import abc 8 | from typing import Optional, Union 9 | import dataclasses 10 | import pycyphal 11 | from uavcan.register import Value_1 as Value # pylint: disable=wrong-import-order 12 | 13 | if sys.version_info >= (3, 9): 14 | from collections.abc import MutableMapping 15 | else: # pragma: no cover 16 | from typing import MutableMapping # pylint: disable=ungrouped-imports 17 | 18 | 19 | __all__ = ["Value", "Backend", "Entry", "BackendError"] 20 | 21 | 22 | class BackendError(RuntimeError): 23 | """ 24 | Unsuccessful storage transaction. This is a very low-level error representing a system configuration issue. 25 | """ 26 | 27 | 28 | @dataclasses.dataclass(frozen=True) 29 | class Entry: 30 | value: Value 31 | mutable: bool 32 | 33 | 34 | class Backend(MutableMapping[str, Entry]): 35 | """ 36 | Register backend interface implementing the :class:`MutableMapping` interface. 37 | The registers are ordered lexicographically by name. 38 | """ 39 | 40 | @property 41 | @abc.abstractmethod 42 | def location(self) -> str: 43 | """ 44 | The physical storage location for the data (e.g., file name). 45 | """ 46 | raise NotImplementedError 47 | 48 | @property 49 | @abc.abstractmethod 50 | def persistent(self) -> bool: 51 | """ 52 | An in-memory DB is reported as non-persistent. 53 | """ 54 | raise NotImplementedError 55 | 56 | @abc.abstractmethod 57 | def close(self) -> None: 58 | raise NotImplementedError 59 | 60 | @abc.abstractmethod 61 | def index(self, index: int) -> Optional[str]: 62 | """ 63 | :returns: Name of the register at the specified index or None if the index is out of range. 64 | See ordering requirements in the class docs. 65 | """ 66 | raise NotImplementedError 67 | 68 | @abc.abstractmethod 69 | def __setitem__(self, key: str, value: Union[Entry, Value]) -> None: 70 | """ 71 | If the register does not exist, it is either created or nothing is done, depending on the implementation. 72 | If exists, it will be overwritten unconditionally with the specified value. 73 | Observe that the method accepts either :class:`Entry` or :class:`Value`. 74 | 75 | The value shall be of the same type as the register, the caller is responsible to ensure that 76 | (implementations may lift this restriction if the type can be changed). 77 | 78 | The mutability flag is ignored (it is intended mostly for the Cyphal Register Interface, not for local use). 79 | """ 80 | raise NotImplementedError 81 | 82 | def __repr__(self) -> str: 83 | return pycyphal.util.repr_attributes(self, repr(self.location), persistent=self.persistent) 84 | -------------------------------------------------------------------------------- /pycyphal/dsdl/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | """ 6 | This module is used for automatic generation of Python classes from DSDL type definitions and 7 | also for various manipulations on them. 8 | Auto-generated classes have a high-level application-facing API and built-in auto-generated 9 | serialization and deserialization routines. 10 | 11 | The serialization code heavily relies on NumPy and the data alignment analysis implemented in PyDSDL. 12 | Some of the technical details are covered in the following posts: 13 | 14 | - https://forum.opencyphal.org/t/pycyphal-design-thread/504 15 | - https://github.com/OpenCyphal/pydsdl/pull/24 16 | 17 | The main entity of this module is the function :func:`compile`. 18 | """ 19 | 20 | from ._compiler import compile as compile # pylint: disable=redefined-builtin 21 | from ._compiler import compile_all as compile_all 22 | from ._compiler import GeneratedPackageInfo as GeneratedPackageInfo 23 | 24 | from ._import_hook import install_import_hook as install_import_hook 25 | 26 | from ._support_wrappers import serialize as serialize 27 | from ._support_wrappers import deserialize as deserialize 28 | from ._support_wrappers import get_model as get_model 29 | from ._support_wrappers import get_class as get_class 30 | from ._support_wrappers import get_extent_bytes as get_extent_bytes 31 | from ._support_wrappers import get_fixed_port_id as get_fixed_port_id 32 | from ._support_wrappers import get_attribute as get_attribute 33 | from ._support_wrappers import set_attribute as set_attribute 34 | from ._support_wrappers import is_serializable as is_serializable 35 | from ._support_wrappers import is_message_type as is_message_type 36 | from ._support_wrappers import is_service_type as is_service_type 37 | from ._support_wrappers import to_builtin as to_builtin 38 | from ._support_wrappers import update_from_builtin as update_from_builtin 39 | 40 | 41 | def generate_package(*args, **kwargs): # type: ignore # pragma: no cover 42 | """Deprecated alias of :func:`compile`.""" 43 | import warnings 44 | 45 | warnings.warn( 46 | "pycyphal.dsdl.generate_package() is deprecated; use pycyphal.dsdl.compile() instead.", 47 | DeprecationWarning, 48 | ) 49 | return compile(*args, **kwargs) 50 | -------------------------------------------------------------------------------- /pycyphal/dsdl/_support_wrappers.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | 4 | """ 5 | This module intentionally avoids importing ``nunavut_support`` at the module level to avoid dependency on 6 | autogenerated code unless explicitly requested by the application. 7 | """ 8 | 9 | from typing import TypeVar, Type, Sequence, Any, Iterable, Optional, Dict 10 | import pydsdl 11 | 12 | 13 | T = TypeVar("T") 14 | 15 | 16 | def serialize(obj: Any) -> Iterable[memoryview]: 17 | """ 18 | A wrapper over ``nunavut_support.serialize``. 19 | The ``nunavut_support`` module will be generated automatically if it is not importable. 20 | """ 21 | import nunavut_support 22 | 23 | return nunavut_support.serialize(obj) 24 | 25 | 26 | def deserialize(dtype: Type[T], fragmented_serialized_representation: Sequence[memoryview]) -> Optional[T]: 27 | """ 28 | A wrapper over ``nunavut_support.deserialize``. 29 | The ``nunavut_support`` module will be generated automatically if it is not importable. 30 | """ 31 | import nunavut_support 32 | 33 | return nunavut_support.deserialize(dtype, fragmented_serialized_representation) 34 | 35 | 36 | def get_model(class_or_instance: Any) -> pydsdl.CompositeType: 37 | """ 38 | A wrapper over ``nunavut_support.get_model``. 39 | The ``nunavut_support`` module will be generated automatically if it is not importable. 40 | """ 41 | import nunavut_support 42 | 43 | return nunavut_support.get_model(class_or_instance) 44 | 45 | 46 | def get_class(model: pydsdl.CompositeType) -> type: 47 | """ 48 | A wrapper over ``nunavut_support.get_class``. 49 | The ``nunavut_support`` module will be generated automatically if it is not importable. 50 | """ 51 | import nunavut_support 52 | 53 | return nunavut_support.get_class(model) 54 | 55 | 56 | def get_extent_bytes(class_or_instance: Any) -> int: 57 | """ 58 | A wrapper over ``nunavut_support.get_extent_bytes``. 59 | The ``nunavut_support`` module will be generated automatically if it is not importable. 60 | """ 61 | import nunavut_support 62 | 63 | return nunavut_support.get_extent_bytes(class_or_instance) 64 | 65 | 66 | def get_fixed_port_id(class_or_instance: Any) -> Optional[int]: 67 | """ 68 | A wrapper over ``nunavut_support.get_fixed_port_id``. 69 | The ``nunavut_support`` module will be generated automatically if it is not importable. 70 | """ 71 | import nunavut_support 72 | 73 | return nunavut_support.get_fixed_port_id(class_or_instance) 74 | 75 | 76 | def get_attribute(obj: Any, name: str) -> Any: 77 | """ 78 | A wrapper over ``nunavut_support.get_attribute``. 79 | The ``nunavut_support`` module will be generated automatically if it is not importable. 80 | """ 81 | import nunavut_support 82 | 83 | return nunavut_support.get_attribute(obj, name) 84 | 85 | 86 | def set_attribute(obj: Any, name: str, value: Any) -> None: 87 | """ 88 | A wrapper over ``nunavut_support.set_attribute``. 89 | The ``nunavut_support`` module will be generated automatically if it is not importable. 90 | """ 91 | import nunavut_support 92 | 93 | return nunavut_support.set_attribute(obj, name, value) 94 | 95 | 96 | def is_serializable(dtype: Any) -> bool: 97 | """ 98 | A wrapper over ``nunavut_support.is_serializable``. 99 | The ``nunavut_support`` module will be generated automatically if it is not importable. 100 | """ 101 | import nunavut_support 102 | 103 | return nunavut_support.is_serializable(dtype) 104 | 105 | 106 | def is_message_type(dtype: Any) -> bool: 107 | """ 108 | A wrapper over ``nunavut_support.is_message_type``. 109 | The ``nunavut_support`` module will be generated automatically if it is not importable. 110 | """ 111 | import nunavut_support 112 | 113 | return nunavut_support.is_message_type(dtype) 114 | 115 | 116 | def is_service_type(dtype: Any) -> bool: 117 | """ 118 | A wrapper over ``nunavut_support.is_service_type``. 119 | The ``nunavut_support`` module will be generated automatically if it is not importable. 120 | """ 121 | import nunavut_support 122 | 123 | return nunavut_support.is_service_type(dtype) 124 | 125 | 126 | def to_builtin(obj: object) -> Dict[str, Any]: 127 | """ 128 | A wrapper over ``nunavut_support.to_builtin``. 129 | The ``nunavut_support`` module will be generated automatically if it is not importable. 130 | """ 131 | import nunavut_support 132 | 133 | return nunavut_support.to_builtin(obj) 134 | 135 | 136 | def update_from_builtin(destination: T, source: Any) -> T: 137 | """ 138 | A wrapper over ``nunavut_support.update_from_builtin``. 139 | The ``nunavut_support`` module will be generated automatically if it is not importable. 140 | """ 141 | import nunavut_support 142 | 143 | return nunavut_support.update_from_builtin(destination, source) 144 | -------------------------------------------------------------------------------- /pycyphal/presentation/_port/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from ._base import Port as Port 6 | from ._base import Closable as Closable 7 | from ._base import MessagePort as MessagePort 8 | from ._base import ServicePort as ServicePort 9 | from ._base import DEFAULT_PRIORITY as DEFAULT_PRIORITY 10 | from ._base import DEFAULT_SERVICE_REQUEST_TIMEOUT as DEFAULT_SERVICE_REQUEST_TIMEOUT 11 | from ._base import OutgoingTransferIDCounter as OutgoingTransferIDCounter 12 | from ._base import PortFinalizer as PortFinalizer 13 | 14 | from ._publisher import Publisher as Publisher 15 | from ._publisher import PublisherImpl as PublisherImpl 16 | 17 | from ._subscriber import Subscriber as Subscriber 18 | from ._subscriber import SubscriberImpl as SubscriberImpl 19 | from ._subscriber import SubscriberStatistics as SubscriberStatistics 20 | 21 | from ._client import Client as Client 22 | from ._client import ClientImpl as ClientImpl 23 | from ._client import ClientStatistics as ClientStatistics 24 | 25 | from ._server import Server as Server 26 | from ._server import ServerStatistics as ServerStatistics 27 | from ._server import ServiceRequestMetadata as ServiceRequestMetadata 28 | from ._server import ServiceRequestHandler as ServiceRequestHandler 29 | 30 | from ._error import PortClosedError as PortClosedError 31 | from ._error import RequestTransferIDVariabilityExhaustedError as RequestTransferIDVariabilityExhaustedError 32 | -------------------------------------------------------------------------------- /pycyphal/presentation/_port/_base.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import abc 7 | import typing 8 | import pycyphal.util 9 | import pycyphal.transport 10 | 11 | 12 | DEFAULT_PRIORITY = pycyphal.transport.Priority.NOMINAL 13 | """ 14 | This value is not mandated by Specification, it is an implementation detail. 15 | """ 16 | 17 | DEFAULT_SERVICE_REQUEST_TIMEOUT = 1.0 18 | """ 19 | This value is recommended by Specification. 20 | """ 21 | 22 | PortFinalizer = typing.Callable[[typing.Sequence[pycyphal.transport.Session]], None] 23 | 24 | 25 | T = typing.TypeVar("T") 26 | 27 | 28 | class OutgoingTransferIDCounter: 29 | """ 30 | A member of the output transfer-ID map. Essentially this is just a boxed integer. 31 | The value is monotonically increasing starting from zero; 32 | transport-specific modulus is computed by the underlying transport(s). 33 | """ 34 | 35 | def __init__(self) -> None: 36 | """ 37 | Initializes the counter to zero. 38 | """ 39 | self._value: int = 0 40 | 41 | def get_then_increment(self) -> int: 42 | """ 43 | Samples the counter with post-increment; i.e., like ``i++``. 44 | """ 45 | out = self._value 46 | self._value += 1 47 | return out 48 | 49 | def override(self, value: int) -> None: 50 | """ 51 | Assigns a new value. Raises a :class:`ValueError` if the value is not a non-negative integer. 52 | """ 53 | value = int(value) 54 | if value >= 0: 55 | self._value = value 56 | else: 57 | raise ValueError(f"Not a valid transfer-ID value: {value}") 58 | 59 | def __repr__(self) -> str: 60 | return pycyphal.util.repr_attributes(self, self._value) 61 | 62 | 63 | class Closable(abc.ABC): 64 | """ 65 | Base class for closable session resources. 66 | """ 67 | 68 | @abc.abstractmethod 69 | def close(self) -> None: 70 | """ 71 | Invalidates the object and closes the underlying resources if necessary. 72 | 73 | If the closed object had a blocked task waiting for data, the task will raise a 74 | :class:`pycyphal.presentation.PortClosedError` shortly after close; 75 | or, if the task was started by the closed instance itself, it will be silently cancelled. 76 | At the moment the library provides no guarantees regarding how quickly the exception will be raised 77 | or the task cancelled; it is only guaranteed that it will happen automatically eventually, the 78 | application need not be involved in that. 79 | """ 80 | raise NotImplementedError 81 | 82 | 83 | class Port(Closable, typing.Generic[T]): 84 | """ 85 | The base class for any presentation layer session such as publisher, subscriber, client, or server. 86 | The term "port" came to be from . 87 | """ 88 | 89 | @property 90 | @abc.abstractmethod 91 | def dtype(self) -> typing.Type[T]: 92 | """ 93 | The generated Python class modeling the corresponding DSDL data type. 94 | """ 95 | raise NotImplementedError 96 | 97 | @property 98 | @abc.abstractmethod 99 | def port_id(self) -> int: 100 | """ 101 | The immutable subject-/service-ID of the underlying transport session instance. 102 | """ 103 | raise NotImplementedError 104 | 105 | @abc.abstractmethod 106 | def __repr__(self) -> str: 107 | raise NotImplementedError 108 | 109 | 110 | # noinspection DuplicatedCode 111 | class MessagePort(Port[T]): 112 | """ 113 | The base class for publishers and subscribers. 114 | """ 115 | 116 | @property 117 | @abc.abstractmethod 118 | def transport_session(self) -> pycyphal.transport.Session: 119 | """ 120 | The underlying transport session instance. Input for subscribers, output for publishers. 121 | One instance per session specifier. 122 | """ 123 | raise NotImplementedError 124 | 125 | @property 126 | def port_id(self) -> int: 127 | ds = self.transport_session.specifier.data_specifier 128 | assert isinstance(ds, pycyphal.transport.MessageDataSpecifier) 129 | return ds.subject_id 130 | 131 | def __repr__(self) -> str: 132 | import nunavut_support 133 | 134 | return pycyphal.util.repr_attributes( 135 | self, dtype=str(nunavut_support.get_model(self.dtype)), transport_session=self.transport_session 136 | ) 137 | 138 | 139 | # noinspection DuplicatedCode 140 | class ServicePort(Port[T]): 141 | @property 142 | @abc.abstractmethod 143 | def input_transport_session(self) -> pycyphal.transport.InputSession: 144 | """ 145 | The underlying transport session instance used for the input transfers 146 | (requests for servers, responses for clients). One instance per session specifier. 147 | """ 148 | raise NotImplementedError 149 | 150 | @property 151 | def port_id(self) -> int: 152 | ds = self.input_transport_session.specifier.data_specifier 153 | assert isinstance(ds, pycyphal.transport.ServiceDataSpecifier) 154 | return ds.service_id 155 | 156 | def __repr__(self) -> str: 157 | import nunavut_support 158 | 159 | return pycyphal.util.repr_attributes( 160 | self, dtype=str(nunavut_support.get_model(self.dtype)), input_transport_session=self.input_transport_session 161 | ) 162 | -------------------------------------------------------------------------------- /pycyphal/presentation/_port/_error.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import pycyphal.transport 6 | 7 | 8 | class PortClosedError(pycyphal.transport.ResourceClosedError): 9 | """ 10 | Raised when an attempt is made to use a presentation-layer session instance that has been closed. 11 | Observe that it is a specialization of the corresponding transport-layer error type. 12 | Double-close is NOT an error, so closing the same instance twice will not result in this exception being raised. 13 | """ 14 | 15 | 16 | class RequestTransferIDVariabilityExhaustedError(pycyphal.transport.TransportError): 17 | """ 18 | Raised when an attempt is made to invoke more concurrent requests that supported by the transport layer. 19 | For CAN, the number is 32; for some transports the number is unlimited (technically, there is always a limit, 20 | but for some transports, such as the serial transport, it is unreachable in practice). 21 | """ 22 | -------------------------------------------------------------------------------- /pycyphal/presentation/subscription_synchronizer/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from ._common import get_timestamp_field as get_timestamp_field 6 | from ._common import get_local_reception_timestamp as get_local_reception_timestamp 7 | from ._common import get_local_reception_monotonic_timestamp as get_local_reception_monotonic_timestamp 8 | 9 | from ._common import MessageWithMetadata as MessageWithMetadata 10 | from ._common import SynchronizedGroup as SynchronizedGroup 11 | from ._common import Synchronizer as Synchronizer 12 | -------------------------------------------------------------------------------- /pycyphal/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenCyphal/pycyphal/71eb3ae06b51820f0f4ec27e2f1edc48f73fad8e/pycyphal/py.typed -------------------------------------------------------------------------------- /pycyphal/transport/_data_specifier.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import enum 7 | import dataclasses 8 | 9 | 10 | @dataclasses.dataclass(frozen=True) 11 | class DataSpecifier: 12 | """ 13 | The data specifier defines what category and type of data is exchanged over a transport session. 14 | See the abstract transport model for details. 15 | """ 16 | 17 | 18 | @dataclasses.dataclass(frozen=True) 19 | class MessageDataSpecifier(DataSpecifier): 20 | SUBJECT_ID_MASK = 2**13 - 1 21 | 22 | subject_id: int 23 | 24 | def __post_init__(self) -> None: 25 | if not (0 <= self.subject_id <= self.SUBJECT_ID_MASK): 26 | raise ValueError(f"Invalid subject-ID: {self.subject_id}") 27 | 28 | 29 | @dataclasses.dataclass(frozen=True) 30 | class ServiceDataSpecifier(DataSpecifier): 31 | class Role(enum.Enum): 32 | REQUEST = enum.auto() 33 | """ 34 | Request output role is for clients. 35 | Request input role is for servers. 36 | """ 37 | RESPONSE = enum.auto() 38 | """ 39 | Response output role is for servers. 40 | Response input role is for clients. 41 | """ 42 | 43 | SERVICE_ID_MASK = 2**9 - 1 44 | 45 | service_id: int 46 | role: Role 47 | 48 | def __post_init__(self) -> None: 49 | assert self.role in self.Role 50 | if not (0 <= self.service_id <= self.SERVICE_ID_MASK): 51 | raise ValueError(f"Invalid service ID: {self.service_id}") 52 | -------------------------------------------------------------------------------- /pycyphal/transport/_error.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | 6 | class TransportError(RuntimeError): 7 | """ 8 | This is the root exception class for all transport-related errors. 9 | Exception types defined at the higher layers up the protocol stack (e.g., the presentation layer) 10 | also inherit from this type, so the application may use this type as the base exception type for all 11 | Cyphal-related errors that occur at runtime. 12 | 13 | This exception type hierarchy is intentionally separated from DSDL-related errors that may occur at 14 | code generation time. 15 | """ 16 | 17 | 18 | class InvalidTransportConfigurationError(TransportError): 19 | """ 20 | The transport could not be initialized or the operation could not be performed 21 | because the specified configuration is invalid. 22 | """ 23 | 24 | 25 | class InvalidMediaConfigurationError(InvalidTransportConfigurationError): 26 | """ 27 | The transport could not be initialized or the operation could not be performed 28 | because the specified media configuration is invalid. 29 | """ 30 | 31 | 32 | class UnsupportedSessionConfigurationError(TransportError): 33 | """ 34 | The requested session configuration is not supported by this transport. 35 | For example, this exception would be raised if one attempted to create a unicast output for messages over 36 | the CAN bus transport. 37 | """ 38 | 39 | 40 | class OperationNotDefinedForAnonymousNodeError(TransportError): 41 | """ 42 | The requested action would normally be possible, but it is currently not because the transport instance does not 43 | have a node-ID assigned. 44 | """ 45 | 46 | 47 | class ResourceClosedError(TransportError): 48 | """ 49 | The requested operation could not be performed because an associated resource has already been terminated. 50 | Double-close should not raise exceptions. 51 | """ 52 | -------------------------------------------------------------------------------- /pycyphal/transport/_payload_metadata.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import dataclasses 7 | 8 | 9 | @dataclasses.dataclass(frozen=True) 10 | class PayloadMetadata: 11 | """ 12 | This information is obtained from the data type definition. 13 | 14 | Eventually, this type might include the runtime type identification information, 15 | if it is ever implemented in Cyphal. The alpha revision used to contain the "data type hash" field here, 16 | but this concept was found deficient and removed from the proposal. 17 | You can find related discussion in https://forum.opencyphal.org/t/alternative-transport-protocols-in-uavcan/324. 18 | """ 19 | 20 | extent_bytes: int 21 | """ 22 | The minimum amount of memory required to hold any serialized representation of any compatible version 23 | of the data type; or, on other words, it is the the maximum possible size of received objects. 24 | The size is specified in bytes because extent is guaranteed (by definition) to be an integer number of bytes long. 25 | 26 | This parameter is determined by the data type author at the data type definition time. 27 | It is typically larger than the maximum object size in order to allow the data type author to 28 | introduce more fields in the future versions of the type; 29 | for example, ``MyMessage.1.0`` may have the maximum size of 100 bytes and the extent 200 bytes; 30 | a revised version ``MyMessage.1.1`` may have the maximum size anywhere between 0 and 200 bytes. 31 | It is always safe to pick a larger value if not sure. 32 | You will find a more rigorous description in the Cyphal Specification. 33 | 34 | Transport implementations may use this information to statically size receive buffers. 35 | """ 36 | 37 | def __post_init__(self) -> None: 38 | if self.extent_bytes < 0: 39 | raise ValueError(f"Invalid extent [byte]: {self.extent_bytes}") 40 | -------------------------------------------------------------------------------- /pycyphal/transport/_transfer.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import enum 7 | import typing 8 | import dataclasses 9 | import pycyphal.util 10 | from ._timestamp import Timestamp 11 | 12 | 13 | FragmentedPayload = typing.Sequence[memoryview] 14 | """ 15 | Transfer payload is allowed to be segmented to facilitate zero-copy implementations. 16 | The format of the memoryview object should be 'B'. 17 | We're using Sequence and not Iterable to permit sharing across multiple consumers. 18 | """ 19 | 20 | 21 | class Priority(enum.IntEnum): 22 | """ 23 | Transfer priority enumeration follows the recommended names provided in the Cyphal specification. 24 | We use integers here in order to allow usage of static lookup tables for conversion into transport-specific 25 | priority values. The particular integer values used here may be meaningless for some transports. 26 | """ 27 | 28 | EXCEPTIONAL = 0 29 | IMMEDIATE = 1 30 | FAST = 2 31 | HIGH = 3 32 | NOMINAL = 4 33 | LOW = 5 34 | SLOW = 6 35 | OPTIONAL = 7 36 | 37 | 38 | @dataclasses.dataclass(frozen=True) 39 | class Transfer: 40 | """ 41 | Cyphal transfer representation. 42 | """ 43 | 44 | timestamp: Timestamp 45 | """ 46 | For output (tx) transfers this field contains the transfer creation timestamp. 47 | For input (rx) transfers this field contains the first frame reception timestamp. 48 | """ 49 | 50 | priority: Priority 51 | """ 52 | See :class:`Priority`. 53 | """ 54 | 55 | transfer_id: int 56 | """ 57 | When transmitting, the appropriate modulus will be computed by the transport automatically. 58 | Higher layers shall use monotonically increasing transfer-ID counters. 59 | """ 60 | 61 | fragmented_payload: FragmentedPayload 62 | """ 63 | See :class:`FragmentedPayload`. This is the serialized application-level payload. 64 | Fragmentation may be completely arbitrary. 65 | Received transfers usually have it fragmented such that one fragment corresponds to one received frame. 66 | Outgoing transfers usually fragment it according to the structure of the serialized data object. 67 | The purpose of fragmentation is to eliminate unnecessary data copying within the protocol stack. 68 | :func:`pycyphal.transport.commons.refragment` is designed to facilitate regrouping when sending a transfer. 69 | """ 70 | 71 | def __repr__(self) -> str: 72 | fragmented_payload = "+".join(f"{len(x)}B" for x in self.fragmented_payload) 73 | kwargs = {f.name: getattr(self, f.name) for f in dataclasses.fields(self)} 74 | kwargs["priority"] = self.priority.name 75 | kwargs["fragmented_payload"] = f"[{fragmented_payload}]" 76 | del kwargs["timestamp"] 77 | return pycyphal.util.repr_attributes(self, str(self.timestamp), **kwargs) 78 | 79 | 80 | @dataclasses.dataclass(frozen=True, repr=False) 81 | class TransferFrom(Transfer): 82 | """ 83 | Specialization for received transfers. 84 | """ 85 | 86 | source_node_id: typing.Optional[int] 87 | """ 88 | None indicates anonymous transfers. 89 | """ 90 | -------------------------------------------------------------------------------- /pycyphal/transport/can/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | """ 6 | Cyphal/CAN transport overview 7 | +++++++++++++++++++++++++++++ 8 | 9 | This module implements Cyphal/CAN -- the CAN transport for Cyphal, both Classic CAN and CAN FD, 10 | as defined in the Cyphal specification. 11 | Cyphal does not distinguish between the two aside from the MTU difference; neither does this implementation. 12 | Classic CAN is essentially treated as CAN FD with MTU of 8 bytes. 13 | 14 | Different CAN hardware is supported through the media sublayer; please refer to :mod:`pycyphal.transport.can.media`. 15 | 16 | Per the Cyphal specification, the CAN transport supports broadcast messages and unicast services: 17 | 18 | +--------------------+--------------------------+---------------------------+ 19 | | Supported transfers| Unicast | Broadcast | 20 | +====================+==========================+===========================+ 21 | |**Message** | No | Yes | 22 | +--------------------+--------------------------+---------------------------+ 23 | |**Service** | Yes | Banned by Specification | 24 | +--------------------+--------------------------+---------------------------+ 25 | 26 | 27 | Tooling 28 | +++++++ 29 | 30 | Some of the media sub-layer implementations support virtual CAN bus interfaces 31 | (e.g., SocketCAN on GNU/Linux); they are often useful for testing. 32 | Please read the media sub-layer documentation for details. 33 | 34 | 35 | Inheritance diagram 36 | +++++++++++++++++++ 37 | 38 | .. inheritance-diagram:: pycyphal.transport.can._can 39 | pycyphal.transport.can._session._input 40 | pycyphal.transport.can._session._output 41 | pycyphal.transport.can._tracer 42 | :parts: 1 43 | """ 44 | 45 | # Please keep the elements well-ordered because the order is reflected in the docs. 46 | # Core components first. 47 | from ._can import CANTransport as CANTransport 48 | 49 | from ._session import CANInputSession as CANInputSession 50 | from ._session import CANOutputSession as CANOutputSession 51 | 52 | # Statistics. 53 | from ._can import CANTransportStatistics as CANTransportStatistics 54 | 55 | from ._session import CANInputSessionStatistics as CANInputSessionStatistics 56 | from ._session import TransferReassemblyErrorID as TransferReassemblyErrorID 57 | 58 | # Analysis. 59 | from ._tracer import CANCapture as CANCapture 60 | from ._tracer import CANErrorTrace as CANErrorTrace 61 | from ._tracer import CANTracer as CANTracer 62 | 63 | # Media sub-layer. 64 | from . import media as media 65 | -------------------------------------------------------------------------------- /pycyphal/transport/can/_frame.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import typing 7 | import dataclasses 8 | import pycyphal.util 9 | from .media import DataFrame, FrameFormat 10 | 11 | 12 | TRANSFER_ID_MODULO = 32 13 | 14 | TRANSFER_CRC_LENGTH_BYTES = 2 15 | 16 | 17 | @dataclasses.dataclass(frozen=True) 18 | class CyphalFrame: 19 | identifier: int 20 | transfer_id: int 21 | start_of_transfer: bool 22 | end_of_transfer: bool 23 | toggle_bit: bool 24 | padded_payload: memoryview 25 | 26 | def __post_init__(self) -> None: 27 | if self.transfer_id < 0: 28 | raise ValueError("Transfer ID cannot be negative") 29 | 30 | if self.start_of_transfer and not self.toggle_bit: 31 | raise ValueError(f"The toggle bit must be set in the first frame of the transfer") 32 | 33 | def compile(self) -> DataFrame: 34 | tail = self.transfer_id % TRANSFER_ID_MODULO 35 | if self.start_of_transfer: 36 | tail |= 1 << 7 37 | if self.end_of_transfer: 38 | tail |= 1 << 6 39 | if self.toggle_bit: 40 | tail |= 1 << 5 41 | 42 | data = bytearray(self.padded_payload) 43 | data.append(tail) 44 | return DataFrame(FrameFormat.EXTENDED, self.identifier, data) 45 | 46 | @staticmethod 47 | def parse(source: DataFrame) -> typing.Optional[CyphalFrame]: 48 | if source.format != FrameFormat.EXTENDED: 49 | return None 50 | if len(source.data) < 1: 51 | return None 52 | 53 | padded_payload, tail = memoryview(source.data)[:-1], source.data[-1] 54 | transfer_id = tail & (TRANSFER_ID_MODULO - 1) 55 | sot, eot, tog = tuple(tail & (1 << x) != 0 for x in (7, 6, 5)) 56 | if sot and not tog: 57 | return None 58 | 59 | return CyphalFrame( 60 | identifier=source.identifier, 61 | transfer_id=transfer_id, 62 | start_of_transfer=sot, 63 | end_of_transfer=eot, 64 | toggle_bit=tog, 65 | padded_payload=padded_payload, 66 | ) 67 | 68 | @staticmethod 69 | def get_required_padding(data_length: int) -> int: 70 | return DataFrame.get_required_padding(data_length + 1) # +1 for the tail byte 71 | 72 | def __repr__(self) -> str: 73 | kwargs = {f.name: getattr(self, f.name) for f in dataclasses.fields(self)} 74 | kwargs["identifier"] = f"0x{self.identifier:08x}" 75 | kwargs["padded_payload"] = bytes(self.padded_payload).hex() 76 | return pycyphal.util.repr_attributes(self, **kwargs) 77 | 78 | 79 | def compute_transfer_id_forward_distance(a: int, b: int) -> int: 80 | """ 81 | The algorithm is defined in the CAN bus transport layer specification of the Cyphal Specification. 82 | """ 83 | assert a >= 0 and b >= 0 84 | a %= TRANSFER_ID_MODULO 85 | b %= TRANSFER_ID_MODULO 86 | d = b - a 87 | if d < 0: 88 | d += TRANSFER_ID_MODULO 89 | 90 | assert 0 <= d < TRANSFER_ID_MODULO 91 | assert (a + d) & (TRANSFER_ID_MODULO - 1) == b 92 | return d 93 | 94 | 95 | def _unittest_can_transfer_id_forward_distance() -> None: 96 | cfd = compute_transfer_id_forward_distance 97 | assert 0 == cfd(0, 0) 98 | assert 1 == cfd(0, 1) 99 | assert 7 == cfd(0, 7) 100 | assert 0 == cfd(7, 7) 101 | assert 1 == cfd(31, 0) 102 | assert 5 == cfd(0, 5) 103 | assert 31 == cfd(31, 30) 104 | assert 30 == cfd(7, 5) 105 | 106 | 107 | def _unittest_can_cyphal_frame() -> None: 108 | from pytest import raises 109 | 110 | CyphalFrame(123, 123, True, False, True, memoryview(b"")) 111 | CyphalFrame(123, 123, False, False, True, memoryview(b"")) 112 | CyphalFrame(123, 123, False, False, False, memoryview(b"")) 113 | 114 | with raises(ValueError): 115 | CyphalFrame(123, -1, True, False, True, memoryview(b"")) 116 | 117 | with raises(ValueError): 118 | CyphalFrame(123, 123, True, False, False, memoryview(b"")) 119 | 120 | ref = CyphalFrame( 121 | identifier=0, 122 | transfer_id=0, 123 | start_of_transfer=False, 124 | end_of_transfer=False, 125 | toggle_bit=False, 126 | padded_payload=memoryview(b""), 127 | ) 128 | assert ref == CyphalFrame.parse(DataFrame(FrameFormat.EXTENDED, 0, bytearray(b"\x00"))) 129 | 130 | ref = CyphalFrame( 131 | identifier=123456, 132 | transfer_id=12, 133 | start_of_transfer=True, 134 | end_of_transfer=False, 135 | toggle_bit=True, 136 | padded_payload=memoryview(b"Hello"), 137 | ) 138 | assert ref == CyphalFrame.parse(DataFrame(FrameFormat.EXTENDED, 123456, bytearray(b"Hello\xac"))) 139 | 140 | ref = CyphalFrame( 141 | identifier=1234567, 142 | transfer_id=12, 143 | start_of_transfer=False, 144 | end_of_transfer=True, 145 | toggle_bit=True, 146 | padded_payload=memoryview(b"Hello"), 147 | ) 148 | assert ref == CyphalFrame.parse(DataFrame(FrameFormat.EXTENDED, 1234567, bytearray(b"Hello\x6c"))) 149 | 150 | assert CyphalFrame.parse(DataFrame(FrameFormat.EXTENDED, 1234567, bytearray(b"Hello\xcc"))) is None # Bad toggle 151 | 152 | assert CyphalFrame.parse(DataFrame(FrameFormat.EXTENDED, 1234567, bytearray(b""))) is None # No tail byte 153 | 154 | assert CyphalFrame.parse(DataFrame(FrameFormat.BASE, 123, bytearray(b"Hello\x6c"))) is None # Bad frame format 155 | -------------------------------------------------------------------------------- /pycyphal/transport/can/_input_dispatch_table.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import typing 7 | from pycyphal.transport import MessageDataSpecifier, ServiceDataSpecifier, InputSessionSpecifier 8 | from ._session import CANInputSession 9 | from ._identifier import CANID 10 | 11 | 12 | class InputDispatchTable: 13 | """ 14 | Time-memory trade-off: the input dispatch table is tens of megabytes large, but the lookup is very fast and O(1). 15 | This is necessary to ensure scalability for high-load applications such as real-time network monitoring. 16 | """ 17 | 18 | _NUM_SUBJECTS = MessageDataSpecifier.SUBJECT_ID_MASK + 1 19 | _NUM_SERVICES = ServiceDataSpecifier.SERVICE_ID_MASK + 1 20 | _NUM_NODE_IDS = CANID.NODE_ID_MASK + 1 21 | 22 | # Services multiplied by two to account for requests and responses. 23 | # One added to nodes to allow promiscuous inputs which don't care about source node ID. 24 | _TABLE_SIZE = (_NUM_SUBJECTS + _NUM_SERVICES * 2) * (_NUM_NODE_IDS + 1) 25 | 26 | def __init__(self) -> None: 27 | # This method of construction is an order of magnitude faster than range-based. It matters here. A lot. 28 | self._table: typing.List[typing.Optional[CANInputSession]] = [None] * (self._TABLE_SIZE + 1) 29 | 30 | # A parallel dict is necessary for constant-complexity element listing. Traversing the table takes forever. 31 | self._dict: typing.Dict[InputSessionSpecifier, CANInputSession] = {} 32 | 33 | @property 34 | def items(self) -> typing.Iterable[CANInputSession]: 35 | return self._dict.values() 36 | 37 | def add(self, session: CANInputSession) -> None: 38 | """ 39 | This method is used only when a new input session is created; performance is not a priority. 40 | """ 41 | key = session.specifier 42 | self._table[self._compute_index(key)] = session 43 | self._dict[key] = session 44 | 45 | def get(self, specifier: InputSessionSpecifier) -> typing.Optional[CANInputSession]: 46 | """ 47 | Constant-time lookup. Invoked for every received frame. 48 | """ 49 | return self._table[self._compute_index(specifier)] 50 | 51 | def remove(self, specifier: InputSessionSpecifier) -> None: 52 | """ 53 | This method is used only when an input session is destroyed; performance is not a priority. 54 | """ 55 | self._table[self._compute_index(specifier)] = None 56 | del self._dict[specifier] 57 | 58 | @staticmethod 59 | def _compute_index(specifier: InputSessionSpecifier) -> int: 60 | ds, nid = specifier.data_specifier, specifier.remote_node_id 61 | if isinstance(ds, MessageDataSpecifier): 62 | dim1 = ds.subject_id 63 | elif isinstance(ds, ServiceDataSpecifier): 64 | if ds.role == ds.Role.REQUEST: 65 | dim1 = ds.service_id + InputDispatchTable._NUM_SUBJECTS 66 | elif ds.role == ds.Role.RESPONSE: 67 | dim1 = ds.service_id + InputDispatchTable._NUM_SUBJECTS + InputDispatchTable._NUM_SERVICES 68 | else: 69 | assert False 70 | else: 71 | assert False 72 | 73 | dim2_cardinality = InputDispatchTable._NUM_NODE_IDS + 1 74 | dim2 = nid if nid is not None else InputDispatchTable._NUM_NODE_IDS 75 | 76 | point = dim1 * dim2_cardinality + dim2 77 | 78 | assert 0 <= point < InputDispatchTable._TABLE_SIZE 79 | return point 80 | 81 | 82 | def _unittest_input_dispatch_table() -> None: 83 | from pytest import raises 84 | from pycyphal.transport import PayloadMetadata 85 | 86 | t = InputDispatchTable() 87 | assert len(list(t.items)) == 0 88 | assert t.get(InputSessionSpecifier(MessageDataSpecifier(1234), None)) is None 89 | with raises(LookupError): 90 | t.remove(InputSessionSpecifier(MessageDataSpecifier(1234), 123)) 91 | 92 | a = CANInputSession( 93 | InputSessionSpecifier(MessageDataSpecifier(1234), None), 94 | PayloadMetadata(456), 95 | lambda: None, 96 | ) 97 | t.add(a) 98 | t.add(a) 99 | assert list(t.items) == [a] 100 | assert t.get(InputSessionSpecifier(MessageDataSpecifier(1234), None)) == a 101 | t.remove(InputSessionSpecifier(MessageDataSpecifier(1234), None)) 102 | assert len(list(t.items)) == 0 103 | 104 | 105 | def _unittest_slow_input_dispatch_table_index() -> None: 106 | table_size = InputDispatchTable._TABLE_SIZE # pylint: disable=protected-access 107 | values: typing.Set[int] = set() 108 | for node_id in (*range(InputDispatchTable._NUM_NODE_IDS), None): # pylint: disable=protected-access 109 | for subj in range(InputDispatchTable._NUM_SUBJECTS): # pylint: disable=protected-access 110 | out = InputDispatchTable._compute_index( # pylint: disable=protected-access 111 | InputSessionSpecifier(MessageDataSpecifier(subj), node_id) 112 | ) 113 | assert out not in values 114 | values.add(out) 115 | assert out < table_size 116 | 117 | for serv in range(InputDispatchTable._NUM_SERVICES): # pylint: disable=protected-access 118 | for role in ServiceDataSpecifier.Role: 119 | out = InputDispatchTable._compute_index( # pylint: disable=protected-access 120 | InputSessionSpecifier(ServiceDataSpecifier(serv, role), node_id) 121 | ) 122 | assert out not in values 123 | values.add(out) 124 | assert out < table_size 125 | 126 | assert len(values) == table_size 127 | -------------------------------------------------------------------------------- /pycyphal/transport/can/_session/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from ._base import SessionFinalizer as SessionFinalizer 6 | 7 | from ._input import CANInputSession as CANInputSession 8 | from ._input import CANInputSessionStatistics as CANInputSessionStatistics 9 | 10 | from ._output import CANOutputSession as CANOutputSession 11 | from ._output import BroadcastCANOutputSession as BroadcastCANOutputSession 12 | from ._output import UnicastCANOutputSession as UnicastCANOutputSession 13 | from ._output import SendTransaction as SendTransaction 14 | 15 | from ._transfer_reassembler import TransferReassemblyErrorID as TransferReassemblyErrorID 16 | from ._transfer_reassembler import TransferReassembler as TransferReassembler 17 | 18 | from ._transfer_sender import serialize_transfer as serialize_transfer 19 | -------------------------------------------------------------------------------- /pycyphal/transport/can/_session/_base.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import typing 7 | import logging 8 | import pycyphal.transport 9 | 10 | 11 | SessionFinalizer = typing.Callable[[], None] 12 | 13 | 14 | _logger = logging.getLogger(__name__) 15 | 16 | 17 | class CANSession: 18 | def __init__(self, finalizer: SessionFinalizer): 19 | self._close_finalizer: typing.Optional[SessionFinalizer] = finalizer 20 | 21 | def _raise_if_closed(self) -> None: 22 | if self._close_finalizer is None: 23 | raise pycyphal.transport.ResourceClosedError( 24 | f"The requested action cannot be performed because the session object {self} is closed" 25 | ) 26 | 27 | def close(self) -> None: 28 | fin = self._close_finalizer 29 | if fin is not None: 30 | self._close_finalizer = None 31 | fin() 32 | -------------------------------------------------------------------------------- /pycyphal/transport/can/media/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from ._media import Media as Media 6 | 7 | from ._frame import FrameFormat as FrameFormat 8 | from ._frame import DataFrame as DataFrame 9 | from ._frame import Envelope as Envelope 10 | 11 | from ._filter import FilterConfiguration as FilterConfiguration 12 | from ._filter import optimize_filter_configurations as optimize_filter_configurations 13 | -------------------------------------------------------------------------------- /pycyphal/transport/can/media/_frame.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import enum 7 | import typing 8 | import dataclasses 9 | import pycyphal 10 | 11 | 12 | class FrameFormat(enum.IntEnum): 13 | BASE = 11 14 | EXTENDED = 29 15 | 16 | 17 | @dataclasses.dataclass(frozen=True) 18 | class DataFrame: 19 | format: FrameFormat 20 | identifier: int 21 | data: bytearray 22 | 23 | def __post_init__(self) -> None: 24 | assert isinstance(self.format, FrameFormat) 25 | if not (0 <= self.identifier < 2 ** int(self.format)): 26 | raise ValueError(f"Invalid CAN ID for format {self.format}: {self.identifier}") 27 | 28 | if len(self.data) not in _LENGTH_TO_DLC: 29 | raise ValueError(f"Unsupported data length: {len(self.data)}") 30 | 31 | @property 32 | def dlc(self) -> int: 33 | """Not to be confused with ``len(data)``.""" 34 | return _LENGTH_TO_DLC[len(self.data)] # The length is checked at the time of construction 35 | 36 | @staticmethod 37 | def convert_dlc_to_length(dlc: int) -> int: 38 | try: 39 | return _DLC_TO_LENGTH[dlc] 40 | except LookupError: 41 | raise ValueError(f"{dlc} is not a valid DLC") from None 42 | 43 | @staticmethod 44 | def get_required_padding(data_length: int) -> int: 45 | """ 46 | Computes padding to nearest valid CAN FD frame size. 47 | 48 | >>> DataFrame.get_required_padding(6) 49 | 0 50 | >>> DataFrame.get_required_padding(61) 51 | 3 52 | """ 53 | supremum = next(x for x in _DLC_TO_LENGTH if x >= data_length) # pragma: no branch 54 | assert supremum >= data_length 55 | return supremum - data_length 56 | 57 | def __repr__(self) -> str: 58 | ide = { 59 | FrameFormat.EXTENDED: "0x%08x", 60 | FrameFormat.BASE: "0x%03x", 61 | }[self.format] % self.identifier 62 | return pycyphal.util.repr_attributes(self, id=ide, data=self.data.hex()) 63 | 64 | 65 | @dataclasses.dataclass(frozen=True) 66 | class Envelope: 67 | """ 68 | The envelope models a singular input/output frame transaction. 69 | It is a media layer frame extended with IO-related metadata. 70 | """ 71 | 72 | frame: DataFrame 73 | loopback: bool 74 | """Loopback request for outgoing frames; loopback indicator for received frames.""" 75 | 76 | 77 | _DLC_TO_LENGTH = [0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64] 78 | _LENGTH_TO_DLC: typing.Dict[int, int] = dict(zip(*list(zip(*enumerate(_DLC_TO_LENGTH)))[::-1])) 79 | assert len(_LENGTH_TO_DLC) == 16 == len(_DLC_TO_LENGTH) 80 | for item in _DLC_TO_LENGTH: 81 | assert _DLC_TO_LENGTH[_LENGTH_TO_DLC[item]] == item, "Invalid DLC tables" 82 | 83 | 84 | def _unittest_can_media_frame() -> None: 85 | from pytest import raises 86 | 87 | for fmt in FrameFormat: 88 | with raises(ValueError): 89 | DataFrame(fmt, -1, bytearray()) 90 | 91 | with raises(ValueError): 92 | DataFrame(fmt, 2 ** int(fmt), bytearray()) 93 | 94 | with raises(ValueError): 95 | DataFrame(FrameFormat.EXTENDED, 123, bytearray(b"a" * 9)) 96 | 97 | with raises(ValueError): 98 | DataFrame.convert_dlc_to_length(16) 99 | 100 | for sz in range(100): 101 | try: 102 | f = DataFrame(FrameFormat.EXTENDED, 123, bytearray(b"a" * sz)) 103 | except ValueError: 104 | pass 105 | else: 106 | assert f.convert_dlc_to_length(f.dlc) == sz 107 | -------------------------------------------------------------------------------- /pycyphal/transport/can/media/candump/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from ._candump import CandumpMedia as CandumpMedia 6 | -------------------------------------------------------------------------------- /pycyphal/transport/can/media/pythoncan/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from ._pythoncan import PythonCANMedia as PythonCANMedia 6 | -------------------------------------------------------------------------------- /pycyphal/transport/can/media/socketcan/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | """ 6 | The module is always importable but is functional only on GNU/Linux. 7 | 8 | For testing or experimentation on a local machine it is often convenient to use a virtual CAN bus instead of a real one. 9 | Using SocketCAN, one can set up a virtual CAN bus interface as follows:: 10 | 11 | modprobe can 12 | modprobe can_raw 13 | modprobe vcan 14 | ip link add dev vcan0 type vcan 15 | ip link set vcan0 mtu 72 # Enable CAN FD by configuring the MTU of 64+8 16 | ip link set up vcan0 17 | 18 | Where ``vcan0`` can be replaced with any other valid interface name. 19 | Please read the SocketCAN documentation for more information. 20 | """ 21 | 22 | from sys import platform as _platform 23 | 24 | if _platform == "linux": 25 | from ._socketcan import SocketCANMedia as SocketCANMedia 26 | -------------------------------------------------------------------------------- /pycyphal/transport/can/media/socketcand/__init__.py: -------------------------------------------------------------------------------- 1 | from ._socketcand import SocketcandMedia as SocketcandMedia 2 | -------------------------------------------------------------------------------- /pycyphal/transport/commons/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | """ 6 | This module does not implement a transport, and it is not a part of the abstract transport model. 7 | It contains a collection of software components implementing common logic reusable 8 | with different transport implementations. 9 | It is expected that some transport implementations may be unable to rely on these. 10 | 11 | This module is unlikely to be useful for a regular library user (not a developer). 12 | """ 13 | 14 | from . import crc as crc 15 | from . import high_overhead_transport as high_overhead_transport 16 | 17 | from ._refragment import refragment as refragment 18 | -------------------------------------------------------------------------------- /pycyphal/transport/commons/crc/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | """ 6 | This module contains implementations of various CRC algorithms used by the transports. 7 | 8 | `32-Bit Cyclic Redundancy Codes for Internet Applications (Philip Koopman) 9 | `_. 10 | """ 11 | 12 | from ._base import CRCAlgorithm as CRCAlgorithm 13 | from ._crc16_ccitt import CRC16CCITT as CRC16CCITT 14 | from ._crc32c import CRC32C as CRC32C 15 | from ._crc64we import CRC64WE as CRC64WE 16 | -------------------------------------------------------------------------------- /pycyphal/transport/commons/crc/_base.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import abc 7 | import typing 8 | 9 | 10 | class CRCAlgorithm(abc.ABC): 11 | """ 12 | Implementations are default-constructible. 13 | """ 14 | 15 | @abc.abstractmethod 16 | def add(self, data: typing.Union[bytes, bytearray, memoryview]) -> None: 17 | """ 18 | Updates the value with the specified block of data. 19 | """ 20 | raise NotImplementedError 21 | 22 | @abc.abstractmethod 23 | def check_residue(self) -> bool: 24 | """ 25 | Checks if the current state matches the algorithm-specific residue. 26 | """ 27 | raise NotImplementedError 28 | 29 | @property 30 | @abc.abstractmethod 31 | def value(self) -> int: 32 | """ 33 | The current CRC value, with output XOR applied, if applicable. 34 | """ 35 | raise NotImplementedError 36 | 37 | @property 38 | @abc.abstractmethod 39 | def value_as_bytes(self) -> bytes: 40 | """ 41 | The current CRC value serialized in the algorithm-specific byte order. 42 | """ 43 | raise NotImplementedError 44 | 45 | @classmethod 46 | def new(cls, *fragments: typing.Union[bytes, bytearray, memoryview]) -> CRCAlgorithm: 47 | """ 48 | A factory that creates the new instance with the value computed over the fragments. 49 | """ 50 | self = cls() 51 | for frag in fragments: 52 | self.add(frag) 53 | return self 54 | -------------------------------------------------------------------------------- /pycyphal/transport/commons/crc/_crc16_ccitt.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import typing 6 | from ._base import CRCAlgorithm 7 | 8 | 9 | class CRC16CCITT(CRCAlgorithm): 10 | """ 11 | - Name: CRC-16/CCITT-FALSE 12 | - Initial value: 0xFFFF 13 | - Polynomial: 0x1021 14 | - Reverse: No 15 | - Output XOR: 0 16 | - Residue: 0 17 | - Check: 0x29B1 18 | 19 | >>> assert CRC16CCITT().value == 0xFFFF 20 | >>> c = CRC16CCITT() 21 | >>> c.add(b'123456') 22 | >>> c.add(b'789') 23 | >>> c.value 24 | 10673 25 | >>> c.add(b'') 26 | >>> c.value 27 | 10673 28 | >>> c.add(c.value_as_bytes) 29 | >>> c.value 30 | 0 31 | >>> c.check_residue() 32 | True 33 | """ 34 | 35 | def __init__(self) -> None: 36 | assert len(self._TABLE) == 256 37 | self._value = 0xFFFF 38 | 39 | def add(self, data: typing.Union[bytes, bytearray, memoryview]) -> None: 40 | val = self._value 41 | for x in data: 42 | val = ((val << 8) & 0xFFFF) ^ self._TABLE[(val >> 8) ^ x] 43 | self._value = val 44 | 45 | def check_residue(self) -> bool: 46 | return self._value == 0 47 | 48 | @property 49 | def value(self) -> int: 50 | return self._value 51 | 52 | @property 53 | def value_as_bytes(self) -> bytes: 54 | return self.value.to_bytes(2, "big") 55 | 56 | # fmt: off 57 | _TABLE = [ 58 | 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7, 59 | 0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF, 60 | 0x1231, 0x0210, 0x3273, 0x2252, 0x52B5, 0x4294, 0x72F7, 0x62D6, 61 | 0x9339, 0x8318, 0xB37B, 0xA35A, 0xD3BD, 0xC39C, 0xF3FF, 0xE3DE, 62 | 0x2462, 0x3443, 0x0420, 0x1401, 0x64E6, 0x74C7, 0x44A4, 0x5485, 63 | 0xA56A, 0xB54B, 0x8528, 0x9509, 0xE5EE, 0xF5CF, 0xC5AC, 0xD58D, 64 | 0x3653, 0x2672, 0x1611, 0x0630, 0x76D7, 0x66F6, 0x5695, 0x46B4, 65 | 0xB75B, 0xA77A, 0x9719, 0x8738, 0xF7DF, 0xE7FE, 0xD79D, 0xC7BC, 66 | 0x48C4, 0x58E5, 0x6886, 0x78A7, 0x0840, 0x1861, 0x2802, 0x3823, 67 | 0xC9CC, 0xD9ED, 0xE98E, 0xF9AF, 0x8948, 0x9969, 0xA90A, 0xB92B, 68 | 0x5AF5, 0x4AD4, 0x7AB7, 0x6A96, 0x1A71, 0x0A50, 0x3A33, 0x2A12, 69 | 0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E, 0x9B79, 0x8B58, 0xBB3B, 0xAB1A, 70 | 0x6CA6, 0x7C87, 0x4CE4, 0x5CC5, 0x2C22, 0x3C03, 0x0C60, 0x1C41, 71 | 0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD, 0xAD2A, 0xBD0B, 0x8D68, 0x9D49, 72 | 0x7E97, 0x6EB6, 0x5ED5, 0x4EF4, 0x3E13, 0x2E32, 0x1E51, 0x0E70, 73 | 0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC, 0xBF1B, 0xAF3A, 0x9F59, 0x8F78, 74 | 0x9188, 0x81A9, 0xB1CA, 0xA1EB, 0xD10C, 0xC12D, 0xF14E, 0xE16F, 75 | 0x1080, 0x00A1, 0x30C2, 0x20E3, 0x5004, 0x4025, 0x7046, 0x6067, 76 | 0x83B9, 0x9398, 0xA3FB, 0xB3DA, 0xC33D, 0xD31C, 0xE37F, 0xF35E, 77 | 0x02B1, 0x1290, 0x22F3, 0x32D2, 0x4235, 0x5214, 0x6277, 0x7256, 78 | 0xB5EA, 0xA5CB, 0x95A8, 0x8589, 0xF56E, 0xE54F, 0xD52C, 0xC50D, 79 | 0x34E2, 0x24C3, 0x14A0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, 80 | 0xA7DB, 0xB7FA, 0x8799, 0x97B8, 0xE75F, 0xF77E, 0xC71D, 0xD73C, 81 | 0x26D3, 0x36F2, 0x0691, 0x16B0, 0x6657, 0x7676, 0x4615, 0x5634, 82 | 0xD94C, 0xC96D, 0xF90E, 0xE92F, 0x99C8, 0x89E9, 0xB98A, 0xA9AB, 83 | 0x5844, 0x4865, 0x7806, 0x6827, 0x18C0, 0x08E1, 0x3882, 0x28A3, 84 | 0xCB7D, 0xDB5C, 0xEB3F, 0xFB1E, 0x8BF9, 0x9BD8, 0xABBB, 0xBB9A, 85 | 0x4A75, 0x5A54, 0x6A37, 0x7A16, 0x0AF1, 0x1AD0, 0x2AB3, 0x3A92, 86 | 0xFD2E, 0xED0F, 0xDD6C, 0xCD4D, 0xBDAA, 0xAD8B, 0x9DE8, 0x8DC9, 87 | 0x7C26, 0x6C07, 0x5C64, 0x4C45, 0x3CA2, 0x2C83, 0x1CE0, 0x0CC1, 88 | 0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8, 89 | 0x6E17, 0x7E36, 0x4E55, 0x5E74, 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0, 90 | ] 91 | # fmt: on 92 | -------------------------------------------------------------------------------- /pycyphal/transport/commons/crc/_crc32c.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import typing 6 | from ._base import CRCAlgorithm 7 | 8 | 9 | class CRC32C(CRCAlgorithm): 10 | """ 11 | `32-Bit Cyclic Redundancy Codes for Internet Applications (Philip Koopman) 12 | `_. 13 | 14 | `CRC-32C (Castagnoli) for C++ and .NET `_. 15 | 16 | - Name: CRC-32/ISCSI, CRC-32C, CRC-32/CASTAGNOLI 17 | - Initial value: 0xFFFFFFFF 18 | - Polynomial: 0x1EDC6F41 19 | - Output XOR: 0xFFFFFFFF 20 | - Residue: 0xB798B438 21 | - Check: 0xE3069283 22 | 23 | >>> assert CRC32C().value == 0 24 | >>> c = CRC32C() 25 | >>> c.add(b'123456') 26 | >>> c.add(b'789') 27 | >>> c.value # 0xE3069283 28 | 3808858755 29 | >>> c.add(b'') 30 | >>> c.value 31 | 3808858755 32 | >>> c.add(c.value_as_bytes) 33 | >>> c.value # Inverted residue 34 | 1214729159 35 | >>> c.check_residue() 36 | True 37 | >>> CRC32C.new(b'123', b'', b'456789').value 38 | 3808858755 39 | """ 40 | 41 | def __init__(self) -> None: 42 | assert len(self._TABLE) == 256 43 | self._value = 0xFFFFFFFF 44 | 45 | def add(self, data: typing.Union[bytes, bytearray, memoryview]) -> None: 46 | val = self._value 47 | for x in data: 48 | val = (val >> 8) ^ self._TABLE[x ^ (val & 0xFF)] 49 | self._value = val 50 | 51 | def check_residue(self) -> bool: 52 | return self._value == 0xB798B438 # Checked before the output XOR is applied. 53 | 54 | @property 55 | def value(self) -> int: 56 | assert 0 <= self._value <= 0xFFFFFFFF 57 | return self._value ^ 0xFFFFFFFF 58 | 59 | @property 60 | def value_as_bytes(self) -> bytes: 61 | return self.value.to_bytes(4, "little") 62 | 63 | # fmt: off 64 | _TABLE = [ 65 | 0x00000000, 0xF26B8303, 0xE13B70F7, 0x1350F3F4, 0xC79A971F, 0x35F1141C, 0x26A1E7E8, 0xD4CA64EB, 66 | 0x8AD958CF, 0x78B2DBCC, 0x6BE22838, 0x9989AB3B, 0x4D43CFD0, 0xBF284CD3, 0xAC78BF27, 0x5E133C24, 67 | 0x105EC76F, 0xE235446C, 0xF165B798, 0x030E349B, 0xD7C45070, 0x25AFD373, 0x36FF2087, 0xC494A384, 68 | 0x9A879FA0, 0x68EC1CA3, 0x7BBCEF57, 0x89D76C54, 0x5D1D08BF, 0xAF768BBC, 0xBC267848, 0x4E4DFB4B, 69 | 0x20BD8EDE, 0xD2D60DDD, 0xC186FE29, 0x33ED7D2A, 0xE72719C1, 0x154C9AC2, 0x061C6936, 0xF477EA35, 70 | 0xAA64D611, 0x580F5512, 0x4B5FA6E6, 0xB93425E5, 0x6DFE410E, 0x9F95C20D, 0x8CC531F9, 0x7EAEB2FA, 71 | 0x30E349B1, 0xC288CAB2, 0xD1D83946, 0x23B3BA45, 0xF779DEAE, 0x05125DAD, 0x1642AE59, 0xE4292D5A, 72 | 0xBA3A117E, 0x4851927D, 0x5B016189, 0xA96AE28A, 0x7DA08661, 0x8FCB0562, 0x9C9BF696, 0x6EF07595, 73 | 0x417B1DBC, 0xB3109EBF, 0xA0406D4B, 0x522BEE48, 0x86E18AA3, 0x748A09A0, 0x67DAFA54, 0x95B17957, 74 | 0xCBA24573, 0x39C9C670, 0x2A993584, 0xD8F2B687, 0x0C38D26C, 0xFE53516F, 0xED03A29B, 0x1F682198, 75 | 0x5125DAD3, 0xA34E59D0, 0xB01EAA24, 0x42752927, 0x96BF4DCC, 0x64D4CECF, 0x77843D3B, 0x85EFBE38, 76 | 0xDBFC821C, 0x2997011F, 0x3AC7F2EB, 0xC8AC71E8, 0x1C661503, 0xEE0D9600, 0xFD5D65F4, 0x0F36E6F7, 77 | 0x61C69362, 0x93AD1061, 0x80FDE395, 0x72966096, 0xA65C047D, 0x5437877E, 0x4767748A, 0xB50CF789, 78 | 0xEB1FCBAD, 0x197448AE, 0x0A24BB5A, 0xF84F3859, 0x2C855CB2, 0xDEEEDFB1, 0xCDBE2C45, 0x3FD5AF46, 79 | 0x7198540D, 0x83F3D70E, 0x90A324FA, 0x62C8A7F9, 0xB602C312, 0x44694011, 0x5739B3E5, 0xA55230E6, 80 | 0xFB410CC2, 0x092A8FC1, 0x1A7A7C35, 0xE811FF36, 0x3CDB9BDD, 0xCEB018DE, 0xDDE0EB2A, 0x2F8B6829, 81 | 0x82F63B78, 0x709DB87B, 0x63CD4B8F, 0x91A6C88C, 0x456CAC67, 0xB7072F64, 0xA457DC90, 0x563C5F93, 82 | 0x082F63B7, 0xFA44E0B4, 0xE9141340, 0x1B7F9043, 0xCFB5F4A8, 0x3DDE77AB, 0x2E8E845F, 0xDCE5075C, 83 | 0x92A8FC17, 0x60C37F14, 0x73938CE0, 0x81F80FE3, 0x55326B08, 0xA759E80B, 0xB4091BFF, 0x466298FC, 84 | 0x1871A4D8, 0xEA1A27DB, 0xF94AD42F, 0x0B21572C, 0xDFEB33C7, 0x2D80B0C4, 0x3ED04330, 0xCCBBC033, 85 | 0xA24BB5A6, 0x502036A5, 0x4370C551, 0xB11B4652, 0x65D122B9, 0x97BAA1BA, 0x84EA524E, 0x7681D14D, 86 | 0x2892ED69, 0xDAF96E6A, 0xC9A99D9E, 0x3BC21E9D, 0xEF087A76, 0x1D63F975, 0x0E330A81, 0xFC588982, 87 | 0xB21572C9, 0x407EF1CA, 0x532E023E, 0xA145813D, 0x758FE5D6, 0x87E466D5, 0x94B49521, 0x66DF1622, 88 | 0x38CC2A06, 0xCAA7A905, 0xD9F75AF1, 0x2B9CD9F2, 0xFF56BD19, 0x0D3D3E1A, 0x1E6DCDEE, 0xEC064EED, 89 | 0xC38D26C4, 0x31E6A5C7, 0x22B65633, 0xD0DDD530, 0x0417B1DB, 0xF67C32D8, 0xE52CC12C, 0x1747422F, 90 | 0x49547E0B, 0xBB3FFD08, 0xA86F0EFC, 0x5A048DFF, 0x8ECEE914, 0x7CA56A17, 0x6FF599E3, 0x9D9E1AE0, 91 | 0xD3D3E1AB, 0x21B862A8, 0x32E8915C, 0xC083125F, 0x144976B4, 0xE622F5B7, 0xF5720643, 0x07198540, 92 | 0x590AB964, 0xAB613A67, 0xB831C993, 0x4A5A4A90, 0x9E902E7B, 0x6CFBAD78, 0x7FAB5E8C, 0x8DC0DD8F, 93 | 0xE330A81A, 0x115B2B19, 0x020BD8ED, 0xF0605BEE, 0x24AA3F05, 0xD6C1BC06, 0xC5914FF2, 0x37FACCF1, 94 | 0x69E9F0D5, 0x9B8273D6, 0x88D28022, 0x7AB90321, 0xAE7367CA, 0x5C18E4C9, 0x4F48173D, 0xBD23943E, 95 | 0xF36E6F75, 0x0105EC76, 0x12551F82, 0xE03E9C81, 0x34F4F86A, 0xC69F7B69, 0xD5CF889D, 0x27A40B9E, 96 | 0x79B737BA, 0x8BDCB4B9, 0x988C474D, 0x6AE7C44E, 0xBE2DA0A5, 0x4C4623A6, 0x5F16D052, 0xAD7D5351, 97 | ] 98 | # fmt: on 99 | -------------------------------------------------------------------------------- /pycyphal/transport/commons/high_overhead_transport/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | """ 6 | This module contains common classes and algorithms used in a certain category of transports 7 | which we call **High Overhead Transports**. 8 | They are designed for highly capable mediums where packets are large and data transfer speeds are high. 9 | 10 | For example, UDP, Serial, and IEEE 802.15.4 are high-overhead transports. 11 | CAN, on the other hand, is not a high-overhead transport; 12 | none of the entities defined in this module can be used with CAN. 13 | """ 14 | 15 | from ._frame import Frame as Frame 16 | 17 | from ._transfer_serializer import serialize_transfer as serialize_transfer 18 | 19 | from ._transfer_reassembler import TransferReassembler as TransferReassembler 20 | 21 | from ._common import TransferCRC as TransferCRC 22 | 23 | from ._alien_transfer_reassembler import AlienTransferReassembler as AlienTransferReassembler 24 | -------------------------------------------------------------------------------- /pycyphal/transport/commons/high_overhead_transport/_alien_transfer_reassembler.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import typing 6 | from pycyphal.transport import TransferFrom, Timestamp 7 | from . import TransferReassembler, Frame 8 | 9 | 10 | class AlienTransferReassembler: 11 | """ 12 | This is a wrapper over :class:`TransferReassembler` optimized for tracing rather than real-time communication. 13 | It implements heuristics optimized for diagnostics and inspection rather than real-time operation. 14 | 15 | The caller is expected to keep a registry (dict) of session tracers indexed by their session specifiers, 16 | which are extracted from captured transport frames. 17 | """ 18 | 19 | _MAX_INTERVAL = 1.0 20 | _TID_TIMEOUT_MULTIPLIER = 2.0 # TID = 2*interval as suggested in the Specification. 21 | 22 | _EXTENT_BYTES = 2**32 23 | """ 24 | The extent is effectively unlimited -- we want to be able to process all transfers. 25 | """ 26 | 27 | def __init__(self, source_node_id: int) -> None: 28 | self._last_error: typing.Optional[TransferReassembler.Error] = None 29 | self._reassembler = TransferReassembler( 30 | source_node_id=source_node_id, 31 | extent_bytes=AlienTransferReassembler._EXTENT_BYTES, 32 | on_error_callback=self._register_reassembly_error, 33 | ) 34 | self._last_transfer_monotonic: float = 0.0 35 | self._interval = float(AlienTransferReassembler._MAX_INTERVAL) 36 | 37 | def process_frame( 38 | self, timestamp: Timestamp, frame: Frame 39 | ) -> typing.Union[TransferFrom, TransferReassembler.Error, None]: 40 | trf = self._reassembler.process_frame( 41 | timestamp=timestamp, frame=frame, transfer_id_timeout=self.transfer_id_timeout 42 | ) 43 | if trf is None: 44 | out, self._last_error = self._last_error, None 45 | return out 46 | 47 | # Update the transfer-ID timeout. 48 | delta = float(trf.timestamp.monotonic) - self._last_transfer_monotonic 49 | delta = min(AlienTransferReassembler._MAX_INTERVAL, max(0.0, delta)) 50 | self._interval = (self._interval + delta) * 0.5 51 | self._last_transfer_monotonic = float(trf.timestamp.monotonic) 52 | 53 | return trf 54 | 55 | @property 56 | def transfer_id_timeout(self) -> float: 57 | """ 58 | The current value of the auto-deduced transfer-ID timeout. 59 | It is automatically adjusted whenever a new transfer is received. 60 | """ 61 | return self._interval * AlienTransferReassembler._TID_TIMEOUT_MULTIPLIER 62 | 63 | def _register_reassembly_error(self, error: TransferReassembler.Error) -> None: 64 | self._last_error = error 65 | -------------------------------------------------------------------------------- /pycyphal/transport/commons/high_overhead_transport/_common.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | from ..crc import CRC32C 7 | 8 | TransferCRC = CRC32C 9 | -------------------------------------------------------------------------------- /pycyphal/transport/commons/high_overhead_transport/_frame.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import dataclasses 7 | import pycyphal 8 | 9 | 10 | @dataclasses.dataclass(frozen=True) 11 | class Frame: 12 | """ 13 | The base class of a high-overhead-transport frame. 14 | It is used with the common transport algorithms defined in this module. 15 | Concrete transport implementations should make their transport-specific frame dataclasses inherit from this class. 16 | Derived types are recommended to not override ``__repr__()``. 17 | """ 18 | 19 | priority: pycyphal.transport.Priority 20 | """ 21 | Transfer priority should be the same for all frames within the transfer. 22 | """ 23 | 24 | transfer_id: int 25 | """ 26 | Transfer-ID is incremented whenever a transfer under a specific session-specifier is emitted. 27 | Always non-negative. 28 | """ 29 | 30 | index: int 31 | """ 32 | Index of the frame within its transfer, starting from zero. Always non-negative. 33 | """ 34 | 35 | end_of_transfer: bool 36 | """ 37 | True for the last frame within the transfer. 38 | """ 39 | 40 | payload: memoryview 41 | """ 42 | The data carried by the frame. Multi-frame transfer payload is suffixed with its CRC32C. May be empty. 43 | """ 44 | 45 | def __post_init__(self) -> None: 46 | if not isinstance(self.priority, pycyphal.transport.Priority): 47 | raise TypeError(f"Invalid priority: {self.priority}") 48 | 49 | if self.transfer_id < 0: 50 | raise ValueError(f"Invalid transfer-ID: {self.transfer_id}") 51 | 52 | if self.index < 0: 53 | raise ValueError(f"Invalid frame index: {self.index}") 54 | 55 | if not isinstance(self.end_of_transfer, bool): 56 | raise TypeError(f"Bad end of transfer flag: {type(self.end_of_transfer).__name__}") 57 | 58 | if not isinstance(self.payload, memoryview): 59 | raise TypeError(f"Bad payload type: {type(self.payload).__name__}") 60 | 61 | @property 62 | def single_frame_transfer(self) -> bool: 63 | return self.index == 0 and self.end_of_transfer 64 | 65 | def __repr__(self) -> str: 66 | """ 67 | If the payload is unreasonably long for a sensible string representation, 68 | it is truncated and suffixed with an ellipsis. 69 | """ 70 | payload_length_limit = 100 71 | if len(self.payload) > payload_length_limit: 72 | payload = bytes(self.payload[:payload_length_limit]).hex() + "..." 73 | else: 74 | payload = bytes(self.payload).hex() 75 | kwargs = {f.name: getattr(self, f.name) for f in dataclasses.fields(self)} 76 | kwargs["priority"] = self.priority.name 77 | kwargs["payload"] = payload 78 | return pycyphal.util.repr_attributes(self, **kwargs) 79 | 80 | 81 | # noinspection PyTypeChecker 82 | def _unittest_frame_base_ctor() -> None: 83 | from pytest import raises 84 | from pycyphal.transport import Priority 85 | 86 | Frame(priority=Priority.LOW, transfer_id=1234, index=321, end_of_transfer=True, payload=memoryview(b"")) 87 | 88 | with raises(TypeError): 89 | Frame(priority=2, transfer_id=1234, index=321, end_of_transfer=True, payload=memoryview(b"")) # type: ignore 90 | 91 | with raises(TypeError): 92 | Frame( 93 | priority=Priority.LOW, 94 | transfer_id=1234, 95 | index=321, 96 | end_of_transfer=1, # type: ignore 97 | payload=memoryview(b""), 98 | ) 99 | 100 | with raises(TypeError): 101 | Frame(priority=Priority.LOW, transfer_id=1234, index=321, end_of_transfer=False, payload=b"") # type: ignore 102 | 103 | with raises(ValueError): 104 | Frame(priority=Priority.LOW, transfer_id=-1, index=321, end_of_transfer=True, payload=memoryview(b"")) 105 | 106 | with raises(ValueError): 107 | Frame(priority=Priority.LOW, transfer_id=0, index=-1, end_of_transfer=True, payload=memoryview(b"")) 108 | -------------------------------------------------------------------------------- /pycyphal/transport/commons/high_overhead_transport/_transfer_serializer.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import typing 6 | import itertools 7 | import pycyphal 8 | from ._frame import Frame 9 | from ._common import TransferCRC 10 | 11 | 12 | FrameType = typing.TypeVar("FrameType", bound=Frame) 13 | 14 | 15 | def serialize_transfer( 16 | fragmented_payload: typing.Sequence[memoryview], 17 | max_frame_payload_bytes: int, 18 | frame_factory: typing.Callable[[int, bool, memoryview], FrameType], 19 | ) -> typing.Iterable[FrameType]: 20 | r""" 21 | Constructs an ordered sequence of frames ready for transmission from the provided data fragments. 22 | Compatible with any high-overhead transport. 23 | 24 | :param fragmented_payload: The transfer payload we're going to be sending. 25 | 26 | :param max_frame_payload_bytes: Max payload per transport-layer frame. 27 | 28 | :param frame_factory: A callable that accepts (frame index, end of transfer, payload) and returns a frame. 29 | Normally this would be a closure. 30 | 31 | :return: An iterable that yields frames. 32 | 33 | >>> import dataclasses 34 | >>> from pycyphal.transport.commons.high_overhead_transport import Frame 35 | >>> @dataclasses.dataclass(frozen=True) 36 | ... class MyFrameType(Frame): 37 | ... pass # Transport-specific definition goes here. 38 | >>> priority = pycyphal.transport.Priority.NOMINAL 39 | >>> transfer_id = 12345 40 | >>> def construct_frame(index: int, end_of_transfer: bool, payload: memoryview) -> MyFrameType: 41 | ... return MyFrameType(priority=priority, 42 | ... transfer_id=transfer_id, 43 | ... index=index, 44 | ... end_of_transfer=end_of_transfer, 45 | ... payload=payload) 46 | >>> frames = list(serialize_transfer( 47 | ... fragmented_payload=[ 48 | ... memoryview(b'He thought about the Horse: '), # The CRC of this quote is 0xDDD1FF3A 49 | ... memoryview(b'how was she doing there, in the fog?'), 50 | ... ], 51 | ... max_frame_payload_bytes=53, 52 | ... frame_factory=construct_frame, 53 | ... )) 54 | >>> frames 55 | [MyFrameType(..., index=0, end_of_transfer=False, ...), MyFrameType(..., index=1, end_of_transfer=True, ...)] 56 | >>> bytes(frames[0].payload) # 53 bytes long, as configured. 57 | b'He thought about the Horse: how was she doing there, ' 58 | >>> bytes(frames[1].payload) # The stuff at the end is the four bytes of multi-frame transfer CRC. 59 | b'in the fog?:\xff\xd1\xdd' 60 | 61 | >>> single_frame = list(serialize_transfer( 62 | ... fragmented_payload=[ 63 | ... memoryview(b'FOUR'), 64 | ... ], 65 | ... max_frame_payload_bytes=8, 66 | ... frame_factory=construct_frame, 67 | ... )) 68 | >>> single_frame 69 | [MyFrameType(..., index=0, end_of_transfer=True, ...)] 70 | >>> bytes(single_frame[0].payload) # 8 bytes long, as configured. 71 | b'FOUR-\xb8\xa4\x81' 72 | """ 73 | assert max_frame_payload_bytes > 0 74 | payload_length = sum(map(len, fragmented_payload)) 75 | # SINGLE-FRAME TRANSFER 76 | if payload_length <= max_frame_payload_bytes - 4: # 4 bytes for crc! 77 | crc_bytes = TransferCRC.new(*fragmented_payload).value_as_bytes 78 | payload_with_crc = memoryview(b"".join(list(fragmented_payload) + [memoryview(crc_bytes)])) 79 | assert len(payload_with_crc) == payload_length + 4 80 | assert max_frame_payload_bytes >= len(payload_with_crc) 81 | yield frame_factory(0, True, payload_with_crc) 82 | # MULTI-FRAME TRANSFER 83 | else: 84 | crc_bytes = TransferCRC.new(*fragmented_payload).value_as_bytes 85 | refragmented = pycyphal.transport.commons.refragment( 86 | itertools.chain(fragmented_payload, (memoryview(crc_bytes),)), max_frame_payload_bytes 87 | ) 88 | for frame_index, (end_of_transfer, frag) in enumerate(pycyphal.util.mark_last(refragmented)): 89 | yield frame_factory(frame_index, end_of_transfer, frag) 90 | 91 | 92 | def _unittest_serialize_transfer() -> None: 93 | from pycyphal.transport import Priority 94 | 95 | priority = Priority.NOMINAL 96 | transfer_id = 12345678901234567890 97 | 98 | def construct_frame(index: int, end_of_transfer: bool, payload: memoryview) -> Frame: 99 | return Frame( 100 | priority=priority, transfer_id=transfer_id, index=index, end_of_transfer=end_of_transfer, payload=payload 101 | ) 102 | 103 | hello_world_crc = pycyphal.transport.commons.crc.CRC32C() 104 | hello_world_crc.add(b"hello world") 105 | 106 | empty_crc = pycyphal.transport.commons.crc.CRC32C() 107 | empty_crc.add(b"") 108 | 109 | assert [ 110 | construct_frame(0, True, memoryview(b"hello world" + hello_world_crc.value_as_bytes)), 111 | ] == list(serialize_transfer([memoryview(b"hello"), memoryview(b" "), memoryview(b"world")], 100, construct_frame)) 112 | 113 | assert [ 114 | construct_frame(0, True, memoryview(b"" + empty_crc.value_as_bytes)), 115 | ] == list(serialize_transfer([], 100, construct_frame)) 116 | 117 | assert [ 118 | construct_frame(0, False, memoryview(b"hello")), 119 | construct_frame(1, False, memoryview(b" worl")), 120 | construct_frame(2, True, memoryview(b"d" + hello_world_crc.value_as_bytes)), 121 | ] == list(serialize_transfer([memoryview(b"hello"), memoryview(b" "), memoryview(b"world")], 5, construct_frame)) 122 | -------------------------------------------------------------------------------- /pycyphal/transport/loopback/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from ._loopback import LoopbackTransport as LoopbackTransport 6 | 7 | from ._input_session import LoopbackInputSession as LoopbackInputSession 8 | 9 | from ._output_session import LoopbackOutputSession as LoopbackOutputSession 10 | from ._output_session import LoopbackFeedback as LoopbackFeedback 11 | 12 | from ._tracer import LoopbackCapture as LoopbackCapture 13 | from ._tracer import LoopbackTracer as LoopbackTracer 14 | -------------------------------------------------------------------------------- /pycyphal/transport/loopback/_input_session.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import typing 6 | import asyncio 7 | 8 | import pycyphal.transport 9 | 10 | 11 | class LoopbackInputSession(pycyphal.transport.InputSession): 12 | DEFAULT_TRANSFER_ID_TIMEOUT = 2 13 | 14 | def __init__( 15 | self, 16 | specifier: pycyphal.transport.InputSessionSpecifier, 17 | payload_metadata: pycyphal.transport.PayloadMetadata, 18 | closer: typing.Callable[[], None], 19 | ): 20 | self._specifier = specifier 21 | self._payload_metadata = payload_metadata 22 | self._closer = closer 23 | self._transfer_id_timeout = float(self.DEFAULT_TRANSFER_ID_TIMEOUT) 24 | self._stats = pycyphal.transport.SessionStatistics() 25 | self._queue: asyncio.Queue[pycyphal.transport.TransferFrom] = asyncio.Queue() 26 | super().__init__() 27 | 28 | async def receive(self, monotonic_deadline: float) -> typing.Optional[pycyphal.transport.TransferFrom]: 29 | timeout = monotonic_deadline - asyncio.get_running_loop().time() 30 | try: 31 | if timeout > 0: 32 | out = await asyncio.wait_for(self._queue.get(), timeout) 33 | else: 34 | out = self._queue.get_nowait() 35 | except asyncio.TimeoutError: 36 | return None 37 | except asyncio.QueueEmpty: 38 | return None 39 | else: 40 | self._stats.transfers += 1 41 | self._stats.frames += 1 42 | self._stats.payload_bytes += sum(map(len, out.fragmented_payload)) 43 | return out 44 | 45 | async def push(self, transfer: pycyphal.transport.TransferFrom) -> None: 46 | """ 47 | Inserts a transfer into the receive queue of this loopback session. 48 | """ 49 | # TODO: handle Transfer ID like a real transport would: drop duplicates, handle transfer-ID timeout. 50 | # This is not very important for this demo transport but users may expect a more accurate modeling. 51 | await self._queue.put(transfer) 52 | 53 | @property 54 | def transfer_id_timeout(self) -> float: 55 | return self._transfer_id_timeout 56 | 57 | @transfer_id_timeout.setter 58 | def transfer_id_timeout(self, value: float) -> None: 59 | value = float(value) 60 | if value > 0: 61 | self._transfer_id_timeout = float(value) 62 | else: 63 | raise ValueError(f"Invalid TID timeout: {value!r}") 64 | 65 | @property 66 | def specifier(self) -> pycyphal.transport.InputSessionSpecifier: 67 | return self._specifier 68 | 69 | @property 70 | def payload_metadata(self) -> pycyphal.transport.PayloadMetadata: 71 | return self._payload_metadata 72 | 73 | def sample_statistics(self) -> pycyphal.transport.SessionStatistics: 74 | return self._stats 75 | 76 | def close(self) -> None: 77 | self._closer() 78 | 79 | 80 | def _unittest_session() -> None: 81 | import pytest 82 | 83 | closed = False 84 | 85 | specifier = pycyphal.transport.InputSessionSpecifier(pycyphal.transport.MessageDataSpecifier(123), 123) 86 | payload_metadata = pycyphal.transport.PayloadMetadata(1234) 87 | 88 | def do_close() -> None: 89 | nonlocal closed 90 | closed = True 91 | 92 | ses = LoopbackInputSession(specifier=specifier, payload_metadata=payload_metadata, closer=do_close) 93 | 94 | ses.transfer_id_timeout = 123.456 95 | with pytest.raises(ValueError): 96 | ses.transfer_id_timeout = -0.1 97 | assert ses.transfer_id_timeout == pytest.approx(123.456) 98 | 99 | assert specifier == ses.specifier 100 | assert payload_metadata == ses.payload_metadata 101 | 102 | assert not closed 103 | ses.close() 104 | assert closed 105 | -------------------------------------------------------------------------------- /pycyphal/transport/loopback/_output_session.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import typing 6 | import asyncio 7 | import pycyphal.transport 8 | 9 | 10 | TransferRouter = typing.Callable[[pycyphal.transport.Transfer, float], typing.Awaitable[bool]] 11 | 12 | 13 | class LoopbackFeedback(pycyphal.transport.Feedback): 14 | def __init__(self, transfer_timestamp: pycyphal.transport.Timestamp): 15 | self._transfer_timestamp = transfer_timestamp 16 | 17 | @property 18 | def original_transfer_timestamp(self) -> pycyphal.transport.Timestamp: 19 | return self._transfer_timestamp 20 | 21 | @property 22 | def first_frame_transmission_timestamp(self) -> pycyphal.transport.Timestamp: 23 | return self._transfer_timestamp 24 | 25 | 26 | class LoopbackOutputSession(pycyphal.transport.OutputSession): 27 | def __init__( 28 | self, 29 | specifier: pycyphal.transport.OutputSessionSpecifier, 30 | payload_metadata: pycyphal.transport.PayloadMetadata, 31 | closer: typing.Callable[[], None], 32 | router: TransferRouter, 33 | ): 34 | self._specifier = specifier 35 | self._payload_metadata = payload_metadata 36 | self._closer = closer 37 | self._router = router 38 | self._stats = pycyphal.transport.SessionStatistics() 39 | self._feedback_handler: typing.Optional[typing.Callable[[pycyphal.transport.Feedback], None]] = None 40 | self._injected_exception: typing.Optional[Exception] = None 41 | self._should_timeout = False 42 | self._delay = 0.0 43 | 44 | def enable_feedback(self, handler: typing.Callable[[pycyphal.transport.Feedback], None]) -> None: 45 | self._feedback_handler = handler 46 | 47 | def disable_feedback(self) -> None: 48 | self._feedback_handler = None 49 | 50 | async def send(self, transfer: pycyphal.transport.Transfer, monotonic_deadline: float) -> bool: 51 | if self._injected_exception is not None: 52 | raise self._injected_exception 53 | if self._delay > 0: 54 | await asyncio.sleep(self._delay) 55 | out = False if self._should_timeout else await self._router(transfer, monotonic_deadline) 56 | if out: 57 | self._stats.transfers += 1 58 | self._stats.frames += 1 59 | self._stats.payload_bytes += sum(map(len, transfer.fragmented_payload)) 60 | if self._feedback_handler is not None: 61 | self._feedback_handler(LoopbackFeedback(transfer.timestamp)) 62 | else: 63 | self._stats.drops += 1 64 | 65 | return out 66 | 67 | @property 68 | def specifier(self) -> pycyphal.transport.OutputSessionSpecifier: 69 | return self._specifier 70 | 71 | @property 72 | def payload_metadata(self) -> pycyphal.transport.PayloadMetadata: 73 | return self._payload_metadata 74 | 75 | def sample_statistics(self) -> pycyphal.transport.SessionStatistics: 76 | return self._stats 77 | 78 | def close(self) -> None: 79 | self._injected_exception = pycyphal.transport.ResourceClosedError(f"{self} is closed") 80 | self._closer() 81 | 82 | @property 83 | def exception(self) -> typing.Optional[Exception]: 84 | """ 85 | This is a test rigging. 86 | Use this property to configure an exception object that will be raised when :func:`send` is invoked. 87 | Set None to remove the injected exception (None is the default value). 88 | Useful for testing error handling logic. 89 | """ 90 | return self._injected_exception 91 | 92 | @exception.setter 93 | def exception(self, value: typing.Optional[Exception]) -> None: 94 | if isinstance(value, Exception) or value is None: 95 | self._injected_exception = value 96 | else: 97 | raise ValueError(f"Bad exception: {value}") 98 | 99 | @property 100 | def delay(self) -> float: 101 | return self._delay 102 | 103 | @delay.setter 104 | def delay(self, value: float) -> None: 105 | self._delay = float(value) 106 | 107 | @property 108 | def should_timeout(self) -> bool: 109 | return self._should_timeout 110 | 111 | @should_timeout.setter 112 | def should_timeout(self, value: bool) -> None: 113 | self._should_timeout = bool(value) 114 | 115 | 116 | def _unittest_session() -> None: 117 | closed = False 118 | 119 | specifier = pycyphal.transport.OutputSessionSpecifier(pycyphal.transport.MessageDataSpecifier(123), 123) 120 | payload_metadata = pycyphal.transport.PayloadMetadata(1234) 121 | 122 | def do_close() -> None: 123 | nonlocal closed 124 | closed = True 125 | 126 | async def do_route(_a: pycyphal.transport.Transfer, _b: float) -> bool: 127 | raise NotImplementedError 128 | 129 | ses = LoopbackOutputSession( 130 | specifier=specifier, payload_metadata=payload_metadata, closer=do_close, router=do_route 131 | ) 132 | 133 | assert specifier == ses.specifier 134 | assert payload_metadata == ses.payload_metadata 135 | 136 | assert not closed 137 | ses.close() 138 | assert closed 139 | 140 | ts = pycyphal.transport.Timestamp.now() 141 | fb = LoopbackFeedback(ts) 142 | assert fb.first_frame_transmission_timestamp == ts 143 | assert fb.original_transfer_timestamp == ts 144 | -------------------------------------------------------------------------------- /pycyphal/transport/loopback/_tracer.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import typing 7 | import dataclasses 8 | import pycyphal.transport.loopback 9 | from pycyphal.transport import Trace, TransferTrace, Capture 10 | 11 | 12 | @dataclasses.dataclass(frozen=True) 13 | class LoopbackCapture(pycyphal.transport.Capture): 14 | """ 15 | Since the loopback transport is not really a transport, its capture events contain entire transfers. 16 | """ 17 | 18 | transfer: pycyphal.transport.AlienTransfer 19 | 20 | @staticmethod 21 | def get_transport_type() -> typing.Type[pycyphal.transport.loopback.LoopbackTransport]: 22 | return pycyphal.transport.loopback.LoopbackTransport 23 | 24 | 25 | class LoopbackTracer(pycyphal.transport.Tracer): 26 | """ 27 | Since loopback transport does not have frames to trace, this tracer simply returns the transfer 28 | from the capture object. 29 | """ 30 | 31 | def update(self, cap: Capture) -> typing.Optional[Trace]: 32 | if isinstance(cap, LoopbackCapture): 33 | return TransferTrace(cap.timestamp, cap.transfer, transfer_id_timeout=0) 34 | return None 35 | -------------------------------------------------------------------------------- /pycyphal/transport/redundant/_deduplicator/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from ._base import Deduplicator as Deduplicator 6 | 7 | from ._monotonic import MonotonicDeduplicator as MonotonicDeduplicator 8 | 9 | from ._cyclic import CyclicDeduplicator as CyclicDeduplicator 10 | -------------------------------------------------------------------------------- /pycyphal/transport/redundant/_deduplicator/_base.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import abc 7 | import typing 8 | import pycyphal.transport 9 | 10 | 11 | class Deduplicator(abc.ABC): 12 | """ 13 | The abstract class implementing the transfer-wise deduplication strategy. 14 | **Users of redundant transports do not need to deduplicate their transfers manually 15 | as it will be done automatically.** 16 | Please read the module documentation for further details. 17 | """ 18 | 19 | MONOTONIC_TRANSFER_ID_MODULO_THRESHOLD = int(2**48) 20 | """ 21 | An inferior transport whose transfer-ID modulo is less than this value is expected to experience 22 | transfer-ID overflows routinely during its operation. Otherwise, the transfer-ID is not expected to 23 | overflow for centuries. 24 | 25 | A transfer-ID counter that is expected to overflow is called "cyclic", otherwise it's "monotonic". 26 | Read https://forum.opencyphal.org/t/alternative-transport-protocols/324. 27 | See :meth:`new`. 28 | """ 29 | 30 | @staticmethod 31 | def new(transfer_id_modulo: int) -> Deduplicator: 32 | """ 33 | A helper factory that constructs a :class:`MonotonicDeduplicator` if the argument is not less than 34 | :attr:`MONOTONIC_TRANSFER_ID_MODULO_THRESHOLD`, otherwise constructs a :class:`CyclicDeduplicator`. 35 | """ 36 | from . import CyclicDeduplicator, MonotonicDeduplicator 37 | 38 | if transfer_id_modulo >= Deduplicator.MONOTONIC_TRANSFER_ID_MODULO_THRESHOLD: 39 | return MonotonicDeduplicator() 40 | return CyclicDeduplicator(transfer_id_modulo) 41 | 42 | @abc.abstractmethod 43 | def should_accept_transfer( 44 | self, 45 | *, 46 | iface_id: int, 47 | transfer_id_timeout: float, 48 | timestamp: pycyphal.transport.Timestamp, 49 | source_node_id: typing.Optional[int], 50 | transfer_id: int, 51 | ) -> bool: 52 | """ 53 | The iface-ID is an arbitrary integer that is unique within the redundant group identifying the transport 54 | instance the transfer was received from. 55 | It could be the index of the redundant interface (e.g., 0, 1, 2 for a triply-redundant transport), 56 | or it could be something else like a memory address of a related object. 57 | Embedded applications usually use indexes, whereas in PyCyphal it may be more convenient to use :func:`id`. 58 | 59 | The transfer-ID timeout is specified in seconds. It is used to handle the case of a node restart. 60 | """ 61 | raise NotImplementedError 62 | -------------------------------------------------------------------------------- /pycyphal/transport/redundant/_deduplicator/_cyclic.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import typing 6 | import dataclasses 7 | import pycyphal.transport 8 | from ._base import Deduplicator 9 | 10 | 11 | class CyclicDeduplicator(Deduplicator): 12 | def __init__(self, transfer_id_modulo: int) -> None: 13 | self._tid_modulo = int(transfer_id_modulo) 14 | assert self._tid_modulo > 0 15 | self._remote_states: typing.List[typing.Optional[_RemoteState]] = [] 16 | 17 | def should_accept_transfer( 18 | self, 19 | *, 20 | iface_id: int, 21 | transfer_id_timeout: float, 22 | timestamp: pycyphal.transport.Timestamp, 23 | source_node_id: typing.Optional[int], 24 | transfer_id: int, 25 | ) -> bool: 26 | if source_node_id is None: 27 | # Anonymous transfers are fully stateless, so always accepted. 28 | # This may lead to duplications and reordering but this is a design limitation. 29 | return True 30 | 31 | # If a similar architecture is used on an embedded system, this normally would be a static array. 32 | if len(self._remote_states) <= source_node_id: 33 | self._remote_states += [None] * (source_node_id - len(self._remote_states) + 1) 34 | assert len(self._remote_states) == source_node_id + 1 35 | 36 | if self._remote_states[source_node_id] is None: 37 | # First transfer from this node, create new state and accept unconditionally. 38 | self._remote_states[source_node_id] = _RemoteState(iface_id=iface_id, last_timestamp=timestamp) 39 | return True 40 | 41 | # We have seen transfers from this node before, so we need to perform actual deduplication. 42 | state = self._remote_states[source_node_id] 43 | assert state is not None 44 | 45 | # If the current interface was seen working recently, reject traffic from other interfaces. 46 | # Note that the time delta may be negative due to timestamping variations and inner latency variations. 47 | time_delta = timestamp.monotonic - state.last_timestamp.monotonic 48 | iface_switch_allowed = time_delta > transfer_id_timeout 49 | if not iface_switch_allowed and state.iface_id != iface_id: 50 | return False 51 | 52 | # TODO: The TID modulo setting is not currently used yet. 53 | # TODO: It may be utilized later to implement faster iface fallback. 54 | 55 | # Either we're on the same interface or (the interface is new and the current one seems to be down). 56 | state.iface_id = iface_id 57 | state.last_timestamp = timestamp 58 | return True 59 | 60 | 61 | @dataclasses.dataclass 62 | class _RemoteState: 63 | iface_id: int 64 | last_timestamp: pycyphal.transport.Timestamp 65 | -------------------------------------------------------------------------------- /pycyphal/transport/redundant/_deduplicator/_monotonic.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import typing 6 | import dataclasses 7 | import pycyphal.transport 8 | from ._base import Deduplicator 9 | 10 | 11 | class MonotonicDeduplicator(Deduplicator): 12 | def __init__(self) -> None: 13 | self._remote_states: typing.List[typing.Optional[_RemoteState]] = [] 14 | 15 | def should_accept_transfer( 16 | self, 17 | *, 18 | iface_id: int, 19 | transfer_id_timeout: float, 20 | timestamp: pycyphal.transport.Timestamp, 21 | source_node_id: typing.Optional[int], 22 | transfer_id: int, 23 | ) -> bool: 24 | del iface_id # Not used in monotonic deduplicator. 25 | if source_node_id is None: 26 | # Anonymous transfers are fully stateless, so always accepted. 27 | # This may lead to duplications and reordering but this is a design limitation. 28 | return True 29 | 30 | # If a similar architecture is used on an embedded system, this normally would be a static array. 31 | if len(self._remote_states) <= source_node_id: 32 | self._remote_states += [None] * (source_node_id - len(self._remote_states) + 1) 33 | assert len(self._remote_states) == source_node_id + 1 34 | 35 | if self._remote_states[source_node_id] is None: 36 | # First transfer from this node, create new state and accept unconditionally. 37 | self._remote_states[source_node_id] = _RemoteState(last_transfer_id=transfer_id, last_timestamp=timestamp) 38 | return True 39 | 40 | # We have seen transfers from this node before, so we need to perform actual deduplication. 41 | state = self._remote_states[source_node_id] 42 | assert state is not None 43 | 44 | # If we have seen transfers with higher TID values recently, reject this one as duplicate. 45 | tid_timeout = (timestamp.monotonic - state.last_timestamp.monotonic) > transfer_id_timeout 46 | if not tid_timeout and transfer_id <= state.last_transfer_id: 47 | return False 48 | 49 | # Otherwise, this is either a new transfer or a TID timeout condition has occurred. 50 | state.last_transfer_id = transfer_id 51 | state.last_timestamp = timestamp 52 | return True 53 | 54 | 55 | @dataclasses.dataclass 56 | class _RemoteState: 57 | last_transfer_id: int 58 | last_timestamp: pycyphal.transport.Timestamp 59 | -------------------------------------------------------------------------------- /pycyphal/transport/redundant/_error.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import pycyphal.transport 6 | 7 | 8 | class InconsistentInferiorConfigurationError(pycyphal.transport.InvalidTransportConfigurationError): 9 | """ 10 | Raised when a redundant transport instance is asked to attach a new inferior whose configuration 11 | does not match that of the other inferiors or of the redundant transport itself. 12 | """ 13 | -------------------------------------------------------------------------------- /pycyphal/transport/redundant/_session/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from ._base import RedundantSession as RedundantSession 6 | from ._base import RedundantSessionStatistics as RedundantSessionStatistics 7 | 8 | from ._input import RedundantInputSession as RedundantInputSession 9 | from ._input import RedundantTransferFrom as RedundantTransferFrom 10 | 11 | from ._output import RedundantOutputSession as RedundantOutputSession 12 | from ._output import RedundantFeedback as RedundantFeedback 13 | -------------------------------------------------------------------------------- /pycyphal/transport/redundant/_session/_base.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import abc 6 | import typing 7 | import logging 8 | import dataclasses 9 | import pycyphal.transport 10 | 11 | 12 | _logger = logging.getLogger(__name__) 13 | 14 | 15 | @dataclasses.dataclass 16 | class RedundantSessionStatistics(pycyphal.transport.SessionStatistics): 17 | """ 18 | Aggregate statistics for all inferior sessions in a redundant group. 19 | This is an atomic immutable sample; it is not updated after construction. 20 | """ 21 | 22 | inferiors: typing.List[pycyphal.transport.SessionStatistics] = dataclasses.field(default_factory=list) 23 | """ 24 | The ordering is guaranteed to match that of :attr:`RedundantSession.inferiors`. 25 | """ 26 | 27 | 28 | class RedundantSession(abc.ABC): 29 | """ 30 | The base for all redundant session instances. 31 | 32 | A redundant session may be constructed even if the redundant transport itself has no inferiors. 33 | When a new inferior transport is attached/detached to/from the redundant group, 34 | dependent session instances are automatically reconfigured, transparently to the user. 35 | 36 | The higher layers of the protocol stack are therefore shielded from any changes made to the stack 37 | below the redundant transport instance; existing sessions and other instances are never invalidated. 38 | This guarantee allows one to construct applications whose underlying transport configuration 39 | can be changed at runtime. 40 | """ 41 | 42 | @property 43 | @abc.abstractmethod 44 | def specifier(self) -> pycyphal.transport.SessionSpecifier: 45 | raise NotImplementedError 46 | 47 | @property 48 | @abc.abstractmethod 49 | def payload_metadata(self) -> pycyphal.transport.PayloadMetadata: 50 | raise NotImplementedError 51 | 52 | @property 53 | @abc.abstractmethod 54 | def inferiors(self) -> typing.Sequence[pycyphal.transport.Session]: 55 | """ 56 | Read-only access to the list of inferiors. 57 | The ordering is guaranteed to match that of :attr:`RedundantTransport.inferiors`. 58 | """ 59 | raise NotImplementedError 60 | 61 | @abc.abstractmethod 62 | def close(self) -> None: 63 | """ 64 | Closes and detaches all inferior sessions. 65 | If any of the sessions fail to close, an error message will be logged, but no exception will be raised. 66 | The instance will no longer be usable afterward. 67 | """ 68 | raise NotImplementedError 69 | 70 | @abc.abstractmethod 71 | def _add_inferior(self, session: pycyphal.transport.Session) -> None: 72 | """ 73 | If the new session is already an inferior, this method does nothing. 74 | If anything goes wrong during the initial setup, the inferior will not be added and 75 | an appropriate exception will be raised. 76 | 77 | This method is intended to be invoked by the transport class. 78 | The Python's type system does not allow us to concisely define module-internal APIs. 79 | """ 80 | raise NotImplementedError 81 | 82 | @abc.abstractmethod 83 | def _close_inferior(self, session_index: int) -> None: 84 | """ 85 | If the index is out of range, this method does nothing. 86 | Removal always succeeds regardless of any exceptions raised. 87 | 88 | Like its counterpart, this method is supposed to be invoked by the transport class. 89 | """ 90 | raise NotImplementedError 91 | -------------------------------------------------------------------------------- /pycyphal/transport/serial/_session/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from ._base import SerialSession as SerialSession 6 | 7 | from ._output import SerialOutputSession as SerialOutputSession 8 | from ._output import SerialFeedback as SerialFeedback 9 | 10 | from ._input import SerialInputSession as SerialInputSession 11 | from ._input import SerialInputSessionStatistics as SerialInputSessionStatistics 12 | -------------------------------------------------------------------------------- /pycyphal/transport/serial/_session/_base.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import typing 6 | import pycyphal 7 | 8 | 9 | class SerialSession: 10 | def __init__(self, finalizer: typing.Callable[[], None]): 11 | self._close_finalizer: typing.Optional[typing.Callable[[], None]] = finalizer 12 | if not callable(self._close_finalizer): # pragma: no cover 13 | raise TypeError(f"Invalid finalizer: {type(self._close_finalizer).__name__}") 14 | 15 | def close(self) -> None: 16 | fin = self._close_finalizer 17 | if fin is not None: 18 | self._close_finalizer = None 19 | fin() 20 | 21 | def _raise_if_closed(self) -> None: 22 | if self._close_finalizer is None: 23 | raise pycyphal.transport.ResourceClosedError(f"Session is closed: {self}") 24 | -------------------------------------------------------------------------------- /pycyphal/transport/udp/_ip/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from ._socket_factory import SocketFactory as SocketFactory 6 | from ._socket_factory import Sniffer as Sniffer 7 | 8 | from ._endpoint_mapping import IPAddress as IPAddress 9 | from ._endpoint_mapping import CYPHAL_PORT as CYPHAL_PORT 10 | from ._endpoint_mapping import service_node_id_to_multicast_group as service_node_id_to_multicast_group 11 | from ._endpoint_mapping import message_data_specifier_to_multicast_group as message_data_specifier_to_multicast_group 12 | 13 | from ._link_layer import LinkLayerPacket as LinkLayerPacket 14 | from ._link_layer import LinkLayerCapture as LinkLayerCapture 15 | -------------------------------------------------------------------------------- /pycyphal/transport/udp/_session/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from ._input import UDPInputSession as UDPInputSession 6 | from ._input import PromiscuousUDPInputSession as PromiscuousUDPInputSession 7 | from ._input import SelectiveUDPInputSession as SelectiveUDPInputSession 8 | 9 | from ._input import UDPInputSessionStatistics as UDPInputSessionStatistics 10 | from ._input import PromiscuousUDPInputSessionStatistics as PromiscuousUDPInputSessionStatistics 11 | from ._input import SelectiveUDPInputSessionStatistics as SelectiveUDPInputSessionStatistics 12 | 13 | from ._output import UDPOutputSession as UDPOutputSession 14 | from ._output import UDPFeedback as UDPFeedback 15 | -------------------------------------------------------------------------------- /pycyphal/util/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | """ 6 | The util package contains various entities that are commonly useful in PyCyphal-based applications. 7 | """ 8 | 9 | from ._broadcast import broadcast as broadcast 10 | 11 | from ._introspect import import_submodules as import_submodules 12 | from ._introspect import iter_descendants as iter_descendants 13 | 14 | from ._mark_last import mark_last as mark_last 15 | 16 | from ._repr import repr_attributes as repr_attributes 17 | from ._repr import repr_attributes_noexcept as repr_attributes_noexcept 18 | -------------------------------------------------------------------------------- /pycyphal/util/_broadcast.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import typing 6 | import logging 7 | 8 | R = typing.TypeVar("R") 9 | 10 | _logger = logging.getLogger(__name__) 11 | 12 | 13 | def broadcast( 14 | functions: typing.Iterable[typing.Callable[..., R]], 15 | ) -> typing.Callable[..., typing.List[typing.Union[R, Exception]]]: 16 | """ 17 | Returns a function that invokes each supplied function in series with the specified arguments 18 | following the specified order. 19 | If a function is executed successfully, its result is added to the output list. 20 | If it raises an exception, the exception is suppressed, logged, and added to the output list instead of the result. 21 | 22 | This function is mostly intended for invoking various handlers. 23 | 24 | .. doctest:: 25 | :hide: 26 | 27 | >>> _logger.setLevel(100) # Suppress error reports from the following doctest. 28 | 29 | >>> def add(a, b): 30 | ... return a + b 31 | >>> def fail(a, b): 32 | ... raise ValueError(f'Arguments: {a}, {b}') 33 | >>> broadcast([add, fail])(4, b=5) 34 | [9, ValueError('Arguments: 4, 5')] 35 | >>> broadcast([print])('Hello', 'world!') 36 | Hello world! 37 | [None] 38 | >>> broadcast([])() 39 | [] 40 | """ 41 | 42 | def delegate(*args: typing.Any, **kwargs: typing.Any) -> typing.List[typing.Union[R, Exception]]: 43 | out: typing.List[typing.Union[R, Exception]] = [] 44 | for fn in functions: 45 | try: 46 | r: typing.Union[R, Exception] = fn(*args, **kwargs) 47 | except Exception as ex: 48 | r = ex 49 | _logger.exception("Unhandled exception in %s: %s", fn, ex) 50 | out.append(r) 51 | return out 52 | 53 | return delegate 54 | -------------------------------------------------------------------------------- /pycyphal/util/_introspect.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import types 6 | import typing 7 | import pkgutil 8 | import importlib 9 | 10 | 11 | T = typing.TypeVar("T", bound=object) # https://github.com/python/mypy/issues/5374 12 | 13 | 14 | def iter_descendants(ty: typing.Type[T]) -> typing.Iterable[typing.Type[T]]: 15 | # noinspection PyTypeChecker,PyUnresolvedReferences 16 | """ 17 | Returns a recursively descending iterator over all subclasses of the argument. 18 | 19 | >>> class A: pass 20 | >>> class B(A): pass 21 | >>> class C(B): pass 22 | >>> class D(A): pass 23 | >>> set(iter_descendants(A)) == {B, C, D} 24 | True 25 | >>> list(iter_descendants(D)) 26 | [] 27 | >>> bool in set(iter_descendants(int)) 28 | True 29 | 30 | Practical example -- discovering what transports are available: 31 | 32 | >>> import pycyphal 33 | >>> pycyphal.util.import_submodules(pycyphal.transport) 34 | >>> list(sorted(map(lambda t: t.__name__, pycyphal.util.iter_descendants(pycyphal.transport.Transport)))) 35 | [...'CANTransport'...'RedundantTransport'...'SerialTransport'...] 36 | """ 37 | # noinspection PyArgumentList 38 | for t in ty.__subclasses__(): 39 | yield t 40 | yield from iter_descendants(t) 41 | 42 | 43 | def import_submodules( 44 | root_module: types.ModuleType, error_handler: typing.Optional[typing.Callable[[str, ImportError], None]] = None 45 | ) -> None: 46 | # noinspection PyTypeChecker,PyUnresolvedReferences 47 | """ 48 | Recursively imports all submodules and subpackages of the specified Python module or package. 49 | This is mostly intended for automatic import of all available specialized implementations 50 | of a certain functionality when they are spread out through several submodules which are not 51 | auto-imported. 52 | 53 | :param root_module: The module to start the recursive descent from. 54 | 55 | :param error_handler: If None (default), any :class:`ImportError` is raised normally, 56 | thereby terminating the import process after the first import error (e.g., a missing dependency). 57 | Otherwise, this would be a function that is invoked whenever an import error is encountered 58 | instead of raising the exception. The arguments are: 59 | 60 | - the name of the parent module whose import could not be completed due to the error; 61 | - the culprit of type :class:`ImportError`. 62 | 63 | >>> import pycyphal 64 | >>> pycyphal.util.import_submodules(pycyphal.transport) # One missing dependency would fail everything. 65 | >>> pycyphal.transport.loopback.LoopbackTransport 66 | 67 | 68 | >>> import tests.util.import_error # For demo purposes, this package contains a missing import. 69 | >>> pycyphal.util.import_submodules(tests.util.import_error) # Yup, it fails. 70 | Traceback (most recent call last): 71 | ... 72 | ModuleNotFoundError: No module named 'nonexistent_module_should_raise_import_error' 73 | >>> pycyphal.util.import_submodules(tests.util.import_error, # The handler allows us to ignore ImportError. 74 | ... lambda parent, ex: print(parent, ex.name)) 75 | tests.util.import_error._subpackage nonexistent_module_should_raise_import_error 76 | """ 77 | for _, module_name, _ in pkgutil.walk_packages(root_module.__path__, root_module.__name__ + "."): 78 | try: 79 | importlib.import_module(module_name) 80 | except ImportError as ex: 81 | if error_handler is None: 82 | raise 83 | error_handler(module_name, ex) 84 | -------------------------------------------------------------------------------- /pycyphal/util/_mark_last.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import typing 6 | 7 | 8 | T = typing.TypeVar("T") 9 | 10 | 11 | def mark_last(it: typing.Iterable[T]) -> typing.Iterable[typing.Tuple[bool, T]]: 12 | """ 13 | This is an iteration helper like :func:`enumerate`. It amends every item with a boolean flag which is False 14 | for all items except the last one. If the input iterable is empty, yields nothing. 15 | 16 | >>> list(mark_last([])) 17 | [] 18 | >>> list(mark_last([123])) 19 | [(True, 123)] 20 | >>> list(mark_last([123, 456])) 21 | [(False, 123), (True, 456)] 22 | >>> list(mark_last([123, 456, 789])) 23 | [(False, 123), (False, 456), (True, 789)] 24 | """ 25 | it = iter(it) 26 | try: 27 | last = next(it) 28 | except StopIteration: 29 | pass 30 | else: 31 | for val in it: 32 | yield False, last 33 | last = val 34 | yield True, last 35 | -------------------------------------------------------------------------------- /pycyphal/util/_repr.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | 6 | def repr_attributes(obj: object, *anonymous_elements: object, **named_elements: object) -> str: 7 | """ 8 | A simple helper function that constructs a :func:`repr` form of an object. Used widely across the library. 9 | String representations will be obtained by invoking :func:`str` on each value. 10 | 11 | >>> class Aa: pass 12 | >>> assert repr_attributes(Aa()) == 'Aa()' 13 | >>> assert repr_attributes(Aa(), 123) == 'Aa(123)' 14 | >>> assert repr_attributes(Aa(), foo=123) == 'Aa(foo=123)' 15 | >>> assert repr_attributes(Aa(), 456, foo=123, bar=repr('abc')) == "Aa(456, foo=123, bar='abc')" 16 | """ 17 | fld = list(map(str, anonymous_elements)) + list(f"{name}={value}" for name, value in named_elements.items()) 18 | return f"{type(obj).__name__}(" + ", ".join(fld) + ")" 19 | 20 | 21 | def repr_attributes_noexcept(obj: object, *anonymous_elements: object, **named_elements: object) -> str: 22 | """ 23 | A robust version of :meth:`repr_attributes` that never raises exceptions. 24 | 25 | >>> class Aa: pass 26 | >>> repr_attributes_noexcept(Aa(), 456, foo=123, bar=repr('abc')) 27 | "Aa(456, foo=123, bar='abc')" 28 | >>> class Bb: 29 | ... def __repr__(self) -> str: 30 | ... raise Exception('Ford, you are turning into a penguin') 31 | >>> repr_attributes_noexcept(Aa(), foo=Bb()) 32 | "" 33 | >>> class Cc(Exception): 34 | ... def __str__(self) -> str: raise Cc() # Infinite recursion 35 | ... def __repr__(self) -> str: raise Cc() # Infinite recursion 36 | >>> repr_attributes_noexcept(Aa(), foo=Cc()) 37 | '' 38 | """ 39 | try: 40 | return repr_attributes(obj, *anonymous_elements, **named_elements) 41 | except Exception as ex: 42 | # noinspection PyBroadException 43 | try: 44 | return f"" 45 | except Exception: 46 | return "" 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | target-version = ['py311'] 4 | include = ''' 5 | ((pycyphal|tests)/.*\.pyi?$) 6 | | 7 | (demo/[a-z0-9_]+\.py$) 8 | ''' 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (C) 2019 OpenCyphal 4 | # This software is distributed under the terms of the MIT License. 5 | # Author: Pavel Kirienko 6 | 7 | import setuptools 8 | 9 | setuptools.setup() 10 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=PyCyphal 2 | sonar.organization=opencyphal 3 | sonar.sources=pycyphal 4 | sonar.python.coverage.reportPaths=.coverage.xml 5 | sonar.python.version=3.10,3.11 6 | sonar.host.url=https://sonarcloud.io 7 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import os 6 | import sys 7 | import asyncio 8 | import logging 9 | from typing import Awaitable, TypeVar, Any 10 | from . import dsdl as dsdl 11 | from .dsdl import DEMO_DIR as DEMO_DIR 12 | 13 | assert ("PYTHONASYNCIODEBUG" in os.environ) or ( 14 | os.environ.get("IGNORE_PYTHONASYNCIODEBUG", False) 15 | ), "PYTHONASYNCIODEBUG should be set while running the tests" 16 | 17 | 18 | _logger = logging.getLogger(__name__) 19 | 20 | _T = TypeVar("_T") 21 | 22 | _PATCH_RESTORE_PREFIX = "_pycyphal_orig_" 23 | 24 | 25 | def asyncio_allow_event_loop_access_from_top_level() -> None: 26 | """ 27 | This monkeypatch is needed to make doctests behave as if they were executed from inside an event loop. 28 | It is often required to access the current event loop from a non-async function invoked from the regular 29 | doctest context. 30 | One could use ``asyncio.get_event_loop`` for that until Python 3.10, where this behavior has been deprecated. 31 | 32 | Ideally, we should be able to run the entire doctest suite with an event loop available and ``await`` being 33 | enabled at the top level; however, as of right now this is not possible yet. 34 | You will find more info on this here: https://github.com/Erotemic/xdoctest/issues/115 35 | Until a proper solution is available, this hack will have to stay here. 36 | 37 | This function shall be invoked per test, because the test suite undoes its effect before starting the next test. 38 | """ 39 | _logger.info("asyncio_allow_event_loop_access_from_top_level()") 40 | 41 | def swap(mod: Any, name: str, new: Any) -> None: 42 | restore = _PATCH_RESTORE_PREFIX + name 43 | if not hasattr(mod, restore): 44 | setattr(mod, restore, getattr(mod, name)) 45 | setattr(mod, name, new) 46 | 47 | swap(asyncio, "get_event_loop", asyncio.get_event_loop_policy().get_event_loop) 48 | swap(asyncio, "get_running_loop", asyncio.get_event_loop_policy().get_event_loop) 49 | 50 | 51 | def asyncio_restore() -> None: 52 | count = 0 53 | for mod in [asyncio, asyncio.events]: 54 | for k, v in mod.__dict__.items(): 55 | if k.startswith(_PATCH_RESTORE_PREFIX): 56 | count += 1 57 | setattr(mod, k[len(_PATCH_RESTORE_PREFIX) :], v) 58 | _logger.info("asyncio_restore() %r", count) 59 | 60 | 61 | def doctest_await(future: Awaitable[_T]) -> _T: 62 | """ 63 | This is a helper for writing doctests of async functions. Behaves just like ``await``. 64 | This is a hack; when the proper solution is available it should be removed: 65 | https://github.com/Erotemic/xdoctest/issues/115 66 | """ 67 | asyncio.get_event_loop().slow_callback_duration = max(asyncio.get_event_loop().slow_callback_duration, 10.0) 68 | return asyncio.get_event_loop().run_until_complete(future) 69 | -------------------------------------------------------------------------------- /tests/application/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import typing 6 | import pycyphal 7 | 8 | 9 | def get_transport(node_id: typing.Optional[int]) -> pycyphal.transport.Transport: 10 | from pycyphal.transport.udp import UDPTransport 11 | 12 | return UDPTransport("127.42.0.1", local_node_id=node_id) 13 | -------------------------------------------------------------------------------- /tests/application/diagnostic.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import re 6 | import typing 7 | from typing import Dict 8 | import asyncio 9 | import logging 10 | import pytest 11 | import pycyphal 12 | from pycyphal.transport.loopback import LoopbackTransport 13 | 14 | pytestmark = pytest.mark.asyncio 15 | 16 | 17 | async def _unittest_slow_diagnostic_subscriber( 18 | compiled: typing.List[pycyphal.dsdl.GeneratedPackageInfo], caplog: typing.Any 19 | ) -> None: 20 | from pycyphal.application import make_node, NodeInfo, diagnostic, make_registry 21 | from uavcan.time import SynchronizedTimestamp_1_0 22 | 23 | assert compiled 24 | asyncio.get_running_loop().slow_callback_duration = 1.0 25 | 26 | node = make_node( 27 | NodeInfo(), 28 | make_registry(None, typing.cast(Dict[str, bytes], {})), 29 | transport=LoopbackTransport(2222), 30 | ) 31 | node.start() 32 | pub = node.make_publisher(diagnostic.Record) 33 | diagnostic.DiagnosticSubscriber(node) 34 | 35 | caplog.clear() 36 | await pub.publish( 37 | diagnostic.Record( 38 | timestamp=SynchronizedTimestamp_1_0(123456789), 39 | severity=diagnostic.Severity(diagnostic.Severity.INFO), 40 | text="Hello world!", 41 | ) 42 | ) 43 | await asyncio.sleep(1.0) 44 | print("Captured log records:") 45 | for lr in caplog.records: 46 | print(" ", lr) 47 | assert isinstance(lr, logging.LogRecord) 48 | pat = r"uavcan\.diagnostic\.Record: node=2222 severity=2 ts_sync=123\.456789 ts_local=\S+:\nHello world!" 49 | if lr.levelno == logging.INFO and re.match(pat, lr.message): 50 | break 51 | else: 52 | assert False, "Expected log message not captured" 53 | 54 | pub.close() 55 | node.close() 56 | await asyncio.sleep(1.0) # Let the background tasks terminate. 57 | -------------------------------------------------------------------------------- /tests/application/transport_factory_candump.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import typing 7 | import asyncio 8 | from decimal import Decimal 9 | from pathlib import Path 10 | import pytest 11 | import pycyphal 12 | 13 | 14 | pytestmark = pytest.mark.asyncio 15 | 16 | 17 | async def _unittest_slow_make_transport_candump( 18 | compiled: typing.List[pycyphal.dsdl.GeneratedPackageInfo], 19 | tmp_path: Path, 20 | ) -> None: 21 | from pycyphal.application import make_transport, make_registry 22 | from pycyphal.transport import Capture 23 | from pycyphal.transport.can import CANCapture 24 | 25 | asyncio.get_running_loop().slow_callback_duration = 3.0 26 | assert compiled 27 | candump_file = tmp_path / "candump.log" 28 | candump_file.write_text(_CANDUMP_TEST_DATA) 29 | 30 | registry = make_registry(None, {}) # type: ignore 31 | registry["uavcan.can.iface"] = "candump:" + str(candump_file) 32 | 33 | tr = make_transport(registry) 34 | print("Transport:", tr) 35 | assert tr 36 | 37 | captures: list[CANCapture] = [] 38 | 39 | def handle_capture(cap: Capture) -> None: 40 | assert isinstance(cap, CANCapture) 41 | print(cap) 42 | captures.append(cap) 43 | 44 | tr.begin_capture(handle_capture) 45 | await asyncio.sleep(4.0) 46 | assert len(captures) == 2 47 | 48 | assert captures[0].timestamp.system == Decimal("1657800490.360135") 49 | assert captures[0].frame.identifier == 0x0C60647D 50 | assert captures[0].frame.format == pycyphal.transport.can.media.FrameFormat.EXTENDED 51 | assert captures[0].frame.data == bytes.fromhex("020000FB") 52 | 53 | assert captures[1].timestamp.system == Decimal("1657800490.360136") 54 | assert captures[1].frame.identifier == 0x10606E7D 55 | assert captures[1].frame.format == pycyphal.transport.can.media.FrameFormat.EXTENDED 56 | assert captures[1].frame.data == bytes.fromhex("00000000000000BB") 57 | 58 | captures.clear() 59 | await asyncio.sleep(10.0) 60 | tr.close() 61 | assert len(captures) == 2 62 | 63 | assert captures[0].timestamp.system == Decimal("1657800499.360152") 64 | assert captures[0].frame.identifier == 0x10606E7D 65 | assert captures[0].frame.format == pycyphal.transport.can.media.FrameFormat.EXTENDED 66 | assert captures[0].frame.data == bytes.fromhex("000000000000003B") 67 | 68 | assert captures[1].timestamp.system == Decimal("1657800499.360317") 69 | assert captures[1].frame.identifier == 0x1060787D 70 | assert captures[1].frame.format == pycyphal.transport.can.media.FrameFormat.EXTENDED 71 | assert captures[1].frame.data == bytes.fromhex("0000C07F147CB71B") 72 | 73 | 74 | _CANDUMP_TEST_DATA = """ 75 | (1657800490.360135) slcan0 0C60647D#020000FB 76 | (1657800490.360136) slcan0 10606E7D#00000000000000BB 77 | (1657800490.360149) slcan1 10606E7D#000000000000001B 78 | (1657800499.360152) slcan0 10606E7D#000000000000003B 79 | (1657800499.360305) slcan2 1060787D#00000000000000BB 80 | (1657800499.360317) slcan0 1060787D#0000C07F147CB71B 81 | (1657800499.361011) slcan1 1060787D#412BCC7B 82 | """ 83 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import sys 6 | import typing 7 | import logging 8 | import subprocess 9 | import pytest 10 | 11 | # The fixture is imported here to make it visible to other tests in this suite. 12 | from .dsdl.conftest import compiled as compiled # noqa # pylint: disable=unused-import 13 | 14 | 15 | GIBIBYTE = 1024**3 16 | 17 | MEMORY_LIMIT = 8 * GIBIBYTE 18 | """ 19 | The test suite artificially limits the amount of consumed memory in order to avoid triggering the OOM killer 20 | should a test go crazy and eat all memory. 21 | """ 22 | 23 | _logger = logging.getLogger(__name__) 24 | 25 | 26 | @pytest.fixture(scope="session", autouse=True) 27 | def _configure_host_environment() -> None: 28 | def execute(*cmd: typing.Any, ensure_success: bool = True) -> typing.Tuple[int, str, str]: 29 | cmd = tuple(map(str, cmd)) 30 | out = subprocess.run( # pylint: disable=subprocess-run-check 31 | cmd, 32 | encoding="utf8", 33 | stdout=subprocess.PIPE, 34 | stderr=subprocess.PIPE, 35 | ) 36 | stdout, stderr = out.stdout, out.stderr 37 | _logger.debug("%s stdout:\n%s", cmd, stdout) 38 | _logger.debug("%s stderr:\n%s", cmd, stderr) 39 | if out.returncode != 0 and ensure_success: # pragma: no cover 40 | raise subprocess.CalledProcessError(out.returncode, cmd, stdout, stderr) 41 | assert isinstance(stdout, str) and isinstance(stderr, str) 42 | return out.returncode, stdout, stderr 43 | 44 | if sys.platform.startswith("linux"): 45 | import resource # pylint: disable=import-error 46 | 47 | _logger.info("Limiting process memory usage to %.1f GiB", MEMORY_LIMIT / GIBIBYTE) 48 | resource.setrlimit(resource.RLIMIT_AS, (MEMORY_LIMIT, MEMORY_LIMIT)) 49 | 50 | # Set up virtual SocketCAN interfaces. 51 | execute("sudo", "modprobe", "can") 52 | execute("sudo", "modprobe", "can_raw") 53 | execute("sudo", "modprobe", "vcan") 54 | for idx in range(3): 55 | iface = f"vcan{idx}" 56 | execute("sudo", "ip", "link", "add", "dev", iface, "type", "vcan", ensure_success=False) 57 | execute("sudo", "ip", "link", "set", iface, "mtu", 72) # Enable both Classic CAN and CAN FD. 58 | execute("sudo", "ip", "link", "set", "up", iface) 59 | 60 | if sys.platform.startswith("win"): 61 | import ctypes 62 | 63 | # Reconfigure the system timer to run at a higher resolution. This is desirable for the real-time tests. 64 | t = ctypes.c_ulong() 65 | ctypes.WinDLL("NTDLL.DLL").NtSetTimerResolution(5000, 1, ctypes.byref(t)) 66 | _logger.info("System timer resolution: %.3f ms", t.value / 10e3) 67 | 68 | 69 | @pytest.fixture(autouse=True) 70 | def _revert_asyncio_monkeypatch() -> None: 71 | """ 72 | Ensures that every test is executed with the original, unpatched asyncio, unless explicitly requested otherwise. 73 | """ 74 | from . import asyncio_restore 75 | 76 | asyncio_restore() 77 | -------------------------------------------------------------------------------- /tests/demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenCyphal/pycyphal/71eb3ae06b51820f0f4ec27e2f1edc48f73fad8e/tests/demo/__init__.py -------------------------------------------------------------------------------- /tests/demo/_setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import os 6 | from typing import Any 7 | from ._subprocess import BackgroundChildProcess 8 | 9 | 10 | def _unittest_slow_demo_setup_py(cd_to_demo: Any) -> None: 11 | _ = cd_to_demo 12 | proc = BackgroundChildProcess( 13 | "python", 14 | "setup.py", 15 | "build", 16 | environment_variables={ 17 | "PATH": os.environ.get("PATH", ""), 18 | "SYSTEMROOT": os.environ.get("SYSTEMROOT", ""), # https://github.com/appveyor/ci/issues/1995 19 | # setup.py uses manual DSDL compilation so disable import hook instead of setting PYCYPHAL_PATH 20 | "PYCYPHAL_NO_IMPORT_HOOK": "True", 21 | "HOME": os.environ.get("HOME", ""), 22 | "USERPROFILE": os.environ.get("USERPROFILE", ""), 23 | "HOMEDRIVE": os.environ.get("HOMEDRIVE", ""), 24 | "HOMEPATH": os.environ.get("HOMEPATH", ""), 25 | }, 26 | ) 27 | exit_code, stdout = proc.wait(120) 28 | print(stdout) 29 | assert exit_code == 0 30 | -------------------------------------------------------------------------------- /tests/demo/_subprocess.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from __future__ import annotations 6 | import sys 7 | import shutil 8 | import typing 9 | import logging 10 | import subprocess 11 | 12 | 13 | _logger = logging.getLogger(__name__) 14 | 15 | 16 | class BackgroundChildProcess: 17 | r""" 18 | A wrapper over :class:`subprocess.Popen`. 19 | This wrapper allows collection of stdout upon completion. At first I tried using a background reader 20 | thread that was blocked on ``stdout.readlines()``, but that solution ended up being dysfunctional because 21 | it is fundamentally incompatible with internal stdio buffering in the monitored process which 22 | we have absolutely no control over from our local process. Sure, there exist options to suppress buffering, 23 | such as the ``-u`` flag in Python or the PYTHONUNBUFFERED env var, but they would make the test environment 24 | unnecessarily fragile, so I opted to use a simpler approach where we just run the process until it kicks 25 | the bucket and then loot the output from its dead body. 26 | 27 | >>> p = BackgroundChildProcess('ping', '127.0.0.1') 28 | >>> p.wait(0.5) 29 | Traceback (most recent call last): 30 | ... 31 | subprocess.TimeoutExpired: ... 32 | >>> p.kill() 33 | """ 34 | 35 | def __init__(self, *args: str, environment_variables: typing.Optional[typing.Dict[str, str]] = None): 36 | cmd = _make_process_args(*args) 37 | _logger.info("Starting in background: %s with env vars: %s", args, environment_variables) 38 | 39 | if sys.platform.startswith("win"): 40 | # If the current process group is used, CTRL_C_EVENT will kill the parent and everyone in the group! 41 | creationflags: int = subprocess.CREATE_NEW_PROCESS_GROUP 42 | else: 43 | creationflags = 0 44 | 45 | # Buffering must be DISABLED, otherwise we can't read data on Windows after the process is interrupted. 46 | # For some reason stdout is not flushed at exit there. 47 | self._inferior = subprocess.Popen( # pylint: disable=consider-using-with 48 | cmd, 49 | stdout=subprocess.PIPE, 50 | stderr=sys.stderr, 51 | encoding="utf8", 52 | env=_get_env(environment_variables), 53 | creationflags=creationflags, 54 | bufsize=0, 55 | ) 56 | 57 | @staticmethod 58 | def cli(*args: str, environment_variables: typing.Optional[typing.Dict[str, str]] = None) -> BackgroundChildProcess: 59 | """ 60 | A convenience factory for running the CLI tool. 61 | """ 62 | return BackgroundChildProcess("python", "-m", "pycyphal", *args, environment_variables=environment_variables) 63 | 64 | def wait(self, timeout: float, interrupt: typing.Optional[bool] = False) -> typing.Tuple[int, str]: 65 | if interrupt and self._inferior.poll() is None: 66 | self.interrupt() 67 | stdout = self._inferior.communicate(timeout=timeout)[0] 68 | exit_code = int(self._inferior.returncode) 69 | return exit_code, stdout 70 | 71 | def kill(self) -> None: 72 | self._inferior.kill() 73 | 74 | def interrupt(self) -> None: 75 | import signal 76 | 77 | try: 78 | self._inferior.send_signal(signal.SIGINT) 79 | except ValueError: # pragma: no cover 80 | # On Windows, SIGINT is not supported, and CTRL_C_EVENT does nothing. 81 | self._inferior.send_signal(getattr(signal, "CTRL_BREAK_EVENT")) 82 | 83 | @property 84 | def pid(self) -> int: 85 | return int(self._inferior.pid) 86 | 87 | @property 88 | def alive(self) -> bool: 89 | return self._inferior.poll() is None 90 | 91 | 92 | def _get_env(environment_variables: typing.Optional[typing.Dict[str, str]] = None) -> typing.Dict[str, str]: 93 | # Buffering must be DISABLED, otherwise we can't read data on Windows after the process is interrupted. 94 | # For some reason stdout is not flushed at exit there. 95 | env = { 96 | "PYTHONUNBUFFERED": "1", 97 | } 98 | env.update(environment_variables or {}) 99 | return env 100 | 101 | 102 | def _make_process_args(executable: str, *args: str) -> typing.Sequence[str]: 103 | # On Windows, the path lookup is not performed so we have to find the executable manually. 104 | # On GNU/Linux it doesn't matter so we do it anyway for consistency. 105 | resolved = shutil.which(executable) 106 | if not resolved: # pragma: no cover 107 | raise RuntimeError(f"Cannot locate executable: {executable}") 108 | executable = resolved 109 | return list(map(str, [executable] + list(args))) 110 | -------------------------------------------------------------------------------- /tests/demo/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from typing import Iterator 6 | import os 7 | import pytest 8 | from .. import DEMO_DIR 9 | 10 | 11 | @pytest.fixture() 12 | def cd_to_demo() -> Iterator[None]: 13 | restore_to = os.getcwd() 14 | os.chdir(DEMO_DIR) 15 | yield 16 | os.chdir(restore_to) 17 | -------------------------------------------------------------------------------- /tests/dsdl/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from .conftest import compile as compile # pylint: disable=redefined-builtin 6 | from .conftest import DEMO_DIR as DEMO_DIR 7 | -------------------------------------------------------------------------------- /tests/dsdl/_compiler.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import typing 6 | import logging 7 | import pathlib 8 | import tempfile 9 | import pytest 10 | import pycyphal.dsdl 11 | from .conftest import DEMO_DIR 12 | 13 | 14 | def _unittest_bad_usage() -> None: 15 | with pytest.raises(TypeError): 16 | # noinspection PyTypeChecker 17 | pycyphal.dsdl.compile("irrelevant", "irrelevant") # type: ignore 18 | 19 | 20 | def _unittest_module_import_path_usage_suggestion(caplog: typing.Any) -> None: 21 | caplog.set_level(logging.INFO) 22 | output_directory = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with 23 | output_directory_name = pathlib.Path(output_directory.name).resolve() 24 | caplog.clear() 25 | pycyphal.dsdl.compile(DEMO_DIR / "public_regulated_data_types" / "uavcan", output_directory=output_directory.name) 26 | logs = caplog.record_tuples 27 | print("Captured log entries:", logs, sep="\n") 28 | for e in logs: 29 | if "dsdl" in e[0] and str(output_directory_name) in e[2]: 30 | assert e[1] == logging.INFO 31 | assert " path" in e[2] 32 | assert "Path(" not in e[2] # Ensure decent formatting 33 | break 34 | else: 35 | assert False 36 | try: 37 | output_directory.cleanup() # This may fail on Windows with Python 3.7, we don't care. 38 | except PermissionError: # pragma: no cover 39 | pass 40 | 41 | 42 | def _unittest_issue_133() -> None: 43 | with pytest.raises(ValueError, match=".*output directory.*"): 44 | pycyphal.dsdl.compile(pathlib.Path.cwd() / "irrelevant") 45 | -------------------------------------------------------------------------------- /tests/dsdl/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import sys 6 | import pickle 7 | import typing 8 | import shutil 9 | import logging 10 | import functools 11 | import importlib 12 | from pathlib import Path 13 | import pytest 14 | import pycyphal.dsdl 15 | 16 | 17 | # Please maintain these carefully if you're changing the project's directory structure. 18 | SELF_DIR = Path(__file__).resolve().parent 19 | LIBRARY_ROOT_DIR = SELF_DIR.parent.parent 20 | DEMO_DIR = LIBRARY_ROOT_DIR / "demo" 21 | DESTINATION_DIR = Path.cwd().resolve() / ".compiled" 22 | 23 | _CACHE_FILE_NAME = "pydsdl_cache.pickle.tmp" 24 | 25 | 26 | @functools.lru_cache() 27 | def compile() -> typing.List[pycyphal.dsdl.GeneratedPackageInfo]: # pylint: disable=redefined-builtin 28 | """ 29 | Runs the DSDL package generator against the standard and test namespaces, emits a list of GeneratedPackageInfo. 30 | Automatically adds the path to the generated packages to sys path to make them importable. 31 | The output is cached permanently on disk in a file in the output directory because the workings of PyDSDL or 32 | Nunavut are outside of the scope of responsibilities of this test suite, yet generation takes a long time. 33 | To force regeneration, remove the generated package directories. 34 | """ 35 | if str(DESTINATION_DIR) not in sys.path: # pragma: no cover 36 | sys.path.insert(0, str(DESTINATION_DIR)) 37 | importlib.invalidate_caches() 38 | cache_file = DESTINATION_DIR / _CACHE_FILE_NAME 39 | 40 | if DESTINATION_DIR.exists(): # pragma: no cover 41 | if cache_file.exists(): 42 | with open(cache_file, "rb") as f: 43 | out = pickle.load(f) 44 | assert out and isinstance(out, list) 45 | assert all(map(lambda x: isinstance(x, pycyphal.dsdl.GeneratedPackageInfo), out)) # type: ignore 46 | return out # type: ignore 47 | 48 | shutil.rmtree(DESTINATION_DIR, ignore_errors=True) 49 | DESTINATION_DIR.mkdir(parents=True, exist_ok=True) 50 | 51 | pydsdl_logger = logging.getLogger("pydsdl") 52 | pydsdl_logging_level = pydsdl_logger.level 53 | try: 54 | pydsdl_logger.setLevel(logging.INFO) 55 | out = pycyphal.dsdl.compile_all( 56 | [ 57 | DEMO_DIR / "public_regulated_data_types" / "uavcan", 58 | DEMO_DIR / "custom_data_types" / "sirius_cyber_corp", 59 | SELF_DIR / "test_dsdl_namespace", 60 | ], 61 | DESTINATION_DIR, 62 | ) 63 | finally: 64 | pydsdl_logger.setLevel(pydsdl_logging_level) 65 | 66 | with open(cache_file, "wb") as f: 67 | pickle.dump(out, f) 68 | 69 | assert out and isinstance(out, list) 70 | assert all(map(lambda x: isinstance(x, pycyphal.dsdl.GeneratedPackageInfo), out)) 71 | return out # type: ignore 72 | 73 | 74 | compiled = pytest.fixture(scope="session")(compile) 75 | -------------------------------------------------------------------------------- /tests/dsdl/test_dsdl_namespace/delimited/A.1.0.dsdl: -------------------------------------------------------------------------------- 1 | @union 2 | BSealed.1.0 sea 3 | BDelimited.1.0 del 4 | @extent 56 * 8 5 | -------------------------------------------------------------------------------- /tests/dsdl/test_dsdl_namespace/delimited/A.1.1.dsdl: -------------------------------------------------------------------------------- 1 | @union 2 | BSealed.1.0 sea 3 | BDelimited.1.1 del 4 | @extent 56 * 8 5 | -------------------------------------------------------------------------------- /tests/dsdl/test_dsdl_namespace/delimited/BDelimited.1.0.dsdl: -------------------------------------------------------------------------------- 1 | CVariable.1.0[<=2] var 2 | CFixed.1.0[<=2] fix 3 | @extent 40 * 8 4 | -------------------------------------------------------------------------------- /tests/dsdl/test_dsdl_namespace/delimited/BDelimited.1.1.dsdl: -------------------------------------------------------------------------------- 1 | CVariable.1.1[<=2] var 2 | CFixed.1.1[<=2] fix 3 | @extent 40 * 8 4 | -------------------------------------------------------------------------------- /tests/dsdl/test_dsdl_namespace/delimited/BSealed.1.0.dsdl: -------------------------------------------------------------------------------- 1 | CVariable.1.0[<=2] var 2 | CFixed.1.0[<=2] fix 3 | @sealed 4 | -------------------------------------------------------------------------------- /tests/dsdl/test_dsdl_namespace/delimited/CFixed.1.0.dsdl: -------------------------------------------------------------------------------- 1 | uint8[2] a 2 | @extent 4 * 8 3 | -------------------------------------------------------------------------------- /tests/dsdl/test_dsdl_namespace/delimited/CFixed.1.1.dsdl: -------------------------------------------------------------------------------- 1 | uint8[3] a 2 | int8 b 3 | @extent 4 * 8 4 | -------------------------------------------------------------------------------- /tests/dsdl/test_dsdl_namespace/delimited/CVariable.1.0.dsdl: -------------------------------------------------------------------------------- 1 | uint8[<=2] a 2 | int8 b 3 | @extent 4 * 8 4 | -------------------------------------------------------------------------------- /tests/dsdl/test_dsdl_namespace/delimited/CVariable.1.1.dsdl: -------------------------------------------------------------------------------- 1 | uint8[<=2] a 2 | @extent 4 * 8 3 | -------------------------------------------------------------------------------- /tests/dsdl/test_dsdl_namespace/if/B.1.0.dsdl: -------------------------------------------------------------------------------- 1 | @union 2 | C.1.0[2] x 3 | C.1.0[<=2] y 4 | @sealed 5 | -------------------------------------------------------------------------------- /tests/dsdl/test_dsdl_namespace/if/C.1.0.dsdl: -------------------------------------------------------------------------------- 1 | @union 2 | @deprecated 3 | uint8 x 4 | int8 y 5 | @sealed 6 | -------------------------------------------------------------------------------- /tests/dsdl/test_dsdl_namespace/if/del.1.0.dsdl: -------------------------------------------------------------------------------- 1 | void8 2 | B.1.0[2] else 3 | B.1.0[<=2] raise 4 | @sealed 5 | -------------------------------------------------------------------------------- /tests/dsdl/test_dsdl_namespace/numpy/CombinatorialExplosion.0.1.dsdl: -------------------------------------------------------------------------------- 1 | # This data type is crafted to trigger the combinatorial explosion problem: https://github.com/OpenCyphal/pydsdl/issues/23 2 | # The problem is now fixed so we introduce this type to shield us against regressions. 3 | # If DSDL compilation takes over a few minutes, you have a combinatorial problem somewhere in the compiler. 4 | 5 | uavcan.primitive.String.1.0[<=1024] foo 6 | uavcan.primitive.String.1.0[256] bar 7 | 8 | @extent 100 * (1024 ** 2) * 8 # One hundred megabytes should be about right. 9 | -------------------------------------------------------------------------------- /tests/dsdl/test_dsdl_namespace/numpy/Complex.254.255.dsdl: -------------------------------------------------------------------------------- 1 | @union 2 | float16 VALUE = 3.14159265358979 3 | uavcan.node.port.ID.1.0[<=2] property 4 | uavcan.register.Value.1.0[2] id 5 | truncated uint2[<=5] bytes 6 | truncated uint7[5] str 7 | @extent 1024 * 8 8 | -------------------------------------------------------------------------------- /tests/dsdl/test_dsdl_namespace/numpy/RGB888_3840x2748.0.1.dsdl: -------------------------------------------------------------------------------- 1 | @deprecated 2 | 3 | uint16 PIXELS_PER_ROW = 3840 4 | uint16 ROWS_PER_IMAGE = 2748 5 | uint32 PIXELS_PER_IMAGE = PIXELS_PER_ROW * ROWS_PER_IMAGE 6 | 7 | uavcan.time.SynchronizedTimestamp.1.0 timestamp # Image capture time 8 | void8 9 | 10 | @assert _offset_ == {64} 11 | uint8[PIXELS_PER_IMAGE * 3] pixels # Row major, top-left pixel first, color ordering RGB 12 | 13 | @sealed 14 | -------------------------------------------------------------------------------- /tests/presentation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenCyphal/pycyphal/71eb3ae06b51820f0f4ec27e2f1edc48f73fad8e/tests/presentation/__init__.py -------------------------------------------------------------------------------- /tests/presentation/_rpc.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import typing 6 | import asyncio 7 | import pytest 8 | import pycyphal 9 | from .conftest import TransportFactory 10 | 11 | pytestmark = pytest.mark.asyncio 12 | 13 | 14 | async def _unittest_slow_presentation_rpc( 15 | compiled: typing.List[pycyphal.dsdl.GeneratedPackageInfo], transport_factory: TransportFactory 16 | ) -> None: 17 | assert compiled 18 | import uavcan.register 19 | import uavcan.primitive 20 | import uavcan.time 21 | from pycyphal.transport import Priority, Timestamp 22 | 23 | asyncio.get_running_loop().slow_callback_duration = 5.0 24 | 25 | tran_a, tran_b, _ = transport_factory(123, 42) 26 | assert tran_a.local_node_id == 123 27 | assert tran_b.local_node_id == 42 28 | 29 | pres_a = pycyphal.presentation.Presentation(tran_a) 30 | pres_b = pycyphal.presentation.Presentation(tran_b) 31 | 32 | assert pres_a.transport is tran_a 33 | 34 | server = pres_a.get_server_with_fixed_service_id(uavcan.register.Access_1_0) 35 | assert server is pres_a.get_server_with_fixed_service_id(uavcan.register.Access_1_0) 36 | 37 | client0 = pres_b.make_client_with_fixed_service_id(uavcan.register.Access_1_0, 123) 38 | client1 = pres_b.make_client_with_fixed_service_id(uavcan.register.Access_1_0, 123) 39 | client_dead = pres_b.make_client_with_fixed_service_id(uavcan.register.Access_1_0, 111) 40 | assert client0 is not client1 41 | assert client0._maybe_impl is not None # pylint: disable=protected-access 42 | assert client1._maybe_impl is not None # pylint: disable=protected-access 43 | assert client0._maybe_impl is client1._maybe_impl # pylint: disable=protected-access 44 | assert client0._maybe_impl is not client_dead._maybe_impl # pylint: disable=protected-access 45 | assert client0._maybe_impl.proxy_count == 2 # pylint: disable=protected-access 46 | assert client_dead._maybe_impl is not None # pylint: disable=protected-access 47 | assert client_dead._maybe_impl.proxy_count == 1 # pylint: disable=protected-access 48 | 49 | with pytest.raises(TypeError): 50 | # noinspection PyTypeChecker 51 | pres_a.make_publisher_with_fixed_subject_id(uavcan.register.Access_1_0) 52 | with pytest.raises(TypeError): 53 | # noinspection PyTypeChecker 54 | pres_a.make_subscriber_with_fixed_subject_id(uavcan.register.Access_1_0) 55 | 56 | assert client0.response_timeout == pytest.approx(1.0) 57 | client0.response_timeout = 0.1 58 | assert client0.response_timeout == pytest.approx(0.1) 59 | client0.priority = Priority.SLOW 60 | 61 | last_request = uavcan.register.Access_1_0.Request() 62 | last_metadata = pycyphal.presentation.ServiceRequestMetadata( 63 | timestamp=Timestamp(0, 0), priority=Priority(0), transfer_id=0, client_node_id=0 64 | ) 65 | response: typing.Optional[uavcan.register.Access_1_0.Response] = None 66 | 67 | async def server_handler( 68 | request: uavcan.register.Access_1_0.Request, metadata: pycyphal.presentation.ServiceRequestMetadata 69 | ) -> typing.Optional[uavcan.register.Access_1_0.Response]: 70 | nonlocal last_metadata 71 | print("SERVICE REQUEST:", request, metadata) 72 | assert isinstance(request, server.dtype.Request) and isinstance(request, uavcan.register.Access_1_0.Request) 73 | assert repr(last_request) == repr(request) 74 | last_metadata = metadata 75 | return response 76 | 77 | server.serve_in_background(server_handler) 78 | 79 | last_request = uavcan.register.Access_1_0.Request( 80 | name=uavcan.register.Name_1_0("Hello world!"), 81 | value=uavcan.register.Value_1_0(string=uavcan.primitive.String_1_0("Profanity will not be tolerated")), 82 | ) 83 | result_a = await client0(last_request) 84 | assert result_a is None, "Expected to fail" 85 | assert last_metadata.client_node_id == 42 86 | assert last_metadata.transfer_id == 0 87 | assert last_metadata.priority == Priority.SLOW 88 | 89 | client0.response_timeout = 2.0 # Increase the timeout back because otherwise the test fails on slow systems. 90 | 91 | last_request = uavcan.register.Access_1_0.Request(name=uavcan.register.Name_1_0("security.uber_secure_password")) 92 | response = uavcan.register.Access_1_0.Response( 93 | timestamp=uavcan.time.SynchronizedTimestamp_1_0(123456789), 94 | mutable=True, 95 | persistent=False, 96 | value=uavcan.register.Value_1_0(string=uavcan.primitive.String_1_0("hunter2")), 97 | ) 98 | client0.priority = Priority.IMMEDIATE 99 | result_b = await client0(last_request) 100 | assert repr(result_b) == repr(response) 101 | assert last_metadata.client_node_id == 42 102 | assert last_metadata.transfer_id == 1 103 | assert last_metadata.priority == Priority.IMMEDIATE 104 | 105 | server.close() 106 | client0.close() 107 | client1.close() 108 | client_dead.close() 109 | # Double-close has no effect (no error either): 110 | server.close() 111 | client0.close() 112 | client1.close() 113 | client_dead.close() 114 | 115 | # Allow the tasks to finish 116 | await asyncio.sleep(0.1) 117 | 118 | # Make sure the transport sessions have been closed properly, this is supremely important. 119 | assert list(pres_a.transport.input_sessions) == [] 120 | assert list(pres_b.transport.input_sessions) == [] 121 | assert list(pres_a.transport.output_sessions) == [] 122 | assert list(pres_b.transport.output_sessions) == [] 123 | 124 | pres_a.close() 125 | pres_b.close() 126 | 127 | await asyncio.sleep(1) # Let all pending tasks finalize properly to avoid stack traces in the output. 128 | -------------------------------------------------------------------------------- /tests/presentation/subscription_synchronizer/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | -------------------------------------------------------------------------------- /tests/transport/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenCyphal/pycyphal/71eb3ae06b51820f0f4ec27e2f1edc48f73fad8e/tests/transport/__init__.py -------------------------------------------------------------------------------- /tests/transport/_primitives.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | 6 | def _unittest_transport_primitives() -> None: 7 | from pytest import raises 8 | from pycyphal.transport import InputSessionSpecifier, OutputSessionSpecifier 9 | from pycyphal.transport import MessageDataSpecifier, ServiceDataSpecifier, PayloadMetadata 10 | 11 | with raises(ValueError): 12 | MessageDataSpecifier(-1) 13 | 14 | with raises(ValueError): 15 | MessageDataSpecifier(32768) 16 | 17 | with raises(ValueError): 18 | ServiceDataSpecifier(-1, ServiceDataSpecifier.Role.REQUEST) 19 | 20 | with raises(ValueError): 21 | InputSessionSpecifier(MessageDataSpecifier(123), -1) 22 | 23 | with raises(ValueError): 24 | OutputSessionSpecifier(ServiceDataSpecifier(100, ServiceDataSpecifier.Role.RESPONSE), None) 25 | 26 | with raises(ValueError): 27 | PayloadMetadata(-1) 28 | -------------------------------------------------------------------------------- /tests/transport/can/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from . import media 6 | -------------------------------------------------------------------------------- /tests/transport/can/media/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from . import mock as mock 6 | -------------------------------------------------------------------------------- /tests/transport/can/media/_socketcan.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | import sys 6 | import typing 7 | import asyncio 8 | import pytest 9 | 10 | 11 | if sys.platform != "linux": # pragma: no cover 12 | pytest.skip("SocketCAN test skipped because the system is not GNU/Linux", allow_module_level=True) 13 | 14 | pytestmark = pytest.mark.asyncio 15 | 16 | 17 | async def _unittest_can_socketcan() -> None: 18 | from pycyphal.transport import Timestamp 19 | from pycyphal.transport.can.media import Envelope, DataFrame, FrameFormat, FilterConfiguration 20 | 21 | from pycyphal.transport.can.media.socketcan import SocketCANMedia 22 | 23 | available = SocketCANMedia.list_available_interface_names() 24 | print("Available SocketCAN ifaces:", available) 25 | assert "vcan0" in available, ( 26 | "Either the interface listing method is not working or the environment is not configured correctly. " 27 | 'Please ensure that the virtual SocketCAN interface "vcan0" is available, and its MTU is set to 64+8.' 28 | ) 29 | 30 | media_a = SocketCANMedia("vcan0", 12) 31 | media_b = SocketCANMedia("vcan0", 64) 32 | 33 | assert media_a.mtu == 12 34 | assert media_b.mtu == 64 35 | assert media_a.interface_name == "vcan0" 36 | assert media_b.interface_name == "vcan0" 37 | assert media_a.number_of_acceptance_filters == media_b.number_of_acceptance_filters 38 | assert media_a._maybe_thread is None # pylint: disable=protected-access 39 | assert media_b._maybe_thread is None # pylint: disable=protected-access 40 | 41 | media_a.configure_acceptance_filters([FilterConfiguration.new_promiscuous()]) 42 | media_b.configure_acceptance_filters([FilterConfiguration.new_promiscuous()]) 43 | 44 | rx_a: typing.List[typing.Tuple[Timestamp, Envelope]] = [] 45 | 46 | def on_rx_a(frames: typing.Iterable[typing.Tuple[Timestamp, Envelope]]) -> None: 47 | nonlocal rx_a 48 | frames = list(frames) 49 | print("RX A:", frames) 50 | rx_a += frames 51 | 52 | def on_rx_b(frames: typing.Iterable[typing.Tuple[Timestamp, Envelope]]) -> None: 53 | frames = list(frames) 54 | print("RX B:", frames) 55 | asyncio.ensure_future(media_b.send((e for _, e in frames), asyncio.get_event_loop().time() + 1.0)) 56 | 57 | media_a.start(on_rx_a, False) 58 | media_b.start(on_rx_b, True) 59 | 60 | assert media_a._maybe_thread is not None # pylint: disable=protected-access 61 | assert media_b._maybe_thread is not None # pylint: disable=protected-access 62 | 63 | await asyncio.sleep(2.0) # This wait is needed to ensure that the RX thread handles select() timeout properly 64 | 65 | ts_begin = Timestamp.now() 66 | await media_a.send( 67 | [ 68 | Envelope(DataFrame(FrameFormat.BASE, 0x123, bytearray(range(6))), loopback=True), 69 | Envelope(DataFrame(FrameFormat.EXTENDED, 0x1BADC0FE, bytearray(range(8))), loopback=True), 70 | ], 71 | asyncio.get_event_loop().time() + 1.0, 72 | ) 73 | await media_a.send( 74 | [ 75 | Envelope(DataFrame(FrameFormat.EXTENDED, 0x1FF45678, bytearray(range(0))), loopback=False), 76 | ], 77 | asyncio.get_event_loop().time() + 1.0, 78 | ) 79 | await asyncio.sleep(1.0) 80 | ts_end = Timestamp.now() 81 | 82 | print("rx_a:", rx_a) 83 | # Three sent back from the other end, two loopback 84 | assert len(rx_a) == 5 85 | for t, _ in rx_a: 86 | assert ts_begin.monotonic_ns <= t.monotonic_ns <= ts_end.monotonic_ns 87 | assert ts_begin.system_ns <= t.system_ns <= ts_end.system_ns 88 | 89 | rx_loopback = [e.frame for t, e in rx_a if e.loopback] 90 | rx_external = [e.frame for t, e in rx_a if not e.loopback] 91 | assert len(rx_loopback) == 2 and len(rx_external) == 3 92 | 93 | assert rx_loopback[0].identifier == 0x123 94 | assert rx_loopback[0].data == bytearray(range(6)) 95 | assert rx_loopback[0].format == FrameFormat.BASE 96 | 97 | assert rx_loopback[1].identifier == 0x1BADC0FE 98 | assert rx_loopback[1].data == bytearray(range(8)) 99 | assert rx_loopback[1].format == FrameFormat.EXTENDED 100 | 101 | assert rx_external[0].identifier == 0x123 102 | assert rx_external[0].data == bytearray(range(6)) 103 | assert rx_external[0].format == FrameFormat.BASE 104 | 105 | assert rx_external[1].identifier == 0x1BADC0FE 106 | assert rx_external[1].data == bytearray(range(8)) 107 | assert rx_external[1].format == FrameFormat.EXTENDED 108 | 109 | assert rx_external[2].identifier == 0x1FF45678 110 | assert rx_external[2].data == bytearray(range(0)) 111 | assert rx_external[2].format == FrameFormat.EXTENDED 112 | 113 | media_a.close() 114 | media_b.close() 115 | 116 | await asyncio.sleep(1) # Let all pending tasks finalize properly to avoid stack traces in the output. 117 | -------------------------------------------------------------------------------- /tests/transport/can/media/_socketcand.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # pylint: disable=protected-access,duplicate-code 4 | 5 | import sys 6 | import typing 7 | import asyncio 8 | import logging 9 | import subprocess 10 | import pytest 11 | 12 | from pycyphal.transport import Timestamp 13 | from pycyphal.transport.can.media import Envelope, DataFrame, FrameFormat 14 | from pycyphal.transport.can.media.socketcand import SocketcandMedia 15 | 16 | if sys.platform != "linux": # pragma: no cover 17 | pytest.skip("Socketcand test skipped because the system is not GNU/Linux", allow_module_level=True) 18 | 19 | _logger = logging.getLogger(__name__) 20 | 21 | 22 | @pytest.fixture() 23 | def _start_socketcand() -> typing.Generator[None, None, None]: 24 | # starting a socketcand daemon in background 25 | cmd = ["socketcand", "-i", "vcan0", "-l", "lo", "-p", "29536"] 26 | 27 | socketcand = subprocess.Popen( # pylint: disable=consider-using-with 28 | cmd, 29 | encoding="utf8", 30 | stdout=subprocess.PIPE, 31 | stderr=subprocess.PIPE, 32 | ) 33 | 34 | try: 35 | stdout, stderr = socketcand.communicate(timeout=1) 36 | except subprocess.TimeoutExpired: 37 | pass # Successful liftoff 38 | else: 39 | _logger.debug("%s stdout:\n%s", cmd, stdout) 40 | _logger.debug("%s stderr:\n%s", cmd, stderr) 41 | raise subprocess.CalledProcessError(socketcand.returncode, cmd, stdout, stderr) 42 | 43 | yield None 44 | socketcand.kill() 45 | 46 | 47 | @pytest.mark.asyncio 48 | async def _unittest_can_socketcand(_start_socketcand: None) -> None: 49 | asyncio.get_running_loop().slow_callback_duration = 5.0 50 | 51 | media_a = SocketcandMedia("vcan0", "127.0.0.1") 52 | media_b = SocketcandMedia("vcan0", "127.0.0.1") 53 | 54 | assert media_a.mtu == 8 55 | assert media_b.mtu == 8 56 | assert media_a.interface_name == "socketcand:vcan0:127.0.0.1:29536" 57 | assert media_b.interface_name == "socketcand:vcan0:127.0.0.1:29536" 58 | assert media_a.channel_name == "vcan0" 59 | assert media_b.channel_name == "vcan0" 60 | assert media_a.host_name == "127.0.0.1" 61 | assert media_b.host_name == "127.0.0.1" 62 | assert media_a.port_name == 29536 63 | assert media_b.port_name == 29536 64 | assert media_a.number_of_acceptance_filters == media_b.number_of_acceptance_filters 65 | assert media_a._maybe_thread is None 66 | assert media_b._maybe_thread is None 67 | 68 | rx_a: typing.List[typing.Tuple[Timestamp, Envelope]] = [] 69 | rx_b: typing.List[typing.Tuple[Timestamp, Envelope]] = [] 70 | 71 | def on_rx_a(frames: typing.Iterable[typing.Tuple[Timestamp, Envelope]]) -> None: 72 | nonlocal rx_a 73 | frames = list(frames) 74 | print("RX A:", frames) 75 | rx_a += frames 76 | 77 | def on_rx_b(frames: typing.Iterable[typing.Tuple[Timestamp, Envelope]]) -> None: 78 | nonlocal rx_b 79 | frames = list(frames) 80 | print("RX B:", frames) 81 | rx_b += frames 82 | 83 | media_a.start(on_rx_a, False) 84 | media_b.start(on_rx_b, False) 85 | 86 | assert media_a._maybe_thread is not None 87 | assert media_b._maybe_thread is not None 88 | 89 | await asyncio.sleep(2.0) # This wait is needed to ensure that the RX thread handles read timeout properly 90 | 91 | ts_begin = Timestamp.now() 92 | await media_b.send( 93 | [ 94 | Envelope(DataFrame(FrameFormat.EXTENDED, 0xBADC0FE, bytearray(range(8))), loopback=True), 95 | Envelope(DataFrame(FrameFormat.EXTENDED, 0x12345678, bytearray(range(0))), loopback=False), 96 | Envelope(DataFrame(FrameFormat.BASE, 0x123, bytearray(range(6))), loopback=True), 97 | ], 98 | asyncio.get_event_loop().time() + 1.0, 99 | ) 100 | await asyncio.sleep(0.1) 101 | ts_end = Timestamp.now() 102 | 103 | print("rx_a:", rx_a) 104 | # Three received from another part 105 | assert len(rx_a) == 3 106 | for ts, _f in rx_a: 107 | assert ts_begin.monotonic_ns <= ts.monotonic_ns <= ts_end.monotonic_ns 108 | assert ts_begin.system_ns <= ts.system_ns <= ts_end.system_ns 109 | 110 | rx_external = list(filter(lambda x: True, rx_a)) 111 | 112 | assert rx_external[0][1].frame.identifier == 0xBADC0FE 113 | assert rx_external[0][1].frame.data == bytearray(range(8)) 114 | assert rx_external[0][1].frame.format == FrameFormat.EXTENDED 115 | 116 | assert rx_external[1][1].frame.identifier == 0x12345678 117 | assert rx_external[1][1].frame.data == bytearray(range(0)) 118 | assert rx_external[1][1].frame.format == FrameFormat.EXTENDED 119 | 120 | assert rx_external[2][1].frame.identifier == 0x123 121 | assert rx_external[2][1].frame.data == bytearray(range(6)) 122 | assert rx_external[2][1].frame.format == FrameFormat.BASE 123 | 124 | print("rx_b:", rx_b) 125 | # Two messages are loopback and were copied 126 | assert len(rx_b) == 2 127 | 128 | rx_loopback = list(filter(lambda x: True, rx_b)) 129 | 130 | assert rx_loopback[0][1].frame.identifier == 0xBADC0FE 131 | assert rx_loopback[0][1].frame.data == bytearray(range(8)) 132 | assert rx_loopback[0][1].frame.format == FrameFormat.EXTENDED 133 | 134 | assert rx_loopback[1][1].frame.identifier == 0x123 135 | assert rx_loopback[1][1].frame.data == bytearray(range(6)) 136 | assert rx_loopback[1][1].frame.format == FrameFormat.BASE 137 | 138 | media_a.close() 139 | media_b.close() 140 | -------------------------------------------------------------------------------- /tests/transport/can/media/mock/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | from ._media import MockMedia as MockMedia 6 | from ._media import FrameCollector as FrameCollector 7 | -------------------------------------------------------------------------------- /tests/transport/loopback/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | -------------------------------------------------------------------------------- /tests/transport/redundant/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | -------------------------------------------------------------------------------- /tests/transport/serial/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | 5 | VIRTUAL_BUS_URI = "socket://127.0.0.1:50905" 6 | """ 7 | Using ``localhost`` may significantly increase initialization latency on Windows due to slow DNS lookup. 8 | """ 9 | -------------------------------------------------------------------------------- /tests/transport/udp/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | -------------------------------------------------------------------------------- /tests/transport/udp/ip/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenCyphal/pycyphal/71eb3ae06b51820f0f4ec27e2f1edc48f73fad8e/tests/transport/udp/ip/__init__.py -------------------------------------------------------------------------------- /tests/util/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 OpenCyphal 2 | # This software is distributed under the terms of the MIT License. 3 | # Author: Pavel Kirienko 4 | -------------------------------------------------------------------------------- /tests/util/import_error/__init__.py: -------------------------------------------------------------------------------- 1 | # This module is specifically designed to raise ImportError when imported. This is needed for testing purposes. 2 | -------------------------------------------------------------------------------- /tests/util/import_error/_subpackage/__init__.py: -------------------------------------------------------------------------------- 1 | # This module is specifically designed to raise ImportError when imported. This is needed for testing purposes. 2 | 3 | # noinspection PyUnresolvedReferences 4 | import nonexistent_module_should_raise_import_error # type: ignore # pylint: disable=import-error 5 | --------------------------------------------------------------------------------