Files
MeshTerm/meshterm/core/device_config.py
T
Jean-Pierre MartineauandClaude Opus 5 369e29e44a Five ways the scripted output lied, and the name that crashed it
The review pass found the CLI breaking its own written rules.

A node broadcasts its own name, so every name the CLI prints is remote
data — and Rich read `[...]` in it as a style tag. A node called
`[bold]Loud` printed as `Loud`, silently no longer the string that
identifies it; a node called `[/]Bob` raised MarkupError and took the
command down. The log handler then failed the same way while reporting
it, replacing the diagnostic with a traceback about the diagnostic. No
log line and no scripted line is ever marked up, so all three consoles
now say so.

`right=` never right-aligned anything. Rich's `Text.wrap` returns early
on `overflow="ignore"`, above the justify step, so asking for one threw
the other away — every numeric lane in the CLI was left-aligned while
CLAUDE.md, script.py's own docstring and the manual all said otherwise.
"crop" refuses to elide just as firmly and still aligns.

`config show` printed an empty string as `""` and `config set` read
those two characters literally, so feeding a dump back in replaced every
empty setting with a pair of quote marks — and the next dump looked
identical, so nothing ever said so. It also masked an *unreported* PIN
behind six bullets, telling the reader the radio was PIN-locked when it
was not: `conceal` already declined to mask the menu's absence token and
had simply never been shown the CLI's.

`meshterm about` drew the `---` above its colophon as a rule sized to
the console, which on a 16384-cell console is one 48KB line in a 4KB
page. A rule is a frame; the scripted copy drops it with the QR.

The contract fixture set only `--db`, which moves the history and
nothing else — so the suite read whatever was in the developer's own
~/.meshterm. That is how `[/]Bob`, planted by hand on one machine, could
crash the suite there and pass everywhere else. $MESHTERM_HOME moves the
whole directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 02:17:41 -04:00

