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:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-06 00:54:13 -04:00
1 parent afc906cf2f
commit 5d2c1908e8
3 files changed
+125 -29

No files matched your search

+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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"