Double-clicking the executable on Windows gives the process its own console, and Windows destroys that console the moment the process ends. So a traceback was drawn and erased in the same instant: the report was "it displays a trace but the window closes almost instantly", which is not a rendering problem, it is the window being owned by the thing that died. Two changes, because one of them is not enough. The window now waits. `GetConsoleProcessList` says how many processes share this console; when the answer is one, nobody else is holding the window open and it will close with us. On that path only, and only after a failure, MeshTerm asks for a keypress before it goes. A run started from a shell leaves its output on the screen anyway, and a run that worked should never make anyone press a key to be allowed to leave. And the interactive session logs what killed it. It was catching the missing Windows console and re-raising everything else untouched, which is precisely backwards for the case that needs a record most: the session whose console is about to vanish is the one whose traceback nobody will get to read. It goes into the file first, and the terminal is told where the file is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
416 lines
17 KiB
Python
416 lines
17 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
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import typer
|
|
from rich.console import Console
|
|
|
|
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.instancelock import InstanceBusy, hold_instance_lock
|
|
from .core.preferences import PreferenceError, Preferences
|
|
from .core.selection import DeviceSelectionError
|
|
from .persistence.logging import configure_logging, get_logger, level_from_name, log_path
|
|
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()
|
|
# Loaded before logging is configured, because how much goes in the file is one of
|
|
# them — and handed to the context afterwards so the file is not read twice.
|
|
prefs = Preferences.load(settings.config_dir / "preferences.yaml")
|
|
configure_logging(
|
|
console,
|
|
settings.config_dir,
|
|
level=logging.INFO,
|
|
file_level=level_from_name(prefs.log_level),
|
|
quiet=quiet or json_output,
|
|
)
|
|
|
|
app_ctx = AppContext(
|
|
preferences=prefs,
|
|
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
|
|
|
|
# The interactive session is the one that holds the stores in memory and rewrites
|
|
# them whole, so it is the one that claims the directory. One-shot subcommands are
|
|
# brief and mostly read; blocking `meshterm contacts` because a menu is open in
|
|
# another window would be an obstacle rather than a guard.
|
|
try:
|
|
with hold_instance_lock(settings.config_dir):
|
|
asyncio.run(_drive(run_menu(app_ctx), app_ctx))
|
|
except InstanceBusy as exc:
|
|
console.print(f"[err]✗[/err] {exc}")
|
|
raise typer.Exit(code=1) from None
|
|
except Exception as exc:
|
|
if type(exc).__name__ == "NoConsoleScreenBufferError":
|
|
# prompt_toolkit needs a real Win32 console and reports its absence in the
|
|
# language of its own internals. A traceback reads as "this app is broken"
|
|
# when the answer is "open a different terminal", so answer that instead.
|
|
get_logger().warning("no Windows console available: %s", exc)
|
|
_report_no_windows_console(console)
|
|
raise typer.Exit(code=1) from None
|
|
# Logged before it is re-raised, and this is the case the log matters most for:
|
|
# a session launched by double-clicking the executable owns its console window,
|
|
# so when it dies the window closes with it and takes the traceback along. The
|
|
# file is the only thing left to read afterwards.
|
|
get_logger().exception("the interactive session raised an unhandled error")
|
|
_report_log_location(console, settings.config_dir)
|
|
raise
|
|
|
|
|
|
def _report_log_location(console: Console, config_dir: Path) -> None:
|
|
"""Point at the log after a fault, since it is the copy that outlives the terminal."""
|
|
console.print()
|
|
console.print(f"[muted]The details are in {log_path(config_dir)}[/muted]")
|
|
console.print("[muted]Attach it to a bug report. For more detail next time, raise[/muted]")
|
|
console.print("[muted]'Log detail' on the Preferences page.[/muted]")
|
|
|
|
|
|
def _report_no_windows_console(console: Console) -> None:
|
|
"""Explain a console MeshTerm cannot draw in, and how to get one it can.
|
|
|
|
Reached when prompt_toolkit finds no Win32 console screen buffer: a Git Bash, MSYS or
|
|
Cygwin shell — which announce themselves as ``xterm`` and are not Windows consoles at
|
|
all — or a process whose output has been redirected away from one.
|
|
"""
|
|
console.print("[err]✗[/err] MeshTerm needs a Windows console, and could not find one.")
|
|
console.print()
|
|
console.print("That usually means one of two things:")
|
|
console.print()
|
|
console.print(" • You are in Git Bash, MSYS or Cygwin. Those are not Windows consoles.")
|
|
console.print(" Open [accent]Windows Terminal[/accent] or [accent]PowerShell[/accent]")
|
|
console.print(" and run it from there.")
|
|
console.print(" • The output is piped or redirected somewhere. The full-screen menu")
|
|
console.print(" needs the terminal itself.")
|
|
console.print()
|
|
console.print("Every screen also has a subcommand, and those pipe fine —")
|
|
console.print("try [accent]meshterm --help[/accent].")
|
|
|
|
|
|
@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. Logged
|
|
# at warning because it is a thing that went wrong, even though it is an ordinary
|
|
# one — a log that only holds crashes cannot answer "what happened before it".
|
|
get_logger().warning("%s failed: %s", tool.name, exc)
|
|
_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.
|
|
if is_connection_lost(exc):
|
|
get_logger().warning("%s lost the device connection: %s", tool.name, exc)
|
|
_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
|
|
# Anything else is a real fault. It still reaches the terminal as a traceback,
|
|
# because a person looking at one wants to see it — but it also lands in the log,
|
|
# which is the copy that survives the terminal being closed and is the one thing
|
|
# worth attaching to a bug report.
|
|
get_logger().exception("%s raised an unhandled error", tool.name)
|
|
_report_log_location(_state.console, _state.settings.config_dir)
|
|
raise
|
|
|
|
|
|
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 _owns_its_console() -> bool:
|
|
"""Whether this process is the only one on its console — i.e. it was double-clicked.
|
|
|
|
Windows gives a console to a process launched from Explorer and destroys it the moment
|
|
that process ends, so an error message is displayed and erased in the same instant.
|
|
Launched from a shell instead, the console belongs to the shell and survives.
|
|
|
|
``GetConsoleProcessList`` tells the two apart: it reports how many processes are
|
|
attached to this console. One is us, alone, holding a window that dies with us.
|
|
|
|
Returns:
|
|
``True`` only on Windows, and only when nothing else shares the console.
|
|
"""
|
|
if sys.platform != "win32":
|
|
return False
|
|
try:
|
|
import ctypes
|
|
|
|
buffer = (ctypes.c_uint * 4)()
|
|
count = ctypes.windll.kernel32.GetConsoleProcessList(buffer, 4) # type: ignore[attr-defined]
|
|
except Exception: # pragma: no cover - no console at all, or a stubbed kernel32
|
|
return False
|
|
return count == 1
|
|
|
|
|
|
def main() -> None:
|
|
"""Console-script entry point.
|
|
|
|
Holds the window open after a failure when MeshTerm owns it, because otherwise the
|
|
error is drawn and destroyed together and the person who needs to read it sees a
|
|
flash. Only on that path: a successful run should not make anyone press a key, and a
|
|
run from a shell leaves its output on the screen anyway.
|
|
"""
|
|
try:
|
|
app()
|
|
except SystemExit as exit_request:
|
|
if exit_request.code and _owns_its_console():
|
|
_wait_before_the_window_closes()
|
|
raise
|
|
except BaseException:
|
|
if _owns_its_console():
|
|
_wait_before_the_window_closes()
|
|
raise
|
|
|
|
|
|
def _wait_before_the_window_closes() -> None:
|
|
"""Ask for a keypress so a message stays readable, and never fail doing it."""
|
|
try:
|
|
print()
|
|
print("-- Press Enter to close this window --")
|
|
input()
|
|
except Exception: # pragma: no cover - stdin closed, redirected, or gone
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|