702 lines
26 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
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: dict[int, str] | None = None
minimum: float | None = None
maximum: float | None = None
strict_choices: bool = True
max_key: str | None = None
max_length: int | None = None
# --- value parsing / formatting ----------------------------------------------
_TRUE = {"1", "true", "yes", "on", "y"}
_FALSE = {"0", "false", "no", "off", "n"}
def parse_value(spec: SettingSpec, raw: Any, snapshot: dict | None = 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":
# `config show` prints an empty string as the two characters `""`, because a key
# with nothing after it reads as a truncated line rather than as an empty value.
# A value the dump prints has to be one `set` takes back (CLAUDE.md, "A value must
# round-trip"), so the quotes come off here — otherwise feeding a dump back in
# silently replaces every empty setting with a pair of quote marks, and the next
# dump looks identical, so nothing ever tells you.
if text == '""':
text = ""
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, 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: dict | None) -> float | None:
"""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: dict | None = None,
path_hash_mode: int | None = 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="Who may read this node's sensor readings",
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: The preset's name, spelled exactly as MeshCore's own list spells it, so
this list can be read against the phone app or the web flasher.
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).
path_hash_size: Per-hop path-hash size in bytes where the preset names one (a
few regions run 2-byte hashes); ``None`` leaves that setting where it is.
"""
name: str
freq: float
bw: float
sf: int
cr: int
path_hash_size: int | None = None
@property
def summary(self) -> str:
"""The four parameters on one line, in the order MeshCore's own list prints."""
return f"{self.freq:.3f} SF{self.sf} BW{self.bw:g} CR{self.cr}"
def as_settings(self) -> dict[str, Any]:
"""Return this preset as a ``{setting_key: value}`` mapping for staging."""
values: dict[str, Any] = {
"radio_freq": self.freq,
"radio_bw": self.bw,
"radio_sf": self.sf,
"radio_cr": self.cr,
}
if self.path_hash_size is not None:
# The firmware stores the *mode*, one less than the size it carries per hop
# (see _PATH_HASH_CHOICES); MeshCore's own config GUI converts it the same way.
values["path_hash_mode"] = self.path_hash_size - 1
return values
# MeshCore's suggested radio settings, mirrored verbatim — names, order and values — from
# the list its apps, the web flasher and config.meshcore.dev all read at
# ``https://api.meshcore.nz/api/v1/config`` (``config.suggested_radio_settings.entries``),
# fetched 2026-09-04. The presets are community-maintained and they move: through 2025
# most regions left the original 250 kHz / SF11 modulation for a "narrow" 62.5 kHz one,
# which is why MeshCore still lists the settings it superseded under its own
# "(Deprecated)" names — nodes that never re-tuned are still out there on them.
#
# Every node on a mesh must match all four parameters, so the only useful preset is the
# one the local mesh actually runs. A handful of regions also name a path-hash size,
# which the preset stages alongside the radio fields exactly as MeshCore's GUI does.
RADIO_PRESETS: list[RadioPreset] = [
RadioPreset("Australia", 915.800, 250.0, 10, 5),
RadioPreset("Australia (Narrow)", 916.575, 62.5, 7, 8),
RadioPreset("Australia (Mid)", 915.075, 125.0, 9, 5),
RadioPreset("Australia: SA, WA", 923.125, 62.5, 8, 8),
RadioPreset("Australia: QLD", 923.125, 62.5, 8, 5),
RadioPreset("Brazil", 923.125, 62.5, 8, 8),
RadioPreset("Costa Rica", 910.525, 125.0, 11, 5),
RadioPreset("EU/UK (Narrow)", 869.618, 62.5, 8, 8),
RadioPreset("EU/UK (Deprecated)", 869.525, 250.0, 11, 5),
RadioPreset("Czech Republic (Narrow)", 869.432, 62.5, 7, 5),
RadioPreset("EU 433MHz (Long Range)", 433.650, 250.0, 11, 5),
RadioPreset("EU 433MHz (Narrow)", 433.650, 62.5, 8, 8),
RadioPreset("Hungary", 869.618, 62.5, 7, 5, 2),
RadioPreset("Netherlands", 869.618, 62.5, 7, 5),
RadioPreset("New Zealand (Narrow)", 917.375, 62.5, 7, 5, 2),
RadioPreset("New Zealand (Gisborne)", 917.375, 250.0, 11, 5, 1),
RadioPreset("Portugal 433", 433.375, 62.5, 9, 6),
RadioPreset("Portugal 868", 869.618, 62.5, 7, 6),
RadioPreset("Slovakia", 869.618, 62.5, 7, 5, 2),
RadioPreset("Switzerland", 869.618, 62.5, 8, 8),
RadioPreset("USA/Canada (Recommended)", 910.525, 62.5, 7, 5),
RadioPreset("Vietnam (Narrow)", 920.250, 62.5, 8, 5),
RadioPreset("Vietnam (Deprecated)", 920.250, 250.0, 11, 5),
]
#: Frequencies are set in kHz steps, so a snapshot matches a preset within half of one.
_FREQ_TOLERANCE_MHZ = 0.0005
def current_preset(snapshot: dict[str, Any]) -> RadioPreset | None:
"""Return the preset the radio is tuned to, or ``None`` where it matches none.
Matched on the four radio parameters alone — the ones every node on the mesh has to
agree on — never on the path-hash size a few presets also carry, which is how
MeshCore's own config GUI reads "which of these am I on?".
Args:
snapshot: A device snapshot (or one with staged values merged over it).
"""
for preset in RADIO_PRESETS:
freq, bw = snapshot.get("radio_freq"), snapshot.get("radio_bw")
if (
isinstance(freq, (int, float))
and isinstance(bw, (int, float))
and abs(float(freq) - preset.freq) < _FREQ_TOLERANCE_MHZ
and float(bw) == preset.bw
and snapshot.get("radio_sf") == preset.sf
and snapshot.get("radio_cr") == preset.cr
):
return preset
return None
# --- the registry ------------------------------------------------------------
DEVICE_SETTINGS: list[SettingSpec] = [
# Identity
SettingSpec(
"name",
"Node name",
"The name other nodes see",
"Identity",
"str",
getter=_get("name"),
apply=lambda d, v, s: d.set_name(v),
),
SettingSpec(
"adv_lat",
"Latitude",
"How far north or south this node says it is",
"Identity",
"float",
minimum=-90.0,
maximum=90.0,
getter=_get("adv_lat"),
apply=_coords_apply("adv_lat"),
),
SettingSpec(
"adv_lon",
"Longitude",
"How far east or west this node says it is",
"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",
"Code a phone needs to pair over Bluetooth",
"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)",
"Must match every other node on your mesh",
"Radio",
"float",
minimum=100.0,
maximum=1000.0,
getter=_get("radio_freq"),
apply=_radio_apply("freq"),
),
SettingSpec(
"radio_bw",
"Bandwidth (kHz)",
"Wider is faster, narrower reaches further",
"Radio",
"float",
minimum=1.0,
maximum=1000.0,
getter=_get("radio_bw"),
apply=_radio_apply("bw"),
),
SettingSpec(
"radio_sf",
"Spreading factor",
"Higher reaches further but sends slower",
"Radio",
"int",
minimum=5,
maximum=12,
getter=_get("radio_sf"),
apply=_radio_apply("sf"),
),
SettingSpec(
"radio_cr",
"Coding rate",
"Higher survives noise better, sends slower",
"Radio",
"int",
minimum=5,
maximum=8,
getter=_get("radio_cr"),
apply=_radio_apply("cr"),
),
SettingSpec(
"tx_power",
"TX power (dBm)",
"How loud this radio transmits",
"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",
"Caps how much of the air we use; 0 = no cap",
"Tuning",
"float",
minimum=0.0,
maximum=9.0,
getter=_get("airtime_factor"),
apply=_tuning_apply("airtime_factor"),
),
SettingSpec(
"rx_delay",
"RX delay",
"Wait this long before acting on what we hear",
"Tuning",
"float",
minimum=0.0,
maximum=20.0,
getter=_get("rx_delay"),
apply=_tuning_apply("rx_delay"),
),
# Behavior
SettingSpec(
"manual_add_contacts",
"Add contacts by hand",
"Only save a node when you say so",
"Behavior",
"bool",
getter=_get("manual_add_contacts"),
apply=lambda d, v, s: d.set_manual_add_contacts(v),
),
SettingSpec(
"autoadd_config",
"Auto-add contacts",
"Which kinds of node get saved on their own; 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",
"Keep traffic to one group; empty reaches everyone",
"Behavior",
"str",
max_length=30,
getter=_get("flood_scope"),
apply=lambda d, v, s: d.set_default_flood_scope(v),
),
SettingSpec(
"adv_loc_policy",
"Share location",
"Tell other nodes where this one is",
"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 extra receipts so replies get through",
"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",
"Longer hop ids mix up fewer nodes, but cost space",
"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]