Files
MeshTerm/meshtools/tools/config.py
T
Jean-Pierre MartineauandClaude Opus 4.8 a03dedb09c Add message delivery retry, channel capacity discovery, and a compose byte budget
Three interrelated improvements to the chat/channel experience:

Outgoing byte budget & unified transcript (ui/chat.py, ui/tui/prompt.py):
- Cap outgoing messages at MeshCore's UTF-8 limits (150B direct, 130B
  channel) and block over-limit sends, keeping the buffer intact to trim.
- Show a live `used/limit` gauge in the compose bar whose used count steps
  green→yellow→orange→red by bytes remaining, with truer yellow/orange hues.
- Color the overflow characters red in the compose line (multibyte-safe).
- Render direct chats with the same grouped, per-sender layout as channels
  (headers, day dividers) instead of one line per message.

Direct-message delivery retry (services/chat_service.py, persistence,
core/models.py, tools/chat.py, ui/tui/session.py):
- Track outbound acked state (None awaiting / True / False) with a persisted
  row_id, add resend_direct + update_chat_ack, and wire Ctrl-R to retry the
  latest unacknowledged message in place with ⏳/✅/❌ delivery glyphs.

Channel slot capacity discovery (core/channels.py, core/connection.py,
ui/channels.py, tools):
- Discover the firmware's real channel-slot count via a bounded read-only
  probe (Device.channel_capacity) instead of assuming 8; show used/total in
  the manager and refresh the chat slot→identity cache after any mutation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:12:25 -04:00

355 lines
14 KiB
Python

