Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 63 additions & 9 deletions can/interfaces/socketcand/socketcand.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand All @@ -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
Expand All @@ -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":
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions doc/changelog.d/2018.added.rst
Original file line number Diff line number Diff line change
@@ -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.
123 changes: 122 additions & 1 deletion test/test_socketcand.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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()