Files
MeshTerm/meshterm/tools/info.py
T
Jean-Pierre MartineauandClaude Fable 5 f69d1785b7 Performance sweep: cache the render funnel, the queries, the graphs
Every repaint re-rasterized every row and re-ran every layout; every
screen open re-scanned the whole history. Now work is done once per
change instead of once per frame:

Render pipeline — a content-keyed ANSI cache in render_to_ansi (every
Text/str row app-wide), memoized corner glow, and panel-wrap memos for
the base frame and stacked dialogs, all dropped on a platform switch or
emoji recalibration. Idle-frame compose falls 5-100x per screen
(contacts 5.6->0.1 ms, record card 8.5->0.07 ms, trace 7.6->0.6 ms).

Repository — an observed_at index turns every since-window query into a
range walk (recent_observations 7.5->1 ms); heard_nodes and first_seen
aggregate in one streaming pass by ISO-string comparison instead of
hydrating dataclasses and sorting; node_names groups in SQL (NOT
INDEXED past a slow planner choice).

Per packet — livefeed no longer repaints on events that drew nothing,
the channel list's stats TTL rises above the tick so the repository is
not queried per frame, watch-store alerts batch through the throttled
flush instead of a full-state disk write per new node, and the header's
360-bucket histogram/flags tuples are memoized per minute and packet.

Per open — the resolvers, topology canonical()/display_name(), and
Yen's route search memoize over their frozen inputs; timemachine, chat,
and livefeed drop duplicate queries; the config editor, device actions,
info, advert, and tx-optimize reuse the devstate session cache instead
of re-asking the radio.

Per frame, per screen — dashboard folds five window scans into one
digest; walk caches everything derived from its frozen graph (the
onward-count quadratic included); packet viewer, record dialog, trophy
rows, trace stats/log, and the node-detail route fan memoize on their
frozen inputs, invalidated by revision counters where the input can
move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:14:53 -04:00

176 lines
6.1 KiB
Python

"""The ``info`` tool: show the connected companion device's identity and radio config."""
from __future__ import annotations
import time
from typing import Any, Optional
import typer
from rich.panel import Panel
from rich.text import Text
from ..context import AppContext
from ..core.connection import Device
from .base import Tool, ToolResult, register
#: MeshCore advert-type byte -> human-readable node role.
_ROLES = {1: "companion", 2: "repeater", 3: "room server", 4: "sensor"}
@register
class InfoTool(Tool):
"""Display device identity and radio configuration.
The known-contacts list lives in the separate ``nodes`` tool.
"""
name = "info"
title = "Device info"
icon = "📋"
help = "Show device status, identity, and radio config"
category = "Device management"
order = 11 # after Send advert (10), which slots in behind Device actions
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Query the device and render its live status plus its full configuration.
Args:
ctx: Shared application context.
params: Unused.
Returns:
A :class:`ToolResult` summarizing the device name.
"""
from ..ui.config_editor import cached_snapshot, config_table
device = await ctx.device()
snapshot = await cached_snapshot(ctx, device)
custom = await device.get_custom_vars()
# A live status panel (role, firmware, battery, clock, radio/packet statistics)
# above every current setting with a short explanation of each.
ctx.ui.show(await _status_panel(device, snapshot), Text(""), config_table(snapshot, custom))
return ToolResult(summary={"name": snapshot.get("name")})
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``info`` subcommand.
Args:
app: The Typer application.
"""
from ..cli import run_tool_command
@app.command(name=self.name, help=self.help)
def _info() -> None:
run_tool_command(self, {})
# -- the status panel -----------------------------------------------------------
async def _status_panel(device: Device, snapshot: dict) -> Panel:
"""Build the live status panel: role, firmware, battery, clock, and statistics.
Every read beyond the snapshot is best-effort — firmware predating a query (battery,
clock, stats) simply contributes no row — so the panel renders on any device.
Args:
device: The connected device to query.
snapshot: The already-built settings snapshot (for role and identity fields).
Returns:
A Rich :class:`Panel` of label/value status rows.
"""
rows: list[tuple[str, Text]] = []
adv_type = snapshot.get("adv_type")
if adv_type is not None:
role = _ROLES.get(int(adv_type), f"type {adv_type}")
rows.append(("role", Text(role, style="brand")))
info = await _try(device.get_device_info) or {}
if info.get("model"):
rows.append(("model", Text(str(info["model"]))))
firmware = " ".join(str(info[k]) for k in ("ver", "fw_build") if info.get(k))
if firmware:
rows.append(("firmware", Text(firmware)))
battery = await _try(device.get_battery) or {}
level = battery.get("level")
if level:
rows.append(("battery", Text(f"{int(level) / 1000:.2f} V")))
if battery.get("total_kb"):
rows.append(
("storage", Text(f"{battery.get('used_kb', 0)} / {battery['total_kb']} kB"))
)
clock = await _try(device.get_time)
if clock:
stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(clock))
rows.append(("clock", Text.assemble(stamp, (f" {_drift(clock)}", "muted"))))
stats = await _try(device.get_stats) or {}
if stats.get("uptime_secs") is not None:
rows.append(("uptime", Text(_uptime(int(stats["uptime_secs"])))))
if stats.get("noise_floor") is not None:
parts = Text(f"{stats['noise_floor']} dBm noise floor")
if stats.get("last_rssi") is not None:
parts.append(f" · last RSSI {stats['last_rssi']} dBm", style="muted")
if stats.get("last_snr") is not None:
parts.append(f" · last SNR {stats['last_snr']:+.1f} dB", style="muted")
rows.append(("radio", parts))
if stats.get("tx_air_secs") is not None:
rows.append(
("airtime", Text(f"TX {stats['tx_air_secs']} s · RX {stats.get('rx_air_secs', 0)} s"))
)
if stats.get("recv") is not None:
packets = Text(f"{stats.get('sent', 0)} sent · {stats['recv']} received")
if stats.get("recv_errors"):
packets.append(f" · {stats['recv_errors']} receive errors", style="warn")
rows.append(("packets", packets))
width = max((len(label) for label, _ in rows), default=0)
body = Text()
for i, (label, value) in enumerate(rows):
if i:
body.append("\n")
body.append(f"{label.ljust(width)} ", style="muted")
body.append_text(value)
if not rows:
body = Text("no status reported", style="muted")
return Panel(body, title="[accent]Status[/accent]", border_style="accent", expand=False)
async def _try(read) -> Optional[Any]:
"""Await a device read, returning ``None`` when the firmware doesn't support it."""
try:
return await read()
except Exception: # noqa: BLE001 - optional read; absence is acceptable
return None
def _drift(device_epoch: int) -> str:
"""Describe the device clock's drift against this computer's clock."""
drift = device_epoch - int(time.time())
if abs(drift) < 2:
return "(in sync)"
direction = "ahead" if drift > 0 else "behind"
return f"({abs(drift)} s {direction})"
def _uptime(secs: int) -> str:
"""Render an uptime as ``3d 2h 41m`` (seconds shown only under a minute)."""
if secs < 60:
return f"{secs} s"
days, rem = divmod(secs, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
parts = []
if days:
parts.append(f"{days}d")
if hours:
parts.append(f"{hours}h")
parts.append(f"{minutes}m")
return " ".join(parts)