`ruff format .` across the repo: 153 files, no behaviour change. This is the second gate CONTRIBUTING.md promises, and until now it reported 154 files needing reformat — so a first contributor ran the command the guide told them to run and could not tell their own diff from the background. Landed on its own, with nothing else in it, because a whole-repo reformat is only safe to skip past in blame if it is provably nothing but layout. It was checked that way rather than assumed: every changed file was formatted in memory and compared by full `ast.dump()`, and 152 of 153 come out structurally identical (the one exception was a docstring opening on a quote character, fixed in the commit before this one). The string and comment token streams were diffed separately, so a quote flip or an edited comment could not hide inside a re-layout — five quote-style normalisations and sixteen implicit-concatenation joins, all value-identical, and one commented-out line gaining the space after its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite is 1888 green, gallery gate and `test_theme16` included. What actually changed, in order of how much of the diff it is: wrapped calls and signatures rejoin onto one line where they fit inside the hundred columns; the magic trailing comma expands what it is there to expand; complex slices gain their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are laid out to be read were fenced in the previous commit and are untouched here. The aligned trailing-comment column is gone from about ninety lanes that were not fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced where it sits. Every comment stays on the entry it describes, so nothing there reads wrong; it just no longer lines up. Add this commit to `.git-blame-ignore-revs`, which follows immediately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
284 lines
11 KiB
Python
284 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",
|
|
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: 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()
|