Files
MeshTerm/meshterm/cli.py
T
Jean-Pierre MartineauandClaude Opus 5 cef7a764af How MeshTerm behaves gets a page of its own
Three kinds of "setting" were tangled into two files and one of them had no
screen at all. The radio's settings live on the radio and are edited on Device
config. Machine setup -- where the database is, which companion to talk to --
stays in config.toml, a text editor's job. Everything in between was app
behaviour: six keys sitting in config.toml with nothing to open them, and a
dozen more that were only module constants a reader could never reach.

Those are preferences now, and they have a page. core/preferences.py is a
PrefSpec registry in the shape of the device-config one -- group, type, bounds,
help, and a default -- and the page is built from the same parts as the device
editor, deliberately: one grouped lane-aligned list, current -> new staged in
warn, Apply/Back appearing only once something is staged, the shared discard
confirm on the way out. Two editors that stage values should not be two screens
to learn. What it adds is a Defaults section holding one row that stages every
changed preference back to its built-in value; it is drawn only while a reset
would do something, and it stages rather than writes, so it is as reviewable and
as Esc-discardable as any other change and the file is still only ever written
by the one action at the bottom. Its rows scroll sideways where the device
editor's don't: the explanation is the widest thing on the row and the only part
that runs past the edge, so <-> slide it under a pinned setting and value.

The file is preferences.yaml and holds only the values you disagree with the
code about -- grouped and commented, each entry above its help and its default.
That is what lets a later change to a default reach an install that never asked
to be exempt; delete a line and the code takes over again. A hand edit gone
wrong costs that line, never the session.

Every preference is wired to something that reads it. ctx.preferences is the
explicit path; preferences.current() is for the render layer, which has no
context to reach through, the same reason get_platform() exists.

The About pages come along into a This app section, the third scope after this
node and other nodes, with Preferences leading it -- the one row there that
changes MeshTerm rather than describing it. A section of one would have broken
the menu's own no-bucket-of-one rule, and the app's scope is the honest home for
both halves anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:36:40 -04:00

288 lines
11 KiB
Python

