Files
MeshTerm/meshtools/core/device_config.py
T
Jean-Pierre MartineauandClaude Opus 4.8 421781f13d Fix auto-path traces, harden contacts retrieval, add device config tooling
Trace:
- Auto-path now ends every route at the target's own hash (verified on
  hardware: a trace only replies when its destination is the final hop), so
  direct neighbors and learned multi-hop routes both resolve correctly.
- Retry the transient "no event received during contacts retrieval" blip and
  surface a clean DeviceCommandError instead of a raw traceback.
- Route summary annotates each node with its hash at the command's path-hash
  width, wrapping after an arrow when the line is too long.
- Default trace count changed from 5 to 3.

Also includes the in-progress device-config feature (config_io, device_config,
config tool, config editor UI, tests) and related supporting edits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:19:21 -04:00

420 lines
16 KiB
Python

"""Declarative registry of every settable device configuration value.
Each :class:`SettingSpec` describes one tunable setting: how to read its current value
from a device snapshot, how to parse/validate/format it, and how to apply it back to the
:class:`~meshtools.core.connection.Device`. The same registry drives the interactive
editor, the ``config`` CLI subcommands, and TOML backup/restore — add a setting once and
it appears everywhere, mirroring the tool registry pattern.
Settings whose protocol command takes several fields at once (radio, coordinates, tuning,
telemetry modes) rebuild the full command from the current snapshot plus the one changed
field, so a single setting can be edited in isolation.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, Optional
from .connection import Device
# Display categories, in the order the editor and `config show` present them.
CATEGORIES = ("Identity", "Radio", "Tuning", "Behavior", "Experimental")
class DeviceConfigError(ValueError):
"""Raised when a value cannot be parsed or fails validation.
The message is user-facing (printed directly by the CLI and editor).
"""
@dataclass(slots=True)
class SettingSpec:
"""Specification for one settable configuration value.
Attributes:
key: Canonical key (matches the ``SELF_INFO`` field name where one exists).
label: Human-friendly name for display.
help: One-line description.
category: One of :data:`CATEGORIES`.
value_type: ``"str" | "int" | "float" | "bool" | "enum"``.
choices: For ``enum``, a mapping of allowed int value to label.
minimum: Inclusive lower bound for numeric types, if any.
maximum: Inclusive upper bound for numeric types, if any.
strict_choices: When ``True`` (the default) an ``enum`` value must be one of
:attr:`choices`. When ``False`` the choices are offered as a convenience menu
but any in-range integer is still accepted — used for firmware fields whose
full value domain we don't enumerate exhaustively.
getter: Extracts the current value from a snapshot dict.
apply: Coroutine applying a parsed value to a device, given the snapshot.
"""
key: str
label: str
help: str
category: str
value_type: str
getter: Callable[[dict], Any]
apply: Callable[[Device, Any, dict], Awaitable[None]]
choices: Optional[dict[int, str]] = None
minimum: Optional[float] = None
maximum: Optional[float] = None
strict_choices: bool = True
# --- value parsing / formatting ----------------------------------------------
_TRUE = {"1", "true", "yes", "on", "y"}
_FALSE = {"0", "false", "no", "off", "n"}
def parse_value(spec: SettingSpec, raw: Any) -> Any:
"""Parse and validate a raw value (typically a CLI/TOML string) for ``spec``.
Args:
spec: The target setting.
raw: The raw value to coerce (string or already-typed scalar).
Returns:
The typed, range-checked value ready for :meth:`SettingSpec.apply`.
Raises:
DeviceConfigError: If the value is the wrong type or out of range.
"""
text = str(raw).strip()
if spec.value_type == "str":
return text
if spec.value_type == "bool":
low = text.lower()
if low in _TRUE:
return True
if low in _FALSE:
return False
raise DeviceConfigError(f"{spec.key}: expected a boolean, got {raw!r}")
# Numeric (int / enum / float): wrap only the conversion, so a DeviceConfigError
# raised by the range/enum checks below is not caught and rewrapped here.
try:
if spec.value_type == "float":
value: Any = float(text)
else: # int or enum
value = int(text, 0) if isinstance(raw, str) else int(raw)
except (ValueError, TypeError) as exc:
raise DeviceConfigError(f"{spec.key}: invalid {spec.value_type} value {raw!r}") from exc
if (
spec.value_type == "enum"
and spec.strict_choices
and spec.choices is not None
and value not in spec.choices
):
allowed = ", ".join(str(k) for k in spec.choices)
raise DeviceConfigError(f"{spec.key}: must be one of {allowed}, got {value}")
if spec.minimum is not None and value < spec.minimum:
raise DeviceConfigError(f"{spec.key}: must be >= {spec.minimum}, got {value}")
if spec.maximum is not None and value > spec.maximum:
raise DeviceConfigError(f"{spec.key}: must be <= {spec.maximum}, got {value}")
return value
def format_value(spec: SettingSpec, value: Any) -> str:
"""Render a value for display.
Args:
spec: The setting the value belongs to.
value: The current value (may be ``None`` if unknown).
Returns:
A human-readable string.
"""
if value is None:
return "?"
if spec.value_type == "bool":
return "true" if value else "false"
if spec.value_type == "enum" and spec.choices is not None and value in spec.choices:
return f"{value} ({spec.choices[value]})"
return str(value)
# --- snapshot ----------------------------------------------------------------
async def build_snapshot(device: Device) -> dict:
"""Read a device's full current configuration into one dict.
Merges ``SELF_INFO`` with the separately-read tuning parameters and path-hash mode.
Optional reads that a given firmware does not support are skipped silently so the rest
of the snapshot still renders.
Args:
device: A connected device.
Returns:
A dict keyed like ``SELF_INFO`` plus ``rx_delay``, ``airtime_factor`` and
``path_hash_mode``.
"""
snapshot = dict(await device.get_self_info())
try:
snapshot.update(await device.get_tuning())
except Exception: # noqa: BLE001 - optional read; absence is acceptable
pass
try:
snapshot["path_hash_mode"] = await device.get_path_hash_mode()
except Exception: # noqa: BLE001 - optional read; absence is acceptable
pass
return snapshot
# --- coupled-command apply helpers -------------------------------------------
def _radio_apply(field_name: str) -> Callable[[Device, Any, dict], Awaitable[None]]:
"""Build an apply that updates one radio field, preserving the others."""
async def apply(device: Device, value: Any, snapshot: dict) -> None:
params = {
"freq": snapshot.get("radio_freq"),
"bw": snapshot.get("radio_bw"),
"sf": snapshot.get("radio_sf"),
"cr": snapshot.get("radio_cr"),
}
params[field_name] = value
await device.set_radio(params["freq"], params["bw"], params["sf"], params["cr"])
return apply
def _coords_apply(field_name: str) -> Callable[[Device, Any, dict], Awaitable[None]]:
"""Build an apply that updates one coordinate, preserving the other."""
async def apply(device: Device, value: Any, snapshot: dict) -> None:
lat = value if field_name == "adv_lat" else snapshot.get("adv_lat", 0.0)
lon = value if field_name == "adv_lon" else snapshot.get("adv_lon", 0.0)
await device.set_coords(float(lat or 0.0), float(lon or 0.0))
return apply
def _tuning_apply(field_name: str) -> Callable[[Device, Any, dict], Awaitable[None]]:
"""Build an apply that updates one tuning field, preserving the other."""
async def apply(device: Device, value: Any, snapshot: dict) -> None:
rx = value if field_name == "rx_delay" else snapshot.get("rx_delay", 0)
af = value if field_name == "airtime_factor" else snapshot.get("airtime_factor", 0)
await device.set_tuning(int(rx or 0), int(af or 0))
return apply
def _telemetry_apply(field_name: str) -> Callable[[Device, Any, dict], Awaitable[None]]:
"""Build an apply that updates one telemetry mode, preserving the others."""
async def apply(device: Device, value: Any, snapshot: dict) -> None:
base = value if field_name == "telemetry_mode_base" else snapshot.get("telemetry_mode_base", 0)
loc = value if field_name == "telemetry_mode_loc" else snapshot.get("telemetry_mode_loc", 0)
env = value if field_name == "telemetry_mode_env" else snapshot.get("telemetry_mode_env", 0)
await device.set_telemetry_modes(int(base or 0), int(loc or 0), int(env or 0))
return apply
def _get(key: str) -> Callable[[dict], Any]:
"""Build a getter that reads ``key`` from a snapshot."""
return lambda snapshot: snapshot.get(key)
_TELEMETRY_CHOICES = {0: "off", 1: "always", 2: "device-only", 3: "all"}
# adv_loc_policy / multi_acks are single firmware bytes; we list the values seen in the
# wild but keep them non-strict so an unfamiliar value is still accepted.
_ADV_LOC_CHOICES = {0: "don't share location", 1: "share location in adverts"}
_MULTI_ACKS_CHOICES = {0: "off", 1: "on"}
# path_hash_mode is a 2-bit field; the hash size carried per hop is mode + 1 bytes.
_PATH_HASH_CHOICES = {
0: "1-byte hashes (default)",
1: "2-byte hashes",
2: "3-byte hashes",
3: "4-byte hashes",
}
def _telemetry_spec(key: str, label: str) -> SettingSpec:
"""Build a telemetry-mode setting spec (shared shape for base/loc/env)."""
return SettingSpec(
key=key, label=label, help="Telemetry reporting mode.", category="Behavior",
value_type="enum", choices=_TELEMETRY_CHOICES, minimum=0, maximum=3,
getter=_get(key), apply=_telemetry_apply(key),
)
# --- radio presets -----------------------------------------------------------
@dataclass(frozen=True, slots=True)
class RadioPreset:
"""A named, standard set of radio parameters applied as one unit.
Attributes:
name: Human-friendly preset name (region or trade-off).
help: One-line description of when to use it.
freq: Carrier frequency in MHz.
bw: Channel bandwidth in kHz.
sf: LoRa spreading factor.
cr: LoRa coding-rate denominator (5-8 = 4/5-4/8).
"""
name: str
help: str
freq: float
bw: float
sf: int
cr: int
def as_settings(self) -> dict[str, Any]:
"""Return this preset as a ``{setting_key: value}`` mapping for staging."""
return {
"radio_freq": self.freq,
"radio_bw": self.bw,
"radio_sf": self.sf,
"radio_cr": self.cr,
}
# Standard MeshCore presets. The default modulation (250 kHz / SF11 / CR5) is shared
# across regions; the region presets differ only in the default channel frequency. The
# trade-off presets keep the EU frequency but trade airtime for range or speed.
RADIO_PRESETS: list[RadioPreset] = [
RadioPreset("EU / UK 868", "MeshCore default for the 868 MHz band.", 869.525, 250.0, 11, 5),
RadioPreset("US 915", "MeshCore default for the US 915 MHz band.", 910.525, 250.0, 11, 5),
RadioPreset("AU / NZ 915", "MeshCore default for the AU/NZ 915 MHz band.", 915.525, 250.0, 11, 5),
RadioPreset("Long range (slow)", "Narrow bandwidth + high SF: max range, low data rate.", 869.525, 125.0, 12, 8),
RadioPreset("Fast (short range)", "Wide bandwidth + low SF: short range, low airtime.", 869.525, 500.0, 7, 5),
]
# --- the registry ------------------------------------------------------------
DEVICE_SETTINGS: list[SettingSpec] = [
# Identity
SettingSpec(
"name", "Node name", "Advertised name of this node.", "Identity", "str",
getter=_get("name"), apply=lambda d, v, s: d.set_name(v),
),
SettingSpec(
"adv_lat", "Latitude", "Advertised latitude (decimal degrees).", "Identity",
"float", minimum=-90.0, maximum=90.0,
getter=_get("adv_lat"), apply=_coords_apply("adv_lat"),
),
SettingSpec(
"adv_lon", "Longitude", "Advertised longitude (decimal degrees).", "Identity",
"float", minimum=-180.0, maximum=180.0,
getter=_get("adv_lon"), apply=_coords_apply("adv_lon"),
),
SettingSpec(
"device_pin", "Device PIN", "BLE pairing PIN.", "Identity", "int",
minimum=0, maximum=999999,
getter=_get("device_pin"), apply=lambda d, v, s: d.set_device_pin(v),
),
# Radio
SettingSpec(
"radio_freq", "Frequency (MHz)", "Carrier frequency in MHz.", "Radio", "float",
minimum=100.0, maximum=1000.0,
getter=_get("radio_freq"), apply=_radio_apply("freq"),
),
SettingSpec(
"radio_bw", "Bandwidth (kHz)", "Channel bandwidth in kHz.", "Radio", "float",
minimum=1.0, maximum=1000.0,
getter=_get("radio_bw"), apply=_radio_apply("bw"),
),
SettingSpec(
"radio_sf", "Spreading factor", "LoRa spreading factor.", "Radio", "int",
minimum=5, maximum=12,
getter=_get("radio_sf"), apply=_radio_apply("sf"),
),
SettingSpec(
"radio_cr", "Coding rate", "LoRa coding-rate denominator (5-8 = 4/5-4/8).",
"Radio", "int", minimum=5, maximum=8,
getter=_get("radio_cr"), apply=_radio_apply("cr"),
),
SettingSpec(
"tx_power", "TX power (dBm)", "Transmit power.", "Radio", "int",
minimum=1, maximum=22,
getter=_get("tx_power"), apply=lambda d, v, s: d.set_tx_power(v),
),
# Tuning
SettingSpec(
"rx_delay", "RX delay", "Receive delay tuning parameter.", "Tuning", "int",
minimum=0,
getter=_get("rx_delay"), apply=_tuning_apply("rx_delay"),
),
SettingSpec(
"airtime_factor", "Airtime factor", "Airtime budgeting factor.", "Tuning", "int",
minimum=0,
getter=_get("airtime_factor"), apply=_tuning_apply("airtime_factor"),
),
# Behavior
SettingSpec(
"manual_add_contacts", "Manual add contacts",
"Require contacts to be added manually.", "Behavior", "bool",
getter=_get("manual_add_contacts"),
apply=lambda d, v, s: d.set_manual_add_contacts(v),
),
SettingSpec(
"adv_loc_policy", "Advert location policy",
"Whether this node shares its location in adverts.", "Behavior", "enum",
choices=_ADV_LOC_CHOICES, minimum=0, strict_choices=False,
getter=_get("adv_loc_policy"), apply=lambda d, v, s: d.set_adv_loc_policy(v),
),
SettingSpec(
"multi_acks", "Multi-acks",
"Send multiple acknowledgements for added delivery reliability.", "Behavior",
"enum", choices=_MULTI_ACKS_CHOICES, minimum=0, strict_choices=False,
getter=_get("multi_acks"), apply=lambda d, v, s: d.set_multi_acks(v),
),
_telemetry_spec("telemetry_mode_base", "Telemetry mode (base)"),
_telemetry_spec("telemetry_mode_loc", "Telemetry mode (location)"),
_telemetry_spec("telemetry_mode_env", "Telemetry mode (environment)"),
# Experimental
SettingSpec(
"path_hash_mode", "Path-hash mode",
"Per-hop path-hash size; larger resists hash collisions but adds packet overhead.",
"Experimental", "enum", choices=_PATH_HASH_CHOICES, minimum=0, maximum=3,
getter=_get("path_hash_mode"), apply=lambda d, v, s: d.set_path_hash_mode(v),
),
]
_BY_KEY: dict[str, SettingSpec] = {s.key: s for s in DEVICE_SETTINGS}
def get_spec(key: str) -> SettingSpec:
"""Return the spec for ``key``.
Args:
key: A setting key.
Returns:
The matching :class:`SettingSpec`.
Raises:
DeviceConfigError: If no such setting exists.
"""
spec = _BY_KEY.get(key)
if spec is None:
known = ", ".join(sorted(_BY_KEY))
raise DeviceConfigError(f"unknown setting {key!r}. Known settings: {known}")
return spec
def settings_by_category() -> list[tuple[str, list[SettingSpec]]]:
"""Return settings grouped by category in display order.
Returns:
A list of ``(category, specs)`` pairs following :data:`CATEGORIES` order.
"""
return [
(cat, [s for s in DEVICE_SETTINGS if s.category == cat])
for cat in CATEGORIES
]