Store channel messages under a slot-independent channel identity (sha256 of the effective secret) instead of the raw slot index, so reordering or re-keying slots no longer scrambles history. DB schema v3->v4 adds a channel_id column with an idempotent migration and a lazy backfill that maps legacy rows to the channel currently in each slot. Recognize the firmware's fixed-key default "Public" channel as public: add DEFAULT_PUBLIC_SECRET / KNOWN_PUBLIC_SECRETS and split is_name_derived (key reproducible from the name, so it can be re-derived on write) from is_public_channel (shared meshwide). The default channel no longer mislabels itself as private, and its well-known key is preserved on edit/reorder. Lead each chat picker row with a glyph: the channel's openness marker (# name-derived, globe fixed-key public, lock private) or, for a direct chat, a speech balloon once we've exchanged messages and a person before. A shared channel_glyph() keeps the channel manager and the picker in sync. Round out the messaging UX: live callable row titles and plain-text filtering in the TUI select screen, plus assorted device-picker, monitor, route-map, and config-editor menu refinements. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
150 lines
5.5 KiB
Python
150 lines
5.5 KiB
Python
"""The ``devices`` tool: enumerate serial companion devices and (in the menu) pick one.
|
|
|
|
Discovery never opens the radio — it only lists what is attached. On the CLI this is a
|
|
read-only inventory; selection there happens by passing ``--port`` to a command, which is
|
|
remembered after it connects. In the interactive menu this tool additionally offers the
|
|
device picker to change the session's active device.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import typer
|
|
from rich.table import Table
|
|
|
|
from ..context import AppContext
|
|
from ..core.discovery import discover_devices
|
|
from .base import Tool, ToolResult, register
|
|
|
|
#: "LoRa?" table cell per discovery confidence tier: a bare serial bridge is only a "maybe",
|
|
#: since the same chip shows up on plenty of non-LoRa hardware.
|
|
_LORA_CELL: dict[str, str] = {
|
|
"board": "[ok]yes[/ok]",
|
|
"bridge": "[warn]maybe[/warn]",
|
|
"unknown": "[muted]—[/muted]",
|
|
}
|
|
|
|
|
|
@register
|
|
class DevicesTool(Tool):
|
|
"""List attached serial devices, flag likely LoRa hardware, mark the default."""
|
|
|
|
name = "devices"
|
|
help = "List attached serial devices and pick the companion to use."
|
|
category = "Device"
|
|
order = 5
|
|
|
|
async def prompt_params(self, ctx: AppContext) -> dict[str, Any]:
|
|
"""In the menu, show the device picker so the user can change the active device.
|
|
|
|
The picker itself carries a "Back" row, so it reads like every other menu screen
|
|
rather than a bare yes/no confirmation.
|
|
|
|
Args:
|
|
ctx: Shared application context.
|
|
|
|
Returns:
|
|
Always ``{}`` — the table in :meth:`run` reflects the (possibly changed) device.
|
|
"""
|
|
if ctx.mock:
|
|
return {}
|
|
|
|
from ..ui.device_picker import prompt_device
|
|
|
|
devices = discover_devices()
|
|
chosen = await prompt_device(
|
|
ctx.ui, devices, ctx.device_store.load(), cancel_label="Back"
|
|
)
|
|
if chosen is not None:
|
|
ctx.selected_device = chosen
|
|
ctx.port_override = chosen.port
|
|
ctx.ui.note(
|
|
f"[ok]●[/ok] active device set to [accent]{chosen.label}[/accent] "
|
|
"[muted](remembered once it connects)[/muted]"
|
|
)
|
|
return {}
|
|
|
|
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
|
"""Enumerate serial devices and render them as a table.
|
|
|
|
Args:
|
|
ctx: Shared application context.
|
|
params: Unused beyond the menu's selection bookkeeping.
|
|
|
|
Returns:
|
|
A :class:`ToolResult` summarizing how many devices were found.
|
|
"""
|
|
devices = discover_devices()
|
|
remembered = ctx.device_store.load()
|
|
active = ctx.selected_device
|
|
active_port = ctx.port_override or (active.port if active else None)
|
|
|
|
if ctx.json_output:
|
|
import json
|
|
|
|
payload = [
|
|
{
|
|
"port": d.port,
|
|
"label": d.label,
|
|
"vendor": d.vendor_label,
|
|
"likely_lora": d.is_likely_lora,
|
|
"confidence": d.confidence,
|
|
"serial_number": d.serial_number,
|
|
"stable_id": d.stable_id,
|
|
"remembered": remembered is not None and remembered.matches(d),
|
|
"active": d.port == active_port,
|
|
}
|
|
for d in devices
|
|
]
|
|
ctx.console.print_json(json.dumps(payload))
|
|
return ToolResult(summary={"count": len(devices)})
|
|
|
|
if not devices:
|
|
ctx.ui.note(
|
|
"[warn]No serial devices detected.[/warn] "
|
|
"Connect a companion device, or use [accent]--mock[/accent] for the simulator."
|
|
)
|
|
return ToolResult(summary={"count": 0})
|
|
|
|
table = Table(title="Serial devices", border_style="muted", expand=False)
|
|
table.add_column("", style="ok", no_wrap=True) # active/remembered markers
|
|
table.add_column("Port", style="brand")
|
|
table.add_column("Device")
|
|
table.add_column("Vendor", style="muted")
|
|
table.add_column("LoRa?", justify="center")
|
|
table.add_column("Serial", style="muted")
|
|
for d in devices:
|
|
is_remembered = remembered is not None and remembered.matches(d)
|
|
is_active = d.port == active_port
|
|
marker = ("●" if is_active else "") + ("★" if is_remembered else "")
|
|
device_name = d.product or d.description or "[muted]?[/muted]"
|
|
if is_remembered and remembered.node_name: # the mesh name learned on connect
|
|
device_name = f"{remembered.node_name} [muted]({device_name})[/muted]"
|
|
table.add_row(
|
|
marker,
|
|
d.port,
|
|
device_name,
|
|
d.vendor_label or "[muted]?[/muted]",
|
|
_LORA_CELL[d.confidence],
|
|
d.serial_number or "[muted]—[/muted]",
|
|
)
|
|
ctx.ui.show(table)
|
|
ctx.ui.note(
|
|
"[muted]● active ★ remembered default. "
|
|
"Pass --port <PORT> to select on the CLI.[/muted]"
|
|
)
|
|
return ToolResult(summary={"count": len(devices)})
|
|
|
|
def register_cli(self, app: typer.Typer) -> None:
|
|
"""Register the ``devices`` subcommand (inventory only; no connection).
|
|
|
|
Args:
|
|
app: The Typer application.
|
|
"""
|
|
from ..cli import run_tool_command
|
|
|
|
@app.command(name=self.name, help=self.help)
|
|
def _devices() -> None:
|
|
run_tool_command(self, {})
|