diff --git a/docs/devices/uconsole.md b/docs/devices/uconsole.md index ce9d352..03d1816 100644 --- a/docs/devices/uconsole.md +++ b/docs/devices/uconsole.md @@ -186,12 +186,14 @@ meshterm --spi `--spi` finds the AIO's radio at `/dev/spidev1.0` and starts a node on it. The startup device picker also lists it — a row reading **uConsole AIO (/dev/spidev1.0)** with a 📍 icon — whenever that device node exists, so `meshterm` with no flags gets you there too. -MeshTerm knows it's on a uConsole from the uConsole's own screen, which ClockworkPi's -uConsole overlay declares. Without that overlay, the row reads **SPI radio** instead. +MeshTerm only calls it that once four things all say it's on a uConsole: the uConsole's +screen, power chip, and backlight as ClockworkPi's uConsole overlay declares them, and the +uConsole's own keyboard on USB. If any one is missing, the row reads **SPI radio** instead. You're connected when **Device info** or the dashboard shows a node name instead of a connection error. MeshTerm reports the device model as **uConsole AIO** (or **MeshTerm SPI -node** without the overlay), and the header's connection label reads **SPI**. +node** where it isn't sure it's on a uConsole), and the header's connection label reads +**SPI**. **What happens underneath.** MeshTerm starts the node when it connects and ends it when you disconnect or quit — including a crash: the node exits with MeshTerm, and the kernel diff --git a/meshterm/core/spiradio.py b/meshterm/core/spiradio.py index e18d702..24f92ee 100644 --- a/meshterm/core/spiradio.py +++ b/meshterm/core/spiradio.py @@ -40,7 +40,7 @@ import os import shutil import subprocess import sys -from collections.abc import Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass, replace from pathlib import Path @@ -230,8 +230,9 @@ CARDPUTER_ZERO_CAP = SpiWiring( ) -#: Where Linux exposes the device tree the machine booted with. +#: Where Linux exposes the device tree the machine booted with, and its USB devices. DEVICE_TREE = Path("/sys/firmware/devicetree/base") +USB_DEVICES = Path("/sys/bus/usb/devices") @functools.cache @@ -254,6 +255,58 @@ def device_tree_compatibles(root: Path = DEVICE_TREE) -> frozenset[str]: return frozenset(found) +def usb_devices(root: Path = USB_DEVICES) -> frozenset[tuple[str, str, str, str]]: + """Every USB device attached now, as ``(vendor id, product id, maker, product)``. + + The ids are the lowercase hex sysfs gives; a device that names no maker or product + has ``""`` there. Empty where there is no sysfs. + """ + + def read(path: Path) -> str: + try: + return path.read_text(encoding="utf-8", errors="replace").strip() + except OSError: + return "" + + found: set[tuple[str, str, str, str]] = set() + for device in root.glob("*"): + vendor = read(device / "idVendor") + if vendor: + found.add( + ( + vendor, + read(device / "idProduct"), + read(device / "manufacturer"), + read(device / "product"), + ) + ) + return frozenset(found) + + +#: What ClockworkPi's uConsole overlay puts in the device tree: the uConsole's own 5-inch +#: screen (the DevTerm's is ``cw,cwd686``), its AXP228 power chip, and its backlight. Seen +#: on JP's uConsole, a CM5; matched by compatible, never by path, since the paths move +#: with the Compute Module. +UCONSOLE_TREE = frozenset({"cw,cwu50", "x-powers,axp228", "ocp8178-backlight"}) + +#: The uConsole's built-in keyboard as USB reports it. 1eaf:0024 alone is a generic +#: hobby-board id (LeafLabs' Maple), so its maker and product names are part of the mark. +UCONSOLE_KEYBOARD = ("1eaf", "0024", "ClockworkPI", "uConsole") + + +def is_uconsole() -> bool: + """Whether this machine is certainly a ClockworkPi uConsole. + + A uConsole runs a stock Compute Module, whose model string says only that, so no one + fact will do: every mark must be there, from two independent sources. The device-tree + marks are ClockworkPi's overlay describing the hardware (:data:`UCONSOLE_TREE`); the + keyboard is the hardware answering for itself (:data:`UCONSOLE_KEYBOARD`). An overlay + applied to the wrong machine has no uConsole keyboard, and a uConsole keyboard plugged + into another machine has no uConsole overlay. + """ + return UCONSOLE_TREE <= device_tree_compatibles() and UCONSOLE_KEYBOARD in usb_devices() + + @dataclass(frozen=True) class BuiltinRadio: """A radio whose wiring ships with MeshTerm, listed with no profile where its board is. @@ -264,38 +317,30 @@ class BuiltinRadio: wiring: How it is wired. marker: A path only its board has, for a radio whose ``/dev/spidev*`` node alone would claim every Raspberry Pi with SPI switched on. Empty for none. - compatible: A device-tree ``compatible`` only its board's tree carries, for a board - that marks itself there rather than with a path of its own. Empty for none. + board: A test only its machine passes, for one that has no single path of its + own (:func:`is_uconsole`); ``None`` for none. """ name: str wiring: SpiWiring marker: str = "" - compatible: str = "" + board: Callable[[], bool] | None = None def present(self) -> bool: - """Whether this machine is its board: the SPI node exists, and every marker it has.""" + """Whether this machine is its board: the SPI node exists, and every mark it has.""" return ( spi_present(self.wiring) and (not self.marker or os.path.exists(self.marker)) - and (not self.compatible or self.compatible in device_tree_compatibles()) + and (self.board is None or self.board()) ) -#: The uConsole's own 5-inch screen, as ClockworkPi's uConsole overlay declares it. A -#: uConsole runs a stock Compute Module, whose model string says only that, and its power -#: chip and backlight are the DevTerm's too; the panel is the one part no other machine -#: has (the DevTerm's is ``cw,cwd686``). Seen on JP's uConsole, a CM5, at -#: ``/axi/pcie@1000120000/rp1/dsi@128000/panel@0``: matched by compatible, not by path, -#: since the path moves with the Compute Module. -UCONSOLE_PANEL = "cw,cwu50" - #: The radios MeshTerm knows the wiring of, in the order they are listed; the first that is #: present on a node is the one used there. A profile on the same node wins over any of them. #: The AIO is named only where the machine is certainly a uConsole, and listed unnamed on #: any other board with ``spidev1.0``, where its default wiring is still the best guess. BUILTIN_RADIOS = ( - BuiltinRadio("uConsole AIO", SpiWiring(), compatible=UCONSOLE_PANEL), + BuiltinRadio("uConsole AIO", SpiWiring(), board=is_uconsole), BuiltinRadio("", SpiWiring()), BuiltinRadio("Cap LoRa-1262", CARDPUTER_ZERO_CAP, marker="/sys/class/leds/ext_5v_out"), ) diff --git a/tests/test_spiradio.py b/tests/test_spiradio.py index 60190b5..89ae9a9 100644 --- a/tests/test_spiradio.py +++ b/tests/test_spiradio.py @@ -292,30 +292,79 @@ def test_the_cap_reports_itself_by_name(cardputer: Path) -> None: assert spiradio.board_name(spiradio.CARDPUTER_ZERO_CAP) == "" # some other Pi +#: Every mark of a uConsole, as JP's CM5 uConsole showed them. +_UCONSOLE_MARKS = ( + *sorted(spiradio.UCONSOLE_TREE), + spiradio.UCONSOLE_KEYBOARD, +) + + @pytest.fixture() -def uconsole(monkeypatch: pytest.MonkeyPatch) -> set[str]: - """A machine with the AIO's SPI node; add the uConsole's panel to make it a uConsole.""" - tree: set[str] = {"raspberrypi,5-compute-module", "brcm,bcm2712"} +def uconsole(monkeypatch: pytest.MonkeyPatch) -> set: + """A uConsole with the AIO's SPI node: every mark present, for a test to take one away.""" + marks: set = set(_UCONSOLE_MARKS) + tree = {"raspberrypi,5-compute-module", "brcm,bcm2712"} + usb = {("1d6b", "0002", "Linux", "xHCI Host Controller")} monkeypatch.setattr(spiradio, "spi_present", lambda w: w.spidev == "/dev/spidev1.0") - monkeypatch.setattr(spiradio, "device_tree_compatibles", lambda: frozenset(tree)) - return tree + monkeypatch.setattr( + spiradio, + "device_tree_compatibles", + lambda: frozenset(tree | {m for m in marks if isinstance(m, str)}), + ) + monkeypatch.setattr( + spiradio, + "usb_devices", + lambda: frozenset(usb | {m for m in marks if isinstance(m, tuple)}), + ) + return marks -def test_the_aio_is_named_on_a_uconsole(uconsole: set[str]) -> None: - """The uConsole's own screen in the device tree is what makes the AIO the uConsole AIO.""" - uconsole.add(spiradio.UCONSOLE_PANEL) +def test_the_aio_is_named_on_a_uconsole(uconsole: set) -> None: + """With every mark of a uConsole present, the AIO is the uConsole AIO.""" radios = spiradio.spi_radios({}) assert [(d.port, d.name) for d in radios] == [("/dev/spidev1.0", "uConsole AIO")] assert spiradio.board_name(SpiWiring()) == "uConsole AIO" -def test_the_aio_s_node_elsewhere_stays_unnamed(uconsole: set[str]) -> None: - """Any other Pi with spidev1.0 is listed on the AIO's wiring, but never called one.""" +@pytest.mark.parametrize("missing", _UCONSOLE_MARKS, ids=str) +def test_one_missing_mark_is_enough_to_doubt_it(uconsole: set, missing: object) -> None: + """Any one mark missing and the radio is listed on the AIO's wiring, but never named.""" + uconsole.discard(missing) radios = spiradio.spi_radios({}) assert [(d.port, d.name) for d in radios] == [("/dev/spidev1.0", None)] assert spiradio.board_name(SpiWiring()) == "" +def test_the_keyboard_s_names_are_part_of_its_mark(uconsole: set) -> None: + """1eaf:0024 is any LeafLabs Maple; only ClockworkPi's uConsole keyboard counts.""" + uconsole.discard(spiradio.UCONSOLE_KEYBOARD) + uconsole.add(("1eaf", "0024", "LeafLabs", "Maple")) + assert spiradio.board_name(SpiWiring()) == "" + + +def test_usb_devices_are_read_from_sysfs(tmp_path: Path) -> None: + """Ids, maker and product per device; a device naming none reads as empty there.""" + keyboard = tmp_path / "1-1.1" + keyboard.mkdir() + for name, value in ( + ("idVendor", "1eaf"), + ("idProduct", "0024"), + ("manufacturer", "ClockworkPI"), + ("product", "uConsole"), + ): + (keyboard / name).write_text(value + "\n", encoding="utf-8") + hub = tmp_path / "1-1.4" + hub.mkdir() + (hub / "idVendor").write_text("1a86\n", encoding="utf-8") + (hub / "idProduct").write_text("8091\n", encoding="utf-8") + (tmp_path / "1-1.1-if0").mkdir() # an interface, not a device: no idVendor + assert spiradio.usb_devices(tmp_path) == { + ("1eaf", "0024", "ClockworkPI", "uConsole"), + ("1a86", "8091", "", ""), + } + assert spiradio.usb_devices(tmp_path / "nowhere") == frozenset() + + def test_the_device_tree_is_read_whole(tmp_path: Path) -> None: """Every compatible, at any depth, each of a node's NUL-separated strings.""" panel = tmp_path / "axi" / "dsi@128000" / "panel@0"