The CLI had grown the menu's manners -- colour, boxes, section headings, glyph columns, a closing check mark -- and every one of them is in the way of the thing it is actually for. It now prints like a standard Unix utility: plain text, no escape sequences at all, no frames, one record per line, and nothing wrapped, so a long line runs off the right instead of folding its fields onto a second one. meshterm/ui/script.py is the whole vocabulary. Listings are an uppercase header and space-aligned records; key/value blocks are sysctl's shape, and a `get` prints the bare value alone. Names are quoted wherever they share a line, because a name can hold a space or a comma and a bare one is not a field. A route reads "Origin" (3d),"Relay" (f2),"Us" (a1) -- the hash outside the quotes, our own node a hop like any other rather than a star, since the hash is what carries the identity that colour carries on screen. Times are absolute local ISO-8601 and "-" is the one token for absent. The return value is now half the report: 0 ok, 1 failure, 2 usage, 3 no device, 4 the device was reached and the operation failed, 5 nothing to report. That last one is the point -- a command that ran fine and found nothing prints nothing and says so in $?, so a script never has to count output lines to tell an empty mesh from a full one. Errors and progress moved to stderr, and an expected failure no longer arrives as a traceback. Two things lose their scripted face rather than get a degraded one. The map is a picture whose nodes are told apart by colour: monochrome it would be unreadable, coloured it could not be piped anywhere useful, and the nodes themselves are still in `contacts`. The QR codes are a second rendering of a link the output already prints. ui.ack joins ui.note to draw the line the change needed: a note is output, an acknowledgement is reassurance a person wants and a script must filter out, so the menu prints it and the CLI drops it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
287 lines
11 KiB
Python
287 lines
11 KiB
Python
"""The ``info`` tool: show the connected companion device's identity and radio config."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
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 = "This node"
|
|
order = 10 # what it is, before anything that changes it
|
|
|
|
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 rich.console import Group
|
|
|
|
from ..ui.config_editor import cached_snapshot, config_table, has_pin
|
|
|
|
device = await ctx.device()
|
|
snapshot = await cached_snapshot(ctx, device)
|
|
|
|
# On the CLI this is the *status* report and nothing else: how the radio is doing
|
|
# right now, one fact per line. What it is set to is `config show`'s answer, in
|
|
# the very keys `config get`/`config set` take, so printing the settings here as
|
|
# well would be the same table twice under two different spellings.
|
|
session = getattr(ctx.ui, "session", None)
|
|
if session is None:
|
|
from ..ui import script
|
|
|
|
ctx.ui.show(script.pairs(await status_pairs(device, snapshot)))
|
|
return ToolResult(summary={"name": snapshot.get("name")})
|
|
|
|
from ..ui.device_info_screen import DeviceInfoScreen
|
|
|
|
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. The panel is read
|
|
# once; only the table differs between the two states, and it is cheap to rebuild.
|
|
panel = await _status_panel(device, snapshot)
|
|
|
|
def page(reveal_pin: bool) -> Group:
|
|
return Group(panel, Text(""), config_table(snapshot, custom, reveal_pin=reveal_pin))
|
|
|
|
# The page is its own screen because it has a key of its own: the pairing PIN is
|
|
# masked until ``p`` uncovers it.
|
|
await session.run_screen(
|
|
DeviceInfoScreen(session, page, title=self.title, conceals=has_pin(snapshot))
|
|
)
|
|
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 _read_status(device: Device) -> dict[str, Any]:
|
|
"""Read every live status value the firmware will answer for, best-effort.
|
|
|
|
The one place the device is asked. Both faces build from what it returns: the menu's
|
|
panel composes prose rows out of these values, the CLI prints them one fact per line
|
|
(see :func:`_status_panel` and :func:`status_pairs`). Every read is optional —
|
|
firmware predating a query simply contributes nothing — so this works on any device.
|
|
|
|
Args:
|
|
device: The connected device to query.
|
|
|
|
Returns:
|
|
The merged raw readings: ``info``, ``battery``, ``stats`` and ``clock``.
|
|
"""
|
|
return {
|
|
"info": await _try(device.get_device_info) or {},
|
|
"battery": await _try(device.get_battery) or {},
|
|
"stats": await _try(device.get_stats) or {},
|
|
"clock": await _try(device.get_time),
|
|
}
|
|
|
|
|
|
async def _status_panel(device: Device, snapshot: dict) -> Panel:
|
|
"""Build the live status panel: role, firmware, battery, clock, and statistics.
|
|
|
|
The menu's face of :func:`_read_status` — each row a short phrase rather than an
|
|
atomic value, because a person reads "-110 dBm noise floor · last RSSI -62 dBm" in one
|
|
go where a script wants the three numbers apart.
|
|
|
|
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]] = []
|
|
readings = await _read_status(device)
|
|
info, battery, stats = readings["info"], readings["battery"], readings["stats"]
|
|
|
|
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")))
|
|
|
|
if info.get("model"):
|
|
rows.append(("model", Text(str(info["model"]))))
|
|
firmware = _firmware(info)
|
|
if firmware:
|
|
rows.append(("firmware", Text(firmware)))
|
|
|
|
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 = readings["clock"]
|
|
if clock:
|
|
stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(clock))
|
|
rows.append(("clock", Text.assemble(stamp, (f" {_drift(clock)}", "muted"))))
|
|
|
|
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)
|
|
|
|
|
|
def _firmware(info: dict) -> str:
|
|
"""The firmware version and build joined, or ``""`` when neither was reported."""
|
|
return " ".join(str(info[k]) for k in ("ver", "fw_build") if info.get(k))
|
|
|
|
|
|
async def status_pairs(device: Device, snapshot: dict) -> list[tuple[str, str]]:
|
|
"""The device's live status as scripted key/value facts.
|
|
|
|
The CLI's face of :func:`_read_status`. Where the panel writes a phrase per row, this
|
|
writes one value per key and puts the unit in the key — ``battery_v``, ``uptime_s``,
|
|
``noise_floor_dbm`` — so nothing has to be pulled back out of prose. A reading the
|
|
firmware did not answer for is absent rather than :data:`~meshterm.ui.script.NONE`:
|
|
these are optional queries, and a missing key says "this device does not report it"
|
|
where a dash would claim it reported nothing.
|
|
|
|
Args:
|
|
device: The connected device to query.
|
|
snapshot: The already-built settings snapshot (for role and identity).
|
|
|
|
Returns:
|
|
``(key, value)`` pairs in reading order.
|
|
"""
|
|
readings = await _read_status(device)
|
|
info, battery, stats = readings["info"], readings["battery"], readings["stats"]
|
|
pairs: list[tuple[str, str]] = []
|
|
|
|
def add(key: str, value: Any) -> None:
|
|
"""Record one fact, skipping a reading the firmware did not answer for."""
|
|
if value is not None and value != "":
|
|
pairs.append((key, str(value)))
|
|
|
|
# Identity first: which radio this is. Everything else it *can be set to* is
|
|
# `config show`'s answer, not this one. The name is not quoted here the way it is in
|
|
# a listing: a key/value line has exactly two fields, so the value is the rest of the
|
|
# line whatever it contains, and quotes would only be something to strip back off.
|
|
add("name", snapshot.get("name"))
|
|
add("public_key", str(snapshot.get("public_key") or "").lower() or None)
|
|
adv_type = snapshot.get("adv_type")
|
|
add("role", _ROLES.get(int(adv_type), f"type {adv_type}") if adv_type is not None else None)
|
|
|
|
add("model", info.get("model"))
|
|
add("firmware", _firmware(info) or None)
|
|
|
|
level = battery.get("level")
|
|
add("battery_v", f"{int(level) / 1000:.2f}" if level else None)
|
|
if battery.get("total_kb"):
|
|
add("storage_used_kb", battery.get("used_kb", 0))
|
|
add("storage_total_kb", battery["total_kb"])
|
|
|
|
clock = readings["clock"]
|
|
if clock:
|
|
add("clock", datetime.fromtimestamp(clock, tz=timezone.utc).astimezone().isoformat())
|
|
add("clock_drift_s", clock - int(time.time()))
|
|
|
|
add("uptime_s", stats.get("uptime_secs"))
|
|
add("noise_floor_dbm", stats.get("noise_floor"))
|
|
add("last_rssi_dbm", stats.get("last_rssi"))
|
|
add("last_snr_db", f"{stats['last_snr']:+.1f}" if stats.get("last_snr") is not None else None)
|
|
add("tx_air_s", stats.get("tx_air_secs"))
|
|
add("rx_air_s", stats.get("rx_air_secs"))
|
|
add("packets_sent", stats.get("sent"))
|
|
add("packets_received", stats.get("recv"))
|
|
add("receive_errors", stats.get("recv_errors"))
|
|
return pairs
|
|
|
|
|
|
async def _try(read) -> Any | None:
|
|
"""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)
|