"""The ``config`` tool: view and change every device configuration value.
Interactively it launches a full editor (see :mod:`meshtools.ui.config_editor`); on the
CLI it exposes generic key/value subcommands plus backup/restore, channels, custom vars,
and a gated set of destructive operations. Both paths funnel into :meth:`ConfigTool.run`,
which executes a list of operation tuples so every change is logged through the run record.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
import typer
from rich.table import Table
from ..context import AppContext
from ..core.channels import CHANNEL_SLOT_PROBE_CAP
from ..core.config_io import backup_config, plan_restore, read_backup
from ..core.connection import Device
from ..core.device_config import (
build_snapshot,
format_value,
get_spec,
parse_value,
)
from .base import Tool, ToolResult, register
@register
class ConfigTool(Tool):
"""View and edit the connected device's full configuration."""
name = "config"
help = "View and change device configuration (name, radio, experimental, ...)."
category = "Device"
order = 8
async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]:
"""Launch the interactive editor and collect the operations to perform.
Args:
ctx: Shared application context.
Returns:
``{"ops": [...]}`` to execute, or ``None`` if the user cancelled.
"""
from ..ui.config_editor import edit_config
ops = await edit_config(ctx)
if not ops:
return None
return {"ops": ops}
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Execute a list of configuration operations against the device.
Args:
ctx: Shared application context.
params: ``ops`` — a list of operation tuples (see module docstring).
Returns:
A :class:`ToolResult` summarizing how many values changed.
"""
device = await ctx.device()
ops: list[tuple] = list(params.get("ops") or [])
snapshot = await build_snapshot(device)
changes, artifacts = await apply_ops(ctx, device, snapshot, ops)
message = f"[ok]✓[/ok] applied [brand]{changes}[/brand] change(s)" if changes else None
return ToolResult(summary={"changes": changes}, message=message, artifacts=artifacts)
# -- CLI --------------------------------------------------------------------
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``config`` subcommand group.
Args:
app: The Typer application.
"""
from ..cli import run_tool_command
config_app = typer.Typer(
help=self.help, no_args_is_help=False, rich_markup_mode="rich"
)
@config_app.callback(invoke_without_command=True)
def _root(ctx: typer.Context) -> None:
"""Show the configuration when no subcommand is given."""
if ctx.invoked_subcommand is None:
run_tool_command(self, {"ops": [("show",)]})
@config_app.command("show", help="Show all current settings.")
def _show_cmd() -> None:
run_tool_command(self, {"ops": [("show",)]})
@config_app.command("get", help="Print one setting's value.")
def _get_cmd(key: str = typer.Argument(..., help="Setting key.")) -> None:
run_tool_command(self, {"ops": [("get", key)]})
@config_app.command("set", help="Set one setting to a value.")
def _set_cmd(
key: str = typer.Argument(..., help="Setting key."),
value: str = typer.Argument(..., help="New value."),
) -> None:
run_tool_command(self, {"ops": [("set", key, value)]})
@config_app.command("backup", help="Write all settings to a TOML file.")
def _backup_cmd(path: Path = typer.Argument(..., help="Destination file.")) -> None:
run_tool_command(self, {"ops": [("backup", path)]})
@config_app.command("restore", help="Apply settings from a TOML backup.")
def _restore_cmd(
path: Path = typer.Argument(..., help="Backup file."),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without applying."),
) -> None:
run_tool_command(self, {"ops": [("restore", path, dry_run)]})
@config_app.command("custom", help="Set an experimental custom variable.")
def _custom_cmd(
key: str = typer.Argument(..., help="Variable name."),
value: str = typer.Argument(..., help="Variable value."),
) -> None:
run_tool_command(self, {"ops": [("set_custom", key, value)]})
@config_app.command("channel", help="Configure a channel slot.")
def _channel_cmd(
index: int = typer.Argument(..., help="Channel slot index."),
name: str = typer.Argument(..., help="Channel name (# derives the secret)."),
secret: Optional[str] = typer.Option(None, "--secret", help="16-byte hex secret."),
) -> None:
secret_bytes = bytes.fromhex(secret) if secret else None
run_tool_command(self, {"ops": [("set_channel", index, name, secret_bytes)]})
@config_app.command("advert", help="Broadcast an advertisement.")
def _advert_cmd(
flood: bool = typer.Option(False, "--flood", help="Flood across the mesh."),
) -> None:
run_tool_command(self, {"ops": [("advert", flood)]})
@config_app.command("export-key", help="Export the private key (sensitive).")
def _export_key_cmd(
out: Optional[Path] = typer.Option(None, "--out", help="Write to file instead of stdout."),
) -> None:
ops = [("export_key", out)] if out else [("export_key",)]
run_tool_command(self, {"ops": ops})
@config_app.command("import-key", help="Import a private key (overwrites identity).")
def _import_key_cmd(
key_hex: str = typer.Argument(..., help="Private key as hex."),
yes: bool = typer.Option(False, "--yes", help="Confirm this destructive action."),
) -> None:
_require_yes(yes, "import-key overwrites the device identity")
run_tool_command(self, {"ops": [("import_key", key_hex)]})
@config_app.command("reboot", help="Reboot the device.")
def _reboot_cmd(
yes: bool = typer.Option(False, "--yes", help="Confirm reboot."),
) -> None:
_require_yes(yes, "reboot restarts the device")
run_tool_command(self, {"ops": [("reboot",)]})
@config_app.command("factory-reset", help="Erase all data and reset to defaults.")
def _factory_reset_cmd(
yes: bool = typer.Option(False, "--yes", help="Confirm this destructive action."),
) -> None:
_require_yes(yes, "factory-reset erases ALL data on the device")
run_tool_command(self, {"ops": [("factory_reset",)]})
app.add_typer(config_app, name=self.name)
# -- op execution (shared by the tool and the interactive editor) -------------
async def apply_ops(
ctx: AppContext, device: Device, snapshot: dict, ops: list[tuple]
) -> tuple[int, list[str]]:
"""Execute a list of configuration operation tuples against ``device``.
This is the single executor for every config operation, used both by
:meth:`ConfigTool.run` (for staged, applied changes) and by the interactive editor's
danger zone (which runs its action immediately rather than staging it).
Args:
ctx: Shared application context (for console output).
device: The connected device to act on.
snapshot: The current device snapshot; updated in place as settings change so
coupled commands rebuild from current values.
ops: Operation tuples (see the module docstring).
Returns:
A ``(changes, artifacts)`` pair: the number of value changes applied and any
file paths produced.
"""
changes = 0
artifacts: list[str] = []
for op in ops:
kind = op[0]
if kind == "show":
await _show(ctx, device, snapshot)
elif kind == "get":
spec = get_spec(op[1])
ctx.ui.note(
f"[muted]{spec.key}[/muted] = {format_value(spec, spec.getter(snapshot))}"
)
elif kind == "set":
changes += await _apply_setting(ctx, device, op[1], op[2], snapshot)
elif kind == "set_custom":
await device.set_custom_var(op[1], op[2])
ctx.ui.note(f"[ok]✓[/ok] custom [brand]{op[1]}[/brand] = {op[2]}")
changes += 1
elif kind == "set_channel":
await device.set_channel(op[1], op[2], op[3])
ctx.ui.note(f"[ok]✓[/ok] channel {op[1]} = [brand]{op[2]}[/brand]")
changes += 1
elif kind == "backup":
artifacts.append(str(await _backup(device, snapshot, op[1])))
elif kind == "restore":
changes += await _restore(ctx, device, snapshot, op[1], op[2])
elif kind == "advert":
await device.send_advert(len(op) > 1 and bool(op[1]))
ctx.ui.note("[ok]✓[/ok] advertisement sent")
elif kind == "reboot":
await device.reboot()
ctx.ui.note("[warn]device rebooting[/warn]")
elif kind == "export_key":
await _export_key(ctx, device, op[1] if len(op) > 1 else None, artifacts)
elif kind == "import_key":
await device.import_private_key(op[1])
ctx.ui.note("[ok]✓[/ok] private key imported")
changes += 1
elif kind == "factory_reset":
await device.factory_reset()
ctx.ui.note("[err]device factory-reset[/err]")
else: # pragma: no cover - guarded by the call sites that build ops
ctx.ui.note(f"[err]unknown config op: {kind}[/err]")
return changes, artifacts
async def _apply_setting(
ctx: AppContext, device: Device, key: str, raw: Any, snapshot: dict
) -> int:
"""Parse, apply and record one setting; keep ``snapshot`` consistent.
The local snapshot is updated with the new value so a later coupled change in the
same batch (e.g. another radio field) is rebuilt from current values.
Returns:
``1`` (a change was applied).
"""
spec = get_spec(key)
value = parse_value(spec, raw)
await spec.apply(device, value, snapshot)
snapshot[key] = value
ctx.ui.note(f"[ok]✓[/ok] [brand]{key}[/brand] = {format_value(spec, value)}")
return 1
async def _show(ctx: AppContext, device: Device, snapshot: dict) -> None:
"""Render the full configuration table plus custom variables."""
from ..ui.config_editor import config_table
custom = await device.get_custom_vars()
ctx.ui.show(config_table(snapshot, custom))
async def _backup(device: Device, snapshot: dict, path: Path) -> Path:
"""Write a TOML backup of the current configuration."""
custom = await device.get_custom_vars()
channels = await _read_channels(device)
return backup_config(Path(path), snapshot, custom, channels)
async def _restore(
ctx: AppContext, device: Device, snapshot: dict, path: Path, dry_run: bool
) -> int:
"""Apply (or preview) a backup file against the current configuration.
Returns:
The number of changes applied (always ``0`` for a dry run).
"""
backup = read_backup(Path(path))
custom = await device.get_custom_vars()
ops = plan_restore(backup, snapshot, custom)
if not ops:
ctx.ui.note("[muted]restore: device already matches the backup.[/muted]")
return 0
if dry_run:
table = Table(title="Restore preview", border_style="warn", expand=False)
table.add_column("Operation")
table.add_column("Target")
table.add_column("Value")
for op in ops:
table.add_row(op[0], str(op[1]), str(op[2] if len(op) > 2 else ""))
ctx.ui.show(table)
ctx.ui.note("[muted]dry run — nothing was changed.[/muted]")
return 0
changes = 0
for op in ops:
if op[0] == "set":
changes += await _apply_setting(ctx, device, op[1], op[2], snapshot)
elif op[0] == "set_custom":
await device.set_custom_var(op[1], op[2])
changes += 1
elif op[0] == "set_channel":
await device.set_channel(op[1], op[2], op[3])
changes += 1
return changes
async def _export_key(
ctx: AppContext, device: Device, out: Optional[Path], artifacts: list[str]
) -> None:
"""Export the private key, to a file if ``out`` is given, else to the console."""
key_hex = await device.export_private_key()
if out is not None:
path = Path(out)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(key_hex, encoding="utf-8")
artifacts.append(str(path))
ctx.ui.note("[warn]private key written — keep this file secret.[/warn]")
else:
ctx.ui.note("[warn]private key (keep secret):[/warn] [muted]" + key_hex + "[/muted]")
async def _read_channels(device: Device) -> list[dict]:
"""Probe channel slots and return the configured ones."""
channels: list[dict] = []
for idx in range(CHANNEL_SLOT_PROBE_CAP):
try:
ch = await device.get_channel(idx)
except Exception: # noqa: BLE001 - firmware may not support channel reads
break
if ch:
channels.append(ch)
return channels
def _require_yes(yes: bool, what: str) -> None:
"""Abort a destructive CLI command unless ``--yes`` was passed.
Args:
yes: Whether the user passed ``--yes``.
what: Human-readable description of the consequence.
Raises:
typer.Exit: With code 1 if confirmation was not given.
"""
if not yes:
typer.secho(f"Refusing: {what}. Re-run with --yes to confirm.", fg="red")
raise typer.Exit(1)