Files
MeshTerm/meshterm/tools/contacts.py
T
Jean-Pierre MartineauandClaude Opus 5.5 6b90e0505b Docstrings and comments of meshterm/tools are in ASD-STE100
The same rewrite as for the core package. No help= string, epilog, or
other UX text changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:48:44 -04:00

183 lines
7.9 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The ``contacts`` tool: list the contacts that this node knows.
The menu opens the Contacts screen, which you can sort and which starts with our node.
The CLI prints only the contact list, because our node is not a contact.
``meshterm info`` reports our node, with much more detail than a row can hold.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import typer
from ..context import AppContext
from ..core import exitcodes
from ..core.models import NODE_TYPE_LABELS
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..core.models import Contact
from ..ui.report import Listing
@register
class ContactsTool(Tool):
"""List this node and its known contacts, each with its hash."""
name = "contacts"
title = "Contacts"
icon = "👥"
help = "Known contacts — last heard, packets, type"
category = "Message"
order = 40 # the address book from which the three tools above select a recipient
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Query the device and render the contact list.
Args:
ctx: The shared application context.
params: Not used.
Returns:
A :class:`ToolResult` with a summary of the number of known contacts.
"""
from ..ui.surface import TuiUi
from ..ui.widgets import ContactsSort, ordered_contacts
# Read through the session cache. On a busy node, the contacts table is a slow
# round trip. If MeshTerm reads it again (and the self info) at each visit to the
# menu, that is a main cause of slow navigation. The cache keeps them for the session
# and reads the contacts again in the background (refer to
# :class:`~meshterm.services.device_state.DeviceState`).
info = await ctx.devstate.self_info()
contacts = await ctx.devstate.contacts()
# The hash (the first part of a key, which a forced path uses as the address) is
# ``mode + 1`` bytes wide. Light it in each key. The mode is an optional read. Thus
# if the firmware does not report it, light nothing.
try:
mode = await ctx.devstate.path_hash_mode()
except Exception: # noqa: BLE001 - an optional read. If it is absent, nothing is lit.
mode = None
prefix_bytes = (mode + 1) if isinstance(mode, int) and 0 <= mode <= 3 else 0
# The counts of overheard packets from the background monitor, keyed by node hash.
# A contact that we never overheard passively has no entry.
counts = {n.node: n.count for n in ctx.repo.heard_nodes() if n.node}
self_name = str(info.get("name") or "this node")
self_key = str(info.get("public_key") or "")
# The list opens with the most recently heard node first. The list must answer "who
# is out there now", and an order with the newest first answers that immediately.
# A list from A to Z does not. Then the Ctrl+arrows sort the list again (and
# ``--sort`` sets the order of the CLI).
sort_name = str(params.get("sort") or "heard")
# In the menu, give the list to the interactive screen, so that the Ctrl+arrows sort
# it again while it is open. Their ring goes through the four columns of the shared
# contact list (also the hash). On the scripted CLI, print the plain listing one
# time, in the requested order.
if isinstance(ctx.ui, TuiUi):
from ..ui.contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING
from ..ui.contacts_screen import open_contacts
sort = ContactsSort.from_name(sort_name, SORT_COLUMNS, SORT_OPENS_ASCENDING)
await open_contacts(ctx, self_name, self_key, contacts, prefix_bytes, counts, sort)
return ToolResult(summary={"contacts": len(contacts)})
sort = ContactsSort.from_name(sort_name)
listing = _listing(ordered_contacts(contacts, counts, sort), counts, prefix_bytes)
return ToolResult(
summary={"contacts": len(contacts)},
report=(listing,),
exit_code=exitcodes.OK if contacts else exitcodes.NO_RESULT,
)
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``contacts`` subcommand.
Args:
app: The Typer application.
"""
from ..cli import run_tool_command
from ..ui.widgets import ContactsSort
@app.command(name=self.name, help=self.help)
def _contacts(
sort: str = typer.Option(
"heard", "--sort", "-s", help="Order contacts by: heard, name, packets"
),
) -> None:
# For an unknown name, `ContactsSort.from_name` uses the first column. This is
# on purpose, so that a saved preference from an older build cannot block the
# list of the menu. On the command line, the same silence hides a typing error:
# the caller asked for one order and got another, and nothing said so.
if sort not in ContactsSort.names():
choices = ", ".join(ContactsSort.names())
raise typer.BadParameter(f"--sort must be one of: {choices} (got {sort!r})")
run_tool_command(self, {"sort": sort})
def _listing(contacts: list[Contact], counts: dict[str, int], prefix_bytes: int) -> Listing:
"""The contact list, stated one time for the two faces.
``TYPE`` is the advertised role of the node, in words. The menu draws a coloured glyph
there, but a monochrome ``▲`` must have a legend, and the CLI has no legends.
``HEARD`` is a relative age, because the user opens this listing to ask "recently?".
``HASH`` is the token that ``--path`` and ``--to`` accept. Before, the user had to cut
it out of ``KEY`` by hand. ``LOCATION`` is a fact that the model always had, but no
column had space for it. ``KEY`` stays full and last. Thus it goes off the right side
with no harm and is never cut: a caller cannot give a truncated key back to a command.
Args:
contacts: The contacts, already in the requested order.
counts: The counts of overheard packets, keyed by node id (from the passive
monitor).
prefix_bytes: The path hash width of the device. Because of it, ``HASH`` is the
token by which a forced path addresses this node, not a random part of the
key.
Returns:
The listing.
"""
from ..ui import fields
from ..ui.report import Listing
from ..ui.widgets import contact_packets
rows = []
for contact in contacts:
key = (contact.public_key or "").lower()
rows.append(
{
"node": fields.NodeRef(
name=contact.name,
key=key or None,
hash=(key or contact.key_prefix.lower())[: prefix_bytes * 2] or None,
type=NODE_TYPE_LABELS.get(contact.node_type),
),
"heard_at": contact.last_seen,
"packets": contact_packets(contact, counts),
"position": (
fields.Position(contact.lat, contact.lon) if contact.has_location else None
),
}
)
return Listing(
key="contacts",
columns=(
fields.node(
"node",
lanes=(("name", "NAME"), ("type", "TYPE"), ("hash", "HASH"), ("key", "KEY")),
),
fields.when("heard_at", "HEARD", absent="never"),
fields.integer("packets", "PKTS"),
fields.position(),
),
rows=rows,
# The four lanes of the node are not together. What the user looks for (who, what,
# how recently, how much) comes first, and the two long hex fields go to the right.
order=("NAME", "TYPE", "HEARD", "PKTS", "HASH", "LOCATION", "KEY"),
)