diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ddb3028c..588d9a96b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,6 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] experimental: [false] python-version: [ - "3.8", "3.9", "3.10", "3.11", @@ -42,6 +41,7 @@ jobs: - 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 @@ -80,10 +80,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e .[lint] - - name: mypy 3.8 - run: | - mypy --python-version 3.8 . + pip install --group lint -e . - name: mypy 3.9 run: | mypy --python-version 3.9 . @@ -96,6 +93,9 @@ jobs: - name: mypy 3.12 run: | mypy --python-version 3.12 . + - name: mypy 3.13 + run: | + mypy --python-version 3.13 . - name: ruff run: | ruff check can @@ -119,7 +119,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e .[lint] + pip install --group lint - name: Code Format Check with Black run: | black --check --verbose . diff --git a/.readthedocs.yml b/.readthedocs.yml index dad8c28db..6fe4009e4 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -10,6 +10,9 @@ build: os: ubuntu-22.04 tools: python: "3.12" + jobs: + post_install: + - pip install --group docs # Build documentation in the docs/ directory with Sphinx sphinx: @@ -23,10 +26,11 @@ 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 - mf4 + - remote + - serial diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d2581d9d..39cbaa716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,48 @@ +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 ============= diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 389d26412..524908dfc 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -83,3 +83,12 @@ Felix Nieuwenhuizen @felixn @Tbruno25 @RitheeshBaradwaj +@vijaysubbiah20 +@liamkinne +@RitheeshBaradwaj +@BenGardiner +@bures +@NickCao +@SWolfSchunk +@belliriccardo +@cssedev diff --git a/README.rst b/README.rst index d2f05b2c1..3c185f6cb 100644 --- a/README.rst +++ b/README.rst @@ -3,7 +3,7 @@ python-can |pypi| |conda| |python_implementation| |downloads| |downloads_monthly| -|docs| |github-actions| |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/ @@ -41,10 +41,6 @@ python-can :target: https://coveralls.io/github/hardbyte/python-can?branch=develop :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 @@ -64,6 +60,7 @@ Library Version Python 3.x 2.7+, 3.5+ 4.0+ 3.7+ 4.3+ 3.8+ + 4.6+ 3.9+ ============================== =========== diff --git a/can/__init__.py b/can/__init__.py index 324803b9e..b1bd636c1 100644 --- a/can/__init__.py +++ b/can/__init__.py @@ -8,20 +8,23 @@ import contextlib import logging from importlib.metadata import PackageNotFoundError, version -from typing import Any, Dict +from typing import Any __all__ = [ + "VALID_INTERFACES", "ASCReader", "ASCWriter", "AsyncBufferedReader", - "BitTiming", - "BitTimingFd", "BLFReader", "BLFWriter", + "BitTiming", + "BitTimingFd", "BufferedReader", "Bus", "BusABC", "BusState", + "CSVReader", + "CSVWriter", "CanError", "CanInitializationError", "CanInterfaceNotImplementedError", @@ -30,18 +33,16 @@ "CanTimeoutError", "CanutilsLogReader", "CanutilsLogWriter", - "CSVReader", - "CSVWriter", "CyclicSendTaskABC", "LimitedDurationCyclicSendTaskABC", "Listener", - "Logger", "LogReader", - "ModifiableCyclicTaskABC", - "Message", - "MessageSync", + "Logger", "MF4Reader", "MF4Writer", + "Message", + "MessageSync", + "ModifiableCyclicTaskABC", "Notifier", "Printer", "RedirectReader", @@ -49,11 +50,10 @@ "SizedRotatingLogger", "SqliteReader", "SqliteWriter", - "ThreadSafeBus", "TRCFileVersion", "TRCReader", "TRCWriter", - "VALID_INTERFACES", + "ThreadSafeBus", "bit_timing", "broadcastmanager", "bus", @@ -64,8 +64,8 @@ "interfaces", "io", "listener", - "logconvert", "log", + "logconvert", "logger", "message", "notifier", @@ -130,4 +130,4 @@ log = logging.getLogger("can") -rc: Dict[str, Any] = {} +rc: dict[str, Any] = {} diff --git a/can/_entry_points.py b/can/_entry_points.py index 6842e3c1a..e8ce92d7c 100644 --- a/can/_entry_points.py +++ b/can/_entry_points.py @@ -2,7 +2,7 @@ import sys from dataclasses import dataclass from importlib.metadata import entry_points -from typing import Any, List +from typing import Any @dataclass @@ -20,14 +20,14 @@ def load(self) -> Any: # "Compatibility Note". if sys.version_info >= (3, 10): - def read_entry_points(group: str) -> List[_EntryPoint]: + def read_entry_points(group: str) -> list[_EntryPoint]: return [ _EntryPoint(ep.name, ep.module, ep.attr) for ep in entry_points(group=group) ] else: - def read_entry_points(group: str) -> List[_EntryPoint]: + def read_entry_points(group: str) -> list[_EntryPoint]: return [ _EntryPoint(ep.name, *ep.value.split(":", maxsplit=1)) for ep in entry_points().get(group, []) diff --git a/can/bit_timing.py b/can/bit_timing.py index f5d50eac1..4b0074472 100644 --- a/can/bit_timing.py +++ b/can/bit_timing.py @@ -1,6 +1,7 @@ # pylint: disable=too-many-lines import math -from typing import TYPE_CHECKING, Iterator, List, Mapping, cast +from collections.abc import Iterator, Mapping +from typing import TYPE_CHECKING, cast if TYPE_CHECKING: from can.typechecking import BitTimingDict, BitTimingFdDict @@ -155,7 +156,7 @@ def from_bitrate_and_segments( 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 @@ -232,7 +233,7 @@ def iterate_from_sample_point( raise ValueError(f"sample_point (={sample_point}) must not be below 50%.") for brp in range(1, 65): - nbt = round(int(f_clock / (bitrate * brp))) + nbt = int(f_clock / (bitrate * brp)) if nbt < 8: break @@ -240,7 +241,7 @@ def iterate_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) @@ -286,7 +287,7 @@ 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] = list( + possible_solutions: list[BitTiming] = list( cls.iterate_from_sample_point(f_clock, bitrate, sample_point) ) @@ -312,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: @@ -322,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: @@ -433,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 @@ -458,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__() @@ -716,10 +717,8 @@ def from_bitrate_and_segments( # pylint: disable=too-many-arguments 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 @@ -787,7 +786,7 @@ def iterate_from_sample_point( sync_seg = 1 for nom_brp in range(1, 257): - nbt = round(int(f_clock / (nom_bitrate * nom_brp))) + nbt = int(f_clock / (nom_bitrate * nom_brp)) if nbt < 1: break @@ -795,7 +794,7 @@ def iterate_from_sample_point( if abs(effective_nom_bitrate - nom_bitrate) > nom_bitrate / 256: continue - nom_tseg1 = int(round(nom_sample_point / 100 * nbt)) - 1 + 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 @@ -811,7 +810,7 @@ def iterate_from_sample_point( if abs(effective_data_bitrate - data_bitrate) > data_bitrate / 256: continue - data_tseg1 = int(round(data_sample_point / 100 * dbt)) - 1 + 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 @@ -876,7 +875,7 @@ def from_sample_point( f"data_sample_point (={data_sample_point}) must not be below 50%." ) - possible_solutions: List[BitTimingFd] = list( + possible_solutions: list[BitTimingFd] = list( cls.iterate_from_sample_point( f_clock, nom_bitrate, @@ -923,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: @@ -933,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: @@ -969,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: @@ -979,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: @@ -1106,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 @@ -1138,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/broadcastmanager.py b/can/broadcastmanager.py index 319fb0f53..b2bc28e76 100644 --- a/can/broadcastmanager.py +++ b/can/broadcastmanager.py @@ -12,13 +12,12 @@ import threading import time import warnings +from collections.abc import Sequence from typing import ( TYPE_CHECKING, Callable, Final, Optional, - Sequence, - Tuple, Union, cast, ) @@ -61,7 +60,7 @@ def create_timer(self) -> _Pywin32Event: ): event = self.win32event.CreateWaitableTimer(None, False, None) - return cast(_Pywin32Event, event) + 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) @@ -121,13 +120,13 @@ def __init__( # Take the Arbitration ID of the first element self.arbitration_id = messages[0].arbitration_id self.period = period - self.period_ns = int(round(period * 1e9)) + self.period_ns = round(period * 1e9) self.messages = messages @staticmethod def _check_and_convert_messages( - messages: Union[Sequence[Message], Message] - ) -> Tuple[Message, ...]: + messages: Union[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. @@ -194,7 +193,7 @@ def start(self) -> None: class ModifiableCyclicTaskABC(CyclicSendTaskABC, abc.ABC): - def _check_modified_messages(self, messages: Tuple[Message, ...]) -> None: + def _check_modified_messages(self, messages: tuple[Message, ...]) -> None: """Helper function to perform error checking when modifying the data in the cyclic task. diff --git a/can/bus.py b/can/bus.py index a12808ab6..0d031a18b 100644 --- a/can/bus.py +++ b/can/bus.py @@ -6,18 +6,14 @@ import logging import threading from abc import ABC, ABCMeta, abstractmethod +from collections.abc import Iterator, Sequence from enum import Enum, auto from time import time from types import TracebackType from typing import ( Any, Callable, - Iterator, - List, Optional, - Sequence, - Tuple, - Type, Union, cast, ) @@ -97,7 +93,7 @@ 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, @@ -147,7 +143,7 @@ def recv(self, timeout: Optional[float] = None) -> Optional[Message]: def _recv_internal( self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + ) -> tuple[Optional[Message], bool]: """ Read a message from the bus and tell whether it was filtered. This methods may be called by :meth:`~can.BusABC.recv` @@ -276,7 +272,7 @@ def send_periodic( # Create a backend specific task; will be patched to a _SelfRemovingCyclicTask later task = cast( - _SelfRemovingCyclicTask, + "_SelfRemovingCyclicTask", self._send_periodic_internal( msgs, period, duration, autostart, modifier_callback ), @@ -452,7 +448,7 @@ 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) + _filter = cast("can.typechecking.CanFilterExtended", _filter) if _filter["extended"] != msg.is_extended_id: continue @@ -491,7 +487,7 @@ def __enter__(self) -> Self: def __exit__( self, - exc_type: Optional[Type[BaseException]], + exc_type: Optional[type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType], ) -> None: @@ -529,7 +525,7 @@ def protocol(self) -> CanProtocol: return self._can_protocol @staticmethod - def _detect_available_configs() -> List[can.typechecking.AutoDetectedConfig]: + def _detect_available_configs() -> list[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..6e3850354 --- /dev/null +++ b/can/cli.py @@ -0,0 +1,320 @@ +import argparse +import re +from collections.abc import Sequence +from typing import Any, Optional, Union + +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: Optional[str] = None, + group_title: Optional[str] = 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: Optional[str] = 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: Union[str, Sequence[Any], None], + option_string: Optional[str] = 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: Union[str, Sequence[Any], None], + option_string: Optional[str] = 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: Union[str, Sequence[Any], None], + option_string: Optional[str] = None, + ) -> None: + if not isinstance(values, list): + raise argparse.ArgumentError(self, "Invalid --bus-kwargs argument") + + bus_kwargs: dict[str, Union[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: Union[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, Union[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 0336b03d3..8336941be 100644 --- a/can/ctypesutil.py +++ b/can/ctypesutil.py @@ -5,11 +5,11 @@ import ctypes import logging import sys -from typing import Any, Callable, Optional, Tuple, Union +from typing import Any, Callable, Optional, Union log = logging.getLogger("can.ctypesutil") -__all__ = ["CLibrary", "HANDLE", "PHANDLE", "HRESULT"] +__all__ = ["HANDLE", "HRESULT", "PHANDLE", "CLibrary"] if sys.platform == "win32": _LibBase = ctypes.WinDLL @@ -32,7 +32,7 @@ def map_symbol( self, func_name: str, restype: Any = None, - argtypes: Tuple[Any, ...] = (), + argtypes: tuple[Any, ...] = (), errcheck: Optional[Callable[..., Any]] = None, ) -> Any: """ diff --git a/can/exceptions.py b/can/exceptions.py index e1c970e27..8abc75147 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,14 +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, Type - -if sys.version_info >= (3, 9): - from collections.abc import Generator -else: - from typing import Generator +from typing import Optional class CanError(Exception): @@ -114,7 +109,7 @@ class CanTimeoutError(CanError, TimeoutError): @contextmanager def error_check( error_message: Optional[str] = None, - exception_type: Type[CanError] = CanOperationError, + 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 2b7e27f8d..eee58ff41 100644 --- a/can/interface.py +++ b/can/interface.py @@ -4,9 +4,11 @@ CyclicSendTasks. """ +import concurrent.futures.thread import importlib import logging -from typing import Any, Iterable, List, Optional, Type, Union, cast +from collections.abc import Callable, Iterable +from typing import Any, Optional, Union, cast from . import util from .bus import BusABC @@ -18,7 +20,7 @@ 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,7 +54,7 @@ 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) @util.deprecated_args_alias( @@ -138,8 +140,9 @@ def Bus( # noqa: N802 def detect_available_configs( - interfaces: Union[None, str, Iterable[str]] = None -) -> List[AutoDetectedConfig]: + interfaces: Union[None, str, Iterable[str]] = None, + timeout: float = 5.0, +) -> list[AutoDetectedConfig]: """Detect all configurations/channels that the interfaces could currently connect with. @@ -147,59 +150,84 @@ def detect_available_configs( 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 + # otherwise assume iterable of strings - result = [] - for interface in interfaces: + # Collect detection callbacks + callbacks: dict[str, Callable[[], list[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 f220d28e5..1b401639a 100644 --- a/can/interfaces/__init__.py +++ b/can/interfaces/__init__.py @@ -2,8 +2,6 @@ Interfaces contain low level implementations that interact with CAN hardware. """ -from typing import Dict, Tuple - from can._entry_points import read_entry_points __all__ = [ @@ -35,7 +33,7 @@ ] # 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"), diff --git a/can/interfaces/canalystii.py b/can/interfaces/canalystii.py index 2fef19497..d85211130 100644 --- a/can/interfaces/canalystii.py +++ b/can/interfaces/canalystii.py @@ -1,8 +1,9 @@ import logging import time from collections import deque +from collections.abc import Sequence from ctypes import c_ubyte -from typing import Any, Deque, Dict, Optional, Sequence, Tuple, Union +from typing import Any, Optional, Union import canalystii as driver @@ -26,7 +27,7 @@ def __init__( timing: Optional[Union[BitTiming, BitTimingFd]] = None, can_filters: Optional[CanFilters] = None, rx_queue_size: Optional[int] = None, - **kwargs: Dict[str, Any], + **kwargs: dict[str, Any], ): """ @@ -68,7 +69,7 @@ def __init__( self.channels = list(channel) 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.rx_queue: deque[tuple[int, driver.Message]] = deque(maxlen=rx_queue_size) self.device = driver.CanalystDevice(device_index=device) self._can_protocol = CanProtocol.CAN_20 @@ -129,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() @@ -166,7 +167,7 @@ def poll_received_messages(self) -> None: def _recv_internal( self, timeout: Optional[float] = None - ) -> Tuple[Optional[Message], bool]: + ) -> tuple[Optional[Message], bool]: """ :param timeout: float in seconds diff --git a/can/interfaces/etas/__init__.py b/can/interfaces/etas/__init__.py index 2bfcbf427..9d4d0bd2a 100644 --- a/can/interfaces/etas/__init__.py +++ b/can/interfaces/etas/__init__.py @@ -1,5 +1,5 @@ import time -from typing import Dict, List, Optional, Tuple +from typing import Optional import can from can.exceptions import CanInitializationError @@ -16,7 +16,7 @@ def __init__( bitrate: int = 1000000, fd: bool = True, data_bitrate: int = 2000000, - **kwargs: Dict[str, any], + **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 @@ -122,7 +122,7 @@ def __init__( def _recv_internal( self, timeout: Optional[float] - ) -> Tuple[Optional[can.Message], bool]: + ) -> tuple[Optional[can.Message], bool]: ociMsgs = (ctypes.POINTER(OCI_CANMessageEx) * 1)() ociMsg = OCI_CANMessageEx() ociMsgs[0] = ctypes.pointer(ociMsg) @@ -295,12 +295,12 @@ def state(self, new_state: can.BusState) -> None: raise NotImplementedError("Setting state is not implemented.") @staticmethod - def _detect_available_configs() -> List[can.typechecking.AutoDetectedConfig]: + 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: List[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 6268350ee..4ab541f43 100644 --- a/can/interfaces/gs_usb.py +++ b/can/interfaces/gs_usb.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, Tuple +from typing import Optional import usb from gs_usb.constants import CAN_EFF_FLAG, CAN_ERR_FLAG, CAN_MAX_DLC, CAN_RTR_FLAG @@ -119,7 +119,7 @@ def send(self, msg: can.Message, timeout: Optional[float] = None): def _recv_internal( self, timeout: Optional[float] - ) -> Tuple[Optional[can.Message], bool]: + ) -> tuple[Optional[can.Message], bool]: """ Read a message from the bus and tell whether it was filtered. This methods may be called by :meth:`~can.BusABC.recv` diff --git a/can/interfaces/ics_neovi/__init__.py b/can/interfaces/ics_neovi/__init__.py index 0252c7345..74cb43af4 100644 --- a/can/interfaces/ics_neovi/__init__.py +++ b/can/interfaces/ics_neovi/__init__.py @@ -1,5 +1,4 @@ -""" -""" +""" """ __all__ = [ "ICSApiError", diff --git a/can/interfaces/iscan.py b/can/interfaces/iscan.py index be0b0dae8..79b4f754d 100644 --- a/can/interfaces/iscan.py +++ b/can/interfaces/iscan.py @@ -5,7 +5,7 @@ import ctypes import logging import time -from typing import Optional, Tuple, Union +from typing import Optional, Union from can import ( BusABC, @@ -117,7 +117,7 @@ def __init__( def _recv_internal( self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + ) -> tuple[Optional[Message], bool]: raw_msg = MessageExStruct() end_time = time.time() + timeout if timeout is not None else None while True: diff --git a/can/interfaces/ixxat/canlib.py b/can/interfaces/ixxat/canlib.py index a1693aeed..e6ad25d57 100644 --- a/can/interfaces/ixxat/canlib.py +++ b/can/interfaces/ixxat/canlib.py @@ -1,4 +1,5 @@ -from typing import Callable, List, Optional, Sequence, Union +from collections.abc import Sequence +from typing import Callable, Optional, Union import can.interfaces.ixxat.canlib_vcinpl as vcinpl import can.interfaces.ixxat.canlib_vcinpl2 as vcinpl2 @@ -174,5 +175,5 @@ def state(self) -> BusState: return self.bus.state @staticmethod - def _detect_available_configs() -> List[AutoDetectedConfig]: + def _detect_available_configs() -> list[AutoDetectedConfig]: return vcinpl._detect_available_configs() diff --git a/can/interfaces/ixxat/canlib_vcinpl.py b/can/interfaces/ixxat/canlib_vcinpl.py index 0579d0942..098b022bb 100644 --- a/can/interfaces/ixxat/canlib_vcinpl.py +++ b/can/interfaces/ixxat/canlib_vcinpl.py @@ -13,8 +13,10 @@ import functools import logging import sys +import time import warnings -from typing import Callable, List, Optional, Sequence, Tuple, Union +from collections.abc import Sequence +from typing import Callable, Optional, Union from can import ( BusABC, @@ -35,11 +37,11 @@ from .exceptions import * __all__ = [ - "VCITimeout", - "VCIError", + "IXXATBus", "VCIBusOffError", "VCIDeviceNotFoundError", - "IXXATBus", + "VCIError", + "VCITimeout", "vciFormatError", ] @@ -64,7 +66,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: @@ -619,7 +621,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 @@ -692,6 +702,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") @@ -708,7 +721,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", @@ -740,11 +754,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, @@ -890,7 +905,7 @@ def __init__( 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 @@ -961,7 +976,7 @@ def get_ixxat_hwids(): return hwids -def _detect_available_configs() -> List[AutoDetectedConfig]: +def _detect_available_configs() -> list[AutoDetectedConfig]: config_list = [] # list in wich to store the resulting bus kwargs # used to detect HWID diff --git a/can/interfaces/ixxat/canlib_vcinpl2.py b/can/interfaces/ixxat/canlib_vcinpl2.py index 5872f76b9..b7698277f 100644 --- a/can/interfaces/ixxat/canlib_vcinpl2.py +++ b/can/interfaces/ixxat/canlib_vcinpl2.py @@ -15,7 +15,8 @@ import sys import time import warnings -from typing import Callable, Optional, Sequence, Tuple, Union +from collections.abc import Sequence +from typing import Callable, Optional, Union from can import ( BusABC, @@ -34,11 +35,11 @@ from .exceptions import * __all__ = [ - "VCITimeout", - "VCIError", + "IXXATBus", "VCIBusOffError", "VCIDeviceNotFoundError", - "IXXATBus", + "VCIError", + "VCITimeout", "vciFormatError", ] @@ -62,7 +63,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: @@ -726,7 +727,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 @@ -831,7 +840,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 ): @@ -853,7 +864,8 @@ 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") @@ -867,11 +879,12 @@ def _recv_internal(self, timeout): return None, True data_len = 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 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, @@ -1010,7 +1023,7 @@ def __init__( 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 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 611955db7..680ed1ac6 100644 --- a/can/interfaces/ixxat/structures.py +++ b/can/interfaces/ixxat/structures.py @@ -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 8e7c3feb9..7cfb13d8d 100644 --- a/can/interfaces/kvaser/__init__.py +++ b/can/interfaces/kvaser/__init__.py @@ -1,5 +1,4 @@ -""" -""" +""" """ __all__ = [ "CANLIBInitializationError", diff --git a/can/interfaces/kvaser/canlib.py b/can/interfaces/kvaser/canlib.py index 51a77a567..9731a4415 100644 --- a/can/interfaces/kvaser/canlib.py +++ b/can/interfaces/kvaser/canlib.py @@ -554,6 +554,15 @@ def __init__( 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 @@ -671,6 +680,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 @@ -682,6 +692,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, diff --git a/can/interfaces/kvaser/constants.py b/can/interfaces/kvaser/constants.py index 3d01faa84..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 @@ -195,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/neousys/__init__.py b/can/interfaces/neousys/__init__.py index 44bd3127f..f3e0cb039 100644 --- a/can/interfaces/neousys/__init__.py +++ b/can/interfaces/neousys/__init__.py @@ -1,4 +1,4 @@ -""" Neousys CAN bus driver """ +"""Neousys CAN bus driver""" __all__ = [ "NeousysBus", diff --git a/can/interfaces/neousys/neousys.py b/can/interfaces/neousys/neousys.py index cb9d0174d..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 diff --git a/can/interfaces/nican.py b/can/interfaces/nican.py index 8a2efade7..1abf0b35f 100644 --- a/can/interfaces/nican.py +++ b/can/interfaces/nican.py @@ -16,7 +16,7 @@ import ctypes import logging import sys -from typing import Optional, Tuple, Type +from typing import Optional import can.typechecking from can import ( @@ -112,7 +112,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)) @@ -281,7 +281,7 @@ def __init__( def _recv_internal( self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + ) -> tuple[Optional[Message], bool]: """ Read a message from a NI-CAN bus. diff --git a/can/interfaces/nixnet.py b/can/interfaces/nixnet.py index 2b3cbd69a..c723d1f52 100644 --- a/can/interfaces/nixnet.py +++ b/can/interfaces/nixnet.py @@ -14,7 +14,7 @@ import warnings from queue import SimpleQueue from types import ModuleType -from typing import Any, List, Optional, Tuple, Union +from typing import Any, Optional, Union import can.typechecking from can import BitTiming, BitTimingFd, BusABC, CanProtocol, Message @@ -203,7 +203,7 @@ def fd(self) -> bool: def _recv_internal( self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + ) -> tuple[Optional[Message], bool]: end_time = time.perf_counter() + timeout if timeout is not None else None while True: @@ -327,7 +327,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 73e351665..a3eafcb4d 100644 --- a/can/interfaces/pcan/__init__.py +++ b/can/interfaces/pcan/__init__.py @@ -1,5 +1,4 @@ -""" -""" +""" """ __all__ = [ "PcanBus", diff --git a/can/interfaces/pcan/pcan.py b/can/interfaces/pcan/pcan.py index d0372a83c..ef3b23e3b 100644 --- a/can/interfaces/pcan/pcan.py +++ b/can/interfaces/pcan/pcan.py @@ -6,7 +6,7 @@ import platform import time import warnings -from typing import Any, List, Optional, Tuple, Union +from typing import Any, Optional, Union from packaging import version @@ -502,7 +502,7 @@ def set_device_number(self, device_number): def _recv_internal( self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + ) -> tuple[Optional[Message], bool]: end_time = time.time() + timeout if timeout is not None else None while True: @@ -726,7 +726,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( diff --git a/can/interfaces/seeedstudio/__init__.py b/can/interfaces/seeedstudio/__init__.py index 0b3c7b1c9..9466bde43 100644 --- a/can/interfaces/seeedstudio/__init__.py +++ b/can/interfaces/seeedstudio/__init__.py @@ -1,6 +1,3 @@ -""" -""" - __all__ = [ "SeeedBus", "seeedstudio", diff --git a/can/interfaces/serial/__init__.py b/can/interfaces/serial/__init__.py index beb6457bb..6327530d7 100644 --- a/can/interfaces/serial/__init__.py +++ b/can/interfaces/serial/__init__.py @@ -1,5 +1,4 @@ -""" -""" +""" """ __all__ = [ "SerialBus", diff --git a/can/interfaces/serial/serial_can.py b/can/interfaces/serial/serial_can.py index 476cbd624..12ce5aff1 100644 --- a/can/interfaces/serial/serial_can.py +++ b/can/interfaces/serial/serial_can.py @@ -10,7 +10,7 @@ import io import logging import struct -from typing import Any, List, Optional, Tuple +from typing import Any, Optional from can import ( BusABC, @@ -38,10 +38,17 @@ from serial.tools.list_ports import comports as list_comports except ImportError: # If unavailable on some platform, just return nothing - def list_comports() -> List[Any]: + def list_comports() -> 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): """ Enable basic can communication over a serial device. @@ -116,9 +123,6 @@ def send(self, msg: Message, timeout: Optional[float] = None) -> None: :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. @@ -134,19 +138,25 @@ def send(self, msg: Message, timeout: Optional[float] = None) -> None: raise ValueError(f"Timestamp is out of range: {msg.timestamp}") from None # Pack arbitration ID - try: - arbitration_id = struct.pack(" None: def _recv_internal( self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + ) -> tuple[Optional[Message], bool]: """ Read a message from the serial device. @@ -171,11 +181,6 @@ 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() @@ -188,10 +193,14 @@ def _recv_internal( s = self._ser.read(4) arbitration_id = 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) @@ -204,6 +213,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 @@ -229,7 +241,7 @@ def fileno(self) -> int: raise CanOperationError("Cannot fetch fileno") from exception @staticmethod - def _detect_available_configs() -> List[AutoDetectedConfig]: + def _detect_available_configs() -> list[AutoDetectedConfig]: return [ {"interface": "serial", "channel": port.device} for port in list_comports() ] diff --git a/can/interfaces/slcan.py b/can/interfaces/slcan.py index f023e084c..c51b298cc 100644 --- a/can/interfaces/slcan.py +++ b/can/interfaces/slcan.py @@ -5,16 +5,18 @@ import io import logging import time -from typing import Any, Optional, Tuple +import warnings +from queue import SimpleQueue +from typing import Any, Optional, Union, cast -from can import BusABC, CanProtocol, Message, typechecking - -from ..exceptions import ( +from can import BitTiming, BitTimingFd, BusABC, CanProtocol, Message, typechecking +from can.exceptions import ( CanInitializationError, CanInterfaceNotImplementedError, CanOperationError, error_check, ) +from can.util import check_or_adjust_timing_clock, deprecated_args_alias logger = logging.getLogger(__name__) @@ -54,12 +56,17 @@ 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, + tty_baudrate: int = 115200, bitrate: Optional[int] = None, - btr: Optional[str] = None, + timing: Optional[Union[BitTiming, BitTimingFd]] = None, sleep_after_open: float = _SLEEP_AFTER_SERIAL_OPEN, rtscts: bool = False, listen_only: bool = False, @@ -70,12 +77,16 @@ 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 are not supported. :param poll_interval: Poll interval in seconds when reading messages :param sleep_after_open: @@ -97,41 +108,54 @@ def __init__( if serial is None: raise CanInterfaceNotImplementedError("The serial module is not installed") + btr: Optional[str] = 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): + raise NotImplementedError( + f"CAN FD is not supported by {self.__class__.__name__}." + ) + 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: """ @@ -153,7 +177,8 @@ def set_bitrate(self, bitrate: int) -> None: 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) @@ -173,7 +198,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: @@ -205,13 +230,16 @@ def close(self) -> None: def _recv_internal( self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + ) -> tuple[Optional[Message], bool]: canId = None remote = False extended = False data = None - string = self._read(timeout) + if self._queue.qsize(): + string: Optional[str] = self._queue.get_nowait() + else: + string = self._read(timeout) if not string: pass @@ -277,7 +305,7 @@ 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" @@ -287,7 +315,7 @@ def fileno(self) -> int: def get_version( self, timeout: Optional[float] - ) -> Tuple[Optional[int], Optional[int]]: + ) -> tuple[Optional[int], Optional[int]]: """Get HW and SW version of the slcan interface. :param timeout: @@ -298,19 +326,21 @@ 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]: @@ -322,15 +352,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/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 40da0d094..30b75108a 100644 --- a/can/interfaces/socketcan/socketcan.py +++ b/can/interfaces/socketcan/socketcan.py @@ -15,7 +15,8 @@ import threading import time import warnings -from typing import Callable, Dict, List, Optional, Sequence, Tuple, Type, Union +from collections.abc import Sequence +from typing import Callable, Optional, Union import can from can import BusABC, CanProtocol, Message @@ -50,13 +51,13 @@ # Setup BCM struct def bcm_header_factory( - fields: List[Tuple[str, Union[Type[ctypes.c_uint32], Type[ctypes.c_long]]]], + fields: list[tuple[str, Union[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, Union[type[ctypes.c_uint8], type[ctypes.c_uint32], type[ctypes.c_long]] ] ] = [] pad_index = 0 @@ -139,23 +140,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 @@ -175,14 +221,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( @@ -231,7 +293,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)) @@ -259,12 +321,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: @@ -659,7 +740,7 @@ 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() @@ -739,7 +820,7 @@ def shutdown(self) -> None: def _recv_internal( self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + ) -> tuple[Optional[Message], 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) @@ -912,7 +993,7 @@ 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() diff --git a/can/interfaces/socketcan/utils.py b/can/interfaces/socketcan/utils.py index 679c9cefc..80dcb203f 100644 --- a/can/interfaces/socketcan/utils.py +++ b/can/interfaces/socketcan/utils.py @@ -9,7 +9,7 @@ import struct import subprocess import sys -from typing import List, Optional, cast +from typing import Optional, cast from can import typechecking from can.interfaces.socketcan.constants import CAN_EFF_FLAG @@ -28,7 +28,7 @@ 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) + 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"]: @@ -39,7 +39,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 diff --git a/can/interfaces/socketcand/socketcand.py b/can/interfaces/socketcand/socketcand.py index 7a2cc6fd0..d401102f7 100644 --- a/can/interfaces/socketcand/socketcand.py +++ b/can/interfaces/socketcand/socketcand.py @@ -17,7 +17,6 @@ import urllib.parse as urlparselib import xml.etree.ElementTree as ET from collections import deque -from typing import List import can @@ -27,7 +26,7 @@ DEFAULT_SOCKETCAND_DISCOVERY_PORT = 42000 -def detect_beacon(timeout_ms: int = 3100) -> List[can.typechecking.AutoDetectedConfig]: +def detect_beacon(timeout_ms: int = 3100) -> list[can.typechecking.AutoDetectedConfig]: """ Detects socketcand servers @@ -125,10 +124,11 @@ def detect_beacon(timeout_ms: int = 3100) -> List[can.typechecking.AutoDetectedC 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) @@ -147,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 @@ -340,7 +365,7 @@ def shutdown(self): self.__socket.close() @staticmethod - def _detect_available_configs() -> List[can.typechecking.AutoDetectedConfig]: + def _detect_available_configs() -> list[can.typechecking.AutoDetectedConfig]: try: return detect_beacon() except Exception as e: diff --git a/can/interfaces/systec/exceptions.py b/can/interfaces/systec/exceptions.py index dcd94bdbf..8768b412a 100644 --- a/can/interfaces/systec/exceptions.py +++ b/can/interfaces/systec/exceptions.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from typing import Dict from can import CanError @@ -22,7 +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): @@ -51,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 @@ -77,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 @@ -102,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/udp_multicast/bus.py b/can/interfaces/udp_multicast/bus.py index 8ca2d516b..45882ec07 100644 --- a/can/interfaces/udp_multicast/bus.py +++ b/can/interfaces/udp_multicast/bus.py @@ -1,35 +1,38 @@ import errno import logging +import platform import select import socket import struct +import time import warnings -from typing import List, Optional, Tuple, Union +from typing import Optional, Union import can from can import BusABC, CanProtocol from can.typechecking import AutoDetectedConfig -from .utils import check_msgpack_installed, pack_message, unpack_message +from .utils import is_msgpack_installed, pack_message, unpack_message -try: +is_linux = platform.system() == "Linux" +if is_linux: from fcntl import ioctl -except ModuleNotFoundError: # Missing on Windows - pass - 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 +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] # 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. @@ -97,7 +100,7 @@ def __init__( fd: bool = True, **kwargs, ) -> None: - check_msgpack_installed() + is_msgpack_installed() if receive_own_messages: raise can.CanInterfaceNotImplementedError( @@ -165,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 [ { @@ -268,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 @@ -330,7 +341,7 @@ def send(self, data: bytes, timeout: Optional[float] = None) -> None: def recv( self, timeout: Optional[float] = None - ) -> Optional[Tuple[bytes, IP_ADDRESS_INFO, float]]: + ) -> Optional[tuple[bytes, IP_ADDRESS_INFO, float]]: """ Receive up to **max_buffer** bytes. @@ -353,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( @@ -385,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 35a0df185..c6b2630a5 100644 --- a/can/interfaces/udp_multicast/utils.py +++ b/can/interfaces/udp_multicast/utils.py @@ -2,7 +2,7 @@ Defines common functions. """ -from typing import Any, Dict, Optional +from typing import Any, Optional from can import CanInterfaceNotImplementedError, Message from can.typechecking import ReadableBytesLike @@ -13,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: @@ -25,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, @@ -44,7 +56,7 @@ def pack_message(message: Message) -> bytes: def unpack_message( data: ReadableBytesLike, - replace: Optional[Dict[str, Any]] = None, + replace: Optional[dict[str, Any]] = None, check: bool = False, ) -> Message: """Unpack a can.Message from a msgpack byte blob. @@ -58,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 a2b587842..f818130ee 100644 --- a/can/interfaces/usb2can/__init__.py +++ b/can/interfaces/usb2can/__init__.py @@ -1,12 +1,11 @@ -""" -""" +""" """ __all__ = [ "Usb2CanAbstractionLayer", "Usb2canBus", "serial_selector", - "usb2canabstractionlayer", "usb2canInterface", + "usb2canabstractionlayer", ] from .usb2canabstractionlayer import Usb2CanAbstractionLayer diff --git a/can/interfaces/usb2can/serial_selector.py b/can/interfaces/usb2can/serial_selector.py index 92a3a07a2..18ad3f873 100644 --- a/can/interfaces/usb2can/serial_selector.py +++ b/can/interfaces/usb2can/serial_selector.py @@ -1,12 +1,11 @@ -""" -""" +""" """ import logging -from typing import List log = logging.getLogger("can.usb2can") try: + import pythoncom import win32com.client except ImportError: log.warning( @@ -44,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. @@ -52,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 c89e394df..adc16e8b3 100644 --- a/can/interfaces/usb2can/usb2canInterface.py +++ b/can/interfaces/usb2can/usb2canInterface.py @@ -4,9 +4,18 @@ import logging from ctypes import byref -from typing import Optional - -from can import BusABC, CanInitializationError, CanOperationError, CanProtocol, Message +from typing import Optional, Union + +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 ( @@ -78,6 +87,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. @@ -97,8 +113,8 @@ def __init__( channel: Optional[str] = None, dll: str = "usb2can.dll", flags: int = 0x00000008, - *_, bitrate: int = 500000, + timing: Optional[Union[BitTiming, BitTimingFd]] = None, serial: Optional[str] = None, **kwargs, ): @@ -114,13 +130,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}" - self._can_protocol = CanProtocol.CAN_20 - 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) diff --git a/can/interfaces/vector/__init__.py b/can/interfaces/vector/__init__.py index 3a5b13a1f..e78783f1f 100644 --- a/can/interfaces/vector/__init__.py +++ b/can/interfaces/vector/__init__.py @@ -1,5 +1,4 @@ -""" -""" +""" """ __all__ = [ "VectorBus", diff --git a/can/interfaces/vector/canlib.py b/can/interfaces/vector/canlib.py index d307d076f..986f52002 100644 --- a/can/interfaces/vector/canlib.py +++ b/can/interfaces/vector/canlib.py @@ -10,17 +10,13 @@ import os import time import warnings +from collections.abc import Iterator, Sequence from types import ModuleType from typing import ( Any, Callable, - Dict, - Iterator, - List, NamedTuple, Optional, - Sequence, - Tuple, Union, cast, ) @@ -204,8 +200,8 @@ def __init__( is_fd = isinstance(timing, BitTimingFd) if timing else fd self.mask = 0 - 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 @@ -220,7 +216,7 @@ def __init__( # 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) + channel_index = cast("int", _channel_index) else: channel_index = self._find_global_channel_idx( channel=channel, @@ -383,7 +379,7 @@ def _find_global_channel_idx( channel: int, serial: Optional[int], app_name: Optional[str], - channel_configs: List["VectorChannelConfig"], + channel_configs: list["VectorChannelConfig"], ) -> int: if serial is not None: serial_found = False @@ -410,7 +406,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. @@ -439,7 +435,7 @@ def _has_init_access(self, channel: int) -> bool: return bool(self.permission_mask & self.channel_masks[channel]) def _read_bus_params( - self, channel_index: int, vcc_list: List["VectorChannelConfig"] + self, channel_index: int, vcc_list: list["VectorChannelConfig"] ) -> "VectorBusParams": for vcc in vcc_list: if vcc.channel_index == channel_index: @@ -712,7 +708,7 @@ def _apply_filters(self, filters: Optional[CanFilters]) -> None: def _recv_internal( self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + ) -> tuple[Optional[Message], bool]: end_time = time.time() + timeout if timeout is not None else None while True: @@ -986,7 +982,7 @@ def reset(self) -> None: ) @staticmethod - def _detect_available_configs() -> List[AutoDetectedConfig]: + def _detect_available_configs() -> list[AutoDetectedConfig]: configs = [] channel_configs = get_channel_configs() LOG.info("Found %d channels", len(channel_configs)) @@ -1037,7 +1033,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[Union[int, xldefine.XL_HardwareType], int, int]: """Retrieve information for an application in Vector Hardware Configuration. :param app_name: @@ -1243,14 +1239,14 @@ def _read_bus_params_from_c_struct( ) -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( diff --git a/can/interfaces/virtual.py b/can/interfaces/virtual.py index 62ad0cfe3..aa858913e 100644 --- a/can/interfaces/virtual.py +++ b/can/interfaces/virtual.py @@ -12,7 +12,7 @@ from copy import deepcopy from random import randint from threading import RLock -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Optional from can import CanOperationError from can.bus import BusABC, CanProtocol @@ -25,7 +25,7 @@ # 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]]] = {} + channels: dict[Optional[Any], list[queue.Queue[Message]]] = {} else: channels = {} channels_lock = RLock() @@ -125,7 +125,7 @@ def _check_if_open(self) -> None: def _recv_internal( self, timeout: Optional[float] - ) -> Tuple[Optional[Message], bool]: + ) -> tuple[Optional[Message], bool]: self._check_if_open() try: msg = self.queue.get(block=True, timeout=timeout) @@ -168,7 +168,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. diff --git a/can/io/__init__.py b/can/io/__init__.py index 5601f2591..69894c3d0 100644 --- a/can/io/__init__.py +++ b/can/io/__init__.py @@ -4,22 +4,22 @@ """ __all__ = [ + "MESSAGE_READERS", + "MESSAGE_WRITERS", "ASCReader", "ASCWriter", - "BaseRotatingLogger", "BLFReader", "BLFWriter", - "CanutilsLogReader", - "CanutilsLogWriter", + "BaseRotatingLogger", "CSVReader", "CSVWriter", - "Logger", + "CanutilsLogReader", + "CanutilsLogWriter", "LogReader", - "MESSAGE_READERS", - "MESSAGE_WRITERS", - "MessageSync", + "Logger", "MF4Reader", "MF4Writer", + "MessageSync", "Printer", "SizedRotatingLogger", "SqliteReader", diff --git a/can/io/asc.py b/can/io/asc.py index deb7d429e..0bea823fd 100644 --- a/can/io/asc.py +++ b/can/io/asc.py @@ -8,8 +8,9 @@ import logging import re +from collections.abc import Generator from datetime import datetime -from typing import Any, Dict, Final, Generator, Optional, TextIO, Union +from typing import Any, Final, Optional, TextIO, Union from ..message import Message from ..typechecking import StringPathLike @@ -153,7 +154,7 @@ def _datetime_to_timestamp(datetime_string: str) -> float: raise ValueError(f"Incompatible datetime string {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) @@ -169,7 +170,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() @@ -178,7 +179,7 @@ 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": @@ -213,7 +214,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 @@ -285,7 +286,7 @@ def __iter__(self) -> Generator[Message, None, None]: # J1939 message or some other unsupported event continue - msg_kwargs: Dict[str, Union[float, bool, int]] = {} + msg_kwargs: dict[str, Union[float, bool, int]] = {} try: _timestamp, channel, rest_of_message = line.split(None, 2) timestamp = float(_timestamp) + self.start_time diff --git a/can/io/blf.py b/can/io/blf.py index 81146233d..6a1231fcc 100644 --- a/can/io/blf.py +++ b/can/io/blf.py @@ -17,14 +17,16 @@ import struct import time import zlib -from typing import Any, BinaryIO, Generator, List, Optional, Tuple, Union, cast +from collections.abc import Generator +from decimal import Decimal +from typing import Any, BinaryIO, Optional, Union, cast from ..message import Message from ..typechecking import StringPathLike from ..util import channel2int, dlc2len, len2dlc from .generic import BinaryIOMessageReader, FileIOMessageWriter -TSystemTime = Tuple[int, int, int, int, int, int, int, int] +TSystemTime = tuple[int, int, int, int, int, int, int, int] class BLFParseError(Exception): @@ -98,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: 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, @@ -126,6 +131,7 @@ def systemtime_to_timestamp(systemtime: TSystemTime) -> float: systemtime[5], systemtime[6], systemtime[7] * 1000, + tzinfo=datetime.timezone.utc, ) return t.timestamp() except ValueError: @@ -160,8 +166,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"" @@ -239,7 +249,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 +273,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 +344,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,7 +368,7 @@ 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, ) @@ -403,7 +423,7 @@ def __init__( 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): @@ -417,10 +437,10 @@ def __init__( self.uncompressed_size = header[11] self.object_count = header[12] self.start_timestamp: Optional[float] = systemtime_to_timestamp( - cast(TSystemTime, header[14:22]) + cast("TSystemTime", header[14:22]) ) self.stop_timestamp: Optional[float] = systemtime_to_timestamp( - cast(TSystemTime, header[22:30]) + cast("TSystemTime", header[22:30]) ) # Jump to the end of the file self.file.seek(0, 2) @@ -519,7 +539,10 @@ def _add_object(self, obj_type, data, timestamp=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 diff --git a/can/io/canutils.py b/can/io/canutils.py index d7ae99daf..e83c21926 100644 --- a/can/io/canutils.py +++ b/can/io/canutils.py @@ -5,7 +5,8 @@ """ import logging -from typing import Any, Generator, TextIO, Union +from collections.abc import Generator +from typing import Any, TextIO, Union from can.message import Message @@ -165,7 +166,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 2abaeb70e..dcc7996f7 100644 --- a/can/io/csv.py +++ b/can/io/csv.py @@ -10,7 +10,8 @@ """ from base64 import b64decode, b64encode -from typing import Any, Generator, TextIO, Union +from collections.abc import Generator +from typing import Any, TextIO, Union from can.message import Message diff --git a/can/io/generic.py b/can/io/generic.py index 55468ff16..82523c3cd 100644 --- a/can/io/generic.py +++ b/can/io/generic.py @@ -3,16 +3,15 @@ import gzip import locale from abc import ABCMeta +from collections.abc import Iterable +from contextlib import AbstractContextManager from types import TracebackType from typing import ( Any, BinaryIO, - ContextManager, - Iterable, Literal, Optional, TextIO, - Type, Union, cast, ) @@ -24,7 +23,7 @@ from ..message import Message -class BaseIOHandler(ContextManager, metaclass=ABCMeta): +class BaseIOHandler(AbstractContextManager): """A generic file handler that can be used for reading and writing. Can be used as a context manager. @@ -50,7 +49,7 @@ def __init__( """ if file is None or (hasattr(file, "read") and hasattr(file, "write")): # file is None or some file-like object - self.file = cast(Optional[typechecking.FileLike], file) + self.file = cast("Optional[typechecking.FileLike]", file) else: encoding: Optional[str] = ( None @@ -60,8 +59,7 @@ def __init__( # pylint: disable=consider-using-with # file is some path-like object self.file = cast( - typechecking.FileLike, - open(cast(typechecking.StringPathLike, file), mode, encoding=encoding), + "typechecking.FileLike", open(file, mode, encoding=encoding) ) # for multiple inheritance @@ -72,7 +70,7 @@ def __enter__(self) -> Self: def __exit__( self, - exc_type: Optional[Type[BaseException]], + exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType], ) -> Literal[False]: diff --git a/can/io/logger.py b/can/io/logger.py index f54223741..f9f029759 100644 --- a/can/io/logger.py +++ b/can/io/logger.py @@ -12,13 +12,9 @@ Any, Callable, ClassVar, - Dict, Final, Literal, Optional, - Set, - Tuple, - Type, cast, ) @@ -43,7 +39,7 @@ #: A map of file suffixes to their corresponding #: :class:`can.io.generic.MessageWriter` class -MESSAGE_WRITERS: Final[Dict[str, Type[MessageWriter]]] = { +MESSAGE_WRITERS: Final[dict[str, type[MessageWriter]]] = { ".asc": ASCWriter, ".blf": BLFWriter, ".csv": CSVWriter, @@ -66,7 +62,7 @@ def _update_writer_plugins() -> None: MESSAGE_WRITERS[entry_point.key] = writer_class -def _get_logger_for_suffix(suffix: str) -> Type[MessageWriter]: +def _get_logger_for_suffix(suffix: str) -> type[MessageWriter]: try: return MESSAGE_WRITERS[suffix] except KeyError: @@ -77,7 +73,7 @@ def _get_logger_for_suffix(suffix: str) -> Type[MessageWriter]: def _compress( filename: StringPathLike, **kwargs: Any -) -> Tuple[Type[MessageWriter], FileLike]: +) -> tuple[type[MessageWriter], FileLike]: """ Return the suffix and io object of the decompressed file. File will automatically recompress upon close. @@ -171,7 +167,7 @@ class BaseRotatingLogger(MessageWriter, ABC): Subclasses must set the `_writer` attribute upon initialization. """ - _supported_formats: ClassVar[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 @@ -268,7 +264,7 @@ def _get_new_writer(self, filename: StringPathLike) -> FileIOMessageWriter: if isinstance(logger, FileIOMessageWriter): return logger elif isinstance(logger, Printer) and logger.file is not None: - return cast(FileIOMessageWriter, logger) + return cast("FileIOMessageWriter", logger) raise ValueError( f'The log format of "{pathlib.Path(filename).name}" ' @@ -290,7 +286,7 @@ def __enter__(self) -> Self: def __exit__( self, - exc_type: Optional[Type[BaseException]], + exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType], ) -> Literal[False]: @@ -347,7 +343,7 @@ class SizedRotatingLogger(BaseRotatingLogger): :meth:`~can.Listener.stop` is called. """ - _supported_formats: ClassVar[Set[str]] = {".asc", ".blf", ".csv", ".log", ".txt"} + _supported_formats: ClassVar[set[str]] = {".asc", ".blf", ".csv", ".log", ".txt"} def __init__( self, diff --git a/can/io/mf4.py b/can/io/mf4.py index 042bf8765..557d882e1 100644 --- a/can/io/mf4.py +++ b/can/io/mf4.py @@ -5,16 +5,19 @@ 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, Generator, Optional, Union, cast +from typing import Any, BinaryIO, Optional, Union, cast from ..message import Message from ..typechecking import StringPathLike -from ..util import channel2int, dlc2len, len2dlc +from ..util import channel2int, len2dlc from .generic import BinaryIOMessageReader, BinaryIOMessageWriter logger = logging.getLogger("can.io.mf4") @@ -22,10 +25,10 @@ try: import asammdf import numpy as np - from asammdf import Signal + from asammdf import Signal, Source from asammdf.blocks.mdf_v4 import MDF4 - from asammdf.blocks.v4_blocks import SourceInformation - from asammdf.blocks.v4_constants import BUS_TYPE_CAN, SOURCE_BUS + 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( @@ -70,6 +73,8 @@ ) except ImportError: asammdf = None + MDF4 = None + Signal = None CAN_MSG_EXT = 0x80000000 @@ -120,7 +125,7 @@ def __init__( super().__init__(file, mode="w+b") now = datetime.now() - self._mdf = cast(MDF4, MDF(version="4.10")) + self._mdf = cast("MDF4", MDF(version="4.10")) self._mdf.header.start_time = now self.last_timestamp = self._start_time = now.timestamp() @@ -180,7 +185,9 @@ def __init__( def file_size(self) -> 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 + return cast( + "int", self._mdf._tempfile.tell() # pylint: disable=protected-access + ) def stop(self) -> None: self._mdf.save(self.file, compression=self._compression_level) @@ -266,13 +273,179 @@ def on_message_received(self, msg: Message) -> None: self._rtr_buffer = np.zeros(1, dtype=RTR_DTYPE) +class FrameIterator(metaclass=abc.ABCMeta): + """ + 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 that were recorded with python-can. + 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: + 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: + 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: + 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: Union[StringPathLike, BinaryIO], @@ -293,193 +466,65 @@ def __init__( self._mdf: MDF4 if isinstance(file, BufferedIOBase): - self._mdf = MDF(BytesIO(file.read())) + self._mdf = cast("MDF4", MDF(BytesIO(file.read()))) else: - self._mdf = MDF(file) - - self.start_timestamp = self._mdf.header.start_time.timestamp() - - masters = [self._mdf.get_master(i) for i in range(3)] - - masters = [ - np.core.records.fromarrays((master, np.ones(len(master)) * i)) - for i, master in enumerate(masters) - ] - - self.masters = np.sort(np.concatenate(masters)) - - def __iter__(self) -> Generator[Message, None, None]: - standard_counter = 0 - error_counter = 0 - rtr_counter = 0 - - for timestamp, group_index in self.masters: - # standard frames - if group_index == 0: - sample = self._mdf.get( - "CAN_DataFrame", - group=group_index, - raw=True, - record_offset=standard_counter, - record_count=1, - ) - - try: - channel = int(sample["CAN_DataFrame.BusChannel"][0]) - except ValueError: - channel = None - - if sample["CAN_DataFrame.EDL"] == 0: - is_extended_id = bool(sample["CAN_DataFrame.IDE"][0]) - arbitration_id = int(sample["CAN_DataFrame.ID"][0]) - is_rx = int(sample["CAN_DataFrame.Dir"][0]) == 0 - size = int(sample["CAN_DataFrame.DataLength"][0]) - dlc = int(sample["CAN_DataFrame.DLC"][0]) - data = sample["CAN_DataFrame.DataBytes"][0, :size].tobytes() - - msg = Message( - timestamp=timestamp + self.start_timestamp, - is_error_frame=False, - is_remote_frame=False, - is_fd=False, - is_extended_id=is_extended_id, - channel=channel, - is_rx=is_rx, - arbitration_id=arbitration_id, - data=data, - dlc=dlc, - ) - - else: - is_extended_id = bool(sample["CAN_DataFrame.IDE"][0]) - arbitration_id = int(sample["CAN_DataFrame.ID"][0]) - is_rx = int(sample["CAN_DataFrame.Dir"][0]) == 0 - size = int(sample["CAN_DataFrame.DataLength"][0]) - dlc = dlc2len(sample["CAN_DataFrame.DLC"][0]) - data = sample["CAN_DataFrame.DataBytes"][0, :size].tobytes() - error_state_indicator = bool(sample["CAN_DataFrame.ESI"][0]) - bitrate_switch = bool(sample["CAN_DataFrame.BRS"][0]) - - msg = Message( - timestamp=timestamp + self.start_timestamp, - is_error_frame=False, - is_remote_frame=False, - is_fd=True, - is_extended_id=is_extended_id, - channel=channel, - arbitration_id=arbitration_id, - is_rx=is_rx, - data=data, - dlc=dlc, - bitrate_switch=bitrate_switch, - error_state_indicator=error_state_indicator, + 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: Optional[Source] = 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 + ) ) - - yield msg - standard_counter += 1 - - # error frames - elif group_index == 1: - sample = self._mdf.get( - "CAN_ErrorFrame", - group=group_index, - raw=True, - record_offset=error_counter, - record_count=1, - ) - - try: - channel = int(sample["CAN_ErrorFrame.BusChannel"][0]) - except ValueError: - channel = None - - if sample["CAN_ErrorFrame.EDL"] == 0: - is_extended_id = bool(sample["CAN_ErrorFrame.IDE"][0]) - arbitration_id = int(sample["CAN_ErrorFrame.ID"][0]) - is_rx = int(sample["CAN_ErrorFrame.Dir"][0]) == 0 - size = int(sample["CAN_ErrorFrame.DataLength"][0]) - dlc = int(sample["CAN_ErrorFrame.DLC"][0]) - data = sample["CAN_ErrorFrame.DataBytes"][0, :size].tobytes() - - msg = Message( - timestamp=timestamp + self.start_timestamp, - is_error_frame=True, - is_remote_frame=False, - is_fd=False, - is_extended_id=is_extended_id, - channel=channel, - arbitration_id=arbitration_id, - is_rx=is_rx, - data=data, - dlc=dlc, + elif "CAN_ErrorFrame" in channel_names: + iterators.append( + self._CANErrorFrameIterator( + self._mdf, group_index, self._start_timestamp + ) ) - - else: - is_extended_id = bool(sample["CAN_ErrorFrame.IDE"][0]) - arbitration_id = int(sample["CAN_ErrorFrame.ID"][0]) - is_rx = int(sample["CAN_ErrorFrame.Dir"][0]) == 0 - size = int(sample["CAN_ErrorFrame.DataLength"][0]) - dlc = dlc2len(sample["CAN_ErrorFrame.DLC"][0]) - data = sample["CAN_ErrorFrame.DataBytes"][0, :size].tobytes() - error_state_indicator = bool(sample["CAN_ErrorFrame.ESI"][0]) - bitrate_switch = bool(sample["CAN_ErrorFrame.BRS"][0]) - - msg = Message( - timestamp=timestamp + self.start_timestamp, - is_error_frame=True, - is_remote_frame=False, - is_fd=True, - is_extended_id=is_extended_id, - channel=channel, - arbitration_id=arbitration_id, - is_rx=is_rx, - data=data, - dlc=dlc, - bitrate_switch=bitrate_switch, - error_state_indicator=error_state_indicator, + elif "CAN_RemoteFrame" in channel_names: + iterators.append( + self._CANRemoteFrameIterator( + self._mdf, group_index, self._start_timestamp + ) ) - - yield msg - error_counter += 1 - - # remote frames else: - sample = self._mdf.get( - "CAN_RemoteFrame", - group=group_index, - raw=True, - record_offset=rtr_counter, - record_count=1, - ) - - try: - channel = int(sample["CAN_RemoteFrame.BusChannel"][0]) - except ValueError: - channel = None - - is_extended_id = bool(sample["CAN_RemoteFrame.IDE"][0]) - arbitration_id = int(sample["CAN_RemoteFrame.ID"][0]) - is_rx = int(sample["CAN_RemoteFrame.Dir"][0]) == 0 - dlc = int(sample["CAN_RemoteFrame.DLC"][0]) - - msg = Message( - timestamp=timestamp + self.start_timestamp, - is_error_frame=False, - is_remote_frame=True, - is_fd=False, - is_extended_id=is_extended_id, - channel=channel, - arbitration_id=arbitration_id, - is_rx=is_rx, - dlc=dlc, - ) - - yield msg - - rtr_counter += 1 - - self.stop() + # 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 214112164..2451eab41 100644 --- a/can/io/player.py +++ b/can/io/player.py @@ -7,14 +7,10 @@ import gzip import pathlib import time +from collections.abc import Generator, Iterable from typing import ( Any, - Dict, Final, - Generator, - Iterable, - Tuple, - Type, Union, ) @@ -32,7 +28,7 @@ #: A map of file suffixes to their corresponding #: :class:`can.io.generic.MessageReader` class -MESSAGE_READERS: Final[Dict[str, Type[MessageReader]]] = { +MESSAGE_READERS: Final[dict[str, type[MessageReader]]] = { ".asc": ASCReader, ".blf": BLFReader, ".csv": CSVReader, @@ -54,7 +50,7 @@ def _update_reader_plugins() -> None: MESSAGE_READERS[entry_point.key] = reader_class -def _get_logger_for_suffix(suffix: str) -> Type[MessageReader]: +def _get_logger_for_suffix(suffix: str) -> type[MessageReader]: """Find MessageReader class for given suffix.""" try: return MESSAGE_READERS[suffix] @@ -64,7 +60,7 @@ def _get_logger_for_suffix(suffix: str) -> Type[MessageReader]: def _decompress( filename: StringPathLike, -) -> Tuple[Type[MessageReader], Union[str, FileLike]]: +) -> tuple[type[MessageReader], Union[str, FileLike]]: """ Return the suffix and io object of the decompressed file. """ diff --git a/can/io/printer.py b/can/io/printer.py index 67c353cc6..30bc227ab 100644 --- a/can/io/printer.py +++ b/can/io/printer.py @@ -44,7 +44,7 @@ def __init__( def on_message_received(self, msg: Message) -> None: if self.write_to_file: - cast(TextIO, self.file).write(str(msg) + "\n") + cast("TextIO", self.file).write(str(msg) + "\n") else: print(msg) # noqa: T201 diff --git a/can/io/sqlite.py b/can/io/sqlite.py index 43fd761e9..686e2d038 100644 --- a/can/io/sqlite.py +++ b/can/io/sqlite.py @@ -8,7 +8,8 @@ import sqlite3 import threading import time -from typing import Any, Generator +from collections.abc import Generator +from typing import Any from can.listener import BufferedReader from can.message import Message diff --git a/can/io/trc.py b/can/io/trc.py index f0595c23e..a07a53a4d 100644 --- a/can/io/trc.py +++ b/can/io/trc.py @@ -9,17 +9,15 @@ import logging import os +from collections.abc import Generator from datetime import datetime, timedelta, timezone from enum import Enum -from typing import Any, Callable, Dict, Generator, List, Optional, TextIO, Union +from typing import Any, Callable, Optional, TextIO, Union from ..message import Message from ..typechecking import StringPathLike -from ..util import channel2int, dlc2len, len2dlc -from .generic import ( - TextIOMessageReader, - TextIOMessageWriter, -) +from ..util import channel2int, len2dlc +from .generic import TextIOMessageReader, TextIOMessageWriter logger = logging.getLogger("can.io.trc") @@ -58,13 +56,22 @@ def __init__( """ super().__init__(file, mode="r") self.file_version = TRCFileVersion.UNKNOWN - self.start_time: Optional[datetime] = None - self.columns: Dict[str, int] = {} + 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, ...]], Optional[Message]] = ( + lambda x: None + ) + + @property + def start_time(self) -> Optional[datetime]: + if self._start_time: + return datetime.fromtimestamp(self._start_time, timezone.utc) + return None def _extract_header(self): line = "" @@ -89,9 +96,10 @@ def _extract_header(self): 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])) + 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"): @@ -99,6 +107,7 @@ def _extract_header(self): 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(";"): @@ -107,7 +116,7 @@ def _extract_header(self): break if self.file_version >= TRCFileVersion.V1_1: - if self.start_time is None: + if self._start_time is None: raise ValueError("File has no start time information") if self.file_version >= TRCFileVersion.V2_0: @@ -132,7 +141,7 @@ def _extract_header(self): return line - def _parse_msg_v1_0(self, cols: List[str]) -> Optional[Message]: + def _parse_msg_v1_0(self, cols: tuple[str, ...]) -> Optional[Message]: arbit_id = cols[2] if arbit_id == "FFFFFFFF": logger.info("TRCReader: Dropping bus info line") @@ -147,16 +156,11 @@ def _parse_msg_v1_0(self, cols: List[str]) -> Optional[Message]: 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, ...]) -> Optional[Message]: arbit_id = cols[3] msg = Message() - if isinstance(self.start_time, datetime): - msg.timestamp = ( - self.start_time + timedelta(milliseconds=float(cols[1])) - ).timestamp() - else: - 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 @@ -165,16 +169,11 @@ def _parse_msg_v1_1(self, cols: List[str]) -> Optional[Message]: msg.is_rx = cols[2] == "Rx" return msg - def _parse_msg_v1_3(self, cols: List[str]) -> Optional[Message]: + def _parse_msg_v1_3(self, cols: tuple[str, ...]) -> Optional[Message]: arbit_id = cols[4] msg = Message() - if isinstance(self.start_time, datetime): - msg.timestamp = ( - self.start_time + timedelta(milliseconds=float(cols[1])) - ).timestamp() - else: - 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 = int(cols[2]) @@ -183,7 +182,7 @@ def _parse_msg_v1_3(self, cols: List[str]) -> Optional[Message]: msg.is_rx = cols[3] == "Rx" return msg - def _parse_msg_v2_x(self, cols: List[str]) -> Optional[Message]: + def _parse_msg_v2_x(self, cols: tuple[str, ...]) -> Optional[Message]: type_ = cols[self.columns["T"]] bus = self.columns.get("B", None) @@ -192,32 +191,25 @@ def _parse_msg_v2_x(self, cols: List[str]) -> Optional[Message]: dlc = len2dlc(length) elif "L" in self.columns: dlc = int(cols[self.columns["L"]]) - length = dlc2len(dlc) else: raise ValueError("No length/dlc columns present.") msg = Message() - if isinstance(self.start_time, datetime): - msg.timestamp = ( - self.start_time + timedelta(milliseconds=float(cols[self.columns["O"]])) - ).timestamp() - else: - msg.timestamp = float(cols[1]) / 1000 + 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.data = bytearray( - [int(cols[i + self.columns["D"]], 16) for i in range(length)] - ) + if dlc: + 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"] + 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, ...]) -> Optional[Message]: dtype = cols[2] if dtype in ("Tx", "Rx"): return self._parse_msg_v1_1(cols) @@ -225,7 +217,7 @@ def _parse_cols_v1_1(self, cols: List[str]) -> Optional[Message]: logger.info("TRCReader: Unsupported type '%s'", dtype) return None - def _parse_cols_v1_3(self, cols: List[str]) -> Optional[Message]: + def _parse_cols_v1_3(self, cols: tuple[str, ...]) -> Optional[Message]: dtype = cols[3] if dtype in ("Tx", "Rx"): return self._parse_msg_v1_3(cols) @@ -233,9 +225,9 @@ def _parse_cols_v1_3(self, cols: List[str]) -> Optional[Message]: logger.info("TRCReader: Unsupported type '%s'", dtype) return None - def _parse_cols_v2_x(self, cols: List[str]) -> Optional[Message]: + def _parse_cols_v2_x(self, cols: tuple[str, ...]) -> Optional[Message]: dtype = cols[self.columns["T"]] - if dtype in ["DT", "FD", "FB"]: + if dtype in {"DT", "FD", "FB", "FE", "BI"}: return self._parse_msg_v2_x(cols) else: logger.info("TRCReader: Unsupported type '%s'", dtype) @@ -244,7 +236,7 @@ def _parse_cols_v2_x(self, cols: List[str]) -> Optional[Message]: def _parse_line(self, line: str) -> Optional[Message]: 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) diff --git a/can/listener.py b/can/listener.py index 1f19c3acd..b450cf36d 100644 --- a/can/listener.py +++ b/can/listener.py @@ -6,8 +6,9 @@ import sys import warnings from abc import ABCMeta, abstractmethod +from collections.abc import AsyncIterator from queue import Empty, SimpleQueue -from typing import Any, AsyncIterator, Optional +from typing import Any, Optional from can.bus import BusABC from can.message import Message @@ -135,6 +136,7 @@ class AsyncBufferedReader( """ def __init__(self, **kwargs: Any) -> None: + self._is_stopped: bool = False self.buffer: asyncio.Queue[Message] if "loop" in kwargs: @@ -149,7 +151,6 @@ def __init__(self, **kwargs: Any) -> None: return self.buffer = asyncio.Queue() - self._is_stopped: bool = False def on_message_received(self, msg: Message) -> None: """Append a message to the buffer. diff --git a/can/logger.py b/can/logger.py index 81e9527f0..8274d6668 100644 --- a/can/logger.py +++ b/can/logger.py @@ -1,166 +1,30 @@ import argparse import errno -import re import sys from datetime import datetime from typing import ( TYPE_CHECKING, - Any, - Dict, - List, - Optional, - Sequence, - Tuple, Union, ) -import can -from can import Bus, BusState, Logger, SizedRotatingLogger +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 -from can.util import cast_from_string if TYPE_CHECKING: from can.io import BaseRotatingLogger from can.io.generic import MessageWriter - from can.typechecking import CanFilter - - -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 --filter 100:7FC 200:7F0" - "\nNote that the ID and mask are always interpreted as hex values", - metavar="{:,~}", - nargs=argparse.ONE_OR_MORE, - action=_CanFilterAction, - dest="can_filters", - **kwargs, - ) - - -def _create_bus(parsed_args: argparse.Namespace, **kwargs: Any) -> can.BusABC: - 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 - if getattr(parsed_args, "can_filters", None): - config["can_filters"] = parsed_args.can_filters - - return Bus(parsed_args.channel, **config) - - -class _CanFilterAction(argparse.Action): - def __call__( - self, - parser: argparse.ArgumentParser, - namespace: argparse.Namespace, - values: Union[str, Sequence[Any], None], - option_string: Optional[str] = None, - ) -> None: - if not isinstance(values, list): - raise argparse.ArgumentError(None, "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(None, "Invalid filter argument") - can_filters.append({"can_id": can_id, "can_mask": can_mask}) - - setattr(namespace, self.dest, can_filters) - - -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, Union[str, int, float, bool]] = {} - for key, string_val in map(_split_arg, unknown_args): - args[key] = cast_from_string(string_val) - return args def _parse_logger_args( - args: List[str], -) -> Tuple[argparse.Namespace, TAdditionalCliArgs]: + args: list[str], +) -> tuple[argparse.Namespace, TAdditionalCliArgs]: """Parse command line arguments for logger script.""" parser = argparse.ArgumentParser( @@ -168,11 +32,9 @@ def _parse_logger_args( "given file.", ) - # Generate the standard arguments: - # Channel, bitrate, data_bitrate, interface, app_name, CAN-FD support - _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", @@ -180,7 +42,7 @@ def _parse_logger_args( default=None, ) - parser.add_argument( + logger_group.add_argument( "-a", "--append", dest="append", @@ -188,7 +50,7 @@ def _parse_logger_args( action="store_true", ) - parser.add_argument( + logger_group.add_argument( "-s", "--file_size", dest="file_size", @@ -200,7 +62,7 @@ def _parse_logger_args( default=None, ) - parser.add_argument( + logger_group.add_argument( "-v", action="count", dest="verbosity", @@ -209,9 +71,7 @@ def _parse_logger_args( 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.", @@ -221,6 +81,12 @@ def _parse_logger_args( "--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 not args: parser.print_help(sys.stderr) @@ -233,7 +99,8 @@ def _parse_logger_args( def main() -> None: results, additional_config = _parse_logger_args(sys.argv[1:]) - bus = _create_bus(results, **additional_config) + bus = create_bus_from_namespace(results) + _set_logging_level_from_namespace(results) if results.active: bus.state = BusState.ACTIVE diff --git a/can/message.py b/can/message.py index 6dc6a83bd..d8d94ea84 100644 --- a/can/message.py +++ b/can/message.py @@ -32,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 diff --git a/can/notifier.py b/can/notifier.py index 088f0802e..2b9944450 100644 --- a/can/notifier.py +++ b/can/notifier.py @@ -7,7 +7,17 @@ import logging import threading import time -from typing import Any, Awaitable, Callable, Iterable, List, Optional, Union +from collections.abc import Awaitable, Iterable +from contextlib import AbstractContextManager +from types import TracebackType +from typing import ( + Any, + Callable, + Final, + NamedTuple, + Optional, + Union, +) from can.bus import BusABC from can.listener import Listener @@ -18,10 +28,88 @@ MessageRecipient = Union[Listener, Callable[[Message], Union[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): + + _registry: Final = _NotifierRegistry() + def __init__( self, - bus: Union[BusABC, List[BusABC]], + bus: Union[BusABC, list[BusABC]], listeners: Iterable[MessageRecipient], timeout: float = 1.0, loop: Optional[asyncio.AbstractEventLoop] = None, @@ -32,61 +120,81 @@ 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._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[Union[int, threading.Thread]] = [] + _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) -> Union[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. @@ -94,7 +202,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): @@ -108,6 +216,10 @@ def stop(self, timeout: float = 5) -> 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: # determine message handling callable early, not inside while loop if self._loop: @@ -118,7 +230,7 @@ def _rx_thread(self, bus: BusABC) -> None: else: handle_message = self._on_message_received - while self._running: + while not self._stopped: try: if msg := bus.recv(self.timeout): with self._lock: @@ -183,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: Optional[type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + if not self._stopped: + self.stop() diff --git a/can/player.py b/can/player.py index 40e4cc43a..a92cccc3d 100644 --- a/can/player.py +++ b/can/player.py @@ -9,19 +9,29 @@ import errno import sys from datetime import datetime -from typing import Iterable, cast +from typing import TYPE_CHECKING, cast -from can import LogReader, Message, MessageSync +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, +) -from .logger import _create_base_argument_parser, _create_bus, _parse_additional_config +if TYPE_CHECKING: + from collections.abc import Iterable + + from can import Message 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( + player_group.add_argument( "-f", "--file_name", dest="log_file", @@ -29,7 +39,7 @@ def main() -> None: default=None, ) - parser.add_argument( + player_group.add_argument( "-v", action="count", dest="verbosity", @@ -38,27 +48,27 @@ 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, @@ -66,13 +76,19 @@ def main() -> None: help=" skip gaps greater than 's' seconds", ) - parser.add_argument( + 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,14 +97,15 @@ 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 create_bus_from_namespace(results) as bus: with LogReader(results.infile, **additional_config) as reader: in_sync = MessageSync( - cast(Iterable[Message], reader), + cast("Iterable[Message]", reader), timestamps=results.timestamps, gap=results.gap, skip=results.skip, diff --git a/can/typechecking.py b/can/typechecking.py index b0d1c22ac..36343ddaa 100644 --- a/can/typechecking.py +++ b/can/typechecking.py @@ -1,5 +1,4 @@ -"""Types for mypy type-checking -""" +"""Types for mypy type-checking""" import gzip import struct @@ -51,13 +50,13 @@ class CanFilterExtended(TypedDict): StringPathLike = typing.Union[str, "os.PathLike[str]"] AcceptedIOType = typing.Union[FileLike, StringPathLike] -BusConfig = typing.NewType("BusConfig", typing.Dict[str, typing.Any]) +BusConfig = typing.NewType("BusConfig", dict[str, typing.Any]) # Used by CLI scripts -TAdditionalCliArgs: TypeAlias = typing.Dict[str, typing.Union[str, int, float, bool]] -TDataStructs: TypeAlias = typing.Dict[ - typing.Union[int, typing.Tuple[int, ...]], - typing.Union[struct.Struct, typing.Tuple, None], +TAdditionalCliArgs: TypeAlias = dict[str, typing.Union[str, int, float, bool]] +TDataStructs: TypeAlias = dict[ + typing.Union[int, tuple[int, ...]], + typing.Union[struct.Struct, tuple, None], ] diff --git a/can/util.py b/can/util.py index 7f2f824db..2f32dda8e 100644 --- a/can/util.py +++ b/can/util.py @@ -12,15 +12,13 @@ import platform import re import warnings +from collections.abc import Iterable from configparser import ConfigParser from time import get_clock_info, perf_counter, time from typing import ( Any, Callable, - Dict, - Iterable, Optional, - Tuple, TypeVar, Union, cast, @@ -53,7 +51,7 @@ def load_file_config( path: Optional[typechecking.AcceptedIOType] = None, section: str = "default" -) -> Dict[str, str]: +) -> dict[str, str]: """ Loads configuration from file with following content:: @@ -77,7 +75,7 @@ def load_file_config( else: config.read(path) - _config: Dict[str, str] = {} + _config: dict[str, str] = {} if config.has_section(section): _config.update(config.items(section)) @@ -85,7 +83,7 @@ def load_file_config( return _config -def load_environment_config(context: Optional[str] = None) -> Dict[str, str]: +def load_environment_config(context: Optional[str] = None) -> dict[str, str]: """ Loads config dict from environmental variables (if set): @@ -111,7 +109,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) @@ -123,7 +121,7 @@ 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, + config: Optional[dict[str, Any]] = None, context: Optional[str] = None, ) -> typechecking.BusConfig: """ @@ -178,7 +176,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[Union[dict[str, Any], Callable[[Any], dict[str, Any]]]]", [ given_config, can.rc, @@ -212,7 +210,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). @@ -251,10 +249,10 @@ def _create_bus_config(config: Dict[str, Any]) -> typechecking.BusConfig: if "fd" in config: config["fd"] = config["fd"] not in (0, False) - return cast(typechecking.BusConfig, config) + return cast("typechecking.BusConfig", config) -def _dict2timing(data: Dict[str, Any]) -> Union[BitTiming, BitTimingFd, None]: +def _dict2timing(data: dict[str, Any]) -> Union[BitTiming, BitTimingFd, None]: """Try to instantiate a :class:`~can.BitTiming` or :class:`~can.BitTimingFd` from a dictionary. Return `None` if not possible.""" @@ -396,8 +394,8 @@ def _rename_kwargs( func_name: str, start: str, end: Optional[str], - kwargs: P1.kwargs, - aliases: Dict[str, Optional[str]], + kwargs: dict[str, Any], + aliases: dict[str, Optional[str]], ) -> None: """Helper function for `deprecated_args_alias`""" for alias, new in aliases.items(): @@ -468,7 +466,7 @@ def check_or_adjust_timing_clock(timing: T2, valid_clocks: Iterable[int]) -> T2: ) from None -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 diff --git a/can/viewer.py b/can/viewer.py index 45c313b07..81e8942a4 100644 --- a/can/viewer.py +++ b/can/viewer.py @@ -27,16 +27,14 @@ import struct import sys import time -from typing import Dict, List, Tuple from can import __version__ -from can.logger import ( - _append_filter_argument, - _create_base_argument_parser, - _create_bus, - _parse_additional_config, +from can.cli import ( + _set_logging_level_from_namespace, + add_bus_arguments, + create_bus_from_namespace, ) -from can.typechecking import TAdditionalCliArgs, TDataStructs +from can.typechecking import TDataStructs logger = logging.getLogger("can.viewer") @@ -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: dict, 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] @@ -391,8 +388,8 @@ def _fill_text(self, text, width, indent): def _parse_viewer_args( - args: List[str], -) -> Tuple[argparse.Namespace, TDataStructs, TAdditionalCliArgs]: + args: list[str], +) -> tuple[argparse.Namespace, TDataStructs]: # Parse command line arguments parser = argparse.ArgumentParser( "python -m can.viewer", @@ -413,9 +410,8 @@ def _parse_viewer_args( 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") @@ -472,8 +468,6 @@ def _parse_viewer_args( default="", ) - _append_filter_argument(optional, "-f") - optional.add_argument( "-v", action="count", @@ -489,6 +483,8 @@ def _parse_viewer_args( raise SystemExit(errno.EINVAL) parsed_args, unknown_args = parser.parse_known_args(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. @@ -525,7 +521,7 @@ def _parse_viewer_args( 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: @@ -538,15 +534,13 @@ def _parse_viewer_args( else: data_structs[key] = struct.Struct(fmt) - additional_config = _parse_additional_config( - [*parsed_args.extra_args, *unknown_args] - ) - return parsed_args, data_structs, additional_config + return parsed_args, data_structs def main() -> None: - parsed_args, data_structs, additional_config = _parse_viewer_args(sys.argv[1:]) - bus = _create_bus(parsed_args, **additional_config) + 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] 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/conf.py b/doc/conf.py index 34ce385cb..f4a9ab95f 100755 --- a/doc/conf.py +++ b/doc/conf.py @@ -136,7 +136,7 @@ ] # 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/development.rst b/doc/development.rst index a8332eeb6..074c1318d 100644 --- a/doc/development.rst +++ b/doc/development.rst @@ -52,7 +52,7 @@ The documentation can be built with:: The linters can be run with:: - pip install -e .[lint] + pip install --group lint -e . black --check can mypy can ruff check can @@ -110,7 +110,6 @@ Creating a new Release ---------------------- - 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. 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/interfaces/gs_usb.rst b/doc/interfaces/gs_usb.rst index e9c0131c5..8bab07c6f 100755 --- a/doc/interfaces/gs_usb.rst +++ b/doc/interfaces/gs_usb.rst @@ -6,7 +6,7 @@ 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`` or ``channel`` (starting from 0) if using automatic device detection: diff --git a/doc/interfaces/serial.rst b/doc/interfaces/serial.rst index 99ee54df6..566ec7755 100644 --- a/doc/interfaces/serial.rst +++ b/doc/interfaces/serial.rst @@ -30,7 +30,9 @@ six parts. The start and the stop byte for the frame, the timestamp, DLC, arbitration ID and the payload. The payload has a variable length of between 0 and 8 bytes, the other parts are fixed. Both, the timestamp and the arbitration ID will be interpreted as 4 byte unsigned integers. The DLC is -also an unsigned integer with a length of 1 byte. +also an unsigned integer with a length of 1 byte. Extended (29-bit) +identifiers are encoded by adding 0x80000000 to the ID. For example, a +29-bit CAN ID of 0x123 is encoded with an arbitration ID of 0x80000123. Serial frame format ^^^^^^^^^^^^^^^^^^^ @@ -102,3 +104,21 @@ Examples of serial frames +================+=====================+======+=====================+==============+ | 0xAA | 0x66 0x73 0x00 0x00 | 0x00 | 0x01 0x00 0x00 0x00 | 0xBB | +----------------+---------------------+------+---------------------+--------------+ + +.. rubric:: Extended Frame CAN message with 0 byte payload with an 29-bit CAN ID + ++----------------+---------+ +| CAN message | ++----------------+---------+ +| Arbitration ID | Payload | ++================+=========+ +| 0x80000001 (1) | None | ++----------------+---------+ + ++----------------+---------------------+------+---------------------+--------------+ +| Serial frame | ++----------------+---------------------+------+---------------------+--------------+ +| Start of frame | Timestamp | DLC | Arbitration ID | End of frame | ++================+=====================+======+=====================+==============+ +| 0xAA | 0x66 0x73 0x00 0x00 | 0x00 | 0x01 0x00 0x00 0x80 | 0xBB | ++----------------+---------------------+------+---------------------+--------------+ 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/other-tools.rst b/doc/other-tools.rst index db06812ca..607db6c8a 100644 --- a/doc/other-tools.rst +++ b/doc/other-tools.rst @@ -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 bfdedf3c6..8e60c50c2 100644 --- a/doc/plugin-interface.rst +++ b/doc/plugin-interface.rst @@ -73,7 +73,7 @@ The table below lists interface drivers that can be added by installing addition +----------------------------+-------------------------------------------------------+ | `python-can-sontheim`_ | CAN Driver for Sontheim CAN interfaces (e.g. CANfox) | +----------------------------+-------------------------------------------------------+ -| `zlgcan-driver-py`_ | Python wrapper for zlgcan-driver-rs | +| `zlgcan`_ | Python wrapper for zlgcan-driver-rs | +----------------------------+-------------------------------------------------------+ | `python-can-cando`_ | Python wrapper for Netronics' CANdo and CANdoISO | +----------------------------+-------------------------------------------------------+ @@ -82,6 +82,6 @@ The table below lists interface drivers that can be added by installing addition .. _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-driver-py: https://github.com/zhuyu4839/zlgcan-driver +.. _zlgcan: https://github.com/jesses2025smith/zlgcan-driver .. _python-can-cando: https://github.com/belliriccardo/python-can-cando 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 index 3ab6c78ac..763fcd72b 100644 --- a/examples/cyclic_checksum.py +++ b/examples/cyclic_checksum.py @@ -59,6 +59,5 @@ def compute_xbr_checksum(message: can.Message, counter: int) -> int: if __name__ == "__main__": with can.Bus(channel=0, interface="virtual", receive_own_messages=True) as _bus: - notifier = can.Notifier(bus=_bus, listeners=[print]) - cyclic_checksum_send(_bus) - notifier.stop() + with can.Notifier(bus=_bus, listeners=[print]): + cyclic_checksum_send(_bus) diff --git a/examples/print_notifier.py b/examples/print_notifier.py index 8d55ca1dc..e6e11dbec 100755 --- a/examples/print_notifier.py +++ b/examples/print_notifier.py @@ -8,14 +8,13 @@ def main(): with can.Bus(interface="virtual", receive_own_messages=True) as bus: print_listener = can.Printer() - notifier = 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) - notifier.stop() + 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/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/serial_com.py b/examples/serial_com.py index 538c8d12f..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 diff --git a/examples/vcan_filtered.py b/examples/vcan_filtered.py index 9c67390ab..22bca706c 100755 --- a/examples/vcan_filtered.py +++ b/examples/vcan_filtered.py @@ -18,14 +18,11 @@ def main(): # 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) - notifier.stop() + 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 f2b6ac04f..ee98fec24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,9 +11,8 @@ dependencies = [ "wrapt~=1.10", "packaging >= 23.1", "typing_extensions>=3.10.0.0", - "msgpack~=1.1.0; platform_system != 'Windows'", ] -requires-python = ">=3.8" +requires-python = ">=3.9" license = { text = "LGPL v3" } classifiers = [ "Development Status :: 5 - Production/Stable", @@ -29,7 +28,6 @@ classifiers = [ "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Programming Language :: Python", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -59,30 +57,50 @@ repository = "https://github.com/hardbyte/python-can" changelog = "https://github.com/hardbyte/python-can/blob/develop/CHANGELOG.md" [project.optional-dependencies] -lint = [ - "pylint==3.2.*", - "ruff==0.7.0", - "black==24.10.*", - "mypy==1.12.*", -] -pywin32 = ["pywin32>=305"] +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"] +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-driver-py"] +zlgcan = ["zlgcan"] 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==3.3.*", + "ruff==0.11.12", + "black==25.1.*", + "mypy==1.16.*", +] +test = [ + "pytest==8.3.*", + "pytest-timeout==2.1.*", + "coveralls==3.3.1", + "pytest-cov==4.0.0", + "coverage==6.5.0", + "hypothesis~=6.35.0", + "pyserial~=3.5", + "parameterized~=0.8", +] [tool.setuptools.dynamic] readme = { file = "README.rst" } @@ -122,7 +140,6 @@ exclude = [ "^can/interfaces/neousys", "^can/interfaces/pcan", "^can/interfaces/serial", - "^can/interfaces/slcan", "^can/interfaces/socketcan", "^can/interfaces/systec", "^can/interfaces/udp_multicast", @@ -134,7 +151,7 @@ exclude = [ line-length = 100 [tool.ruff.lint] -select = [ +extend-select = [ "A", # flake8-builtins "B", # flake8-bugbear "C4", # flake8-comprehensions @@ -165,8 +182,11 @@ ignore = [ "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/viewer.py" = ["T20"] # flake8-print +"examples/*" = ["T20"] # flake8-print [tool.ruff.lint.isort] known-first-party = ["can"] @@ -188,6 +208,7 @@ disable = [ "too-many-branches", "too-many-instance-attributes", "too-many-locals", + "too-many-positional-arguments", "too-many-public-methods", "too-many-statements", ] diff --git a/test/back2back_test.py b/test/back2back_test.py index 90cf8a9bf..a46597ef4 100644 --- a/test/back2back_test.py +++ b/test/back2back_test.py @@ -14,13 +14,12 @@ 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_OSX, IS_PYPY, - IS_TRAVIS, - IS_UNIX, TEST_CAN_FD, TEST_INTERFACE_SOCKETCAN, ) @@ -302,9 +301,13 @@ 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" @@ -319,9 +322,13 @@ def test_unique_message_instances(self): # 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" diff --git a/test/contextmanager_test.py b/test/contextmanager_test.py index 3adb1e7c6..fe87f33b0 100644 --- a/test/contextmanager_test.py +++ b/test/contextmanager_test.py @@ -17,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() @@ -27,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 592556926..b78420e4e 100644 --- a/test/data/example_data.py +++ b/test/data/example_data.py @@ -160,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/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/listener_test.py b/test/listener_test.py index b530afa60..bbcbed56e 100644 --- a/test/listener_test.py +++ b/test/listener_test.py @@ -1,7 +1,6 @@ #!/usr/bin/env python -""" -""" +""" """ import asyncio import logging import os @@ -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 0fbe065d2..f3fe485b2 100644 --- a/test/logformats_test.py +++ b/test/logformats_test.py @@ -694,7 +694,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, ) @@ -789,6 +788,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""" diff --git a/test/network_test.py b/test/network_test.py index 50070ef40..3a231dff7 100644 --- a/test/network_test.py +++ b/test/network_test.py @@ -6,16 +6,14 @@ import unittest import can +from test.config import IS_PYPY logging.getLogger(__file__).setLevel(logging.WARNING) # make a random bool: def rbool(): - return bool(round(random.random())) - - -channel = "vcan0" + return random.choice([False, True]) class ControllerAreaNetworkTestCase(unittest.TestCase): @@ -51,74 +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, interface="virtual") - - 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() - - with contextlib.suppress(NotImplementedError): - 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 6982130cf..c21d51f04 100644 --- a/test/notifier_test.py +++ b/test/notifier_test.py @@ -12,16 +12,19 @@ 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) + self.assertFalse(notifier.stopped) msg = can.Message() bus1.send(msg) time.sleep(0.1) @@ -33,6 +36,39 @@ def test_multiple_bus(self): 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() + 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 5fa90704b..409485112 100644 --- a/test/serial_test.py +++ b/test/serial_test.py @@ -84,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 arbitration id + Tests the transfer with the lowest standard arbitration id """ - msg = can.Message(arbitration_id=0) + 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_id(self): + def test_rx_tx_max_std_id(self): """ - Tests the transfer with the highest arbitration id + Tests the transfer with the highest standard arbitration id """ - msg = can.Message(arbitration_id=536870911) + 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_min_ext_id(self): + """ + Tests the transfer with the lowest extended arbitration id + """ + 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) @@ -137,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 7116efc9d..c4c1a2340 100644 --- a/test/simplecyclic_test.py +++ b/test/simplecyclic_test.py @@ -5,8 +5,11 @@ """ import gc +import sys import time +import traceback import unittest +from threading import Thread from time import sleep from typing import List from unittest.mock import MagicMock @@ -87,6 +90,8 @@ def test_removing_bus_tasks(self): # Note calling task.stop will remove the task from the Bus's internal task management list task.stop() + self.join_threads([task.thread for task in tasks], 5.0) + assert len(bus._periodic_tasks) == 0 bus.shutdown() @@ -115,8 +120,7 @@ def test_managed_tasks(self): for task in tasks: task.stop() - for task in tasks: - assert task.thread.join(5.0) is None, "Task didn't stop before timeout" + self.join_threads([task.thread for task in tasks], 5.0) bus.shutdown() @@ -142,9 +146,7 @@ def test_stopping_perodic_tasks(self): # stop the other half using the bus api bus.stop_all_periodic_tasks(remove_tasks=False) - - for task in tasks: - assert task.thread.join(5.0) is None, "Task didn't stop before timeout" + 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) @@ -161,7 +163,7 @@ def test_restart_perodic_tasks(self): is_extended_id=False, arbitration_id=0x123, data=[0, 1, 2, 3, 4, 5, 6, 7] ) - def _read_all_messages(_bus: can.interfaces.virtual.VirtualBus) -> None: + def _read_all_messages(_bus: "can.interfaces.virtual.VirtualBus") -> None: sleep(safe_timeout) while not _bus.queue.empty(): _bus.recv(timeout=period) @@ -207,9 +209,8 @@ def _read_all_messages(_bus: can.interfaces.virtual.VirtualBus) -> None: # Stop all tasks and wait for the thread to exit bus.stop_all_periodic_tasks() - if isinstance(task, can.broadcastmanager.ThreadBasedCyclicSendTask): - # Avoids issues where the thread is still running when the bus is shutdown - task.thread.join(safe_timeout) + # Avoids issues where the thread is still running when the bus is shutdown + self.join_threads([task.thread], 5.0) @unittest.skipIf(IS_CI, "fails randomly when run on CI server") def test_thread_based_cyclic_send_task(self): @@ -288,6 +289,27 @@ def increment_first_byte(msg: can.Message) -> None: 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_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_interface_canalystii.py b/test/test_interface_canalystii.py index 65d9ee74b..3bdd281d2 100755 --- a/test/test_interface_canalystii.py +++ b/test/test_interface_canalystii.py @@ -1,7 +1,6 @@ #!/usr/bin/env python -""" -""" +""" """ import unittest from ctypes import c_ubyte diff --git a/test/test_kvaser.py b/test/test_kvaser.py index 8d18976aa..c18b3bc15 100644 --- a/test/test_kvaser.py +++ b/test/test_kvaser.py @@ -1,7 +1,6 @@ #!/usr/bin/env python -""" -""" +""" """ import ctypes import time diff --git a/test/test_logger.py b/test/test_logger.py index 10df2557b..41778ab6a 100644 --- a/test/test_logger.py +++ b/test/test_logger.py @@ -14,6 +14,7 @@ import pytest import can +import can.cli import can.logger @@ -89,7 +90,7 @@ def test_log_virtual_with_config(self): "--bitrate", "250000", "--fd", - "--data_bitrate", + "--data-bitrate", "2000000", ] can.logger.main() @@ -111,7 +112,7 @@ def test_parse_logger_args(self): "--bitrate", "250000", "--fd", - "--data_bitrate", + "--data-bitrate", "2000000", "--receive-own-messages=True", ] @@ -139,6 +140,63 @@ def test_parse_can_filters_list(self): ) 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", @@ -148,7 +206,7 @@ def test_parse_additional_config(self): "--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" @@ -175,16 +233,16 @@ def test_parse_additional_config(self): 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_neovi.py b/test/test_neovi.py index d8f54960a..8c816bef2 100644 --- a/test/test_neovi.py +++ b/test/test_neovi.py @@ -1,7 +1,6 @@ #!/usr/bin/env python -""" -""" +""" """ import pickle import unittest diff --git a/test/test_slcan.py b/test/test_slcan.py index e1531e500..220a6d7e0 100644 --- a/test/test_slcan.py +++ b/test/test_slcan.py @@ -1,9 +1,9 @@ #!/usr/bin/env python -import unittest -from typing import cast +import unittest.mock +from typing import cast, Optional -import serial +from serial.serialutil import SerialBase import can.interfaces.slcan @@ -21,20 +21,69 @@ 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 = cast( can.interfaces.slcan.slcanBus, can.Bus("loop://", interface="slcan", sleep_after_open=0, timeout=TIMEOUT), ) - self.serial = cast(serial.Serial, self.bus.serialPortOrig) + 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) @@ -44,7 +93,7 @@ def test_recv_extended(self): self.assertSequenceEqual(msg.data, [0xAA, 0x55]) # Ewert Energy Systems CANDapter specific - self.serial.write(b"x12ABCDEF2AA55\r") + self.serial.set_input_buffer(b"x12ABCDEF2AA55\r") msg = self.bus.recv(TIMEOUT) self.assertIsNotNone(msg) self.assertEqual(msg.arbitration_id, 0x12ABCDEF) @@ -54,15 +103,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) + 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) @@ -72,15 +125,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) + 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) @@ -89,15 +146,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) + 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) @@ -106,19 +167,23 @@ 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) + 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) @@ -130,28 +195,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_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_vector.py b/test/test_vector.py index ca46526fb..5d074f614 100644 --- a/test/test_vector.py +++ b/test/test_vector.py @@ -1042,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 3bd32b25a..e71d06dc8 100644 --- a/test/test_viewer.py +++ b/test/test_viewer.py @@ -397,19 +397,19 @@ def test_pack_unpack(self): ) def test_parse_args(self): - parsed_args, _, _ = _parse_viewer_args(["-b", "250000"]) + parsed_args, _ = _parse_viewer_args(["-b", "250000"]) self.assertEqual(parsed_args.bitrate, 250000) - parsed_args, _, _ = _parse_viewer_args(["--bitrate", "500000"]) + parsed_args, _ = _parse_viewer_args(["--bitrate", "500000"]) self.assertEqual(parsed_args.bitrate, 500000) - parsed_args, _, _ = _parse_viewer_args(["-c", "can0"]) + parsed_args, _ = _parse_viewer_args(["-c", "can0"]) self.assertEqual(parsed_args.channel, "can0") - parsed_args, _, _ = _parse_viewer_args(["--channel", "PCAN_USBBUS1"]) + parsed_args, _ = _parse_viewer_args(["--channel", "PCAN_USBBUS1"]) self.assertEqual(parsed_args.channel, "PCAN_USBBUS1") - parsed_args, data_structs, _ = _parse_viewer_args(["-d", "100:=6.0; platform_python_implementation=="CPython" and python_version<"3.13" - pywin32>=305; platform_system=="Windows" and platform_python_implementation=="CPython" and python_version<"3.13" - + asammdf>=6.0; platform_python_implementation=="CPython" and python_version<"3.14" + msgpack~=1.1.0; python_version<"3.14" + pywin32>=305; platform_system=="Windows" and platform_python_implementation=="CPython" and python_version<"3.14" commands = pytest {posargs} - extras = canalystii -recreate = True - [testenv:gh] passenv = CI @@ -32,13 +24,14 @@ passenv = [testenv:docs] description = Build and test the documentation basepython = py312 -deps = - -r doc/doc-requirements.txt - gs-usb - +dependency_groups = + docs extras = canalystii - + gs-usb + mf4 + remote + serial commands = python -m sphinx -b html -Wan --keep-going doc build python -m sphinx -b doctest -W --keep-going doc build