"""Typer entry point.
Global options are parsed once in the callback, which builds the shared
:class:`~meshterm.context.AppContext`. Running with no subcommand launches the
interactive menu; otherwise the selected tool's subcommand runs. Both paths funnel
through :func:`run_tool_command` / :func:`run_menu`, so behavior stays identical.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Optional
import typer
from .context import AppContext
from .core.admin_store import AdminStore
from .core.config import Settings
from .core.connection import DeviceCommandError, is_connection_lost
from .core.device_config import DeviceConfigError
from .core.preferences import PreferenceError
from .core.device_store import DeviceStore
from .core.selection import DeviceSelectionError
from .persistence.logging import configure_logging
from .persistence.repository import Repository
from .platforms import Resolution, resolve, set_platform
from .tools import all_tools
from .tools.base import Tool, ToolResult
from .ui.theme import make_console
def _unframe_help_panels() -> None:
"""Render Typer's help/error sections as coloured headings instead of boxed panels.
Typer's rich help gives us the colour we want — yellow usage, cyan options, green
switches — but wraps each Options/Commands/Error section in a rounded :class:`Panel`,
the boxed framing scripted output is meant to be free of. There's no constant to drop
that box, so we swap the ``Panel`` the help formatter calls for a thin stand-in that
keeps the colour and the section title (as a bold heading, indigo like the app accent,
red for errors) but no border — the same heading-over-content shape tool results use
(see :func:`~meshterm.ui.surface._deframe`). The inner tables are already box-less.
"""
import typer.rich_utils as rich_utils
from rich.console import Group
from rich.text import Text
def _bare_panel(
renderable: object,
*,
title: object = None,
border_style: str = "",
**_: object,
) -> Group:
style = "bold red" if border_style == "red" else "bold #818cf8"
heading = Text(str(title), style=style) if title else Text("")
return Group(Text(""), heading, renderable) # type: ignore[list-item]
rich_utils.Panel = _bare_panel # type: ignore[assignment,misc]
_unframe_help_panels()
app = typer.Typer(
add_completion=False,
no_args_is_help=False,
# Rich help for its colour (usage, option flags, metavars), but with the boxed
# section panels flattened to plain headings by _unframe_help_panels() above — a
# splash of colour, none of the framing.
rich_markup_mode="rich",
help="MeshTerm — a modern toolkit for tuning and exploring your MeshCore mesh",
)
# The context built by the callback and consumed by subcommands within one process.
_state: Optional[AppContext] = None
# The platform resolution the callback made, for the ``platform`` diagnostic subcommand
# to report back (see :func:`platform_command`) without re-deriving it independently.
_platform_resolution: Optional[Resolution] = None
@app.callback(invoke_without_command=True)
def main_callback(
ctx: typer.Context,
profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Device profile"),
port: Optional[str] = typer.Option(None, "--port", help="Serial port override"),
ble: Optional[str] = typer.Option(
None, "--ble", help="Bluetooth address of a companion device (selects the BLE transport)"
),
ble_pin: Optional[str] = typer.Option(
None, "--ble-pin", help="BLE pairing PIN, if the Bluetooth companion requires one"
),
tcp: Optional[str] = typer.Option(
None,
"--tcp",
help="Network address host[:port] of a TCP companion (selects the TCP transport)",
),
mock: bool = typer.Option(False, "--mock", help="Use the built-in simulator"),
db_path: Optional[Path] = typer.Option(None, "--db", help="SQLite database path"),
json_output: bool = typer.Option(False, "--json", help="Machine-readable output"),
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress console logging"),
platform: Optional[str] = typer.Option(
None,
"--platform",
help="Force the UI platform (regular|picocalc) instead of auto-detecting it",
),
) -> None:
"""Build the application context and dispatch to the menu or a subcommand.
Args:
ctx: The Click/Typer context.
profile: Named device profile to use.
port: Explicit serial port, overriding the profile.
ble: Explicit Bluetooth address, selecting the BLE transport.
ble_pin: Optional BLE pairing PIN for the Bluetooth companion.
tcp: Explicit network address ``host[:port]``, selecting the TCP transport.
mock: Whether to use the simulator instead of real hardware.
db_path: Override the database location.
json_output: Request machine-readable output from tools.
quiet: Suppress console logging (file logging continues).
platform: Explicit ``--platform`` override; see :func:`meshterm.platforms.resolve`.
"""
global _state, _platform_resolution
# Resolved and installed first, before anything below it (make_console, the theme,
# the eventual TuiSession) reads the active platform.
try:
_platform_resolution = resolve(platform)
except ValueError as exc:
raise typer.BadParameter(str(exc), param_hint="--platform") from exc
set_platform(_platform_resolution.platform)
settings = Settings.load()
if db_path is not None:
settings.db_path = db_path
console = make_console()
configure_logging(
console, settings.config_dir, level=logging.INFO, quiet=quiet or json_output
)
app_ctx = AppContext(
console=console,
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(settings.config_dir / "devices.json"),
admin_store=AdminStore(settings.config_dir / "admin.json"),
profile=settings.resolve_profile(profile),
mock=mock,
port_override=port,
ble_override=ble,
tcp_override=tcp,
ble_pin=ble_pin,
json_output=json_output,
explicit_selection=(
profile is not None or port is not None or ble is not None or tcp is not None
),
)
_state = app_ctx
ctx.call_on_close(app_ctx.repo.close)
if ctx.invoked_subcommand is None:
from .ui.menu import run_menu
asyncio.run(_drive(run_menu(app_ctx), app_ctx))
@app.command(name="platform")
def platform_command() -> None:
"""Print the resolved platform and every input the resolution looked at.
A diagnostic for the platform seam (see ``meshterm/platforms.py``): confirms which
flavour a given invocation would run as, and why — the ``--platform``/
``MESHTERM_PLATFORM`` inputs, what ``/proc/device-tree/model`` reports (``None`` off
the actual hardware), and which of those decided it.
"""
assert _platform_resolution is not None # set by the callback that always runs first
r = _platform_resolution
console = make_console()
console.print(f"platform: [accent]{r.platform.name}[/accent] (source: {r.source})")
console.print(f" --platform: {r.flag or '(not passed)'}")
console.print(f" MESHTERM_PLATFORM: {r.env or '(not set)'}")
console.print(f" /proc/device-tree/model: {r.detected_model or '(unavailable)'}")
@app.command(name="specimen")
def specimen_command() -> None:
"""Print the visual-language specimen for the active platform.
Every mark, icon funnel, colour scale and fold on one card, drawn through the same
theme and glyph machinery the TUI uses — so on the PicoCalc console it is the
font/palette acceptance screen, and with ``--platform picocalc`` on a desktop it
previews that flavour. See :mod:`meshterm.ui.specimen`.
"""
from .ui.specimen import specimen_lines
console = make_console()
for line in specimen_lines():
console.print(line)
def run_tool_command(tool: Tool, params: dict) -> None:
"""Execute a tool from a CLI subcommand and render its result.
Builds and tears down the device connection within a single event loop so the
``meshcore`` client is never used across loops.
Args:
tool: The tool to run.
params: Parameters parsed from the subcommand's options.
"""
assert _state is not None # set by the callback before any subcommand runs
try:
asyncio.run(_drive(_execute_and_render(tool, params, _state), _state))
except (
DeviceSelectionError, DeviceConfigError, DeviceCommandError, PreferenceError
) as exc:
# Expected user-facing error (ambiguous/absent device, a bad config or preference
# value, or a transient command failure): show the message, not a traceback.
_state.console.print(f"[err]✗[/err] {exc}")
raise typer.Exit(1) from exc
except Exception as exc:
# A dropped serial link (device unplugged/powered off mid-command) can't be recovered
# from in a one-shot scripted run the way the interactive menu offers — but it should
# still read as a clean message, not a traceback. Anything else propagates as before.
if not is_connection_lost(exc):
raise
_state.console.print(
"[err]✗[/err] the connection to your device was lost "
"(it may have been unplugged or powered off)."
)
raise typer.Exit(1) from exc
async def _drive(coro, ctx: AppContext) -> None:
"""Await a coroutine then disconnect the device (but keep the repo open).
Args:
coro: The coroutine to run (a tool execution or the menu loop).
ctx: The application context whose device should be closed afterward.
"""
try:
await coro
finally:
if ctx._device is not None:
try:
await ctx._device.disconnect()
except Exception: # noqa: BLE001 - a dead/lost link must not crash teardown
pass
ctx._device = None
async def _execute_and_render(tool: Tool, params: dict, ctx: AppContext) -> ToolResult:
"""Run a tool and print its closing message and artifact list.
Args:
tool: The tool to execute.
params: Parameters for the tool.
ctx: The application context.
Returns:
The tool's :class:`ToolResult`.
"""
result = await tool.execute(ctx, params)
if result.message:
ctx.console.print(result.message)
for artifact in result.artifacts:
ctx.console.print(f"[ok]●[/ok] wrote [accent]{artifact}[/accent]")
return result
def _register_all() -> None:
"""Register every tool's CLI subcommand with the Typer app."""
for tool in all_tools():
tool.register_cli(app)
_register_all()
def main() -> None:
"""Console-script entry point."""
app()
if __name__ == "__main__":
main()