Files
MeshTerm/meshterm/cli.py
T
Jean-Pierre MartineauandClaude Opus 5 4f60f44512 A build you can download and run without Python
Most people who want MeshTerm have a radio and a terminal, not a Python
toolchain. This is the install for them: one file, download it, run it.

`packaging/meshterm.spec` freezes the app with PyInstaller in console mode —
which is not a detail, since a windowed build of a terminal program starts with
nowhere to draw and exits. The assets land at `meshterm/assets` inside the bundle
so that `Path(__file__).parent.parent / "assets"` keeps resolving exactly as it
does in a checkout, and bleak's per-platform backends are named as hidden imports
because nothing static ever sees the one that actually gets used. UPX is off: a
few megabytes are not worth being flagged by antivirus on a first release.

The build was run here rather than only written. The Windows executable comes out
at 14 MB, runs from a directory with no source tree in sight, and draws the
specimen card — which is the real test, because the specimen reads the asset
files.

`installers.yml` does the same on five machines, because frozen builds cannot be
cross-compiled. Windows, macOS on both Apple silicon and Intel (plenty of people
still have one, and an arm64 build will not run for them), and Linux on x64 and
ARM64 — the last for the uConsole. Each leg runs the binary from a temporary
directory before uploading it, so a build that only works beside its own source
fails on the runner instead of on somebody's laptop, and each one gets a checksum
written beside it. It publishes nothing; attaching these to a release stays a
separate decision.

Also here: `meshterm --help` now opens with the same sentence as the package
summary, rather than a fourth description of the project written in its own way.

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

290 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
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.device_store import DeviceStore
from .core.preferences import PreferenceError
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",
# The one description, same as the package summary and the PyPI page. See
# `meshterm.__doc__` — if this drifts from that, one of them is lying.
help=(
"MeshTerm is a full-featured TUI-based MeshCore client for your terminal. "
"Supports USB, Bluetooth and TCP companion connection. "
"For Windows, macOS and Linux."
),
)
# The context built by the callback and consumed by subcommands within one process.
_state: AppContext | None = 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: Resolution | None = None
@app.callback(invoke_without_command=True)
def main_callback(
ctx: typer.Context,
profile: str | None = typer.Option(None, "--profile", "-p", help="Device profile"),
port: str | None = typer.Option(None, "--port", help="Serial port override"),
ble: str | None = typer.Option(
None, "--ble", help="Bluetooth address of a companion device (selects the BLE transport)"
),
ble_pin: str | None = typer.Option(
None, "--ble-pin", help="BLE pairing PIN, if the Bluetooth companion requires one"
),
tcp: str | None = 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: Path | None = 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: str | None = 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()