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>
914 lines
37 KiB
Python
914 lines
37 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""A LoRa radio on the host's own SPI bus, driven by a node MeshTerm runs only while it runs.
|
|
|
|
Some boards — the uConsole's AIO is the one this was built for — put the radio chip straight
|
|
on the host's SPI bus, with no microcontroller and so no companion firmware to talk to. A
|
|
software node has to run somewhere. The standalone ``meshterm-spi-bridge`` service runs one
|
|
all the time, which keeps the node on the mesh while MeshTerm is closed but holds the radio's
|
|
pins for good, so no other LoRa program can use them until the service is stopped.
|
|
|
|
This is the other shape: **MeshTerm runs the node itself, for exactly as long as it is
|
|
connected.** :class:`SpiRadioDevice` starts the node (:mod:`meshterm.core.radionode`) as a
|
|
child process when it connects, talks to it over the ordinary companion protocol on a
|
|
loopback port — so every feature works exactly as it does against a network companion — and
|
|
ends it when it disconnects. The pins go back when MeshTerm quits, and also when it crashes:
|
|
the node exits with its parent (see :mod:`~meshterm.core.radionode`), and the kernel frees a
|
|
process's GPIO lines and SPI handle when it exits.
|
|
|
|
**Why a child process and not a library call.** The radio library (``openhop-core``) is an
|
|
optional install, deliberately not something every desktop MeshTerm carries, and the
|
|
one-file build can't import it at all. So the node runs under whichever Python *has* the
|
|
library (:func:`find_runtime`): MeshTerm's own when it was installed with the ``spi``
|
|
extra, the bridge's venv, or the ``meshcore-uconsole`` package's. A separate process also
|
|
keeps the node's radio timing off the event loop that draws the screen.
|
|
|
|
**One radio, one identity.** A node's state — its identity key, preferences, channels and
|
|
contacts — lives under ``<config_dir>/radio/<spidev>/``, keyed by the SPI device rather than
|
|
by a profile name, so reaching the same radio from the picker one day and from a profile the
|
|
next is still the same node. The first time a radio is used, the bridge's identity and
|
|
contacts are copied in (:func:`import_bridge_state`) so an existing node keeps its key, and
|
|
with it its place in everyone else's contact list.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import functools
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from collections.abc import Callable, Iterable, Mapping
|
|
from dataclasses import dataclass, replace
|
|
from pathlib import Path
|
|
|
|
from .config import DeviceProfile, SpiWiring
|
|
from .connection import DeviceCommandError, MeshCoreDevice
|
|
from .discovery import TRANSPORT_SERIAL, DiscoveredDevice, spi_device
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
#: The radio library the node needs, as ``pip`` spells it — what every "install it" hint says.
|
|
RUNTIME_PACKAGE = "openhop-core[hardware]"
|
|
|
|
#: How long a starting node gets to report ready. It imports the radio library, brings the
|
|
#: chip up (retrying a busy pin a few times) and starts listening; on a PicoCalc-class board
|
|
#: the import alone is several seconds.
|
|
READY_TIMEOUT_S = 60.0
|
|
|
|
#: How long a node gets to shut down on its own after its stdin closes, before it is sent
|
|
#: SIGTERM, and then again before SIGKILL. Its own shutdown saves contacts and frees the
|
|
#: radio; the signals are for a node that has stopped answering.
|
|
STOP_GRACE_S = 8.0
|
|
TERM_GRACE_S = 3.0
|
|
|
|
#: What the node is seeded with the first time a radio is used and it has no preferences of
|
|
#: its own: MeshCore's US/Canada preset at the AIO's 22 dBm. Afterwards the node's saved
|
|
#: settings rule, edited on Device config like any companion's.
|
|
DEFAULT_SEED: dict[str, object] = {
|
|
"frequency_hz": 910_525_000,
|
|
"bandwidth_hz": 62_500,
|
|
"spreading_factor": 7,
|
|
"coding_rate": 5,
|
|
"tx_power_dbm": 22,
|
|
}
|
|
|
|
#: The standalone bridge's service name and the directory it keeps its state in.
|
|
BRIDGE_SERVICE = "meshterm-spi-bridge.service"
|
|
BRIDGE_NAME = "meshterm-spi-bridge"
|
|
|
|
#: ``MESHCORE_*`` variables the bridge reads, mapped to the node preference each seeds.
|
|
_BRIDGE_RADIO_ENV = {
|
|
"MESHCORE_FREQUENCY": "frequency_hz",
|
|
"MESHCORE_BANDWIDTH": "bandwidth_hz",
|
|
"MESHCORE_SPREADING_FACTOR": "spreading_factor",
|
|
"MESHCORE_CODING_RATE": "coding_rate",
|
|
"MESHCORE_TX_POWER": "tx_power_dbm",
|
|
}
|
|
|
|
|
|
class SpiRadioError(DeviceCommandError):
|
|
"""The node could not be started, worded for the person who has to fix it.
|
|
|
|
A :class:`~meshterm.core.connection.DeviceCommandError`, because that is what the
|
|
startup picker and the connect path pass through as an *actionable* failure — shown
|
|
with its remedy — rather than folding it into "didn't answer".
|
|
|
|
Attributes:
|
|
kind: What went wrong — ``unsupported``, ``runtime``, ``no-spi``, ``no-gpio``,
|
|
``permission``, ``busy``, or ``failed`` (see :func:`explain`).
|
|
"""
|
|
|
|
def __init__(self, kind: str, message: str) -> None:
|
|
"""Carry ``message`` as the error text and ``kind`` as its classification."""
|
|
super().__init__(message)
|
|
self.kind = kind
|
|
|
|
|
|
# --- where things are ----------------------------------------------------------------------
|
|
|
|
|
|
def node_script() -> Path:
|
|
"""The node's source file, which is run by path under another interpreter.
|
|
|
|
It sits beside this module in a checkout and in an install, and the one-file build
|
|
ships it as a data file at the same relative place (``packaging/meshterm.spec``).
|
|
"""
|
|
return Path(__file__).with_name("radionode.py")
|
|
|
|
|
|
def state_dir(config_dir: Path, wiring: SpiWiring) -> Path:
|
|
"""Where the node on ``wiring``'s SPI device keeps its identity, settings and lists."""
|
|
return config_dir / "radio" / Path(wiring.spidev).name
|
|
|
|
|
|
def _on_linux() -> bool:
|
|
"""Whether this is Linux, the one place a radio on the SPI bus can be driven from."""
|
|
return sys.platform.startswith("linux")
|
|
|
|
|
|
def _xdg_data_home() -> Path:
|
|
"""``$XDG_DATA_HOME``, or ``~/.local/share`` where it is unset."""
|
|
raw = os.environ.get("XDG_DATA_HOME", "").strip()
|
|
return Path(raw) if raw else Path.home() / ".local" / "share"
|
|
|
|
|
|
def spi_present(wiring: SpiWiring) -> bool:
|
|
"""Whether ``wiring``'s SPI device node exists on this machine (Linux only)."""
|
|
return _on_linux() and os.path.exists(wiring.spidev)
|
|
|
|
|
|
#: The labels the kernel gives the GPIO controller behind a Raspberry Pi's 40-pin header, in
|
|
#: the order they are looked for: the RP1 a Pi 5 or CM5 carries, then the BCM2711 of a Pi 4
|
|
#: or CM4, then the BCM2835 family of the older boards. Its *number* is no fact about the
|
|
#: board: chip 0 on a CM4, chip 4 on a CM5 under the kernels it shipped with, chip 0 again
|
|
#: once Raspberry Pi aliased the RP1 there (raspberrypi/linux#6144), and on some later
|
|
#: kernels another number still, as probe order moved it. The label is what stays put.
|
|
HEADER_GPIO_LABELS = ("pinctrl-rp1", "pinctrl-bcm2711", "pinctrl-bcm2835")
|
|
|
|
#: ``GPIO_GET_CHIPINFO_IOCTL``: ``_IOR(0xB4, 0x01, struct gpiochip_info)``, which fills a
|
|
#: 68-byte ``{char name[32]; char label[32]; u32 lines}`` for the chip the fd is open on.
|
|
_GPIO_GET_CHIPINFO_IOCTL = 0x8044B401
|
|
|
|
|
|
def gpio_chip_labels(dev: Path = Path("/dev")) -> dict[int, str]:
|
|
"""Each ``/dev/gpiochip<n>``'s label as the kernel reports it, by ``n``.
|
|
|
|
Asked of the GPIO character device itself, which needs nothing installed and only the
|
|
read access the radio's pins need anyway. Empty off Linux, and a chip that can't be
|
|
opened (no ``gpio`` group yet) is simply left out — the radio's own start reports that.
|
|
"""
|
|
try:
|
|
import fcntl
|
|
except ImportError: # not a POSIX system: no GPIO character devices to ask
|
|
return {}
|
|
labels: dict[int, str] = {}
|
|
for path in dev.glob("gpiochip[0-9]*"):
|
|
try:
|
|
number = int(path.name.removeprefix("gpiochip"))
|
|
fd = os.open(path, os.O_RDONLY)
|
|
except (ValueError, OSError):
|
|
continue
|
|
try:
|
|
info = bytearray(68)
|
|
fcntl.ioctl(fd, _GPIO_GET_CHIPINFO_IOCTL, info)
|
|
except OSError:
|
|
continue
|
|
finally:
|
|
os.close(fd)
|
|
labels[number] = bytes(info[32:64]).split(b"\0", 1)[0].decode("ascii", "replace")
|
|
return labels
|
|
|
|
|
|
def header_gpio_chip(labels: Mapping[int, str]) -> int | None:
|
|
"""The chip carrying a Pi's 40-pin header, by its label (:data:`HEADER_GPIO_LABELS`)."""
|
|
for wanted in HEADER_GPIO_LABELS:
|
|
for number in sorted(labels):
|
|
if labels[number] == wanted:
|
|
return number
|
|
return None
|
|
|
|
|
|
def gpio_chip(wiring: SpiWiring) -> int:
|
|
"""The ``/dev/gpiochip<n>`` the wiring's pins are on.
|
|
|
|
The profile's ``gpio_chip`` where it names one. Otherwise the Raspberry Pi header's
|
|
controller, found by its label — so a uConsole's CM4 and CM5 both work on the AIO's
|
|
defaults, whatever number the kernel gave the chip this boot — and chip 0 where no
|
|
controller carries a header label, which is what the default meant before it looked.
|
|
"""
|
|
if wiring.gpio_chip >= 0:
|
|
return wiring.gpio_chip
|
|
found = header_gpio_chip(gpio_chip_labels())
|
|
return 0 if found is None else found
|
|
|
|
|
|
#: The M5Stack Cap LoRa-1262 on a Cardputer Zero's 14-pin EXT header, confirmed on the
|
|
#: hardware and against M5's own factory test (which names the same three lines): the SX1262
|
|
#: on the second chip select of SPI0, which it shares with the panel on the first. The Cap's
|
|
#: header is unpowered until the device tree's ``ext_5v_out`` switch is on, and two of its
|
|
#: pins (reset, interrupt) reach the chip only while ``ext_usb_gpio_fun`` routes them to GPIO
|
|
#: rather than to USB. Its PI4IOE5V6408 at 0x43 on I2C 1 enables the RF path from pin P0,
|
|
#: which Meshtastic's Cardputer variant drives the same way. DIO2 switches the antenna and
|
|
#: DIO3 feeds a 1.8 V TCXO, as on the AIO. Its GPS talks NMEA at 115200 on the header's
|
|
#: UART, which is the Pi's ``serial0`` (``/dev/ttyS0`` on the Zero) — powered, like the
|
|
#: radio, by ``ext_5v_out``, so it speaks only while the node runs.
|
|
CARDPUTER_ZERO_CAP = SpiWiring(
|
|
bus_id=0,
|
|
cs_id=1,
|
|
reset_pin=26,
|
|
busy_pin=22,
|
|
irq_pin=23,
|
|
leds=("ext_5v_out=1", "ext_usb_gpio_fun=0"),
|
|
pi4io_bus=1,
|
|
pi4io_address=0x43,
|
|
pi4io_high=(0,),
|
|
gps_port="/dev/serial0",
|
|
gps_baud=115200,
|
|
)
|
|
|
|
|
|
#: 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
|
|
def device_tree_compatibles(root: Path = DEVICE_TREE) -> frozenset[str]:
|
|
"""Every ``compatible`` string in the booted device tree; empty where there is none.
|
|
|
|
Read once a run: the tree is fixed at boot, and the device screen asks on every pass.
|
|
"""
|
|
found: set[str] = set()
|
|
try:
|
|
for path in root.rglob("compatible"):
|
|
try:
|
|
found.update(
|
|
v.decode("ascii", "replace") for v in path.read_bytes().split(b"\0") if v
|
|
)
|
|
except OSError:
|
|
continue
|
|
except OSError:
|
|
pass
|
|
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.
|
|
|
|
Attributes:
|
|
name: What the device screen calls it, and what its node reports as its model;
|
|
empty for the plain "SPI radio".
|
|
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.
|
|
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 = ""
|
|
board: Callable[[], bool] | None = None
|
|
|
|
def present(self) -> bool:
|
|
"""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 (self.board is None or self.board())
|
|
)
|
|
|
|
|
|
#: 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(), board=is_uconsole),
|
|
BuiltinRadio("", SpiWiring()),
|
|
BuiltinRadio("Cap LoRa-1262", CARDPUTER_ZERO_CAP, marker="/sys/class/leds/ext_5v_out"),
|
|
)
|
|
|
|
|
|
def board_name(wiring: SpiWiring) -> str:
|
|
"""The name of the shipped board ``wiring``'s radio is on, or ``""`` for none.
|
|
|
|
What the node reports as its model, so the Cap reads as "Cap LoRa-1262" and the AIO
|
|
as "uConsole AIO" wherever a device's model is shown. Matched by the SPI node on this
|
|
machine's board rather than by the whole wiring, so a profile that adjusts the Cap's
|
|
pins is still the Cap.
|
|
"""
|
|
for radio in BUILTIN_RADIOS:
|
|
if radio.name and radio.wiring.spidev == wiring.spidev and radio.present():
|
|
return radio.name
|
|
return ""
|
|
|
|
|
|
def builtin_wiring(spidev: str) -> SpiWiring | None:
|
|
"""The shipped wiring for the radio on ``spidev``, when this machine is its board."""
|
|
for radio in BUILTIN_RADIOS:
|
|
if radio.wiring.spidev == spidev and radio.present():
|
|
return radio.wiring
|
|
return None
|
|
|
|
|
|
def spi_radios(
|
|
profiles: Mapping[str, DeviceProfile] | None, listed: Iterable[DiscoveredDevice] = ()
|
|
) -> list[DiscoveredDevice]:
|
|
"""The radios on this machine's own SPI bus, as devices — THE one answer to "which are here".
|
|
|
|
One per ``transport = "spi"`` profile whose ``/dev/spidev*`` node exists, named by the
|
|
profile; and, where no profile covers its node, one per :data:`BUILTIN_RADIOS` entry
|
|
whose board this machine is — the uConsole AIO whenever its node exists, the Cardputer
|
|
Zero's Cap on a Cardputer Zero. Nothing is probed — a radio on the bus has no node
|
|
running until one is chosen — so a radio is listed exactly when there is a device node
|
|
to open. The device screen and ``meshterm devices`` both list from here, so the two can
|
|
never disagree about what is attached.
|
|
|
|
Args:
|
|
profiles: The configured profiles, or ``None`` when none are loaded.
|
|
listed: Devices already gathered; a radio among them is not listed twice.
|
|
|
|
Returns:
|
|
One SPI :class:`~meshterm.core.discovery.DiscoveredDevice` per radio not in ``listed``.
|
|
"""
|
|
seen = {d.stable_id for d in listed}
|
|
radios: list[DiscoveredDevice] = []
|
|
for wiring, name in _present_wirings(profiles):
|
|
device = spi_device(wiring, name=name)
|
|
if device.stable_id in seen:
|
|
continue
|
|
seen.add(device.stable_id)
|
|
radios.append(device)
|
|
return radios
|
|
|
|
|
|
def _present_wirings(
|
|
profiles: Mapping[str, DeviceProfile] | None,
|
|
) -> list[tuple[SpiWiring, str]]:
|
|
"""Each radio on this machine's SPI bus as ``(wiring, name)``: profiles first, then boards."""
|
|
wirings = [(p.spi or SpiWiring(), p.name) for p in (profiles or {}).values() if p.is_spi]
|
|
wirings = [(w, name) for w, name in wirings if spi_present(w)]
|
|
covered = {wiring.spidev for wiring, _name in wirings}
|
|
for builtin in BUILTIN_RADIOS:
|
|
if builtin.wiring.spidev not in covered and builtin.present():
|
|
wirings.append((builtin.wiring, builtin.name))
|
|
covered.add(builtin.wiring.spidev)
|
|
return wirings
|
|
|
|
|
|
def without_radio_ports(
|
|
devices: Iterable[DiscoveredDevice], profiles: Mapping[str, DeviceProfile] | None
|
|
) -> list[DiscoveredDevice]:
|
|
"""``devices`` less the serial ports a radio on the SPI bus owns — its board's GPS.
|
|
|
|
A board's GPS answers a port scan like any serial device, and with nothing to name it
|
|
(no USB identity, so pyserial calls it ``n/a``) it was listed beside the radio as a
|
|
companion to connect to: on a Cardputer Zero with its Cap, a row of its own above or
|
|
below the Cap, and with nothing remembered, the one port a command-line connect would
|
|
pick. It is the radio's node that reads it, so wherever that radio is listed, the port
|
|
is not. Matched by the port's real path, since a wiring names the stable alias
|
|
(``/dev/serial0``) and the scan the device it points at (``/dev/ttyS0``).
|
|
"""
|
|
owned = {
|
|
os.path.realpath(wiring.gps_port)
|
|
for wiring, _name in _present_wirings(profiles)
|
|
if wiring.gps_port
|
|
}
|
|
return [
|
|
d
|
|
for d in devices
|
|
if not (d.transport == TRANSPORT_SERIAL and d.port and os.path.realpath(d.port) in owned)
|
|
]
|
|
|
|
|
|
# --- finding a Python with the radio library -----------------------------------------------
|
|
|
|
#: Each interpreter probed this session: path -> whether it can import the radio library.
|
|
_probed: dict[str, bool] = {}
|
|
|
|
|
|
def _runtime_candidates(wiring: SpiWiring) -> list[str]:
|
|
"""Interpreters worth probing for the radio library, most specific first."""
|
|
candidates: list[str] = []
|
|
if wiring.python:
|
|
candidates.append(os.path.expanduser(wiring.python))
|
|
if not getattr(sys, "frozen", False):
|
|
candidates.append(sys.executable) # MeshTerm's own, installed with the ``spi`` extra
|
|
data = _xdg_data_home()
|
|
candidates += [
|
|
str(data / BRIDGE_NAME / "venv" / "bin" / "python"),
|
|
"/opt/venvs/meshcore-uconsole/bin/python",
|
|
]
|
|
system = shutil.which("python3")
|
|
if system:
|
|
candidates.append(system)
|
|
seen: set[str] = set()
|
|
return [c for c in candidates if c and not (c in seen or seen.add(c))]
|
|
|
|
|
|
def _can_import_runtime(python: str) -> bool:
|
|
"""Whether ``python`` can import ``openhop_core`` (one short subprocess, then cached)."""
|
|
if python in _probed:
|
|
return _probed[python]
|
|
ok = False
|
|
if os.path.exists(python):
|
|
try:
|
|
result = subprocess.run(
|
|
[python, "-c", "import openhop_core"],
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
env=child_env(),
|
|
timeout=30,
|
|
check=False,
|
|
)
|
|
ok = result.returncode == 0
|
|
except (OSError, subprocess.SubprocessError):
|
|
ok = False
|
|
_probed[python] = ok
|
|
return ok
|
|
|
|
|
|
def find_runtime(wiring: SpiWiring) -> str:
|
|
"""An interpreter that can run the node, or raise :class:`SpiRadioError` (``runtime``).
|
|
|
|
A ``python`` named in the wiring is the only candidate when it is set — naming one is a
|
|
decision, and quietly falling back past it would hide that it doesn't work.
|
|
"""
|
|
candidates = _runtime_candidates(wiring)
|
|
if wiring.python:
|
|
candidates = candidates[:1]
|
|
for python in candidates:
|
|
if _can_import_runtime(python):
|
|
return python
|
|
if wiring.python:
|
|
raise SpiRadioError("runtime", f"{wiring.python} cannot import openhop_core")
|
|
raise SpiRadioError("runtime", "no Python on this machine has the radio library")
|
|
|
|
|
|
def child_env() -> dict[str, str]:
|
|
"""The environment for a program MeshTerm starts, minus what a frozen build injected.
|
|
|
|
The one-file build's bootloader leaves its own variables behind (``_PYI_*``,
|
|
``_MEIPASS2``) and points ``LD_LIBRARY_PATH`` at its unpacked libraries; another Python
|
|
started with those loads MeshTerm's bundled copies of libssl and friends instead of its
|
|
own. PyInstaller keeps the original under ``LD_LIBRARY_PATH_ORIG``, which is put back.
|
|
"""
|
|
env = {k: v for k, v in os.environ.items() if not (k.startswith("_PYI") or k == "_MEIPASS2")}
|
|
if getattr(sys, "frozen", False):
|
|
original = env.pop("LD_LIBRARY_PATH_ORIG", None)
|
|
if original is not None:
|
|
env["LD_LIBRARY_PATH"] = original
|
|
else:
|
|
env.pop("LD_LIBRARY_PATH", None)
|
|
env["PYTHONUNBUFFERED"] = "1"
|
|
return env
|
|
|
|
|
|
# --- who else has the radio ----------------------------------------------------------------
|
|
|
|
|
|
def radio_holder() -> str | None:
|
|
"""Name the program already holding the radio, when it is one we know how to stop.
|
|
|
|
Asked before starting the node, because the node's own answer to a held pin is a bare
|
|
"busy" after several seconds of retries, and the two usual holders each have a one-line
|
|
fix worth saying instead.
|
|
"""
|
|
if not _on_linux():
|
|
return None
|
|
systemctl = shutil.which("systemctl")
|
|
if systemctl:
|
|
try:
|
|
active = subprocess.run(
|
|
[systemctl, "--user", "is-active", "--quiet", BRIDGE_SERVICE],
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=5,
|
|
check=False,
|
|
)
|
|
if active.returncode == 0:
|
|
return "bridge"
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
for cmdline in Path("/proc").glob("[0-9]*/cmdline"):
|
|
try:
|
|
argv = cmdline.read_bytes().split(b"\0")
|
|
except OSError:
|
|
continue
|
|
if any(os.path.basename(a) == b"meshcore-console" for a in argv[:3]):
|
|
return "console"
|
|
return None
|
|
|
|
|
|
def explain(kind: str, detail: str, wiring: SpiWiring, holder: str | None = None) -> str:
|
|
"""The sentence a failed start is reported with: what is wrong and what to do about it."""
|
|
if kind == "busy" or holder:
|
|
if holder == "bridge":
|
|
return (
|
|
f"the {BRIDGE_NAME} service has the radio — stop it with "
|
|
f"`systemctl --user stop {BRIDGE_NAME}` (and `disable` it to keep it stopped)"
|
|
)
|
|
if holder == "console":
|
|
return "meshcore-console has the radio open — close it and connect again"
|
|
return (
|
|
"another program has the radio's pins "
|
|
f"(`sudo lsof /dev/gpiochip{gpio_chip(wiring)}` shows which)"
|
|
)
|
|
if kind == "runtime":
|
|
return (
|
|
f"{detail}. Install it with `pipx inject mesh-term '{RUNTIME_PACKAGE}'`, or into a "
|
|
"venv of its own and name that venv's python as `python` in the profile's spi table"
|
|
)
|
|
if kind == "no-spi":
|
|
return (
|
|
f"{wiring.spidev} does not exist — enable SPI (on a uConsole, add "
|
|
"`dtoverlay=spi1-1cs` to /boot/firmware/config.txt) and reboot"
|
|
)
|
|
if kind == "no-gpio":
|
|
return f"/dev/gpiochip{gpio_chip(wiring)} does not exist — check `gpio_chip` in the profile"
|
|
if kind == "no-i2c":
|
|
return (
|
|
f"/dev/i2c-{wiring.pi4io_bus} does not exist — enable I2C "
|
|
"(`dtparam=i2c_arm=on` in /boot/firmware/config.txt) and reboot"
|
|
)
|
|
if kind == "absent":
|
|
return f"{detail} — is the radio attached, and seated the right way round?"
|
|
if kind == "permission":
|
|
groups = "spi,gpio,i2c" if wiring.pi4io_bus >= 0 else "spi,gpio"
|
|
return (
|
|
f"{detail} — add yourself to the {groups.replace(',', ', ')} groups "
|
|
f"(`sudo usermod -aG {groups} $USER`) and log in again"
|
|
)
|
|
if kind == "unsupported":
|
|
return detail
|
|
return f"the radio node failed: {detail}"
|
|
|
|
|
|
# --- first use: carrying over the bridge's node --------------------------------------------
|
|
|
|
|
|
def _bridge_radio_seed() -> dict[str, int]:
|
|
"""The radio settings the bridge service runs with, from its unit's environment.
|
|
|
|
The bridge takes its frequency, preset and power from ``MESHCORE_*`` variables, usually
|
|
set in a drop-in; a node that inherits the bridge's identity should come up on the same
|
|
channel of air, not on the default preset. Empty when there is no such service.
|
|
"""
|
|
systemctl = shutil.which("systemctl")
|
|
if not systemctl:
|
|
return {}
|
|
try:
|
|
shown = subprocess.run(
|
|
[systemctl, "--user", "show", "--property=Environment", BRIDGE_SERVICE],
|
|
stdin=subprocess.DEVNULL,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
timeout=5,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return {}
|
|
seed: dict[str, int] = {}
|
|
for word in shown.stdout.removeprefix("Environment=").split():
|
|
name, _, value = word.partition("=")
|
|
if name in _BRIDGE_RADIO_ENV:
|
|
try:
|
|
seed[_BRIDGE_RADIO_ENV[name]] = int(value)
|
|
except ValueError:
|
|
continue
|
|
return seed
|
|
|
|
|
|
def import_bridge_state(state: Path) -> list[str]:
|
|
"""Copy the bridge's node into a radio's state directory the first time it is used.
|
|
|
|
Runs only while the state directory has no identity yet, and copies rather than moves, so
|
|
the bridge still works for anyone who goes back to it. The identity is looked for where
|
|
the bridge itself looks, in its order: the ``meshcore-uconsole`` GUI's key first (the
|
|
bridge shares it), then the bridge's own. Contacts come from the bridge's snapshot, whose
|
|
records are the radio library's own contact dicts, so they load unchanged. Radio settings
|
|
are seeded from the service's environment, and the node keeps the bridge's name.
|
|
|
|
Args:
|
|
state: The radio's state directory.
|
|
|
|
Returns:
|
|
What was carried over, for the log — empty when nothing was.
|
|
"""
|
|
if (state / "identity.key").exists():
|
|
return []
|
|
data = _xdg_data_home()
|
|
bridge = data / BRIDGE_NAME
|
|
carried: list[str] = []
|
|
for key in (data / "meshcore-uconsole" / "identity.key", bridge / "identity.key"):
|
|
if key.is_file():
|
|
state.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(key, state / "identity.key")
|
|
os.chmod(state / "identity.key", 0o600)
|
|
carried.append(f"identity from {key}")
|
|
break
|
|
if not carried:
|
|
return []
|
|
contacts = bridge / "contacts.json"
|
|
if contacts.is_file() and not (state / "contacts.json").exists():
|
|
shutil.copyfile(contacts, state / "contacts.json")
|
|
carried.append(f"contacts from {contacts}")
|
|
if not (state / "prefs.json").exists():
|
|
prefs: dict[str, object] = {"node_name": "uConsole", **_bridge_radio_seed()}
|
|
(state / "prefs.json").write_text(json.dumps(prefs, indent=2), encoding="utf-8")
|
|
carried.append("the bridge's name and radio settings")
|
|
return carried
|
|
|
|
|
|
# --- the node process ----------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class NodeProcess:
|
|
"""One running node: the child process and the loopback port it serves on.
|
|
|
|
Attributes:
|
|
process: The child.
|
|
port: The loopback TCP port its frame server listens on.
|
|
public_key: The node's public key, hex, as it reported it.
|
|
log_path: Where its stderr goes.
|
|
"""
|
|
|
|
process: asyncio.subprocess.Process
|
|
port: int
|
|
public_key: str
|
|
log_path: Path
|
|
|
|
@property
|
|
def alive(self) -> bool:
|
|
"""Whether the child is still running."""
|
|
return self.process.returncode is None
|
|
|
|
async def stop(self) -> None:
|
|
"""End the node and wait for it: close its stdin, then SIGTERM, then SIGKILL.
|
|
|
|
Closing stdin is the polite request — the node saves its contacts and frees the radio
|
|
on the way out. The signals are for a node that has stopped listening, and each wait
|
|
is bounded so a wedged child can never hold up a quit.
|
|
"""
|
|
proc = self.process
|
|
if proc.returncode is not None:
|
|
return
|
|
if proc.stdin is not None:
|
|
proc.stdin.close()
|
|
for grace, escalate in ((STOP_GRACE_S, proc.terminate), (TERM_GRACE_S, proc.kill)):
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=grace)
|
|
return
|
|
except asyncio.TimeoutError:
|
|
try:
|
|
escalate()
|
|
except ProcessLookupError:
|
|
return
|
|
await proc.wait()
|
|
|
|
|
|
def _rotate(log_path: Path) -> None:
|
|
"""Keep the previous run's node log beside this one's, and no more."""
|
|
if log_path.exists():
|
|
try:
|
|
log_path.replace(log_path.with_suffix(".log.1"))
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
async def start_node(wiring: SpiWiring, state: Path, *, node_name: str) -> NodeProcess:
|
|
"""Start the node on ``wiring``'s radio and wait until it is listening.
|
|
|
|
Args:
|
|
wiring: How the radio is wired.
|
|
state: The radio's state directory.
|
|
node_name: The name a brand-new node advertises until it is renamed.
|
|
|
|
Returns:
|
|
The running node.
|
|
|
|
Raises:
|
|
SpiRadioError: If it can't be started, with the reason already worded.
|
|
"""
|
|
if not _on_linux():
|
|
raise SpiRadioError("unsupported", "a radio on the SPI bus needs Linux")
|
|
# The chip the pins are on, found by label where the profile names none: settled once
|
|
# here, so the node, every error message and the `lsof` hint all name the same chip.
|
|
wiring = replace(wiring, gpio_chip=await asyncio.to_thread(gpio_chip, wiring))
|
|
# Each of these runs a short subprocess or two; none of them belongs on the event loop
|
|
# that is drawing the "connecting" card.
|
|
holder = await asyncio.to_thread(radio_holder)
|
|
if holder:
|
|
raise SpiRadioError("busy", explain("busy", "", wiring, holder))
|
|
try:
|
|
python = await asyncio.to_thread(find_runtime, wiring)
|
|
except SpiRadioError as exc:
|
|
raise SpiRadioError(exc.kind, explain(exc.kind, str(exc), wiring)) from None
|
|
|
|
state.mkdir(parents=True, exist_ok=True)
|
|
for line in await asyncio.to_thread(import_bridge_state, state):
|
|
_log.info("spi radio: carried over %s", line)
|
|
config = {
|
|
"state_dir": str(state),
|
|
"wiring": {
|
|
"bus_id": wiring.bus_id,
|
|
"cs_id": wiring.cs_id,
|
|
"cs_pin": wiring.cs_pin,
|
|
"gpio_chip": wiring.gpio_chip,
|
|
"use_gpiod_backend": wiring.use_gpiod_backend,
|
|
"reset_pin": wiring.reset_pin,
|
|
"busy_pin": wiring.busy_pin,
|
|
"irq_pin": wiring.irq_pin,
|
|
"txen_pin": wiring.txen_pin,
|
|
"rxen_pin": wiring.rxen_pin,
|
|
"en_pins": list(wiring.en_pins) or None,
|
|
"leds": list(wiring.leds),
|
|
"pi4io_bus": wiring.pi4io_bus,
|
|
"pi4io_address": wiring.pi4io_address,
|
|
"pi4io_high": list(wiring.pi4io_high),
|
|
"use_dio2_rf": wiring.use_dio2_rf,
|
|
"use_dio3_tcxo": wiring.use_dio3_tcxo,
|
|
"is_waveshare": wiring.is_waveshare,
|
|
"gps_port": wiring.gps_port,
|
|
"gps_baud": wiring.gps_baud,
|
|
},
|
|
"seed": {"node_name": node_name, **DEFAULT_SEED},
|
|
"model": board_name(wiring),
|
|
}
|
|
log_path = state / "node.log"
|
|
_rotate(log_path)
|
|
_log.info("spi radio: starting the node under %s (log %s)", python, log_path)
|
|
with log_path.open("wb") as log_file:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
python,
|
|
str(node_script()),
|
|
"--config",
|
|
json.dumps(config),
|
|
stdin=asyncio.subprocess.PIPE,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=log_file,
|
|
env=child_env(),
|
|
start_new_session=True, # ^C in the terminal is MeshTerm's to handle, not the node's
|
|
)
|
|
node = NodeProcess(process=proc, port=0, public_key="", log_path=log_path)
|
|
try:
|
|
line = await asyncio.wait_for(proc.stdout.readline(), timeout=READY_TIMEOUT_S)
|
|
except asyncio.CancelledError:
|
|
proc.kill() # abandoned mid-start (a probe's own deadline): nothing may keep the radio
|
|
raise
|
|
except asyncio.TimeoutError:
|
|
await node.stop()
|
|
detail = f"it did not start within {READY_TIMEOUT_S:.0f}s (see {log_path})"
|
|
raise SpiRadioError("failed", explain("failed", detail, wiring)) from None
|
|
try:
|
|
status = json.loads(line) if line else {}
|
|
except ValueError:
|
|
status = {}
|
|
if status.get("event") != "ready":
|
|
await node.stop()
|
|
kind = str(status.get("kind") or "failed")
|
|
detail = str(status.get("message") or f"it exited without a word (see {log_path})")
|
|
holder = await asyncio.to_thread(radio_holder) if kind == "busy" else None
|
|
raise SpiRadioError(kind, explain(kind, detail, wiring, holder))
|
|
node.port = int(status["port"])
|
|
node.public_key = str(status.get("public_key") or "")
|
|
_log.info("spi radio: node %s… ready on 127.0.0.1:%d", node.public_key[:16], node.port)
|
|
return node
|
|
|
|
|
|
# --- the device ----------------------------------------------------------------------------
|
|
|
|
|
|
class SpiRadioDevice(MeshCoreDevice):
|
|
"""A companion connection to a node MeshTerm starts on the host's own radio.
|
|
|
|
Underneath it is exactly a network companion on ``127.0.0.1`` — every command, event
|
|
and trace goes through :class:`MeshCoreDevice`'s TCP path unchanged — with the node's
|
|
lifetime tied to the connection's: :meth:`connect` starts it, :meth:`disconnect` ends
|
|
it, and a reconnect (which disconnects and builds a fresh device) starts a fresh one.
|
|
"""
|
|
|
|
def __init__(self, wiring: SpiWiring, state: Path, *, node_name: str = "MeshTerm") -> None:
|
|
"""Prepare the device; nothing is started until :meth:`connect`.
|
|
|
|
Args:
|
|
wiring: How the radio is wired.
|
|
state: The radio's state directory (see :func:`state_dir`).
|
|
node_name: The name a brand-new node advertises until it is renamed.
|
|
"""
|
|
super().__init__(transport="tcp", host="127.0.0.1", tcp_port=0)
|
|
self._wiring = wiring
|
|
self._state = state
|
|
self._node_name = node_name
|
|
self._node: NodeProcess | None = None
|
|
|
|
@property
|
|
def transport(self) -> str:
|
|
"""``"spi"`` — the loopback socket underneath is an implementation detail."""
|
|
return "spi"
|
|
|
|
@property
|
|
def endpoint(self) -> str:
|
|
"""The SPI device the radio is on, which is how the radio is named and remembered."""
|
|
return self._wiring.spidev
|
|
|
|
@property
|
|
def wiring(self) -> SpiWiring:
|
|
"""How the radio this device drives is wired."""
|
|
return self._wiring
|
|
|
|
async def connect(self) -> None: # noqa: D102 - inherited docstring
|
|
if self._mc is not None:
|
|
return
|
|
if self._node is None or not self._node.alive:
|
|
self._node = await start_node(self._wiring, self._state, node_name=self._node_name)
|
|
self._tcp_port = self._node.port
|
|
try:
|
|
await super().connect()
|
|
except BaseException:
|
|
await self._stop_node()
|
|
raise
|
|
|
|
async def link_present(self) -> bool: # noqa: D102 - inherited docstring
|
|
if self._node is not None and not self._node.alive:
|
|
return False # the node died, so the radio is gone whatever the socket says
|
|
return await super().link_present()
|
|
|
|
async def disconnect(self) -> None: # noqa: D102 - inherited docstring
|
|
try:
|
|
await super().disconnect()
|
|
finally:
|
|
await self._stop_node()
|
|
|
|
async def _stop_node(self) -> None:
|
|
"""End the node, if one is running, and forget it."""
|
|
node, self._node = self._node, None
|
|
if node is not None:
|
|
await node.stop()
|
|
|
|
def _no_response_message(self) -> str:
|
|
"""The node started but never answered as a companion: point at its log."""
|
|
where = self._node.log_path if self._node is not None else self._state / "node.log"
|
|
return f"the radio node on {self._wiring.spidev} did not answer; its log is {where}"
|