Skip to content
95 changes: 72 additions & 23 deletions can/interfaces/udp_multicast/bus.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand All @@ -21,6 +27,7 @@

# Additional constants for the interaction with Unix kernels
SO_TIMESTAMPNS = 35
SIOCGSTAMP = 0x8906


class UdpMulticastBus(BusABC):
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion doc/interfaces/udp_multicast.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------
Expand Down
2 changes: 1 addition & 1 deletion requirements-lint.txt
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions test/back2back_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):

Expand Down