Files
MeshTerm/meshterm/core/device_config.py
T
Jean-Pierre MartineauandClaude Opus 5 9ff25b3709 Device config: a coupled setting must not re-send a stale sibling
Restoring a backup onto a freshly flashed companion put the identity and
the channels back, reported all eighteen operations applied, and left the
node advertising from latitude 0.0 on the factory frequency.

Latitude and longitude reach the firmware through one command, and so do
the four radio parameters, so changing one means re-sending its siblings
unchanged. Those siblings are read from the caller's snapshot — taken
once, before the first write. Applying adv_lat preserved the stale
longitude; applying adv_lon then preserved the stale *latitude*, undoing
the first write. Same shape for radio_freq followed by radio_sf. Nothing
errored: each op did exactly what it was told, with a picture of the
device that was two writes out of date.

So each coupled applier now records what it set. Any later sibling in the
same batch preserves the new value rather than the one that was true
before the batch began — whether the batch comes from config restore or
from the editor's staged changes, which had the same hole.

And the Device PIN row, found while writing the restore instructions: it
read 'device_pin' out of a snapshot that only ever held SELF_INFO, while
real firmware reports the code as 'ble_pin' in the device-query frame. It
could therefore only ever render "?" on hardware — writable, with no way
to see what you wrote, so a typo sat in NVS until the next pairing. The
snapshot now folds in that frame (SELF_INFO still wins where they
overlap, as probe_device already had it), the row reads the firmware's
own name while keeping the key its backups and CLI use, and the simulator
reports it the same way so --mock shows the same screen.

Verified on the reflashed device: the row reads 701307, and model and
firmware version arrive with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 03:30:55 -04:00

