diff --git a/doc/configuration.rst b/doc/configuration.rst index cb7e4c520..103d14860 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -3691,6 +3691,35 @@ Arguments: setup/teardown the interface on activate/deactivate. Set this to ``False`` if you are managing the interface externally. +CanInterfaceDriver +~~~~~~~~~~~~~~~~~~ +The :any:`CanInterfaceDriver` provides access to local and remote CAN +interfaces. For remote interfaces, the ``helpers/labgrid-raw-interface`` +must be installed in the PATH on the exporter (see +`RawNetworkInterfaceDriver`_ for details). + +The driver supports: + +- Setting up the interface (requires privileges) +- Sending and receiving frames on the CAN interface +- Configuring filters on the socket + +Bind to: + iface: + - `NetworkInterface`_ + - `RemoteNetworkInterface`_ + - `USBNetworkInterface`_ + +Implements: + - None + +Arguments: + - bitrate (int): The CAN bitrate to use when setting up or verifying the + interface. + - dbitrate (int): optional, CAN-FD data bitrate to use when setting up or + verifying the interface. This enables CAN-FD support on the interface + and the socket. + LAA Drivers ~~~~~~~~~~~ Drivers for devices connected via a `Linaro Automation Appliance (LAA) diff --git a/helpers/labgrid-raw-interface b/helpers/labgrid-raw-interface index 0b5217f18..151b4e3a0 100755 --- a/helpers/labgrid-raw-interface +++ b/helpers/labgrid-raw-interface @@ -15,6 +15,7 @@ import subprocess import json import struct import fcntl +import socket import yaml @@ -103,6 +104,31 @@ def handle_ns_macvtap(options): os.execlp("labgrid-tap-fwd", "labgrid-tap-fwd", str(macvtap_fd.fileno())) +def handle_canpipe(options): + cmd = ["ip", "link", "set", "dev", options.ifname] + + subprocess.check_call(cmd + ["down"]) + + cmd += ["up"] + cmd += ["type", "can"] + cmd += ["bitrate", str(options.bitrate)] + + if options.dbitrate is not None: + cmd += ["dbitrate", str(options.dbitrate)] + cmd += ["fd", "on"] + else: + cmd += ["fd", "off"] + + subprocess.check_call(cmd) + + with socket.socket(socket.PF_CAN, socket.SOCK_RAW | socket.SOCK_NONBLOCK, socket.CAN_RAW) as can: + can.bind((options.ifname,)) + can.setsockopt(socket.SOL_CAN_RAW, socket.CAN_RAW_FD_FRAMES, 1) + + os.set_inheritable(can.fileno(), True) + os.execlp("labgrid-tap-fwd", "labgrid-tap-fwd", str(can.fileno())) + + def main(program, options): if not options.ifname: raise ValueError("Empty interface name.") @@ -116,7 +142,7 @@ def main(program, options): if options.ifname in denylist: raise ValueError(f"Interface name '{options.ifname}' is denied in denylist.") - programs = ["tcpreplay", "tcpdump", "ip", "ethtool", "ns-macvtap"] + programs = ["tcpreplay", "tcpdump", "ip", "ethtool", "ns-macvtap", "canpipe"] if program not in programs: raise ValueError(f"Invalid program {program} called with wrapper, valid programs are: {programs}") @@ -186,6 +212,10 @@ def main(program, options): handle_ns_macvtap(options) return + elif program == "canpipe": + handle_canpipe(options) + return + try: os.execvp(args[0], args) except FileNotFoundError as e: @@ -245,6 +275,11 @@ if __name__ == "__main__": ns_mactap_parser.add_argument("pid", type=int, help="pid of namespace agent") ns_mactap_parser.add_argument("--mac-address", type=str, metavar="ADDRESS", help="Set interface MAC address to ADDRESS") + canpipe_parser = subparsers.add_parser("canpipe") + canpipe_parser.add_argument("ifname", type=str, help="CAN interface name") + canpipe_parser.add_argument("bitrate", type=int, help="CAN interface bitrate") + canpipe_parser.add_argument("dbitrate", type=int, default=None, nargs='?', help="CAN interface FD bitrate") + args = parser.parse_args() try: main(args.program, args) diff --git a/labgrid/driver/__init__.py b/labgrid/driver/__init__.py index d3cd6f55e..d41c65689 100644 --- a/labgrid/driver/__init__.py +++ b/labgrid/driver/__init__.py @@ -54,3 +54,4 @@ LAAUSBGadgetMassStorageDriver, LAAUSBDriver, \ LAAButtonDriver, LAALedDriver, LAATempDriver, LAAWattDriver, \ LAAProviderDriver +from .caninterfacedriver import CanInterfaceDriver diff --git a/labgrid/driver/caninterfacedriver.py b/labgrid/driver/caninterfacedriver.py new file mode 100644 index 000000000..122d5d20b --- /dev/null +++ b/labgrid/driver/caninterfacedriver.py @@ -0,0 +1,295 @@ +import json +import os +import select +import socket +import struct +import subprocess +from typing import List + +from attrs import define, field + +from .common import Driver +from ..factory import target_factory +from ..step import step +from ..util.agentwrapper import AgentWrapper +from ..util.netns import NSSocket +from ..util import Timeout +from ..resource.remote import RemoteNetworkInterface + + +@define +class CanFrame: + """CanFrame - CAN frame header and payload received or sent on the CAN socket""" + + id: int = field(repr=lambda value: value.to_bytes(4).hex()) + "CAN ID of the frame" + + data: bytes = field(repr=lambda value: value.hex(), default=b'') + "List of payload bytes" + + flags: int = field(default=0, kw_only=True) + "Set of socketcan frame flags for frame" + + mtu = 16 + "Serialized data size" + + header = struct.Struct("=IBB2x") + "C structure header format description" + + @property + def len(self) -> int: + "Length of payload data in the frame" + return len(self.data) + + @classmethod + def from_bytes(cls, data: bytes) -> "CanFrame": + """Deserialize frame data received from CAN socket""" + id_, len_, flags = cls.header.unpack_from(data) + return cls(id_, data[8:8+len_], flags=flags) + + def to_bytes(self) -> bytes: + """Serialize frame data to write to CAN socket""" + header = self.header.pack(self.id, self.len, self.flags) + return (header + self.data).ljust(self.mtu, b"\x00") + + +@define +class CanFdFrame(CanFrame): + """CanFdFrame - CAN-FD frame header and payload received or sent on the CAN socket + + See ``CanFrame`` description for details. + """ + + BRS = 0x01 + ESI = 0x02 + FDF = 0x04 + + def __attrs_post_init__(self): + # The FD frame flag is always implied for CAN-FD frames + self.flags |= self.FDF + + mtu = 72 + "Serialized data size" + + +@define +class CanFilter: + """Canfilter - Configuration for a single filter item on the CAN socket""" + + id: int = field(repr=lambda value: value.to_bytes(4).hex()) + "The CAN ID to match against in the filter" + + mask: int | None = field(repr=lambda value: value.to_bytes(4).hex(), default=None) + "The mask to apply on the configured and received CAN ids before comparing their values" + + header = struct.Struct("=II") + + def __attrs_post_init__(self): + if self.mask is None: + self.mask = self.id + + def to_bytes(self) -> bytes: + return self.header.pack(self.id, self.mask) + + +@target_factory.reg_driver +@define(eq=False) +class CanInterfaceDriver(Driver): + """CanNetworkInterface - Read and write CAN frames, and gain access to a CAN interface + + Opens a socket on the bound CAN interface, and provides method to filter, receive, and + send frames using that socket. + + For remote CAN interfaces (i.e. on exporters), frames are piped to/from a local virtual + CAN interface created in a network namespace. External programs that need access to the + interface should be executed using the command prefix exposed in ``namespace_prefix``. + + Args: + bitrate (int): The bitrate to configure on the interface + dbitrate (int): optional, enable CANFD and configure this databitrate + """ + + bindings = { + "iface": {"RemoteNetworkInterface", "NetworkInterface", "USBNetworkInterface"}, + } + bitrate: int = field() + dbitrate: int | None = None + + def __attrs_post_init__(self): + super().__attrs_post_init__() + self.remote_pipe = None + self.local_pipe = None + self.can = None + self.ns = None + + def on_activate(self): + if isinstance(self.iface, RemoteNetworkInterface): + self.can = self.setup_can_pipe() + else: + self.can = self.setup_can() + + if self.dbitrate: + self.can.setsockopt(socket.SOL_CAN_RAW, socket.CAN_RAW_FD_FRAMES, 1) + + self.can.setsockopt(socket.SOL_CAN_RAW, socket.CAN_RAW_RECV_OWN_MSGS, 0) + self.can.bind((self.iface.ifname,)) + + def on_deactivate(self): + self.can = self.can.close() + + if isinstance(self.iface, RemoteNetworkInterface): + self.local_pipe = self.local_pipe.terminate() + self.remote_pipe = self.remote_pipe.terminate() + self.ns = None + + @Driver.check_active + @step(result=True) + def recv(self, timeout: float = 120.0) -> CanFrame | CanFdFrame: + """Receive one CAN frame from the socket + + Returns a CanFrame or CanFdFrame with the received data. + """ + recv_timeout = Timeout(timeout) + + while not recv_timeout.expired: + readable, _, _ = select.select([self.can], [], [], recv_timeout.remaining) + if self.can not in readable: + continue + + data = self.can.recv(CanFdFrame.mtu) + if len(data) == CanFrame.mtu: + return CanFrame.from_bytes(data) + elif len(data) == CanFdFrame.mtu: + return CanFdFrame.from_bytes(data) + + raise TimeoutError( + f"Timeout after {recv_timeout.timeout} seconds while reading from can" + ) + + @Driver.check_active + @step(args=["frame"]) + def send(self, frame: CanFrame | CanFdFrame): + """Transmit one frame on the CAN socket. + + Args: + frame (CanFrame | CanFdFrame): Frame to send on the socket. + """ + self.can.send(frame.to_bytes()) + + @Driver.check_active + @step(args=["filters"], result=True) + def filter(self, filters: List[CanFilter] | None = None): + """Disable or configure a list of filters on the CAN socket. + + Args: + filter(None or [CanFilter]): Pass None to disable all filters, or a list + of filters to configure on the socket. + """ + if filters is None: + data = CanFilter(0, 0).to_bytes() + else: + data = b''.join([f.to_bytes() for f in filters]) + + self.can.setsockopt(socket.SOL_CAN_RAW, socket.CAN_RAW_FILTER, data) + + @Driver.check_active + @property + def namespace_prefix(self) -> List[str]: + if self.ns is None: + return [] + + return self.ns.get_prefix() + + def setup_can(self): + ifname = self.iface.ifname + cmd = ["ip", "-json", "-pretty", "-details", "link", "show", "dev", ifname] + output = subprocess.check_output(cmd) + settings = json.loads(output)[0] + kind = settings.get("linkinfo", {}).get("info_kind") + + if kind == "vcan" and os.getuid() == 0: + # Virtual CAN devices don't require settings + cmd = ["ip", "link", "set", "dev", ifname, "up"] + subprocess.check_call(cmd) + elif kind == "vcan": + # Verify virtual CAN interface is up + if "UP" not in settings.get("flags", []): + raise RuntimeError(f"{ifname} not up") + elif os.getuid() == 0: + # Configure CAN interface according to configuration + cmd_base = ["ip", "link", "set", "dev", ifname] + + subprocess.check_call(cmd_base + ["down"]) + + cmd_args = [ + "up", + "type", "can", + "bitrate", str(self.bitrate), + ] + + if self.dbitrate is not None: + cmd_args += ["dbitrate", str(self.dbitrate)] + cmd_args += ["fd", "on"] + else: + cmd_args += ["fd", "off"] + + subprocess.check_call(cmd_base + cmd_args) + else: + # Verify CAN interface settings + if "UP" not in settings.get("flags", []): + raise RuntimeError(f"{ifname} not up") + + info_data = settings.get("linkinfo", {}).get("info_data", {}) + + bitrate = info_data.get("bittiming", {}).get("bitrate") + if bitrate != self.bitrate: + raise RuntimeError(f"{ifname} bitrate mismatch: expected {self.bitrate}; got {bitrate}") + + dbitrate = info_data.get("data_bittiming", {}).get("bitrate") + if dbitrate != self.dbitrate: + raise RuntimeError(f"{ifname} dbitrate mismatch: expected {self.dbitrate}; got {dbitrate}") + + controlmode = settings.get("linkinfo", {}).get("info_data", {}).get("ctrlmode", []) + if self.dbitrate is not None and "FD" not in controlmode: + raise RuntimeError(f"{ifname} not configured to CAN-FD") + + return socket.socket(socket.PF_CAN, socket.SOCK_RAW | socket.SOCK_NONBLOCK, socket.CAN_RAW) + + def setup_can_pipe(self): + # Start pipe to/from can interface on remote exporter + cmd = self.iface.command_prefix + cmd += ["sudo", "labgrid-raw-interface", "canpipe", self.iface.ifname, str(self.bitrate)] + + if self.dbitrate is not None: + cmd += [str(self.dbitrate)] + + self.remote_pipe = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + + # Start pipe to/from vcan interface locally + wrapper = AgentWrapper() + + self.ns = wrapper.load("netns") + self.ns.unshare() + + _, can_fd = self.ns.create_vcan(self.iface.ifname, self.dbitrate is not None) + + self.local_pipe = subprocess.Popen( + ["labgrid-tap-fwd", str(can_fd)], + stdin=self.remote_pipe.stdout, + stdout=self.remote_pipe.stdin, + pass_fds=(can_fd,), + ) + + self.remote_pipe.stdin.close() + self.remote_pipe.stdout.close() + + # Create namespaced CAN socket to use with vcan interface + ret, fd = self.ns.create_socket(socket.PF_CAN, socket.SOCK_RAW | socket.SOCK_NONBLOCK, socket.CAN_RAW) + if "error" in ret: + raise OSError(*ret["error"]) + + return NSSocket(fileno=fd)._attach_remote_sock(ret["id"], self.ns) diff --git a/labgrid/util/agents/netns.py b/labgrid/util/agents/netns.py index 0c4467d73..430864c08 100644 --- a/labgrid/util/agents/netns.py +++ b/labgrid/util/agents/netns.py @@ -104,6 +104,19 @@ def handle_create_tun(*, address=None): return ("", os.fdopen(os.dup(dev_tun.fileno()))) +def handle_create_vcan(ifname, enable_fd): + subprocess.run(["ip", "link", "add", "dev", ifname, "type", "vcan"], check=True) + subprocess.run(["ip", "link", "set", "dev", ifname, "up"], check=True) + + with socket.socket(socket.PF_CAN, socket.SOCK_RAW | socket.SOCK_NONBLOCK, socket.CAN_RAW) as can: + can.bind((ifname,)) + + if enable_fd: + can.setsockopt(socket.SOL_CAN_RAW, socket.CAN_RAW_FD_FRAMES, 1) + + return ("", os.fdopen(os.dup(can.fileno()))) + + def handle_socket(*args, **kwargs): try: # The socket creation parameters are constant integers defined by the @@ -154,7 +167,7 @@ def handle_get_links(): def handle_get_prefix(): """Returns the command prefix to use to execute commands in the namespace""" - return ["nsenter", "-t", str(os.getpid()), "-U", "-n", "-m", "--preserve-credentials"] + return ["nsenter", "-t", str(os.getpid()), "-U", "-n", "--preserve-credentials"] def handle_get_pid(): @@ -185,6 +198,7 @@ def handle_getaddrinfo(*args, **kwargs): "unshare": handle_unshare, "create_tun": handle_create_tun, "create_socket": handle_socket, + "create_vcan": handle_create_vcan, "get_links": handle_get_links, "get_prefix": handle_get_prefix, "get_pid": handle_get_pid, diff --git a/tests/test_can.py b/tests/test_can.py new file mode 100644 index 000000000..3e7802993 --- /dev/null +++ b/tests/test_can.py @@ -0,0 +1,82 @@ +import socket + +import pytest + +from labgrid.driver import CanInterfaceDriver +from labgrid.driver.caninterfacedriver import CanFdFrame, CanFilter, CanFrame +from labgrid.resource import NetworkInterface + + +FRAMES = ( + CanFrame(0x123, b'\x01'), + CanFrame(0x456, b'\x11\x22\x33\x44\x55\x66\x77\x88'), + CanFrame(0x789, b'\x44\x33\x22\x11'), +) + + +FRAMES_FD = ( + CanFdFrame(0x123, b'\x01'), + CanFdFrame(0x456, b'\x11\x22\x33\x44\x55\x66\x77\x88'), + CanFdFrame(0x789, b'\x44\x33\x22\x11', flags=CanFdFrame.BRS), + CanFdFrame(0x765, b'\xff' * 64), +) + + +def vcan0_exists(): + try: + socket.if_nametoindex("vcan0") + return True + except OSError: + return False + + +pytestmark = pytest.mark.skipif(not vcan0_exists(), reason="Virtual CAN interface not found") + + +@pytest.fixture +def vcan(): + sock = socket.socket(socket.PF_CAN, socket.SOCK_RAW | socket.SOCK_NONBLOCK, socket.CAN_RAW) + sock.bind(("vcan0",)) + + return sock + + +@pytest.mark.parametrize("frame", FRAMES) +def test_can_frames(target, vcan, frame): + iface = NetworkInterface(target, "vcan", "vcan0") + can = CanInterfaceDriver(target, "vcan", "500000") + target.activate(can) + + vcan.send(frame.to_bytes()) + assert can.recv() == frame + + +@pytest.mark.parametrize("frame", FRAMES_FD) +def test_can_frames_fd(target, vcan, frame): + iface = NetworkInterface(target, "vcan", "vcan0") + can = CanInterfaceDriver(target, "vcan", "500000", "2000000") + target.activate(can) + + vcan.setsockopt(socket.SOL_CAN_RAW, socket.CAN_RAW_FD_FRAMES, 1) + + vcan.send(frame.to_bytes()) + assert can.recv() == frame + + +def test_can_filer(target, vcan): + iface = NetworkInterface(target, "vcan", "vcan0") + can = CanInterfaceDriver(target, "vcan", "500000") + target.activate(can) + + can.filter([CanFilter(0x123), CanFilter(0x002, 0x003)]) + + frame = CanFrame(0x123, b'\x01') + vcan.send(frame.to_bytes()) + assert can.recv() == frame + + vcan.send(CanFrame(0x321, b'\x02').to_bytes()) + vcan.send(frame.to_bytes()) + assert can.recv() == frame + + vcan.send(CanFrame(0x012, b'\x03').to_bytes()) + assert can.recv()