From 78a565f49e08d00f73d24fadbf8fb8cabf5e5778 Mon Sep 17 00:00:00 2001 From: Sidiq Moltafet <109406581+smoltafet@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:59:53 -0700 Subject: [PATCH] socketcand: add support for connecting via a Unix domain socket --- can/interfaces/socketcand/socketcand.py | 72 ++++++++++++-- doc/changelog.d/2018.added.rst | 1 + test/test_socketcand.py | 123 +++++++++++++++++++++++- 3 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 doc/changelog.d/2018.added.rst diff --git a/can/interfaces/socketcand/socketcand.py b/can/interfaces/socketcand/socketcand.py index d401102f7..232489032 100644 --- a/can/interfaces/socketcand/socketcand.py +++ b/can/interfaces/socketcand/socketcand.py @@ -188,13 +188,19 @@ def convert_can_message_to_ascii_message(can_message: can.Message) -> str: return f"< send {can_id_string} {length:X} {bytes_string} >" -def connect_to_server(s, host, port): +def connect_to_server(s, address): + """Connect the socket *s* to a socketcand server, retrying for up to 10s. + + :param address: + Either a ``(host, port)`` tuple for a TCP connection, or the path of + a Unix domain socket as a string. + """ timeout_ms = 10000 now = time.time() * 1000 end_time = now + timeout_ms while now < end_time: try: - s.connect((host, port)) + s.connect(address) return except Exception as e: log.warning(f"Failed to connect to server: {type(e)} Message: {e}") @@ -205,7 +211,16 @@ def connect_to_server(s, host, port): class SocketCanDaemonBus(can.BusABC): - def __init__(self, channel, host, port, tcp_tune=False, can_filters=None, **kwargs): + def __init__( + self, + channel, + host=None, + port=None, + unix_socket_path=None, + tcp_tune=False, + can_filters=None, + **kwargs, + ): """Connects to a CAN bus served by socketcand. It implements :meth:`can.BusABC._detect_available_configs` to search for @@ -221,21 +236,56 @@ def __init__(self, channel, host, port, tcp_tune=False, can_filters=None, **kwar The can interface name served by socketcand. An example channel would be 'vcan0' or 'can0'. :param host: - The host address of the socketcand server. + The host address of the socketcand server. Required together with + *port*, unless *unix_socket_path* is given. :param port: - The port of the socketcand server. + The port of the socketcand server. Required together with + *host*, unless *unix_socket_path* is given. + :param unix_socket_path: + The path of a Unix domain socket to connect to instead of using + TCP. socketcand can listen on such a socket, which is useful + when the daemon runs on the same machine, e.g. when its socket + is passed into a container. Mutually exclusive with *host* and + *port*. :param tcp_tune: This tunes the TCP socket for low latency (TCP_NODELAY, and TCP_QUICKACK). - This option is not available under windows. + This option is not available under windows, and has no effect + when connecting through a Unix domain socket. :param can_filters: See :meth:`can.BusABC.set_filters`. """ + if unix_socket_path is not None: + if host is not None or port is not None: + raise can.CanInitializationError( + "'host'/'port' and 'unix_socket_path' are mutually exclusive; " + "pass either a TCP address or a Unix socket path, not both" + ) + if not hasattr(socket, "AF_UNIX"): + raise can.CanInitializationError( + "Unix domain sockets are not available on this platform" + ) + elif host is None or port is None: + raise can.CanInitializationError( + "Either 'host' and 'port', or 'unix_socket_path' must be given " + "to connect to a socketcand server" + ) + self.__host = host self.__port = port + self.__unix_socket_path = unix_socket_path self.__tcp_tune = tcp_tune - self.__socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if unix_socket_path is not None: + self.__socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + if self.__tcp_tune: + self.__tcp_tune = False + log.warning( + "'tcp_tune' does not apply to Unix domain sockets. " + "Setting to False" + ) + else: + self.__socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) if self.__tcp_tune: if os.name == "nt": @@ -247,8 +297,12 @@ def __init__(self, channel, host, port, tcp_tune=False, can_filters=None, **kwar self.__message_buffer = deque() self.__receive_buffer = "" # i know string is not the most efficient here self.channel = channel - self.channel_info = f"socketcand on {channel}@{host}:{port}" - connect_to_server(self.__socket, self.__host, self.__port) + if unix_socket_path is not None: + self.channel_info = f"socketcand on {channel}@{unix_socket_path}" + connect_to_server(self.__socket, self.__unix_socket_path) + else: + self.channel_info = f"socketcand on {channel}@{host}:{port}" + connect_to_server(self.__socket, (self.__host, self.__port)) self._expect_msg("< hi >") log.info( diff --git a/doc/changelog.d/2018.added.rst b/doc/changelog.d/2018.added.rst new file mode 100644 index 000000000..7bc469ff4 --- /dev/null +++ b/doc/changelog.d/2018.added.rst @@ -0,0 +1 @@ +The ``socketcand`` interface can now connect to a socketcand daemon through a Unix domain socket via the new ``unix_socket_path`` argument, as an alternative to a TCP host/port pair. diff --git a/test/test_socketcand.py b/test/test_socketcand.py index 7050b9f20..379e1fd62 100644 --- a/test/test_socketcand.py +++ b/test/test_socketcand.py @@ -1,8 +1,13 @@ #!/usr/bin/env python +import os +import socket +import tempfile +import threading import unittest + import can -from can.interfaces.socketcand import socketcand +from can.interfaces.socketcand import SocketCanDaemonBus, socketcand class TestConvertAsciiMessageToCanMessage(unittest.TestCase): @@ -42,5 +47,121 @@ def test_missing_ending_character(self): self.assertIsNone(msg) +class FakeSocketcandServer(threading.Thread): + """A minimal socketcand server speaking just enough of the protocol for + the bus handshake, one outgoing frame and one incoming frame.""" + + def __init__(self, server_socket): + super().__init__(daemon=True) + self._server_socket = server_socket + self.received = [] + self.frame_to_send = "< frame 123 1680000000.0 01020304 >" + self.error = None + + @staticmethod + def _recv_message(conn, buffer): + while ">" not in buffer: + chunk = conn.recv(1024).decode("ascii") + if not chunk: + raise ConnectionError("client closed the connection") + buffer += chunk + message, _, buffer = buffer.partition(">") + return message + ">", buffer + + def run(self): + try: + conn, _ = self._server_socket.accept() + with conn: + buffer = "" + conn.sendall(b"< hi >") + message, buffer = self._recv_message(conn, buffer) + self.received.append(message) + conn.sendall(b"< ok >") + message, buffer = self._recv_message(conn, buffer) + self.received.append(message) + conn.sendall(b"< ok >") + # record the frame the bus sends us + message, buffer = self._recv_message(conn, buffer) + self.received.append(message) + # then push one frame back to the bus + conn.sendall(self.frame_to_send.encode("ascii")) + # keep the connection open until the client goes away + while conn.recv(1024): + pass + except (ConnectionError, OSError): + pass + except Exception as exc: + self.error = exc + finally: + self._server_socket.close() + + +class TestSocketcandBusConnections(unittest.TestCase): + def _run_bus_roundtrip(self, bus, expected_channel_info_part): + try: + self.assertIn(expected_channel_info_part, bus.channel_info) + bus.send( + can.Message( + arbitration_id=0x123, + data=[1, 2, 3, 4], + is_extended_id=False, + check=True, + ) + ) + msg = bus.recv(timeout=5) + self.assertIsNotNone(msg) + self.assertEqual(msg.arbitration_id, 0x123) + self.assertEqual(msg.data, bytearray([1, 2, 3, 4])) + self.assertEqual(msg.channel, "can0") + finally: + bus.shutdown() + + def test_unix_socket_connection(self): + with tempfile.TemporaryDirectory() as tmp_dir: + socket_path = os.path.join(tmp_dir, "socketcand.sock") + server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server_socket.bind(socket_path) + server_socket.listen(1) + server = FakeSocketcandServer(server_socket) + server.start() + + bus = SocketCanDaemonBus(channel="can0", unix_socket_path=socket_path) + self._run_bus_roundtrip(bus, socket_path) + server.join(timeout=5) + self.assertIsNone(server.error) + self.assertEqual(server.received[0], "< open can0 >") + self.assertEqual(server.received[1], "< rawmode >") + self.assertEqual(server.received[2], "< send 123 4 1 2 3 4 >") + + def test_tcp_connection_still_works(self): + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.bind(("127.0.0.1", 0)) + port = server_socket.getsockname()[1] + server_socket.listen(1) + server = FakeSocketcandServer(server_socket) + server.start() + + bus = SocketCanDaemonBus(channel="can0", host="127.0.0.1", port=port) + self._run_bus_roundtrip(bus, f"127.0.0.1:{port}") + server.join(timeout=5) + self.assertIsNone(server.error) + self.assertEqual(server.received[2], "< send 123 4 1 2 3 4 >") + + def test_unix_socket_and_tcp_address_are_mutually_exclusive(self): + with self.assertRaises(can.CanInitializationError): + SocketCanDaemonBus( + channel="can0", + host="127.0.0.1", + port=29536, + unix_socket_path="/run/socketcand.sock", + ) + + def test_missing_address_raises(self): + with self.assertRaises(can.CanInitializationError): + SocketCanDaemonBus(channel="can0") + with self.assertRaises(can.CanInitializationError): + SocketCanDaemonBus(channel="can0", host="127.0.0.1") + + if __name__ == "__main__": unittest.main()