├── .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 | [](https://github.com/OpenCyphal/pycyphal/actions/workflows/test-and-release.yml) [](https://pycyphal.readthedocs.io/) [](https://coveralls.io/github/OpenCyphal/pycyphal) [](https://sonarcloud.io/dashboard?id=PyCyphal) [](https://sonarcloud.io/dashboard?id=PyCyphal) [](https://pypi.org/project/pycyphal/) [](https://github.com/psf/black) [](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 |
--------------------------------------------------------------------------------