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>
288 lines
11 KiB
Python
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()
|