531 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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:`~meshterm.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.
max_key: Snapshot key holding this setting's *device-reported* inclusive maximum
(e.g. ``max_tx_power`` bounding ``tx_power``). Checked by :func:`parse_value`
when it is given a snapshot, tightening the static :attr:`maximum` to what
the connected hardware actually supports.
max_length: For ``str`` values, the longest accepted string (protocol field
widths, e.g. the flood scope's 31-byte name slot).
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
max_key: Optional[str] = None
max_length: Optional[int] = None
# --- value parsing / formatting ----------------------------------------------
_TRUE = {"1", "true", "yes", "on", "y"}
_FALSE = {"0", "false", "no", "off", "n"}
def parse_value(spec: SettingSpec, raw: Any, snapshot: Optional[dict] = None) -> 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).
snapshot: Optional device snapshot; when given and the spec names a
:attr:`~SettingSpec.max_key`, the device-reported maximum found there
tightens the static bound (e.g. TX power capped at this board's max).
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":
if spec.max_length is not None and len(text) > spec.max_length:
raise DeviceConfigError(
f"{spec.key}: must be at most {spec.max_length} characters, "
f"got {len(text)}"
)
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}")
maximum = effective_maximum(spec, snapshot)
if maximum is not None and value > maximum:
raise DeviceConfigError(f"{spec.key}: must be <= {maximum:g}, got {value}")
return value
def effective_maximum(spec: SettingSpec, snapshot: Optional[dict]) -> Optional[float]:
"""The inclusive maximum for ``spec``: the device-reported one when known, else static.
Args:
spec: The setting.
snapshot: The device snapshot the reported maximum is read from (may be ``None``).
Returns:
The tighter of the spec's static bound and the snapshot's ``max_key`` value, or
``None`` when neither exists.
"""
maximum = spec.maximum
if snapshot is not None and spec.max_key is not None:
reported = snapshot.get(spec.max_key)
if reported is not None:
try:
reported_f = float(reported)
except (TypeError, ValueError):
return maximum
maximum = reported_f if maximum is None else min(maximum, reported_f)
return maximum
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 == "str" and value == "":
return "(not set)"
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,
*,
self_info: Optional[dict] = None,
path_hash_mode: Optional[int] = None,
) -> 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.
self_info: An already-read ``SELF_INFO`` dict to reuse instead of asking the
radio again — a menu caller passes the devstate session cache here, saving
a round-trip on every screen open. Omit to read live (a refresh after a
restore or reset must not trust any cache).
path_hash_mode: An already-read path-hash mode to reuse, on the same terms.
Returns:
A dict keyed like ``SELF_INFO`` plus ``rx_delay``, ``airtime_factor``,
``path_hash_mode``, ``autoadd_config`` and ``flood_scope``.
"""
snapshot = dict(self_info if self_info is not None else await device.get_self_info())
try:
snapshot.update(await device.get_tuning())
except Exception: # noqa: BLE001 - optional read; absence is acceptable
pass
if path_hash_mode is not None:
snapshot["path_hash_mode"] = path_hash_mode
else:
try:
snapshot["path_hash_mode"] = await device.get_path_hash_mode()
except Exception: # noqa: BLE001 - optional read; absence is acceptable
pass
try:
snapshot["autoadd_config"] = await device.get_autoadd_config()
except Exception: # noqa: BLE001 - optional read; absence is acceptable
pass
try:
snapshot["flood_scope"] = await device.get_default_flood_scope()
except Exception: # noqa: BLE001 - optional read; absence is acceptable
pass
# The device-query frame is a *different* payload from SELF_INFO, and some settings
# only exist there — the BLE pairing code among them, which real firmware reports as
# ``ble_pin`` and never puts in SELF_INFO. Without this the Device PIN row could only
# ever render "?" on hardware: writable, but with no way to read back what you wrote.
# Merged underneath, so a key SELF_INFO also carries keeps the SELF_INFO value (the
# same precedence ``probe_device`` uses when it folds the two together).
try:
snapshot = {**await device.get_device_info(), **snapshot}
except Exception: # noqa: BLE001 - optional read; absence is acceptable
pass
return snapshot
# --- coupled-command apply helpers -------------------------------------------
# Several settings do not have a command of their own: the firmware takes latitude and
# longitude together, and the four radio parameters together, so changing one means
# re-sending its siblings unchanged. Those siblings are read from ``snapshot``, which is
# the caller's picture of the device — and that picture is read *once*, before the first
# write. So each of these appliers records what it just set, and the reason is a bug that
# reached a real device: restoring a backup that differed in both latitude and longitude
# applied ``adv_lat`` (preserving the stale longitude), then ``adv_lon`` (preserving the
# stale *latitude*) — and the second write silently undid the first. Same for a restore
# touching two radio fields. Writing back keeps every later sibling in the same batch
# honest, whether the batch comes from ``config restore`` or the editor's staged changes.
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"])
snapshot[f"radio_{field_name}"] = value
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))
snapshot[field_name] = value
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.0)
af = value if field_name == "airtime_factor" else snapshot.get("airtime_factor", 0.0)
await device.set_tuning(float(rx or 0.0), float(af or 0.0))
snapshot[field_name] = value
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: "off", 1: "on"}
_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 node name", "Identity", "str",
getter=_get("name"), apply=lambda d, v, s: d.set_name(v),
),
SettingSpec(
"adv_lat", "Latitude", "Advertised latitude, degrees", "Identity",
"float", minimum=-90.0, maximum=90.0,
getter=_get("adv_lat"), apply=_coords_apply("adv_lat"),
),
SettingSpec(
"adv_lon", "Longitude", "Advertised longitude, degrees", "Identity",
"float", minimum=-180.0, maximum=180.0,
getter=_get("adv_lon"), apply=_coords_apply("adv_lon"),
),
SettingSpec(
# Written as ``device_pin`` (the name the CLI, the backup TOML and ``set_devicepin``
# all use) but *read* as ``ble_pin``, which is what the firmware calls it in the
# device-query frame — the only place it appears. Keeping the key means existing
# backups still restore; reading the firmware's own name means the row shows a
# value instead of "?" on real hardware. The fallback keeps the canonical key
# working for anything that reports it directly.
"device_pin", "Device PIN", "BLE pairing PIN", "Identity", "int",
minimum=0, maximum=999999,
getter=lambda snapshot: snapshot.get("ble_pin", snapshot.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 (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, up to this board's reported max",
"Radio", "int", minimum=1, maximum=30, max_key="max_tx_power",
getter=_get("tx_power"), apply=lambda d, v, s: d.set_tx_power(v),
),
# Tuning. Both are firmware floats moved over the wire ×1000; the ranges are the
# firmware's own constrain() bounds. (The repeater-side TX delay factors are *not*
# here: companion firmware ignores them — they are remote-CLI settings on repeaters.)
SettingSpec(
"airtime_factor", "Airtime factor",
"Airtime budget: duty cycle = 100 / (factor + 1) %, so 0 = unlimited", "Tuning",
"float", minimum=0.0, maximum=9.0,
getter=_get("airtime_factor"), apply=_tuning_apply("airtime_factor"),
),
SettingSpec(
"rx_delay", "RX delay",
"Base delay before handling received packets, seconds", "Tuning",
"float", minimum=0.0, maximum=20.0,
getter=_get("rx_delay"), apply=_tuning_apply("rx_delay"),
),
# Behavior
SettingSpec(
"manual_add_contacts", "Manual add contacts",
"Require adding contacts manually", "Behavior", "bool",
getter=_get("manual_add_contacts"),
apply=lambda d, v, s: d.set_manual_add_contacts(v),
),
SettingSpec(
"autoadd_config", "Contact auto-add",
"Bitmask of advert types auto-added as contacts (0 = none)", "Behavior", "int",
minimum=0, maximum=255,
getter=_get("autoadd_config"), apply=lambda d, v, s: d.set_autoadd_config(v),
),
SettingSpec(
"flood_scope", "Flood scope",
"Limit flood routing to one #scope; empty reaches the whole mesh", "Behavior",
"str", max_length=30,
getter=_get("flood_scope"), apply=lambda d, v, s: d.set_default_flood_scope(v),
),
SettingSpec(
"adv_loc_policy", "Advert location policy",
"Share 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",
"Extra acknowledgements for 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 collisions, adds 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
]