The SPI node reported every radio as "MeshTerm SPI node", so the Cardputer's Cap read that way wherever a device's model is shown. The node is now told the shipped board it runs on (`board_name`, matched by the SPI node on this machine's board, so a profile that adjusts the Cap's pins is still the Cap) and reports that; a radio on a board MeshTerm doesn't know by name keeps "MeshTerm SPI node". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
973 lines
39 KiB
Python
973 lines
39 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 —
|
||
and, on a board with a GPS, its switch and interval
|
||
``channels.json`` the channel table, slot by slot
|
||
``contacts.json`` the contact list
|
||
================= =============================================================
|
||
|
||
**What firmware would do with a GPS, this does.** A board whose wiring names a GPS port
|
||
(the Cardputer Zero's Cap) gets the two settings MeshCore's companion firmware gives a
|
||
board with a receiver — ``gps`` to run it and ``gps_interval`` to pace it — reported and
|
||
set as custom variables, which is where MeshTerm's Device config already looks for them.
|
||
While it runs, a valid fix becomes the node's position: the one its self-info reports
|
||
and, when the node shares its location, its adverts carry (:class:`Gps`).
|
||
|
||
**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 functools
|
||
import json
|
||
import logging
|
||
import operator
|
||
import os
|
||
import signal
|
||
import sys
|
||
import threading
|
||
import time
|
||
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,
|
||
#: for a radio on a board MeshTerm doesn't know by name (the Cap reports "Cap LoRa-1262").
|
||
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`` /
|
||
``no-i2c`` (the device node doesn't exist), ``permission`` (it exists but this user
|
||
can't open it), ``busy`` (another program holds the radio's pins), ``absent`` (the board
|
||
is there but the radio on it doesn't answer), 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}")
|
||
|
||
|
||
# --- the board around the chip -------------------------------------------------------------
|
||
|
||
#: Where the kernel puts the switches a device tree hands to ``gpio-leds``.
|
||
LEDS = Path("/sys/class/leds")
|
||
|
||
#: How long a header that was just powered gets before anything on it is spoken to. The
|
||
#: Cap's regulator and expander are up well inside this; the chip's own reset follows anyway.
|
||
POWER_SETTLE_S = 0.1
|
||
|
||
#: ``I2C_SLAVE``: address the open ``/dev/i2c-*`` at one device. Refused (``EBUSY``) while a
|
||
#: kernel driver is bound to that address, which is the right answer — it is then not ours.
|
||
_I2C_SLAVE = 0x0703
|
||
|
||
#: The PI4IOE5V6408's registers, and the manufacturer field of its ID register (bits 7–5),
|
||
#: which is how a different chip at the same address is told apart.
|
||
_PI4IO_ID, _PI4IO_DIRECTION, _PI4IO_OUTPUT, _PI4IO_HIGH_Z = 0x01, 0x03, 0x05, 0x07
|
||
_PI4IO_MAKER = 0b101
|
||
|
||
|
||
def set_leds(entries: list[str], root: Path = LEDS) -> list[tuple[Path, str]]:
|
||
"""Set each ``name=brightness`` switch, and return what each read before, to put back.
|
||
|
||
A switch that is missing means a wiring written for another board; one this user can't
|
||
write is the ``gpio`` group the radio's pins need anyway.
|
||
"""
|
||
previous: list[tuple[Path, str]] = []
|
||
for entry in entries:
|
||
name, _, value = entry.partition("=")
|
||
path = root / name / "brightness"
|
||
if not path.exists():
|
||
raise NodeError("failed", f"{path} does not exist — this board has no {name} switch")
|
||
try:
|
||
before = path.read_text(encoding="ascii").strip()
|
||
path.write_text(value, encoding="ascii")
|
||
except PermissionError:
|
||
raise NodeError("permission", f"no write permission on {path}") from None
|
||
previous.append((path, before))
|
||
return previous
|
||
|
||
|
||
def restore_leds(previous: list[tuple[Path, str]]) -> None:
|
||
"""Put each switch back as :func:`set_leds` found it, last first (best-effort)."""
|
||
for path, before in reversed(previous):
|
||
try:
|
||
path.write_text(before, encoding="ascii")
|
||
except OSError as exc:
|
||
log.warning("could not put %s back to %s: %s", path, before, exc)
|
||
|
||
|
||
def drive_pi4io(bus: int, address: int, high: list[int], dev: Path = Path("/dev")) -> None:
|
||
"""Drive ``high``'s pins of a PI4IOE5V6408 high, as outputs, and leave the rest inputs.
|
||
|
||
The output level is written before the direction, so a pin becomes an output already
|
||
high rather than glitching low first. The ID register is read first: on a board where
|
||
this is the radio's own expander, silence there is the radio not being attached — the
|
||
one failure worth saying in those words.
|
||
"""
|
||
path = dev / f"i2c-{bus}"
|
||
_check_device(str(path), "no-i2c", "I2C bus")
|
||
import fcntl
|
||
|
||
mask = sum(1 << pin for pin in high) & 0xFF
|
||
fd = os.open(path, os.O_RDWR)
|
||
try:
|
||
try:
|
||
fcntl.ioctl(fd, _I2C_SLAVE, address)
|
||
except OSError as exc:
|
||
raise NodeError("busy", f"{address:#04x} on {path} is held: {exc}") from None
|
||
try:
|
||
os.write(fd, bytes([_PI4IO_ID]))
|
||
ident = os.read(fd, 1)[0]
|
||
except OSError:
|
||
raise NodeError("absent", f"nothing answers at {address:#04x} on {path}") from None
|
||
if ident >> 5 != _PI4IO_MAKER:
|
||
raise NodeError(
|
||
"absent", f"{address:#04x} on {path} is not a PI4IOE5V6408 (ID {ident:#04x})"
|
||
)
|
||
for register, value in (
|
||
(_PI4IO_OUTPUT, mask),
|
||
(_PI4IO_DIRECTION, mask),
|
||
(_PI4IO_HIGH_Z, 0xFF & ~mask),
|
||
):
|
||
os.write(fd, bytes([register, value]))
|
||
finally:
|
||
os.close(fd)
|
||
log.info("PI4IO %#04x on %s: pins %s high", address, path, high)
|
||
|
||
|
||
def prepare_board(wiring: dict) -> list[tuple[Path, str]]:
|
||
"""Switch on what the chip needs before the radio library touches it.
|
||
|
||
The board's LED-class switches first (power, pin routing), then its RF expander, which
|
||
is only reachable once the header it sits on is powered. Returns the switches' previous
|
||
states, which the caller puts back when the node ends — including when it never got
|
||
going, so a failed start leaves the header as it found it.
|
||
"""
|
||
previous = set_leds(list(wiring.get("leds") or ()), root=LEDS)
|
||
if previous:
|
||
time.sleep(POWER_SETTLE_S)
|
||
try:
|
||
if int(wiring.get("pi4io_bus", -1)) >= 0:
|
||
drive_pi4io(
|
||
int(wiring["pi4io_bus"]),
|
||
int(wiring.get("pi4io_address", 0x43)),
|
||
list(wiring.get("pi4io_high") or ()),
|
||
)
|
||
except BaseException:
|
||
restore_leds(previous)
|
||
raise
|
||
return previous
|
||
|
||
|
||
# --- the GPS beside the chip ---------------------------------------------------------------
|
||
|
||
#: The custom variables a board with a GPS answers to, by MeshCore firmware's own names.
|
||
GPS_VARS = ("gps", "gps_interval")
|
||
|
||
#: The firmware's ceiling on ``gps_interval`` (``constrain(…, 0, 86400)``): one day.
|
||
GPS_INTERVAL_MAX_S = 86400
|
||
|
||
#: NMEA allows a sentence 82 characters; a partial line well past that is noise on the wire,
|
||
#: not a sentence still arriving, and is dropped rather than grown.
|
||
_NMEA_MAX = 512
|
||
|
||
|
||
def nmea_fix(line: str) -> tuple[float, float] | None:
|
||
"""The position one NMEA sentence reports as a valid fix, as ``(lat, lon)``, or ``None``.
|
||
|
||
Two sentences carry a fix, from any constellation's talker (``GP``, ``GN``, ``GL``,
|
||
``GA``, ``GB``…): RMC, valid when its status is ``A``, and GGA, valid when its fix
|
||
quality isn't ``0``. Everything else is ``None`` — another sentence, a sentence whose
|
||
checksum doesn't match (a byte lost on the wire), a field that isn't a coordinate, and
|
||
the no-fix sentences a receiver sends until it has one.
|
||
"""
|
||
line = line.strip()
|
||
body, star, given = line.removeprefix("$").partition("*")
|
||
if not line.startswith("$") or not star or len(given) < 2:
|
||
return None
|
||
try:
|
||
if int(given[:2], 16) != functools.reduce(operator.xor, body.encode("ascii", "replace"), 0):
|
||
return None
|
||
except ValueError:
|
||
return None
|
||
fields = body.split(",")
|
||
kind = fields[0][-3:]
|
||
if kind == "RMC" and len(fields) > 6 and fields[2] == "A":
|
||
coords = fields[3:7]
|
||
elif kind == "GGA" and len(fields) > 6 and fields[6] not in ("", "0"):
|
||
coords = fields[2:6]
|
||
else:
|
||
return None
|
||
try:
|
||
return _degrees(coords[0], coords[1], "NS", 2), _degrees(coords[2], coords[3], "EW", 3)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _degrees(value: str, hemisphere: str, hemispheres: str, degree_digits: int) -> float:
|
||
"""NMEA's ``ddmm.mmmm`` (``dddmm.mmmm`` east–west) and its hemisphere, as signed degrees."""
|
||
if len(hemisphere) != 1 or hemisphere not in hemispheres or len(value) <= degree_digits:
|
||
raise ValueError(value)
|
||
minutes = float(value[degree_digits:])
|
||
unsigned = int(value[:degree_digits]) + minutes / 60
|
||
if not 0 <= minutes < 60 or unsigned > (90 if degree_digits == 2 else 180):
|
||
raise ValueError(value)
|
||
return -unsigned if hemisphere == hemispheres[1] else unsigned
|
||
|
||
|
||
def _saved_int(saved: dict, key: str, default: int) -> int:
|
||
"""``saved[key]`` as an int, or ``default`` where it is missing or isn't one."""
|
||
try:
|
||
return int(saved.get(key, default))
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
class Gps:
|
||
"""The board's GPS receiver, run the way MeshCore companion firmware runs one.
|
||
|
||
Two settings, under the firmware's names and with its defaults: ``gps``, off until it is
|
||
switched on and remembered across restarts, and ``gps_interval``, the seconds between
|
||
position updates — ``0``, the default, takes every fix, which is the firmware's one a
|
||
second. The receiver's NMEA is read on the node's own event loop (a few sentences a
|
||
second is nothing beside the radio), and a valid fix is written into the node's
|
||
preferences, which is where its self-info frame and its adverts both read the position
|
||
from. Firmware keeps a fix in RAM only; here the position is saved with the node's other
|
||
preferences whenever they are, and once more as the node shuts down, so a restart
|
||
indoors begins from the last fix rather than from a position set by hand long ago.
|
||
|
||
Attributes:
|
||
port: The receiver's serial port.
|
||
baud: Its line speed.
|
||
enabled: Whether ``gps`` is on.
|
||
interval_s: ``gps_interval``.
|
||
node: The companion whose position a fix moves; set once it exists.
|
||
moved: Whether a fix has moved the position since the preferences were last saved.
|
||
"""
|
||
|
||
def __init__(self, port: str, baud: int, saved: Any = None) -> None:
|
||
"""Take the port and speed from the wiring, and the two settings from ``prefs.json``."""
|
||
saved = saved if isinstance(saved, dict) else {}
|
||
self.port = port
|
||
self.baud = baud
|
||
self.enabled = _saved_int(saved, "gps_enabled", 0) == 1
|
||
self.interval_s = min(max(_saved_int(saved, "gps_interval", 0), 0), GPS_INTERVAL_MAX_S)
|
||
self.node: Any = None
|
||
self.moved = False
|
||
self._fd: int | None = None
|
||
self._pending = b""
|
||
self._next_at = 0.0
|
||
self._fixed = False
|
||
|
||
@property
|
||
def running(self) -> bool:
|
||
"""Whether the receiver's port is open and being read."""
|
||
return self._fd is not None
|
||
|
||
def custom_vars(self) -> dict[str, str]:
|
||
"""The two settings as the firmware reports them: ``gps`` says whether it *runs*."""
|
||
return {"gps": "1" if self.running else "0", "gps_interval": str(self.interval_s)}
|
||
|
||
def saved(self) -> dict[str, int]:
|
||
"""The two settings as ``prefs.json`` keeps them, under the firmware's field names."""
|
||
return {"gps_enabled": int(self.enabled), "gps_interval": self.interval_s}
|
||
|
||
def set_var(self, name: str, value: str) -> bool:
|
||
"""Set ``gps`` or ``gps_interval``; ``False`` refuses the value, changing nothing."""
|
||
value = value.strip()
|
||
if name == "gps" and value in ("0", "1"):
|
||
if value == "1":
|
||
try:
|
||
self.start()
|
||
except OSError as exc:
|
||
log.warning("GPS on %s did not open: %s", self.port, exc)
|
||
return False
|
||
else:
|
||
self.stop()
|
||
self.enabled = value == "1"
|
||
return True
|
||
if name == "gps_interval":
|
||
try:
|
||
seconds = int(value)
|
||
except ValueError:
|
||
return False
|
||
self.interval_s = min(max(seconds, 0), GPS_INTERVAL_MAX_S)
|
||
self._next_at = 0.0 # the new pace counts from the next fix
|
||
return True
|
||
return False
|
||
|
||
def start(self) -> None:
|
||
"""Open the port raw at the receiver's speed and read it on the running loop.
|
||
|
||
Raises:
|
||
OSError: The port can't be opened or set up (missing, not ours, not a tty).
|
||
"""
|
||
if self._fd is not None:
|
||
return
|
||
import termios
|
||
import tty
|
||
|
||
speed = getattr(termios, f"B{self.baud}", None)
|
||
if speed is None:
|
||
raise OSError(f"{self.baud} is not a line speed this system has")
|
||
fd = os.open(self.port, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
|
||
try:
|
||
tty.setraw(fd)
|
||
attrs = termios.tcgetattr(fd)
|
||
attrs[4] = attrs[5] = speed # input and output speed
|
||
attrs[2] |= termios.CLOCAL | termios.CREAD
|
||
termios.tcsetattr(fd, termios.TCSANOW, attrs)
|
||
termios.tcflush(fd, termios.TCIFLUSH)
|
||
asyncio.get_running_loop().add_reader(fd, self._readable)
|
||
except termios.error as exc:
|
||
os.close(fd)
|
||
raise OSError(f"{self.port} is not a serial port ({exc})") from None
|
||
except BaseException:
|
||
os.close(fd)
|
||
raise
|
||
self._fd, self._pending, self._next_at, self._fixed = fd, b"", 0.0, False
|
||
log.info("GPS on %s at %d baud", self.port, self.baud)
|
||
|
||
def stop(self) -> None:
|
||
"""Stop reading and close the port; the position keeps the last fix."""
|
||
fd, self._fd = self._fd, None
|
||
if fd is None:
|
||
return
|
||
try:
|
||
asyncio.get_running_loop().remove_reader(fd)
|
||
except RuntimeError: # no loop left to remove it from: closing is enough
|
||
pass
|
||
os.close(fd)
|
||
log.info("GPS on %s stopped", self.port)
|
||
|
||
def _readable(self) -> None:
|
||
"""Read what the port has; a port that hangs up or fails is stopped, not retried."""
|
||
try:
|
||
chunk = os.read(self._fd, 4096) # type: ignore[arg-type]
|
||
except BlockingIOError:
|
||
return
|
||
except OSError as exc:
|
||
log.warning("GPS on %s failed: %s", self.port, exc)
|
||
self.stop()
|
||
return
|
||
if not chunk: # hung up: it would read as ready forever
|
||
log.warning("GPS on %s hung up", self.port)
|
||
self.stop()
|
||
return
|
||
self.feed(chunk)
|
||
|
||
def feed(self, chunk: bytes, now: float | None = None) -> None:
|
||
"""Take bytes as they arrive and act on each whole sentence among them."""
|
||
*lines, self._pending = (self._pending + chunk).split(b"\n")
|
||
if len(self._pending) > _NMEA_MAX:
|
||
self._pending = b""
|
||
for raw in lines:
|
||
fix = nmea_fix(raw.decode("ascii", "replace"))
|
||
if fix is not None:
|
||
self._take(fix, time.monotonic() if now is None else now)
|
||
|
||
def _take(self, fix: tuple[float, float], now: float) -> None:
|
||
"""Make ``fix`` the node's position, once per interval."""
|
||
if self.node is None or now < self._next_at:
|
||
return
|
||
if not self._fixed: # once a start, and never where: a log is pasted into issues
|
||
log.info("GPS has a fix")
|
||
self._fixed = True
|
||
prefs = self.node.prefs
|
||
if (prefs.latitude, prefs.longitude) != fix:
|
||
prefs.latitude, prefs.longitude = fix
|
||
self.moved = True
|
||
self._next_at = now + max(self.interval_s, 1)
|
||
|
||
|
||
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), ``seed``
|
||
(name and radio settings for a node with no ``prefs.json`` yet) and ``model``
|
||
(the board's name, reported as the device model; :data:`DEVICE_MODEL` without).
|
||
report: Sends the one status line to the parent.
|
||
|
||
Returns:
|
||
The process exit status.
|
||
"""
|
||
import importlib
|
||
|
||
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 board's switches go back as they were however the node ends — a radio that never
|
||
# came up included — so a failed start leaves the header as it found it.
|
||
switched = prepare_board(wiring)
|
||
try:
|
||
return await _serve(
|
||
state,
|
||
wiring,
|
||
seed,
|
||
report,
|
||
model=str(config.get("model") or DEVICE_MODEL),
|
||
models=models,
|
||
sx1262=sx1262,
|
||
companion_mod=companion_mod,
|
||
identity_mod=identity_mod,
|
||
)
|
||
finally:
|
||
restore_leds(switched)
|
||
|
||
|
||
async def _serve( # noqa: PLR0913 - the library modules run_node imported, handed on
|
||
state: Path,
|
||
wiring: dict,
|
||
seed: dict,
|
||
report, # noqa: ANN001 - (dict) -> None
|
||
*,
|
||
model: str,
|
||
models: Any,
|
||
sx1262: Any,
|
||
companion_mod: Any,
|
||
identity_mod: Any,
|
||
) -> int:
|
||
"""Bring the radio up on a prepared board, report ready, and serve until told to stop."""
|
||
import inspect
|
||
|
||
# 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),
|
||
)
|
||
saved_prefs = _read_json(state / "prefs.json")
|
||
apply_saved_prefs(prefs, saved_prefs)
|
||
gps = (
|
||
Gps(str(wiring["gps_port"]), int(wiring.get("gps_baud") or 9600), saved_prefs)
|
||
if wiring.get("gps_port")
|
||
else None
|
||
)
|
||
|
||
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, gps)(
|
||
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()
|
||
if gps is not None:
|
||
gps.node = node
|
||
if gps.enabled:
|
||
try:
|
||
gps.start()
|
||
except OSError as exc: # the radio still works; `gps` reads 0 until it opens
|
||
log.warning("GPS on %s did not open: %s", gps.port, exc)
|
||
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=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")
|
||
if gps is not None:
|
||
gps.stop()
|
||
if gps.moved:
|
||
node._save_prefs() # the last fix, so a restart begins where the node was
|
||
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, gps: Gps | None = None) -> 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.
|
||
With a ``gps``, the board's two GPS settings are among its custom variables, as the
|
||
firmware lists them, and are saved beside the preferences the library knows.
|
||
"""
|
||
|
||
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 get_custom_vars(self) -> dict[str, str]:
|
||
"""The library's variables, and a GPS board's two as firmware reports them."""
|
||
found = super().get_custom_vars()
|
||
if gps is not None:
|
||
found.update(gps.custom_vars())
|
||
return found
|
||
|
||
def set_custom_var(self, name: str, value: str) -> bool:
|
||
"""Set a variable; ``gps`` and ``gps_interval`` run the receiver and are saved."""
|
||
if gps is None or name not in GPS_VARS:
|
||
return super().set_custom_var(name, value)
|
||
if not gps.set_var(name, value):
|
||
return False
|
||
self._save_prefs()
|
||
return True
|
||
|
||
def _save_prefs(self) -> None:
|
||
try:
|
||
saved = prefs_to_json(self.prefs)
|
||
if gps is not None:
|
||
saved.update(gps.saved())
|
||
_write_json(state / "prefs.json", saved)
|
||
if gps is not None:
|
||
gps.moved = False
|
||
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())
|