Files
MeshTerm/meshterm/core/radionode.py
T
Jean-Pierre MartineauandClaude Opus 5.5 dee4674942 The SPI radio listens for the preamble the mesh actually sends
MeshCore transmits a 32-symbol preamble up to SF8 (16 above), but the
node and the bridge configured the SX1262 for the library's fixed 12.
The chip waits for the sync word only about as long as the preamble it
expects, so it locked onto each packet, gave up, and locked on again
further along the same preamble - decoding a packet only when it
happened to catch the tail. On the uConsole that meant 0 of 5 detected
packets decoded after a send; with 32 symbols, 4 of 4.

The node now derives the preamble from the spreading factor as MeshCore
does, and re-sends it to the listening chip when Device config changes
the SF. preamble_length leaves the SPI wiring table (it was protocol,
never wiring) and is retired; the bridge defaults to the same rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 03:12:52 -04:00

553 lines
22 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The software MeshCore node MeshTerm runs on a radio wired straight to the host.
A board like the uConsole's AIO puts an SX1262 on the host's SPI bus with no
microcontroller and no firmware in front of it, so there is no companion to talk to until
something runs one. This file is that something: MeshTerm starts it as a **child process**
when it connects (:mod:`meshterm.core.spiradio` is the parent's half), talks to it over the
standard companion protocol on a loopback port like any network companion, and ends it when
it disconnects — so the radio's pins are held exactly as long as MeshTerm is using them.
**Self-contained on purpose.** Nothing here imports MeshTerm. The radio library
(``openhop_core``) is an optional install that often lives in a different interpreter than
MeshTerm's — the one-file build cannot import it at all, and on a uConsole it usually sits
in its own venv — so the parent runs *this file* by path under whichever Python has the
library. Standard library plus ``openhop_core``, and nothing else, is the contract that
makes that work; ``tests/test_radionode.py`` holds it.
**What firmware would remember, this remembers.** The library keeps preferences, channels
and contacts in memory only, which is why a node run this way used to come up with no
channels after every restart. Everything a firmware companion keeps in flash is kept in the
state directory the parent names instead:
================= =============================================================
``identity.key`` the node's private seed — its identity on the mesh
``prefs.json`` name, radio settings, TX power, position, the other prefs
``channels.json`` the channel table, slot by slot
``contacts.json`` the contact list
================= =============================================================
**Talking to the parent.** The parent reads exactly one JSON line from this process's
stdout: ``{"event": "ready", "port": …, "public_key": …}`` once the frame server is
listening, or ``{"event": "error", "kind": …, "message": …}`` if the radio could not be
opened. The library prints its own diagnostics to stdout, so the real stdout is set aside
for that one line and everything else is sent to stderr, which the parent keeps as the
node's log.
**Letting go of the radio.** GPIO lines requested through the character device and an open
``spidev`` are the kernel's to release, and it releases them when the process ends however
it ends. The work is making sure the process *does* end with its parent: it exits when its
stdin reaches end-of-file (the parent closing it, or the parent dying and the kernel closing
it), and on Linux it also asks for ``SIGTERM`` the moment its parent goes.
"""
from __future__ import annotations
import argparse
import asyncio
import dataclasses
import json
import logging
import os
import signal
import sys
import threading
from pathlib import Path
from typing import Any
#: The radio library this node runs on. ``pymc_core`` (its name before 2026) is not
#: supported here: it needs three shims that ``openhop_core`` made unnecessary, and the
#: standalone bridge still carries them for anyone on it.
RUNTIME = "openhop_core"
#: How many times ``radio.begin()`` is tried when a GPIO line is busy, and the pause before
#: each retry. A node that just exited released its lines as it went, so the retries are for
#: a line some *other* program is letting go of — not for waiting out a program that holds it.
BEGIN_ATTEMPTS = 3
BEGIN_BACKOFF_S = 1.5
#: The model string the frame server reports, which MeshTerm shows as the device model.
DEVICE_MODEL = "MeshTerm SPI node"
log = logging.getLogger("radionode")
# --- errors the parent is told about -------------------------------------------------------
class NodeError(Exception):
"""A failure to bring the node up, with the ``kind`` the parent words its message by.
Kinds: ``runtime`` (the radio library is missing or too old), ``no-spi`` / ``no-gpio``
(the device node doesn't exist), ``permission`` (it exists but this user can't open it),
``busy`` (another program holds the radio's pins), and ``failed`` (anything else).
"""
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
class _Recent(logging.Handler):
"""Keeps the library's recent error lines, to tell *why* ``radio.begin()`` gave up.
The GPIO manager answers a busy or forbidden pin by logging the reason and calling
``sys.exit``, so the reason exists only as a log line; this is where it is read back.
"""
def __init__(self) -> None:
super().__init__(level=logging.WARNING)
self.lines: list[str] = []
def emit(self, record: logging.LogRecord) -> None:
self.lines.append(record.getMessage())
del self.lines[:-20]
def classify_begin_failure(lines: list[str]) -> str:
"""The error kind a failed ``radio.begin()`` amounts to, from the library's log lines."""
text = " ".join(lines).lower()
if "already in use" in text or "resource busy" in text:
return "busy"
if "permission denied" in text:
return "permission"
return "failed"
# --- the state directory -------------------------------------------------------------------
def _write_json(path: Path, value: Any) -> None:
"""Replace ``path`` with ``value`` as JSON in one rename (a crash keeps the old file)."""
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
tmp.write_text(json.dumps(value, indent=2), encoding="utf-8")
tmp.replace(path)
def _read_json(path: Path) -> Any:
"""``path`` parsed as JSON, or ``None`` when it is missing or unreadable."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
def load_identity_seed(state: Path, mint) -> bytes: # noqa: ANN001 - () -> bytes
"""The node's private seed from ``identity.key``, minting and saving one if there is none.
Args:
state: The node's state directory.
mint: Makes a fresh seed (the library's key generator) when none is saved yet.
"""
path = state / "identity.key"
try:
seed = path.read_bytes()
except OSError:
seed = b""
if seed:
return seed
seed = bytes(mint())
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
tmp.write_bytes(seed)
try:
os.chmod(tmp, 0o600)
except OSError:
pass
tmp.replace(path)
log.info("minted a new node identity at %s", path)
return seed
def prefs_to_json(prefs: Any) -> dict:
"""A ``NodePrefs`` as JSON-safe fields (``bytes`` as hex), for ``prefs.json``."""
out = {}
for field in dataclasses.fields(prefs):
value = getattr(prefs, field.name)
out[field.name] = value.hex() if isinstance(value, (bytes, bytearray)) else value
return out
def apply_saved_prefs(prefs: Any, saved: Any) -> None:
"""Load ``prefs.json`` into a ``NodePrefs`` in place, field by field.
Only fields the library's ``NodePrefs`` still declares are taken, each coerced to the
type its default has, so a field the library dropped is ignored and a hand-edited value
of the wrong type is skipped rather than carried into the radio.
"""
if not isinstance(saved, dict):
return
for field in dataclasses.fields(prefs):
if field.name not in saved:
continue
current = getattr(prefs, field.name)
raw = saved[field.name]
try:
if isinstance(current, (bytes, bytearray)):
value: Any = bytes.fromhex(str(raw))
elif isinstance(current, bool):
value = bool(raw)
elif isinstance(current, int):
value = int(raw)
elif isinstance(current, float):
value = float(raw)
else:
value = str(raw)
except (TypeError, ValueError):
log.warning("prefs.json: ignoring %s=%r", field.name, raw)
continue
setattr(prefs, field.name, value)
def channels_to_json(store: Any) -> list[dict]:
"""The channel table as ``[{"idx", "name", "secret"}]`` in slot order."""
out = []
for idx in range(store.max_channels):
channel = store.get(idx)
if channel is not None and channel.name:
out.append({"idx": idx, "name": channel.name, "secret": bytes(channel.secret).hex()})
return out
def saved_channels(saved: Any) -> list[tuple[int, str, bytes]]:
"""``channels.json`` as ``(idx, name, secret)`` triples, skipping malformed entries."""
out = []
for entry in saved if isinstance(saved, list) else []:
try:
out.append((int(entry["idx"]), str(entry["name"]), bytes.fromhex(entry["secret"])))
except (KeyError, TypeError, ValueError):
continue
return [c for c in out if c[1]]
# --- the node ------------------------------------------------------------------------------
def preamble_for_sf(spreading_factor: int) -> int:
"""The LoRa preamble, in symbols, MeshCore uses at a spreading factor: 32 up to SF8, else 16.
This is MeshCore's own rule (``RadioLibWrapper::preambleLengthForSF``), and the receiver
has to follow it, not only the transmitter. The SX1262 waits for the sync word only about
as long as the preamble it was told to expect, so a node listening for 12 symbols against
a mesh sending 32 locks on, gives up, and locks on again further along the same preamble
— and decodes a packet only when it happens to lock on near the end. That was the whole
of "the uConsole misses replies other radios hear": the preamble was a fixed 12, the
library's default, and at SF7 the mesh sends 32.
"""
return 32 if spreading_factor <= 8 else 16
def radio_kwargs(signature_params, wiring: dict, prefs: Any) -> dict: # noqa: ANN001
"""The ``SX1262Radio`` constructor arguments: the board's wiring plus the saved radio.
Passes only what this library version's constructor accepts, so a knob it lacks is
dropped instead of refusing the call. ``None`` means "not set" and is never passed.
"""
wanted = dict(wiring)
wanted.update(
preamble_length=preamble_for_sf(prefs.spreading_factor),
frequency=prefs.frequency_hz,
bandwidth=prefs.bandwidth_hz,
spreading_factor=prefs.spreading_factor,
coding_rate=prefs.coding_rate,
tx_power=prefs.tx_power_dbm,
)
return {k: v for k, v in wanted.items() if k in signature_params and v is not None}
def _check_device(path: str, missing_kind: str, what: str) -> None:
"""Refuse early, with the right kind, when a device node is absent or unopenable."""
if not os.path.exists(path):
raise NodeError(missing_kind, f"{path} does not exist — is the {what} enabled?")
if not os.access(path, os.R_OK | os.W_OK):
raise NodeError("permission", f"no read/write permission on {path}")
def _persist_contacts(store: Any, path: Path) -> None:
"""Snapshot the contact list after every change, the way the firmware writes flash."""
def save() -> None:
try:
_write_json(path, store.to_dicts())
except Exception as exc: # noqa: BLE001 - a failed snapshot must not stop the node
log.warning("could not save contacts: %s", exc)
for name in ("add", "add_or_overwrite", "update", "remove", "clear"):
original = getattr(store, name, None)
if original is None:
continue
def wrapped(*args, _original=original, **kwargs): # noqa: ANN002, ANN003, ANN202
result = _original(*args, **kwargs)
save()
return result
setattr(store, name, wrapped)
store.save_snapshot = save
async def run_node(config: dict, report) -> int: # noqa: ANN001 - (dict) -> None
"""Bring the node up, report ready, and serve until told to stop.
Args:
config: ``state_dir``, ``wiring`` (the board's pins and switches) and ``seed``
(name and radio settings for a node with no ``prefs.json`` yet).
report: Sends the one status line to the parent.
Returns:
The process exit status.
"""
import importlib
import inspect
try:
rt = importlib.import_module(RUNTIME)
companion_mod = importlib.import_module(f"{RUNTIME}.companion")
models = importlib.import_module(f"{RUNTIME}.companion.models")
identity_mod = importlib.import_module(f"{RUNTIME}.protocol.identity")
sx1262 = importlib.import_module(f"{RUNTIME}.hardware.sx1262_wrapper")
except ImportError as exc:
raise NodeError("runtime", f"{RUNTIME} is not importable here ({exc})") from exc
log.info("node runtime %s %s (%s)", RUNTIME, getattr(rt, "__version__", "?"), sys.executable)
state = Path(config["state_dir"])
state.mkdir(parents=True, exist_ok=True)
wiring = dict(config.get("wiring") or {})
seed = dict(config.get("seed") or {})
bus, cs = int(wiring.get("bus_id", 0)), int(wiring.get("cs_id", 0))
_check_device(f"/dev/spidev{bus}.{cs}", "no-spi", "SPI overlay")
_check_device(f"/dev/gpiochip{int(wiring.get('gpio_chip', 0))}", "no-gpio", "GPIO chip")
# The saved preferences decide the radio the chip is brought up on; the seed only fills
# in a node that has never saved any.
prefs = models.NodePrefs(
node_name=seed.get("node_name", "MeshTerm"),
tx_power_dbm=seed.get("tx_power_dbm", 22),
frequency_hz=seed.get("frequency_hz", 910_525_000),
bandwidth_hz=seed.get("bandwidth_hz", 62_500),
spreading_factor=seed.get("spreading_factor", 7),
coding_rate=seed.get("coding_rate", 5),
)
apply_saved_prefs(prefs, _read_json(state / "prefs.json"))
SX1262Radio = sx1262.SX1262Radio
kwargs = radio_kwargs(inspect.signature(SX1262Radio.__init__).parameters, wiring, prefs)
log.info("radio config %s", " ".join(f"{k}={v}" for k, v in kwargs.items()))
radio = SX1262Radio(**kwargs)
recent = _Recent()
logging.getLogger().addHandler(recent)
for attempt in range(BEGIN_ATTEMPTS):
recent.lines.clear()
try:
if radio.begin():
break
kind = classify_begin_failure(recent.lines)
except SystemExit: # the GPIO manager's answer to a busy or forbidden pin
kind = classify_begin_failure(recent.lines)
except Exception as exc: # noqa: BLE001 - reported to the parent, not raised
recent.lines.append(str(exc))
kind = classify_begin_failure(recent.lines)
try:
radio.cleanup()
except Exception: # noqa: BLE001, S110 - best-effort release before retrying
pass
if kind != "busy" or attempt == BEGIN_ATTEMPTS - 1:
detail = recent.lines[-1] if recent.lines else "radio.begin() failed"
raise NodeError(kind, detail)
await asyncio.sleep(BEGIN_BACKOFF_S * (attempt + 1))
logging.getLogger().removeHandler(recent)
log.info("radio ready")
try:
seed_bytes = load_identity_seed(
state, lambda: identity_mod.LocalIdentity().get_signing_key_bytes()
)
identity = identity_mod.LocalIdentity(seed_bytes)
node = _persistent_companion(companion_mod.CompanionRadio, state)(
radio,
identity,
node_name=prefs.node_name,
radio_config={
"frequency": prefs.frequency_hz,
"bandwidth": prefs.bandwidth_hz,
"spreading_factor": prefs.spreading_factor,
"coding_rate": prefs.coding_rate,
"tx_power": prefs.tx_power_dbm,
},
)
apply_saved_prefs(node.prefs, prefs_to_json(prefs))
for idx, name, secret in saved_channels(_read_json(state / "channels.json")):
node.set_channel(idx, name, secret)
contacts = _read_json(state / "contacts.json")
if isinstance(contacts, list):
node.contacts.load_from_dicts(contacts)
log.info("restored %d contact(s)", node.contacts.get_count())
_persist_contacts(node.contacts, state / "contacts.json")
node.add_push_callback(
"channel_updated",
lambda _idx, _channel: _write_json(
state / "channels.json", channels_to_json(node.channels)
),
)
await node.start()
public_key = node.get_public_key()
server = companion_mod.CompanionFrameServer(
bridge=node,
companion_hash=f"{public_key[0]:02x}",
port=0,
bind_address="127.0.0.1",
local_hash=public_key[0],
device_model=DEVICE_MODEL,
client_idle_timeout_sec=None,
)
await server.start()
# The one push the library's frame server doesn't subscribe to: every overheard frame
# with its SNR/RSSI, which MeshTerm's live feed is built from.
node.add_push_callback("rx_log_data", server.push_rx_raw)
port = server._server.sockets[0].getsockname()[1]
except Exception:
radio.cleanup()
raise
log.info("node %s… listening on 127.0.0.1:%d", public_key.hex()[:16], port)
report({"event": "ready", "port": port, "public_key": public_key.hex()})
try:
await _until_told_to_stop()
finally:
log.info("shutting down")
for step in (server.stop, node.stop):
try:
await step()
except Exception as exc: # noqa: BLE001 - keep going: the radio still needs freeing
log.warning("shutdown step failed: %s", exc)
node.contacts.save_snapshot()
radio.cleanup()
return 0
def apply_preamble(radio: Any, symbols: int) -> None:
"""Give a running radio a new preamble length, for both what it sends and what it hears.
The driver reads ``preamble_length`` afresh for every transmission, but reception keeps
the packet parameters it was last given, so those are re-sent from standby and the chip
put back to listening.
"""
if getattr(radio, "preamble_length", symbols) == symbols:
return
radio.preamble_length = symbols
lora = getattr(radio, "lora", None)
if lora is None:
return
try:
lora.setStandby(lora.STANDBY_RC)
lora.setPacketParamsLoRa(symbols, lora.HEADER_EXPLICIT, 64, lora.CRC_ON, lora.IQ_STANDARD)
lora.request(lora.RX_CONTINUOUS)
log.info("preamble set to %d symbols", symbols)
except Exception as exc: # noqa: BLE001 - the next transmission sets it regardless
log.warning("could not apply the new preamble while listening: %s", exc)
def _persistent_companion(base: type, state: Path) -> type:
"""``CompanionRadio`` with its preferences written to ``prefs.json`` on every change.
``_save_prefs`` is the library's own hook for exactly this ("subclasses that need
persistence … should override this method"), called after every preference setter.
"""
class PersistentCompanion(base): # type: ignore[misc, valid-type]
def set_radio_params(self, freq_hz: int, bw_hz: int, sf: int, cr: int) -> bool:
"""Retune, and bring the preamble along when the spreading factor moves it.
The library retunes the modulation but leaves the packet parameters alone, and
the preamble is one of them (see :func:`preamble_for_sf`).
"""
ok = super().set_radio_params(freq_hz, bw_hz, sf, cr)
if ok:
apply_preamble(self._radio, preamble_for_sf(sf))
return ok
def _save_prefs(self) -> None:
try:
_write_json(state / "prefs.json", prefs_to_json(self.prefs))
except Exception as exc: # noqa: BLE001 - a failed save must not fail the setter
log.warning("could not save prefs: %s", exc)
return PersistentCompanion
async def _until_told_to_stop() -> None:
"""Wait for stdin to close or a SIGTERM/SIGINT — the parent's two ways of saying stop."""
loop = asyncio.get_running_loop()
stop = asyncio.Event()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, stop.set)
except (NotImplementedError, RuntimeError):
pass
def watch_stdin() -> None:
try:
while sys.stdin.buffer.read(4096):
pass
except (OSError, ValueError):
pass
loop.call_soon_threadsafe(stop.set)
threading.Thread(target=watch_stdin, name="parent-watch", daemon=True).start()
await stop.wait()
def _die_with_parent() -> None:
"""On Linux, have the kernel send SIGTERM when the parent exits (best-effort)."""
if not sys.platform.startswith("linux"):
return
try:
import ctypes
PR_SET_PDEATHSIG = 1
ctypes.CDLL("libc.so.6", use_errno=True).prctl(PR_SET_PDEATHSIG, signal.SIGTERM)
except (OSError, AttributeError):
pass
def main(argv: list[str] | None = None) -> int:
"""Entry point: ``python radionode.py --config '<json>'``."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--config", required=True, help="the node's configuration, as JSON")
args = parser.parse_args(argv)
# One line on the real stdout is the parent's; everything else goes to stderr.
status = os.fdopen(os.dup(1), "w", encoding="utf-8")
os.dup2(2, 1)
sys.stdout = sys.stderr
def report(message: dict) -> None:
status.write(json.dumps(message) + "\n")
status.flush()
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
_die_with_parent()
try:
return asyncio.run(run_node(json.loads(args.config), report))
except NodeError as exc:
log.error("%s: %s", exc.kind, exc)
report({"event": "error", "kind": exc.kind, "message": str(exc)})
return 1
except Exception as exc: # noqa: BLE001 - the parent gets a sentence, the log the trace
log.exception("node failed")
report({"event": "error", "kind": "failed", "message": str(exc)})
return 1
if __name__ == "__main__":
raise SystemExit(main())