diff --git a/can/interfaces/udp_multicast/bus.py b/can/interfaces/udp_multicast/bus.py index 8fc286627..6b7e57bd9 100644 --- a/can/interfaces/udp_multicast/bus.py +++ b/can/interfaces/udp_multicast/bus.py @@ -1,8 +1,14 @@ +import errno import logging import select import socket import struct +try: + from fcntl import ioctl +except ModuleNotFoundError: # Missing on Windows + pass + from typing import List, Optional, Tuple, Union log = logging.getLogger(__name__) @@ -21,6 +27,7 @@ # Additional constants for the interaction with Unix kernels SO_TIMESTAMPNS = 35 +SIOCGSTAMP = 0x8906 class UdpMulticastBus(BusABC): @@ -174,6 +181,9 @@ def __init__( self.hop_limit = hop_limit self.max_buffer = max_buffer + # `False` will always work, no matter the setup. This might be changed by _create_socket(). + self.timestamp_nanosecond = False + # Look up multicast group address in name server and find out IP version of the first suitable target # and then get the address family of it (socket.AF_INET or socket.AF_INET6) connection_candidates = socket.getaddrinfo( # type: ignore @@ -200,8 +210,15 @@ def __init__( # used in recv() self.received_timestamp_struct = "@ll" - ancillary_data_size = struct.calcsize(self.received_timestamp_struct) - self.received_ancillary_buffer_size = socket.CMSG_SPACE(ancillary_data_size) + self.received_timestamp_struct_size = struct.calcsize( + self.received_timestamp_struct + ) + if self.timestamp_nanosecond: + self.received_ancillary_buffer_size = socket.CMSG_SPACE( + self.received_timestamp_struct_size + ) + else: + self.received_ancillary_buffer_size = 0 # used by send() self._send_destination = (self.group, self.port) @@ -238,7 +255,15 @@ def _create_socket(self, address_family: socket.AddressFamily) -> socket.socket: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # set how to receive timestamps - sock.setsockopt(socket.SOL_SOCKET, SO_TIMESTAMPNS, 1) + try: + sock.setsockopt(socket.SOL_SOCKET, SO_TIMESTAMPNS, 1) + except OSError as error: + if error.errno == errno.ENOPROTOOPT: # It is unavailable on macOS + self.timestamp_nanosecond = False + else: + raise error + else: + self.timestamp_nanosecond = True # Bind it to the port (on any interface) sock.bind(("", self.port)) @@ -272,18 +297,22 @@ def send(self, data: bytes, timeout: Optional[float] = None) -> None: :param timeout: the timeout in seconds after which an Exception is raised is sending has failed :param data: the data to be sent - :raises OSError: if an error occurred while writing to the underlying socket - :raises socket.timeout: if the timeout ran out before sending was completed (this is a subclass of - *OSError*) + :raises can.CanOperationError: if an error occurred while writing to the underlying socket + :raises can.CanTimeoutError: if the timeout ran out before sending was completed """ if timeout != self._last_send_timeout: self._last_send_timeout = timeout # this applies to all blocking calls on the socket, but sending is the only one that is blocking self._socket.settimeout(timeout) - bytes_sent = self._socket.sendto(data, self._send_destination) - if bytes_sent < len(data): - raise socket.timeout() + try: + bytes_sent = self._socket.sendto(data, self._send_destination) + if bytes_sent < len(data): + raise TimeoutError() + except TimeoutError: + raise can.CanTimeoutError() from None + except OSError as error: + raise can.CanOperationError("failed to send via socket") from error def recv( self, timeout: Optional[float] = None @@ -320,21 +349,41 @@ def recv( self.max_buffer, self.received_ancillary_buffer_size ) - # fetch timestamp; this is configured in in _create_socket() - assert len(ancillary_data) == 1, "only requested a single extra field" - cmsg_level, cmsg_type, cmsg_data = ancillary_data[0] - assert ( - cmsg_level == socket.SOL_SOCKET and cmsg_type == SO_TIMESTAMPNS - ), "received control message type that was not requested" - # see https://man7.org/linux/man-pages/man3/timespec.3.html -> struct timespec for details - seconds, nanoseconds = struct.unpack( - self.received_timestamp_struct, cmsg_data - ) - if nanoseconds >= 1e9: - raise can.CanError( - f"Timestamp nanoseconds field was out of range: {nanoseconds} not less than 1e9" + # fetch timestamp; this is configured in _create_socket() + if self.timestamp_nanosecond: + # Very similar to timestamp handling in can/interfaces/socketcan/socketcan.py -> capture_message() + if len(ancillary_data) != 1: + raise can.CanOperationError( + "Only requested a single extra field but got a different amount" + ) + cmsg_level, cmsg_type, cmsg_data = ancillary_data[0] + if cmsg_level != socket.SOL_SOCKET or cmsg_type != SO_TIMESTAMPNS: + raise can.CanOperationError( + "received control message type that was not requested" + ) + # see https://man7.org/linux/man-pages/man3/timespec.3.html -> struct timespec for details + seconds, nanoseconds = struct.unpack( + self.received_timestamp_struct, cmsg_data + ) + if nanoseconds >= 1e9: + raise can.CanOperationError( + f"Timestamp nanoseconds field was out of range: {nanoseconds} not less than 1e9" + ) + 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 ) - timestamp = seconds + nanoseconds * 1.0e-9 + if microseconds >= 1e6: + raise can.CanOperationError( + f"Timestamp microseconds field was out of range: {microseconds} not less than 1e6" + ) + timestamp = seconds + microseconds * 1e-6 return raw_message_data, sender_address, timestamp diff --git a/doc/interfaces/udp_multicast.rst b/doc/interfaces/udp_multicast.rst index be5882f20..f2775727c 100644 --- a/doc/interfaces/udp_multicast.rst +++ b/doc/interfaces/udp_multicast.rst @@ -25,7 +25,7 @@ for specifying multicast IP addresses. Supported Platforms ------------------- -It should work on most Unix systems (including Linux with kernel 2.6.22+) but currently not on Windows. +It should work on most Unix systems (including Linux with kernel 2.6.22+ and macOS) but currently not on Windows. Example ------- diff --git a/requirements-lint.txt b/requirements-lint.txt index 68caecd03..f62eeb189 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1,5 +1,5 @@ pylint==2.12.2 -black~=22.1.0 +black~=22.3.0 mypy==0.931 mypy-extensions==0.4.3 types-setuptools diff --git a/test/back2back_test.py b/test/back2back_test.py index 479274343..b5ae4e27c 100644 --- a/test/back2back_test.py +++ b/test/back2back_test.py @@ -285,7 +285,7 @@ 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.skipUnless( - IS_UNIX and not IS_OSX, + IS_UNIX and not (IS_CI and IS_OSX), "only supported on Unix systems (but not on macOS at Travis CI and GitHub Actions)", ) class BasicTestUdpMulticastBusIPv4(Back2BackTestCase): @@ -303,8 +303,8 @@ 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.skipUnless( - IS_UNIX and not (IS_TRAVIS or IS_OSX), - "only supported on Unix systems (but not on Travis CI; and not an macOS at GitHub Actions)", + 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)", ) class BasicTestUdpMulticastBusIPv6(Back2BackTestCase):