Opening a screen re-read the same stable facts from the companion every time — the contacts table, self-info, the path-hash mode, the channel-slot probe. Over Bluetooth each is a round-trip, and on a busy node two dominate: get_contacts (~2.4 s with hundreds of contacts) and the channel probe (~2.6 s, it walks every slot index), so navigation stalled for seconds on each screen. Measured on hardware; and asyncio.gather makes it worse, not better — the meshcore BLE UART serializes commands, so concurrent reads collide. DeviceState (ctx.devstate) reads each fact once and serves it from cache. Contacts use stale-while-revalidate on a 90 s TTL — a read past it returns the cached list instantly and refreshes behind it, never blocking navigation on the slow call. Self-info, path-hash mode, and channel slots are held until an in-app write invalidates them: config apply drops self-info + mode, the channel editor drops channels, a reconnect resets everything. The hot screen openers (nodes, dashboard, chat, trace, TX, map, atlas, time machine, courier, watchtower) now read through the cache. The first screen opened in a session still pays the one read — shared across every screen — and every open after is instant. Transport-agnostic, so serial benefits too; harmless on --mock. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
87 lines
3.4 KiB
Python
87 lines
3.4 KiB
Python
"""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"
|
|
title = "Nodes"
|
|
icon = "👥"
|
|
help = "List this node and known contacts (last heard, packets, type, key)"
|
|
category = "Mesh"
|
|
order = 10 # right under the Dashboard: the "who's out there" view
|
|
|
|
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.surface import TuiUi
|
|
from ..ui.widgets import NodesSort, nodes_table
|
|
|
|
# Read through the session cache: on a busy node the contacts table is a slow
|
|
# round-trip, and re-fetching it (plus self-info) on every menu visit is a chief cause
|
|
# of sluggish navigation. The cache holds them for the session and refreshes contacts
|
|
# in the background (see :class:`~meshterm.services.device_state.DeviceState`).
|
|
info = await ctx.devstate.self_info()
|
|
contacts = await ctx.devstate.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 ctx.devstate.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
|
|
|
|
# Overheard-packet tallies from background monitoring, keyed by node hash; a
|
|
# contact we've never passively overheard simply 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 "")
|
|
sort = NodesSort.from_name(str(params.get("sort") or "name"))
|
|
|
|
# In the menu, hand the list to the interactive screen so the arrows re-sort it live;
|
|
# on the scripted CLI, render the table once in the requested order.
|
|
if isinstance(ctx.ui, TuiUi):
|
|
from ..ui.nodes_screen import open_nodes
|
|
|
|
await open_nodes(ctx, self_name, self_key, contacts, prefix_bytes, counts, sort)
|
|
else:
|
|
ctx.ui.show(nodes_table(self_name, self_key, contacts, prefix_bytes, counts, sort))
|
|
|
|
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(
|
|
sort: str = typer.Option(
|
|
"name", "--sort", "-s", help="Order contacts by: name, heard, packets"
|
|
),
|
|
) -> None:
|
|
run_tool_command(self, {"sort": sort})
|