diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..dbe907783 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "pip" + # Enable version updates for development dependencies + directory: "/" + schedule: + interval: "monthly" + versioning-strategy: "increase-if-necessary" + groups: + dev-deps: + patterns: + - "*" + + - package-ecosystem: "github-actions" + # Enable version updates for GitHub Actions + directory: "/" + schedule: + interval: "monthly" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..e6ce365d1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,37 @@ + + +## Summary of Changes + + + +- + +## Related Issues / Pull Requests + + + +- Closes # +- Related to # + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation update +- [ ] Refactoring +- [ ] Other (please describe): + +## Checklist + +- [ ] I have followed the [contribution guide](https://python-can.readthedocs.io/en/main/development.html). +- [ ] I have added or updated tests as appropriate. +- [ ] I have added or updated documentation as appropriate. +- [ ] I have added a [news fragment](doc/changelog.d/) for towncrier. +- [ ] All checks and tests pass (`tox`). + +## Additional Notes + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f96578e5..e340b2508 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,56 +5,60 @@ on: types: [ published ] pull_request: push: + branches-ignore: + - 'dependabot/**' env: PY_COLORS: "1" +permissions: + contents: read + jobs: test: runs-on: ${{ matrix.os }} - continue-on-error: ${{ matrix.experimental }} # See: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idcontinue-on-error strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - experimental: [false] - python-version: [ - "3.7", - "3.8", - "3.9", - "3.10", - "3.11", - "pypy-3.7", - "pypy-3.8", - "pypy-3.9", + env: [ + "py310", + "py311", + "py312", + "py313", + "py314", +# "py313t", +# "py314t", + "pypy310", + "pypy311", ] fail-fast: false steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # 6.0.0 with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install tox + fetch-depth: 0 + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # 7.1.4 + - name: Install tox + run: uv tool install tox --with tox-uv - name: Setup SocketCAN if: ${{ matrix.os == 'ubuntu-latest' }} run: | + sudo apt-get update sudo apt-get -y install linux-modules-extra-$(uname -r) sudo ./test/open_vcan.sh - name: Test with pytest via tox run: | - tox -e gh + tox -e ${{ matrix.env }} env: # SocketCAN tests currently fail with PyPy because it does not support raw CAN sockets - # See: https://foss.heptapod.net/pypy/pypy/-/issues/3809 - TEST_SOCKETCAN: "${{ matrix.os == 'ubuntu-latest' && ! startsWith(matrix.python-version, 'pypy' ) }}" + # See: https://github.com/pypy/pypy/issues/3808 + TEST_SOCKETCAN: "${{ matrix.os == 'ubuntu-latest' && ! startsWith(matrix.env, 'pypy' ) }}" - name: Coveralls Parallel - uses: coverallsapp/github-action@master + uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # 2.3.7 with: github-token: ${{ secrets.github_token }} - flag-name: Unittests-${{ matrix.os }}-${{ matrix.python-version }} + flag-name: Unittests-${{ matrix.os }}-${{ matrix.env }} parallel: true path-to-lcov: ./coverage.lcov @@ -62,8 +66,12 @@ jobs: needs: test runs-on: ubuntu-latest steps: + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # 6.0.0 + with: + fetch-depth: 0 + persist-credentials: false - name: Coveralls Finished - uses: coverallsapp/github-action@master + uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # 2.3.7 with: github-token: ${{ secrets.github_token }} parallel-finished: true @@ -71,113 +79,76 @@ jobs: static-code-analysis: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements-lint.txt - - name: mypy 3.7 - run: | - mypy --python-version 3.7 . - - name: mypy 3.8 - run: | - mypy --python-version 3.8 . - - name: mypy 3.9 - run: | - mypy --python-version 3.9 . - - name: mypy 3.10 - run: | - mypy --python-version 3.10 . - - name: mypy 3.11 - run: | - mypy --python-version 3.11 . - - name: pylint - run: | - pylint --rcfile=.pylintrc \ - can/**.py \ - can/io \ - setup.py \ - doc/conf.py \ - scripts/**.py \ - examples/**.py \ - can/interfaces/socketcan - - format: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # 6.0.0 with: - python-version: "3.10" - - name: Install dependencies + fetch-depth: 0 + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # 7.1.4 + - name: Install tox + run: uv tool install tox --with tox-uv + - name: Run linters run: | - python -m pip install --upgrade pip - pip install -r requirements-lint.txt - - name: Code Format Check with Black + tox -e lint + - name: Run type checker run: | - black --check --verbose . + tox -e type docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # 6.0.0 with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e .[canalystii,gs_usb] - pip install -r doc/doc-requirements.txt + fetch-depth: 0 + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # 7.1.4 + - name: Install tox + run: uv tool install tox --with tox-uv - name: Build documentation run: | - python -m sphinx -Wan --keep-going doc build - - name: Run doctest - run: | - python -m sphinx -b doctest -W --keep-going doc build - - uses: actions/upload-artifact@v3 - with: - name: sphinx-out - path: ./build/ - retention-days: 5 + tox -e docs build: + name: Packaging runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # 6.0.0 with: - python-version: "3.10" + fetch-depth: 0 + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # 7.1.4 - name: Build wheel and sdist - run: pipx run build + run: uv build - name: Check build artifacts - run: pipx run twine check --strict dist/* + run: uvx twine check --strict dist/* - name: Save artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 with: - name: python-can-dist + name: release path: ./dist upload_pypi: needs: [build] + name: Release to PyPi runs-on: ubuntu-latest + permissions: + id-token: write + attestations: write # upload to PyPI only on release if: github.event.release && github.event.action == 'published' steps: - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # 6.0.0 with: - name: python-can-dist path: dist + merge-multiple: true - - uses: pypa/gh-action-pypi-publish@v1.4.2 + - name: Generate artifact attestation + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # 3.0.0 with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} + subject-path: 'dist/*' + + - name: Publish release distributions to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # 1.13.0 diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml deleted file mode 100644 index 68c6f56d8..000000000 --- a/.github/workflows/format-code.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Format Code - -on: - push: - paths: - - '**.py' - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements-lint.txt - - name: Code Format Check with Black - run: | - black --verbose . - - name: Commit Formated Code - uses: EndBug/add-and-commit@v9 - with: - message: "Format code with black" - # Ref https://git-scm.com/docs/git-add#_examples - add: './*.py' diff --git a/.gitignore b/.gitignore index 03775bd7c..e4d402ff4 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,8 @@ __pycache__/ # Distribution / packaging .Python env/ -venv/ +.venv*/ +venv*/ build/ develop-eggs/ dist/ diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index cc4c50d88..000000000 --- a/.pylintrc +++ /dev/null @@ -1,502 +0,0 @@ -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. -extension-pkg-whitelist= - -# Add files or directories to be ignored. They should be base names, not -# paths. -ignore=CVS - -# Add files or directories matching the regex patterns to be ignored. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -# number of processors available to use. -jobs=0 - -# Control the amount of potential inferred values when inferring a single -# object. This can help the performance when dealing with large functions or -# complex, nested conditions. -limit-inference-results=100 - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins= - -# Pickle collected data for later comparisons. -persistent=yes - -# Specify a configuration file. -#rcfile= - -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once). You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use "--disable=all --enable=classes -# --disable=W". -disable=invalid-name, - missing-docstring, - empty-docstring, - wrong-import-order, - wrong-import-position, - too-few-public-methods, - too-many-public-methods, - too-many-branches, - too-many-locals, - too-many-statements, - no-else-raise, - wildcard-import, - no-else-return, - fixme, # We deliberately use TODO/FIXME inline - abstract-class-instantiated, # Needed for can.Bus - duplicate-code, # Needed due to https://github.com/PyCQA/pylint/issues/214 - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable=c-extension-no-member, - useless-suppression, - - -[REPORTS] - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details. -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio). You can also give a reporter class, e.g. -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages. -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - -# Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then -# it will be considered as an explicit return statement and no message will be -# printed. -never-returning-functions=sys.exit - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# Tells whether to warn about missing members when the owner of the attribute -# is inferred to be None. -ignore-none=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=no - -# Minimum lines number of a similarity. -min-similarity-lines=4 - - -[LOGGING] - -# Format style used to check logging format string. `old` means using % -# formatting, while `new` is for `{}` formatting. -logging-format-style=old - -# Logging modules to check that the string format arguments are in logging -# function parameter format. -logging-modules=logging - - -[STRING] - -# This flag controls whether the implicit-str-concat-in-sequence should -# generate a warning on implicit string concatenation in sequences defined over -# several lines. -check-str-concat-over-line-jumps=no - - -[BASIC] - -# Naming style matching correct argument names. -argument-naming-style=snake_case - -# Regular expression matching correct argument names. Overrides argument- -# naming-style. -#argument-rgx= - -# Naming style matching correct attribute names. -attr-naming-style=snake_case - -# Regular expression matching correct attribute names. Overrides attr-naming- -# style. -#attr-rgx= - -# Bad variable names which should always be refused, separated by a comma. -bad-names=foo, - bar, - baz, - toto, - tutu, - tata - -# Naming style matching correct class attribute names. -class-attribute-naming-style=any - -# Regular expression matching correct class attribute names. Overrides class- -# attribute-naming-style. -#class-attribute-rgx= - -# Naming style matching correct class names. -class-naming-style=PascalCase - -# Regular expression matching correct class names. Overrides class-naming- -# style. -#class-rgx= - -# Naming style matching correct constant names. -const-naming-style=UPPER_CASE - -# Regular expression matching correct constant names. Overrides const-naming- -# style. -#const-rgx= - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Naming style matching correct function names. -function-naming-style=snake_case - -# Regular expression matching correct function names. Overrides function- -# naming-style. -#function-rgx= - -# Good variable names which should always be accepted, separated by a comma. -good-names=i, - j, - k, - ex, - Run, - _ - -# Include a hint for the correct naming format with invalid-name. -include-naming-hint=no - -# Naming style matching correct inline iteration names. -inlinevar-naming-style=any - -# Regular expression matching correct inline iteration names. Overrides -# inlinevar-naming-style. -#inlinevar-rgx= - -# Naming style matching correct method names. -method-naming-style=snake_case - -# Regular expression matching correct method names. Overrides method-naming- -# style. -#method-rgx= - -# Naming style matching correct module names. -module-naming-style=snake_case - -# Regular expression matching correct module names. Overrides module-naming- -# style. -#module-rgx= - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -# These decorators are taken in consideration only for invalid-name. -property-classes=abc.abstractproperty - -# Naming style matching correct variable names. -variable-naming-style=snake_case - -# Regular expression matching correct variable names. Overrides variable- -# naming-style. -#variable-rgx= - - -[SPELLING] - -# Limits count of emitted suggestions for spelling mistakes. -max-spelling-suggestions=4 - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package.. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid defining new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_, - _cb - -# A regular expression matching the name of dummy variables (i.e. expected to -# not be used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore. -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME, - XXX, - TODO - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=100 - -# Maximum number of lines in a module. -max-module-lines=1000 - - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[IMPORTS] - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma. -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled). -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled). -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled). -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__, - __new__, - setUp - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict, - _fields, - _replace, - _source, - _make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=cls - - -[DESIGN] - -# Maximum number of arguments for function / method. -max-args=10 - -# Maximum number of attributes for a class (see R0902). -max-attributes=10 - -# Maximum number of boolean expressions in an if statement. -max-bool-expr=5 - -# Maximum number of branch for function / method body. -max-branches=12 - -# Maximum number of locals for function / method body. -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body. -max-returns=6 - -# Maximum number of statements in function / method body. -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "BaseException, Exception". -overgeneral-exceptions=BaseException, - Exception diff --git a/.readthedocs.yml b/.readthedocs.yml index 74cb9dbdd..a8c61d2de 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -9,7 +9,10 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.10" + python: "3.13" + jobs: + post_install: + - pip install --group docs # Build documentation in the docs/ directory with Sphinx sphinx: @@ -23,9 +26,8 @@ formats: # Optionally declare the Python requirements required to build your docs python: install: - - requirements: doc/doc-requirements.txt - method: pip path: . extra_requirements: - canalystii - - gs_usb + - gs-usb diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9bd1920e0..000000000 --- a/.travis.yml +++ /dev/null @@ -1,65 +0,0 @@ -language: python - -# Linux setup -dist: focal - -cache: - directories: - - "$HOME/.cache/pip" - -install: - - if [[ "$TEST_SOCKETCAN" ]]; then sudo bash test/open_vcan.sh ; fi - - travis_retry python setup.py install - -script: - - | - # install tox - travis_retry pip install tox - # Run the tests - tox -e travis - -jobs: - allow_failures: - # Allow arm64 builds to fail - - arch: arm64 - - include: - # Stages with the same name get run in parallel. - # Jobs within a stage can also be named. - - # testing socketcan on Trusty & Python 3.6, since it is not available on Xenial - - stage: test - name: Socketcan - os: linux - arch: amd64 - dist: trusty - python: "3.6" - sudo: required - env: TEST_SOCKETCAN=TRUE - - # arm64 builds - - stage: test - name: Linux arm64 - os: linux - arch: arm64 - language: generic - sudo: required - addons: - apt: - update: true - env: HOST_ARM64=TRUE - before_install: - - sudo apt install -y python3 python3-pip - # Travis doesn't seem to provide Python binaries yet for this arch - - sudo update-alternatives --install /usr/bin/python python $(which python3) 10 - - sudo update-alternatives --install /usr/bin/pip pip $(which pip3) 10 - # The below is the same as in the Socketcan job but with elevated privileges - install: - - if [[ "$TEST_SOCKETCAN" ]]; then sudo bash test/open_vcan.sh ; fi - - travis_retry sudo python setup.py install - script: - - | - # install tox - travis_retry sudo pip install tox - # Run the tests - sudo tox -e travis diff --git a/CHANGELOG.md b/CHANGELOG.md index c6ff2bbbf..75ecab49e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,326 @@ -Version 4.1.0 -==== +# Changelog -Breaking Changes ----------------- + + + + +## Version [v4.6.1](https://github.com/hardbyte/python-can/tree/v4.6.1) - 2025-08-12 + +### Fixed + +- Fix initialisation of an slcan bus, when setting a bitrate. When using CAN 2.0 (not FD), the default setting for `data_bitrate` was invalid, causing an exception. ([#1978](https://github.com/hardbyte/python-can/issues/1978)) + + +## Version [v4.6.0](https://github.com/hardbyte/python-can/tree/v4.6.0) - 2025-08-05 + +### Removed + +- Remove support for Python 3.8. ([#1931](https://github.com/hardbyte/python-can/issues/1931)) +- Unknown command line arguments ("extra args") are no longer passed down to `can.Bus()` instantiation. Use the `--bus-kwargs` argument instead. ([#1949](https://github.com/hardbyte/python-can/issues/1949)) +- Remove `can.io.generic.BaseIOHandler` class. Improve `can.io.*` type annotations by using `typing.Generic`. ([#1951](https://github.com/hardbyte/python-can/issues/1951)) + +### Added + +- Support 11-bit identifiers in the `serial` interface. ([#1758](https://github.com/hardbyte/python-can/issues/1758)) +- Keep track of active Notifiers and make Notifier usable as a context manager. Add function `Notifier.find_instances(bus)` to find the active Notifier for a given bus instance. ([#1890](https://github.com/hardbyte/python-can/issues/1890)) +- Add Windows support to `udp_multicast` interface. ([#1914](https://github.com/hardbyte/python-can/issues/1914)) +- Add FD support to `slcan` according to CANable 2.0 implementation. ([#1920](https://github.com/hardbyte/python-can/issues/1920)) +- Add support for error messages to the `socketcand` interface. ([#1941](https://github.com/hardbyte/python-can/issues/1941)) +- Add support for remote and error frames in the `serial` interface. ([#1948](https://github.com/hardbyte/python-can/issues/1948)) +- Add public functions `can.cli.add_bus_arguments` and `can.cli.create_bus_from_namespace` for creating bus command line options. Currently downstream packages need to implement their own logic to configure *python-can* buses. Now *python-can* can create and parse bus options for third party packages. ([#1949](https://github.com/hardbyte/python-can/issues/1949)) +- Add support for remote frames to `TRCReader`. ([#1953](https://github.com/hardbyte/python-can/issues/1953)) +- Mention the `python-can-candle` package in the plugin interface section of the documentation. ([#1954](https://github.com/hardbyte/python-can/issues/1954)) +- Add new CLI tool `python -m can.bridge` (or just `can_bridge`) to create a software bridge between two physical buses. ([#1961](https://github.com/hardbyte/python-can/issues/1961)) + +### Changed + +- Allow sending Classic CAN frames with a DLC value larger than 8 using the `socketcan` interface. ([#1851](https://github.com/hardbyte/python-can/issues/1851)) +- The `gs_usb` extra dependency was renamed to `gs-usb`. + The `lint` extra dependency was removed and replaced with new PEP 735 dependency groups `lint`, `docs` and `test`. ([#1945](https://github.com/hardbyte/python-can/issues/1945)) +- Update dependency name from `zlgcan-driver-py` to `zlgcan`. ([#1946](https://github.com/hardbyte/python-can/issues/1946)) +- Use ThreadPoolExecutor in `detect_available_configs()` to reduce runtime and add `timeout` parameter. ([#1947](https://github.com/hardbyte/python-can/issues/1947)) +- Update contribution guide. ([#1960](https://github.com/hardbyte/python-can/issues/1960)) + +### Fixed + +- Fix a bug in `slcanBus.get_version()` and `slcanBus.get_serial_number()`: If any other data was received during the function call, then `None` was returned. ([#1904](https://github.com/hardbyte/python-can/issues/1904)) +- Fix incorrect padding of CAN FD payload in `BlfReader`. ([#1906](https://github.com/hardbyte/python-can/issues/1906)) +- Set correct message direction for messages received with `kvaser` interface and `receive_own_messages=True`. ([#1908](https://github.com/hardbyte/python-can/issues/1908)) +- Fix timestamp rounding error in `BlfWriter`. ([#1921](https://github.com/hardbyte/python-can/issues/1921)) +- Fix timestamp rounding error in `BlfReader`. ([#1927](https://github.com/hardbyte/python-can/issues/1927)) +- Handle timer overflow message and build timestamp according to the epoch in the `ixxat` interface. ([#1934](https://github.com/hardbyte/python-can/issues/1934)) +- Avoid unsupported `ioctl` function call to allow usage of the `udp_multicast` interface on MacOS. ([#1940](https://github.com/hardbyte/python-can/issues/1940)) +- Fix configuration file parsing for the `state` bus parameter. ([#1957](https://github.com/hardbyte/python-can/issues/1957)) +- Mf4Reader: support non-standard `CAN_DataFrame.Dir` values in mf4 files created by [ihedvall/mdflib](https://github.com/ihedvall/mdflib). ([#1967](https://github.com/hardbyte/python-can/issues/1967)) +- PcanBus: Set `Message.channel` attribute in `PcanBus.recv()`. ([#1969](https://github.com/hardbyte/python-can/issues/1969)) + + +## Version 4.5.0 + +### Features + +* gs_usb command-line support (and documentation updates and stability fixes) by @BenGardiner in https://github.com/hardbyte/python-can/pull/1790 +* Faster and more general MF4 support by @cssedev in https://github.com/hardbyte/python-can/pull/1892 +* ASCWriter speed improvement by @pierreluctg in https://github.com/hardbyte/python-can/pull/1856 +* Faster Message string representation by @pierreluctg in https://github.com/hardbyte/python-can/pull/1858 +* Added Netronic's CANdo and CANdoISO adapters interface by @belliriccardo in https://github.com/hardbyte/python-can/pull/1887 +* Add autostart option to BusABC.send_periodic() to fix issue #1848 by @SWolfSchunk in https://github.com/hardbyte/python-can/pull/1853 +* Improve TestBusConfig by @zariiii9003 in https://github.com/hardbyte/python-can/pull/1804 +* Improve speed of TRCReader by @lebuni in https://github.com/hardbyte/python-can/pull/1893 + +### Bug Fixes + +* Fix Kvaser timestamp by @zariiii9003 in https://github.com/hardbyte/python-can/pull/1878 +* Set end_time in ThreadBasedCyclicSendTask.start() by @zariiii9003 in https://github.com/hardbyte/python-can/pull/1871 +* Fix regex in _parse_additional_config() by @zariiii9003 in https://github.com/hardbyte/python-can/pull/1868 +* Fix for #1849 (PCAN fails when PCAN_ERROR_ILLDATA is read via ReadFD) by @bures in https://github.com/hardbyte/python-can/pull/1850 +* Period must be >= 1ms for BCM using Win32 API by @pierreluctg in https://github.com/hardbyte/python-can/pull/1847 +* Fix ASCReader Crash on "Start of Measurement" Line by @RitheeshBaradwaj in https://github.com/hardbyte/python-can/pull/1811 +* Resolve AttributeError within NicanError by @vijaysubbiah20 in https://github.com/hardbyte/python-can/pull/1806 + + +### Miscellaneous + +* Fix CI by @zariiii9003 in https://github.com/hardbyte/python-can/pull/1889 +* Update msgpack dependency by @zariiii9003 in https://github.com/hardbyte/python-can/pull/1875 +* Add tox environment for doctest by @zariiii9003 in https://github.com/hardbyte/python-can/pull/1870 +* Use typing_extensions.TypedDict on python < 3.12 for pydantic support by @NickCao in https://github.com/hardbyte/python-can/pull/1845 +* Replace PyPy3.8 with PyPy3.10 by @zariiii9003 in https://github.com/hardbyte/python-can/pull/1838 +* Fix slcan tests by @zariiii9003 in https://github.com/hardbyte/python-can/pull/1834 +* Test on Python 3.13 by @zariiii9003 in https://github.com/hardbyte/python-can/pull/1833 +* Stop notifier in examples by @zariiii9003 in https://github.com/hardbyte/python-can/pull/1814 +* Use setuptools_scm by @zariiii9003 in https://github.com/hardbyte/python-can/pull/1810 +* Added extra info for Kvaser dongles by @FedericoSpada in https://github.com/hardbyte/python-can/pull/1797 +* Socketcand: show actual response as well as expected in error by @liamkinne in https://github.com/hardbyte/python-can/pull/1807 +* Refactor CLI filter parsing, add tests by @zariiii9003 in https://github.com/hardbyte/python-can/pull/1805 +* Add zlgcan to docs by @zariiii9003 in https://github.com/hardbyte/python-can/pull/1839 + + +## Version 4.4.2 + +### Bug Fixes + +* Remove `abstractmethod` decorator from `Listener.stop()` (#1770, #1795) +* Fix `SizedRotatingLogger` file suffix bug (#1792, #1793) +* gs_usb: Use `BitTiming` class internally to configure bitrate (#1747, #1748) +* pcan: Fix unpack error in `PcanBus._detect_available_configs()` (#1767) +* socketcan: Improve error handling in `SocketcanBus.__init__()` (#1771) +* socketcan: Do not log exception on non-linux platforms (#1800) +* vector, kvaser: Activate channels after CAN filters were applied (#1413, #1708, #1796) + +### Features + +* kvaser: Add support for non-ISO CAN FD (#1752) +* neovi: Return timestamps relative to epoch (#1789) +* slcan: Support CANdapter extended length arbitration ID (#1506, #1528) +* slcan: Add support for `listen_only` mode (#1496) +* vector: Add support for `listen_only` mode (#1764) + + +## Version 4.4.0 + +### Features + +* TRC 1.3 Support: Added support for .trc log files as generated by PCAN Explorer v5 and other tools, expanding compatibility with common log file formats (#1753). +* ASCReader refactor: improved the ASCReader code (#1717). +* SYSTEC Interface Enhancements: Added the ability to pass an explicit DLC value to the send() method when using the SYSTEC interface, enhancing flexibility for message definitions (#1756). +* Socketcand Beacon Detection: Introduced a feature for detecting socketcand beacons, facilitating easier connection and configuration with socketcand servers (#1687). +* PCAN Driver Echo Frames: Enabled echo frames in the PCAN driver when receive_own_messages is set, improving feedback for message transmissions (#1723). +* CAN FD Bus Connection for VectorBus: Enabled connecting to CAN FD buses without specifying bus timings, simplifying the connection process for users (#1716). +* Neousys Configs Detection: Updated the detection mechanism for available Neousys configurations, ensuring more accurate and comprehensive configuration discovery (#1744). + +### Bug Fixes + +* Send Periodic Messages: Fixed an issue where fixed-duration periodic messages were sent one extra time beyond their intended count (#1713). +* Vector Interface on Windows 11: Addressed compatibility issues with the Vector interface on Windows 11, ensuring stable operation across the latest OS version (#1731). +* ASCWriter Millisecond Handling: Corrected the handling of milliseconds in ASCWriter, ensuring accurate time representation in log files (#1734). +* Various minor bug fixes: Addressed several minor bugs to improve overall stability and performance. + +### Miscellaneous + +* Invert default value logic for BusABC._is_shutdown. (#1774) +* Implemented various logging enhancements to provide more detailed and useful operational insights (#1703). +* Updated CI to use OIDC for connecting GitHub Actions to PyPi, improving security and access control for CI workflows. +* Fix CI to work for MacOS (#1772). +* +The release also includes various other minor enhancements and bug fixes aimed at improving the reliability and performance of the software. + + +## Version 4.3.1 + +### Bug Fixes + +* Fix socketcand erroneously discarding frames (#1700) +* Fix initialization order in EtasBus (#1693, #1704) + +### Documentation + +* Fix install instructions for neovi (#1694, #1697) + + +## Version 4.3.0 + +### Breaking Changes + +* Raise Minimum Python Version to 3.8 (#1597) +* Do not stop notifier if exception was handled (#1645) + +### Bug Fixes + +* Vector: channel detection fails, if there is an active flexray channel (#1634) +* ixxat: Fix exception in 'state' property on bus coupling errors (#1647) +* NeoVi: Fixed serial number range (#1650) +* PCAN: Fix timestamp offset due to timezone (#1651) +* Catch `pywintypes.error` in broadcast manager (#1659) +* Fix BLFReader error for incomplete or truncated stream (#1662) +* PCAN: remove Windows registry check to fix 32bit compatibility (#1672) +* Vector: Skip the `can_op_mode check` if the device reports `can_op_mode=0` (#1678) +* Vector: using the config from `detect_available_configs` might raise XL_ERR_INVALID_CHANNEL_MASK error (#1681) + +### Features + +#### API + +* Add `modifier_callback` parameter to `BusABC.send_periodic` for auto-modifying cyclic tasks (#703) +* Add `protocol` property to BusABC to determine active CAN Protocol (#1532) +* Change Bus constructor implementation and typing (#1557) +* Add optional `strict` parameter to relax BitTiming & BitTimingFd Validation (#1618) +* Add `BitTiming.iterate_from_sample_point` static methods (#1671) + +#### IO + +* Can Player compatibility with interfaces that use additional configuration (#1610) + +#### Interface Improvements + +* Kvaser: Add BitTiming/BitTimingFd support to KvaserBus (#1510) +* Ixxat: Implement `detect_available_configs` for the Ixxat bus. (#1607) +* NeoVi: Enable send and receive on network ID above 255 (#1627) +* Vector: Send HighPriority Message to flush Tx buffer (#1636) +* PCAN: Optimize send performance (#1640) +* PCAN: Support version string of older PCAN basic API (#1644) +* Kvaser: add parameter exclusive and `override_exclusive` (#1660) +* socketcand: Add parameter `tcp_tune` to reduce latency (#1683) + +#### Miscellaneous + +* Distinguish Text/Binary-IO for Reader/Writer classes. (#1585) +* Convert setup.py to pyproject.toml (#1592) +* activate ruff pycodestyle checks (#1602) +* Update linter instructions in development.rst (#1603) +* remove unnecessary script files (#1604) +* BigEndian test fixes (#1625) +* align `ID:` in can.Message string (#1635) +* Use same configuration file as Linux on macOS (#1657) +* We do not need to account for drift when we `USE_WINDOWS_EVENTS` (#1666, #1679) +* Update linters, activate more ruff rules (#1669) +* Add Python 3.12 Support / Test Python 3.12 (#1673) + + +## Version 4.2.2 + +### Bug Fixes + +* Fix socketcan KeyError (#1598, #1599). +* Fix IXXAT not properly shutdown message (#1606). +* Fix Mf4Reader and TRCReader incompatibility with extra CLI args (#1610). +* Fix decoding error in Kvaser constructor for non-ASCII product name (#1613). + + +## Version 4.2.1 + +### Bug Fixes + +* The ASCWriter now logs the correct channel for error frames (#1578, #1583). +* Fix PCAN library detection (#1579, #1580). +* On Windows, the first two periodic frames were sent without delay (#1590). + + +## Version 4.2.0 + +### Breaking Changes + +* The ``can.BitTiming`` class was replaced with the new + ``can.BitTiming`` and `can.BitTimingFd` classes (#1468, #1515). + Early adopters of ``can.BitTiming`` will need to update their code. Check the + [documentation](https://python-can.readthedocs.io/en/develop/bit_timing.html) + for more information. Currently, the following interfaces support the new classes: + * canalystii (#1468) + * cantact (#1468) + * nixnet (#1520) + * pcan (#1514) + * vector (#1470, #1516) + + There are open pull requests for kvaser (#1510), slcan (#1512) and usb2can (#1511). Testing + and reviewing of these open PRs would be most appreciated. + +### Features + +#### IO +* Add support for MF4 files (#1289). +* Add support for version 2 TRC files and other TRC file enhancements (#1530). + +#### Type Annotations +* Export symbols to satisfy type checkers (#1547, #1551, #1558, #1568). + +#### Interface Improvements +* Add ``__del__`` method to ``can.BusABC`` to automatically release resources (#1489, #1564). +* pcan: Update PCAN Basic to 4.6.2.753 (#1481). +* pcan: Use select instead of polling on Linux (#1410). +* socketcan: Use ip link JSON output in ``find_available_interfaces`` (#1478). +* socketcan: Enable SocketCAN interface tests in GitHub CI (#1484). +* slcan: improve receiving performance (#1490). +* usb2can: Stop using root logger (#1483). +* usb2can: Faster channel detection on Windows (#1480). +* vector: Only check sample point instead of tseg & sjw (#1486). +* vector: add VN5611 hwtype (#1501). + +### Documentation + +* Add new section about related tools to documentation. Add a list of + plugin interface packages (#1457). + +### Bug Fixes + +* Automatic type conversion for config values (#1498, #1499). +* pcan: Fix ``Bus.__new__`` for CAN-FD interfaces (#1458, #1460). +* pcan: Fix Detection of Library on Windows on ARM (#1463). +* socketcand: extended ID bug fixes (#1504, #1508). +* vector: improve robustness against unknown HardwareType values (#1500, #1502). + +### Deprecations + +* The ``bustype`` parameter of ``can.Bus`` is deprecated and will be + removed in version 5.0, use ``interface`` instead. (#1462). +* The ``context`` parameter of ``can.Bus`` is deprecated and will be + removed in version 5.0, use ``config_context`` instead. (#1474). +* The ``bit_timing`` parameter of ``CantactBus`` is deprecated and will be + removed in version 5.0, use ``timing`` instead. (#1468). +* The ``bit_timing`` parameter of ``CANalystIIBus`` is deprecated and will be + removed in version 5.0, use ``timing`` instead. (#1468). +* The ``brs`` and ``log_errors`` parameters of `` NiXNETcanBus`` are deprecated + and will be removed in version 5.0. (#1520). + +### Miscellaneous + +* Use high resolution timer on Windows to improve + timing precision for BroadcastManager (#1449). +* Improve ThreadBasedCyclicSendTask timing (#1539). +* Make code examples executable on Linux (#1452). +* Fix CanFilter type annotation (#1456). +* Fix ``The entry_points().get`` deprecation warning and improve + type annotation of ``can.interfaces.BACKENDS`` (#1465). +* Add ``ignore_config`` parameter to ``can.Bus`` (#1474). +* Add deprecation period to utility function ``deprecated_args_alias`` (#1477). +* Add `ruff` to the CI system (#1551) + +## Version 4.1.0 + +### Breaking Changes * ``windows-curses`` was moved to optional dependencies (#1395). Use ``pip install python-can[viewer]`` if you are using the ``can.viewer`` @@ -11,11 +329,9 @@ Breaking Changes from camelCase to snake_case (#1422). -Features --------- - -### IO +### Features +#### IO * The canutils logger preserves message direction (#1244) and uses common interface names (e.g. can0) instead of just channel numbers (#1271). @@ -26,11 +342,11 @@ Features and player initialisation (#1366). * Initial support for TRC files (#1217) -### Type Annotations +#### Type Annotations * python-can now includes the ``py.typed`` marker to support type checking according to PEP 561 (#1344). -### Interface Improvements +#### Interface Improvements * The gs_usb interface can be selected by device index instead of USB bus/address. Loopback frames are now correctly marked with the ``is_rx`` flag (#1270). @@ -44,8 +360,7 @@ Features be applied according to the arguments of ``VectorBus.__init__`` (#1426). * Ixxat bus now implements BusState api and detects errors (#1141) -Bug Fixes ---------- +### Bug Fixes * Improve robustness of USB2CAN serial number detection (#1129). * Fix channel2int conversion (#1268, #1269). @@ -67,8 +382,7 @@ Bug Fixes * Raise ValueError if gzip is used with incompatible log formats (#1429). * Allow restarting of transmission tasks for socketcan (#1440) -Miscellaneous -------------- +### Miscellaneous * Allow ICSApiError to be pickled and un-pickled (#1341) * Sort interface names in CLI API to make documentation reproducible (#1342) @@ -78,8 +392,7 @@ Miscellaneous * Migrate code coverage reporting from Codecov to Coveralls (#1430) * Migrate building docs and publishing releases to PyPi from Travis-CI to GitHub Actions (#1433) -Version 4.0.0 -==== +## Version 4.0.0 TL;DR: This release includes a ton of improvements from 2.5 years of development! 🎉 Test thoroughly after switching. @@ -93,8 +406,7 @@ Therefore, users are strongly advised to thoroughly test their programs against Re-reading the documentation for your interfaces might be helpful too as limitations and capabilities might have changed or are more explicit. While we did try to avoid breaking changes, in some cases it was not feasible and in particular, many implementation details have changed. -Major features --------------- +### Major features * Type hints for the core library and some interfaces (#652 and many others) * Support for Python 3.7-3.10+ only (dropped support for Python 2.* and 3.5-3.6) (#528 and many others) @@ -102,8 +414,7 @@ Major features * [Support for automatic configuration detection](https://python-can.readthedocs.io/en/develop/api.html#can.detect_available_configs) in most interfaces (#303, #640, #641, #811, #1077, #1085) * Better alignment of interfaces and IO to common conventions and semantics -New interfaces --------------- +### New interfaces * udp_multicast (#644) * robotell (#731) @@ -114,8 +425,7 @@ New interfaces * socketcand (#1140) * etas (#1144) -Improved interfaces -------------------- +### Improved interfaces * socketcan * Support for multiple Cyclic Messages in Tasks (#610) @@ -192,8 +502,7 @@ Improved interfaces * Fix transmitting onto a busy bus (#1114) * Replace binary library with python driver (#726, #1127) -Other API changes and improvements ----------------------------------- +### Other API changes and improvements * CAN FD frame support is pretty complete (#963) * ASCWriter (#604) and ASCReader (#741) @@ -224,8 +533,7 @@ Other API changes and improvements * Add changed byte highlighting to viewer.py (#1159) * Change DLC to DL in Message.\_\_str\_\_() (#1212) -Other Bugfixes --------------- +### Other Bugfixes * BLF PDU padding (#459) * stop_all_periodic_tasks skipping every other task (#634, #637, #645) @@ -251,8 +559,7 @@ Other Bugfixes * Some smaller bugfixes are not listed here since the problems were never part of a proper release * ASCReader & ASCWriter using DLC as data length (#1245, #1246) -Behind the scenes & Quality assurance -------------------------------------- +### Behind the scenes & Quality assurance * We publish both source distributions (`sdist`) and binary wheels (`bdist_wheel`) (#1059, #1071) * Many interfaces were partly rewritten to modernize the code or to better handle errors @@ -270,16 +577,13 @@ Behind the scenes & Quality assurance * [Good test coverage](https://app.codecov.io/gh/hardbyte/python-can/branch/develop) for all but the interfaces * Testing: Many of the new features directly added tests, and coverage of existing code was improved too (for example: #1031, #581, #585, #586, #942, #1196, #1198) -Version 3.3.4 -==== +## Version 3.3.4 Last call for Python2 support. * #850 Fix socket.error is a deprecated alias of OSError used on Python versions lower than 3.3. -Version 3.3.3 -==== - +## Version 3.3.3 Backported fixes from 4.x development branch which targets Python 3. * #798 Backport caching msg.data value in neovi interface. @@ -297,37 +601,30 @@ Backported fixes from 4.x development branch which targets Python 3. * #605 Socketcan BCM status fix. -Version 3.3.2 -==== +## Version 3.3.2 Minor bug fix release addressing issue in PCAN RTR. -Version 3.3.1 -==== +## Version 3.3.1 Minor fix to setup.py to only require pytest-runner when necessary. -Version 3.3.0 -==== +## Version 3.3.0 * Adding CAN FD 64 frame support to blf reader * Updates to installation instructions * Clean up bits generator in PCAN interface #588 * Minor fix to use latest tools when building wheels on travis. -Version 3.2.1 -==== +## Version 3.2.1 * CAN FD 64 frame support to blf reader * Minor fix to use latest tools when building wheels on travis. * Updates links in documentation. -Version 3.2.0 -==== - +## Version 3.2.0 -Major features --------------- +### Major features * FD support added for Pcan by @bmeisels with input from @markuspi, @christiansandberg & @felixdivo in PR #537 @@ -335,8 +632,7 @@ Major features and Python 3.5. Support has been removed for Python 3.4 in this release in PR #532 -Other notable changes ---------------------- +### Other notable changes * #533 BusState is now an enum. * #535 This release should automatically be published to PyPi by travis. @@ -351,26 +647,21 @@ https://github.com/hardbyte/python-can/milestone/7?closed=1 Pulls: #522, #526, #527, #536, #540, #546, #547, #548, #533, #559, #569, #571, #572, #575 -Backend Specific Changes ------------------------- +### Backend Specific Changes -pcan -~~~~ +#### pcan * FD -slcan -~~~~ +#### slcan * ability to set custom can speed instead of using predefined speed values. #553 -socketcan -~~~~ +#### socketcan * Bug fix to properly support 32bit systems. #573 -usb2can -~~~~ +#### usb2can * slightly better error handling * multiple serial devices can be found @@ -378,25 +669,20 @@ usb2can Pulls #511, #535 -vector -~~~~ +#### vector * handle `app_name`. #525 -Version 3.1.1 -==== +## Version 3.1.1 -Major features --------------- +### Major features Two new interfaces this release: - SYSTEC contributed by @idaniel86 in PR #466 - CANalyst-II contributed by @smeng9 in PR #476 - -Other notable changes ---------------------- +### Other notable changes * #477 The kvaser interface now supports bus statistics via a custom bus method. * #434 neovi now supports receiving own messages @@ -413,18 +699,15 @@ Other notable changes * #455 Fix to `Message` initializer * Small bugfixes and improvements -Version 3.1.0 -==== +## Version 3.1.0 Version 3.1.0 was built with old wheel and/or setuptools packages and was replaced with v3.1.1 after an installation but was discovered. -Version 3.0.0 -==== +## Version 3.0.0 -Major features --------------- +### Major features * Adds support for developing `asyncio` applications with `python-can` more easily. This can be useful when implementing protocols that handles simultaneous connections to many nodes since you can write @@ -437,8 +720,7 @@ Major features by calling the bus's new `stop_all_periodic_tasks` method. #412 -Breaking changes ----------------- +### Breaking changes * Interfaces should no longer override `send_periodic` and instead implement `_send_periodic_internal` to allow the Bus base class to manage tasks. #426 @@ -448,8 +730,7 @@ Breaking changes read/writer constructors from `filename` to `file`. -Other notable changes ---------------------- +### Other notable changes * can.Message class updated #413 - Addition of a `Message.equals` method. @@ -481,59 +762,48 @@ Other notable changes General fixes, cleanup and docs changes: (#347, #348, #367, #368, #370, #371, #373, #420, #417, #419, #432) -Backend Specific Changes ------------------------- +### Backend Specific Changes -3rd party interfaces -~~~~~~~~~~~~~~~~~~~~ +#### 3rd party interfaces * Deprecated `python_can.interface` entry point instead use `can.interface`. #389 -neovi -~~~~~ +#### neovi * Added support for CAN-FD #408 * Fix issues checking if bus is open. #381 * Adding multiple channels support. #415 -nican -~~~~~ +#### nican * implements reset instead of custom `flush_tx_buffer`. #364 -pcan -~~~~ +#### pcan * now supported on OSX. #365 - -serial -~~~~~~ +#### serial * Removed TextIOWrapper from serial. #383 * switch to `serial_for_url` enabling using remote ports via `loop://`, ``socket://` and `rfc2217://` URLs. #393 * hardware handshake using `rtscts` kwarg #402 -socketcan -~~~~~~~~~ +#### socketcan * socketcan tasks now reuse a bcm socket #404, #425, #426, * socketcan bugfix to receive error frames #384 -vector -~~~~~~ +#### vector * Vector interface now implements `_detect_available_configs`. #362 * Added support to select device by serial number. #387 -Version 2.2.1 (2018-07-12) -===== +## Version 2.2.1 (2018-07-12) * Fix errors and warnings when importing library on Windows * Fix Vector backend raising ValueError when hardware is not connected -Version 2.2.0 (2018-06-30) -===== +## Version 2.2.0 (2018-06-30) * Fallback message filtering implemented in Python for interfaces that don't offer better accelerated mechanism. * SocketCAN interfaces have been merged (Now use `socketcan` instead of either `socketcan_native` and `socketcan_ctypes`), @@ -544,8 +814,7 @@ Version 2.2.0 (2018-06-30) * Dropped support for Python 3.3 (officially reached end-of-life in Sept. 2017) * Deprecated the old `CAN` module, please use the newer `can` entry point (will be removed in an upcoming major version) -Version 2.1.0 (2018-02-17) -===== +## Version 2.1.0 (2018-02-17) * Support for out of tree can interfaces with pluggy. * Initial support for CAN-FD for socketcan_native and kvaser interfaces. @@ -557,8 +826,7 @@ Version 2.1.0 (2018-02-17) * Other misc improvements and bug fixes -Version 2.0.0 (2018-01-05 -===== +## Version 2.0.0 (2018-01-05) After an extended baking period we have finally tagged version 2.0.0! diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c00e9bd32..2f4194b31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1 +1 @@ -Please read the [Development - Contributing](https://python-can.readthedocs.io/en/stable/development.html#contributing) guidelines in the documentation site. +Please read the [Development - Contributing](https://python-can.readthedocs.io/en/main/development.html#contributing) guidelines in the documentation site. diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index ae7792e42..524908dfc 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -81,4 +81,14 @@ Felix Nieuwenhuizen @fjburgos @pkess @felixn -@Tbruno25 \ No newline at end of file +@Tbruno25 +@RitheeshBaradwaj +@vijaysubbiah20 +@liamkinne +@RitheeshBaradwaj +@BenGardiner +@bures +@NickCao +@SWolfSchunk +@belliriccardo +@cssedev diff --git a/README.rst b/README.rst index 6e65e505c..6e75d8d7d 100644 --- a/README.rst +++ b/README.rst @@ -3,7 +3,7 @@ python-can |pypi| |conda| |python_implementation| |downloads| |downloads_monthly| -|docs| |github-actions| |build_travis| |coverage| |mergify| |formatter| +|docs| |github-actions| |coverage| |formatter| .. |pypi| image:: https://img.shields.io/pypi/v/python-can.svg :target: https://pypi.python.org/pypi/python-can/ @@ -17,11 +17,11 @@ python-can :target: https://pypi.python.org/pypi/python-can/ :alt: Supported Python implementations -.. |downloads| image:: https://pepy.tech/badge/python-can +.. |downloads| image:: https://static.pepy.tech/badge/python-can :target: https://pepy.tech/project/python-can :alt: Downloads on PePy -.. |downloads_monthly| image:: https://pepy.tech/badge/python-can/month +.. |downloads_monthly| image:: https://static.pepy.tech/badge/python-can/month :target: https://pepy.tech/project/python-can :alt: Monthly downloads on PePy @@ -37,18 +37,10 @@ python-can :target: https://github.com/hardbyte/python-can/actions/workflows/ci.yml :alt: Github Actions workflow status -.. |build_travis| image:: https://img.shields.io/travis/hardbyte/python-can/develop.svg?label=Travis%20CI - :target: https://app.travis-ci.com/github/hardbyte/python-can - :alt: Travis CI Server for develop branch - -.. |coverage| image:: https://coveralls.io/repos/github/hardbyte/python-can/badge.svg?branch=develop - :target: https://coveralls.io/github/hardbyte/python-can?branch=develop +.. |coverage| image:: https://coveralls.io/repos/github/hardbyte/python-can/badge.svg?branch=main + :target: https://coveralls.io/github/hardbyte/python-can?branch=main :alt: Test coverage reports on Coveralls.io -.. |mergify| image:: https://img.shields.io/endpoint.svg?url=https://api.mergify.com/v1/badges/hardbyte/python-can&style=flat - :target: https://mergify.io - :alt: Mergify Status - The **C**\ ontroller **A**\ rea **N**\ etwork is a bus standard designed to allow microcontrollers and devices to communicate with each other. It has priority based bus arbitration and reliable deterministic @@ -66,7 +58,10 @@ Library Version Python ------------------------------ ----------- 2.x 2.6+, 3.4+ 3.x 2.7+, 3.5+ - 4.x 3.7+ + 4.0+ 3.7+ + 4.3+ 3.8+ + 4.6+ 3.9+ + main branch 3.10+ ============================== =========== @@ -78,7 +73,7 @@ Features - receiving, sending, and periodically sending messages - normal and extended arbitration IDs - `CAN FD `__ support -- many different loggers and readers supporting playback: ASC (CANalyzer format), BLF (Binary Logging Format by Vector), TRC, CSV, SQLite, and Canutils log +- many different loggers and readers supporting playback: ASC (CANalyzer format), BLF (Binary Logging Format by Vector), MF4 (Measurement Data Format v4 by ASAM), TRC, CSV, SQLite, and Canutils log - efficient in-kernel or in-hardware filtering of messages on supported interfaces - bus configuration reading from a file or from environment variables - command line tools for working with CAN buses (see the `docs `__) diff --git a/can/__init__.py b/can/__init__.py index d9ff5ffcd..b1bd636c1 100644 --- a/can/__init__.py +++ b/can/__init__.py @@ -5,48 +5,129 @@ messages on a can bus. """ +import contextlib import logging -from typing import Dict, Any +from importlib.metadata import PackageNotFoundError, version +from typing import Any -__version__ = "4.1.0" - -log = logging.getLogger("can") - -rc: Dict[str, Any] = {} - -from .listener import Listener, BufferedReader, RedirectReader, AsyncBufferedReader +__all__ = [ + "VALID_INTERFACES", + "ASCReader", + "ASCWriter", + "AsyncBufferedReader", + "BLFReader", + "BLFWriter", + "BitTiming", + "BitTimingFd", + "BufferedReader", + "Bus", + "BusABC", + "BusState", + "CSVReader", + "CSVWriter", + "CanError", + "CanInitializationError", + "CanInterfaceNotImplementedError", + "CanOperationError", + "CanProtocol", + "CanTimeoutError", + "CanutilsLogReader", + "CanutilsLogWriter", + "CyclicSendTaskABC", + "LimitedDurationCyclicSendTaskABC", + "Listener", + "LogReader", + "Logger", + "MF4Reader", + "MF4Writer", + "Message", + "MessageSync", + "ModifiableCyclicTaskABC", + "Notifier", + "Printer", + "RedirectReader", + "RestartableCyclicTaskABC", + "SizedRotatingLogger", + "SqliteReader", + "SqliteWriter", + "TRCFileVersion", + "TRCReader", + "TRCWriter", + "ThreadSafeBus", + "bit_timing", + "broadcastmanager", + "bus", + "ctypesutil", + "detect_available_configs", + "exceptions", + "interface", + "interfaces", + "io", + "listener", + "log", + "logconvert", + "logger", + "message", + "notifier", + "player", + "set_logging_level", + "thread_safe_bus", + "typechecking", + "util", + "viewer", +] +from . import typechecking # isort:skip +from . import util # isort:skip +from . import broadcastmanager, interface +from .bit_timing import BitTiming, BitTimingFd +from .broadcastmanager import ( + CyclicSendTaskABC, + LimitedDurationCyclicSendTaskABC, + ModifiableCyclicTaskABC, + RestartableCyclicTaskABC, +) +from .bus import BusABC, BusState, CanProtocol from .exceptions import ( CanError, - CanInterfaceNotImplementedError, CanInitializationError, + CanInterfaceNotImplementedError, CanOperationError, CanTimeoutError, ) - -from .util import set_logging_level - +from .interface import Bus, detect_available_configs +from .interfaces import VALID_INTERFACES +from .io import ( + ASCReader, + ASCWriter, + BLFReader, + BLFWriter, + CanutilsLogReader, + CanutilsLogWriter, + CSVReader, + CSVWriter, + Logger, + LogReader, + MessageSync, + MF4Reader, + MF4Writer, + Printer, + SizedRotatingLogger, + SqliteReader, + SqliteWriter, + TRCFileVersion, + TRCReader, + TRCWriter, +) +from .listener import AsyncBufferedReader, BufferedReader, Listener, RedirectReader from .message import Message -from .bus import BusABC, BusState -from .thread_safe_bus import ThreadSafeBus from .notifier import Notifier -from .interfaces import VALID_INTERFACES -from . import interface -from .interface import Bus, detect_available_configs -from .bit_timing import BitTiming, BitTimingFd +from .thread_safe_bus import ThreadSafeBus +from .util import set_logging_level -from .io import Logger, SizedRotatingLogger, Printer, LogReader, MessageSync -from .io import ASCWriter, ASCReader -from .io import BLFReader, BLFWriter -from .io import CanutilsLogReader, CanutilsLogWriter -from .io import CSVWriter, CSVReader -from .io import SqliteWriter, SqliteReader -from .io import TRCReader, TRCWriter, TRCFileVersion +with contextlib.suppress(PackageNotFoundError): + __version__ = version("python-can") -from .broadcastmanager import ( - CyclicSendTaskABC, - LimitedDurationCyclicSendTaskABC, - ModifiableCyclicTaskABC, - MultiRateCyclicSendTaskABC, - RestartableCyclicTaskABC, -) +log = logging.getLogger("can") + +rc: dict[str, Any] = {} diff --git a/can/_entry_points.py b/can/_entry_points.py new file mode 100644 index 000000000..fd1a62d24 --- /dev/null +++ b/can/_entry_points.py @@ -0,0 +1,21 @@ +import importlib +from dataclasses import dataclass +from importlib.metadata import entry_points +from typing import Any + + +@dataclass +class _EntryPoint: + key: str + module_name: str + class_name: str + + def load(self) -> Any: + module = importlib.import_module(self.module_name) + return getattr(module, self.class_name) + + +def read_entry_points(group: str) -> list[_EntryPoint]: + return [ + _EntryPoint(ep.name, ep.module, ep.attr) for ep in entry_points(group=group) + ] diff --git a/can/bit_timing.py b/can/bit_timing.py index 4fada145b..2bb04bfbe 100644 --- a/can/bit_timing.py +++ b/can/bit_timing.py @@ -1,11 +1,13 @@ # pylint: disable=too-many-lines import math -from typing import List, Mapping, Iterator, cast +from collections.abc import Iterator, Mapping +from typing import TYPE_CHECKING, cast -from can.typechecking import BitTimingFdDict, BitTimingDict +if TYPE_CHECKING: + from can.typechecking import BitTimingDict, BitTimingFdDict -class BitTiming(Mapping): +class BitTiming(Mapping[str, int]): """Representation of a bit timing configuration for a CAN 2.0 bus. The class can be constructed in multiple ways, depending on the information @@ -36,6 +38,7 @@ def __init__( tseg2: int, sjw: int, nof_samples: int = 1, + strict: bool = False, ) -> None: """ :param int f_clock: @@ -56,6 +59,10 @@ def __init__( In this case, the bit will be sampled three quanta in a row, with the last sample being taken in the edge between TSEG1 and TSEG2. Three samples should only be used for relatively slow baudrates. + :param bool strict: + If True, restrict bit timings to the minimum required range as defined in + ISO 11898. This can be used to ensure compatibility across a wide variety + of CAN hardware. :raises ValueError: if the arguments are invalid. """ @@ -68,19 +75,13 @@ def __init__( "nof_samples": nof_samples, } self._validate() + if strict: + self._restrict_to_minimum_range() def _validate(self) -> None: - if not 8 <= self.nbt <= 25: - raise ValueError(f"nominal bit time (={self.nbt}) must be in [8...25].") - if not 1 <= self.brp <= 64: raise ValueError(f"bitrate prescaler (={self.brp}) must be in [1...64].") - if not 5_000 <= self.bitrate <= 2_000_000: - raise ValueError( - f"bitrate (={self.bitrate}) must be in [5,000...2,000,000]." - ) - if not 1 <= self.tseg1 <= 16: raise ValueError(f"tseg1 (={self.tseg1}) must be in [1...16].") @@ -104,6 +105,18 @@ def _validate(self) -> None: if self.nof_samples not in (1, 3): raise ValueError("nof_samples must be 1 or 3") + def _restrict_to_minimum_range(self) -> None: + if not 8 <= self.nbt <= 25: + raise ValueError(f"nominal bit time (={self.nbt}) must be in [8...25].") + + if not 1 <= self.brp <= 32: + raise ValueError(f"bitrate prescaler (={self.brp}) must be in [1...32].") + + if not 5_000 <= self.bitrate <= 1_000_000: + raise ValueError( + f"bitrate (={self.bitrate}) must be in [5,000...1,000,000]." + ) + @classmethod def from_bitrate_and_segments( cls, @@ -113,6 +126,7 @@ def from_bitrate_and_segments( tseg2: int, sjw: int, nof_samples: int = 1, + strict: bool = False, ) -> "BitTiming": """Create a :class:`~can.BitTiming` instance from bitrate and segment lengths. @@ -134,11 +148,15 @@ def from_bitrate_and_segments( In this case, the bit will be sampled three quanta in a row, with the last sample being taken in the edge between TSEG1 and TSEG2. Three samples should only be used for relatively slow baudrates. + :param bool strict: + If True, restrict bit timings to the minimum required range as defined in + ISO 11898. This can be used to ensure compatibility across a wide variety + of CAN hardware. :raises ValueError: if the arguments are invalid. """ try: - brp = int(round(f_clock / (bitrate * (1 + tseg1 + tseg2)))) + brp = round(f_clock / (bitrate * (1 + tseg1 + tseg2))) except ZeroDivisionError: raise ValueError("Invalid inputs") from None @@ -149,6 +167,7 @@ def from_bitrate_and_segments( tseg2=tseg2, sjw=sjw, nof_samples=nof_samples, + strict=strict, ) if abs(bt.bitrate - bitrate) > bitrate / 256: raise ValueError( @@ -175,6 +194,11 @@ def from_registers( :raises ValueError: if the arguments are invalid. """ + if not 0 <= btr0 < 2**16: + raise ValueError(f"Invalid btr0 value. ({btr0})") + if not 0 <= btr1 < 2**16: + raise ValueError(f"Invalid btr1 value. ({btr1})") + brp = (btr0 & 0x3F) + 1 sjw = (btr0 >> 6) + 1 tseg1 = (btr1 & 0xF) + 1 @@ -190,17 +214,10 @@ def from_registers( ) @classmethod - def from_sample_point( + def iterate_from_sample_point( cls, f_clock: int, bitrate: int, sample_point: float = 69.0 - ) -> "BitTiming": - """Create a :class:`~can.BitTiming` instance for a sample point. - - This function tries to find bit timings, which are close to the requested - sample point. It does not take physical bus properties into account, so the - calculated bus timings might not work properly for you. - - The :func:`oscillator_tolerance` function might be helpful to evaluate the - bus timings. + ) -> Iterator["BitTiming"]: + """Create a :class:`~can.BitTiming` iterator with all the solutions for a sample point. :param int f_clock: The CAN system clock frequency in Hz. @@ -215,9 +232,8 @@ def from_sample_point( if sample_point < 50.0: raise ValueError(f"sample_point (={sample_point}) must not be below 50%.") - possible_solutions: List[BitTiming] = [] for brp in range(1, 65): - nbt = round(int(f_clock / (bitrate * brp))) + nbt = int(f_clock / (bitrate * brp)) if nbt < 8: break @@ -225,7 +241,7 @@ def from_sample_point( if abs(effective_bitrate - bitrate) > bitrate / 256: continue - tseg1 = int(round(sample_point / 100 * nbt)) - 1 + tseg1 = round(sample_point / 100 * nbt) - 1 # limit tseg1, so tseg2 is at least 1 TQ tseg1 = min(tseg1, nbt - 2) @@ -239,11 +255,42 @@ def from_sample_point( tseg1=tseg1, tseg2=tseg2, sjw=sjw, + strict=True, ) - possible_solutions.append(bt) + yield bt except ValueError: continue + @classmethod + def from_sample_point( + cls, f_clock: int, bitrate: int, sample_point: float = 69.0 + ) -> "BitTiming": + """Create a :class:`~can.BitTiming` instance for a sample point. + + This function tries to find bit timings, which are close to the requested + sample point. It does not take physical bus properties into account, so the + calculated bus timings might not work properly for you. + + The :func:`oscillator_tolerance` function might be helpful to evaluate the + bus timings. + + :param int f_clock: + The CAN system clock frequency in Hz. + :param int bitrate: + Bitrate in bit/s. + :param int sample_point: + The sample point value in percent. + :raises ValueError: + if the arguments are invalid. + """ + + if sample_point < 50.0: + raise ValueError(f"sample_point (={sample_point}) must not be below 50%.") + + possible_solutions: list[BitTiming] = list( + cls.iterate_from_sample_point(f_clock, bitrate, sample_point) + ) + if not possible_solutions: raise ValueError("No suitable bit timings found.") @@ -266,7 +313,7 @@ def f_clock(self) -> int: @property def bitrate(self) -> int: """Bitrate in bits/s.""" - return int(round(self.f_clock / (self.nbt * self.brp))) + return round(self.f_clock / (self.nbt * self.brp)) @property def brp(self) -> int: @@ -276,7 +323,7 @@ def brp(self) -> int: @property def tq(self) -> int: """Time quantum in nanoseconds""" - return int(round(self.brp / self.f_clock * 1e9)) + return round(self.brp / self.f_clock * 1e9) @property def nbt(self) -> int: @@ -316,12 +363,12 @@ def sample_point(self) -> float: @property def btr0(self) -> int: - """Bit timing register 0.""" + """Bit timing register 0 for SJA1000.""" return (self.sjw - 1) << 6 | self.brp - 1 @property def btr1(self) -> int: - """Bit timing register 1.""" + """Bit timing register 1 for SJA1000.""" sam = 1 if self.nof_samples == 3 else 0 return sam << 7 | (self.tseg2 - 1) << 4 | self.tseg1 - 1 @@ -373,6 +420,7 @@ def recreate_with_f_clock(self, f_clock: int) -> "BitTiming": tseg2=self.tseg2, sjw=self.sjw, nof_samples=self.nof_samples, + strict=True, ) except ValueError: pass @@ -386,7 +434,7 @@ def recreate_with_f_clock(self, f_clock: int) -> "BitTiming": "f_clock change failed because of sample point discrepancy." ) # adapt synchronization jump width, so it has the same size relative to bit time as self - sjw = int(round(self.sjw / self.nbt * bt.nbt)) + sjw = round(self.sjw / self.nbt * bt.nbt) sjw = max(1, min(4, bt.tseg2, sjw)) bt._data["sjw"] = sjw # pylint: disable=protected-access bt._data["nof_samples"] = self.nof_samples # pylint: disable=protected-access @@ -411,7 +459,7 @@ def __repr__(self) -> str: return f"can.{self.__class__.__name__}({args})" def __getitem__(self, key: str) -> int: - return cast(int, self._data.__getitem__(key)) + return cast("int", self._data.__getitem__(key)) def __len__(self) -> int: return self._data.__len__() @@ -429,7 +477,7 @@ def __hash__(self) -> int: return tuple(self._data.values()).__hash__() -class BitTimingFd(Mapping): +class BitTimingFd(Mapping[str, int]): """Representation of a bit timing configuration for a CAN FD bus. The class can be constructed in multiple ways, depending on the information @@ -474,7 +522,7 @@ class BitTimingFd(Mapping): ) """ - def __init__( + def __init__( # pylint: disable=too-many-arguments self, f_clock: int, nom_brp: int, @@ -485,6 +533,7 @@ def __init__( data_tseg1: int, data_tseg2: int, data_sjw: int, + strict: bool = False, ) -> None: """ Initialize a BitTimingFd instance with the specified parameters. @@ -513,6 +562,10 @@ def __init__( :param int data_sjw: The Synchronization Jump Width for the data phase. This value determines the maximum number of time quanta that the controller can resynchronize every bit. + :param bool strict: + If True, restrict bit timings to the minimum required range as defined in + ISO 11898. This can be used to ensure compatibility across a wide variety + of CAN hardware. :raises ValueError: if the arguments are invalid. """ @@ -528,32 +581,23 @@ def __init__( "data_sjw": data_sjw, } self._validate() + if strict: + self._restrict_to_minimum_range() def _validate(self) -> None: - if self.nbt < 8: - raise ValueError(f"nominal bit time (={self.nbt}) must be at least 8.") - - if self.dbt < 8: - raise ValueError(f"data bit time (={self.dbt}) must be at least 8.") - - if not 1 <= self.nom_brp <= 256: - raise ValueError( - f"nominal bitrate prescaler (={self.nom_brp}) must be in [1...256]." - ) - - if not 1 <= self.data_brp <= 256: - raise ValueError( - f"data bitrate prescaler (={self.data_brp}) must be in [1...256]." - ) + for param, value in self._data.items(): + if value < 0: # type: ignore[operator] + err_msg = f"'{param}' (={value}) must not be negative." + raise ValueError(err_msg) - if not 5_000 <= self.nom_bitrate <= 2_000_000: + if self.nom_brp < 1: raise ValueError( - f"nom_bitrate (={self.nom_bitrate}) must be in [5,000...2,000,000]." + f"nominal bitrate prescaler (={self.nom_brp}) must be at least 1." ) - if not 25_000 <= self.data_bitrate <= 8_000_000: + if self.data_brp < 1: raise ValueError( - f"data_bitrate (={self.data_bitrate}) must be in [25,000...8,000,000]." + f"data bitrate prescaler (={self.data_brp}) must be at least 1." ) if self.data_bitrate < self.nom_bitrate: @@ -562,30 +606,12 @@ def _validate(self) -> None: f"equal to nom_bitrate (={self.nom_bitrate})" ) - if not 2 <= self.nom_tseg1 <= 256: - raise ValueError(f"nom_tseg1 (={self.nom_tseg1}) must be in [2...256].") - - if not 1 <= self.nom_tseg2 <= 128: - raise ValueError(f"nom_tseg2 (={self.nom_tseg2}) must be in [1...128].") - - if not 1 <= self.data_tseg1 <= 32: - raise ValueError(f"data_tseg1 (={self.data_tseg1}) must be in [1...32].") - - if not 1 <= self.data_tseg2 <= 16: - raise ValueError(f"data_tseg2 (={self.data_tseg2}) must be in [1...16].") - - if not 1 <= self.nom_sjw <= 128: - raise ValueError(f"nom_sjw (={self.nom_sjw}) must be in [1...128].") - if self.nom_sjw > self.nom_tseg2: raise ValueError( f"nom_sjw (={self.nom_sjw}) must not be " f"greater than nom_tseg2 (={self.nom_tseg2})." ) - if not 1 <= self.data_sjw <= 16: - raise ValueError(f"data_sjw (={self.data_sjw}) must be in [1...128].") - if self.data_sjw > self.data_tseg2: raise ValueError( f"data_sjw (={self.data_sjw}) must not be " @@ -604,8 +630,46 @@ def _validate(self) -> None: f"(data_sample_point={self.data_sample_point:.2f}%)." ) + def _restrict_to_minimum_range(self) -> None: + # restrict to minimum required range as defined in ISO 11898 + if not 8 <= self.nbt <= 80: + raise ValueError(f"Nominal bit time (={self.nbt}) must be in [8...80]") + + if not 5 <= self.dbt <= 25: + raise ValueError(f"Nominal bit time (={self.dbt}) must be in [5...25]") + + if not 1 <= self.data_tseg1 <= 16: + raise ValueError(f"data_tseg1 (={self.data_tseg1}) must be in [1...16].") + + if not 2 <= self.data_tseg2 <= 8: + raise ValueError(f"data_tseg2 (={self.data_tseg2}) must be in [2...8].") + + if not 1 <= self.data_sjw <= 8: + raise ValueError(f"data_sjw (={self.data_sjw}) must be in [1...8].") + + if self.nom_brp == self.data_brp: + # shared prescaler + if not 2 <= self.nom_tseg1 <= 128: + raise ValueError(f"nom_tseg1 (={self.nom_tseg1}) must be in [2...128].") + + if not 2 <= self.nom_tseg2 <= 32: + raise ValueError(f"nom_tseg2 (={self.nom_tseg2}) must be in [2...32].") + + if not 1 <= self.nom_sjw <= 32: + raise ValueError(f"nom_sjw (={self.nom_sjw}) must be in [1...32].") + else: + # separate prescaler + if not 2 <= self.nom_tseg1 <= 64: + raise ValueError(f"nom_tseg1 (={self.nom_tseg1}) must be in [2...64].") + + if not 2 <= self.nom_tseg2 <= 16: + raise ValueError(f"nom_tseg2 (={self.nom_tseg2}) must be in [2...16].") + + if not 1 <= self.nom_sjw <= 16: + raise ValueError(f"nom_sjw (={self.nom_sjw}) must be in [1...16].") + @classmethod - def from_bitrate_and_segments( + def from_bitrate_and_segments( # pylint: disable=too-many-arguments cls, f_clock: int, nom_bitrate: int, @@ -616,6 +680,7 @@ def from_bitrate_and_segments( data_tseg1: int, data_tseg2: int, data_sjw: int, + strict: bool = False, ) -> "BitTimingFd": """ Create a :class:`~can.BitTimingFd` instance with the bitrates and segments lengths. @@ -644,14 +709,16 @@ def from_bitrate_and_segments( :param int data_sjw: The Synchronization Jump Width for the data phase. This value determines the maximum number of time quanta that the controller can resynchronize every bit. + :param bool strict: + If True, restrict bit timings to the minimum required range as defined in + ISO 11898. This can be used to ensure compatibility across a wide variety + of CAN hardware. :raises ValueError: if the arguments are invalid. """ try: - nom_brp = int(round(f_clock / (nom_bitrate * (1 + nom_tseg1 + nom_tseg2)))) - data_brp = int( - round(f_clock / (data_bitrate * (1 + data_tseg1 + data_tseg2))) - ) + nom_brp = round(f_clock / (nom_bitrate * (1 + nom_tseg1 + nom_tseg2))) + data_brp = round(f_clock / (data_bitrate * (1 + data_tseg1 + data_tseg2))) except ZeroDivisionError: raise ValueError("Invalid inputs.") from None @@ -665,6 +732,7 @@ def from_bitrate_and_segments( data_tseg1=data_tseg1, data_tseg2=data_tseg2, data_sjw=data_sjw, + strict=strict, ) if abs(bt.nom_bitrate - nom_bitrate) > nom_bitrate / 256: @@ -682,22 +750,15 @@ def from_bitrate_and_segments( return bt @classmethod - def from_sample_point( + def iterate_from_sample_point( cls, f_clock: int, nom_bitrate: int, nom_sample_point: float, data_bitrate: int, data_sample_point: float, - ) -> "BitTimingFd": - """Create a :class:`~can.BitTimingFd` instance for a given nominal/data sample point pair. - - This function tries to find bit timings, which are close to the requested - sample points. It does not take physical bus properties into account, so the - calculated bus timings might not work properly for you. - - The :func:`oscillator_tolerance` function might be helpful to evaluate the - bus timings. + ) -> Iterator["BitTimingFd"]: + """Create an :class:`~can.BitTimingFd` iterator with all the solutions for a sample point. :param int f_clock: The CAN system clock frequency in Hz. @@ -722,36 +783,36 @@ def from_sample_point( f"data_sample_point (={data_sample_point}) must not be below 50%." ) - possible_solutions: List[BitTimingFd] = [] + sync_seg = 1 for nom_brp in range(1, 257): - nbt = round(int(f_clock / (nom_bitrate * nom_brp))) - if nbt < 8: + nbt = int(f_clock / (nom_bitrate * nom_brp)) + if nbt < 1: break effective_nom_bitrate = f_clock / (nbt * nom_brp) if abs(effective_nom_bitrate - nom_bitrate) > nom_bitrate / 256: continue - nom_tseg1 = int(round(nom_sample_point / 100 * nbt)) - 1 - # limit tseg1, so tseg2 is at least 1 TQ - nom_tseg1 = min(nom_tseg1, nbt - 2) + nom_tseg1 = round(nom_sample_point / 100 * nbt) - 1 + # limit tseg1, so tseg2 is at least 2 TQ + nom_tseg1 = min(nom_tseg1, nbt - sync_seg - 2) nom_tseg2 = nbt - nom_tseg1 - 1 nom_sjw = min(nom_tseg2, 128) for data_brp in range(1, 257): dbt = round(int(f_clock / (data_bitrate * data_brp))) - if dbt < 8: + if dbt < 1: break effective_data_bitrate = f_clock / (dbt * data_brp) if abs(effective_data_bitrate - data_bitrate) > data_bitrate / 256: continue - data_tseg1 = int(round(data_sample_point / 100 * dbt)) - 1 - # limit tseg1, so tseg2 is at least 1 TQ - data_tseg1 = min(data_tseg1, dbt - 2) + data_tseg1 = round(data_sample_point / 100 * dbt) - 1 + # limit tseg1, so tseg2 is at least 2 TQ + data_tseg1 = min(data_tseg1, dbt - sync_seg - 2) data_tseg2 = dbt - data_tseg1 - 1 data_sjw = min(data_tseg2, 16) @@ -767,11 +828,63 @@ def from_sample_point( data_tseg1=data_tseg1, data_tseg2=data_tseg2, data_sjw=data_sjw, + strict=True, ) - possible_solutions.append(bt) + yield bt except ValueError: continue + @classmethod + def from_sample_point( + cls, + f_clock: int, + nom_bitrate: int, + nom_sample_point: float, + data_bitrate: int, + data_sample_point: float, + ) -> "BitTimingFd": + """Create a :class:`~can.BitTimingFd` instance for a sample point. + + This function tries to find bit timings, which are close to the requested + sample points. It does not take physical bus properties into account, so the + calculated bus timings might not work properly for you. + + The :func:`oscillator_tolerance` function might be helpful to evaluate the + bus timings. + + :param int f_clock: + The CAN system clock frequency in Hz. + :param int nom_bitrate: + Nominal bitrate in bit/s. + :param int nom_sample_point: + The sample point value of the arbitration phase in percent. + :param int data_bitrate: + Data bitrate in bit/s. + :param int data_sample_point: + The sample point value of the data phase in percent. + :raises ValueError: + if the arguments are invalid. + """ + if nom_sample_point < 50.0: + raise ValueError( + f"nom_sample_point (={nom_sample_point}) must not be below 50%." + ) + + if data_sample_point < 50.0: + raise ValueError( + f"data_sample_point (={data_sample_point}) must not be below 50%." + ) + + possible_solutions: list[BitTimingFd] = list( + cls.iterate_from_sample_point( + f_clock, + nom_bitrate, + nom_sample_point, + data_bitrate, + data_sample_point, + ) + ) + if not possible_solutions: raise ValueError("No suitable bit timings found.") @@ -809,7 +922,7 @@ def f_clock(self) -> int: @property def nom_bitrate(self) -> int: """Nominal (arbitration phase) bitrate.""" - return int(round(self.f_clock / (self.nbt * self.nom_brp))) + return round(self.f_clock / (self.nbt * self.nom_brp)) @property def nom_brp(self) -> int: @@ -819,7 +932,7 @@ def nom_brp(self) -> int: @property def nom_tq(self) -> int: """Nominal time quantum in nanoseconds""" - return int(round(self.nom_brp / self.f_clock * 1e9)) + return round(self.nom_brp / self.f_clock * 1e9) @property def nbt(self) -> int: @@ -855,7 +968,7 @@ def nom_sample_point(self) -> float: @property def data_bitrate(self) -> int: """Bitrate of the data phase in bit/s.""" - return int(round(self.f_clock / (self.dbt * self.data_brp))) + return round(self.f_clock / (self.dbt * self.data_brp)) @property def data_brp(self) -> int: @@ -865,7 +978,7 @@ def data_brp(self) -> int: @property def data_tq(self) -> int: """Data time quantum in nanoseconds""" - return int(round(self.data_brp / self.f_clock * 1e9)) + return round(self.data_brp / self.f_clock * 1e9) @property def dbt(self) -> int: @@ -971,6 +1084,7 @@ def recreate_with_f_clock(self, f_clock: int) -> "BitTimingFd": data_tseg1=self.data_tseg1, data_tseg2=self.data_tseg2, data_sjw=self.data_sjw, + strict=True, ) except ValueError: pass @@ -991,10 +1105,10 @@ def recreate_with_f_clock(self, f_clock: int) -> "BitTimingFd": "f_clock change failed because of sample point discrepancy." ) # adapt synchronization jump width, so it has the same size relative to bit time as self - nom_sjw = int(round(self.nom_sjw / self.nbt * bt.nbt)) + nom_sjw = round(self.nom_sjw / self.nbt * bt.nbt) nom_sjw = max(1, min(bt.nom_tseg2, nom_sjw)) bt._data["nom_sjw"] = nom_sjw # pylint: disable=protected-access - data_sjw = int(round(self.data_sjw / self.dbt * bt.dbt)) + data_sjw = round(self.data_sjw / self.dbt * bt.dbt) data_sjw = max(1, min(bt.data_tseg2, data_sjw)) bt._data["data_sjw"] = data_sjw # pylint: disable=protected-access bt._validate() # pylint: disable=protected-access @@ -1023,7 +1137,7 @@ def __repr__(self) -> str: return f"can.{self.__class__.__name__}({args})" def __getitem__(self, key: str) -> int: - return cast(int, self._data.__getitem__(key)) + return cast("int", self._data.__getitem__(key)) def __len__(self) -> int: return self._data.__len__() diff --git a/can/bridge.py b/can/bridge.py new file mode 100644 index 000000000..57ebb368d --- /dev/null +++ b/can/bridge.py @@ -0,0 +1,66 @@ +""" +Creates a bridge between two CAN buses. + +This will connect to two CAN buses. Messages received on one +bus will be sent to the other bus and vice versa. +""" + +import argparse +import errno +import sys +import time +from datetime import datetime +from typing import Final + +from can.cli import add_bus_arguments, create_bus_from_namespace +from can.listener import RedirectReader +from can.notifier import Notifier + +BRIDGE_DESCRIPTION: Final = """\ +Bridge two CAN buses. + +Both can buses will be connected so that messages from bus1 will be sent on +bus2 and messages from bus2 will be sent to bus1. +""" +BUS_1_PREFIX: Final = "bus1" +BUS_2_PREFIX: Final = "bus2" + + +def _parse_bridge_args(args: list[str]) -> argparse.Namespace: + """Parse command line arguments for bridge script.""" + + parser = argparse.ArgumentParser(description=BRIDGE_DESCRIPTION) + add_bus_arguments(parser, prefix=BUS_1_PREFIX, group_title="Bus 1 arguments") + add_bus_arguments(parser, prefix=BUS_2_PREFIX, group_title="Bus 2 arguments") + + # print help message when no arguments were given + if not args: + parser.print_help(sys.stderr) + raise SystemExit(errno.EINVAL) + + results, _unknown_args = parser.parse_known_args(args) + return results + + +def main() -> None: + results = _parse_bridge_args(sys.argv[1:]) + + with ( + create_bus_from_namespace(results, prefix=BUS_1_PREFIX) as bus1, + create_bus_from_namespace(results, prefix=BUS_2_PREFIX) as bus2, + ): + reader1_to_2 = RedirectReader(bus2) + reader2_to_1 = RedirectReader(bus1) + with Notifier(bus1, [reader1_to_2]), Notifier(bus2, [reader2_to_1]): + print(f"CAN Bridge (Started on {datetime.now()})") + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + pass + + print(f"CAN Bridge (Stopped on {datetime.now()})") + + +if __name__ == "__main__": + main() diff --git a/can/broadcastmanager.py b/can/broadcastmanager.py index cfa4c658d..1fea9ac50 100644 --- a/can/broadcastmanager.py +++ b/can/broadcastmanager.py @@ -5,29 +5,82 @@ :meth:`can.BusABC.send_periodic`. """ -from typing import Optional, Sequence, Tuple, Union, Callable, TYPE_CHECKING +import abc +import logging +import platform +import sys +import threading +import time +import warnings +from collections.abc import Callable, Sequence +from typing import ( + TYPE_CHECKING, + Final, + cast, +) from can import typechecking +from can.message import Message if TYPE_CHECKING: from can.bus import BusABC -from can.message import Message -import abc -import logging -import threading -import time +log = logging.getLogger("can.bcm") +NANOSECONDS_IN_SECOND: Final[int] = 1_000_000_000 -# try to import win32event for event-based cyclic send task (needs the pywin32 package) -try: - import win32event - HAS_EVENTS = True -except ImportError: - HAS_EVENTS = False +class _Pywin32Event: + handle: int -log = logging.getLogger("can.bcm") + +class _Pywin32: + def __init__(self) -> None: + import pywintypes # noqa: PLC0415 # pylint: disable=import-outside-toplevel,import-error + import win32event # noqa: PLC0415 # pylint: disable=import-outside-toplevel,import-error + + self.pywintypes = pywintypes + self.win32event = win32event + + def create_timer(self) -> _Pywin32Event: + try: + event = self.win32event.CreateWaitableTimerEx( + None, + None, + self.win32event.CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, + self.win32event.TIMER_ALL_ACCESS, + ) + except ( + AttributeError, + OSError, + self.pywintypes.error, # pylint: disable=no-member + ): + event = self.win32event.CreateWaitableTimer(None, False, None) + + return cast("_Pywin32Event", event) + + def set_timer(self, event: _Pywin32Event, period_ms: int) -> None: + self.win32event.SetWaitableTimer(event.handle, 0, period_ms, None, None, False) + + def stop_timer(self, event: _Pywin32Event) -> None: + self.win32event.SetWaitableTimer(event.handle, 0, 0, None, None, False) + + def wait_0(self, event: _Pywin32Event) -> None: + self.win32event.WaitForSingleObject(event.handle, 0) + + def wait_inf(self, event: _Pywin32Event) -> None: + self.win32event.WaitForSingleObject( + event.handle, + self.win32event.INFINITE, + ) + + +PYWIN32: _Pywin32 | None = None +if sys.platform == "win32" and sys.version_info < (3, 11): + try: + PYWIN32 = _Pywin32() + except ImportError: + pass class CyclicTask(abc.ABC): @@ -44,14 +97,12 @@ def stop(self) -> None: """ -class CyclicSendTaskABC(CyclicTask): +class CyclicSendTaskABC(CyclicTask, abc.ABC): """ Message send task with defined period """ - def __init__( - self, messages: Union[Sequence[Message], Message], period: float - ) -> None: + def __init__(self, messages: Sequence[Message] | Message, period: float) -> None: """ :param messages: The messages to be sent periodically. @@ -64,12 +115,13 @@ def __init__( # Take the Arbitration ID of the first element self.arbitration_id = messages[0].arbitration_id self.period = period + self.period_ns = round(period * 1e9) self.messages = messages @staticmethod def _check_and_convert_messages( - messages: Union[Sequence[Message], Message] - ) -> Tuple[Message, ...]: + messages: Sequence[Message] | Message, + ) -> tuple[Message, ...]: """Helper function to convert a Message or Sequence of messages into a tuple, and raises an error when the given value is invalid. @@ -104,12 +156,12 @@ def _check_and_convert_messages( return messages -class LimitedDurationCyclicSendTaskABC(CyclicSendTaskABC): +class LimitedDurationCyclicSendTaskABC(CyclicSendTaskABC, abc.ABC): def __init__( self, - messages: Union[Sequence[Message], Message], + messages: Sequence[Message] | Message, period: float, - duration: Optional[float], + duration: float | None, ) -> None: """Message send task with a defined duration and period. @@ -124,9 +176,10 @@ def __init__( """ super().__init__(messages, period) self.duration = duration + self.end_time: float | None = None -class RestartableCyclicTaskABC(CyclicSendTaskABC): +class RestartableCyclicTaskABC(CyclicSendTaskABC, abc.ABC): """Adds support for restarting a stopped cyclic task""" @abc.abstractmethod @@ -134,10 +187,8 @@ def start(self) -> None: """Restart a stopped periodic task.""" -class ModifiableCyclicTaskABC(CyclicSendTaskABC): - """Adds support for modifying a periodic message""" - - def _check_modified_messages(self, messages: Tuple[Message, ...]) -> None: +class ModifiableCyclicTaskABC(CyclicSendTaskABC, abc.ABC): + def _check_modified_messages(self, messages: tuple[Message, ...]) -> None: """Helper function to perform error checking when modifying the data in the cyclic task. @@ -159,7 +210,7 @@ def _check_modified_messages(self, messages: Tuple[Message, ...]) -> None: "from when the task was created" ) - def modify_data(self, messages: Union[Sequence[Message], Message]) -> None: + def modify_data(self, messages: Sequence[Message] | Message) -> None: """Update the contents of the periodically sent messages, without altering the timing. @@ -180,13 +231,13 @@ def modify_data(self, messages: Union[Sequence[Message], Message]) -> None: self.messages = messages -class MultiRateCyclicSendTaskABC(CyclicSendTaskABC): +class MultiRateCyclicSendTaskABC(CyclicSendTaskABC, abc.ABC): """A Cyclic send task that supports switches send frequency after a set time.""" def __init__( self, channel: typechecking.Channel, - messages: Union[Sequence[Message], Message], + messages: Sequence[Message] | Message, count: int, # pylint: disable=unused-argument initial_period: float, # pylint: disable=unused-argument subsequent_period: float, @@ -208,7 +259,7 @@ def __init__( class ThreadBasedCyclicSendTask( - ModifiableCyclicTaskABC, LimitedDurationCyclicSendTaskABC, RestartableCyclicTaskABC + LimitedDurationCyclicSendTaskABC, ModifiableCyclicTaskABC, RestartableCyclicTaskABC ): """Fallback cyclic send task using daemon thread.""" @@ -216,10 +267,12 @@ def __init__( self, bus: "BusABC", lock: threading.Lock, - messages: Union[Sequence[Message], Message], + messages: Sequence[Message] | Message, period: float, - duration: Optional[float] = None, - on_error: Optional[Callable[[Exception], bool]] = None, + duration: float | None = None, + on_error: Callable[[Exception], bool] | None = None, + autostart: bool = True, + modifier_callback: Callable[[Message], None] | None = None, ) -> None: """Transmits `messages` with a `period` seconds for `duration` seconds on a `bus`. @@ -240,30 +293,38 @@ def __init__( self.bus = bus self.send_lock = lock self.stopped = True - self.thread: Optional[threading.Thread] = None - self.end_time: Optional[float] = ( - time.perf_counter() + duration if duration else None - ) + self.thread: threading.Thread | None = None self.on_error = on_error + self.modifier_callback = modifier_callback + + self.period_ms = int(round(period * 1000, 0)) + + self.event: _Pywin32Event | None = None + if PYWIN32: + if self.period_ms == 0: + # A period of 0 would mean that the timer is signaled only once + raise ValueError("The period cannot be smaller than 0.001 (1 ms)") + self.event = PYWIN32.create_timer() + elif ( + sys.platform == "win32" + and sys.version_info < (3, 11) + and platform.python_implementation() == "CPython" + ): + warnings.warn( + f"{self.__class__.__name__} may achieve better timing accuracy " + f"if the 'pywin32' package is installed.", + RuntimeWarning, + stacklevel=1, + ) - if HAS_EVENTS: - self.period_ms = int(round(period * 1000, 0)) - try: - self.event = win32event.CreateWaitableTimerEx( - None, - None, - win32event.CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, - win32event.TIMER_ALL_ACCESS, - ) - except (AttributeError, OSError): - self.event = win32event.CreateWaitableTimer(None, False, None) - - self.start() + if autostart: + self.start() def stop(self) -> None: - if HAS_EVENTS: - win32event.CancelWaitableTimer(self.event.handle) self.stopped = True + if self.event and PYWIN32: + # Reset and signal any pending wait by setting the timer to 0 + PYWIN32.stop_timer(self.event) def start(self) -> None: self.stopped = False @@ -272,35 +333,56 @@ def start(self) -> None: self.thread = threading.Thread(target=self._run, name=name) self.thread.daemon = True - if HAS_EVENTS: - win32event.SetWaitableTimer( - self.event.handle, 0, self.period_ms, None, None, False - ) + self.end_time: float | None = ( + time.perf_counter() + self.duration if self.duration else None + ) + + if self.event and PYWIN32: + PYWIN32.set_timer(self.event, self.period_ms) self.thread.start() def _run(self) -> None: msg_index = 0 + msg_due_time_ns = time.perf_counter_ns() + + if self.event and PYWIN32: + # Make sure the timer is non-signaled before entering the loop + PYWIN32.wait_0(self.event) + while not self.stopped: - # Prevent calling bus.send from multiple threads - with self.send_lock: - started = time.perf_counter() - try: - self.bus.send(self.messages[msg_index]) - except Exception as exc: # pylint: disable=broad-except - log.exception(exc) - if self.on_error: - if not self.on_error(exc): - break - else: - break if self.end_time is not None and time.perf_counter() >= self.end_time: + self.stop() break + + try: + if self.modifier_callback is not None: + self.modifier_callback(self.messages[msg_index]) + with self.send_lock: + # Prevent calling bus.send from multiple threads + self.bus.send(self.messages[msg_index]) + except Exception as exc: # pylint: disable=broad-except + log.exception(exc) + + # stop if `on_error` callback was not given + if self.on_error is None: + self.stop() + raise exc + + # stop if `on_error` returns False + if not self.on_error(exc): + self.stop() + break + + if not self.event: + msg_due_time_ns += self.period_ns + msg_index = (msg_index + 1) % len(self.messages) - if HAS_EVENTS: - win32event.WaitForSingleObject(self.event.handle, self.period_ms) + if self.event and PYWIN32: + PYWIN32.wait_inf(self.event) else: # Compensate for the time it takes to send the message - delay = self.period - (time.perf_counter() - started) - time.sleep(max(0.0, delay)) + delay_ns = msg_due_time_ns - time.perf_counter_ns() + if delay_ns > 0: + time.sleep(delay_ns / NANOSECONDS_IN_SECOND) diff --git a/can/bus.py b/can/bus.py index f29b8ea6e..03425caaa 100644 --- a/can/bus.py +++ b/can/bus.py @@ -2,18 +2,22 @@ Contains the ABC bus implementation and its documentation. """ -from typing import cast, Any, Iterator, List, Optional, Sequence, Tuple, Union - -import can.typechecking - -from abc import ABC, ABCMeta, abstractmethod -import can +import contextlib import logging import threading -from time import time +from abc import ABC, abstractmethod +from collections.abc import Callable, Iterator, Sequence from enum import Enum, auto +from time import time +from types import TracebackType +from typing import ( + cast, +) + +from typing_extensions import Self -from can.broadcastmanager import ThreadBasedCyclicSendTask, CyclicSendTaskABC +import can.typechecking +from can.broadcastmanager import CyclicSendTaskABC, ThreadBasedCyclicSendTask from can.message import Message LOG = logging.getLogger(__name__) @@ -27,7 +31,16 @@ class BusState(Enum): ERROR = auto() -class BusABC(metaclass=ABCMeta): +class CanProtocol(Enum): + """The CAN protocol type supported by a :class:`can.BusABC` instance""" + + CAN_20 = auto() + CAN_FD = auto() # ISO Mode + CAN_FD_NON_ISO = auto() + CAN_XL = auto() + + +class BusABC(ABC): """The CAN Bus Abstract Base Class that serves as the basis for all concrete interfaces. @@ -44,12 +57,16 @@ class BusABC(metaclass=ABCMeta): #: Log level for received messages RECV_LOGGING_LEVEL = 9 + #: Assume that no cleanup is needed until something was initialized + _is_shutdown: bool = True + _can_protocol: CanProtocol = CanProtocol.CAN_20 + @abstractmethod def __init__( self, - channel: Any, - can_filters: Optional[can.typechecking.CanFilters] = None, - **kwargs: object + channel: can.typechecking.Channel, + can_filters: can.typechecking.CanFilters | None = None, + **kwargs: object, ): """Construct and open a CAN bus instance of the specified type. @@ -71,13 +88,17 @@ def __init__( :raises ~can.exceptions.CanInitializationError: If the bus cannot be initialized """ - self._periodic_tasks: List[_SelfRemovingCyclicTask] = [] + self._periodic_tasks: list[_SelfRemovingCyclicTask] = [] self.set_filters(can_filters) + # Flip the class default value when the constructor finishes. That + # usually means the derived class constructor was also successful, + # since it calls this parent constructor last. + self._is_shutdown: bool = False def __str__(self) -> str: return self.channel_info - def recv(self, timeout: Optional[float] = None) -> Optional[Message]: + def recv(self, timeout: float | None = None) -> Message | None: """Block waiting for a message from the Bus. :param timeout: @@ -93,7 +114,6 @@ def recv(self, timeout: Optional[float] = None) -> Optional[Message]: time_left = timeout while True: - # try to get a message msg, already_filtered = self._recv_internal(timeout=time_left) @@ -109,7 +129,6 @@ def recv(self, timeout: Optional[float] = None) -> Optional[Message]: # try next one only if there still is time, and with # reduced timeout else: - time_left = timeout - (time() - start) if time_left > 0: @@ -117,9 +136,7 @@ def recv(self, timeout: Optional[float] = None) -> Optional[Message]: return None - def _recv_internal( - self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + def _recv_internal(self, timeout: float | None) -> tuple[Message | None, bool]: """ Read a message from the bus and tell whether it was filtered. This methods may be called by :meth:`~can.BusABC.recv` @@ -162,7 +179,7 @@ def _recv_internal( raise NotImplementedError("Trying to read from a write only bus?") @abstractmethod - def send(self, msg: Message, timeout: Optional[float] = None) -> None: + def send(self, msg: Message, timeout: float | None = None) -> None: """Transmit a message to the CAN bus. Override this method to enable the transmit path. @@ -183,10 +200,12 @@ def send(self, msg: Message, timeout: Optional[float] = None) -> None: def send_periodic( self, - msgs: Union[Message, Sequence[Message]], + msgs: Message | Sequence[Message], period: float, - duration: Optional[float] = None, + duration: float | None = None, store_task: bool = True, + autostart: bool = True, + modifier_callback: Callable[[Message], None] | None = None, ) -> can.broadcastmanager.CyclicSendTaskABC: """Start sending messages at a given period on this bus. @@ -208,6 +227,14 @@ def send_periodic( :param store_task: If True (the default) the task will be attached to this Bus instance. Disable to instead manage tasks manually. + :param autostart: + If True (the default) the sending task will immediately start after creation. + Otherwise, the task has to be started by calling the + tasks :meth:`~can.RestartableCyclicTaskABC.start` method on it. + :param modifier_callback: + Function which should be used to modify each message's data before + sending. The callback modifies the :attr:`~can.Message.data` of the + message and returns ``None``. :return: A started task instance. Note the task can be stopped (and depending on the backend modified) by calling the task's @@ -222,7 +249,7 @@ def send_periodic( .. note:: - For extremely long running Bus instances with many short lived + For extremely long-running Bus instances with many short-lived tasks the default api with ``store_task==True`` may not be appropriate as the stopped tasks are still taking up memory as they are associated with the Bus instance. @@ -238,10 +265,11 @@ def send_periodic( # Create a backend specific task; will be patched to a _SelfRemovingCyclicTask later task = cast( - _SelfRemovingCyclicTask, - self._send_periodic_internal(msgs, period, duration), + "_SelfRemovingCyclicTask", + self._send_periodic_internal( + msgs, period, duration, autostart, modifier_callback + ), ) - # we wrap the task's stop method to also remove it from the Bus's list of tasks periodic_tasks = self._periodic_tasks original_stop_method = task.stop @@ -255,7 +283,7 @@ def wrapped_stop_method(remove_task: bool = True) -> None: pass # allow the task to be already removed original_stop_method() - task.stop = wrapped_stop_method # type: ignore + task.stop = wrapped_stop_method # type: ignore[method-assign] if store_task: self._periodic_tasks.append(task) @@ -264,9 +292,11 @@ def wrapped_stop_method(remove_task: bool = True) -> None: def _send_periodic_internal( self, - msgs: Union[Sequence[Message], Message], + msgs: Sequence[Message] | Message, period: float, - duration: Optional[float] = None, + duration: float | None = None, + autostart: bool = True, + modifier_callback: Callable[[Message], None] | None = None, ) -> can.broadcastmanager.CyclicSendTaskABC: """Default implementation of periodic message sending using threading. @@ -279,6 +309,10 @@ def _send_periodic_internal( :param duration: The duration between sending each message at the given rate. If no duration is provided, the task will continue indefinitely. + :param autostart: + If True (the default) the sending task will immediately start after creation. + Otherwise, the task has to be started by calling the + tasks :meth:`~can.RestartableCyclicTaskABC.start` method on it. :return: A started task instance. Note the task can be stopped (and depending on the backend modified) by calling the @@ -290,7 +324,13 @@ def _send_periodic_internal( threading.Lock() ) task = ThreadBasedCyclicSendTask( - self, self._lock_send_periodic, msgs, period, duration + bus=self, + lock=self._lock_send_periodic, + messages=msgs, + period=period, + duration=duration, + autostart=autostart, + modifier_callback=modifier_callback, ) return task @@ -303,6 +343,10 @@ def stop_all_periodic_tasks(self, remove_tasks: bool = True) -> None: :param remove_tasks: Stop tracking the stopped tasks. """ + if not hasattr(self, "_periodic_tasks"): + # avoid AttributeError for partially initialized BusABC instance + return + for task in self._periodic_tasks: # we cannot let `task.stop()` modify `self._periodic_tasks` while we are # iterating over it (#634) @@ -329,7 +373,7 @@ def __iter__(self) -> Iterator[Message]: yield msg @property - def filters(self) -> Optional[can.typechecking.CanFilters]: + def filters(self) -> can.typechecking.CanFilters | None: """ Modify the filters of this bus. See :meth:`~can.BusABC.set_filters` for details. @@ -337,12 +381,10 @@ def filters(self) -> Optional[can.typechecking.CanFilters]: return self._filters @filters.setter - def filters(self, filters: Optional[can.typechecking.CanFilters]) -> None: + def filters(self, filters: can.typechecking.CanFilters | None) -> None: self.set_filters(filters) - def set_filters( - self, filters: Optional[can.typechecking.CanFilters] = None - ) -> None: + def set_filters(self, filters: can.typechecking.CanFilters | None = None) -> None: """Apply filtering to all messages received by this Bus. All messages that match at least one filter are returned. @@ -365,9 +407,10 @@ def set_filters( messages based only on the arbitration ID and mask. """ self._filters = filters or None - self._apply_filters(self._filters) + with contextlib.suppress(NotImplementedError): + self._apply_filters(self._filters) - def _apply_filters(self, filters: Optional[can.typechecking.CanFilters]) -> None: + def _apply_filters(self, filters: can.typechecking.CanFilters | None) -> None: """ Hook for applying the filters to the underlying kernel or hardware if supported/implemented by the interface. @@ -375,6 +418,7 @@ def _apply_filters(self, filters: Optional[can.typechecking.CanFilters]) -> None :param filters: See :meth:`~can.BusABC.set_filters` for details. """ + raise NotImplementedError def _matches_filters(self, msg: Message) -> bool: """Checks whether the given message matches at least one of the @@ -395,7 +439,6 @@ def _matches_filters(self, msg: Message) -> bool: for _filter in self._filters: # check if this filter even applies to the message if "extended" in _filter: - _filter = cast(can.typechecking.CanFilterExtended, _filter) if _filter["extended"] != msg.is_extended_id: continue @@ -414,20 +457,40 @@ def _matches_filters(self, msg: Message) -> bool: def flush_tx_buffer(self) -> None: """Discard every message that may be queued in the output buffer(s).""" + raise NotImplementedError def shutdown(self) -> None: """ - Called to carry out any interface specific cleanup required - in shutting down a bus. + Called to carry out any interface specific cleanup required in shutting down a bus. + + This method can be safely called multiple times. """ + if self._is_shutdown: + LOG.debug("%s is already shut down", self.__class__) + return + + self._is_shutdown = True self.stop_all_periodic_tasks() - def __enter__(self): + def __enter__(self) -> Self: return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: self.shutdown() + def __del__(self) -> None: + if not self._is_shutdown: + LOG.warning("%s was not properly shut down", self.__class__.__name__) + # We do some best-effort cleanup if the user + # forgot to properly close the bus instance + with contextlib.suppress(AttributeError): + self.shutdown() + @property def state(self) -> BusState: """ @@ -442,8 +505,17 @@ def state(self, new_state: BusState) -> None: """ raise NotImplementedError("Property is not implemented.") + @property + def protocol(self) -> CanProtocol: + """Return the CAN protocol used by this bus instance. + + This value is set at initialization time and does not change + during the lifetime of a bus instance. + """ + return self._can_protocol + @staticmethod - def _detect_available_configs() -> List[can.typechecking.AutoDetectedConfig]: + def _detect_available_configs() -> Sequence[can.typechecking.AutoDetectedConfig]: """Detect all configurations/channels that this interface could currently connect with. diff --git a/can/cli.py b/can/cli.py new file mode 100644 index 000000000..d0ff70126 --- /dev/null +++ b/can/cli.py @@ -0,0 +1,320 @@ +import argparse +import re +from collections.abc import Sequence +from typing import Any + +import can +from can.typechecking import CanFilter, TAdditionalCliArgs +from can.util import _dict2timing, cast_from_string + + +def add_bus_arguments( + parser: argparse.ArgumentParser, + *, + filter_arg: bool = False, + prefix: str | None = None, + group_title: str | None = None, +) -> None: + """Adds CAN bus configuration options to an argument parser. + + :param parser: + The argument parser to which the options will be added. + :param filter_arg: + Whether to include the filter argument. + :param prefix: + An optional prefix for the argument names, allowing configuration of multiple buses. + :param group_title: + The title of the argument group. If not provided, a default title will be generated + based on the prefix. For example, "bus arguments (prefix)" if a prefix is specified, + or "bus arguments" otherwise. + """ + if group_title is None: + group_title = f"bus arguments ({prefix})" if prefix else "bus arguments" + + group = parser.add_argument_group(group_title) + + flags = [f"--{prefix}-channel"] if prefix else ["-c", "--channel"] + dest = f"{prefix}_channel" if prefix else "channel" + group.add_argument( + *flags, + dest=dest, + default=argparse.SUPPRESS, + metavar="CHANNEL", + help=r"Most backend interfaces require some sort of channel. For " + r"example with the serial interface the channel might be a rfcomm" + r' device: "/dev/rfcomm0". With the socketcan interface valid ' + r'channel examples include: "can0", "vcan0".', + ) + + flags = [f"--{prefix}-interface"] if prefix else ["-i", "--interface"] + dest = f"{prefix}_interface" if prefix else "interface" + group.add_argument( + *flags, + dest=dest, + default=argparse.SUPPRESS, + choices=sorted(can.VALID_INTERFACES), + help="""Specify the backend CAN interface to use. If left blank, + fall back to reading from configuration files.""", + ) + + flags = [f"--{prefix}-bitrate"] if prefix else ["-b", "--bitrate"] + dest = f"{prefix}_bitrate" if prefix else "bitrate" + group.add_argument( + *flags, + dest=dest, + type=int, + default=argparse.SUPPRESS, + metavar="BITRATE", + help="Bitrate to use for the CAN bus.", + ) + + flags = [f"--{prefix}-fd"] if prefix else ["--fd"] + dest = f"{prefix}_fd" if prefix else "fd" + group.add_argument( + *flags, + dest=dest, + default=argparse.SUPPRESS, + action="store_true", + help="Activate CAN-FD support", + ) + + flags = [f"--{prefix}-data-bitrate"] if prefix else ["--data-bitrate"] + dest = f"{prefix}_data_bitrate" if prefix else "data_bitrate" + group.add_argument( + *flags, + dest=dest, + type=int, + default=argparse.SUPPRESS, + metavar="DATA_BITRATE", + help="Bitrate to use for the data phase in case of CAN-FD.", + ) + + flags = [f"--{prefix}-timing"] if prefix else ["--timing"] + dest = f"{prefix}_timing" if prefix else "timing" + group.add_argument( + *flags, + dest=dest, + action=_BitTimingAction, + nargs=argparse.ONE_OR_MORE, + default=argparse.SUPPRESS, + metavar="TIMING_ARG", + help="Configure bit rate and bit timing. For example, use " + "`--timing f_clock=8_000_000 tseg1=5 tseg2=2 sjw=2 brp=2 nof_samples=1` for classical CAN " + "or `--timing f_clock=80_000_000 nom_tseg1=119 nom_tseg2=40 nom_sjw=40 nom_brp=1 " + "data_tseg1=29 data_tseg2=10 data_sjw=10 data_brp=1` for CAN FD. " + "Check the python-can documentation to verify whether your " + "CAN interface supports the `timing` argument.", + ) + + if filter_arg: + flags = [f"--{prefix}-filter"] if prefix else ["--filter"] + dest = f"{prefix}_can_filters" if prefix else "can_filters" + group.add_argument( + *flags, + dest=dest, + nargs=argparse.ONE_OR_MORE, + action=_CanFilterAction, + default=argparse.SUPPRESS, + metavar="{:,~}", + help="R|Space separated CAN filters for the given CAN interface:" + "\n : (matches when & mask ==" + " can_id & mask)" + "\n ~ (matches when & mask !=" + " can_id & mask)" + "\nFx to show only frames with ID 0x100 to 0x103 and 0x200 to 0x20F:" + "\n python -m can.viewer --filter 100:7FC 200:7F0" + "\nNote that the ID and mask are always interpreted as hex values", + ) + + flags = [f"--{prefix}-bus-kwargs"] if prefix else ["--bus-kwargs"] + dest = f"{prefix}_bus_kwargs" if prefix else "bus_kwargs" + group.add_argument( + *flags, + dest=dest, + action=_BusKwargsAction, + nargs=argparse.ONE_OR_MORE, + default=argparse.SUPPRESS, + metavar="BUS_KWARG", + help="Pass keyword arguments down to the instantiation of the bus class. " + "For example, `-i vector -c 1 --bus-kwargs app_name=MyCanApp serial=1234` is equivalent " + "to opening the bus with `can.Bus('vector', channel=1, app_name='MyCanApp', serial=1234)", + ) + + +def create_bus_from_namespace( + namespace: argparse.Namespace, + *, + prefix: str | None = None, + **kwargs: Any, +) -> can.BusABC: + """Creates and returns a CAN bus instance based on the provided namespace and arguments. + + :param namespace: + The namespace containing parsed arguments. + :param prefix: + An optional prefix for the argument names, enabling support for multiple buses. + :param kwargs: + Additional keyword arguments to configure the bus. + :return: + A CAN bus instance. + """ + config: dict[str, Any] = {"single_handle": True, **kwargs} + + for keyword in ( + "channel", + "interface", + "bitrate", + "fd", + "data_bitrate", + "can_filters", + "timing", + "bus_kwargs", + ): + prefixed_keyword = f"{prefix}_{keyword}" if prefix else keyword + + if prefixed_keyword in namespace: + value = getattr(namespace, prefixed_keyword) + + if keyword == "bus_kwargs": + config.update(value) + else: + config[keyword] = value + + try: + return can.Bus(**config) + except Exception as exc: + err_msg = f"Unable to instantiate bus from arguments {vars(namespace)}." + raise argparse.ArgumentError(None, err_msg) from exc + + +class _CanFilterAction(argparse.Action): + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, + ) -> None: + if not isinstance(values, list): + raise argparse.ArgumentError(self, "Invalid filter argument") + + print(f"Adding filter(s): {values}") + can_filters: list[CanFilter] = [] + + for filt in values: + if ":" in filt: + parts = filt.split(":") + can_id = int(parts[0], base=16) + can_mask = int(parts[1], base=16) + elif "~" in filt: + parts = filt.split("~") + can_id = int(parts[0], base=16) | 0x20000000 # CAN_INV_FILTER + can_mask = int(parts[1], base=16) & 0x20000000 # socket.CAN_ERR_FLAG + else: + raise argparse.ArgumentError(self, "Invalid filter argument") + can_filters.append({"can_id": can_id, "can_mask": can_mask}) + + setattr(namespace, self.dest, can_filters) + + +class _BitTimingAction(argparse.Action): + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, + ) -> None: + if not isinstance(values, list): + raise argparse.ArgumentError(self, "Invalid --timing argument") + + timing_dict: dict[str, int] = {} + for arg in values: + try: + key, value_string = arg.split("=") + value = int(value_string) + timing_dict[key] = value + except ValueError: + raise argparse.ArgumentError( + self, f"Invalid timing argument: {arg}" + ) from None + + if not (timing := _dict2timing(timing_dict)): + err_msg = "Invalid --timing argument. Incomplete parameters." + raise argparse.ArgumentError(self, err_msg) + + setattr(namespace, self.dest, timing) + print(timing) + + +class _BusKwargsAction(argparse.Action): + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, + ) -> None: + if not isinstance(values, list): + raise argparse.ArgumentError(self, "Invalid --bus-kwargs argument") + + bus_kwargs: dict[str, str | int | float | bool] = {} + + for arg in values: + try: + match = re.match( + r"^(?P[_a-zA-Z][_a-zA-Z0-9]*)=(?P\S*?)$", + arg, + ) + if not match: + raise ValueError + key = match["name"].replace("-", "_") + string_val = match["value"] + bus_kwargs[key] = cast_from_string(string_val) + except ValueError: + raise argparse.ArgumentError( + self, + f"Unable to parse bus keyword argument '{arg}'", + ) from None + + setattr(namespace, self.dest, bus_kwargs) + + +def _add_extra_args( + parser: argparse.ArgumentParser | argparse._ArgumentGroup, +) -> None: + parser.add_argument( + "extra_args", + nargs=argparse.REMAINDER, + help="The remaining arguments will be used for logger/player initialisation. " + "For example, `can_logger -i virtual -c test -f logfile.blf --compression-level=9` " + "passes the keyword argument `compression_level=9` to the BlfWriter.", + ) + + +def _parse_additional_config(unknown_args: Sequence[str]) -> TAdditionalCliArgs: + for arg in unknown_args: + if not re.match(r"^--[a-zA-Z][a-zA-Z0-9\-]*=\S*?$", arg): + raise ValueError(f"Parsing argument {arg} failed") + + def _split_arg(_arg: str) -> tuple[str, str]: + left, right = _arg.split("=", 1) + return left.lstrip("-").replace("-", "_"), right + + args: dict[str, str | int | float | bool] = {} + for key, string_val in map(_split_arg, unknown_args): + args[key] = cast_from_string(string_val) + return args + + +def _set_logging_level_from_namespace(namespace: argparse.Namespace) -> None: + if "verbosity" in namespace: + logging_level_names = [ + "critical", + "error", + "warning", + "info", + "debug", + "subdebug", + ] + can.set_logging_level(logging_level_names[min(5, namespace.verbosity)]) diff --git a/can/ctypesutil.py b/can/ctypesutil.py index 4cfebb5b8..0798de910 100644 --- a/can/ctypesutil.py +++ b/can/ctypesutil.py @@ -1,28 +1,27 @@ -# type: ignore """ This module contains common `ctypes` utils. """ + import ctypes import logging import sys - -from typing import Any, Callable, Optional, Tuple, Union +from collections.abc import Callable +from typing import Any log = logging.getLogger("can.ctypesutil") -__all__ = ["CLibrary", "HANDLE", "PHANDLE", "HRESULT"] +__all__ = ["HANDLE", "HRESULT", "PHANDLE", "CLibrary"] - -try: +if sys.platform == "win32": _LibBase = ctypes.WinDLL _FUNCTION_TYPE = ctypes.WINFUNCTYPE -except AttributeError: +else: _LibBase = ctypes.CDLL _FUNCTION_TYPE = ctypes.CFUNCTYPE class CLibrary(_LibBase): - def __init__(self, library_or_path: Union[str, ctypes.CDLL]) -> None: + def __init__(self, library_or_path: str | ctypes.CDLL) -> None: self.func_name: Any if isinstance(library_or_path, str): @@ -34,8 +33,8 @@ def map_symbol( self, func_name: str, restype: Any = None, - argtypes: Tuple[Any, ...] = (), - errcheck: Optional[Callable[..., Any]] = None, + argtypes: tuple[Any, ...] = (), + errcheck: Callable[..., Any] | None = None, ) -> Any: """ Map and return a symbol (function) from a C library. A reference to the @@ -61,7 +60,7 @@ def map_symbol( f'Could not map function "{func_name}" from library {self._name}' ) from None - func._name = func_name # pylint: disable=protected-access + func._name = func_name # type: ignore[attr-defined] # pylint: disable=protected-access log.debug( 'Wrapped function "%s", result type: %s, error_check %s', func_name, diff --git a/can/exceptions.py b/can/exceptions.py index 57130082a..696701399 100644 --- a/can/exceptions.py +++ b/can/exceptions.py @@ -1,6 +1,6 @@ """ There are several specific :class:`Exception` classes to allow user -code to react to specific scenarios related to CAN busses:: +code to react to specific scenarios related to CAN buses:: Exception (Python standard library) +-- ... @@ -15,17 +15,9 @@ :class:`ValueError`. This should always be documented for the function at hand. """ -import sys +from collections.abc import Generator from contextlib import contextmanager -from typing import Optional -from typing import Type - -if sys.version_info >= (3, 9): - from collections.abc import Generator -else: - from typing import Generator - class CanError(Exception): """Base class for all CAN related exceptions. @@ -58,7 +50,7 @@ class CanError(Exception): def __init__( self, message: str = "", - error_code: Optional[int] = None, + error_code: int | None = None, ) -> None: self.error_code = error_code super().__init__( @@ -115,8 +107,8 @@ class CanTimeoutError(CanError, TimeoutError): @contextmanager def error_check( - error_message: Optional[str] = None, - exception_type: Type[CanError] = CanOperationError, + error_message: str | None = None, + exception_type: type[CanError] = CanOperationError, ) -> Generator[None, None, None]: """Catches any exceptions and turns them into the new type while preserving the stack trace.""" try: diff --git a/can/interface.py b/can/interface.py index 04fc84ae9..efde5b214 100644 --- a/can/interface.py +++ b/can/interface.py @@ -4,21 +4,23 @@ CyclicSendTasks. """ +import concurrent.futures.thread import importlib import logging -from typing import Any, cast, Iterable, Type, Optional, Union, List +from collections.abc import Callable, Iterable, Sequence +from typing import Any, cast from . import util from .bus import BusABC -from .interfaces import BACKENDS from .exceptions import CanInterfaceNotImplementedError +from .interfaces import BACKENDS from .typechecking import AutoDetectedConfig, Channel log = logging.getLogger("can.interface") log_autodetect = log.getChild("detect_available_configs") -def _get_class_for_interface(interface: str) -> Type[BusABC]: +def _get_class_for_interface(interface: str) -> type[BusABC]: """ Returns the main bus class for the given interface. @@ -52,11 +54,23 @@ def _get_class_for_interface(interface: str) -> Type[BusABC]: f"'{interface}': {e}" ) from None - return cast(Type[BusABC], bus_class) + return cast("type[BusABC]", bus_class) -class Bus(BusABC): # pylint: disable=abstract-method - """Bus wrapper with configuration loading. +@util.deprecated_args_alias( + deprecation_start="4.2.0", + deprecation_end="5.0.0", + bustype="interface", + context="config_context", +) +def Bus( # noqa: N802 + channel: Channel | None = None, + interface: str | None = None, + config_context: str | None = None, + ignore_config: bool = False, + **kwargs: Any, +) -> BusABC: + """Create a new bus instance with configuration loading. Instantiates a CAN Bus of the given ``interface``, falls back to reading a configuration file from default locations. @@ -99,111 +113,121 @@ class Bus(BusABC): # pylint: disable=abstract-method if the ``channel`` could not be determined """ - @staticmethod - @util.deprecated_args_alias( - deprecation_start="4.2.0", - deprecation_end="5.0.0", - bustype="interface", - context="config_context", - ) - def __new__( # type: ignore - cls: Any, - channel: Optional[Channel] = None, - interface: Optional[str] = None, - config_context: Optional[str] = None, - ignore_config: bool = False, - **kwargs: Any, - ) -> BusABC: - # figure out the rest of the configuration; this might raise an error - if interface is not None: - kwargs["interface"] = interface - if channel is not None: - kwargs["channel"] = channel - - if not ignore_config: - kwargs = util.load_config(config=kwargs, context=config_context) - - # resolve the bus class to use for that interface - cls = _get_class_for_interface(kwargs["interface"]) - - # remove the "interface" key, so it doesn't get passed to the backend - del kwargs["interface"] - - # make sure the bus can handle this config format - channel = kwargs.pop("channel", channel) - if channel is None: - # Use the default channel for the backend - bus = cls(**kwargs) - else: - bus = cls(channel, **kwargs) - - return cast(BusABC, bus) + # figure out the rest of the configuration; this might raise an error + if interface is not None: + kwargs["interface"] = interface + if channel is not None: + kwargs["channel"] = channel + + if not ignore_config: + kwargs = util.load_config(config=kwargs, context=config_context) + + # resolve the bus class to use for that interface + cls = _get_class_for_interface(kwargs["interface"]) + + # remove the "interface" key, so it doesn't get passed to the backend + del kwargs["interface"] + + # make sure the bus can handle this config format + channel = kwargs.pop("channel", channel) + if channel is None: + # Use the default channel for the backend + bus = cls(**kwargs) + else: + bus = cls(channel, **kwargs) + + return bus def detect_available_configs( - interfaces: Union[None, str, Iterable[str]] = None -) -> List[AutoDetectedConfig]: + interfaces: None | str | Iterable[str] = None, + timeout: float = 5.0, +) -> Sequence[AutoDetectedConfig]: """Detect all configurations/channels that the interfaces could currently connect with. - This might be quite time consuming. + This might be quite time-consuming. Automated configuration detection may not be implemented by every interface on every platform. This method will not raise - an error in that case, but with rather return an empty list + an error in that case, but will rather return an empty list for that interface. :param interfaces: either - the name of an interface to be searched in as a string, - an iterable of interface names to search in, or - `None` to search in all known interfaces. + :param timeout: maximum number of seconds to wait for all interface + detection tasks to complete. If exceeded, any pending tasks + will be cancelled, a warning will be logged, and the method + will return results gathered so far. :rtype: list[dict] :return: an iterable of dicts, each suitable for usage in - the constructor of :class:`can.BusABC`. + the constructor of :class:`can.BusABC`. Interfaces that + timed out will be logged as warnings and excluded. """ - # Figure out where to search + # Determine which interfaces to search if interfaces is None: interfaces = BACKENDS elif isinstance(interfaces, str): interfaces = (interfaces,) - # else it is supposed to be an iterable of strings - - result = [] - for interface in interfaces: + # otherwise assume iterable of strings + # Collect detection callbacks + callbacks: dict[str, Callable[[], Sequence[AutoDetectedConfig]]] = {} + for interface_keyword in interfaces: try: - bus_class = _get_class_for_interface(interface) + bus_class = _get_class_for_interface(interface_keyword) + callbacks[interface_keyword] = ( + bus_class._detect_available_configs # pylint: disable=protected-access + ) except CanInterfaceNotImplementedError: log_autodetect.debug( 'interface "%s" cannot be loaded for detection of available configurations', - interface, + interface_keyword, ) - continue - # get available channels - try: - available = list( - bus_class._detect_available_configs() # pylint: disable=protected-access - ) - except NotImplementedError: - log_autodetect.debug( - 'interface "%s" does not support detection of available configurations', - interface, - ) - else: - log_autodetect.debug( - 'interface "%s" detected %i available configurations', - interface, - len(available), - ) - - # add the interface name to the configs if it is not already present - for config in available: - if "interface" not in config: - config["interface"] = interface - - # append to result - result += available + result: list[AutoDetectedConfig] = [] + # Use manual executor to allow shutdown without waiting + executor = concurrent.futures.ThreadPoolExecutor() + try: + futures_to_keyword = { + executor.submit(func): kw for kw, func in callbacks.items() + } + done, not_done = concurrent.futures.wait( + futures_to_keyword, + timeout=timeout, + return_when=concurrent.futures.ALL_COMPLETED, + ) + # Log timed-out tasks + if not_done: + log_autodetect.warning( + "Timeout (%.2fs) reached for interfaces: %s", + timeout, + ", ".join(sorted(futures_to_keyword[fut] for fut in not_done)), + ) + # Process completed futures + for future in done: + keyword = futures_to_keyword[future] + try: + available = future.result() + except NotImplementedError: + log_autodetect.debug( + 'interface "%s" does not support detection of available configurations', + keyword, + ) + else: + log_autodetect.debug( + 'interface "%s" detected %i available configurations', + keyword, + len(available), + ) + for config in available: + config.setdefault("interface", keyword) + result.extend(available) + finally: + # shutdown immediately, do not wait for pending threads + executor.shutdown(wait=False, cancel_futures=True) return result diff --git a/can/interfaces/__init__.py b/can/interfaces/__init__.py index 3065e9bfd..1b401639a 100644 --- a/can/interfaces/__init__.py +++ b/can/interfaces/__init__.py @@ -2,11 +2,38 @@ Interfaces contain low level implementations that interact with CAN hardware. """ -import sys -from typing import cast, Dict, Tuple +from can._entry_points import read_entry_points + +__all__ = [ + "BACKENDS", + "VALID_INTERFACES", + "canalystii", + "cantact", + "etas", + "gs_usb", + "ics_neovi", + "iscan", + "ixxat", + "kvaser", + "neousys", + "nican", + "nixnet", + "pcan", + "robotell", + "seeedstudio", + "serial", + "slcan", + "socketcan", + "socketcand", + "systec", + "udp_multicast", + "usb2can", + "vector", + "virtual", +] # interface_name => (module, classname) -BACKENDS: Dict[str, Tuple[str, str]] = { +BACKENDS: dict[str, tuple[str, str]] = { "kvaser": ("can.interfaces.kvaser", "KvaserBus"), "socketcan": ("can.interfaces.socketcan", "SocketcanBus"), "serial": ("can.interfaces.serial.serial_can", "SerialBus"), @@ -32,35 +59,12 @@ "socketcand": ("can.interfaces.socketcand", "SocketCanDaemonBus"), } -if sys.version_info >= (3, 8): - from importlib.metadata import entry_points - - # See https://docs.python.org/3/library/importlib.metadata.html#entry-points, "Compatibility Note". - if sys.version_info >= (3, 10): - BACKENDS.update( - { - interface.name: (interface.module, interface.attr) - for interface in entry_points(group="can.interface") - } - ) - else: - # The entry_points().get(...) causes a deprecation warning on Python >= 3.10. - BACKENDS.update( - { - interface.name: cast( - Tuple[str, str], tuple(interface.value.split(":", maxsplit=1)) - ) - for interface in entry_points().get("can.interface", []) - } - ) -else: - from pkg_resources import iter_entry_points - BACKENDS.update( - { - interface.name: (interface.module_name, interface.attrs[0]) - for interface in iter_entry_points("can.interface") - } - ) +BACKENDS.update( + { + interface.key: (interface.module_name, interface.class_name) + for interface in read_entry_points(group="can.interface") + } +) -VALID_INTERFACES = frozenset(BACKENDS.keys()) +VALID_INTERFACES = frozenset(sorted(BACKENDS.keys())) diff --git a/can/interfaces/canalystii.py b/can/interfaces/canalystii.py index 7150a60bd..e2bf7555e 100644 --- a/can/interfaces/canalystii.py +++ b/can/interfaces/canalystii.py @@ -1,16 +1,17 @@ -import collections -from ctypes import c_ubyte import logging import time -from typing import Any, Dict, Optional, Deque, Sequence, Tuple, Union - -from can import BitTiming, BusABC, Message, BitTimingFd -from can.exceptions import CanTimeoutError, CanInitializationError -from can.typechecking import CanFilters -from can.util import deprecated_args_alias, check_or_adjust_timing_clock +from collections import deque +from collections.abc import Sequence +from ctypes import c_ubyte +from typing import Any import canalystii as driver +from can import BitTiming, BitTimingFd, BusABC, CanProtocol, Message +from can.exceptions import CanTimeoutError +from can.typechecking import CanFilters +from can.util import check_or_adjust_timing_clock, deprecated_args_alias + logger = logging.getLogger(__name__) @@ -20,13 +21,13 @@ class CANalystIIBus(BusABC): ) def __init__( self, - channel: Union[int, Sequence[int], str] = (0, 1), + channel: int | Sequence[int] | str = (0, 1), device: int = 0, - bitrate: Optional[int] = None, - timing: Optional[Union[BitTiming, BitTimingFd]] = None, - can_filters: Optional[CanFilters] = None, - rx_queue_size: Optional[int] = None, - **kwargs: Dict[str, Any], + bitrate: int | None = None, + timing: BitTiming | BitTimingFd | None = None, + can_filters: CanFilters | None = None, + rx_queue_size: int | None = None, + **kwargs: dict[str, Any], ): """ @@ -50,11 +51,15 @@ def __init__( If set, software received message queue can only grow to this many messages (for all channels) before older messages are dropped """ - super().__init__(channel=channel, can_filters=can_filters, **kwargs) - if not (bitrate or timing): raise ValueError("Either bitrate or timing argument is required") + # Do this after the error handling + super().__init__( + channel=channel, + can_filters=can_filters, + **kwargs, + ) if isinstance(channel, str): # Assume comma separated string of channels self.channels = [int(ch.strip()) for ch in channel.split(",")] @@ -63,23 +68,23 @@ def __init__( else: # Sequence[int] self.channels = list(channel) - self.rx_queue = collections.deque( - maxlen=rx_queue_size - ) # type: Deque[Tuple[int, driver.Message]] - self.channel_info = f"CANalyst-II: device {device}, channels {self.channels}" - + self.rx_queue: deque[tuple[int, driver.Message]] = deque(maxlen=rx_queue_size) self.device = driver.CanalystDevice(device_index=device) - for channel in self.channels: + self._can_protocol = CanProtocol.CAN_20 + + for single_channel in self.channels: if isinstance(timing, BitTiming): timing = check_or_adjust_timing_clock(timing, valid_clocks=[8_000_000]) - self.device.init(channel, timing0=timing.btr0, timing1=timing.btr1) + self.device.init( + single_channel, timing0=timing.btr0, timing1=timing.btr1 + ) elif isinstance(timing, BitTimingFd): raise NotImplementedError( f"CAN FD is not supported by {self.__class__.__name__}." ) else: - self.device.init(channel, bitrate=bitrate) + self.device.init(single_channel, bitrate=bitrate) # Delay to use between each poll for new messages # @@ -89,7 +94,7 @@ def __init__( # system. RX_POLL_DELAY = 0.020 - def send(self, msg: Message, timeout: Optional[float] = None) -> None: + def send(self, msg: Message, timeout: float | None = None) -> None: """Send a CAN message to the bus :param msg: message to send @@ -125,7 +130,7 @@ def send(self, msg: Message, timeout: Optional[float] = None) -> None: if timeout is not None and not send_result: raise CanTimeoutError(f"Send timed out after {timeout} seconds") - def _recv_from_queue(self) -> Tuple[Message, bool]: + def _recv_from_queue(self) -> tuple[Message, bool]: """Return a message from the internal receive queue""" channel, raw_msg = self.rx_queue.popleft() @@ -161,8 +166,8 @@ def poll_received_messages(self) -> None: ) def _recv_internal( - self, timeout: Optional[float] = None - ) -> Tuple[Optional[Message], bool]: + self, timeout: float | None = None + ) -> tuple[Message | None, bool]: """ :param timeout: float in seconds @@ -189,7 +194,7 @@ def _recv_internal( return (None, False) - def flush_tx_buffer(self, channel: Optional[int] = None) -> None: + def flush_tx_buffer(self, channel: int | None = None) -> None: """Flush the TX buffer of the device. :param channel: diff --git a/can/interfaces/cantact.py b/can/interfaces/cantact.py index 20e4d0cb7..ee01fbf94 100644 --- a/can/interfaces/cantact.py +++ b/can/interfaces/cantact.py @@ -2,18 +2,21 @@ Interface for CANtact devices from Linklayer Labs """ -import time import logging -from typing import Optional, Union, Any +import time +from collections.abc import Sequence +from typing import Any from unittest.mock import Mock -from can import BusABC, Message, BitTiming, BitTimingFd +from can import BitTiming, BitTimingFd, BusABC, CanProtocol, Message + from ..exceptions import ( CanInitializationError, CanInterfaceNotImplementedError, error_check, ) -from ..util import deprecated_args_alias, check_or_adjust_timing_clock +from ..typechecking import AutoDetectedConfig +from ..util import check_or_adjust_timing_clock, deprecated_args_alias logger = logging.getLogger(__name__) @@ -30,7 +33,7 @@ class CantactBus(BusABC): """CANtact interface""" @staticmethod - def _detect_available_configs(): + def _detect_available_configs() -> Sequence[AutoDetectedConfig]: try: interface = cantact.Interface() except (NameError, SystemError, AttributeError): @@ -39,7 +42,7 @@ def _detect_available_configs(): ) return [] - channels = [] + channels: list[AutoDetectedConfig] = [] for i in range(0, interface.channel_count()): channels.append({"interface": "cantact", "channel": f"ch:{i}"}) return channels @@ -53,7 +56,7 @@ def __init__( bitrate: int = 500_000, poll_interval: float = 0.01, monitor: bool = False, - timing: Optional[Union[BitTiming, BitTimingFd]] = None, + timing: BitTiming | BitTimingFd | None = None, **kwargs: Any, ) -> None: """ @@ -86,6 +89,7 @@ def __init__( self.channel = int(channel) self.channel_info = f"CANtact: ch:{channel}" + self._can_protocol = CanProtocol.CAN_20 # Configure the interface with error_check("Cannot setup the cantact.Interface", CanInitializationError): @@ -113,10 +117,18 @@ def __init__( self.interface.start() super().__init__( - channel=channel, bitrate=bitrate, poll_interval=poll_interval, **kwargs + channel=channel, + bitrate=bitrate, + poll_interval=poll_interval, + **kwargs, ) - def _recv_internal(self, timeout): + def _recv_internal(self, timeout: float | None) -> tuple[Message | None, bool]: + if timeout is None: + raise TypeError( + f"{self.__class__.__name__} expects a numeric `timeout` value." + ) + with error_check("Cannot receive message"): frame = self.interface.recv(int(timeout * 1000)) if frame is None: @@ -135,7 +147,7 @@ def _recv_internal(self, timeout): ) return msg, False - def send(self, msg, timeout=None): + def send(self, msg: Message, timeout: float | None = None) -> None: with error_check("Cannot send message"): self.interface.send( self.channel, @@ -146,13 +158,13 @@ def send(self, msg, timeout=None): msg.data, ) - def shutdown(self): + def shutdown(self) -> None: super().shutdown() with error_check("Cannot shutdown interface"): self.interface.stop() -def mock_recv(timeout): +def mock_recv(timeout: int) -> dict[str, Any] | None: if timeout > 0: return { "id": 0x123, diff --git a/can/interfaces/etas/__init__.py b/can/interfaces/etas/__init__.py index 3a203a50d..f8364a3fd 100644 --- a/can/interfaces/etas/__init__.py +++ b/can/interfaces/etas/__init__.py @@ -1,9 +1,9 @@ -import ctypes import time -from typing import Dict, List, Optional, Tuple +from typing import Any import can -from ...exceptions import CanInitializationError +from can.exceptions import CanInitializationError + from .boa import * @@ -11,14 +11,15 @@ class EtasBus(can.BusABC): def __init__( self, channel: str, - can_filters: Optional[can.typechecking.CanFilters] = None, + can_filters: can.typechecking.CanFilters | None = None, receive_own_messages: bool = False, bitrate: int = 1000000, fd: bool = True, data_bitrate: int = 2000000, - **kwargs: object, + **kwargs: dict[str, Any], ): self.receive_own_messages = receive_own_messages + self._can_protocol = can.CanProtocol.CAN_FD if fd else can.CanProtocol.CAN_20 nodeRange = CSI_NodeRange(CSI_NODE_MIN, CSI_NODE_MAX) self.tree = ctypes.POINTER(CSI_Tree)() @@ -116,9 +117,10 @@ def __init__( self.channel_info = channel - def _recv_internal( - self, timeout: Optional[float] - ) -> Tuple[Optional[can.Message], bool]: + # Super call must be after child init since super calls set_filters + super().__init__(channel=channel, **kwargs) + + def _recv_internal(self, timeout: float | None) -> tuple[can.Message | None, bool]: ociMsgs = (ctypes.POINTER(OCI_CANMessageEx) * 1)() ociMsg = OCI_CANMessageEx() ociMsgs[0] = ctypes.pointer(ociMsg) @@ -185,7 +187,7 @@ def _recv_internal( return (msg, True) - def send(self, msg: can.Message, timeout: Optional[float] = None) -> None: + def send(self, msg: can.Message, timeout: float | None = None) -> None: ociMsgs = (ctypes.POINTER(OCI_CANMessageEx) * 1)() ociMsg = OCI_CANMessageEx() ociMsgs[0] = ctypes.pointer(ociMsg) @@ -215,7 +217,7 @@ def send(self, msg: can.Message, timeout: Optional[float] = None) -> None: OCI_WriteCANDataEx(self.txQueue, OCI_NO_TIME, ociMsgs, 1, None) - def _apply_filters(self, filters: Optional[can.typechecking.CanFilters]) -> None: + def _apply_filters(self, filters: can.typechecking.CanFilters | None) -> None: if self._oci_filters: OCI_RemoveCANFrameFilterEx(self.rxQueue, self._oci_filters, 1) @@ -225,10 +227,10 @@ def _apply_filters(self, filters: Optional[can.typechecking.CanFilters]) -> None self._oci_filters = (ctypes.POINTER(OCI_CANRxFilterEx) * len(filters))() - for i, filter in enumerate(filters): + for i, filter_ in enumerate(filters): f = OCI_CANRxFilterEx() - f.frameIDValue = filter["can_id"] - f.frameIDMask = filter["can_mask"] + f.frameIDValue = filter_["can_id"] + f.frameIDMask = filter_["can_mask"] f.tag = 0 f.flagsValue = 0 if self.receive_own_messages: @@ -237,7 +239,7 @@ def _apply_filters(self, filters: Optional[can.typechecking.CanFilters]) -> None else: # enable the SR bit in the mask. since the bit is 0 in flagsValue -> do not self-receive f.flagsMask = OCI_CAN_MSG_FLAG_SELFRECEPTION - if filter.get("extended"): + if filter_.get("extended"): f.flagsValue |= OCI_CAN_MSG_FLAG_EXTENDED f.flagsMask |= OCI_CAN_MSG_FLAG_EXTENDED self._oci_filters[i].contents = f @@ -248,6 +250,7 @@ def flush_tx_buffer(self) -> None: OCI_ResetQueue(self.txQueue) def shutdown(self) -> None: + super().shutdown() # Cleanup TX if self.txQueue: OCI_DestroyCANTxQueue(self.txQueue) @@ -289,12 +292,13 @@ def state(self, new_state: can.BusState) -> None: # raise CanOperationError(f"OCI_AdaptCANConfiguration failed with error 0x{ec:X}") raise NotImplementedError("Setting state is not implemented.") - def _detect_available_configs() -> List[can.typechecking.AutoDetectedConfig]: + @staticmethod + def _detect_available_configs() -> list[can.typechecking.AutoDetectedConfig]: nodeRange = CSI_NodeRange(CSI_NODE_MIN, CSI_NODE_MAX) tree = ctypes.POINTER(CSI_Tree)() CSI_CreateProtocolTree(ctypes.c_char_p(b""), nodeRange, ctypes.byref(tree)) - nodes: Dict[str, str] = [] + nodes: list[dict[str, str]] = [] def _findNodes(tree, prefix): uri = f"{prefix}/{tree.contents.item.uriName.decode()}" diff --git a/can/interfaces/gs_usb.py b/can/interfaces/gs_usb.py index 185d28acf..6297fc1f5 100644 --- a/can/interfaces/gs_usb.py +++ b/can/interfaces/gs_usb.py @@ -1,15 +1,14 @@ -from typing import Optional, Tuple +import logging +import usb +from gs_usb.constants import CAN_EFF_FLAG, CAN_ERR_FLAG, CAN_MAX_DLC, CAN_RTR_FLAG from gs_usb.gs_usb import GsUsb -from gs_usb.gs_usb_frame import GsUsbFrame, GS_USB_NONE_ECHO_ID -from gs_usb.constants import CAN_ERR_FLAG, CAN_RTR_FLAG, CAN_EFF_FLAG, CAN_MAX_DLC +from gs_usb.gs_usb_frame import GS_USB_NONE_ECHO_ID, GsUsbFrame + import can -import usb -import logging from ..exceptions import CanInitializationError, CanOperationError - logger = logging.getLogger(__name__) @@ -17,7 +16,7 @@ class GsUsbBus(can.BusABC): def __init__( self, channel, - bitrate, + bitrate: int = 500_000, index=None, bus=None, address=None, @@ -33,11 +32,16 @@ def __init__( :param can_filters: not supported :param bitrate: CAN network bandwidth (bits/s) """ + self._is_shutdown = False if (index is not None) and ((bus or address) is not None): raise CanInitializationError( - f"index and bus/address cannot be used simultaneously" + "index and bus/address cannot be used simultaneously" ) + if index is None and address is None and bus is None: + index = channel + + self._index = None if index is not None: devs = GsUsb.scan() if len(devs) <= index: @@ -45,6 +49,7 @@ def __init__( f"Cannot find device {index}. Devices found: {len(devs)}" ) gs_usb = devs[index] + self._index = index else: gs_usb = GsUsb.find(bus=bus, address=address) if not gs_usb: @@ -52,13 +57,31 @@ def __init__( self.gs_usb = gs_usb self.channel_info = channel + self._can_protocol = can.CanProtocol.CAN_20 - self.gs_usb.set_bitrate(bitrate) + bit_timing = can.BitTiming.from_sample_point( + f_clock=self.gs_usb.device_capability.fclk_can, + bitrate=bitrate, + sample_point=87.5, + ) + props_seg = 1 + self.gs_usb.set_timing( + prop_seg=props_seg, + phase_seg1=bit_timing.tseg1 - props_seg, + phase_seg2=bit_timing.tseg2, + sjw=bit_timing.sjw, + brp=bit_timing.brp, + ) self.gs_usb.start() + self._bitrate = bitrate - super().__init__(channel=channel, can_filters=can_filters, **kwargs) + super().__init__( + channel=channel, + can_filters=can_filters, + **kwargs, + ) - def send(self, msg: can.Message, timeout: Optional[float] = None): + def send(self, msg: can.Message, timeout: float | None = None): """Transmit a message to the CAN bus. :param Message msg: A message object. @@ -85,17 +108,15 @@ def send(self, msg: can.Message, timeout: Optional[float] = None): frame = GsUsbFrame() frame.can_id = can_id frame.can_dlc = msg.dlc - frame.timestamp_us = int(msg.timestamp * 1000000) + frame.timestamp_us = 0 # timestamp frame field is only useful on receive frame.data = list(msg.data) try: self.gs_usb.send(frame) - except usb.core.USBError: - raise CanOperationError("The message could not be sent") + except usb.core.USBError as exc: + raise CanOperationError("The message could not be sent") from exc - def _recv_internal( - self, timeout: Optional[float] - ) -> Tuple[Optional[can.Message], bool]: + def _recv_internal(self, timeout: float | None) -> tuple[can.Message | None, bool]: """ Read a message from the bus and tell whether it was filtered. This methods may be called by :meth:`~can.BusABC.recv` @@ -137,5 +158,21 @@ def _recv_internal( return msg, False def shutdown(self): + if self._is_shutdown: + return + super().shutdown() self.gs_usb.stop() + if self._index is not None: + # Avoid errors on subsequent __init() by repeating the .scan() and .start() that would otherwise fail + # the next time the device is opened in __init__() + devs = GsUsb.scan() + if self._index < len(devs): + gs_usb = devs[self._index] + try: + gs_usb.set_bitrate(self._bitrate) + gs_usb.start() + gs_usb.stop() + except usb.core.USBError: + pass + self._is_shutdown = True diff --git a/can/interfaces/ics_neovi/__init__.py b/can/interfaces/ics_neovi/__init__.py index 548e3ea3f..74cb43af4 100644 --- a/can/interfaces/ics_neovi/__init__.py +++ b/can/interfaces/ics_neovi/__init__.py @@ -1,7 +1,11 @@ -""" -""" +""" """ -from .neovi_bus import NeoViBus -from .neovi_bus import ICSApiError -from .neovi_bus import ICSInitializationError -from .neovi_bus import ICSOperationError +__all__ = [ + "ICSApiError", + "ICSInitializationError", + "ICSOperationError", + "NeoViBus", + "neovi_bus", +] + +from .neovi_bus import ICSApiError, ICSInitializationError, ICSOperationError, NeoViBus diff --git a/can/interfaces/ics_neovi/neovi_bus.py b/can/interfaces/ics_neovi/neovi_bus.py index 4972ed479..815ed6fa0 100644 --- a/can/interfaces/ics_neovi/neovi_bus.py +++ b/can/interfaces/ics_neovi/neovi_bus.py @@ -8,20 +8,24 @@ * https://github.com/intrepidcs/python_ics """ +import functools import logging import os import tempfile -from collections import deque, defaultdict, Counter +from collections import Counter, defaultdict, deque +from datetime import datetime +from functools import partial from itertools import cycle from threading import Event from warnings import warn -from can import Message, BusABC +from can import BusABC, CanProtocol, Message + from ...exceptions import ( CanError, - CanTimeoutError, - CanOperationError, CanInitializationError, + CanOperationError, + CanTimeoutError, ) logger = logging.getLogger(__name__) @@ -40,7 +44,6 @@ try: from filelock import FileLock except ImportError as ie: - logger.warning( "Using ICS neoVI can backend without the " "filelock module installed may cause some issues!: %s", @@ -66,6 +69,9 @@ def __exit__(self, exc_type, exc_val, exc_tb): open_lock = FileLock(os.path.join(tempfile.gettempdir(), "neovi.lock")) description_id = cycle(range(1, 0x8000)) +ICS_EPOCH = datetime.fromisoformat("2007-01-01") +ICS_EPOCH_DELTA = (ICS_EPOCH - datetime.fromisoformat("1970-01-01")).total_seconds() + class ICSApiError(CanError): """ @@ -108,8 +114,10 @@ def __reduce__(self): def error_number(self) -> int: """Deprecated. Renamed to :attr:`can.CanError.error_code`.""" warn( - "ICSApiError::error_number has been renamed to error_code defined by CanError", + "ICSApiError::error_number has been replaced by ICSApiError.error_code in python-can 4.0" + "and will be remove in version 5.0.", DeprecationWarning, + stacklevel=2, ) return self.error_code @@ -126,6 +134,27 @@ class ICSOperationError(ICSApiError, CanOperationError): pass +def check_if_bus_open(func): + """ + Decorator that checks if the bus is open before executing the function. + + If the bus is not open, it raises a CanOperationError. + """ + + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + """ + Wrapper function that checks if the bus is open before executing the function. + + :raises CanOperationError: If the bus is not open. + """ + if self._is_shutdown: + raise CanOperationError("Cannot operate on a closed bus") + return func(self, *args, **kwargs) + + return wrapper + + class NeoViBus(BusABC): """ The CAN Bus implemented for the python_ics interface @@ -169,7 +198,11 @@ def __init__(self, channel, can_filters=None, **kwargs): if ics is None: raise ImportError("Please install python-ics") - super().__init__(channel=channel, can_filters=can_filters, **kwargs) + super().__init__( + channel=channel, + can_filters=can_filters, + **kwargs, + ) logger.info(f"CAN Filters: {can_filters}") logger.info(f"Got configuration of: {kwargs}") @@ -190,6 +223,9 @@ def __init__(self, channel, can_filters=None, **kwargs): serial = kwargs.get("serial") self.dev = self._find_device(type_filter, serial) + is_fd = kwargs.get("fd", False) + self._can_protocol = CanProtocol.CAN_FD if is_fd else CanProtocol.CAN_20 + with open_lock: ics.open_device(self.dev) @@ -198,7 +234,7 @@ def __init__(self, channel, can_filters=None, **kwargs): for channel in self.channels: ics.set_bit_rate(self.dev, kwargs.get("bitrate"), channel) - if kwargs.get("fd", False): + if is_fd: if "data_bitrate" in kwargs: for channel in self.channels: ics.set_fd_bit_rate( @@ -215,10 +251,8 @@ def __init__(self, channel, can_filters=None, **kwargs): self._use_system_timestamp = bool(kwargs.get("use_system_timestamp", False)) self._receive_own_messages = kwargs.get("receive_own_messages", True) - self.channel_info = "{} {} CH:{}".format( - self.dev.Name, - self.get_serial_number(self.dev), - self.channels, + self.channel_info = ( + f"{self.dev.Name} {self.get_serial_number(self.dev)} CH:{self.channels}" ) logger.info(f"Using device: {self.channel_info}") @@ -247,7 +281,7 @@ def get_serial_number(device): :return: ics device serial string :rtype: str """ - if int("AA0000", 36) < device.SerialNumber < int("ZZZZZZ", 36): + if int("0A0000", 36) < device.SerialNumber < int("ZZZZZZ", 36): return ics.base36enc(device.SerialNumber) else: return str(device.SerialNumber) @@ -294,23 +328,25 @@ def _find_device(self, type_filter=None, serial=None): for device in devices: if serial is None or self.get_serial_number(device) == str(serial): return device - else: - msg = ["No device"] - if type_filter is not None: - msg.append(f"with type {type_filter}") - if serial is not None: - msg.append(f"with serial {serial}") - msg.append("found.") - raise CanInitializationError(" ".join(msg)) + msg = ["No device"] + + if type_filter is not None: + msg.append(f"with type {type_filter}") + if serial is not None: + msg.append(f"with serial {serial}") + msg.append("found.") + raise CanInitializationError(" ".join(msg)) + @check_if_bus_open def _process_msg_queue(self, timeout=0.1): try: messages, errors = ics.get_messages(self.dev, False, timeout) except ics.RuntimeError: return for ics_msg in messages: - if ics_msg.NetworkID not in self.channels: + channel = ics_msg.NetworkID | (ics_msg.NetworkID2 << 8) + if channel not in self.channels: continue is_tx = bool(ics_msg.StatusBitField & ics.SPY_STATUS_TX_MSG) @@ -352,55 +388,42 @@ def _get_timestamp_for_msg(self, ics_msg): return ics_msg.TimeSystem else: # This is the hardware time stamp. - return ics.get_timestamp_for_msg(self.dev, ics_msg) + return ics.get_timestamp_for_msg(self.dev, ics_msg) + ICS_EPOCH_DELTA def _ics_msg_to_message(self, ics_msg): is_fd = ics_msg.Protocol == ics.SPY_PROTOCOL_CANFD + message_from_ics = partial( + Message, + timestamp=self._get_timestamp_for_msg(ics_msg), + arbitration_id=ics_msg.ArbIDOrHeader, + is_extended_id=bool(ics_msg.StatusBitField & ics.SPY_STATUS_XTD_FRAME), + is_remote_frame=bool(ics_msg.StatusBitField & ics.SPY_STATUS_REMOTE_FRAME), + is_error_frame=bool(ics_msg.StatusBitField2 & ics.SPY_STATUS2_ERROR_FRAME), + channel=ics_msg.NetworkID | (ics_msg.NetworkID2 << 8), + dlc=ics_msg.NumberBytesData, + is_fd=is_fd, + is_rx=not bool(ics_msg.StatusBitField & ics.SPY_STATUS_TX_MSG), + ) + if is_fd: if ics_msg.ExtraDataPtrEnabled: data = ics_msg.ExtraDataPtr[: ics_msg.NumberBytesData] else: data = ics_msg.Data[: ics_msg.NumberBytesData] - return Message( - timestamp=self._get_timestamp_for_msg(ics_msg), - arbitration_id=ics_msg.ArbIDOrHeader, + return message_from_ics( data=data, - dlc=ics_msg.NumberBytesData, - is_extended_id=bool(ics_msg.StatusBitField & ics.SPY_STATUS_XTD_FRAME), - is_fd=is_fd, - is_rx=not bool(ics_msg.StatusBitField & ics.SPY_STATUS_TX_MSG), - is_remote_frame=bool( - ics_msg.StatusBitField & ics.SPY_STATUS_REMOTE_FRAME - ), - is_error_frame=bool( - ics_msg.StatusBitField2 & ics.SPY_STATUS2_ERROR_FRAME - ), error_state_indicator=bool( ics_msg.StatusBitField3 & ics.SPY_STATUS3_CANFD_ESI ), bitrate_switch=bool( ics_msg.StatusBitField3 & ics.SPY_STATUS3_CANFD_BRS ), - channel=ics_msg.NetworkID, ) else: - return Message( - timestamp=self._get_timestamp_for_msg(ics_msg), - arbitration_id=ics_msg.ArbIDOrHeader, + return message_from_ics( data=ics_msg.Data[: ics_msg.NumberBytesData], - dlc=ics_msg.NumberBytesData, - is_extended_id=bool(ics_msg.StatusBitField & ics.SPY_STATUS_XTD_FRAME), - is_fd=is_fd, - is_rx=not bool(ics_msg.StatusBitField & ics.SPY_STATUS_TX_MSG), - is_remote_frame=bool( - ics_msg.StatusBitField & ics.SPY_STATUS_REMOTE_FRAME - ), - is_error_frame=bool( - ics_msg.StatusBitField2 & ics.SPY_STATUS2_ERROR_FRAME - ), - channel=ics_msg.NetworkID, ) def _recv_internal(self, timeout=0.1): @@ -413,6 +436,7 @@ def _recv_internal(self, timeout=0.1): return None, False return msg, False + @check_if_bus_open def send(self, msg, timeout=0): """Transmit a message to the CAN bus. @@ -472,12 +496,16 @@ def send(self, msg, timeout=0): message.StatusBitField2 = 0 message.StatusBitField3 = flag3 if msg.channel is not None: - message.NetworkID = msg.channel + network_id = msg.channel elif len(self.channels) == 1: - message.NetworkID = self.channels[0] + network_id = self.channels[0] else: raise ValueError("msg.channel must be set when using multiple channels.") + message.NetworkID, message.NetworkID2 = int(network_id & 0xFF), int( + (network_id >> 8) & 0xFF + ) + if timeout != 0: msg_desc_id = next(description_id) message.DescriptionID = msg_desc_id diff --git a/can/interfaces/iscan.py b/can/interfaces/iscan.py index e76d9d060..2fa19942a 100644 --- a/can/interfaces/iscan.py +++ b/can/interfaces/iscan.py @@ -3,16 +3,17 @@ """ import ctypes -import time import logging -from typing import Optional, Tuple, Union +import time -from can import BusABC, Message from can import ( + BusABC, CanError, - CanInterfaceNotImplementedError, CanInitializationError, + CanInterfaceNotImplementedError, CanOperationError, + CanProtocol, + Message, ) logger = logging.getLogger(__name__) @@ -80,7 +81,7 @@ class IscanBus(BusABC): def __init__( self, - channel: Union[str, int], + channel: str | int, bitrate: int = 500000, poll_interval: float = 0.01, **kwargs, @@ -98,6 +99,7 @@ def __init__( self.channel = ctypes.c_ubyte(int(channel)) self.channel_info = f"IS-CAN: {self.channel}" + self._can_protocol = CanProtocol.CAN_20 if bitrate not in self.BAUDRATES: raise ValueError(f"Invalid bitrate, choose one of {set(self.BAUDRATES)}") @@ -106,12 +108,13 @@ def __init__( iscan.isCAN_DeviceInitEx(self.channel, self.BAUDRATES[bitrate]) super().__init__( - channel=channel, bitrate=bitrate, poll_interval=poll_interval, **kwargs + channel=channel, + bitrate=bitrate, + poll_interval=poll_interval, + **kwargs, ) - def _recv_internal( - self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + def _recv_internal(self, timeout: float | None) -> tuple[Message | None, bool]: raw_msg = MessageExStruct() end_time = time.time() + timeout if timeout is not None else None while True: @@ -141,7 +144,7 @@ def _recv_internal( ) return msg, False - def send(self, msg: Message, timeout: Optional[float] = None) -> None: + def send(self, msg: Message, timeout: float | None = None) -> None: raw_msg = MessageExStruct( msg.arbitration_id, bool(msg.is_extended_id), @@ -157,7 +160,6 @@ def shutdown(self) -> None: class IscanError(CanError): - ERROR_CODES = { 0: "Success", 1: "No access to device", diff --git a/can/interfaces/ixxat/__init__.py b/can/interfaces/ixxat/__init__.py index 1419d97a1..6fe79adb8 100644 --- a/can/interfaces/ixxat/__init__.py +++ b/can/interfaces/ixxat/__init__.py @@ -4,7 +4,18 @@ Copyright (C) 2016-2021 Giuseppe Corbelli """ +__all__ = [ + "IXXATBus", + "canlib", + "canlib_vcinpl", + "canlib_vcinpl2", + "constants", + "exceptions", + "get_ixxat_hwids", + "structures", +] + from can.interfaces.ixxat.canlib import IXXATBus -from can.interfaces.ixxat.canlib_vcinpl import ( - get_ixxat_hwids, -) # import this and not the one from vcinpl2 for backward compatibility + +# import this and not the one from vcinpl2 for backward compatibility +from can.interfaces.ixxat.canlib_vcinpl import get_ixxat_hwids diff --git a/can/interfaces/ixxat/canlib.py b/can/interfaces/ixxat/canlib.py index a20e4f59b..528e86d5e 100644 --- a/can/interfaces/ixxat/canlib.py +++ b/can/interfaces/ixxat/canlib.py @@ -1,10 +1,13 @@ +from collections.abc import Callable, Sequence + import can.interfaces.ixxat.canlib_vcinpl as vcinpl import can.interfaces.ixxat.canlib_vcinpl2 as vcinpl2 - -from can import BusABC, Message -from can.bus import BusState - -from typing import Optional +from can import ( + BusABC, + BusState, + CyclicSendTaskABC, + Message, +) class IXXATBus(BusABC): @@ -22,21 +25,21 @@ def __init__( channel: int, can_filters=None, receive_own_messages: bool = False, - unique_hardware_id: Optional[int] = None, + unique_hardware_id: int | None = None, extended: bool = True, fd: bool = False, - rx_fifo_size: int = None, - tx_fifo_size: int = None, + rx_fifo_size: int | None = None, + tx_fifo_size: int | None = None, bitrate: int = 500000, data_bitrate: int = 2000000, - sjw_abr: int = None, - tseg1_abr: int = None, - tseg2_abr: int = None, - sjw_dbr: int = None, - tseg1_dbr: int = None, - tseg2_dbr: int = None, - ssp_dbr: int = None, - **kwargs + sjw_abr: int | None = None, + tseg1_abr: int | None = None, + tseg2_abr: int | None = None, + sjw_dbr: int | None = None, + tseg1_dbr: int | None = None, + tseg2_dbr: int | None = None, + ssp_dbr: int | None = None, + **kwargs, ): """ :param channel: @@ -113,7 +116,7 @@ def __init__( tseg1_dbr=tseg1_dbr, tseg2_dbr=tseg2_dbr, ssp_dbr=ssp_dbr, - **kwargs + **kwargs, ) else: if rx_fifo_size is None: @@ -129,9 +132,12 @@ def __init__( rx_fifo_size=rx_fifo_size, tx_fifo_size=tx_fifo_size, bitrate=bitrate, - **kwargs + **kwargs, ) + super().__init__(channel=channel, **kwargs) + self._can_protocol = self.bus.protocol + def flush_tx_buffer(self): """Flushes the transmit buffer on the IXXAT""" return self.bus.flush_tx_buffer() @@ -140,13 +146,23 @@ def _recv_internal(self, timeout): """Read a message from IXXAT device.""" return self.bus._recv_internal(timeout) - def send(self, msg: Message, timeout: Optional[float] = None) -> None: + def send(self, msg: Message, timeout: float | None = None) -> None: return self.bus.send(msg, timeout) - def _send_periodic_internal(self, msgs, period, duration=None): - return self.bus._send_periodic_internal(msgs, period, duration) + def _send_periodic_internal( + self, + msgs: Sequence[Message] | Message, + period: float, + duration: float | None = None, + autostart: bool = True, + modifier_callback: Callable[[Message], None] | None = None, + ) -> CyclicSendTaskABC: + return self.bus._send_periodic_internal( + msgs, period, duration, autostart, modifier_callback + ) def shutdown(self) -> None: + super().shutdown() self.bus.shutdown() @property @@ -155,3 +171,7 @@ def state(self) -> BusState: Return the current state of the hardware """ return self.bus.state + + @staticmethod + def _detect_available_configs() -> Sequence[vcinpl.AutoDetectedIxxatConfig]: + return vcinpl._detect_available_configs() diff --git a/can/interfaces/ixxat/canlib_vcinpl.py b/can/interfaces/ixxat/canlib_vcinpl.py index 8304a6dd7..7c4becafd 100644 --- a/can/interfaces/ixxat/canlib_vcinpl.py +++ b/can/interfaces/ixxat/canlib_vcinpl.py @@ -13,27 +13,34 @@ import functools import logging import sys -from typing import Optional, Callable, Tuple - -from can import BusABC, Message -from can.bus import BusState -from can.exceptions import CanInterfaceNotImplementedError, CanInitializationError -from can.broadcastmanager import ( +import time +import warnings +from collections.abc import Callable, Sequence + +from can import ( + BusABC, + BusState, + CanProtocol, + CyclicSendTaskABC, LimitedDurationCyclicSendTaskABC, + Message, RestartableCyclicTaskABC, ) -from can.ctypesutil import CLibrary, HANDLE, PHANDLE, HRESULT as ctypes_HRESULT +from can.ctypesutil import HANDLE, PHANDLE, CLibrary +from can.ctypesutil import HRESULT as ctypes_HRESULT +from can.exceptions import CanInitializationError, CanInterfaceNotImplementedError +from can.typechecking import AutoDetectedConfig from can.util import deprecated_args_alias from . import constants, structures from .exceptions import * __all__ = [ - "VCITimeout", - "VCIError", + "IXXATBus", "VCIBusOffError", "VCIDeviceNotFoundError", - "IXXATBus", + "VCIError", + "VCITimeout", "vciFormatError", ] @@ -58,7 +65,7 @@ def __vciFormatErrorExtended( - library_instance: CLibrary, function: Callable, vret: int, args: Tuple + library_instance: CLibrary, function: Callable, vret: int, args: tuple ): """Format a VCI error and attach failed function, decoded HRESULT and arguments :param CLibrary library_instance: @@ -73,8 +80,8 @@ def __vciFormatErrorExtended( Formatted string """ # TODO: make sure we don't generate another exception - return "{} - arguments were {}".format( - __vciFormatError(library_instance, function, vret), args + return ( + f"{__vciFormatError(library_instance, function, vret)} - arguments were {args}" ) @@ -428,7 +435,7 @@ def __init__( channel: int, can_filters=None, receive_own_messages: bool = False, - unique_hardware_id: Optional[int] = None, + unique_hardware_id: int | None = None, extended: bool = True, rx_fifo_size: int = 16, tx_fifo_size: int = 16, @@ -489,6 +496,7 @@ def __init__( self._channel_capabilities = structures.CANCAPABILITIES() self._message = structures.CANMSG() self._payload = (ctypes.c_byte * 8)() + self._can_protocol = CanProtocol.CAN_20 # Search for supplied device if unique_hardware_id is None: @@ -505,13 +513,11 @@ def __init__( if unique_hardware_id is None: raise VCIDeviceNotFoundError( "No IXXAT device(s) connected or device(s) in use by other process(es)." - ) + ) from None else: raise VCIDeviceNotFoundError( - "Unique HW ID {} not connected or not available.".format( - unique_hardware_id - ) - ) + f"Unique HW ID {unique_hardware_id} not connected or not available." + ) from None else: if (unique_hardware_id is None) or ( self._device_info.UniqueHardwareId.AsChar @@ -614,7 +620,15 @@ def __init__( log.info("Accepting ID: 0x%X MASK: 0x%X", code, mask) # Start the CAN controller. Messages will be forwarded to the channel + start_begin = time.time() _canlib.canControlStart(self._control_handle, constants.TRUE) + start_end = time.time() + + # Calculate an offset to make them relative to epoch + # Assume that the time offset is in the middle of the start command + self._timeoffset = start_begin + (start_end - start_begin / 2) + self._overrunticks = 0 + self._starttickoffset = 0 # For cyclic transmit list. Set when .send_periodic() is first called self._scheduler = None @@ -687,6 +701,9 @@ def _recv_internal(self, timeout): f"Unknown CAN info message code {self._message.abData[0]}", ) ) + # Handle CAN start info message + if self._message.abData[0] == constants.CAN_INFO_START: + self._starttickoffset = self._message.dwTime elif self._message.uMsgInfo.Bits.type == constants.CAN_MSGTYPE_ERROR: if self._message.uMsgInfo.Bytes.bFlags & constants.CAN_MSGFLAGS_OVR: log.warning("CAN error: data overrun") @@ -703,7 +720,8 @@ def _recv_internal(self, timeout): self._message.uMsgInfo.Bytes.bFlags, ) elif self._message.uMsgInfo.Bits.type == constants.CAN_MSGTYPE_TIMEOVR: - pass + # Add the number of timestamp overruns to the high word + self._overrunticks += self._message.dwMsgId << 32 else: log.warning( "Unexpected message info type 0x%X", @@ -735,11 +753,12 @@ def _recv_internal(self, timeout): # Timed out / can message type is not DATA return None, True - # The _message.dwTime is a 32bit tick value and will overrun, - # so expect to see the value restarting from 0 rx_msg = Message( - timestamp=self._message.dwTime - / self._tick_resolution, # Relative time in s + timestamp=( + (self._message.dwTime + self._overrunticks - self._starttickoffset) + / self._tick_resolution + ) + + self._timeoffset, is_remote_frame=bool(self._message.uMsgInfo.Bits.rtr), is_extended_id=bool(self._message.uMsgInfo.Bits.ext), arbitration_id=self._message.dwMsgId, @@ -750,7 +769,7 @@ def _recv_internal(self, timeout): return rx_msg, True - def send(self, msg: Message, timeout: Optional[float] = None) -> None: + def send(self, msg: Message, timeout: float | None = None) -> None: """ Sends a message on the bus. The interface may buffer the message. @@ -783,20 +802,51 @@ def send(self, msg: Message, timeout: Optional[float] = None) -> None: # Want to log outgoing messages? # log.log(self.RECV_LOGGING_LEVEL, "Sent: %s", message) - def _send_periodic_internal(self, msgs, period, duration=None): + def _send_periodic_internal( + self, + msgs: Sequence[Message] | Message, + period: float, + duration: float | None = None, + autostart: bool = True, + modifier_callback: Callable[[Message], None] | None = None, + ) -> CyclicSendTaskABC: """Send a message using built-in cyclic transmit list functionality.""" - if self._scheduler is None: - self._scheduler = HANDLE() - _canlib.canSchedulerOpen(self._device_handle, self.channel, self._scheduler) - caps = structures.CANCAPABILITIES() - _canlib.canSchedulerGetCaps(self._scheduler, caps) - self._scheduler_resolution = caps.dwClockFreq / caps.dwCmsDivisor - _canlib.canSchedulerActivate(self._scheduler, constants.TRUE) - return CyclicSendTask( - self._scheduler, msgs, period, duration, self._scheduler_resolution + if modifier_callback is None: + if self._scheduler is None: + self._scheduler = HANDLE() + _canlib.canSchedulerOpen( + self._device_handle, self.channel, self._scheduler + ) + caps = structures.CANCAPABILITIES() + _canlib.canSchedulerGetCaps(self._scheduler, caps) + self._scheduler_resolution = caps.dwClockFreq / caps.dwCmsDivisor + _canlib.canSchedulerActivate(self._scheduler, constants.TRUE) + return CyclicSendTask( + self._scheduler, + msgs, + period, + duration, + self._scheduler_resolution, + autostart=autostart, + ) + + # fallback to thread based cyclic task + warnings.warn( + f"{self.__class__.__name__} falls back to a thread-based cyclic task, " + "when the `modifier_callback` argument is given.", + stacklevel=3, + ) + return BusABC._send_periodic_internal( + self, + msgs=msgs, + period=period, + duration=duration, + autostart=autostart, + modifier_callback=modifier_callback, ) def shutdown(self): + super().shutdown() if self._scheduler is not None: _canlib.canSchedulerClose(self._scheduler) _canlib.canChannelClose(self._channel_handle) @@ -823,7 +873,7 @@ def state(self) -> BusState: error_byte_2 = status.dwStatus & 0xF0 # CAN_STATUS_BUSCERR = 0x20 # bus coupling error if error_byte_2 & constants.CAN_STATUS_BUSCERR: - raise BusState.ERROR + return BusState.ERROR return BusState.ACTIVE @@ -834,7 +884,15 @@ def state(self) -> BusState: class CyclicSendTask(LimitedDurationCyclicSendTaskABC, RestartableCyclicTaskABC): """A message in the cyclic transmit list.""" - def __init__(self, scheduler, msgs, period, duration, resolution): + def __init__( + self, + scheduler, + msgs, + period, + duration, + resolution, + autostart: bool = True, + ): super().__init__(msgs, period, duration) if len(self.messages) != 1: raise ValueError( @@ -846,7 +904,7 @@ def __init__(self, scheduler, msgs, period, duration, resolution): self._count = int(duration / period) if duration else 0 self._msg = structures.CANCYCLICTXMSG() - self._msg.wCycleTime = int(round(period * resolution)) + self._msg.wCycleTime = round(period * resolution) self._msg.dwMsgId = self.messages[0].arbitration_id self._msg.uMsgInfo.Bits.type = constants.CAN_MSGTYPE_DATA self._msg.uMsgInfo.Bits.ext = 1 if self.messages[0].is_extended_id else 0 @@ -854,7 +912,8 @@ def __init__(self, scheduler, msgs, period, duration, resolution): self._msg.uMsgInfo.Bits.dlc = self.messages[0].dlc for i, b in enumerate(self.messages[0].data): self._msg.abData[i] = b - self.start() + if autostart: + self.start() def start(self): """Start transmitting message (add to list if needed).""" @@ -914,3 +973,59 @@ def get_ixxat_hwids(): _canlib.vciEnumDeviceClose(device_handle) return hwids + + +def _detect_available_configs() -> Sequence["AutoDetectedIxxatConfig"]: + config_list = [] # list in which to store the resulting bus kwargs + + # used to detect HWID + device_handle = HANDLE() + device_info = structures.VCIDEVICEINFO() + + # used to attempt to open channels + channel_handle = HANDLE() + device_handle2 = HANDLE() + + try: + _canlib.vciEnumDeviceOpen(ctypes.byref(device_handle)) + while True: + try: + _canlib.vciEnumDeviceNext(device_handle, ctypes.byref(device_info)) + except StopIteration: + break + else: + hwid = device_info.UniqueHardwareId.AsChar.decode("ascii") + _canlib.vciDeviceOpen( + ctypes.byref(device_info.VciObjectId), + ctypes.byref(device_handle2), + ) + for channel in range(4): + try: + _canlib.canChannelOpen( + device_handle2, + channel, + constants.FALSE, + ctypes.byref(channel_handle), + ) + except Exception: + # Array outside of bounds error == accessing a channel not in the hardware + break + else: + _canlib.canChannelClose(channel_handle) + config_list.append( + { + "interface": "ixxat", + "channel": channel, + "unique_hardware_id": hwid, + } + ) + _canlib.vciDeviceClose(device_handle2) + _canlib.vciEnumDeviceClose(device_handle) + except AttributeError: + pass # _canlib is None in the CI tests -> return a blank list + + return config_list + + +class AutoDetectedIxxatConfig(AutoDetectedConfig): + unique_hardware_id: int diff --git a/can/interfaces/ixxat/canlib_vcinpl2.py b/can/interfaces/ixxat/canlib_vcinpl2.py index b8ed916dc..b6789885a 100644 --- a/can/interfaces/ixxat/canlib_vcinpl2.py +++ b/can/interfaces/ixxat/canlib_vcinpl2.py @@ -13,35 +13,37 @@ import functools import logging import sys -from typing import Optional, Callable, Tuple - -from can import BusABC, Message -from can.exceptions import CanInterfaceNotImplementedError, CanInitializationError -from can.broadcastmanager import ( +import time +import warnings +from collections.abc import Callable, Sequence + +from can import ( + BusABC, + CanProtocol, + CyclicSendTaskABC, LimitedDurationCyclicSendTaskABC, + Message, RestartableCyclicTaskABC, ) -from can.ctypesutil import CLibrary, HANDLE, PHANDLE, HRESULT as ctypes_HRESULT - -import can.util -from can.util import deprecated_args_alias +from can.ctypesutil import HANDLE, PHANDLE, CLibrary +from can.ctypesutil import HRESULT as ctypes_HRESULT +from can.exceptions import CanInitializationError, CanInterfaceNotImplementedError +from can.util import deprecated_args_alias, dlc2len, len2dlc from . import constants, structures from .exceptions import * __all__ = [ - "VCITimeout", - "VCIError", + "IXXATBus", "VCIBusOffError", "VCIDeviceNotFoundError", - "IXXATBus", + "VCIError", + "VCITimeout", "vciFormatError", ] log = logging.getLogger("can.ixxat") -from time import perf_counter - # Hack to have vciFormatError as a free function, see below vciFormatError = None @@ -60,7 +62,7 @@ def __vciFormatErrorExtended( - library_instance: CLibrary, function: Callable, vret: int, args: Tuple + library_instance: CLibrary, function: Callable, vret: int, args: tuple ): """Format a VCI error and attach failed function, decoded HRESULT and arguments :param CLibrary library_instance: @@ -75,8 +77,8 @@ def __vciFormatErrorExtended( Formatted string """ # TODO: make sure we don't generate another exception - return "{} - arguments were {}".format( - __vciFormatError(library_instance, function, vret), args + return ( + f"{__vciFormatError(library_instance, function, vret)} - arguments were {args}" ) @@ -141,7 +143,7 @@ def __check_status(result, function, args): _canlib.map_symbol( "vciFormatError", None, (ctypes_HRESULT, ctypes.c_char_p, ctypes.c_uint32) ) - except: + except ImportError: _canlib.map_symbol( "vciFormatErrorA", None, (ctypes_HRESULT, ctypes.c_char_p, ctypes.c_uint32) ) @@ -428,19 +430,19 @@ def __init__( channel: int, can_filters=None, receive_own_messages: int = False, - unique_hardware_id: Optional[int] = None, + unique_hardware_id: int | None = None, extended: bool = True, rx_fifo_size: int = 1024, tx_fifo_size: int = 128, bitrate: int = 500000, data_bitrate: int = 2000000, - sjw_abr: int = None, - tseg1_abr: int = None, - tseg2_abr: int = None, - sjw_dbr: int = None, - tseg1_dbr: int = None, - tseg2_dbr: int = None, - ssp_dbr: int = None, + sjw_abr: int | None = None, + tseg1_abr: int | None = None, + tseg2_abr: int | None = None, + sjw_dbr: int | None = None, + tseg1_dbr: int | None = None, + tseg2_dbr: int | None = None, + ssp_dbr: int | None = None, **kwargs, ): """ @@ -507,17 +509,15 @@ def __init__( tseg1_abr is None or tseg2_abr is None or sjw_abr is None ): raise ValueError( - "To use bitrate {} (that has not predefined preset) is mandatory to use also parameters tseg1_abr, tseg2_abr and swj_abr".format( - bitrate - ) + f"To use bitrate {bitrate} (that has not predefined preset) is mandatory " + f"to use also parameters tseg1_abr, tseg2_abr and swj_abr" ) if data_bitrate not in constants.CAN_DATABITRATE_PRESETS and ( tseg1_dbr is None or tseg2_dbr is None or sjw_dbr is None ): raise ValueError( - "To use data_bitrate {} (that has not predefined preset) is mandatory to use also parameters tseg1_dbr, tseg2_dbr and swj_dbr".format( - data_bitrate - ) + f"To use data_bitrate {data_bitrate} (that has not predefined preset) is mandatory " + f"to use also parameters tseg1_dbr, tseg2_dbr and swj_dbr" ) if rx_fifo_size <= 0: @@ -536,6 +536,7 @@ def __init__( self._channel_capabilities = structures.CANCAPABILITIES2() self._message = structures.CANMSG2() self._payload = (ctypes.c_byte * 64)() + self._can_protocol = CanProtocol.CAN_FD # Search for supplied device if unique_hardware_id is None: @@ -552,13 +553,11 @@ def __init__( if unique_hardware_id is None: raise VCIDeviceNotFoundError( "No IXXAT device(s) connected or device(s) in use by other process(es)." - ) + ) from None else: raise VCIDeviceNotFoundError( - "Unique HW ID {} not connected or not available.".format( - unique_hardware_id - ) - ) + f"Unique HW ID {unique_hardware_id} not connected or not available." + ) from None else: if (unique_hardware_id is None) or ( self._device_info.UniqueHardwareId.AsChar @@ -578,7 +577,9 @@ def __init__( ctypes.byref(self._device_handle), ) except Exception as exception: - raise CanInitializationError(f"Could not open device: {exception}") + raise CanInitializationError( + f"Could not open device: {exception}" + ) from exception log.info("Using unique HW ID %s", self._device_info.UniqueHardwareId.AsChar) @@ -599,7 +600,7 @@ def __init__( except Exception as exception: raise CanInitializationError( f"Could not open and initialize channel: {exception}" - ) + ) from exception # Signal TX/RX events when at least one frame has been handled _canlib.canChannelInitialize( @@ -725,7 +726,15 @@ def __init__( log.info("Accepting ID: 0x%X MASK: 0x%X", code, mask) # Start the CAN controller. Messages will be forwarded to the channel + start_begin = time.time() _canlib.canControlStart(self._control_handle, constants.TRUE) + start_end = time.time() + + # Calculate an offset to make them relative to epoch + # Assume that the time offset is in the middle of the start command + self._timeoffset = start_begin + (start_end - start_begin / 2) + self._overrunticks = 0 + self._starttickoffset = 0 # For cyclic transmit list. Set when .send_periodic() is first called self._scheduler = None @@ -808,7 +817,7 @@ def _recv_internal(self, timeout): else: timeout_ms = int(timeout * 1000) remaining_ms = timeout_ms - t0 = perf_counter() + t0 = time.perf_counter() while True: try: @@ -830,7 +839,9 @@ def _recv_internal(self, timeout): f"Unknown CAN info message code {self._message.abData[0]}", ) ) - + # Handle CAN start info message + elif self._message.abData[0] == constants.CAN_INFO_START: + self._starttickoffset = self._message.dwTime elif ( self._message.uMsgInfo.Bits.type == constants.CAN_MSGTYPE_ERROR ): @@ -852,12 +863,13 @@ def _recv_internal(self, timeout): self._message.uMsgInfo.Bits.type == constants.CAN_MSGTYPE_TIMEOVR ): - pass + # Add the number of timestamp overruns to the high word + self._overrunticks += self._message.dwMsgId << 32 else: log.warning("Unexpected message info type") if t0 is not None: - remaining_ms = timeout_ms - int((perf_counter() - t0) * 1000) + remaining_ms = timeout_ms - int((time.perf_counter() - t0) * 1000) if remaining_ms < 0: break @@ -865,12 +877,13 @@ def _recv_internal(self, timeout): # Timed out / can message type is not DATA return None, True - data_len = can.util.dlc2len(self._message.uMsgInfo.Bits.dlc) - # The _message.dwTime is a 32bit tick value and will overrun, - # so expect to see the value restarting from 0 + data_len = dlc2len(self._message.uMsgInfo.Bits.dlc) rx_msg = Message( - timestamp=self._message.dwTime - / self._tick_resolution, # Relative time in s + timestamp=( + (self._message.dwTime + self._overrunticks - self._starttickoffset) + / self._tick_resolution + ) + + self._timeoffset, is_remote_frame=bool(self._message.uMsgInfo.Bits.rtr), is_fd=bool(self._message.uMsgInfo.Bits.edl), is_rx=True, @@ -888,7 +901,7 @@ def _recv_internal(self, timeout): return rx_msg, True - def send(self, msg: Message, timeout: Optional[float] = None) -> None: + def send(self, msg: Message, timeout: float | None = None) -> None: """ Sends a message on the bus. The interface may buffer the message. @@ -915,7 +928,7 @@ def send(self, msg: Message, timeout: Optional[float] = None) -> None: message.uMsgInfo.Bits.edl = 1 if msg.is_fd else 0 message.dwMsgId = msg.arbitration_id if msg.dlc: # this dlc means number of bytes of payload - message.uMsgInfo.Bits.dlc = can.util.len2dlc(msg.dlc) + message.uMsgInfo.Bits.dlc = len2dlc(msg.dlc) data_len_dif = msg.dlc - len(msg.data) data = msg.data + bytearray( [0] * data_len_dif @@ -931,22 +944,53 @@ def send(self, msg: Message, timeout: Optional[float] = None) -> None: else: _canlib.canChannelPostMessage(self._channel_handle, message) - def _send_periodic_internal(self, msgs, period, duration=None): + def _send_periodic_internal( + self, + msgs: Sequence[Message] | Message, + period: float, + duration: float | None = None, + autostart: bool = True, + modifier_callback: Callable[[Message], None] | None = None, + ) -> CyclicSendTaskABC: """Send a message using built-in cyclic transmit list functionality.""" - if self._scheduler is None: - self._scheduler = HANDLE() - _canlib.canSchedulerOpen(self._device_handle, self.channel, self._scheduler) - caps = structures.CANCAPABILITIES2() - _canlib.canSchedulerGetCaps(self._scheduler, caps) - self._scheduler_resolution = ( - caps.dwCmsClkFreq / caps.dwCmsDivisor - ) # TODO: confirm - _canlib.canSchedulerActivate(self._scheduler, constants.TRUE) - return CyclicSendTask( - self._scheduler, msgs, period, duration, self._scheduler_resolution + if modifier_callback is None: + if self._scheduler is None: + self._scheduler = HANDLE() + _canlib.canSchedulerOpen( + self._device_handle, self.channel, self._scheduler + ) + caps = structures.CANCAPABILITIES2() + _canlib.canSchedulerGetCaps(self._scheduler, caps) + self._scheduler_resolution = ( + caps.dwCmsClkFreq / caps.dwCmsDivisor + ) # TODO: confirm + _canlib.canSchedulerActivate(self._scheduler, constants.TRUE) + return CyclicSendTask( + self._scheduler, + msgs, + period, + duration, + self._scheduler_resolution, + autostart=autostart, + ) + + # fallback to thread based cyclic task + warnings.warn( + f"{self.__class__.__name__} falls back to a thread-based cyclic task, " + "when the `modifier_callback` argument is given.", + stacklevel=3, + ) + return BusABC._send_periodic_internal( + self, + msgs=msgs, + period=period, + duration=duration, + autostart=autostart, + modifier_callback=modifier_callback, ) def shutdown(self): + super().shutdown() if self._scheduler is not None: _canlib.canSchedulerClose(self._scheduler) _canlib.canChannelClose(self._channel_handle) @@ -958,7 +1002,15 @@ def shutdown(self): class CyclicSendTask(LimitedDurationCyclicSendTaskABC, RestartableCyclicTaskABC): """A message in the cyclic transmit list.""" - def __init__(self, scheduler, msgs, period, duration, resolution): + def __init__( + self, + scheduler, + msgs, + period, + duration, + resolution, + autostart: bool = True, + ): super().__init__(msgs, period, duration) if len(self.messages) != 1: raise ValueError( @@ -970,7 +1022,7 @@ def __init__(self, scheduler, msgs, period, duration, resolution): self._count = int(duration / period) if duration else 0 self._msg = structures.CANCYCLICTXMSG2() - self._msg.wCycleTime = int(round(period * resolution)) + self._msg.wCycleTime = round(period * resolution) self._msg.dwMsgId = self.messages[0].arbitration_id self._msg.uMsgInfo.Bits.type = constants.CAN_MSGTYPE_DATA self._msg.uMsgInfo.Bits.ext = 1 if self.messages[0].is_extended_id else 0 @@ -978,7 +1030,8 @@ def __init__(self, scheduler, msgs, period, duration, resolution): self._msg.uMsgInfo.Bits.dlc = self.messages[0].dlc for i, b in enumerate(self.messages[0].data): self._msg.abData[i] = b - self.start() + if autostart: + self.start() def start(self): """Start transmitting message (add to list if needed).""" diff --git a/can/interfaces/ixxat/exceptions.py b/can/interfaces/ixxat/exceptions.py index 50b84dfa4..771eec307 100644 --- a/can/interfaces/ixxat/exceptions.py +++ b/can/interfaces/ixxat/exceptions.py @@ -12,11 +12,11 @@ ) __all__ = [ - "VCITimeout", - "VCIError", - "VCIRxQueueEmptyError", "VCIBusOffError", "VCIDeviceNotFoundError", + "VCIError", + "VCIRxQueueEmptyError", + "VCITimeout", ] diff --git a/can/interfaces/ixxat/structures.py b/can/interfaces/ixxat/structures.py index 419a52973..680ed1ac6 100644 --- a/can/interfaces/ixxat/structures.py +++ b/can/interfaces/ixxat/structures.py @@ -51,17 +51,17 @@ class UniqueHardwareId(ctypes.Union): ] def __str__(self): - return "Mfg: {}, Dev: {} HW: {}.{}.{}.{} Drv: {}.{}.{}.{}".format( - self.Manufacturer, - self.Description, - self.HardwareBranchVersion, - self.HardwareMajorVersion, - self.HardwareMinorVersion, - self.HardwareBuildVersion, - self.DriverReleaseVersion, - self.DriverMajorVersion, - self.DriverMinorVersion, - self.DriverBuildVersion, + return ( + f"Mfg: {self.Manufacturer}, " + f"Dev: {self.Description} " + f"HW: {self.HardwareBranchVersion}" + f".{self.HardwareMajorVersion}" + f".{self.HardwareMinorVersion}" + f".{self.HardwareBuildVersion} " + f"Drv: {self.DriverReleaseVersion}" + f".{self.DriverMajorVersion}" + f".{self.DriverMinorVersion}" + f".{self.DriverBuildVersion}" ) @@ -201,13 +201,13 @@ class CANBTP(ctypes.Structure): ] def __str__(self): - return "dwMode=%d, dwBPS=%d, wTS1=%d, wTS2=%d, wSJW=%d, wTDO=%d" % ( - self.dwMode, - self.dwBPS, - self.wTS1, - self.wTS2, - self.wSJW, - self.wTDO, + return ( + f"dwMode={self.dwMode:d}, " + f"dwBPS={self.dwBPS:d}, " + f"wTS1={self.wTS1:d}, " + f"wTS2={self.wTS2:d}, " + f"wSJW={self.wSJW:d}, " + f"wTDO={self.wTDO:d}" ) diff --git a/can/interfaces/kvaser/__init__.py b/can/interfaces/kvaser/__init__.py index 36a21db9f..7cfb13d8d 100644 --- a/can/interfaces/kvaser/__init__.py +++ b/can/interfaces/kvaser/__init__.py @@ -1,4 +1,13 @@ -""" -""" +""" """ + +__all__ = [ + "CANLIBInitializationError", + "CANLIBOperationError", + "KvaserBus", + "canlib", + "constants", + "get_channel_info", + "structures", +] from can.interfaces.kvaser.canlib import * diff --git a/can/interfaces/kvaser/canlib.py b/can/interfaces/kvaser/canlib.py index 2bbf8f0bf..4403b60ca 100644 --- a/can/interfaces/kvaser/canlib.py +++ b/can/interfaces/kvaser/canlib.py @@ -6,15 +6,16 @@ Copyright (C) 2010 Dynamic Controls """ +import ctypes +import logging import sys import time -import logging -import ctypes -from can import BusABC -from ...exceptions import CanError, CanInitializationError, CanOperationError -from can import Message -from can.util import time_perfcounter_correlation +from can import BitTiming, BitTimingFd, BusABC, CanProtocol, Message +from can.exceptions import CanError, CanInitializationError, CanOperationError +from can.typechecking import CanFilters +from can.util import check_or_adjust_timing_clock, time_perfcounter_correlation + from . import constants as canstat from . import structures @@ -62,7 +63,6 @@ def __get_canlib_function(func_name, argtypes=None, restype=None, errcheck=None) class CANLIBError(CanError): - """ Try to display errors that occur within the wrapped C library nicely. """ @@ -199,6 +199,17 @@ def __check_bus_handle_validity(handle, function, arguments): errcheck=__check_status_initialization, ) + canSetBusParamsC200 = __get_canlib_function( + "canSetBusParamsC200", + argtypes=[ + c_canHandle, + ctypes.c_byte, + ctypes.c_byte, + ], + restype=canstat.c_canStatus, + errcheck=__check_status_initialization, + ) + canSetBusParamsFd = __get_canlib_function( "canSetBusParamsFd", argtypes=[ @@ -360,7 +371,13 @@ class KvaserBus(BusABC): The CAN Bus implemented for the Kvaser interface. """ - def __init__(self, channel, can_filters=None, **kwargs): + def __init__( + self, + channel: int, + can_filters: CanFilters | None = None, + timing: BitTiming | BitTimingFd | None = None, + **kwargs, + ): """ :param int channel: The Channel id to create this bus with. @@ -370,6 +387,12 @@ def __init__(self, channel, can_filters=None, **kwargs): Backend Configuration + :param timing: + An instance of :class:`~can.BitTiming` or :class:`~can.BitTimingFd` + to specify the bit timing parameters for the Kvaser interface. If provided, it + takes precedence over the all other timing-related parameters. + Note that the `f_clock` property of the `timing` instance must be 16_000_000 (16MHz) + for standard CAN or 80_000_000 (80MHz) for CAN FD. :param int bitrate: Bitrate of channel in bit/s :param bool accept_virtual: @@ -404,10 +427,19 @@ def __init__(self, channel, can_filters=None, **kwargs): computer, set this to True or set single_handle to True. :param bool fd: If CAN-FD frames should be supported. + :param bool fd_non_iso: + Open the channel in Non-ISO (Bosch) FD mode. Only applies for FD buses. + This changes the handling of the stuff-bit counter and the CRC. Defaults + to False (ISO mode) + :param bool exclusive: + Don't allow sharing of this CANlib channel. + :param bool override_exclusive: + Open the channel even if it is opened for exclusive access already. :param int data_bitrate: Which bitrate to use for data phase in CAN FD. Defaults to arbitration bitrate. - + :param bool no_init_access: + Don't open the handle with init access. """ log.info(f"CAN Filters: {can_filters}") @@ -420,34 +452,53 @@ def __init__(self, channel, can_filters=None, **kwargs): driver_mode = kwargs.get("driver_mode", DRIVER_MODE_NORMAL) single_handle = kwargs.get("single_handle", False) receive_own_messages = kwargs.get("receive_own_messages", False) + exclusive = kwargs.get("exclusive", False) + override_exclusive = kwargs.get("override_exclusive", False) accept_virtual = kwargs.get("accept_virtual", True) - fd = kwargs.get("fd", False) + no_init_access = kwargs.get("no_init_access", False) + fd = isinstance(timing, BitTimingFd) if timing else kwargs.get("fd", False) data_bitrate = kwargs.get("data_bitrate", None) + fd_non_iso = kwargs.get("fd_non_iso", False) try: channel = int(channel) except ValueError: - raise ValueError("channel must be an integer") - self.channel = channel + raise ValueError("channel must be an integer") from None - log.debug("Initialising bus instance") + self.channel = channel self.single_handle = single_handle + self._can_protocol = CanProtocol.CAN_20 + if fd_non_iso: + self._can_protocol = CanProtocol.CAN_FD_NON_ISO + elif fd: + self._can_protocol = CanProtocol.CAN_FD + log.debug("Initialising bus instance") num_channels = ctypes.c_int(0) canGetNumberOfChannels(ctypes.byref(num_channels)) num_channels = int(num_channels.value) log.info("Found %d available channels", num_channels) for idx in range(num_channels): channel_info = get_channel_info(idx) + channel_info = f'{channel_info["device_name"]}, S/N {channel_info["serial"]} (#{channel_info["dongle_channel"]})' log.info("%d: %s", idx, channel_info) if idx == channel: self.channel_info = channel_info flags = 0 + if exclusive: + flags |= canstat.canOPEN_EXCLUSIVE + if override_exclusive: + flags |= canstat.canOPEN_OVERRIDE_EXCLUSIVE if accept_virtual: flags |= canstat.canOPEN_ACCEPT_VIRTUAL + if no_init_access: + flags |= canstat.canOPEN_NO_INIT_ACCESS if fd: - flags |= canstat.canOPEN_CAN_FD + if fd_non_iso: + flags |= canstat.canOPEN_CAN_FD_NONISO + else: + flags |= canstat.canOPEN_CAN_FD log.debug("Creating read handle to bus channel: %s", channel) self._read_handle = canOpenChannel(channel, flags) @@ -457,22 +508,43 @@ def __init__(self, channel, can_filters=None, **kwargs): ctypes.byref(ctypes.c_long(TIMESTAMP_RESOLUTION)), 4, ) - - if fd: - if "tseg1" not in kwargs and bitrate in BITRATE_FD: - # Use predefined bitrate for arbitration - bitrate = BITRATE_FD[bitrate] - if data_bitrate in BITRATE_FD: - # Use predefined bitrate for data - data_bitrate = BITRATE_FD[data_bitrate] - elif not data_bitrate: - # Use same bitrate for arbitration and data phase - data_bitrate = bitrate - canSetBusParamsFd(self._read_handle, data_bitrate, tseg1, tseg2, sjw) + if isinstance(timing, BitTimingFd): + timing = check_or_adjust_timing_clock(timing, [80_000_000]) + canSetBusParams( + self._read_handle, + timing.nom_bitrate, + timing.nom_tseg1, + timing.nom_tseg2, + timing.nom_sjw, + 1, + 0, + ) + canSetBusParamsFd( + self._read_handle, + timing.data_bitrate, + timing.data_tseg1, + timing.data_tseg2, + timing.data_sjw, + ) + elif isinstance(timing, BitTiming): + timing = check_or_adjust_timing_clock(timing, [16_000_000]) + canSetBusParamsC200(self._read_handle, timing.btr0, timing.btr1) else: - if "tseg1" not in kwargs and bitrate in BITRATE_OBJS: - bitrate = BITRATE_OBJS[bitrate] - canSetBusParams(self._read_handle, bitrate, tseg1, tseg2, sjw, no_samp, 0) + if fd: + if "tseg1" not in kwargs and bitrate in BITRATE_FD: + # Use predefined bitrate for arbitration + bitrate = BITRATE_FD[bitrate] + if data_bitrate in BITRATE_FD: + # Use predefined bitrate for data + data_bitrate = BITRATE_FD[data_bitrate] + elif not data_bitrate: + # Use same bitrate for arbitration and data phase + data_bitrate = bitrate + canSetBusParamsFd(self._read_handle, data_bitrate, tseg1, tseg2, sjw) + else: + if "tseg1" not in kwargs and bitrate in BITRATE_OBJS: + bitrate = BITRATE_OBJS[bitrate] + canSetBusParams(self._read_handle, bitrate, tseg1, tseg2, sjw, no_samp, 0) # By default, use local echo if single handle is used (see #160) local_echo = single_handle or receive_own_messages @@ -485,13 +557,26 @@ def __init__(self, channel, can_filters=None, **kwargs): 1, ) + # enable canMSG_LOCAL_TXACK flag in received messages + + canIoCtlInit( + self._read_handle, + canstat.canIOCTL_SET_LOCAL_TXACK, + ctypes.byref(ctypes.c_byte(local_echo)), + 1, + ) + if self.single_handle: log.debug("We don't require separate handles to the bus") self._write_handle = self._read_handle else: log.debug("Creating separate handle for TX on channel: %s", channel) - self._write_handle = canOpenChannel(channel, flags) - canBusOn(self._read_handle) + if exclusive: + flags_ = flags & ~canstat.canOPEN_EXCLUSIVE + flags_ |= canstat.canOPEN_OVERRIDE_EXCLUSIVE + else: + flags_ = flags + self._write_handle = canOpenChannel(channel, flags_) can_driver_mode = ( canstat.canDRIVER_SILENT @@ -499,9 +584,24 @@ def __init__(self, channel, can_filters=None, **kwargs): else canstat.canDRIVER_NORMAL ) canSetBusOutputControl(self._write_handle, can_driver_mode) - log.debug("Going bus on TX handle") + + self._is_filtered = False + super().__init__( + channel=channel, + can_filters=can_filters, + **kwargs, + ) + + # activate channel after CAN filters were applied + log.debug("Go on bus") + if not self.single_handle: + canBusOn(self._read_handle) canBusOn(self._write_handle) + # timestamp must be set after bus is online, otherwise kvReadTimer may return erroneous values + self._timestamp_offset = self._update_timestamp_offset() + + def _update_timestamp_offset(self) -> float: timer = ctypes.c_uint(0) try: if time.get_clock_info("time").resolution > 1e-5: @@ -509,18 +609,15 @@ def __init__(self, channel, can_filters=None, **kwargs): kvReadTimer(self._read_handle, ctypes.byref(timer)) current_perfcounter = time.perf_counter() now = ts + (current_perfcounter - perfcounter) - self._timestamp_offset = now - (timer.value * TIMESTAMP_FACTOR) + return now - (timer.value * TIMESTAMP_FACTOR) else: kvReadTimer(self._read_handle, ctypes.byref(timer)) - self._timestamp_offset = time.time() - (timer.value * TIMESTAMP_FACTOR) + return time.time() - (timer.value * TIMESTAMP_FACTOR) except Exception as exc: # timer is usually close to 0 log.info(str(exc)) - self._timestamp_offset = time.time() - (timer.value * TIMESTAMP_FACTOR) - - self._is_filtered = False - super().__init__(channel=channel, can_filters=can_filters, **kwargs) + return time.time() - (timer.value * TIMESTAMP_FACTOR) def _apply_filters(self, filters): if filters and len(filters) == 1: @@ -545,7 +642,7 @@ def _apply_filters(self, filters): for extended in (0, 1): canSetAcceptanceFilter(handle, 0, 0, extended) except (NotImplementedError, CANLIBError) as e: - log.error("An error occured while disabling filtering: %s", e) + log.error("An error occurred while disabling filtering: %s", e) def flush_tx_buffer(self): """Wipeout the transmit buffer on the Kvaser.""" @@ -586,6 +683,7 @@ def _recv_internal(self, timeout=None): is_remote_frame = bool(flags & canstat.canMSG_RTR) is_error_frame = bool(flags & canstat.canMSG_ERROR_FRAME) is_fd = bool(flags & canstat.canFDMSG_FDF) + is_rx = not bool(flags & canstat.canMSG_LOCAL_TXACK) bitrate_switch = bool(flags & canstat.canFDMSG_BRS) error_state_indicator = bool(flags & canstat.canFDMSG_ESI) msg_timestamp = timestamp.value * TIMESTAMP_FACTOR @@ -597,6 +695,7 @@ def _recv_internal(self, timeout=None): is_error_frame=is_error_frame, is_remote_frame=is_remote_frame, is_fd=is_fd, + is_rx=is_rx, bitrate_switch=bitrate_switch, error_state_indicator=error_state_indicator, channel=self.channel, @@ -686,16 +785,19 @@ def get_stats(self) -> structures.BusStatistics: @staticmethod def _detect_available_configs(): - num_channels = ctypes.c_int(0) + config_list = [] + try: + num_channels = ctypes.c_int(0) canGetNumberOfChannels(ctypes.byref(num_channels)) + + for channel in range(0, int(num_channels.value)): + info = get_channel_info(channel) + + config_list.append({"interface": "kvaser", "channel": channel, **info}) except (CANLIBError, NameError): pass - - return [ - {"interface": "kvaser", "channel": channel} - for channel in range(num_channels.value) - ] + return config_list def get_channel_info(channel): @@ -722,7 +824,11 @@ def get_channel_info(channel): ctypes.sizeof(number), ) - return f"{name.value.decode('ascii')}, S/N {serial.value} (#{number.value + 1})" + return { + "device_name": name.value.decode("ascii", errors="replace"), + "serial": serial.value, + "dongle_channel": number.value + 1, + } init_kvaser_library() diff --git a/can/interfaces/kvaser/constants.py b/can/interfaces/kvaser/constants.py index 9dd3a9163..dc710648c 100644 --- a/can/interfaces/kvaser/constants.py +++ b/can/interfaces/kvaser/constants.py @@ -63,6 +63,7 @@ def CANSTATUS_SUCCESS(status): canMSG_ERROR_FRAME = 0x0020 canMSG_TXACK = 0x0040 canMSG_TXRQ = 0x0080 +canMSG_LOCAL_TXACK = 0x1000_0000 canFDMSG_FDF = 0x010000 canFDMSG_BRS = 0x020000 @@ -161,6 +162,8 @@ def CANSTATUS_SUCCESS(status): canDRIVER_SELFRECEPTION = 8 canDRIVER_OFF = 0 +canOPEN_EXCLUSIVE = 0x0008 +canOPEN_REQUIRE_EXTENDED = 0x0010 canOPEN_ACCEPT_VIRTUAL = 0x0020 canOPEN_OVERRIDE_EXCLUSIVE = 0x0040 canOPEN_REQUIRE_INIT_ACCESS = 0x0080 @@ -193,6 +196,7 @@ def CANSTATUS_SUCCESS(status): canIOCTL_GET_USB_THROTTLE = 29 canIOCTL_SET_BUSON_TIME_AUTO_RESET = 30 canIOCTL_SET_LOCAL_TXECHO = 32 +canIOCTL_SET_LOCAL_TXACK = 46 canIOCTL_PREFER_EXT = 1 canIOCTL_PREFER_STD = 2 canIOCTL_CLEAR_ERROR_COUNTERS = 5 diff --git a/can/interfaces/kvaser/structures.py b/can/interfaces/kvaser/structures.py index 996f16c37..0229cc10a 100644 --- a/can/interfaces/kvaser/structures.py +++ b/can/interfaces/kvaser/structures.py @@ -23,16 +23,13 @@ class BusStatistics(ctypes.Structure): def __str__(self): return ( - "std_data: {}, std_remote: {}, ext_data: {}, ext_remote: {}, " - "err_frame: {}, bus_load: {:.1f}%, overruns: {}" - ).format( - self.std_data, - self.std_remote, - self.ext_data, - self.ext_remote, - self.err_frame, - self.bus_load / 100.0, - self.overruns, + f"std_data: {self.std_data}, " + f"std_remote: {self.std_remote}, " + f"ext_data: {self.ext_data}, " + f"ext_remote: {self.ext_remote}, " + f"err_frame: {self.err_frame}, " + f"bus_load: {self.bus_load / 100.0:.1f}%, " + f"overruns: {self.overruns}" ) @property diff --git a/can/interfaces/neousys/__init__.py b/can/interfaces/neousys/__init__.py index 3aa87332c..f3e0cb039 100644 --- a/can/interfaces/neousys/__init__.py +++ b/can/interfaces/neousys/__init__.py @@ -1,3 +1,8 @@ -""" Neousys CAN bus driver """ +"""Neousys CAN bus driver""" + +__all__ = [ + "NeousysBus", + "neousys", +] from can.interfaces.neousys.neousys import NeousysBus diff --git a/can/interfaces/neousys/neousys.py b/can/interfaces/neousys/neousys.py index 57f947aa4..7e8c877b4 100644 --- a/can/interfaces/neousys/neousys.py +++ b/can/interfaces/neousys/neousys.py @@ -1,4 +1,4 @@ -""" Neousys CAN bus driver """ +"""Neousys CAN bus driver""" # # This kind of interface can be found for example on Neousys POC-551VTC @@ -14,35 +14,35 @@ # pylint: disable=too-many-instance-attributes # pylint: disable=wrong-import-position -import queue import logging import platform -from time import time - +import queue from ctypes import ( - byref, CFUNCTYPE, + POINTER, + Structure, + byref, c_ubyte, c_uint, c_ushort, - POINTER, sizeof, - Structure, ) +from time import time try: from ctypes import WinDLL except ImportError: from ctypes import CDLL -from can import BusABC, Message -from ...exceptions import ( +from can import ( + BusABC, CanInitializationError, - CanOperationError, CanInterfaceNotImplementedError, + CanOperationError, + CanProtocol, + Message, ) - logger = logging.getLogger(__name__) @@ -151,6 +151,7 @@ def __init__(self, channel, device=0, bitrate=500000, **kwargs): self.channel = channel self.device = device self.channel_info = f"Neousys Can: device {self.device}, channel {self.channel}" + self._can_protocol = CanProtocol.CAN_20 self.queue = queue.Queue() @@ -238,5 +239,8 @@ def shutdown(self): @staticmethod def _detect_available_configs(): - # There is only one channel - return [{"interface": "neousys", "channel": 0}] + if NEOUSYS_CANLIB is None: + return [] + else: + # There is only one channel + return [{"interface": "neousys", "channel": 0}] diff --git a/can/interfaces/nican.py b/can/interfaces/nican.py index ea13e28e8..ba5b991c9 100644 --- a/can/interfaces/nican.py +++ b/can/interfaces/nican.py @@ -17,16 +17,16 @@ import logging import sys -from can import BusABC, Message import can.typechecking -from ..exceptions import ( +from can import ( + BusABC, CanError, + CanInitializationError, CanInterfaceNotImplementedError, CanOperationError, - CanInitializationError, + CanProtocol, + Message, ) -from typing import Optional, Tuple, Type - logger = logging.getLogger(__name__) @@ -88,7 +88,7 @@ class NicanError(CanError): def __init__(self, function, error_code: int, arguments) -> None: super().__init__( - message=f"{function} failed: {get_error_message(self.error_code)}", + message=f"{function} failed: {get_error_message(error_code)}", error_code=error_code, ) @@ -111,7 +111,7 @@ def check_status( result: int, function, arguments, - error_class: Type[NicanError] = NicanOperationError, + error_class: type[NicanError] = NicanOperationError, ) -> int: if result > 0: logger.warning(get_error_message(result)) @@ -186,8 +186,8 @@ class NicanBus(BusABC): def __init__( self, channel: str, - can_filters: Optional[can.typechecking.CanFilters] = None, - bitrate: Optional[int] = None, + can_filters: can.typechecking.CanFilters | None = None, + bitrate: int | None = None, log_errors: bool = True, **kwargs, ) -> None: @@ -219,6 +219,7 @@ def __init__( self.channel = channel self.channel_info = f"NI-CAN: {channel}" + self._can_protocol = CanProtocol.CAN_20 channel_bytes = channel.encode("ascii") config = [(NC_ATTR_START_ON_OPEN, True), (NC_ATTR_LOG_COMM_ERRS, log_errors)] @@ -277,9 +278,7 @@ def __init__( **kwargs, ) - def _recv_internal( - self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + def _recv_internal(self, timeout: float | None) -> tuple[Message | None, bool]: """ Read a message from a NI-CAN bus. @@ -328,7 +327,7 @@ def _recv_internal( ) return msg, True - def send(self, msg: Message, timeout: Optional[float] = None) -> None: + def send(self, msg: Message, timeout: float | None = None) -> None: """ Send a message to NI-CAN. diff --git a/can/interfaces/nixnet.py b/can/interfaces/nixnet.py index 6c3e63697..ec303a364 100644 --- a/can/interfaces/nixnet.py +++ b/can/interfaces/nixnet.py @@ -11,22 +11,23 @@ import logging import os import time +import warnings from queue import SimpleQueue from types import ModuleType -from typing import Optional, List, Union, Tuple, Any +from typing import Any import can.typechecking -from can import BusABC, Message, BitTiming, BitTimingFd +from can import BitTiming, BitTimingFd, BusABC, CanProtocol, Message from can.exceptions import ( CanInitializationError, - CanOperationError, CanInterfaceNotImplementedError, + CanOperationError, ) from can.util import check_or_adjust_timing_clock, deprecated_args_alias logger = logging.getLogger(__name__) -nixnet: Optional[ModuleType] = None +nixnet: ModuleType | None = None try: import nixnet # type: ignore import nixnet.constants # type: ignore @@ -51,12 +52,12 @@ def __init__( self, channel: str = "CAN1", bitrate: int = 500_000, - timing: Optional[Union[BitTiming, BitTimingFd]] = None, - can_filters: Optional[can.typechecking.CanFilters] = None, + timing: BitTiming | BitTimingFd | None = None, + can_filters: can.typechecking.CanFilters | None = None, receive_own_messages: bool = False, can_termination: bool = False, fd: bool = False, - fd_bitrate: Optional[int] = None, + fd_bitrate: int | None = None, poll_interval: float = 0.001, **kwargs: Any, ) -> None: @@ -103,10 +104,11 @@ def __init__( self.poll_interval = poll_interval - self.fd = isinstance(timing, BitTimingFd) if timing else fd + is_fd = isinstance(timing, BitTimingFd) if timing else fd + self._can_protocol = CanProtocol.CAN_FD if is_fd else CanProtocol.CAN_20 # Set database for the initialization - database_name = ":can_fd_brs:" if self.fd else ":memory:" + database_name = ":can_fd_brs:" if is_fd else ":memory:" try: # We need two sessions for this application, @@ -158,7 +160,7 @@ def __init__( if bitrate: self._interface.baud_rate = bitrate - if self.fd: + if is_fd: # See page 951 of NI-XNET Hardware and Software Manual # to set custom can configuration self._interface.can_fd_baud_rate = fd_bitrate or bitrate @@ -188,9 +190,18 @@ def __init__( **kwargs, ) - def _recv_internal( - self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + @property + def fd(self) -> bool: + class_name = self.__class__.__name__ + warnings.warn( + f"The {class_name}.fd property is deprecated and superseded by " + f"{class_name}.protocol. It is scheduled for removal in python-can version 5.0.", + DeprecationWarning, + stacklevel=2, + ) + return self._can_protocol is CanProtocol.CAN_FD + + def _recv_internal(self, timeout: float | None) -> tuple[Message | None, bool]: end_time = time.perf_counter() + timeout if timeout is not None else None while True: @@ -243,7 +254,7 @@ def _recv_internal( ) return msg, False - def send(self, msg: Message, timeout: Optional[float] = None) -> None: + def send(self, msg: Message, timeout: float | None = None) -> None: """ Send a message using NI-XNET. @@ -314,7 +325,7 @@ def shutdown(self) -> None: self._session_receive.close() @staticmethod - def _detect_available_configs() -> List[can.typechecking.AutoDetectedConfig]: + def _detect_available_configs() -> list[can.typechecking.AutoDetectedConfig]: configs = [] try: diff --git a/can/interfaces/pcan/__init__.py b/can/interfaces/pcan/__init__.py index 0f28b0ffe..a3eafcb4d 100644 --- a/can/interfaces/pcan/__init__.py +++ b/can/interfaces/pcan/__init__.py @@ -1,4 +1,10 @@ -""" -""" +""" """ + +__all__ = [ + "PcanBus", + "PcanError", + "basic", + "pcan", +] from can.interfaces.pcan.pcan import PcanBus, PcanError diff --git a/can/interfaces/pcan/basic.py b/can/interfaces/pcan/basic.py index b2624802a..4d175c645 100644 --- a/can/interfaces/pcan/basic.py +++ b/can/interfaces/pcan/basic.py @@ -15,19 +15,15 @@ # more Info at http://www.peak-system.com # Module Imports +import logging +import platform from ctypes import * from ctypes.util import find_library -import platform - -import logging PLATFORM = platform.system() IS_WINDOWS = PLATFORM == "Windows" IS_LINUX = PLATFORM == "Linux" -if IS_WINDOWS: - import winreg - logger = logging.getLogger("can.pcan") # /////////////////////////////////////////////////////////// @@ -298,73 +294,63 @@ # PCAN parameter values # -PCAN_PARAMETER_OFF = int(0x00) # The PCAN parameter is not set (inactive) -PCAN_PARAMETER_ON = int(0x01) # The PCAN parameter is set (active) -PCAN_FILTER_CLOSE = int(0x00) # The PCAN filter is closed. No messages will be received -PCAN_FILTER_OPEN = int( - 0x01 -) # The PCAN filter is fully opened. All messages will be received -PCAN_FILTER_CUSTOM = int( - 0x02 -) # The PCAN filter is custom configured. Only registered messages will be received -PCAN_CHANNEL_UNAVAILABLE = int( - 0x00 -) # The PCAN-Channel handle is illegal, or its associated hardware is not available -PCAN_CHANNEL_AVAILABLE = int( - 0x01 -) # The PCAN-Channel handle is available to be connected (PnP Hardware: it means furthermore that the hardware is plugged-in) -PCAN_CHANNEL_OCCUPIED = int( - 0x02 -) # The PCAN-Channel handle is valid, and is already being used +PCAN_PARAMETER_OFF = 0x00 # The PCAN parameter is not set (inactive) +PCAN_PARAMETER_ON = 0x01 # The PCAN parameter is set (active) +PCAN_FILTER_CLOSE = 0x00 # The PCAN filter is closed. No messages will be received +PCAN_FILTER_OPEN = ( + 0x01 # The PCAN filter is fully opened. All messages will be received +) +PCAN_FILTER_CUSTOM = 0x02 # The PCAN filter is custom configured. Only registered messages will be received +PCAN_CHANNEL_UNAVAILABLE = 0x00 # The PCAN-Channel handle is illegal, or its associated hardware is not available +PCAN_CHANNEL_AVAILABLE = 0x01 # The PCAN-Channel handle is available to be connected (PnP Hardware: it means furthermore that the hardware is plugged-in) +PCAN_CHANNEL_OCCUPIED = ( + 0x02 # The PCAN-Channel handle is valid, and is already being used +) PCAN_CHANNEL_PCANVIEW = ( PCAN_CHANNEL_AVAILABLE | PCAN_CHANNEL_OCCUPIED ) # The PCAN-Channel handle is already being used by a PCAN-View application, but is available to connect -LOG_FUNCTION_DEFAULT = int(0x00) # Logs system exceptions / errors -LOG_FUNCTION_ENTRY = int(0x01) # Logs the entries to the PCAN-Basic API functions -LOG_FUNCTION_PARAMETERS = int( - 0x02 -) # Logs the parameters passed to the PCAN-Basic API functions -LOG_FUNCTION_LEAVE = int(0x04) # Logs the exits from the PCAN-Basic API functions -LOG_FUNCTION_WRITE = int(0x08) # Logs the CAN messages passed to the CAN_Write function -LOG_FUNCTION_READ = int( - 0x10 -) # Logs the CAN messages received within the CAN_Read function -LOG_FUNCTION_ALL = int( - 0xFFFF -) # Logs all possible information within the PCAN-Basic API functions +LOG_FUNCTION_DEFAULT = 0x00 # Logs system exceptions / errors +LOG_FUNCTION_ENTRY = 0x01 # Logs the entries to the PCAN-Basic API functions +LOG_FUNCTION_PARAMETERS = ( + 0x02 # Logs the parameters passed to the PCAN-Basic API functions +) +LOG_FUNCTION_LEAVE = 0x04 # Logs the exits from the PCAN-Basic API functions +LOG_FUNCTION_WRITE = 0x08 # Logs the CAN messages passed to the CAN_Write function +LOG_FUNCTION_READ = 0x10 # Logs the CAN messages received within the CAN_Read function +LOG_FUNCTION_ALL = ( + 0xFFFF # Logs all possible information within the PCAN-Basic API functions +) -TRACE_FILE_SINGLE = int( - 0x00 -) # A single file is written until it size reaches PAN_TRACE_SIZE -TRACE_FILE_SEGMENTED = int( - 0x01 -) # Traced data is distributed in several files with size PAN_TRACE_SIZE -TRACE_FILE_DATE = int(0x02) # Includes the date into the name of the trace file -TRACE_FILE_TIME = int(0x04) # Includes the start time into the name of the trace file -TRACE_FILE_OVERWRITE = int( - 0x80 -) # Causes the overwriting of available traces (same name) +TRACE_FILE_SINGLE = ( + 0x00 # A single file is written until it size reaches PAN_TRACE_SIZE +) +TRACE_FILE_SEGMENTED = ( + 0x01 # Traced data is distributed in several files with size PAN_TRACE_SIZE +) +TRACE_FILE_DATE = 0x02 # Includes the date into the name of the trace file +TRACE_FILE_TIME = 0x04 # Includes the start time into the name of the trace file +TRACE_FILE_OVERWRITE = 0x80 # Causes the overwriting of available traces (same name) -FEATURE_FD_CAPABLE = int(0x01) # Device supports flexible data-rate (CAN-FD) -FEATURE_DELAY_CAPABLE = int( - 0x02 -) # Device supports a delay between sending frames (FPGA based USB devices) -FEATURE_IO_CAPABLE = int( - 0x04 -) # Device supports I/O functionality for electronic circuits (USB-Chip devices) +FEATURE_FD_CAPABLE = 0x01 # Device supports flexible data-rate (CAN-FD) +FEATURE_DELAY_CAPABLE = ( + 0x02 # Device supports a delay between sending frames (FPGA based USB devices) +) +FEATURE_IO_CAPABLE = ( + 0x04 # Device supports I/O functionality for electronic circuits (USB-Chip devices) +) -SERVICE_STATUS_STOPPED = int(0x01) # The service is not running -SERVICE_STATUS_RUNNING = int(0x04) # The service is running +SERVICE_STATUS_STOPPED = 0x01 # The service is not running +SERVICE_STATUS_RUNNING = 0x04 # The service is running # Other constants # -MAX_LENGTH_HARDWARE_NAME = int( - 33 -) # Maximum length of the name of a device: 32 characters + terminator -MAX_LENGTH_VERSION_STRING = int( - 256 -) # Maximum length of a version string: 255 characters + terminator +MAX_LENGTH_HARDWARE_NAME = ( + 33 # Maximum length of the name of a device: 32 characters + terminator +) +MAX_LENGTH_VERSION_STRING = ( + 256 # Maximum length of a version string: 255 characters + terminator +) # PCAN message types # @@ -670,6 +656,7 @@ class TPCANChannelInformation(Structure): # PCAN-Basic API function declarations # /////////////////////////////////////////////////////////// + # PCAN-Basic API class implementation # class PCANBasic: @@ -678,25 +665,17 @@ class PCANBasic: def __init__(self): if platform.system() == "Windows": load_library_func = windll.LoadLibrary - - # look for Peak drivers in Windows registry - with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as reg: - try: - with winreg.OpenKey(reg, r"SOFTWARE\PEAK-System\PEAK-Drivers"): - pass - except OSError: - raise OSError("The PEAK-driver could not be found!") from None else: load_library_func = cdll.LoadLibrary if platform.system() == "Windows" or "CYGWIN" in platform.system(): - lib_name = "PCANBasic.dll" + lib_name = "PCANBasic" elif platform.system() == "Darwin": # PCBUSB library is a third-party software created # and maintained by the MacCAN project - lib_name = "libPCBUSB.dylib" + lib_name = "PCBUSB" else: - lib_name = "libpcanbasic.so" + lib_name = "pcanbasic" lib_path = find_library(lib_name) if not lib_path: @@ -715,11 +694,10 @@ def Initialize( self, Channel, Btr0Btr1, - HwType=TPCANType(0), - IOPort=c_uint(0), - Interrupt=c_ushort(0), + HwType=TPCANType(0), # noqa: B008 + IOPort=c_uint(0), # noqa: B008 + Interrupt=c_ushort(0), # noqa: B008 ): - """Initializes a PCAN Channel Parameters: @@ -744,7 +722,6 @@ def Initialize( # Initializes a FD capable PCAN Channel # def InitializeFD(self, Channel, BitrateFD): - """Initializes a FD capable PCAN Channel Parameters: @@ -775,7 +752,6 @@ def InitializeFD(self, Channel, BitrateFD): # Uninitializes one or all PCAN Channels initialized by CAN_Initialize # def Uninitialize(self, Channel): - """Uninitializes one or all PCAN Channels initialized by CAN_Initialize Remarks: @@ -797,7 +773,6 @@ def Uninitialize(self, Channel): # Resets the receive and transmit queues of the PCAN Channel # def Reset(self, Channel): - """Resets the receive and transmit queues of the PCAN Channel Remarks: @@ -819,7 +794,6 @@ def Reset(self, Channel): # Gets the current status of a PCAN Channel # def GetStatus(self, Channel): - """Gets the current status of a PCAN Channel Parameters: @@ -838,7 +812,6 @@ def GetStatus(self, Channel): # Reads a CAN message from the receive queue of a PCAN Channel # def Read(self, Channel): - """Reads a CAN message from the receive queue of a PCAN Channel Remarks: @@ -867,7 +840,6 @@ def Read(self, Channel): # Reads a CAN message from the receive queue of a FD capable PCAN Channel # def ReadFD(self, Channel): - """Reads a CAN message from the receive queue of a FD capable PCAN Channel Remarks: @@ -896,7 +868,6 @@ def ReadFD(self, Channel): # Transmits a CAN message # def Write(self, Channel, MessageBuffer): - """Transmits a CAN message Parameters: @@ -916,7 +887,6 @@ def Write(self, Channel, MessageBuffer): # Transmits a CAN message over a FD capable PCAN Channel # def WriteFD(self, Channel, MessageBuffer): - """Transmits a CAN message over a FD capable PCAN Channel Parameters: @@ -936,7 +906,6 @@ def WriteFD(self, Channel, MessageBuffer): # Configures the reception filter # def FilterMessages(self, Channel, FromID, ToID, Mode): - """Configures the reception filter Remarks: @@ -963,7 +932,6 @@ def FilterMessages(self, Channel, FromID, ToID, Mode): # Retrieves a PCAN Channel value # def GetValue(self, Channel, Parameter): - """Retrieves a PCAN Channel value Remarks: @@ -999,7 +967,7 @@ def GetValue(self, Channel, Parameter): elif Parameter == PCAN_ATTACHED_CHANNELS: res = self.GetValue(Channel, PCAN_ATTACHED_CHANNELS_COUNT) if TPCANStatus(res[0]) != PCAN_ERROR_OK: - return (TPCANStatus(res[0]),) + return TPCANStatus(res[0]), () mybuffer = (TPCANChannelInformation * res[1])() elif ( @@ -1026,7 +994,6 @@ def GetValue(self, Channel, Parameter): # error code, in any desired language # def SetValue(self, Channel, Parameter, Buffer): - """Returns a descriptive text of a given TPCANStatus error code, in any desired language @@ -1069,7 +1036,6 @@ def SetValue(self, Channel, Parameter, Buffer): raise def GetErrorText(self, Error, Language=0): - """Configures or sets a PCAN Channel value Remarks: @@ -1098,7 +1064,6 @@ def GetErrorText(self, Error, Language=0): raise def LookUpChannel(self, Parameters): - """Finds a PCAN-Basic channel that matches with the given parameters Remarks: diff --git a/can/interfaces/pcan/pcan.py b/can/interfaces/pcan/pcan.py index 2ed0ff445..a2f5f361f 100644 --- a/can/interfaces/pcan/pcan.py +++ b/can/interfaces/pcan/pcan.py @@ -1,75 +1,80 @@ """ Enable basic CAN over a PCAN USB device. """ + import logging -import time -from datetime import datetime import platform -from typing import Optional, List, Tuple, Union, Any +import time +import warnings +from typing import Any from packaging import version from can import ( - BusABC, - BusState, BitTiming, BitTimingFd, - Message, + BusABC, + BusState, CanError, - CanOperationError, CanInitializationError, + CanOperationError, + CanProtocol, + Message, ) from can.util import check_or_adjust_timing_clock, dlc2len, len2dlc + from .basic import ( - PCAN_BITRATES, - PCAN_FD_PARAMETER_LIST, - PCAN_CHANNEL_NAMES, - PCAN_NONEBUS, - PCAN_BAUD_500K, - PCAN_TYPE_ISA, - PCANBasic, - PCAN_ERROR_OK, + FEATURE_FD_CAPABLE, + IS_LINUX, + IS_WINDOWS, + PCAN_ALLOW_ECHO_FRAMES, PCAN_ALLOW_ERROR_FRAMES, - PCAN_PARAMETER_ON, - PCAN_RECEIVE_EVENT, PCAN_API_VERSION, + PCAN_ATTACHED_CHANNELS, + PCAN_BAUD_500K, + PCAN_BITRATES, + PCAN_BUSOFF_AUTORESET, + PCAN_CHANNEL_AVAILABLE, + PCAN_CHANNEL_CONDITION, + PCAN_CHANNEL_FEATURES, + PCAN_CHANNEL_IDENTIFYING, + PCAN_CHANNEL_NAMES, PCAN_DEVICE_NUMBER, - PCAN_ERROR_QRCVEMPTY, - PCAN_ERROR_BUSLIGHT, + PCAN_DICT_STATUS, PCAN_ERROR_BUSHEAVY, - PCAN_MESSAGE_EXTENDED, - PCAN_MESSAGE_RTR, - PCAN_MESSAGE_FD, + PCAN_ERROR_BUSLIGHT, + PCAN_ERROR_ILLDATA, + PCAN_ERROR_OK, + PCAN_ERROR_QRCVEMPTY, + PCAN_FD_PARAMETER_LIST, + PCAN_LANBUS1, + PCAN_LISTEN_ONLY, PCAN_MESSAGE_BRS, - PCAN_MESSAGE_ESI, + PCAN_MESSAGE_ECHO, PCAN_MESSAGE_ERRFRAME, + PCAN_MESSAGE_ESI, + PCAN_MESSAGE_EXTENDED, + PCAN_MESSAGE_FD, + PCAN_MESSAGE_RTR, PCAN_MESSAGE_STANDARD, - TPCANMsgFD, - TPCANMsg, - PCAN_CHANNEL_IDENTIFYING, - PCAN_LISTEN_ONLY, + PCAN_NONEBUS, PCAN_PARAMETER_OFF, - TPCANHandle, - IS_LINUX, - IS_WINDOWS, + PCAN_PARAMETER_ON, + PCAN_PCCBUS1, PCAN_PCIBUS1, + PCAN_RECEIVE_EVENT, + PCAN_TYPE_ISA, PCAN_USBBUS1, - PCAN_PCCBUS1, - PCAN_LANBUS1, - PCAN_CHANNEL_CONDITION, - PCAN_CHANNEL_AVAILABLE, - PCAN_CHANNEL_FEATURES, - FEATURE_FD_CAPABLE, - PCAN_DICT_STATUS, - PCAN_BUSOFF_AUTORESET, + VALID_PCAN_CAN_CLOCKS, + VALID_PCAN_FD_CLOCKS, + PCANBasic, TPCANBaudrate, - PCAN_ATTACHED_CHANNELS, TPCANChannelInformation, - VALID_PCAN_FD_CLOCKS, - VALID_PCAN_CAN_CLOCKS, + TPCANHandle, + TPCANMsg, + TPCANMsgFD, ) - # Set up logging log = logging.getLogger("can.pcan") @@ -83,8 +88,8 @@ if uptime.boottime() is None: boottimeEpoch = 0 else: - boottimeEpoch = (uptime.boottime() - datetime.fromtimestamp(0)).total_seconds() -except ImportError as error: + boottimeEpoch = uptime.boottime().timestamp() +except ImportError: log.warning( "uptime library not available, timestamps are relative to boot time and not to Epoch UTC", ) @@ -96,7 +101,7 @@ try: # Try builtin Python 3 Windows API from _overlapped import CreateEvent - from _winapi import WaitForSingleObject, WAIT_OBJECT_0, INFINITE + from _winapi import INFINITE, WAIT_OBJECT_0, WaitForSingleObject HAS_EVENTS = True except ImportError: @@ -104,8 +109,6 @@ elif IS_LINUX: try: - import errno - import os import select HAS_EVENTS = True @@ -117,10 +120,11 @@ class PcanBus(BusABC): def __init__( self, channel: str = "PCAN_USBBUS1", - device_id: Optional[int] = None, + device_id: int | None = None, state: BusState = BusState.ACTIVE, - timing: Optional[Union[BitTiming, BitTimingFd]] = None, + timing: BitTiming | BitTimingFd | None = None, bitrate: int = 500000, + receive_own_messages: bool = False, **kwargs: Any, ): """A PCAN USB interface to CAN. @@ -163,6 +167,9 @@ def __init__( Default is 500 kbit/s. Ignored if using CanFD. + :param receive_own_messages: + Enable self-reception of sent messages. + :param bool fd: Should the Bus be initialized in CAN-FD mode. @@ -246,8 +253,9 @@ def __init__( err_msg = f"Cannot find a channel with ID {device_id:08x}" raise ValueError(err_msg) + is_fd = isinstance(timing, BitTimingFd) if timing else kwargs.get("fd", False) + self._can_protocol = CanProtocol.CAN_FD if is_fd else CanProtocol.CAN_20 self.channel_info = str(channel) - self.fd = isinstance(timing, BitTimingFd) if timing else kwargs.get("fd", False) hwtype = PCAN_TYPE_ISA ioport = 0x02A0 @@ -271,7 +279,7 @@ def __init__( result = self.m_objPCANBasic.Initialize( self.m_PcanHandle, pcan_bitrate, hwtype, ioport, interrupt ) - elif self.fd: + elif is_fd: if isinstance(timing, BitTimingFd): timing = check_or_adjust_timing_clock( timing, sorted(VALID_PCAN_FD_CLOCKS, reverse=True) @@ -283,7 +291,7 @@ def __init__( clock_param = "f_clock" if "f_clock" in kwargs else "f_clock_mhz" fd_parameters_values = [ f"{key}={kwargs[key]}" - for key in (clock_param,) + PCAN_FD_PARAMETER_LIST + for key in (clock_param, *PCAN_FD_PARAMETER_LIST) if key in kwargs ] @@ -316,6 +324,14 @@ def __init__( "Ignoring error. PCAN_ALLOW_ERROR_FRAMES is still unsupported by OSX Library PCANUSB v0.11.2" ) + if receive_own_messages: + result = self.m_objPCANBasic.SetValue( + self.m_PcanHandle, PCAN_ALLOW_ECHO_FRAMES, PCAN_PARAMETER_ON + ) + + if result != PCAN_ERROR_OK: + raise PcanCanInitializationError(self._get_formatted_error(result)) + if kwargs.get("auto_reset", False): result = self.m_objPCANBasic.SetValue( self.m_PcanHandle, PCAN_BUSOFF_AUTORESET, PCAN_PARAMETER_ON @@ -338,7 +354,12 @@ def __init__( if result != PCAN_ERROR_OK: raise PcanCanInitializationError(self._get_formatted_error(result)) - super().__init__(channel=channel, state=state, bitrate=bitrate, **kwargs) + super().__init__( + channel=channel, + state=state, + bitrate=bitrate, + **kwargs, + ) def _find_channel_by_dev_id(self, device_id): """ @@ -391,9 +412,7 @@ def bits(n): for b in bits(error): stsReturn = self.m_objPCANBasic.GetErrorText(b, 0x9) if stsReturn[0] != PCAN_ERROR_OK: - text = "An error occurred. Error-code's text ({:X}h) couldn't be retrieved".format( - error - ) + text = f"An error occurred. Error-code's text ({error:X}h) couldn't be retrieved" else: text = stsReturn[1].decode("utf-8", errors="replace") @@ -408,9 +427,12 @@ def bits(n): def get_api_version(self): error, value = self.m_objPCANBasic.GetValue(PCAN_NONEBUS, PCAN_API_VERSION) if error != PCAN_ERROR_OK: - raise CanInitializationError(f"Failed to read pcan basic api version") + raise CanInitializationError("Failed to read pcan basic api version") + + # fix https://github.com/hardbyte/python-can/issues/1642 + version_string = value.decode("ascii").replace(",", ".").replace(" ", "") - return version.parse(value.decode("ascii")) + return version.parse(version_string) def check_api_version(self): apv = self.get_api_version() @@ -478,13 +500,11 @@ def set_device_number(self, device_number): return False return True - def _recv_internal( - self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + def _recv_internal(self, timeout: float | None) -> tuple[Message | None, bool]: end_time = time.time() + timeout if timeout is not None else None while True: - if self.fd: + if self._can_protocol is CanProtocol.CAN_FD: result, pcan_msg, pcan_timestamp = self.m_objPCANBasic.ReadFD( self.m_PcanHandle ) @@ -501,7 +521,7 @@ def _recv_internal( # receive queue is empty, wait or return on timeout if end_time is None: - time_left: Optional[float] = None + time_left: float | None = None timed_out = False else: time_left = max(0.0, end_time - time.time()) @@ -534,6 +554,12 @@ def _recv_internal( elif result & (PCAN_ERROR_BUSLIGHT | PCAN_ERROR_BUSHEAVY): log.warning(self._get_formatted_error(result)) + elif result == PCAN_ERROR_ILLDATA: + # When there is an invalid frame on CAN bus (in our case CAN FD), PCAN first reports result PCAN_ERROR_ILLDATA + # and then it sends the error frame. If the PCAN_ERROR_ILLDATA is not ignored, python-can throws an exception. + # So we ignore any PCAN_ERROR_ILLDATA results here. + pass + else: raise PcanCanOperationError(self._get_formatted_error(result)) @@ -542,11 +568,12 @@ def _recv_internal( is_extended_id = bool(pcan_msg.MSGTYPE & PCAN_MESSAGE_EXTENDED.value) is_remote_frame = bool(pcan_msg.MSGTYPE & PCAN_MESSAGE_RTR.value) is_fd = bool(pcan_msg.MSGTYPE & PCAN_MESSAGE_FD.value) + is_rx = not bool(pcan_msg.MSGTYPE & PCAN_MESSAGE_ECHO.value) bitrate_switch = bool(pcan_msg.MSGTYPE & PCAN_MESSAGE_BRS.value) error_state_indicator = bool(pcan_msg.MSGTYPE & PCAN_MESSAGE_ESI.value) is_error_frame = bool(pcan_msg.MSGTYPE & PCAN_MESSAGE_ERRFRAME.value) - if self.fd: + if self._can_protocol is CanProtocol.CAN_FD: dlc = dlc2len(pcan_msg.DLC) timestamp = boottimeEpoch + (pcan_timestamp.value / (1000.0 * 1000.0)) else: @@ -561,6 +588,7 @@ def _recv_internal( ) rx_msg = Message( + channel=self.channel_info, timestamp=timestamp, arbitration_id=pcan_msg.ID, is_extended_id=is_extended_id, @@ -569,6 +597,7 @@ def _recv_internal( dlc=dlc, data=pcan_msg.DATA[:dlc], is_fd=is_fd, + is_rx=is_rx, bitrate_switch=bitrate_switch, error_state_indicator=error_state_indicator, ) @@ -592,7 +621,7 @@ def send(self, msg, timeout=None): if msg.error_state_indicator: msgType |= PCAN_MESSAGE_ESI.value - if self.fd: + if self._can_protocol is CanProtocol.CAN_FD: # create a TPCANMsg message structure CANMsg = TPCANMsgFD() @@ -602,8 +631,7 @@ def send(self, msg, timeout=None): CANMsg.MSGTYPE = msgType # copy data - for i in range(msg.dlc): - CANMsg.DATA[i] = msg.data[i] + CANMsg.DATA[: msg.dlc] = msg.data[: msg.dlc] log.debug("Data: %s", msg.data) log.debug("Type: %s", type(msg.data)) @@ -622,8 +650,7 @@ def send(self, msg, timeout=None): # if a remote frame will be sent, data bytes are not important. if not msg.is_remote_frame: # copy data - for i in range(CANMsg.LEN): - CANMsg.DATA[i] = msg.data[i] + CANMsg.DATA[: CANMsg.LEN] = msg.data[: CANMsg.LEN] log.debug("Data: %s", msg.data) log.debug("Type: %s", type(msg.data)) @@ -651,6 +678,17 @@ def shutdown(self): self.m_objPCANBasic.Uninitialize(self.m_PcanHandle) + @property + def fd(self) -> bool: + class_name = self.__class__.__name__ + warnings.warn( + f"The {class_name}.fd property is deprecated and superseded by {class_name}.protocol. " + "It is scheduled for removal in python-can version 5.0.", + DeprecationWarning, + stacklevel=2, + ) + return self._can_protocol is CanProtocol.CAN_FD + @property def state(self): return self._state @@ -687,7 +725,7 @@ def _detect_available_configs(): res, value = library_handle.GetValue(PCAN_NONEBUS, PCAN_ATTACHED_CHANNELS) if res != PCAN_ERROR_OK: return interfaces - channel_information: List[TPCANChannelInformation] = list(value) + channel_information: list[TPCANChannelInformation] = list(value) for channel in channel_information: # find channel name in PCAN_CHANNEL_NAMES by value channel_name = next( @@ -753,7 +791,7 @@ def _detect_available_configs(): pass return channels - def status_string(self) -> Optional[str]: + def status_string(self) -> str | None: """ Query the PCAN bus status. diff --git a/can/interfaces/robotell.py b/can/interfaces/robotell.py index 4d82c1922..b24543856 100644 --- a/can/interfaces/robotell.py +++ b/can/interfaces/robotell.py @@ -3,11 +3,11 @@ """ import io -import time import logging -from typing import Optional +import time + +from can import BusABC, CanProtocol, Message -from can import BusABC, Message from ..exceptions import CanInterfaceNotImplementedError, CanOperationError logger = logging.getLogger(__name__) @@ -91,6 +91,7 @@ def __init__( if bitrate is not None: self.set_bitrate(bitrate) + self._can_protocol = CanProtocol.CAN_20 self.channel_info = ( f"Robotell USB-CAN s/n {self.get_serial_number(1)} on {channel}" ) @@ -374,11 +375,11 @@ def fileno(self): except io.UnsupportedOperation: raise NotImplementedError( "fileno is not implemented using current CAN bus on this platform" - ) + ) from None except Exception as exception: raise CanOperationError("Cannot fetch fileno") from exception - def get_serial_number(self, timeout: Optional[int]) -> Optional[str]: + def get_serial_number(self, timeout: int | None) -> str | None: """Get serial number of the slcan interface. :param timeout: diff --git a/can/interfaces/seeedstudio/__init__.py b/can/interfaces/seeedstudio/__init__.py index cb1c17f1d..9466bde43 100644 --- a/can/interfaces/seeedstudio/__init__.py +++ b/can/interfaces/seeedstudio/__init__.py @@ -1,4 +1,6 @@ -""" -""" +__all__ = [ + "SeeedBus", + "seeedstudio", +] from can.interfaces.seeedstudio.seeedstudio import SeeedBus diff --git a/can/interfaces/seeedstudio/seeedstudio.py b/can/interfaces/seeedstudio/seeedstudio.py index 4d09ca0cd..26339616c 100644 --- a/can/interfaces/seeedstudio/seeedstudio.py +++ b/can/interfaces/seeedstudio/seeedstudio.py @@ -6,13 +6,13 @@ SKU 114991193 """ +import io import logging import struct -import io from time import time import can -from can import BusABC, Message +from can import BusABC, CanProtocol, Message logger = logging.getLogger("seeedbus") @@ -63,8 +63,8 @@ def __init__( frame_type="STD", operation_mode="normal", bitrate=500000, - *args, - **kwargs + can_filters=None, + **kwargs, ): """ :param str channel: @@ -86,6 +86,12 @@ def __init__( :param bitrate CAN bus bit rate, selected from available list. + :param can_filters: + A list of CAN filter dictionaries. If one filter is provided, + it will be used by the high-performance hardware filter. If + zero or more than one filter is provided, software-based + filtering will be used. Defaults to None (no filtering). + :raises can.CanInitializationError: If the given parameters are invalid. :raises can.CanInterfaceNotImplementedError: If the serial module is not installed. """ @@ -95,11 +101,23 @@ def __init__( "the serial module is not installed" ) + can_id = 0x00 + can_mask = 0x00 + self._is_filtered = False + + if can_filters and len(can_filters) == 1: + self._is_filtered = True + hw_filter = can_filters[0] + can_id = hw_filter["can_id"] + can_mask = hw_filter["can_mask"] + self.bit_rate = bitrate self.frame_type = frame_type self.op_mode = operation_mode - self.filter_id = bytearray([0x00, 0x00, 0x00, 0x00]) - self.mask_id = bytearray([0x00, 0x00, 0x00, 0x00]) + self.filter_id = struct.pack(" List[Any]: - return [] + +CAN_ERR_FLAG = 0x20000000 +CAN_RTR_FLAG = 0x40000000 +CAN_EFF_FLAG = 0x80000000 +CAN_ID_MASK_EXT = 0x1FFFFFFF +CAN_ID_MASK_STD = 0x7FF class SerialBus(BusABC): @@ -54,8 +57,7 @@ def __init__( baudrate: int = 115200, timeout: float = 0.1, rtscts: bool = False, - *args, - **kwargs, + **kwargs: Any, ) -> None: """ :param channel: @@ -87,6 +89,7 @@ def __init__( raise TypeError("Must specify a serial port.") self.channel_info = f"Serial interface: {channel}" + self._can_protocol = CanProtocol.CAN_20 try: self._ser = serial.serial_for_url( @@ -97,7 +100,7 @@ def __init__( "could not create the serial device" ) from error - super().__init__(channel, *args, **kwargs) + super().__init__(channel, **kwargs) def shutdown(self) -> None: """ @@ -106,16 +109,13 @@ def shutdown(self) -> None: super().shutdown() self._ser.close() - def send(self, msg: Message, timeout: Optional[float] = None) -> None: + def send(self, msg: Message, timeout: float | None = None) -> None: """ Send a message over the serial device. :param msg: Message to send. - .. note:: Flags like ``extended_id``, ``is_remote_frame`` and - ``is_error_frame`` will be ignored. - .. note:: If the timestamp is a float value it will be converted to an integer. @@ -128,20 +128,28 @@ def send(self, msg: Message, timeout: Optional[float] = None) -> None: try: timestamp = struct.pack(" None: except serial.SerialTimeoutException as error: raise CanTimeoutError() from error - def _recv_internal( - self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + def _recv_internal(self, timeout: float | None) -> tuple[Message | None, bool]: """ Read a message from the serial device. @@ -166,16 +172,10 @@ def _recv_internal( :returns: Received message and :obj:`False` (because no filtering as taken place). - - .. warning:: - Flags like ``is_extended_id``, ``is_remote_frame`` and ``is_error_frame`` - will not be set over this function, the flags in the return - message are the default values. """ try: rx_byte = self._ser.read() if rx_byte and ord(rx_byte) == 0xAA: - s = self._ser.read(4) timestamp = struct.unpack("= 0x20000000: - raise ValueError( - "received arbitration id may not exceed 2^29 (0x20000000)" - ) + is_extended_id = bool(arbitration_id & CAN_EFF_FLAG) + is_error_frame = bool(arbitration_id & CAN_ERR_FLAG) + is_remote_frame = bool(arbitration_id & CAN_RTR_FLAG) + + if is_extended_id: + arbitration_id = arbitration_id & CAN_ID_MASK_EXT + else: + arbitration_id = arbitration_id & CAN_ID_MASK_STD data = self._ser.read(dlc) @@ -200,6 +204,9 @@ def _recv_internal( arbitration_id=arbitration_id, dlc=dlc, data=data, + is_extended_id=is_extended_id, + is_error_frame=is_error_frame, + is_remote_frame=is_remote_frame, ) return msg, False @@ -216,16 +223,20 @@ def _recv_internal( def fileno(self) -> int: try: - return self._ser.fileno() + return cast("int", self._ser.fileno()) except io.UnsupportedOperation: raise NotImplementedError( "fileno is not implemented using current CAN bus on this platform" - ) + ) from None except Exception as exception: raise CanOperationError("Cannot fetch fileno") from exception @staticmethod - def _detect_available_configs() -> List[AutoDetectedConfig]: - return [ - {"interface": "serial", "channel": port.device} for port in list_comports() - ] + def _detect_available_configs() -> Sequence[AutoDetectedConfig]: + configs: list[AutoDetectedConfig] = [] + if serial is None: + return configs + + for port in serial.tools.list_ports.comports(): + configs.append({"interface": "serial", "channel": port.device}) + return configs diff --git a/can/interfaces/slcan.py b/can/interfaces/slcan.py index bcb3121ca..086d9ed32 100644 --- a/can/interfaces/slcan.py +++ b/can/interfaces/slcan.py @@ -2,21 +2,26 @@ Interface for slcan compatible interfaces (win32/linux). """ -from typing import Any, Optional, Tuple - import io -import time import logging +import time +import warnings +from queue import SimpleQueue +from typing import Any, cast -from can import BusABC, Message -from ..exceptions import ( - CanInterfaceNotImplementedError, +from can import BitTiming, BitTimingFd, BusABC, CanProtocol, Message, typechecking +from can.exceptions import ( CanInitializationError, + CanInterfaceNotImplementedError, CanOperationError, error_check, ) -from can import typechecking - +from can.util import ( + CAN_FD_DLC, + check_or_adjust_timing_clock, + deprecated_args_alias, + len2dlc, +) logger = logging.getLogger(__name__) @@ -48,6 +53,11 @@ class slcanBus(BusABC): 1000000: "S8", 83300: "S9", } + _DATA_BITRATES = { + 0: "", + 2000000: "Y2", + 5000000: "Y5", + } _SLEEP_AFTER_SERIAL_OPEN = 2 # in seconds @@ -56,14 +66,20 @@ class slcanBus(BusABC): LINE_TERMINATOR = b"\r" + @deprecated_args_alias( + deprecation_start="4.5.0", + deprecation_end="5.0.0", + ttyBaudrate="tty_baudrate", + ) def __init__( self, channel: typechecking.ChannelStr, - ttyBaudrate: int = 115200, - bitrate: Optional[int] = None, - btr: Optional[str] = None, + tty_baudrate: int = 115200, + bitrate: int | None = None, + timing: BitTiming | BitTimingFd | None = None, sleep_after_open: float = _SLEEP_AFTER_SERIAL_OPEN, rtscts: bool = False, + listen_only: bool = False, timeout: float = 0.001, **kwargs: Any, ) -> None: @@ -71,62 +87,91 @@ def __init__( :param str channel: port of underlying serial or usb device (e.g. ``/dev/ttyUSB0``, ``COM8``, ...) Must not be empty. Can also end with ``@115200`` (or similarly) to specify the baudrate. - :param int ttyBaudrate: + :param int tty_baudrate: baudrate of underlying serial or usb device (Ignored if set via the ``channel`` parameter) :param bitrate: Bitrate in bit/s - :param btr: - BTR register value to set custom can speed + :param timing: + Optional :class:`~can.BitTiming` instance to use for custom bit timing setting. + If this argument is set then it overrides the bitrate and btr arguments. The + `f_clock` value of the timing instance must be set to 8_000_000 (8MHz) + for standard CAN. + CAN FD and the :class:`~can.BitTimingFd` class have partial support according to the non-standard + slcan protocol implementation in the CANABLE 2.0 firmware: currently only data rates of 2M and 5M. :param poll_interval: Poll interval in seconds when reading messages :param sleep_after_open: Time to wait in seconds after opening serial connection :param rtscts: turn hardware handshake (RTS/CTS) on and off + :param listen_only: + If True, open interface/channel in listen mode with ``L`` command. + Otherwise, the (default) ``O`` command is still used. See ``open`` method. :param timeout: Timeout for the serial or usb device in seconds (default 0.001) + :raise ValueError: if both ``bitrate`` and ``btr`` are set or the channel is invalid :raise CanInterfaceNotImplementedError: if the serial module is missing :raise CanInitializationError: if the underlying serial connection could not be established """ + self._listen_only = listen_only + if serial is None: raise CanInterfaceNotImplementedError("The serial module is not installed") + btr: str | None = kwargs.get("btr", None) + if btr is not None: + warnings.warn( + "The 'btr' argument is deprecated since python-can v4.5.0 " + "and scheduled for removal in v5.0.0. " + "Use the 'timing' argument instead.", + DeprecationWarning, + stacklevel=1, + ) + if not channel: # if None or empty raise ValueError("Must specify a serial port.") if "@" in channel: (channel, baudrate) = channel.split("@") - ttyBaudrate = int(baudrate) + tty_baudrate = int(baudrate) with error_check(exception_type=CanInitializationError): self.serialPortOrig = serial.serial_for_url( channel, - baudrate=ttyBaudrate, + baudrate=tty_baudrate, rtscts=rtscts, timeout=timeout, ) + self._queue: SimpleQueue[str] = SimpleQueue() self._buffer = bytearray() + self._can_protocol = CanProtocol.CAN_20 time.sleep(sleep_after_open) with error_check(exception_type=CanInitializationError): - if bitrate is not None and btr is not None: - raise ValueError("Bitrate and btr mutually exclusive.") - if bitrate is not None: - self.set_bitrate(bitrate) - if btr is not None: - self.set_bitrate_reg(btr) + if isinstance(timing, BitTiming): + timing = check_or_adjust_timing_clock(timing, valid_clocks=[8_000_000]) + self.set_bitrate_reg(f"{timing.btr0:02X}{timing.btr1:02X}") + elif isinstance(timing, BitTimingFd): + self.set_bitrate(timing.nom_bitrate, timing.data_bitrate) + else: + if bitrate is not None and btr is not None: + raise ValueError("Bitrate and btr mutually exclusive.") + if bitrate is not None: + self.set_bitrate(bitrate) + if btr is not None: + self.set_bitrate_reg(btr) self.open() - super().__init__( - channel, ttyBaudrate=115200, bitrate=None, rtscts=False, **kwargs - ) + super().__init__(channel, **kwargs) - def set_bitrate(self, bitrate: int) -> None: + def set_bitrate(self, bitrate: int, data_bitrate: int | None = None) -> None: """ :param bitrate: Bitrate in bit/s + :param data_bitrate: + Data Bitrate in bit/s for FD frames :raise ValueError: if ``bitrate`` is not among the possible values """ @@ -136,14 +181,26 @@ def set_bitrate(self, bitrate: int) -> None: bitrates = ", ".join(str(k) for k in self._BITRATES.keys()) raise ValueError(f"Invalid bitrate, choose one of {bitrates}.") + # If data_bitrate is None, we set it to 0 which means no data bitrate + if data_bitrate is None: + data_bitrate = 0 + + if data_bitrate in self._DATA_BITRATES: + dbitrate_code = self._DATA_BITRATES[data_bitrate] + else: + dbitrates = ", ".join(str(k) for k in self._DATA_BITRATES.keys()) + raise ValueError(f"Invalid data bitrate, choose one of {dbitrates}.") + self.close() self._write(bitrate_code) + self._write(dbitrate_code) self.open() def set_bitrate_reg(self, btr: str) -> None: """ :param btr: - BTR register value to set custom can speed + BTR register value to set custom can speed as a string `xxyy` where + xx is the BTR0 value in hex and yy is the BTR1 value in hex. """ self.close() self._write("s" + btr) @@ -154,7 +211,7 @@ def _write(self, string: str) -> None: self.serialPortOrig.write(string.encode() + self.LINE_TERMINATOR) self.serialPortOrig.flush() - def _read(self, timeout: Optional[float]) -> Optional[str]: + def _read(self, timeout: float | None) -> str | None: _timeout = serial.Timeout(timeout) with error_check("Could not read from serial device"): @@ -163,7 +220,7 @@ def _read(self, timeout: Optional[float]) -> Optional[str]: # We read the `serialPortOrig.in_waiting` only once here. in_waiting = self.serialPortOrig.in_waiting for _ in range(max(1, in_waiting)): - new_byte = self.serialPortOrig.read(size=1) + new_byte = self.serialPortOrig.read(1) if new_byte: self._buffer.extend(new_byte) else: @@ -185,25 +242,33 @@ def flush(self) -> None: self.serialPortOrig.reset_input_buffer() def open(self) -> None: - self._write("O") + if self._listen_only: + self._write("L") + else: + self._write("O") def close(self) -> None: self._write("C") - def _recv_internal( - self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: - + def _recv_internal(self, timeout: float | None) -> tuple[Message | None, bool]: canId = None remote = False extended = False data = None + isFd = False + fdBrs = False - string = self._read(timeout) + if self._queue.qsize(): + string: str | None = self._queue.get_nowait() + else: + string = self._read(timeout) if not string: pass - elif string[0] == "T": + elif string[0] in ( + "T", + "x", # x is an alternative extended message identifier for CANDapter + ): # extended frame canId = int(string[1:9], 16) dlc = int(string[9]) @@ -225,6 +290,34 @@ def _recv_internal( dlc = int(string[9]) extended = True remote = True + elif string[0] == "d": + # FD standard frame + canId = int(string[1:4], 16) + dlc = int(string[4], 16) + isFd = True + data = bytearray.fromhex(string[5 : 5 + CAN_FD_DLC[dlc] * 2]) + elif string[0] == "D": + # FD extended frame + canId = int(string[1:9], 16) + dlc = int(string[9], 16) + extended = True + isFd = True + data = bytearray.fromhex(string[10 : 10 + CAN_FD_DLC[dlc] * 2]) + elif string[0] == "b": + # FD with bitrate switch + canId = int(string[1:4], 16) + dlc = int(string[4], 16) + isFd = True + fdBrs = True + data = bytearray.fromhex(string[5 : 5 + CAN_FD_DLC[dlc] * 2]) + elif string[0] == "B": + # FD extended with bitrate switch + canId = int(string[1:9], 16) + dlc = int(string[9], 16) + extended = True + isFd = True + fdBrs = True + data = bytearray.fromhex(string[10 : 10 + CAN_FD_DLC[dlc] * 2]) if canId is not None: msg = Message( @@ -232,13 +325,15 @@ def _recv_internal( is_extended_id=extended, timestamp=time.time(), # Better than nothing... is_remote_frame=remote, - dlc=dlc, + is_fd=isFd, + bitrate_switch=fdBrs, + dlc=CAN_FD_DLC[dlc], data=data, ) return msg, False return None, False - def send(self, msg: Message, timeout: Optional[float] = None) -> None: + def send(self, msg: Message, timeout: float | None = None) -> None: if timeout != self.serialPortOrig.write_timeout: self.serialPortOrig.write_timeout = timeout if msg.is_remote_frame: @@ -246,6 +341,20 @@ def send(self, msg: Message, timeout: Optional[float] = None) -> None: sendStr = f"R{msg.arbitration_id:08X}{msg.dlc:d}" else: sendStr = f"r{msg.arbitration_id:03X}{msg.dlc:d}" + elif msg.is_fd: + fd_dlc = len2dlc(msg.dlc) + if msg.bitrate_switch: + if msg.is_extended_id: + sendStr = f"B{msg.arbitration_id:08X}{fd_dlc:X}" + else: + sendStr = f"b{msg.arbitration_id:03X}{fd_dlc:X}" + sendStr += msg.data.hex().upper() + else: + if msg.is_extended_id: + sendStr = f"D{msg.arbitration_id:08X}{fd_dlc:X}" + else: + sendStr = f"d{msg.arbitration_id:03X}{fd_dlc:X}" + sendStr += msg.data.hex().upper() else: if msg.is_extended_id: sendStr = f"T{msg.arbitration_id:08X}{msg.dlc:d}" @@ -262,17 +371,15 @@ def shutdown(self) -> None: def fileno(self) -> int: try: - return self.serialPortOrig.fileno() + return cast("int", self.serialPortOrig.fileno()) except io.UnsupportedOperation: raise NotImplementedError( "fileno is not implemented using current CAN bus on this platform" - ) + ) from None except Exception as exception: raise CanOperationError("Cannot fetch fileno") from exception - def get_version( - self, timeout: Optional[float] - ) -> Tuple[Optional[int], Optional[int]]: + def get_version(self, timeout: float | None) -> tuple[int | None, int | None]: """Get HW and SW version of the slcan interface. :param timeout: @@ -283,22 +390,24 @@ def get_version( int hw_version is the hardware version or None on timeout int sw_version is the software version or None on timeout """ + _timeout = serial.Timeout(timeout) cmd = "V" self._write(cmd) - string = self._read(timeout) - - if not string: - pass - elif string[0] == cmd and len(string) == 6: - # convert ASCII coded version - hw_version = int(string[1:3]) - sw_version = int(string[3:5]) - return hw_version, sw_version - + while True: + if string := self._read(_timeout.time_left()): + if string[0] == cmd: + # convert ASCII coded version + hw_version = int(string[1:3]) + sw_version = int(string[3:5]) + return hw_version, sw_version + else: + self._queue.put_nowait(string) + if _timeout.expired(): + break return None, None - def get_serial_number(self, timeout: Optional[float]) -> Optional[str]: + def get_serial_number(self, timeout: float | None) -> str | None: """Get serial number of the slcan interface. :param timeout: @@ -307,15 +416,17 @@ def get_serial_number(self, timeout: Optional[float]) -> Optional[str]: :return: :obj:`None` on timeout or a :class:`str` object. """ + _timeout = serial.Timeout(timeout) cmd = "N" self._write(cmd) - string = self._read(timeout) - - if not string: - pass - elif string[0] == cmd and len(string) == 6: - serial_number = string[1:-1] - return serial_number - + while True: + if string := self._read(_timeout.time_left()): + if string[0] == cmd: + serial_number = string[1:-1] + return serial_number + else: + self._queue.put_nowait(string) + if _timeout.expired(): + break return None diff --git a/can/interfaces/socketcan/__init__.py b/can/interfaces/socketcan/__init__.py index e08c18f50..6e279c2fe 100644 --- a/can/interfaces/socketcan/__init__.py +++ b/can/interfaces/socketcan/__init__.py @@ -2,4 +2,13 @@ See: https://www.kernel.org/doc/Documentation/networking/can.txt """ -from .socketcan import SocketcanBus, CyclicSendTask, MultiRateCyclicSendTask +__all__ = [ + "CyclicSendTask", + "MultiRateCyclicSendTask", + "SocketcanBus", + "constants", + "socketcan", + "utils", +] + +from .socketcan import CyclicSendTask, MultiRateCyclicSendTask, SocketcanBus diff --git a/can/interfaces/socketcan/constants.py b/can/interfaces/socketcan/constants.py index 3144a2cfa..941d52573 100644 --- a/can/interfaces/socketcan/constants.py +++ b/can/interfaces/socketcan/constants.py @@ -53,8 +53,18 @@ SIOCGSTAMP = 0x8906 EXTFLG = 0x0004 -CANFD_BRS = 0x01 -CANFD_ESI = 0x02 +CANFD_BRS = 0x01 # bit rate switch (second bitrate for payload data) +CANFD_ESI = 0x02 # error state indicator of the transmitting node +CANFD_FDF = 0x04 # mark CAN FD for dual use of struct canfd_frame + +# CAN payload length and DLC definitions according to ISO 11898-1 +CAN_MAX_DLC = 8 +CAN_MAX_RAW_DLC = 15 +CAN_MAX_DLEN = 8 + +# CAN FD payload length and DLC definitions according to ISO 11898-7 +CANFD_MAX_DLC = 15 +CANFD_MAX_DLEN = 64 CANFD_MTU = 72 diff --git a/can/interfaces/socketcan/socketcan.py b/can/interfaces/socketcan/socketcan.py index 74fbe8197..6dc856cbf 100644 --- a/can/interfaces/socketcan/socketcan.py +++ b/can/interfaces/socketcan/socketcan.py @@ -5,17 +5,28 @@ At the end of the file the usage of the internal methods is shown. """ -from typing import Dict, List, Optional, Sequence, Tuple, Type, Union - -import logging import ctypes import ctypes.util +import errno +import logging import select import socket import struct -import time import threading -import errno +import time +import warnings +from collections.abc import Callable, Sequence + +import can +from can import BusABC, CanProtocol, Message +from can.broadcastmanager import ( + LimitedDurationCyclicSendTaskABC, + ModifiableCyclicTaskABC, + RestartableCyclicTaskABC, +) +from can.interfaces.socketcan import constants +from can.interfaces.socketcan.utils import find_available_interfaces, pack_filters +from can.typechecking import CanFilters log = logging.getLogger(__name__) log_tx = log.getChild("tx") @@ -30,28 +41,21 @@ log.error("socket.CMSG_SPACE not available on this platform") -import can -from can import Message, BusABC -from can.broadcastmanager import ( - ModifiableCyclicTaskABC, - RestartableCyclicTaskABC, - LimitedDurationCyclicSendTaskABC, +# Constants needed for precise handling of timestamps +RECEIVED_TIMESTAMP_STRUCT = struct.Struct("@ll") +RECEIVED_ANCILLARY_BUFFER_SIZE = ( + CMSG_SPACE(RECEIVED_TIMESTAMP_STRUCT.size) if CMSG_SPACE_available else 0 ) -from can.typechecking import CanFilters -from can.interfaces.socketcan import constants -from can.interfaces.socketcan.utils import pack_filters, find_available_interfaces # Setup BCM struct def bcm_header_factory( - fields: List[Tuple[str, Union[Type[ctypes.c_uint32], Type[ctypes.c_long]]]], + fields: list[tuple[str, type[ctypes.c_uint32] | type[ctypes.c_long]]], alignment: int = 8, ): curr_stride = 0 - results: List[ - Tuple[ - str, Union[Type[ctypes.c_uint8], Type[ctypes.c_uint32], Type[ctypes.c_long]] - ] + results: list[ + tuple[str, type[ctypes.c_uint8] | type[ctypes.c_uint32] | type[ctypes.c_long]] ] = [] pad_index = 0 for field in fields: @@ -133,23 +137,68 @@ def bcm_header_factory( # The 32bit can id is directly followed by the 8bit data link count # The data field is aligned on an 8 byte boundary, hence we add padding # which aligns the data field to an 8 byte boundary. -CAN_FRAME_HEADER_STRUCT = struct.Struct("=IBB2x") +CAN_FRAME_HEADER_STRUCT = struct.Struct("=IBB1xB") def build_can_frame(msg: Message) -> bytes: """CAN frame packing/unpacking (see 'struct can_frame' in ) /** - * struct can_frame - basic CAN frame structure - * @can_id: the CAN ID of the frame and CAN_*_FLAG flags, see above. - * @can_dlc: the data length field of the CAN frame - * @data: the CAN frame payload. - */ + * struct can_frame - Classical CAN frame structure (aka CAN 2.0B) + * @can_id: CAN ID of the frame and CAN_*_FLAG flags, see canid_t definition + * @len: CAN frame payload length in byte (0 .. 8) + * @can_dlc: deprecated name for CAN frame payload length in byte (0 .. 8) + * @__pad: padding + * @__res0: reserved / padding + * @len8_dlc: optional DLC value (9 .. 15) at 8 byte payload length + * len8_dlc contains values from 9 .. 15 when the payload length is + * 8 bytes but the DLC value (see ISO 11898-1) is greater then 8. + * CAN_CTRLMODE_CC_LEN8_DLC flag has to be enabled in CAN driver. + * @data: CAN frame payload (up to 8 byte) + */ struct can_frame { canid_t can_id; /* 32 bit CAN_ID + EFF/RTR/ERR flags */ - __u8 can_dlc; /* data length code: 0 .. 8 */ - __u8 data[8] __attribute__((aligned(8))); + union { + /* CAN frame payload length in byte (0 .. CAN_MAX_DLEN) + * was previously named can_dlc so we need to carry that + * name for legacy support + */ + __u8 len; + __u8 can_dlc; /* deprecated */ + } __attribute__((packed)); /* disable padding added in some ABIs */ + __u8 __pad; /* padding */ + __u8 __res0; /* reserved / padding */ + __u8 len8_dlc; /* optional DLC for 8 byte payload length (9 .. 15) */ + __u8 data[CAN_MAX_DLEN] __attribute__((aligned(8))); }; + /* + * defined bits for canfd_frame.flags + * + * The use of struct canfd_frame implies the FD Frame (FDF) bit to + * be set in the CAN frame bitstream on the wire. The FDF bit switch turns + * the CAN controllers bitstream processor into the CAN FD mode which creates + * two new options within the CAN FD frame specification: + * + * Bit Rate Switch - to indicate a second bitrate is/was used for the payload + * Error State Indicator - represents the error state of the transmitting node + * + * As the CANFD_ESI bit is internally generated by the transmitting CAN + * controller only the CANFD_BRS bit is relevant for real CAN controllers when + * building a CAN FD frame for transmission. Setting the CANFD_ESI bit can make + * sense for virtual CAN interfaces to test applications with echoed frames. + * + * The struct can_frame and struct canfd_frame intentionally share the same + * layout to be able to write CAN frame content into a CAN FD frame structure. + * When this is done the former differentiation via CAN_MTU / CANFD_MTU gets + * lost. CANFD_FDF allows programmers to mark CAN FD frames in the case of + * using struct canfd_frame for mixed CAN / CAN FD content (dual use). + * Since the introduction of CAN XL the CANFD_FDF flag is set in all CAN FD + * frame structures provided by the CAN subsystem of the Linux kernel. + */ + #define CANFD_BRS 0x01 /* bit rate switch (second bitrate for payload data) */ + #define CANFD_ESI 0x02 /* error state indicator of the transmitting node */ + #define CANFD_FDF 0x04 /* mark CAN FD for dual use of struct canfd_frame */ + /** * struct canfd_frame - CAN flexible data rate frame structure * @can_id: CAN ID of the frame and CAN_*_FLAG flags, see canid_t definition @@ -169,14 +218,30 @@ def build_can_frame(msg: Message) -> bytes: }; """ can_id = _compose_arbitration_id(msg) + flags = 0 + + # The socketcan code identify the received FD frame by the packet length. + # So, padding to the data length is performed according to the message type (Classic / FD) + if msg.is_fd: + flags |= constants.CANFD_FDF + max_len = constants.CANFD_MAX_DLEN + else: + max_len = constants.CAN_MAX_DLEN + if msg.bitrate_switch: flags |= constants.CANFD_BRS if msg.error_state_indicator: flags |= constants.CANFD_ESI - max_len = 64 if msg.is_fd else 8 + data = bytes(msg.data).ljust(max_len, b"\x00") - return CAN_FRAME_HEADER_STRUCT.pack(can_id, msg.dlc, flags) + data + + if msg.is_remote_frame: + data_len = msg.dlc + else: + data_len = min(i for i in can.util.CAN_FD_DLC if i >= len(msg.data)) + header = CAN_FRAME_HEADER_STRUCT.pack(can_id, data_len, flags, msg.dlc) + return header + data def build_bcm_header( @@ -225,7 +290,7 @@ def build_bcm_transmit_header( # Note `TX_COUNTEVT` creates the message TX_EXPIRED when count expires flags |= constants.TX_COUNTEVT - def split_time(value: float) -> Tuple[int, int]: + def split_time(value: float) -> tuple[int, int]: """Given seconds as a float, return whole seconds and microseconds""" seconds = int(value) microseconds = int(1e6 * (value - seconds)) @@ -253,12 +318,31 @@ def build_bcm_update_header(can_id: int, msg_flags: int, nframes: int = 1) -> by ) -def dissect_can_frame(frame: bytes) -> Tuple[int, int, int, bytes]: - can_id, can_dlc, flags = CAN_FRAME_HEADER_STRUCT.unpack_from(frame) - if len(frame) != constants.CANFD_MTU: +def is_frame_fd(frame: bytes): + # According to the SocketCAN implementation the frame length + # should indicate if the message is FD or not (not the flag value) + return len(frame) == constants.CANFD_MTU + + +def dissect_can_frame(frame: bytes) -> tuple[int, int, int, bytes]: + can_id, data_len, flags, len8_dlc = CAN_FRAME_HEADER_STRUCT.unpack_from(frame) + + if data_len not in can.util.CAN_FD_DLC: + data_len = min(i for i in can.util.CAN_FD_DLC if i >= data_len) + + can_dlc = data_len + + if not is_frame_fd(frame): # Flags not valid in non-FD frames flags = 0 - return can_id, can_dlc, flags, frame[8 : 8 + can_dlc] + + if ( + data_len == constants.CAN_MAX_DLEN + and constants.CAN_MAX_DLEN < len8_dlc <= constants.CAN_MAX_RAW_DLC + ): + can_dlc = len8_dlc + + return can_id, can_dlc, flags, frame[8 : 8 + data_len] def create_bcm_socket(channel: str) -> socket.socket: @@ -318,9 +402,10 @@ def __init__( self, bcm_socket: socket.socket, task_id: int, - messages: Union[Sequence[Message], Message], + messages: Sequence[Message] | Message, period: float, - duration: Optional[float] = None, + duration: float | None = None, + autostart: bool = True, ) -> None: """Construct and :meth:`~start` a task. @@ -343,10 +428,13 @@ def __init__( self.bcm_socket = bcm_socket self.task_id = task_id - self._tx_setup(self.messages) + if autostart: + self._tx_setup(self.messages) def _tx_setup( - self, messages: Sequence[Message], raise_if_task_exists: bool = True + self, + messages: Sequence[Message], + raise_if_task_exists: bool = True, ) -> None: # Create a low level packed frame to pass to the kernel body = bytearray() @@ -416,7 +504,7 @@ def stop(self) -> None: stopframe = build_bcm_tx_delete_header(self.task_id, self.flags) send_bcm(self.bcm_socket, stopframe) - def modify_data(self, messages: Union[Sequence[Message], Message]) -> None: + def modify_data(self, messages: Sequence[Message] | Message) -> None: """Update the contents of the periodically sent CAN messages by sending TX_SETUP message to Linux kernel. @@ -514,9 +602,7 @@ def bind_socket(sock: socket.socket, channel: str = "can0") -> None: log.debug("Bound socket.") -def capture_message( - sock: socket.socket, get_channel: bool = False -) -> Optional[Message]: +def capture_message(sock: socket.socket, get_channel: bool = False) -> Message | None: """ Captures a message from given socket. @@ -537,7 +623,9 @@ def capture_message( else: channel = None except OSError as error: - raise can.CanOperationError(f"Error receiving: {error.strerror}", error.errno) + raise can.CanOperationError( + f"Error receiving: {error.strerror}", error.errno + ) from error can_id, can_dlc, flags, data = dissect_can_frame(cf) @@ -596,13 +684,7 @@ def capture_message( return msg -# Constants needed for precise handling of timestamps -if CMSG_SPACE_available: - RECEIVED_TIMESTAMP_STRUCT = struct.Struct("@ll") - RECEIVED_ANCILLARY_BUFFER_SIZE = CMSG_SPACE(RECEIVED_TIMESTAMP_STRUCT.size) - - -class SocketcanBus(BusABC): +class SocketcanBus(BusABC): # pylint: disable=abstract-method """A SocketCAN interface to CAN. It implements :meth:`can.BusABC._detect_available_configs` to search for @@ -615,7 +697,7 @@ def __init__( receive_own_messages: bool = False, local_loopback: bool = True, fd: bool = False, - can_filters: Optional[CanFilters] = None, + can_filters: CanFilters | None = None, ignore_rx_error_frames=False, **kwargs, ) -> None: @@ -653,10 +735,11 @@ def __init__( self.socket = create_socket() self.channel = channel self.channel_info = f"socketcan channel '{channel}'" - self._bcm_sockets: Dict[str, socket.socket] = {} + self._bcm_sockets: dict[str, socket.socket] = {} self._is_filtered = False self._task_id = 0 self._task_id_guard = threading.Lock() + self._can_protocol = CanProtocol.CAN_FD if fd else CanProtocol.CAN_20 # set the local_loopback parameter try: @@ -703,15 +786,23 @@ def __init__( # so this is always supported by the kernel self.socket.setsockopt(socket.SOL_SOCKET, constants.SO_TIMESTAMPNS, 1) - bind_socket(self.socket, channel) - kwargs.update( - { - "receive_own_messages": receive_own_messages, - "fd": fd, - "local_loopback": local_loopback, - } + try: + bind_socket(self.socket, channel) + kwargs.update( + { + "receive_own_messages": receive_own_messages, + "fd": fd, + "local_loopback": local_loopback, + } + ) + except OSError as error: + log.error("Could not access SocketCAN device %s (%s)", channel, error) + raise + super().__init__( + channel=channel, + can_filters=can_filters, + **kwargs, ) - super().__init__(channel=channel, can_filters=can_filters, **kwargs) def shutdown(self) -> None: """Stops all active periodic tasks and closes the socket.""" @@ -722,9 +813,7 @@ def shutdown(self) -> None: log.debug("Closing raw can socket") self.socket.close() - def _recv_internal( - self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + def _recv_internal(self, timeout: float | None) -> tuple[Message | None, bool]: try: # get all sockets that are ready (can be a list with a single value # being self.socket or an empty list if self.socket is not ready) @@ -733,7 +822,7 @@ def _recv_internal( # something bad happened (e.g. the interface went down) raise can.CanOperationError( f"Failed to receive: {error.strerror}", error.errno - ) + ) from error if ready_receive_sockets: # not empty get_channel = self.channel == "" @@ -746,7 +835,7 @@ def _recv_internal( # socket wasn't readable or timeout occurred return None, self._is_filtered - def send(self, msg: Message, timeout: Optional[float] = None) -> None: + def send(self, msg: Message, timeout: float | None = None) -> None: """Transmit a message to the CAN bus. :param msg: A message object. @@ -784,7 +873,7 @@ def send(self, msg: Message, timeout: Optional[float] = None) -> None: raise can.CanOperationError("Transmit buffer full") - def _send_once(self, data: bytes, channel: Optional[str] = None) -> int: + def _send_once(self, data: bytes, channel: str | None = None) -> int: try: if self.channel == "" and channel: # Message must be addressed to a specific channel @@ -794,15 +883,17 @@ def _send_once(self, data: bytes, channel: Optional[str] = None) -> int: except OSError as error: raise can.CanOperationError( f"Failed to transmit: {error.strerror}", error.errno - ) + ) from error return sent def _send_periodic_internal( self, - msgs: Union[Sequence[Message], Message], + msgs: Sequence[Message] | Message, period: float, - duration: Optional[float] = None, - ) -> CyclicSendTask: + duration: float | None = None, + autostart: bool = True, + modifier_callback: Callable[[Message], None] | None = None, + ) -> can.broadcastmanager.CyclicSendTaskABC: """Start sending messages at a given period on this bus. The Linux kernel's Broadcast Manager SocketCAN API is used to schedule @@ -811,13 +902,17 @@ def _send_periodic_internal( :class:`CyclicSendTask` within BCM provides flexibility to schedule CAN messages sending with the same CAN ID, but different CAN data. - :param messages: + :param msgs: The message(s) to be sent periodically. :param period: The rate in seconds at which to send the messages. :param duration: Approximate duration in seconds to continue sending messages. If no duration is provided, the task will continue indefinitely. + :param autostart: + If True (the default) the sending task will immediately start after creation. + Otherwise, the task has to be started by calling the + tasks :meth:`~can.RestartableCyclicTaskABC.start` method on it. :raises ValueError: If task identifier passed to :class:`CyclicSendTask` can't be used @@ -834,15 +929,33 @@ def _send_periodic_internal( general the message will be sent at the given rate until at least *duration* seconds. """ - msgs = LimitedDurationCyclicSendTaskABC._check_and_convert_messages( # pylint: disable=protected-access - msgs - ) + if modifier_callback is None: + msgs = LimitedDurationCyclicSendTaskABC._check_and_convert_messages( # pylint: disable=protected-access + msgs + ) - msgs_channel = str(msgs[0].channel) if msgs[0].channel else None - bcm_socket = self._get_bcm_socket(msgs_channel or self.channel) - task_id = self._get_next_task_id() - task = CyclicSendTask(bcm_socket, task_id, msgs, period, duration) - return task + msgs_channel = str(msgs[0].channel) if msgs[0].channel else None + bcm_socket = self._get_bcm_socket(msgs_channel or self.channel) + task_id = self._get_next_task_id() + task = CyclicSendTask( + bcm_socket, task_id, msgs, period, duration, autostart=autostart + ) + return task + + # fallback to thread based cyclic task + warnings.warn( + f"{self.__class__.__name__} falls back to a thread-based cyclic task, " + "when the `modifier_callback` argument is given.", + stacklevel=3, + ) + return BusABC._send_periodic_internal( + self, + msgs=msgs, + period=period, + duration=duration, + autostart=autostart, + modifier_callback=modifier_callback, + ) def _get_next_task_id(self) -> int: with self._task_id_guard: @@ -854,7 +967,7 @@ def _get_bcm_socket(self, channel: str) -> socket.socket: self._bcm_sockets[channel] = create_bcm_socket(self.channel) return self._bcm_sockets[channel] - def _apply_filters(self, filters: Optional[can.typechecking.CanFilters]) -> None: + def _apply_filters(self, filters: can.typechecking.CanFilters | None) -> None: try: self.socket.setsockopt( constants.SOL_CAN_RAW, constants.CAN_RAW_FILTER, pack_filters(filters) @@ -873,40 +986,8 @@ def fileno(self) -> int: return self.socket.fileno() @staticmethod - def _detect_available_configs() -> List[can.typechecking.AutoDetectedConfig]: + def _detect_available_configs() -> list[can.typechecking.AutoDetectedConfig]: return [ {"interface": "socketcan", "channel": channel} for channel in find_available_interfaces() ] - - -if __name__ == "__main__": - # This example demonstrates how to use the internal methods of this module. - # It creates two sockets on vcan0 to test sending and receiving. - # - # If you want to try it out you can do the following (possibly using sudo): - # - # modprobe vcan - # ip link add dev vcan0 type vcan - # ip link set vcan0 up - # - log.setLevel(logging.DEBUG) - - def receiver(event: threading.Event) -> None: - receiver_socket = create_socket() - bind_socket(receiver_socket, "vcan0") - print("Receiver is waiting for a message...") - event.set() - print(f"Receiver got: {capture_message(receiver_socket)}") - - def sender(event: threading.Event) -> None: - event.wait() - sender_socket = create_socket() - bind_socket(sender_socket, "vcan0") - msg = Message(arbitration_id=0x01, data=b"\x01\x02\x03") - sender_socket.send(build_can_frame(msg)) - print("Sender sent a message.") - - e = threading.Event() - threading.Thread(target=receiver, args=(e,)).start() - threading.Thread(target=sender, args=(e,)).start() diff --git a/can/interfaces/socketcan/utils.py b/can/interfaces/socketcan/utils.py index 7a8538135..0740f769d 100644 --- a/can/interfaces/socketcan/utils.py +++ b/can/interfaces/socketcan/utils.py @@ -8,7 +8,7 @@ import os import struct import subprocess -from typing import cast, Optional, List +import sys from can import typechecking from can.interfaces.socketcan.constants import CAN_EFF_FLAG @@ -16,7 +16,7 @@ log = logging.getLogger(__name__) -def pack_filters(can_filters: Optional[typechecking.CanFilters] = None) -> bytes: +def pack_filters(can_filters: typechecking.CanFilters | None = None) -> bytes: if can_filters is None: # Pass all messages can_filters = [{"can_id": 0, "can_mask": 0}] @@ -27,7 +27,6 @@ def pack_filters(can_filters: Optional[typechecking.CanFilters] = None) -> bytes can_id = can_filter["can_id"] can_mask = can_filter["can_mask"] if "extended" in can_filter: - can_filter = cast(typechecking.CanFilterExtended, can_filter) # Match on either 11-bit OR 29-bit messages instead of both can_mask |= CAN_EFF_FLAG if can_filter["extended"]: @@ -38,7 +37,7 @@ def pack_filters(can_filters: Optional[typechecking.CanFilters] = None) -> bytes return struct.pack(can_filter_fmt, *filter_data) -def find_available_interfaces() -> List[str]: +def find_available_interfaces() -> list[str]: """Returns the names of all open can/vcan interfaces The function calls the ``ip link list`` command. If the lookup fails, an error @@ -46,6 +45,8 @@ def find_available_interfaces() -> List[str]: :return: The list of available and active CAN interfaces or an empty list of the command failed """ + if sys.platform != "linux": + return [] try: command = ["ip", "-json", "link", "list", "up"] @@ -66,11 +67,11 @@ def find_available_interfaces() -> List[str]: output_json, ) - interfaces = [i["ifname"] for i in output_json if i["link_type"] == "can"] + interfaces = [i["ifname"] for i in output_json if i.get("link_type") == "can"] return interfaces -def error_code_to_str(code: Optional[int]) -> str: +def error_code_to_str(code: int | None) -> str: """ Converts a given error code (errno) to a useful and human readable string. diff --git a/can/interfaces/socketcand/__init__.py b/can/interfaces/socketcand/__init__.py index 442c06d8b..64950f7f4 100644 --- a/can/interfaces/socketcand/__init__.py +++ b/can/interfaces/socketcand/__init__.py @@ -6,4 +6,10 @@ http://www.domologic.de """ -from .socketcand import SocketCanDaemonBus +__all__ = [ + "SocketCanDaemonBus", + "detect_beacon", + "socketcand", +] + +from .socketcand import SocketCanDaemonBus, detect_beacon diff --git a/can/interfaces/socketcand/socketcand.py b/can/interfaces/socketcand/socketcand.py index 32b9a0edf..d401102f7 100644 --- a/can/interfaces/socketcand/socketcand.py +++ b/can/interfaces/socketcand/socketcand.py @@ -7,22 +7,128 @@ Copyright (C) 2021 DOMOLOGIC GmbH http://www.domologic.de """ -import can -import socket -import select + import logging +import os +import select +import socket import time import traceback +import urllib.parse as urlparselib +import xml.etree.ElementTree as ET from collections import deque +import can + log = logging.getLogger(__name__) +DEFAULT_SOCKETCAND_DISCOVERY_ADDRESS = "" +DEFAULT_SOCKETCAND_DISCOVERY_PORT = 42000 + + +def detect_beacon(timeout_ms: int = 3100) -> list[can.typechecking.AutoDetectedConfig]: + """ + Detects socketcand servers + + This is what :meth:`can.detect_available_configs` ends up calling to search + for available socketcand servers with a default timeout of 3100ms + (socketcand sends a beacon packet every 3000ms). + + Using this method directly allows for adjusting the timeout. Extending + the timeout beyond the default time period could be useful if UDP + packet loss is a concern. + + :param timeout_ms: + Timeout in milliseconds to wait for socketcand beacon packets + + :return: + See :meth:`~can.detect_available_configs` + """ + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.bind( + (DEFAULT_SOCKETCAND_DISCOVERY_ADDRESS, DEFAULT_SOCKETCAND_DISCOVERY_PORT) + ) + log.info( + "Listening on for socketcand UDP advertisement on %s:%s", + DEFAULT_SOCKETCAND_DISCOVERY_ADDRESS, + DEFAULT_SOCKETCAND_DISCOVERY_PORT, + ) + + now = time.time() * 1000 + end_time = now + timeout_ms + while (time.time() * 1000) < end_time: + try: + # get all sockets that are ready (can be a list with a single value + # being self.socket or an empty list if self.socket is not ready) + ready_receive_sockets, _, _ = select.select([sock], [], [], 1) + + if not ready_receive_sockets: + log.debug("No advertisement received") + continue + + msg = sock.recv(1024).decode("utf-8") + root = ET.fromstring(msg) + if root.tag != "CANBeacon": + log.debug("Unexpected message received over UDP") + continue + + det_devs = [] + det_host = None + det_port = None + for child in root: + if child.tag == "Bus": + bus_name = child.attrib["name"] + det_devs.append(bus_name) + elif child.tag == "URL": + url = urlparselib.urlparse(child.text) + det_host = url.hostname + det_port = url.port + + if not det_devs: + log.debug( + "Got advertisement, but no SocketCAN devices advertised by socketcand" + ) + continue + + if (det_host is None) or (det_port is None): + det_host = None + det_port = None + log.debug( + "Got advertisement, but no SocketCAN URL advertised by socketcand" + ) + continue + + log.info(f"Found SocketCAN devices: {det_devs}") + return [ + { + "interface": "socketcand", + "host": det_host, + "port": det_port, + "channel": channel, + } + for channel in det_devs + ] + + except ET.ParseError: + log.debug("Unexpected message received over UDP") + continue + + except Exception as exc: + # something bad happened (e.g. the interface went down) + log.error(f"Failed to detect beacon: {exc} {traceback.format_exc()}") + raise OSError( + f"Failed to detect beacon: {exc} {traceback.format_exc()}" + ) from exc + + return [] + def convert_ascii_message_to_can_message(ascii_msg: str) -> can.Message: - if not ascii_msg.startswith("< frame ") or not ascii_msg.endswith(" >"): - log.warning(f"Could not parse ascii message: {ascii_msg}") + if not ascii_msg.endswith(" >"): + log.warning(f"Missing ending character in ascii message: {ascii_msg}") return None - else: + + if ascii_msg.startswith("< frame "): # frame_string = ascii_msg.removeprefix("< frame ").removesuffix(" >") frame_string = ascii_msg[8:-2] parts = frame_string.split(" ", 3) @@ -41,6 +147,31 @@ def convert_ascii_message_to_can_message(ascii_msg: str) -> can.Message: ) return can_message + if ascii_msg.startswith("< error "): + frame_string = ascii_msg[8:-2] + parts = frame_string.split(" ", 3) + can_id, timestamp = int(parts[0], 16), float(parts[1]) + is_ext = len(parts[0]) != 3 + + # socketcand sends no data in the error message so we don't have information + # about the error details, therefore the can frame is created with one + # data byte set to zero + data = bytearray([0]) + can_dlc = len(data) + can_message = can.Message( + timestamp=timestamp, + arbitration_id=can_id & 0x1FFFFFFF, + is_error_frame=True, + data=data, + dlc=can_dlc, + is_extended_id=True, + is_rx=True, + ) + return can_message + + log.warning(f"Could not parse ascii message: {ascii_msg}") + return None + def convert_can_message_to_ascii_message(can_message: can.Message) -> str: # Note: socketcan bus adds extended flag, remote_frame_flag & error_flag to id @@ -74,10 +205,45 @@ def connect_to_server(s, host, port): class SocketCanDaemonBus(can.BusABC): - def __init__(self, channel, host, port, can_filters=None, **kwargs): + def __init__(self, channel, host, port, tcp_tune=False, can_filters=None, **kwargs): + """Connects to a CAN bus served by socketcand. + + It implements :meth:`can.BusABC._detect_available_configs` to search for + available interfaces. + + It will attempt to connect to the server for up to 10s, after which a + TimeoutError exception will be thrown. + + If the handshake with the socketcand server fails, a CanError exception + is thrown. + + :param channel: + The can interface name served by socketcand. + An example channel would be 'vcan0' or 'can0'. + :param host: + The host address of the socketcand server. + :param port: + The port of the socketcand server. + :param tcp_tune: + This tunes the TCP socket for low latency (TCP_NODELAY, and + TCP_QUICKACK). + This option is not available under windows. + :param can_filters: + See :meth:`can.BusABC.set_filters`. + """ self.__host = host self.__port = port + + self.__tcp_tune = tcp_tune self.__socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + if self.__tcp_tune: + if os.name == "nt": + self.__tcp_tune = False + log.warning("'tcp_tune' not available in Windows. Setting to False") + else: + self.__socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self.__message_buffer = deque() self.__receive_buffer = "" # i know string is not the most efficient here self.channel = channel @@ -90,9 +256,9 @@ def __init__(self, channel, host, port, can_filters=None, **kwargs): ) self._tcp_send(f"< open {channel} >") self._expect_msg("< ok >") - self._tcp_send(f"< rawmode >") + self._tcp_send("< rawmode >") self._expect_msg("< ok >") - super().__init__(channel=channel, can_filters=can_filters) + super().__init__(channel=channel, can_filters=can_filters, **kwargs) def _recv_internal(self, timeout): if len(self.__message_buffer) != 0: @@ -108,7 +274,7 @@ def _recv_internal(self, timeout): except OSError as exc: # something bad happened (e.g. the interface went down) log.error(f"Failed to receive: {exc}") - raise can.CanError(f"Failed to receive: {exc}") + raise can.CanError(f"Failed to receive: {exc}") from exc try: if not ready_receive_sockets: @@ -119,6 +285,8 @@ def _recv_internal(self, timeout): ascii_msg = self.__socket.recv(1024).decode( "ascii" ) # may contain multiple messages + if self.__tcp_tune: + self.__socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_QUICKACK, 1) self.__receive_buffer += ascii_msg log.debug(f"Received Ascii Message: {ascii_msg}") buffer_view = self.__receive_buffer @@ -155,9 +323,7 @@ def _recv_internal(self, timeout): self.__message_buffer.append(parsed_can_message) buffer_view = buffer_view[end + 1 :] - self.__receive_buffer = self.__receive_buffer[ - chars_processed_successfully + 1 : - ] + self.__receive_buffer = self.__receive_buffer[chars_processed_successfully:] can_message = ( None if len(self.__message_buffer) == 0 @@ -167,21 +333,41 @@ def _recv_internal(self, timeout): except Exception as exc: log.error(f"Failed to receive: {exc} {traceback.format_exc()}") - raise can.CanError(f"Failed to receive: {exc} {traceback.format_exc()}") + raise can.CanError( + f"Failed to receive: {exc} {traceback.format_exc()}" + ) from exc def _tcp_send(self, msg: str): log.debug(f"Sending TCP Message: '{msg}'") self.__socket.sendall(msg.encode("ascii")) + if self.__tcp_tune: + self.__socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_QUICKACK, 1) def _expect_msg(self, msg): ascii_msg = self.__socket.recv(256).decode("ascii") + if self.__tcp_tune: + self.__socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_QUICKACK, 1) if not ascii_msg == msg: - raise can.CanError(f"{msg} message expected!") + raise can.CanError(f"Expected '{msg}' got: '{ascii_msg}'") def send(self, msg, timeout=None): + """Transmit a message to the CAN bus. + + :param msg: A message object. + :param timeout: Ignored + """ ascii_msg = convert_can_message_to_ascii_message(msg) self._tcp_send(ascii_msg) def shutdown(self): - self.stop_all_periodic_tasks() + """Stops all active periodic tasks and closes the socket.""" + super().shutdown() self.__socket.close() + + @staticmethod + def _detect_available_configs() -> list[can.typechecking.AutoDetectedConfig]: + try: + return detect_beacon() + except Exception as e: + log.warning(f"Could not detect socketcand beacon: {e}") + return [] diff --git a/can/interfaces/systec/__init__.py b/can/interfaces/systec/__init__.py index be7004b6a..e38a52fb1 100644 --- a/can/interfaces/systec/__init__.py +++ b/can/interfaces/systec/__init__.py @@ -1 +1,10 @@ +__all__ = [ + "UcanBus", + "constants", + "exceptions", + "structures", + "ucan", + "ucanbus", +] + from can.interfaces.systec.ucanbus import UcanBus diff --git a/can/interfaces/systec/constants.py b/can/interfaces/systec/constants.py index 96952c17e..8caf9eab4 100644 --- a/can/interfaces/systec/constants.py +++ b/can/interfaces/systec/constants.py @@ -1,4 +1,6 @@ -from ctypes import c_ubyte as BYTE, c_ushort as WORD, c_ulong as DWORD +from ctypes import c_ubyte as BYTE +from ctypes import c_ulong as DWORD +from ctypes import c_ushort as WORD #: Maximum number of modules that are supported. MAX_MODULES = 64 diff --git a/can/interfaces/systec/exceptions.py b/can/interfaces/systec/exceptions.py index 733326194..8768b412a 100644 --- a/can/interfaces/systec/exceptions.py +++ b/can/interfaces/systec/exceptions.py @@ -1,10 +1,9 @@ -from typing import Dict - from abc import ABC, abstractmethod -from .constants import ReturnCode from can import CanError +from .constants import ReturnCode + class UcanException(CanError, ABC): """Base class for USB can errors.""" @@ -22,8 +21,7 @@ def __init__(self, result, func, arguments): @property @abstractmethod - def _error_message_mapping(self) -> Dict[ReturnCode, str]: - ... + def _error_message_mapping(self) -> dict[ReturnCode, str]: ... class UcanError(UcanException): @@ -52,7 +50,7 @@ class UcanError(UcanException): } @property - def _error_message_mapping(self) -> Dict[ReturnCode, str]: + def _error_message_mapping(self) -> dict[ReturnCode, str]: return UcanError._ERROR_MESSAGES @@ -78,7 +76,7 @@ class UcanCmdError(UcanException): } @property - def _error_message_mapping(self) -> Dict[ReturnCode, str]: + def _error_message_mapping(self) -> dict[ReturnCode, str]: return UcanCmdError._ERROR_MESSAGES @@ -103,5 +101,5 @@ class UcanWarning(UcanException): } @property - def _error_message_mapping(self) -> Dict[ReturnCode, str]: + def _error_message_mapping(self) -> dict[ReturnCode, str]: return UcanWarning._ERROR_MESSAGES diff --git a/can/interfaces/systec/structures.py b/can/interfaces/systec/structures.py index 841474b80..a50ac4c26 100644 --- a/can/interfaces/systec/structures.py +++ b/can/interfaces/systec/structures.py @@ -1,12 +1,20 @@ -from ctypes import Structure, POINTER, sizeof +import os +from ctypes import POINTER, Structure, sizeof +from ctypes import ( + c_long as BOOL, +) from ctypes import ( c_ubyte as BYTE, - c_ushort as WORD, +) +from ctypes import ( c_ulong as DWORD, - c_long as BOOL, +) +from ctypes import ( + c_ushort as WORD, +) +from ctypes import ( c_void_p as LPVOID, ) -import os # Workaround for Unix based platforms to be able to load structures for testing, etc... if os.name == "nt": @@ -43,10 +51,14 @@ class CanMsg(Structure): DWORD, ), # Receive time stamp in ms (for transmit messages no meaning) ] + __hash__ = Structure.__hash__ - def __init__(self, id=0, frame_format=MsgFrameFormat.MSG_FF_STD, data=None): + def __init__( + self, id_=0, frame_format=MsgFrameFormat.MSG_FF_STD, data=None, dlc=None + ): data = [] if data is None else data - super().__init__(id, frame_format, len(data), (BYTE * 8)(*data), 0) + dlc = len(data) if dlc is None else dlc + super().__init__(id_, frame_format, dlc, (BYTE * 8)(*data), 0) def __eq__(self, other): if not isinstance(other, CanMsg): @@ -63,8 +75,8 @@ def id(self): return self.m_dwID @id.setter - def id(self, id): - self.m_dwID = id + def id(self, value): + self.m_dwID = value @property def frame_format(self): @@ -105,6 +117,7 @@ class Status(Structure): ("m_wCanStatus", WORD), # CAN error status (see enum :class:`CanStatus`) ("m_wUsbStatus", WORD), # USB error status (see enum :class:`UsbStatus`) ] + __hash__ = Structure.__hash__ def __eq__(self, other): if not isinstance(other, Status): @@ -160,6 +173,7 @@ class InitCanParam(Structure): WORD, ), # number of transmit buffer entries (default is 4096) ] + __hash__ = Structure.__hash__ def __init__( self, mode, BTR, OCR, AMR, ACR, baudrate, rx_buffer_entries, tx_buffer_entries @@ -266,6 +280,7 @@ class HardwareInfoEx(Structure): ("m_dwUniqueId3", DWORD), ("m_dwFlags", DWORD), # additional flags ] + __hash__ = Structure.__hash__ def __init__(self): super().__init__(sizeof(HardwareInfoEx)) @@ -378,6 +393,7 @@ class ChannelInfo(Structure): WORD, ), # CAN status (same as received by method :meth:`UcanServer.get_status`) ] + __hash__ = Structure.__hash__ def __init__(self): super().__init__(sizeof(ChannelInfo)) diff --git a/can/interfaces/systec/ucan.py b/can/interfaces/systec/ucan.py index a6de4e9f5..f969532d7 100644 --- a/can/interfaces/systec/ucan.py +++ b/can/interfaces/systec/ucan.py @@ -1,14 +1,12 @@ import logging import sys - from ctypes import byref from ctypes import c_wchar_p as LPWSTR from ...exceptions import CanInterfaceNotImplementedError - from .constants import * -from .structures import * from .exceptions import * +from .structures import * log = logging.getLogger("can.systec") @@ -411,7 +409,7 @@ def init_hardware(self, serial=None, device_number=ANY_MODULE): Initializes the device with the corresponding serial or device number. :param int or None serial: Serial number of the USB-CANmodul. - :param int device_number: Device number (0 – 254, or :const:`ANY_MODULE` for the first device). + :param int device_number: Device number (0 - 254, or :const:`ANY_MODULE` for the first device). """ if not self._hw_is_initialized: # initialize hardware either by device number or serial diff --git a/can/interfaces/systec/ucanbus.py b/can/interfaces/systec/ucanbus.py index 7d8b6133a..00f101e4e 100644 --- a/can/interfaces/systec/ucanbus.py +++ b/can/interfaces/systec/ucanbus.py @@ -1,12 +1,19 @@ import logging from threading import Event -from can import BusABC, BusState, Message -from ...exceptions import CanError, CanInitializationError, CanOperationError +from can import ( + BusABC, + BusState, + CanError, + CanInitializationError, + CanOperationError, + CanProtocol, + Message, +) from .constants import * -from .structures import * from .exceptions import UcanException +from .structures import * from .ucan import UcanServer log = logging.getLogger("can.systec") @@ -104,6 +111,8 @@ def __init__(self, channel, can_filters=None, **kwargs): ) from exception self.channel = int(channel) + self._can_protocol = CanProtocol.CAN_20 + device_number = int(kwargs.get("device_number", ANY_MODULE)) # configuration options @@ -134,18 +143,22 @@ def __init__(self, channel, can_filters=None, **kwargs): self._ucan.init_hardware(device_number=device_number) self._ucan.init_can(self.channel, **self._params) hw_info_ex, _, _ = self._ucan.get_hardware_info() - self.channel_info = "{}, S/N {}, CH {}, BTR {}".format( - self._ucan.get_product_code_message(hw_info_ex.product_code), - hw_info_ex.serial, - self.channel, - self._ucan.get_baudrate_message(self.BITRATES[bitrate]), + self.channel_info = ( + f"{self._ucan.get_product_code_message(hw_info_ex.product_code)}, " + f"S/N {hw_info_ex.serial}, " + f"CH {self.channel}, " + f"BTR {self._ucan.get_baudrate_message(self.BITRATES[bitrate])}" ) except UcanException as exception: raise CanInitializationError() from exception self._is_filtered = False - super().__init__(channel=channel, can_filters=can_filters, **kwargs) + super().__init__( + channel=channel, + can_filters=can_filters, + **kwargs, + ) def _recv_internal(self, timeout): try: @@ -194,6 +207,7 @@ def send(self, msg, timeout=None): | (MsgFrameFormat.MSG_FF_EXT if msg.is_extended_id else 0) | (MsgFrameFormat.MSG_FF_RTR if msg.is_remote_frame else 0), msg.data, + msg.dlc, ) self._ucan.write_can_msg(self.channel, [message]) except UcanException as exception: diff --git a/can/interfaces/udp_multicast/__init__.py b/can/interfaces/udp_multicast/__init__.py index 0ce1ce389..d52c028f0 100644 --- a/can/interfaces/udp_multicast/__init__.py +++ b/can/interfaces/udp_multicast/__init__.py @@ -1,3 +1,9 @@ """A module to allow CAN over UDP on IPv4/IPv6 multicast.""" +__all__ = [ + "UdpMulticastBus", + "bus", + "utils", +] + from .bus import UdpMulticastBus diff --git a/can/interfaces/udp_multicast/bus.py b/can/interfaces/udp_multicast/bus.py index 2ba1205b1..87a0800fa 100644 --- a/can/interfaces/udp_multicast/bus.py +++ b/can/interfaces/udp_multicast/bus.py @@ -1,34 +1,38 @@ import errno import logging +import platform import select import socket import struct - -try: - from fcntl import ioctl -except ModuleNotFoundError: # Missing on Windows - pass - -from typing import List, Optional, Tuple, Union - -log = logging.getLogger(__name__) +import time +import warnings +from typing import Any import can -from can import BusABC +from can import BusABC, CanProtocol, Message from can.typechecking import AutoDetectedConfig -from .utils import pack_message, unpack_message, check_msgpack_installed +from .utils import is_msgpack_installed, pack_message, unpack_message + +is_linux = platform.system() == "Linux" +if is_linux: + from fcntl import ioctl + +log = logging.getLogger(__name__) # see socket.getaddrinfo() -IPv4_ADDRESS_INFO = Tuple[str, int] # address, port -IPv6_ADDRESS_INFO = Tuple[str, int, int, int] # address, port, flowinfo, scope_id -IP_ADDRESS_INFO = Union[IPv4_ADDRESS_INFO, IPv6_ADDRESS_INFO] +IPv4_ADDRESS_INFO = tuple[str, int] # address, port +IPv6_ADDRESS_INFO = tuple[str, int, int, int] # address, port, flowinfo, scope_id +IP_ADDRESS_INFO = IPv4_ADDRESS_INFO | IPv6_ADDRESS_INFO # Additional constants for the interaction with Unix kernels SO_TIMESTAMPNS = 35 SIOCGSTAMP = 0x8906 +# Additional constants for the interaction with the Winsock API +WSAEINVAL = 10022 + class UdpMulticastBus(BusABC): """A virtual interface for CAN communications between multiple processes using UDP over Multicast IP. @@ -94,21 +98,35 @@ def __init__( hop_limit: int = 1, receive_own_messages: bool = False, fd: bool = True, - **kwargs, + **kwargs: Any, ) -> None: - check_msgpack_installed() + is_msgpack_installed() if receive_own_messages: raise can.CanInterfaceNotImplementedError( "receiving own messages is not yet implemented" ) - super().__init__(channel, **kwargs) + super().__init__( + channel, + **kwargs, + ) - self.is_fd = fd self._multicast = GeneralPurposeUdpMulticastBus(channel, port, hop_limit) + self._can_protocol = CanProtocol.CAN_FD if fd else CanProtocol.CAN_20 + + @property + def is_fd(self) -> bool: + class_name = self.__class__.__name__ + warnings.warn( + f"The {class_name}.is_fd property is deprecated and superseded by " + f"{class_name}.protocol. It is scheduled for removal in python-can version 5.0.", + DeprecationWarning, + stacklevel=2, + ) + return self._can_protocol is CanProtocol.CAN_FD - def _recv_internal(self, timeout: Optional[float]): + def _recv_internal(self, timeout: float | None) -> tuple[Message | None, bool]: result = self._multicast.recv(timeout) if not result: return None, False @@ -123,13 +141,13 @@ def _recv_internal(self, timeout: Optional[float]): "could not unpack received message" ) from exception - if not self.is_fd and can_message.is_fd: + if self._can_protocol is not CanProtocol.CAN_FD and can_message.is_fd: return None, False return can_message, False - def send(self, msg: can.Message, timeout: Optional[float] = None) -> None: - if not self.is_fd and msg.is_fd: + def send(self, msg: can.Message, timeout: float | None = None) -> None: + if self._can_protocol is not CanProtocol.CAN_FD and msg.is_fd: raise can.CanOperationError( "cannot send FD message over bus with CAN FD disabled" ) @@ -150,7 +168,7 @@ def shutdown(self) -> None: self._multicast.shutdown() @staticmethod - def _detect_available_configs() -> List[AutoDetectedConfig]: + def _detect_available_configs() -> list[AutoDetectedConfig]: if hasattr(socket, "CMSG_SPACE"): return [ { @@ -186,7 +204,7 @@ def __init__( # Look up multicast group address in name server and find out IP version of the first suitable target # and then get the address family of it (socket.AF_INET or socket.AF_INET6) - connection_candidates = socket.getaddrinfo( # type: ignore + connection_candidates = socket.getaddrinfo( group, self.port, type=socket.SOCK_DGRAM ) sock = None @@ -222,7 +240,7 @@ def __init__( # used by send() self._send_destination = (self.group, self.port) - self._last_send_timeout: Optional[float] = None + self._last_send_timeout: float | None = None def _create_socket(self, address_family: socket.AddressFamily) -> socket.socket: """Creates a new socket. This might fail and raise an exception! @@ -239,7 +257,6 @@ def _create_socket(self, address_family: socket.AddressFamily) -> socket.socket: # configure the socket try: - # set hop limit / TTL ttl_as_binary = struct.pack("@I", self.hop_limit) if self.ip_version == 4: @@ -254,11 +271,19 @@ def _create_socket(self, address_family: socket.AddressFamily) -> socket.socket: # Allow multiple programs to access that address + port sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # Option not supported on Windows. + if hasattr(socket, "SO_REUSEPORT"): + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + # set how to receive timestamps try: sock.setsockopt(socket.SOL_SOCKET, SO_TIMESTAMPNS, 1) except OSError as error: - if error.errno == errno.ENOPROTOOPT: # It is unavailable on macOS + if ( + error.errno == errno.ENOPROTOOPT + or error.errno == errno.EINVAL + or error.errno == WSAEINVAL + ): # It is unavailable on macOS (ENOPROTOOPT) or windows(EINVAL/WSAEINVAL) self.timestamp_nanosecond = False else: raise error @@ -292,7 +317,7 @@ def _create_socket(self, address_family: socket.AddressFamily) -> socket.socket: "could not create or configure socket" ) from error - def send(self, data: bytes, timeout: Optional[float] = None) -> None: + def send(self, data: bytes, timeout: float | None = None) -> None: """Send data to all group members. This call blocks. :param timeout: the timeout in seconds after which an Exception is raised is sending has failed @@ -315,8 +340,8 @@ def send(self, data: bytes, timeout: Optional[float] = None) -> None: raise can.CanOperationError("failed to send via socket") from error def recv( - self, timeout: Optional[float] = None - ) -> Optional[Tuple[bytes, IP_ADDRESS_INFO, float]]: + self, timeout: float | None = None + ) -> tuple[bytes, IP_ADDRESS_INFO, float] | None: """ Receive up to **max_buffer** bytes. @@ -339,18 +364,18 @@ def recv( ) from exc if ready_receive_sockets: # not empty - # fetch data & source address - ( - raw_message_data, - ancillary_data, - _, # flags - sender_address, - ) = self._socket.recvmsg( - self.max_buffer, self.received_ancillary_buffer_size - ) - # fetch timestamp; this is configured in _create_socket() if self.timestamp_nanosecond: + # fetch data, timestamp & source address + ( + raw_message_data, + ancillary_data, + _, # flags + sender_address, + ) = self._socket.recvmsg( + self.max_buffer, self.received_ancillary_buffer_size + ) + # Very similar to timestamp handling in can/interfaces/socketcan/socketcan.py -> capture_message() if len(ancillary_data) != 1: raise can.CanOperationError( @@ -371,14 +396,29 @@ def recv( ) timestamp = seconds + nanoseconds * 1.0e-9 else: - result_buffer = ioctl( - self._socket.fileno(), - SIOCGSTAMP, - bytes(self.received_timestamp_struct_size), - ) - seconds, microseconds = struct.unpack( - self.received_timestamp_struct, result_buffer + # fetch data & source address + (raw_message_data, sender_address) = self._socket.recvfrom( + self.max_buffer ) + + if is_linux: + # This ioctl isn't supported on Darwin & Windows. + result_buffer = ioctl( + self._socket.fileno(), + SIOCGSTAMP, + bytes(self.received_timestamp_struct_size), + ) + seconds, microseconds = struct.unpack( + self.received_timestamp_struct, result_buffer + ) + else: + # fallback to time.time_ns + now = time.time() + + # Extract seconds and microseconds + seconds = int(now) + microseconds = int((now - seconds) * 1000000) + if microseconds >= 1e6: raise can.CanOperationError( f"Timestamp microseconds field was out of range: {microseconds} not less than 1e6" diff --git a/can/interfaces/udp_multicast/utils.py b/can/interfaces/udp_multicast/utils.py index 2658bf0a9..1e1d62c23 100644 --- a/can/interfaces/udp_multicast/utils.py +++ b/can/interfaces/udp_multicast/utils.py @@ -2,12 +2,9 @@ Defines common functions. """ -from typing import Any -from typing import Dict -from typing import Optional +from typing import Any, cast -from can import Message -from can import CanInterfaceNotImplementedError +from can import CanInterfaceNotImplementedError, Message from can.typechecking import ReadableBytesLike try: @@ -16,10 +13,22 @@ msgpack = None -def check_msgpack_installed() -> None: - """Raises a :class:`can.CanInterfaceNotImplementedError` if `msgpack` is not installed.""" +def is_msgpack_installed(raise_exception: bool = True) -> bool: + """Check whether the ``msgpack`` module is installed. + + :param raise_exception: + If True, raise a :class:`can.CanInterfaceNotImplementedError` when ``msgpack`` is not installed. + If False, return False instead. + :return: + True if ``msgpack`` is installed, False otherwise. + :raises can.CanInterfaceNotImplementedError: + If ``msgpack`` is not installed and ``raise_exception`` is True. + """ if msgpack is None: - raise CanInterfaceNotImplementedError("msgpack not installed") + if raise_exception: + raise CanInterfaceNotImplementedError("msgpack not installed") + return False + return True def pack_message(message: Message) -> bytes: @@ -28,7 +37,7 @@ def pack_message(message: Message) -> bytes: :param message: the message to be packed """ - check_msgpack_installed() + is_msgpack_installed() as_dict = { "timestamp": message.timestamp, "arbitration_id": message.arbitration_id, @@ -42,12 +51,12 @@ def pack_message(message: Message) -> bytes: "bitrate_switch": message.bitrate_switch, "error_state_indicator": message.error_state_indicator, } - return msgpack.packb(as_dict, use_bin_type=True) + return cast("bytes", msgpack.packb(as_dict, use_bin_type=True)) def unpack_message( data: ReadableBytesLike, - replace: Optional[Dict[str, Any]] = None, + replace: dict[str, Any] | None = None, check: bool = False, ) -> Message: """Unpack a can.Message from a msgpack byte blob. @@ -61,7 +70,7 @@ def unpack_message( :raise ValueError: if `check` is true and the message metadata is invalid in some way :raise Exception: if there was another problem while unpacking """ - check_msgpack_installed() + is_msgpack_installed() as_dict = msgpack.unpackb(data, raw=False) if replace is not None: as_dict.update(replace) diff --git a/can/interfaces/usb2can/__init__.py b/can/interfaces/usb2can/__init__.py index 4ccff1cb0..f818130ee 100644 --- a/can/interfaces/usb2can/__init__.py +++ b/can/interfaces/usb2can/__init__.py @@ -1,5 +1,12 @@ -""" -""" +""" """ + +__all__ = [ + "Usb2CanAbstractionLayer", + "Usb2canBus", + "serial_selector", + "usb2canInterface", + "usb2canabstractionlayer", +] -from .usb2canInterface import Usb2canBus from .usb2canabstractionlayer import Usb2CanAbstractionLayer +from .usb2canInterface import Usb2canBus diff --git a/can/interfaces/usb2can/serial_selector.py b/can/interfaces/usb2can/serial_selector.py index c2e48ff97..18ad3f873 100644 --- a/can/interfaces/usb2can/serial_selector.py +++ b/can/interfaces/usb2can/serial_selector.py @@ -1,15 +1,16 @@ -""" -""" +""" """ import logging -from typing import List log = logging.getLogger("can.usb2can") try: + import pythoncom import win32com.client except ImportError: - log.warning("win32com.client module required for usb2can") + log.warning( + "win32com.client module required for usb2can. Install the 'pywin32' package." + ) raise @@ -42,7 +43,7 @@ def WMIDateStringToDate(dtmDate) -> str: return strDateTime -def find_serial_devices(serial_matcher: str = "") -> List[str]: +def find_serial_devices(serial_matcher: str = "") -> list[str]: """ Finds a list of USB devices where the serial number (partially) matches the given string. @@ -50,6 +51,7 @@ def find_serial_devices(serial_matcher: str = "") -> List[str]: only device IDs starting with this string are returned """ serial_numbers = [] + pythoncom.CoInitialize() wmi = win32com.client.GetObject("winmgmts:") for usb_controller in wmi.InstancesOf("Win32_USBControllerDevice"): usb_device = wmi.Get(usb_controller.Dependent) diff --git a/can/interfaces/usb2can/usb2canInterface.py b/can/interfaces/usb2can/usb2canInterface.py index 504b61c7b..66c171f4d 100644 --- a/can/interfaces/usb2can/usb2canInterface.py +++ b/can/interfaces/usb2can/usb2canInterface.py @@ -4,16 +4,27 @@ import logging from ctypes import byref -from typing import Optional -from can import BusABC, Message, CanInitializationError, CanOperationError -from .usb2canabstractionlayer import Usb2CanAbstractionLayer, CanalMsg, CanalError +from can import ( + BitTiming, + BitTimingFd, + BusABC, + CanInitializationError, + CanOperationError, + CanProtocol, + Message, +) +from can.util import check_or_adjust_timing_clock + +from .serial_selector import find_serial_devices from .usb2canabstractionlayer import ( IS_ERROR_FRAME, - IS_REMOTE_FRAME, IS_ID_TYPE, + IS_REMOTE_FRAME, + CanalError, + CanalMsg, + Usb2CanAbstractionLayer, ) -from .serial_selector import find_serial_devices # Set up logging log = logging.getLogger("can.usb2can") @@ -75,6 +86,13 @@ class Usb2canBus(BusABC): Bitrate of channel in bit/s. Values will be limited to a maximum of 1000 Kb/s. Default is 500 Kbs + :param timing: + Optional :class:`~can.BitTiming` instance to use for custom bit timing setting. + If this argument is set then it overrides the bitrate argument. The + `f_clock` value of the timing instance must be set to 32_000_000 (32MHz) + for standard CAN. + CAN FD and the :class:`~can.BitTimingFd` class are not supported. + :param flags: Flags to directly pass to open function of the usb2can abstraction layer. @@ -91,15 +109,14 @@ class Usb2canBus(BusABC): def __init__( self, - channel: Optional[str] = None, + channel: str | None = None, dll: str = "usb2can.dll", flags: int = 0x00000008, - *_, bitrate: int = 500000, - serial: Optional[str] = None, + timing: BitTiming | BitTimingFd | None = None, + serial: str | None = None, **kwargs, ): - self.can = Usb2CanAbstractionLayer(dll) # get the serial number of the device @@ -112,12 +129,28 @@ def __init__( raise CanInitializationError("could not automatically find any device") device_id = devices[0] - # convert to kb/s and cap: max rate is 1000 kb/s - baudrate = min(int(bitrate // 1000), 1000) - self.channel_info = f"USB2CAN device {device_id}" - connector = f"{device_id}; {baudrate}" + if isinstance(timing, BitTiming): + timing = check_or_adjust_timing_clock(timing, valid_clocks=[32_000_000]) + connector = ( + f"{device_id};" + "0;" + f"{timing.tseg1};" + f"{timing.tseg2};" + f"{timing.sjw};" + f"{timing.brp}" + ) + elif isinstance(timing, BitTimingFd): + raise NotImplementedError( + f"CAN FD is not supported by {self.__class__.__name__}." + ) + else: + # convert to kb/s and cap: max rate is 1000 kb/s + baudrate = min(int(bitrate // 1000), 1000) + connector = f"{device_id};{baudrate}" + + self._can_protocol = CanProtocol.CAN_20 self.handle = self.can.open(connector, flags) super().__init__(channel=channel, **kwargs) @@ -134,7 +167,6 @@ def send(self, msg, timeout=None): raise CanOperationError("could not send message", error_code=status) def _recv_internal(self, timeout): - messagerx = CanalMsg() if timeout == 0: @@ -174,7 +206,7 @@ def _detect_available_configs(): return Usb2canBus.detect_available_configs() @staticmethod - def detect_available_configs(serial_matcher: Optional[str] = None): + def detect_available_configs(serial_matcher: str | None = None): """ Uses the *Windows Management Instrumentation* to identify serial devices. diff --git a/can/interfaces/usb2can/usb2canabstractionlayer.py b/can/interfaces/usb2can/usb2canabstractionlayer.py index a6708cb42..9fbf5c15c 100644 --- a/can/interfaces/usb2can/usb2canabstractionlayer.py +++ b/can/interfaces/usb2can/usb2canabstractionlayer.py @@ -3,11 +3,12 @@ Socket CAN is recommended under Unix/Linux systems. """ +import logging from ctypes import * from enum import IntEnum -import logging import can + from ...exceptions import error_check from ...typechecking import StringPathLike @@ -149,7 +150,7 @@ def open(self, configuration: str, flags: int): # catch any errors thrown by this call and re-raise raise can.CanInitializationError( f'CanalOpen() failed, configuration: "{configuration}", error: {ex}' - ) + ) from ex else: # any greater-than-zero return value indicates a success # (see https://grodansparadis.gitbooks.io/the-vscp-daemon/canal_interface_specification.html) diff --git a/can/interfaces/vector/__init__.py b/can/interfaces/vector/__init__.py index c5eae7140..e78783f1f 100644 --- a/can/interfaces/vector/__init__.py +++ b/can/interfaces/vector/__init__.py @@ -1,12 +1,28 @@ -""" -""" +""" """ + +__all__ = [ + "VectorBus", + "VectorBusParams", + "VectorCanFdParams", + "VectorCanParams", + "VectorChannelConfig", + "VectorError", + "VectorInitializationError", + "VectorOperationError", + "canlib", + "exceptions", + "get_channel_configs", + "xlclass", + "xldefine", + "xldriver", +] from .canlib import ( VectorBus, - get_channel_configs, - VectorChannelConfig, VectorBusParams, - VectorCanParams, VectorCanFdParams, + VectorCanParams, + VectorChannelConfig, + get_channel_configs, ) -from .exceptions import VectorError, VectorOperationError, VectorInitializationError +from .exceptions import VectorError, VectorInitializationError, VectorOperationError diff --git a/can/interfaces/vector/canlib.py b/can/interfaces/vector/canlib.py index d53b1418d..8bdd77b83 100644 --- a/can/interfaces/vector/canlib.py +++ b/can/interfaces/vector/canlib.py @@ -4,109 +4,100 @@ Authors: Julien Grave , Christian Sandberg """ -# Import Standard Python Modules -# ============================== +import contextlib import ctypes import logging -import time import os +import time +import warnings +from collections.abc import Callable, Iterator, Sequence from types import ModuleType from typing import ( - List, - NamedTuple, - Optional, - Tuple, - Sequence, - Union, Any, - Dict, - Callable, + NamedTuple, cast, ) -WaitForSingleObject: Optional[Callable[[int, int], int]] -INFINITE: Optional[int] -try: - # Try builtin Python 3 Windows API - from _winapi import WaitForSingleObject, INFINITE # type: ignore - - HAS_EVENTS = True -except ImportError: - WaitForSingleObject, INFINITE = None, None - HAS_EVENTS = False - -# Import Modules -# ============== from can import ( - BusABC, - Message, - CanInterfaceNotImplementedError, - CanInitializationError, BitTiming, BitTimingFd, + BusABC, + CanInitializationError, + CanInterfaceNotImplementedError, + CanProtocol, + Message, ) +from can.typechecking import AutoDetectedConfig, CanFilters from can.util import ( - len2dlc, - dlc2len, + check_or_adjust_timing_clock, deprecated_args_alias, + dlc2len, + len2dlc, time_perfcounter_correlation, - check_or_adjust_timing_clock, ) -from can.typechecking import AutoDetectedConfig, CanFilters -# Define Module Logger -# ==================== -LOG = logging.getLogger(__name__) - -# Import Vector API modules -# ========================= +from . import xlclass, xldefine from .exceptions import VectorError, VectorInitializationError, VectorOperationError -from . import xldefine, xlclass + +LOG = logging.getLogger(__name__) # Import safely Vector API module for Travis tests -xldriver: Optional[ModuleType] = None +xldriver: ModuleType | None = None try: from . import xldriver -except Exception as exc: +except FileNotFoundError as exc: LOG.warning("Could not import vxlapi: %s", exc) +WaitForSingleObject: Callable[[int, int], int] | None +INFINITE: int | None +try: + # Try builtin Python 3 Windows API + from _winapi import ( # type: ignore[attr-defined,no-redef,unused-ignore] + INFINITE, + WaitForSingleObject, + ) + + HAS_EVENTS = True +except ImportError: + WaitForSingleObject, INFINITE = None, None + HAS_EVENTS = False + class VectorBus(BusABC): """The CAN Bus implemented for the Vector interface.""" - deprecated_args = dict( - sjwAbr="sjw_abr", - tseg1Abr="tseg1_abr", - tseg2Abr="tseg2_abr", - sjwDbr="sjw_dbr", - tseg1Dbr="tseg1_dbr", - tseg2Dbr="tseg2_dbr", - ) - @deprecated_args_alias( deprecation_start="4.0.0", deprecation_end="5.0.0", - **deprecated_args, + **{ + "sjwAbr": "sjw_abr", + "tseg1Abr": "tseg1_abr", + "tseg2Abr": "tseg2_abr", + "sjwDbr": "sjw_dbr", + "tseg1Dbr": "tseg1_dbr", + "tseg2Dbr": "tseg2_dbr", + }, ) def __init__( self, - channel: Union[int, Sequence[int], str], - can_filters: Optional[CanFilters] = None, + channel: int | Sequence[int] | str, + can_filters: CanFilters | None = None, poll_interval: float = 0.01, receive_own_messages: bool = False, - timing: Optional[Union[BitTiming, BitTimingFd]] = None, - bitrate: Optional[int] = None, + timing: BitTiming | BitTimingFd | None = None, + bitrate: int | None = None, rx_queue_size: int = 2**14, - app_name: Optional[str] = "CANalyzer", - serial: Optional[int] = None, + app_name: str | None = "CANalyzer", + serial: int | None = None, fd: bool = False, - data_bitrate: Optional[int] = None, + data_bitrate: int | None = None, sjw_abr: int = 2, tseg1_abr: int = 6, tseg2_abr: int = 3, sjw_dbr: int = 2, tseg1_dbr: int = 6, tseg2_dbr: int = 3, + listen_only: bool | None = False, **kwargs: Any, ) -> None: """ @@ -160,6 +151,8 @@ def __init__( Bus timing value tseg1 (data) :param tseg2_dbr: Bus timing value tseg2 (data) + :param listen_only: + if the bus should be set to listen only mode. :raise ~can.exceptions.CanInterfaceNotImplementedError: If the current operating system is not supported or the driver could not be loaded. @@ -201,19 +194,33 @@ def __init__( ) channel_configs = get_channel_configs() + is_fd = isinstance(timing, BitTimingFd) if timing else fd self.mask = 0 - self.fd = isinstance(timing, BitTimingFd) if timing else fd - self.channel_masks: Dict[int, int] = {} - self.index_to_channel: Dict[int, int] = {} + self.channel_masks: dict[int, int] = {} + self.index_to_channel: dict[int, int] = {} + self._can_protocol = CanProtocol.CAN_FD if is_fd else CanProtocol.CAN_20 + + self._listen_only = listen_only for channel in self.channels: - channel_index = self._find_global_channel_idx( - channel=channel, - serial=serial, - app_name=app_name, - channel_configs=channel_configs, - ) + if ( + len(self.channels) == 1 + and (_channel_index := kwargs.get("channel_index", None)) is not None + ): + # VectorBus._detect_available_configs() might return multiple + # devices with the same serial number, e.g. if a VN8900 is connected via both USB and Ethernet + # at the same time. If the VectorBus is instantiated with a config, that was returned from + # VectorBus._detect_available_configs(), then use the contained global channel_index + # to avoid any ambiguities. + channel_index = cast("int", _channel_index) + else: + channel_index = self._find_global_channel_idx( + channel=channel, + serial=serial, + app_name=app_name, + channel_configs=channel_configs, + ) LOG.debug("Channel index %d found", channel) channel_mask = 1 << channel_index @@ -223,12 +230,12 @@ def __init__( permission_mask = xlclass.XLaccess() # Set mask to request channel init permission if needed - if bitrate or fd or timing: + if bitrate or fd or timing or self._listen_only: permission_mask.value = self.mask interface_version = ( xldefine.XL_InterfaceVersion.XL_INTERFACE_VERSION_V4 - if self.fd + if is_fd else xldefine.XL_InterfaceVersion.XL_INTERFACE_VERSION ) @@ -245,42 +252,65 @@ def __init__( self.permission_mask = permission_mask.value LOG.debug( - "Open Port: PortHandle: %d, PermissionMask: 0x%X", + "Open Port: PortHandle: %d, ChannelMask: 0x%X, PermissionMask: 0x%X", self.port_handle.value, - permission_mask.value, + self.mask, + self.permission_mask, ) + assert_timing = (bitrate or timing) and not self.__testing + # set CAN settings - for channel in self.channels: - if isinstance(timing, BitTiming): - timing = check_or_adjust_timing_clock(timing, [16_000_000, 8_000_000]) - self._set_bit_timing( - channel=channel, - timing=timing, + if isinstance(timing, BitTiming): + timing = check_or_adjust_timing_clock(timing, [16_000_000, 8_000_000]) + self._set_bit_timing(channel_mask=self.mask, timing=timing) + if assert_timing: + self._check_can_settings( + channel_mask=self.mask, + bitrate=timing.bitrate, + sample_point=timing.sample_point, ) - elif isinstance(timing, BitTimingFd): - timing = check_or_adjust_timing_clock(timing, [80_000_000]) - self._set_bit_timing_fd( - channel=channel, - timing=timing, + elif isinstance(timing, BitTimingFd): + timing = check_or_adjust_timing_clock(timing, [80_000_000]) + self._set_bit_timing_fd(channel_mask=self.mask, timing=timing) + if assert_timing: + self._check_can_settings( + channel_mask=self.mask, + bitrate=timing.nom_bitrate, + sample_point=timing.nom_sample_point, + fd=True, + data_bitrate=timing.data_bitrate, + data_sample_point=timing.data_sample_point, ) - elif fd: - self._set_bit_timing_fd( - channel=channel, - timing=BitTimingFd.from_bitrate_and_segments( - f_clock=80_000_000, - nom_bitrate=bitrate or 500_000, - nom_tseg1=tseg1_abr, - nom_tseg2=tseg2_abr, - nom_sjw=sjw_abr, - data_bitrate=data_bitrate or bitrate or 500_000, - data_tseg1=tseg1_dbr, - data_tseg2=tseg2_dbr, - data_sjw=sjw_dbr, - ), + elif fd: + timing = BitTimingFd.from_bitrate_and_segments( + f_clock=80_000_000, + nom_bitrate=bitrate or 500_000, + nom_tseg1=tseg1_abr, + nom_tseg2=tseg2_abr, + nom_sjw=sjw_abr, + data_bitrate=data_bitrate or bitrate or 500_000, + data_tseg1=tseg1_dbr, + data_tseg2=tseg2_dbr, + data_sjw=sjw_dbr, + ) + self._set_bit_timing_fd(channel_mask=self.mask, timing=timing) + if assert_timing: + self._check_can_settings( + channel_mask=self.mask, + bitrate=timing.nom_bitrate, + sample_point=timing.nom_sample_point, + fd=True, + data_bitrate=timing.data_bitrate, + data_sample_point=timing.data_sample_point, ) - elif bitrate: - self._set_bitrate(channel=channel, bitrate=bitrate) + elif bitrate: + self._set_bitrate(channel_mask=self.mask, bitrate=bitrate) + if assert_timing: + self._check_can_settings(channel_mask=self.mask, bitrate=bitrate) + + if self._listen_only: + self._set_output_mode(channel_mask=self.mask, listen_only=True) # Enable/disable TX receipts tx_receipts = 1 if receive_own_messages else 0 @@ -292,14 +322,6 @@ def __init__( else: LOG.info("Install pywin32 to avoid polling") - try: - self.xldriver.xlActivateChannel( - self.port_handle, self.mask, xldefine.XL_BusTypes.XL_BUS_TYPE_CAN, 0 - ) - except VectorOperationError as error: - self.shutdown() - raise VectorInitializationError.from_generic(error) from None - # Calculate time offset for absolute timestamps offset = xlclass.XLuint64() try: @@ -323,14 +345,38 @@ def __init__( self._time_offset = 0.0 self._is_filtered = False - super().__init__(channel=channel, can_filters=can_filters, **kwargs) + super().__init__( + channel=channel, + can_filters=can_filters, + **kwargs, + ) + + # activate channels after CAN filters were applied + try: + self.xldriver.xlActivateChannel( + self.port_handle, self.mask, xldefine.XL_BusTypes.XL_BUS_TYPE_CAN, 0 + ) + except VectorOperationError as error: + self.shutdown() + raise VectorInitializationError.from_generic(error) from None + + @property + def fd(self) -> bool: + class_name = self.__class__.__name__ + warnings.warn( + f"The {class_name}.fd property is deprecated and superseded by " + f"{class_name}.protocol. It is scheduled for removal in python-can version 5.0.", + DeprecationWarning, + stacklevel=2, + ) + return self._can_protocol is CanProtocol.CAN_FD def _find_global_channel_idx( self, channel: int, - serial: Optional[int], - app_name: Optional[str], - channel_configs: List["VectorChannelConfig"], + serial: int | None, + app_name: str | None, + channel_configs: list["VectorChannelConfig"], ) -> int: if serial is not None: serial_found = False @@ -357,7 +403,7 @@ def _find_global_channel_idx( app_name, channel ) idx = cast( - int, self.xldriver.xlGetChannelIndex(hw_type, hw_index, hw_channel) + "int", self.xldriver.xlGetChannelIndex(hw_type, hw_index, hw_channel) ) if idx < 0: # Undocumented behavior! See issue #353. @@ -385,41 +431,63 @@ def _find_global_channel_idx( def _has_init_access(self, channel: int) -> bool: return bool(self.permission_mask & self.channel_masks[channel]) - def _read_bus_params(self, channel: int) -> "VectorBusParams": - channel_mask = self.channel_masks[channel] - - vcc_list = get_channel_configs() + def _read_bus_params( + self, channel_index: int, vcc_list: list["VectorChannelConfig"] + ) -> "VectorBusParams": for vcc in vcc_list: - if vcc.channel_mask == channel_mask: - return vcc.bus_params - + if vcc.channel_index == channel_index: + bus_params = vcc.bus_params + if bus_params is None: + # for CAN channels, this should never be `None` + raise ValueError("Invalid bus parameters.") + return bus_params + + channel = self.index_to_channel[channel_index] raise CanInitializationError( f"Channel configuration for channel {channel} not found." ) - def _set_bitrate(self, channel: int, bitrate: int) -> None: - # set parameters if channel has init access - if self._has_init_access(channel): + def _set_output_mode(self, channel_mask: int, listen_only: bool) -> None: + # set parameters for channels with init access + channel_mask = channel_mask & self.permission_mask + + if channel_mask: + if listen_only: + self.xldriver.xlCanSetChannelOutput( + self.port_handle, + channel_mask, + xldefine.XL_OutputMode.XL_OUTPUT_MODE_SILENT, + ) + else: + self.xldriver.xlCanSetChannelOutput( + self.port_handle, + channel_mask, + xldefine.XL_OutputMode.XL_OUTPUT_MODE_NORMAL, + ) + + LOG.info("xlCanSetChannelOutput: listen_only=%u", listen_only) + else: + LOG.warning("No channels with init access to set listen only mode") + + def _set_bitrate(self, channel_mask: int, bitrate: int) -> None: + # set parameters for channels with init access + channel_mask = channel_mask & self.permission_mask + if channel_mask: self.xldriver.xlCanSetChannelBitrate( self.port_handle, - self.channel_masks[channel], + channel_mask, bitrate, ) LOG.info("xlCanSetChannelBitrate: baudr.=%u", bitrate) - if not self.__testing: - self._check_can_settings( - channel=channel, - bitrate=bitrate, - ) - - def _set_bit_timing(self, channel: int, timing: BitTiming) -> None: - # set parameters if channel has init access - if self._has_init_access(channel): + def _set_bit_timing(self, channel_mask: int, timing: BitTiming) -> None: + # set parameters for channels with init access + channel_mask = channel_mask & self.permission_mask + if channel_mask: if timing.f_clock == 8_000_000: self.xldriver.xlCanSetChannelParamsC200( self.port_handle, - self.channel_masks[channel], + channel_mask, timing.btr0, timing.btr1, ) @@ -437,7 +505,7 @@ def _set_bit_timing(self, channel: int, timing: BitTiming) -> None: chip_params.sam = timing.nof_samples self.xldriver.xlCanSetChannelParams( self.port_handle, - self.channel_masks[channel], + channel_mask, chip_params, ) LOG.info( @@ -452,20 +520,14 @@ def _set_bit_timing(self, channel: int, timing: BitTiming) -> None: f"timing.f_clock must be 8_000_000 or 16_000_000 (is {timing.f_clock})" ) - if not self.__testing: - self._check_can_settings( - channel=channel, - bitrate=timing.bitrate, - sample_point=timing.sample_point, - ) - def _set_bit_timing_fd( self, - channel: int, + channel_mask: int, timing: BitTimingFd, ) -> None: - # set parameters if channel has init access - if self._has_init_access(channel): + # set parameters for channels with init access + channel_mask = channel_mask & self.permission_mask + if channel_mask: canfd_conf = xlclass.XLcanFdConf() canfd_conf.arbitrationBitRate = timing.nom_bitrate canfd_conf.sjwAbr = timing.nom_sjw @@ -476,7 +538,7 @@ def _set_bit_timing_fd( canfd_conf.tseg1Dbr = timing.data_tseg1 canfd_conf.tseg2Dbr = timing.data_tseg2 self.xldriver.xlCanFdSetConfiguration( - self.port_handle, self.channel_masks[channel], canfd_conf + self.port_handle, channel_mask, canfd_conf ) LOG.info( "xlCanFdSetConfiguration.: ABaudr.=%u, DBaudr.=%u", @@ -496,102 +558,102 @@ def _set_bit_timing_fd( canfd_conf.tseg2Dbr, ) - if not self.__testing: - self._check_can_settings( - channel=channel, - bitrate=timing.nom_bitrate, - sample_point=timing.nom_sample_point, - fd=True, - data_bitrate=timing.data_bitrate, - data_sample_point=timing.data_sample_point, - ) - def _check_can_settings( self, - channel: int, + channel_mask: int, bitrate: int, - sample_point: Optional[float] = None, + sample_point: float | None = None, fd: bool = False, - data_bitrate: Optional[int] = None, - data_sample_point: Optional[float] = None, + data_bitrate: int | None = None, + data_sample_point: float | None = None, ) -> None: """Compare requested CAN settings to active settings in driver.""" - bus_params = self._read_bus_params(channel) - # use canfd even if fd==False, bus_params.can and bus_params.canfd are a C union - bus_params_data = bus_params.canfd - settings_acceptable = True - - # check bus type - settings_acceptable &= ( - bus_params.bus_type is xldefine.XL_BusTypes.XL_BUS_TYPE_CAN - ) - - # check CAN operation mode - if fd: - settings_acceptable &= bool( - bus_params_data.can_op_mode - & xldefine.XL_CANFD_BusParams_CanOpMode.XL_BUS_PARAMS_CANOPMODE_CANFD - ) - elif bus_params_data.can_op_mode != 0: # can_op_mode is always 0 for cancaseXL - settings_acceptable &= bool( - bus_params_data.can_op_mode - & xldefine.XL_CANFD_BusParams_CanOpMode.XL_BUS_PARAMS_CANOPMODE_CAN20 + vcc_list = get_channel_configs() + for channel_index in _iterate_channel_index(channel_mask): + bus_params = self._read_bus_params( + channel_index=channel_index, vcc_list=vcc_list ) + # use bus_params.canfd even if fd==False, bus_params.can and bus_params.canfd are a C union + bus_params_data = bus_params.canfd + settings_acceptable = True - # check bitrates - if bitrate: + # check bus type settings_acceptable &= ( - abs(bus_params_data.bitrate - bitrate) < bitrate / 256 - ) - if fd and data_bitrate: - settings_acceptable &= ( - abs(bus_params_data.data_bitrate - data_bitrate) < data_bitrate / 256 + bus_params.bus_type is xldefine.XL_BusTypes.XL_BUS_TYPE_CAN ) - # check sample points - if sample_point: - nom_sample_point_act = ( - 100 - * (1 + bus_params_data.tseg1_abr) - / (1 + bus_params_data.tseg1_abr + bus_params_data.tseg2_abr) - ) - settings_acceptable &= ( - abs(nom_sample_point_act - sample_point) < 2.0 # 2 percent tolerance - ) - if fd and data_sample_point: - data_sample_point_act = ( - 100 - * (1 + bus_params_data.tseg1_dbr) - / (1 + bus_params_data.tseg1_dbr + bus_params_data.tseg2_dbr) - ) - settings_acceptable &= ( - abs(data_sample_point_act - data_sample_point) - < 2.0 # 2 percent tolerance - ) + # check CAN operation mode + # skip the check if can_op_mode is 0 + # as it happens for cancaseXL, VN7600 and sometimes on other hardware (VN1640) + if bus_params_data.can_op_mode: + if fd: + settings_acceptable &= bool( + bus_params_data.can_op_mode + & xldefine.XL_CANFD_BusParams_CanOpMode.XL_BUS_PARAMS_CANOPMODE_CANFD + ) + else: + settings_acceptable &= bool( + bus_params_data.can_op_mode + & xldefine.XL_CANFD_BusParams_CanOpMode.XL_BUS_PARAMS_CANOPMODE_CAN20 + ) + + # check bitrates + if bitrate: + settings_acceptable &= ( + abs(bus_params_data.bitrate - bitrate) < bitrate / 256 + ) + if fd and data_bitrate: + settings_acceptable &= ( + abs(bus_params_data.data_bitrate - data_bitrate) + < data_bitrate / 256 + ) - if not settings_acceptable: - # The error message depends on the currently active CAN settings. - # If the active operation mode is CAN FD, show the active CAN FD timings, - # otherwise show CAN 2.0 timings. - if bool( - bus_params_data.can_op_mode - & xldefine.XL_CANFD_BusParams_CanOpMode.XL_BUS_PARAMS_CANOPMODE_CANFD - ): - active_settings = bus_params.canfd._asdict() - active_settings["can_op_mode"] = "CAN FD" - else: - active_settings = bus_params.can._asdict() - active_settings["can_op_mode"] = "CAN 2.0" - settings_string = ", ".join( - [f"{key}: {val}" for key, val in active_settings.items()] - ) - raise CanInitializationError( - f"The requested settings could not be set for channel {channel}. " - f"Another application might have set incompatible settings. " - f"These are the currently active settings: {settings_string}." - ) + # check sample points + if sample_point: + nom_sample_point_act = ( + 100 + * (1 + bus_params_data.tseg1_abr) + / (1 + bus_params_data.tseg1_abr + bus_params_data.tseg2_abr) + ) + settings_acceptable &= ( + abs(nom_sample_point_act - sample_point) + < 2.0 # 2 percent tolerance + ) + if fd and data_sample_point: + data_sample_point_act = ( + 100 + * (1 + bus_params_data.tseg1_dbr) + / (1 + bus_params_data.tseg1_dbr + bus_params_data.tseg2_dbr) + ) + settings_acceptable &= ( + abs(data_sample_point_act - data_sample_point) + < 2.0 # 2 percent tolerance + ) - def _apply_filters(self, filters: Optional[CanFilters]) -> None: + if not settings_acceptable: + # The error message depends on the currently active CAN settings. + # If the active operation mode is CAN FD, show the active CAN FD timings, + # otherwise show CAN 2.0 timings. + if bool( + bus_params_data.can_op_mode + & xldefine.XL_CANFD_BusParams_CanOpMode.XL_BUS_PARAMS_CANOPMODE_CANFD + ): + active_settings = bus_params.canfd._asdict() + active_settings["can_op_mode"] = "CAN FD" + else: + active_settings = bus_params.can._asdict() + active_settings["can_op_mode"] = "CAN 2.0" + settings_string = ", ".join( + [f"{key}: {val}" for key, val in active_settings.items()] + ) + channel = self.index_to_channel[channel_index] + raise CanInitializationError( + f"The requested settings could not be set for channel {channel}. " + f"Another application might have set incompatible settings. " + f"These are the currently active settings: {settings_string}." + ) + + def _apply_filters(self, filters: CanFilters | None) -> None: if filters: # Only up to one filter per ID type allowed if len(filters) == 1 or ( @@ -605,9 +667,11 @@ def _apply_filters(self, filters: Optional[CanFilters]) -> None: self.mask, can_filter["can_id"], can_filter["can_mask"], - xldefine.XL_AcceptanceFilter.XL_CAN_EXT - if can_filter.get("extended") - else xldefine.XL_AcceptanceFilter.XL_CAN_STD, + ( + xldefine.XL_AcceptanceFilter.XL_CAN_EXT + if can_filter.get("extended") + else xldefine.XL_AcceptanceFilter.XL_CAN_STD + ), ) except VectorOperationError as exception: LOG.warning("Could not set filters: %s", exception) @@ -639,14 +703,12 @@ def _apply_filters(self, filters: Optional[CanFilters]) -> None: except VectorOperationError as exc: LOG.warning("Could not reset filters: %s", exc) - def _recv_internal( - self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + def _recv_internal(self, timeout: float | None) -> tuple[Message | None, bool]: end_time = time.time() + timeout if timeout is not None else None while True: try: - if self.fd: + if self._can_protocol is CanProtocol.CAN_FD: msg = self._recv_canfd() else: msg = self._recv_can() @@ -674,7 +736,7 @@ def _recv_internal( # Wait a short time until we try again time.sleep(self.poll_interval) - def _recv_canfd(self) -> Optional[Message]: + def _recv_canfd(self) -> Message | None: xl_can_rx_event = xlclass.XLcanRxEvent() self.xldriver.xlCanReceive(self.port_handle, xl_can_rx_event) @@ -719,7 +781,7 @@ def _recv_canfd(self) -> Optional[Message]: data=data_struct.data[:dlc], ) - def _recv_can(self) -> Optional[Message]: + def _recv_can(self) -> Message | None: xl_event = xlclass.XLevent() event_count = ctypes.c_uint(1) self.xldriver.xlReceive(self.port_handle, event_count, xl_event) @@ -775,12 +837,12 @@ def handle_canfd_event(self, event: xlclass.XLcanRxEvent) -> None: `XL_CAN_EV_TAG_TX_ERROR`, `XL_TIMER` or `XL_CAN_EV_TAG_CHIP_STATE` tag. """ - def send(self, msg: Message, timeout: Optional[float] = None) -> None: + def send(self, msg: Message, timeout: float | None = None) -> None: self._send_sequence([msg]) def _send_sequence(self, msgs: Sequence[Message]) -> int: """Send messages and return number of successful transmissions.""" - if self.fd: + if self._can_protocol is CanProtocol.CAN_FD: return self._send_can_fd_msg_sequence(msgs) else: return self._send_can_msg_sequence(msgs) @@ -865,13 +927,48 @@ def _build_xl_can_tx_event(msg: Message) -> xlclass.XLcanTxEvent: return xl_can_tx_event def flush_tx_buffer(self) -> None: - self.xldriver.xlCanFlushTransmitQueue(self.port_handle, self.mask) + """ + Flush the TX buffer of the bus. + + Implementation does not use function ``xlCanFlushTransmitQueue`` of the XL driver, as it works only + for XL family devices. + + .. warning:: + Using this function will flush the queue and send a high voltage message (ID = 0, DLC = 0, no data). + """ + if self._can_protocol is CanProtocol.CAN_FD: + xl_can_tx_event = xlclass.XLcanTxEvent() + xl_can_tx_event.tag = xldefine.XL_CANFD_TX_EventTags.XL_CAN_EV_TAG_TX_MSG + xl_can_tx_event.tagData.canMsg.msgFlags |= ( + xldefine.XL_CANFD_TX_MessageFlags.XL_CAN_TXMSG_FLAG_HIGHPRIO + ) + + self.xldriver.xlCanTransmitEx( + self.port_handle, + self.mask, + ctypes.c_uint(1), + ctypes.c_uint(0), + xl_can_tx_event, + ) + else: + xl_event = xlclass.XLevent() + xl_event.tag = xldefine.XL_EventTags.XL_TRANSMIT_MSG + xl_event.tagData.msg.flags |= ( + xldefine.XL_MessageFlags.XL_CAN_MSG_FLAG_OVERRUN + | xldefine.XL_MessageFlags.XL_CAN_MSG_FLAG_WAKEUP + ) + + self.xldriver.xlCanTransmit( + self.port_handle, self.mask, ctypes.c_uint(1), xl_event + ) def shutdown(self) -> None: super().shutdown() - self.xldriver.xlDeactivateChannel(self.port_handle, self.mask) - self.xldriver.xlClosePort(self.port_handle) - self.xldriver.xlCloseDriver() + + with contextlib.suppress(VectorError): + self.xldriver.xlDeactivateChannel(self.port_handle, self.mask) + self.xldriver.xlClosePort(self.port_handle) + self.xldriver.xlCloseDriver() def reset(self) -> None: self.xldriver.xlDeactivateChannel(self.port_handle, self.mask) @@ -880,8 +977,8 @@ def reset(self) -> None: ) @staticmethod - def _detect_available_configs() -> List[AutoDetectedConfig]: - configs = [] + def _detect_available_configs() -> Sequence["AutoDetectedVectorConfig"]: + configs: list[AutoDetectedVectorConfig] = [] channel_configs = get_channel_configs() LOG.info("Found %d channels", len(channel_configs)) for channel_config in channel_configs: @@ -897,15 +994,13 @@ def _detect_available_configs() -> List[AutoDetectedConfig]: ) configs.append( { - # data for use in VectorBus.__init__(): "interface": "vector", "channel": channel_config.hw_channel, "serial": channel_config.serial_number, - # data for use in VectorBus.set_application_config(): + "channel_index": channel_config.channel_index, "hw_type": channel_config.hw_type, "hw_index": channel_config.hw_index, "hw_channel": channel_config.hw_channel, - # additional information: "supports_fd": bool( channel_config.channel_capabilities & xldefine.XL_ChannelCapabilities.XL_CHANNEL_FLAG_CANFD_ISO_SUPPORT @@ -913,7 +1008,7 @@ def _detect_available_configs() -> List[AutoDetectedConfig]: "vector_channel_config": channel_config, } ) - return configs # type: ignore + return configs @staticmethod def popup_vector_hw_configuration(wait_for_finish: int = 0) -> None: @@ -930,7 +1025,7 @@ def popup_vector_hw_configuration(wait_for_finish: int = 0) -> None: @staticmethod def get_application_config( app_name: str, app_channel: int - ) -> Tuple[Union[int, xldefine.XL_HardwareType], int, int]: + ) -> tuple[int | xldefine.XL_HardwareType, int, int]: """Retrieve information for an application in Vector Hardware Configuration. :param app_name: @@ -976,7 +1071,7 @@ def get_application_config( def set_application_config( app_name: str, app_channel: int, - hw_type: Union[int, xldefine.XL_HardwareType], + hw_type: int | xldefine.XL_HardwareType, hw_index: int, hw_channel: int, **kwargs: Any, @@ -1070,7 +1165,7 @@ class VectorChannelConfig(NamedTuple): """NamedTuple which contains the channel properties from Vector XL API.""" name: str - hw_type: Union[int, xldefine.XL_HardwareType] + hw_type: int | xldefine.XL_HardwareType hw_index: int hw_channel: int channel_index: int @@ -1079,12 +1174,25 @@ class VectorChannelConfig(NamedTuple): channel_bus_capabilities: xldefine.XL_BusCapabilities is_on_bus: bool connected_bus_type: xldefine.XL_BusTypes - bus_params: VectorBusParams + bus_params: VectorBusParams | None serial_number: int article_number: int transceiver_name: str +class AutoDetectedVectorConfig(AutoDetectedConfig): + # data for use in VectorBus.__init__(): + serial: int + channel_index: int + # data for use in VectorBus.set_application_config(): + hw_type: int + hw_index: int + hw_channel: int + # additional information: + supports_fd: bool + vector_channel_config: VectorChannelConfig + + def _get_xl_driver_config() -> xlclass.XLdriverConfig: if xldriver is None: raise VectorError( @@ -1099,9 +1207,14 @@ def _get_xl_driver_config() -> xlclass.XLdriverConfig: return driver_config -def _read_bus_params_from_c_struct(bus_params: xlclass.XLbusParams) -> VectorBusParams: +def _read_bus_params_from_c_struct( + bus_params: xlclass.XLbusParams, +) -> VectorBusParams | None: + bus_type = xldefine.XL_BusTypes(bus_params.busType) + if bus_type is not xldefine.XL_BusTypes.XL_BUS_TYPE_CAN: + return None return VectorBusParams( - bus_type=xldefine.XL_BusTypes(bus_params.busType), + bus_type=bus_type, can=VectorCanParams( bitrate=bus_params.data.can.bitRate, sjw=bus_params.data.can.sjw, @@ -1131,14 +1244,14 @@ def _read_bus_params_from_c_struct(bus_params: xlclass.XLbusParams) -> VectorBus ) -def get_channel_configs() -> List[VectorChannelConfig]: +def get_channel_configs() -> list[VectorChannelConfig]: """Read channel properties from Vector XL API.""" try: driver_config = _get_xl_driver_config() except VectorError: return [] - channel_list: List[VectorChannelConfig] = [] + channel_list: list[VectorChannelConfig] = [] for i in range(driver_config.channelCount): xlcc: xlclass.XLchannelConfig = driver_config.channel[i] vcc = VectorChannelConfig( @@ -1165,9 +1278,16 @@ def get_channel_configs() -> List[VectorChannelConfig]: return channel_list -def _hw_type(hw_type: int) -> Union[int, xldefine.XL_HardwareType]: +def _hw_type(hw_type: int) -> int | xldefine.XL_HardwareType: try: return xldefine.XL_HardwareType(hw_type) except ValueError: LOG.warning(f'Unknown XL_HardwareType value "{hw_type}"') return hw_type + + +def _iterate_channel_index(channel_mask: int) -> Iterator[int]: + """Iterate over channel indexes in channel mask.""" + for channel_index, bit in enumerate(reversed(bin(channel_mask)[2:])): + if bit == "1": + yield channel_index diff --git a/can/interfaces/vector/exceptions.py b/can/interfaces/vector/exceptions.py index 53c774e6f..779365893 100644 --- a/can/interfaces/vector/exceptions.py +++ b/can/interfaces/vector/exceptions.py @@ -1,10 +1,14 @@ """Exception/error declarations for the vector interface.""" +from typing import Any + from can import CanError, CanInitializationError, CanOperationError class VectorError(CanError): - def __init__(self, error_code, error_string, function): + def __init__( + self, error_code: int | None, error_string: str, function: str + ) -> None: super().__init__( message=f"{function} failed ({error_string})", error_code=error_code ) @@ -12,7 +16,7 @@ def __init__(self, error_code, error_string, function): # keep reference to args for pickling self._args = error_code, error_string, function - def __reduce__(self): + def __reduce__(self) -> str | tuple[Any, ...]: return type(self), self._args, {} diff --git a/can/interfaces/vector/xldefine.py b/can/interfaces/vector/xldefine.py index e2fd288b9..ebc0971c1 100644 --- a/can/interfaces/vector/xldefine.py +++ b/can/interfaces/vector/xldefine.py @@ -6,7 +6,6 @@ # ============================== from enum import IntEnum, IntFlag - MAX_MSG_LEN = 8 XL_CAN_MAX_DATA_LEN = 64 XL_INVALID_PORTHANDLE = -1 diff --git a/can/interfaces/vector/xldriver.py b/can/interfaces/vector/xldriver.py index 29791e32f..faed23b36 100644 --- a/can/interfaces/vector/xldriver.py +++ b/can/interfaces/vector/xldriver.py @@ -5,25 +5,22 @@ Authors: Julien Grave , Christian Sandberg """ -# Import Standard Python Modules -# ============================== import ctypes import logging import platform -from .exceptions import VectorOperationError, VectorInitializationError +from ctypes.util import find_library -# Define Module Logger -# ==================== -LOG = logging.getLogger(__name__) - -# Vector XL API Definitions -# ========================= from . import xlclass +from .exceptions import VectorInitializationError, VectorOperationError + +LOG = logging.getLogger(__name__) # Load Windows DLL DLL_NAME = "vxlapi64" if platform.architecture()[0] == "64bit" else "vxlapi" -_xlapi_dll = ctypes.windll.LoadLibrary(DLL_NAME) - +if dll_path := find_library(DLL_NAME): + _xlapi_dll = ctypes.windll.LoadLibrary(dll_path) +else: + raise FileNotFoundError(f"Vector XL library not found: {DLL_NAME}") # ctypes wrapping for API functions xlGetErrorString = _xlapi_dll.xlGetErrorString diff --git a/can/interfaces/virtual.py b/can/interfaces/virtual.py index 25b7abfb0..ba33a6ea8 100644 --- a/can/interfaces/virtual.py +++ b/can/interfaces/virtual.py @@ -6,35 +6,30 @@ and reside in the same process will receive the same messages. """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING - -from copy import deepcopy import logging -import time import queue -from threading import RLock +import time +from copy import deepcopy from random import randint +from threading import RLock +from typing import Any, Final from can import CanOperationError -from can.bus import BusABC +from can.bus import BusABC, CanProtocol from can.message import Message -from can.typechecking import AutoDetectedConfig +from can.typechecking import AutoDetectedConfig, Channel logger = logging.getLogger(__name__) - # Channels are lists of queues, one for each connection -if TYPE_CHECKING: - # https://mypy.readthedocs.io/en/stable/runtime_troubles.html#using-classes-that-are-generic-in-stubs-but-not-at-runtime - channels: Dict[Optional[Any], List[queue.Queue[Message]]] = {} -else: - channels = {} -channels_lock = RLock() +channels: Final[dict[Channel, list[queue.Queue[Message]]]] = {} +channels_lock: Final = RLock() class VirtualBus(BusABC): """ - A virtual CAN bus using an internal message queue. It can be used for example for testing. + A virtual CAN bus using an internal message queue. It can be used for + example for testing. In this interface, a channel is an arbitrary object used as an identifier for connected buses. @@ -49,32 +44,64 @@ class VirtualBus(BusABC): if a message is sent to 5 receivers with the timeout set to 1.0. .. warning:: - This interface guarantees reliable delivery and message ordering, but does *not* implement rate - limiting or ID arbitration/prioritization under high loads. Please refer to the section - :ref:`virtual_interfaces_doc` for more information on this and a comparison to alternatives. + This interface guarantees reliable delivery and message ordering, but + does *not* implement rate limiting or ID arbitration/prioritization + under high loads. Please refer to the section + :ref:`virtual_interfaces_doc` for more information on this and a + comparison to alternatives. """ def __init__( self, - channel: Any = None, + channel: Channel = "channel-0", receive_own_messages: bool = False, rx_queue_size: int = 0, preserve_timestamps: bool = False, + protocol: CanProtocol = CanProtocol.CAN_20, **kwargs: Any, ) -> None: + """ + The constructed instance has access to the bus identified by the + channel parameter. It is able to see all messages transmitted on the + bus by virtual instances constructed with the same channel identifier. + + :param channel: The channel identifier. This parameter can be an + arbitrary hashable value. The bus instance will be able to see + messages from other virtual bus instances that were created with + the same value. + :param receive_own_messages: If set to True, sent messages will be + reflected back on the input queue. + :param rx_queue_size: The size of the reception queue. The reception + queue stores messages until they are read. If the queue reaches + its capacity, it will start dropping the oldest messages to make + room for new ones. If set to 0, the queue has an infinite capacity. + Be aware that this can cause memory leaks if messages are read + with a lower frequency than they arrive on the bus. + :param preserve_timestamps: If set to True, messages transmitted via + :func:`~can.BusABC.send` will keep the timestamp set in the + :class:`~can.Message` instance. Otherwise, the timestamp value + will be replaced with the current system time. + :param protocol: The protocol implemented by this bus instance. The + value does not affect the operation of the bus instance and can + be set to an arbitrary value for testing purposes. + :param kwargs: Additional keyword arguments passed to the parent + constructor. + """ super().__init__( - channel=channel, receive_own_messages=receive_own_messages, **kwargs + channel=channel, + receive_own_messages=receive_own_messages, + **kwargs, ) # the channel identifier may be an arbitrary object self.channel_id = channel + self._can_protocol = protocol self.channel_info = f"Virtual bus channel {self.channel_id}" self.receive_own_messages = receive_own_messages self.preserve_timestamps = preserve_timestamps self._open = True with channels_lock: - # Create a new channel if one does not exist if self.channel_id not in channels: channels[self.channel_id] = [] @@ -91,9 +118,7 @@ def _check_if_open(self) -> None: if not self._open: raise CanOperationError("Cannot operate on a closed bus") - def _recv_internal( - self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + def _recv_internal(self, timeout: float | None) -> tuple[Message | None, bool]: self._check_if_open() try: msg = self.queue.get(block=True, timeout=timeout) @@ -102,7 +127,7 @@ def _recv_internal( else: return msg, False - def send(self, msg: Message, timeout: Optional[float] = None) -> None: + def send(self, msg: Message, timeout: float | None = None) -> None: self._check_if_open() timestamp = msg.timestamp if self.preserve_timestamps else time.time() @@ -136,7 +161,7 @@ def shutdown(self) -> None: del channels[self.channel_id] @staticmethod - def _detect_available_configs() -> List[AutoDetectedConfig]: + def _detect_available_configs() -> list[AutoDetectedConfig]: """ Returns all currently used channels as well as one other currently unused channel. @@ -151,7 +176,7 @@ def _detect_available_configs() -> List[AutoDetectedConfig]: available_channels = list(channels.keys()) # find a currently unused channel - def get_extra(): + def get_extra() -> str: return f"channel-{randint(0, 9999)}" extra = get_extra() diff --git a/can/io/__init__.py b/can/io/__init__.py index 6dc9ac1af..69894c3d0 100644 --- a/can/io/__init__.py +++ b/can/io/__init__.py @@ -3,15 +3,55 @@ and Writers based off the file extension. """ +__all__ = [ + "MESSAGE_READERS", + "MESSAGE_WRITERS", + "ASCReader", + "ASCWriter", + "BLFReader", + "BLFWriter", + "BaseRotatingLogger", + "CSVReader", + "CSVWriter", + "CanutilsLogReader", + "CanutilsLogWriter", + "LogReader", + "Logger", + "MF4Reader", + "MF4Writer", + "MessageSync", + "Printer", + "SizedRotatingLogger", + "SqliteReader", + "SqliteWriter", + "TRCFileVersion", + "TRCReader", + "TRCWriter", + "asc", + "blf", + "canutils", + "csv", + "generic", + "logger", + "mf4", + "player", + "printer", + "sqlite", + "trc", +] + # Generic -from .logger import Logger, BaseRotatingLogger, SizedRotatingLogger -from .player import LogReader, MessageSync +from .logger import MESSAGE_WRITERS, BaseRotatingLogger, Logger, SizedRotatingLogger +from .player import MESSAGE_READERS, LogReader, MessageSync + +# isort: split # Format specific -from .asc import ASCWriter, ASCReader +from .asc import ASCReader, ASCWriter from .blf import BLFReader, BLFWriter from .canutils import CanutilsLogReader, CanutilsLogWriter -from .csv import CSVWriter, CSVReader -from .sqlite import SqliteReader, SqliteWriter +from .csv import CSVReader, CSVWriter +from .mf4 import MF4Reader, MF4Writer from .printer import Printer -from .trc import TRCReader, TRCWriter, TRCFileVersion +from .sqlite import SqliteReader, SqliteWriter +from .trc import TRCFileVersion, TRCReader, TRCWriter diff --git a/can/io/asc.py b/can/io/asc.py index eb59c0471..fcf8fc5e4 100644 --- a/can/io/asc.py +++ b/can/io/asc.py @@ -5,38 +5,43 @@ - https://bitbucket.org/tobylorenz/vector_asc/src/master/src/Vector/ASC/tests/unittests/data/ - under `test/data/logfile.asc` """ -import re -from typing import Any, Generator, List, Optional, Dict, Union, TextIO -from datetime import datetime -import time import logging +import re +from collections.abc import Generator +from datetime import datetime +from typing import Any, Final, TextIO from ..message import Message -from ..util import channel2int, len2dlc, dlc2len -from .generic import FileIOMessageWriter, MessageReader from ..typechecking import StringPathLike - +from ..util import channel2int, dlc2len, len2dlc +from .generic import TextIOMessageReader, TextIOMessageWriter CAN_MSG_EXT = 0x80000000 CAN_ID_MASK = 0x1FFFFFFF BASE_HEX = 16 BASE_DEC = 10 +ASC_TRIGGER_REGEX: Final = re.compile( + r"begin\s+triggerblock\s+\w+\s+(?P.+)", re.IGNORECASE +) +ASC_MESSAGE_REGEX: Final = re.compile( + r"\d+\.\d+\s+(\d+\s+(\w+\s+(Tx|Rx)|ErrorFrame)|CANFD)", + re.ASCII | re.IGNORECASE, +) + logger = logging.getLogger("can.io.asc") -class ASCReader(MessageReader): +class ASCReader(TextIOMessageReader): """ Iterator of CAN messages from a ASC logging file. Meta data (comments, bus statistics, J1939 Transport Protocol messages) is ignored. """ - file: TextIO - def __init__( self, - file: Union[StringPathLike, TextIO], + file: StringPathLike | TextIO, base: str = "hex", relative_timestamp: bool = True, **kwargs: Any, @@ -59,15 +64,15 @@ def __init__( self.base = base self._converted_base = self._check_base(base) self.relative_timestamp = relative_timestamp - self.date: Optional[str] = None + self.date: str | None = None self.start_time = 0.0 # TODO - what is this used for? The ASC Writer only prints `absolute` - self.timestamps_format: Optional[str] = None + self.timestamps_format: str | None = None self.internal_events_logged = False def _extract_header(self) -> None: - for line in self.file: - line = line.strip() + for _line in self.file: + line = _line.strip() datetime_match = re.match( r"date\s+\w+\s+(?P.+)", line, re.IGNORECASE @@ -111,43 +116,52 @@ def _extract_header(self) -> None: @staticmethod def _datetime_to_timestamp(datetime_string: str) -> float: - # ugly locale independent solution month_map = { - "Jan": 1, - "Feb": 2, - "Mar": 3, - "Apr": 4, - "May": 5, - "Jun": 6, - "Jul": 7, - "Aug": 8, - "Sep": 9, - "Oct": 10, - "Nov": 11, - "Dec": 12, - "Mär": 3, - "Mai": 5, - "Okt": 10, - "Dez": 12, + "jan": 1, + "feb": 2, + "mar": 3, + "apr": 4, + "may": 5, + "jun": 6, + "jul": 7, + "aug": 8, + "sep": 9, + "oct": 10, + "nov": 11, + "dec": 12, + "mär": 3, + "mai": 5, + "okt": 10, + "dez": 12, } - for name, number in month_map.items(): - datetime_string = datetime_string.replace(name, str(number).zfill(2)) datetime_formats = ( "%m %d %I:%M:%S.%f %p %Y", "%m %d %I:%M:%S %p %Y", "%m %d %H:%M:%S.%f %Y", "%m %d %H:%M:%S %Y", + "%m %d %H:%M:%S.%f %p %Y", + "%m %d %H:%M:%S %p %Y", ) + + datetime_string_parts = datetime_string.split(" ", 1) + month = datetime_string_parts[0].strip().lower() + + try: + datetime_string_parts[0] = f"{month_map[month]:02d}" + except KeyError: + raise ValueError(f"Unsupported month abbreviation: {month}") from None + datetime_string = " ".join(datetime_string_parts) + for format_str in datetime_formats: try: return datetime.strptime(datetime_string, format_str).timestamp() except ValueError: continue - raise ValueError(f"Incompatible datetime string {datetime_string}") + raise ValueError(f"Unsupported datetime format: '{datetime_string}'") - def _extract_can_id(self, str_can_id: str, msg_kwargs: Dict[str, Any]) -> None: + def _extract_can_id(self, str_can_id: str, msg_kwargs: dict[str, Any]) -> None: if str_can_id[-1:].lower() == "x": msg_kwargs["is_extended_id"] = True can_id = int(str_can_id[0:-1], self._converted_base) @@ -163,7 +177,7 @@ def _check_base(base: str) -> int: return BASE_DEC if base == "dec" else BASE_HEX def _process_data_string( - self, data_str: str, data_length: int, msg_kwargs: Dict[str, Any] + self, data_str: str, data_length: int, msg_kwargs: dict[str, Any] ) -> None: frame = bytearray() data = data_str.split() @@ -172,9 +186,8 @@ def _process_data_string( msg_kwargs["data"] = frame def _process_classic_can_frame( - self, line: str, msg_kwargs: Dict[str, Any] + self, line: str, msg_kwargs: dict[str, Any] ) -> Message: - # CAN error frame if line.strip()[0:10].lower() == "errorframe": # Error Frame @@ -208,7 +221,7 @@ def _process_classic_can_frame( return Message(**msg_kwargs) - def _process_fd_can_frame(self, line: str, msg_kwargs: Dict[str, Any]) -> Message: + def _process_fd_can_frame(self, line: str, msg_kwargs: dict[str, Any]) -> Message: channel, direction, rest_of_message = line.split(None, 2) # See ASCWriter msg_kwargs["channel"] = int(channel) - 1 @@ -258,15 +271,10 @@ def _process_fd_can_frame(self, line: str, msg_kwargs: Dict[str, Any]) -> Messag def __iter__(self) -> Generator[Message, None, None]: self._extract_header() - for line in self.file: - line = line.strip() + for _line in self.file: + line = _line.strip() - trigger_match = re.match( - r"begin\s+triggerblock\s+\w+\s+(?P.+)", - line, - re.IGNORECASE, - ) - if trigger_match: + if trigger_match := ASC_TRIGGER_REGEX.match(line): datetime_str = trigger_match.group("datetime_string") self.start_time = ( 0.0 @@ -275,16 +283,17 @@ def __iter__(self) -> Generator[Message, None, None]: ) continue - if not re.match( - r"\d+\.\d+\s+(\d+\s+(\w+\s+(Tx|Rx)|ErrorFrame)|CANFD)", - line, - re.ASCII | re.IGNORECASE, - ): + # Handle the "Start of measurement" line + if re.match(r"^\d+\.\d+\s+Start of measurement", line): + # Skip this line as it's just an indicator + continue + + if not ASC_MESSAGE_REGEX.match(line): # line might be a comment, chip status, # J1939 message or some other unsupported event continue - msg_kwargs: Dict[str, Union[float, bool, int]] = {} + msg_kwargs: dict[str, float | bool | int] = {} try: _timestamp, channel, rest_of_message = line.split(None, 2) timestamp = float(_timestamp) + self.start_time @@ -311,7 +320,7 @@ def __iter__(self) -> Generator[Message, None, None]: self.stop() -class ASCWriter(FileIOMessageWriter): +class ASCWriter(TextIOMessageWriter): """Logs CAN data to an ASCII log file (.asc). The measurement starts with the timestamp of the first registered message. @@ -320,8 +329,6 @@ class ASCWriter(FileIOMessageWriter): It the first message does not have a timestamp, it is set to zero. """ - file: TextIO - FORMAT_MESSAGE = "{channel} {id:<15} {dir:<4} {dtype} {data}" FORMAT_MESSAGE_FD = " ".join( [ @@ -344,13 +351,12 @@ class ASCWriter(FileIOMessageWriter): "{bit_timing_conf_ext_data:>8}", ] ) - FORMAT_START_OF_FILE_DATE = "%a %b %d %I:%M:%S.%f %p %Y" - FORMAT_DATE = "%a %b %d %I:%M:%S.{} %p %Y" + FORMAT_DATE = "%a %b %d %H:%M:%S.{} %Y" FORMAT_EVENT = "{timestamp: 9.6f} {message}\n" def __init__( self, - file: Union[StringPathLike, TextIO], + file: StringPathLike | TextIO, channel: int = 1, **kwargs: Any, ) -> None: @@ -371,12 +377,8 @@ def __init__( self.channel = channel # write start of file header - now = datetime.now().strftime(self.FORMAT_START_OF_FILE_DATE) - # Note: CANoe requires that the microsecond field only have 3 digits - idx = now.index(".") # Find the index in the string of the decimal - # Keep decimal and first three ms digits (4), remove remaining digits - now = now.replace(now[idx + 4 : now[idx:].index(" ") + idx], "") - self.file.write(f"date {now}\n") + start_time = self._format_header_datetime(datetime.now()) + self.file.write(f"date {start_time}\n") self.file.write("base hex timestamps absolute\n") self.file.write("internal events logged\n") @@ -385,13 +387,22 @@ def __init__( self.last_timestamp = 0.0 self.started = 0.0 + def _format_header_datetime(self, dt: datetime) -> str: + # Note: CANoe requires that the microsecond field only have 3 digits + # Since Python strftime only supports microsecond formatters, we must + # manually include the millisecond portion before passing the format + # to strftime + msec = dt.microsecond // 1000 % 1000 + format_w_msec = self.FORMAT_DATE.format(msec) + return dt.strftime(format_w_msec) + def stop(self) -> None: # This is guaranteed to not be None since we raise ValueError in __init__ if not self.file.closed: self.file.write("End TriggerBlock\n") super().stop() - def log_event(self, message: str, timestamp: Optional[float] = None) -> None: + def log_event(self, message: str, timestamp: float | None = None) -> None: """Add a message to the log file. :param message: an arbitrary message @@ -404,12 +415,11 @@ def log_event(self, message: str, timestamp: Optional[float] = None) -> None: # this is the case for the very first message: if not self.header_written: - self.last_timestamp = timestamp or 0.0 - self.started = self.last_timestamp - mlsec = repr(self.last_timestamp).split(".")[1][:3] - formatted_date = time.strftime( - self.FORMAT_DATE.format(mlsec), time.localtime(self.last_timestamp) - ) + self.started = self.last_timestamp = timestamp or 0.0 + + start_time = datetime.fromtimestamp(self.last_timestamp) + formatted_date = self._format_header_datetime(start_time) + self.file.write(f"Begin Triggerblock {formatted_date}\n") self.header_written = True self.log_event("Start of measurement") # caution: this is a recursive call! @@ -423,25 +433,25 @@ def log_event(self, message: str, timestamp: Optional[float] = None) -> None: self.file.write(line) def on_message_received(self, msg: Message) -> None: + channel = channel2int(msg.channel) + if channel is None: + channel = self.channel + else: + # Many interfaces start channel numbering at 0 which is invalid + channel += 1 if msg.is_error_frame: - self.log_event(f"{self.channel} ErrorFrame", msg.timestamp) + self.log_event(f"{channel} ErrorFrame", msg.timestamp) return if msg.is_remote_frame: dtype = f"r {msg.dlc:x}" # New after v8.5 - data: List[str] = [] + data: str = "" else: dtype = f"d {msg.dlc:x}" - data = [f"{byte:02X}" for byte in msg.data] + data = msg.data.hex(" ").upper() arb_id = f"{msg.arbitration_id:X}" if msg.is_extended_id: arb_id += "x" - channel = channel2int(msg.channel) - if channel is None: - channel = self.channel - else: - # Many interfaces start channel numbering at 0 which is invalid - channel += 1 if msg.is_fd: flags = 0 flags |= 1 << 12 @@ -458,7 +468,7 @@ def on_message_received(self, msg: Message) -> None: esi=1 if msg.error_state_indicator else 0, dlc=len2dlc(msg.dlc), data_length=len(msg.data), - data=" ".join(data), + data=data, message_duration=0, message_length=0, flags=flags, @@ -474,6 +484,6 @@ def on_message_received(self, msg: Message) -> None: id=arb_id, dir="Rx" if msg.is_rx else "Tx", dtype=dtype, - data=" ".join(data), + data=data, ) self.log_event(serialized, msg.timestamp) diff --git a/can/io/blf.py b/can/io/blf.py index 8d5ade8c8..77bd02fae 100644 --- a/can/io/blf.py +++ b/can/io/blf.py @@ -12,20 +12,21 @@ objects types. """ -import struct -import zlib import datetime -import time import logging -from typing import List, BinaryIO, Generator, Union, Tuple, Optional, cast, Any +import struct +import time +import zlib +from collections.abc import Generator, Iterator +from decimal import Decimal +from typing import Any, BinaryIO, cast from ..message import Message -from ..util import len2dlc, dlc2len, channel2int from ..typechecking import StringPathLike -from .generic import FileIOMessageWriter, MessageReader - +from ..util import channel2int, dlc2len, len2dlc +from .generic import BinaryIOMessageReader, BinaryIOMessageWriter -TSystemTime = Tuple[int, int, int, int, int, int, int, int] +TSystemTime = tuple[int, int, int, int, int, int, int, int] class BLFParseError(Exception): @@ -99,12 +100,15 @@ class BLFParseError(Exception): TIME_TEN_MICS = 0x00000001 TIME_ONE_NANS = 0x00000002 +TIME_TEN_MICS_FACTOR = Decimal("1e-5") +TIME_ONE_NANS_FACTOR = Decimal("1e-9") -def timestamp_to_systemtime(timestamp: float) -> TSystemTime: + +def timestamp_to_systemtime(timestamp: float | None) -> TSystemTime: if timestamp is None or timestamp < 631152000: # Probably not a Unix timestamp return 0, 0, 0, 0, 0, 0, 0, 0 - t = datetime.datetime.fromtimestamp(round(timestamp, 3)) + t = datetime.datetime.fromtimestamp(round(timestamp, 3), tz=datetime.timezone.utc) return ( t.year, t.month, @@ -127,13 +131,14 @@ def systemtime_to_timestamp(systemtime: TSystemTime) -> float: systemtime[5], systemtime[6], systemtime[7] * 1000, + tzinfo=datetime.timezone.utc, ) return t.timestamp() except ValueError: return 0 -class BLFReader(MessageReader): +class BLFReader(BinaryIOMessageReader): """ Iterator of CAN messages from a Binary Logging File. @@ -141,11 +146,9 @@ class BLFReader(MessageReader): silently ignored. """ - file: BinaryIO - def __init__( self, - file: Union[StringPathLike, BinaryIO], + file: StringPathLike | BinaryIO, **kwargs: Any, ) -> None: """ @@ -161,8 +164,12 @@ def __init__( self.file_size = header[10] self.uncompressed_size = header[11] self.object_count = header[12] - self.start_timestamp = systemtime_to_timestamp(cast(TSystemTime, header[14:22])) - self.stop_timestamp = systemtime_to_timestamp(cast(TSystemTime, header[22:30])) + self.start_timestamp = systemtime_to_timestamp( + cast("TSystemTime", header[14:22]) + ) + self.stop_timestamp = systemtime_to_timestamp( + cast("TSystemTime", header[22:30]) + ) # Read rest of header self.file.read(header[1] - FILE_HEADER_STRUCT.size) self._tail = b"" @@ -183,12 +190,13 @@ def __iter__(self) -> Generator[Message, None, None]: self.file.read(obj_size % 4) if obj_type == LOG_CONTAINER: - method, uncompressed_size = LOG_CONTAINER_STRUCT.unpack_from(obj_data) + method, _ = LOG_CONTAINER_STRUCT.unpack_from(obj_data) container_data = obj_data[LOG_CONTAINER_STRUCT.size :] if method == NO_COMPRESSION: data = container_data elif method == ZLIB_DEFLATE: - data = zlib.decompress(container_data, 15, uncompressed_size) + zobj = zlib.decompressobj() + data = zobj.decompress(container_data) else: # Unknown compression method LOG.warning("Unknown compression method (%d)", method) @@ -196,7 +204,7 @@ def __iter__(self) -> Generator[Message, None, None]: yield from self._parse_container(data) self.stop() - def _parse_container(self, data): + def _parse_container(self, data: bytes) -> Iterator[Message]: if self._tail: data = b"".join((self._tail, data)) try: @@ -207,7 +215,7 @@ def _parse_container(self, data): # Save the remaining data that could not be processed self._tail = data[self._pos :] - def _parse_data(self, data): + def _parse_data(self, data: bytes) -> Iterator[Message]: """Optimized inner loop by making local copies of global variables and class members and hardcoding some values.""" unpack_obj_header_base = OBJ_HEADER_BASE_STRUCT.unpack_from @@ -239,7 +247,7 @@ def _parse_data(self, data): raise BLFParseError("Could not find next object") from None header = unpack_obj_header_base(data, pos) # print(header) - signature, _, header_version, obj_size, obj_type = header + signature, header_size, header_version, obj_size, obj_type = header if signature != b"LOBJ": raise BLFParseError() @@ -263,8 +271,8 @@ def _parse_data(self, data): continue # Calculate absolute timestamp in seconds - factor = 1e-5 if flags == 1 else 1e-9 - timestamp = timestamp * factor + start_timestamp + factor = TIME_TEN_MICS_FACTOR if flags == 1 else TIME_ONE_NANS_FACTOR + timestamp = float(Decimal(timestamp) * factor) + start_timestamp if obj_type in (CAN_MESSAGE, CAN_MESSAGE2): channel, flags, dlc, can_id, can_data = unpack_can_msg(data, pos) @@ -334,10 +342,20 @@ def _parse_data(self, data): _, _, direction, - _, + ext_data_offset, _, ) = unpack_can_fd_64_msg(data, pos) - pos += can_fd_64_msg_size + + # :issue:`1905`: `valid_bytes` can be higher than the actually available data. + # Add zero-byte padding to mimic behavior of CANoe and binlog.dll. + data_field_length = min( + valid_bytes, + (ext_data_offset or obj_size) - header_size - can_fd_64_msg_size, + ) + msg_data_offset = pos + can_fd_64_msg_size + msg_data = data[msg_data_offset : msg_data_offset + data_field_length] + msg_data = msg_data.ljust(valid_bytes, b"\x00") + yield Message( timestamp=timestamp, arbitration_id=can_id & 0x1FFFFFFF, @@ -348,20 +366,18 @@ def _parse_data(self, data): bitrate_switch=bool(fd_flags & 0x2000), error_state_indicator=bool(fd_flags & 0x4000), dlc=dlc2len(dlc), - data=data[pos : pos + valid_bytes], + data=msg_data, channel=channel - 1, ) pos = next_pos -class BLFWriter(FileIOMessageWriter): +class BLFWriter(BinaryIOMessageWriter): """ Logs CAN data to a Binary Logging File compatible with Vector's tools. """ - file: BinaryIO - #: Max log container size of uncompressed data max_container_size = 128 * 1024 @@ -370,7 +386,7 @@ class BLFWriter(FileIOMessageWriter): def __init__( self, - file: Union[StringPathLike, BinaryIO], + file: StringPathLike | BinaryIO, append: bool = False, channel: int = 1, compression_level: int = -1, @@ -392,18 +408,16 @@ def __init__( Z_DEFAULT_COMPRESSION represents a default compromise between speed and compression (currently equivalent to level 6). """ - mode = "rb+" if append else "wb" try: - super().__init__(file, mode=mode) + super().__init__(file, mode="rb+" if append else "wb") except FileNotFoundError: # Trying to append to a non-existing file, create a new one append = False - mode = "wb" - super().__init__(file, mode=mode) + super().__init__(file, mode="wb") assert self.file is not None self.channel = channel self.compression_level = compression_level - self._buffer: List[bytes] = [] + self._buffer: list[bytes] = [] self._buffer_size = 0 # If max container size is located in kwargs, then update the instance if kwargs.get("max_container_size", False): @@ -416,11 +430,11 @@ def __init__( raise BLFParseError("Unexpected file format") self.uncompressed_size = header[11] self.object_count = header[12] - self.start_timestamp: Optional[float] = systemtime_to_timestamp( - cast(TSystemTime, header[14:22]) + self.start_timestamp: float | None = systemtime_to_timestamp( + cast("TSystemTime", header[14:22]) ) - self.stop_timestamp: Optional[float] = systemtime_to_timestamp( - cast(TSystemTime, header[22:30]) + self.stop_timestamp: float | None = systemtime_to_timestamp( + cast("TSystemTime", header[22:30]) ) # Jump to the end of the file self.file.seek(0, 2) @@ -432,7 +446,7 @@ def __init__( # Write a default header which will be updated when stopped self._write_header(FILE_HEADER_SIZE) - def _write_header(self, filesize): + def _write_header(self, filesize: int) -> None: header = [b"LOGG", FILE_HEADER_SIZE, self.application_id, 0, 0, 0, 2, 6, 8, 1] # The meaning of "count of objects read" is unknown header.extend([filesize, self.uncompressed_size, self.object_count, 0]) @@ -442,7 +456,7 @@ def _write_header(self, filesize): # Pad to header size self.file.write(b"\x00" * (FILE_HEADER_SIZE - FILE_HEADER_STRUCT.size)) - def on_message_received(self, msg): + def on_message_received(self, msg: Message) -> None: channel = channel2int(msg.channel) if channel is None: channel = self.channel @@ -494,7 +508,7 @@ def on_message_received(self, msg): data = CAN_MSG_STRUCT.pack(channel, flags, msg.dlc, arb_id, can_data) self._add_object(CAN_MESSAGE, data, msg.timestamp) - def log_event(self, text, timestamp=None): + def log_event(self, text: str, timestamp: float | None = None) -> None: """Add an arbitrary message to the log file as a global marker. :param str text: @@ -505,21 +519,26 @@ def log_event(self, text, timestamp=None): """ try: # Only works on Windows - text = text.encode("mbcs") + encoded = text.encode("mbcs") except LookupError: - text = text.encode("ascii") + encoded = text.encode("ascii") comment = b"Added by python-can" marker = b"python-can" data = GLOBAL_MARKER_STRUCT.pack( - 0, 0xFFFFFF, 0xFF3300, 0, len(text), len(marker), len(comment) + 0, 0xFFFFFF, 0xFF3300, 0, len(encoded), len(marker), len(comment) ) - self._add_object(GLOBAL_MARKER, data + text + marker + comment, timestamp) + self._add_object(GLOBAL_MARKER, data + encoded + marker + comment, timestamp) - def _add_object(self, obj_type, data, timestamp=None): + def _add_object( + self, obj_type: int, data: bytes, timestamp: float | None = None + ) -> None: if timestamp is None: timestamp = self.stop_timestamp or time.time() if self.start_timestamp is None: - self.start_timestamp = timestamp + # Save start timestamp using the same precision as the BLF format + # Truncating to milliseconds to avoid rounding errors when calculating + # the timestamp difference + self.start_timestamp = int(timestamp * 1000) / 1000 self.stop_timestamp = timestamp timestamp = int((timestamp - self.start_timestamp) * 1e9) header_size = OBJ_HEADER_BASE_STRUCT.size + OBJ_HEADER_V1_STRUCT.size @@ -541,7 +560,7 @@ def _add_object(self, obj_type, data, timestamp=None): if self._buffer_size >= self.max_container_size: self._flush() - def _flush(self): + def _flush(self) -> None: """Compresses and writes data in the buffer to file.""" if self.file.closed: return @@ -555,7 +574,7 @@ def _flush(self): self._buffer = [tail] self._buffer_size = len(tail) if not self.compression_level: - data = uncompressed_data + data: "bytes | memoryview[int]" = uncompressed_data # noqa: UP037 method = NO_COMPRESSION else: data = zlib.compress(uncompressed_data, self.compression_level) @@ -578,7 +597,7 @@ def file_size(self) -> int: """Return an estimate of the current file size in bytes.""" return self.file.tell() + self._buffer_size - def stop(self): + def stop(self) -> None: """Stops logging and closes the file.""" self._flush() if self.file.seekable(): diff --git a/can/io/canutils.py b/can/io/canutils.py index c57a6ca97..800125b73 100644 --- a/can/io/canutils.py +++ b/can/io/canutils.py @@ -5,11 +5,13 @@ """ import logging -from typing import Generator, TextIO, Union, Any +from collections.abc import Generator +from typing import Any, TextIO from can.message import Message -from .generic import FileIOMessageWriter, MessageReader + from ..typechecking import StringPathLike +from .generic import TextIOMessageReader, TextIOMessageWriter log = logging.getLogger("can.io.canutils") @@ -22,7 +24,7 @@ CANFD_ESI = 0x02 -class CanutilsLogReader(MessageReader): +class CanutilsLogReader(TextIOMessageReader): """ Iterator over CAN messages from a .log Logging File (candump -L). @@ -32,11 +34,9 @@ class CanutilsLogReader(MessageReader): ``(0.0) vcan0 001#8d00100100820100`` """ - file: TextIO - def __init__( self, - file: Union[StringPathLike, TextIO], + file: StringPathLike | TextIO, **kwargs: Any, ) -> None: """ @@ -48,7 +48,6 @@ def __init__( def __iter__(self) -> Generator[Message, None, None]: for line in self.file: - # skip empty lines temp = line.strip() if not temp: @@ -64,7 +63,7 @@ def __iter__(self) -> Generator[Message, None, None]: timestamp = float(timestamp_string[1:-1]) can_id_string, data = frame.split("#", maxsplit=1) - channel: Union[int, str] + channel: int | str if channel_string.isdigit(): channel = int(channel_string) else: @@ -122,7 +121,7 @@ def __iter__(self) -> Generator[Message, None, None]: self.stop() -class CanutilsLogWriter(FileIOMessageWriter): +class CanutilsLogWriter(TextIOMessageWriter): """Logs CAN data to an ASCII log file (.log). This class is is compatible with "candump -L". @@ -133,7 +132,7 @@ class CanutilsLogWriter(FileIOMessageWriter): def __init__( self, - file: Union[StringPathLike, TextIO], + file: StringPathLike | TextIO, channel: str = "vcan0", append: bool = False, **kwargs: Any, @@ -147,13 +146,12 @@ def __init__( :param bool append: if set to `True` messages are appended to the file, else the file is truncated """ - mode = "a" if append else "w" - super().__init__(file, mode=mode) + super().__init__(file, mode="a" if append else "w") self.channel = channel - self.last_timestamp = None + self.last_timestamp: float | None = None - def on_message_received(self, msg): + def on_message_received(self, msg: Message) -> None: # this is the case for the very first message: if self.last_timestamp is None: self.last_timestamp = msg.timestamp or 0.0 @@ -165,7 +163,7 @@ def on_message_received(self, msg): timestamp = msg.timestamp channel = msg.channel if msg.channel is not None else self.channel - if isinstance(channel, int) or isinstance(channel, str) and channel.isdigit(): + if isinstance(channel, int) or (isinstance(channel, str) and channel.isdigit()): channel = f"can{channel}" framestr = f"({timestamp:f}) {channel}" diff --git a/can/io/csv.py b/can/io/csv.py index ecfc5de35..0c8ba02a4 100644 --- a/can/io/csv.py +++ b/can/io/csv.py @@ -9,15 +9,17 @@ of a CSV file. """ -from base64 import b64encode, b64decode -from typing import TextIO, Generator, Union, Any +from base64 import b64decode, b64encode +from collections.abc import Generator +from typing import Any, TextIO from can.message import Message -from .generic import FileIOMessageWriter, MessageReader + from ..typechecking import StringPathLike +from .generic import TextIOMessageReader, TextIOMessageWriter -class CSVReader(MessageReader): +class CSVReader(TextIOMessageReader): """Iterator over CAN messages from a .csv file that was generated by :class:`~can.CSVWriter` or that uses the same format as described there. Assumes that there is a header @@ -26,11 +28,9 @@ class CSVReader(MessageReader): Any line separator is accepted. """ - file: TextIO - def __init__( self, - file: Union[StringPathLike, TextIO], + file: StringPathLike | TextIO, **kwargs: Any, ) -> None: """ @@ -49,7 +49,6 @@ def __iter__(self) -> Generator[Message, None, None]: return for line in self.file: - timestamp, arbitration_id, extended, remote, error, dlc, data = line.split( "," ) @@ -67,7 +66,7 @@ def __iter__(self) -> Generator[Message, None, None]: self.stop() -class CSVWriter(FileIOMessageWriter): +class CSVWriter(TextIOMessageWriter): """Writes a comma separated text file with a line for each message. Includes a header line. @@ -88,11 +87,9 @@ class CSVWriter(FileIOMessageWriter): Each line is terminated with a platform specific line separator. """ - file: TextIO - def __init__( self, - file: Union[StringPathLike, TextIO], + file: StringPathLike | TextIO, append: bool = False, **kwargs: Any, ) -> None: @@ -105,8 +102,7 @@ def __init__( the file is truncated and starts with a newly written header line """ - mode = "a" if append else "w" - super().__init__(file, mode=mode) + super().__init__(file, mode="a" if append else "w") # Write a header row if not append: diff --git a/can/io/generic.py b/can/io/generic.py index 77bba4501..bda4e1cce 100644 --- a/can/io/generic.py +++ b/can/io/generic.py @@ -1,109 +1,258 @@ -"""Contains generic base classes for file IO.""" +"""This module provides abstract base classes for CAN message reading and writing operations +to various file formats. + +.. note:: + All classes in this module are abstract and should be subclassed to implement + specific file format handling. +""" + import locale -from abc import ABCMeta +import os +from abc import ABC, abstractmethod +from collections.abc import Iterable +from contextlib import AbstractContextManager +from io import BufferedIOBase, TextIOWrapper +from pathlib import Path +from types import TracebackType from typing import ( - Optional, - cast, - Iterable, - Type, - ContextManager, + TYPE_CHECKING, Any, + BinaryIO, + Generic, + Literal, + TextIO, + TypeVar, ) -from typing_extensions import Literal -from types import TracebackType -import can -import can.typechecking +from typing_extensions import Self + +from ..listener import Listener +from ..message import Message +from ..typechecking import FileLike, StringPathLike + +if TYPE_CHECKING: + from _typeshed import ( + OpenBinaryModeReading, + OpenBinaryModeUpdating, + OpenBinaryModeWriting, + OpenTextModeReading, + OpenTextModeUpdating, + OpenTextModeWriting, + ) + + +#: type parameter used in generic classes :class:`MessageReader` and :class:`MessageWriter` +_IoTypeVar = TypeVar("_IoTypeVar", bound=FileLike) + + +class MessageWriter(AbstractContextManager["MessageWriter"], Listener, ABC): + """Abstract base class for all CAN message writers. + + This class serves as a foundation for implementing different message writer formats. + It combines context manager capabilities with the message listener interface. + + :param file: Path-like object or string representing the output file location + :param kwargs: Additional keyword arguments for specific writer implementations + """ + + @abstractmethod + def __init__(self, file: StringPathLike, **kwargs: Any) -> None: + pass + + @abstractmethod + def stop(self) -> None: + """Stop handling messages and cleanup any resources.""" + + def __enter__(self) -> Self: + """Enter the context manager.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> Literal[False]: + """Exit the context manager and ensure proper cleanup.""" + self.stop() + return False + + +class SizedMessageWriter(MessageWriter, ABC): + """Abstract base class for message writers that can report their file size. + + This class extends :class:`MessageWriter` with the ability to determine the size + of the output file. + """ + + @abstractmethod + def file_size(self) -> int: + """Get the current size of the output file in bytes. + + :return: The size of the file in bytes + :rtype: int + """ -class BaseIOHandler(ContextManager, metaclass=ABCMeta): - """A generic file handler that can be used for reading and writing. +class FileIOMessageWriter(SizedMessageWriter, Generic[_IoTypeVar]): + """Base class for writers that operate on file descriptors. - Can be used as a context manager. + This class provides common functionality for writers that work with file objects. - :attr file: - the file-like object that is kept internally, or `None` if none - was opened + :param file: A path-like object or file object to write to + :param kwargs: Additional keyword arguments for specific writer implementations + + :ivar file: The file object being written to """ - file: Optional[can.typechecking.FileLike] + file: _IoTypeVar + + @abstractmethod + def __init__(self, file: StringPathLike | _IoTypeVar, **kwargs: Any) -> None: + pass + + def stop(self) -> None: + """Close the file and stop writing.""" + self.file.close() + + def file_size(self) -> int: + """Get the current file size.""" + return self.file.tell() + + +class TextIOMessageWriter(FileIOMessageWriter[TextIO | TextIOWrapper], ABC): + """Text-based message writer implementation. + + :param file: Text file to write to + :param mode: File open mode for text operations + :param kwargs: Additional arguments like encoding + """ def __init__( self, - file: Optional[can.typechecking.AcceptedIOType], - mode: str = "rt", + file: StringPathLike | TextIO | TextIOWrapper, + mode: "OpenTextModeUpdating | OpenTextModeWriting" = "w", **kwargs: Any, ) -> None: - """ - :param file: a path-like object to open a file, a file-like object - to be used as a file or `None` to not use a file at all - :param mode: the mode that should be used to open the file, see - :func:`open`, ignored if *file* is `None` - """ - if file is None or (hasattr(file, "read") and hasattr(file, "write")): - # file is None or some file-like object - self.file = cast(Optional[can.typechecking.FileLike], file) - else: - encoding: Optional[str] = ( - None - if "b" in mode - else kwargs.get("encoding", locale.getpreferredencoding(False)) - ) + if isinstance(file, (str, os.PathLike)): + encoding: str = kwargs.get("encoding", locale.getpreferredencoding(False)) # pylint: disable=consider-using-with - # file is some path-like object - self.file = cast( - can.typechecking.FileLike, - open( - cast(can.typechecking.StringPathLike, file), mode, encoding=encoding - ), - ) - - # for multiple inheritance - super().__init__() - - def __enter__(self) -> "BaseIOHandler": + self.file = Path(file).open(mode=mode, encoding=encoding) + else: + self.file = file + + +class BinaryIOMessageWriter(FileIOMessageWriter[BinaryIO | BufferedIOBase], ABC): + """Binary file message writer implementation. + + :param file: Binary file to write to + :param mode: File open mode for binary operations + :param kwargs: Additional implementation specific arguments + """ + + def __init__( # pylint: disable=unused-argument + self, + file: StringPathLike | BinaryIO | BufferedIOBase, + mode: "OpenBinaryModeUpdating | OpenBinaryModeWriting" = "wb", + **kwargs: Any, + ) -> None: + if isinstance(file, (str, os.PathLike)): + # pylint: disable=consider-using-with,unspecified-encoding + self.file = Path(file).open(mode=mode) + else: + self.file = file + + +class MessageReader(AbstractContextManager["MessageReader"], Iterable[Message], ABC): + """Abstract base class for all CAN message readers. + + This class serves as a foundation for implementing different message reader formats. + It combines context manager capabilities with iteration interface. + + :param file: Path-like object or string representing the input file location + :param kwargs: Additional keyword arguments for specific reader implementations + """ + + @abstractmethod + def __init__(self, file: StringPathLike, **kwargs: Any) -> None: + pass + + @abstractmethod + def stop(self) -> None: + """Stop reading messages and cleanup any resources.""" + + def __enter__(self) -> Self: return self def __exit__( self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, ) -> Literal[False]: self.stop() return False - def stop(self) -> None: - """Closes the underlying file-like object and flushes it, if it was opened in write mode.""" - if self.file is not None: - # this also implies a flush() - self.file.close() +class FileIOMessageReader(MessageReader, Generic[_IoTypeVar]): + """Base class for readers that operate on file descriptors. -class MessageWriter(BaseIOHandler, can.Listener, metaclass=ABCMeta): - """The base class for all writers.""" + This class provides common functionality for readers that work with file objects. - file: Optional[can.typechecking.FileLike] + :param file: A path-like object or file object to read from + :param kwargs: Additional keyword arguments for specific reader implementations + :ivar file: The file object being read from + """ + + file: _IoTypeVar -class FileIOMessageWriter(MessageWriter, metaclass=ABCMeta): - """A specialized base class for all writers with file descriptors.""" + @abstractmethod + def __init__(self, file: StringPathLike | _IoTypeVar, **kwargs: Any) -> None: + pass - file: can.typechecking.FileLike + def stop(self) -> None: + self.file.close() + + +class TextIOMessageReader(FileIOMessageReader[TextIO | TextIOWrapper], ABC): + """Text-based message reader implementation. + + :param file: Text file to read from + :param mode: File open mode for text operations + :param kwargs: Additional arguments like encoding + """ def __init__( - self, file: can.typechecking.AcceptedIOType, mode: str = "wt", **kwargs: Any + self, + file: StringPathLike | TextIO | TextIOWrapper, + mode: "OpenTextModeReading" = "r", + **kwargs: Any, ) -> None: - # Not possible with the type signature, but be verbose for user-friendliness - if file is None: - raise ValueError("The given file cannot be None") + if isinstance(file, (str, os.PathLike)): + encoding: str = kwargs.get("encoding", locale.getpreferredencoding(False)) + # pylint: disable=consider-using-with + self.file = Path(file).open(mode=mode, encoding=encoding) + else: + self.file = file - super().__init__(file, mode, **kwargs) - def file_size(self) -> int: - """Return an estimate of the current file size in bytes.""" - return self.file.tell() +class BinaryIOMessageReader(FileIOMessageReader[BinaryIO | BufferedIOBase], ABC): + """Binary file message reader implementation. + :param file: Binary file to read from + :param mode: File open mode for binary operations + :param kwargs: Additional implementation specific arguments + """ -class MessageReader(BaseIOHandler, Iterable[can.Message], metaclass=ABCMeta): - """The base class for all readers.""" + def __init__( # pylint: disable=unused-argument + self, + file: StringPathLike | BinaryIO | BufferedIOBase, + mode: "OpenBinaryModeReading" = "rb", + **kwargs: Any, + ) -> None: + if isinstance(file, (str, os.PathLike)): + # pylint: disable=consider-using-with,unspecified-encoding + self.file = Path(file).open(mode=mode) + else: + self.file = file diff --git a/can/io/logger.py b/can/io/logger.py index b6ea23380..5009c9756 100644 --- a/can/io/logger.py +++ b/can/io/logger.py @@ -2,41 +2,123 @@ See the :class:`Logger` class. """ +import gzip import os import pathlib from abc import ABC, abstractmethod +from collections.abc import Callable from datetime import datetime -import gzip -from typing import Any, Optional, Callable, Type, Tuple, cast, Dict, Set - from types import TracebackType +from typing import ( + Any, + ClassVar, + Final, + Literal, +) -from typing_extensions import Literal -from pkg_resources import iter_entry_points +from typing_extensions import Self +from .._entry_points import read_entry_points from ..message import Message -from ..listener import Listener -from .generic import BaseIOHandler, FileIOMessageWriter, MessageWriter +from ..typechecking import StringPathLike from .asc import ASCWriter from .blf import BLFWriter from .canutils import CanutilsLogWriter from .csv import CSVWriter -from .sqlite import SqliteWriter +from .generic import ( + BinaryIOMessageWriter, + FileIOMessageWriter, + MessageWriter, + SizedMessageWriter, + TextIOMessageWriter, +) +from .mf4 import MF4Writer from .printer import Printer +from .sqlite import SqliteWriter from .trc import TRCWriter -from ..typechecking import StringPathLike, FileLike, AcceptedIOType - -class Logger(MessageWriter): +#: A map of file suffixes to their corresponding +#: :class:`can.io.generic.MessageWriter` class +MESSAGE_WRITERS: Final[dict[str, type[MessageWriter]]] = { + ".asc": ASCWriter, + ".blf": BLFWriter, + ".csv": CSVWriter, + ".db": SqliteWriter, + ".log": CanutilsLogWriter, + ".mf4": MF4Writer, + ".trc": TRCWriter, + ".txt": Printer, +} + + +def _update_writer_plugins() -> None: + """Update available message writer plugins from entry points.""" + for entry_point in read_entry_points("can.io.message_writer"): + if entry_point.key in MESSAGE_WRITERS: + continue + + writer_class = entry_point.load() + if issubclass(writer_class, MessageWriter): + MESSAGE_WRITERS[entry_point.key] = writer_class + + +def _get_logger_for_suffix(suffix: str) -> type[MessageWriter]: + try: + return MESSAGE_WRITERS[suffix] + except KeyError: + raise ValueError( + f'No write support for unknown log format "{suffix}"' + ) from None + + +def _compress(filename: StringPathLike, **kwargs: Any) -> FileIOMessageWriter[Any]: + """ + Return the suffix and io object of the decompressed file. + File will automatically recompress upon close. """ - Logs CAN messages to a file. + suffixes = pathlib.Path(filename).suffixes + if len(suffixes) != 2: + raise ValueError( + f"No write support for unknown log format \"{''.join(suffixes)}\"" + ) from None + + real_suffix = suffixes[-2].lower() + if real_suffix in (".blf", ".db"): + raise ValueError( + f"The file type {real_suffix} is currently incompatible with gzip." + ) + logger_type = _get_logger_for_suffix(real_suffix) + append = kwargs.get("append", False) + + if issubclass(logger_type, BinaryIOMessageWriter): + return logger_type( + file=gzip.open(filename=filename, mode="ab" if append else "wb"), **kwargs + ) + + elif issubclass(logger_type, TextIOMessageWriter): + return logger_type( + file=gzip.open(filename=filename, mode="at" if append else "wt"), **kwargs + ) + + raise ValueError( + f"The file type {real_suffix} is currently incompatible with gzip." + ) + + +def Logger( # noqa: N802 + filename: StringPathLike | None, **kwargs: Any +) -> MessageWriter: + """Find and return the appropriate :class:`~can.io.generic.MessageWriter` instance + for a given file suffix. The format is determined from the file suffix which can be one of: - * .asc: :class:`can.ASCWriter` + * .asc :class:`can.ASCWriter` * .blf :class:`can.BLFWriter` * .csv: :class:`can.CSVWriter` - * .db: :class:`can.SqliteWriter` + * .db :class:`can.SqliteWriter` * .log :class:`can.CanutilsLogWriter` + * .mf4 :class:`can.MF4Writer` + (optional, depends on `asammdf `_) * .trc :class:`can.TRCWriter` * .txt :class:`can.Printer` @@ -48,80 +130,32 @@ class Logger(MessageWriter): The log files may be incomplete until `stop()` is called due to buffering. + :param filename: + the filename/path of the file to write to, + may be a path-like object or None to + instantiate a :class:`~can.Printer` + :raises ValueError: + if the filename's suffix is of an unknown file type + .. note:: - This class itself is just a dispatcher, and any positional and keyword + This function itself is just a dispatcher, and any positional and keyword arguments are passed on to the returned instance. """ - fetched_plugins = False - message_writers: Dict[str, Type[MessageWriter]] = { - ".asc": ASCWriter, - ".blf": BLFWriter, - ".csv": CSVWriter, - ".db": SqliteWriter, - ".log": CanutilsLogWriter, - ".trc": TRCWriter, - ".txt": Printer, - } - - @staticmethod - def __new__( # type: ignore - cls: Any, filename: Optional[StringPathLike], **kwargs: Any - ) -> MessageWriter: - """ - :param filename: the filename/path of the file to write to, - may be a path-like object or None to - instantiate a :class:`~can.Printer` - :raises ValueError: if the filename's suffix is of an unknown file type - """ - if filename is None: - return Printer(**kwargs) - - if not Logger.fetched_plugins: - Logger.message_writers.update( - { - writer.name: writer.load() - for writer in iter_entry_points("can.io.message_writer") - } - ) - Logger.fetched_plugins = True - - suffix = pathlib.PurePath(filename).suffix.lower() - - file_or_filename: AcceptedIOType = filename - if suffix == ".gz": - suffix, file_or_filename = Logger.compress(filename, **kwargs) - - try: - return Logger.message_writers[suffix](file=file_or_filename, **kwargs) - except KeyError: - raise ValueError( - f'No write support for this unknown log format "{suffix}"' - ) from None - - @staticmethod - def compress(filename: StringPathLike, **kwargs: Any) -> Tuple[str, FileLike]: - """ - Return the suffix and io object of the decompressed file. - File will automatically recompress upon close. - """ - real_suffix = pathlib.Path(filename).suffixes[-2].lower() - if real_suffix in (".blf", ".db"): - raise ValueError( - f"The file type {real_suffix} is currently incompatible with gzip." - ) - if kwargs.get("append", False): - mode = "ab" if real_suffix == ".blf" else "at" - else: - mode = "wb" if real_suffix == ".blf" else "wt" + if filename is None: + return Printer(**kwargs) - return real_suffix, gzip.open(filename, mode) + _update_writer_plugins() - def on_message_received(self, msg: Message) -> None: - pass + suffix = pathlib.PurePath(filename).suffix.lower() + if suffix == ".gz": + return _compress(filename, **kwargs) + logger_type = _get_logger_for_suffix(suffix) + return logger_type(file=filename, **kwargs) -class BaseRotatingLogger(Listener, BaseIOHandler, ABC): + +class BaseRotatingLogger(MessageWriter, ABC): """ Base class for rotating CAN loggers. This class is not meant to be instantiated directly. Subclasses must implement the :meth:`should_rollover` @@ -137,34 +171,29 @@ class BaseRotatingLogger(Listener, BaseIOHandler, ABC): Subclasses must set the `_writer` attribute upon initialization. """ - _supported_formats: Set[str] = set() + _supported_formats: ClassVar[set[str]] = set() #: If this attribute is set to a callable, the :meth:`~BaseRotatingLogger.rotation_filename` #: method delegates to this callable. The parameters passed to the callable are #: those passed to :meth:`~BaseRotatingLogger.rotation_filename`. - namer: Optional[Callable[[StringPathLike], StringPathLike]] = None + namer: Callable[[StringPathLike], StringPathLike] | None = None #: If this attribute is set to a callable, the :meth:`~BaseRotatingLogger.rotate` method #: delegates to this callable. The parameters passed to the callable are those #: passed to :meth:`~BaseRotatingLogger.rotate`. - rotator: Optional[Callable[[StringPathLike, StringPathLike], None]] = None + rotator: Callable[[StringPathLike, StringPathLike], None] | None = None #: An integer counter to track the number of rollovers. rollover_count: int = 0 def __init__(self, **kwargs: Any) -> None: - Listener.__init__(self) - BaseIOHandler.__init__(self, file=None) - self.writer_kwargs = kwargs - # Expected to be set by the subclass - self._writer: FileIOMessageWriter = None # type: ignore - @property - def writer(self) -> FileIOMessageWriter: + @abstractmethod + def writer(self) -> MessageWriter: """This attribute holds an instance of a writer class which manages the actual file IO.""" - return self._writer + raise NotImplementedError def rotation_filename(self, default_name: StringPathLike) -> StringPathLike: """Modify the filename of a log file when rotating. @@ -216,7 +245,7 @@ def on_message_received(self, msg: Message) -> None: self.writer.on_message_received(msg) - def _get_new_writer(self, filename: StringPathLike) -> FileIOMessageWriter: + def _get_new_writer(self, filename: StringPathLike) -> MessageWriter: """Instantiate a new writer. .. note:: @@ -228,17 +257,16 @@ def _get_new_writer(self, filename: StringPathLike) -> FileIOMessageWriter: :return: An instance of a writer class. """ - suffix = "".join(pathlib.Path(filename).suffixes[-2:]).lower() - - if suffix in self._supported_formats: + suffixes = pathlib.Path(filename).suffixes + for suffix_length in range(len(suffixes), 0, -1): + suffix = "".join(suffixes[-suffix_length:]).lower() + if suffix not in self._supported_formats: + continue logger = Logger(filename=filename, **self.writer_kwargs) - if isinstance(logger, FileIOMessageWriter): - return logger - elif isinstance(logger, Printer) and logger.file is not None: - return cast(FileIOMessageWriter, logger) + return logger - raise Exception( - f'The log format "{suffix}" ' + raise ValueError( + f'The log format of "{pathlib.Path(filename).name}" ' f"is not supported by {self.__class__.__name__}. " f"{self.__class__.__name__} supports the following formats: " f"{', '.join(self._supported_formats)}" @@ -252,16 +280,17 @@ def stop(self) -> None: """ self.writer.stop() - def __enter__(self) -> "BaseRotatingLogger": + def __enter__(self) -> Self: return self def __exit__( self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, ) -> Literal[False]: - return self._writer.__exit__(exc_type, exc_val, exc_tb) + self.stop() + return False @abstractmethod def should_rollover(self, msg: Message) -> bool: @@ -314,7 +343,7 @@ class SizedRotatingLogger(BaseRotatingLogger): :meth:`~can.Listener.stop` is called. """ - _supported_formats = {".asc", ".blf", ".csv", ".log", ".txt"} + _supported_formats: ClassVar[set[str]] = {".asc", ".blf", ".csv", ".log", ".txt"} def __init__( self, @@ -337,14 +366,25 @@ def __init__( self._writer = self._get_new_writer(self.base_filename) + def _get_new_writer(self, filename: StringPathLike) -> SizedMessageWriter: + writer = super()._get_new_writer(filename) + if isinstance(writer, SizedMessageWriter): + return writer + raise TypeError + + @property + def writer(self) -> SizedMessageWriter: + return self._writer + def should_rollover(self, msg: Message) -> bool: if self.max_bytes <= 0: return False - if self.writer.file_size() >= self.max_bytes: - return True + file_size = self.writer.file_size() + if file_size is None: + return False - return False + return file_size >= self.max_bytes def do_rollover(self) -> None: if self.writer: diff --git a/can/io/mf4.py b/can/io/mf4.py new file mode 100644 index 000000000..fcde2e193 --- /dev/null +++ b/can/io/mf4.py @@ -0,0 +1,540 @@ +""" +Contains handling of MF4 logging files. + +MF4 files represent Measurement Data Format (MDF) version 4 as specified by +the ASAM MDF standard (see https://www.asam.net/standards/detail/mdf/) +""" + +import abc +import heapq +import logging +from collections.abc import Generator, Iterator +from datetime import datetime +from hashlib import md5 +from io import BufferedIOBase, BytesIO +from pathlib import Path +from typing import Any, BinaryIO, cast + +from ..message import Message +from ..typechecking import StringPathLike +from ..util import channel2int, len2dlc +from .generic import BinaryIOMessageReader, BinaryIOMessageWriter + +logger = logging.getLogger("can.io.mf4") + +try: + import asammdf + import numpy as np + from asammdf import Signal, Source + from asammdf.blocks.mdf_v4 import MDF4 + from asammdf.blocks.v4_blocks import ChannelGroup, SourceInformation + from asammdf.blocks.v4_constants import BUS_TYPE_CAN, FLAG_CG_BUS_EVENT, SOURCE_BUS + from asammdf.mdf import MDF + + STD_DTYPE = np.dtype( + [ + ("CAN_DataFrame.BusChannel", " None: + """ + :param file: + A path-like object or as file-like object to write to. + If this is a file-like object, is has to be opened in + binary write mode, not text write mode. + :param database: + optional path to a DBC or ARXML file that contains message description. + :param compression_level: + compression option as integer (default 2) + * 0 - no compression + * 1 - deflate (slower, but produces smaller files) + * 2 - transposition + deflate (slowest, but produces the smallest files) + """ + if asammdf is None: + raise NotImplementedError( + "The asammdf package was not found. Install python-can with " + "the optional dependency [mf4] to use the MF4Writer." + ) + + if kwargs.get("append", False): + raise ValueError( + f"{self.__class__.__name__} is currently not equipped to " + f"append messages to an existing file." + ) + + super().__init__(file, mode="w+b") + now = datetime.now() + self._mdf = cast("MDF4", MDF(version="4.10")) + self._mdf.header.start_time = now + self.last_timestamp = self._start_time = now.timestamp() + + self._compression_level = compression_level + + if database: + database = Path(database).resolve() + if database.exists(): + data = database.read_bytes() + attachment = data, database.name, md5(data).digest() + else: + attachment = None + else: + attachment = None + + acquisition_source = SourceInformation( + source_type=SOURCE_BUS, bus_type=BUS_TYPE_CAN + ) + + # standard frames group + self._mdf.append( + Signal( + name="CAN_DataFrame", + samples=np.array([], dtype=STD_DTYPE), + timestamps=np.array([], dtype=" int: + """Return an estimate of the current file size in bytes.""" + # TODO: find solution without accessing private attributes of asammdf + return cast( + "int", + self._mdf._tempfile.tell(), # pylint: disable=protected-access,no-member + ) + + def stop(self) -> None: + self._mdf.save(self.file, compression=self._compression_level) + self._mdf.close() + super().stop() + + def on_message_received(self, msg: Message) -> None: + channel = channel2int(msg.channel) + + timestamp = msg.timestamp + if timestamp is None: + timestamp = self.last_timestamp + else: + self.last_timestamp = max(self.last_timestamp, timestamp) + + timestamp -= self._start_time + + if msg.is_remote_frame: + if channel is not None: + self._rtr_buffer["CAN_RemoteFrame.BusChannel"] = channel + + self._rtr_buffer["CAN_RemoteFrame.ID"] = msg.arbitration_id + self._rtr_buffer["CAN_RemoteFrame.IDE"] = int(msg.is_extended_id) + self._rtr_buffer["CAN_RemoteFrame.Dir"] = 0 if msg.is_rx else 1 + self._rtr_buffer["CAN_RemoteFrame.DLC"] = msg.dlc + + sigs = [(np.array([timestamp]), None), (self._rtr_buffer, None)] + self._mdf.extend(2, sigs) + + elif msg.is_error_frame: + if channel is not None: + self._err_buffer["CAN_ErrorFrame.BusChannel"] = channel + + self._err_buffer["CAN_ErrorFrame.ID"] = msg.arbitration_id + self._err_buffer["CAN_ErrorFrame.IDE"] = int(msg.is_extended_id) + self._err_buffer["CAN_ErrorFrame.Dir"] = 0 if msg.is_rx else 1 + data = msg.data + size = len(data) + self._err_buffer["CAN_ErrorFrame.DataLength"] = size + self._err_buffer["CAN_ErrorFrame.DataBytes"][0, :size] = data + if msg.is_fd: + self._err_buffer["CAN_ErrorFrame.DLC"] = len2dlc(msg.dlc) + self._err_buffer["CAN_ErrorFrame.ESI"] = int(msg.error_state_indicator) + self._err_buffer["CAN_ErrorFrame.BRS"] = int(msg.bitrate_switch) + self._err_buffer["CAN_ErrorFrame.EDL"] = 1 + else: + self._err_buffer["CAN_ErrorFrame.DLC"] = msg.dlc + self._err_buffer["CAN_ErrorFrame.ESI"] = 0 + self._err_buffer["CAN_ErrorFrame.BRS"] = 0 + self._err_buffer["CAN_ErrorFrame.EDL"] = 0 + + sigs = [(np.array([timestamp]), None), (self._err_buffer, None)] + self._mdf.extend(1, sigs) + + else: + if channel is not None: + self._std_buffer["CAN_DataFrame.BusChannel"] = channel + + self._std_buffer["CAN_DataFrame.ID"] = msg.arbitration_id + self._std_buffer["CAN_DataFrame.IDE"] = int(msg.is_extended_id) + self._std_buffer["CAN_DataFrame.Dir"] = 0 if msg.is_rx else 1 + data = msg.data + size = len(data) + self._std_buffer["CAN_DataFrame.DataLength"] = size + self._std_buffer["CAN_DataFrame.DataBytes"][0, :size] = data + if msg.is_fd: + self._std_buffer["CAN_DataFrame.DLC"] = len2dlc(msg.dlc) + self._std_buffer["CAN_DataFrame.ESI"] = int(msg.error_state_indicator) + self._std_buffer["CAN_DataFrame.BRS"] = int(msg.bitrate_switch) + self._std_buffer["CAN_DataFrame.EDL"] = 1 + else: + self._std_buffer["CAN_DataFrame.DLC"] = msg.dlc + self._std_buffer["CAN_DataFrame.ESI"] = 0 + self._std_buffer["CAN_DataFrame.BRS"] = 0 + self._std_buffer["CAN_DataFrame.EDL"] = 0 + + sigs = [(np.array([timestamp]), None), (self._std_buffer, None)] + self._mdf.extend(0, sigs) + + # reset buffer structure + self._std_buffer = np.zeros(1, dtype=STD_DTYPE) + self._err_buffer = np.zeros(1, dtype=ERR_DTYPE) + self._rtr_buffer = np.zeros(1, dtype=RTR_DTYPE) + + +class FrameIterator(abc.ABC): + """ + Iterator helper class for common handling among CAN DataFrames, ErrorFrames and RemoteFrames. + """ + + # Number of records to request for each asammdf call + _chunk_size = 1000 + + def __init__(self, mdf: MDF4, group_index: int, start_timestamp: float, name: str): + self._mdf = mdf + self._group_index = group_index + self._start_timestamp = start_timestamp + self._name = name + + # Extract names + channel_group: ChannelGroup = self._mdf.groups[self._group_index] + + self._channel_names = [] + + for channel in channel_group.channels: + if str(channel.name).startswith(f"{self._name}."): + self._channel_names.append(channel.name) + + def _get_data(self, current_offset: int) -> Signal: + # NOTE: asammdf suggests using select instead of get. Select seem to miss converting some + # channels which get does convert as expected. + data_raw = self._mdf.get( + self._name, + self._group_index, + record_offset=current_offset, + record_count=self._chunk_size, + raw=False, + ) + + return data_raw + + @abc.abstractmethod + def __iter__(self) -> Generator[Message, None, None]: + pass + + +class MF4Reader(BinaryIOMessageReader): + """ + Iterator of CAN messages from a MF4 logging file. + + The MF4Reader only supports MF4 files with CAN bus logging. + """ + + # NOTE: Readout based on the bus logging code from asammdf GUI + + class _CANDataFrameIterator(FrameIterator): + + def __init__(self, mdf: MDF4, group_index: int, start_timestamp: float): + super().__init__(mdf, group_index, start_timestamp, "CAN_DataFrame") + + def __iter__(self) -> Generator[Message, None, None]: + for current_offset in range( + 0, + self._mdf.groups[self._group_index].channel_group.cycles_nr, + self._chunk_size, + ): + data = self._get_data(current_offset) + names = data.samples[0].dtype.names + + for i in range(len(data)): + data_length = int(data["CAN_DataFrame.DataLength"][i]) + + kv: dict[str, Any] = { + "timestamp": float(data.timestamps[i]) + self._start_timestamp, + "arbitration_id": int(data["CAN_DataFrame.ID"][i]) & 0x1FFFFFFF, + "data": data["CAN_DataFrame.DataBytes"][i][ + :data_length + ].tobytes(), + } + + if "CAN_DataFrame.BusChannel" in names: + kv["channel"] = int(data["CAN_DataFrame.BusChannel"][i]) + if "CAN_DataFrame.Dir" in names: + if data["CAN_DataFrame.Dir"][i].dtype.kind == "S": + kv["is_rx"] = data["CAN_DataFrame.Dir"][i] == b"Rx" + else: + kv["is_rx"] = int(data["CAN_DataFrame.Dir"][i]) == 0 + if "CAN_DataFrame.IDE" in names: + kv["is_extended_id"] = bool(data["CAN_DataFrame.IDE"][i]) + if "CAN_DataFrame.EDL" in names: + kv["is_fd"] = bool(data["CAN_DataFrame.EDL"][i]) + if "CAN_DataFrame.BRS" in names: + kv["bitrate_switch"] = bool(data["CAN_DataFrame.BRS"][i]) + if "CAN_DataFrame.ESI" in names: + kv["error_state_indicator"] = bool(data["CAN_DataFrame.ESI"][i]) + + yield Message(**kv) + + class _CANErrorFrameIterator(FrameIterator): + + def __init__(self, mdf: MDF4, group_index: int, start_timestamp: float): + super().__init__(mdf, group_index, start_timestamp, "CAN_ErrorFrame") + + def __iter__(self) -> Generator[Message, None, None]: + for current_offset in range( + 0, + self._mdf.groups[self._group_index].channel_group.cycles_nr, + self._chunk_size, + ): + data = self._get_data(current_offset) + names = data.samples[0].dtype.names + + for i in range(len(data)): + kv: dict[str, Any] = { + "timestamp": float(data.timestamps[i]) + self._start_timestamp, + "is_error_frame": True, + } + + if "CAN_ErrorFrame.BusChannel" in names: + kv["channel"] = int(data["CAN_ErrorFrame.BusChannel"][i]) + if "CAN_ErrorFrame.Dir" in names: + if data["CAN_ErrorFrame.Dir"][i].dtype.kind == "S": + kv["is_rx"] = data["CAN_ErrorFrame.Dir"][i] == b"Rx" + else: + kv["is_rx"] = int(data["CAN_ErrorFrame.Dir"][i]) == 0 + if "CAN_ErrorFrame.ID" in names: + kv["arbitration_id"] = ( + int(data["CAN_ErrorFrame.ID"][i]) & 0x1FFFFFFF + ) + if "CAN_ErrorFrame.IDE" in names: + kv["is_extended_id"] = bool(data["CAN_ErrorFrame.IDE"][i]) + if "CAN_ErrorFrame.EDL" in names: + kv["is_fd"] = bool(data["CAN_ErrorFrame.EDL"][i]) + if "CAN_ErrorFrame.BRS" in names: + kv["bitrate_switch"] = bool(data["CAN_ErrorFrame.BRS"][i]) + if "CAN_ErrorFrame.ESI" in names: + kv["error_state_indicator"] = bool( + data["CAN_ErrorFrame.ESI"][i] + ) + if "CAN_ErrorFrame.RTR" in names: + kv["is_remote_frame"] = bool(data["CAN_ErrorFrame.RTR"][i]) + if ( + "CAN_ErrorFrame.DataLength" in names + and "CAN_ErrorFrame.DataBytes" in names + ): + data_length = int(data["CAN_ErrorFrame.DataLength"][i]) + kv["data"] = data["CAN_ErrorFrame.DataBytes"][i][ + :data_length + ].tobytes() + + yield Message(**kv) + + class _CANRemoteFrameIterator(FrameIterator): + + def __init__(self, mdf: MDF4, group_index: int, start_timestamp: float): + super().__init__(mdf, group_index, start_timestamp, "CAN_RemoteFrame") + + def __iter__(self) -> Generator[Message, None, None]: + for current_offset in range( + 0, + self._mdf.groups[self._group_index].channel_group.cycles_nr, + self._chunk_size, + ): + data = self._get_data(current_offset) + names = data.samples[0].dtype.names + + for i in range(len(data)): + kv: dict[str, Any] = { + "timestamp": float(data.timestamps[i]) + self._start_timestamp, + "arbitration_id": int(data["CAN_RemoteFrame.ID"][i]) + & 0x1FFFFFFF, + "dlc": int(data["CAN_RemoteFrame.DLC"][i]), + "is_remote_frame": True, + } + + if "CAN_RemoteFrame.BusChannel" in names: + kv["channel"] = int(data["CAN_RemoteFrame.BusChannel"][i]) + if "CAN_RemoteFrame.Dir" in names: + if data["CAN_RemoteFrame.Dir"][i].dtype.kind == "S": + kv["is_rx"] = data["CAN_RemoteFrame.Dir"][i] == b"Rx" + else: + kv["is_rx"] = int(data["CAN_RemoteFrame.Dir"][i]) == 0 + if "CAN_RemoteFrame.IDE" in names: + kv["is_extended_id"] = bool(data["CAN_RemoteFrame.IDE"][i]) + + yield Message(**kv) + + def __init__( + self, + file: StringPathLike | BinaryIO, + **kwargs: Any, + ) -> None: + """ + :param file: a path-like object or as file-like object to read from + If this is a file-like object, is has to be opened in + binary read mode, not text read mode. + """ + if asammdf is None: + raise NotImplementedError( + "The asammdf package was not found. Install python-can with " + "the optional dependency [mf4] to use the MF4Reader." + ) + + super().__init__(file, mode="rb") + + self._mdf: MDF4 + if isinstance(file, BufferedIOBase): + self._mdf = cast("MDF4", MDF(BytesIO(file.read()))) + else: + self._mdf = cast("MDF4", MDF(file)) + + self._start_timestamp = self._mdf.header.start_time.timestamp() + + def __iter__(self) -> Iterator[Message]: + # To handle messages split over multiple channel groups, create a single iterator per + # channel group and merge these iterators into a single iterator using heapq. + iterators: list[FrameIterator] = [] + for group_index, group in enumerate(self._mdf.groups): + channel_group: ChannelGroup = group.channel_group + + if not channel_group.flags & FLAG_CG_BUS_EVENT: + # Not a bus event, skip + continue + + if channel_group.cycles_nr == 0: + # No data, skip + continue + + acquisition_source: Source | None = channel_group.acq_source + + if acquisition_source is None: + # No source information, skip + continue + if not acquisition_source.source_type & Source.SOURCE_BUS: + # Not a bus type (likely already covered by the channel group flag), skip + continue + + channel_names = [channel.name for channel in group.channels] + + if acquisition_source.bus_type == Source.BUS_TYPE_CAN: + if "CAN_DataFrame" in channel_names: + iterators.append( + self._CANDataFrameIterator( + self._mdf, group_index, self._start_timestamp + ) + ) + elif "CAN_ErrorFrame" in channel_names: + iterators.append( + self._CANErrorFrameIterator( + self._mdf, group_index, self._start_timestamp + ) + ) + elif "CAN_RemoteFrame" in channel_names: + iterators.append( + self._CANRemoteFrameIterator( + self._mdf, group_index, self._start_timestamp + ) + ) + else: + # Unknown bus type, skip + continue + + # Create merged iterator over all the groups, using the timestamps as comparison key + return iter(heapq.merge(*iterators, key=lambda x: x.timestamp)) + + def stop(self) -> None: + self._mdf.close() + self._mdf = None + super().stop() diff --git a/can/io/player.py b/can/io/player.py index 0e062ecb7..c0015b185 100644 --- a/can/io/player.py +++ b/can/io/player.py @@ -1,111 +1,128 @@ """ This module contains the generic :class:`LogReader` as well as :class:`MessageSync` which plays back messages -in the recorded order an time intervals. +in the recorded order and time intervals. """ + import gzip import pathlib import time -import typing - -from pkg_resources import iter_entry_points +from collections.abc import Generator, Iterable +from typing import ( + Any, + Final, +) -from .generic import MessageReader +from .._entry_points import read_entry_points +from ..message import Message +from ..typechecking import StringPathLike from .asc import ASCReader from .blf import BLFReader from .canutils import CanutilsLogReader from .csv import CSVReader +from .generic import BinaryIOMessageReader, MessageReader, TextIOMessageReader +from .mf4 import MF4Reader from .sqlite import SqliteReader from .trc import TRCReader -from ..typechecking import StringPathLike, FileLike, AcceptedIOType -from ..message import Message +#: A map of file suffixes to their corresponding +#: :class:`can.io.generic.MessageReader` class +MESSAGE_READERS: Final[dict[str, type[MessageReader]]] = { + ".asc": ASCReader, + ".blf": BLFReader, + ".csv": CSVReader, + ".db": SqliteReader, + ".log": CanutilsLogReader, + ".mf4": MF4Reader, + ".trc": TRCReader, +} + + +def _update_reader_plugins() -> None: + """Update available message reader plugins from entry points.""" + for entry_point in read_entry_points("can.io.message_reader"): + if entry_point.key in MESSAGE_READERS: + continue + + reader_class = entry_point.load() + if issubclass(reader_class, MessageReader): + MESSAGE_READERS[entry_point.key] = reader_class -class LogReader(MessageReader): + +def _get_logger_for_suffix(suffix: str) -> type[MessageReader]: + """Find MessageReader class for given suffix.""" + try: + return MESSAGE_READERS[suffix] + except KeyError: + raise ValueError(f'No read support for unknown log format "{suffix}"') from None + + +def _decompress(filename: StringPathLike, **kwargs: Any) -> MessageReader: """ - Replay logged CAN messages from a file. + Return the suffix and io object of the decompressed file. + """ + suffixes = pathlib.Path(filename).suffixes + if len(suffixes) != 2: + raise ValueError( + f"No read support for unknown log format \"{''.join(suffixes)}\"" + ) + + real_suffix = suffixes[-2].lower() + reader_type = _get_logger_for_suffix(real_suffix) + + if issubclass(reader_type, TextIOMessageReader): + return reader_type(gzip.open(filename, mode="rt"), **kwargs) + elif issubclass(reader_type, BinaryIOMessageReader): + return reader_type(gzip.open(filename, mode="rb"), **kwargs) + + raise ValueError(f"No read support for unknown log format \"{''.join(suffixes)}\"") + + +def LogReader(filename: StringPathLike, **kwargs: Any) -> MessageReader: # noqa: N802 + """Find and return the appropriate :class:`~can.io.generic.MessageReader` instance + for a given file suffix. The format is determined from the file suffix which can be one of: - * .asc - * .blf - * .csv - * .db - * .log - * .trc + * .asc :class:`can.ASCReader` + * .blf :class:`can.BLFReader` + * .csv :class:`can.CSVReader` + * .db :class:`can.SqliteReader` + * .log :class:`can.CanutilsLogReader` + * .mf4 :class:`can.MF4Reader` + (optional, depends on `asammdf `_) + * .trc :class:`can.TRCReader` Gzip compressed files can be used as long as the original files suffix is one of the above (e.g. filename.asc.gz). - Exposes a simple iterator interface, to use simply: + Exposes a simple iterator interface, to use simply:: + + for msg in can.LogReader("some/path/to/my_file.log"): + print(msg) - >>> for msg in LogReader("some/path/to/my_file.log"): - ... print(msg) + :param filename: + the filename/path of the file to read from + :raises ValueError: + if the filename's suffix is of an unknown file type .. note:: There are no time delays, if you want to reproduce the measured delays between messages look at the :class:`can.MessageSync` class. .. note:: - This class itself is just a dispatcher, and any positional an keyword + This function itself is just a dispatcher, and any positional and keyword arguments are passed on to the returned instance. """ - fetched_plugins = False - message_readers: typing.Dict[str, typing.Type[MessageReader]] = { - ".asc": ASCReader, - ".blf": BLFReader, - ".csv": CSVReader, - ".db": SqliteReader, - ".log": CanutilsLogReader, - ".trc": TRCReader, - } - - @staticmethod - def __new__( # type: ignore - cls: typing.Any, - filename: StringPathLike, - **kwargs: typing.Any, - ) -> MessageReader: - """ - :param filename: the filename/path of the file to read from - :raises ValueError: if the filename's suffix is of an unknown file type - """ - if not LogReader.fetched_plugins: - LogReader.message_readers.update( - { - reader.name: reader.load() - for reader in iter_entry_points("can.io.message_reader") - } - ) - LogReader.fetched_plugins = True - - suffix = pathlib.PurePath(filename).suffix.lower() - - file_or_filename: AcceptedIOType = filename - if suffix == ".gz": - suffix, file_or_filename = LogReader.decompress(filename) - try: - return LogReader.message_readers[suffix](file=file_or_filename, **kwargs) - except KeyError: - raise ValueError( - f'No read support for this unknown log format "{suffix}"' - ) from None - - @staticmethod - def decompress( - filename: StringPathLike, - ) -> typing.Tuple[str, typing.Union[str, FileLike]]: - """ - Return the suffix and io object of the decompressed file. - """ - real_suffix = pathlib.Path(filename).suffixes[-2].lower() - mode = "rb" if real_suffix == ".blf" else "rt" + _update_reader_plugins() - return real_suffix, gzip.open(filename, mode) + suffix = pathlib.PurePath(filename).suffix.lower() + if suffix == ".gz": + return _decompress(filename) - def __iter__(self) -> typing.Generator[Message, None, None]: - raise NotImplementedError() + reader_type = _get_logger_for_suffix(suffix) + return reader_type(file=filename, **kwargs) class MessageSync: @@ -115,7 +132,7 @@ class MessageSync: def __init__( self, - messages: typing.Iterable[Message], + messages: Iterable[Message], timestamps: bool = True, gap: float = 0.0001, skip: float = 60.0, @@ -127,19 +144,28 @@ def __init__( as the time between messages. :param gap: Minimum time between sent messages in seconds :param skip: Skip periods of inactivity greater than this (in seconds). + + Example:: + + import can + + with can.LogReader("my_logfile.asc") as reader, can.Bus(interface="virtual") as bus: + for msg in can.MessageSync(messages=reader): + print(msg) + bus.send(msg) + """ self.raw_messages = messages self.timestamps = timestamps self.gap = gap self.skip = skip - def __iter__(self) -> typing.Generator[Message, None, None]: + def __iter__(self) -> Generator[Message, None, None]: t_wakeup = playback_start_time = time.perf_counter() recorded_start_time = None t_skipped = 0.0 for message in self.raw_messages: - # Work out the correct wait time if self.timestamps: if recorded_start_time is None: diff --git a/can/io/printer.py b/can/io/printer.py index 01da12e84..c41a83691 100644 --- a/can/io/printer.py +++ b/can/io/printer.py @@ -3,17 +3,18 @@ """ import logging - -from typing import Optional, TextIO, Union, Any, cast +import sys +from io import TextIOWrapper +from typing import Any, TextIO from ..message import Message -from .generic import MessageWriter from ..typechecking import StringPathLike +from .generic import TextIOMessageWriter log = logging.getLogger("can.io.printer") -class Printer(MessageWriter): +class Printer(TextIOMessageWriter): """ The Printer class is a subclass of :class:`~can.Listener` which simply prints any messages it receives to the terminal (stdout). A message is turned into a @@ -23,34 +24,31 @@ class Printer(MessageWriter): standard out """ - file: Optional[TextIO] - def __init__( self, - file: Optional[Union[StringPathLike, TextIO]] = None, + file: StringPathLike | TextIO | TextIOWrapper = sys.stdout, append: bool = False, - **kwargs: Any + **kwargs: Any, ) -> None: """ :param file: An optional path-like object or a file-like object to "print" to instead of writing to standard out (stdout). - If this is a file-like object, is has to be opened in text + If this is a file-like object, it has to be opened in text write mode, not binary write mode. :param append: If set to `True` messages, are appended to the file, else the file is truncated """ - self.write_to_file = file is not None - mode = "a" if append else "w" - super().__init__(file, mode=mode) + super().__init__(file, mode="a" if append else "w") def on_message_received(self, msg: Message) -> None: - if self.write_to_file: - cast(TextIO, self.file).write(str(msg) + "\n") - else: - print(msg) + self.file.write(str(msg) + "\n") def file_size(self) -> int: """Return an estimate of the current file size in bytes.""" - if self.file is not None: + if self.file is not sys.stdout: return self.file.tell() return 0 + + def stop(self) -> None: + if self.file is not sys.stdout: + super().stop() diff --git a/can/io/sqlite.py b/can/io/sqlite.py index 33f5d293f..5f4885adb 100644 --- a/can/io/sqlite.py +++ b/can/io/sqlite.py @@ -4,19 +4,23 @@ .. note:: The database schema is given in the documentation of the loggers. """ -import time -import threading import logging import sqlite3 -from typing import Generator, Any +import threading +import time +from collections.abc import Generator, Iterator +from typing import Any, TypeAlias from can.listener import BufferedReader from can.message import Message -from .generic import MessageWriter, MessageReader + from ..typechecking import StringPathLike +from .generic import MessageReader, MessageWriter log = logging.getLogger("can.io.sqlite") +_MessageTuple: TypeAlias = "tuple[float, int, bool, bool, bool, int, memoryview[int]]" + class SqliteReader(MessageReader): """ @@ -47,7 +51,6 @@ def __init__( do not accept file-like objects as the `file` parameter. It also runs in ``append=True`` mode all the time. """ - super().__init__(file=None) self._conn = sqlite3.connect(file) self._cursor = self._conn.cursor() self.table_name = table_name @@ -57,7 +60,7 @@ def __iter__(self) -> Generator[Message, None, None]: yield SqliteReader._assemble_message(frame_data) @staticmethod - def _assemble_message(frame_data): + def _assemble_message(frame_data: _MessageTuple) -> Message: timestamp, can_id, is_extended, is_remote, is_error, dlc, data = frame_data return Message( timestamp=timestamp, @@ -69,12 +72,12 @@ def _assemble_message(frame_data): data=data, ) - def __len__(self): + def __len__(self) -> int: # this might not run in constant time result = self._cursor.execute(f"SELECT COUNT(*) FROM {self.table_name}") return int(result.fetchone()[0]) - def read_all(self): + def read_all(self) -> Iterator[Message]: """Fetches all messages in the database. :rtype: Generator[can.Message] @@ -82,9 +85,8 @@ def read_all(self): result = self._cursor.execute(f"SELECT * FROM {self.table_name}").fetchall() return (SqliteReader._assemble_message(frame) for frame in result) - def stop(self): + def stop(self) -> None: """Closes the connection to the database.""" - super().stop() self._conn.close() @@ -152,11 +154,10 @@ def __init__( f"The append argument should not be used in " f"conjunction with the {self.__class__.__name__}." ) - super().__init__(file=None) + BufferedReader.__init__(self) self.table_name = table_name self._db_filename = file self._stop_running_event = threading.Event() - self._conn = None self._writer_thread = threading.Thread(target=self._db_writer_thread) self._writer_thread.start() self.num_frames = 0 @@ -165,7 +166,8 @@ def __init__( f"INSERT INTO {self.table_name} VALUES (?, ?, ?, ?, ?, ?, ?)" ) - def _create_db(self): + @staticmethod + def _create_db(file: StringPathLike, table_name: str) -> sqlite3.Connection: """Creates a new databae or opens a connection to an existing one. .. note:: @@ -173,11 +175,11 @@ def _create_db(self): hence we setup the db here. It has the upside of running async. """ log.debug("Creating sqlite database") - self._conn = sqlite3.connect(self._db_filename) + conn = sqlite3.connect(file) # create table structure - self._conn.cursor().execute( - f"""CREATE TABLE IF NOT EXISTS {self.table_name} + conn.cursor().execute( + f"""CREATE TABLE IF NOT EXISTS {table_name} ( ts REAL, arbitration_id INTEGER, @@ -188,14 +190,16 @@ def _create_db(self): data BLOB )""" ) - self._conn.commit() + conn.commit() + + return conn - def _db_writer_thread(self): - self._create_db() + def _db_writer_thread(self) -> None: + conn = SqliteWriter._create_db(self._db_filename, self.table_name) try: while True: - messages = [] # reset buffer + messages: list[_MessageTuple] = [] # reset buffer msg = self.get_message(self.GET_MESSAGE_TIMEOUT) while msg is not None: @@ -224,10 +228,10 @@ def _db_writer_thread(self): count = len(messages) if count > 0: - with self._conn: + with conn: # log.debug("Writing %d frames to db", count) - self._conn.executemany(self._insert_template, messages) - self._conn.commit() # make the changes visible to the entire database + conn.executemany(self._insert_template, messages) + conn.commit() # make the changes visible to the entire database self.num_frames += count self.last_write = time.time() @@ -236,14 +240,13 @@ def _db_writer_thread(self): break finally: - self._conn.close() + conn.close() log.info("Stopped sqlite writer after writing %d messages", self.num_frames) - def stop(self): + def stop(self) -> None: """Stops the reader an writes all remaining messages to the database. Thus, this might take a while and block. """ BufferedReader.stop(self) self._stop_running_event.set() self._writer_thread.join() - MessageReader.stop(self) diff --git a/can/io/trc.py b/can/io/trc.py index ec08d1af1..c02bdcfe9 100644 --- a/can/io/trc.py +++ b/can/io/trc.py @@ -5,20 +5,20 @@ for file format description Version 1.1 will be implemented as it is most commonly used -""" # noqa +""" -from datetime import datetime, timedelta -from enum import Enum -import io -import os import logging -from typing import Generator, Optional, Union, TextIO, Callable, List +import os +from collections.abc import Callable, Generator +from datetime import datetime, timedelta, timezone +from enum import Enum +from io import TextIOWrapper +from typing import Any, TextIO from ..message import Message -from ..util import channel2int -from .generic import FileIOMessageWriter, MessageReader from ..typechecking import StringPathLike - +from ..util import channel2int, len2dlc +from .generic import TextIOMessageReader, TextIOMessageWriter logger = logging.getLogger("can.io.trc") @@ -32,17 +32,21 @@ class TRCFileVersion(Enum): V2_0 = 200 V2_1 = 201 + def __ge__(self, other: Any) -> bool: + if isinstance(other, TRCFileVersion): + return self.value >= other.value + return NotImplemented -class TRCReader(MessageReader): + +class TRCReader(TextIOMessageReader): """ Iterator of CAN messages from a TRC logging file. """ - file: TextIO - def __init__( self, - file: Union[StringPathLike, TextIO], + file: StringPathLike | TextIO, + **kwargs: Any, ) -> None: """ :param file: a path-like object or as file-like object to read from @@ -51,50 +55,90 @@ def __init__( """ super().__init__(file, mode="r") self.file_version = TRCFileVersion.UNKNOWN + self._start_time: float = 0 + self.columns: dict[str, int] = {} + self._num_columns = -1 if not self.file: raise ValueError("The given file cannot be None") - self._parse_cols: Callable[[List[str]], Optional[Message]] = lambda x: None + self._parse_cols: Callable[[tuple[str, ...]], Message | None] = lambda x: None + + @property + def start_time(self) -> datetime | None: + if self._start_time: + return datetime.fromtimestamp(self._start_time, timezone.utc) + return None - def _extract_header(self): + def _extract_header(self) -> str: line = "" - for line in self.file: - line = line.strip() + for _line in self.file: + line = _line.strip() if line.startswith(";$FILEVERSION"): logger.debug("TRCReader: Found file version '%s'", line) try: file_version = line.split("=")[1] if file_version == "1.1": self.file_version = TRCFileVersion.V1_1 + elif file_version == "1.3": + self.file_version = TRCFileVersion.V1_3 + elif file_version == "2.0": + self.file_version = TRCFileVersion.V2_0 elif file_version == "2.1": self.file_version = TRCFileVersion.V2_1 else: self.file_version = TRCFileVersion.UNKNOWN except IndexError: logger.debug("TRCReader: Failed to parse version") + elif line.startswith(";$STARTTIME"): + logger.debug("TRCReader: Found start time '%s'", line) + try: + self._start_time = ( + datetime(1899, 12, 30, tzinfo=timezone.utc) + + timedelta(days=float(line.split("=")[1])) + ).timestamp() + except IndexError: + logger.debug("TRCReader: Failed to parse start time") + elif line.startswith(";$COLUMNS"): + logger.debug("TRCReader: Found columns '%s'", line) + try: + columns = line.split("=")[1].split(",") + self.columns = {column: columns.index(column) for column in columns} + self._num_columns = len(columns) - 1 + except IndexError: + logger.debug("TRCReader: Failed to parse columns") elif line.startswith(";"): continue else: break + if self.file_version >= TRCFileVersion.V1_1: + if self._start_time is None: + raise ValueError("File has no start time information") + + if self.file_version >= TRCFileVersion.V2_0: + if not self.columns: + raise ValueError("File has no column information") + if self.file_version == TRCFileVersion.UNKNOWN: logger.info( "TRCReader: No file version was found, so version 1.0 is assumed" ) - self._parse_cols = self._parse_msg_V1_0 + self._parse_cols = self._parse_msg_v1_0 elif self.file_version == TRCFileVersion.V1_0: - self._parse_cols = self._parse_msg_V1_0 + self._parse_cols = self._parse_msg_v1_0 elif self.file_version == TRCFileVersion.V1_1: - self._parse_cols = self._parse_cols_V1_1 - elif self.file_version == TRCFileVersion.V2_1: - self._parse_cols = self._parse_cols_V2_1 + self._parse_cols = self._parse_cols_v1_1 + elif self.file_version == TRCFileVersion.V1_3: + self._parse_cols = self._parse_cols_v1_3 + elif self.file_version in [TRCFileVersion.V2_0, TRCFileVersion.V2_1]: + self._parse_cols = self._parse_cols_v2_x else: raise NotImplementedError("File version not fully implemented for reading") return line - def _parse_msg_V1_0(self, cols: List[str]) -> Optional[Message]: + def _parse_msg_v1_0(self, cols: tuple[str, ...]) -> Message | None: arbit_id = cols[2] if arbit_id == "FFFFFFFF": logger.info("TRCReader: Dropping bus info line") @@ -106,53 +150,100 @@ def _parse_msg_V1_0(self, cols: List[str]) -> Optional[Message]: msg.is_extended_id = len(arbit_id) > 4 msg.channel = 1 msg.dlc = int(cols[3]) - msg.data = bytearray([int(cols[i + 4], 16) for i in range(msg.dlc)]) + if len(cols) > 4 and cols[4] == "RTR": + msg.is_remote_frame = True + else: + msg.data = bytearray([int(cols[i + 4], 16) for i in range(msg.dlc)]) return msg - def _parse_msg_V1_1(self, cols: List[str]) -> Optional[Message]: + def _parse_msg_v1_1(self, cols: tuple[str, ...]) -> Message | None: arbit_id = cols[3] msg = Message() - msg.timestamp = float(cols[1]) / 1000 + msg.timestamp = float(cols[1]) / 1000 + self._start_time msg.arbitration_id = int(arbit_id, 16) msg.is_extended_id = len(arbit_id) > 4 msg.channel = 1 msg.dlc = int(cols[4]) - msg.data = bytearray([int(cols[i + 5], 16) for i in range(msg.dlc)]) + if len(cols) > 5 and cols[5] == "RTR": + msg.is_remote_frame = True + else: + msg.data = bytearray([int(cols[i + 5], 16) for i in range(msg.dlc)]) msg.is_rx = cols[2] == "Rx" return msg - def _parse_msg_V2_1(self, cols: List[str]) -> Optional[Message]: + def _parse_msg_v1_3(self, cols: tuple[str, ...]) -> Message | None: + arbit_id = cols[4] + + msg = Message() + msg.timestamp = float(cols[1]) / 1000 + self._start_time + msg.arbitration_id = int(arbit_id, 16) + msg.is_extended_id = len(arbit_id) > 4 + msg.channel = int(cols[2]) + msg.dlc = int(cols[6]) + if len(cols) > 7 and cols[7] == "RTR": + msg.is_remote_frame = True + else: + msg.data = bytearray([int(cols[i + 7], 16) for i in range(msg.dlc)]) + msg.is_rx = cols[3] == "Rx" + return msg + + def _parse_msg_v2_x(self, cols: tuple[str, ...]) -> Message | None: + type_ = cols[self.columns["T"]] + bus = self.columns.get("B", None) + + if "l" in self.columns: + length = int(cols[self.columns["l"]]) + dlc = len2dlc(length) + elif "L" in self.columns: + dlc = int(cols[self.columns["L"]]) + else: + raise ValueError("No length/dlc columns present.") + msg = Message() - msg.timestamp = float(cols[1]) / 1000 - msg.arbitration_id = int(cols[4], 16) - msg.is_extended_id = len(cols[4]) > 4 - msg.channel = int(cols[3]) - msg.dlc = int(cols[7]) - msg.data = bytearray([int(cols[i + 8], 16) for i in range(msg.dlc)]) - msg.is_rx = cols[5] == "Rx" + msg.timestamp = float(cols[self.columns["O"]]) / 1000 + self._start_time + msg.arbitration_id = int(cols[self.columns["I"]], 16) + msg.is_extended_id = len(cols[self.columns["I"]]) > 4 + msg.channel = int(cols[bus]) if bus is not None else 1 + msg.dlc = dlc + msg.is_remote_frame = type_ in {"RR"} + if dlc and not msg.is_remote_frame: + msg.data = bytearray.fromhex(cols[self.columns["D"]]) + msg.is_rx = cols[self.columns["d"]] == "Rx" + msg.is_fd = type_ in {"FD", "FB", "FE", "BI"} + msg.bitrate_switch = type_ in {"FB", "FE"} + msg.error_state_indicator = type_ in {"FE", "BI"} + return msg - def _parse_cols_V1_1(self, cols: List[str]) -> Optional[Message]: + def _parse_cols_v1_1(self, cols: tuple[str, ...]) -> Message | None: dtype = cols[2] if dtype in ("Tx", "Rx"): - return self._parse_msg_V1_1(cols) + return self._parse_msg_v1_1(cols) else: logger.info("TRCReader: Unsupported type '%s'", dtype) return None - def _parse_cols_V2_1(self, cols: List[str]) -> Optional[Message]: - dtype = cols[2] - if dtype == "DT": - return self._parse_msg_V2_1(cols) + def _parse_cols_v1_3(self, cols: tuple[str, ...]) -> Message | None: + dtype = cols[3] + if dtype in ("Tx", "Rx"): + return self._parse_msg_v1_3(cols) + else: + logger.info("TRCReader: Unsupported type '%s'", dtype) + return None + + def _parse_cols_v2_x(self, cols: tuple[str, ...]) -> Message | None: + dtype = cols[self.columns["T"]] + if dtype in {"DT", "FD", "FB", "FE", "BI", "RR"}: + return self._parse_msg_v2_x(cols) else: logger.info("TRCReader: Unsupported type '%s'", dtype) return None - def _parse_line(self, line: str) -> Optional[Message]: + def _parse_line(self, line: str) -> Message | None: logger.debug("TRCReader: Parse '%s'", line) try: - cols = line.split() + cols = tuple(line.split(maxsplit=self._num_columns)) return self._parse_cols(cols) except IndexError: logger.warning("TRCReader: Failed to parse message '%s'", line) @@ -183,7 +274,7 @@ def __iter__(self) -> Generator[Message, None, None]: self.stop() -class TRCWriter(FileIOMessageWriter): +class TRCWriter(TextIOMessageWriter): """Logs CAN data to text file (.trc). The measurement starts with the timestamp of the first registered message. @@ -192,9 +283,6 @@ class TRCWriter(FileIOMessageWriter): If the first message does not have a timestamp, it is set to zero. """ - file: TextIO - first_timestamp: Optional[float] - FORMAT_MESSAGE = ( "{msgnr:>7} {time:13.3f} DT {channel:>2} {id:>8} {dir:>2} - {dlc:<4} {data}" ) @@ -202,8 +290,9 @@ class TRCWriter(FileIOMessageWriter): def __init__( self, - file: Union[StringPathLike, TextIO], + file: StringPathLike | TextIO | TextIOWrapper, channel: int = 1, + **kwargs: Any, ) -> None: """ :param file: a path-like object or as file-like object to write to @@ -215,7 +304,7 @@ def __init__( super().__init__(file, mode="w") self.channel = channel - if isinstance(self.file, io.TextIOWrapper): + if hasattr(self.file, "reconfigure"): self.file.reconfigure(newline="\r\n") else: raise TypeError("File must be opened in text mode.") @@ -223,12 +312,12 @@ def __init__( self.filepath = os.path.abspath(self.file.name) self.header_written = False self.msgnr = 0 - self.first_timestamp = None + self.first_timestamp: float | None = None self.file_version = TRCFileVersion.V2_1 self._msg_fmt_string = self.FORMAT_MESSAGE_V1_0 self._format_message = self._format_message_init - def _write_header_V1_0(self, start_time: timedelta) -> None: + def _write_header_v1_0(self, start_time: datetime) -> None: lines = [ ";##########################################################################", f"; {self.filepath}", @@ -249,13 +338,13 @@ def _write_header_V1_0(self, start_time: timedelta) -> None: ] self.file.writelines(line + "\n" for line in lines) - def _write_header_V2_1(self, header_time: timedelta, start_time: datetime) -> None: - milliseconds = int( - (header_time.seconds * 1000) + (header_time.microseconds / 1000) + def _write_header_v2_1(self, start_time: datetime) -> None: + header_time = start_time - datetime( + year=1899, month=12, day=30, tzinfo=timezone.utc ) lines = [ ";$FILEVERSION=2.1", - f";$STARTTIME={header_time.days}.{milliseconds}", + f";$STARTTIME={header_time/timedelta(days=1)}", ";$COLUMNS=N,O,T,B,I,d,R,L,D", ";", f"; {self.filepath}", @@ -275,7 +364,7 @@ def _write_header_V2_1(self, header_time: timedelta, start_time: datetime) -> No ] self.file.writelines(line + "\n" for line in lines) - def _format_message_by_format(self, msg, channel): + def _format_message_by_format(self, msg: Message, channel: int) -> str: if msg.is_extended_id: arb_id = f"{msg.arbitration_id:07X}" else: @@ -283,6 +372,8 @@ def _format_message_by_format(self, msg, channel): data = [f"{byte:02X}" for byte in msg.data] + if self.first_timestamp is None: + raise ValueError serialized = self._msg_fmt_string.format( msgnr=self.msgnr, time=(msg.timestamp - self.first_timestamp) * 1000, @@ -294,7 +385,7 @@ def _format_message_by_format(self, msg, channel): ) return serialized - def _format_message_init(self, msg, channel): + def _format_message_init(self, msg: Message, channel: int) -> str: if self.file_version == TRCFileVersion.V1_0: self._format_message = self._format_message_by_format self._msg_fmt_string = self.FORMAT_MESSAGE_V1_0 @@ -308,14 +399,12 @@ def _format_message_init(self, msg, channel): def write_header(self, timestamp: float) -> None: # write start of file header - ref_time = datetime(year=1899, month=12, day=30) - start_time = datetime.now() + timedelta(seconds=timestamp) - header_time = start_time - ref_time + start_time = datetime.fromtimestamp(timestamp, timezone.utc) if self.file_version == TRCFileVersion.V1_0: - self._write_header_V1_0(header_time) + self._write_header_v1_0(start_time) elif self.file_version == TRCFileVersion.V2_1: - self._write_header_V2_1(header_time, start_time) + self._write_header_v2_1(start_time) else: raise NotImplementedError("File format is not supported") self.header_written = True @@ -348,7 +437,7 @@ def on_message_received(self, msg: Message) -> None: if msg.is_fd: logger.warning("TRCWriter: Logging CAN FD is not implemented") return - else: - serialized = self._format_message(msg, channel) - self.msgnr += 1 + + serialized = self._format_message(msg, channel) + self.msgnr += 1 self.log_event(serialized, msg.timestamp) diff --git a/can/listener.py b/can/listener.py index e68d813d1..1e289bea6 100644 --- a/can/listener.py +++ b/can/listener.py @@ -2,18 +2,18 @@ This module contains the implementation of `can.Listener` and some readers. """ -import sys -import warnings import asyncio -from abc import ABCMeta, abstractmethod -from queue import SimpleQueue, Empty -from typing import Any, AsyncIterator, Optional +import warnings +from abc import ABC, abstractmethod +from collections.abc import AsyncIterator +from queue import Empty, SimpleQueue +from typing import Any -from can.message import Message from can.bus import BusABC +from can.message import Message -class Listener(metaclass=ABCMeta): +class Listener(ABC): """The basic listener that can be called directly to handle some CAN message:: @@ -29,9 +29,6 @@ class Listener(metaclass=ABCMeta): listener.stop() """ - def __init__(self, *args: Any, **kwargs: Any) -> None: - pass - @abstractmethod def on_message_received(self, msg: Message) -> None: """This method is called to handle the given message. @@ -49,7 +46,7 @@ def on_error(self, exc: Exception) -> None: """ raise NotImplementedError() - def stop(self) -> None: + def stop(self) -> None: # noqa: B027 """ Stop handling new messages, carry out any final tasks to ensure data is persisted and cleanup any open resources. @@ -85,9 +82,7 @@ class BufferedReader(Listener): # pylint: disable=abstract-method :attr is_stopped: ``True`` if the reader has been stopped """ - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - + def __init__(self) -> None: # set to "infinite" size self.buffer: SimpleQueue[Message] = SimpleQueue() self.is_stopped: bool = False @@ -103,7 +98,7 @@ def on_message_received(self, msg: Message) -> None: else: self.buffer.put(msg) - def get_message(self, timeout: float = 0.5) -> Optional[Message]: + def get_message(self, timeout: float = 0.5) -> Message | None: """ Attempts to retrieve the message that has been in the queue for the longest amount of time (FIFO). If no message is available, it blocks for given timeout or until a @@ -139,21 +134,17 @@ class AsyncBufferedReader( print(msg) """ - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - - self.buffer: "asyncio.Queue[Message]" + def __init__(self, **kwargs: Any) -> None: + self._is_stopped: bool = False + self.buffer: asyncio.Queue[Message] if "loop" in kwargs: warnings.warn( "The 'loop' argument is deprecated since python-can 4.0.0 " "and has no effect starting with Python 3.10", DeprecationWarning, + stacklevel=2, ) - if sys.version_info < (3, 10): - self.buffer = asyncio.Queue(loop=kwargs["loop"]) - return - self.buffer = asyncio.Queue() def on_message_received(self, msg: Message) -> None: @@ -161,7 +152,8 @@ def on_message_received(self, msg: Message) -> None: Must only be called inside an event loop! """ - self.buffer.put_nowait(msg) + if not self._is_stopped: + self.buffer.put_nowait(msg) async def get_message(self) -> Message: """ @@ -178,3 +170,6 @@ def __aiter__(self) -> AsyncIterator[Message]: async def __anext__(self) -> Message: return await self.buffer.get() + + def stop(self) -> None: + self._is_stopped = True diff --git a/can/logconvert.py b/can/logconvert.py index d89155758..4527dc23e 100644 --- a/can/logconvert.py +++ b/can/logconvert.py @@ -2,20 +2,24 @@ Convert a log file from one format to another. """ -import sys import argparse import errno +import sys +from typing import TYPE_CHECKING, NoReturn -from can import LogReader, Logger, SizedRotatingLogger +from can import Logger, LogReader, SizedRotatingLogger + +if TYPE_CHECKING: + from can.io.generic import MessageWriter class ArgumentParser(argparse.ArgumentParser): - def error(self, message): + def error(self, message: str) -> NoReturn: self.print_help(sys.stderr) self.exit(errno.EINVAL, f"{self.prog}: error: {message}\n") -def main(): +def main() -> None: parser = ArgumentParser( description="Convert a log file from one format to another.", ) @@ -46,9 +50,8 @@ def main(): args = parser.parse_args() with LogReader(args.input) as reader: - if args.file_size: - logger = SizedRotatingLogger( + logger: MessageWriter = SizedRotatingLogger( base_filename=args.output, max_bytes=args.file_size ) else: diff --git a/can/logger.py b/can/logger.py index 9448fe6b4..537356643 100644 --- a/can/logger.py +++ b/can/logger.py @@ -1,153 +1,39 @@ -import re -import sys import argparse -from datetime import datetime import errno -from typing import Any, Dict, List, Union, Sequence, Tuple - -import can -from can.io import BaseRotatingLogger -from can.io.generic import MessageWriter -from can.util import cast_from_string -from . import Bus, BusState, Logger, SizedRotatingLogger -from .typechecking import CanFilter, CanFilters - - -def _create_base_argument_parser(parser: argparse.ArgumentParser) -> None: - """Adds common options to an argument parser.""" - - parser.add_argument( - "-c", - "--channel", - help=r"Most backend interfaces require some sort of channel. For " - r"example with the serial interface the channel might be a rfcomm" - r' device: "/dev/rfcomm0". With the socketcan interface valid ' - r'channel examples include: "can0", "vcan0".', - ) - - parser.add_argument( - "-i", - "--interface", - dest="interface", - help="""Specify the backend CAN interface to use. If left blank, - fall back to reading from configuration files.""", - choices=sorted(can.VALID_INTERFACES), - ) - - parser.add_argument( - "-b", "--bitrate", type=int, help="Bitrate to use for the CAN bus." - ) - - parser.add_argument("--fd", help="Activate CAN-FD support", action="store_true") - - parser.add_argument( - "--data_bitrate", - type=int, - help="Bitrate to use for the data phase in case of CAN-FD.", - ) - - parser.add_argument( - "extra_args", - nargs=argparse.REMAINDER, - help="The remaining arguments will be used for the interface and " - "logger/player initialisation. " - "For example, `-i vector -c 1 --app-name=MyCanApp` is the equivalent " - "to opening the bus with `Bus('vector', channel=1, app_name='MyCanApp')", - ) - - -def _append_filter_argument( - parser: Union[ - argparse.ArgumentParser, - argparse._ArgumentGroup, - ], - *args: str, - **kwargs: Any, -) -> None: - """Adds the ``filter`` option to an argument parser.""" - - parser.add_argument( - *args, - "--filter", - help="R|Space separated CAN filters for the given CAN interface:" - "\n : (matches when & mask ==" - " can_id & mask)" - "\n ~ (matches when & mask !=" - " can_id & mask)" - "\nFx to show only frames with ID 0x100 to 0x103 and 0x200 to 0x20F:" - "\n python -m can.viewer -f 100:7FC 200:7F0" - "\nNote that the ID and mask are always interpreted as hex values", - metavar="{:,~}", - nargs=argparse.ONE_OR_MORE, - default="", - **kwargs, - ) - - -def _create_bus(parsed_args: Any, **kwargs: Any) -> can.Bus: - logging_level_names = ["critical", "error", "warning", "info", "debug", "subdebug"] - can.set_logging_level(logging_level_names[min(5, parsed_args.verbosity)]) - - config: Dict[str, Any] = {"single_handle": True, **kwargs} - if parsed_args.interface: - config["interface"] = parsed_args.interface - if parsed_args.bitrate: - config["bitrate"] = parsed_args.bitrate - if parsed_args.fd: - config["fd"] = True - if parsed_args.data_bitrate: - config["data_bitrate"] = parsed_args.data_bitrate - - return Bus(parsed_args.channel, **config) # type: ignore - - -def _parse_filters(parsed_args: Any) -> CanFilters: - can_filters: List[CanFilter] = [] - - if parsed_args.filter: - print(f"Adding filter(s): {parsed_args.filter}") - for filt in parsed_args.filter: - if ":" in filt: - parts = filt.split(":") - can_id = int(parts[0], base=16) - can_mask = int(parts[1], base=16) - elif "~" in filt: - parts = filt.split("~") - can_id = int(parts[0], base=16) | 0x20000000 # CAN_INV_FILTER - can_mask = int(parts[1], base=16) & 0x20000000 # socket.CAN_ERR_FLAG - else: - raise argparse.ArgumentError(None, "Invalid filter argument") - can_filters.append({"can_id": can_id, "can_mask": can_mask}) - - return can_filters - - -def _parse_additional_config( - unknown_args: Sequence[str], -) -> Dict[str, Union[str, int, float, bool]]: - for arg in unknown_args: - if not re.match(r"^--[a-zA-Z\-]*?=\S*?$", arg): - raise ValueError(f"Parsing argument {arg} failed") - - def _split_arg(_arg: str) -> Tuple[str, str]: - left, right = _arg.split("=", 1) - return left.lstrip("--").replace("-", "_"), right - - args: Dict[str, Union[str, int, float, bool]] = {} - for key, string_val in map(_split_arg, unknown_args): - args[key] = cast_from_string(string_val) - return args - +import sys +from datetime import datetime +from typing import ( + TYPE_CHECKING, +) + +from can import BusState, Logger, SizedRotatingLogger +from can.cli import ( + _add_extra_args, + _parse_additional_config, + _set_logging_level_from_namespace, + add_bus_arguments, + create_bus_from_namespace, +) +from can.typechecking import TAdditionalCliArgs + +if TYPE_CHECKING: + from can.io import BaseRotatingLogger + from can.io.generic import MessageWriter + + +def _parse_logger_args( + args: list[str], +) -> tuple[argparse.Namespace, TAdditionalCliArgs]: + """Parse command line arguments for logger script.""" -def main() -> None: parser = argparse.ArgumentParser( description="Log CAN traffic, printing messages to stdout or to a " "given file.", ) - _create_base_argument_parser(parser) + logger_group = parser.add_argument_group("logger arguments") - parser.add_argument( + logger_group.add_argument( "-f", "--file_name", dest="log_file", @@ -155,7 +41,7 @@ def main() -> None: default=None, ) - parser.add_argument( + logger_group.add_argument( "-a", "--append", dest="append", @@ -163,7 +49,7 @@ def main() -> None: action="store_true", ) - parser.add_argument( + logger_group.add_argument( "-s", "--file_size", dest="file_size", @@ -175,7 +61,7 @@ def main() -> None: default=None, ) - parser.add_argument( + logger_group.add_argument( "-v", action="count", dest="verbosity", @@ -184,9 +70,7 @@ def main() -> None: default=2, ) - _append_filter_argument(parser) - - state_group = parser.add_mutually_exclusive_group(required=False) + state_group = logger_group.add_mutually_exclusive_group(required=False) state_group.add_argument( "--active", help="Start the bus as active, this is applied by default.", @@ -196,14 +80,26 @@ def main() -> None: "--passive", help="Start the bus as passive.", action="store_true" ) + # handle remaining arguments + _add_extra_args(logger_group) + + # add bus options + add_bus_arguments(parser, filter_arg=True) + # print help message when no arguments were given - if len(sys.argv) < 2: + if not args: parser.print_help(sys.stderr) raise SystemExit(errno.EINVAL) - results, unknown_args = parser.parse_known_args() + results, unknown_args = parser.parse_known_args(args) additional_config = _parse_additional_config([*results.extra_args, *unknown_args]) - bus = _create_bus(results, can_filters=_parse_filters(results), **additional_config) + return results, additional_config + + +def main() -> None: + results, additional_config = _parse_logger_args(sys.argv[1:]) + bus = create_bus_from_namespace(results) + _set_logging_level_from_namespace(results) if results.active: bus.state = BusState.ACTIVE @@ -213,7 +109,7 @@ def main() -> None: print(f"Connected to {bus.__class__.__name__}: {bus.channel_info}") print(f"Can Logger (Started on {datetime.now()})") - logger: Union[MessageWriter, BaseRotatingLogger] + logger: MessageWriter | BaseRotatingLogger if results.file_size: logger = SizedRotatingLogger( base_filename=results.log_file, diff --git a/can/message.py b/can/message.py index 48933b2da..3e60ca641 100644 --- a/can/message.py +++ b/can/message.py @@ -6,12 +6,11 @@ starting with Python 3.7. """ -from typing import Optional - -from . import typechecking - from copy import deepcopy from math import isinf, isnan +from typing import Any + +from . import typechecking class Message: # pylint: disable=too-many-instance-attributes; OK for a dataclass @@ -33,19 +32,19 @@ class Message: # pylint: disable=too-many-instance-attributes; OK for a datacla """ __slots__ = ( - "timestamp", + "__weakref__", # support weak references to messages "arbitration_id", - "is_extended_id", - "is_remote_frame", - "is_error_frame", + "bitrate_switch", "channel", - "dlc", "data", + "dlc", + "error_state_indicator", + "is_error_frame", + "is_extended_id", "is_fd", + "is_remote_frame", "is_rx", - "bitrate_switch", - "error_state_indicator", - "__weakref__", # support weak references to messages + "timestamp", ) def __init__( # pylint: disable=too-many-locals, too-many-arguments @@ -55,9 +54,9 @@ def __init__( # pylint: disable=too-many-locals, too-many-arguments is_extended_id: bool = True, is_remote_frame: bool = False, is_error_frame: bool = False, - channel: Optional[typechecking.Channel] = None, - dlc: Optional[int] = None, - data: Optional[typechecking.CanData] = None, + channel: typechecking.Channel | None = None, + dlc: int | None = None, + data: typechecking.CanData | None = None, is_fd: bool = False, is_rx: bool = True, bitrate_switch: bool = False, @@ -111,10 +110,10 @@ def __init__( # pylint: disable=too-many-locals, too-many-arguments def __str__(self) -> str: field_strings = [f"Timestamp: {self.timestamp:>15.6f}"] if self.is_extended_id: - arbitration_id_string = f"ID: {self.arbitration_id:08x}" + arbitration_id_string = f"{self.arbitration_id:08x}" else: - arbitration_id_string = f"ID: {self.arbitration_id:04x}" - field_strings.append(arbitration_id_string.rjust(12, " ")) + arbitration_id_string = f"{self.arbitration_id:03x}" + field_strings.append(f"ID: {arbitration_id_string:>8}") flag_string = " ".join( [ @@ -131,12 +130,11 @@ def __str__(self) -> str: field_strings.append(flag_string) field_strings.append(f"DL: {self.dlc:2d}") - data_strings = [] + data_strings = "" if self.data is not None: - for index in range(0, min(self.dlc, len(self.data))): - data_strings.append(f"{self.data[index]:02x}") + data_strings = self.data[: min(self.dlc, len(self.data))].hex(" ") if data_strings: # if not empty - field_strings.append(" ".join(data_strings).ljust(24, " ")) + field_strings.append(data_strings.ljust(24, " ")) else: field_strings.append(" " * 24) @@ -187,7 +185,7 @@ def __repr__(self) -> str: return f"can.Message({', '.join(args)})" - def __format__(self, format_spec: Optional[str]) -> str: + def __format__(self, format_spec: str | None) -> str: if not format_spec: return self.__str__() else: @@ -212,7 +210,7 @@ def __copy__(self) -> "Message": error_state_indicator=self.error_state_indicator, ) - def __deepcopy__(self, memo: dict) -> "Message": + def __deepcopy__(self, memo: dict[int, Any] | None) -> "Message": return Message( timestamp=self.timestamp, arbitration_id=self.arbitration_id, @@ -246,7 +244,7 @@ def _check(self) -> None: if self.is_remote_frame: if self.is_error_frame: raise ValueError( - "a message cannot be a remote and an error frame at the sane time" + "a message cannot be a remote and an error frame at the same time" ) if self.is_fd: raise ValueError("CAN FD does not support remote frames") @@ -291,7 +289,8 @@ def _check(self) -> None: def equals( self, other: "Message", - timestamp_delta: Optional[float] = 1.0e-6, + timestamp_delta: float | None = 1.0e-6, + check_channel: bool = True, check_direction: bool = True, ) -> bool: """ @@ -300,6 +299,7 @@ def equals( :param other: the message to compare with :param timestamp_delta: the maximum difference in seconds at which two timestamps are still considered equal or `None` to not compare timestamps + :param check_channel: whether to compare the message channel :param check_direction: whether to compare the messages' directions (Tx/Rx) :return: True if and only if the given message equals this one @@ -323,7 +323,7 @@ def equals( and self.data == other.data and self.is_remote_frame == other.is_remote_frame and self.is_error_frame == other.is_error_frame - and self.channel == other.channel + and (self.channel == other.channel or not check_channel) and self.is_fd == other.is_fd and self.bitrate_switch == other.bitrate_switch and self.error_state_indicator == other.error_state_indicator diff --git a/can/notifier.py b/can/notifier.py index 2adae431e..cb91cf7b4 100644 --- a/can/notifier.py +++ b/can/notifier.py @@ -3,10 +3,18 @@ """ import asyncio +import functools import logging import threading import time -from typing import Callable, Iterable, List, Optional, Union, Awaitable +from collections.abc import Awaitable, Callable, Iterable +from contextlib import AbstractContextManager +from types import TracebackType +from typing import ( + Any, + Final, + NamedTuple, +) from can.bus import BusABC from can.listener import Listener @@ -14,16 +22,94 @@ logger = logging.getLogger("can.Notifier") -MessageRecipient = Union[Listener, Callable[[Message], Union[Awaitable[None], None]]] +MessageRecipient = Listener | Callable[[Message], Awaitable[None] | None] -class Notifier: +class _BusNotifierPair(NamedTuple): + bus: "BusABC" + notifier: "Notifier" + + +class _NotifierRegistry: + """A registry to manage the association between CAN buses and Notifiers. + + This class ensures that a bus is not added to multiple active Notifiers. + """ + + def __init__(self) -> None: + """Initialize the registry with an empty list of bus-notifier pairs and a threading lock.""" + self.pairs: list[_BusNotifierPair] = [] + self.lock = threading.Lock() + + def register(self, bus: BusABC, notifier: "Notifier") -> None: + """Register a bus and its associated notifier. + + Ensures that a bus is not added to multiple active :class:`~can.Notifier` instances. + + :param bus: + The CAN bus to register. + :param notifier: + The :class:`~can.Notifier` instance associated with the bus. + :raises ValueError: + If the bus is already assigned to an active Notifier. + """ + with self.lock: + for pair in self.pairs: + if bus is pair.bus and not pair.notifier.stopped: + raise ValueError( + "A bus can not be added to multiple active Notifier instances." + ) + self.pairs.append(_BusNotifierPair(bus, notifier)) + + def unregister(self, bus: BusABC, notifier: "Notifier") -> None: + """Unregister a bus and its associated notifier. + + Removes the bus-notifier pair from the registry. + + :param bus: + The CAN bus to unregister. + :param notifier: + The :class:`~can.Notifier` instance associated with the bus. + """ + with self.lock: + registered_pairs_to_remove: list[_BusNotifierPair] = [] + for pair in self.pairs: + if pair.bus is bus and pair.notifier is notifier: + registered_pairs_to_remove.append(pair) + for pair in registered_pairs_to_remove: + self.pairs.remove(pair) + + def find_instances(self, bus: BusABC) -> tuple["Notifier", ...]: + """Find the :class:`~can.Notifier` instances associated with a given CAN bus. + + This method searches the registry for the :class:`~can.Notifier` + that is linked to the specified bus. If the bus is found, the + corresponding :class:`~can.Notifier` instances are returned. If the bus is not + found in the registry, an empty tuple is returned. + + :param bus: + The CAN bus for which to find the associated :class:`~can.Notifier` . + :return: + A tuple of :class:`~can.Notifier` instances associated with the given bus. + """ + instance_list = [] + with self.lock: + for pair in self.pairs: + if bus is pair.bus: + instance_list.append(pair.notifier) + return tuple(instance_list) + + +class Notifier(AbstractContextManager["Notifier"]): + + _registry: Final = _NotifierRegistry() + def __init__( self, - bus: Union[BusABC, List[BusABC]], + bus: BusABC | list[BusABC], listeners: Iterable[MessageRecipient], timeout: float = 1.0, - loop: Optional[asyncio.AbstractEventLoop] = None, + loop: asyncio.AbstractEventLoop | None = None, ) -> None: """Manages the distribution of :class:`~can.Message` instances to listeners. @@ -31,61 +117,82 @@ def __init__( .. Note:: - Remember to call `stop()` after all messages are received as + Remember to call :meth:`~can.Notifier.stop` after all messages are received as many listeners carry out flush operations to persist data. - :param bus: A :ref:`bus` or a list of buses to listen to. + :param bus: + A :ref:`bus` or a list of buses to consume messages from. :param listeners: An iterable of :class:`~can.Listener` or callables that receive a :class:`~can.Message` and return nothing. - :param timeout: An optional maximum number of seconds to wait for any :class:`~can.Message`. - :param loop: An :mod:`asyncio` event loop to schedule the ``listeners`` in. + :param timeout: + An optional maximum number of seconds to wait for any :class:`~can.Message`. + :param loop: + An :mod:`asyncio` event loop to schedule the ``listeners`` in. + :raises ValueError: + If a passed in *bus* is already assigned to an active :class:`~can.Notifier`. """ - self.listeners: List[MessageRecipient] = list(listeners) - self.bus = bus + self.listeners: list[MessageRecipient] = list(listeners) + self._bus_list: list[BusABC] = [] self.timeout = timeout self._loop = loop #: Exception raised in thread - self.exception: Optional[Exception] = None + self.exception: Exception | None = None - self._running = True + self._stopped = False self._lock = threading.Lock() - self._readers: List[Union[int, threading.Thread]] = [] - buses = self.bus if isinstance(self.bus, list) else [self.bus] - for each_bus in buses: + self._readers: list[int | threading.Thread] = [] + self._tasks: set[asyncio.Task] = set() + _bus_list: list[BusABC] = bus if isinstance(bus, list) else [bus] + for each_bus in _bus_list: self.add_bus(each_bus) + @property + def bus(self) -> BusABC | tuple["BusABC", ...]: + """Return the associated bus or a tuple of buses.""" + if len(self._bus_list) == 1: + return self._bus_list[0] + return tuple(self._bus_list) + def add_bus(self, bus: BusABC) -> None: """Add a bus for notification. :param bus: CAN bus instance. + :raises ValueError: + If the *bus* is already assigned to an active :class:`~can.Notifier`. """ - reader: int = -1 + # add bus to notifier registry + Notifier._registry.register(bus, self) + + # add bus to internal bus list + self._bus_list.append(bus) + + file_descriptor: int = -1 try: - reader = bus.fileno() + file_descriptor = bus.fileno() except NotImplementedError: # Bus doesn't support fileno, we fall back to thread based reader pass - if self._loop is not None and reader >= 0: + if self._loop is not None and file_descriptor >= 0: # Use bus file descriptor to watch for messages - self._loop.add_reader(reader, self._on_message_available, bus) - self._readers.append(reader) + self._loop.add_reader(file_descriptor, self._on_message_available, bus) + self._readers.append(file_descriptor) else: reader_thread = threading.Thread( target=self._rx_thread, args=(bus,), - name=f'can.notifier for bus "{bus.channel_info}"', + name=f'{self.__class__.__qualname__} for bus "{bus.channel_info}"', ) reader_thread.daemon = True reader_thread.start() self._readers.append(reader_thread) - def stop(self, timeout: float = 5) -> None: + def stop(self, timeout: float = 5.0) -> None: """Stop notifying Listeners when new :class:`~can.Message` objects arrive and call :meth:`~can.Listener.stop` on each Listener. @@ -93,7 +200,7 @@ def stop(self, timeout: float = 5) -> None: Max time in seconds to wait for receive threads to finish. Should be longer than timeout given at instantiation. """ - self._running = False + self._stopped = True end_time = time.time() + timeout for reader in self._readers: if isinstance(reader, threading.Thread): @@ -104,46 +211,53 @@ def stop(self, timeout: float = 5) -> None: # reader is a file descriptor self._loop.remove_reader(reader) for listener in self.listeners: - # Mypy prefers this over a hasattr(...) check - getattr(listener, "stop", lambda: None)() + if hasattr(listener, "stop"): + listener.stop() + + # remove bus from registry + for bus in self._bus_list: + Notifier._registry.unregister(bus, self) def _rx_thread(self, bus: BusABC) -> None: - msg = None - try: - while self._running: - if msg is not None: + # determine message handling callable early, not inside while loop + if self._loop: + handle_message: Callable[[Message], Any] = functools.partial( + self._loop.call_soon_threadsafe, + self._on_message_received, # type: ignore[arg-type] + ) + else: + handle_message = self._on_message_received + + while not self._stopped: + try: + if msg := bus.recv(self.timeout): with self._lock: - if self._loop is not None: - self._loop.call_soon_threadsafe( - self._on_message_received, msg - ) - else: - self._on_message_received(msg) - msg = bus.recv(self.timeout) - except Exception as exc: # pylint: disable=broad-except - self.exception = exc - if self._loop is not None: - self._loop.call_soon_threadsafe(self._on_error, exc) - # Raise anyways - raise - elif not self._on_error(exc): - # If it was not handled, raise the exception here - raise - else: - # It was handled, so only log it - logger.info("suppressed exception: %s", exc) + handle_message(msg) + except Exception as exc: # pylint: disable=broad-except + self.exception = exc + if self._loop is not None: + self._loop.call_soon_threadsafe(self._on_error, exc) + # Raise anyway + raise + elif not self._on_error(exc): + # If it was not handled, raise the exception here + raise + else: + # It was handled, so only log it + logger.debug("suppressed exception: %s", exc) def _on_message_available(self, bus: BusABC) -> None: - msg = bus.recv(0) - if msg is not None: + if msg := bus.recv(0): self._on_message_received(msg) def _on_message_received(self, msg: Message) -> None: for callback in self.listeners: res = callback(msg) - if res is not None and self._loop is not None and asyncio.iscoroutine(res): - # Schedule coroutine - self._loop.create_task(res) + if res and self._loop and asyncio.iscoroutine(res): + # Schedule coroutine and keep a reference to the task + task = self._loop.create_task(res) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) def _on_error(self, exc: Exception) -> bool: """Calls ``on_error()`` for all listeners if they implement it. @@ -153,12 +267,9 @@ def _on_error(self, exc: Exception) -> bool: was_handled = False for listener in self.listeners: - on_error = getattr( - listener, "on_error", None - ) # Mypy prefers this over hasattr(...) - if on_error is not None: + if hasattr(listener, "on_error"): try: - on_error(exc) + listener.on_error(exc) except NotImplementedError: pass else: @@ -184,3 +295,33 @@ def remove_listener(self, listener: MessageRecipient) -> None: :raises ValueError: if `listener` was never added to this notifier """ self.listeners.remove(listener) + + @property + def stopped(self) -> bool: + """Return ``True``, if Notifier was properly shut down with :meth:`~can.Notifier.stop`.""" + return self._stopped + + @staticmethod + def find_instances(bus: BusABC) -> tuple["Notifier", ...]: + """Find :class:`~can.Notifier` instances associated with a given CAN bus. + + This method searches the registry for the :class:`~can.Notifier` + that is linked to the specified bus. If the bus is found, the + corresponding :class:`~can.Notifier` instances are returned. If the bus is not + found in the registry, an empty tuple is returned. + + :param bus: + The CAN bus for which to find the associated :class:`~can.Notifier` . + :return: + A tuple of :class:`~can.Notifier` instances associated with the given bus. + """ + return Notifier._registry.find_instances(bus) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + if not self._stopped: + self.stop() diff --git a/can/player.py b/can/player.py index c029981be..6190a58d8 100644 --- a/can/player.py +++ b/can/player.py @@ -5,31 +5,64 @@ Similar to canplayer in the can-utils package. """ -import sys import argparse -from datetime import datetime import errno -from typing import cast, Iterable - -from can import LogReader, Message, MessageSync - -from .logger import _create_base_argument_parser, _create_bus, _parse_additional_config +import math +import sys +from datetime import datetime +from typing import TYPE_CHECKING, cast + +from can import LogReader, MessageSync +from can.cli import ( + _add_extra_args, + _parse_additional_config, + _set_logging_level_from_namespace, + add_bus_arguments, + create_bus_from_namespace, +) + +if TYPE_CHECKING: + from collections.abc import Iterable + + from can import Message + + +def _parse_loop(value: str) -> int | float: + """Parse the loop argument, allowing integer or 'i' for infinite.""" + if value == "i": + return float("inf") + try: + return int(value) + except ValueError as exc: + err_msg = "Loop count must be an integer or 'i' for infinite." + raise argparse.ArgumentTypeError(err_msg) from exc + + +def _format_player_start_message(iteration: int, loop_count: int | float) -> str: + """ + Generate a status message indicating the start of a CAN log replay iteration. + + :param iteration: + The current loop iteration (zero-based). + :param loop_count: + Total number of replay loops, or infinity for endless replay. + :return: + A formatted string describing the replay start and loop information. + """ + if loop_count < 2: + loop_info = "" + else: + loop_val = "∞" if math.isinf(loop_count) else str(loop_count) + loop_info = f" [loop {iteration + 1}/{loop_val}]" + return f"Can LogReader (Started on {datetime.now()}){loop_info}" def main() -> None: parser = argparse.ArgumentParser(description="Replay CAN traffic.") - _create_base_argument_parser(parser) + player_group = parser.add_argument_group("Player arguments") - parser.add_argument( - "-f", - "--file_name", - dest="log_file", - help="Path and base log filename, for supported types see can.LogReader.", - default=None, - ) - - parser.add_argument( + player_group.add_argument( "-v", action="count", dest="verbosity", @@ -38,41 +71,58 @@ def main() -> None: default=2, ) - parser.add_argument( + player_group.add_argument( "--ignore-timestamps", dest="timestamps", help="""Ignore timestamps (send all frames immediately with minimum gap between frames)""", action="store_false", ) - parser.add_argument( + player_group.add_argument( "--error-frames", help="Also send error frames to the interface.", action="store_true", ) - parser.add_argument( + player_group.add_argument( "-g", "--gap", type=float, help=" minimum time between replayed frames", default=0.0001, ) - parser.add_argument( + player_group.add_argument( "-s", "--skip", type=float, default=60 * 60 * 24, - help=" skip gaps greater than 's' seconds", + help="Skip gaps greater than 's' seconds between messages. " + "Default is 86400 (24 hours), meaning only very large gaps are skipped. " + "Set to 0 to never skip any gaps (all delays are preserved). " + "Set to a very small value (e.g., 1e-4) " + "to skip all gaps and send messages as fast as possible.", ) - - parser.add_argument( + player_group.add_argument( + "-l", + "--loop", + type=_parse_loop, + metavar="NUM", + default=1, + help="Replay file NUM times. Use 'i' for infinite loop (default: 1)", + ) + player_group.add_argument( "infile", metavar="input-file", type=str, help="The file to replay. For supported types see can.LogReader.", ) + # handle remaining arguments + _add_extra_args(player_group) + + # add bus options + add_bus_arguments(parser) + # print help message when no arguments were given if len(sys.argv) < 2: parser.print_help(sys.stderr) @@ -81,31 +131,34 @@ def main() -> None: results, unknown_args = parser.parse_known_args() additional_config = _parse_additional_config([*results.extra_args, *unknown_args]) + _set_logging_level_from_namespace(results) verbosity = results.verbosity error_frames = results.error_frames - with _create_bus(results, **additional_config) as bus: - with LogReader(results.infile, **additional_config) as reader: - - in_sync = MessageSync( - cast(Iterable[Message], reader), - timestamps=results.timestamps, - gap=results.gap, - skip=results.skip, - ) - - print(f"Can LogReader (Started on {datetime.now()})") - - try: - for message in in_sync: - if message.is_error_frame and not error_frames: - continue - if verbosity >= 3: - print(message) - bus.send(message) - except KeyboardInterrupt: - pass + with create_bus_from_namespace(results) as bus: + loop_count: int | float = results.loop + iteration = 0 + try: + while iteration < loop_count: + with LogReader(results.infile, **additional_config) as reader: + in_sync = MessageSync( + cast("Iterable[Message]", reader), + timestamps=results.timestamps, + gap=results.gap, + skip=results.skip, + ) + print(_format_player_start_message(iteration, loop_count)) + + for message in in_sync: + if message.is_error_frame and not error_frames: + continue + if verbosity >= 3: + print(message) + bus.send(message) + iteration += 1 + except KeyboardInterrupt: + pass if __name__ == "__main__": diff --git a/can/thread_safe_bus.py b/can/thread_safe_bus.py index 6cf28fd99..518604364 100644 --- a/can/thread_safe_bus.py +++ b/can/thread_safe_bus.py @@ -1,4 +1,14 @@ +from contextlib import nullcontext from threading import RLock +from typing import TYPE_CHECKING, Any, cast + +from . import typechecking +from .bus import BusState, CanProtocol +from .interface import Bus +from .message import Message + +if TYPE_CHECKING: + from .bus import BusABC try: # Only raise an exception on instantiation but allow module @@ -7,33 +17,13 @@ import_exc = None except ImportError as exc: - ObjectProxy = object + ObjectProxy = None # type: ignore[misc,assignment] import_exc = exc -from .interface import Bus - - -try: - from contextlib import nullcontext - -except ImportError: - class nullcontext: # type: ignore - """A context manager that does nothing at all. - A fallback for Python 3.7's :class:`contextlib.nullcontext` manager. - """ - - def __init__(self, enter_result=None): - self.enter_result = enter_result - - def __enter__(self): - return self.enter_result - - def __exit__(self, *args): - pass - - -class ThreadSafeBus(ObjectProxy): # pylint: disable=abstract-method +class ThreadSafeBus( + ObjectProxy +): # pylint: disable=abstract-method # type: ignore[assignment] """ Contains a thread safe :class:`can.BusABC` implementation that wraps around an existing interface instance. All public methods @@ -50,67 +40,82 @@ class ThreadSafeBus(ObjectProxy): # pylint: disable=abstract-method instead of :meth:`~can.BusABC.recv` directly. """ - def __init__(self, *args, **kwargs): + def __init__( + self, + channel: typechecking.Channel | None = None, + interface: str | None = None, + config_context: str | None = None, + ignore_config: bool = False, + **kwargs: Any, + ) -> None: if import_exc is not None: raise import_exc - super().__init__(Bus(*args, **kwargs)) + super().__init__( + Bus( + channel=channel, + interface=interface, + config_context=config_context, + ignore_config=ignore_config, + **kwargs, + ) + ) + + # store wrapped bus as a proxy-local attribute. Name it with the + # `_self_` prefix so wrapt won't forward it onto the wrapped object. + self._self_wrapped = cast( + "BusABC", object.__getattribute__(self, "__wrapped__") + ) # now, BusABC.send_periodic() does not need a lock anymore, but the # implementation still requires a context manager - # pylint: disable=protected-access - self.__wrapped__._lock_send_periodic = nullcontext() - # pylint: enable=protected-access + self._self_wrapped._lock_send_periodic = nullcontext() # type: ignore[assignment] # init locks for sending and receiving separately - self._lock_send = RLock() - self._lock_recv = RLock() - - def recv( - self, timeout=None, *args, **kwargs - ): # pylint: disable=keyword-arg-before-vararg - with self._lock_recv: - return self.__wrapped__.recv(timeout=timeout, *args, **kwargs) + self._self_lock_send = RLock() + self._self_lock_recv = RLock() - def send( - self, msg, timeout=None, *args, **kwargs - ): # pylint: disable=keyword-arg-before-vararg - with self._lock_send: - return self.__wrapped__.send(msg, timeout=timeout, *args, **kwargs) + def recv(self, timeout: float | None = None) -> Message | None: + with self._self_lock_recv: + return self._self_wrapped.recv(timeout=timeout) - # send_periodic does not need a lock, since the underlying - # `send` method is already synchronized + def send(self, msg: Message, timeout: float | None = None) -> None: + with self._self_lock_send: + return self._self_wrapped.send(msg=msg, timeout=timeout) @property - def filters(self): - with self._lock_recv: - return self.__wrapped__.filters + def filters(self) -> typechecking.CanFilters | None: + with self._self_lock_recv: + return self._self_wrapped.filters @filters.setter - def filters(self, filters): - with self._lock_recv: - self.__wrapped__.filters = filters + def filters(self, filters: typechecking.CanFilters | None) -> None: + with self._self_lock_recv: + self._self_wrapped.filters = filters - def set_filters( - self, filters=None, *args, **kwargs - ): # pylint: disable=keyword-arg-before-vararg - with self._lock_recv: - return self.__wrapped__.set_filters(filters=filters, *args, **kwargs) + def set_filters(self, filters: typechecking.CanFilters | None = None) -> None: + with self._self_lock_recv: + return self._self_wrapped.set_filters(filters=filters) - def flush_tx_buffer(self, *args, **kwargs): - with self._lock_send: - return self.__wrapped__.flush_tx_buffer(*args, **kwargs) + def flush_tx_buffer(self) -> None: + with self._self_lock_send: + return self._self_wrapped.flush_tx_buffer() - def shutdown(self, *args, **kwargs): - with self._lock_send, self._lock_recv: - return self.__wrapped__.shutdown(*args, **kwargs) + def shutdown(self) -> None: + with self._self_lock_send, self._self_lock_recv: + return self._self_wrapped.shutdown() @property - def state(self): - with self._lock_send, self._lock_recv: - return self.__wrapped__.state + def state(self) -> BusState: + with self._self_lock_send, self._self_lock_recv: + return self._self_wrapped.state @state.setter - def state(self, new_state): - with self._lock_send, self._lock_recv: - self.__wrapped__.state = new_state + def state(self, new_state: BusState) -> None: + with self._self_lock_send, self._self_lock_recv: + self._self_wrapped.state = new_state + + @property + def protocol(self) -> CanProtocol: + with self._self_lock_send, self._self_lock_recv: + return self._self_wrapped.protocol diff --git a/can/typechecking.py b/can/typechecking.py index dc5c22270..56ac5927f 100644 --- a/can/typechecking.py +++ b/can/typechecking.py @@ -1,56 +1,67 @@ -"""Types for mypy type-checking -""" -import gzip -import typing +"""Types for mypy type-checking""" -if typing.TYPE_CHECKING: - import os +import io +import os +import sys +from collections.abc import Iterable, Sequence +from typing import IO, TYPE_CHECKING, Any, NewType, TypeAlias -import typing_extensions +if sys.version_info >= (3, 12): + from typing import TypedDict +else: + from typing_extensions import TypedDict -class CanFilter(typing_extensions.TypedDict): - can_id: int - can_mask: int +if TYPE_CHECKING: + import struct -class CanFilterExtended(typing_extensions.TypedDict): +class _CanFilterBase(TypedDict): can_id: int can_mask: int + + +class CanFilter(_CanFilterBase, total=False): extended: bool -CanFilters = typing.Sequence[typing.Union[CanFilter, CanFilterExtended]] +CanFilters = Sequence[CanFilter] # TODO: Once buffer protocol support lands in typing, we should switch to that, # since can.message.Message attempts to call bytearray() on the given data, so # this should have the same typing info. # # See: https://github.com/python/typing/issues/593 -CanData = typing.Union[bytes, bytearray, int, typing.Iterable[int]] +CanData = bytes | bytearray | int | Iterable[int] # Used for the Abstract Base Class ChannelStr = str ChannelInt = int -Channel = typing.Union[ChannelInt, ChannelStr] +Channel = ChannelInt | ChannelStr | Sequence[ChannelInt] # Used by the IO module -FileLike = typing.Union[typing.TextIO, typing.BinaryIO, gzip.GzipFile] -StringPathLike = typing.Union[str, "os.PathLike[str]"] -AcceptedIOType = typing.Union[FileLike, StringPathLike] +FileLike = IO[Any] | io.TextIOWrapper | io.BufferedIOBase +StringPathLike = str | os.PathLike[str] + +BusConfig = NewType("BusConfig", dict[str, Any]) -BusConfig = typing.NewType("BusConfig", typing.Dict[str, typing.Any]) +# Used by CLI scripts +TAdditionalCliArgs: TypeAlias = dict[str, str | int | float | bool] +TDataStructs: TypeAlias = dict[ + int | tuple[int, ...], + "struct.Struct | tuple[struct.Struct, *tuple[float, ...]]", +] -class AutoDetectedConfig(typing_extensions.TypedDict): +class AutoDetectedConfig(TypedDict): interface: str channel: Channel -ReadableBytesLike = typing.Union[bytes, bytearray, memoryview] +ReadableBytesLike = bytes | bytearray | memoryview -class BitTimingDict(typing_extensions.TypedDict): +class BitTimingDict(TypedDict): f_clock: int brp: int tseg1: int @@ -59,7 +70,7 @@ class BitTimingDict(typing_extensions.TypedDict): nof_samples: int -class BitTimingFdDict(typing_extensions.TypedDict): +class BitTimingFdDict(TypedDict): f_clock: int nom_brp: int nom_tseg1: int diff --git a/can/util.py b/can/util.py index 42d99272e..4cbeec60e 100644 --- a/can/util.py +++ b/can/util.py @@ -1,6 +1,8 @@ """ Utilities and configuration file parsing. """ + +import contextlib import copy import functools import json @@ -10,25 +12,22 @@ import platform import re import warnings +from collections.abc import Callable, Iterable from configparser import ConfigParser -from time import time, perf_counter, get_clock_info +from time import get_clock_info, perf_counter, time from typing import ( Any, - Callable, - cast, - Dict, - Iterable, - Tuple, - Optional, - Union, TypeVar, + cast, ) +from typing_extensions import ParamSpec + import can + from . import typechecking from .bit_timing import BitTiming, BitTimingFd -from .exceptions import CanInitializationError -from .exceptions import CanInterfaceNotImplementedError +from .exceptions import CanInitializationError, CanInterfaceNotImplementedError from .interfaces import VALID_INTERFACES log = logging.getLogger("can.util") @@ -41,15 +40,15 @@ CONFIG_FILES = ["~/can.conf"] -if platform.system() == "Linux": +if platform.system() in ("Linux", "Darwin"): CONFIG_FILES.extend(["/etc/can.conf", "~/.can", "~/.canrc"]) elif platform.system() == "Windows" or platform.python_implementation() == "IronPython": CONFIG_FILES.extend(["can.ini", os.path.join(os.getenv("APPDATA", ""), "can.ini")]) def load_file_config( - path: Optional[typechecking.AcceptedIOType] = None, section: str = "default" -) -> Dict[str, str]: + path: typechecking.StringPathLike | None = None, section: str = "default" +) -> dict[str, str]: """ Loads configuration from file with following content:: @@ -66,14 +65,14 @@ def load_file_config( config = ConfigParser() # make sure to not transform the entries such that capitalization is preserved - config.optionxform = lambda entry: entry # type: ignore + config.optionxform = lambda optionstr: optionstr # type: ignore[method-assign] if path is None: config.read([os.path.expanduser(path) for path in CONFIG_FILES]) else: config.read(path) - _config: Dict[str, str] = {} + _config: dict[str, str] = {} if config.has_section(section): _config.update(config.items(section)) @@ -81,7 +80,7 @@ def load_file_config( return _config -def load_environment_config(context: Optional[str] = None) -> Dict[str, str]: +def load_environment_config(context: str | None = None) -> dict[str, str]: """ Loads config dict from environmental variables (if set): @@ -107,7 +106,7 @@ def load_environment_config(context: Optional[str] = None) -> Dict[str, str]: context_suffix = f"_{context}" if context else "" can_config_key = f"CAN_CONFIG{context_suffix}" - config: Dict[str, str] = json.loads(os.environ.get(can_config_key, "{}")) + config: dict[str, str] = json.loads(os.environ.get(can_config_key, "{}")) for key, val in mapper.items(): config_option = os.environ.get(val + context_suffix, None) @@ -118,9 +117,9 @@ def load_environment_config(context: Optional[str] = None) -> Dict[str, str]: def load_config( - path: Optional[typechecking.AcceptedIOType] = None, - config: Optional[Dict[str, Any]] = None, - context: Optional[str] = None, + path: typechecking.StringPathLike | None = None, + config: dict[str, Any] | None = None, + context: str | None = None, ) -> typechecking.BusConfig: """ Returns a dict with configuration details which is loaded from (in this order): @@ -174,7 +173,7 @@ def load_config( # Use the given dict for default values config_sources = cast( - Iterable[Union[Dict[str, Any], Callable[[Any], Dict[str, Any]]]], + "Iterable[dict[str, Any] | Callable[[Any], dict[str, Any]]]", [ given_config, can.rc, @@ -188,9 +187,8 @@ def load_config( ) # Slightly complex here to only search for the file config if required - for cfg in config_sources: - if callable(cfg): - cfg = cfg(context) + for _cfg in config_sources: + cfg = _cfg(context) if callable(_cfg) else _cfg # remove legacy operator (and copy to interface if not already present) if "bustype" in cfg: if "interface" not in cfg or not cfg["interface"]: @@ -209,7 +207,7 @@ def load_config( return bus_config -def _create_bus_config(config: Dict[str, Any]) -> typechecking.BusConfig: +def _create_bus_config(config: dict[str, Any]) -> typechecking.BusConfig: """Validates some config values, performs compatibility mappings and creates specific structures (e.g. for bit timings). @@ -241,29 +239,45 @@ def _create_bus_config(config: Dict[str, Any]) -> typechecking.BusConfig: if not 0 < port < 65535: raise ValueError("Port config must be inside 0-65535 range!") - if config.get("timing", None) is None: - try: - if set(typechecking.BitTimingFdDict.__annotations__).issubset(config): - config["timing"] = can.BitTimingFd( - **{ - key: int(config[key]) - for key in typechecking.BitTimingFdDict.__annotations__ - } - ) - elif set(typechecking.BitTimingDict.__annotations__).issubset(config): - config["timing"] = can.BitTiming( - **{ - key: int(config[key]) - for key in typechecking.BitTimingDict.__annotations__ - } - ) - except (ValueError, TypeError): - pass + if "timing" not in config: + if timing := _dict2timing(config): + config["timing"] = timing if "fd" in config: config["fd"] = config["fd"] not in (0, False) - return cast(typechecking.BusConfig, config) + if "state" in config and not isinstance(config["state"], can.BusState): + try: + config["state"] = can.BusState[config["state"]] + except KeyError as e: + raise ValueError("State config not valid!") from e + + return cast("typechecking.BusConfig", config) + + +def _dict2timing(data: dict[str, Any]) -> BitTiming | BitTimingFd | None: + """Try to instantiate a :class:`~can.BitTiming` or :class:`~can.BitTimingFd` from + a dictionary. Return `None` if not possible.""" + + with contextlib.suppress(ValueError, TypeError): + if set(typechecking.BitTimingFdDict.__annotations__).issubset(data): + return BitTimingFd( + **{ + key: int(data[key]) + for key in typechecking.BitTimingFdDict.__annotations__ + }, + strict=False, + ) + elif set(typechecking.BitTimingDict.__annotations__).issubset(data): + return BitTiming( + **{ + key: int(data[key]) + for key in typechecking.BitTimingDict.__annotations__ + }, + strict=False, + ) + + return None def set_logging_level(level_name: str) -> None: @@ -308,7 +322,7 @@ def dlc2len(dlc: int) -> int: return CAN_FD_DLC[dlc] if dlc <= 15 else 64 -def channel2int(channel: Optional[typechecking.Channel]) -> Optional[int]: +def channel2int(channel: typechecking.Channel | None) -> int | None: """Try to convert the channel to an integer. :param channel: @@ -325,9 +339,15 @@ def channel2int(channel: Optional[typechecking.Channel]) -> Optional[int]: return None -def deprecated_args_alias( # type: ignore - deprecation_start: str, deprecation_end: Optional[str] = None, **aliases -): +P1 = ParamSpec("P1") +T1 = TypeVar("T1") + + +def deprecated_args_alias( + deprecation_start: str, + deprecation_end: str | None = None, + **aliases: str | None, +) -> Callable[[Callable[P1, T1]], Callable[P1, T1]]: """Allows to rename/deprecate a function kwarg(s) and optionally have the deprecated kwarg(s) set as alias(es) @@ -356,9 +376,9 @@ def library_function(new_arg): """ - def deco(f): + def deco(f: Callable[P1, T1]) -> Callable[P1, T1]: @functools.wraps(f) - def wrapper(*args, **kwargs): + def wrapper(*args: P1.args, **kwargs: P1.kwargs) -> T1: _rename_kwargs( func_name=f.__name__, start=deprecation_start, @@ -373,10 +393,42 @@ def wrapper(*args, **kwargs): return deco -T = TypeVar("T", BitTiming, BitTimingFd) +def _rename_kwargs( + func_name: str, + start: str, + end: str | None, + kwargs: dict[str, Any], + aliases: dict[str, str | None], +) -> None: + """Helper function for `deprecated_args_alias`""" + for alias, new in aliases.items(): + if alias in kwargs: + deprecation_notice = ( + f"The '{alias}' argument is deprecated since python-can v{start}" + ) + if end: + deprecation_notice += ( + f", and scheduled for removal in python-can v{end}" + ) + deprecation_notice += "." + + value = kwargs.pop(alias) + if new is not None: + deprecation_notice += f" Use '{new}' instead." + + if new in kwargs: + raise TypeError( + f"{func_name} received both '{alias}' (deprecated) and '{new}'." + ) + kwargs[new] = value + + warnings.warn(deprecation_notice, DeprecationWarning, stacklevel=3) + +T2 = TypeVar("T2", BitTiming, BitTimingFd) -def check_or_adjust_timing_clock(timing: T, valid_clocks: Iterable[int]) -> T: + +def check_or_adjust_timing_clock(timing: T2, valid_clocks: Iterable[int]) -> T2: """Adjusts the given timing instance to have an *f_clock* value that is within the allowed values specified by *valid_clocks*. If the *f_clock* value of timing is already within *valid_clocks*, then *timing* is returned unchanged. @@ -403,7 +455,8 @@ def check_or_adjust_timing_clock(timing: T, valid_clocks: Iterable[int]) -> T: adjusted_timing = timing.recreate_with_f_clock(clock) warnings.warn( f"Adjusted f_clock in {timing.__class__.__name__} from " - f"{timing.f_clock} to {adjusted_timing.f_clock}" + f"{timing.f_clock} to {adjusted_timing.f_clock}", + stacklevel=2, ) return adjusted_timing except ValueError: @@ -416,39 +469,7 @@ def check_or_adjust_timing_clock(timing: T, valid_clocks: Iterable[int]) -> T: ) from None -def _rename_kwargs( - func_name: str, - start: str, - end: Optional[str], - kwargs: Dict[str, str], - aliases: Dict[str, str], -) -> None: - """Helper function for `deprecated_args_alias`""" - for alias, new in aliases.items(): - if alias in kwargs: - deprecation_notice = ( - f"The '{alias}' argument is deprecated since python-can v{start}" - ) - if end: - deprecation_notice += ( - f", and scheduled for removal in python-can v{end}" - ) - deprecation_notice += "." - - value = kwargs.pop(alias) - if new is not None: - deprecation_notice += f" Use '{new}' instead." - - if new in kwargs: - raise TypeError( - f"{func_name} received both '{alias}' (deprecated) and '{new}'." - ) - kwargs[new] = value - - warnings.warn(deprecation_notice, DeprecationWarning) - - -def time_perfcounter_correlation() -> Tuple[float, float]: +def time_perfcounter_correlation() -> tuple[float, float]: """Get the `perf_counter` value nearest to when time.time() is updated Computed if the default timer used by `time.time` on this platform has a resolution @@ -477,7 +498,7 @@ def time_perfcounter_correlation() -> Tuple[float, float]: return t1, performance_counter -def cast_from_string(string_val: str) -> Union[str, int, float, bool]: +def cast_from_string(string_val: str) -> str | int | float | bool: """Perform trivial type conversion from :class:`str` values. :param string_val: @@ -497,11 +518,3 @@ def cast_from_string(string_val: str) -> Union[str, int, float, bool]: # value is string return string_val - - -if __name__ == "__main__": - print("Searching for configuration named:") - print("\n".join(CONFIG_FILES)) - print() - print("Settings:") - print(load_config()) diff --git a/can/viewer.py b/can/viewer.py index 5539ee3fb..8d9d228bb 100644 --- a/can/viewer.py +++ b/can/viewer.py @@ -27,29 +27,27 @@ import struct import sys import time -from typing import Dict, List, Tuple, Union from can import __version__ -from .logger import ( - _create_bus, - _parse_filters, - _append_filter_argument, - _create_base_argument_parser, - _parse_additional_config, +from can.cli import ( + _set_logging_level_from_namespace, + add_bus_arguments, + create_bus_from_namespace, ) - +from can.typechecking import TDataStructs logger = logging.getLogger("can.viewer") try: import curses - from curses.ascii import ESC as KEY_ESC, SP as KEY_SPACE + from curses.ascii import ESC as KEY_ESC # type: ignore[attr-defined,unused-ignore] + from curses.ascii import SP as KEY_SPACE # type: ignore[attr-defined,unused-ignore] except ImportError: # Probably on Windows while windows-curses is not installed (e.g. in PyPy) logger.warning( "You won't be able to use the viewer program without curses installed!" ) - curses = None # type: ignore + curses = None # type: ignore[assignment] class CanViewer: # pylint: disable=too-many-instance-attributes @@ -161,14 +159,13 @@ def run(self): # Unpack the data and then convert it into SI-units @staticmethod - def unpack_data(cmd: int, cmd_to_struct: Dict, data: bytes) -> List[float]: + def unpack_data(cmd: int, cmd_to_struct: TDataStructs, data: bytes) -> list[float]: if not cmd_to_struct or not data: # These messages do not contain a data package return [] - for key in cmd_to_struct: + for key, value in cmd_to_struct.items(): if cmd == key if isinstance(key, int) else cmd in key: - value = cmd_to_struct[key] if isinstance(value, tuple): # The struct is given as the fist argument struct_t: struct.Struct = value[0] @@ -176,7 +173,9 @@ def unpack_data(cmd: int, cmd_to_struct: Dict, data: bytes) -> List[float]: # The conversion from raw values to SI-units are given in the rest of the tuple values = [ d // val if isinstance(val, int) else float(d) / val - for d, val in zip(struct_t.unpack(data), value[1:]) + for d, val in zip( + struct_t.unpack(data), value[1:], strict=False + ) ] else: # No conversion from SI-units is needed @@ -390,7 +389,9 @@ def _fill_text(self, text, width, indent): return super()._fill_text(text, width, indent) -def parse_args(args: List[str]) -> Tuple: +def _parse_viewer_args( + args: list[str], +) -> tuple[argparse.Namespace, TDataStructs]: # Parse command line arguments parser = argparse.ArgumentParser( "python -m can.viewer", @@ -411,9 +412,8 @@ def parse_args(args: List[str]) -> Tuple: allow_abbrev=False, ) - # Generate the standard arguments: - # Channel, bitrate, data_bitrate, interface, app_name, CAN-FD support - _create_base_argument_parser(parser) + # add bus options group + add_bus_arguments(parser, filter_arg=True, group_title="Bus arguments") optional = parser.add_argument_group("Optional arguments") @@ -470,8 +470,6 @@ def parse_args(args: List[str]) -> Tuple: default="", ) - _append_filter_argument(optional, "-f") - optional.add_argument( "-v", action="count", @@ -487,8 +485,8 @@ def parse_args(args: List[str]) -> Tuple: raise SystemExit(errno.EINVAL) parsed_args, unknown_args = parser.parse_known_args(args) - - can_filters = _parse_filters(parsed_args) + if unknown_args: + print("Unknown arguments:", unknown_args) # Dictionary used to convert between Python values and C structs represented as Python strings. # If the value is 'None' then the message does not contain any data package. @@ -510,9 +508,7 @@ def parse_args(args: List[str]) -> Tuple: # similarly the values # are divided by the value in order to convert from real units to raw integer values. - data_structs: Dict[ - Union[int, Tuple[int, ...]], Union[struct.Struct, Tuple, None] - ] = {} + data_structs: TDataStructs = {} if parsed_args.decode: if os.path.isfile(parsed_args.decode[0]): with open(parsed_args.decode[0], encoding="utf-8") as f: @@ -527,7 +523,7 @@ def parse_args(args: List[str]) -> Tuple: key, fmt = int(tmp[0], base=16), tmp[1] # The scaling - scaling: List[float] = [] + scaling: list[float] = [] for t in tmp[2:]: # First try to convert to int, if that fails, then convert to a float try: @@ -536,24 +532,18 @@ def parse_args(args: List[str]) -> Tuple: scaling.append(float(t)) if scaling: - data_structs[key] = (struct.Struct(fmt),) + tuple(scaling) + data_structs[key] = (struct.Struct(fmt), *scaling) else: data_structs[key] = struct.Struct(fmt) - additional_config = _parse_additional_config( - [*parsed_args.extra_args, *unknown_args] - ) - return parsed_args, can_filters, data_structs, additional_config + return parsed_args, data_structs def main() -> None: - parsed_args, can_filters, data_structs, additional_config = parse_args(sys.argv[1:]) - - if can_filters: - additional_config.update({"can_filters": can_filters}) - bus = _create_bus(parsed_args, **additional_config) - - curses.wrapper(CanViewer, bus, data_structs) + parsed_args, data_structs = _parse_viewer_args(sys.argv[1:]) + bus = create_bus_from_namespace(parsed_args) + _set_logging_level_from_namespace(parsed_args) + curses.wrapper(CanViewer, bus, data_structs) # type: ignore[attr-defined,unused-ignore] if __name__ == "__main__": diff --git a/doc/api.rst b/doc/api.rst index 053bd34a4..50095589c 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -10,11 +10,12 @@ A form of CAN interface is also required. .. toctree:: - :maxdepth: 1 + :maxdepth: 2 bus message - listeners + notifier + file_io asyncio bcm errors diff --git a/doc/bit_timing.rst b/doc/bit_timing.rst index 73005a3c6..bf7aad486 100644 --- a/doc/bit_timing.rst +++ b/doc/bit_timing.rst @@ -1,10 +1,6 @@ Bit Timing Configuration ======================== -.. attention:: - This feature is experimental. The implementation might change in future - versions. - The CAN protocol, specified in ISO 11898, allows the bitrate, sample point and number of samples to be optimized for a given application. These parameters, known as bit timings, can be adjusted to meet the requirements diff --git a/doc/bus.rst b/doc/bus.rst index 51ed0220b..f63c244c2 100644 --- a/doc/bus.rst +++ b/doc/bus.rst @@ -3,10 +3,10 @@ Bus --- -The :class:`~can.Bus` provides a wrapper around a physical or virtual CAN Bus. +The :class:`~can.BusABC` class provides a wrapper around a physical or virtual CAN Bus. -An interface specific instance is created by instantiating the :class:`~can.Bus` -class with a particular ``interface``, for example:: +An interface specific instance is created by calling the :func:`~can.Bus` +function with a particular ``interface``, for example:: vector_bus = can.Bus(interface='vector', ...) @@ -77,13 +77,18 @@ See :meth:`~can.BusABC.set_filters` for the implementation. Bus API ''''''' -.. autoclass:: can.Bus +.. autofunction:: can.Bus + +.. autoclass:: can.BusABC :class-doc-from: class - :show-inheritance: :members: :inherited-members: -.. autoclass:: can.bus.BusState +.. autoclass:: can.BusState + :members: + :undoc-members: + +.. autoclass:: can.bus.CanProtocol :members: :undoc-members: diff --git a/doc/changelog.d/.gitignore b/doc/changelog.d/.gitignore new file mode 100644 index 000000000..b56b00acb --- /dev/null +++ b/doc/changelog.d/.gitignore @@ -0,0 +1,11 @@ +# Ignore everything... +* +!.gitignore + +# ...except markdown news fragments +!*.security.md +!*.removed.md +!*.deprecated.md +!*.added.md +!*.changed.md +!*.fixed.md diff --git a/doc/changelog.d/1815.added.md b/doc/changelog.d/1815.added.md new file mode 100644 index 000000000..65756fb41 --- /dev/null +++ b/doc/changelog.d/1815.added.md @@ -0,0 +1 @@ +Added support for replaying CAN log files multiple times or infinitely in the player script via the new --loop/-l argument. diff --git a/doc/changelog.d/1815.removed.md b/doc/changelog.d/1815.removed.md new file mode 100644 index 000000000..61b4e9b1d --- /dev/null +++ b/doc/changelog.d/1815.removed.md @@ -0,0 +1 @@ +Removed the unused --file_name/-f argument from the player CLI. diff --git a/doc/changelog.d/1938.fixed.md b/doc/changelog.d/1938.fixed.md new file mode 100644 index 000000000..f9aad1089 --- /dev/null +++ b/doc/changelog.d/1938.fixed.md @@ -0,0 +1 @@ +Keep a reference to asyncio tasks in `can.Notifier` as recommended by [python documentation](https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task). diff --git a/doc/changelog.d/1987.added.md b/doc/changelog.d/1987.added.md new file mode 100644 index 000000000..398add3e3 --- /dev/null +++ b/doc/changelog.d/1987.added.md @@ -0,0 +1 @@ +Add [python-can-coe](https://c0d3.sh/smarthome/python-can-coe) interface plugin to the documentation. diff --git a/doc/changelog.d/1995.added.md b/doc/changelog.d/1995.added.md new file mode 100644 index 000000000..81e39d0df --- /dev/null +++ b/doc/changelog.d/1995.added.md @@ -0,0 +1 @@ +Added hardware filter support for SeeedBus during initialization, with a software fallback. \ No newline at end of file diff --git a/doc/changelog.d/1996.removed.md b/doc/changelog.d/1996.removed.md new file mode 100644 index 000000000..77458ad75 --- /dev/null +++ b/doc/changelog.d/1996.removed.md @@ -0,0 +1 @@ +Remove support for end-of-life Python 3.9. \ No newline at end of file diff --git a/doc/changelog.d/2009.changed.md b/doc/changelog.d/2009.changed.md new file mode 100644 index 000000000..6e68198a1 --- /dev/null +++ b/doc/changelog.d/2009.changed.md @@ -0,0 +1 @@ +Improved datetime parsing and added support for “double-defined” datetime strings (such as, e.g., `"30 15:06:13.191 pm 2017"`) for ASCReader class. \ No newline at end of file diff --git a/doc/conf.py b/doc/conf.py index cea93440d..5e413361c 100755 --- a/doc/conf.py +++ b/doc/conf.py @@ -6,9 +6,10 @@ # -- Imports ------------------------------------------------------------------- -import sys -import os import ctypes +import os +import sys +from importlib.metadata import version as get_version from unittest.mock import MagicMock # If extensions (or modules to document with autodoc) are in another directory, @@ -16,8 +17,7 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath("..")) -import can # pylint: disable=wrong-import-position -from can import ctypesutil +from can import ctypesutil # pylint: disable=wrong-import-position # -- General configuration ----------------------------------------------------- @@ -27,9 +27,10 @@ # |version| and |release|, also used in various other places throughout the # built documents. # +# The full version, including alpha/beta/rc tags. +release: str = get_version("python-can") # The short X.Y version. -version = can.__version__.split("-", maxsplit=1)[0] -release = can.__version__ +version = ".".join(release.split(".")[:2]) # General information about the project. project = "python-can" @@ -67,7 +68,7 @@ graphviz_output_format = "png" # 'svg' # The suffix of source filenames. -source_suffix = ".rst" +source_suffix = {".rst": "restructuredtext"} # The encoding of source files. # source_encoding = 'utf-8-sig' @@ -121,19 +122,27 @@ # disable specific warnings nitpick_ignore = [ # Ignore warnings for type aliases. Remove once Sphinx supports PEP613 + ("py:class", "OpenTextModeUpdating"), + ("py:class", "OpenTextModeWriting"), + ("py:class", "OpenBinaryModeUpdating"), + ("py:class", "OpenBinaryModeWriting"), + ("py:class", "OpenTextModeReading"), + ("py:class", "OpenBinaryModeReading"), ("py:class", "BusConfig"), ("py:class", "can.typechecking.BusConfig"), ("py:class", "can.typechecking.CanFilter"), ("py:class", "can.typechecking.CanFilterExtended"), ("py:class", "can.typechecking.AutoDetectedConfig"), - ("py:class", "can.util.T"), + ("py:class", "can.util.T1"), + ("py:class", "can.util.T2"), + ("py:class", "~P1"), # intersphinx fails to reference some builtins ("py:class", "asyncio.events.AbstractEventLoop"), - ("py:class", "_thread.allocate_lock"), + ("py:class", "_thread.lock"), ] # mock windows specific attributes -autodoc_mock_imports = ["win32com"] +autodoc_mock_imports = ["win32com", "pythoncom"] ctypes.windll = MagicMock() ctypesutil.HRESULT = ctypes.c_long diff --git a/doc/configuration.rst b/doc/configuration.rst index 494351350..2951a63b1 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -36,7 +36,7 @@ You can also specify the interface and channel for each Bus instance:: Configuration File ------------------ -On Linux systems the config file is searched in the following paths: +On Linux and macOS systems the config file is searched in the following paths: #. ``~/can.conf`` #. ``/etc/can.conf`` @@ -83,8 +83,8 @@ The configuration can also contain additional sections (or context): from can.interface import Bus - hs_bus = Bus(context='HS') - ms_bus = Bus(context='MS') + hs_bus = Bus(config_context='HS') + ms_bus = Bus(config_context='MS') Environment Variables --------------------- @@ -159,4 +159,4 @@ Lookup table of interface names: | ``"virtual"`` | :doc:`interfaces/virtual` | +---------------------+-------------------------------------+ -Additional interface types can be added via the :ref:`plugin interface`. \ No newline at end of file +Additional interface types can be added via the :ref:`plugin interface`. diff --git a/doc/development.rst b/doc/development.rst index cfb8dbe5d..40604c346 100644 --- a/doc/development.rst +++ b/doc/development.rst @@ -1,127 +1,281 @@ Developer's Overview ==================== +Quick Start for Contributors +---------------------------- +* Fork the repository on GitHub and clone your fork. +* Create a new branch for your changes. +* Set up your development environment. +* Make your changes, add/update tests and documentation as needed. +* Run `tox` to check your changes. +* Push your branch and open a pull request. Contributing ------------ -Contribute to source code, documentation, examples and report issues: -https://github.com/hardbyte/python-can +Welcome! Thank you for your interest in python-can. Whether you want to fix a bug, +add a feature, improve documentation, write examples, help solve issues, +or simply report a problem, your contribution is valued. +Contributions are made via the `python-can GitHub repository `_. +If you have questions, feel free to open an issue or start a discussion on GitHub. -Note that the latest released version on PyPi may be significantly behind the -``develop`` branch. Please open any feature requests against the ``develop`` branch +If you're new to the codebase, see the next section for an overview of the code structure. +For more about the internals, see :ref:`internalapi` and information on extending the ``can.io`` module. -There is also a `python-can `__ -mailing list for development discussion. +Code Structure +^^^^^^^^^^^^^^ + +The modules in ``python-can`` are: + ++---------------------------------+------------------------------------------------------+ +|Module | Description | ++=================================+======================================================+ +|:doc:`interfaces ` | Contains interface dependent code. | ++---------------------------------+------------------------------------------------------+ +|:doc:`bus ` | Contains the interface independent Bus object. | ++---------------------------------+------------------------------------------------------+ +|:doc:`message ` | Contains the interface independent Message object. | ++---------------------------------+------------------------------------------------------+ +|:doc:`io ` | Contains a range of file readers and writers. | ++---------------------------------+------------------------------------------------------+ +|:doc:`broadcastmanager ` | Contains interface independent broadcast manager | +| | code. | ++---------------------------------+------------------------------------------------------+ -Some more information about the internals of this library can be found -in the chapter :ref:`internalapi`. -There is also additional information on extending the ``can.io`` module. +Step-by-Step Contribution Guide +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +1. **Fork and Clone the Repository** -Pre-releases ------------- + * Fork the python-can repository on GitHub to your own account. + * Clone your fork: + + .. code-block:: shell + + git clone https://github.com//python-can.git + cd python-can + + * Create a new branch for your work: + + .. code-block:: shell + + git checkout -b my-feature-branch + + * Ensure your branch is up to date with the latest changes from the main repository. + First, add the main repository as a remote (commonly named `upstream`) if you haven't already: + + .. code-block:: shell + + git remote add upstream https://github.com/hardbyte/python-can.git + + Then, regularly fetch and rebase from the main branch: + + .. code-block:: shell + + git fetch upstream + git rebase upstream/main + +2. **Set Up Your Development Environment** + + We recommend using `uv `__ to install development tools and run CLI utilities. + `uv` is a modern Python packaging tool that can quickly create virtual environments and manage dependencies, + including downloading required Python versions automatically. The `uvx` command allows you to run CLI tools + in isolated environments, separate from your global Python installation. This is useful for installing and + running Python applications (such as tox) without affecting your project's dependencies or environment. + + **Install tox (if not already available):** + + + .. code-block:: shell + + uv tool install tox --with tox-uv + + + **Quickly running your local python-can code** + + To run a local script (e.g., `snippet.py`) using your current python-can code, + you can use either the traditional `virtualenv` and `pip` workflow or the modern `uv` tool. + + **Traditional method (virtualenv + pip):** + + Create a virtual environment and install the package in editable mode. + This allows changes to your local code to be reflected immediately, without reinstalling. + + .. code-block:: shell + + # Create a new virtual environment + python -m venv .venv + + # Activate the environment + .venv\Scripts\activate # On Windows + source .venv/bin/activate # On Unix/macOS + + # Upgrade pip and install python-can in editable mode with development dependencies + python -m pip install --upgrade pip + pip install -e .[dev] -The latest pre-release can be installed with:: + # Run your script + python snippet.py - pip install --upgrade --pre python-can + **Modern method (uv):** + With `uv`, you can run your script directly: + .. code-block:: shell -Building & Installing ---------------------- + uv run snippet.py -The following assumes that the commands are executed from the root of the repository: + When ``uv run ...`` is called inside a project, + `uv` automatically sets up the environment and symlinks local packages. + No editable install is needed—changes to your code are reflected immediately. -The project can be built with:: +3. **Make Your Changes** - pipx run build - pipx run twine check dist/* + * Edit code, documentation, or tests as needed. + * If you fix a bug or add a feature, add or update tests in the ``test/`` directory. + * If your change affects users, update documentation in ``doc/`` and relevant docstrings. -The project can be installed in editable mode with:: +4. **Test Your Changes** - pip install -e . + This project uses `tox `__ to automate all checks (tests, lint, type, docs). + Tox will set up isolated environments and run the right tools for you. -The unit tests can be run with:: + To run all checks: - pipx run tox -e py + .. code-block:: shell -The documentation can be built with:: + tox - pip install -r doc/doc-requirements.txt - python -m sphinx -an doc build + To run a specific check, use: -The linters can be run with:: + .. code-block:: shell - pip install -r requirements-lint.txt - pylint --rcfile=.pylintrc-wip can/**.py - black --check --verbose can + tox -e lint # Run code style and lint checks (black, ruff, pylint) + tox -e type # Run type checks (mypy) + tox -e docs # Build and test documentation (sphinx) + tox -e py # Run tests (pytest) + To run all checks in parallel (where supported), you can use: + + .. code-block:: shell + + tox p + + Some environments require specific Python versions. + If you use `uv`, it will automatically download and manage these for you. + + + +5. **Add a News Fragment for the Changelog** + + This project uses `towncrier `__ to manage the changelog in + ``CHANGELOG.md``. For every user-facing change (new feature, bugfix, deprecation, etc.), you + must add a news fragment: + + * News fragments are short files describing your change, stored in ``doc/changelog.d``. + * Name each fragment ``..md``, where ```` is one of: + ``added``, ``changed``, ``deprecated``, ``removed``, ``fixed``, or ``security``. + * Example (for a feature added in PR #1234): + + .. code-block:: shell + + echo "Added support for CAN FD." > doc/changelog.d/1234.added.md + + * Or use the towncrier CLI: + + .. code-block:: shell + + uvx towncrier create --dir doc/changelog.d -c "Added support for CAN FD." 1234.added.md + + * For changes not tied to an issue/PR, the fragment name must start with a plus symbol + (e.g., ``+mychange.added.md``). Towncrier calls these "orphan fragments". + + .. note:: You do not need to manually update ``CHANGELOG.md``—maintainers will build the + changelog at release time. + +6. **(Optional) Build Source Distribution and Wheels** + + If you want to manually build the source distribution (sdist) and wheels for python-can, + you can use `uvx` to run the build and twine tools: + + .. code-block:: shell + + uv build + uvx twine check --strict dist/* + +7. **Push and Submit Your Contribution** + + * Push your branch: + + .. code-block:: shell + + git push origin my-feature-branch + + * Open a pull request from your branch to the ``main`` branch of the main python-can repository on GitHub. + + Please be patient — maintainers review contributions as time allows. Creating a new interface/backend -------------------------------- +.. attention:: + Please note: Pull requests that attempt to add new hardware interfaces directly to the + python-can codebase will not be accepted. Instead, we encourage contributors to create + plugins by publishing a Python package containing your :class:`can.BusABC` subclass and + using it within the python-can API. We will mention your package in this documentation + and add it as an optional dependency. For current best practices, please refer to + :ref:`plugin interface`. + + The following guideline is retained for informational purposes only and is not valid for new + contributions. + These steps are a guideline on how to add a new backend to python-can. -- Create a module (either a ``*.py`` or an entire subdirectory depending - on the complexity) inside ``can.interfaces`` -- Implement the central part of the backend: the bus class that extends +* Create a module (either a ``*.py`` or an entire subdirectory depending + on the complexity) inside ``can.interfaces``. See ``can/interfaces/socketcan`` for a reference implementation. +* Implement the central part of the backend: the bus class that extends :class:`can.BusABC`. See :ref:`businternals` for more info on this one! -- Register your backend bus class in ``BACKENDS`` in the file ``can.interfaces.__init__.py``. -- Add docs where appropriate. At a minimum add to ``doc/interfaces.rst`` and add +* Register your backend bus class in ``BACKENDS`` in the file ``can.interfaces.__init__.py``. +* Add docs where appropriate. At a minimum add to ``doc/interfaces.rst`` and add a new interface specific document in ``doc/interface/*``. It should document the supported platforms and also the hardware/software it requires. A small snippet of how to install the dependencies would also be useful to get people started without much friction. -- Also, don't forget to document your classes, methods and function with docstrings. -- Add tests in ``test/*`` where appropriate. - To get started, have a look at ``back2back_test.py``: - Simply add a test case like ``BasicTestSocketCan`` and some basic tests will be executed for the new interface. +* Also, don't forget to document your classes, methods and function with docstrings. +* Add tests in ``test/*`` where appropriate. For example, see ``test/back2back_test.py`` and add a test case like ``BasicTestSocketCan`` for your new interface. -.. attention:: - We strongly recommend using the :ref:`plugin interface` to extend python-can. - Publish a python package that contains your :class:`can.BusABC` subclass and use - it within the python-can API. We will mention your package inside this documentation - and add it as an optional dependency. +Creating a new Release +---------------------- -Code Structure --------------- +Releases are automated via GitHub Actions. To create a new release: -The modules in ``python-can`` are: +* Build the changelog with towncrier: -+---------------------------------+------------------------------------------------------+ -|Module | Description | -+=================================+======================================================+ -|:doc:`interfaces ` | Contains interface dependent code. | -+---------------------------------+------------------------------------------------------+ -|:doc:`bus ` | Contains the interface independent Bus object. | -+---------------------------------+------------------------------------------------------+ -|:doc:`message ` | Contains the interface independent Message object. | -+---------------------------------+------------------------------------------------------+ -|:doc:`io ` | Contains a range of file readers and writers. | -+---------------------------------+------------------------------------------------------+ -|:doc:`broadcastmanager ` | Contains interface independent broadcast manager | -| | code. | -+---------------------------------+------------------------------------------------------+ + * Collect all news fragments and update ``CHANGELOG.md`` by running: -Creating a new Release ----------------------- + .. code-block:: shell + + uvx towncrier build --yes --version vX.Y.Z + + (Replace ``vX.Y.Z`` with the new version number. **The version must exactly match the tag you will create for the release.**) + This will add all news fragments to the changelog and remove the fragments by default. + + .. note:: You can generate the changelog for prereleases, but keep the news + fragments so they are included in the final release. To do this, replace ``--yes`` with ``--keep``. + This will update ``CHANGELOG.md`` but leave the fragments in place for future builds. + + * Review ``CHANGELOG.md`` for accuracy and completeness. + +* Ensure all tests pass and documentation is up-to-date. +* Update ``CONTRIBUTORS.txt`` with any new contributors. +* For larger changes, update ``doc/history.rst``. +* Create a new tag and GitHub release (e.g., ``vX.Y.Z``) targeting the ``main`` + branch. Add release notes and publish. +* The CI workflow will automatically build, check, and upload the release to PyPI + and other platforms. -- Release from the ``main`` branch (except for pre-releases). -- Update the library version in ``__init__.py`` using `semantic versioning `__. -- Check if any deprecations are pending. -- Run all tests and examples against available hardware. -- Update ``CONTRIBUTORS.txt`` with any new contributors. -- For larger changes update ``doc/history.rst``. -- Sanity check that documentation has stayed inline with code. -- Create a temporary virtual environment. Run ``python setup.py install`` and ``tox``. -- Create and upload the distribution: ``python setup.py sdist bdist_wheel``. -- Sign the packages with gpg ``gpg --detach-sign -a dist/python_can-X.Y.Z-py3-none-any.whl``. -- Upload with twine ``twine upload dist/python-can-X.Y.Z*``. -- In a new virtual env check that the package can be installed with pip: ``pip install python-can==X.Y.Z``. -- Create a new tag in the repository. -- Check the release on +* You can monitor the release status on: `PyPi `__, `Read the Docs `__ and - `GitHub `__. + `GitHub Releases `__. diff --git a/doc/doc-requirements.txt b/doc/doc-requirements.txt deleted file mode 100644 index 9a01cf589..000000000 --- a/doc/doc-requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -sphinx>=5.2.3 -sphinxcontrib-programoutput -sphinx-inline-tabs -sphinx-copybutton -furo diff --git a/doc/listeners.rst b/doc/file_io.rst similarity index 58% rename from doc/listeners.rst rename to doc/file_io.rst index 260854d2a..329ccac53 100644 --- a/doc/listeners.rst +++ b/doc/file_io.rst @@ -1,110 +1,20 @@ +File IO +======= -Reading and Writing Messages -============================ -.. _notifier: - -Notifier --------- - -The Notifier object is used as a message distributor for a bus. Notifier creates a thread to read messages from the bus and distributes them to listeners. - -.. autoclass:: can.Notifier - :members: - -.. _listeners_doc: - -Listener --------- - -The Listener class is an "abstract" base class for any objects which wish to -register to receive notifications of new messages on the bus. A Listener can -be used in two ways; the default is to **call** the Listener with a new -message, or by calling the method **on_message_received**. - -Listeners are registered with :ref:`notifier` object(s) which ensure they are -notified whenever a new message is received. - -.. literalinclude:: ../examples/print_notifier.py - :language: python - :linenos: - :emphasize-lines: 8,9 - - -Subclasses of Listener that do not override **on_message_received** will cause -:class:`NotImplementedError` to be thrown when a message is received on -the CAN bus. - -.. autoclass:: can.Listener - :members: - -There are some listeners that already ship together with `python-can` -and are listed below. -Some of them allow messages to be written to files, and the corresponding file -readers are also documented here. - -.. note :: - - Please note that writing and the reading a message might not always yield a - completely unchanged message again, since some properties are not (yet) - supported by some file formats. - -.. note :: - - Additional file formats for both reading/writing log files can be added via - a plugin reader/writer. An external package can register a new reader - by using the ``can.io.message_reader`` entry point. Similarly, a writer can - be added using the ``can.io.message_writer`` entry point. - - The format of the entry point is ``reader_name=module:classname`` where ``classname`` - is a :class:`can.io.generic.BaseIOHandler` concrete implementation. - - :: - - entry_points={ - 'can.io.message_reader': [ - '.asc = my_package.io.asc:ASCReader' - ] - }, - - -BufferedReader --------------- - -.. autoclass:: can.BufferedReader - :members: - -.. autoclass:: can.AsyncBufferedReader - :members: - - -RedirectReader --------------- - -.. autoclass:: can.RedirectReader - :members: - - -Logger ------- - -The :class:`can.Logger` uses the following :class:`can.Listener` types to -create log files with different file types of the messages received. - -.. autoclass:: can.Logger - :members: - -.. autoclass:: can.io.BaseRotatingLogger - :members: - -.. autoclass:: can.SizedRotatingLogger - :members: +Reading and Writing Files +------------------------- +.. autofunction:: can.LogReader +.. autofunction:: can.Logger +.. autodata:: can.io.logger.MESSAGE_WRITERS +.. autodata:: can.io.player.MESSAGE_READERS Printer ------- .. autoclass:: can.Printer + :show-inheritance: :members: @@ -112,9 +22,11 @@ CSVWriter --------- .. autoclass:: can.CSVWriter + :show-inheritance: :members: .. autoclass:: can.CSVReader + :show-inheritance: :members: @@ -122,9 +34,11 @@ SqliteWriter ------------ .. autoclass:: can.SqliteWriter + :show-inheritance: :members: .. autoclass:: can.SqliteReader + :show-inheritance: :members: @@ -164,6 +78,7 @@ engineered from existing log files. One description of the format can be found ` .. autoclass:: can.ASCWriter + :show-inheritance: :members: ASCReader reads CAN data from ASCII log files .asc, @@ -172,24 +87,27 @@ as further references can-utils can be used: `log2asc `_. .. autoclass:: can.ASCReader + :show-inheritance: :members: Log (.log can-utils Logging format) ----------------------------------- -CanutilsLogWriter logs CAN data to an ASCII log file compatible with `can-utils ` +CanutilsLogWriter logs CAN data to an ASCII log file compatible with `can-utils `_ As specification following references can-utils can be used: `asc2log `_, `log2asc `_. .. autoclass:: can.CanutilsLogWriter + :show-inheritance: :members: **CanutilsLogReader** reads CAN data from ASCII log files .log .. autoclass:: can.CanutilsLogReader + :show-inheritance: :members: @@ -204,13 +122,48 @@ The data is stored in a compressed format which makes it very compact. .. note:: Channels will be converted to integers. .. autoclass:: can.BLFWriter + :show-inheritance: :members: The following class can be used to read messages from BLF file: .. autoclass:: can.BLFReader + :show-inheritance: + :members: + + +MF4 (Measurement Data Format v4) +-------------------------------- + +Implements support for MF4 (Measurement Data Format v4) which is a proprietary +format from ASAM (Association for Standardization of Automation and Measuring Systems), widely used in +many automotive software (Vector CANape, ETAS INCA, dSPACE ControlDesk, etc.). + +The data is stored in a compressed format which makes it compact. + +.. note:: MF4 support has to be installed as an extra with for example ``pip install python-can[mf4]``. + +.. note:: Channels will be converted to integers. + +.. note:: MF4Writer does not suppport the append mode. + + +.. autoclass:: can.MF4Writer + :show-inheritance: :members: +The MDF format is very flexible regarding the internal structure and it is used to handle data from multiple sources, not just CAN bus logging. +MDF4Writer will always create a fixed internal file structure where there will be three channel groups (for standard, error and remote frames). +Using this fixed file structure allows for a simple implementation of MDF4Writer and MF4Reader classes. +Therefor MF4Reader can only replay files created with MF4Writer. + +The following class can be used to read messages from MF4 file: + +.. autoclass:: can.MF4Reader + :show-inheritance: + :members: + + TRC ---- @@ -221,9 +174,31 @@ Implements basic support for the TRC file format. Comments and contributions are welcome on what file versions might be relevant. .. autoclass:: can.TRCWriter + :show-inheritance: :members: The following class can be used to read messages from TRC file: .. autoclass:: can.TRCReader + :show-inheritance: + :members: + + +Rotating Loggers +---------------- + +.. autoclass:: can.io.BaseRotatingLogger + :show-inheritance: :members: + +.. autoclass:: can.SizedRotatingLogger + :show-inheritance: + :members: + + +Replaying Files +--------------- + +.. autoclass:: can.MessageSync + :members: + diff --git a/doc/installation.rst b/doc/installation.rst index 6b2a2cfb2..822de2ce0 100644 --- a/doc/installation.rst +++ b/doc/installation.rst @@ -21,6 +21,13 @@ Install the ``can`` package from PyPi with ``pip`` or similar:: $ pip install python-can[serial] +Pre-releases +------------ + +The latest pre-release can be installed with:: + + pip install --upgrade --pre python-can + GNU/Linux dependencies ---------------------- @@ -131,5 +138,7 @@ reinstall. Download or clone the source repository then: :: - python setup.py develop + # install in editable mode + cd + python3 -m pip install -e . diff --git a/doc/interfaces/gs_usb.rst b/doc/interfaces/gs_usb.rst index 3a869911c..8bab07c6f 100755 --- a/doc/interfaces/gs_usb.rst +++ b/doc/interfaces/gs_usb.rst @@ -6,15 +6,18 @@ Geschwister Schneider and candleLight Windows/Linux/Mac CAN driver based on usbfs or WinUSB WCID for Geschwister Schneider USB/CAN devices and candleLight USB CAN interfaces. -Install: ``pip install "python-can[gs_usb]"`` +Install: ``pip install "python-can[gs-usb]"`` -Usage: pass device ``index`` (starting from 0) if using automatic device detection: +Usage: pass device ``index`` or ``channel`` (starting from 0) if using automatic device detection: :: import can + import usb + dev = usb.core.find(idVendor=0x1D50, idProduct=0x606F) bus = can.Bus(interface="gs_usb", channel=dev.product, index=0, bitrate=250000) + bus = can.Bus(interface="gs_usb", channel=0, bitrate=250000) # same Alternatively, pass ``bus`` and ``address`` to open a specific device. The parameters can be got by ``pyusb`` as shown below: @@ -50,7 +53,7 @@ Windows, Linux and Mac. ``libusb`` must be installed. On Windows a tool such as `Zadig `_ can be used to set the USB device driver to - ``libusb-win32``. + ``libusbK``. Supplementary Info diff --git a/doc/interfaces/ixxat.rst b/doc/interfaces/ixxat.rst index f73a01036..1337bf738 100644 --- a/doc/interfaces/ixxat.rst +++ b/doc/interfaces/ixxat.rst @@ -56,17 +56,42 @@ VCI documentation, section "Message filters" for more info. List available devices ---------------------- -In case you have connected multiple IXXAT devices, you have to select them by using their unique hardware id. -To get a list of all connected IXXAT you can use the function ``get_ixxat_hwids()`` as demonstrated below: + +In case you have connected multiple IXXAT devices, you have to select them by using their unique hardware id. +The function :meth:`~can.detect_available_configs` can be used to generate a list of :class:`~can.BusABC` constructors +(including the channel number and unique hardware ID number for the connected devices). .. testsetup:: ixxat + from unittest.mock import Mock + import can + assert hasattr(can, "detect_available_configs") + can.detect_available_configs = Mock( + "interface", + return_value=[{'interface': 'ixxat', 'channel': 0, 'unique_hardware_id': 'HW441489'}, {'interface': 'ixxat', 'channel': 0, 'unique_hardware_id': 'HW107422'}, {'interface': 'ixxat', 'channel': 1, 'unique_hardware_id': 'HW107422'}], + ) + + .. doctest:: ixxat + + >>> import can + >>> configs = can.detect_available_configs("ixxat") + >>> for config in configs: + ... print(config) + {'interface': 'ixxat', 'channel': 0, 'unique_hardware_id': 'HW441489'} + {'interface': 'ixxat', 'channel': 0, 'unique_hardware_id': 'HW107422'} + {'interface': 'ixxat', 'channel': 1, 'unique_hardware_id': 'HW107422'} + + +You may also get a list of all connected IXXAT devices using the function ``get_ixxat_hwids()`` as demonstrated below: + + .. testsetup:: ixxat2 + from unittest.mock import Mock import can.interfaces.ixxat assert hasattr(can.interfaces.ixxat, "get_ixxat_hwids") can.interfaces.ixxat.get_ixxat_hwids = Mock(side_effect=lambda: ['HW441489', 'HW107422']) - .. doctest:: ixxat + .. doctest:: ixxat2 >>> from can.interfaces.ixxat import get_ixxat_hwids >>> for hwid in get_ixxat_hwids(): diff --git a/doc/interfaces/neovi.rst b/doc/interfaces/neovi.rst index 0baf08055..bc711d86b 100644 --- a/doc/interfaces/neovi.rst +++ b/doc/interfaces/neovi.rst @@ -23,7 +23,7 @@ package. - Install ``python-can`` with the ``neovi`` extras: .. code-block:: bash - pip install python-ics[neovi] + pip install python-can[neovi] Configuration diff --git a/doc/interfaces/pcan.rst b/doc/interfaces/pcan.rst index 790264627..48e7dba05 100644 --- a/doc/interfaces/pcan.rst +++ b/doc/interfaces/pcan.rst @@ -15,7 +15,7 @@ Here is an example configuration file for using `PCAN-USB `__ is part of the -`Linux-CAN `__ project, providing a +`Socketcand `__ is part of the +`Linux-CAN `__ project, providing a Network-to-CAN bridge as a Linux damon. It implements a specific `TCP/IP based communication protocol `__ to transfer CAN frames and control commands. @@ -11,7 +11,7 @@ to transfer CAN frames and control commands. The main advantage compared to UDP-based protocols (e.g. virtual interface) is, that TCP guarantees delivery and that the message order is kept. -Here is a small example dumping all can messages received by a socketcand +Here is a small example dumping all can messages received by a socketcand daemon running on a remote Raspberry Pi: .. code-block:: python @@ -37,6 +37,43 @@ The output may look like this:: Timestamp: 1637791111.609763 ID: 0000031d X Rx DLC: 8 16 27 d8 3d fe d8 31 24 Timestamp: 1637791111.634630 ID: 00000587 X Rx DLC: 8 4e 06 85 23 6f 81 2b 65 + +This interface also supports :meth:`~can.detect_available_configs`. + +.. code-block:: python + + import can + import can.interfaces.socketcand + + cfg = can.interfaces.socketcand._detect_available_configs() + if cfg: + bus = can.Bus(**cfg[0]) + +The socketcand daemon broadcasts UDP beacons every 3 seconds. The default +detection method waits for slightly more than 3 seconds to receive the beacon +packet. If you want to increase the timeout, you can use +:meth:`can.interfaces.socketcand.detect_beacon` directly. Below is an example +which detects the beacon and uses the configuration to create a socketcand bus. + +.. code-block:: python + + import can + import can.interfaces.socketcand + + cfg = can.interfaces.socketcand.detect_beacon(6000) + if cfg: + bus = can.Bus(**cfg[0]) + +Bus +--- + +.. autoclass:: can.interfaces.socketcand.SocketCanDaemonBus + :show-inheritance: + :member-order: bysource + :members: + +.. autofunction:: can.interfaces.socketcand.detect_beacon + Socketcand Quickstart --------------------- diff --git a/doc/interfaces/udp_multicast.rst b/doc/interfaces/udp_multicast.rst index 4f9745615..b2a049d83 100644 --- a/doc/interfaces/udp_multicast.rst +++ b/doc/interfaces/udp_multicast.rst @@ -22,6 +22,15 @@ sufficiently reliable for this interface to function properly. Please refer to the `Bus class documentation`_ below for configuration options and useful resources for specifying multicast IP addresses. +Installation +------------------- + +The Multicast IP Interface depends on the **msgpack** python library, +which is automatically installed with the `multicast` extra keyword:: + + $ pip install python-can[multicast] + + Supported Platforms ------------------- @@ -53,6 +62,9 @@ from ``bus_1`` to ``bus_2``: # give the notifier enough time to get triggered by the second bus time.sleep(2.0) + # clean-up + notifier.stop() + Bus Class Documentation ----------------------- diff --git a/doc/internal-api.rst b/doc/internal-api.rst index b8c108fb5..9e544f052 100644 --- a/doc/internal-api.rst +++ b/doc/internal-api.rst @@ -57,23 +57,32 @@ They **might** implement the following: and thus might not provide message filtering: -Concrete instances are usually created by :class:`can.Bus` which takes the users +Concrete instances are usually created by :func:`can.Bus` which takes the users configuration into account. Bus Internals ~~~~~~~~~~~~~ -Several methods are not documented in the main :class:`can.Bus` +Several methods are not documented in the main :class:`can.BusABC` as they are primarily useful for library developers as opposed to -library users. This is the entire ABC bus class with all internal -methods: +library users. -.. autoclass:: can.BusABC - :members: - :private-members: - :special-members: +.. automethod:: can.BusABC.__init__ + +.. automethod:: can.BusABC.__iter__ + +.. automethod:: can.BusABC.__str__ + +.. autoattribute:: can.BusABC.__weakref__ +.. automethod:: can.BusABC._recv_internal + +.. automethod:: can.BusABC._apply_filters + +.. automethod:: can.BusABC._send_periodic_internal + +.. automethod:: can.BusABC._detect_available_configs About the IO module @@ -81,8 +90,8 @@ About the IO module Handling of the different file formats is implemented in ``can.io``. Each file/IO type is within a separate module and ideally implements both a *Reader* and a *Writer*. -The reader usually extends :class:`can.io.generic.BaseIOHandler`, while -the writer often additionally extends :class:`can.Listener`, +The reader extends :class:`can.io.generic.MessageReader`, while the writer extends +:class:`can.io.generic.MessageWriter`, a subclass of the :class:`can.Listener`, to be able to be passed directly to a :class:`can.Notifier`. @@ -95,9 +104,9 @@ Ideally add both reading and writing support for the new file format, although t 1. Create a new module: *can/io/canstore.py* (*or* simply copy some existing one like *can/io/csv.py*) -2. Implement a reader ``CanstoreReader`` (which often extends :class:`can.io.generic.BaseIOHandler`, but does not have to). +2. Implement a reader ``CanstoreReader`` which extends :class:`can.io.generic.MessageReader`. Besides from a constructor, only ``__iter__(self)`` needs to be implemented. -3. Implement a writer ``CanstoreWriter`` (which often extends :class:`can.io.generic.BaseIOHandler` and :class:`can.Listener`, but does not have to). +3. Implement a writer ``CanstoreWriter`` which extends :class:`can.io.generic.MessageWriter`. Besides from a constructor, only ``on_message_received(self, msg)`` needs to be implemented. 4. Add a case to ``can.io.player.LogReader``'s ``__new__()``. 5. Document the two new classes (and possibly additional helpers) with docstrings and comments. @@ -117,7 +126,10 @@ IO Utilities .. automodule:: can.io.generic + :show-inheritance: :members: + :private-members: + :member-order: bysource diff --git a/doc/message.rst b/doc/message.rst index d47473e17..e0003cfe5 100644 --- a/doc/message.rst +++ b/doc/message.rst @@ -44,7 +44,7 @@ Message 2\ :sup:`29` - 1 for 29-bit identifiers). >>> print(Message(is_extended_id=False, arbitration_id=100)) - Timestamp: 0.000000 ID: 0064 S Rx DL: 0 + Timestamp: 0.000000 ID: 064 S Rx DL: 0 .. attribute:: data @@ -106,7 +106,7 @@ Message Previously this was exposed as `id_type`. >>> print(Message(is_extended_id=False)) - Timestamp: 0.000000 ID: 0000 S Rx DL: 0 + Timestamp: 0.000000 ID: 000 S Rx DL: 0 >>> print(Message(is_extended_id=True)) Timestamp: 0.000000 ID: 00000000 X Rx DL: 0 diff --git a/doc/notifier.rst b/doc/notifier.rst new file mode 100644 index 000000000..e1b160a6e --- /dev/null +++ b/doc/notifier.rst @@ -0,0 +1,87 @@ +Notifier and Listeners +====================== + +.. _notifier: + +Notifier +-------- + +The Notifier object is used as a message distributor for a bus. The Notifier +uses an event loop or creates a thread to read messages from the bus and +distributes them to listeners. + +.. autoclass:: can.Notifier + :members: + +.. _listeners_doc: + +Listener +-------- + +The Listener class is an "abstract" base class for any objects which wish to +register to receive notifications of new messages on the bus. A Listener can +be used in two ways; the default is to **call** the Listener with a new +message, or by calling the method **on_message_received**. + +Listeners are registered with :ref:`notifier` object(s) which ensure they are +notified whenever a new message is received. + +.. literalinclude:: ../examples/print_notifier.py + :language: python + :linenos: + :emphasize-lines: 8,9 + + +Subclasses of Listener that do not override **on_message_received** will cause +:class:`NotImplementedError` to be thrown when a message is received on +the CAN bus. + +.. autoclass:: can.Listener + :members: + +There are some listeners that already ship together with `python-can` +and are listed below. +Some of them allow messages to be written to files, and the corresponding file +readers are also documented here. + +.. note :: + + Please note that writing and the reading a message might not always yield a + completely unchanged message again, since some properties are not (yet) + supported by some file formats. + +.. note :: + + Additional file formats for both reading/writing log files can be added via + a plugin reader/writer. An external package can register a new reader + by using the ``can.io.message_reader`` entry point. Similarly, a writer can + be added using the ``can.io.message_writer`` entry point. + + The format of the entry point is ``reader_name=module:classname`` where ``classname`` + is a concrete implementation of :class:`~can.io.generic.MessageReader` or + :class:`~can.io.generic.MessageWriter`. + + :: + + entry_points={ + 'can.io.message_reader': [ + '.asc = my_package.io.asc:ASCReader' + ] + }, + + +BufferedReader +-------------- + +.. autoclass:: can.BufferedReader + :members: + +.. autoclass:: can.AsyncBufferedReader + :members: + + +RedirectReader +-------------- + +.. autoclass:: can.RedirectReader + :members: diff --git a/doc/other-tools.rst b/doc/other-tools.rst index eab3c4f43..607db6c8a 100644 --- a/doc/other-tools.rst +++ b/doc/other-tools.rst @@ -1,4 +1,4 @@ -Other CAN bus tools +Other CAN Bus Tools =================== In order to keep the project maintainable, the scope of the package is limited to providing common @@ -47,7 +47,7 @@ CAN Frame Parsing tools etc. (implemented in Python) #. CAN Message / Database scripting * The `cantools`_ package provides multiple methods for interacting with can message database - files, and using these files to monitor live busses with a command line monitor tool. + files, and using these files to monitor live buses with a command line monitor tool. #. CAN Message / Log Decoding * The `canmatrix`_ module provides methods for converting between multiple popular message frame definition file formats (e.g. .DBC files, .KCD files, .ARXML files etc.). diff --git a/doc/plugin-interface.rst b/doc/plugin-interface.rst index bab8c85a9..612148033 100644 --- a/doc/plugin-interface.rst +++ b/doc/plugin-interface.rst @@ -62,16 +62,38 @@ The table below lists interface drivers that can be added by installing addition .. note:: The packages listed below are maintained by other authors. Any issues should be reported in their corresponding repository and **not** in the python-can repository. -+----------------------------+-------------------------------------------------------+ -| Name | Description | -+============================+=======================================================+ -| `python-can-cvector`_ | Cython based version of the 'VectorBus' | -+----------------------------+-------------------------------------------------------+ -| `python-can-remote`_ | CAN over network bridge | -+----------------------------+-------------------------------------------------------+ -| `python-can-sontheim`_ | CAN Driver for Sontheim CAN interfaces (e.g. CANfox) | -+----------------------------+-------------------------------------------------------+ - ++----------------------------+----------------------------------------------------------+ +| Name | Description | ++============================+==========================================================+ +| `python-can-canine`_ | CAN Driver for the CANine CAN interface | ++----------------------------+----------------------------------------------------------+ +| `python-can-cvector`_ | Cython based version of the 'VectorBus' | ++----------------------------+----------------------------------------------------------+ +| `python-can-remote`_ | CAN over network bridge | ++----------------------------+----------------------------------------------------------+ +| `python-can-sontheim`_ | CAN Driver for Sontheim CAN interfaces (e.g. CANfox) | ++----------------------------+----------------------------------------------------------+ +| `zlgcan`_ | Python wrapper for zlgcan-driver-rs | ++----------------------------+----------------------------------------------------------+ +| `python-can-cando`_ | Python wrapper for Netronics' CANdo and CANdoISO | ++----------------------------+----------------------------------------------------------+ +| `python-can-candle`_ | A full-featured driver for candleLight | ++----------------------------+----------------------------------------------------------+ +| `python-can-coe`_ | A CAN-over-Ethernet interface for Technische Alternative | ++----------------------------+----------------------------------------------------------+ +| `RP1210`_ | CAN channels in RP1210 Vehicle Diagnostic Adapters | ++----------------------------+----------------------------------------------------------+ +| `python-can-damiao`_ | Interface for Damiao USB-CAN adapters | ++----------------------------+----------------------------------------------------------+ + +.. _python-can-canine: https://github.com/tinymovr/python-can-canine .. _python-can-cvector: https://github.com/zariiii9003/python-can-cvector .. _python-can-remote: https://github.com/christiansandberg/python-can-remote .. _python-can-sontheim: https://github.com/MattWoodhead/python-can-sontheim +.. _zlgcan: https://github.com/jesses2025smith/zlgcan-driver +.. _python-can-cando: https://github.com/belliriccardo/python-can-cando +.. _python-can-candle: https://github.com/BIRLab/python-can-candle +.. _python-can-coe: https://c0d3.sh/smarthome/python-can-coe +.. _RP1210: https://github.com/dfieschko/RP1210 +.. _python-can-damiao: https://github.com/gaoyichuan/python-can-damiao + diff --git a/doc/scripts.rst b/doc/scripts.rst index 5a615afa7..1d730a74b 100644 --- a/doc/scripts.rst +++ b/doc/scripts.rst @@ -1,9 +1,9 @@ -Scripts -======= +Command Line Tools +================== The following modules are callable from ``python-can``. -They can be called for example by ``python -m can.logger`` or ``can_logger.py`` (if installed using pip). +They can be called for example by ``python -m can.logger`` or ``can_logger`` (if installed using pip). can.logger ---------- @@ -12,12 +12,14 @@ Command line help, called with ``--help``: .. command-output:: python -m can.logger -h + :shell: can.player ---------- .. command-output:: python -m can.player -h + :shell: can.viewer @@ -52,9 +54,20 @@ By default the ``can.viewer`` uses the :doc:`/interfaces/socketcan` interface. A The full usage page can be seen below: .. command-output:: python -m can.viewer -h + :shell: + + +can.bridge +---------- + +A small application that can be used to connect two can buses: + +.. command-output:: python -m can.bridge -h + :shell: can.logconvert -------------- .. command-output:: python -m can.logconvert -h + :shell: diff --git a/doc/utils.rst b/doc/utils.rst index a87d411a9..9c742e2fb 100644 --- a/doc/utils.rst +++ b/doc/utils.rst @@ -4,4 +4,7 @@ Utilities .. autofunction:: can.detect_available_configs +.. autofunction:: can.cli.add_bus_arguments + +.. autofunction:: can.cli.create_bus_from_namespace diff --git a/examples/asyncio_demo.py b/examples/asyncio_demo.py index d29f03bc5..6befbe7a9 100755 --- a/examples/asyncio_demo.py +++ b/examples/asyncio_demo.py @@ -5,10 +5,12 @@ """ import asyncio -from typing import List +from typing import TYPE_CHECKING import can -from can.notifier import MessageRecipient + +if TYPE_CHECKING: + from can.notifier import MessageRecipient def print_message(msg: can.Message) -> None: @@ -25,32 +27,28 @@ async def main() -> None: reader = can.AsyncBufferedReader() logger = can.Logger("logfile.asc") - listeners: List[MessageRecipient] = [ + listeners: list[MessageRecipient] = [ print_message, # Callback function reader, # AsyncBufferedReader() listener logger, # Regular Listener object ] # Create Notifier with an explicit loop to use for scheduling of callbacks - loop = asyncio.get_running_loop() - notifier = can.Notifier(bus, listeners, loop=loop) - # Start sending first message - bus.send(can.Message(arbitration_id=0)) - - print("Bouncing 10 messages...") - for _ in range(10): - # Wait for next message from AsyncBufferedReader - msg = await reader.get_message() - # Delay response - await asyncio.sleep(0.5) - msg.arbitration_id += 1 - bus.send(msg) - - # Wait for last message to arrive - await reader.get_message() - print("Done!") - - # Clean-up - notifier.stop() + with can.Notifier(bus, listeners, loop=asyncio.get_running_loop()): + # Start sending first message + bus.send(can.Message(arbitration_id=0)) + + print("Bouncing 10 messages...") + for _ in range(10): + # Wait for next message from AsyncBufferedReader + msg = await reader.get_message() + # Delay response + await asyncio.sleep(0.5) + msg.arbitration_id += 1 + bus.send(msg) + + # Wait for last message to arrive + await reader.get_message() + print("Done!") if __name__ == "__main__": diff --git a/examples/cyclic_checksum.py b/examples/cyclic_checksum.py new file mode 100644 index 000000000..763fcd72b --- /dev/null +++ b/examples/cyclic_checksum.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python + +""" +This example demonstrates how to send a periodic message containing +an automatically updating counter and checksum. + +Expects a virtual interface: + + python3 -m examples.cyclic_checksum +""" + +import logging +import time + +import can + +logging.basicConfig(level=logging.INFO) + + +def cyclic_checksum_send(bus: can.BusABC) -> None: + """ + Sends periodic messages every 1 s with no explicit timeout. + The message's counter and checksum is updated before each send. + Sleeps for 10 seconds then stops the task. + """ + message = can.Message(arbitration_id=0x78, data=[0, 1, 2, 3, 4, 5, 6, 0]) + print("Starting to send an auto-updating message every 100ms for 3 s") + task = bus.send_periodic(msgs=message, period=0.1, modifier_callback=update_message) + time.sleep(3) + task.stop() + print("stopped cyclic send") + + +def update_message(message: can.Message) -> None: + counter = increment_counter(message) + checksum = compute_xbr_checksum(message, counter) + message.data[7] = (checksum << 4) + counter + + +def increment_counter(message: can.Message) -> int: + counter = message.data[7] & 0x0F + counter += 1 + counter %= 16 + + return counter + + +def compute_xbr_checksum(message: can.Message, counter: int) -> int: + """ + Computes an XBR checksum as per SAE J1939 SPN 3188. + """ + checksum = sum(message.data[:7]) + checksum += sum(message.arbitration_id.to_bytes(length=4, byteorder="big")) + checksum += counter & 0x0F + xbr_checksum = ((checksum >> 4) + checksum) & 0x0F + + return xbr_checksum + + +if __name__ == "__main__": + with can.Bus(channel=0, interface="virtual", receive_own_messages=True) as _bus: + with can.Notifier(bus=_bus, listeners=[print]): + cyclic_checksum_send(_bus) diff --git a/examples/print_notifier.py b/examples/print_notifier.py index b6554ccd2..e6e11dbec 100755 --- a/examples/print_notifier.py +++ b/examples/print_notifier.py @@ -1,20 +1,20 @@ #!/usr/bin/env python import time + import can def main(): - - with can.Bus(receive_own_messages=True) as bus: + with can.Bus(interface="virtual", receive_own_messages=True) as bus: print_listener = can.Printer() - can.Notifier(bus, [print_listener]) - - bus.send(can.Message(arbitration_id=1, is_extended_id=True)) - bus.send(can.Message(arbitration_id=2, is_extended_id=True)) - bus.send(can.Message(arbitration_id=1, is_extended_id=False)) - - time.sleep(1.0) + with can.Notifier(bus, listeners=[print_listener]): + # using Notifier as a context manager automatically calls `Notifier.stop()` + # at the end of the `with` block + bus.send(can.Message(arbitration_id=1, is_extended_id=True)) + bus.send(can.Message(arbitration_id=2, is_extended_id=True)) + bus.send(can.Message(arbitration_id=1, is_extended_id=False)) + time.sleep(1.0) if __name__ == "__main__": diff --git a/examples/receive_all.py b/examples/receive_all.py index d8d8714fc..e9410e49f 100755 --- a/examples/receive_all.py +++ b/examples/receive_all.py @@ -14,7 +14,6 @@ def receive_all(): # this uses the default configuration (for example from environment variables, or a # config file) see https://python-can.readthedocs.io/en/stable/configuration.html with can.Bus() as bus: - # set to read-only, only supported on some interfaces try: bus.state = BusState.PASSIVE diff --git a/examples/send_multiple.py b/examples/send_multiple.py index fdcaa5b59..9123e1bc8 100755 --- a/examples/send_multiple.py +++ b/examples/send_multiple.py @@ -4,8 +4,8 @@ This demo creates multiple processes of producers to spam a socketcan bus. """ -from time import sleep from concurrent.futures import ProcessPoolExecutor +from time import sleep import can diff --git a/examples/send_one.py b/examples/send_one.py index 7e3fb8a4c..41b3a3cd0 100755 --- a/examples/send_one.py +++ b/examples/send_one.py @@ -13,7 +13,6 @@ def send_one(): # this uses the default configuration (for example from the config file) # see https://python-can.readthedocs.io/en/stable/configuration.html with can.Bus() as bus: - # Using specific buses works similar: # bus = can.Bus(interface='socketcan', channel='vcan0', bitrate=250000) # bus = can.Bus(interface='pcan', channel='PCAN_USBBUS1', bitrate=250000) diff --git a/examples/serial_com.py b/examples/serial_com.py index 76b95c3e7..9f203b2e0 100755 --- a/examples/serial_com.py +++ b/examples/serial_com.py @@ -18,8 +18,8 @@ com0com: http://com0com.sourceforge.net/ """ -import time import threading +import time import can @@ -50,7 +50,6 @@ def main(): """Controls the sender and receiver.""" with can.Bus(interface="serial", channel="/dev/ttyS10") as server: with can.Bus(interface="serial", channel="/dev/ttyS11") as client: - tx_msg = can.Message( arbitration_id=0x01, data=[0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88], diff --git a/examples/simple_log_converter.py b/examples/simple_log_converter.py index e82669f54..20e8fba75 100755 --- a/examples/simple_log_converter.py +++ b/examples/simple_log_converter.py @@ -17,8 +17,7 @@ def main(): with can.LogReader(sys.argv[1]) as reader: with can.Logger(sys.argv[2]) as writer: - - for msg in reader: # pylint: disable=not-an-iterable + for msg in reader: writer.on_message_received(msg) diff --git a/examples/vcan_filtered.py b/examples/vcan_filtered.py index d48a9d8cb..22bca706c 100755 --- a/examples/vcan_filtered.py +++ b/examples/vcan_filtered.py @@ -12,20 +12,17 @@ def main(): """Send some messages to itself and apply filtering.""" with can.Bus(interface="virtual", receive_own_messages=True) as bus: - can_filters = [{"can_id": 1, "can_mask": 0xF, "extended": True}] bus.set_filters(can_filters) # print all incoming messages, which includes the ones sent, # since we set receive_own_messages to True # assign to some variable so it does not garbage collected - notifier = can.Notifier(bus, [can.Printer()]) # pylint: disable=unused-variable - - bus.send(can.Message(arbitration_id=1, is_extended_id=True)) - bus.send(can.Message(arbitration_id=2, is_extended_id=True)) - bus.send(can.Message(arbitration_id=1, is_extended_id=False)) - - time.sleep(1.0) + with can.Notifier(bus, [can.Printer()]): # pylint: disable=unused-variable + bus.send(can.Message(arbitration_id=1, is_extended_id=True)) + bus.send(can.Message(arbitration_id=2, is_extended_id=True)) + bus.send(can.Message(arbitration_id=1, is_extended_id=False)) + time.sleep(1.0) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 17d87f033..94bf60823 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,256 @@ [build-system] -requires = [ - "setuptools >= 40.8", - "wheel", -] +requires = ["setuptools >= 77.0", "setuptools_scm>=8"] build-backend = "setuptools.build_meta" + +[project] +name = "python-can" +dynamic = ["readme", "version"] +description = "Controller Area Network interface module for Python" +authors = [{ name = "python-can contributors" }] +dependencies = [ + "wrapt >= 1.10, < 3", + "packaging >= 23.1", + "typing_extensions>=3.10.0.0", +] +requires-python = ">=3.10" +license = "LGPL-3.0-only" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Education", + "Intended Audience :: Information Technology", + "Intended Audience :: Manufacturing", + "Intended Audience :: Telecommunications Industry", + "Natural Language :: English", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Embedded Systems", + "Topic :: Software Development :: Embedded Systems :: Controller Area Network (CAN)", + "Topic :: System :: Hardware :: Hardware Drivers", + "Topic :: System :: Logging", + "Topic :: System :: Monitoring", + "Topic :: System :: Networking", + "Topic :: Utilities", +] + +[project.scripts] +can_logconvert = "can.logconvert:main" +can_logger = "can.logger:main" +can_player = "can.player:main" +can_viewer = "can.viewer:main" +can_bridge = "can.bridge:main" + +[project.urls] +homepage = "https://github.com/hardbyte/python-can" +documentation = "https://python-can.readthedocs.io" +repository = "https://github.com/hardbyte/python-can" +changelog = "https://github.com/hardbyte/python-can/blob/develop/CHANGELOG.md" + +[project.optional-dependencies] +pywin32 = ["pywin32>=305; platform_system == 'Windows' and platform_python_implementation == 'CPython'"] +seeedstudio = ["pyserial>=3.0"] +serial = ["pyserial~=3.0"] +neovi = ["filelock", "python-ics>=2.12"] +canalystii = ["canalystii>=0.1.0"] +cantact = ["cantact>=0.0.7"] +cvector = ["python-can-cvector"] +gs-usb = ["gs-usb>=0.2.1"] +nixnet = ["nixnet>=0.3.2"] +pcan = ["uptime~=3.0.1"] +remote = ["python-can-remote"] +sontheim = ["python-can-sontheim>=0.1.2"] +canine = ["python-can-canine>=0.2.2"] +zlgcan = ["zlgcan"] +candle = ["python-can-candle>=1.2.2"] +rp1210 = ["rp1210>=1.0.1"] +damiao = ["python-can-damiao"] +viewer = [ + "windows-curses; platform_system == 'Windows' and platform_python_implementation=='CPython'" +] +mf4 = ["asammdf>=6.0.0"] +multicast = ["msgpack~=1.1.0"] + +[dependency-groups] +docs = [ + "sphinx>=5.2.3", + "sphinxcontrib-programoutput", + "sphinx-inline-tabs", + "sphinx-copybutton", + "furo", +] +lint = [ + "pylint==4.0.*", + "ruff==0.14.*", + "black==25.11.*", + "mypy==1.19.*", +] +test = [ + "pytest==9.0.*", + "pytest-timeout==2.4.*", + "pytest-modern==0.7.*;platform_system!='Windows'", + "coveralls==4.0.*", + "pytest-cov==7.0.*", + "coverage==7.12.*", + "hypothesis==6.*", + "parameterized==0.9.*", +] +dev = [ + {include-group = "docs"}, + {include-group = "lint"}, + {include-group = "test"}, +] + +[tool.setuptools.dynamic] +readme = { file = "README.rst" } +[tool.setuptools.package-data] +"*" = ["README.rst", "CONTRIBUTORS.txt", "LICENSE.txt", "CHANGELOG.md"] +doc = ["*.*"] +examples = ["*.py"] +can = ["py.typed"] + +[tool.setuptools.packages.find] +include = ["can*"] + +[tool.setuptools_scm] +# can be empty if no extra settings are needed, presence enables setuptools_scm + +[tool.mypy] +warn_return_any = true +warn_unused_configs = true +ignore_missing_imports = true +no_implicit_optional = true +disallow_incomplete_defs = true +warn_redundant_casts = true +warn_unused_ignores = true +exclude = [ + "^build", + "^doc/conf.py$", + "^test", + "^can/interfaces/etas", + "^can/interfaces/gs_usb", + "^can/interfaces/ics_neovi", + "^can/interfaces/iscan", + "^can/interfaces/ixxat", + "^can/interfaces/kvaser", + "^can/interfaces/nican", + "^can/interfaces/neousys", + "^can/interfaces/pcan", + "^can/interfaces/socketcan", + "^can/interfaces/systec", + "^can/interfaces/usb2can", +] + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +extend-select = [ + "A", # flake8-builtins + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "F", # pyflakes + "E", # pycodestyle errors + "I", # isort + "N", # pep8-naming + "PGH", # pygrep-hooks + "PL", # pylint + "RUF", # ruff-specific rules + "T20", # flake8-print + "TCH", # flake8-type-checking + "UP", # pyupgrade + "W", # pycodestyle warnings + "YTT", # flake8-2020 +] +ignore = [ + "B026", # star-arg-unpacking-after-keyword-arg + "PLR", # pylint refactor +] + +[tool.ruff.lint.per-file-ignores] +"can/interfaces/*" = [ + "E501", # Line too long + "F403", # undefined-local-with-import-star + "F405", # undefined-local-with-import-star-usage + "N", # pep8-naming + "PGH003", # blanket-type-ignore + "RUF012", # mutable-class-default +] +"can/cli.py" = ["T20"] # flake8-print +"can/logger.py" = ["T20"] # flake8-print +"can/player.py" = ["T20"] # flake8-print +"can/bridge.py" = ["T20"] # flake8-print +"can/viewer.py" = ["T20"] # flake8-print +"examples/*" = ["T20"] # flake8-print + +[tool.ruff.lint.isort] +known-first-party = ["can"] + +[tool.pylint] +extension-pkg-allow-list = ["curses"] +disable = [ + "cyclic-import", + "duplicate-code", + "fixme", + "invalid-name", + "missing-class-docstring", + "missing-function-docstring", + "missing-module-docstring", + "no-else-raise", + "no-else-return", + "too-few-public-methods", + "too-many-arguments", + "too-many-branches", + "too-many-instance-attributes", + "too-many-locals", + "too-many-positional-arguments", + "too-many-public-methods", + "too-many-statements", +] + +[tool.towncrier] +directory = "doc/changelog.d" +filename = "CHANGELOG.md" +start_string = "\n" +underlines = ["", "", ""] +title_format = "## Version [{version}](https://github.com/hardbyte/python-can/tree/{version}) - {project_date}" +issue_format = "[#{issue}](https://github.com/hardbyte/python-can/issues/{issue})" + +[[tool.towncrier.type]] +directory = "security" +name = "Security" +showcontent = true + +[[tool.towncrier.type]] +directory = "removed" +name = "Removed" +showcontent = true + +[[tool.towncrier.type]] +directory = "deprecated" +name = "Deprecated" +showcontent = true + +[[tool.towncrier.type]] +directory = "added" +name = "Added" +showcontent = true + +[[tool.towncrier.type]] +directory = "changed" +name = "Changed" +showcontent = true + +[[tool.towncrier.type]] +directory = "fixed" +name = "Fixed" +showcontent = true diff --git a/requirements-lint.txt b/requirements-lint.txt deleted file mode 100644 index 28bcf2aa2..000000000 --- a/requirements-lint.txt +++ /dev/null @@ -1,5 +0,0 @@ -pylint==2.15.9 -black~=22.10.0 -mypy==0.991 -mypy-extensions==0.4.3 -types-setuptools diff --git a/scripts/can_logconvert.py b/scripts/can_logconvert.py deleted file mode 100644 index 3cd8839a2..000000000 --- a/scripts/can_logconvert.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python - -""" -See :mod:`can.logconvert`. -""" - -from can.logconvert import main - - -if __name__ == "__main__": - main() diff --git a/scripts/can_logger.py b/scripts/can_logger.py deleted file mode 100644 index 4202448e6..000000000 --- a/scripts/can_logger.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python - -""" -See :mod:`can.logger`. -""" - -from can.logger import main - - -if __name__ == "__main__": - main() diff --git a/scripts/can_player.py b/scripts/can_player.py deleted file mode 100644 index 1fe44175d..000000000 --- a/scripts/can_player.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python - -""" -See :mod:`can.player`. -""" - -from can.player import main - - -if __name__ == "__main__": - main() diff --git a/scripts/can_viewer.py b/scripts/can_viewer.py deleted file mode 100644 index eef990b0e..000000000 --- a/scripts/can_viewer.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python - -""" -See :mod:`can.viewer`. -""" - -from can.viewer import main - - -if __name__ == "__main__": - main() diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 2f3ee032f..000000000 --- a/setup.cfg +++ /dev/null @@ -1,35 +0,0 @@ -[metadata] -license_files = LICENSE.txt - -[mypy] -warn_return_any = True -warn_unused_configs = True -ignore_missing_imports = True -no_implicit_optional = True -disallow_incomplete_defs = True -warn_redundant_casts = True -warn_unused_ignores = True -exclude = - (?x)( - venv - |^doc/conf.py$ - |^test - |^setup.py$ - |^can/interfaces/__init__.py - |^can/interfaces/etas - |^can/interfaces/gs_usb - |^can/interfaces/ics_neovi - |^can/interfaces/iscan - |^can/interfaces/ixxat - |^can/interfaces/kvaser - |^can/interfaces/nican - |^can/interfaces/neousys - |^can/interfaces/pcan - |^can/interfaces/serial - |^can/interfaces/slcan - |^can/interfaces/socketcan - |^can/interfaces/systec - |^can/interfaces/udp_multicast - |^can/interfaces/usb2can - |^can/interfaces/virtual - ) diff --git a/setup.py b/setup.py deleted file mode 100644 index 8cabc8c02..000000000 --- a/setup.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python - -""" -Setup script for the `can` package. -Learn more at https://github.com/hardbyte/python-can/ -""" - -# pylint: disable=invalid-name - -from os import listdir -from os.path import isfile, join -import re -import logging -from setuptools import setup, find_packages - -logging.basicConfig(level=logging.WARNING) - -with open("can/__init__.py", encoding="utf-8") as fd: - version = re.search( - r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', fd.read(), re.MULTILINE - ).group(1) - -with open("README.rst", encoding="utf-8") as f: - long_description = f.read() - -# Dependencies -extras_require = { - "seeedstudio": ["pyserial>=3.0"], - "serial": ["pyserial~=3.0"], - "neovi": ["filelock", "python-ics>=2.12"], - "canalystii": ["canalystii>=0.1.0"], - "cantact": ["cantact>=0.0.7"], - "cvector": ["python-can-cvector"], - "gs_usb": ["gs_usb>=0.2.1"], - "nixnet": ["nixnet>=0.3.2"], - "pcan": ["uptime~=3.0.1"], - "remote": ["python-can-remote"], - "sontheim": ["python-can-sontheim>=0.1.2"], - "viewer": [ - 'windows-curses;platform_system=="Windows" and platform_python_implementation=="CPython"' - ], -} - -setup( - # Description - name="python-can", - url="https://github.com/hardbyte/python-can", - description="Controller Area Network interface module for Python", - long_description=long_description, - long_description_content_type="text/x-rst", - classifiers=[ - # a list of all available ones: https://pypi.org/classifiers/ - "Programming Language :: Python", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Natural Language :: English", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", - "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", - "Operating System :: MacOS", - "Operating System :: POSIX :: Linux", - "Operating System :: Microsoft :: Windows", - "Development Status :: 5 - Production/Stable", - "Environment :: Console", - "Intended Audience :: Developers", - "Intended Audience :: Education", - "Intended Audience :: Information Technology", - "Intended Audience :: Manufacturing", - "Intended Audience :: Telecommunications Industry", - "Natural Language :: English", - "Topic :: System :: Logging", - "Topic :: System :: Monitoring", - "Topic :: System :: Networking", - "Topic :: System :: Hardware :: Hardware Drivers", - "Topic :: Utilities", - ], - version=version, - packages=find_packages(exclude=["test*", "doc", "scripts", "examples"]), - scripts=list(filter(isfile, (join("scripts/", f) for f in listdir("scripts/")))), - author="python-can contributors", - license="LGPL v3", - package_data={ - "": ["README.rst", "CONTRIBUTORS.txt", "LICENSE.txt", "CHANGELOG.md"], - "doc": ["*.*"], - "examples": ["*.py"], - "can": ["py.typed"], - }, - # Installation - # see https://www.python.org/dev/peps/pep-0345/#version-specifiers - python_requires=">=3.7", - install_requires=[ - "setuptools", - "wrapt~=1.10", - "typing_extensions>=3.10.0.0", - 'pywin32>=305;platform_system=="Windows" and platform_python_implementation=="CPython"', - 'msgpack~=1.0.0;platform_system!="Windows"', - "packaging", - ], - extras_require=extras_require, -) diff --git a/test/back2back_test.py b/test/back2back_test.py index 54d619878..ce7c39e2a 100644 --- a/test/back2back_test.py +++ b/test/back2back_test.py @@ -4,24 +4,24 @@ This module tests two buses attached to each other. """ +import random import unittest -from time import sleep, time from multiprocessing.dummy import Pool as ThreadPool -import random +from time import sleep, time import pytest import can +from can import CanInterfaceNotImplementedError from can.interfaces.udp_multicast import UdpMulticastBus +from can.interfaces.udp_multicast.utils import is_msgpack_installed from .config import ( IS_CI, - IS_UNIX, IS_OSX, - IS_TRAVIS, - TEST_INTERFACE_SOCKETCAN, - TEST_CAN_FD, IS_PYPY, + TEST_CAN_FD, + TEST_INTERFACE_SOCKETCAN, ) @@ -33,7 +33,7 @@ class Back2BackTestCase(unittest.TestCase): """ BITRATE = 500000 - TIMEOUT = 0.1 + TIMEOUT = 1.0 if IS_PYPY else 0.1 INTERFACE_1 = "virtual" CHANNEL_1 = "virtual_channel_0" @@ -115,7 +115,7 @@ def test_timestamp(self): self.assertTrue( 1.75 <= delta_time <= 2.25, "Time difference should have been 2s +/- 250ms." - "But measured {}".format(delta_time), + f"But measured {delta_time}", ) def test_standard_message(self): @@ -164,37 +164,33 @@ def test_message_is_rx(self): ) def test_message_is_rx_receive_own_messages(self): """The same as `test_message_direction` but testing with `receive_own_messages=True`.""" - bus3 = can.Bus( + with can.Bus( channel=self.CHANNEL_2, interface=self.INTERFACE_2, bitrate=self.BITRATE, fd=TEST_CAN_FD, single_handle=True, receive_own_messages=True, - ) - try: + ) as bus3: msg = can.Message( is_extended_id=False, arbitration_id=0x300, data=[2, 1, 3], is_rx=False ) bus3.send(msg) self_recv_msg_bus3 = bus3.recv(self.TIMEOUT) self.assertTrue(self_recv_msg_bus3.is_rx) - finally: - bus3.shutdown() def test_unique_message_instances(self): """Verify that we have a different instances of message for each bus even with `receive_own_messages=True`. """ - bus3 = can.Bus( + with can.Bus( channel=self.CHANNEL_2, interface=self.INTERFACE_2, bitrate=self.BITRATE, fd=TEST_CAN_FD, single_handle=True, receive_own_messages=True, - ) - try: + ) as bus3: msg = can.Message( is_extended_id=False, arbitration_id=0x300, data=[2, 1, 3] ) @@ -209,8 +205,6 @@ def test_unique_message_instances(self): recv_msg_bus1.data[0] = 4 self.assertNotEqual(recv_msg_bus1.data, recv_msg_bus2.data) self.assertEqual(recv_msg_bus2.data, self_recv_msg_bus3.data) - finally: - bus3.shutdown() def test_fd_message(self): msg = can.Message( @@ -272,10 +266,25 @@ def test_sub_second_timestamp_resolution(self): self.bus2.recv(0) self.bus2.recv(0) + def test_send_periodic_duration(self): + """ + Verify that send_periodic only transmits for the specified duration. + + Regression test for #1713. + """ + for duration, period in [(0.01, 0.003), (0.1, 0.011), (1, 0.4)]: + messages = [] + + self.bus2.send_periodic(can.Message(), period, duration) + while (msg := self.bus1.recv(period + self.TIMEOUT)) is not None: + messages.append(msg) + + delta_t = messages[-1].timestamp - messages[0].timestamp + assert delta_t < duration + 0.05 + @unittest.skipUnless(TEST_INTERFACE_SOCKETCAN, "skip testing of socketcan") class BasicTestSocketCan(Back2BackTestCase): - INTERFACE_1 = "socketcan" CHANNEL_1 = "vcan0" INTERFACE_2 = "socketcan" @@ -284,27 +293,34 @@ class BasicTestSocketCan(Back2BackTestCase): # this doesn't even work on Travis CI for macOS; for example, see # https://travis-ci.org/github/hardbyte/python-can/jobs/745389871 +@unittest.skipIf( + IS_CI and IS_OSX, + "not supported for macOS CI", +) @unittest.skipUnless( - IS_UNIX and not (IS_CI and IS_OSX), - "only supported on Unix systems (but not on macOS at Travis CI and GitHub Actions)", + is_msgpack_installed(raise_exception=False), + "msgpack not installed", ) class BasicTestUdpMulticastBusIPv4(Back2BackTestCase): - INTERFACE_1 = "udp_multicast" CHANNEL_1 = UdpMulticastBus.DEFAULT_GROUP_IPv4 INTERFACE_2 = "udp_multicast" CHANNEL_2 = UdpMulticastBus.DEFAULT_GROUP_IPv4 def test_unique_message_instances(self): - with self.assertRaises(NotImplementedError): + with self.assertRaises(CanInterfaceNotImplementedError): super().test_unique_message_instances() # this doesn't even work for loopback multicast addresses on Travis CI; for example, see # https://travis-ci.org/github/hardbyte/python-can/builds/745065503 +@unittest.skipIf( + IS_CI and IS_OSX, + "not supported for macOS CI", +) @unittest.skipUnless( - IS_UNIX and not (IS_TRAVIS or (IS_CI and IS_OSX)), - "only supported on Unix systems (but not on Travis CI; and not on macOS at GitHub Actions)", + is_msgpack_installed(raise_exception=False), + "msgpack not installed", ) class BasicTestUdpMulticastBusIPv6(Back2BackTestCase): HOST_LOCAL_MCAST_GROUP_IPv6 = "ff11:7079:7468:6f6e:6465:6d6f:6d63:6173" @@ -315,7 +331,7 @@ class BasicTestUdpMulticastBusIPv6(Back2BackTestCase): CHANNEL_2 = HOST_LOCAL_MCAST_GROUP_IPv6 def test_unique_message_instances(self): - with self.assertRaises(NotImplementedError): + with self.assertRaises(CanInterfaceNotImplementedError): super().test_unique_message_instances() @@ -323,13 +339,12 @@ def test_unique_message_instances(self): try: bus_class = can.interface._get_class_for_interface("etas") TEST_INTERFACE_ETAS = True -except can.exceptions.CanInterfaceNotImplementedError: +except CanInterfaceNotImplementedError: pass @unittest.skipUnless(TEST_INTERFACE_ETAS, "skip testing of etas interface") class BasicTestEtas(Back2BackTestCase): - if TEST_INTERFACE_ETAS: configs = can.interface.detect_available_configs(interfaces="etas") diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 000000000..c54238be1 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,24 @@ +import pytest + +from can.interfaces import virtual + + +@pytest.fixture(autouse=True) +def check_unclosed_virtual_channel(): + """ + Pytest fixture for detecting leaked virtual CAN channels. + + - The fixture yields control to the test. + - After the test completes, it acquires `virtual.channels_lock` and asserts + that `virtual.channels` is empty. + - If a test leaves behind any unclosed virtual CAN channels, the assertion + will fail, surfacing resource leaks early. + + This helps maintain test isolation and prevents subtle bugs caused by + leftover state between tests. + """ + + yield + + with virtual.channels_lock: + assert len(virtual.channels) == 0 diff --git a/test/contextmanager_test.py b/test/contextmanager_test.py index 014dfb121..fe87f33b0 100644 --- a/test/contextmanager_test.py +++ b/test/contextmanager_test.py @@ -5,6 +5,7 @@ """ import unittest + import can @@ -16,9 +17,10 @@ def setUp(self): ) def test_open_buses(self): - with can.Bus(interface="virtual") as bus_send, can.Bus( - interface="virtual" - ) as bus_recv: + with ( + can.Bus(interface="virtual") as bus_send, + can.Bus(interface="virtual") as bus_recv, + ): bus_send.send(self.msg_send) msg_recv = bus_recv.recv() @@ -26,9 +28,10 @@ def test_open_buses(self): self.assertTrue(msg_recv) def test_use_closed_bus(self): - with can.Bus(interface="virtual") as bus_send, can.Bus( - interface="virtual" - ) as bus_recv: + with ( + can.Bus(interface="virtual") as bus_send, + can.Bus(interface="virtual") as bus_recv, + ): bus_send.send(self.msg_send) # Receiving a frame after bus has been closed should raise a CanException diff --git a/test/data/example_data.py b/test/data/example_data.py index 0fa70993a..b78420e4e 100644 --- a/test/data/example_data.py +++ b/test/data/example_data.py @@ -33,48 +33,59 @@ def sort_messages(messages): [ Message( # empty + timestamp=1e-4, ), Message( # only data - data=[0x00, 0x42] + timestamp=2e-4, + data=[0x00, 0x42], ), Message( # no data + timestamp=3e-4, arbitration_id=0xAB, is_extended_id=False, ), Message( # no data + timestamp=4e-4, arbitration_id=0x42, is_extended_id=True, ), Message( # no data - arbitration_id=0xABCDEF + timestamp=5e-4, + arbitration_id=0xABCDEF, ), Message( # empty data - data=[] + timestamp=6e-4, + data=[], ), Message( # empty data - data=[0xFF, 0xFE, 0xFD] + timestamp=7e-4, + data=[0xFF, 0xFE, 0xFD], ), Message( # with channel as integer - channel=0 + timestamp=8e-4, + channel=0, ), Message( # with channel as integer - channel=42 + timestamp=9e-4, + channel=42, ), Message( # with channel as string - channel="vcan0" + timestamp=10e-4, + channel="vcan0", ), Message( # with channel as string - channel="awesome_channel" + timestamp=11e-4, + channel="awesome_channel", ), Message( arbitration_id=0xABCDEF, @@ -109,10 +120,10 @@ def sort_messages(messages): TEST_MESSAGES_CAN_FD = sort_messages( [ - Message(is_fd=True, data=range(64)), - Message(is_fd=True, data=range(8)), - Message(is_fd=True, data=range(8), bitrate_switch=True), - Message(is_fd=True, data=range(8), error_state_indicator=True), + Message(timestamp=12e-4, is_fd=True, data=range(64)), + Message(timestamp=13e-4, is_fd=True, data=range(8)), + Message(timestamp=14e-4, is_fd=True, data=range(8), bitrate_switch=True), + Message(timestamp=15e-4, is_fd=True, data=range(8), error_state_indicator=True), ] ) @@ -149,7 +160,7 @@ def sort_messages(messages): TEST_MESSAGES_ERROR_FRAMES = sort_messages( [ - Message(is_error_frame=True), + Message(is_error_frame=True, timestamp=TEST_TIME), Message(is_error_frame=True, timestamp=TEST_TIME + 0.170), Message(is_error_frame=True, timestamp=TEST_TIME + 17.157), ] diff --git a/test/data/ip_link_list.json b/test/data/ip_link_list.json new file mode 100644 index 000000000..a96313b43 --- /dev/null +++ b/test/data/ip_link_list.json @@ -0,0 +1,91 @@ +[ + { + "ifindex": 1, + "ifname": "lo", + "flags": [ + "LOOPBACK", + "UP", + "LOWER_UP" + ], + "mtu": 65536, + "qdisc": "noqueue", + "operstate": "UNKNOWN", + "linkmode": "DEFAULT", + "group": "default", + "txqlen": 1000, + "link_type": "loopback", + "address": "00:00:00:00:00:00", + "broadcast": "00:00:00:00:00:00" + }, + { + "ifindex": 2, + "ifname": "eth0", + "flags": [ + "NO-CARRIER", + "BROADCAST", + "MULTICAST", + "UP" + ], + "mtu": 1500, + "qdisc": "fq_codel", + "operstate": "DOWN", + "linkmode": "DEFAULT", + "group": "default", + "txqlen": 1000, + "link_type": "ether", + "address": "11:22:33:44:55:66", + "broadcast": "ff:ff:ff:ff:ff:ff" + }, + { + "ifindex": 3, + "ifname": "wlan0", + "flags": [ + "BROADCAST", + "MULTICAST", + "UP", + "LOWER_UP" + ], + "mtu": 1500, + "qdisc": "noqueue", + "operstate": "UP", + "linkmode": "DORMANT", + "group": "default", + "txqlen": 1000, + "link_type": "ether", + "address": "11:22:33:44:55:66", + "broadcast": "ff:ff:ff:ff:ff:ff" + }, + { + "ifindex": 48, + "ifname": "vcan0", + "flags": [ + "NOARP", + "UP", + "LOWER_UP" + ], + "mtu": 72, + "qdisc": "noqueue", + "operstate": "UNKNOWN", + "linkmode": "DEFAULT", + "group": "default", + "txqlen": 1000, + "link_type": "can" + }, + { + "ifindex": 50, + "ifname": "mycustomCan123", + "flags": [ + "NOARP", + "UP", + "LOWER_UP" + ], + "mtu": 72, + "qdisc": "noqueue", + "operstate": "UNKNOWN", + "linkmode": "DEFAULT", + "group": "default", + "txqlen": 1000, + "link_type": "can" + }, + {} +] \ No newline at end of file diff --git a/test/data/issue_1905.blf b/test/data/issue_1905.blf new file mode 100644 index 000000000..a896a6d7c Binary files /dev/null and b/test/data/issue_1905.blf differ diff --git a/test/data/single_frame.asc b/test/data/single_frame.asc new file mode 100644 index 000000000..cae9d1b4d --- /dev/null +++ b/test/data/single_frame.asc @@ -0,0 +1,7 @@ +date Sat Sep 30 15:06:13.191 2017 +base hex timestamps absolute +internal events logged +Begin Triggerblock Sat Sep 30 15:06:13.191 2017 + 0.000000 Start of measurement + 0.000000 1 123x Rx d 40 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F 20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F 30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F +End TriggerBlock diff --git a/test/data/single_frame_us_locale.asc b/test/data/single_frame_us_locale.asc new file mode 100644 index 000000000..f6bfcc3db --- /dev/null +++ b/test/data/single_frame_us_locale.asc @@ -0,0 +1,7 @@ +date Sat Sep 30 15:06:13.191 2017 +base hex timestamps absolute +internal events logged +Begin Triggerblock Sat Sep 30 15:06:13.191 2017 + 0.000000 Start of measurement + 0.000000 1 123x Rx d 1 68 +End TriggerBlock diff --git a/test/data/test_CanMessage.trc b/test/data/test_CanMessage.trc index 215997b57..8b1361808 100644 --- a/test/data/test_CanMessage.trc +++ b/test/data/test_CanMessage.trc @@ -1,5 +1,5 @@ ;$FILEVERSION=2.1 -;$STARTTIME=0 +;$STARTTIME=43008.920986006946 ;$COLUMNS=N,O,T,B,I,d,R,L,D ; ; C:\Users\User\Desktop\python-can\test\data\test_CanMessage.trc diff --git a/test/data/test_CanMessage_V1_0_BUS1.trc b/test/data/test_CanMessage_V1_0_BUS1.trc index 8985db188..c3905ae47 100644 --- a/test/data/test_CanMessage_V1_0_BUS1.trc +++ b/test/data/test_CanMessage_V1_0_BUS1.trc @@ -1,10 +1,10 @@ ;########################################################################## -; C:\Users\User\Desktop\python-can\test\data\test_CanMessage_V1_0_BUS1.trc +; C:\NewFileName_BUS1.trc ; -; CAN activities imported from C:\Users\User\Desktop\python-can\test\data\test_CanMessage_V1_1.trc +; CAN activities imported from C:\test_CanMessage_V1_1.trc ; Start time: 18.12.2021 14:28:07.062 ; PCAN-Net: N/A -; Generated by PEAK-Converter Version 2.2.4.136 +; Generated by PEAK-Converter Version 3.0.4.594 ; ; Columns description: ; ~~~~~~~~~~~~~~~~~~~~~ @@ -26,3 +26,4 @@ 9) 20798 00000100 8 00 00 00 00 00 00 00 00 10) 20956 00000100 8 00 00 00 00 00 00 00 00 11) 21097 00000100 8 00 00 00 00 00 00 00 00 + 12) 48937 0704 1 RTR \ No newline at end of file diff --git a/test/data/test_CanMessage_V1_1.trc b/test/data/test_CanMessage_V1_1.trc index 5a02cd59b..9a9cc7574 100644 --- a/test/data/test_CanMessage_V1_1.trc +++ b/test/data/test_CanMessage_V1_1.trc @@ -22,4 +22,5 @@ 8) 20592.7 Tx 00000100 8 00 00 00 00 00 00 00 00 9) 20798.6 Tx 00000100 8 00 00 00 00 00 00 00 00 10) 20956.0 Tx 00000100 8 00 00 00 00 00 00 00 00 - 11) 21097.1 Tx 00000100 8 00 00 00 00 00 00 00 00 + 11) 21097.1 Tx 00000100 8 00 00 00 00 00 00 00 00 + 12) 48937.6 Rx 0704 1 RTR diff --git a/test/data/test_CanMessage_V1_3.trc b/test/data/test_CanMessage_V1_3.trc new file mode 100644 index 000000000..96db1748c --- /dev/null +++ b/test/data/test_CanMessage_V1_3.trc @@ -0,0 +1,35 @@ +;$FILEVERSION=1.3 +;$STARTTIME=44548.6028595139 +; C:\NewFileName_V1_3.trc +; +; Start time: 18.12.2021 14:28:07.062.1 +; +; Generated by PEAK-Converter Version 3.0.4.594 +; Data imported from C:\test_CanMessage_V1_1.trc +;------------------------------------------------------------------------------- +; Bus Name Connection Protocol +; N/A N/A N/A CAN +;------------------------------------------------------------------------------- +; Message Number +; | Time Offset (ms) +; | | Bus +; | | | Type +; | | | | ID (hex) +; | | | | | Reserved +; | | | | | | Data Length Code +; | | | | | | | Data Bytes (hex) ... +; | | | | | | | | +; | | | | | | | | +;---+-- ------+------ +- --+-- ----+--- +- -+-- -+ -- -- -- -- -- -- -- + 1) 17535.400 1 Tx 00000100 - 8 00 00 00 00 00 00 00 00 + 2) 17540.300 1 Warng FFFFFFFF - 4 00 00 00 08 BUSHEAVY + 3) 17700.300 1 Tx 00000100 - 8 00 00 00 00 00 00 00 00 + 4) 17873.800 1 Tx 00000100 - 8 00 00 00 00 00 00 00 00 + 5) 19295.400 1 Tx 0000 - 8 00 00 00 00 00 00 00 00 + 6) 19500.600 1 Tx 0000 - 8 00 00 00 00 00 00 00 00 + 7) 19705.200 1 Tx 0000 - 8 00 00 00 00 00 00 00 00 + 8) 20592.700 1 Tx 00000100 - 8 00 00 00 00 00 00 00 00 + 9) 20798.600 1 Tx 00000100 - 8 00 00 00 00 00 00 00 00 + 10) 20956.000 1 Tx 00000100 - 8 00 00 00 00 00 00 00 00 + 11) 21097.100 1 Tx 00000100 - 8 00 00 00 00 00 00 00 00 + 12) 48937.600 1 Rx 0704 - 1 RTR diff --git a/test/data/test_CanMessage_V2_0_BUS1.trc b/test/data/test_CanMessage_V2_0_BUS1.trc index cf2384df0..c1af8abc1 100644 --- a/test/data/test_CanMessage_V2_0_BUS1.trc +++ b/test/data/test_CanMessage_V2_0_BUS1.trc @@ -2,18 +2,18 @@ ;$STARTTIME=44548.6028595139 ;$COLUMNS=N,O,T,I,d,l,D ; -; C:\Users\User\Desktop\python-can\test\data\test_CanMessage_V2_0_BUS1.trc +; C:\test_CanMessage_V2_0_BUS1.trc ; Start time: 18.12.2021 14:28:07.062.001 -; Generated by PEAK-Converter Version 2.2.4.136 -; Data imported from C:\Users\User\Desktop\python-can\test\data\test_CanMessage_V1_1.trc +; Generated by PEAK-Converter Version 3.0.4.594 +; Data imported from C:\test_CanMessage_V1_1.trc ;------------------------------------------------------------------------------- ; Connection Bit rate -; N/A N/A +; N/A N/A ;------------------------------------------------------------------------------- ; Message Time Type ID Rx/Tx ; Number Offset | [hex] | Data Length ; | [ms] | | | | Data [hex] ... -; | | | | | | | +; | | | | | | | ;---+-- ------+------ +- --+----- +- +- +- -- -- -- -- -- -- -- 1 17535.400 DT 00000100 Tx 8 00 00 00 00 00 00 00 00 2 17540.300 ST Rx 00 00 00 08 @@ -26,3 +26,4 @@ 9 20798.600 DT 00000100 Tx 8 00 00 00 00 00 00 00 00 10 20956.000 DT 00000100 Tx 8 00 00 00 00 00 00 00 00 11 21097.100 DT 00000100 Tx 8 00 00 00 00 00 00 00 00 + 12 48937.600 RR 0704 Rx 1 \ No newline at end of file diff --git a/test/data/test_CanMessage_V2_1.trc b/test/data/test_CanMessage_V2_1.trc index 55ceefaf1..0d259f084 100644 --- a/test/data/test_CanMessage_V2_1.trc +++ b/test/data/test_CanMessage_V2_1.trc @@ -2,19 +2,19 @@ ;$STARTTIME=44548.6028595139 ;$COLUMNS=N,O,T,B,I,d,R,L,D ; -; C:\Users\User\Desktop\python-can\test\data\test_CanMessage_V2_1.trc +; C:\test_CanMessage_V2_1.trc ; Start time: 18.12.2021 14:28:07.062.001 -; Generated by PEAK-Converter Version 2.2.4.136 -; Data imported from C:\Users\User\Desktop\python-can\test\data\test_CanMessage_V1_1.trc +; Generated by PEAK-Converter Version 3.0.4.594 +; Data imported from C:\test_CanMessage_V1_1.trc ;------------------------------------------------------------------------------- ; Bus Name Connection Protocol -; N/A N/A N/A N/A +; N/A N/A N/A N/A ;------------------------------------------------------------------------------- ; Message Time Type ID Rx/Tx ; Number Offset | Bus [hex] | Reserved ; | [ms] | | | | | Data Length Code ; | | | | | | | | Data [hex] ... -; | | | | | | | | | +; | | | | | | | | | ;---+-- ------+------ +- +- --+----- +- +- +--- +- -- -- -- -- -- -- -- 1 17535.400 DT 1 00000100 Tx - 8 00 00 00 00 00 00 00 00 2 17540.300 ST 1 - Rx - 4 00 00 00 08 @@ -27,3 +27,4 @@ 9 20798.600 DT 1 00000100 Tx - 8 00 00 00 00 00 00 00 00 10 20956.000 DT 1 00000100 Tx - 8 00 00 00 00 00 00 00 00 11 21097.100 DT 1 00000100 Tx - 8 00 00 00 00 00 00 00 00 + 12 48937.600 RR 1 0704 Rx - 1 \ No newline at end of file diff --git a/test/listener_test.py b/test/listener_test.py index 9b2e9e93b..bbcbed56e 100644 --- a/test/listener_test.py +++ b/test/listener_test.py @@ -1,15 +1,14 @@ #!/usr/bin/env python -""" -""" +""" """ import asyncio -import unittest -import random import logging -import tempfile import os +import random +import tempfile +import unittest import warnings -from os.path import join, dirname +from os.path import dirname, join import can @@ -160,8 +159,13 @@ def testBufferedListenerReceives(self): def test_deprecated_loop_arg(recwarn): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + warnings.simplefilter("always") - can.AsyncBufferedReader(loop=asyncio.get_event_loop()) + can.AsyncBufferedReader(loop=loop) assert len(recwarn) > 0 assert recwarn.pop(DeprecationWarning) recwarn.clear() diff --git a/test/logformats_test.py b/test/logformats_test.py index 05c8b986f..f8a8de91d 100644 --- a/test/logformats_test.py +++ b/test/logformats_test.py @@ -11,60 +11,97 @@ TODO: correctly set preserves_channel and adds_default_channel """ +import locale import logging -import unittest -from parameterized import parameterized -import tempfile import os -from abc import abstractmethod, ABCMeta -from itertools import zip_longest +import tempfile +import unittest +from abc import ABCMeta, abstractmethod +from contextlib import contextmanager from datetime import datetime +from itertools import zip_longest +from pathlib import Path +from unittest.mock import patch + +from parameterized import parameterized import can from can.io import blf - from .data.example_data import ( + TEST_COMMENTS, TEST_MESSAGES_BASE, - TEST_MESSAGES_REMOTE_FRAMES, - TEST_MESSAGES_ERROR_FRAMES, TEST_MESSAGES_CAN_FD, - TEST_COMMENTS, + TEST_MESSAGES_ERROR_FRAMES, + TEST_MESSAGES_REMOTE_FRAMES, sort_messages, ) from .message_helper import ComparingMessagesTestCase logging.basicConfig(level=logging.DEBUG) +try: + import asammdf +except ModuleNotFoundError: + asammdf = None + + +@contextmanager +def override_locale(category: int, locale_str: str) -> None: + prev_locale = locale.getlocale(category) + locale.setlocale(category, locale_str) + yield + locale.setlocale(category, prev_locale) + class ReaderWriterExtensionTest(unittest.TestCase): - message_writers_and_readers = {} - for suffix, writer in can.Logger.message_writers.items(): - message_writers_and_readers[suffix] = ( - writer, - can.LogReader.message_readers.get(suffix), - ) + def _get_suffix_case_variants(self, suffix): + return [ + suffix.upper(), + suffix.lower(), + f"can.msg.ext{suffix}", + "".join([c.upper() if i % 2 else c for i, c in enumerate(suffix)]), + ] - def test_extension_matching(self): - for suffix, (writer, reader) in self.message_writers_and_readers.items(): - suffix_variants = [ - suffix.upper(), - suffix.lower(), - f"can.msg.ext{suffix}", - "".join([c.upper() if i % 2 else c for i, c in enumerate(suffix)]), - ] - for suffix_variant in suffix_variants: - tmp_file = tempfile.NamedTemporaryFile( - suffix=suffix_variant, delete=False - ) - tmp_file.close() - try: + def _test_extension(self, suffix): + WriterType = can.io.MESSAGE_WRITERS.get(suffix) + ReaderType = can.io.MESSAGE_READERS.get(suffix) + for suffix_variant in self._get_suffix_case_variants(suffix): + tmp_file = tempfile.NamedTemporaryFile(suffix=suffix_variant, delete=False) + tmp_file.close() + try: + if WriterType: with can.Logger(tmp_file.name) as logger: - assert type(logger) == writer - if reader is not None: - with can.LogReader(tmp_file.name) as player: - assert type(player) == reader - finally: - os.remove(tmp_file.name) + assert type(logger) == WriterType + if ReaderType: + with can.LogReader(tmp_file.name) as player: + assert type(player) == ReaderType + finally: + os.remove(tmp_file.name) + + def test_extension_matching_asc(self): + self._test_extension(".asc") + + def test_extension_matching_blf(self): + self._test_extension(".blf") + + def test_extension_matching_csv(self): + self._test_extension(".csv") + + def test_extension_matching_db(self): + self._test_extension(".db") + + def test_extension_matching_log(self): + self._test_extension(".log") + + def test_extension_matching_txt(self): + self._test_extension(".txt") + + def test_extension_matching_mf4(self): + try: + self._test_extension(".mf4") + except NotImplementedError: + if asammdf is not None: + raise class ReaderWriterTest(unittest.TestCase, ComparingMessagesTestCase, metaclass=ABCMeta): @@ -100,6 +137,7 @@ def _setup_instance_helper( allowed_timestamp_delta=0.0, preserves_channel=True, adds_default_channel=None, + assert_file_closed=True, ): """ :param Callable writer_constructor: the constructor of the writer class @@ -150,6 +188,7 @@ def _setup_instance_helper( self.reader_constructor = reader_constructor self.binary_file = binary_file self.test_append_enabled = test_append + self.assert_file_closed = assert_file_closed ComparingMessagesTestCase.__init__( self, @@ -175,7 +214,7 @@ def test_path_like_explicit_stop(self): self._write_all(writer) self._ensure_fsync(writer) writer.stop() - if hasattr(writer.file, "closed"): + if self.assert_file_closed: self.assertTrue(writer.file.closed) print("reading all messages") @@ -183,7 +222,7 @@ def test_path_like_explicit_stop(self): read_messages = list(reader) # redundant, but this checks if stop() can be called multiple times reader.stop() - if hasattr(writer.file, "closed"): + if self.assert_file_closed: self.assertTrue(writer.file.closed) # check if at least the number of messages matches @@ -206,7 +245,7 @@ def test_path_like_context_manager(self): self._write_all(writer) self._ensure_fsync(writer) w = writer - if hasattr(w.file, "closed"): + if self.assert_file_closed: self.assertTrue(w.file.closed) # read all written messages @@ -214,7 +253,7 @@ def test_path_like_context_manager(self): with self.reader_constructor(self.test_file_name) as reader: read_messages = list(reader) r = reader - if hasattr(r.file, "closed"): + if self.assert_file_closed: self.assertTrue(r.file.closed) # check if at least the number of messages matches; @@ -237,7 +276,7 @@ def test_file_like_explicit_stop(self): self._write_all(writer) self._ensure_fsync(writer) writer.stop() - if hasattr(my_file, "closed"): + if self.assert_file_closed: self.assertTrue(my_file.closed) print("reading all messages") @@ -246,7 +285,7 @@ def test_file_like_explicit_stop(self): read_messages = list(reader) # redundant, but this checks if stop() can be called multiple times reader.stop() - if hasattr(my_file, "closed"): + if self.assert_file_closed: self.assertTrue(my_file.closed) # check if at least the number of messages matches @@ -270,7 +309,7 @@ def test_file_like_context_manager(self): self._write_all(writer) self._ensure_fsync(writer) w = writer - if hasattr(my_file, "closed"): + if self.assert_file_closed: self.assertTrue(my_file.closed) # read all written messages @@ -279,7 +318,7 @@ def test_file_like_context_manager(self): with self.reader_constructor(my_file) as reader: read_messages = list(reader) r = reader - if hasattr(my_file, "closed"): + if self.assert_file_closed: self.assertTrue(my_file.closed) # check if at least the number of messages matches; @@ -343,7 +382,7 @@ def _write_all(self, writer): writer(msg) def _ensure_fsync(self, io_handler): - if hasattr(io_handler.file, "fileno"): + if hasattr(io_handler, "file") and hasattr(io_handler.file, "fileno"): io_handler.file.flush() os.fsync(io_handler.file.fileno()) @@ -377,12 +416,16 @@ def _setup_instance(self): adds_default_channel=0, ) + def _get_logfile_location(self, filename: str) -> Path: + my_dir = Path(__file__).parent + return my_dir / "data" / filename + def _read_log_file(self, filename, **kwargs): - logfile = os.path.join(os.path.dirname(__file__), "data", filename) + logfile = self._get_logfile_location(filename) with can.ASCReader(logfile, **kwargs) as reader: return list(reader) - def test_absolute_time(self): + def test_read_absolute_time(self): time_from_file = "Sat Sep 30 10:06:13.191 PM 2017" start_time = datetime.strptime( time_from_file, self.FORMAT_START_OF_FILE_DATE @@ -410,7 +453,7 @@ def test_absolute_time(self): actual = self._read_log_file("test_CanMessage.asc", relative_timestamp=False) self.assertMessagesEqual(actual, expected_messages) - def test_can_message(self): + def test_read_can_message(self): expected_messages = [ can.Message( timestamp=2.5010, @@ -433,7 +476,7 @@ def test_can_message(self): actual = self._read_log_file("test_CanMessage.asc") self.assertMessagesEqual(actual, expected_messages) - def test_can_remote_message(self): + def test_read_can_remote_message(self): expected_messages = [ can.Message( timestamp=2.510001, @@ -462,7 +505,7 @@ def test_can_remote_message(self): actual = self._read_log_file("test_CanRemoteMessage.asc") self.assertMessagesEqual(actual, expected_messages) - def test_can_fd_remote_message(self): + def test_read_can_fd_remote_message(self): expected_messages = [ can.Message( timestamp=30.300981, @@ -478,7 +521,7 @@ def test_can_fd_remote_message(self): actual = self._read_log_file("test_CanFdRemoteMessage.asc") self.assertMessagesEqual(actual, expected_messages) - def test_can_fd_message(self): + def test_read_can_fd_message(self): expected_messages = [ can.Message( timestamp=30.005021, @@ -515,7 +558,7 @@ def test_can_fd_message(self): actual = self._read_log_file("test_CanFdMessage.asc") self.assertMessagesEqual(actual, expected_messages) - def test_can_fd_message_64(self): + def test_read_can_fd_message_64(self): expected_messages = [ can.Message( timestamp=30.506898, @@ -540,7 +583,7 @@ def test_can_fd_message_64(self): actual = self._read_log_file("test_CanFdMessage64.asc") self.assertMessagesEqual(actual, expected_messages) - def test_can_and_canfd_error_frames(self): + def test_read_can_and_canfd_error_frames(self): expected_messages = [ can.Message(timestamp=2.501000, channel=0, is_error_frame=True), can.Message(timestamp=3.501000, channel=0, is_error_frame=True), @@ -556,15 +599,121 @@ def test_can_and_canfd_error_frames(self): actual = self._read_log_file("test_CanErrorFrames.asc") self.assertMessagesEqual(actual, expected_messages) - def test_ignore_comments(self): + def test_read_ignore_comments(self): _msg_list = self._read_log_file("logfile.asc") - def test_no_triggerblock(self): + def test_read_no_triggerblock(self): _msg_list = self._read_log_file("issue_1256.asc") - def test_can_dlc_greater_than_8(self): + def test_read_can_dlc_greater_than_8(self): _msg_list = self._read_log_file("issue_1299.asc") + def test_read_error_frame_channel(self): + # gh-issue 1578 + err_frame = can.Message(is_error_frame=True, channel=4) + + temp_file = tempfile.NamedTemporaryFile("w", delete=False) + temp_file.close() + + try: + with can.ASCWriter(temp_file.name) as writer: + writer.on_message_received(err_frame) + + with can.ASCReader(temp_file.name) as reader: + msg_list = list(reader) + assert len(msg_list) == 1 + assert err_frame.equals( + msg_list[0], check_channel=True + ), f"{err_frame!r}!={msg_list[0]!r}" + finally: + os.unlink(temp_file.name) + + def test_write_millisecond_handling(self): + now = datetime( + year=2017, month=9, day=30, hour=15, minute=6, second=13, microsecond=191456 + ) + + # We temporarily set the locale to C to ensure test reproducibility + with override_locale(category=locale.LC_TIME, locale_str="C"): + # We mock datetime.now during ASCWriter __init__ for reproducibility + # Unfortunately, now() is a readonly attribute, so we mock datetime + with patch("can.io.asc.datetime") as mock_datetime: + mock_datetime.now.return_value = now + writer = can.ASCWriter(self.test_file_name) + + msg = can.Message( + timestamp=now.timestamp(), arbitration_id=0x123, data=b"h" + ) + writer.on_message_received(msg) + + writer.stop() + + actual_file = Path(self.test_file_name) + expected_file = self._get_logfile_location("single_frame_us_locale.asc") + + self.assertEqual(expected_file.read_text(), actual_file.read_text()) + + def test_write(self): + now = datetime( + year=2017, month=9, day=30, hour=15, minute=6, second=13, microsecond=191456 + ) + + # We temporarily set the locale to C to ensure test reproducibility + with override_locale(category=locale.LC_TIME, locale_str="C"): + # We mock datetime.now during ASCWriter __init__ for reproducibility + # Unfortunately, now() is a readonly attribute, so we mock datetime + with patch("can.io.asc.datetime") as mock_datetime: + mock_datetime.now.return_value = now + writer = can.ASCWriter(self.test_file_name) + + msg = can.Message( + timestamp=now.timestamp(), + arbitration_id=0x123, + data=range(64), + ) + + with writer: + writer.on_message_received(msg) + + actual_file = Path(self.test_file_name) + expected_file = self._get_logfile_location("single_frame.asc") + + self.assertEqual(expected_file.read_text(), actual_file.read_text()) + + @parameterized.expand( + [ + ( + "May 27 04:09:35.000 pm 2014", + datetime(2014, 5, 27, 16, 9, 35, 0).timestamp(), + ), + ( + "Mai 27 04:09:35.000 pm 2014", + datetime(2014, 5, 27, 16, 9, 35, 0).timestamp(), + ), + ( + "Apr 28 10:44:52.480 2022", + datetime(2022, 4, 28, 10, 44, 52, 480000).timestamp(), + ), + ( + "Sep 30 15:06:13.191 2017", + datetime(2017, 9, 30, 15, 6, 13, 191000).timestamp(), + ), + ( + "Sep 30 15:06:13.191 pm 2017", + datetime(2017, 9, 30, 15, 6, 13, 191000).timestamp(), + ), + ( + "Sep 30 15:06:13.191 am 2017", + datetime(2017, 9, 30, 15, 6, 13, 191000).timestamp(), + ), + ] + ) + def test_datetime_to_timestamp( + self, datetime_string: str, expected_timestamp: float + ): + timestamp = can.ASCReader._datetime_to_timestamp(datetime_string) + self.assertAlmostEqual(timestamp, expected_timestamp) + class TestBlfFileFormat(ReaderWriterTest): """Tests can.BLFWriter and can.BLFReader. @@ -581,7 +730,6 @@ def _setup_instance(self): check_fd=True, check_comments=False, test_append=True, - allowed_timestamp_delta=1.0e-6, preserves_channel=False, adds_default_channel=0, ) @@ -676,6 +824,30 @@ def test_timestamp_to_systemtime(self): places=3, ) + def test_issue_1905(self): + expected = can.Message( + timestamp=1735654183.491113, + channel=6, + arbitration_id=0x6A9, + is_extended_id=False, + is_fd=True, + bitrate_switch=True, + error_state_indicator=False, + dlc=64, + data=bytearray( + b"\xff\xff\xff\xff\xff\xff\xff\xff" + b"\xff\xff\xff\xff\xff\xff\xff\xff" + b"\xff\xff\xff\xff\xff\xff\xff\xff" + b"\xff\xff\xff\xff\xff\xff\xff\xff" + b"\xff\xff\xff\xff\xff\xff\xff\xff" + b"\xff\xff\xff\xff\xff\xff\xff\xff" + b"\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00" + ), + ) + msgs = self._read_log_file("issue_1905.blf") + self.assertMessageEqual(expected, msgs[0]) + class TestCanutilsFileFormat(ReaderWriterTest): """Tests can.CanutilsLogWriter and can.CanutilsLogReader""" @@ -707,6 +879,22 @@ def _setup_instance(self): ) +@unittest.skipIf(asammdf is None, "MF4 is unavailable") +class TestMF4FileFormat(ReaderWriterTest): + """Tests can.MF4Writer and can.MF4Reader""" + + def _setup_instance(self): + super()._setup_instance_helper( + can.MF4Writer, + can.MF4Reader, + binary_file=True, + check_comments=False, + preserves_channel=False, + allowed_timestamp_delta=1e-4, + adds_default_channel=0, + ) + + class TestSqliteDatabaseFormat(ReaderWriterTest): """Tests can.SqliteWriter and can.SqliteReader""" @@ -719,6 +907,7 @@ def _setup_instance(self): check_comments=False, preserves_channel=False, adds_default_channel=None, + assert_file_closed=False, ) @unittest.skip("not implemented") @@ -772,7 +961,7 @@ def test_not_crashes_with_stdout(self): printer(message) def test_not_crashes_with_file(self): - with tempfile.NamedTemporaryFile("w", delete=False) as temp_file: + with tempfile.NamedTemporaryFile("w") as temp_file: with can.Printer(temp_file) as printer: for message in self.messages: printer(message) @@ -810,9 +999,10 @@ class TestTrcFileFormatGen(TestTrcFileFormatBase): """Generic tests for can.TRCWriter and can.TRCReader with different file versions""" def test_can_message(self): + start_time = 1506809173.191 # 30.09.2017 22:06:13.191.000 as timestamp expected_messages = [ can.Message( - timestamp=2.5010, + timestamp=start_time + 2.5010, arbitration_id=0xC8, is_extended_id=False, is_rx=False, @@ -821,7 +1011,7 @@ def test_can_message(self): data=[9, 8, 7, 6, 5, 4, 3, 2], ), can.Message( - timestamp=17.876708, + timestamp=start_time + 17.876708, arbitration_id=0x6F9, is_extended_id=False, channel=0, @@ -836,15 +1026,24 @@ def test_can_message(self): [ ("V1_0", "test_CanMessage_V1_0_BUS1.trc", False), ("V1_1", "test_CanMessage_V1_1.trc", True), + ("V1_3", "test_CanMessage_V1_3.trc", True), + ("V2_0", "test_CanMessage_V2_0_BUS1.trc", True), ("V2_1", "test_CanMessage_V2_1.trc", True), ] ) def test_can_message_versions(self, name, filename, is_rx_support): with self.subTest(name): + if name == "V1_0": + # Version 1.0 does not support start time + start_time = 0 + else: + start_time = ( + 1639837687.062001 # 18.12.2021 14:28:07.062.001 as timestamp + ) def msg_std(timestamp): msg = can.Message( - timestamp=timestamp, + timestamp=timestamp + start_time, arbitration_id=0x000, is_extended_id=False, channel=1, @@ -857,7 +1056,7 @@ def msg_std(timestamp): def msg_ext(timestamp): msg = can.Message( - timestamp=timestamp, + timestamp=timestamp + start_time, arbitration_id=0x100, is_extended_id=True, channel=1, @@ -868,6 +1067,20 @@ def msg_ext(timestamp): msg.is_rx = False return msg + def msg_rtr(timestamp): + msg = can.Message( + timestamp=timestamp + start_time, + arbitration_id=0x704, + is_extended_id=False, + is_remote_frame=True, + channel=1, + dlc=1, + data=[], + ) + if is_rx_support: + msg.is_rx = True + return msg + expected_messages = [ msg_ext(17.5354), msg_ext(17.7003), @@ -879,15 +1092,17 @@ def msg_ext(timestamp): msg_ext(20.7986), msg_ext(20.9560), msg_ext(21.0971), + msg_rtr(48.9376), ] actual = self._read_log_file(filename) self.assertMessagesEqual(actual, expected_messages) def test_not_supported_version(self): - with self.assertRaises(NotImplementedError): - writer = can.TRCWriter("test.trc") - writer.file_version = can.TRCFileVersion.UNKNOWN - writer.on_message_received(can.Message()) + with tempfile.NamedTemporaryFile(mode="w") as f: + with self.assertRaises(NotImplementedError): + writer = can.TRCWriter(f) + writer.file_version = can.TRCFileVersion.UNKNOWN + writer.on_message_received(can.Message()) class TestTrcFileFormatV1_0(TestTrcFileFormatBase): diff --git a/test/message_helper.py b/test/message_helper.py index fe193097b..d10e9195f 100644 --- a/test/message_helper.py +++ b/test/message_helper.py @@ -4,8 +4,6 @@ This module contains a helper for writing test cases that need to compare messages. """ -from copy import copy - class ComparingMessagesTestCase: """ @@ -28,31 +26,14 @@ def assertMessageEqual(self, message_1, message_2): Checks that two messages are equal, according to the given rules. """ - if message_1.equals(message_2, timestamp_delta=self.allowed_timestamp_delta): - return - elif self.preserves_channel: + if not message_1.equals( + message_2, + check_channel=self.preserves_channel, + timestamp_delta=self.allowed_timestamp_delta, + ): print(f"Comparing: message 1: {message_1!r}") print(f" message 2: {message_2!r}") - self.fail( - "messages are unequal with allowed timestamp delta {}".format( - self.allowed_timestamp_delta - ) - ) - else: - message_2 = copy(message_2) # make sure this method is pure - message_2.channel = message_1.channel - if message_1.equals( - message_2, timestamp_delta=self.allowed_timestamp_delta - ): - return - else: - print(f"Comparing: message 1: {message_1!r}") - print(f" message 2: {message_2!r}") - self.fail( - "messages are unequal with allowed timestamp delta {} even when ignoring channels".format( - self.allowed_timestamp_delta - ) - ) + self.fail(f"messages are unequal: \n{message_1}\n{message_2}") def assertMessagesEqual(self, messages_1, messages_2): """ diff --git a/test/network_test.py b/test/network_test.py index 58c305a38..3a231dff7 100644 --- a/test/network_test.py +++ b/test/network_test.py @@ -1,19 +1,19 @@ #!/usr/bin/env python - - -import unittest -import threading -import random +import contextlib import logging +import random +import threading +import unittest -logging.getLogger(__file__).setLevel(logging.WARNING) +import can +from test.config import IS_PYPY -# make a random bool: -rbool = lambda: bool(round(random.random())) +logging.getLogger(__file__).setLevel(logging.WARNING) -import can -channel = "vcan0" +# make a random bool: +def rbool(): + return random.choice([False, True]) class ControllerAreaNetworkTestCase(unittest.TestCase): @@ -49,73 +49,51 @@ def tearDown(self): # Restore the defaults can.rc = self._can_rc - def producer(self, ready_event, msg_read): - self.client_bus = can.interface.Bus(channel=channel) - ready_event.wait() - for i in range(self.num_messages): - m = can.Message( - arbitration_id=self.ids[i], - is_remote_frame=self.remote_flags[i], - is_error_frame=self.error_flags[i], - is_extended_id=self.extended_flags[i], - data=self.data[i], - ) - # logging.debug("writing message: {}".format(m)) - if msg_read is not None: - # Don't send until the other thread is ready - msg_read.wait() - msg_read.clear() - - self.client_bus.send(m) + def producer(self, channel: str): + with can.interface.Bus(channel=channel) as client_bus: + for i in range(self.num_messages): + m = can.Message( + arbitration_id=self.ids[i], + is_remote_frame=self.remote_flags[i], + is_error_frame=self.error_flags[i], + is_extended_id=self.extended_flags[i], + data=self.data[i], + ) + client_bus.send(m) def testProducer(self): """Verify that we can send arbitrary messages on the bus""" logging.debug("testing producer alone") - ready = threading.Event() - ready.set() - self.producer(ready, None) - + self.producer(channel="testProducer") logging.debug("producer test complete") def testProducerConsumer(self): logging.debug("testing producer/consumer") - ready = threading.Event() - msg_read = threading.Event() - - self.server_bus = can.interface.Bus(channel=channel) - - t = threading.Thread(target=self.producer, args=(ready, msg_read)) - t.start() - - # Ensure there are no messages on the bus - while True: - m = self.server_bus.recv(timeout=0.5) - if m is None: - print("No messages... lets go") - break - else: - self.fail("received messages before the test has started ...") - ready.set() - i = 0 - while i < self.num_messages: - msg_read.set() - msg = self.server_bus.recv(timeout=0.5) - self.assertIsNotNone(msg, "Didn't receive a message") - # logging.debug("Received message {} with data: {}".format(i, msg.data)) - - self.assertEqual(msg.is_extended_id, self.extended_flags[i]) - if not msg.is_remote_frame: - self.assertEqual(msg.data, self.data[i]) - self.assertEqual(msg.arbitration_id, self.ids[i]) - - self.assertEqual(msg.is_error_frame, self.error_flags[i]) - self.assertEqual(msg.is_remote_frame, self.remote_flags[i]) - - i += 1 - t.join() - - self.server_bus.flush_tx_buffer() - self.server_bus.shutdown() + read_timeout = 2.0 if IS_PYPY else 0.5 + channel = "testProducerConsumer" + + with can.interface.Bus(channel=channel, interface="virtual") as server_bus: + t = threading.Thread(target=self.producer, args=(channel,)) + t.start() + + i = 0 + while i < self.num_messages: + msg = server_bus.recv(timeout=read_timeout) + self.assertIsNotNone(msg, "Didn't receive a message") + + self.assertEqual(msg.is_extended_id, self.extended_flags[i]) + if not msg.is_remote_frame: + self.assertEqual(msg.data, self.data[i]) + self.assertEqual(msg.arbitration_id, self.ids[i]) + + self.assertEqual(msg.is_error_frame, self.error_flags[i]) + self.assertEqual(msg.is_remote_frame, self.remote_flags[i]) + + i += 1 + t.join() + + with contextlib.suppress(NotImplementedError): + server_bus.flush_tx_buffer() if __name__ == "__main__": diff --git a/test/notifier_test.py b/test/notifier_test.py index ca2093f55..d8512a00b 100644 --- a/test/notifier_test.py +++ b/test/notifier_test.py @@ -1,8 +1,8 @@ #!/usr/bin/env python -import unittest -import time import asyncio +import time +import unittest import can @@ -12,27 +12,65 @@ def test_single_bus(self): with can.Bus("test", interface="virtual", receive_own_messages=True) as bus: reader = can.BufferedReader() notifier = can.Notifier(bus, [reader], 0.1) + self.assertFalse(notifier.stopped) msg = can.Message() bus.send(msg) self.assertIsNotNone(reader.get_message(1)) notifier.stop() + self.assertTrue(notifier.stopped) def test_multiple_bus(self): - with can.Bus(0, interface="virtual", receive_own_messages=True) as bus1: - with can.Bus(1, interface="virtual", receive_own_messages=True) as bus2: - reader = can.BufferedReader() - notifier = can.Notifier([bus1, bus2], [reader], 0.1) + with ( + can.Bus(0, interface="virtual", receive_own_messages=True) as bus1, + can.Bus(1, interface="virtual", receive_own_messages=True) as bus2, + ): + reader = can.BufferedReader() + notifier = can.Notifier([bus1, bus2], [reader], 0.1) + self.assertFalse(notifier.stopped) + msg = can.Message() + bus1.send(msg) + time.sleep(0.1) + bus2.send(msg) + recv_msg = reader.get_message(1) + self.assertIsNotNone(recv_msg) + self.assertEqual(recv_msg.channel, 0) + recv_msg = reader.get_message(1) + self.assertIsNotNone(recv_msg) + self.assertEqual(recv_msg.channel, 1) + notifier.stop() + self.assertTrue(notifier.stopped) + + def test_context_manager(self): + with can.Bus("test", interface="virtual", receive_own_messages=True) as bus: + reader = can.BufferedReader() + with can.Notifier(bus, [reader], 0.1) as notifier: + self.assertFalse(notifier.stopped) msg = can.Message() - bus1.send(msg) - time.sleep(0.1) - bus2.send(msg) - recv_msg = reader.get_message(1) - self.assertIsNotNone(recv_msg) - self.assertEqual(recv_msg.channel, 0) - recv_msg = reader.get_message(1) - self.assertIsNotNone(recv_msg) - self.assertEqual(recv_msg.channel, 1) + bus.send(msg) + self.assertIsNotNone(reader.get_message(1)) notifier.stop() + self.assertTrue(notifier.stopped) + + def test_registry(self): + with can.Bus("test", interface="virtual", receive_own_messages=True) as bus: + reader = can.BufferedReader() + with can.Notifier(bus, [reader], 0.1) as notifier: + # creating a second notifier for the same bus must fail + self.assertRaises(ValueError, can.Notifier, bus, [reader], 0.1) + + # find_instance must return the existing instance + self.assertEqual(can.Notifier.find_instances(bus), (notifier,)) + + # Notifier is stopped, find_instances() must return an empty tuple + self.assertEqual(can.Notifier.find_instances(bus), ()) + + # now the first notifier is stopped, a new notifier can be created without error: + with can.Notifier(bus, [reader], 0.1) as notifier: + # the next notifier call should fail again since there is an active notifier already + self.assertRaises(ValueError, can.Notifier, bus, [reader], 0.1) + + # find_instance must return the existing instance + self.assertEqual(can.Notifier.find_instances(bus), (notifier,)) class AsyncNotifierTest(unittest.TestCase): diff --git a/test/serial_test.py b/test/serial_test.py index aa6c71994..409485112 100644 --- a/test/serial_test.py +++ b/test/serial_test.py @@ -12,9 +12,8 @@ import can from can.interfaces.serial.serial_can import SerialBus -from .message_helper import ComparingMessagesTestCase from .config import IS_PYPY - +from .message_helper import ComparingMessagesTestCase # Mentioned in #1010 TIMEOUT = 0.5 if IS_PYPY else 0.1 # 0.1 is the default set in SerialBus @@ -44,7 +43,6 @@ def reset(self): class SimpleSerialTestBase(ComparingMessagesTestCase): - MAX_TIMESTAMP = 0xFFFFFFFF / 1000 def __init__(self): @@ -52,6 +50,9 @@ def __init__(self): self, allowed_timestamp_delta=None, preserves_channel=True ) + def test_can_protocol(self): + self.assertEqual(self.bus.protocol, can.CanProtocol.CAN_20) + def test_rx_tx_min_max_data(self): """ Tests the transfer from 0x00 to 0xFF for a 1 byte payload @@ -83,20 +84,38 @@ def test_rx_tx_data_none(self): msg_receive = self.bus.recv() self.assertMessageEqual(msg, msg_receive) - def test_rx_tx_min_id(self): + def test_rx_tx_min_std_id(self): + """ + Tests the transfer with the lowest standard arbitration id + """ + msg = can.Message(arbitration_id=0, is_extended_id=False) + self.bus.send(msg) + msg_receive = self.bus.recv() + self.assertMessageEqual(msg, msg_receive) + + def test_rx_tx_max_std_id(self): """ - Tests the transfer with the lowest arbitration id + Tests the transfer with the highest standard arbitration id """ - msg = can.Message(arbitration_id=0) + msg = can.Message(arbitration_id=0x7FF, is_extended_id=False) self.bus.send(msg) msg_receive = self.bus.recv() self.assertMessageEqual(msg, msg_receive) - def test_rx_tx_max_id(self): + def test_rx_tx_min_ext_id(self): """ - Tests the transfer with the highest arbitration id + Tests the transfer with the lowest extended arbitration id """ - msg = can.Message(arbitration_id=536870911) + msg = can.Message(arbitration_id=0x000, is_extended_id=True) + self.bus.send(msg) + msg_receive = self.bus.recv() + self.assertMessageEqual(msg, msg_receive) + + def test_rx_tx_max_ext_id(self): + """ + Tests the transfer with the highest extended arbitration id + """ + msg = can.Message(arbitration_id=0x1FFFFFFF, is_extended_id=True) self.bus.send(msg) msg_receive = self.bus.recv() self.assertMessageEqual(msg, msg_receive) @@ -136,6 +155,28 @@ def test_rx_tx_min_timestamp_error(self): msg = can.Message(timestamp=-1) self.assertRaises(ValueError, self.bus.send, msg) + def test_rx_tx_err_frame(self): + """ + Test the transfer of error frames. + """ + msg = can.Message( + is_extended_id=False, is_error_frame=True, is_remote_frame=False + ) + self.bus.send(msg) + msg_receive = self.bus.recv() + self.assertMessageEqual(msg, msg_receive) + + def test_rx_tx_rtr_frame(self): + """ + Test the transfer of remote frames. + """ + msg = can.Message( + is_extended_id=False, is_error_frame=False, is_remote_frame=True + ) + self.bus.send(msg) + msg_receive = self.bus.recv() + self.assertMessageEqual(msg, msg_receive) + def test_when_no_fileno(self): """ Tests for the fileno method catching the missing pyserial implementeation on the Windows platform diff --git a/test/simplecyclic_test.py b/test/simplecyclic_test.py index 639694bfa..22a11e643 100644 --- a/test/simplecyclic_test.py +++ b/test/simplecyclic_test.py @@ -4,14 +4,19 @@ This module tests cyclic send tasks. """ -from time import sleep +import gc +import platform +import sys +import time +import traceback import unittest +from threading import Thread +from time import sleep from unittest.mock import MagicMock -import gc import can -from .config import * +from .config import IS_CI, IS_PYPY from .message_helper import ComparingMessagesTestCase @@ -31,164 +36,278 @@ def test_cycle_time(self): is_extended_id=False, arbitration_id=0x123, data=[0, 1, 2, 3, 4, 5, 6, 7] ) - with can.interface.Bus(interface="virtual") as bus1: - with can.interface.Bus(interface="virtual") as bus2: + with ( + can.interface.Bus(interface="virtual") as bus1, + can.interface.Bus(interface="virtual") as bus2, + ): + # disabling the garbage collector makes the time readings more reliable + gc.disable() - # disabling the garbage collector makes the time readings more reliable - gc.disable() - - task = bus1.send_periodic(msg, 0.01, 1) - self.assertIsInstance(task, can.broadcastmanager.CyclicSendTaskABC) + task = bus1.send_periodic(msg, 0.01, 1) + self.assertIsInstance(task, can.broadcastmanager.CyclicSendTaskABC) - sleep(2) - size = bus2.queue.qsize() - # About 100 messages should have been transmitted - self.assertTrue( - 80 <= size <= 120, - "100 +/- 20 messages should have been transmitted. But queue contained {}".format( - size - ), - ) - last_msg = bus2.recv() - next_last_msg = bus2.recv() + sleep(2) + size = bus2.queue.qsize() + # About 100 messages should have been transmitted + self.assertTrue( + 80 <= size <= 120, + "100 +/- 20 messages should have been transmitted. But queue contained {}".format( + size + ), + ) + last_msg = bus2.recv() + next_last_msg = bus2.recv() - # we need to reenable the garbage collector again - gc.enable() + # we need to reenable the garbage collector again + gc.enable() - # Check consecutive messages are spaced properly in time and have - # the same id/data - self.assertMessageEqual(last_msg, next_last_msg) + # Check consecutive messages are spaced properly in time and have + # the same id/data + self.assertMessageEqual(last_msg, next_last_msg) - # Check the message id/data sent is the same as message received - # Set timestamp and channel to match recv'd because we don't care - # and they are not initialized by the can.Message constructor. - msg.timestamp = last_msg.timestamp - msg.channel = last_msg.channel - self.assertMessageEqual(msg, last_msg) + # Check the message id/data sent is the same as message received + # Set timestamp and channel to match recv'd because we don't care + # and they are not initialized by the can.Message constructor. + msg.timestamp = last_msg.timestamp + msg.channel = last_msg.channel + self.assertMessageEqual(msg, last_msg) def test_removing_bus_tasks(self): - bus = can.interface.Bus(interface="virtual") - tasks = [] - for task_i in range(10): - msg = can.Message( - is_extended_id=False, - arbitration_id=0x123, - data=[0, 1, 2, 3, 4, 5, 6, 7], - ) - msg.arbitration_id = task_i - task = bus.send_periodic(msg, 0.1, 1) - tasks.append(task) - self.assertIsInstance(task, can.broadcastmanager.CyclicSendTaskABC) + with can.interface.Bus(interface="virtual") as bus: + tasks = [] + for task_i in range(10): + msg = can.Message( + is_extended_id=False, + arbitration_id=0x123, + data=[0, 1, 2, 3, 4, 5, 6, 7], + ) + msg.arbitration_id = task_i + task = bus.send_periodic(msg, 0.1, 1) + tasks.append(task) + self.assertIsInstance(task, can.broadcastmanager.CyclicSendTaskABC) - assert len(bus._periodic_tasks) == 10 + assert len(bus._periodic_tasks) == 10 - for task in tasks: - # Note calling task.stop will remove the task from the Bus's internal task management list - task.stop() + for task in tasks: + # Note calling task.stop will remove the task from the Bus's internal task management list + task.stop() - assert len(bus._periodic_tasks) == 0 - bus.shutdown() + self.join_threads([task.thread for task in tasks], 5.0) - def test_managed_tasks(self): - bus = can.interface.Bus(interface="virtual", receive_own_messages=True) - tasks = [] - for task_i in range(3): - msg = can.Message( - is_extended_id=False, - arbitration_id=0x123, - data=[0, 1, 2, 3, 4, 5, 6, 7], - ) - msg.arbitration_id = task_i - task = bus.send_periodic(msg, 0.1, 10, store_task=False) - tasks.append(task) - self.assertIsInstance(task, can.broadcastmanager.CyclicSendTaskABC) + assert len(bus._periodic_tasks) == 0 - assert len(bus._periodic_tasks) == 0 + def test_managed_tasks(self): + with can.interface.Bus(interface="virtual", receive_own_messages=True) as bus: + tasks = [] + for task_i in range(3): + msg = can.Message( + is_extended_id=False, + arbitration_id=0x123, + data=[0, 1, 2, 3, 4, 5, 6, 7], + ) + msg.arbitration_id = task_i + task = bus.send_periodic(msg, 0.1, 10, store_task=False) + tasks.append(task) + self.assertIsInstance(task, can.broadcastmanager.CyclicSendTaskABC) - # Self managed tasks should still be sending messages - for _ in range(50): - received_msg = bus.recv(timeout=5.0) - assert received_msg is not None - assert received_msg.arbitration_id in {0, 1, 2} + assert len(bus._periodic_tasks) == 0 - for task in tasks: - task.stop() + # Self managed tasks should still be sending messages + for _ in range(50): + received_msg = bus.recv(timeout=5.0) + assert received_msg is not None + assert received_msg.arbitration_id in {0, 1, 2} - for task in tasks: - assert task.thread.join(5.0) is None, "Task didn't stop before timeout" + for task in tasks: + task.stop() - bus.shutdown() + self.join_threads([task.thread for task in tasks], 5.0) def test_stopping_perodic_tasks(self): - bus = can.interface.Bus(interface="virtual") - tasks = [] - for task_i in range(10): - msg = can.Message( - is_extended_id=False, - arbitration_id=0x123, - data=[0, 1, 2, 3, 4, 5, 6, 7], - ) - msg.arbitration_id = task_i - task = bus.send_periodic(msg, 0.1, 1) - tasks.append(task) - - assert len(bus._periodic_tasks) == 10 - # stop half the tasks using the task object - for task in tasks[::2]: - task.stop() + with can.interface.Bus(interface="virtual") as bus: + tasks = [] + for task_i in range(10): + msg = can.Message( + is_extended_id=False, + arbitration_id=0x123, + data=[0, 1, 2, 3, 4, 5, 6, 7], + ) + msg.arbitration_id = task_i + task = bus.send_periodic(msg, period=0.1) + tasks.append(task) - assert len(bus._periodic_tasks) == 5 + assert len(bus._periodic_tasks) == 10 + # stop half the tasks using the task object + for task in tasks[::2]: + task.stop() - # stop the other half using the bus api - bus.stop_all_periodic_tasks(remove_tasks=False) + assert len(bus._periodic_tasks) == 5 - for task in tasks: - assert task.thread.join(5.0) is None, "Task didn't stop before timeout" + # stop the other half using the bus api + bus.stop_all_periodic_tasks(remove_tasks=False) + self.join_threads([task.thread for task in tasks], 5.0) - # Tasks stopped via `stop_all_periodic_tasks` with remove_tasks=False should - # still be associated with the bus (e.g. for restarting) - assert len(bus._periodic_tasks) == 5 + # Tasks stopped via `stop_all_periodic_tasks` with remove_tasks=False should + # still be associated with the bus (e.g. for restarting) + assert len(bus._periodic_tasks) == 5 - bus.shutdown() + def test_restart_perodic_tasks(self): + period = 0.01 + safe_timeout = period * 5 if not IS_PYPY else 1.0 + duration = 0.3 - @unittest.skipIf(IS_CI, "fails randomly when run on CI server") - def test_thread_based_cyclic_send_task(self): - bus = can.ThreadSafeBus(interface="virtual") msg = can.Message( is_extended_id=False, arbitration_id=0x123, data=[0, 1, 2, 3, 4, 5, 6, 7] ) - # good case, bus is up - on_error_mock = MagicMock(return_value=False) - task = can.broadcastmanager.ThreadBasedCyclicSendTask( - bus, bus._lock_send_periodic, msg, 0.1, 3, on_error_mock - ) - task.start() - sleep(1) - on_error_mock.assert_not_called() - task.stop() - bus.shutdown() + def _read_all_messages(_bus: "can.interfaces.virtual.VirtualBus") -> None: + sleep(safe_timeout) + while not _bus.queue.empty(): + _bus.recv(timeout=period) + sleep(safe_timeout) + + with can.ThreadSafeBus(interface="virtual", receive_own_messages=True) as bus: + task = bus.send_periodic(msg, period) + self.assertIsInstance(task, can.broadcastmanager.ThreadBasedCyclicSendTask) + + # Test that the task is sending messages + sleep(safe_timeout) + assert not bus.queue.empty(), "messages should have been transmitted" + + # Stop the task and check that messages are no longer being sent + bus.stop_all_periodic_tasks(remove_tasks=False) + _read_all_messages(bus) + assert bus.queue.empty(), "messages should not have been transmitted" + + # Restart the task and check that messages are being sent again + task.start() + sleep(safe_timeout) + assert not bus.queue.empty(), "messages should have been transmitted" + + # Stop the task and check that messages are no longer being sent + bus.stop_all_periodic_tasks(remove_tasks=False) + _read_all_messages(bus) + assert bus.queue.empty(), "messages should not have been transmitted" + + # Restart the task with limited duration and wait until it stops + task.duration = duration + task.start() + sleep(duration + safe_timeout) + assert task.stopped + assert time.time() > task.end_time + assert not bus.queue.empty(), "messages should have been transmitted" + _read_all_messages(bus) + assert bus.queue.empty(), "messages should not have been transmitted" + + # Restart the task and check that messages are being sent again + task.start() + sleep(safe_timeout) + assert not bus.queue.empty(), "messages should have been transmitted" + + # Stop all tasks and wait for the thread to exit + bus.stop_all_periodic_tasks() + # Avoids issues where the thread is still running when the bus is shutdown + self.join_threads([task.thread], 5.0) - # bus has been shutted down + @unittest.skipIf(IS_CI, "fails randomly when run on CI server") + def test_thread_based_cyclic_send_task(self): + with can.ThreadSafeBus(interface="virtual") as bus: + msg = can.Message( + is_extended_id=False, + arbitration_id=0x123, + data=[0, 1, 2, 3, 4, 5, 6, 7], + ) + + # good case, bus is up + on_error_mock = MagicMock(return_value=False) + task = can.broadcastmanager.ThreadBasedCyclicSendTask( + bus=bus, + lock=bus._lock_send_periodic, + messages=msg, + period=0.1, + duration=3, + on_error=on_error_mock, + ) + sleep(1) + on_error_mock.assert_not_called() + task.stop() + + # bus has been shut down on_error_mock = MagicMock(return_value=False) task = can.broadcastmanager.ThreadBasedCyclicSendTask( - bus, bus._lock_send_periodic, msg, 0.1, 3, on_error_mock + bus=bus, + lock=bus._lock_send_periodic, + messages=msg, + period=0.1, + duration=3, + on_error=on_error_mock, ) - task.start() sleep(1) - self.assertEqual(on_error_mock.call_count, 1) + self.assertEqual(1, on_error_mock.call_count) task.stop() - # bus is still shutted down, but on_error returns True + # bus is still shut down, but on_error returns True on_error_mock = MagicMock(return_value=True) task = can.broadcastmanager.ThreadBasedCyclicSendTask( - bus, bus._lock_send_periodic, msg, 0.1, 3, on_error_mock + bus=bus, + lock=bus._lock_send_periodic, + messages=msg, + period=0.1, + duration=3, + on_error=on_error_mock, ) - task.start() sleep(1) self.assertTrue(on_error_mock.call_count > 1) task.stop() + def test_modifier_callback(self) -> None: + msg_list: list[can.Message] = [] + + def increment_first_byte(msg: can.Message) -> None: + msg.data[0] = (msg.data[0] + 1) % 256 + + original_msg = can.Message( + is_extended_id=False, arbitration_id=0x123, data=[0] * 8 + ) + + with can.ThreadSafeBus(interface="virtual", receive_own_messages=True) as bus: + notifier = can.Notifier(bus=bus, listeners=[msg_list.append]) + task = bus.send_periodic( + msgs=original_msg, period=0.001, modifier_callback=increment_first_byte + ) + time.sleep(0.2) + task.stop() + notifier.stop() + + self.assertEqual(b"\x01\x00\x00\x00\x00\x00\x00\x00", bytes(msg_list[0].data)) + self.assertEqual(b"\x02\x00\x00\x00\x00\x00\x00\x00", bytes(msg_list[1].data)) + self.assertEqual(b"\x03\x00\x00\x00\x00\x00\x00\x00", bytes(msg_list[2].data)) + self.assertEqual(b"\x04\x00\x00\x00\x00\x00\x00\x00", bytes(msg_list[3].data)) + self.assertEqual(b"\x05\x00\x00\x00\x00\x00\x00\x00", bytes(msg_list[4].data)) + self.assertEqual(b"\x06\x00\x00\x00\x00\x00\x00\x00", bytes(msg_list[5].data)) + self.assertEqual(b"\x07\x00\x00\x00\x00\x00\x00\x00", bytes(msg_list[6].data)) + + @staticmethod + def join_threads(threads: list[Thread], timeout: float) -> None: + stuck_threads: list[Thread] = [] + t0 = time.perf_counter() + for thread in threads: + time_left = timeout - (time.perf_counter() - t0) + if time_left > 0.0: + thread.join(time_left) + if thread.is_alive(): + if platform.python_implementation() == "CPython": + # print thread frame to help with debugging + frame = sys._current_frames()[thread.ident] + traceback.print_stack(frame, file=sys.stderr) + stuck_threads.append(thread) + if stuck_threads: + err_message = ( + f"Threads did not stop within {timeout:.1f} seconds: " + f"[{', '.join([str(t) for t in stuck_threads])}]" + ) + raise RuntimeError(err_message) + if __name__ == "__main__": unittest.main() diff --git a/test/test_bit_timing.py b/test/test_bit_timing.py index a0d4e03a5..514c31244 100644 --- a/test/test_bit_timing.py +++ b/test/test_bit_timing.py @@ -11,7 +11,7 @@ def test_sja1000(): """Test some values obtained using other bit timing calculators.""" timing = can.BitTiming( - f_clock=8_000_000, brp=4, tseg1=11, tseg2=4, sjw=2, nof_samples=3 + f_clock=8_000_000, brp=4, tseg1=11, tseg2=4, sjw=2, nof_samples=3, strict=True ) assert timing.f_clock == 8_000_000 assert timing.bitrate == 125_000 @@ -25,7 +25,9 @@ def test_sja1000(): assert timing.btr0 == 0x43 assert timing.btr1 == 0xBA - timing = can.BitTiming(f_clock=8_000_000, brp=1, tseg1=13, tseg2=2, sjw=1) + timing = can.BitTiming( + f_clock=8_000_000, brp=1, tseg1=13, tseg2=2, sjw=1, strict=True + ) assert timing.f_clock == 8_000_000 assert timing.bitrate == 500_000 assert timing.brp == 1 @@ -38,7 +40,9 @@ def test_sja1000(): assert timing.btr0 == 0x00 assert timing.btr1 == 0x1C - timing = can.BitTiming(f_clock=8_000_000, brp=1, tseg1=5, tseg2=2, sjw=1) + timing = can.BitTiming( + f_clock=8_000_000, brp=1, tseg1=5, tseg2=2, sjw=1, strict=True + ) assert timing.f_clock == 8_000_000 assert timing.bitrate == 1_000_000 assert timing.brp == 1 @@ -84,7 +88,7 @@ def test_from_bitrate_and_segments(): assert timing.btr1 == 0x1C timing = can.BitTiming.from_bitrate_and_segments( - f_clock=8_000_000, bitrate=1_000_000, tseg1=5, tseg2=2, sjw=1 + f_clock=8_000_000, bitrate=1_000_000, tseg1=5, tseg2=2, sjw=1, strict=True ) assert timing.f_clock == 8_000_000 assert timing.bitrate == 1_000_000 @@ -127,8 +131,24 @@ def test_from_bitrate_and_segments(): assert timing.data_sjw == 10 assert timing.data_sample_point == 75 + # test strict invalid + with pytest.raises(ValueError): + can.BitTimingFd.from_bitrate_and_segments( + f_clock=80_000_000, + nom_bitrate=500_000, + nom_tseg1=119, + nom_tseg2=40, + nom_sjw=40, + data_bitrate=2_000_000, + data_tseg1=29, + data_tseg2=10, + data_sjw=10, + strict=True, + ) + def test_can_fd(): + # test non-strict timing = can.BitTimingFd( f_clock=80_000_000, nom_brp=1, @@ -149,7 +169,6 @@ def test_can_fd(): assert timing.nom_tseg2 == 40 assert timing.nom_sjw == 40 assert timing.nom_sample_point == 75 - assert timing.f_clock == 80_000_000 assert timing.data_bitrate == 2_000_000 assert timing.data_brp == 1 assert timing.dbt == 40 @@ -158,6 +177,50 @@ def test_can_fd(): assert timing.data_sjw == 10 assert timing.data_sample_point == 75 + # test strict invalid + with pytest.raises(ValueError): + can.BitTimingFd( + f_clock=80_000_000, + nom_brp=1, + nom_tseg1=119, + nom_tseg2=40, + nom_sjw=40, + data_brp=1, + data_tseg1=29, + data_tseg2=10, + data_sjw=10, + strict=True, + ) + + # test strict valid + timing = can.BitTimingFd( + f_clock=80_000_000, + nom_brp=2, + nom_tseg1=59, + nom_tseg2=20, + nom_sjw=20, + data_brp=2, + data_tseg1=14, + data_tseg2=5, + data_sjw=5, + strict=True, + ) + assert timing.f_clock == 80_000_000 + assert timing.nom_bitrate == 500_000 + assert timing.nom_brp == 2 + assert timing.nbt == 80 + assert timing.nom_tseg1 == 59 + assert timing.nom_tseg2 == 20 + assert timing.nom_sjw == 20 + assert timing.nom_sample_point == 75 + assert timing.data_bitrate == 2_000_000 + assert timing.data_brp == 2 + assert timing.dbt == 20 + assert timing.data_tseg1 == 14 + assert timing.data_tseg2 == 5 + assert timing.data_sjw == 5 + assert timing.data_sample_point == 75 + def test_from_btr(): timing = can.BitTiming.from_registers(f_clock=8_000_000, btr0=0x00, btr1=0x14) @@ -175,7 +238,7 @@ def test_from_btr(): def test_btr_persistence(): f_clock = 8_000_000 for btr0btr1 in PCAN_BITRATES.values(): - btr1, btr0 = struct.unpack("BB", btr0btr1) + btr0, btr1 = struct.pack(">H", btr0btr1.value) t = can.BitTiming.from_registers(f_clock, btr0, btr1) assert t.btr0 == btr0 @@ -223,6 +286,32 @@ def test_from_sample_point(): ) +def test_iterate_from_sample_point(): + for sp in range(50, 100): + solutions = list( + can.BitTiming.iterate_from_sample_point( + f_clock=16_000_000, + bitrate=500_000, + sample_point=sp, + ) + ) + assert len(solutions) >= 2 + + for nsp in range(50, 100): + for dsp in range(50, 100): + solutions = list( + can.BitTimingFd.iterate_from_sample_point( + f_clock=80_000_000, + nom_bitrate=500_000, + nom_sample_point=nsp, + data_bitrate=2_000_000, + data_sample_point=dsp, + ) + ) + + assert len(solutions) >= 2 + + def test_equality(): t1 = can.BitTiming.from_registers(f_clock=8_000_000, btr0=0x00, btr1=0x14) t2 = can.BitTiming(f_clock=8_000_000, brp=1, tseg1=5, tseg2=2, sjw=1, nof_samples=1) diff --git a/test/test_bridge.py b/test/test_bridge.py new file mode 100644 index 000000000..ee41bd949 --- /dev/null +++ b/test/test_bridge.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python + +""" +This module tests the functions inside of bridge.py +""" + +import random +import string +import sys +import threading +import time +from time import sleep as real_sleep +import unittest.mock + +import can +import can.bridge +from can.interfaces import virtual + +from .message_helper import ComparingMessagesTestCase + + +class TestBridgeScriptModule(unittest.TestCase, ComparingMessagesTestCase): + + TIMEOUT = 3.0 + + def __init__(self, *args, **kwargs): + unittest.TestCase.__init__(self, *args, **kwargs) + ComparingMessagesTestCase.__init__( + self, + allowed_timestamp_delta=None, + preserves_channel=False, + ) + + def setUp(self) -> None: + self.stop_event = threading.Event() + + self.channel1 = "".join(random.choices(string.ascii_letters, k=8)) + self.channel2 = "".join(random.choices(string.ascii_letters, k=8)) + + self.cli_args = [ + "--bus1-interface", + "virtual", + "--bus1-channel", + self.channel1, + "--bus2-interface", + "virtual", + "--bus2-channel", + self.channel2, + ] + + self.testmsg = can.Message( + arbitration_id=0xC0FFEE, data=[0, 25, 0, 1, 3, 1, 4, 1], is_extended_id=True + ) + + def fake_sleep(self, duration): + """A fake replacement for time.sleep that checks periodically + whether self.stop_event is set, and raises KeyboardInterrupt + if so. + + This allows tests to simulate an interrupt (like Ctrl+C) + during long sleeps, in a controlled and responsive way. + """ + interval = 0.05 # Small interval for responsiveness + t_wakeup = time.perf_counter() + duration + while time.perf_counter() < t_wakeup: + if self.stop_event.is_set(): + raise KeyboardInterrupt("Simulated interrupt from fake_sleep") + real_sleep(interval) + + def test_bridge(self): + with ( + unittest.mock.patch("can.bridge.time.sleep", new=self.fake_sleep), + unittest.mock.patch("can.bridge.sys.argv", [sys.argv[0], *self.cli_args]), + ): + # start script + thread = threading.Thread(target=can.bridge.main) + thread.start() + + # wait until script instantiates virtual buses + t0 = time.perf_counter() + while True: + with virtual.channels_lock: + if ( + self.channel1 in virtual.channels + and self.channel2 in virtual.channels + ): + break + if time.perf_counter() > t0 + 2.0: + raise TimeoutError("Bridge script did not create virtual buses") + real_sleep(0.2) + + # create buses with the same channels as in scripts + with ( + can.interfaces.virtual.VirtualBus(self.channel1) as bus1, + can.interfaces.virtual.VirtualBus(self.channel2) as bus2, + ): + # send test message to bus1, it should be received on bus2 + bus1.send(self.testmsg) + recv_msg = bus2.recv(self.TIMEOUT) + self.assertMessageEqual(self.testmsg, recv_msg) + + # assert that both buses are empty + self.assertIsNone(bus1.recv(0)) + self.assertIsNone(bus2.recv(0)) + + # send test message to bus2, it should be received on bus1 + bus2.send(self.testmsg) + recv_msg = bus1.recv(self.TIMEOUT) + self.assertMessageEqual(self.testmsg, recv_msg) + + # assert that both buses are empty + self.assertIsNone(bus1.recv(0)) + self.assertIsNone(bus2.recv(0)) + + # stop the bridge script + self.stop_event.set() + thread.join() + + # assert that the virtual buses were closed + with virtual.channels_lock: + self.assertNotIn(self.channel1, virtual.channels) + self.assertNotIn(self.channel2, virtual.channels) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_bus.py b/test/test_bus.py index e11d829d3..6a09a6deb 100644 --- a/test/test_bus.py +++ b/test/test_bus.py @@ -1,3 +1,4 @@ +import gc from unittest.mock import patch import can @@ -7,8 +8,16 @@ def test_bus_ignore_config(): with patch.object( target=can.util, attribute="load_config", side_effect=can.util.load_config ): - _ = can.Bus(interface="virtual", ignore_config=True) - assert not can.util.load_config.called + with can.Bus(interface="virtual", ignore_config=True): + assert not can.util.load_config.called - _ = can.Bus(interface="virtual") - assert can.util.load_config.called + with can.Bus(interface="virtual"): + assert can.util.load_config.called + + +@patch.object(can.bus.BusABC, "shutdown") +def test_bus_attempts_self_cleanup(mock_shutdown): + bus = can.Bus(interface="virtual") + del bus + gc.collect() + mock_shutdown.assert_called() diff --git a/test/test_cantact.py b/test/test_cantact.py index 2cc3e479c..f90655ae5 100644 --- a/test/test_cantact.py +++ b/test/test_cantact.py @@ -14,6 +14,8 @@ class CantactTest(unittest.TestCase): def test_bus_creation(self): bus = can.Bus(channel=0, interface="cantact", _testing=True) self.assertIsInstance(bus, cantact.CantactBus) + self.assertEqual(bus.protocol, can.CanProtocol.CAN_20) + cantact.MockInterface.set_bitrate.assert_called() cantact.MockInterface.set_bit_timing.assert_not_called() cantact.MockInterface.set_enabled.assert_called() @@ -25,7 +27,10 @@ def test_bus_creation_bittiming(self): bt = can.BitTiming(f_clock=24_000_000, brp=3, tseg1=13, tseg2=2, sjw=1) bus = can.Bus(channel=0, interface="cantact", timing=bt, _testing=True) + self.assertIsInstance(bus, cantact.CantactBus) + self.assertEqual(bus.protocol, can.CanProtocol.CAN_20) + cantact.MockInterface.set_bitrate.assert_not_called() cantact.MockInterface.set_bit_timing.assert_called() cantact.MockInterface.set_enabled.assert_called() diff --git a/test/test_cli.py b/test/test_cli.py new file mode 100644 index 000000000..ecc662832 --- /dev/null +++ b/test/test_cli.py @@ -0,0 +1,154 @@ +import argparse +import unittest +from unittest.mock import patch + +from can.cli import add_bus_arguments, create_bus_from_namespace + + +class TestCliUtils(unittest.TestCase): + def test_add_bus_arguments(self): + parser = argparse.ArgumentParser() + add_bus_arguments(parser, filter_arg=True, prefix="test") + + parsed_args = parser.parse_args( + [ + "--test-channel", + "0", + "--test-interface", + "vector", + "--test-timing", + "f_clock=8000000", + "brp=4", + "tseg1=11", + "tseg2=4", + "sjw=2", + "nof_samples=3", + "--test-filter", + "100:7FF", + "200~7F0", + "--test-bus-kwargs", + "app_name=MyApp", + "serial=1234", + ] + ) + + self.assertNotIn("channel", parsed_args) + self.assertNotIn("test_bitrate", parsed_args) + self.assertNotIn("test_data_bitrate", parsed_args) + self.assertNotIn("test_fd", parsed_args) + + self.assertEqual(parsed_args.test_channel, "0") + self.assertEqual(parsed_args.test_interface, "vector") + self.assertEqual(parsed_args.test_timing.f_clock, 8000000) + self.assertEqual(parsed_args.test_timing.brp, 4) + self.assertEqual(parsed_args.test_timing.tseg1, 11) + self.assertEqual(parsed_args.test_timing.tseg2, 4) + self.assertEqual(parsed_args.test_timing.sjw, 2) + self.assertEqual(parsed_args.test_timing.nof_samples, 3) + self.assertEqual(len(parsed_args.test_can_filters), 2) + self.assertEqual(parsed_args.test_can_filters[0]["can_id"], 0x100) + self.assertEqual(parsed_args.test_can_filters[0]["can_mask"], 0x7FF) + self.assertEqual(parsed_args.test_can_filters[1]["can_id"], 0x200 | 0x20000000) + self.assertEqual( + parsed_args.test_can_filters[1]["can_mask"], 0x7F0 & 0x20000000 + ) + self.assertEqual(parsed_args.test_bus_kwargs["app_name"], "MyApp") + self.assertEqual(parsed_args.test_bus_kwargs["serial"], 1234) + + def test_add_bus_arguments_no_prefix(self): + parser = argparse.ArgumentParser() + add_bus_arguments(parser, filter_arg=True) + + parsed_args = parser.parse_args( + [ + "--channel", + "0", + "--interface", + "vector", + "--timing", + "f_clock=8000000", + "brp=4", + "tseg1=11", + "tseg2=4", + "sjw=2", + "nof_samples=3", + "--filter", + "100:7FF", + "200~7F0", + "--bus-kwargs", + "app_name=MyApp", + "serial=1234", + ] + ) + + self.assertEqual(parsed_args.channel, "0") + self.assertEqual(parsed_args.interface, "vector") + self.assertEqual(parsed_args.timing.f_clock, 8000000) + self.assertEqual(parsed_args.timing.brp, 4) + self.assertEqual(parsed_args.timing.tseg1, 11) + self.assertEqual(parsed_args.timing.tseg2, 4) + self.assertEqual(parsed_args.timing.sjw, 2) + self.assertEqual(parsed_args.timing.nof_samples, 3) + self.assertEqual(len(parsed_args.can_filters), 2) + self.assertEqual(parsed_args.can_filters[0]["can_id"], 0x100) + self.assertEqual(parsed_args.can_filters[0]["can_mask"], 0x7FF) + self.assertEqual(parsed_args.can_filters[1]["can_id"], 0x200 | 0x20000000) + self.assertEqual(parsed_args.can_filters[1]["can_mask"], 0x7F0 & 0x20000000) + self.assertEqual(parsed_args.bus_kwargs["app_name"], "MyApp") + self.assertEqual(parsed_args.bus_kwargs["serial"], 1234) + + @patch("can.Bus") + def test_create_bus_from_namespace(self, mock_bus): + namespace = argparse.Namespace( + test_channel="vcan0", + test_interface="virtual", + test_bitrate=500000, + test_data_bitrate=2000000, + test_fd=True, + test_can_filters=[{"can_id": 0x100, "can_mask": 0x7FF}], + test_bus_kwargs={"app_name": "MyApp", "serial": 1234}, + ) + + create_bus_from_namespace(namespace, prefix="test") + + mock_bus.assert_called_once_with( + channel="vcan0", + interface="virtual", + bitrate=500000, + data_bitrate=2000000, + fd=True, + can_filters=[{"can_id": 0x100, "can_mask": 0x7FF}], + app_name="MyApp", + serial=1234, + single_handle=True, + ) + + @patch("can.Bus") + def test_create_bus_from_namespace_no_prefix(self, mock_bus): + namespace = argparse.Namespace( + channel="vcan0", + interface="virtual", + bitrate=500000, + data_bitrate=2000000, + fd=True, + can_filters=[{"can_id": 0x100, "can_mask": 0x7FF}], + bus_kwargs={"app_name": "MyApp", "serial": 1234}, + ) + + create_bus_from_namespace(namespace) + + mock_bus.assert_called_once_with( + channel="vcan0", + interface="virtual", + bitrate=500000, + data_bitrate=2000000, + fd=True, + can_filters=[{"can_id": 0x100, "can_mask": 0x7FF}], + app_name="MyApp", + serial=1234, + single_handle=True, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_cyclic_socketcan.py b/test/test_cyclic_socketcan.py index 30c86d6a5..f19ce95b9 100644 --- a/test/test_cyclic_socketcan.py +++ b/test/test_cyclic_socketcan.py @@ -3,9 +3,9 @@ """ This module tests multiple message cyclic send tasks. """ +import time import unittest -import time import can from .config import TEST_INTERFACE_SOCKETCAN diff --git a/test/test_interface.py b/test/test_interface.py new file mode 100644 index 000000000..271e90b1b --- /dev/null +++ b/test/test_interface.py @@ -0,0 +1,42 @@ +import importlib +from unittest.mock import patch + +import pytest + +import can +from can.interfaces import BACKENDS + + +@pytest.fixture(params=(BACKENDS.keys())) +def constructor(request): + mod, cls = BACKENDS[request.param] + + try: + module = importlib.import_module(mod) + constructor = getattr(module, cls) + except: + pytest.skip("Unable to load interface") + + return constructor + + +@pytest.fixture +def interface(constructor): + class MockInterface(constructor): + def __init__(self): + pass + + def __del__(self): + pass + + return MockInterface() + + +@patch.object(can.bus.BusABC, "shutdown") +def test_interface_calls_parent_shutdown(mock_shutdown, interface): + try: + interface.shutdown() + except: + pass + finally: + mock_shutdown.assert_called() diff --git a/test/test_interface_canalystii.py b/test/test_interface_canalystii.py index 4d1d3eb84..3bdd281d2 100755 --- a/test/test_interface_canalystii.py +++ b/test/test_interface_canalystii.py @@ -1,14 +1,13 @@ #!/usr/bin/env python -""" -""" +""" """ -import time import unittest -from unittest.mock import Mock, patch, call from ctypes import c_ubyte +from unittest.mock import call, patch import canalystii as driver # low-level driver module, mock out this layer + import can from can.interfaces.canalystii import CANalystIIBus @@ -22,6 +21,9 @@ def test_initialize_from_constructor(self): with create_mock_device() as mock_device: instance = mock_device.return_value bus = CANalystIIBus(bitrate=1000000) + + self.assertEqual(bus.protocol, can.CanProtocol.CAN_20) + instance.init.assert_has_calls( [ call(0, bitrate=1000000), @@ -34,6 +36,8 @@ def test_initialize_single_channel_only(self): with create_mock_device() as mock_device: instance = mock_device.return_value bus = CANalystIIBus(channel, bitrate=1000000) + + self.assertEqual(bus.protocol, can.CanProtocol.CAN_20) instance.init.assert_called_once_with(channel, bitrate=1000000) def test_initialize_with_timing_registers(self): @@ -43,6 +47,8 @@ def test_initialize_with_timing_registers(self): f_clock=8_000_000, btr0=0x03, btr1=0x6F ) bus = CANalystIIBus(bitrate=None, timing=timing) + self.assertEqual(bus.protocol, can.CanProtocol.CAN_20) + instance.init.assert_has_calls( [ call(0, timing0=0x03, timing1=0x6F), diff --git a/test/test_interface_ixxat.py b/test/test_interface_ixxat.py index 484a88b58..90b5f7adc 100644 --- a/test/test_interface_ixxat.py +++ b/test/test_interface_ixxat.py @@ -8,6 +8,7 @@ """ import unittest + import can @@ -50,6 +51,18 @@ def setUp(self): raise unittest.SkipTest("not available on this platform") def test_bus_creation(self): + try: + configs = can.detect_available_configs("ixxat") + if configs: + for interface_kwargs in configs: + bus = can.Bus(**interface_kwargs) + bus.shutdown() + else: + raise unittest.SkipTest("No adapters were detected") + except can.CanInterfaceNotImplementedError: + raise unittest.SkipTest("not available on this platform") + + def test_bus_creation_incorrect_channel(self): # non-existent channel -> use arbitrary high value with self.assertRaises(can.CanInitializationError): can.Bus(interface="ixxat", channel=0xFFFF) diff --git a/test/test_interface_ixxat_fd.py b/test/test_interface_ixxat_fd.py index 80060a7ed..7274498aa 100644 --- a/test/test_interface_ixxat_fd.py +++ b/test/test_interface_ixxat_fd.py @@ -8,6 +8,7 @@ """ import unittest + import can diff --git a/test/test_kvaser.py b/test/test_kvaser.py index fda8b8316..1ad035dec 100644 --- a/test/test_kvaser.py +++ b/test/test_kvaser.py @@ -1,8 +1,8 @@ #!/usr/bin/env python -""" -""" +""" """ +import ctypes import time import unittest from unittest.mock import Mock @@ -10,8 +10,7 @@ import pytest import can -from can.interfaces.kvaser import canlib -from can.interfaces.kvaser import constants +from can.interfaces.kvaser import canlib, constants class KvaserTest(unittest.TestCase): @@ -21,6 +20,7 @@ def setUp(self): canlib.canIoCtl = Mock(return_value=0) canlib.canIoCtlInit = Mock(return_value=0) canlib.kvReadTimer = Mock() + canlib.canSetBusParamsC200 = Mock() canlib.canSetBusParams = Mock() canlib.canSetBusParamsFd = Mock() canlib.canBusOn = Mock() @@ -46,9 +46,30 @@ def tearDown(self): def test_bus_creation(self): self.assertIsInstance(self.bus, canlib.KvaserBus) + self.assertEqual(self.bus.protocol, can.CanProtocol.CAN_20) self.assertTrue(canlib.canOpenChannel.called) self.assertTrue(canlib.canBusOn.called) + def test_bus_creation_illegal_channel_name(self): + # Test if the bus constructor is able to deal with non-ASCII characters + def canGetChannelDataMock( + channel: ctypes.c_int, + param: ctypes.c_int, + buf: ctypes.c_void_p, + bufsize: ctypes.c_size_t, + ): + if param == constants.canCHANNELDATA_DEVDESCR_ASCII: + buf_char_ptr = ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)) + for i, char in enumerate(b"hello\x7a\xcb"): + buf_char_ptr[i] = char + + canlib.canGetChannelData = canGetChannelDataMock + bus = can.Bus(channel=0, interface="kvaser") + + self.assertTrue(bus.channel_info.startswith("hello")) + + bus.shutdown() + def test_bus_shutdown(self): self.bus.shutdown() self.assertTrue(canlib.canBusOff.called) @@ -141,15 +162,28 @@ def test_recv_standard(self): def test_available_configs(self): configs = canlib.KvaserBus._detect_available_configs() expected = [ - {"interface": "kvaser", "channel": 0}, - {"interface": "kvaser", "channel": 1}, + { + "interface": "kvaser", + "channel": 0, + "dongle_channel": 1, + "device_name": "", + "serial": 0, + }, + { + "interface": "kvaser", + "channel": 1, + "dongle_channel": 1, + "device_name": "", + "serial": 0, + }, ] self.assertListEqual(configs, expected) def test_canfd_default_data_bitrate(self): canlib.canSetBusParams.reset_mock() canlib.canSetBusParamsFd.reset_mock() - can.Bus(channel=0, interface="kvaser", fd=True) + bus = can.Bus(channel=0, interface="kvaser", fd=True) + self.assertEqual(bus.protocol, can.CanProtocol.CAN_FD) canlib.canSetBusParams.assert_called_once_with( 0, constants.canFD_BITRATE_500K_80P, 0, 0, 0, 0, 0 ) @@ -157,11 +191,70 @@ def test_canfd_default_data_bitrate(self): 0, constants.canFD_BITRATE_500K_80P, 0, 0, 0 ) + def test_can_timing(self): + canlib.canSetBusParams.reset_mock() + canlib.canSetBusParamsFd.reset_mock() + timing = can.BitTiming.from_bitrate_and_segments( + f_clock=16_000_000, + bitrate=125_000, + tseg1=13, + tseg2=2, + sjw=1, + ) + can.Bus(channel=0, interface="kvaser", timing=timing) + canlib.canSetBusParamsC200.assert_called_once_with(0, timing.btr0, timing.btr1) + + def test_canfd_timing(self): + canlib.canSetBusParams.reset_mock() + canlib.canSetBusParamsFd.reset_mock() + canlib.canOpenChannel.reset_mock() + timing = can.BitTimingFd.from_bitrate_and_segments( + f_clock=80_000_000, + nom_bitrate=500_000, + nom_tseg1=68, + nom_tseg2=11, + nom_sjw=10, + data_bitrate=2_000_000, + data_tseg1=10, + data_tseg2=9, + data_sjw=8, + ) + can.Bus(channel=0, interface="kvaser", timing=timing) + canlib.canSetBusParams.assert_called_once_with(0, 500_000, 68, 11, 10, 1, 0) + canlib.canSetBusParamsFd.assert_called_once_with(0, 2_000_000, 10, 9, 8) + canlib.canOpenChannel.assert_called_with( + 0, constants.canOPEN_CAN_FD | constants.canOPEN_ACCEPT_VIRTUAL + ) + + def test_canfd_non_iso(self): + canlib.canSetBusParams.reset_mock() + canlib.canSetBusParamsFd.reset_mock() + canlib.canOpenChannel.reset_mock() + timing = can.BitTimingFd.from_bitrate_and_segments( + f_clock=80_000_000, + nom_bitrate=500_000, + nom_tseg1=68, + nom_tseg2=11, + nom_sjw=10, + data_bitrate=2_000_000, + data_tseg1=10, + data_tseg2=9, + data_sjw=8, + ) + bus = can.Bus(channel=0, interface="kvaser", timing=timing, fd_non_iso=True) + self.assertEqual(bus.protocol, can.CanProtocol.CAN_FD_NON_ISO) + canlib.canSetBusParams.assert_called_once_with(0, 500_000, 68, 11, 10, 1, 0) + canlib.canSetBusParamsFd.assert_called_once_with(0, 2_000_000, 10, 9, 8) + canlib.canOpenChannel.assert_called_with( + 0, constants.canOPEN_CAN_FD_NONISO | constants.canOPEN_ACCEPT_VIRTUAL + ) + def test_canfd_nondefault_data_bitrate(self): canlib.canSetBusParams.reset_mock() canlib.canSetBusParamsFd.reset_mock() data_bitrate = 2000000 - can.Bus(channel=0, interface="kvaser", fd=True, data_bitrate=data_bitrate) + bus = can.Bus(channel=0, interface="kvaser", fd=True, data_bitrate=data_bitrate) + self.assertEqual(bus.protocol, can.CanProtocol.CAN_FD) bitrate_constant = canlib.BITRATE_FD[data_bitrate] canlib.canSetBusParams.assert_called_once_with( 0, constants.canFD_BITRATE_500K_80P, 0, 0, 0, 0, 0 @@ -184,6 +277,19 @@ def test_bus_get_stats(self): self.assertTrue(canlib.canGetBusStatistics.called) self.assertIsInstance(stats, canlib.structures.BusStatistics) + def test_bus_no_init_access(self): + canlib.canOpenChannel.reset_mock() + bus = can.Bus(interface="kvaser", channel=0, no_init_access=True) + + self.assertGreater(canlib.canOpenChannel.call_count, 0) + for call in canlib.canOpenChannel.call_args_list: + self.assertEqual( + call[0][1] & constants.canOPEN_NO_INIT_ACCESS, + constants.canOPEN_NO_INIT_ACCESS, + ) + + bus.shutdown() + @staticmethod def canGetNumberOfChannels(count): count._obj.value = 2 diff --git a/test/test_logger.py b/test/test_logger.py index bb0015a89..41778ab6a 100644 --- a/test/test_logger.py +++ b/test/test_logger.py @@ -4,20 +4,19 @@ This module tests the functions inside of logger.py """ -import unittest -from unittest import mock -from unittest.mock import Mock import gzip import os import sys +import unittest +from unittest import mock +from unittest.mock import Mock import pytest import can +import can.cli import can.logger -from .config import * - class TestLoggerScriptModule(unittest.TestCase): def setUp(self) -> None: @@ -91,7 +90,7 @@ def test_log_virtual_with_config(self): "--bitrate", "250000", "--fd", - "--data_bitrate", + "--data-bitrate", "2000000", ] can.logger.main() @@ -108,6 +107,96 @@ def test_log_virtual_sizedlogger(self): self.assertSuccessfullCleanup() self.mock_logger_sized.assert_called_once() + def test_parse_logger_args(self): + args = self.baseargs + [ + "--bitrate", + "250000", + "--fd", + "--data-bitrate", + "2000000", + "--receive-own-messages=True", + ] + results, additional_config = can.logger._parse_logger_args(args[1:]) + assert results.interface == "virtual" + assert results.bitrate == 250_000 + assert results.fd is True + assert results.data_bitrate == 2_000_000 + assert additional_config["receive_own_messages"] is True + + def test_parse_can_filters(self): + expected_can_filters = [{"can_id": 0x100, "can_mask": 0x7FC}] + results, additional_config = can.logger._parse_logger_args( + ["--filter", "100:7FC", "--bitrate", "250000"] + ) + assert results.can_filters == expected_can_filters + + def test_parse_can_filters_list(self): + expected_can_filters = [ + {"can_id": 0x100, "can_mask": 0x7FC}, + {"can_id": 0x200, "can_mask": 0x7F0}, + ] + results, additional_config = can.logger._parse_logger_args( + ["--filter", "100:7FC", "200:7F0", "--bitrate", "250000"] + ) + assert results.can_filters == expected_can_filters + + def test_parse_timing(self) -> None: + can20_args = self.baseargs + [ + "--timing", + "f_clock=8_000_000", + "tseg1=5", + "tseg2=2", + "sjw=2", + "brp=2", + "nof_samples=1", + "--app-name=CANalyzer", + ] + results, additional_config = can.logger._parse_logger_args(can20_args[1:]) + assert results.timing == can.BitTiming( + f_clock=8_000_000, brp=2, tseg1=5, tseg2=2, sjw=2, nof_samples=1 + ) + assert additional_config["app_name"] == "CANalyzer" + + canfd_args = self.baseargs + [ + "--timing", + "f_clock=80_000_000", + "nom_tseg1=119", + "nom_tseg2=40", + "nom_sjw=40", + "nom_brp=1", + "data_tseg1=29", + "data_tseg2=10", + "data_sjw=10", + "data_brp=1", + "--app-name=CANalyzer", + ] + results, additional_config = can.logger._parse_logger_args(canfd_args[1:]) + assert results.timing == can.BitTimingFd( + f_clock=80_000_000, + nom_brp=1, + nom_tseg1=119, + nom_tseg2=40, + nom_sjw=40, + data_brp=1, + data_tseg1=29, + data_tseg2=10, + data_sjw=10, + ) + assert additional_config["app_name"] == "CANalyzer" + + # remove f_clock parameter, parsing should fail + incomplete_args = self.baseargs + [ + "--timing", + "tseg1=5", + "tseg2=2", + "sjw=2", + "brp=2", + "nof_samples=1", + "--app-name=CANalyzer", + ] + with self.assertRaises(SystemExit): + can.logger._parse_logger_args(incomplete_args[1:]) + def test_parse_additional_config(self): unknown_args = [ "--app-name=CANalyzer", @@ -115,8 +204,9 @@ def test_parse_additional_config(self): "--receive-own-messages=True", "--false-boolean=False", "--offset=1.5", + "--tseg1-abr=127", ] - parsed_args = can.logger._parse_additional_config(unknown_args) + parsed_args = can.cli._parse_additional_config(unknown_args) assert "app_name" in parsed_args assert parsed_args["app_name"] == "CANalyzer" @@ -139,17 +229,20 @@ def test_parse_additional_config(self): assert "offset" in parsed_args assert parsed_args["offset"] == 1.5 + assert "tseg1_abr" in parsed_args + assert parsed_args["tseg1_abr"] == 127 + with pytest.raises(ValueError): - can.logger._parse_additional_config(["--wrong-format"]) + can.cli._parse_additional_config(["--wrong-format"]) with pytest.raises(ValueError): - can.logger._parse_additional_config(["-wrongformat=value"]) + can.cli._parse_additional_config(["-wrongformat=value"]) with pytest.raises(ValueError): - can.logger._parse_additional_config(["--wrongformat=value1 value2"]) + can.cli._parse_additional_config(["--wrongformat=value1 value2"]) with pytest.raises(ValueError): - can.logger._parse_additional_config(["wrongformat="]) + can.cli._parse_additional_config(["wrongformat="]) class TestLoggerCompressedFile(unittest.TestCase): diff --git a/test/test_message_class.py b/test/test_message_class.py index 4840402ff..8e2367034 100644 --- a/test/test_message_class.py +++ b/test/test_message_class.py @@ -1,22 +1,21 @@ #!/usr/bin/env python -import unittest +import pickle import sys -from math import isinf, isnan +import unittest from copy import copy, deepcopy -import pickle from datetime import timedelta +from math import isinf, isnan -from hypothesis import HealthCheck, given, settings import hypothesis.errors import hypothesis.strategies as st +import pytest +from hypothesis import HealthCheck, given, settings from can import Message +from .config import IS_GITHUB_ACTIONS, IS_PYPY, IS_WINDOWS from .message_helper import ComparingMessagesTestCase -from .config import IS_GITHUB_ACTIONS, IS_WINDOWS, IS_PYPY - -import pytest class TestMessageClass(unittest.TestCase): diff --git a/test/test_message_filtering.py b/test/test_message_filtering.py index e6fe16d46..a73e07aa2 100644 --- a/test/test_message_filtering.py +++ b/test/test_message_filtering.py @@ -10,7 +10,6 @@ from .data.example_data import TEST_ALL_MESSAGES - EXAMPLE_MSG = Message(arbitration_id=0x123, is_extended_id=True) HIGHEST_MSG = Message(arbitration_id=0x1FFFFFFF, is_extended_id=True) diff --git a/test/test_message_sync.py b/test/test_message_sync.py index 7552915e7..90cbe372c 100644 --- a/test/test_message_sync.py +++ b/test/test_message_sync.py @@ -4,19 +4,18 @@ This module tests :class:`can.MessageSync`. """ -from copy import copy -import time import gc - +import time import unittest +from copy import copy + import pytest -from can import MessageSync, Message +from can import Message, MessageSync -from .config import IS_CI, IS_TRAVIS, IS_OSX, IS_GITHUB_ACTIONS, IS_LINUX -from .message_helper import ComparingMessagesTestCase +from .config import IS_CI, IS_GITHUB_ACTIONS, IS_LINUX, IS_OSX, IS_TRAVIS from .data.example_data import TEST_MESSAGES_BASE - +from .message_helper import ComparingMessagesTestCase TEST_FEWER_MESSAGES = TEST_MESSAGES_BASE[::2] diff --git a/test/test_neousys.py b/test/test_neousys.py index 26a220048..69c818869 100644 --- a/test/test_neousys.py +++ b/test/test_neousys.py @@ -1,20 +1,14 @@ #!/usr/bin/env python -import ctypes -import os -import pickle import unittest -from unittest.mock import Mock - from ctypes import ( + POINTER, byref, + c_ubyte, cast, - POINTER, sizeof, - c_ubyte, ) - -import pytest +from unittest.mock import Mock import can from can.interfaces.neousys import neousys @@ -42,12 +36,13 @@ def tearDown(self) -> None: def test_bus_creation(self) -> None: self.assertIsInstance(self.bus, neousys.NeousysBus) - self.assertTrue(neousys.NEOUSYS_CANLIB.CAN_Setup.called) - self.assertTrue(neousys.NEOUSYS_CANLIB.CAN_Start.called) - self.assertTrue(neousys.NEOUSYS_CANLIB.CAN_RegisterReceived.called) - self.assertTrue(neousys.NEOUSYS_CANLIB.CAN_RegisterStatus.called) - self.assertTrue(neousys.NEOUSYS_CANLIB.CAN_Send.not_called) - self.assertTrue(neousys.NEOUSYS_CANLIB.CAN_Stop.not_called) + self.assertEqual(self.bus.protocol, can.CanProtocol.CAN_20) + neousys.NEOUSYS_CANLIB.CAN_Setup.assert_called() + neousys.NEOUSYS_CANLIB.CAN_Start.assert_called() + neousys.NEOUSYS_CANLIB.CAN_RegisterReceived.assert_called() + neousys.NEOUSYS_CANLIB.CAN_RegisterStatus.assert_called() + neousys.NEOUSYS_CANLIB.CAN_Send.assert_not_called() + neousys.NEOUSYS_CANLIB.CAN_Stop.assert_not_called() CAN_Start_args = ( can.interfaces.neousys.neousys.NEOUSYS_CANLIB.CAN_Setup.call_args[0] @@ -68,6 +63,8 @@ def test_bus_creation(self) -> None: def test_bus_creation_bitrate(self) -> None: self.bus = can.Bus(channel=0, interface="neousys", bitrate=200000) self.assertIsInstance(self.bus, neousys.NeousysBus) + self.assertEqual(self.bus.protocol, can.CanProtocol.CAN_20) + CAN_Start_args = ( can.interfaces.neousys.neousys.NEOUSYS_CANLIB.CAN_Setup.call_args[0] ) @@ -101,11 +98,11 @@ def test_send(self) -> None: arbitration_id=0x01, data=[1, 2, 3, 4, 5, 6, 7, 8], is_extended_id=False ) self.bus.send(msg) - self.assertTrue(neousys.NEOUSYS_CANLIB.CAN_Send.called) + neousys.NEOUSYS_CANLIB.CAN_Send.assert_called() def test_shutdown(self) -> None: self.bus.shutdown() - self.assertTrue(neousys.NEOUSYS_CANLIB.CAN_Stop.called) + neousys.NEOUSYS_CANLIB.CAN_Stop.assert_called() if __name__ == "__main__": diff --git a/test/test_neovi.py b/test/test_neovi.py index 181f92377..8c816bef2 100644 --- a/test/test_neovi.py +++ b/test/test_neovi.py @@ -1,9 +1,9 @@ #!/usr/bin/env python -""" -""" +""" """ import pickle import unittest + from can.interfaces.ics_neovi import ICSApiError diff --git a/test/test_pcan.py b/test/test_pcan.py index aa0988a48..a9c6ea922 100644 --- a/test/test_pcan.py +++ b/test/test_pcan.py @@ -3,6 +3,7 @@ """ import ctypes +import struct import unittest from unittest import mock from unittest.mock import Mock, patch @@ -11,7 +12,7 @@ from parameterized import parameterized import can -from can.bus import BusState +from can import BusState, CanProtocol from can.exceptions import CanInitializationError from can.interfaces.pcan import PcanBus, PcanError from can.interfaces.pcan.basic import * @@ -19,7 +20,6 @@ class TestPCANBus(unittest.TestCase): def setUp(self) -> None: - patcher = mock.patch("can.interfaces.pcan.pcan.PCANBasic", spec=True) self.MockPCANBasic = patcher.start() self.addCleanup(patcher.stop) @@ -53,8 +53,12 @@ def test_bus_creation(self) -> None: self.bus = can.Bus(interface="pcan") self.assertIsInstance(self.bus, PcanBus) - self.MockPCANBasic.assert_called_once() + self.assertEqual(self.bus.protocol, CanProtocol.CAN_20) + + with pytest.deprecated_call(): + self.assertFalse(self.bus.fd) + self.MockPCANBasic.assert_called_once() self.mock_pcan.Initialize.assert_called_once() self.mock_pcan.InitializeFD.assert_not_called() @@ -62,7 +66,7 @@ def test_bus_creation_state_error(self) -> None: with self.assertRaises(ValueError): can.Bus(interface="pcan", state=BusState.ERROR) - @parameterized.expand([("f_clock", 8_000_000), ("f_clock_mhz", 8)]) + @parameterized.expand([("f_clock", 80_000_000), ("f_clock_mhz", 80)]) def test_bus_creation_fd(self, clock_param: str, clock_val: int) -> None: self.bus = can.Bus( interface="pcan", @@ -80,6 +84,11 @@ def test_bus_creation_fd(self, clock_param: str, clock_val: int) -> None: ) self.assertIsInstance(self.bus, PcanBus) + self.assertEqual(self.bus.protocol, CanProtocol.CAN_FD) + + with pytest.deprecated_call(): + self.assertTrue(self.bus.fd) + self.MockPCANBasic.assert_called_once() self.mock_pcan.Initialize.assert_not_called() self.mock_pcan.InitializeFD.assert_called_once() @@ -115,6 +124,19 @@ def test_api_version_read_fail(self) -> None: with self.assertRaises(CanInitializationError): self.bus = can.Bus(interface="pcan") + def test_issue1642(self) -> None: + self.PCAN_API_VERSION_SIM = "1, 3, 0, 50" + with self.assertLogs("can.pcan", level="WARNING") as cm: + self.bus = can.Bus(interface="pcan") + found_version_warning = False + for i in cm.output: + if "version" in i and "pcan" in i: + found_version_warning = True + self.assertTrue( + found_version_warning, + f"No warning was logged for incompatible api version {cm.output}", + ) + @parameterized.expand( [ ("no_error", PCAN_ERROR_OK, PCAN_ERROR_OK, "some ok text 1"), @@ -210,6 +232,7 @@ def test_recv(self): self.assertEqual(recv_msg.is_fd, False) self.assertSequenceEqual(recv_msg.data, msg.DATA) self.assertEqual(recv_msg.timestamp, 0) + self.assertEqual(recv_msg.channel, "PCAN_USBBUS1") def test_recv_fd(self): data = (ctypes.c_ubyte * 64)(*[x for x in range(64)]) @@ -233,6 +256,7 @@ def test_recv_fd(self): self.assertEqual(recv_msg.is_fd, True) self.assertSequenceEqual(recv_msg.data, msg.DATA) self.assertEqual(recv_msg.timestamp, 0) + self.assertEqual(recv_msg.channel, "PCAN_USBBUS1") @pytest.mark.timeout(3.0) @patch("select.select", return_value=([], [], [])) @@ -375,9 +399,7 @@ def test_detect_available_configs(self) -> None: self.assertEqual(len(configs), 50) else: value = (TPCANChannelInformation * 1).from_buffer_copy( - b"Q\x00\x05\x00\x01\x00\x00\x00PCAN-USB FD\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b'\x00\x00\x00\x00\x00\x00\x003"\x11\x00\x01\x00\x00\x00' + struct.pack("HBBI33sII", 81, 5, 0, 1, b"PCAN-USB FD", 1122867, 1) ) self.mock_pcan.GetValue = Mock(return_value=(PCAN_ERROR_OK, value)) configs = PcanBus._detect_available_configs() @@ -452,10 +474,11 @@ def test_peak_fd_bus_constructor_regression(self): def test_constructor_bit_timing(self): timing = can.BitTiming.from_registers(f_clock=8_000_000, btr0=0x47, btr1=0x2F) - can.Bus(interface="pcan", channel="PCAN_USBBUS1", timing=timing) + bus = can.Bus(interface="pcan", channel="PCAN_USBBUS1", timing=timing) bitrate_arg = self.mock_pcan.Initialize.call_args[0][1] self.assertEqual(bitrate_arg.value, 0x472F) + self.assertEqual(bus.protocol, CanProtocol.CAN_20) def test_constructor_bit_timing_fd(self): timing = can.BitTimingFd( @@ -469,7 +492,8 @@ def test_constructor_bit_timing_fd(self): data_tseg2=6, data_sjw=1, ) - can.Bus(interface="pcan", channel="PCAN_USBBUS1", timing=timing) + bus = can.Bus(interface="pcan", channel="PCAN_USBBUS1", timing=timing) + self.assertEqual(bus.protocol, CanProtocol.CAN_FD) bitrate_arg = self.mock_pcan.InitializeFD.call_args[0][-1] diff --git a/test/test_player.py b/test/test_player.py index c15bf82e2..c4c3c90ef 100755 --- a/test/test_player.py +++ b/test/test_player.py @@ -4,18 +4,20 @@ This module tests the functions inside of player.py """ +import io +import os +import sys import unittest from unittest import mock from unittest.mock import Mock -import os -import sys -import io + +from parameterized import parameterized + import can import can.player class TestPlayerScriptModule(unittest.TestCase): - logfile = os.path.join(os.path.dirname(__file__), "data", "test_CanMessage.asc") def setUp(self) -> None: @@ -38,7 +40,7 @@ def assertSuccessfulCleanup(self): self.mock_virtual_bus.__exit__.assert_called_once() def test_play_virtual(self): - sys.argv = self.baseargs + [self.logfile] + sys.argv = [*self.baseargs, self.logfile] can.player.main() msg1 = can.Message( timestamp=2.501, @@ -60,19 +62,13 @@ def test_play_virtual(self): dlc=8, data=[0x5, 0xC, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0], ) - if sys.version_info >= (3, 8): - # The args argument was introduced with python 3.8 - self.assertTrue( - msg1.equals(self.mock_virtual_bus.send.mock_calls[0].args[0]) - ) - self.assertTrue( - msg2.equals(self.mock_virtual_bus.send.mock_calls[1].args[0]) - ) + self.assertTrue(msg1.equals(self.mock_virtual_bus.send.mock_calls[0].args[0])) + self.assertTrue(msg2.equals(self.mock_virtual_bus.send.mock_calls[1].args[0])) self.assertSuccessfulCleanup() def test_play_virtual_verbose(self): - sys.argv = self.baseargs + ["-v", self.logfile] - with unittest.mock.patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + sys.argv = [*self.baseargs, "-v", self.logfile] + with mock.patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: can.player.main() self.assertIn("09 08 07 06 05 04 03 02", mock_stdout.getvalue()) self.assertIn("05 0c 00 00 00 00 00 00", mock_stdout.getvalue()) @@ -82,7 +78,7 @@ def test_play_virtual_verbose(self): def test_play_virtual_exit(self): self.MockSleep.side_effect = [None, KeyboardInterrupt] - sys.argv = self.baseargs + [self.logfile] + sys.argv = [*self.baseargs, self.logfile] can.player.main() assert self.mock_virtual_bus.send.call_count <= 2 self.assertSuccessfulCleanup() @@ -91,7 +87,7 @@ def test_play_skip_error_frame(self): logfile = os.path.join( os.path.dirname(__file__), "data", "logfile_errorframes.asc" ) - sys.argv = self.baseargs + ["-v", logfile] + sys.argv = [*self.baseargs, "-v", logfile] can.player.main() self.assertEqual(self.mock_virtual_bus.send.call_count, 9) self.assertSuccessfulCleanup() @@ -100,11 +96,52 @@ def test_play_error_frame(self): logfile = os.path.join( os.path.dirname(__file__), "data", "logfile_errorframes.asc" ) - sys.argv = self.baseargs + ["-v", "--error-frames", logfile] + sys.argv = [*self.baseargs, "-v", "--error-frames", logfile] can.player.main() self.assertEqual(self.mock_virtual_bus.send.call_count, 12) self.assertSuccessfulCleanup() + @parameterized.expand([0, 1, 2, 3]) + def test_play_loop(self, loop_val): + sys.argv = [*self.baseargs, "--loop", str(loop_val), self.logfile] + can.player.main() + msg1 = can.Message( + timestamp=2.501, + arbitration_id=0xC8, + is_extended_id=False, + is_fd=False, + is_rx=False, + channel=1, + dlc=8, + data=[0x9, 0x8, 0x7, 0x6, 0x5, 0x4, 0x3, 0x2], + ) + msg2 = can.Message( + timestamp=17.876708, + arbitration_id=0x6F9, + is_extended_id=False, + is_fd=False, + is_rx=True, + channel=0, + dlc=8, + data=[0x5, 0xC, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0], + ) + for i in range(loop_val): + self.assertTrue( + msg1.equals(self.mock_virtual_bus.send.mock_calls[2 * i + 0].args[0]) + ) + self.assertTrue( + msg2.equals(self.mock_virtual_bus.send.mock_calls[2 * i + 1].args[0]) + ) + self.assertEqual(self.mock_virtual_bus.send.call_count, 2 * loop_val) + self.assertSuccessfulCleanup() + + def test_play_loop_infinite(self): + self.mock_virtual_bus.send.side_effect = [None] * 99 + [KeyboardInterrupt] + sys.argv = [*self.baseargs, "-l", "i", self.logfile] + can.player.main() + self.assertEqual(self.mock_virtual_bus.send.call_count, 100) + self.assertSuccessfulCleanup() + class TestPlayerCompressedFile(TestPlayerScriptModule): """ diff --git a/test/test_robotell.py b/test/test_robotell.py index 64f4acaf1..f95139917 100644 --- a/test/test_robotell.py +++ b/test/test_robotell.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import unittest + import can @@ -14,6 +15,9 @@ def setUp(self): def tearDown(self): self.bus.shutdown() + def test_protocol(self): + self.assertEqual(self.bus.protocol, can.CanProtocol.CAN_20) + def test_recv_extended(self): self.serial.write( bytearray( diff --git a/test/test_rotating_loggers.py b/test/test_rotating_loggers.py index ad4388bf7..77a2c7f5d 100644 --- a/test/test_rotating_loggers.py +++ b/test/test_rotating_loggers.py @@ -6,31 +6,41 @@ import os from pathlib import Path +from typing import cast from unittest.mock import Mock import can +from can.io.generic import FileIOMessageWriter +from can.typechecking import StringPathLike + from .data.example_data import generate_message class TestBaseRotatingLogger: @staticmethod - def _get_instance(path, *args, **kwargs) -> can.io.BaseRotatingLogger: + def _get_instance(file: StringPathLike) -> can.io.BaseRotatingLogger: class SubClass(can.io.BaseRotatingLogger): """Subclass that implements abstract methods for testing.""" _supported_formats = {".asc", ".blf", ".csv", ".log", ".txt"} - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self._writer = can.Printer(file=path / "__unused.txt") + def __init__(self, file: StringPathLike, **kwargs) -> None: + super().__init__(**kwargs) + suffix = Path(file).suffix.lower() + if suffix not in self._supported_formats: + raise ValueError(f"Unsupported file format: {suffix}") + self._writer = can.Logger(filename=file) + + @property + def writer(self) -> FileIOMessageWriter: + return cast(FileIOMessageWriter, self._writer) def should_rollover(self, msg: can.Message) -> bool: return False - def do_rollover(self): - ... + def do_rollover(self): ... - return SubClass(*args, **kwargs) + return SubClass(file=file) def test_import(self): assert hasattr(can.io, "BaseRotatingLogger") @@ -49,48 +59,39 @@ def test_attributes(self): assert hasattr(can.io.BaseRotatingLogger, "do_rollover") def test_get_new_writer(self, tmp_path): - logger_instance = self._get_instance(tmp_path) - - writer = logger_instance._get_new_writer(tmp_path / "file.ASC") - assert isinstance(writer, can.ASCWriter) - writer.stop() + with self._get_instance(tmp_path / "file.ASC") as logger_instance: + assert isinstance(logger_instance.writer, can.ASCWriter) - writer = logger_instance._get_new_writer(tmp_path / "file.BLF") - assert isinstance(writer, can.BLFWriter) - writer.stop() + with self._get_instance(tmp_path / "file.BLF") as logger_instance: + assert isinstance(logger_instance.writer, can.BLFWriter) - writer = logger_instance._get_new_writer(tmp_path / "file.CSV") - assert isinstance(writer, can.CSVWriter) - writer.stop() + with self._get_instance(tmp_path / "file.CSV") as logger_instance: + assert isinstance(logger_instance.writer, can.CSVWriter) - writer = logger_instance._get_new_writer(tmp_path / "file.LOG") - assert isinstance(writer, can.CanutilsLogWriter) - writer.stop() + with self._get_instance(tmp_path / "file.LOG") as logger_instance: + assert isinstance(logger_instance.writer, can.CanutilsLogWriter) - writer = logger_instance._get_new_writer(tmp_path / "file.TXT") - assert isinstance(writer, can.Printer) - writer.stop() + with self._get_instance(tmp_path / "file.TXT") as logger_instance: + assert isinstance(logger_instance.writer, can.Printer) def test_rotation_filename(self, tmp_path): - logger_instance = self._get_instance(tmp_path) + with self._get_instance(tmp_path / "__unused.txt") as logger_instance: + default_name = "default" + assert logger_instance.rotation_filename(default_name) == "default" - default_name = "default" - assert logger_instance.rotation_filename(default_name) == "default" - - logger_instance.namer = lambda x: x + "_by_namer" - assert logger_instance.rotation_filename(default_name) == "default_by_namer" + logger_instance.namer = lambda x: x + "_by_namer" + assert logger_instance.rotation_filename(default_name) == "default_by_namer" def test_rotate_without_rotator(self, tmp_path): - logger_instance = self._get_instance(tmp_path) - source = str(tmp_path / "source.txt") dest = str(tmp_path / "dest.txt") assert os.path.exists(source) is False assert os.path.exists(dest) is False - logger_instance._writer = logger_instance._get_new_writer(source) - logger_instance.stop() + with self._get_instance(source) as logger_instance: + # use context manager to create `source` file and close it + pass assert os.path.exists(source) is True assert os.path.exists(dest) is False @@ -101,17 +102,18 @@ def test_rotate_without_rotator(self, tmp_path): assert os.path.exists(dest) is True def test_rotate_with_rotator(self, tmp_path): - logger_instance = self._get_instance(tmp_path) - - rotator_func = Mock() - logger_instance.rotator = rotator_func - source = str(tmp_path / "source.txt") dest = str(tmp_path / "dest.txt") assert os.path.exists(source) is False assert os.path.exists(dest) is False + with self._get_instance(source) as logger_instance: + # use context manager to create `source` file and close it + pass + + rotator_func = Mock() + logger_instance.rotator = rotator_func logger_instance._writer = logger_instance._get_new_writer(source) logger_instance.stop() @@ -128,61 +130,52 @@ def test_rotate_with_rotator(self, tmp_path): def test_stop(self, tmp_path): """Test if stop() method of writer is called.""" - with self._get_instance(tmp_path) as logger_instance: - logger_instance._writer = logger_instance._get_new_writer( - tmp_path / "file.ASC" - ) - + with self._get_instance(tmp_path / "file.ASC") as logger_instance: # replace stop method of writer with Mock - original_stop = logger_instance.writer.stop - mock_stop = Mock() + mock_stop = Mock(side_effect=logger_instance.writer.stop) logger_instance.writer.stop = mock_stop logger_instance.stop() mock_stop.assert_called() - # close file.ASC to enable cleanup of temp_dir - original_stop() - def test_on_message_received(self, tmp_path): - logger_instance = self._get_instance(tmp_path) + with self._get_instance(tmp_path / "file.ASC") as logger_instance: + # Test without rollover + should_rollover = Mock(return_value=False) + do_rollover = Mock() + writers_on_message_received = Mock() - logger_instance._writer = logger_instance._get_new_writer(tmp_path / "file.ASC") + logger_instance.should_rollover = should_rollover + logger_instance.do_rollover = do_rollover + logger_instance.writer.on_message_received = writers_on_message_received - # Test without rollover - should_rollover = Mock(return_value=False) - do_rollover = Mock() - writers_on_message_received = Mock() - - logger_instance.should_rollover = should_rollover - logger_instance.do_rollover = do_rollover - logger_instance.writer.on_message_received = writers_on_message_received - - msg = generate_message(0x123) - logger_instance.on_message_received(msg) + msg = generate_message(0x123) + logger_instance.on_message_received(msg) - should_rollover.assert_called_with(msg) - do_rollover.assert_not_called() - writers_on_message_received.assert_called_with(msg) + should_rollover.assert_called_with(msg) + do_rollover.assert_not_called() + writers_on_message_received.assert_called_with(msg) - # Test with rollover - should_rollover = Mock(return_value=True) - do_rollover = Mock() - writers_on_message_received = Mock() + # Test with rollover + should_rollover = Mock(return_value=True) + do_rollover = Mock() + writers_on_message_received = Mock() - logger_instance.should_rollover = should_rollover - logger_instance.do_rollover = do_rollover - logger_instance.writer.on_message_received = writers_on_message_received + logger_instance.should_rollover = should_rollover + logger_instance.do_rollover = do_rollover + logger_instance.writer.on_message_received = writers_on_message_received - msg = generate_message(0x123) - logger_instance.on_message_received(msg) + msg = generate_message(0x123) + logger_instance.on_message_received(msg) - should_rollover.assert_called_with(msg) - do_rollover.assert_called() - writers_on_message_received.assert_called_with(msg) + should_rollover.assert_called_with(msg) + do_rollover.assert_called() + writers_on_message_received.assert_called_with(msg) - # stop writer to enable cleanup of temp_dir - logger_instance.stop() + def test_issue_1792(self, tmp_path): + filepath = tmp_path / "2017_Jeep_Grand_Cherokee_3.6L_V6.log" + with self._get_instance(filepath) as logger_instance: + assert isinstance(logger_instance.writer, can.CanutilsLogWriter) class TestSizedRotatingLogger: @@ -201,54 +194,48 @@ def test_create_instance(self, tmp_path): base_filename = "mylogfile.ASC" max_bytes = 512 - logger_instance = can.SizedRotatingLogger( + with can.SizedRotatingLogger( base_filename=tmp_path / base_filename, max_bytes=max_bytes - ) - assert Path(logger_instance.base_filename).name == base_filename - assert logger_instance.max_bytes == max_bytes - assert logger_instance.rollover_count == 0 - assert isinstance(logger_instance.writer, can.ASCWriter) - - logger_instance.stop() + ) as logger_instance: + assert Path(logger_instance.base_filename).name == base_filename + assert logger_instance.max_bytes == max_bytes + assert logger_instance.rollover_count == 0 + assert isinstance(logger_instance.writer, can.ASCWriter) def test_should_rollover(self, tmp_path): base_filename = "mylogfile.ASC" max_bytes = 512 - logger_instance = can.SizedRotatingLogger( + with can.SizedRotatingLogger( base_filename=tmp_path / base_filename, max_bytes=max_bytes - ) - msg = generate_message(0x123) - do_rollover = Mock() - logger_instance.do_rollover = do_rollover - - logger_instance.writer.file.tell = Mock(return_value=511) - assert logger_instance.should_rollover(msg) is False - logger_instance.on_message_received(msg) - do_rollover.assert_not_called() + ) as logger_instance: + msg = generate_message(0x123) + do_rollover = Mock() + logger_instance.do_rollover = do_rollover - logger_instance.writer.file.tell = Mock(return_value=512) - assert logger_instance.should_rollover(msg) is True - logger_instance.on_message_received(msg) - do_rollover.assert_called() + logger_instance.writer.file.tell = Mock(return_value=511) + assert logger_instance.should_rollover(msg) is False + logger_instance.on_message_received(msg) + do_rollover.assert_not_called() - logger_instance.stop() + logger_instance.writer.file.tell = Mock(return_value=512) + assert logger_instance.should_rollover(msg) is True + logger_instance.on_message_received(msg) + do_rollover.assert_called() def test_logfile_size(self, tmp_path): base_filename = "mylogfile.ASC" max_bytes = 1024 msg = generate_message(0x123) - logger_instance = can.SizedRotatingLogger( + with can.SizedRotatingLogger( base_filename=tmp_path / base_filename, max_bytes=max_bytes - ) - for _ in range(128): - logger_instance.on_message_received(msg) - - for file_path in os.listdir(tmp_path): - assert os.path.getsize(tmp_path / file_path) <= 1100 + ) as logger_instance: + for _ in range(128): + logger_instance.on_message_received(msg) - logger_instance.stop() + for file_path in os.listdir(tmp_path): + assert os.path.getsize(tmp_path / file_path) <= 1100 def test_logfile_size_context_manager(self, tmp_path): base_filename = "mylogfile.ASC" diff --git a/test/test_scripts.py b/test/test_scripts.py index a22820bd8..c1a6c082d 100644 --- a/test/test_scripts.py +++ b/test/test_scripts.py @@ -4,10 +4,10 @@ This module tests that the scripts are all callable. """ +import errno import subprocess -import unittest import sys -import errno +import unittest from abc import ABCMeta, abstractmethod from .config import * @@ -74,10 +74,8 @@ class TestLoggerScript(CanScriptTest): def _commands(self): commands = [ "python -m can.logger --help", - "python scripts/can_logger.py --help", + "can_logger --help", ] - if IS_UNIX: - commands += ["can_logger.py --help"] return commands def _import(self): @@ -90,10 +88,8 @@ class TestPlayerScript(CanScriptTest): def _commands(self): commands = [ "python -m can.player --help", - "python scripts/can_player.py --help", + "can_player --help", ] - if IS_UNIX: - commands += ["can_player.py --help"] return commands def _import(self): @@ -102,14 +98,26 @@ def _import(self): return module +class TestBridgeScript(CanScriptTest): + def _commands(self): + commands = [ + "python -m can.bridge --help", + "can_bridge --help", + ] + return commands + + def _import(self): + import can.bridge as module + + return module + + class TestLogconvertScript(CanScriptTest): def _commands(self): commands = [ "python -m can.logconvert --help", - "python scripts/can_logconvert.py --help", + "can_logconvert --help", ] - if IS_UNIX: - commands += ["can_logconvert.py --help"] return commands def _import(self): diff --git a/test/test_slcan.py b/test/test_slcan.py index 8db2d402a..b757ad04d 100644 --- a/test/test_slcan.py +++ b/test/test_slcan.py @@ -1,35 +1,105 @@ #!/usr/bin/env python -import unittest -import can -from .config import IS_PYPY +import unittest.mock +from typing import cast, Optional + +from serial.serialutil import SerialBase +import can.interfaces.slcan + +from .config import IS_PYPY """ Mentioned in #1010 & #1490 -> PyPy works best with pure Python applications. Whenever you use a C extension module, -> it runs much slower than in CPython. The reason is that PyPy can't optimize C extension modules since they're not fully supported. +> PyPy works best with pure Python applications. Whenever you use a C extension module, +> it runs much slower than in CPython. The reason is that PyPy can't optimize C extension modules since they're not fully supported. > In addition, PyPy has to emulate reference counting for that part of the code, making it even slower. https://realpython.com/pypy-faster-python/#it-doesnt-work-well-with-c-extensions """ -TIMEOUT = 0.5 if IS_PYPY else 0.001 # 0.001 is the default set in slcanBus +TIMEOUT = 0.5 if IS_PYPY else 0.01 # 0.001 is the default set in slcanBus + + +class SerialMock(SerialBase): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self._input_buffer = b"" + self._output_buffer = b"" + + def open(self) -> None: + self.is_open = True + + def close(self) -> None: + self.is_open = False + self._input_buffer = b"" + self._output_buffer = b"" + + def read(self, size: int = -1, /) -> bytes: + if size > 0: + data = self._input_buffer[:size] + self._input_buffer = self._input_buffer[size:] + return data + return b"" + + def write(self, b: bytes, /) -> Optional[int]: + self._output_buffer = b + if b == b"N\r": + self.set_input_buffer(b"NA123\r") + elif b == b"V\r": + self.set_input_buffer(b"V1013\r") + return len(b) + + def set_input_buffer(self, expected: bytes) -> None: + self._input_buffer = expected + + def get_output_buffer(self) -> bytes: + return self._output_buffer + + def reset_input_buffer(self) -> None: + self._input_buffer = b"" + + @property + def in_waiting(self) -> int: + return len(self._input_buffer) + + @classmethod + def serial_for_url(cls, *args, **kwargs) -> SerialBase: + return cls(*args, **kwargs) class slcanTestCase(unittest.TestCase): + @unittest.mock.patch("serial.serial_for_url", SerialMock.serial_for_url) def setUp(self): - self.bus = can.Bus( - "loop://", interface="slcan", sleep_after_open=0, timeout=TIMEOUT + self.bus = cast( + can.interfaces.slcan.slcanBus, + can.Bus( + "loop://", + interface="slcan", + sleep_after_open=0, + timeout=TIMEOUT, + bitrate=500000, + ), ) - self.serial = self.bus.serialPortOrig - self.serial.read(self.serial.in_waiting) + self.serial = cast(SerialMock, self.bus.serialPortOrig) + self.serial.reset_input_buffer() def tearDown(self): self.bus.shutdown() def test_recv_extended(self): - self.serial.write(b"T12ABCDEF2AA55\r") + self.serial.set_input_buffer(b"T12ABCDEF2AA55\r") + msg = self.bus.recv(TIMEOUT) + self.assertIsNotNone(msg) + self.assertEqual(msg.arbitration_id, 0x12ABCDEF) + self.assertEqual(msg.is_extended_id, True) + self.assertEqual(msg.is_remote_frame, False) + self.assertEqual(msg.dlc, 2) + self.assertSequenceEqual(msg.data, [0xAA, 0x55]) + + # Ewert Energy Systems CANDapter specific + self.serial.set_input_buffer(b"x12ABCDEF2AA55\r") msg = self.bus.recv(TIMEOUT) self.assertIsNotNone(msg) self.assertEqual(msg.arbitration_id, 0x12ABCDEF) @@ -39,15 +109,19 @@ def test_recv_extended(self): self.assertSequenceEqual(msg.data, [0xAA, 0x55]) def test_send_extended(self): + payload = b"T12ABCDEF2AA55\r" msg = can.Message( arbitration_id=0x12ABCDEF, is_extended_id=True, data=[0xAA, 0x55] ) self.bus.send(msg) - data = self.serial.read(self.serial.in_waiting) - self.assertEqual(data, b"T12ABCDEF2AA55\r") + self.assertEqual(payload, self.serial.get_output_buffer()) + + self.serial.set_input_buffer(payload) + rx_msg = self.bus.recv(TIMEOUT) + self.assertTrue(msg.equals(rx_msg, timestamp_delta=None)) def test_recv_standard(self): - self.serial.write(b"t4563112233\r") + self.serial.set_input_buffer(b"t4563112233\r") msg = self.bus.recv(TIMEOUT) self.assertIsNotNone(msg) self.assertEqual(msg.arbitration_id, 0x456) @@ -57,15 +131,19 @@ def test_recv_standard(self): self.assertSequenceEqual(msg.data, [0x11, 0x22, 0x33]) def test_send_standard(self): + payload = b"t4563112233\r" msg = can.Message( arbitration_id=0x456, is_extended_id=False, data=[0x11, 0x22, 0x33] ) self.bus.send(msg) - data = self.serial.read(self.serial.in_waiting) - self.assertEqual(data, b"t4563112233\r") + self.assertEqual(payload, self.serial.get_output_buffer()) + + self.serial.set_input_buffer(payload) + rx_msg = self.bus.recv(TIMEOUT) + self.assertTrue(msg.equals(rx_msg, timestamp_delta=None)) def test_recv_standard_remote(self): - self.serial.write(b"r1238\r") + self.serial.set_input_buffer(b"r1238\r") msg = self.bus.recv(TIMEOUT) self.assertIsNotNone(msg) self.assertEqual(msg.arbitration_id, 0x123) @@ -74,15 +152,19 @@ def test_recv_standard_remote(self): self.assertEqual(msg.dlc, 8) def test_send_standard_remote(self): + payload = b"r1238\r" msg = can.Message( arbitration_id=0x123, is_extended_id=False, is_remote_frame=True, dlc=8 ) self.bus.send(msg) - data = self.serial.read(self.serial.in_waiting) - self.assertEqual(data, b"r1238\r") + self.assertEqual(payload, self.serial.get_output_buffer()) + + self.serial.set_input_buffer(payload) + rx_msg = self.bus.recv(TIMEOUT) + self.assertTrue(msg.equals(rx_msg, timestamp_delta=None)) def test_recv_extended_remote(self): - self.serial.write(b"R12ABCDEF6\r") + self.serial.set_input_buffer(b"R12ABCDEF6\r") msg = self.bus.recv(TIMEOUT) self.assertIsNotNone(msg) self.assertEqual(msg.arbitration_id, 0x12ABCDEF) @@ -91,19 +173,281 @@ def test_recv_extended_remote(self): self.assertEqual(msg.dlc, 6) def test_send_extended_remote(self): + payload = b"R12ABCDEF6\r" msg = can.Message( arbitration_id=0x12ABCDEF, is_extended_id=True, is_remote_frame=True, dlc=6 ) self.bus.send(msg) - data = self.serial.read(self.serial.in_waiting) - self.assertEqual(data, b"R12ABCDEF6\r") + self.assertEqual(payload, self.serial.get_output_buffer()) + + self.serial.set_input_buffer(payload) + rx_msg = self.bus.recv(TIMEOUT) + self.assertTrue(msg.equals(rx_msg, timestamp_delta=None)) + + def test_recv_fd(self): + self.serial.set_input_buffer(b"d123A303132333435363738393a3b3c3d3e3f\r") + msg = self.bus.recv(TIMEOUT) + self.assertIsNotNone(msg) + self.assertEqual(msg.arbitration_id, 0x123) + self.assertEqual(msg.is_extended_id, False) + self.assertEqual(msg.is_remote_frame, False) + self.assertEqual(msg.is_fd, True) + self.assertEqual(msg.bitrate_switch, False) + self.assertEqual(msg.dlc, 16) + self.assertSequenceEqual( + msg.data, + [ + 0x30, + 0x31, + 0x32, + 0x33, + 0x34, + 0x35, + 0x36, + 0x37, + 0x38, + 0x39, + 0x3A, + 0x3B, + 0x3C, + 0x3D, + 0x3E, + 0x3F, + ], + ) + + def test_send_fd(self): + payload = b"d123A303132333435363738393A3B3C3D3E3F\r" + msg = can.Message( + arbitration_id=0x123, + is_extended_id=False, + is_fd=True, + data=[ + 0x30, + 0x31, + 0x32, + 0x33, + 0x34, + 0x35, + 0x36, + 0x37, + 0x38, + 0x39, + 0x3A, + 0x3B, + 0x3C, + 0x3D, + 0x3E, + 0x3F, + ], + ) + self.bus.send(msg) + self.assertEqual(payload, self.serial.get_output_buffer()) + + self.serial.set_input_buffer(payload) + rx_msg = self.bus.recv(TIMEOUT) + self.assertTrue(msg.equals(rx_msg, timestamp_delta=None)) + + def test_recv_fd_extended(self): + self.serial.set_input_buffer(b"D12ABCDEFA303132333435363738393A3B3C3D3E3F\r") + msg = self.bus.recv(TIMEOUT) + self.assertIsNotNone(msg) + self.assertEqual(msg.arbitration_id, 0x12ABCDEF) + self.assertEqual(msg.is_extended_id, True) + self.assertEqual(msg.is_remote_frame, False) + self.assertEqual(msg.dlc, 16) + self.assertEqual(msg.bitrate_switch, False) + self.assertTrue(msg.is_fd) + self.assertSequenceEqual( + msg.data, + [ + 0x30, + 0x31, + 0x32, + 0x33, + 0x34, + 0x35, + 0x36, + 0x37, + 0x38, + 0x39, + 0x3A, + 0x3B, + 0x3C, + 0x3D, + 0x3E, + 0x3F, + ], + ) + + def test_send_fd_extended(self): + payload = b"D12ABCDEFA303132333435363738393A3B3C3D3E3F\r" + msg = can.Message( + arbitration_id=0x12ABCDEF, + is_extended_id=True, + is_fd=True, + data=[ + 0x30, + 0x31, + 0x32, + 0x33, + 0x34, + 0x35, + 0x36, + 0x37, + 0x38, + 0x39, + 0x3A, + 0x3B, + 0x3C, + 0x3D, + 0x3E, + 0x3F, + ], + ) + self.bus.send(msg) + self.assertEqual(payload, self.serial.get_output_buffer()) + + self.serial.set_input_buffer(payload) + rx_msg = self.bus.recv(TIMEOUT) + self.assertTrue(msg.equals(rx_msg, timestamp_delta=None)) + + def test_recv_fd_brs(self): + self.serial.set_input_buffer(b"b123A303132333435363738393a3b3c3d3e3f\r") + msg = self.bus.recv(TIMEOUT) + self.assertIsNotNone(msg) + self.assertEqual(msg.arbitration_id, 0x123) + self.assertEqual(msg.is_extended_id, False) + self.assertEqual(msg.is_remote_frame, False) + self.assertEqual(msg.is_fd, True) + self.assertEqual(msg.bitrate_switch, True) + self.assertEqual(msg.dlc, 16) + self.assertSequenceEqual( + msg.data, + [ + 0x30, + 0x31, + 0x32, + 0x33, + 0x34, + 0x35, + 0x36, + 0x37, + 0x38, + 0x39, + 0x3A, + 0x3B, + 0x3C, + 0x3D, + 0x3E, + 0x3F, + ], + ) + + def test_send_fd_brs(self): + payload = b"b123A303132333435363738393A3B3C3D3E3F\r" + msg = can.Message( + arbitration_id=0x123, + is_extended_id=False, + is_fd=True, + bitrate_switch=True, + data=[ + 0x30, + 0x31, + 0x32, + 0x33, + 0x34, + 0x35, + 0x36, + 0x37, + 0x38, + 0x39, + 0x3A, + 0x3B, + 0x3C, + 0x3D, + 0x3E, + 0x3F, + ], + ) + self.bus.send(msg) + self.assertEqual(payload, self.serial.get_output_buffer()) + + self.serial.set_input_buffer(payload) + rx_msg = self.bus.recv(TIMEOUT) + self.assertTrue(msg.equals(rx_msg, timestamp_delta=None)) + + def test_recv_fd_brs_extended(self): + self.serial.set_input_buffer(b"B12ABCDEFA303132333435363738393A3B3C3D3E3F\r") + msg = self.bus.recv(TIMEOUT) + self.assertIsNotNone(msg) + self.assertEqual(msg.arbitration_id, 0x12ABCDEF) + self.assertEqual(msg.is_extended_id, True) + self.assertEqual(msg.is_remote_frame, False) + self.assertEqual(msg.dlc, 16) + self.assertEqual(msg.bitrate_switch, True) + self.assertTrue(msg.is_fd) + self.assertSequenceEqual( + msg.data, + [ + 0x30, + 0x31, + 0x32, + 0x33, + 0x34, + 0x35, + 0x36, + 0x37, + 0x38, + 0x39, + 0x3A, + 0x3B, + 0x3C, + 0x3D, + 0x3E, + 0x3F, + ], + ) + + def test_send_fd_brs_extended(self): + payload = b"B12ABCDEFA303132333435363738393A3B3C3D3E3F\r" + msg = can.Message( + arbitration_id=0x12ABCDEF, + is_extended_id=True, + is_fd=True, + bitrate_switch=True, + data=[ + 0x30, + 0x31, + 0x32, + 0x33, + 0x34, + 0x35, + 0x36, + 0x37, + 0x38, + 0x39, + 0x3A, + 0x3B, + 0x3C, + 0x3D, + 0x3E, + 0x3F, + ], + ) + self.bus.send(msg) + self.assertEqual(payload, self.serial.get_output_buffer()) + + self.serial.set_input_buffer(payload) + rx_msg = self.bus.recv(TIMEOUT) + self.assertTrue(msg.equals(rx_msg, timestamp_delta=None)) def test_partial_recv(self): - self.serial.write(b"T12ABCDEF") + self.serial.set_input_buffer(b"T12ABCDEF") msg = self.bus.recv(TIMEOUT) self.assertIsNone(msg) - self.serial.write(b"2AA55\rT12") + self.serial.set_input_buffer(b"2AA55\rT12") msg = self.bus.recv(TIMEOUT) self.assertIsNotNone(msg) self.assertEqual(msg.arbitration_id, 0x12ABCDEF) @@ -115,28 +459,21 @@ def test_partial_recv(self): msg = self.bus.recv(TIMEOUT) self.assertIsNone(msg) - self.serial.write(b"ABCDEF2AA55\r") + self.serial.set_input_buffer(b"ABCDEF2AA55\r") msg = self.bus.recv(TIMEOUT) self.assertIsNotNone(msg) def test_version(self): - self.serial.write(b"V1013\r") hw_ver, sw_ver = self.bus.get_version(0) + self.assertEqual(b"V\r", self.serial.get_output_buffer()) self.assertEqual(hw_ver, 10) self.assertEqual(sw_ver, 13) - hw_ver, sw_ver = self.bus.get_version(0) - self.assertIsNone(hw_ver) - self.assertIsNone(sw_ver) - def test_serial_number(self): - self.serial.write(b"NA123\r") sn = self.bus.get_serial_number(0) + self.assertEqual(b"N\r", self.serial.get_output_buffer()) self.assertEqual(sn, "A123") - sn = self.bus.get_serial_number(0) - self.assertIsNone(sn) - if __name__ == "__main__": unittest.main() diff --git a/test/test_socketcan.py b/test/test_socketcan.py index 324890dad..534ee2a61 100644 --- a/test/test_socketcan.py +++ b/test/test_socketcan.py @@ -5,11 +5,12 @@ """ import ctypes import struct +import sys import unittest import warnings from unittest.mock import patch -import can +import can from can.interfaces.socketcan.constants import ( CAN_BCM_TX_DELETE, CAN_BCM_TX_SETUP, @@ -18,14 +19,15 @@ TX_COUNTEVT, ) from can.interfaces.socketcan.socketcan import ( + BcmMsgHead, bcm_header_factory, build_bcm_header, - build_bcm_tx_delete_header, build_bcm_transmit_header, + build_bcm_tx_delete_header, build_bcm_update_header, - BcmMsgHead, ) -from .config import IS_LINUX, IS_PYPY + +from .config import IS_LINUX, IS_PYPY, TEST_INTERFACE_SOCKETCAN class SocketCANTest(unittest.TestCase): @@ -33,6 +35,7 @@ def setUp(self): self._ctypes_sizeof = ctypes.sizeof self._ctypes_alignment = ctypes.alignment + @unittest.skipIf(sys.version_info >= (3, 14), "Fails on Python 3.14 or newer") @patch("ctypes.sizeof") @patch("ctypes.alignment") def test_bcm_header_factory_32_bit_sizeof_long_4_alignof_long_4( @@ -102,6 +105,7 @@ def side_effect_ctypes_alignment(value): ] self.assertEqual(expected_fields, BcmMsgHead._fields_) + @unittest.skipIf(sys.version_info >= (3, 14), "Fails on Python 3.14 or newer") @patch("ctypes.sizeof") @patch("ctypes.alignment") def test_bcm_header_factory_32_bit_sizeof_long_4_alignof_long_long_8( @@ -171,6 +175,7 @@ def side_effect_ctypes_alignment(value): ] self.assertEqual(expected_fields, BcmMsgHead._fields_) + @unittest.skipIf(sys.version_info >= (3, 14), "Fails on Python 3.14 or newer") @patch("ctypes.sizeof") @patch("ctypes.alignment") def test_bcm_header_factory_64_bit_sizeof_long_8_alignof_long_8( @@ -356,13 +361,23 @@ def test_build_bcm_update_header(self): self.assertEqual(can_id, result.can_id) self.assertEqual(1, result.nframes) + @unittest.skipUnless(TEST_INTERFACE_SOCKETCAN, "Only run when vcan0 is available") + def test_bus_creation_can(self): + bus = can.Bus(interface="socketcan", channel="vcan0", fd=False) + self.assertEqual(bus.protocol, can.CanProtocol.CAN_20) + + @unittest.skipUnless(TEST_INTERFACE_SOCKETCAN, "Only run when vcan0 is available") + def test_bus_creation_can_fd(self): + bus = can.Bus(interface="socketcan", channel="vcan0", fd=True) + self.assertEqual(bus.protocol, can.CanProtocol.CAN_FD) + @unittest.skipUnless(IS_LINUX and IS_PYPY, "Only test when run on Linux with PyPy") def test_pypy_socketcan_support(self): """Wait for PyPy raw CAN socket support This test shall document raw CAN socket support under PyPy. Once this test fails, it is likely that PyPy either implemented raw CAN socket support or at least changed the error that is thrown. - https://foss.heptapod.net/pypy/pypy/-/issues/3809 + https://github.com/pypy/pypy/issues/3808 https://github.com/hardbyte/python-can/issues/1479 """ try: @@ -371,7 +386,7 @@ def test_pypy_socketcan_support(self): if "unknown address family" not in str(e): warnings.warn( "Please check if PyPy has implemented raw CAN socket support! " - "See: https://foss.heptapod.net/pypy/pypy/-/issues/3809" + "See: https://github.com/pypy/pypy/issues/3808" ) diff --git a/test/test_socketcan_helpers.py b/test/test_socketcan_helpers.py index 29ceb11c0..710922290 100644 --- a/test/test_socketcan_helpers.py +++ b/test/test_socketcan_helpers.py @@ -4,14 +4,11 @@ Tests helpers in `can.interfaces.socketcan.socketcan_common`. """ -import gzip -from base64 import b64decode import unittest +from pathlib import Path from unittest import mock -from subprocess import CalledProcessError - -from can.interfaces.socketcan.utils import find_available_interfaces, error_code_to_str +from can.interfaces.socketcan.utils import error_code_to_str, find_available_interfaces from .config import IS_LINUX, TEST_INTERFACE_SOCKETCAN @@ -44,23 +41,15 @@ def test_find_available_interfaces(self): def test_find_available_interfaces_w_patch(self): # Contains lo, eth0, wlan0, vcan0, mycustomCan123 - ip_output_gz_b64 = ( - "H4sIAAAAAAAAA+2UzW+CMBjG7/wVhrNL+BC29IboEqNSwzQejDEViiMC5aNsmmX/+wpZTGUwDAcP" - "y5qmh+d5++bN80u7EXpsfZRnsUTf8yMXn0TQk/u8GqEQM1EMiMjpXoAOGZM3F6mUZxAuhoY55UpL" - "fbWoKjO4Hts7pl/kLdc+pDlrrmuaqnNq4vqZU8wSkSTHOeYHIjFOM4poOevKmlpwbfF+4EfHkLil" - "PRo/G6vZkrcPKcnjwnOxh/KA8h49JQGOimAkSaq03NFz/B0PiffIOfIXkeumOCtiEiUJXG++bp8S" - "5Dooo/WVZeFnvxmYUgsM01fpBmQWfDAN256M7SqioQ2NkWm8LKvGnIU3qTN+xylrV/FdaHrJzmFk" - "gkacozuzZMnhtAGkLANFAaoKBgOgaUDXG0F6Hrje7SDVWpDvAYpuIdmJV4dn2cSx9VUuGiFCe25Y" - "fwTi4KmW4ptzG0ULGvYPLN1APSqdMN3/82TRtOeqSbW5hmcnzygJTRTJivofcEvAgrAVvgD8aLkv" - "/AcAAA==" - ) - ip_output = gzip.decompress(b64decode(ip_output_gz_b64)).decode("ascii") + ip_output = (Path(__file__).parent / "data" / "ip_link_list.json").read_text() with mock.patch("subprocess.check_output") as check_output: check_output.return_value = ip_output - ifs = find_available_interfaces() - self.assertEqual(["vcan0", "mycustomCan123"], ifs) + with mock.patch("sys.platform", "linux"): + ifs = find_available_interfaces() + + self.assertEqual(["vcan0", "mycustomCan123"], ifs) def test_find_available_interfaces_exception(self): with mock.patch("subprocess.check_output") as check_output: diff --git a/test/test_socketcand.py b/test/test_socketcand.py new file mode 100644 index 000000000..7050b9f20 --- /dev/null +++ b/test/test_socketcand.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python + +import unittest +import can +from can.interfaces.socketcand import socketcand + + +class TestConvertAsciiMessageToCanMessage(unittest.TestCase): + def test_valid_frame_message(self): + # Example: < frame 123 1680000000.0 01020304 > + ascii_msg = "< frame 123 1680000000.0 01020304 >" + msg = socketcand.convert_ascii_message_to_can_message(ascii_msg) + self.assertIsInstance(msg, can.Message) + self.assertEqual(msg.arbitration_id, 0x123) + self.assertEqual(msg.timestamp, 1680000000.0) + self.assertEqual(msg.data, bytearray([1, 2, 3, 4])) + self.assertEqual(msg.dlc, 4) + self.assertFalse(msg.is_extended_id) + self.assertTrue(msg.is_rx) + + def test_valid_error_message(self): + # Example: < error 1ABCDEF0 1680000001.0 > + ascii_msg = "< error 1ABCDEF0 1680000001.0 >" + msg = socketcand.convert_ascii_message_to_can_message(ascii_msg) + self.assertIsInstance(msg, can.Message) + self.assertEqual(msg.arbitration_id, 0x1ABCDEF0) + self.assertEqual(msg.timestamp, 1680000001.0) + self.assertEqual(msg.data, bytearray([0])) + self.assertEqual(msg.dlc, 1) + self.assertTrue(msg.is_extended_id) + self.assertTrue(msg.is_error_frame) + self.assertTrue(msg.is_rx) + + def test_invalid_message(self): + ascii_msg = "< unknown 123 0.0 >" + msg = socketcand.convert_ascii_message_to_can_message(ascii_msg) + self.assertIsNone(msg) + + def test_missing_ending_character(self): + ascii_msg = "< frame 123 1680000000.0 01020304" + msg = socketcand.convert_ascii_message_to_can_message(ascii_msg) + self.assertIsNone(msg) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_systec.py b/test/test_systec.py index 7495f75eb..86ed31362 100644 --- a/test/test_systec.py +++ b/test/test_systec.py @@ -36,6 +36,8 @@ def setUp(self): def test_bus_creation(self): self.assertIsInstance(self.bus, ucanbus.UcanBus) + self.assertEqual(self.bus.protocol, can.CanProtocol.CAN_20) + self.assertTrue(ucan.UcanInitHwConnectControlEx.called) self.assertTrue( ucan.UcanInitHardwareEx.called or ucan.UcanInitHardwareEx2.called diff --git a/test/test_util.py b/test/test_util.py index b6c261602..53a9c973b 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -5,15 +5,16 @@ import pytest +import can from can import BitTiming, BitTimingFd from can.exceptions import CanInitializationError from can.util import ( _create_bus_config, _rename_kwargs, + cast_from_string, channel2int, - deprecated_args_alias, check_or_adjust_timing_clock, - cast_from_string, + deprecated_args_alias, ) @@ -21,7 +22,6 @@ class RenameKwargsTest(unittest.TestCase): expected_kwargs = dict(a=1, b=2, c=3, d=4) def _test(self, start: str, end: str, kwargs, aliases): - # Test that we do get the DeprecationWarning when called with deprecated kwargs with self.assertWarnsRegex( DeprecationWarning, "is deprecated.*?" + start + ".*?" + end @@ -133,10 +133,7 @@ def _test_func3(a): class TestBusConfig(unittest.TestCase): - base_config = dict(interface="socketcan", bitrate=500_000) - port_alpha_config = dict(interface="socketcan", bitrate=500_000, port="fail123") - port_to_high_config = dict(interface="socketcan", bitrate=500_000, port="999999") - port_wrong_type_config = dict(interface="socketcan", bitrate=500_000, port=(1234,)) + base_config = {"interface": "socketcan", "bitrate": 500_000} def test_timing_can_use_int(self): """ @@ -148,17 +145,92 @@ def test_timing_can_use_int(self): _create_bus_config({**self.base_config, **timing_conf}) except TypeError as e: self.fail(e) + + def test_port_datatype(self): self.assertRaises( - ValueError, _create_bus_config, {**self.port_alpha_config, **timing_conf} + ValueError, _create_bus_config, {**self.base_config, "port": "fail123"} ) self.assertRaises( - ValueError, _create_bus_config, {**self.port_to_high_config, **timing_conf} + ValueError, _create_bus_config, {**self.base_config, "port": "999999"} ) self.assertRaises( - TypeError, - _create_bus_config, - {**self.port_wrong_type_config, **timing_conf}, + TypeError, _create_bus_config, {**self.base_config, "port": (1234,)} + ) + + try: + _create_bus_config({**self.base_config, "port": "1234"}) + except TypeError as e: + self.fail(e) + + def test_bit_timing_cfg(self): + can_cfg = _create_bus_config( + { + **self.base_config, + "f_clock": "8000000", + "brp": "1", + "tseg1": "5", + "tseg2": "2", + "sjw": "1", + "nof_samples": "1", + } + ) + timing = can_cfg["timing"] + assert isinstance(timing, can.BitTiming) + assert timing.f_clock == 8_000_000 + assert timing.brp == 1 + assert timing.tseg1 == 5 + assert timing.tseg2 == 2 + assert timing.sjw == 1 + + def test_bit_timing_fd_cfg(self): + canfd_cfg = _create_bus_config( + { + **self.base_config, + "f_clock": "80000000", + "nom_brp": "1", + "nom_tseg1": "119", + "nom_tseg2": "40", + "nom_sjw": "40", + "data_brp": "1", + "data_tseg1": "29", + "data_tseg2": "10", + "data_sjw": "10", + } + ) + timing = canfd_cfg["timing"] + assert isinstance(timing, can.BitTimingFd) + assert timing.f_clock == 80_000_000 + assert timing.nom_brp == 1 + assert timing.nom_tseg1 == 119 + assert timing.nom_tseg2 == 40 + assert timing.nom_sjw == 40 + assert timing.data_brp == 1 + assert timing.data_tseg1 == 29 + assert timing.data_tseg2 == 10 + assert timing.data_sjw == 10 + + def test_state_with_str(self): + can_cfg = _create_bus_config( + { + **self.base_config, + "state": "PASSIVE", + } + ) + state = can_cfg["state"] + assert isinstance(state, can.BusState) + assert state is can.BusState.PASSIVE + + def test_state_with_enum(self): + expected_state = can.BusState.PASSIVE + can_cfg = _create_bus_config( + { + **self.base_config, + "state": expected_state, + } ) + state = can_cfg["state"] + assert isinstance(state, can.BusState) + assert state is expected_state class TestChannel2Int(unittest.TestCase): @@ -254,14 +326,10 @@ def test_adjust_timing_fd(self): ) assert new_timing.__class__ == BitTimingFd assert new_timing.f_clock == 80_000_000 - assert new_timing.nom_bitrate == 500_000 - assert new_timing.nom_tseg1 == 119 - assert new_timing.nom_tseg2 == 40 - assert new_timing.nom_sjw == 40 - assert new_timing.data_bitrate == 2_000_000 - assert new_timing.data_tseg1 == 29 - assert new_timing.data_tseg2 == 10 - assert new_timing.data_sjw == 10 + assert new_timing.nom_bitrate == timing.nom_bitrate + assert new_timing.nom_sample_point == timing.nom_sample_point + assert new_timing.data_bitrate == timing.data_bitrate + assert new_timing.data_sample_point == timing.data_sample_point with pytest.raises(CanInitializationError): check_or_adjust_timing_clock(timing, valid_clocks=[8_000, 16_000]) diff --git a/test/test_vector.py b/test/test_vector.py index 7694b31aa..5d074f614 100644 --- a/test/test_vector.py +++ b/test/test_vector.py @@ -9,22 +9,24 @@ import pickle import sys import time +from test.config import IS_WINDOWS from unittest.mock import Mock import pytest import can from can.interfaces.vector import ( - canlib, - xldefine, - xlclass, + VectorBusParams, + VectorCanFdParams, + VectorCanParams, + VectorChannelConfig, VectorError, VectorInitializationError, VectorOperationError, - VectorChannelConfig, + canlib, + xlclass, + xldefine, ) -from can.interfaces.vector import VectorBusParams, VectorCanParams, VectorCanFdParams -from test.config import IS_WINDOWS XLDRIVER_FOUND = canlib.xldriver is not None @@ -46,6 +48,7 @@ def mock_xldriver() -> None: xldriver_mock.xlCanSetChannelAcceptance = Mock(return_value=0) xldriver_mock.xlCanSetChannelBitrate = Mock(return_value=0) xldriver_mock.xlSetNotification = Mock(side_effect=xlSetNotification) + xldriver_mock.xlCanSetChannelOutput = Mock(return_value=0) # bus deactivation functions xldriver_mock.xlDeactivateChannel = Mock(return_value=0) @@ -76,9 +79,49 @@ def mock_xldriver() -> None: canlib.HAS_EVENTS = real_has_events +def test_listen_only_mocked(mock_xldriver) -> None: + bus = can.Bus(channel=0, interface="vector", listen_only=True, _testing=True) + assert isinstance(bus, canlib.VectorBus) + assert bus.protocol == can.CanProtocol.CAN_20 + + can.interfaces.vector.canlib.xldriver.xlCanSetChannelOutput.assert_called() + xlCanSetChannelOutput_args = ( + can.interfaces.vector.canlib.xldriver.xlCanSetChannelOutput.call_args[0] + ) + assert xlCanSetChannelOutput_args[2] == xldefine.XL_OutputMode.XL_OUTPUT_MODE_SILENT + + +@pytest.mark.skipif(not XLDRIVER_FOUND, reason="Vector XL API is unavailable") +def test_listen_only() -> None: + bus = can.Bus( + channel=0, + serial=_find_virtual_can_serial(), + interface="vector", + receive_own_messages=True, + listen_only=True, + ) + assert isinstance(bus, canlib.VectorBus) + assert bus.protocol == can.CanProtocol.CAN_20 + + msg = can.Message( + arbitration_id=0xC0FFEF, data=[1, 2, 3, 4, 5, 6, 7, 8], is_extended_id=True + ) + + bus.send(msg) + + received_msg = bus.recv() + + assert received_msg.arbitration_id == msg.arbitration_id + assert received_msg.data == msg.data + + bus.shutdown() + + def test_bus_creation_mocked(mock_xldriver) -> None: bus = can.Bus(channel=0, interface="vector", _testing=True) assert isinstance(bus, canlib.VectorBus) + assert bus.protocol == can.CanProtocol.CAN_20 + can.interfaces.vector.canlib.xldriver.xlOpenDriver.assert_called() can.interfaces.vector.canlib.xldriver.xlGetApplConfig.assert_called() @@ -95,6 +138,8 @@ def test_bus_creation_mocked(mock_xldriver) -> None: def test_bus_creation() -> None: bus = can.Bus(channel=0, serial=_find_virtual_can_serial(), interface="vector") assert isinstance(bus, canlib.VectorBus) + assert bus.protocol == can.CanProtocol.CAN_20 + bus.shutdown() xl_channel_config = _find_xl_channel_config( @@ -108,12 +153,57 @@ def test_bus_creation() -> None: bus = canlib.VectorBus(channel=0, serial=_find_virtual_can_serial()) assert isinstance(bus, canlib.VectorBus) + assert bus.protocol == can.CanProtocol.CAN_20 + bus.shutdown() + + +@pytest.mark.skipif(not XLDRIVER_FOUND, reason="Vector XL API is unavailable") +def test_bus_creation_channel_index() -> None: + channel_index = 1 + bus = can.Bus( + channel=0, + serial=_find_virtual_can_serial(), + channel_index=channel_index, + interface="vector", + ) + assert isinstance(bus, canlib.VectorBus) + assert bus.protocol == can.CanProtocol.CAN_20 + assert bus.channel_masks[0] == 1 << channel_index + + bus.shutdown() + + +@pytest.mark.skipif(not XLDRIVER_FOUND, reason="Vector XL API is unavailable") +def test_bus_creation_multiple_channels() -> None: + bus = can.Bus( + channel="0, 1", + bitrate=1_000_000, + serial=_find_virtual_can_serial(), + interface="vector", + ) + assert isinstance(bus, canlib.VectorBus) + assert bus.protocol == can.CanProtocol.CAN_20 + assert len(bus.channels) == 2 + assert bus.mask == 3 + + xl_channel_config_0 = _find_xl_channel_config( + serial=_find_virtual_can_serial(), channel=0 + ) + assert xl_channel_config_0.busParams.data.can.bitRate == 1_000_000 + + xl_channel_config_1 = _find_xl_channel_config( + serial=_find_virtual_can_serial(), channel=1 + ) + assert xl_channel_config_1.busParams.data.can.bitRate == 1_000_000 + bus.shutdown() def test_bus_creation_bitrate_mocked(mock_xldriver) -> None: bus = can.Bus(channel=0, interface="vector", bitrate=200_000, _testing=True) assert isinstance(bus, canlib.VectorBus) + assert bus.protocol == can.CanProtocol.CAN_20 + can.interfaces.vector.canlib.xldriver.xlOpenDriver.assert_called() can.interfaces.vector.canlib.xldriver.xlGetApplConfig.assert_called() @@ -139,6 +229,7 @@ def test_bus_creation_bitrate() -> None: bitrate=200_000, ) assert isinstance(bus, canlib.VectorBus) + assert bus.protocol == can.CanProtocol.CAN_20 xl_channel_config = _find_xl_channel_config( serial=_find_virtual_can_serial(), channel=0 @@ -151,6 +242,8 @@ def test_bus_creation_bitrate() -> None: def test_bus_creation_fd_mocked(mock_xldriver) -> None: bus = can.Bus(channel=0, interface="vector", fd=True, _testing=True) assert isinstance(bus, canlib.VectorBus) + assert bus.protocol == can.CanProtocol.CAN_FD + can.interfaces.vector.canlib.xldriver.xlOpenDriver.assert_called() can.interfaces.vector.canlib.xldriver.xlGetApplConfig.assert_called() @@ -171,6 +264,7 @@ def test_bus_creation_fd() -> None: channel=0, serial=_find_virtual_can_serial(), interface="vector", fd=True ) assert isinstance(bus, canlib.VectorBus) + assert bus.protocol == can.CanProtocol.CAN_FD xl_channel_config = _find_xl_channel_config( serial=_find_virtual_can_serial(), channel=0 @@ -202,6 +296,8 @@ def test_bus_creation_fd_bitrate_timings_mocked(mock_xldriver) -> None: _testing=True, ) assert isinstance(bus, canlib.VectorBus) + assert bus.protocol == can.CanProtocol.CAN_FD + can.interfaces.vector.canlib.xldriver.xlOpenDriver.assert_called() can.interfaces.vector.canlib.xldriver.xlGetApplConfig.assert_called() @@ -344,6 +440,7 @@ def test_bus_creation_timing() -> None: timing=timing, ) assert isinstance(bus, canlib.VectorBus) + assert bus.protocol == can.CanProtocol.CAN_20 xl_channel_config = _find_xl_channel_config( serial=_find_virtual_can_serial(), channel=0 @@ -375,6 +472,8 @@ def test_bus_creation_timingfd_mocked(mock_xldriver) -> None: _testing=True, ) assert isinstance(bus, canlib.VectorBus) + assert bus.protocol == can.CanProtocol.CAN_FD + can.interfaces.vector.canlib.xldriver.xlOpenDriver.assert_called() can.interfaces.vector.canlib.xldriver.xlGetApplConfig.assert_called() @@ -423,6 +522,8 @@ def test_bus_creation_timingfd() -> None: timing=timing, ) + assert bus.protocol == can.CanProtocol.CAN_FD + xl_channel_config = _find_xl_channel_config( serial=_find_virtual_can_serial(), channel=0 ) @@ -593,7 +694,38 @@ def test_receive_fd_non_msg_event() -> None: def test_flush_tx_buffer_mocked(mock_xldriver) -> None: bus = can.Bus(channel=0, interface="vector", _testing=True) bus.flush_tx_buffer() - can.interfaces.vector.canlib.xldriver.xlCanFlushTransmitQueue.assert_called() + transmit_args = can.interfaces.vector.canlib.xldriver.xlCanTransmit.call_args[0] + + num_msg = transmit_args[2] + assert num_msg.value == ctypes.c_uint(1).value + + event = transmit_args[3] + assert isinstance(event, xlclass.XLevent) + assert event.tag & xldefine.XL_EventTags.XL_TRANSMIT_MSG + assert event.tagData.msg.flags & ( + xldefine.XL_MessageFlags.XL_CAN_MSG_FLAG_OVERRUN + | xldefine.XL_MessageFlags.XL_CAN_MSG_FLAG_WAKEUP + ) + + +def test_flush_tx_buffer_fd_mocked(mock_xldriver) -> None: + bus = can.Bus(channel=0, interface="vector", fd=True, _testing=True) + bus.flush_tx_buffer() + transmit_args = can.interfaces.vector.canlib.xldriver.xlCanTransmitEx.call_args[0] + + num_msg = transmit_args[2] + assert num_msg.value == ctypes.c_uint(1).value + + num_msg_sent = transmit_args[3] + assert num_msg_sent.value == ctypes.c_uint(0).value + + event = transmit_args[4] + assert isinstance(event, xlclass.XLcanTxEvent) + assert event.tag & xldefine.XL_CANFD_TX_EventTags.XL_CAN_EV_TAG_TX_MSG + assert ( + event.tagData.canMsg.msgFlags + & xldefine.XL_CANFD_TX_MessageFlags.XL_CAN_TXMSG_FLAG_HIGHPRIO + ) @pytest.mark.skipif(not XLDRIVER_FOUND, reason="Vector XL API is unavailable") @@ -769,6 +901,12 @@ def test_vector_subtype_error_from_generic() -> None: raise specific +def test_iterate_channel_index() -> None: + channel_mask = 0x23 # 100011 + channels = list(canlib._iterate_channel_index(channel_mask)) + assert channels == [0, 1, 5] + + @pytest.mark.skipif( sys.byteorder != "little", reason="Test relies on little endian data." ) @@ -782,6 +920,31 @@ def test_get_channel_configs() -> None: canlib._get_xl_driver_config = _original_func +@pytest.mark.skipif( + sys.byteorder != "little", reason="Test relies on little endian data." +) +def test_detect_available_configs() -> None: + _original_func = canlib._get_xl_driver_config + canlib._get_xl_driver_config = _get_predefined_xl_driver_config + + available_configs = canlib.VectorBus._detect_available_configs() + + assert len(available_configs) == 5 + + assert available_configs[0]["interface"] == "vector" + assert available_configs[0]["channel"] == 2 + assert available_configs[0]["serial"] == 1001 + assert available_configs[0]["channel_index"] == 2 + assert available_configs[0]["hw_type"] == xldefine.XL_HardwareType.XL_HWTYPE_VN8900 + assert available_configs[0]["hw_index"] == 0 + assert available_configs[0]["supports_fd"] is True + assert isinstance( + available_configs[0]["vector_channel_config"], VectorChannelConfig + ) + + canlib._get_xl_driver_config = _original_func + + @pytest.mark.skipif(not IS_WINDOWS, reason="Windows specific test") def test_winapi_availability() -> None: assert canlib.WaitForSingleObject is not None @@ -861,7 +1024,7 @@ def _find_xl_channel_config(serial: int, channel: int) -> xlclass.XLchannelConfi raise LookupError("XLchannelConfig not found.") -@functools.lru_cache() +@functools.lru_cache def _find_virtual_can_serial() -> int: """Serial number might be 0 or 100 depending on driver version.""" xl_driver_config = xlclass.XLdriverConfig() @@ -879,142 +1042,142 @@ def _find_virtual_can_serial() -> int: XL_DRIVER_CONFIG_EXAMPLE = ( - b"\x0E\x00\x1E\x14\x0C\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x0e\x00\x1e\x14\x0c\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x56\x4E\x38\x39\x31\x34\x20\x43\x68\x61\x6E\x6E" - b"\x65\x6C\x20\x53\x74\x72\x65\x61\x6D\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x2D\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x04" - b"\x0A\x40\x00\x02\x00\x02\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x56\x4e\x38\x39\x31\x34\x20\x43\x68\x61\x6e\x6e" + b"\x65\x6c\x20\x53\x74\x72\x65\x61\x6d\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x2d\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x04" + b"\x0a\x40\x00\x02\x00\x02\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x0C\x00\x02\x0A\x04\x00\x00\x00\x00\x00\x00\x00\x8E" - b"\x00\x02\x0A\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xE9\x03\x00\x00\x08" - b"\x1C\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x0a\x04\x00\x00\x00\x00\x00\x00\x00\x8e" + b"\x00\x02\x0a\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe9\x03\x00\x00\x08" + b"\x1c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x03\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x56\x4E\x38\x39\x31" - b"\x34\x20\x43\x68\x61\x6E\x6E\x65\x6C\x20\x31\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x2D\x00\x01\x03\x02\x00\x00\x00\x00\x01\x02\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x56\x4e\x38\x39\x31" + b"\x34\x20\x43\x68\x61\x6e\x6e\x65\x6c\x20\x31\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x2d\x00\x01\x03\x02\x00\x00\x00\x00\x01\x02\x00\x00" b"\x00\x00\x00\x00\x00\x02\x10\x00\x08\x07\x01\x04\x00\x00\x00\x00\x00\x00\x04\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C\x00\x02\x0A\x04\x00" - b"\x00\x00\x00\x00\x00\x00\x8E\x00\x02\x0A\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x0a\x04\x00" + b"\x00\x00\x00\x00\x00\x00\x8e\x00\x02\x0a\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\xE9\x03\x00\x00\x08\x1C\x00\x00\x46\x52\x70\x69\x67\x67\x79\x20\x31\x30" - b"\x38\x30\x41\x6D\x61\x67\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\xe9\x03\x00\x00\x08\x1c\x00\x00\x46\x52\x70\x69\x67\x67\x79\x20\x31\x30" + b"\x38\x30\x41\x6d\x61\x67\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x03\x05\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x56\x4E\x38\x39\x31\x34\x20\x43\x68\x61\x6E\x6E\x65\x6C\x20\x32\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x2D\x00\x02\x3C\x01\x00" - b"\x00\x00\x00\x02\x04\x00\x00\x00\x00\x00\x00\x00\x12\x00\x00\xA2\x03\x05\x01\x00" - b"\x00\x00\x04\x00\x00\x01\x00\x00\x00\x20\xA1\x07\x00\x01\x04\x03\x01\x01\x00\x00" + b"\x00\x00\x56\x4e\x38\x39\x31\x34\x20\x43\x68\x61\x6e\x6e\x65\x6c\x20\x32\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x2d\x00\x02\x3c\x01\x00" + b"\x00\x00\x00\x02\x04\x00\x00\x00\x00\x00\x00\x00\x12\x00\x00\xa2\x03\x05\x01\x00" + b"\x00\x00\x04\x00\x00\x01\x00\x00\x00\x20\xa1\x07\x00\x01\x04\x03\x01\x01\x00\x00" b"\x00\x00\x00\x00\x00\x01\x80\x00\x00\x00\x68\x89\x09\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x0C\x00\x02\x0A\x04\x00\x00\x00\x00\x00\x00\x00\x8E\x00\x02\x0A\x00\x00\x00" + b"\x00\x0c\x00\x02\x0a\x04\x00\x00\x00\x00\x00\x00\x00\x8e\x00\x02\x0a\x00\x00\x00" b"\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\xE9\x03\x00\x00\x08\x1C\x00\x00\x4F\x6E\x20" - b"\x62\x6F\x61\x72\x64\x20\x43\x41\x4E\x20\x31\x30\x35\x31\x63\x61\x70\x28\x48\x69" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe9\x03\x00\x00\x08\x1c\x00\x00\x4f\x6e\x20" + b"\x62\x6f\x61\x72\x64\x20\x43\x41\x4e\x20\x31\x30\x35\x31\x63\x61\x70\x28\x48\x69" b"\x67\x68\x73\x70\x65\x65\x64\x29\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x03" b"\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x56\x4E\x38\x39\x31\x34\x20\x43\x68\x61\x6E" - b"\x6E\x65\x6C\x20\x33\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x2D\x00\x03\x3C\x01\x00\x00\x00\x00\x03\x08\x00\x00\x00\x00\x00\x00\x00\x12" - b"\x00\x00\xA2\x03\x09\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x20\xA1\x07\x00" - b"\x01\x04\x03\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x9B\x00\x00\x00\x68\x89\x09" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x0C\x00\x02\x0A\x04\x00\x00\x00\x00\x00\x00\x00" - b"\x8E\x00\x02\x0A\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xE9\x03\x00\x00" - b"\x08\x1C\x00\x00\x4F\x6E\x20\x62\x6F\x61\x72\x64\x20\x43\x41\x4E\x20\x31\x30\x35" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x56\x4e\x38\x39\x31\x34\x20\x43\x68\x61\x6e" + b"\x6e\x65\x6c\x20\x33\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x2d\x00\x03\x3c\x01\x00\x00\x00\x00\x03\x08\x00\x00\x00\x00\x00\x00\x00\x12" + b"\x00\x00\xa2\x03\x09\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x20\xa1\x07\x00" + b"\x01\x04\x03\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x9b\x00\x00\x00\x68\x89\x09" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x0a\x04\x00\x00\x00\x00\x00\x00\x00" + b"\x8e\x00\x02\x0a\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe9\x03\x00\x00" + b"\x08\x1c\x00\x00\x4f\x6e\x20\x62\x6f\x61\x72\x64\x20\x43\x41\x4e\x20\x31\x30\x35" b"\x31\x63\x61\x70\x28\x48\x69\x67\x68\x73\x70\x65\x65\x64\x29\x00\x04\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x03\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x56\x4E\x38\x39" - b"\x31\x34\x20\x43\x68\x61\x6E\x6E\x65\x6C\x20\x34\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x2D\x00\x04\x33\x01\x00\x00\x00\x00\x04\x10\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x56\x4e\x38\x39" + b"\x31\x34\x20\x43\x68\x61\x6e\x6e\x65\x6c\x20\x34\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x2d\x00\x04\x33\x01\x00\x00\x00\x00\x04\x10\x00" b"\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x03\x09\x02\x08\x00\x00\x00\x00\x00\x02" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C\x00\x02\x0A\x03" - b"\x00\x00\x00\x00\x00\x00\x00\x8E\x00\x02\x0A\x00\x00\x00\x00\x00\x00\x00\x01\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x0a\x03" + b"\x00\x00\x00\x00\x00\x00\x00\x8e\x00\x02\x0a\x00\x00\x00\x00\x00\x00\x00\x01\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\xE9\x03\x00\x00\x08\x1C\x00\x00\x4C\x49\x4E\x70\x69\x67\x67\x79\x20" - b"\x37\x32\x36\x39\x6D\x61\x67\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x07\x00\x00\x00\x70\x17\x00\x00\x0C\x09\x03\x04\x58\x02\x10\x0E\x30" + b"\x00\x00\x00\xe9\x03\x00\x00\x08\x1c\x00\x00\x4c\x49\x4e\x70\x69\x67\x67\x79\x20" + b"\x37\x32\x36\x39\x6d\x61\x67\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x07\x00\x00\x00\x70\x17\x00\x00\x0c\x09\x03\x04\x58\x02\x10\x0e\x30" b"\x57\x05\x00\x00\x00\x00\x00\x88\x13\x88\x13\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x56\x4E\x38\x39\x31\x34\x20\x43\x68\x61\x6E\x6E\x65\x6C\x20\x35\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x2D\x00\x05\x00\x00" + b"\x00\x00\x00\x56\x4e\x38\x39\x31\x34\x20\x43\x68\x61\x6e\x6e\x65\x6c\x20\x35\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x2d\x00\x05\x00\x00" b"\x00\x00\x02\x00\x05\x20\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x0C\x00\x02\x0A\x00\x00\x00\x00\x00\x00\x00\x00\x8E\x00\x02\x0A\x00\x00" + b"\x00\x00\x0c\x00\x02\x0a\x00\x00\x00\x00\x00\x00\x00\x00\x8e\x00\x02\x0a\x00\x00" b"\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xE9\x03\x00\x00\x08\x1C\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe9\x03\x00\x00\x08\x1c\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x03\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x56\x4E\x38\x39\x31\x34\x20\x43\x68\x61" - b"\x6E\x6E\x65\x6C\x20\x36\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x2D\x00\x06\x00\x00\x00\x00\x02\x00\x06\x40\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x56\x4e\x38\x39\x31\x34\x20\x43\x68\x61" + b"\x6e\x6e\x65\x6c\x20\x36\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x2d\x00\x06\x00\x00\x00\x00\x02\x00\x06\x40\x00\x00\x00\x00\x00\x00\x00" b"\x02\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C\x00\x02\x0A\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x8E\x00\x02\x0A\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xE9\x03\x00" - b"\x00\x08\x1C\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x0a\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x8e\x00\x02\x0a\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe9\x03\x00" + b"\x00\x08\x1c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x03\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x56\x4E\x38" - b"\x39\x31\x34\x20\x43\x68\x61\x6E\x6E\x65\x6C\x20\x37\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x2D\x00\x07\x00\x00\x00\x00\x02\x00\x07\x80" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x56\x4e\x38" + b"\x39\x31\x34\x20\x43\x68\x61\x6e\x6e\x65\x6c\x20\x37\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x2d\x00\x07\x00\x00\x00\x00\x02\x00\x07\x80" b"\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C\x00\x02\x0A" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x8E\x00\x02\x0A\x00\x00\x00\x00\x00\x00\x00\x01" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x0a" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x8e\x00\x02\x0a\x00\x00\x00\x00\x00\x00\x00\x01" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\xE9\x03\x00\x00\x08\x1C\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\xe9\x03\x00\x00\x08\x1c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x03\x04\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x56\x4E\x38\x39\x31\x34\x20\x43\x68\x61\x6E\x6E\x65\x6C\x20\x38" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x2D\x00\x08\x3C" - b"\x01\x00\x00\x00\x00\x08\x00\x01\x00\x00\x00\x00\x00\x00\x12\x00\x00\xA2\x01\x00" - b"\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x20\xA1\x07\x00\x01\x04\x03\x01\x01" + b"\x00\x00\x00\x00\x56\x4e\x38\x39\x31\x34\x20\x43\x68\x61\x6e\x6e\x65\x6c\x20\x38" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x2d\x00\x08\x3c" + b"\x01\x00\x00\x00\x00\x08\x00\x01\x00\x00\x00\x00\x00\x00\x12\x00\x00\xa2\x01\x00" + b"\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x20\xa1\x07\x00\x01\x04\x03\x01\x01" b"\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x68\x89\x09\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x0C\x00\x02\x0A\x04\x00\x00\x00\x00\x00\x00\x00\x8E\x00\x02\x0A\x00" + b"\x00\x00\x00\x0c\x00\x02\x0a\x04\x00\x00\x00\x00\x00\x00\x00\x8e\x00\x02\x0a\x00" b"\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xE9\x03\x00\x00\x08\x1C\x00\x00\x4F" - b"\x6E\x20\x62\x6F\x61\x72\x64\x20\x43\x41\x4E\x20\x31\x30\x35\x31\x63\x61\x70\x28" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe9\x03\x00\x00\x08\x1c\x00\x00\x4f" + b"\x6e\x20\x62\x6f\x61\x72\x64\x20\x43\x41\x4e\x20\x31\x30\x35\x31\x63\x61\x70\x28" b"\x48\x69\x67\x68\x73\x70\x65\x65\x64\x29\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x03\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x56\x4E\x38\x39\x31\x34\x20\x43\x68" - b"\x61\x6E\x6E\x65\x6C\x20\x39\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x2D\x00\x09\x80\x02\x00\x00\x00\x00\x09\x00\x02\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x56\x4e\x38\x39\x31\x34\x20\x43\x68" + b"\x61\x6e\x6e\x65\x6c\x20\x39\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x2d\x00\x09\x80\x02\x00\x00\x00\x00\x09\x00\x02\x00\x00\x00\x00\x00" b"\x00\x02\x00\x00\x00\x40\x00\x40\x00\x00\x00\x00\x00\x00\x40\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C\x00\x02\x0A\x03\x00\x00\x00\x00\x00" - b"\x00\x00\x8E\x00\x02\x0A\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xE9\x03" - b"\x00\x00\x08\x1C\x00\x00\x44\x2F\x41\x20\x49\x4F\x70\x69\x67\x67\x79\x20\x38\x36" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x0a\x03\x00\x00\x00\x00\x00" + b"\x00\x00\x8e\x00\x02\x0a\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe9\x03" + b"\x00\x00\x08\x1c\x00\x00\x44\x2f\x41\x20\x49\x4f\x70\x69\x67\x67\x79\x20\x38\x36" b"\x34\x32\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x03\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x56\x69" - b"\x72\x74\x75\x61\x6C\x20\x43\x68\x61\x6E\x6E\x65\x6C\x20\x31\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x16\x00\x00\x00\x00\x00\x0A" - b"\x00\x04\x00\x00\x00\x00\x00\x00\x07\x00\x00\xA0\x01\x00\x01\x00\x00\x00\x00\x00" - b"\x00\x01\x00\x00\x00\x20\xA1\x07\x00\x01\x04\x03\x01\x01\x00\x00\x00\x00\x00\x00" - b"\x00\x01\x00\x00\x00\x00\x68\x89\x09\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00\x1E" + b"\x72\x74\x75\x61\x6c\x20\x43\x68\x61\x6e\x6e\x65\x6c\x20\x31\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x16\x00\x00\x00\x00\x00\x0a" + b"\x00\x04\x00\x00\x00\x00\x00\x00\x07\x00\x00\xa0\x01\x00\x01\x00\x00\x00\x00\x00" + b"\x00\x01\x00\x00\x00\x20\xa1\x07\x00\x01\x04\x03\x01\x01\x00\x00\x00\x00\x00\x00" + b"\x00\x01\x00\x00\x00\x00\x68\x89\x09\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00\x1e" b"\x14\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x56\x69\x72\x74\x75\x61\x6C" - b"\x20\x43\x41\x4E\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x56\x69\x72\x74\x75\x61\x6c" + b"\x20\x43\x41\x4e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x56\x69\x72\x74\x75\x61\x6C\x20\x43\x68\x61\x6E\x6E\x65\x6C" + b"\x00\x00\x00\x00\x00\x56\x69\x72\x74\x75\x61\x6c\x20\x43\x68\x61\x6e\x6e\x65\x6c" b"\x20\x32\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01" - b"\x16\x00\x00\x00\x00\x00\x0B\x00\x08\x00\x00\x00\x00\x00\x00\x07\x00\x00\xA0\x01" - b"\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x20\xA1\x07\x00\x01\x04\x03\x01" + b"\x16\x00\x00\x00\x00\x00\x0b\x00\x08\x00\x00\x00\x00\x00\x00\x07\x00\x00\xa0\x01" + b"\x00\x01\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x20\xa1\x07\x00\x01\x04\x03\x01" b"\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x68\x89\x09\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x10\x00\x1E\x14\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x10\x00\x1e\x14\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x56\x69\x72\x74\x75\x61\x6C\x20\x43\x41\x4E\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x56\x69\x72\x74\x75\x61\x6c\x20\x43\x41\x4e\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x02" + 11832 * b"\x00" ) diff --git a/test/test_viewer.py b/test/test_viewer.py index baef10bda..e71d06dc8 100644 --- a/test/test_viewer.py +++ b/test/test_viewer.py @@ -30,14 +30,13 @@ import time import unittest from collections import defaultdict -from typing import Dict, Tuple, Union +from test.config import IS_CI from unittest.mock import patch import pytest import can -from can.viewer import CanViewer, parse_args -from test.config import IS_CI +from can.viewer import CanViewer, _parse_viewer_args # Allow the curses module to be missing (e.g. on PyPy on Windows) try: @@ -398,19 +397,19 @@ def test_pack_unpack(self): ) def test_parse_args(self): - parsed_args, _, _, _ = parse_args(["-b", "250000"]) + parsed_args, _ = _parse_viewer_args(["-b", "250000"]) self.assertEqual(parsed_args.bitrate, 250000) - parsed_args, _, _, _ = parse_args(["--bitrate", "500000"]) + parsed_args, _ = _parse_viewer_args(["--bitrate", "500000"]) self.assertEqual(parsed_args.bitrate, 500000) - parsed_args, _, _, _ = parse_args(["-c", "can0"]) + parsed_args, _ = _parse_viewer_args(["-c", "can0"]) self.assertEqual(parsed_args.channel, "can0") - parsed_args, _, _, _ = parse_args(["--channel", "PCAN_USBBUS1"]) + parsed_args, _ = _parse_viewer_args(["--channel", "PCAN_USBBUS1"]) self.assertEqual(parsed_args.channel, "PCAN_USBBUS1") - parsed_args, _, data_structs, _ = parse_args(["-d", "100:=7.1.2 - pytest-timeout==2.0.2 - coveralls==3.3.1 - pytest-cov==4.0.0 - coverage==6.5.0 - hypothesis~=6.35.0 - pyserial~=3.5 - parameterized~=0.8 - +passenv = + CI + GITHUB_* + COVERALLS_* + PY_COLORS + TEST_SOCKETCAN +dependency_groups = + test +extras = + canalystii + mf4 + multicast + pywin32 + serial + viewer commands = pytest {posargs} +[testenv:py314] extras = canalystii + serial + pywin32 -recreate = True +[testenv:{py313t,py314t,pypy310,pypy311}] +extras = + canalystii + serial -[testenv:gh] -passenv = - CI - GITHUB_* - COVERALLS_* - PY_COLORS - TEST_SOCKETCAN +[testenv:docs] +description = Build and test the documentation +basepython = py313 +dependency_groups = + docs +extras = + canalystii + gs-usb +commands = + python -m sphinx -b html -Wan --keep-going doc build + python -m sphinx -b doctest -W --keep-going doc build -[testenv:travis] -passenv = - CI - TRAVIS - TRAVIS_* - TEST_SOCKETCAN +[testenv:lint] +description = Run linters +basepython = py313 +dependency_groups = + lint +extras = + viewer +commands = + black --check . + ruff check can examples doc + pylint \ + can/**.py \ + can/io \ + doc/conf.py \ + examples/**.py \ + can/interfaces/socketcan +[testenv:type] +description = Run type checker +basepython = py313 +dependency_groups = + lint +extras = +commands = + mypy --python-version 3.10 . + mypy --python-version 3.11 . + mypy --python-version 3.12 . + mypy --python-version 3.13 . + mypy --python-version 3.14 . [pytest] testpaths = test -addopts = -v --timeout=300 --cov=can --cov-config=tox.ini --cov-report=lcov --cov-report=term - +addopts = -v --timeout=300 --cov=can --cov-config=tox.ini --cov-report=lcov --cov-report=term --color=yes [coverage:run] # we could also use branch coverage