From 2cd0fce032450aac643da8c9caf72c87f174a472 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sat, 4 Jul 2026 13:49:58 -0400 Subject: [PATCH] Split the node/contact list out of info into its own nodes tool The info tool showed both the device config and the node/contact list. Move the list into a dedicated nodes tool (Device category, right after info) so each feature does one thing; info now shows identity and radio config only. - nodes tool renders this node + contacts with the path-hash prefix highlighted, reading self-info + contacts + path-hash mode directly. - info drops the contacts read and nodes_table render (and its contacts summary field); the nodes_table widget and its test are unchanged. - README: add a Node list feature row. Co-Authored-By: Claude Opus 4.8 --- README.md | 1 + meshtools/tools/info.py | 27 ++++++----------- meshtools/tools/nodes.py | 63 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 18 deletions(-) create mode 100644 meshtools/tools/nodes.py diff --git a/README.md b/README.md index ef2e8b5..7381f80 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ 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. | +| **Node list** | ✅ working | List this node and its known contacts — full public keys with the path-hash prefix highlighted. | | **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. | diff --git a/meshtools/tools/info.py b/meshtools/tools/info.py index 0100789..260686b 100644 --- a/meshtools/tools/info.py +++ b/meshtools/tools/info.py @@ -1,4 +1,4 @@ -"""The ``info`` tool: show the connected companion device's identity and contacts.""" +"""The ``info`` tool: show the connected companion device's identity and radio config.""" from __future__ import annotations @@ -12,46 +12,37 @@ from .base import Tool, ToolResult, register @register class InfoTool(Tool): - """Display device identity, radio configuration, and known contacts.""" + """Display device identity and radio configuration. + + The known-contacts list lives in the separate ``nodes`` tool. + """ name = "info" - help = "Show device identity, radio config, and contacts." + help = "Show device identity and radio config." category = "Device" order = 10 async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: - """Query the device and render its full configuration and contacts. + """Query the device and render its full configuration. Args: ctx: Shared application context. params: Unused. Returns: - A :class:`ToolResult` summarizing device name and contact count. + A :class:`ToolResult` summarizing the device name. """ from ..core.device_config import build_snapshot from ..ui.config_editor import render_config - from ..ui.widgets import nodes_table device = await ctx.device() snapshot = await build_snapshot(device) custom = await device.get_custom_vars() - contacts = await device.get_contacts() # Render every current setting with a short explanation of each. render_config(ctx.console, snapshot, custom) - # List our own node (first, highlighted) and contacts, each with its full key and - # the path-hash prefix — the slice a forced path addresses — highlighted. - mode = snapshot.get("path_hash_mode") - prefix_bytes = (mode + 1) if isinstance(mode, int) and 0 <= mode <= 3 else 0 - self_name = str(snapshot.get("name") or "this node") - self_key = str(snapshot.get("public_key") or "") - ctx.console.print(nodes_table(self_name, self_key, contacts, prefix_bytes)) - - return ToolResult( - summary={"name": snapshot.get("name"), "contacts": len(contacts)}, - ) + return ToolResult(summary={"name": snapshot.get("name")}) def register_cli(self, app: typer.Typer) -> None: """Register the ``info`` subcommand. diff --git a/meshtools/tools/nodes.py b/meshtools/tools/nodes.py new file mode 100644 index 0000000..250a047 --- /dev/null +++ b/meshtools/tools/nodes.py @@ -0,0 +1,63 @@ +"""The ``nodes`` tool: list this node and the contacts it knows about.""" + +from __future__ import annotations + +from typing import Any + +import typer + +from ..context import AppContext +from .base import Tool, ToolResult, register + + +@register +class NodesTool(Tool): + """List this node and its known contacts, each with its path-hash prefix.""" + + name = "nodes" + help = "List this node and known contacts (full keys, path-hash prefix highlighted)." + category = "Device" + order = 12 + + async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: + """Query the device and render the node/contact list. + + Args: + ctx: Shared application context. + params: Unused. + + Returns: + A :class:`ToolResult` summarizing the number of known contacts. + """ + from ..ui.widgets import nodes_table + + device = await ctx.device() + info = await device.get_self_info() + contacts = await device.get_contacts() + + # The path-hash prefix — the leading slice of a key a forced path addresses — is + # ``mode + 1`` bytes wide; highlight it in every key. The mode is an optional read, + # so fall back to no highlighting when the firmware doesn't report it. + try: + mode = await device.get_path_hash_mode() + except Exception: # noqa: BLE001 - optional read; absence just skips highlighting + mode = None + prefix_bytes = (mode + 1) if isinstance(mode, int) and 0 <= mode <= 3 else 0 + + self_name = str(info.get("name") or "this node") + self_key = str(info.get("public_key") or "") + ctx.console.print(nodes_table(self_name, self_key, contacts, prefix_bytes)) + + return ToolResult(summary={"contacts": len(contacts)}) + + def register_cli(self, app: typer.Typer) -> None: + """Register the ``nodes`` subcommand. + + Args: + app: The Typer application. + """ + from ..cli import run_tool_command + + @app.command(name=self.name, help=self.help) + def _nodes() -> None: + run_tool_command(self, {})