Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
621 lines
25 KiB
Python
621 lines
25 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
"""The trace tools: path traces, live in the menu, one-shot on the CLI.
|
||
|
||
A trace is one walked path — the protocol has no destination field, so "target" is
|
||
purely a UX notion — and the menu splits the feature along exactly that line:
|
||
|
||
* **Trace target** (``trace``): *can I reach this node?* Pick a target from a
|
||
recency-ordered list; the live screen composes/explores symmetric routes that turn
|
||
at the target and come home over the mirrored hops. The list stays pushed underneath
|
||
for the whole visit, so Esc from a walk lands back on the row that launched it —
|
||
freshly re-sorted by what was just traced — and the next node is one Enter away.
|
||
* **Trace path** (``trace-path``): *how far can a route I build carry?* No target and
|
||
no picker — the whole walk is composed hop by hop and only has to end within our
|
||
own earshot. Traces record under the ``(path)`` sentinel, keeping composed walks
|
||
out of the target picker's history.
|
||
|
||
Both open the live trace screen (:mod:`meshterm.ui.trace_screen`) armed but idle:
|
||
nothing transmits until Trace is committed, which runs the chosen sample count
|
||
(1–8 traces, paced between transmissions — repeaters penalize, and can blacklist,
|
||
nodes that burst traffic, so multi-trace runs always sleep a cooldown between sends).
|
||
On the CLI each stays a scriptable one-shot: run one trace, print the route and
|
||
per-hop SNR, exit.
|
||
|
||
Both front ends persist identically: one ``runs`` row per trace, recorded under it, so
|
||
stored history reads the same no matter where it came from.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
import typer
|
||
|
||
from ..context import AppContext
|
||
from ..core import exitcodes
|
||
from ..core.models import (
|
||
LOCAL_DEVICE_LABEL,
|
||
PATH_TRACE_TARGET,
|
||
Contact,
|
||
TraceResult,
|
||
TraceStats,
|
||
)
|
||
from ..services import trace_runner
|
||
from ..ui.widgets import stats_panel
|
||
from .base import Tool, ToolResult, register
|
||
|
||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||
from ..ui.contactlist import ContactListScreen, ContactRow
|
||
|
||
#: The characters a stored trace target must consist of to be treated as a hex key
|
||
#: prefix when folding it back to a contact name in the target picker.
|
||
_HEX_DIGITS = frozenset("0123456789abcdef")
|
||
|
||
|
||
@register
|
||
class TraceTool(Tool):
|
||
"""Trace the path to a target and watch per-hop SNR, live or scripted."""
|
||
|
||
name = "trace"
|
||
title = "Trace target"
|
||
icon = "🎯"
|
||
help = "Trace a target and watch per-hop SNR"
|
||
category = "Explore"
|
||
order = 30 # see a node above, walk to it here
|
||
|
||
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
|
||
"""Nothing to gather here — the target picker lives inside :meth:`run`.
|
||
|
||
The pick and the screen it opens share the contacts read and the routing width,
|
||
and the live path skips the base class's run row (see :meth:`execute`), so the
|
||
whole entry flow sits in :meth:`_run_live` rather than half of it here.
|
||
|
||
Args:
|
||
ctx: Shared application context.
|
||
|
||
Returns:
|
||
``{"live": True}`` — the menu's marker for the interactive path.
|
||
"""
|
||
return {"live": True}
|
||
|
||
async def execute(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||
"""Run — without the outer run row on the live path.
|
||
|
||
The live screen opens one ``runs`` row *per trace* (matching what a scripted
|
||
invocation records), so wrapping the whole screen session in another row would
|
||
double-log it. Scripted runs keep the base class's logging.
|
||
|
||
Args:
|
||
ctx: Shared application context.
|
||
params: Parameters for this invocation.
|
||
|
||
Returns:
|
||
The :class:`ToolResult` from :meth:`run`.
|
||
"""
|
||
if params.get("live"):
|
||
return await self.run(ctx, params)
|
||
return await super().execute(ctx, params)
|
||
|
||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||
"""Open the live screen (menu) or run one persisted trace (CLI).
|
||
|
||
Args:
|
||
ctx: Shared application context.
|
||
params: ``live`` from the menu (the target is picked on the screen); or
|
||
``target`` (str), optional ``path``, and the injected ``_run_id``
|
||
from the CLI.
|
||
|
||
Returns:
|
||
A :class:`ToolResult` with the trace's outcome.
|
||
"""
|
||
if params.get("live"):
|
||
return await self._run_live(ctx)
|
||
return await self._run_cli(ctx, params)
|
||
|
||
# -- interactive (menu) -------------------------------------------------------
|
||
|
||
async def _run_live(self, ctx: AppContext) -> ToolResult:
|
||
"""Ask for the target in a popup, then run the live screen over the menu.
|
||
|
||
The picker is a *question*, not a place: a floating list over the menu, gone the
|
||
moment a target is picked, so the trace screen opens straight over the menu and
|
||
Esc from it lands there. It used to stay pushed as a hub under the trace screen —
|
||
Esc out of a trace landed back on the list, which read as two places where the
|
||
trace is the whole visit; tracing another node is opening the tool again.
|
||
|
||
Args:
|
||
ctx: Shared application context.
|
||
|
||
Returns:
|
||
A :class:`ToolResult` counting the screen opened and the traces run.
|
||
"""
|
||
from ..ui.trace_screen import open_trace
|
||
from ..ui.tui.screen import CANCEL
|
||
|
||
picker = await self._build_picker(ctx)
|
||
if picker is None:
|
||
# Nothing to list (or no full-screen session): the target is typed instead.
|
||
target = await self._prompt_target(ctx)
|
||
else:
|
||
chosen = await ctx.ui.session.run_dialog(picker)
|
||
target = None if chosen is CANCEL or chosen is None else chosen.name
|
||
if target is None:
|
||
return ToolResult(summary={})
|
||
return ToolResult(summary={"sessions": 1, "traces": await open_trace(ctx, target)})
|
||
|
||
async def _prompt_target(self, ctx: AppContext) -> str | None:
|
||
"""Ask for a target by hand — the way in when there is no list to pick from.
|
||
|
||
Reached with no known contacts at all (an empty list would be nothing to pick from)
|
||
and on any surface without a full-screen session. It is also the only way to trace a
|
||
bare key prefix for a node the device doesn't carry as a contact. A question like
|
||
the list it stands in for, so it floats over the menu rather than filling the frame.
|
||
|
||
Args:
|
||
ctx: Shared application context.
|
||
|
||
Returns:
|
||
The typed target, or ``None`` if it was left blank or cancelled.
|
||
"""
|
||
entered = await ctx.ui.text("Target node (name or key prefix):", floating=True)
|
||
return entered.strip() if entered else None
|
||
|
||
async def _build_picker(self, ctx: AppContext) -> ContactListScreen | None:
|
||
"""Build the trace-target picker, or ``None`` when there is no list to draw.
|
||
|
||
The same ``NAME · TRACED · HEARD · PKTS · KEY`` lanes, Ctrl+arrow sort ring, and
|
||
type-to-filter the Contacts screen and the courier recipient draw — but with every
|
||
node type listed (a trace answers *can I reach this node?* for a repeater or room
|
||
just as for a companion) and an extra ``TRACED`` lane: how long ago each node was
|
||
last traced. The list opens sorted by ``TRACED`` descending, so the most recently
|
||
traced node leads and never-traced ones gather at the bottom. Enter commits the
|
||
highlighted node as the target.
|
||
|
||
Args:
|
||
ctx: Shared application context.
|
||
|
||
Returns:
|
||
The picker, or ``None`` when there is nothing to list — see
|
||
:meth:`_prompt_target`.
|
||
"""
|
||
from ..ui.contactlist import (
|
||
TRACE_LANES,
|
||
TRACE_SORT_COLUMNS,
|
||
TRACE_SORT_OPENS_ASCENDING,
|
||
ContactListScreen,
|
||
)
|
||
from ..ui.surface import TuiUi
|
||
from ..ui.widgets import ContactsSort
|
||
|
||
# Through the session cache: this picker runs on every Trace open, and the contacts
|
||
# table is a slow round-trip on a busy node — re-reading it here (in front of the
|
||
# already-cached trace screen) is what kept opening Trace feeling like a stall. See
|
||
# :class:`~meshterm.services.device_state.DeviceState`.
|
||
contacts = await ctx.devstate.contacts()
|
||
if not contacts or not isinstance(ctx.ui, TuiUi):
|
||
return None
|
||
|
||
picker = ContactListScreen(
|
||
"Trace target — pick a target",
|
||
rows=self._picker_rows(ctx, contacts),
|
||
prefix_bytes=await ctx.devstate.routing_prefix_bytes(),
|
||
sort=ContactsSort.from_name("traced", TRACE_SORT_COLUMNS, TRACE_SORT_OPENS_ASCENDING),
|
||
footer_hint="↑↓ move · ^←→↑↓ sort · type to filter · Enter select · Esc back",
|
||
lanes=TRACE_LANES,
|
||
)
|
||
# The contact list is a full-screen page everywhere else; here it is a question
|
||
# asked on the way in, so it floats over the menu like every other lead-in pick.
|
||
picker.floating = True
|
||
return picker
|
||
|
||
@staticmethod
|
||
def _picker_rows(ctx: AppContext, contacts: list[Contact]) -> list[ContactRow]:
|
||
"""The picker's lane data for ``contacts``, read fresh from stored history.
|
||
|
||
Both the ``TRACED`` ages and the packet counts come from the repository, so this is
|
||
cheap enough to redo every time a trace hands the picker back — which is what keeps
|
||
the lane the list is *sorted* by honest about the walk that just happened.
|
||
|
||
Args:
|
||
ctx: Shared application context.
|
||
contacts: The device's contacts, as listed.
|
||
|
||
Returns:
|
||
One :class:`~meshterm.ui.contactlist.ContactRow` per contact, in the order
|
||
given (the screen sorts them).
|
||
"""
|
||
from ..ui.contactlist import ContactRow
|
||
from ..ui.widgets import contact_packets
|
||
|
||
traced = _last_traced_by_name(contacts, ctx.repo.target_last_traced())
|
||
counts = {n.node: n.count for n in ctx.repo.heard_nodes() if n.node}
|
||
return [
|
||
ContactRow(
|
||
value=c,
|
||
name=c.name,
|
||
key=c.public_key or c.key_prefix or "",
|
||
node_type=c.node_type,
|
||
last_seen=c.last_seen,
|
||
count=contact_packets(c, counts),
|
||
last_traced=traced.get(c.name),
|
||
)
|
||
for c in contacts
|
||
]
|
||
|
||
# -- scripted (CLI) -------------------------------------------------------------
|
||
|
||
async def _run_cli(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||
"""Run one trace, persist it, and print the route and per-hop summary.
|
||
|
||
One transmission per invocation is a hard rule on the CLI — repeaters
|
||
penalize (and can blacklist) nodes that burst traffic — so scripted sampling
|
||
means invoking again (with your own pacing between runs).
|
||
|
||
Args:
|
||
ctx: Shared application context.
|
||
params: ``target`` (str), optional ``path``, and the injected ``_run_id``.
|
||
|
||
Returns:
|
||
A :class:`ToolResult` with the trace's outcome.
|
||
"""
|
||
return await _trace_once_cli(
|
||
ctx, params["_run_id"], target=params["target"], path_spec=params.get("path")
|
||
)
|
||
|
||
def register_cli(self, app: typer.Typer) -> None:
|
||
"""Register the ``trace`` subcommand.
|
||
|
||
Args:
|
||
app: The Typer application.
|
||
"""
|
||
from ..cli import run_tool_command
|
||
|
||
@app.command(
|
||
name=self.name, help="Run a single path trace to a target and show per-hop SNR"
|
||
)
|
||
def _trace(
|
||
target: str = typer.Option(..., "--target", "-t", help="Target node name/prefix"),
|
||
path: str | None = typer.Option(
|
||
None,
|
||
"--path",
|
||
# No ``-p`` short form: ``-p`` is the global ``--profile``, and
|
||
# ``_globals_first`` lifts a group option ahead of the subcommand wherever it
|
||
# is typed — so a leaf ``-p`` could never reach this option, only shadow it.
|
||
help="Force a route: comma-separated contact names/hex prefixes (e.g. 3d,f2,3d)",
|
||
),
|
||
) -> None:
|
||
tool_params: dict[str, Any] = {"target": target}
|
||
if path:
|
||
tool_params["path"] = path
|
||
run_tool_command(self, tool_params)
|
||
|
||
|
||
@register
|
||
class TracePathTool(Tool):
|
||
"""Walk a hand-composed route — no target — and see how far it carries."""
|
||
|
||
name = "trace-path"
|
||
title = "Trace path"
|
||
icon = "👣"
|
||
help = "Walk a route you compose, hop by hop"
|
||
category = "Explore"
|
||
order = 32
|
||
|
||
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
|
||
"""No parameters to gather — a path walk has no target to pick.
|
||
|
||
Args:
|
||
ctx: Shared application context.
|
||
|
||
Returns:
|
||
``{"live": True}`` — the route itself is composed on the screen.
|
||
"""
|
||
return {"live": True}
|
||
|
||
async def execute(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||
"""Run — without the outer run row on the live path (see :class:`TraceTool`).
|
||
|
||
Args:
|
||
ctx: Shared application context.
|
||
params: Parameters for this invocation.
|
||
|
||
Returns:
|
||
The :class:`ToolResult` from :meth:`run`.
|
||
"""
|
||
if params.get("live"):
|
||
return await self.run(ctx, params)
|
||
return await super().execute(ctx, params)
|
||
|
||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||
"""Open the live path-walk screen (menu) or run one persisted trace (CLI).
|
||
|
||
Args:
|
||
ctx: Shared application context.
|
||
params: ``live`` from the menu; or ``path`` (str, required) and the
|
||
injected ``_run_id`` from the CLI.
|
||
|
||
Returns:
|
||
A :class:`ToolResult` with the walk's outcome.
|
||
"""
|
||
if params.get("live"):
|
||
from ..ui.trace_screen import open_trace_path
|
||
|
||
traces = await open_trace_path(ctx)
|
||
return ToolResult(summary={"traces": traces})
|
||
return await _trace_once_cli(
|
||
ctx, params["_run_id"], target=PATH_TRACE_TARGET, path_spec=params["path"]
|
||
)
|
||
|
||
def register_cli(self, app: typer.Typer) -> None:
|
||
"""Register the ``trace-path`` subcommand.
|
||
|
||
Args:
|
||
app: The Typer application.
|
||
"""
|
||
from ..cli import run_tool_command
|
||
|
||
@app.command(
|
||
name=self.name,
|
||
help="Walk a composed route once (no target) and show per-hop SNR",
|
||
)
|
||
def _trace_path(
|
||
path: str = typer.Option(
|
||
...,
|
||
"--path",
|
||
# No ``-p`` short form: ``-p`` is the global ``--profile``, and
|
||
# ``_globals_first`` lifts a group option ahead of the subcommand wherever it
|
||
# is typed — so a leaf ``-p`` could never reach this option, only shadow it.
|
||
help="The whole walk: comma-separated contact names/hex prefixes "
|
||
"(must end within earshot of this node)",
|
||
),
|
||
) -> None:
|
||
run_tool_command(self, {"path": path})
|
||
|
||
|
||
async def _trace_once_cli(
|
||
ctx: AppContext, run_id: int, *, target: str, path_spec: str | None
|
||
) -> ToolResult:
|
||
"""Run one persisted trace and print the route and per-hop summary (CLI body).
|
||
|
||
Shared by both scripted trace commands: ``trace`` passes its target (path
|
||
optional — the device routes without one), ``trace-path`` passes the
|
||
:data:`~meshterm.core.models.PATH_TRACE_TARGET` sentinel with a required path.
|
||
|
||
Args:
|
||
ctx: Shared application context.
|
||
run_id: The already-open run row to record under.
|
||
target: The label the trace persists under.
|
||
path_spec: The forced route as typed (names/hex, comma-separated), if any.
|
||
|
||
Returns:
|
||
A :class:`ToolResult` with the trace's outcome.
|
||
"""
|
||
from ..ui.surface import TuiUi
|
||
|
||
is_walk = target == PATH_TRACE_TARGET
|
||
device = await ctx.device()
|
||
|
||
# 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)
|
||
# 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.
|
||
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 for a *target* trace: the device reuses the
|
||
# route it already learned (or floods if it has none). A path walk always has one.
|
||
path: str | None = None
|
||
if path_spec:
|
||
path = trace_runner.parse_trace_path(path_spec, contacts)
|
||
|
||
with ctx.ui.progress("trace") as progress:
|
||
task = progress.add_task("walking the path" if is_walk else f"tracing {target}", total=1)
|
||
result = await device.run_trace(target, path=path)
|
||
ctx.repo.record_trace(run_id, result)
|
||
progress.advance(task)
|
||
|
||
stats = TraceStats.from_traces(target, [result])
|
||
if isinstance(ctx.ui, TuiUi):
|
||
# The stats panel renders route + per-hop readings for the single trace (its
|
||
# medians collapse to the readings themselves).
|
||
ctx.ui.show(
|
||
stats_panel(
|
||
stats,
|
||
device_label,
|
||
resolve,
|
||
route=result if result.success else None,
|
||
device_hash=device_hash,
|
||
)
|
||
)
|
||
report = None
|
||
if not isinstance(ctx.ui, TuiUi):
|
||
report = _trace_report(result, target, path, device_label, resolve, device_hash)
|
||
|
||
summary: dict[str, Any] = {
|
||
"target": target,
|
||
"success": result.success,
|
||
"hops": result.hop_count if result.success else None,
|
||
"min_snr": result.min_snr,
|
||
"rtt_ms": result.round_trip_ms,
|
||
}
|
||
if path:
|
||
summary["path"] = path
|
||
via = f" via [brand]{path}[/brand]" if path else ""
|
||
if is_walk:
|
||
message = (
|
||
f"[ok]✓[/ok] the path came home{via}"
|
||
if result.success
|
||
else f"[err]✗[/err] no reply{via}"
|
||
)
|
||
elif result.success:
|
||
message = f"[ok]✓[/ok] traced [brand]{target}[/brand]{via}"
|
||
else:
|
||
message = f"[err]✗[/err] no reply from [brand]{target}[/brand]{via}"
|
||
# A trace that never came home is not a failure of the command — the radio did
|
||
# transmit and the walk did run. It is a walk with nothing to report, which is what
|
||
# NO_RESULT says, and it is the answer a script most often branches on.
|
||
return ToolResult(
|
||
summary=summary,
|
||
message=message,
|
||
report=report,
|
||
exit_code=exitcodes.OK if result.success else exitcodes.NO_RESULT,
|
||
)
|
||
|
||
|
||
def _trace_report(
|
||
result: TraceResult,
|
||
target: str,
|
||
path: str | None,
|
||
device_label: str,
|
||
resolve: Any,
|
||
device_hash: str | None,
|
||
) -> tuple:
|
||
"""State one trace: the walk's outcome, then its per-hop readings.
|
||
|
||
Two blocks. The first is what the walk *did* — one fact per line, ``route`` among them
|
||
as a drawn line, every node named and carrying the hash it was addressed by. The
|
||
second is a record per hop, and it names its two ends by that same **hash** rather than
|
||
repeating the names: the route line above is where the names are, and the hash is what
|
||
joins the two blocks (it is what carries a node's identity here, the way its colour
|
||
does on a screen). The document embeds the whole node at both ends instead, because a
|
||
structural join needs no key and each edge should be readable on its own.
|
||
|
||
Our own node is a hop like any other, named and hashed. The menu draws it as ``★``
|
||
because a reader never has to be told which node is theirs; this line is as often read
|
||
out of a file by somebody who was not at the prompt when it ran.
|
||
|
||
Args:
|
||
result: The trace that ran.
|
||
target: The label it was addressed to.
|
||
path: The forced route as hex, or ``None`` when the device routed it.
|
||
device_label: Our own node's name, at both ends of the walk.
|
||
resolve: Maps a hop hash to a friendly name when known.
|
||
device_hash: Our own public key, so our ends carry a hash like every other hop.
|
||
|
||
Returns:
|
||
The report's blocks.
|
||
"""
|
||
from ..ui import fields
|
||
from ..ui.fields import NodeRef
|
||
from ..ui.report import Facts, Listing
|
||
|
||
hash_bytes = result.path_hash_bytes
|
||
|
||
def short(value: str | None) -> str | None:
|
||
"""A hash at the width this trace addressed nodes by."""
|
||
if not value:
|
||
return None
|
||
raw = value.lower().removeprefix("0x")
|
||
return raw[: hash_bytes * 2] if hash_bytes else raw
|
||
|
||
def node(label: str) -> NodeRef:
|
||
"""One end of a hop as the shared node shape."""
|
||
if not label or label == device_label:
|
||
return NodeRef(name=device_label, hash=short(device_hash), is_self=True)
|
||
return NodeRef(name=resolve(label) or None, hash=short(label))
|
||
|
||
edges = result.edges(device_label)
|
||
route = None
|
||
if edges:
|
||
route = [node(edges[0].origin)] + [node(edge.destination) for edge in edges]
|
||
|
||
facts = Facts(
|
||
key="trace",
|
||
fields=(
|
||
fields.word("target", "target"),
|
||
fields.spec(),
|
||
fields.flag("success", "success"),
|
||
fields.integer("hops", "hops"),
|
||
fields.snr("min_snr_db", "min_snr_db"),
|
||
fields.decimal("rtt_ms", "rtt_ms", ".0f"),
|
||
# New surface on the machine face: the model has always carried the TX power
|
||
# the walk ran at, and the plain facts block has no room for it.
|
||
fields.hidden("tx_dbm"),
|
||
# What `hash` means on this walk's nodes, so a consumer can join two traces
|
||
# that addressed the same node at different widths.
|
||
fields.hidden("hash_bytes"),
|
||
fields.route("route", "route"),
|
||
),
|
||
values={
|
||
"target": target if target != PATH_TRACE_TARGET else None,
|
||
"path": path,
|
||
"success": result.success,
|
||
"hops": result.hop_count if result.success else None,
|
||
"min_snr_db": result.min_snr,
|
||
"rtt_ms": result.round_trip_ms,
|
||
"tx_dbm": result.tx_power,
|
||
"hash_bytes": hash_bytes,
|
||
"route": route,
|
||
},
|
||
)
|
||
hops = Listing(
|
||
key="edges",
|
||
columns=(
|
||
fields.integer("index", "HOP"),
|
||
fields.node("from", lanes=(("hash", "FROM"),)),
|
||
fields.node("to", lanes=(("hash", "TO"),)),
|
||
fields.snr("snr_db", "SNR_DB"),
|
||
),
|
||
rows=[
|
||
{
|
||
"index": edge.index,
|
||
"from": node(edge.origin),
|
||
"to": node(edge.destination),
|
||
"snr_db": edge.snr,
|
||
}
|
||
for edge in edges
|
||
],
|
||
)
|
||
return (facts, hops)
|
||
|
||
|
||
def _last_traced_by_name(
|
||
contacts: list[Contact], traced: dict[str, datetime]
|
||
) -> dict[str, datetime]:
|
||
"""Map each contact's name to when that node was last traced.
|
||
|
||
The Trace picker's ``TRACED`` lane and default sort read this. Stored trace targets are
|
||
filed exactly as the user addressed them — a contact name one day, a raw hex key prefix
|
||
another — so each target is folded onto the contact it names (by name, case-insensitively,
|
||
or as a prefix of a contact's public key) and the *latest* trace time wins, so a node
|
||
traced under both spellings still shows one honest "last traced" age. This is the inverse
|
||
of the picker's old recent-targets fold, kept per-contact rather than as a name list.
|
||
|
||
Args:
|
||
contacts: The device's current contacts.
|
||
traced: ``target → last-traced time`` from
|
||
:meth:`~meshterm.persistence.repository.Repository.target_last_traced`.
|
||
|
||
Returns:
|
||
``contact name → last-traced time`` for every contact the history can place.
|
||
"""
|
||
by_fold = {c.name.casefold(): c.name for c in contacts}
|
||
out: dict[str, datetime] = {}
|
||
|
||
def note(name: str, when: datetime) -> None:
|
||
current = out.get(name)
|
||
if current is None or when > current:
|
||
out[name] = when
|
||
|
||
for target, when in traced.items():
|
||
named = by_fold.get(target.casefold())
|
||
if named is not None:
|
||
note(named, when)
|
||
continue
|
||
needle = target.lower().removeprefix("0x")
|
||
# Only fold plausible key prefixes (≥2 bytes of hex) — a short hex-looking *name*
|
||
# like "ace" must not be mistaken for an address.
|
||
if len(needle) >= 4 and all(ch in _HEX_DIGITS for ch in needle):
|
||
for contact in contacts:
|
||
if (contact.public_key or "").lower().startswith(needle):
|
||
note(contact.name, when)
|
||
return out
|