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>
This commit is contained in:
1 parent
43353f83a9
commit
421781f13d
19 files changed
+3081
-108
No files matched your search
+15
-1
@@ -6,7 +6,21 @@
|
||||
"Bash(.venv/Scripts/python.exe -c \"import meshcore; print\\('meshcore', getattr\\(meshcore,'__version__','?'\\)\\)\")",
|
||||
"Bash(.venv/Scripts/python.exe -m pytest -q)",
|
||||
"Bash(git -C d:/vibe/MeshTools status)",
|
||||
"Bash(git -C d:/vibe/MeshTools log --oneline -5)"
|
||||
"Bash(git -C d:/vibe/MeshTools log --oneline -5)",
|
||||
"Bash(.venv/Scripts/python.exe -m meshtools --mock config set radio_sf 99)",
|
||||
"Bash(echo \"exit=$?\")",
|
||||
"Bash(python -c \"import meshcore, os; print\\(os.path.dirname\\(meshcore.__file__\\)\\)\")",
|
||||
"Bash(python -m meshtools trace --mock --target Alice --samples 4)",
|
||||
"Bash(pip show *)",
|
||||
"Bash(.venv/Scripts/python.exe -c \"import meshtools.ui.widgets, meshtools.tools.trace; print\\('ok'\\)\")",
|
||||
"PowerShell(python -m pytest tests/test_skeleton.py -k \"trace or neighbor or flood\" -q)",
|
||||
"PowerShell(.venv\\\\Scripts\\\\python.exe -m pytest -q)",
|
||||
"PowerShell(.venv\\\\Scripts\\\\python.exe -m meshtools devices 2>&1)",
|
||||
"PowerShell(.venv\\\\Scripts\\\\python.exe -m meshtools info 2>&1)",
|
||||
"PowerShell(foreach \\($p in 'COM26','COM16','COM17'\\) { Write-Output \"=== $p ===\"; .venv\\\\Scripts\\\\python.exe -m meshtools --port $p --quiet info 2>&1 | Select-Object -First 4 })",
|
||||
"PowerShell(Get-CimInstance Win32_SerialPort | Where-Object { $_.DeviceID -eq 'COM11' } | Select-Object DeviceID, Description, Name | Format-List; Write-Output \"---\"; Get-Process | Where-Object { $_.Name -match 'meshcore|arduino|putty|python|node|serial|cu' } | Select-Object Id, ProcessName, MainWindowTitle | Format-Table -AutoSize)",
|
||||
"PowerShell(foreach \\($id in 25324,30408\\) { try { Stop-Process -Id $id -Force -ErrorAction Stop; Write-Output \"killed $id\" } catch { Write-Output \"could not kill ${id}: $\\($_.Exception.Message\\)\" } }; Start-Sleep -Milliseconds 800; Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | Select-Object ProcessId | Format-Table -AutoSize)",
|
||||
"PowerShell(.venv\\\\Scripts\\\\python.exe -m meshtools --quiet info 2>&1)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+7
-13
@@ -5,21 +5,15 @@ __pycache__/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Tooling caches
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
|
||||
# Local Claude settings (project settings.json is tracked)
|
||||
# Ignore all dot-directories (.venv, .pytest_cache, .ruff_cache, .vscode, ...)
|
||||
.*/
|
||||
# ...except the project's tracked Claude config (settings.local.json stays ignored).
|
||||
!.claude/
|
||||
.claude/settings.local.json
|
||||
|
||||
# Other virtual environments
|
||||
venv/
|
||||
|
||||
# MeshTools runtime data
|
||||
*.db
|
||||
output/
|
||||
@@ -13,6 +13,8 @@ turned into interactive HTML visualizations.
|
||||
| --- | --- | --- |
|
||||
| **Trace** | ✅ skeleton | Run repeated path traces to a target and aggregate per-hop SNR. |
|
||||
| **Device info** | ✅ skeleton | Show the connected companion device's identity and radio config. |
|
||||
| **Device config** | ✅ working | View and change every setting (name, radio, behavior, experimental), with TOML backup/restore, channels, and gated destructive ops. |
|
||||
| **Device discovery** | ✅ working | Enumerate serial devices, pick one interactively, and remember the last good default. |
|
||||
| **History / DB** | ✅ skeleton | Every tool execution and measurement is persisted and queryable. |
|
||||
| **TX-power optimization** | ✅ working | Sweep transmit power (coarse + refine), converge on the best signal, optionally apply, render an interactive chart. |
|
||||
| **Path optimization** | 🚧 planned | Discover and score mesh paths from neighbors outward. |
|
||||
@@ -34,10 +36,21 @@ meshtools
|
||||
|
||||
# Scripted: every menu option is also a subcommand
|
||||
meshtools trace --target Alice --samples 10 --profile yagi
|
||||
|
||||
# Force a route through specific repeaters (MeshCore-app style): comma-separated
|
||||
# contact names and/or hex key prefixes, mixed freely. Blank lets the device route.
|
||||
meshtools trace --target Alice --path "3d,f2,3d"
|
||||
meshtools trace --target Alice --path "3d,Bravo-Repeater,f2"
|
||||
meshtools tx-optimize --target Alice --samples 6 --step 3 --apply
|
||||
meshtools info
|
||||
meshtools history
|
||||
|
||||
# Device configuration: view, set, back up, restore
|
||||
meshtools config # show all current settings
|
||||
meshtools config set radio_sf 9 # change one setting
|
||||
meshtools config backup node.toml # archive every setting to TOML
|
||||
meshtools config restore node.toml --dry-run
|
||||
|
||||
# No radio attached? Use the built-in simulator for development.
|
||||
meshtools --mock trace --target Alice
|
||||
```
|
||||
|
||||
+5
-2
@@ -17,6 +17,8 @@ import typer
|
||||
|
||||
from .context import AppContext
|
||||
from .core.config import Settings
|
||||
from .core.connection import DeviceCommandError
|
||||
from .core.device_config import DeviceConfigError
|
||||
from .core.device_store import DeviceStore
|
||||
from .core.selection import DeviceSelectionError
|
||||
from .persistence.logging import configure_logging
|
||||
@@ -101,8 +103,9 @@ def run_tool_command(tool: Tool, params: dict) -> None:
|
||||
assert _state is not None # set by the callback before any subcommand runs
|
||||
try:
|
||||
asyncio.run(_drive(_execute_and_render(tool, params, _state), _state))
|
||||
except DeviceSelectionError as exc:
|
||||
# Ambiguous/absent device: show the guidance message, not a traceback.
|
||||
except (DeviceSelectionError, DeviceConfigError, DeviceCommandError) as exc:
|
||||
# Expected user-facing error (ambiguous/absent device, bad config value, or a
|
||||
# transient command failure): show the message, not a traceback.
|
||||
_state.console.print(f"[err]✗[/err] {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""TOML backup and restore for device configuration.
|
||||
|
||||
A backup captures every registry setting's current value plus experimental custom
|
||||
variables and configured channels, so a device's configuration can be archived, diffed,
|
||||
or cloned onto another node. Restore turns a backup into the same operation list the
|
||||
``config`` tool executes for live edits, and supports a dry-run diff against the device's
|
||||
current state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import tomli_w
|
||||
|
||||
from .device_config import DEVICE_SETTINGS, get_spec, parse_value
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else: # pragma: no cover - exercised only on 3.10
|
||||
import tomli as tomllib
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Backup:
|
||||
"""A parsed configuration backup.
|
||||
|
||||
Attributes:
|
||||
settings: Registry setting key -> raw value.
|
||||
custom: Experimental custom variable name -> value.
|
||||
channels: One dict per channel with ``index``, ``name`` and hex ``secret``.
|
||||
"""
|
||||
|
||||
settings: dict[str, Any] = field(default_factory=dict)
|
||||
custom: dict[str, str] = field(default_factory=dict)
|
||||
channels: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
def backup_config(
|
||||
path: Path,
|
||||
snapshot: dict,
|
||||
custom_vars: dict[str, str],
|
||||
channels: list[dict],
|
||||
) -> Path:
|
||||
"""Write the current configuration to a TOML file.
|
||||
|
||||
Args:
|
||||
path: Destination file path (parent directories are created).
|
||||
snapshot: A device snapshot (see ``device_config.build_snapshot``).
|
||||
custom_vars: Experimental custom variables.
|
||||
channels: Channel dicts (``channel_idx``, ``channel_name``, ``channel_secret``
|
||||
as raw bytes).
|
||||
|
||||
Returns:
|
||||
The path written.
|
||||
"""
|
||||
settings: dict[str, Any] = {}
|
||||
for spec in DEVICE_SETTINGS:
|
||||
value = spec.getter(snapshot)
|
||||
if value is not None:
|
||||
settings[spec.key] = value
|
||||
|
||||
doc: dict[str, Any] = {"settings": settings}
|
||||
if custom_vars:
|
||||
doc["custom"] = dict(custom_vars)
|
||||
if channels:
|
||||
doc["channels"] = [
|
||||
{
|
||||
"index": ch.get("channel_idx", idx),
|
||||
"name": ch.get("channel_name", ""),
|
||||
"secret": _to_hex(ch.get("channel_secret")),
|
||||
}
|
||||
for idx, ch in enumerate(channels)
|
||||
]
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("wb") as fh:
|
||||
tomli_w.dump(doc, fh)
|
||||
return path
|
||||
|
||||
|
||||
def read_backup(path: Path) -> Backup:
|
||||
"""Read and parse a TOML configuration backup.
|
||||
|
||||
Args:
|
||||
path: The backup file to read.
|
||||
|
||||
Returns:
|
||||
A :class:`Backup`.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If ``path`` does not exist.
|
||||
"""
|
||||
with path.open("rb") as fh:
|
||||
doc = tomllib.load(fh)
|
||||
return Backup(
|
||||
settings=dict(doc.get("settings") or {}),
|
||||
custom=dict(doc.get("custom") or {}),
|
||||
channels=list(doc.get("channels") or []),
|
||||
)
|
||||
|
||||
|
||||
def plan_restore(
|
||||
backup: Backup,
|
||||
snapshot: dict,
|
||||
current_custom: dict[str, str],
|
||||
) -> list[tuple]:
|
||||
"""Diff a backup against the current state and return the operations to apply.
|
||||
|
||||
Only values that differ from the device's current configuration are emitted, so a
|
||||
restore (or its dry-run preview) shows exactly what would change.
|
||||
|
||||
Args:
|
||||
backup: The parsed backup.
|
||||
snapshot: The device's current snapshot.
|
||||
current_custom: The device's current custom variables.
|
||||
|
||||
Returns:
|
||||
A list of operation tuples compatible with the ``config`` tool:
|
||||
``("set", key, value)``, ``("set_custom", key, value)``, and
|
||||
``("set_channel", index, name, secret_bytes)``.
|
||||
|
||||
Raises:
|
||||
DeviceConfigError: If a backed-up setting key or value is invalid.
|
||||
"""
|
||||
ops: list[tuple] = []
|
||||
for key, raw in backup.settings.items():
|
||||
spec = get_spec(key) # raises on unknown key
|
||||
value = parse_value(spec, raw)
|
||||
if value != spec.getter(snapshot):
|
||||
ops.append(("set", key, value))
|
||||
|
||||
for key, value in backup.custom.items():
|
||||
if current_custom.get(key) != value:
|
||||
ops.append(("set_custom", key, str(value)))
|
||||
|
||||
for ch in backup.channels:
|
||||
secret_hex = ch.get("secret") or ""
|
||||
secret = bytes.fromhex(secret_hex) if secret_hex else None
|
||||
ops.append(("set_channel", int(ch.get("index", 0)), str(ch.get("name", "")), secret))
|
||||
|
||||
return ops
|
||||
|
||||
|
||||
def _to_hex(secret: Any) -> str:
|
||||
"""Render a channel secret as a hex string, accepting bytes or str."""
|
||||
if isinstance(secret, (bytes, bytearray)):
|
||||
return secret.hex()
|
||||
return str(secret or "")
|
||||
+506
-31
@@ -24,6 +24,14 @@ TX_POWER_MIN = 1
|
||||
TX_POWER_MAX = 22
|
||||
|
||||
|
||||
class DeviceCommandError(RuntimeError):
|
||||
"""A device command failed in a recoverable, user-facing way.
|
||||
|
||||
Raised for conditions worth reporting cleanly (no traceback) — chiefly the
|
||||
companion's intermittent failure to answer a query in time. Callers may retry.
|
||||
"""
|
||||
|
||||
|
||||
class Device(ABC):
|
||||
"""Abstract companion device exposing the operations MeshTools needs.
|
||||
|
||||
@@ -73,21 +81,141 @@ class Device(ABC):
|
||||
self,
|
||||
target: str,
|
||||
*,
|
||||
path: Optional[list[str]] = None,
|
||||
path: Optional[str] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> TraceResult:
|
||||
"""Run a single path trace to ``target`` and return per-hop SNR.
|
||||
|
||||
Args:
|
||||
target: Name or key prefix of the destination node.
|
||||
path: Optional explicit path to force (list of node identifiers). When
|
||||
``None`` the device chooses the path.
|
||||
path: Optional explicit path to force, as a comma-separated string of
|
||||
single-byte hex key prefixes (e.g. ``"3d,f2,3d"``). When ``None``
|
||||
the device chooses the path.
|
||||
timeout: Seconds to wait for the trace reply.
|
||||
|
||||
Returns:
|
||||
A :class:`TraceResult`; ``success`` is ``False`` on timeout.
|
||||
"""
|
||||
|
||||
# -- configuration: extra reads ---------------------------------------------
|
||||
|
||||
@abstractmethod
|
||||
async def get_tuning(self) -> dict:
|
||||
"""Return radio tuning parameters.
|
||||
|
||||
Returns:
|
||||
A dict with ``rx_delay`` and ``airtime_factor`` (both ints).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_path_hash_mode(self) -> int:
|
||||
"""Return the current path-hash mode (experimental routing flag)."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_custom_vars(self) -> dict[str, str]:
|
||||
"""Return the device's experimental custom key/value variables."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_channel(self, index: int) -> Optional[dict]:
|
||||
"""Return one channel's configuration, or ``None`` if unset.
|
||||
|
||||
Args:
|
||||
index: Zero-based channel slot.
|
||||
|
||||
Returns:
|
||||
A dict with ``channel_idx``, ``channel_name`` and ``channel_secret``
|
||||
(16 raw bytes), or ``None`` when the slot is empty.
|
||||
"""
|
||||
|
||||
# -- configuration: settable values -----------------------------------------
|
||||
|
||||
@abstractmethod
|
||||
async def set_name(self, name: str) -> None:
|
||||
"""Set the node's advertised name."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_coords(self, lat: float, lon: float) -> None:
|
||||
"""Set the node's advertised latitude/longitude (decimal degrees)."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_device_pin(self, pin: int) -> None:
|
||||
"""Set the device's BLE pairing PIN."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_radio(self, freq: float, bw: float, sf: int, cr: int) -> None:
|
||||
"""Set the core radio parameters.
|
||||
|
||||
Args:
|
||||
freq: Frequency in MHz.
|
||||
bw: Bandwidth in kHz.
|
||||
sf: Spreading factor.
|
||||
cr: Coding rate denominator (``5``-``8`` for 4/5-4/8).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def set_tuning(self, rx_delay: int, airtime_factor: int) -> None:
|
||||
"""Set radio tuning parameters (RX delay and airtime budgeting factor)."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_manual_add_contacts(self, enabled: bool) -> None:
|
||||
"""Set whether contacts must be added manually rather than automatically."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_adv_loc_policy(self, policy: int) -> None:
|
||||
"""Set the advert location-sharing policy."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_multi_acks(self, value: int) -> None:
|
||||
"""Set the multi-ack behavior flag."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_telemetry_modes(self, base: int, loc: int, env: int) -> None:
|
||||
"""Set the three telemetry mode fields together (each ``0``-``3``)."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_path_hash_mode(self, mode: int) -> None:
|
||||
"""Set the experimental path-hash routing mode."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_custom_var(self, key: str, value: str) -> None:
|
||||
"""Set an experimental custom variable."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_channel(self, index: int, name: str, secret: Optional[bytes]) -> None:
|
||||
"""Configure a channel slot.
|
||||
|
||||
Args:
|
||||
index: Zero-based channel slot.
|
||||
name: Channel name (a leading ``#`` derives the secret from the name).
|
||||
secret: 16-byte shared secret, or ``None`` to derive it from ``name``.
|
||||
"""
|
||||
|
||||
# -- configuration: actions -------------------------------------------------
|
||||
|
||||
@abstractmethod
|
||||
async def set_time(self, epoch: int) -> None:
|
||||
"""Set the device clock to a UNIX epoch timestamp."""
|
||||
|
||||
@abstractmethod
|
||||
async def send_advert(self, flood: bool = False) -> None:
|
||||
"""Broadcast an advertisement (``flood`` propagates it across the mesh)."""
|
||||
|
||||
@abstractmethod
|
||||
async def reboot(self) -> None:
|
||||
"""Reboot the device."""
|
||||
|
||||
@abstractmethod
|
||||
async def export_private_key(self) -> str:
|
||||
"""Export the device's private key as a hex string (sensitive)."""
|
||||
|
||||
@abstractmethod
|
||||
async def import_private_key(self, key_hex: str) -> None:
|
||||
"""Import a private key from a hex string (overwrites the device identity)."""
|
||||
|
||||
@abstractmethod
|
||||
async def factory_reset(self) -> None:
|
||||
"""Erase all device data and restore factory defaults (destructive)."""
|
||||
|
||||
async def __aenter__(self) -> "Device":
|
||||
"""Enter the async context manager, connecting the device."""
|
||||
await self.connect()
|
||||
@@ -151,10 +279,45 @@ class MeshCoreDevice(Device):
|
||||
result = await mc.commands.send_appstart()
|
||||
return dict(getattr(result, "payload", {}) or {})
|
||||
|
||||
async def _contacts_payload(
|
||||
self, mc, *, retries: int = 3, delay: float = 0.5
|
||||
) -> dict: # noqa: ANN001
|
||||
"""Fetch the raw contacts map, retrying the transient "no event" blip.
|
||||
|
||||
The companion intermittently fails to emit the contacts event in time and
|
||||
returns an error event instead of data (``no event received during contacts
|
||||
retrieval``). That's a recoverable timing hiccup, so retry a few times with a
|
||||
short backoff before surfacing a clean, actionable error.
|
||||
|
||||
Args:
|
||||
mc: The connected ``MeshCore`` client.
|
||||
retries: Number of extra attempts after the first.
|
||||
delay: Seconds to wait between attempts.
|
||||
|
||||
Returns:
|
||||
The contacts payload mapping (possibly empty).
|
||||
|
||||
Raises:
|
||||
DeviceCommandError: If every attempt fails to retrieve contacts.
|
||||
"""
|
||||
reason = ""
|
||||
for attempt in range(retries + 1):
|
||||
event = await mc.commands.get_contacts()
|
||||
if event is None or not getattr(event, "is_error", lambda: False)():
|
||||
return dict(getattr(event, "payload", {}) or {})
|
||||
payload = getattr(event, "payload", {}) or {}
|
||||
reason = str(payload.get("reason", payload))
|
||||
if attempt < retries:
|
||||
await asyncio.sleep(delay)
|
||||
raise DeviceCommandError(
|
||||
f"could not read contacts from the radio ({reason}). "
|
||||
"The companion didn't respond in time — this is usually transient; "
|
||||
"retry, or power-cycle/reconnect the radio if it persists."
|
||||
)
|
||||
|
||||
async def get_contacts(self) -> list[Contact]: # noqa: D102 - inherited docstring
|
||||
mc = self._require()
|
||||
result = await mc.commands.get_contacts()
|
||||
payload = getattr(result, "payload", {}) or {}
|
||||
payload = await self._contacts_payload(mc)
|
||||
contacts: list[Contact] = []
|
||||
for name, info in payload.items():
|
||||
info = info or {}
|
||||
@@ -180,44 +343,231 @@ class MeshCoreDevice(Device):
|
||||
self,
|
||||
target: str,
|
||||
*,
|
||||
path: Optional[list[str]] = None,
|
||||
path: Optional[str] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> TraceResult:
|
||||
from meshcore import EventType # local import keeps mock path dependency-free
|
||||
|
||||
from ..services.trace_runner import path_hash_flags
|
||||
|
||||
mc = self._require()
|
||||
tag = random.randint(0, 0xFFFFFFFF)
|
||||
started = asyncio.get_event_loop().time()
|
||||
await mc.commands.send_trace(auth_code=0, tag=tag, flags=0, path=path)
|
||||
|
||||
# A trace packet has no destination field — it walks an explicit path of
|
||||
# repeater hops. Send the path as raw bytes so any uniform hash width
|
||||
# transmits; ``flags`` carries the path-hash mode (size - 1).
|
||||
path_bytes: Optional[bytes] = None
|
||||
flags = 0
|
||||
if path:
|
||||
hops = [h.strip() for h in path.split(",") if h.strip()]
|
||||
path_bytes = bytes.fromhex("".join(hops))
|
||||
flags = path_hash_flags(len(bytes.fromhex(hops[0]))) or 0
|
||||
else:
|
||||
# No forced path: build a path that ends at the target's own hash
|
||||
# (prepending any learned ``out_path`` repeaters), since a trace only
|
||||
# replies when its destination is the final hop. ``None`` only when the
|
||||
# contact is unknown, leaving the trace to run path-less.
|
||||
resolved = await self._trace_path_to_contact(mc, target)
|
||||
if resolved is not None:
|
||||
path_bytes, flags = resolved
|
||||
await mc.commands.send_trace(auth_code=0, tag=tag, flags=flags, path=path_bytes)
|
||||
event = await mc.wait_for_event(
|
||||
EventType.TRACE_DATA,
|
||||
attribute_filters={"tag": tag},
|
||||
timeout=timeout,
|
||||
)
|
||||
elapsed_ms = (asyncio.get_event_loop().time() - started) * 1000.0
|
||||
# The firmware addresses each hop by a hash of ``1 << flags`` bytes; record it
|
||||
# so the summary can show node hashes at the width the command actually used.
|
||||
hash_bytes = 1 << flags
|
||||
if event is None:
|
||||
return TraceResult(target=target, success=False, round_trip_ms=None)
|
||||
return TraceResult(
|
||||
target=target, success=False, round_trip_ms=None, path_hash_bytes=hash_bytes
|
||||
)
|
||||
|
||||
payload = getattr(event, "payload", {}) or {}
|
||||
raw_snrs = payload.get("path_snrs") or payload.get("snrs") or []
|
||||
nodes = payload.get("path") or []
|
||||
hops = [
|
||||
Hop(
|
||||
index=i,
|
||||
node=str(nodes[i]) if i < len(nodes) else None,
|
||||
snr=raw / 4.0, # protocol encodes SNR as a signed byte of SNR * 4
|
||||
)
|
||||
for i, raw in enumerate(raw_snrs)
|
||||
]
|
||||
hops = parse_trace_hops(payload)
|
||||
return TraceResult(
|
||||
target=target,
|
||||
success=True,
|
||||
hops=hops,
|
||||
round_trip_ms=elapsed_ms,
|
||||
tx_power=await self.get_tx_power(),
|
||||
path_hash_bytes=hash_bytes,
|
||||
raw=payload,
|
||||
)
|
||||
|
||||
async def _trace_path_to_contact(
|
||||
self, mc, target: str
|
||||
) -> Optional[tuple[bytes, int]]: # noqa: ANN001
|
||||
"""Resolve a target contact into a trace ``(path_bytes, flags)``.
|
||||
|
||||
A trace reply only comes back when the *destination's own hash* is the final
|
||||
hop in the path — an empty/destination-less path is silently dropped (verified
|
||||
on hardware: a direct neighbor answers a single-hop trace to its own hash but
|
||||
not a path-less one). So we always end the path at the contact's key prefix,
|
||||
prepending any learned repeater hops (``out_path``) ahead of it:
|
||||
|
||||
* direct neighbor / no learned route → just ``[destination]``;
|
||||
* learned multi-hop route → ``[repeater…, destination]``.
|
||||
|
||||
Each hash is re-encoded at the trace's own width (``1 << flags``, only 1/2/4/8
|
||||
bytes), collapsing a region's routing width (e.g. 3) to the widest representable
|
||||
value (2). The firmware matches by hash prefix, so a narrower prefix still
|
||||
addresses the same node.
|
||||
|
||||
Args:
|
||||
mc: The connected ``MeshCore`` client.
|
||||
target: Contact name (case-insensitive) or public-key prefix.
|
||||
|
||||
Returns:
|
||||
``(path_bytes, flags)`` to walk, or ``None`` only when the contact is
|
||||
unknown or carries no public key to address.
|
||||
"""
|
||||
from ..services.trace_runner import path_hash_flags
|
||||
|
||||
payload = await self._contacts_payload(mc)
|
||||
needle = target.casefold()
|
||||
for name, info in payload.items():
|
||||
info = info or {}
|
||||
adv = info.get("adv_name", name)
|
||||
pub = (info.get("public_key", "") or "").lower().removeprefix("0x")
|
||||
if adv.casefold() != needle and not pub.startswith(needle):
|
||||
continue
|
||||
if not pub:
|
||||
return None # no key to address the trace's destination hop
|
||||
|
||||
# Routing hash width (bytes): the contact's stored mode, or — when it has
|
||||
# no learned route (mode == -1) — our region's mode.
|
||||
mode = int(info.get("out_path_hash_mode", -1))
|
||||
if mode < 0:
|
||||
try:
|
||||
mode = int(await mc.commands.get_path_hash_mode())
|
||||
except Exception:
|
||||
mode = 2 # region default: 3-byte hashes
|
||||
size = max(mode + 1, 1)
|
||||
trace_size = max(s for s in (1, 2, 4, 8) if s <= size)
|
||||
|
||||
hops: list[bytes] = []
|
||||
out_path = (info.get("out_path") or "").strip().lower().removeprefix("0x")
|
||||
out_path_len = int(info.get("out_path_len", -1))
|
||||
if 1 <= out_path_len <= 254 and out_path:
|
||||
# Learned multi-hop route: walk each repeater, collapsed to trace width.
|
||||
route = bytes.fromhex(out_path)[: out_path_len * size]
|
||||
hops += [route[i * size : i * size + trace_size] for i in range(out_path_len)]
|
||||
# Always finish at the destination's own hash so the target recognizes the
|
||||
# trace and replies; for a direct neighbor this single hop is the whole path.
|
||||
hops.append(bytes.fromhex(pub)[:trace_size])
|
||||
return b"".join(hops), path_hash_flags(trace_size) or 0
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _ok(event): # type: ignore[no-untyped-def]
|
||||
"""Return ``event`` if it succeeded, else raise its error payload.
|
||||
|
||||
Args:
|
||||
event: The :class:`meshcore.events.Event` returned by a command.
|
||||
|
||||
Returns:
|
||||
The same event, for convenient chaining.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the device reported an error.
|
||||
"""
|
||||
if event is not None and getattr(event, "is_error", lambda: False)():
|
||||
raise RuntimeError(f"device rejected command: {getattr(event, 'payload', {})}")
|
||||
return event
|
||||
|
||||
async def get_tuning(self) -> dict: # noqa: D102 - inherited docstring
|
||||
event = self._ok(await self._require().commands.get_tuning())
|
||||
payload = getattr(event, "payload", {}) or {}
|
||||
return {
|
||||
"rx_delay": int(payload.get("rx_delay", 0)),
|
||||
"airtime_factor": int(payload.get("airtime_factor", 0)),
|
||||
}
|
||||
|
||||
async def get_path_hash_mode(self) -> int: # noqa: D102 - inherited docstring
|
||||
return int(await self._require().commands.get_path_hash_mode())
|
||||
|
||||
async def get_custom_vars(self) -> dict[str, str]: # noqa: D102 - inherited docstring
|
||||
event = self._ok(await self._require().commands.get_custom_vars())
|
||||
return dict(getattr(event, "payload", {}) or {})
|
||||
|
||||
async def get_channel(self, index: int) -> Optional[dict]: # noqa: D102
|
||||
event = self._ok(await self._require().commands.get_channel(index))
|
||||
payload = getattr(event, "payload", {}) or {}
|
||||
if not payload.get("channel_name"):
|
||||
return None
|
||||
return payload
|
||||
|
||||
async def set_name(self, name: str) -> None: # noqa: D102 - inherited docstring
|
||||
self._ok(await self._require().commands.set_name(name))
|
||||
|
||||
async def set_coords(self, lat: float, lon: float) -> None: # noqa: D102
|
||||
self._ok(await self._require().commands.set_coords(lat, lon))
|
||||
|
||||
async def set_device_pin(self, pin: int) -> None: # noqa: D102 - inherited docstring
|
||||
self._ok(await self._require().commands.set_devicepin(pin))
|
||||
|
||||
async def set_radio(self, freq: float, bw: float, sf: int, cr: int) -> None: # noqa: D102
|
||||
self._ok(await self._require().commands.set_radio(freq, bw, sf, cr))
|
||||
|
||||
async def set_tuning(self, rx_delay: int, airtime_factor: int) -> None: # noqa: D102
|
||||
self._ok(await self._require().commands.set_tuning(rx_delay, airtime_factor))
|
||||
|
||||
async def set_manual_add_contacts(self, enabled: bool) -> None: # noqa: D102
|
||||
self._ok(await self._require().commands.set_manual_add_contacts(enabled))
|
||||
|
||||
async def set_adv_loc_policy(self, policy: int) -> None: # noqa: D102
|
||||
self._ok(await self._require().commands.set_advert_loc_policy(policy))
|
||||
|
||||
async def set_multi_acks(self, value: int) -> None: # noqa: D102 - inherited docstring
|
||||
self._ok(await self._require().commands.set_multi_acks(value))
|
||||
|
||||
async def set_telemetry_modes(self, base: int, loc: int, env: int) -> None: # noqa: D102
|
||||
mc = self._require()
|
||||
result = self._ok(await mc.commands.send_appstart())
|
||||
infos = dict(getattr(result, "payload", {}) or {})
|
||||
infos["telemetry_mode_base"] = base
|
||||
infos["telemetry_mode_loc"] = loc
|
||||
infos["telemetry_mode_env"] = env
|
||||
self._ok(await mc.commands.set_other_params_from_infos(infos))
|
||||
|
||||
async def set_path_hash_mode(self, mode: int) -> None: # noqa: D102
|
||||
self._ok(await self._require().commands.set_path_hash_mode(mode))
|
||||
|
||||
async def set_custom_var(self, key: str, value: str) -> None: # noqa: D102
|
||||
self._ok(await self._require().commands.set_custom_var(key, value))
|
||||
|
||||
async def set_channel(self, index: int, name: str, secret: Optional[bytes]) -> None: # noqa: D102
|
||||
self._ok(await self._require().commands.set_channel(index, name, secret))
|
||||
|
||||
async def set_time(self, epoch: int) -> None: # noqa: D102 - inherited docstring
|
||||
self._ok(await self._require().commands.set_time(epoch))
|
||||
|
||||
async def send_advert(self, flood: bool = False) -> None: # noqa: D102
|
||||
self._ok(await self._require().commands.send_advert(flood))
|
||||
|
||||
async def reboot(self) -> None: # noqa: D102 - inherited docstring
|
||||
await self._require().commands.reboot() # device reboots; no OK reply expected
|
||||
|
||||
async def export_private_key(self) -> str: # noqa: D102 - inherited docstring
|
||||
event = self._ok(await self._require().commands.export_private_key())
|
||||
payload = getattr(event, "payload", {}) or {}
|
||||
key = payload.get("private_key")
|
||||
if key is None:
|
||||
raise RuntimeError("device did not return a private key (export may be disabled)")
|
||||
return key.hex() if isinstance(key, (bytes, bytearray)) else str(key)
|
||||
|
||||
async def import_private_key(self, key_hex: str) -> None: # noqa: D102
|
||||
self._ok(await self._require().commands.import_private_key(bytes.fromhex(key_hex)))
|
||||
|
||||
async def factory_reset(self) -> None: # noqa: D102 - inherited docstring
|
||||
mc = self._require()
|
||||
token = await mc.commands.request_factory_reset()
|
||||
self._ok(await mc.commands.confirm_factory_reset(token))
|
||||
|
||||
|
||||
class MockDevice(Device):
|
||||
"""A deterministic simulator implementing the full :class:`Device` interface.
|
||||
@@ -247,6 +597,33 @@ class MockDevice(Device):
|
||||
Contact(name="Observer-Bot", key_prefix="c3d4e5f6"),
|
||||
Contact(name="Alice", key_prefix="d4e5f6a7"),
|
||||
]
|
||||
# Mutable configuration state, keyed exactly like the real SELF_INFO payload so the
|
||||
# settings registry behaves identically on the simulator and on hardware.
|
||||
self._info: dict = {
|
||||
"name": "MockCompanion",
|
||||
"adv_type": 1,
|
||||
"max_tx_power": 22,
|
||||
"public_key": "00" * 32,
|
||||
"adv_lat": 0.0,
|
||||
"adv_lon": 0.0,
|
||||
"multi_acks": 0,
|
||||
"adv_loc_policy": 0,
|
||||
"telemetry_mode_base": 0,
|
||||
"telemetry_mode_loc": 0,
|
||||
"telemetry_mode_env": 0,
|
||||
"manual_add_contacts": False,
|
||||
"radio_freq": 869.525,
|
||||
"radio_bw": 250.0,
|
||||
"radio_sf": 11,
|
||||
"radio_cr": 5,
|
||||
"simulated": True,
|
||||
}
|
||||
self._tuning: dict = {"rx_delay": 0, "airtime_factor": 0}
|
||||
self._path_hash_mode = 0
|
||||
self._custom_vars: dict[str, str] = {}
|
||||
self._channels: dict[int, dict] = {}
|
||||
self._device_pin = 0
|
||||
self._private_key = "11" * 32
|
||||
|
||||
async def connect(self) -> None: # noqa: D102 - inherited docstring
|
||||
await asyncio.sleep(0)
|
||||
@@ -256,15 +633,7 @@ class MockDevice(Device):
|
||||
self._connected = False
|
||||
|
||||
async def get_self_info(self) -> dict: # noqa: D102 - inherited docstring
|
||||
return {
|
||||
"name": "MockCompanion",
|
||||
"tx_power": self._tx_power,
|
||||
"freq": 869.525,
|
||||
"bw": 250,
|
||||
"sf": 11,
|
||||
"cr": 5,
|
||||
"simulated": True,
|
||||
}
|
||||
return {**self._info, "tx_power": self._tx_power}
|
||||
|
||||
async def get_contacts(self) -> list[Contact]: # noqa: D102 - inherited docstring
|
||||
return list(self._contacts)
|
||||
@@ -275,6 +644,80 @@ class MockDevice(Device):
|
||||
async def set_tx_power(self, value: int) -> None: # noqa: D102 - inherited docstring
|
||||
self._tx_power = value
|
||||
|
||||
async def get_tuning(self) -> dict: # noqa: D102 - inherited docstring
|
||||
return dict(self._tuning)
|
||||
|
||||
async def get_path_hash_mode(self) -> int: # noqa: D102 - inherited docstring
|
||||
return self._path_hash_mode
|
||||
|
||||
async def get_custom_vars(self) -> dict[str, str]: # noqa: D102 - inherited docstring
|
||||
return dict(self._custom_vars)
|
||||
|
||||
async def get_channel(self, index: int) -> Optional[dict]: # noqa: D102
|
||||
return self._channels.get(index)
|
||||
|
||||
async def set_name(self, name: str) -> None: # noqa: D102 - inherited docstring
|
||||
self._info["name"] = name
|
||||
|
||||
async def set_coords(self, lat: float, lon: float) -> None: # noqa: D102
|
||||
self._info["adv_lat"] = lat
|
||||
self._info["adv_lon"] = lon
|
||||
|
||||
async def set_device_pin(self, pin: int) -> None: # noqa: D102 - inherited docstring
|
||||
self._device_pin = pin
|
||||
|
||||
async def set_radio(self, freq: float, bw: float, sf: int, cr: int) -> None: # noqa: D102
|
||||
self._info.update(radio_freq=freq, radio_bw=bw, radio_sf=sf, radio_cr=cr)
|
||||
|
||||
async def set_tuning(self, rx_delay: int, airtime_factor: int) -> None: # noqa: D102
|
||||
self._tuning = {"rx_delay": rx_delay, "airtime_factor": airtime_factor}
|
||||
|
||||
async def set_manual_add_contacts(self, enabled: bool) -> None: # noqa: D102
|
||||
self._info["manual_add_contacts"] = enabled
|
||||
|
||||
async def set_adv_loc_policy(self, policy: int) -> None: # noqa: D102
|
||||
self._info["adv_loc_policy"] = policy
|
||||
|
||||
async def set_multi_acks(self, value: int) -> None: # noqa: D102 - inherited docstring
|
||||
self._info["multi_acks"] = value
|
||||
|
||||
async def set_telemetry_modes(self, base: int, loc: int, env: int) -> None: # noqa: D102
|
||||
self._info.update(
|
||||
telemetry_mode_base=base, telemetry_mode_loc=loc, telemetry_mode_env=env
|
||||
)
|
||||
|
||||
async def set_path_hash_mode(self, mode: int) -> None: # noqa: D102
|
||||
self._path_hash_mode = mode
|
||||
|
||||
async def set_custom_var(self, key: str, value: str) -> None: # noqa: D102
|
||||
self._custom_vars[key] = value
|
||||
|
||||
async def set_channel(self, index: int, name: str, secret: Optional[bytes]) -> None: # noqa: D102
|
||||
self._channels[index] = {
|
||||
"channel_idx": index,
|
||||
"channel_name": name,
|
||||
"channel_secret": secret or (b"\x00" * 16),
|
||||
}
|
||||
|
||||
async def set_time(self, epoch: int) -> None: # noqa: D102 - inherited docstring
|
||||
self._info["clock"] = epoch
|
||||
|
||||
async def send_advert(self, flood: bool = False) -> None: # noqa: D102
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def reboot(self) -> None: # noqa: D102 - inherited docstring
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def export_private_key(self) -> str: # noqa: D102 - inherited docstring
|
||||
return self._private_key
|
||||
|
||||
async def import_private_key(self, key_hex: str) -> None: # noqa: D102
|
||||
self._private_key = bytes.fromhex(key_hex).hex() # validates hex, normalizes
|
||||
|
||||
async def factory_reset(self) -> None: # noqa: D102 - inherited docstring
|
||||
self._custom_vars.clear()
|
||||
self._channels.clear()
|
||||
|
||||
def _expected_snr(self, hop_index: int) -> float:
|
||||
"""Model SNR for a hop as an inverted-U in TX power plus distance falloff.
|
||||
|
||||
@@ -294,29 +737,61 @@ class MockDevice(Device):
|
||||
self,
|
||||
target: str,
|
||||
*,
|
||||
path: Optional[list[str]] = None,
|
||||
path: Optional[str] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> TraceResult:
|
||||
await asyncio.sleep(0.05) # mimic radio latency so progress bars are visible
|
||||
depth = len(path) if path else self._rng.randint(1, 3)
|
||||
forced = [h for h in path.split(",") if h.strip()] if path else None
|
||||
# Mirror the real device: the path-hash width is the byte length of a forced hop.
|
||||
hash_bytes = len(bytes.fromhex(forced[0])) if forced else None
|
||||
depth = len(forced) if forced else self._rng.randint(1, 3)
|
||||
hops: list[Hop] = []
|
||||
for i in range(depth):
|
||||
expected = self._expected_snr(i)
|
||||
snr = expected + self._rng.gauss(0, 1.2) # measurement noise
|
||||
node = path[i] if path else f"hop{i}"
|
||||
node = forced[i] if forced else f"hop{i}"
|
||||
hops.append(Hop(index=i, node=node, snr=round(snr, 1)))
|
||||
|
||||
# Very weak links occasionally drop entirely.
|
||||
# Very weak links occasionally drop entirely (judged on the repeater hops).
|
||||
success = (hops[-1].snr if hops else -99) > -12 or self._rng.random() > 0.1
|
||||
|
||||
# The trace reply returns to us: firmware appends the local device as a final
|
||||
# hash-less hop (``node=None``). Mirror that so the origin/destination framing
|
||||
# naturally has our device at both ends of the path. The return link is modeled
|
||||
# as symmetric to the first outbound hop, so it never alters the bottleneck SNR.
|
||||
if hops:
|
||||
hops.append(Hop(index=depth, node=None, snr=hops[0].snr))
|
||||
return TraceResult(
|
||||
target=target,
|
||||
success=success,
|
||||
hops=hops if success else [],
|
||||
round_trip_ms=round(self._rng.uniform(120, 480), 1) if success else None,
|
||||
tx_power=self._tx_power,
|
||||
path_hash_bytes=hash_bytes,
|
||||
)
|
||||
|
||||
|
||||
def parse_trace_hops(payload: dict) -> list[Hop]:
|
||||
"""Extract per-hop SNR from a ``TRACE_DATA`` event payload.
|
||||
|
||||
meshcore parses a trace reply into ``payload["path"]`` — a list of nodes, each a
|
||||
dict with a repeater ``"hash"`` and its ``"snr"`` (already in dB, signed-byte / 4).
|
||||
The final node is the local device and carries an ``"snr"`` but no ``"hash"``.
|
||||
|
||||
Args:
|
||||
payload: The ``TRACE_DATA`` event payload.
|
||||
|
||||
Returns:
|
||||
The hops in path order; nodes without an SNR reading are skipped.
|
||||
"""
|
||||
hops: list[Hop] = []
|
||||
for node in payload.get("path") or []:
|
||||
if not isinstance(node, dict) or node.get("snr") is None:
|
||||
continue
|
||||
hops.append(Hop(index=len(hops), node=node.get("hash"), snr=float(node["snr"])))
|
||||
return hops
|
||||
|
||||
|
||||
def clamp_tx_power(value: int) -> int:
|
||||
"""Clamp a TX power value to the supported range.
|
||||
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
"""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
|
||||
]
|
||||
@@ -7,10 +7,14 @@ real device, the simulator, or rehydrated from the database identically.
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
#: Label used for our own (local) device when framing a trace path's endpoints.
|
||||
LOCAL_DEVICE_LABEL = "us"
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""Return a timezone-aware UTC timestamp.
|
||||
@@ -63,6 +67,8 @@ class TraceResult:
|
||||
hops: Per-hop SNR readings, ordered from source to destination.
|
||||
round_trip_ms: Round-trip time of the trace in milliseconds, if measured.
|
||||
tx_power: TX power level in effect when the trace ran, if known.
|
||||
path_hash_bytes: Per-hop path-hash width (bytes) used by the trace command, so
|
||||
node hashes can be displayed at the same width the command addressed them.
|
||||
timestamp: When the trace completed.
|
||||
raw: Optional raw event payload for debugging/replay.
|
||||
"""
|
||||
@@ -72,6 +78,7 @@ class TraceResult:
|
||||
hops: list[Hop] = field(default_factory=list)
|
||||
round_trip_ms: Optional[float] = None
|
||||
tx_power: Optional[int] = None
|
||||
path_hash_bytes: Optional[int] = None
|
||||
timestamp: datetime = field(default_factory=utcnow)
|
||||
raw: Optional[dict] = None
|
||||
|
||||
@@ -95,6 +102,67 @@ class TraceResult:
|
||||
nodes = [h.node or f"hop{h.index}" for h in self.hops]
|
||||
return " -> ".join(nodes)
|
||||
|
||||
def edges(self, device_label: str = LOCAL_DEVICE_LABEL) -> list["HopEdge"]:
|
||||
"""Frame the per-hop SNR as directed ``origin -> destination`` edges.
|
||||
|
||||
Each hop's SNR is the signal measured arriving at that node, so an edge runs
|
||||
from the previous node to this one. The first edge therefore originates at our
|
||||
own device, and (because firmware records the reply returning to us as a final
|
||||
hash-less hop) the last edge's destination is our device too.
|
||||
|
||||
Args:
|
||||
device_label: Name to show for our own device at the path's endpoints.
|
||||
|
||||
Returns:
|
||||
One :class:`HopEdge` per hop, in path order.
|
||||
"""
|
||||
edges: list[HopEdge] = []
|
||||
origin = device_label
|
||||
for hop in self.hops:
|
||||
destination = hop.node or device_label
|
||||
edges.append(HopEdge(index=hop.index, origin=origin, destination=destination, snr=hop.snr))
|
||||
origin = destination
|
||||
return edges
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HopEdge:
|
||||
"""A directed link in a trace path: a hop framed as ``origin -> destination``.
|
||||
|
||||
Attributes:
|
||||
index: Zero-based position of the hop along the path.
|
||||
origin: Identifier of the transmitting node (our device for the first edge).
|
||||
destination: Identifier of the receiving node (our device for the last edge).
|
||||
snr: Signal-to-noise ratio in dB measured at ``destination``.
|
||||
"""
|
||||
|
||||
index: int
|
||||
origin: str
|
||||
destination: str
|
||||
snr: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HopAggregate:
|
||||
"""Median SNR for one hop position aggregated across several traces.
|
||||
|
||||
``origin`` and ``destination`` hold raw node identifiers; ``None`` means our own
|
||||
device, so the display label can be applied at render time.
|
||||
|
||||
Attributes:
|
||||
index: Zero-based hop position along the path.
|
||||
origin: Representative transmitting node at this position (``None`` = us).
|
||||
destination: Representative receiving node at this position (``None`` = us).
|
||||
median_snr: Median SNR in dB measured at ``destination`` across the samples.
|
||||
samples: Number of traces that reported this hop.
|
||||
"""
|
||||
|
||||
index: int
|
||||
origin: Optional[str]
|
||||
destination: Optional[str]
|
||||
median_snr: float
|
||||
samples: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TraceStats:
|
||||
@@ -110,6 +178,7 @@ class TraceStats:
|
||||
median_min_snr: Median of each trace's bottleneck SNR, the headline metric.
|
||||
median_rtt_ms: Median round-trip time, if measured.
|
||||
tx_power: TX power level in effect for these samples, if fixed.
|
||||
hop_snrs: Per-hop median SNR across the successful traces, in path order.
|
||||
"""
|
||||
|
||||
target: str
|
||||
@@ -118,6 +187,7 @@ class TraceStats:
|
||||
median_min_snr: Optional[float]
|
||||
median_rtt_ms: Optional[float]
|
||||
tx_power: Optional[int] = None
|
||||
hop_snrs: list[HopAggregate] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def success_rate(self) -> float:
|
||||
@@ -146,8 +216,51 @@ class TraceStats:
|
||||
median_min_snr=statistics.median(min_snrs) if min_snrs else None,
|
||||
median_rtt_ms=statistics.median(rtts) if rtts else None,
|
||||
tx_power=next(iter(tx_powers)) if len(tx_powers) == 1 else None,
|
||||
hop_snrs=cls._aggregate_hops(successes),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _aggregate_hops(successes: list["TraceResult"]) -> list[HopAggregate]:
|
||||
"""Compute the median SNR per hop position across successful traces.
|
||||
|
||||
Hops are grouped by their path position; for each position the SNR median is
|
||||
taken and the most common origin/destination nodes are used so the aggregate
|
||||
path reads like a single representative trace. Node identities are kept raw
|
||||
(``None`` = our device) so a display label can be applied later.
|
||||
|
||||
Args:
|
||||
successes: The successful traces to aggregate.
|
||||
|
||||
Returns:
|
||||
One :class:`HopAggregate` per hop position, ordered along the path.
|
||||
"""
|
||||
snrs_by_index: dict[int, list[float]] = {}
|
||||
origins_by_index: dict[int, list[Optional[str]]] = {}
|
||||
dests_by_index: dict[int, list[Optional[str]]] = {}
|
||||
for trace in successes:
|
||||
origin: Optional[str] = None # the first hop originates at our device
|
||||
for hop in trace.hops:
|
||||
snrs_by_index.setdefault(hop.index, []).append(hop.snr)
|
||||
origins_by_index.setdefault(hop.index, []).append(origin)
|
||||
dests_by_index.setdefault(hop.index, []).append(hop.node)
|
||||
origin = hop.node
|
||||
|
||||
aggregates: list[HopAggregate] = []
|
||||
for index in sorted(snrs_by_index):
|
||||
snrs = snrs_by_index[index]
|
||||
origin = Counter(origins_by_index[index]).most_common(1)[0][0]
|
||||
destination = Counter(dests_by_index[index]).most_common(1)[0][0]
|
||||
aggregates.append(
|
||||
HopAggregate(
|
||||
index=index,
|
||||
origin=origin,
|
||||
destination=destination,
|
||||
median_snr=statistics.median(snrs),
|
||||
samples=len(snrs),
|
||||
)
|
||||
)
|
||||
return aggregates
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TxLevelResult:
|
||||
|
||||
@@ -12,7 +12,9 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from ..core.models import TraceResult, TraceStats, utcnow
|
||||
from datetime import datetime
|
||||
|
||||
from ..core.models import Hop, TraceResult, TraceStats, utcnow
|
||||
from . import db
|
||||
|
||||
|
||||
@@ -158,6 +160,53 @@ class Repository:
|
||||
self._conn.commit()
|
||||
return trace_id
|
||||
|
||||
def latest_trace(
|
||||
self,
|
||||
target: str,
|
||||
*,
|
||||
exclude_run_id: Optional[int] = None,
|
||||
success_only: bool = True,
|
||||
) -> Optional[TraceResult]:
|
||||
"""Return the most recently recorded trace to ``target``, rehydrated with hops.
|
||||
|
||||
Used to show the previous run's result before a new trace starts.
|
||||
|
||||
Args:
|
||||
target: The trace destination to look up.
|
||||
exclude_run_id: A run to skip (typically the in-progress one) so we surface
|
||||
a genuinely prior result.
|
||||
success_only: When ``True``, ignore traces that timed out.
|
||||
|
||||
Returns:
|
||||
The latest matching :class:`TraceResult`, or ``None`` if none is stored.
|
||||
"""
|
||||
sql = "SELECT * FROM traces WHERE target = ?"
|
||||
params: list[Any] = [target]
|
||||
if success_only:
|
||||
sql += " AND success = 1"
|
||||
if exclude_run_id is not None:
|
||||
sql += " AND run_id != ?"
|
||||
params.append(exclude_run_id)
|
||||
sql += " ORDER BY id DESC LIMIT 1"
|
||||
row = self._conn.execute(sql, params).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
hop_rows = self._conn.execute(
|
||||
"SELECT hop_index, node, snr FROM trace_hops WHERE trace_id = ? "
|
||||
"ORDER BY hop_index",
|
||||
(row["id"],),
|
||||
).fetchall()
|
||||
hops = [Hop(index=h["hop_index"], node=h["node"], snr=h["snr"]) for h in hop_rows]
|
||||
return TraceResult(
|
||||
target=row["target"],
|
||||
success=bool(row["success"]),
|
||||
hops=hops,
|
||||
round_trip_ms=row["round_trip_ms"],
|
||||
tx_power=row["tx_power"],
|
||||
timestamp=datetime.fromisoformat(row["created_at"]),
|
||||
)
|
||||
|
||||
def record_tx_sample(self, run_id: int, stats: TraceStats) -> None:
|
||||
"""Persist one robust TX-power sample from an optimization sweep.
|
||||
|
||||
|
||||
@@ -11,17 +11,145 @@ import asyncio
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
from ..core.connection import Device
|
||||
from ..core.models import TraceResult, TraceStats
|
||||
from ..core.models import Contact, TraceResult, TraceStats
|
||||
|
||||
ProgressCallback = Callable[[int, int, TraceResult], None]
|
||||
|
||||
#: Maps a trace hop's key-prefix hash to a display label (a contact name, or the
|
||||
#: hash itself when unknown). ``None`` passes through (our own device).
|
||||
NodeResolver = Callable[[Optional[str]], Optional[str]]
|
||||
|
||||
|
||||
_HEX_DIGITS = frozenset("0123456789abcdef")
|
||||
|
||||
|
||||
def make_node_resolver(contacts: Optional[list[Contact]]) -> NodeResolver:
|
||||
"""Build a resolver that names a trace hop from its key-prefix hash.
|
||||
|
||||
Trace replies identify each repeater only by a short hash — a leading slice of
|
||||
its public key. This maps that hash to the contact's friendly name when we know
|
||||
the node, so results read as names instead of opaque hex, and falls back to the
|
||||
raw hash for unknown nodes.
|
||||
|
||||
Args:
|
||||
contacts: Known contacts to resolve against.
|
||||
|
||||
Returns:
|
||||
A callable taking a hop label and returning a contact name when the hash
|
||||
matches a known contact, otherwise the label unchanged (``None`` = our
|
||||
device, passed through untouched).
|
||||
"""
|
||||
# (name, public_key, key_prefix), all lowercased for case-insensitive prefixing.
|
||||
entries: list[tuple[str, str, str]] = []
|
||||
for c in contacts or []:
|
||||
pub = (c.public_key or "").lower().removeprefix("0x")
|
||||
prefix = (c.key_prefix or "").lower().removeprefix("0x")
|
||||
if c.name and (pub or prefix):
|
||||
entries.append((c.name, pub, prefix))
|
||||
|
||||
def resolve(label: Optional[str]) -> Optional[str]:
|
||||
if not label:
|
||||
return label
|
||||
needle = label.lower().removeprefix("0x")
|
||||
for name, pub, prefix in entries:
|
||||
# The hash is a prefix of the node's key; match either against the full
|
||||
# public key or the (possibly shorter) stored prefix, in either direction.
|
||||
if pub and pub.startswith(needle):
|
||||
return name
|
||||
if prefix and (prefix.startswith(needle) or needle.startswith(prefix)):
|
||||
return name
|
||||
return label
|
||||
|
||||
return resolve
|
||||
|
||||
|
||||
def parse_trace_path(spec: str, contacts: Optional[list[Contact]] = None) -> str:
|
||||
"""Parse a user path spec into the hex path string ``send_trace`` expects.
|
||||
|
||||
The MeshCore trace protocol forces a route through a list of repeaters, each
|
||||
addressed by a leading slice of its public key (the "path hash"). This mirrors
|
||||
the comma-separated path field in the MeshCore mobile apps, e.g. ``"3d,f2,3d"``.
|
||||
|
||||
Each comma-separated token may be either a contact name (resolved to its key
|
||||
prefix, case-insensitively) or a raw hex key prefix; the two may be mixed in a
|
||||
single spec. The path-hash *width* is taken from the hex tokens you type (every
|
||||
hop uses the same width); contact names are truncated to that width. With only
|
||||
contact names and no hex token, each name's full key prefix is used.
|
||||
|
||||
Args:
|
||||
spec: Comma-separated path, e.g. ``"3d5f7a,Alice,f2a1b3"``.
|
||||
contacts: Known contacts used to resolve names to key prefixes.
|
||||
|
||||
Returns:
|
||||
A comma-separated hex string of uniform-width prefixes, e.g. ``"3d5f7a,d4e5f6,f2a1b3"``.
|
||||
|
||||
Raises:
|
||||
ValueError: If a token is neither a known contact nor valid hex, if the hex
|
||||
tokens disagree on width, or if the spec contains no usable hops.
|
||||
"""
|
||||
by_name = {c.name.casefold(): c.key_prefix for c in (contacts or [])}
|
||||
# (hex_text, is_name) per hop; names keep their full prefix until width is known.
|
||||
tokens: list[tuple[str, bool]] = []
|
||||
for raw in spec.split(","):
|
||||
token = raw.strip()
|
||||
if not token:
|
||||
continue
|
||||
key = token.casefold()
|
||||
if key in by_name:
|
||||
tokens.append((by_name[key].lower().removeprefix("0x"), True))
|
||||
else:
|
||||
tokens.append((token.lower().removeprefix("0x"), False))
|
||||
if not tokens:
|
||||
raise ValueError("path is empty")
|
||||
|
||||
# The width is dictated by explicit hex tokens; names are truncated to match.
|
||||
# With names only, fall back to the (uniform) length of the resolved prefixes.
|
||||
hex_widths = {len(text) for text, is_name in tokens if not is_name}
|
||||
if len(hex_widths) > 1:
|
||||
raise ValueError("all hops must use the same number of hex digits")
|
||||
width = hex_widths.pop() if hex_widths else max(len(text) for text, _ in tokens)
|
||||
if width == 0 or width % 2:
|
||||
raise ValueError("hex key prefixes must have an even number of digits")
|
||||
|
||||
hops: list[str] = []
|
||||
for text, is_name in tokens:
|
||||
hop = text[:width] if is_name else text
|
||||
if len(hop) != width or any(ch not in _HEX_DIGITS for ch in hop):
|
||||
raise ValueError(
|
||||
f"{text!r} is not a known contact or a {width // 2}-byte hex prefix"
|
||||
)
|
||||
hops.append(hop)
|
||||
return ",".join(hops)
|
||||
|
||||
|
||||
def path_hash_flags(width_bytes: int) -> Optional[int]:
|
||||
"""Return the trace ``flags`` value that encodes a per-hop path-hash width.
|
||||
|
||||
The trace subsystem encodes the hash size as ``1 << (flags & 3)`` on both the
|
||||
send and receive sides (``send_trace`` and the ``TRACE_DATA`` reader), so only
|
||||
widths of 1, 2, 4, or 8 bytes are representable. This is independent of, and
|
||||
differs from, the ``mode = size - 1`` encoding used for *contact routing*
|
||||
(``out_path_hash_mode``).
|
||||
|
||||
Args:
|
||||
width_bytes: Path-hash width in bytes.
|
||||
|
||||
Returns:
|
||||
The flags value (the exponent ``s``), or ``None`` if the width is not a
|
||||
representable power of two.
|
||||
"""
|
||||
for s in range(4):
|
||||
if (1 << s) == width_bytes:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
async def run_traces(
|
||||
device: Device,
|
||||
target: str,
|
||||
*,
|
||||
samples: int = 5,
|
||||
path: Optional[list[str]] = None,
|
||||
path: Optional[str] = None,
|
||||
cooldown_s: float = 1.0,
|
||||
timeout: float = 10.0,
|
||||
on_result: Optional[ProgressCallback] = None,
|
||||
@@ -65,7 +193,7 @@ async def measure(
|
||||
target: str,
|
||||
*,
|
||||
samples: int = 5,
|
||||
path: Optional[list[str]] = None,
|
||||
path: Optional[str] = None,
|
||||
cooldown_s: float = 1.0,
|
||||
on_result: Optional[ProgressCallback] = None,
|
||||
persist: Optional[Callable[[TraceResult], Awaitable[None] | None]] = None,
|
||||
|
||||
@@ -105,15 +105,18 @@ class Tool(ABC):
|
||||
Raises:
|
||||
Exception: Re-raises any error from :meth:`run` after recording it.
|
||||
"""
|
||||
from ..core.connection import DeviceCommandError
|
||||
from ..core.device_config import DeviceConfigError
|
||||
from ..core.selection import DeviceSelectionError
|
||||
|
||||
run_id = ctx.repo.start_run(self.name, params, ctx.profile_name)
|
||||
ctx.log.debug("run %s start: tool=%s params=%s", run_id, self.name, params)
|
||||
try:
|
||||
result = await self.run(ctx, {**params, "_run_id": run_id})
|
||||
except DeviceSelectionError as exc:
|
||||
# Expected user-facing condition (no/ambiguous device): record it but don't
|
||||
# dump a traceback; callers print the guidance message cleanly.
|
||||
except (DeviceSelectionError, DeviceConfigError, DeviceCommandError) as exc:
|
||||
# Expected user-facing condition (no/ambiguous device, bad config value, or
|
||||
# a transient command failure): record it but don't dump a traceback;
|
||||
# callers print the message cleanly.
|
||||
ctx.repo.finish_run(run_id, "error", {"error": str(exc)})
|
||||
ctx.log.debug("run %s aborted: %s", run_id, exc)
|
||||
raise
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
"""The ``config`` tool: view and change every device configuration value.
|
||||
|
||||
Interactively it launches a full editor (see :mod:`meshtools.ui.config_editor`); on the
|
||||
CLI it exposes generic key/value subcommands plus backup/restore, channels, custom vars,
|
||||
and a gated set of destructive operations. Both paths funnel into :meth:`ConfigTool.run`,
|
||||
which executes a list of operation tuples so every change is logged through the run record.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import typer
|
||||
from rich.table import Table
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core.config_io import backup_config, plan_restore, read_backup
|
||||
from ..core.connection import Device
|
||||
from ..core.device_config import (
|
||||
build_snapshot,
|
||||
format_value,
|
||||
get_spec,
|
||||
parse_value,
|
||||
)
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
# How many channel slots to probe when backing up.
|
||||
_MAX_CHANNELS = 8
|
||||
|
||||
|
||||
@register
|
||||
class ConfigTool(Tool):
|
||||
"""View and edit the connected device's full configuration."""
|
||||
|
||||
name = "config"
|
||||
help = "View and change device configuration (name, radio, experimental, ...)."
|
||||
category = "Device"
|
||||
order = 8
|
||||
|
||||
async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]:
|
||||
"""Launch the interactive editor and collect the operations to perform.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
|
||||
Returns:
|
||||
``{"ops": [...]}`` to execute, or ``None`` if the user cancelled.
|
||||
"""
|
||||
from ..ui.config_editor import edit_config
|
||||
|
||||
ops = await edit_config(ctx)
|
||||
if not ops:
|
||||
return None
|
||||
return {"ops": ops}
|
||||
|
||||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Execute a list of configuration operations against the device.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
params: ``ops`` — a list of operation tuples (see module docstring).
|
||||
|
||||
Returns:
|
||||
A :class:`ToolResult` summarizing how many values changed.
|
||||
"""
|
||||
device = await ctx.device()
|
||||
ops: list[tuple] = list(params.get("ops") or [])
|
||||
snapshot = await build_snapshot(device)
|
||||
changes, artifacts = await apply_ops(ctx, device, snapshot, ops)
|
||||
|
||||
message = f"[ok]✓[/ok] applied [brand]{changes}[/brand] change(s)" if changes else None
|
||||
return ToolResult(summary={"changes": changes}, message=message, artifacts=artifacts)
|
||||
|
||||
# -- CLI --------------------------------------------------------------------
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
"""Register the ``config`` subcommand group.
|
||||
|
||||
Args:
|
||||
app: The Typer application.
|
||||
"""
|
||||
from ..cli import run_tool_command
|
||||
|
||||
config_app = typer.Typer(
|
||||
help=self.help, no_args_is_help=False, rich_markup_mode="rich"
|
||||
)
|
||||
|
||||
@config_app.callback(invoke_without_command=True)
|
||||
def _root(ctx: typer.Context) -> None:
|
||||
"""Show the configuration when no subcommand is given."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
run_tool_command(self, {"ops": [("show",)]})
|
||||
|
||||
@config_app.command("show", help="Show all current settings.")
|
||||
def _show_cmd() -> None:
|
||||
run_tool_command(self, {"ops": [("show",)]})
|
||||
|
||||
@config_app.command("get", help="Print one setting's value.")
|
||||
def _get_cmd(key: str = typer.Argument(..., help="Setting key.")) -> None:
|
||||
run_tool_command(self, {"ops": [("get", key)]})
|
||||
|
||||
@config_app.command("set", help="Set one setting to a value.")
|
||||
def _set_cmd(
|
||||
key: str = typer.Argument(..., help="Setting key."),
|
||||
value: str = typer.Argument(..., help="New value."),
|
||||
) -> None:
|
||||
run_tool_command(self, {"ops": [("set", key, value)]})
|
||||
|
||||
@config_app.command("backup", help="Write all settings to a TOML file.")
|
||||
def _backup_cmd(path: Path = typer.Argument(..., help="Destination file.")) -> None:
|
||||
run_tool_command(self, {"ops": [("backup", path)]})
|
||||
|
||||
@config_app.command("restore", help="Apply settings from a TOML backup.")
|
||||
def _restore_cmd(
|
||||
path: Path = typer.Argument(..., help="Backup file."),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without applying."),
|
||||
) -> None:
|
||||
run_tool_command(self, {"ops": [("restore", path, dry_run)]})
|
||||
|
||||
@config_app.command("custom", help="Set an experimental custom variable.")
|
||||
def _custom_cmd(
|
||||
key: str = typer.Argument(..., help="Variable name."),
|
||||
value: str = typer.Argument(..., help="Variable value."),
|
||||
) -> None:
|
||||
run_tool_command(self, {"ops": [("set_custom", key, value)]})
|
||||
|
||||
@config_app.command("channel", help="Configure a channel slot.")
|
||||
def _channel_cmd(
|
||||
index: int = typer.Argument(..., help="Channel slot index."),
|
||||
name: str = typer.Argument(..., help="Channel name (# derives the secret)."),
|
||||
secret: Optional[str] = typer.Option(None, "--secret", help="16-byte hex secret."),
|
||||
) -> None:
|
||||
secret_bytes = bytes.fromhex(secret) if secret else None
|
||||
run_tool_command(self, {"ops": [("set_channel", index, name, secret_bytes)]})
|
||||
|
||||
@config_app.command("advert", help="Broadcast an advertisement.")
|
||||
def _advert_cmd(
|
||||
flood: bool = typer.Option(False, "--flood", help="Flood across the mesh."),
|
||||
) -> None:
|
||||
run_tool_command(self, {"ops": [("advert", flood)]})
|
||||
|
||||
@config_app.command("export-key", help="Export the private key (sensitive).")
|
||||
def _export_key_cmd(
|
||||
out: Optional[Path] = typer.Option(None, "--out", help="Write to file instead of stdout."),
|
||||
) -> None:
|
||||
ops = [("export_key", out)] if out else [("export_key",)]
|
||||
run_tool_command(self, {"ops": ops})
|
||||
|
||||
@config_app.command("import-key", help="Import a private key (overwrites identity).")
|
||||
def _import_key_cmd(
|
||||
key_hex: str = typer.Argument(..., help="Private key as hex."),
|
||||
yes: bool = typer.Option(False, "--yes", help="Confirm this destructive action."),
|
||||
) -> None:
|
||||
_require_yes(yes, "import-key overwrites the device identity")
|
||||
run_tool_command(self, {"ops": [("import_key", key_hex)]})
|
||||
|
||||
@config_app.command("reboot", help="Reboot the device.")
|
||||
def _reboot_cmd(
|
||||
yes: bool = typer.Option(False, "--yes", help="Confirm reboot."),
|
||||
) -> None:
|
||||
_require_yes(yes, "reboot restarts the device")
|
||||
run_tool_command(self, {"ops": [("reboot",)]})
|
||||
|
||||
@config_app.command("factory-reset", help="Erase all data and reset to defaults.")
|
||||
def _factory_reset_cmd(
|
||||
yes: bool = typer.Option(False, "--yes", help="Confirm this destructive action."),
|
||||
) -> None:
|
||||
_require_yes(yes, "factory-reset erases ALL data on the device")
|
||||
run_tool_command(self, {"ops": [("factory_reset",)]})
|
||||
|
||||
app.add_typer(config_app, name=self.name)
|
||||
|
||||
|
||||
# -- op execution (shared by the tool and the interactive editor) -------------
|
||||
|
||||
|
||||
async def apply_ops(
|
||||
ctx: AppContext, device: Device, snapshot: dict, ops: list[tuple]
|
||||
) -> tuple[int, list[str]]:
|
||||
"""Execute a list of configuration operation tuples against ``device``.
|
||||
|
||||
This is the single executor for every config operation, used both by
|
||||
:meth:`ConfigTool.run` (for staged, applied changes) and by the interactive editor's
|
||||
danger zone (which runs its action immediately rather than staging it).
|
||||
|
||||
Args:
|
||||
ctx: Shared application context (for console output).
|
||||
device: The connected device to act on.
|
||||
snapshot: The current device snapshot; updated in place as settings change so
|
||||
coupled commands rebuild from current values.
|
||||
ops: Operation tuples (see the module docstring).
|
||||
|
||||
Returns:
|
||||
A ``(changes, artifacts)`` pair: the number of value changes applied and any
|
||||
file paths produced.
|
||||
"""
|
||||
changes = 0
|
||||
artifacts: list[str] = []
|
||||
for op in ops:
|
||||
kind = op[0]
|
||||
if kind == "show":
|
||||
await _show(ctx, device, snapshot)
|
||||
elif kind == "get":
|
||||
spec = get_spec(op[1])
|
||||
ctx.console.print(
|
||||
f"[muted]{spec.key}[/muted] = {format_value(spec, spec.getter(snapshot))}"
|
||||
)
|
||||
elif kind == "set":
|
||||
changes += await _apply_setting(ctx, device, op[1], op[2], snapshot)
|
||||
elif kind == "set_custom":
|
||||
await device.set_custom_var(op[1], op[2])
|
||||
ctx.console.print(f"[ok]✓[/ok] custom [brand]{op[1]}[/brand] = {op[2]}")
|
||||
changes += 1
|
||||
elif kind == "set_channel":
|
||||
await device.set_channel(op[1], op[2], op[3])
|
||||
ctx.console.print(f"[ok]✓[/ok] channel {op[1]} = [brand]{op[2]}[/brand]")
|
||||
changes += 1
|
||||
elif kind == "backup":
|
||||
artifacts.append(str(await _backup(device, snapshot, op[1])))
|
||||
elif kind == "restore":
|
||||
changes += await _restore(ctx, device, snapshot, op[1], op[2])
|
||||
elif kind == "advert":
|
||||
await device.send_advert(len(op) > 1 and bool(op[1]))
|
||||
ctx.console.print("[ok]✓[/ok] advertisement sent")
|
||||
elif kind == "reboot":
|
||||
await device.reboot()
|
||||
ctx.console.print("[warn]device rebooting[/warn]")
|
||||
elif kind == "export_key":
|
||||
await _export_key(ctx, device, op[1] if len(op) > 1 else None, artifacts)
|
||||
elif kind == "import_key":
|
||||
await device.import_private_key(op[1])
|
||||
ctx.console.print("[ok]✓[/ok] private key imported")
|
||||
changes += 1
|
||||
elif kind == "factory_reset":
|
||||
await device.factory_reset()
|
||||
ctx.console.print("[err]device factory-reset[/err]")
|
||||
else: # pragma: no cover - guarded by the call sites that build ops
|
||||
ctx.console.print(f"[err]unknown config op: {kind}[/err]")
|
||||
return changes, artifacts
|
||||
|
||||
|
||||
async def _apply_setting(
|
||||
ctx: AppContext, device: Device, key: str, raw: Any, snapshot: dict
|
||||
) -> int:
|
||||
"""Parse, apply and record one setting; keep ``snapshot`` consistent.
|
||||
|
||||
The local snapshot is updated with the new value so a later coupled change in the
|
||||
same batch (e.g. another radio field) is rebuilt from current values.
|
||||
|
||||
Returns:
|
||||
``1`` (a change was applied).
|
||||
"""
|
||||
spec = get_spec(key)
|
||||
value = parse_value(spec, raw)
|
||||
await spec.apply(device, value, snapshot)
|
||||
snapshot[key] = value
|
||||
ctx.console.print(f"[ok]✓[/ok] [brand]{key}[/brand] = {format_value(spec, value)}")
|
||||
return 1
|
||||
|
||||
|
||||
async def _show(ctx: AppContext, device: Device, snapshot: dict) -> None:
|
||||
"""Render the full configuration table plus custom variables."""
|
||||
from ..ui.config_editor import render_config
|
||||
|
||||
custom = await device.get_custom_vars()
|
||||
render_config(ctx.console, snapshot, custom)
|
||||
|
||||
|
||||
async def _backup(device: Device, snapshot: dict, path: Path) -> Path:
|
||||
"""Write a TOML backup of the current configuration."""
|
||||
custom = await device.get_custom_vars()
|
||||
channels = await _read_channels(device)
|
||||
return backup_config(Path(path), snapshot, custom, channels)
|
||||
|
||||
|
||||
async def _restore(
|
||||
ctx: AppContext, device: Device, snapshot: dict, path: Path, dry_run: bool
|
||||
) -> int:
|
||||
"""Apply (or preview) a backup file against the current configuration.
|
||||
|
||||
Returns:
|
||||
The number of changes applied (always ``0`` for a dry run).
|
||||
"""
|
||||
backup = read_backup(Path(path))
|
||||
custom = await device.get_custom_vars()
|
||||
ops = plan_restore(backup, snapshot, custom)
|
||||
if not ops:
|
||||
ctx.console.print("[muted]restore: device already matches the backup.[/muted]")
|
||||
return 0
|
||||
|
||||
if dry_run:
|
||||
table = Table(title="Restore preview", border_style="warn", expand=False)
|
||||
table.add_column("Operation")
|
||||
table.add_column("Target")
|
||||
table.add_column("Value")
|
||||
for op in ops:
|
||||
table.add_row(op[0], str(op[1]), str(op[2] if len(op) > 2 else ""))
|
||||
ctx.console.print(table)
|
||||
ctx.console.print("[muted]dry run — nothing was changed.[/muted]")
|
||||
return 0
|
||||
|
||||
changes = 0
|
||||
for op in ops:
|
||||
if op[0] == "set":
|
||||
changes += await _apply_setting(ctx, device, op[1], op[2], snapshot)
|
||||
elif op[0] == "set_custom":
|
||||
await device.set_custom_var(op[1], op[2])
|
||||
changes += 1
|
||||
elif op[0] == "set_channel":
|
||||
await device.set_channel(op[1], op[2], op[3])
|
||||
changes += 1
|
||||
return changes
|
||||
|
||||
|
||||
async def _export_key(
|
||||
ctx: AppContext, device: Device, out: Optional[Path], artifacts: list[str]
|
||||
) -> None:
|
||||
"""Export the private key, to a file if ``out`` is given, else to the console."""
|
||||
key_hex = await device.export_private_key()
|
||||
if out is not None:
|
||||
path = Path(out)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(key_hex, encoding="utf-8")
|
||||
artifacts.append(str(path))
|
||||
ctx.console.print("[warn]private key written — keep this file secret.[/warn]")
|
||||
else:
|
||||
ctx.console.print(
|
||||
"[warn]private key (keep secret):[/warn] [muted]" + key_hex + "[/muted]"
|
||||
)
|
||||
|
||||
|
||||
async def _read_channels(device: Device) -> list[dict]:
|
||||
"""Probe channel slots and return the configured ones."""
|
||||
channels: list[dict] = []
|
||||
for idx in range(_MAX_CHANNELS):
|
||||
try:
|
||||
ch = await device.get_channel(idx)
|
||||
except Exception: # noqa: BLE001 - firmware may not support channel reads
|
||||
break
|
||||
if ch:
|
||||
channels.append(ch)
|
||||
return channels
|
||||
|
||||
|
||||
def _require_yes(yes: bool, what: str) -> None:
|
||||
"""Abort a destructive CLI command unless ``--yes`` was passed.
|
||||
|
||||
Args:
|
||||
yes: Whether the user passed ``--yes``.
|
||||
what: Human-readable description of the consequence.
|
||||
|
||||
Raises:
|
||||
typer.Exit: With code 1 if confirmation was not given.
|
||||
"""
|
||||
if not yes:
|
||||
typer.secho(f"Refusing: {what}. Re-run with --yes to confirm.", fg="red")
|
||||
raise typer.Exit(1)
|
||||
+9
-10
@@ -21,7 +21,7 @@ class InfoTool(Tool):
|
||||
order = 10
|
||||
|
||||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Query the device and render its self-info and contacts.
|
||||
"""Query the device and render its full configuration and contacts.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
@@ -30,17 +30,16 @@ class InfoTool(Tool):
|
||||
Returns:
|
||||
A :class:`ToolResult` summarizing device name and contact count.
|
||||
"""
|
||||
from ..core.device_config import build_snapshot
|
||||
from ..ui.config_editor import render_config
|
||||
|
||||
device = await ctx.device()
|
||||
info = await device.get_self_info()
|
||||
snapshot = await build_snapshot(device)
|
||||
custom = await device.get_custom_vars()
|
||||
contacts = await device.get_contacts()
|
||||
|
||||
table = Table(title="Device", border_style="muted", expand=False)
|
||||
table.add_column("Field", style="muted")
|
||||
table.add_column("Value")
|
||||
for key in ("name", "tx_power", "freq", "bw", "sf", "cr"):
|
||||
if key in info:
|
||||
table.add_row(key, str(info[key]))
|
||||
ctx.console.print(table)
|
||||
# Render every current setting with a short explanation of each.
|
||||
render_config(ctx.console, snapshot, custom)
|
||||
|
||||
if contacts:
|
||||
ctable = Table(title=f"Contacts ({len(contacts)})", border_style="muted")
|
||||
@@ -51,7 +50,7 @@ class InfoTool(Tool):
|
||||
ctx.console.print(ctable)
|
||||
|
||||
return ToolResult(
|
||||
summary={"name": info.get("name"), "contacts": len(contacts)},
|
||||
summary={"name": snapshot.get("name"), "contacts": len(contacts)},
|
||||
)
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
|
||||
+121
-23
@@ -13,10 +13,15 @@ import questionary
|
||||
import typer
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core.models import LOCAL_DEVICE_LABEL
|
||||
from ..services import trace_runner
|
||||
from ..ui.widgets import make_progress, stats_panel, trace_table
|
||||
from ..ui.widgets import make_progress, stats_panel, traces_table
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
#: Upper bound on traces per run — the per-trace table renders one column each, so
|
||||
#: this keeps the output readable and the radio's duty cycle in check.
|
||||
MAX_TRACES = 8
|
||||
|
||||
|
||||
@register
|
||||
class TraceTool(Tool):
|
||||
@@ -52,11 +57,30 @@ class TraceTool(Tool):
|
||||
return None
|
||||
|
||||
samples_raw = await questionary.text(
|
||||
"How many traces?", default="5", validate=_is_positive_int
|
||||
f"How many traces? (1-{MAX_TRACES})", default="3", validate=_is_valid_sample_count
|
||||
).ask_async()
|
||||
if samples_raw is None:
|
||||
return None
|
||||
return {"target": target.strip(), "samples": int(samples_raw)}
|
||||
|
||||
# Optional explicit path, MeshCore-app style: comma-separated contact names
|
||||
# and/or hex key prefixes (e.g. ``3d,f2,3d``). Blank lets the device route.
|
||||
path_prompt = "Force a path? (comma-separated contacts/hex prefixes, blank = auto):"
|
||||
validate_path = lambda v: _validate_path(v, contacts) # noqa: E731
|
||||
if choices:
|
||||
path_spec = await questionary.autocomplete(
|
||||
path_prompt, choices=choices, ignore_case=True, validate=validate_path
|
||||
).ask_async()
|
||||
else:
|
||||
path_spec = await questionary.text(
|
||||
path_prompt, validate=validate_path
|
||||
).ask_async()
|
||||
if path_spec is None:
|
||||
return None
|
||||
|
||||
params: dict[str, Any] = {"target": target.strip(), "samples": int(samples_raw)}
|
||||
if path_spec.strip():
|
||||
params["path"] = path_spec.strip()
|
||||
return params
|
||||
|
||||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Run the traces, persist each one, and render the summary.
|
||||
@@ -69,41 +93,80 @@ class TraceTool(Tool):
|
||||
A :class:`ToolResult` with the aggregated statistics.
|
||||
"""
|
||||
target = params["target"]
|
||||
samples = int(params.get("samples", 5))
|
||||
samples = max(1, min(MAX_TRACES, int(params.get("samples", 3))))
|
||||
if int(params.get("samples", 3)) > MAX_TRACES:
|
||||
ctx.console.print(f"[warn]capping at {MAX_TRACES} traces[/warn]")
|
||||
run_id = params["_run_id"]
|
||||
device = await ctx.device()
|
||||
|
||||
last_trace = None
|
||||
# Our own device's name labels both endpoints of the path (first hop's origin,
|
||||
# last hop's destination); fall back to a neutral label if it's unavailable.
|
||||
self_info = await device.get_self_info()
|
||||
device_label = str(self_info.get("name") or LOCAL_DEVICE_LABEL)
|
||||
|
||||
# Resolve repeater hashes in the results to contact names where we know them,
|
||||
# so the tables read as names instead of opaque hex prefixes.
|
||||
contacts = await device.get_contacts()
|
||||
resolve = trace_runner.make_node_resolver(contacts)
|
||||
|
||||
# Resolve an optional forced path (names/hex) to the hex string the radio wants.
|
||||
# Leaving it blank is fully supported: the device reuses the route it already
|
||||
# learned for the target (or floods if it has none).
|
||||
path: Optional[str] = None
|
||||
path_spec = params.get("path")
|
||||
if path_spec:
|
||||
path = trace_runner.parse_trace_path(path_spec, contacts)
|
||||
ctx.console.print(f"[dim]forcing path:[/dim] [brand]{path}[/brand]")
|
||||
else:
|
||||
ctx.console.print("[dim]path:[/dim] [muted]auto (device-routed)[/muted]")
|
||||
|
||||
traces: list = []
|
||||
with make_progress(ctx.console) as progress:
|
||||
task = progress.add_task(f"tracing {target}", total=samples)
|
||||
|
||||
def on_result(done: int, total: int, result) -> None: # noqa: ANN001
|
||||
nonlocal last_trace
|
||||
last_trace = result
|
||||
traces.append(result)
|
||||
progress.advance(task)
|
||||
|
||||
stats = await trace_runner.measure(
|
||||
device,
|
||||
target,
|
||||
samples=samples,
|
||||
path=path,
|
||||
cooldown_s=ctx.settings.trace_cooldown_s,
|
||||
on_result=on_result,
|
||||
persist=lambda t: ctx.repo.record_trace(run_id, t),
|
||||
)
|
||||
|
||||
if last_trace is not None and last_trace.success:
|
||||
ctx.console.print(trace_table(last_trace))
|
||||
ctx.console.print(stats_panel(stats))
|
||||
# Use the first reply we got as the representative route shown in the summary
|
||||
# (forced path, or the route the device resolved when auto-routing).
|
||||
current = next((t for t in traces if t.success), None)
|
||||
if traces:
|
||||
ctx.console.print(traces_table(traces, device_label, resolve))
|
||||
ctx.console.print(stats_panel(stats, device_label, resolve, route=current))
|
||||
|
||||
summary: dict[str, Any] = {
|
||||
"target": stats.target,
|
||||
"samples": stats.samples,
|
||||
"success_rate": round(stats.success_rate, 3),
|
||||
"median_min_snr": stats.median_min_snr,
|
||||
"median_rtt_ms": stats.median_rtt_ms,
|
||||
"median_hop_snrs": [
|
||||
{
|
||||
"hop": agg.index,
|
||||
"from": agg.origin or device_label,
|
||||
"to": agg.destination or device_label,
|
||||
"median_snr": agg.median_snr,
|
||||
}
|
||||
for agg in stats.hop_snrs
|
||||
],
|
||||
}
|
||||
if path:
|
||||
summary["path"] = path
|
||||
via = f" via [brand]{path}[/brand]" if path else ""
|
||||
return ToolResult(
|
||||
summary={
|
||||
"target": stats.target,
|
||||
"samples": stats.samples,
|
||||
"success_rate": round(stats.success_rate, 3),
|
||||
"median_min_snr": stats.median_min_snr,
|
||||
"median_rtt_ms": stats.median_rtt_ms,
|
||||
},
|
||||
message=f"[ok]✓[/ok] traced [brand]{target}[/brand] x{samples}",
|
||||
summary=summary,
|
||||
message=f"[ok]✓[/ok] traced [brand]{target}[/brand] x{samples}{via}",
|
||||
)
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
@@ -117,13 +180,43 @@ class TraceTool(Tool):
|
||||
@app.command(name=self.name, help=self.help)
|
||||
def _trace(
|
||||
target: str = typer.Option(..., "--target", "-t", help="Target node name/prefix."),
|
||||
samples: int = typer.Option(5, "--samples", "-n", help="Number of traces."),
|
||||
samples: int = typer.Option(
|
||||
3, "--samples", "-n", help=f"Number of traces (max {MAX_TRACES})."
|
||||
),
|
||||
path: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--path",
|
||||
"-p",
|
||||
help="Force a route: comma-separated contact names/hex prefixes (e.g. 3d,f2,3d).",
|
||||
),
|
||||
) -> None:
|
||||
run_tool_command(self, {"target": target, "samples": samples})
|
||||
tool_params: dict[str, Any] = {"target": target, "samples": samples}
|
||||
if path:
|
||||
tool_params["path"] = path
|
||||
run_tool_command(self, tool_params)
|
||||
|
||||
|
||||
def _is_positive_int(value: str) -> bool | str:
|
||||
"""Validate that a questionary text answer is a positive integer.
|
||||
def _validate_path(value: str, contacts: list[Any]) -> bool | str:
|
||||
"""Validate an optional forced-path spec for the interactive prompt.
|
||||
|
||||
Args:
|
||||
value: The raw comma-separated path entry; blank means "let the device route".
|
||||
contacts: Known contacts used to resolve names in the spec.
|
||||
|
||||
Returns:
|
||||
``True`` if valid (including blank), otherwise an error message string.
|
||||
"""
|
||||
if not value.strip():
|
||||
return True
|
||||
try:
|
||||
trace_runner.parse_trace_path(value, contacts)
|
||||
return True
|
||||
except ValueError as exc:
|
||||
return str(exc)
|
||||
|
||||
|
||||
def _is_valid_sample_count(value: str) -> bool | str:
|
||||
"""Validate that a questionary text answer is a trace count in ``1..MAX_TRACES``.
|
||||
|
||||
Args:
|
||||
value: The raw user input.
|
||||
@@ -132,6 +225,11 @@ def _is_positive_int(value: str) -> bool | str:
|
||||
``True`` if valid, otherwise an error message string.
|
||||
"""
|
||||
try:
|
||||
return int(value) > 0 or "Enter a number greater than zero."
|
||||
count = int(value)
|
||||
except ValueError:
|
||||
return "Enter a whole number."
|
||||
if count < 1:
|
||||
return "Enter a number greater than zero."
|
||||
if count > MAX_TRACES:
|
||||
return f"Maximum {MAX_TRACES} traces."
|
||||
return True
|
||||
@@ -0,0 +1,394 @@
|
||||
"""Interactive device-configuration editor (questionary + Rich).
|
||||
|
||||
Drives the menu flow for the ``config`` tool: it shows the device's current configuration,
|
||||
lets the user stage changes setting-by-setting (with type-aware prompts and validation),
|
||||
and handles custom variables, channels, backup/restore and the destructive "danger zone".
|
||||
It returns an operation list for :class:`~meshtools.tools.config.ConfigTool` to execute and
|
||||
log; it performs no device writes itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import questionary
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core.device_config import (
|
||||
DeviceConfigError,
|
||||
SettingSpec,
|
||||
build_snapshot,
|
||||
format_value,
|
||||
parse_value,
|
||||
settings_by_category,
|
||||
)
|
||||
from .menu import _MENU_STYLE
|
||||
|
||||
# Menu action sentinels (distinct from setting keys, which are plain strings).
|
||||
_PRESETS = "__presets__"
|
||||
_CUSTOM = "__custom__"
|
||||
_CHANNELS = "__channels__"
|
||||
_BACKUP = "__backup__"
|
||||
_RESTORE = "__restore__"
|
||||
_DANGER = "__danger__"
|
||||
_APPLY = "__apply__"
|
||||
_CANCEL = "__cancel__"
|
||||
# Sentinel for the "enter a value myself" option on non-strict enum prompts.
|
||||
_OTHER = "__other__"
|
||||
|
||||
|
||||
async def edit_config(ctx: AppContext) -> Optional[list[tuple]]:
|
||||
"""Run the interactive editor and return the operations to perform.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context (provides the connected device and console).
|
||||
|
||||
Returns:
|
||||
A list of operation tuples for the tool to execute, or ``None`` if the user
|
||||
cancelled without choosing to apply anything.
|
||||
"""
|
||||
console = ctx.console
|
||||
device = await ctx.device()
|
||||
snapshot = await build_snapshot(device)
|
||||
custom = await device.get_custom_vars()
|
||||
|
||||
pending: dict[str, Any] = {} # setting key -> staged new value
|
||||
extra_ops: list[tuple] = [] # custom/channel/backup/restore/danger ops, in order
|
||||
|
||||
while True:
|
||||
render_config(console, snapshot, custom, pending)
|
||||
choice = await _main_menu(snapshot, pending, extra_ops)
|
||||
|
||||
if choice in (None, _CANCEL):
|
||||
return None
|
||||
if choice == _APPLY:
|
||||
ops: list[tuple] = [("set", k, v) for k, v in pending.items()]
|
||||
ops.extend(extra_ops)
|
||||
return ops or None
|
||||
if choice == _PRESETS:
|
||||
await _stage_preset(console, pending)
|
||||
elif choice == _CUSTOM:
|
||||
await _stage_custom_var(console, custom, extra_ops)
|
||||
elif choice == _CHANNELS:
|
||||
await _stage_channel(console, extra_ops)
|
||||
elif choice == _BACKUP:
|
||||
await _stage_backup(extra_ops)
|
||||
elif choice == _RESTORE:
|
||||
await _stage_restore(extra_ops)
|
||||
elif choice == _DANGER:
|
||||
# Danger-zone actions run immediately (after confirmation), not on Apply.
|
||||
if await _danger_zone(ctx, device, snapshot):
|
||||
snapshot = await build_snapshot(device) # state may have changed
|
||||
custom = await device.get_custom_vars()
|
||||
else: # a setting key
|
||||
await _stage_setting(console, choice, snapshot, pending)
|
||||
|
||||
|
||||
# --- rendering ---------------------------------------------------------------
|
||||
|
||||
|
||||
def render_config(
|
||||
console: Console,
|
||||
snapshot: dict,
|
||||
custom: dict[str, str],
|
||||
pending: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Print the current configuration, overlaying any staged changes.
|
||||
|
||||
Args:
|
||||
console: Console to render into.
|
||||
snapshot: Device snapshot from ``build_snapshot``.
|
||||
custom: Current custom variables.
|
||||
pending: Optional staged changes (setting key -> new value).
|
||||
"""
|
||||
pending = pending or {}
|
||||
show_staged = bool(pending)
|
||||
table = Table(title="Device configuration", border_style="muted", expand=False)
|
||||
table.add_column("Setting", style="muted")
|
||||
table.add_column("Current")
|
||||
if show_staged:
|
||||
table.add_column("Staged", style="warn")
|
||||
table.add_column("Description", style="muted")
|
||||
for category, specs in settings_by_category():
|
||||
table.add_section()
|
||||
header = [f"[accent]── {category} ──[/accent]", ""]
|
||||
if show_staged:
|
||||
header.append("")
|
||||
table.add_row(*header, "")
|
||||
for spec in specs:
|
||||
current = format_value(spec, spec.getter(snapshot))
|
||||
row = [f"{spec.label} [muted]({spec.key})[/muted]", current]
|
||||
if show_staged:
|
||||
row.append(format_value(spec, pending[spec.key]) if spec.key in pending else "")
|
||||
table.add_row(*row, spec.help)
|
||||
console.print(table)
|
||||
if custom:
|
||||
console.print(
|
||||
"[muted]custom vars:[/muted] "
|
||||
+ ", ".join(f"{k}={v}" for k, v in custom.items())
|
||||
)
|
||||
|
||||
|
||||
async def _main_menu(snapshot: dict, pending: dict, extra_ops: list) -> Optional[str]:
|
||||
"""Show the top-level editor menu and return the chosen action or setting key.
|
||||
|
||||
Each setting row shows its current value (and any staged new value) plus a one-line
|
||||
explanation, so the user can see and understand what they're changing in place.
|
||||
"""
|
||||
choices: list[questionary.Choice | questionary.Separator] = []
|
||||
for category, specs in settings_by_category():
|
||||
choices.append(questionary.Separator(f"── {category} ──"))
|
||||
for spec in specs:
|
||||
current = format_value(spec, spec.getter(snapshot))
|
||||
shown = (
|
||||
f"{current} → {format_value(spec, pending[spec.key])}"
|
||||
if spec.key in pending
|
||||
else current
|
||||
)
|
||||
choices.append(
|
||||
questionary.Choice(
|
||||
title=f"{spec.label}: {shown} — {spec.help}", value=spec.key
|
||||
)
|
||||
)
|
||||
choices.append(questionary.Separator("── More ──"))
|
||||
choices.append(questionary.Choice(title="Radio presets (standard configs)", value=_PRESETS))
|
||||
choices.append(questionary.Choice(title="Custom / experimental vars", value=_CUSTOM))
|
||||
choices.append(questionary.Choice(title="Channels", value=_CHANNELS))
|
||||
choices.append(questionary.Choice(title="Backup to file", value=_BACKUP))
|
||||
choices.append(questionary.Choice(title="Restore from file", value=_RESTORE))
|
||||
choices.append(questionary.Choice(title="⚠ Danger zone", value=_DANGER))
|
||||
choices.append(questionary.Separator(" "))
|
||||
staged = len(pending) + len(extra_ops)
|
||||
choices.append(questionary.Choice(title=f"✓ Apply ({staged} staged)", value=_APPLY))
|
||||
choices.append(questionary.Choice(title="Cancel (discard)", value=_CANCEL))
|
||||
|
||||
return await questionary.select(
|
||||
"Edit which setting?", choices=choices, style=_MENU_STYLE, qmark="◆"
|
||||
).ask_async()
|
||||
|
||||
|
||||
# --- staging individual changes ----------------------------------------------
|
||||
|
||||
|
||||
async def _stage_setting(
|
||||
console: Console, key: str, snapshot: dict, pending: dict[str, Any]
|
||||
) -> None:
|
||||
"""Prompt for one setting's new value and stage it."""
|
||||
from ..core.device_config import get_spec
|
||||
|
||||
spec = get_spec(key)
|
||||
current = pending.get(key, spec.getter(snapshot))
|
||||
value = await _prompt_value(spec, current)
|
||||
if value is not None:
|
||||
pending[key] = value
|
||||
|
||||
|
||||
async def _prompt_value(spec: SettingSpec, current: Any) -> Any:
|
||||
"""Prompt for a typed value for ``spec``, returning ``None`` on cancel."""
|
||||
if spec.value_type == "bool":
|
||||
return await questionary.confirm(
|
||||
f"{spec.label}?", default=bool(current)
|
||||
).ask_async()
|
||||
|
||||
if spec.value_type == "enum" and spec.choices is not None:
|
||||
items: list[questionary.Choice] = [
|
||||
questionary.Choice(title=f"{k} — {label}", value=k)
|
||||
for k, label in spec.choices.items()
|
||||
]
|
||||
# Non-strict enums list the common values for convenience but still accept any
|
||||
# in-range integer, so offer an escape hatch to type one in.
|
||||
if not spec.strict_choices:
|
||||
items.append(questionary.Choice(title="Other (enter a value)…", value=_OTHER))
|
||||
selected = await questionary.select(
|
||||
f"{spec.label} — {spec.help}",
|
||||
choices=items,
|
||||
default=current if current in spec.choices else None,
|
||||
style=_MENU_STYLE,
|
||||
).ask_async()
|
||||
if selected != _OTHER:
|
||||
return selected
|
||||
# else: fall through to the free-text prompt below.
|
||||
|
||||
def validate(text: str) -> bool | str:
|
||||
try:
|
||||
parse_value(spec, text)
|
||||
return True
|
||||
except DeviceConfigError as exc:
|
||||
return str(exc)
|
||||
|
||||
raw = await questionary.text(
|
||||
f"{spec.label} — {spec.help}",
|
||||
default="" if current is None else str(current),
|
||||
validate=validate,
|
||||
).ask_async()
|
||||
return None if raw is None else parse_value(spec, raw)
|
||||
|
||||
|
||||
async def _stage_preset(console: Console, pending: dict[str, Any]) -> None:
|
||||
"""Pick a standard radio preset and stage all of its fields for review/apply."""
|
||||
from ..core.device_config import RADIO_PRESETS
|
||||
|
||||
choices = [
|
||||
questionary.Choice(
|
||||
title=f"{p.name}: {p.freq} MHz, BW {p.bw}, SF{p.sf}, CR{p.cr} — {p.help}",
|
||||
value=i,
|
||||
)
|
||||
for i, p in enumerate(RADIO_PRESETS)
|
||||
]
|
||||
choices.append(questionary.Choice(title="Cancel", value=None))
|
||||
idx = await questionary.select(
|
||||
"Apply which radio preset?", choices=choices, style=_MENU_STYLE
|
||||
).ask_async()
|
||||
if idx is None:
|
||||
return
|
||||
preset = RADIO_PRESETS[idx]
|
||||
pending.update(preset.as_settings())
|
||||
console.print(
|
||||
f"[muted]staged preset[/muted] [brand]{preset.name}[/brand] "
|
||||
"[muted](review the radio rows, then Apply)[/muted]"
|
||||
)
|
||||
|
||||
|
||||
async def _stage_custom_var(
|
||||
console: Console, custom: dict[str, str], extra_ops: list[tuple]
|
||||
) -> None:
|
||||
"""Prompt for a custom/experimental variable and stage a set operation."""
|
||||
key = await questionary.text("Custom variable name:").ask_async()
|
||||
if not key:
|
||||
return
|
||||
value = await questionary.text(
|
||||
f"Value for {key}:", default=custom.get(key, "")
|
||||
).ask_async()
|
||||
if value is None:
|
||||
return
|
||||
extra_ops.append(("set_custom", key.strip(), value))
|
||||
|
||||
|
||||
async def _stage_channel(console: Console, extra_ops: list[tuple]) -> None:
|
||||
"""Prompt for a channel slot's name/secret and stage a set operation."""
|
||||
idx_raw = await questionary.text(
|
||||
"Channel index:", default="0", validate=_is_int
|
||||
).ask_async()
|
||||
if idx_raw is None:
|
||||
return
|
||||
name = await questionary.text(
|
||||
"Channel name (leading # derives the secret from the name):"
|
||||
).ask_async()
|
||||
if not name:
|
||||
return
|
||||
secret_hex = await questionary.text(
|
||||
"Secret (32 hex chars / 16 bytes; blank to derive from name):",
|
||||
validate=_is_optional_secret,
|
||||
).ask_async()
|
||||
secret = bytes.fromhex(secret_hex) if secret_hex else None
|
||||
extra_ops.append(("set_channel", int(idx_raw), name, secret))
|
||||
|
||||
|
||||
async def _stage_backup(extra_ops: list[tuple]) -> None:
|
||||
"""Prompt for a backup destination path and stage the operation."""
|
||||
path = await questionary.path("Write backup to:", default="meshtools-config.toml").ask_async()
|
||||
if path:
|
||||
extra_ops.append(("backup", Path(path)))
|
||||
|
||||
|
||||
async def _stage_restore(extra_ops: list[tuple]) -> None:
|
||||
"""Prompt for a backup file and whether to preview, then stage the operation."""
|
||||
path = await questionary.path("Restore from:").ask_async()
|
||||
if not path:
|
||||
return
|
||||
dry_run = await questionary.confirm(
|
||||
"Preview changes only (dry run)?", default=True
|
||||
).ask_async()
|
||||
extra_ops.append(("restore", Path(path), bool(dry_run)))
|
||||
|
||||
|
||||
async def _danger_zone(ctx: AppContext, device: Any, snapshot: dict) -> bool:
|
||||
"""Sub-menu for destructive operations, run immediately after confirmation.
|
||||
|
||||
Unlike ordinary settings (which are staged and applied together), danger-zone actions
|
||||
have no meaningful "preview" and are executed the moment they're confirmed.
|
||||
|
||||
Returns:
|
||||
``True`` if the action may have changed device state the editor should re-read
|
||||
(e.g. a factory reset or key import), so the caller can refresh its snapshot.
|
||||
"""
|
||||
from ..tools.config import apply_ops
|
||||
|
||||
console = ctx.console
|
||||
action = await questionary.select(
|
||||
"⚠ Danger zone (runs immediately on confirmation):",
|
||||
choices=[
|
||||
questionary.Choice("Send advert", value="advert"),
|
||||
questionary.Choice("Reboot device", value="reboot"),
|
||||
questionary.Choice("Export private key", value="export_key"),
|
||||
questionary.Choice("Import private key", value="import_key"),
|
||||
questionary.Choice("Factory reset (erase all)", value="factory_reset"),
|
||||
questionary.Choice("Back", value=None),
|
||||
],
|
||||
style=_MENU_STYLE,
|
||||
).ask_async()
|
||||
|
||||
op: Optional[tuple] = None
|
||||
if action in (None, "Back"):
|
||||
return False
|
||||
if action in ("advert", "export_key"):
|
||||
op = (action,)
|
||||
elif action == "import_key":
|
||||
key_hex = await questionary.text("Private key (hex):", validate=_is_hex).ask_async()
|
||||
if key_hex and await _confirm_typed(console, "IMPORT"):
|
||||
op = ("import_key", key_hex.strip())
|
||||
elif action == "reboot":
|
||||
if await questionary.confirm("Reboot the device now?", default=False).ask_async():
|
||||
op = ("reboot",)
|
||||
elif action == "factory_reset":
|
||||
console.print("[err]This erases ALL data on the device and cannot be undone.[/err]")
|
||||
if await _confirm_typed(console, "RESET"):
|
||||
op = ("factory_reset",)
|
||||
|
||||
if op is None:
|
||||
return False
|
||||
await apply_ops(ctx, device, snapshot, [op])
|
||||
return action in ("factory_reset", "import_key")
|
||||
|
||||
|
||||
async def _confirm_typed(console: Console, word: str) -> bool:
|
||||
"""Require the user to type ``word`` exactly to confirm a destructive action."""
|
||||
typed = await questionary.text(f"Type {word!r} to confirm:").ask_async()
|
||||
if typed == word:
|
||||
return True
|
||||
console.print("[muted]confirmation did not match; skipped.[/muted]")
|
||||
return False
|
||||
|
||||
|
||||
# --- validators --------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_int(text: str) -> bool | str:
|
||||
"""Validate that ``text`` is an integer."""
|
||||
try:
|
||||
int(text)
|
||||
return True
|
||||
except ValueError:
|
||||
return "Enter a whole number."
|
||||
|
||||
|
||||
def _is_hex(text: str) -> bool | str:
|
||||
"""Validate that ``text`` is a hex string."""
|
||||
try:
|
||||
bytes.fromhex(text)
|
||||
return True
|
||||
except ValueError:
|
||||
return "Enter hex characters only."
|
||||
|
||||
|
||||
def _is_optional_secret(text: str) -> bool | str:
|
||||
"""Validate an optional 16-byte hex channel secret."""
|
||||
if text == "":
|
||||
return True
|
||||
try:
|
||||
return len(bytes.fromhex(text)) == 16 or "Secret must be exactly 16 bytes (32 hex chars)."
|
||||
except ValueError:
|
||||
return "Enter hex characters only."
|
||||
+245
-20
@@ -2,9 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING, Callable, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.console import Console, Group
|
||||
from rich.panel import Panel
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
@@ -18,12 +18,31 @@ from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from .. import __version__
|
||||
from ..core.models import TraceResult, TraceStats, TxOptResult
|
||||
from ..core.models import (
|
||||
LOCAL_DEVICE_LABEL,
|
||||
HopAggregate,
|
||||
HopEdge,
|
||||
TraceResult,
|
||||
TraceStats,
|
||||
TxOptResult,
|
||||
)
|
||||
from .theme import snr_style
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.discovery import DiscoveredDevice
|
||||
|
||||
#: Maps a hop's raw key-prefix hash to a display label (a contact name when known).
|
||||
NodeResolver = Callable[[Optional[str]], Optional[str]]
|
||||
|
||||
#: Non-breaking space, used in the route line to keep ``name (hash)`` and a node's
|
||||
#: trailing arrow on the same line so wraps only ever land *after* an arrow.
|
||||
_NBSP = " "
|
||||
|
||||
|
||||
def _identity(label: Optional[str]) -> Optional[str]:
|
||||
"""Default node resolver: leave labels untouched."""
|
||||
return label
|
||||
|
||||
|
||||
def banner(
|
||||
profile: Optional[str], mock: bool, selected_device: Optional["DiscoveredDevice"] = None
|
||||
@@ -74,50 +93,256 @@ def make_progress(console: Console) -> Progress:
|
||||
)
|
||||
|
||||
|
||||
def trace_table(result: TraceResult) -> Table:
|
||||
"""Render a single trace's per-hop SNR as a table.
|
||||
def _node_text(
|
||||
label: Optional[str], device_label: str, resolve: NodeResolver = _identity
|
||||
) -> Text:
|
||||
"""Style a node label, making our own device stand out from repeaters.
|
||||
|
||||
Args:
|
||||
result: The trace to display.
|
||||
label: The node identifier to render; ``None`` means our own device.
|
||||
device_label: The label used for our own device.
|
||||
resolve: Maps a raw hop hash to a friendly name when the node is known.
|
||||
|
||||
Returns:
|
||||
A Rich :class:`Table` of hops and SNR values.
|
||||
A styled :class:`Text` for the node.
|
||||
"""
|
||||
table = Table(title=f"Trace -> {result.target}", border_style="muted", expand=False)
|
||||
named = resolve(label) if label else label
|
||||
resolved = named if named else device_label
|
||||
style = "accent" if resolved == device_label else "brand"
|
||||
return Text(resolved, style=style)
|
||||
|
||||
|
||||
def _link_text(
|
||||
origin: Optional[str],
|
||||
destination: Optional[str],
|
||||
device_label: str,
|
||||
resolve: NodeResolver = _identity,
|
||||
) -> Text:
|
||||
"""Render an ``origin -> destination`` link with a styled arrow.
|
||||
|
||||
Args:
|
||||
origin: The transmitting node (``None`` = our device).
|
||||
destination: The receiving node (``None`` = our device).
|
||||
device_label: The label used for our own device.
|
||||
resolve: Maps a raw hop hash to a friendly name when the node is known.
|
||||
|
||||
Returns:
|
||||
A :class:`Text` like ``us → Alice`` with the arrow muted.
|
||||
"""
|
||||
text = _node_text(origin, device_label, resolve)
|
||||
text.append(" → ", style="muted")
|
||||
text.append_text(_node_text(destination, device_label, resolve))
|
||||
return text
|
||||
|
||||
|
||||
def traces_table(
|
||||
traces: list[TraceResult],
|
||||
device_label: str = LOCAL_DEVICE_LABEL,
|
||||
resolve: NodeResolver = _identity,
|
||||
) -> Table:
|
||||
"""Render every trace's per-hop SNR side by side, one column per trace.
|
||||
|
||||
Hops are framed as ``origin -> destination`` links (the first originates at our
|
||||
device, the last returns to it) and aligned by position across traces, so each
|
||||
column is one trace's SNR readings down the shared path. A trailing ``min`` row
|
||||
shows each trace's bottleneck — the per-trace values the run's *median min SNR*
|
||||
summarizes. Traces that never replied appear as a ``✗`` column.
|
||||
|
||||
Args:
|
||||
traces: The individual traces to display, in run order.
|
||||
device_label: Name to show for our own device at the path's endpoints.
|
||||
resolve: Maps a raw hop hash to a friendly contact name when known.
|
||||
|
||||
Returns:
|
||||
A Rich :class:`Table` with a column per trace.
|
||||
"""
|
||||
target = traces[0].target if traces else ""
|
||||
table = Table(title=f"Trace → {target}", border_style="muted", expand=False)
|
||||
table.add_column("Hop", justify="right", style="muted")
|
||||
table.add_column("Node")
|
||||
table.add_column("SNR (dB)", justify="right")
|
||||
if not result.success:
|
||||
table.add_row("-", "[err]no reply[/err]", "-")
|
||||
return table
|
||||
for hop in result.hops:
|
||||
table.add_column("From → To")
|
||||
for i in range(1, len(traces) + 1):
|
||||
table.add_column(f"#{i}", justify="right")
|
||||
|
||||
# Index each trace's edges by hop position so columns line up even when traces
|
||||
# take different-length paths (a missing hop shows as a muted dash).
|
||||
edges_by_trace = [
|
||||
{e.index: e for e in t.edges(device_label)} if t.success else {} for t in traces
|
||||
]
|
||||
indices = sorted({idx for edges in edges_by_trace for idx in edges})
|
||||
for idx in indices:
|
||||
link = next(
|
||||
(
|
||||
_link_text(edges[idx].origin, edges[idx].destination, device_label, resolve)
|
||||
for edges in edges_by_trace
|
||||
if idx in edges
|
||||
),
|
||||
Text("—", style="muted"),
|
||||
)
|
||||
row: list[Text] = [Text(str(idx)), link]
|
||||
for edges in edges_by_trace:
|
||||
edge = edges.get(idx)
|
||||
if edge is None:
|
||||
row.append(Text("—", style="muted"))
|
||||
else:
|
||||
row.append(Text(f"{edge.snr:+.1f}", style=snr_style(edge.snr)))
|
||||
table.add_row(*row)
|
||||
|
||||
# Bottleneck per trace: the weakest link the run's median min SNR is taken over.
|
||||
min_row: list[Text] = [Text("min", style="muted"), Text("bottleneck", style="muted")]
|
||||
for trace in traces:
|
||||
if not trace.success:
|
||||
min_row.append(Text("✗", style="err"))
|
||||
elif trace.min_snr is not None:
|
||||
min_row.append(Text(f"{trace.min_snr:+.1f}", style=snr_style(trace.min_snr)))
|
||||
else:
|
||||
min_row.append(Text("—", style="muted"))
|
||||
table.add_section()
|
||||
table.add_row(*min_row)
|
||||
return table
|
||||
|
||||
|
||||
def _route_node_text(
|
||||
label: Optional[str],
|
||||
device_label: str,
|
||||
resolve: NodeResolver,
|
||||
hash_bytes: Optional[int],
|
||||
) -> Text:
|
||||
"""Render one route node as ``name (hash)``, the hash at the command's width.
|
||||
|
||||
Our own device (``label`` is ``None`` or equal to ``device_label``) carries no
|
||||
hash. A known node shows its friendly name followed by its key-prefix hash; an
|
||||
unknown node shows just the hash. The hash is truncated to ``hash_bytes`` — the
|
||||
per-hop width the trace command used — so it matches how the node was addressed.
|
||||
|
||||
Args:
|
||||
label: The node's raw hop hash, or ``None``/``device_label`` for our device.
|
||||
device_label: The label used for our own device.
|
||||
resolve: Maps a raw hop hash to a friendly contact name when known.
|
||||
hash_bytes: Path-hash width (bytes) to truncate the shown hash to.
|
||||
|
||||
Returns:
|
||||
A styled :class:`Text` for the node. A non-breaking space joins name and hash
|
||||
so they never split across a line wrap.
|
||||
"""
|
||||
if not label or label == device_label:
|
||||
return Text(device_label, style="accent")
|
||||
raw = label.lower().removeprefix("0x")
|
||||
shown = raw[: hash_bytes * 2] if hash_bytes else raw
|
||||
named = resolve(label)
|
||||
if not named or named == label:
|
||||
return Text(shown, style="brand") # unknown node: hash only
|
||||
text = Text(named, style="brand")
|
||||
text.append(f"{_NBSP}(", style="muted") # nbsp keeps "name (hash)" together
|
||||
text.append(shown, style="muted")
|
||||
text.append(")", style="muted")
|
||||
return text
|
||||
|
||||
|
||||
def _route_text(
|
||||
result: TraceResult,
|
||||
device_label: str = LOCAL_DEVICE_LABEL,
|
||||
resolve: NodeResolver = _identity,
|
||||
) -> Text:
|
||||
"""Render a trace's route as a sequence of ``name (hash)`` nodes.
|
||||
|
||||
Shows the path the trace actually walked — the forced path, or the route the
|
||||
device resolved when auto-routing — with each node annotated by its hash at the
|
||||
command's path-hash width, joined by muted arrows, e.g.
|
||||
``Me → Alice (3d63) → Bob (f2a1) → Me``. The line wraps after an arrow when it is
|
||||
too long for the panel, so each continuation line starts on a node.
|
||||
|
||||
Args:
|
||||
result: The trace whose route to display.
|
||||
device_label: Name to show for our own device at the path's endpoints.
|
||||
resolve: Maps a raw hop hash to a friendly contact name when known.
|
||||
|
||||
Returns:
|
||||
A :class:`Text` with the node sequence, or a muted note when no hops exist.
|
||||
"""
|
||||
edges = result.edges(device_label)
|
||||
if not edges:
|
||||
return Text("no hops recorded", style="muted")
|
||||
hash_bytes = result.path_hash_bytes
|
||||
nodes = [edges[0].origin] + [edge.destination for edge in edges]
|
||||
text = Text()
|
||||
for i, node in enumerate(nodes):
|
||||
if i:
|
||||
# A non-breaking space glues the arrow to the preceding node; the trailing
|
||||
# regular space is the only wrap point, so wrapping lands *after* the arrow.
|
||||
text.append(f"{_NBSP}→ ", style="muted")
|
||||
text.append_text(_route_node_text(node, device_label, resolve, hash_bytes))
|
||||
return text
|
||||
|
||||
|
||||
def _hop_medians_table(
|
||||
hop_snrs: list[HopAggregate], device_label: str, resolve: NodeResolver = _identity
|
||||
) -> Table:
|
||||
"""Render the per-hop median SNR aggregated across a run's traces.
|
||||
|
||||
Args:
|
||||
hop_snrs: The per-hop aggregates to display.
|
||||
device_label: Name to show for our own device at the path's endpoints.
|
||||
resolve: Maps a raw hop hash to a friendly contact name when known.
|
||||
|
||||
Returns:
|
||||
A compact Rich :class:`Table` of hop, link, and median SNR.
|
||||
"""
|
||||
table = Table(box=None, padding=(0, 1, 0, 0), expand=False)
|
||||
table.add_column("hop", justify="right", style="muted")
|
||||
table.add_column("from → to")
|
||||
table.add_column("median SNR", justify="right")
|
||||
for agg in hop_snrs:
|
||||
table.add_row(
|
||||
str(hop.index),
|
||||
hop.node or "[muted]?[/muted]",
|
||||
Text(f"{hop.snr:+.1f}", style=snr_style(hop.snr)),
|
||||
str(agg.index),
|
||||
_link_text(agg.origin, agg.destination, device_label, resolve),
|
||||
Text(f"{agg.median_snr:+.1f} dB", style=snr_style(agg.median_snr)),
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
def stats_panel(stats: TraceStats) -> Panel:
|
||||
def stats_panel(
|
||||
stats: TraceStats,
|
||||
device_label: str = LOCAL_DEVICE_LABEL,
|
||||
resolve: NodeResolver = _identity,
|
||||
route: Optional[TraceResult] = None,
|
||||
) -> Panel:
|
||||
"""Summarize aggregated trace statistics in a panel.
|
||||
|
||||
Includes the median SNR for every hop along the path (not just the bottleneck), so
|
||||
a weak link anywhere in the route is visible. When a representative ``route`` trace
|
||||
is given, the path it actually walked is shown as a node sequence, e.g.
|
||||
``Me → Alice → Bob → Me``.
|
||||
|
||||
Args:
|
||||
stats: The aggregated statistics to display.
|
||||
device_label: Name to show for our own device at the path's endpoints.
|
||||
resolve: Maps a raw hop hash to a friendly contact name when known.
|
||||
route: A representative trace whose walked route to display, if any.
|
||||
|
||||
Returns:
|
||||
A Rich :class:`Panel` with success rate and robust SNR/RTT.
|
||||
A Rich :class:`Panel` with the route, success rate, robust SNR/RTT, and
|
||||
per-hop medians.
|
||||
"""
|
||||
snr = stats.median_min_snr
|
||||
snr_text = Text(f"{snr:+.1f} dB", style=snr_style(snr)) if snr is not None else Text("n/a")
|
||||
rtt = f"{stats.median_rtt_ms:.0f} ms" if stats.median_rtt_ms is not None else "n/a"
|
||||
body = Text.assemble(
|
||||
summary = Text.assemble(
|
||||
("target ", "muted"), (f"{stats.target}\n", ""),
|
||||
("success rate ", "muted"), (f"{stats.success_rate:.0%} "
|
||||
f"({stats.successes}/{stats.samples})\n", ""),
|
||||
("median min SNR ", "muted"), snr_text, ("\n", ""),
|
||||
("median RTT ", "muted"), (rtt, ""),
|
||||
)
|
||||
sections: list[Text | Table] = []
|
||||
if route is not None:
|
||||
sections.append(Text("route", style="muted"))
|
||||
sections.append(_route_text(route, device_label, resolve))
|
||||
sections.append(Text()) # blank line before the stats block
|
||||
sections.append(summary)
|
||||
if stats.hop_snrs:
|
||||
sections.append(Text("\nper-hop medians", style="muted"))
|
||||
sections.append(_hop_medians_table(stats.hop_snrs, device_label, resolve))
|
||||
body: Text | Group = sections[0] if len(sections) == 1 else Group(*sections)
|
||||
return Panel(body, title="[accent]trace summary[/accent]", border_style="accent", expand=False)
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ dependencies = [
|
||||
"pyvis>=0.3.2", # interactive HTML mesh graphs
|
||||
"networkx>=3.2", # graph model + path scoring
|
||||
"tomli>=2.0; python_version < '3.11'", # TOML config reader on 3.10
|
||||
"tomli-w>=1.0", # TOML writer for device-config backups
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Tests for the device-configuration registry, backup/restore, and safety gating.
|
||||
|
||||
All run against :class:`MockDevice` — no hardware required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
|
||||
from meshtools.core.config_io import backup_config, plan_restore, read_backup
|
||||
from meshtools.core.connection import MockDevice
|
||||
from meshtools.core.device_config import (
|
||||
RADIO_PRESETS,
|
||||
DeviceConfigError,
|
||||
build_snapshot,
|
||||
format_value,
|
||||
get_spec,
|
||||
parse_value,
|
||||
)
|
||||
from meshtools.tools.config import _require_yes
|
||||
|
||||
|
||||
async def _connected_mock() -> MockDevice:
|
||||
"""Return a connected MockDevice."""
|
||||
device = MockDevice()
|
||||
await device.connect()
|
||||
return device
|
||||
|
||||
|
||||
# -- parsing / formatting ------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_value_types() -> None:
|
||||
"""Each value type coerces from string correctly."""
|
||||
assert parse_value(get_spec("name"), "Node-7") == "Node-7"
|
||||
assert parse_value(get_spec("radio_sf"), "9") == 9
|
||||
assert parse_value(get_spec("radio_freq"), "868.0") == 868.0
|
||||
assert parse_value(get_spec("manual_add_contacts"), "yes") is True
|
||||
assert parse_value(get_spec("manual_add_contacts"), "off") is False
|
||||
|
||||
|
||||
def test_parse_value_range_and_enum_errors() -> None:
|
||||
"""Out-of-range and invalid enum values raise DeviceConfigError."""
|
||||
with pytest.raises(DeviceConfigError, match="<= 12"):
|
||||
parse_value(get_spec("radio_sf"), "99")
|
||||
with pytest.raises(DeviceConfigError, match=">= 5"):
|
||||
parse_value(get_spec("radio_cr"), "1")
|
||||
with pytest.raises(DeviceConfigError, match="one of"):
|
||||
parse_value(get_spec("telemetry_mode_base"), "7")
|
||||
with pytest.raises(DeviceConfigError, match="boolean"):
|
||||
parse_value(get_spec("manual_add_contacts"), "maybe")
|
||||
|
||||
|
||||
def test_format_value() -> None:
|
||||
"""Formatting renders booleans, enums, and unknowns readably."""
|
||||
assert format_value(get_spec("manual_add_contacts"), True) == "true"
|
||||
assert format_value(get_spec("telemetry_mode_base"), 1) == "1 (always)"
|
||||
assert format_value(get_spec("name"), None) == "?"
|
||||
|
||||
|
||||
def test_path_hash_mode_is_strict_enum() -> None:
|
||||
"""path_hash_mode accepts its four modes and rejects anything else."""
|
||||
assert parse_value(get_spec("path_hash_mode"), "3") == 3
|
||||
assert format_value(get_spec("path_hash_mode"), 0) == "0 (1-byte hashes (default))"
|
||||
with pytest.raises(DeviceConfigError, match="one of"):
|
||||
parse_value(get_spec("path_hash_mode"), "4")
|
||||
|
||||
|
||||
def test_non_strict_enum_accepts_unlisted_value() -> None:
|
||||
"""multi_acks/adv_loc_policy list common values but still accept other ints."""
|
||||
spec = get_spec("multi_acks")
|
||||
assert spec.value_type == "enum" and not spec.strict_choices
|
||||
assert parse_value(spec, "5") == 5 # not in choices, but >= minimum: accepted
|
||||
with pytest.raises(DeviceConfigError, match=">= 0"):
|
||||
parse_value(spec, "-1") # still range-checked
|
||||
|
||||
|
||||
def test_radio_presets_map_to_radio_settings() -> None:
|
||||
"""Every preset exposes the four radio fields by their registry keys."""
|
||||
assert RADIO_PRESETS, "expected at least one standard radio preset"
|
||||
for preset in RADIO_PRESETS:
|
||||
settings = preset.as_settings()
|
||||
assert set(settings) == {"radio_freq", "radio_bw", "radio_sf", "radio_cr"}
|
||||
# Each value parses cleanly through its setting spec.
|
||||
for key, value in settings.items():
|
||||
assert parse_value(get_spec(key), value) == value
|
||||
|
||||
|
||||
def test_get_spec_unknown_raises() -> None:
|
||||
"""An unknown key produces a helpful error listing valid keys."""
|
||||
with pytest.raises(DeviceConfigError, match="unknown setting"):
|
||||
get_spec("not_a_setting")
|
||||
|
||||
|
||||
# -- apply against the mock device ---------------------------------------------
|
||||
|
||||
|
||||
async def test_apply_simple_setting() -> None:
|
||||
"""Setting the node name round-trips through the device."""
|
||||
device = await _connected_mock()
|
||||
spec = get_spec("name")
|
||||
await spec.apply(device, parse_value(spec, "Yagi-Hub"), await build_snapshot(device))
|
||||
assert (await device.get_self_info())["name"] == "Yagi-Hub"
|
||||
|
||||
|
||||
async def test_apply_coupled_radio_field_preserves_others() -> None:
|
||||
"""Changing one radio field rebuilds set_radio from the current snapshot."""
|
||||
device = await _connected_mock()
|
||||
spec = get_spec("radio_sf")
|
||||
await spec.apply(device, 9, await build_snapshot(device))
|
||||
info = await device.get_self_info()
|
||||
assert info["radio_sf"] == 9
|
||||
assert info["radio_freq"] == 869.525 # unchanged
|
||||
assert info["radio_bw"] == 250.0
|
||||
assert info["radio_cr"] == 5
|
||||
|
||||
|
||||
async def test_apply_telemetry_mode_preserves_siblings() -> None:
|
||||
"""Editing one telemetry mode leaves the other two intact."""
|
||||
device = await _connected_mock()
|
||||
spec = get_spec("telemetry_mode_loc")
|
||||
await spec.apply(device, 2, await build_snapshot(device))
|
||||
info = await device.get_self_info()
|
||||
assert info["telemetry_mode_loc"] == 2
|
||||
assert info["telemetry_mode_base"] == 0
|
||||
assert info["telemetry_mode_env"] == 0
|
||||
|
||||
|
||||
async def test_custom_var_round_trip() -> None:
|
||||
"""Custom variables persist on the device."""
|
||||
device = await _connected_mock()
|
||||
await device.set_custom_var("exp_flag", "1")
|
||||
assert (await device.get_custom_vars())["exp_flag"] == "1"
|
||||
|
||||
|
||||
# -- backup / restore ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_backup_and_read_round_trip(tmp_path: Path) -> None:
|
||||
"""A backup writes all settings, custom vars, and channels and reads back."""
|
||||
device = await _connected_mock()
|
||||
await device.set_name("Foo")
|
||||
await device.set_channel(0, "Public", b"\x01" * 16)
|
||||
snapshot = await build_snapshot(device)
|
||||
path = backup_config(tmp_path / "cfg.toml", snapshot, {"exp": "1"}, await _channels(device))
|
||||
|
||||
backup = read_backup(path)
|
||||
assert backup.settings["name"] == "Foo"
|
||||
assert backup.settings["radio_sf"] == 11
|
||||
assert backup.custom["exp"] == "1"
|
||||
assert backup.channels[0]["name"] == "Public"
|
||||
assert backup.channels[0]["secret"] == ("01" * 16)
|
||||
|
||||
|
||||
async def test_plan_restore_emits_only_differences(tmp_path: Path) -> None:
|
||||
"""The restore planner returns ops only for values that differ from the device."""
|
||||
source = await _connected_mock()
|
||||
await source.set_name("Foo")
|
||||
path = backup_config(
|
||||
tmp_path / "cfg.toml", await build_snapshot(source), {"exp": "1"}, []
|
||||
)
|
||||
backup = read_backup(path)
|
||||
|
||||
target = await _connected_mock() # fresh device: name still "MockCompanion"
|
||||
ops = plan_restore(backup, await build_snapshot(target), await target.get_custom_vars())
|
||||
|
||||
assert ("set", "name", "Foo") in ops
|
||||
assert ("set_custom", "exp", "1") in ops
|
||||
# Unchanged values (e.g. radio_sf 11 == 11) are not re-applied.
|
||||
assert "radio_sf" not in [op[1] for op in ops if op[0] == "set"]
|
||||
|
||||
|
||||
# -- safety gating -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_require_yes_blocks_without_confirmation() -> None:
|
||||
"""Destructive CLI commands abort unless --yes is passed."""
|
||||
with pytest.raises(typer.Exit):
|
||||
_require_yes(False, "factory reset erases all data")
|
||||
_require_yes(True, "factory reset erases all data") # does not raise
|
||||
|
||||
|
||||
async def _channels(device: MockDevice) -> list[dict]:
|
||||
"""Collect the mock's configured channels for backup."""
|
||||
channels = []
|
||||
for idx in range(8):
|
||||
ch = await device.get_channel(idx)
|
||||
if ch:
|
||||
channels.append(ch)
|
||||
return channels
|
||||
+343
-1
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from meshtools.core.connection import MockDevice, clamp_tx_power
|
||||
from meshtools.core.models import TraceResult, TraceStats
|
||||
from meshtools.core.models import Contact, TraceResult, TraceStats
|
||||
from meshtools.persistence.repository import Repository
|
||||
from meshtools.services import trace_runner
|
||||
|
||||
@@ -38,6 +38,163 @@ def test_trace_stats_aggregation() -> None:
|
||||
assert stats.median_min_snr == pytest.approx(-1.0) # median of [-2.0, 0.0]
|
||||
|
||||
|
||||
def test_parse_trace_path_mixes_names_and_hex() -> None:
|
||||
"""Names and hex mix; the hex width is preserved and names truncate to match."""
|
||||
contacts = [Contact(name="Alice", key_prefix="d4e5f6a7")]
|
||||
# 1-byte hops: hex, a contact name (case-insensitively), hex again
|
||||
assert trace_runner.parse_trace_path("3d, alice ,f2", contacts) == "3d,d4,f2"
|
||||
# the width comes from the hex tokens you type — no truncation to 1 byte
|
||||
assert trace_runner.parse_trace_path("a1b2c3", contacts) == "a1b2c3"
|
||||
|
||||
|
||||
def test_parse_trace_path_preserves_three_byte_width() -> None:
|
||||
"""A 3-byte (6 hex) width is kept, and names truncate to that width."""
|
||||
contacts = [Contact(name="Alice", key_prefix="d4e5f6a7b8c9")]
|
||||
assert (
|
||||
trace_runner.parse_trace_path("3d5f7a,Alice,f2a1b3", contacts)
|
||||
== "3d5f7a,d4e5f6,f2a1b3"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_trace_path_rejects_mixed_widths() -> None:
|
||||
"""Hex hops must all use the same number of bytes."""
|
||||
with pytest.raises(ValueError):
|
||||
trace_runner.parse_trace_path("3d,a1b2c3", contacts=[])
|
||||
|
||||
|
||||
def test_path_hash_flags_power_of_two_only() -> None:
|
||||
"""Trace flags encode the hash width as 1 << s, so only 1/2/4/8 bytes map."""
|
||||
assert trace_runner.path_hash_flags(1) == 0
|
||||
assert trace_runner.path_hash_flags(2) == 1
|
||||
assert trace_runner.path_hash_flags(4) == 2
|
||||
assert trace_runner.path_hash_flags(8) == 3
|
||||
assert trace_runner.path_hash_flags(3) is None
|
||||
|
||||
|
||||
def test_parse_trace_hops_reads_path_snr() -> None:
|
||||
"""Per-hop SNR comes from payload['path'] dicts; the final hash-less node counts."""
|
||||
from meshtools.core.connection import parse_trace_hops
|
||||
|
||||
payload = {
|
||||
"path": [
|
||||
{"hash": "3d", "snr": 4.5},
|
||||
{"hash": "f2", "snr": -2.0},
|
||||
{"snr": 1.25}, # final node: our device, no hash
|
||||
]
|
||||
}
|
||||
hops = parse_trace_hops(payload)
|
||||
assert [h.node for h in hops] == ["3d", "f2", None]
|
||||
assert [h.snr for h in hops] == [4.5, -2.0, 1.25]
|
||||
assert [h.index for h in hops] == [0, 1, 2]
|
||||
|
||||
|
||||
def test_parse_trace_hops_empty_without_path() -> None:
|
||||
"""A reply with no parsed path yields no hops (SNR shows as n/a)."""
|
||||
from meshtools.core.connection import parse_trace_hops
|
||||
|
||||
assert parse_trace_hops({}) == []
|
||||
|
||||
|
||||
def test_route_text_annotates_nodes_with_command_width_hash() -> None:
|
||||
"""The route shows each node's hash at the command's path-hash width."""
|
||||
from meshtools.core.models import Hop
|
||||
from meshtools.ui.widgets import _route_text
|
||||
|
||||
resolve = trace_runner.make_node_resolver(
|
||||
[Contact(name="Alice", public_key="3d63c6" + "00" * 26, key_prefix="3d63c6429436")]
|
||||
)
|
||||
# Known node (resolves to Alice) then the reply returning to our device (node=None).
|
||||
result = TraceResult(
|
||||
target="Alice",
|
||||
success=True,
|
||||
hops=[Hop(0, "3d63c6", 12.0), Hop(1, None, 12.0)],
|
||||
path_hash_bytes=2, # command used 2-byte hashes
|
||||
)
|
||||
plain = _route_text(result, "Me", resolve).plain
|
||||
assert "Alice" in plain
|
||||
assert "(3d63)" in plain # truncated to 2 bytes, not the full 3d63c6
|
||||
assert "3d63c6" not in plain
|
||||
assert plain.count("Me") == 2 # our device at both ends, carrying no hash
|
||||
assert "Me (" not in plain.replace(" ", " ") # device never annotated
|
||||
|
||||
|
||||
def test_route_text_unknown_node_shows_hash_only() -> None:
|
||||
"""An unresolved node shows just its hash, truncated to the command width."""
|
||||
from meshtools.core.models import Hop
|
||||
from meshtools.ui.widgets import _route_text
|
||||
|
||||
result = TraceResult(
|
||||
target="x",
|
||||
success=True,
|
||||
hops=[Hop(0, "aa11bb", 12.0), Hop(1, None, 12.0)],
|
||||
path_hash_bytes=1, # 1-byte width
|
||||
)
|
||||
plain = _route_text(result, "Me").plain.replace(" ", " ")
|
||||
assert "→ aa →" in plain # 1-byte hash, no name, no parens
|
||||
assert "aa11bb" not in plain
|
||||
|
||||
|
||||
def test_parse_trace_path_rejects_unknown_token() -> None:
|
||||
"""A token that is neither a known contact nor valid hex is an error."""
|
||||
with pytest.raises(ValueError):
|
||||
trace_runner.parse_trace_path("nope", contacts=[])
|
||||
with pytest.raises(ValueError):
|
||||
trace_runner.parse_trace_path(" ", contacts=[])
|
||||
|
||||
|
||||
async def test_mock_device_honors_forced_path() -> None:
|
||||
"""A forced path drives the repeater hops; a return-to-us hop is appended."""
|
||||
device = MockDevice()
|
||||
await device.connect()
|
||||
result = await device.run_trace("Alice", path="3d,f2,3d")
|
||||
# Three forced repeater hops, plus the hash-less hop for the reply returning to us.
|
||||
assert result.hop_count == 4
|
||||
assert [h.node for h in result.hops] == ["3d", "f2", "3d", None]
|
||||
|
||||
|
||||
async def test_mock_device_blank_path_traces() -> None:
|
||||
"""A blank/auto path still produces a successful, non-empty trace."""
|
||||
device = MockDevice()
|
||||
await device.connect()
|
||||
# Try several times since the simulator drops very weak links occasionally.
|
||||
results = [await device.run_trace("Alice") for _ in range(10)]
|
||||
assert any(r.success and r.hops for r in results)
|
||||
|
||||
|
||||
def test_trace_edges_endpoints_are_our_device() -> None:
|
||||
"""The first edge originates at us and the last edge returns to us (#3/#4)."""
|
||||
from meshtools.core.models import Hop
|
||||
|
||||
result = TraceResult(
|
||||
target="Alice",
|
||||
success=True,
|
||||
hops=[Hop(0, "3d", 5.0), Hop(1, "f2", 1.0), Hop(2, None, -1.0)],
|
||||
)
|
||||
edges = result.edges(device_label="us")
|
||||
assert [(e.origin, e.destination) for e in edges] == [
|
||||
("us", "3d"),
|
||||
("3d", "f2"),
|
||||
("f2", "us"),
|
||||
]
|
||||
assert [e.snr for e in edges] == [5.0, 1.0, -1.0]
|
||||
|
||||
|
||||
def test_trace_stats_reports_per_hop_medians() -> None:
|
||||
"""Aggregation yields a median SNR for every hop position (#5)."""
|
||||
from meshtools.core.models import Hop
|
||||
|
||||
traces = [
|
||||
TraceResult(target="x", success=True, hops=[Hop(0, "a", 4.0), Hop(1, None, 0.0)]),
|
||||
TraceResult(target="x", success=True, hops=[Hop(0, "a", 8.0), Hop(1, None, 2.0)]),
|
||||
]
|
||||
stats = TraceStats.from_traces("x", traces)
|
||||
# Node identities are raw (None = our device) so a label is applied at render time.
|
||||
assert [(h.origin, h.destination, h.median_snr) for h in stats.hop_snrs] == [
|
||||
(None, "a", 6.0),
|
||||
("a", None, 1.0),
|
||||
]
|
||||
|
||||
|
||||
async def test_mock_device_trace_is_unimodal_in_tx() -> None:
|
||||
"""The simulator peaks near its optimal TX power (averaged over noise)."""
|
||||
device = MockDevice(optimal_tx=14)
|
||||
@@ -91,6 +248,191 @@ def test_tx_plot_writes_html(tmp_path: Path) -> None:
|
||||
assert path.stat().st_size > 1000 # plotly.js inlined → non-trivial size
|
||||
|
||||
|
||||
class _Event:
|
||||
def __init__(self, payload: dict) -> None:
|
||||
self.payload = payload
|
||||
|
||||
def is_error(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _mc_with(contacts: dict, path_hash_mode: int = 2):
|
||||
"""Build a minimal mock ``MeshCore`` client exposing the trace commands used."""
|
||||
|
||||
class _Commands:
|
||||
async def get_contacts(self) -> _Event:
|
||||
return _Event(contacts)
|
||||
|
||||
async def get_path_hash_mode(self) -> int:
|
||||
return path_hash_mode
|
||||
|
||||
class _MC:
|
||||
commands = _Commands()
|
||||
|
||||
return _MC()
|
||||
|
||||
|
||||
async def test_three_byte_route_appends_destination_hash() -> None:
|
||||
"""A learned multi-hop route walks the repeaters then ends at the target (#1).
|
||||
|
||||
Two things matter for a non-power-of-two region (3-byte hashes): each 3-byte
|
||||
routing hash collapses to its leading 2 bytes (the widest representable trace
|
||||
width), and the destination's own hash is appended as the final hop — a trace
|
||||
only replies when its destination is the last hop.
|
||||
"""
|
||||
from meshtools.core.connection import MeshCoreDevice
|
||||
|
||||
mc = _mc_with(
|
||||
{
|
||||
"Repeater": {
|
||||
"adv_name": "Repeater",
|
||||
"public_key": "aabbcc" + "00" * 29,
|
||||
# Two hops, each a 3-byte routing hash (mode 2 => size 3).
|
||||
"out_path": "112233445566",
|
||||
"out_path_len": 2,
|
||||
"out_path_hash_mode": 2,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
device = MeshCoreDevice(port="COM-test")
|
||||
resolved = await device._trace_path_to_contact(mc, "Repeater")
|
||||
assert resolved is not None
|
||||
path_bytes, flags = resolved
|
||||
# Two repeater hops collapsed to 2 bytes, then the target's own 2-byte hash.
|
||||
assert path_bytes == bytes.fromhex("1122" "4455" "aabb")
|
||||
assert flags == trace_runner.path_hash_flags(2) # 2-byte width
|
||||
|
||||
|
||||
async def test_direct_neighbor_resolves_to_destination_hash() -> None:
|
||||
"""A direct neighbor (no learned route) resolves to a single destination hop.
|
||||
|
||||
Verified on hardware: these contacts report ``out_path_len == -1`` (no stored
|
||||
route) yet answer a single-hop trace addressed to their own hash. With the
|
||||
contact's mode unknown (-1) the region's path-hash mode (3-byte) sets the width,
|
||||
collapsed to the representable 2 bytes.
|
||||
"""
|
||||
from meshtools.core.connection import MeshCoreDevice
|
||||
|
||||
mc = _mc_with(
|
||||
{
|
||||
"Neighbor": {
|
||||
"adv_name": "Neighbor",
|
||||
"public_key": "3d63c6" + "00" * 29,
|
||||
# Direct neighbor as the firmware reports it: no learned route.
|
||||
"out_path": "",
|
||||
"out_path_len": -1,
|
||||
"out_path_hash_mode": -1,
|
||||
}
|
||||
},
|
||||
path_hash_mode=2,
|
||||
)
|
||||
|
||||
device = MeshCoreDevice(port="COM-test")
|
||||
resolved = await device._trace_path_to_contact(mc, "Neighbor")
|
||||
assert resolved is not None
|
||||
path_bytes, flags = resolved
|
||||
# Just the target's own hash, region width 3 collapsed to a 2-byte trace hop.
|
||||
assert path_bytes == bytes.fromhex("3d63")
|
||||
assert flags == trace_runner.path_hash_flags(2)
|
||||
|
||||
|
||||
async def test_unknown_contact_resolves_to_none() -> None:
|
||||
"""An unrecognized target yields ``None`` so the trace can fall back to path-less."""
|
||||
from meshtools.core.connection import MeshCoreDevice
|
||||
|
||||
mc = _mc_with(
|
||||
{
|
||||
"Somebody": {
|
||||
"adv_name": "Somebody",
|
||||
"public_key": "abcdef" + "00" * 29,
|
||||
"out_path": "",
|
||||
"out_path_len": -1,
|
||||
"out_path_hash_mode": -1,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
device = MeshCoreDevice(port="COM-test")
|
||||
resolved = await device._trace_path_to_contact(mc, "Nobody")
|
||||
assert resolved is None
|
||||
|
||||
|
||||
async def test_contacts_payload_retries_transient_error() -> None:
|
||||
"""A transient 'no event received' on contacts retrieval is retried, not fatal."""
|
||||
from meshtools.core.connection import MeshCoreDevice
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
class _Err:
|
||||
payload = {"reason": "no event received during contacts retrieval"}
|
||||
|
||||
def is_error(self) -> bool:
|
||||
return True
|
||||
|
||||
class _Ok:
|
||||
payload = {"Repeater": {"adv_name": "Repeater", "public_key": "aabbcc" + "00" * 29}}
|
||||
|
||||
def is_error(self) -> bool:
|
||||
return False
|
||||
|
||||
class _Commands:
|
||||
async def get_contacts(self): # noqa: ANN202
|
||||
calls["n"] += 1
|
||||
return _Err() if calls["n"] < 3 else _Ok()
|
||||
|
||||
class _MC:
|
||||
commands = _Commands()
|
||||
|
||||
device = MeshCoreDevice(port="COM-test")
|
||||
payload = await device._contacts_payload(_MC(), retries=3, delay=0)
|
||||
assert "Repeater" in payload
|
||||
assert calls["n"] == 3 # failed twice, succeeded on the third attempt
|
||||
|
||||
|
||||
async def test_contacts_payload_raises_clean_error_after_retries() -> None:
|
||||
"""Persistent contacts-retrieval failure raises a clean, actionable error."""
|
||||
from meshtools.core.connection import DeviceCommandError, MeshCoreDevice
|
||||
|
||||
class _Err:
|
||||
payload = {"reason": "no event received during contacts retrieval"}
|
||||
|
||||
def is_error(self) -> bool:
|
||||
return True
|
||||
|
||||
class _Commands:
|
||||
async def get_contacts(self): # noqa: ANN202
|
||||
return _Err()
|
||||
|
||||
class _MC:
|
||||
commands = _Commands()
|
||||
|
||||
device = MeshCoreDevice(port="COM-test")
|
||||
with pytest.raises(DeviceCommandError) as exc:
|
||||
await device._contacts_payload(_MC(), retries=2, delay=0)
|
||||
assert "no event received" in str(exc.value)
|
||||
|
||||
|
||||
async def test_latest_trace_returns_previous_run(tmp_path: Path) -> None:
|
||||
"""latest_trace rehydrates the most recent stored trace, excluding a given run."""
|
||||
from meshtools.core.models import Hop
|
||||
|
||||
repo = Repository(tmp_path / "prev.db")
|
||||
old_run = repo.start_run("trace", {"target": "Alice"})
|
||||
repo.record_trace(
|
||||
old_run,
|
||||
TraceResult(target="Alice", success=True, hops=[Hop(0, "3d", 4.0), Hop(1, None, 1.0)]),
|
||||
)
|
||||
new_run = repo.start_run("trace", {"target": "Alice"})
|
||||
|
||||
prev = repo.latest_trace("Alice", exclude_run_id=new_run)
|
||||
assert prev is not None
|
||||
assert [h.node for h in prev.hops] == ["3d", None]
|
||||
assert prev.edges("us")[-1].destination == "us"
|
||||
assert repo.latest_trace("Nobody") is None
|
||||
repo.close()
|
||||
|
||||
|
||||
async def test_repository_round_trip(tmp_path: Path) -> None:
|
||||
"""A run and its traces persist and read back."""
|
||||
repo = Repository(tmp_path / "test.db")
|
||||
|
||||
Reference in new issue
Block a user