Files
MeshTerm/meshterm/core/remote_config.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

334 lines
11 KiB
Python

"""The remote-node settings catalog: what a repeater can be asked and told over the mesh.
Repeaters and room servers are administered through their text CLI — ``get``/``set``
commands sent as admin messages (see
:meth:`~meshterm.core.connection.Device.send_remote_command`) — not through the binary
companion protocol the local device-configuration editor drives. This module is the
data-driven bridge: one :class:`RemoteSetting` per knob the standard MeshCore repeater
firmware exposes, each carrying its CLI spelling, a one-line explanation, and enough
typing to prompt and validate sensibly. The repeater-admin screen renders the catalog in
the local editor's lanes, stages values, and applies them as ``set`` commands; anything
the catalog doesn't cover is one keystroke away in the raw command line.
Replies are parsed *loosely* on purpose: repeater firmware answers tersely and
inconsistently across versions (``"tx: 20"``, ``"TX power = 20 dBm"``, ``"> ok"``), so
values are extracted rather than pattern-matched, and the raw reply is always kept for
display. Firmware without a given key simply answers with an error string, which shows
in the value lane instead of breaking the screen.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
#: Reply text (lowercased) that reads as the firmware refusing/unknowing a command.
_ERRORISH = ("unknown", "error", "err:", "invalid", "denied", "bad ")
@dataclass(frozen=True, slots=True)
class RemoteSetting:
"""One remote-CLI setting: its spelling, presentation, and typing.
Attributes:
key: The CLI parameter name as the firmware spells it (e.g. ``"txdelay"``).
label: The display name shown in the editor lane.
help: One-line explanation (no trailing period), shown beside the value.
category: Editor section heading the setting sorts under.
kind: ``"int"``, ``"float"``, ``"str"``, or ``"bool"`` (on/off).
writable: Whether the firmware accepts a ``set`` for this key.
readable: Whether the firmware answers a ``get`` (passwords do not).
minimum: Lower bound for numeric prompts, when known.
maximum: Upper bound for numeric prompts, when known.
unit: Display unit hint (e.g. ``"dBm"``, ``"min"``), purely cosmetic.
"""
key: str
label: str
help: str
category: str
kind: str = "str"
writable: bool = True
readable: bool = True
minimum: float | None = None
maximum: float | None = None
unit: str = ""
@property
def get_command(self) -> str:
"""The CLI command that reads this setting."""
return f"get {self.key}"
def set_command(self, value: str) -> str:
"""The CLI command that writes ``value`` to this setting."""
return f"set {self.key} {value}"
#: Every knob the standard MeshCore repeater/room firmware exposes over its CLI, in the
#: editor's display order. TX delay and Direct TX delay — absent from the local
#: companion editor because companions don't repeat — are first-class here.
REPEATER_SETTINGS: tuple[RemoteSetting, ...] = (
# -- Identity ---------------------------------------------------------------
RemoteSetting(
key="name",
label="Name",
category="Identity",
kind="str",
help="The node's advertised name",
),
RemoteSetting(
key="lat",
label="Latitude",
category="Identity",
kind="float",
minimum=-90,
maximum=90,
help="Advertised position, decimal degrees",
),
RemoteSetting(
key="lon",
label="Longitude",
category="Identity",
kind="float",
minimum=-180,
maximum=180,
help="Advertised position, decimal degrees",
),
# -- Radio ------------------------------------------------------------------
RemoteSetting(
key="freq",
label="Frequency",
category="Radio",
kind="float",
unit="MHz",
help="Carrier frequency — every node on the mesh must match",
),
RemoteSetting(
key="bw",
label="Bandwidth",
category="Radio",
kind="float",
unit="kHz",
help="Channel bandwidth — must match the mesh",
),
RemoteSetting(
key="sf",
label="Spreading factor",
category="Radio",
kind="int",
minimum=7,
maximum=12,
help="LoRa spreading factor — must match the mesh",
),
RemoteSetting(
key="cr",
label="Coding rate",
category="Radio",
kind="int",
minimum=5,
maximum=8,
help="LoRa coding rate denominator — must match the mesh",
),
RemoteSetting(
key="tx",
label="TX power",
category="Radio",
kind="int",
unit="dBm",
minimum=1,
maximum=30,
help="Transmit power — the TX optimize tool tunes this by measurement",
),
# -- Repeating ----------------------------------------------------------------
RemoteSetting(
key="repeat",
label="Repeat",
category="Repeating",
kind="bool",
help="Whether the node rebroadcasts mesh traffic at all",
),
RemoteSetting(
key="txdelay",
label="TX delay",
category="Repeating",
kind="int",
minimum=0,
help="Delay factor before rebroadcasting a flood packet",
),
RemoteSetting(
key="direct.txdelay",
label="Direct TX delay",
category="Repeating",
kind="int",
minimum=0,
help="Delay factor before forwarding a directed packet",
),
RemoteSetting(
key="rxdelay",
label="RX delay",
category="Repeating",
kind="int",
minimum=0,
help="Base delay before acting on a received packet",
),
RemoteSetting(
key="af",
label="Airtime factor",
category="Repeating",
kind="float",
minimum=0,
help="Duty-cycle multiplier applied to every transmission",
),
RemoteSetting(
key="allow.read.only",
label="Allow read-only",
category="Repeating",
kind="bool",
help="Whether guests may log in with the guest password",
),
# -- Adverts -----------------------------------------------------------------
RemoteSetting(
key="advert.interval",
label="Advert interval",
category="Adverts",
kind="int",
unit="min",
minimum=0,
help="Minutes between the node's own zero-hop adverts (0 = off)",
),
RemoteSetting(
key="flood.advert.interval",
label="Flood advert interval",
category="Adverts",
kind="int",
unit="h",
minimum=0,
help="Hours between the node's own flood adverts (0 = off)",
),
RemoteSetting(
key="flood.max",
label="Flood max",
category="Adverts",
kind="int",
minimum=0,
help="Hop cap the node applies when rebroadcasting floods (0 = no cap)",
),
# -- Access ------------------------------------------------------------------
RemoteSetting(
key="guest.password",
label="Guest password",
category="Access",
kind="str",
readable=False,
help="Read-only login password (write-only over the CLI)",
),
)
#: Commands the remote CLI screen offers as completions, beyond the catalog's
#: ``get``/``set`` spellings: the fixed verbs a MeshCore repeater understands.
KNOWN_COMMANDS: tuple[str, ...] = (
"advert",
"clock",
"clock sync",
"get ",
"neighbors",
"password ",
"reboot",
"set ",
"start ota",
"time ",
"ver",
)
def known_commands() -> list[str]:
"""Every completion the remote CLI offers: verbs plus catalog get/set spellings."""
commands = set(KNOWN_COMMANDS)
for spec in REPEATER_SETTINGS:
if spec.readable:
commands.add(spec.get_command)
if spec.writable:
commands.add(f"set {spec.key} ")
return sorted(commands)
def get_setting(key: str) -> RemoteSetting | None:
"""Look up a catalog setting by its CLI key."""
return next((s for s in REPEATER_SETTINGS if s.key == key), None)
def settings_by_category() -> list[tuple[str, list[RemoteSetting]]]:
"""The catalog grouped by category, in declaration order (the editor's layout)."""
grouped: dict[str, list[RemoteSetting]] = {}
for spec in REPEATER_SETTINGS:
grouped.setdefault(spec.category, []).append(spec)
return list(grouped.items())
def reply_is_error(reply: str) -> bool:
"""Whether a reply text reads as the firmware refusing the command."""
lowered = reply.lower()
return any(marker in lowered for marker in _ERRORISH)
def parse_reply_value(spec: RemoteSetting, reply: str | None) -> str | None:
"""Extract a display value from a ``get`` reply, or ``None`` when unusable.
Numeric settings pull the first number out of the terse, version-varying reply
text (the ``get tx`` convention); strings strip a leading ``key:``/``>`` echo.
Error-ish replies parse as ``None`` so a firmware without the key shows unknown
rather than adopting the error string as a value.
Args:
spec: The setting the reply answers.
reply: The raw reply text, or ``None`` if the node never answered.
Returns:
The value as display text, or ``None``.
"""
if not reply or reply_is_error(reply):
return None
text = reply.strip()
if spec.kind in ("int", "float"):
match = re.search(r"-?\d+(?:\.\d+)?", text)
if match is None:
return None
raw = match.group()
if spec.kind == "int":
return str(int(float(raw)))
return f"{float(raw):g}"
if spec.kind == "bool":
lowered = text.lower()
if any(t in lowered for t in ("on", "true", "yes", "1")):
return "on"
if any(t in lowered for t in ("off", "false", "no", "0")):
return "off"
return None
# Strings: drop a "key:" or "-> " echo prefix if the firmware included one.
text = re.sub(rf"^\s*(?:>|->|{re.escape(spec.key)}\s*[:=])\s*", "", text, flags=re.I)
return text or None
def validate_value(spec: RemoteSetting, raw: str) -> bool | str:
"""Validate a prompted value for ``spec``: ``True``, or the error message to show."""
text = raw.strip()
if not text:
return "Enter a value."
if spec.kind == "bool":
if text.lower() in ("on", "off"):
return True
return "Enter on or off."
if spec.kind in ("int", "float"):
try:
value = float(text)
except ValueError:
return "Enter a number."
if spec.kind == "int" and not float(text).is_integer():
return "Enter a whole number."
if spec.minimum is not None and value < spec.minimum:
return f"Must be ≥ {spec.minimum:g}."
if spec.maximum is not None and value > spec.maximum:
return f"Must be ≤ {spec.maximum:g}."
return True