The menu asks "What would you like to do?" and then grouped its rows by subject, which is not an answer to that. Mesh had grown to seven of the nineteen rows — live watching, passive browsing, and one transmit action in the same bucket — and Tools said nothing at all, since everything in the menu is a tool. So each section now names a doing, and holds what shares it: Message Chat · Channels · Courier · Contacts Watch Dashboard · Live feed · Watchtower · Time machine Explore Map · Mesh walk · Trace target · Trace path · Trophy case This node Device info · Device configuration · Device actions · Send advert Other nodes Repeater admin · TX optimize Four rows moved for a reason. Contacts is the address book the three features above it pick a recipient from, not a mesh view. Time machine is the past tense of the Dashboard, and was stranded in the grab-bag. Send advert is your own node announcing itself — a device action, filed with the mesh views only because it was about the mesh. And Repeater admin and TX optimize are the same job — someone else's radio, over the mesh, with credentials — which the old Device-management/Tools split hid. The two walks join the topology views they walk over, with the boards that score them underneath: see a node, walk to it, get scored. Nothing is renamed (a menu row still names the screen it opens) and no section is now larger than five rows or smaller than two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
176 lines
6.1 KiB
Python
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 = "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 ..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)
|