diff --git a/doc/configuration.rst b/doc/configuration.rst index cb7e4c520..0b271d968 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -4402,6 +4402,77 @@ to achieve the same effect: match: '@ID_PATH': 'pci-0000:05:00.0-usb-3-1.4' +.. _exporter-hub-abstraction: + +USB Hub Abstraction +~~~~~~~~~~~~~~~~~~~ +When a lab uses USB hubs with many ports, the raw ``ID_PATH`` strings in +match entries become long and hard to maintain. The exporter supports a +``hubs`` section that defines USB hubs by name, with a base path and a +mapping from logical port numbers to USB path suffixes. Resources can +then use ``hub`` and ``port`` in their match dict instead of a raw +``ID_PATH``. + +Define hubs at the top level of the exporter configuration: + +.. code-block:: yaml + + hubs: + a: + base: 'pci-0000:00:14.0-usb-0:10' + ports: + 1: '2.1' + 2: '2.2' + 3: '2.3' + # ... + b: + base: 'pci-0000:04:00.0-usb-0:2' + ports: + 1: '1.1' + 2: '1.2' + # ... + +Each hub has a ``base`` path (the PCI path up to the hub's root port) and +a ``ports`` mapping from logical port number to the USB path suffix for +that port. The port suffixes depend on the hub's internal topology and +must be determined for each hub model (for example by plugging a device +into each port and checking ``udevadm info``). + +Resources reference a hub port using ``hub`` and ``port`` in their match +dict. For resources that need an ancestor match (like serial ports), +add ``iface`` to specify the USB interface number: + +.. code-block:: yaml + + board1: + USBSerialPort: + match: + hub: a + port: 3 + iface: '1.0' + + HIDRelay: + index: 4 + match: + hub: a + port: 14 + +When ``iface`` is present, the expansion produces an ``@ID_PATH`` (ancestor +match) with the interface appended after a colon: +``@ID_PATH: pci-0000:00:14.0-usb-0:10.2.3:1.0``. + +When ``iface`` is absent, the expansion produces a plain ``ID_PATH`` (direct +match) with no interface suffix: +``ID_PATH: pci-0000:00:14.0-usb-0:10.1.2``. + +Other match keys (such as ``ID_SERIAL_SHORT``) can be used alongside +``hub``/``port`` and are preserved in the expanded match. Resources that +do not use hub/port matching (e.g. those matched by serial number) are +unaffected. + +The ``hubs`` section is removed from the configuration data after +expansion and does not appear as a resource group. + Templating the Exporter Configuration ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To reduce the amount of repeated declarations when many similar resources diff --git a/doc/usage.rst b/doc/usage.rst index 37527fb40..d5dc86158 100644 --- a/doc/usage.rst +++ b/doc/usage.rst @@ -839,3 +839,49 @@ like this: $ labgrid-client -p example allow sirius/john To remove the allow it is currently necessary to unlock and lock the place. + +Simplifying USB device matching with hubs +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Labs with many USB devices often use multi-port USB hubs, and the raw +``ID_PATH`` strings needed to match each device can be long and error-prone. +The exporter supports a ``hubs`` section in the configuration file that maps +logical hub names and port numbers to USB paths, so that resources can be +described more concisely. + +For example, instead of writing:: + + board1: + USBSerialPort: + match: + '@ID_PATH': 'pci-0000:00:14.0-usb-0:10.2.3:1.0' + +you can define the hub once and reference it by name:: + + hubs: + a: + base: 'pci-0000:00:14.0-usb-0:10' + ports: + 1: '2.1' + 2: '2.2' + 3: '2.3' + + board1: + USBSerialPort: + match: + hub: a + port: 3 + iface: '1.0' + +The ``iface`` field controls both the USB interface suffix and the match +type: when present, the result is an ``@ID_PATH`` ancestor match with the +interface appended (for serial ports and similar multi-interface devices); +when absent, the result is a plain ``ID_PATH`` direct match (for relays, +USB loaders, etc.). + +To determine the port mapping for a hub, plug a device into each port and +check its path with ``udevadm info``. The ``base`` is the common prefix +and each port's suffix is the remainder. + +See :ref:`USB Hub Abstraction ` in the +configuration reference for full details. diff --git a/labgrid/remote/exporter.py b/labgrid/remote/exporter.py index 0e48f1942..e76d3494f 100755 --- a/labgrid/remote/exporter.py +++ b/labgrid/remote/exporter.py @@ -29,6 +29,82 @@ reexec = False +def _expand_hubs(data): + """Expand hub/port references in match dicts to ID_PATH values. + + If the config data contains a top-level 'hubs' key, it is popped and + used to resolve any match dicts that contain 'hub' and 'port' keys + into a full ID_PATH string. + + When 'iface' is also present, the result uses '@ID_PATH' (ancestor + match) with the interface appended after a colon. Without 'iface', + the result uses 'ID_PATH' (direct match) with no interface suffix. + + For example, given:: + + hubs: + a: + base: 'pci-0000:04:00.0-usb-0:2' + ports: + 7: '2.3' + + a match dict ``{'hub': 'a', 'port': 7, 'iface': '1.0'}`` becomes + ``{'@ID_PATH': 'pci-0000:04:00.0-usb-0:2.2.3:1.0'}``. + + Without iface, ``{'hub': 'a', 'port': 7}`` becomes + ``{'ID_PATH': 'pci-0000:04:00.0-usb-0:2.2.3'}``. + """ + hubs = data.pop("hubs", None) + if not hubs: + return + + for group_name, group in data.items(): + if not isinstance(group, dict): + continue + for resource_name, params in group.items(): + if not isinstance(params, dict): + continue + match = params.get("match") + if not isinstance(match, dict): + continue + + hub_name = match.get("hub") + port_num = match.get("port") + if hub_name is None and port_num is None: + continue + if hub_name is None or port_num is None: + raise ExporterError( + f"{group_name}/{resource_name}: 'hub' and 'port' must both be specified in a match" + ) + + hub = hubs.get(hub_name) + if hub is None: + raise ExporterError( + f"{group_name}/{resource_name}: hub '{hub_name}' is not defined in the hubs section" + ) + + ports = hub.get("ports", {}) + # YAML may parse port keys as integers or strings + suffix = ports.get(port_num) + if suffix is None: + suffix = ports.get(str(port_num)) + if suffix is None: + suffix = ports.get(int(port_num)) + if suffix is None: + raise ExporterError( + f"{group_name}/{resource_name}: port {port_num} is not defined in hub '{hub_name}'" + ) + + iface = match.get("iface") + del match["hub"] + del match["port"] + if iface is not None: + del match["iface"] + match["@ID_PATH"] = f"{hub['base']}.{suffix}:{iface}" + else: + match["ID_PATH"] = f"{hub['base']}.{suffix}" + + class ExporterError(Exception): pass @@ -852,6 +928,7 @@ async def run(self) -> None: "name": self.name, } resource_config = ResourceConfig(self.config["resources"], config_template_env) + _expand_hubs(resource_config.data) for group_name, group in resource_config.data.items(): group_name = str(group_name) for resource_name, params in group.items(): diff --git a/tests/test_hub_expansion.py b/tests/test_hub_expansion.py new file mode 100644 index 000000000..a6d775d76 --- /dev/null +++ b/tests/test_hub_expansion.py @@ -0,0 +1,234 @@ +"""Tests for USB hub expansion in the exporter config.""" + +import pytest + +from labgrid.remote.exporter import _expand_hubs, ExporterError + + +def make_data(hubs, groups): + """Build a config data dict with hubs and resource groups.""" + data = {} + if hubs is not None: + data["hubs"] = hubs + data.update(groups) + return data + + +class TestExpandHubs: + def test_with_iface(self): + """hub + port + iface produces @ID_PATH with interface suffix""" + data = make_data( + hubs={"a": {"base": "pci-0000:04:00.0-usb-0:2", "ports": {7: "2.3"}}}, + groups={ + "board1": { + "USBSerialPort": { + "match": { + "hub": "a", + "port": 7, + "iface": "1.0", + } + } + } + }, + ) + _expand_hubs(data) + assert data["board1"]["USBSerialPort"]["match"] == { + "@ID_PATH": "pci-0000:04:00.0-usb-0:2.2.3:1.0", + } + assert "hubs" not in data + + def test_without_iface(self): + """hub + port without iface produces ID_PATH (no @ prefix)""" + data = make_data( + hubs={"a": {"base": "pci-0000:04:00.0-usb-0:2", "ports": {7: "2.3"}}}, + groups={"board1": {"HIDRelay": {"match": {"hub": "a", "port": 7}}}}, + ) + _expand_hubs(data) + assert data["board1"]["HIDRelay"]["match"] == { + "ID_PATH": "pci-0000:04:00.0-usb-0:2.2.3", + } + + def test_multiple_hubs(self): + data = make_data( + hubs={ + "a": {"base": "pci-0000:04:00.0-usb-0:2", "ports": {1: "1.1"}}, + "b": {"base": "pci-0000:05:00.0-usb-0:1", "ports": {3: "3.1"}}, + }, + groups={ + "board1": { + "USBSerialPort": { + "match": { + "hub": "a", + "port": 1, + "iface": "1.0", + } + } + }, + "board2": { + "USBSerialPort": { + "match": { + "hub": "b", + "port": 3, + "iface": "1.0", + } + } + }, + }, + ) + _expand_hubs(data) + assert data["board1"]["USBSerialPort"]["match"]["@ID_PATH"] == "pci-0000:04:00.0-usb-0:2.1.1:1.0" + assert data["board2"]["USBSerialPort"]["match"]["@ID_PATH"] == "pci-0000:05:00.0-usb-0:1.3.1:1.0" + + def test_mixed_iface_and_no_iface(self): + """Serial port with iface and relay without, on the same hub""" + data = make_data( + hubs={"a": {"base": "pci-0:2", "ports": {1: "1.1", 2: "1.2"}}}, + groups={ + "board1": { + "USBSerialPort": { + "match": { + "hub": "a", + "port": 1, + "iface": "1.0", + } + }, + "HIDRelay": {"match": {"hub": "a", "port": 2}}, + } + }, + ) + _expand_hubs(data) + assert data["board1"]["USBSerialPort"]["match"] == { + "@ID_PATH": "pci-0:2.1.1:1.0", + } + assert data["board1"]["HIDRelay"]["match"] == { + "ID_PATH": "pci-0:2.1.2", + } + + def test_string_port_keys(self): + """YAML may parse port keys as strings.""" + data = make_data( + hubs={"a": {"base": "pci-0:2", "ports": {"7": "2.3"}}}, + groups={"g": {"R": {"match": {"hub": "a", "port": 7}}}}, + ) + _expand_hubs(data) + assert data["g"]["R"]["match"]["ID_PATH"] == "pci-0:2.2.3" + + def test_integer_port_keys(self): + """Port keys as integers with string port reference.""" + data = make_data( + hubs={"a": {"base": "pci-0:2", "ports": {7: "2.3"}}}, + groups={"g": {"R": {"match": {"hub": "a", "port": "7"}}}}, + ) + _expand_hubs(data) + assert data["g"]["R"]["match"]["ID_PATH"] == "pci-0:2.2.3" + + def test_preserves_other_match_keys(self): + data = make_data( + hubs={"a": {"base": "pci-0:2", "ports": {1: "1.1"}}}, + groups={ + "g": { + "R": { + "match": { + "hub": "a", + "port": 1, + "iface": "1.0", + "ID_SERIAL_SHORT": "ABC123", + } + } + } + }, + ) + _expand_hubs(data) + match = data["g"]["R"]["match"] + assert match["@ID_PATH"] == "pci-0:2.1.1:1.0" + assert match["ID_SERIAL_SHORT"] == "ABC123" + assert "hub" not in match + assert "port" not in match + assert "iface" not in match + + def test_no_hubs_section(self): + data = make_data( + hubs=None, + groups={"g": {"R": {"match": {"@ID_PATH": "foo"}}}}, + ) + _expand_hubs(data) + assert data["g"]["R"]["match"]["@ID_PATH"] == "foo" + + def test_no_hub_references(self): + """Hubs defined but no resources reference them.""" + data = make_data( + hubs={"a": {"base": "pci-0:2", "ports": {1: "1.1"}}}, + groups={"g": {"R": {"match": {"@ID_PATH": "manual"}}}}, + ) + _expand_hubs(data) + assert data["g"]["R"]["match"]["@ID_PATH"] == "manual" + + def test_undefined_hub(self): + data = make_data( + hubs={"a": {"base": "pci-0:2", "ports": {1: "1.1"}}}, + groups={"g": {"R": {"match": {"hub": "z", "port": 1}}}}, + ) + with pytest.raises(ExporterError, match="hub 'z' is not defined"): + _expand_hubs(data) + + def test_undefined_port(self): + data = make_data( + hubs={"a": {"base": "pci-0:2", "ports": {1: "1.1"}}}, + groups={"g": {"R": {"match": {"hub": "a", "port": 99}}}}, + ) + with pytest.raises(ExporterError, match="port 99 is not defined"): + _expand_hubs(data) + + def test_hub_without_port(self): + data = make_data( + hubs={"a": {"base": "pci-0:2", "ports": {1: "1.1"}}}, + groups={"g": {"R": {"match": {"hub": "a"}}}}, + ) + with pytest.raises(ExporterError, match="must both be specified"): + _expand_hubs(data) + + def test_port_without_hub(self): + data = make_data( + hubs={"a": {"base": "pci-0:2", "ports": {1: "1.1"}}}, + groups={"g": {"R": {"match": {"port": 1}}}}, + ) + with pytest.raises(ExporterError, match="must both be specified"): + _expand_hubs(data) + + def test_location_skipped(self): + """The 'location' key is a string, not a dict — should not crash.""" + data = make_data( + hubs={"a": {"base": "pci-0:2", "ports": {1: "1.1"}}}, + groups={ + "g": { + "location": "lab", + "R": {"match": {"hub": "a", "port": 1}}, + } + }, + ) + _expand_hubs(data) + assert data["g"]["R"]["match"]["ID_PATH"] == "pci-0:2.1.1" + + def test_hubs_removed_from_data(self): + """The hubs section should not appear as a resource group.""" + data = make_data( + hubs={"a": {"base": "pci-0:2", "ports": {1: "1.1"}}}, + groups={"g": {"R": {"match": {"hub": "a", "port": 1}}}}, + ) + _expand_hubs(data) + assert "hubs" not in data + + def test_iface_different_values(self): + """Different interface numbers for different resource types""" + data = make_data( + hubs={"a": {"base": "pci-0:2", "ports": {1: "1.1"}}}, + groups={ + "g": { + "serial0": {"match": {"hub": "a", "port": 1, "iface": "1.0"}}, + "serial1": {"match": {"hub": "a", "port": 1, "iface": "1.1"}}, + } + }, + ) + _expand_hubs(data) + assert data["g"]["serial0"]["match"]["@ID_PATH"] == "pci-0:2.1.1:1.0" + assert data["g"]["serial1"]["match"]["@ID_PATH"] == "pci-0:2.1.1:1.1"