A uConsole is recognised by four marks, not one
Naming the AIO rested on the uConsole's panel alone. It now takes every mark, from two independent sources: ClockworkPi's uConsole overlay describing the hardware in the device tree -- the cwu50 screen, the AXP228 power chip, the OCP8178 backlight -- and the hardware answering for itself on USB, the built-in keyboard (1eaf:0024, made by ClockworkPI, named uConsole; the ids alone are any LeafLabs Maple). An overlay on the wrong machine has no uConsole keyboard, and the keyboard on another machine has no uConsole overlay. Any one missing, and the radio is listed unnamed on the AIO's wiring, as before. `BuiltinRadio.compatible` gives way to `board`, a test only its machine passes (`is_uconsole`). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
afc906cf2f
commit
5d2c1908e8
3 files changed
+125
-29
No files matched your search
@@ -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
|
||||
|
||||
+61
-16
@@ -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"),
|
||||
)
|
||||
|
||||
+59
-10
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user