diff --git a/meshtools/tools/info.py b/meshtools/tools/info.py index 9ce9e86..0100789 100644 --- a/meshtools/tools/info.py +++ b/meshtools/tools/info.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any import typer -from rich.table import Table from ..context import AppContext from .base import Tool, ToolResult, register @@ -32,6 +31,7 @@ class InfoTool(Tool): """ 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) @@ -41,13 +41,13 @@ class InfoTool(Tool): # 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") - ctable.add_column("Name", style="brand") - ctable.add_column("Key prefix", style="muted") - for c in contacts: - ctable.add_row(c.name, c.key_prefix or "[muted]?[/muted]") - ctx.console.print(ctable) + # 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)}, diff --git a/meshtools/tools/trace.py b/meshtools/tools/trace.py index db3ace0..980ed30 100644 --- a/meshtools/tools/trace.py +++ b/meshtools/tools/trace.py @@ -103,6 +103,9 @@ class TraceTool(Tool): # 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) + # Our own public key, so the route's endpoints (us) carry a hash like every + # other hop, addressed at the same path-hash width. + device_hash = str(self_info.get("public_key") or "") or None # Resolve repeater hashes in the results to contact names where we know them, # so the tables read as names instead of opaque hex prefixes. @@ -142,8 +145,10 @@ class TraceTool(Tool): # (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)) + ctx.console.print(traces_table(traces, device_label, resolve, device_hash)) + ctx.console.print( + stats_panel(stats, device_label, resolve, route=current, device_hash=device_hash) + ) summary: dict[str, Any] = { "target": stats.target, diff --git a/meshtools/ui/widgets.py b/meshtools/ui/widgets.py index 963f1cf..02a9f99 100644 --- a/meshtools/ui/widgets.py +++ b/meshtools/ui/widgets.py @@ -20,6 +20,7 @@ from rich.text import Text from .. import __version__ from ..core.models import ( LOCAL_DEVICE_LABEL, + Contact, HopAggregate, HopEdge, TraceResult, @@ -93,45 +94,37 @@ def make_progress(console: Console) -> Progress: ) -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: - 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 styled :class:`Text` for the node. - """ - 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, + hash_bytes: Optional[int] = None, + device_hash: Optional[str] = None, ) -> Text: - """Render an ``origin -> destination`` link with a styled arrow. + """Render an ``origin -> destination`` link as ``name (hash)`` nodes, arrow styled. + + Each end is rendered like the route line: a known node as ``name (hash)``, an + unknown one as its bare hash, and our own device as its label plus ``device_hash`` + when supplied. Hashes are truncated to ``hash_bytes`` so they match how the trace + command addressed each node. 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. + hash_bytes: Path-hash width (bytes) to truncate shown hashes to. + device_hash: Our own device's key/hash, shown alongside its label when known. Returns: - A :class:`Text` like ``us → Alice`` with the arrow muted. + A :class:`Text` like ``us (a1) → Alice (3d)`` with the arrow muted. """ - text = _node_text(origin, device_label, resolve) + text = _route_node_text(origin, device_label, resolve, hash_bytes, device_hash) text.append(" → ", style="muted") - text.append_text(_node_text(destination, device_label, resolve)) + text.append_text( + _route_node_text(destination, device_label, resolve, hash_bytes, device_hash) + ) return text @@ -139,24 +132,30 @@ def traces_table( traces: list[TraceResult], device_label: str = LOCAL_DEVICE_LABEL, resolve: NodeResolver = _identity, + device_hash: Optional[str] = None, ) -> 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. + column is one trace's SNR readings down the shared path. Each node is annotated with + its hash at the command's path-hash width. 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. + device_hash: Our own device's key/hash, annotated onto the path's endpoints. Returns: A Rich :class:`Table` with a column per trace. """ target = traces[0].target if traces else "" + # All traces in a run share the command's path-hash width; take it from the first + # successful one so node hashes render at the width they were addressed. + hash_bytes = next((t.path_hash_bytes for t in traces if t.success), None) table = Table(title=f"Trace → {target}", border_style="muted", expand=False) table.add_column("Hop", justify="right", style="muted") table.add_column("From → To") @@ -172,7 +171,10 @@ def traces_table( for idx in indices: link = next( ( - _link_text(edges[idx].origin, edges[idx].destination, device_label, resolve) + _link_text( + edges[idx].origin, edges[idx].destination, device_label, resolve, + hash_bytes, device_hash, + ) for edges in edges_by_trace if idx in edges ), @@ -201,11 +203,52 @@ def traces_table( return table +def highlighted_hash(value: str, prefix_bytes: int) -> Text: + """Render a full hex key with its leading path-hash prefix highlighted. + + The first ``prefix_bytes`` bytes are the slice other nodes address in a forced trace + path (the path-hash); they are shown in the brand colour and the remainder muted, so + the addressable prefix stands out within the otherwise full key. + + Args: + value: The full key as hex, optionally ``0x``-prefixed and mixed-case. + prefix_bytes: Number of leading bytes the current path-hash mode addresses; ``0`` + (or negative) leaves the whole key un-highlighted. + + Returns: + A styled :class:`Text` of the full key. + """ + raw = value.lower().removeprefix("0x") + split = max(0, prefix_bytes) * 2 + text = Text() + if split: + text.append(raw[:split], style="brand") + text.append(raw[split:], style="muted") + else: + text.append(raw, style="muted") + return text + + +def _shorten_hash(value: str, hash_bytes: Optional[int]) -> str: + """Return ``value`` as bare hex truncated to ``hash_bytes`` bytes. + + Args: + value: A hex hash, optionally ``0x``-prefixed and mixed-case. + hash_bytes: Width in bytes to truncate to; the full value when falsy/unknown. + + Returns: + Lowercase hex with no ``0x`` prefix, at most ``hash_bytes`` bytes wide. + """ + raw = value.lower().removeprefix("0x") + return raw[: hash_bytes * 2] if hash_bytes else raw + + def _route_node_text( label: Optional[str], device_label: str, resolve: NodeResolver, hash_bytes: Optional[int], + device_hash: Optional[str] = None, ) -> Text: """Render one route node as ``name (hash)``, the hash at the command's width. @@ -219,15 +262,21 @@ def _route_node_text( 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. + device_hash: Our own device's key/hash, shown alongside its label when known. 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 + text = Text(device_label, style="accent") + shown = _shorten_hash(device_hash, hash_bytes) if device_hash else "" + if shown: + text.append(f"{_NBSP}(", style="muted") # nbsp keeps "name (hash)" together + text.append(shown, style="muted") + text.append(")", style="muted") + return text + shown = _shorten_hash(label, hash_bytes) named = resolve(label) if not named or named == label: return Text(shown, style="brand") # unknown node: hash only @@ -242,19 +291,21 @@ def _route_text( result: TraceResult, device_label: str = LOCAL_DEVICE_LABEL, resolve: NodeResolver = _identity, + device_hash: Optional[str] = None, ) -> 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. + ``Me (a1b2) → Alice (3d63) → Bob (f2a1) → Me (a1b2)``. 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. + device_hash: Our own device's key/hash, annotated onto its endpoints when known. Returns: A :class:`Text` with the node sequence, or a muted note when no hops exist. @@ -270,12 +321,18 @@ def _route_text( # 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)) + text.append_text( + _route_node_text(node, device_label, resolve, hash_bytes, device_hash) + ) return text def _hop_medians_table( - hop_snrs: list[HopAggregate], device_label: str, resolve: NodeResolver = _identity + hop_snrs: list[HopAggregate], + device_label: str, + resolve: NodeResolver = _identity, + hash_bytes: Optional[int] = None, + device_hash: Optional[str] = None, ) -> Table: """Render the per-hop median SNR aggregated across a run's traces. @@ -283,6 +340,8 @@ def _hop_medians_table( 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. + hash_bytes: Path-hash width (bytes) to truncate shown node hashes to. + device_hash: Our own device's key/hash, annotated onto the path's endpoints. Returns: A compact Rich :class:`Table` of hop, link, and median SNR. @@ -294,7 +353,9 @@ def _hop_medians_table( for agg in hop_snrs: table.add_row( str(agg.index), - _link_text(agg.origin, agg.destination, device_label, resolve), + _link_text( + agg.origin, agg.destination, device_label, resolve, hash_bytes, device_hash + ), Text(f"{agg.median_snr:+.1f} dB", style=snr_style(agg.median_snr)), ) return table @@ -305,6 +366,7 @@ def stats_panel( device_label: str = LOCAL_DEVICE_LABEL, resolve: NodeResolver = _identity, route: Optional[TraceResult] = None, + device_hash: Optional[str] = None, ) -> Panel: """Summarize aggregated trace statistics in a panel. @@ -318,6 +380,7 @@ def stats_panel( 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. + device_hash: Our own device's key/hash, annotated onto the route endpoints. Returns: A Rich :class:`Panel` with the route, success rate, robust SNR/RTT, and @@ -336,16 +399,58 @@ def stats_panel( 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(_route_text(route, device_label, resolve, device_hash)) 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)) + hash_bytes = route.path_hash_bytes if route is not None else None + sections.append( + _hop_medians_table(stats.hop_snrs, device_label, resolve, hash_bytes, device_hash) + ) 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) +def nodes_table( + self_name: str, + self_key: str, + contacts: list[Contact], + prefix_bytes: int, +) -> Table: + """List this node and its known contacts, full keys with the path-hash prefix lit. + + Our own node is the first row, its name highlighted to set it apart. Every node's key + is shown in full with the leading ``prefix_bytes`` bytes — the slice a forced trace + path addresses at the current path-hash mode — highlighted. + + Args: + self_name: This node's advertised name. + self_key: This node's full public key (hex); blank renders as ``?``. + contacts: Known contacts, listed after our own node. + prefix_bytes: Path-hash width in bytes to highlight in every key. + + Returns: + A Rich :class:`Table` of name and full public key. + """ + table = Table(title=f"Nodes ({len(contacts)} contacts)", border_style="muted") + table.add_column("Name") + table.add_column("Public key [muted](path-hash prefix highlighted)[/muted]") + + unknown = Text("?", style="muted") + table.add_row( + Text(f"{self_name} (this node)", style="accent"), # our node, name highlighted + highlighted_hash(self_key, prefix_bytes) if self_key else unknown, + ) + table.add_section() + for c in contacts: + table.add_row( + Text(c.name, style="brand"), + highlighted_hash(c.public_key, prefix_bytes) if c.public_key else unknown, + ) + return table + + def tx_opt_table(result: TxOptResult) -> Table: """Render every measured TX level of an optimization sweep, best row highlighted. diff --git a/tests/test_device_config.py b/tests/test_device_config.py index ad1a10a..5172a1a 100644 --- a/tests/test_device_config.py +++ b/tests/test_device_config.py @@ -69,6 +69,35 @@ def test_path_hash_mode_is_strict_enum() -> None: parse_value(get_spec("path_hash_mode"), "4") +def test_highlighted_hash_highlights_path_hash_prefix() -> None: + """The full key is shown with only its path-hash prefix bytes highlighted.""" + from meshtools.ui.widgets import highlighted_hash + + pub = "aabbccddee" + "00" * 27 + # mode 2 => 3-byte hashes => first 6 hex chars are the addressable prefix. + text = highlighted_hash(pub, prefix_bytes=3) + assert text.plain == pub # the full key is shown + highlighted = [s for s in text.spans if s.style == "brand"] + assert len(highlighted) == 1 + assert text.plain[highlighted[0].start : highlighted[0].end] == "aabbcc" + + +def test_nodes_table_lists_us_first_with_full_keys() -> None: + """The nodes table puts our node first (name highlighted) with full keys.""" + from meshtools.core.models import Contact + from meshtools.ui.widgets import nodes_table + + contacts = [Contact(name="Alice", public_key="3d63c6" + "00" * 29, key_prefix="3d63c6")] + table = nodes_table("Homestead", "aabbcc" + "00" * 29, contacts, prefix_bytes=3) + # Two body rows: our node, then Alice. + names = [cell.plain if hasattr(cell, "plain") else str(cell) for cell in table.columns[0].cells] + assert names[0].startswith("Homestead") + assert "Alice" in names[1] + keys = list(table.columns[1].cells) + assert keys[0].plain == "aabbcc" + "00" * 29 # full key, not a short prefix + assert any(s.style == "brand" for s in keys[0].spans) # prefix highlighted + + 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") diff --git a/tests/test_skeleton.py b/tests/test_skeleton.py index 4aacdbc..1849dfe 100644 --- a/tests/test_skeleton.py +++ b/tests/test_skeleton.py @@ -118,6 +118,47 @@ def test_route_text_annotates_nodes_with_command_width_hash() -> None: assert "Me (" not in plain.replace(" ", " ") # device never annotated +def test_route_text_annotates_our_device_with_hash() -> None: + """Given our key, both endpoints (us) carry our hash at 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, "3d63", 12.0), Hop(1, None, 12.0)], + path_hash_bytes=2, # command used 2-byte hashes + ) + plain = _route_text( + result, "Me", device_hash="a1b2c3" + "00" * 29 + ).plain.replace("\xa0", " ") + assert plain.count("Me (a1b2)") == 2 # our device at both ends, carrying our hash + assert "a1b2c3" not in plain # truncated to the command's 2-byte width + + +def test_traces_table_annotates_links_with_hashes() -> None: + """The per-trace From→To column shows ``name (hash)``, including our device.""" + from meshtools.core.models import Hop + from meshtools.ui.widgets import traces_table + + resolve = trace_runner.make_node_resolver( + [Contact(name="Alice", public_key="3d63c6" + "00" * 26, key_prefix="3d63c6429436")] + ) + traces = [ + TraceResult( + target="Alice", + success=True, + hops=[Hop(0, "3d63c6", 12.0), Hop(1, None, 9.0)], + path_hash_bytes=2, # command used 2-byte hashes + ) + ] + table = traces_table(traces, "Me", resolve, device_hash="a1b2c3" + "00" * 29) + links = [cell.plain.replace("\xa0", " ") for cell in table.columns[1].cells] + assert any("Me (a1b2)" in link for link in links) # our device carries its hash + assert any("Alice (3d63)" in link for link in links) # truncated to the 2-byte width + assert not any("3d63c6" in link for link in links) + + 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