diff --git a/meshterm/__init__.py b/meshterm/__init__.py index 6f6d86b..be0766d 100644 --- a/meshterm/__init__.py +++ b/meshterm/__init__.py @@ -9,17 +9,17 @@ from datetime import datetime __version__ = "0.10.2" __author__ = "Jean-Pierre Martineau" -#: The year MeshTerm was first published; the copyright span starts here. +#: The year when MeshTerm was first published. The copyright span starts in this year. COPYRIGHT_START_YEAR = 2026 def copyright_notice() -> str: """The one canonical copyright line, dated from :data:`COPYRIGHT_START_YEAR` to now. - A single publication year collapses to just that year ("Copyright © 2026 …"); once - time has moved on it becomes a span ("Copyright © 2026-2027 …"). Every splash and - screen that shows a copyright draws it from here so the wording, holder, and span can - never drift apart. + While the year is the publication year, the line shows only that year ("Copyright © + 2026 …"). In a later year, it shows a span ("Copyright © 2026-2027 …"). Each splash and + each screen that shows a copyright gets it from this function. Thus the wording, the + holder, and the span always agree. """ year = datetime.now().year span = ( diff --git a/meshterm/__main__.py b/meshterm/__main__.py index b8c54c4..996d991 100644 --- a/meshterm/__main__.py +++ b/meshterm/__main__.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -"""Enable ``python -m meshterm``.""" +"""Make ``python -m meshterm`` work.""" from .cli import main diff --git a/meshterm/cli.py b/meshterm/cli.py index b1b0b37..302eaac 100644 --- a/meshterm/cli.py +++ b/meshterm/cli.py @@ -1,10 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 """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. +The callback parses the global options one time, and builds the shared +:class:`~meshterm.context.AppContext`. If there is no subcommand, MeshTerm starts the +interactive menu. If there is a subcommand, MeshTerm runs the subcommand of the selected +tool. Both paths go through :func:`run_tool_command` or :func:`run_menu`, so the behaviour +is the same. """ from __future__ import annotations @@ -44,48 +45,49 @@ from .ui.renderers import OutputFormat from .ui.termfont import emoji_support from .ui.theme import make_console -#: The exit-status table, printed under every ``--help``. A documented return value is -#: half of what makes the CLI scriptable (the other half is :mod:`meshterm.ui.script`), -#: and a caller should not have to find it in a README. +#: The table of exit statuses, printed under each ``--help``. A documented return value is +#: one half of what makes the CLI scriptable (the other half is :mod:`meshterm.ui.script`). +#: A caller must not have to look for it in a README. EXIT_STATUS_EPILOG = "Exit status: " + " · ".join( f"{code} {meaning}" for code, meaning in exitcodes.MEANINGS.items() ) class _GlobalOptionsAnywhere(TyperGroup): - """A group whose own options may be typed after the subcommand as well as before. + """A group whose own options the user can type after the subcommand and before it. - Click binds an option to whatever command declares it, so a global declared on the - callback is a parse error one word later: ``meshterm contacts --json`` failed with a - bare usage error while ``meshterm --json contacts`` worked. That is the wrong way - round for an output-format flag — ``-o json`` goes *after* the verb in every tool that - has one, and ``--json`` is the first thing anyone will reach for here. + Click binds an option to the command that declares it. Thus a global option that the + callback declares is a parse error one word later. ``meshterm contacts --json`` failed + with a bare usage error, but ``meshterm --json contacts`` worked. This is the wrong + order for a flag of the output format. In each tool that has such a flag, ``-o json`` + goes after the verb, and ``--json`` is the first option that anyone will try here. - So the group's own options are lifted to the front before Click sees them, and both - orders mean the same run. ``--`` stops the lifting, which is the standard way to say - the rest is data; ``--help`` is deliberately never lifted, since ``contacts --help`` - must stay the *contacts* help rather than silently becoming the program's. + Thus the group moves its own options to the front before Click sees them, and both + orders give the same run. ``--`` stops the moving, which is the standard way to say that + the rest is data. ``--help`` is never moved on purpose, because ``contacts --help`` must + stay the help of *contacts* and must not become the help of the program. """ def parse_args(self, ctx: typer.Context, args: list[str]) -> list[str]: - """Lift this group's options out of ``args``, then parse as Click normally would.""" + """Move the options of this group out of ``args``, then parse as Click does.""" return super().parse_args(ctx, _globals_first(self, args)) def _globals_first(group: TyperGroup, args: list[str]) -> list[str]: - """``args`` with ``group``'s own options moved ahead of the subcommand. + """``args`` with the own options of ``group`` moved ahead of the subcommand. - An option is told from an argument by carrying ``is_flag`` — Typer vendors Click, so - there is no importable ``click.Option`` to test against, and the attribute is the same - question asked without reaching into a private package. + The function finds an option, and tells it from an argument, because the option has + ``is_flag``. Typer includes its own copy of Click, so there is no ``click.Option`` that + code can import to test against. The attribute asks the same question, and the code does + not reach into a private package. Args: group: The command whose options count as global. - args: The argument list as typed. + args: The list of arguments as the user typed it. Returns: - A reordered list. The relative order of the options, and of everything else, is - preserved, so a repeated flag resolves exactly as Click would resolve it. + A list in a new order. The relative order of the options, and of all the other + items, does not change. Thus a repeated flag resolves in the same way as in Click. """ takes_value: dict[str, bool] = {} for param in group.params: @@ -119,12 +121,13 @@ app = typer.Typer( cls=_GlobalOptionsAnywhere, add_completion=False, no_args_is_help=False, - # Click's own plain help, not Typer's rich one: no boxed Options/Commands panels, no - # colour, no markup to strip out of a piped `--help`. The CLI is read by scripts, and - # its help is read the way every other utility's is (see meshterm.ui.script). + # The plain help of Click, not the rich help of Typer. There are no boxed panels for + # Options and Commands, no colour, and no markup to remove from a piped `--help`. Scripts + # read the CLI, and users read its help in the same way as the help of each other + # utility (refer to meshterm.ui.script). rich_markup_mode=None, - # 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. + # The one description. It is the same as the package summary and the PyPI page. Refer to + # `meshterm.__doc__`. If this text and that text are different, one of them is wrong. help=( "MeshTerm is a full-featured TUI MeshCore client for your terminal. " "Connects to a companion over USB, Bluetooth, or TCP. " @@ -135,12 +138,13 @@ app = typer.Typer( def _print_version(value: bool) -> None: - """Print the version and stop, the moment ``--version`` is seen. + """Print the version and stop, when MeshTerm sees ``--version``. - Eager, so it answers before the callback opens a database, resolves a platform or looks - for a radio — the one question that is true of the program rather than of a run. Bare - ``meshterm ``: the caller asked for a version, and a script reading it back has - one field to take. Exits ``0``, because knowing the version is a success. + The option is eager. Thus it answers before the callback opens a database, resolves a + platform, or looks for a radio. The version is true of the program and not of one run. + The output is bare: ``meshterm ``. The caller asked for a version, and a script + that reads it back has one field to take. It exits with ``0``, because to know the + version is a success. """ if not value: return @@ -148,11 +152,11 @@ def _print_version(value: bool) -> None: raise typer.Exit(exitcodes.OK) -# The context built by the callback and consumed by subcommands within one process. +# The context that the callback builds and the subcommands use in 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. +# The platform resolution that the callback made. The ``platform`` diagnostic subcommand +# reports it (refer to :func:`platform_command`) and does not derive it again by itself. _platform_resolution: Resolution | None = None @@ -202,37 +206,38 @@ def main_callback( ), ), ) -> None: - """Build the application context and dispatch to the menu or a subcommand. + """Build the application context, then go to the menu or to a subcommand. Args: - ctx: The Click/Typer context. - version: Print the version and exit (handled eagerly by :func:`_print_version`). - 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. - spi: Select the radio on this machine's SPI bus, whose node MeshTerm runs itself. + ctx: The Click or Typer context. + version: Print the version and exit (:func:`_print_version` handles it eagerly). + profile: The name of the device profile to use. + port: An explicit serial port, which overrides the profile. + ble: An explicit Bluetooth address, which selects the BLE transport. + ble_pin: An optional BLE pairing PIN for the Bluetooth companion. + tcp: An explicit network address ``host[:port]``, which selects the TCP transport. + spi: Select the radio on the SPI bus of this machine. MeshTerm itself runs the node. mock: Whether to use the simulator instead of real hardware. - db_path: Override the database location. Without it, a ``--mock`` run records to - the simulator's own database rather than the real history. + db_path: Override the location of the database. If it is not set, a ``--mock`` run + stores to the own database of the simulator and not to the real history. json_output: Print the answer as JSON instead of aligned text. - absolute: Print times as ISO-8601 instants rather than relative ages, for this run. - quiet: Suppress console logging (file logging continues). - platform: Explicit ``--platform`` override; see :func:`meshterm.platforms.resolve`. + absolute: Print times as ISO-8601 instants instead of relative ages, for this run. + quiet: Suppress console logging (logging to the file continues). + platform: An explicit ``--platform`` override. Refer to + :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. + # MeshTerm resolves and installs the platform first, before the code below it reads the + # active platform (make_console, the theme, and the TuiSession that follows). try: _platform_resolution = resolve(platform) except ValueError as exc: raise typer.BadParameter(str(exc), param_hint="--platform") from exc - # A terminal that cannot draw emoji is asked about here rather than in resolve(), - # which answers "which flavour" from the flag, the env and the device tree and should - # stay that pure — this is the *terminal's* limit, and it applies whichever flavour - # those three chose. See meshterm.ui.termfont.emoji_support. + # MeshTerm asks here whether the terminal can draw emoji, and not in resolve(). + # resolve() answers "which flavour" from the flag, the environment, and the device + # tree, and it must stay that pure. This is a limit of the terminal, and it applies to + # the flavour that those three inputs chose. Refer to meshterm.ui.termfont.emoji_support. active = _platform_resolution.platform if not emoji_support().supported: active = without_emoji(active) @@ -244,67 +249,72 @@ def main_callback( if given ] if spi and len(named_radios) > 1: - # The SPI radio is on this machine and every other flag names a radio that isn't. + # The SPI radio is on this machine. Each other flag names a radio that is not. raise typer.BadParameter(f"--spi and {named_radios[0]} name different devices; pass one") if mock and named_radios: - # One flag says "pretend", the other names one exact radio, and `--mock` used to - # win silently — so a scheduled `--port COM7 --mock info` reported on a simulator - # while reading as though it had reached the radio. Two contradictory claims about - # which device to talk to is a bad command line, not a precedence question. + # One flag says "pretend", and the other names one exact radio. `--mock` used to win + # without a message. Thus a scheduled `--port COM7 --mock info` reported on a + # simulator, and it looked as if it had reached the radio. Two claims that + # contradict each other about the device to talk to are a bad command line. They are + # not a question of precedence. raise typer.BadParameter(f"--mock and {named_radios[0]} name different devices; pass one") settings = Settings.load() if db_path is not None: settings.db_path = db_path elif mock: - # The simulator is a fake radio, not a fake MeshTerm: everything it adverts was - # written to the real history like anything a companion said, and its four - # invented contacts then turned up in the mesh walk, the dashboard and the map of - # the mesh you actually run. A pretend radio gets a pretend history — - # ``/meshterm-mock.db``, created on first use — while ``--db`` still - # wins, because naming a database is a deliberate claim about where a run records. - # ``$MESHTERM_HOME`` remains the only thing that isolates a run whole: the contact - # and channel caches, the outbox and the remembered devices live beside the - # database either way (see docs/cli/README.md). + # The simulator is a fake radio, but it is not a fake MeshTerm. MeshTerm stored all + # that the simulator sent as adverts to the real history, in the same way as + # anything that a companion said. Then its four invented contacts appeared in the + # mesh walk, the dashboard, and the map of the mesh that you really run. A pretend + # radio gets a pretend history: ``/meshterm-mock.db``, which MeshTerm + # creates at first use. ``--db`` still wins, because to name a database is a + # deliberate claim about where a run stores its data. ``$MESHTERM_HOME`` is still the + # only thing that isolates a whole run. The contact and channel caches, the outbox, + # and the remembered devices are next to the database in each case (refer to + # docs/cli/README.md). settings.db_path = settings.config_dir / MOCK_DB_FILENAME - # Two front ends, two consoles, and which one this process gets is settled here — a - # named subcommand is a scripted run, so it prints on the plain, colourless, - # never-wrapping console the CLI's output language is written for (see - # meshterm.ui.script); no subcommand means the menu, which needs the themed one. + # There are two front ends and two consoles. This code decides which console the process + # gets. A named subcommand is a scripted run. It prints on the plain console, without + # colour and without wrapping, for which the output language of the CLI is written + # (refer to meshterm.ui.script). If there is no subcommand, the menu runs, and it needs + # the console with the theme. scripted = ctx.invoked_subcommand is not None console = script.console() if scripted else 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. + # MeshTerm loads the preferences before it configures logging, because one preference + # sets how much goes into the file. It gives them to the context afterwards, so that + # it does not read the file two times. prefs = Preferences.load(settings.config_dir / PREFERENCES_FILENAME) if absolute: - # An override of the preference rather than a second switch beside it: *how - # MeshTerm behaves* is a preference by this project's own rule, and a flag that - # bypassed the registry would be a behaviour a reader could not find. Set, never - # saved — it is a claim about this run. + # An override of the preference, and not a second switch next to it. By the rule of + # this project, how MeshTerm behaves is a preference. A flag that bypassed the + # registry would be a behaviour that a user cannot find. MeshTerm sets it and never + # saves it, because it is a claim about this run. prefs.set("cli_time_format", "absolute") configure_logging( - # A scripted run's stdout carries its answer and nothing else, so log records go - # to stderr instead of interleaving with the data a caller is parsing. + # The stdout of a scripted run carries its answer and nothing else. Thus the log + # records go to stderr, and they do not mix with the data that a caller parses. script.stderr_console() if scripted else console, settings.config_dir, - # And it only hears about faults. Every *expected* failure is already reported on - # stderr in one line and in the exit status (see `_reported`), so echoing the same - # thing again as a WARNING record would say it twice; the file keeps both either - # way. The menu stays at INFO, where the log pane is the only place these show. + # A scripted run hears only about faults. MeshTerm already reports each expected + # failure on stderr in one line and in the exit status (refer to `_reported`). If it + # also printed a WARNING record, the same thing would appear two times. The file + # keeps both in each case. The menu stays at INFO, because the log pane is the only + # place where these records appear. level=logging.ERROR if scripted else logging.INFO, file_level=level_from_name(prefs.log_level), quiet=quiet or json_output, ) - # What the file held that no preference now takes — reported only now, since the file - # was read before there was a log to say so in. + # The file can hold values that no preference takes now. MeshTerm reports them only at + # this point, because it read the file before there was a log to report them to. report_dropped(prefs, get_logger()) if profile is not None and settings.resolve_profile(profile) is None: - # Falling through to ordinary discovery would pick whichever radio is attached, so - # a typo in a scheduled `--profile yagi config advert` transmits from the wrong - # node rather than failing. Naming a profile is a claim about *which* device, and - # an unkeepable claim is a bad argument. + # If MeshTerm continued to the ordinary discovery, it would pick the radio that is + # attached. Then a typing error in a scheduled `--profile yagi config advert` would + # transmit from the wrong node, and would not fail. To name a profile is a claim + # about which device to use, and a claim that MeshTerm cannot keep is a bad argument. known = ", ".join(sorted(settings.profiles)) or "none are defined" raise typer.BadParameter(f"no device profile named {profile!r} (known: {known})") @@ -323,8 +333,8 @@ def main_callback( spi_override=spi, ble_pin=ble_pin, output=OutputFormat.JSON if json_output else OutputFormat.PLAIN, - # Whether a tool may stop and ask, which is a different question from what its - # output looks like. A pipe on stdin means nobody is there to answer. + # Whether a tool can stop and ask. This is a different question from the question + # of what its output looks like. A pipe on stdin means that nobody is there to answer. interactive=_stdin_is_a_person(), explicit_selection=( profile is not None or port is not None or ble is not None or tcp is not None or spi @@ -335,36 +345,37 @@ def main_callback( if ctx.invoked_subcommand is None: if json_output: - # There is no document an interactive session can emit, and launching the - # full-screen menu for a caller that asked for JSON would hang whatever was - # waiting to parse it. + # An interactive session cannot emit a document. If MeshTerm started the + # full-screen menu for a caller that asked for JSON, the program that waits to + # parse the JSON would hang. raise typer.BadParameter("--json needs a subcommand; the menu has no document") from .ui.menu import run_menu - # Before prompt_toolkit takes the screen: this can change the console's own font, - # or hand the whole session to a better terminal, and the full-screen frame should - # be drawn once, in whatever it ends up in. + # This runs before prompt_toolkit takes the screen. It can change the font of the + # console, or give the whole session to a better terminal. MeshTerm must draw the + # full-screen frame one time, in the terminal where the session ends up. if _offer_a_console_that_can_draw_meshterm(console, prefs): return from .services import consolefont as vt_console_font - # The PicoCalc's whole console shares one font, so asking for the 6x8 build changes - # what the *shell* is drawn in too. Save the reader's font before the first paint - # and put it back on the way out — from the `finally` here, and from an `atexit` - # remember() arms, since ^Q and an unhandled error leave by other doors. This is - # the menu's boundary only: a scripted run prints into a console it was invited - # into and never touches its font. + # The whole console of the PicoCalc has one font. Thus a request for the 6x8 build + # also changes the font of the shell. MeshTerm saves the font of the user before the + # first paint, and puts it back when the app ends. It does this in the `finally` + # here, and in an `atexit` handler that remember() arms, because ^Q and an unhandled + # error leave through other paths. This is only the boundary of the menu. A scripted + # run prints into a console that it was invited into, and it never changes the font. saved_font = vt_console_font.remember() vt_console_font.apply(prefs.get("console_font")) - # 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. - # A SIGTERM (the Cardputer launcher's end of a held Esc, a system shutting down) - # leaves the menu the way Quit does, instead of stopping it wherever it stood. + # The interactive session keeps the stores in memory and rewrites them whole. Thus it + # is the session that claims the directory. One-shot subcommands are short and + # mostly read data. If an open menu in another window blocked `meshterm contacts`, + # that would be an obstacle and not a guard. + # A SIGTERM (the end of a held Esc from the launcher of the Cardputer, or a system + # that shuts down) makes the menu leave in the same way as Quit. It does not stop + # the menu at the place where it was. from .services import hold_to_quit hold_to_quit.leave_on_sigterm() @@ -376,16 +387,17 @@ def main_callback( 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. + # prompt_toolkit needs a real Win32 console. It reports that the console is + # absent in the language of its own internals. A traceback looks like "this + # app is broken", but the answer is "open a different terminal". Thus + # MeshTerm gives that answer. 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. + # MeshTerm logs the error before it raises it again. In this case the log is + # most important. A session that the user started with a double-click on the + # executable owns its console window. When the session dies, the window closes + # and the traceback goes with it. Then the file is the only thing to read. get_logger().exception("the interactive session raised an unhandled error") _report_log_location(console, settings.config_dir) raise @@ -394,25 +406,29 @@ def main_callback( def _offer_a_console_that_can_draw_meshterm(console: Console, prefs: Preferences) -> bool: - """On the classic Windows console, get the reader somewhere better than it. + """On the classic Windows console, move the user to a better place. - Two remedies, and they are not asked the same way. + There are two remedies. MeshTerm does not ask about them in the same way. - **Windows Terminal** first, and simply done. It is the only thing that gets the whole - app — emoji icons included, which no font can put on that console, the only Windows - fonts carrying any being proportional and a console taking none of them. Moving there - installs nothing and changes nothing, so there is one sensible answer to a question - that would be put to someone who has not seen the app yet and cannot judge it. + The first remedy is **Windows Terminal**, and MeshTerm does it without a question. It + is the only place that shows the whole app, with the emoji icons. No font can put emoji + on the classic console. The only Windows fonts that have emoji are proportional, and a + console does not take them. The move installs nothing and changes nothing. If MeshTerm + asked, it would ask someone who has not seen the app yet and cannot judge it, and the + question has only one sensible answer. - **A font** second, only where the move was not possible, and that one *is* asked — - it writes a file onto someone's machine, which is theirs to agree to. + The second remedy is **a font**. MeshTerm uses it only where the move was not possible. + MeshTerm does ask about this one, because it writes a file onto the machine of a person, + and that person must agree to it. Args: console: The console to report and ask on. - prefs: The preference set, read for a previous refusal and written on a new one. + prefs: The preference set. The function reads it for a previous refusal and writes a + new refusal to it. Returns: - ``True`` when the session is being reopened elsewhere and this one should stop. + ``True`` when MeshTerm reopens the session in another place and this session must + stop. """ from .ui.termfont import classic_console @@ -429,21 +445,21 @@ def _offer_a_console_that_can_draw_meshterm(console: Console, prefs: Preferences def _move_to_windows_terminal(console: Console) -> bool: """Reopen the session in Windows Terminal, where the app can draw all of itself. - Done rather than offered. The classic console cannot show a single icon whatever it is - given, so "reopen there?" is a question with one sensible answer, asked of someone who - has not seen the app yet and so cannot judge it — and asked at the worst moment, before - anything has been drawn. Moving costs nothing and changes nothing: no install, no - setting, one window instead of another. + MeshTerm does this and does not offer it. The classic console cannot show an icon, in + all cases. Thus "reopen there?" is a question that has one sensible answer. It is asked + of someone who has not seen the app yet and cannot judge it. It is also asked at the + worst moment, before MeshTerm has drawn anything. The move costs nothing and changes + nothing: no install, no setting, and one window instead of another. - It is announced rather than silent, and the announcement names the way to stop it, - because a program that opens a window you did not ask for should at least say so. - ``console_setup`` set to ``off`` keeps the session here for good. + MeshTerm announces the move, and does not do it silently. The announcement names the + way to stop it, because a program that opens a window that the user did not ask for + must at least say so. If ``console_setup`` is ``off``, the session stays here always. Args: console: The console to report on. Returns: - Whether Windows Terminal took over and this session should stand down. + Whether Windows Terminal took over and this session must stop. """ from .core import winterminal @@ -465,24 +481,27 @@ def _move_to_windows_terminal(console: Console) -> bool: def _offer_a_font_this_console_can_draw(console: Console, prefs: Preferences) -> None: - """Offer a font that can at least draw the charts and marks, for a reader who stays. + """Offer a font that can at least draw the charts and marks, for a user who stays. - The console does no font fallback: whatever its font lacks is a box, and its default - — Consolas, or Lucida Console under Windows PowerShell — lacks most of what MeshTerm - is drawn with, including every one of the 44 braille cells the timelines are made of. + The console has no font fallback. If its font does not have a glyph, the console shows + a box. Its default font (Consolas, or Lucida Console under Windows PowerShell) does not + have most of the glyphs that MeshTerm draws with. This includes each of the 44 braille + cells that make the timelines. - Two shapes, depending on what is already there: a machine with Windows Terminal (and - every Windows 11 machine) already has Cascadia, so the offer is only to *use* it; a - bare Windows 10 gets the offer to install the copy MeshTerm ships. + The offer has two forms, which depend on what is already there. A machine with Windows + Terminal (and each Windows 11 machine) already has Cascadia, so the offer is only to use + it. A bare Windows 10 gets the offer to install the copy that MeshTerm includes. - Declining is answered with a plain description of what declining looks like, and then - asked once more — a reader who has never seen the app has no way to know what "some - glyphs may not render" is going to mean. A second decline is remembered, in the - ``console_setup`` preference, so the question is asked twice in total and never again. + If the user declines, MeshTerm answers with a plain description of what the refusal + looks like, and asks one more time. A user who has never seen the app cannot know what + "some glyphs may not render" will mean. MeshTerm remembers a second refusal in the + ``console_setup`` preference. Thus it asks the question two times in total, and then + never again. Args: console: The console to ask on. - prefs: The preference set, read for a previous refusal and written on a new one. + prefs: The preference set. The function reads it for a previous refusal and writes a + new refusal to it. """ from .core import consolefont from .ui.termfont import face_draws_charts, installed_chart_font @@ -526,7 +545,7 @@ def _offer_a_font_this_console_can_draw(console: Console, prefs: Preferences) -> prefs.set("console_setup", "off") try: prefs.save() - except (OSError, RuntimeError) as exc: # a read-only config dir is not fatal here + except (OSError, RuntimeError) as exc: # a read-only config dir is not a fatal error here get_logger().warning("could not remember the console setup choice: %s", exc) console.print() console.print("[muted] Leaving it as it is. Change your mind on the Preferences[/muted]") @@ -535,7 +554,7 @@ def _offer_a_font_this_console_can_draw(console: Console, prefs: Preferences) -> def _use_a_better_console_font(console: Console, installed: str | None) -> None: - """Install (when needed) and select the font, and say plainly what happened.""" + """Install the font if it is necessary, select it, and say plainly what happened.""" from .core import consolefont face = installed @@ -551,27 +570,29 @@ def _use_a_better_console_font(console: Console, installed: str | None) -> None: console.print(f"[ok]✓[/ok] Now drawing with [accent]{face}[/accent].") get_logger().info("console font set to %s", face) else: - # Installed but not selected: the font is there for next time and for the - # console's own properties dialog, which is worth saying rather than swallowing. + # The font is installed but not selected. It is there for the next time, and for the + # properties dialog of the console. MeshTerm says this and does not hide it. console.print(f"[warn]⚠[/warn] {face} is installed, but this console kept its own") console.print(" font. It will be available the next time you open one.") get_logger().warning("console refused the font %s", face) def _asks_yes(console: Console, prompt: str, *, default: bool = False) -> bool: - """Ask a yes/no question on the console. + """Ask a yes or no question on the console. - Not a TUI dialog: this runs before prompt_toolkit owns the screen, alongside the other - plain-console reports here. An unanswerable prompt — no stdin, a closed pipe — takes - the default, and each caller's default is the answer that costs the reader least. + This is not a TUI dialog. It runs before prompt_toolkit owns the screen, with the other + reports on the plain console here. If nobody can answer the prompt (no stdin, or a + closed pipe), the function uses the default. The default of each caller is the answer + that costs the user the least. Args: console: The console to ask on. - prompt: The question, ending in its own spacing. - default: The answer an empty line means, and the one an unreadable stdin takes. + prompt: The question, which ends with its own spacing. + default: The answer that an empty line means, and the answer that an unreadable stdin + gives. Returns: - Whether the reader said yes. + Whether the user said yes. """ console.print(prompt, end="") try: @@ -585,7 +606,7 @@ def _asks_yes(console: Console, prompt: str, *, default: bool = False) -> bool: 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.""" + """Name the log after a fault, because the log 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]") @@ -593,11 +614,12 @@ def _report_log_location(console: Console, config_dir: Path) -> None: def _report_no_windows_console(console: Console) -> None: - """Explain a console MeshTerm cannot draw in, and how to get one it can. + """Explain that MeshTerm cannot draw in this console, and how to get a console that 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. + The code gets here when prompt_toolkit finds no screen buffer of a Win32 console. This + happens in a Git Bash, MSYS, or Cygwin shell. These announce themselves as ``xterm``, + and they are not Windows consoles. It also happens in a process whose output was + redirected away from a console. """ console.print("[err]✗[/err] MeshTerm needs a Windows console, and could not find one.") console.print() @@ -618,22 +640,22 @@ def _report_no_windows_console(console: Console) -> None: help="Show which platform MeshTerm runs as, and why", ) def platform_command() -> None: - """Print the resolved platform and every input the resolution looked at. + """Print the resolved platform and each input that the resolution examined. - 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. + This is a diagnostic for the platform seam (refer to ``meshterm/platforms.py``). It + confirms the flavour that a given run would use, and why. It shows the ``--platform`` + and ``MESHTERM_PLATFORM`` inputs, the value of ``/proc/device-tree/model`` (``None`` off + the real hardware), and the input that decided the result. - It also reports the terminal's own emoji verdict, which is a separate question from - the flavour and the usual reason a Windows session looks plainer than the screenshots - (see :func:`meshterm.ui.termfont.emoji_support`). + It also reports the emoji decision for the terminal. This is a separate question from + the flavour. It is the usual reason why a Windows session looks plainer than the + screenshots (refer to :func:`meshterm.ui.termfont.emoji_support`). """ from .ui import fields from .ui.report import Facts - assert _platform_resolution is not None # set by the callback that always runs first - assert _state is not None # ditto + assert _platform_resolution is not None # the callback sets it, and it always runs first + assert _state is not None # the same r = _platform_resolution emoji = emoji_support() renderers.for_format(_state.output, _state.console).render( @@ -668,27 +690,26 @@ def platform_command() -> None: help="Print every mark, icon, and colour MeshTerm draws", ) def specimen_command() -> None: - """Print the visual-language specimen for the active platform. + """Print the specimen of the visual language 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-lyra`` on a desktop it - previews that flavour. See :mod:`meshterm.ui.specimen`. + The card shows each mark, icon funnel, colour scale, and fold. It is drawn through the + same theme and glyph code that the TUI uses. Thus on the console of the PicoCalc it is + the acceptance screen for the font and the palette. With ``--platform picocalc-lyra`` on + a desktop, it shows a preview of that flavour. Refer to :mod:`meshterm.ui.specimen`. - The one command that keeps its colour, and it builds its own themed console to do it - (rather than the plain one every other subcommand prints on — see - :mod:`meshterm.ui.script`). This is not scripted output that happens to be pretty: the - colour *is* the output. A monochrome specimen would test nothing. + This is the only command that keeps its colour. It builds its own console with the + theme to do this (and does not use the plain console that each other subcommand prints + on, refer to :mod:`meshterm.ui.script`). It is not scripted output that is also pretty. + The colour is the output. A specimen without colour would test nothing. - It is also the one command that *refuses* ``--json``, loudly and as a usage error. - There is no data behind a colour card, so a document of it would be either a lie or an - empty gesture — and a loud refusal is the same answer this project already gives for - the map, the dashboard and the live feed, which simply have no subcommand to refuse - from. + It is also the only command that refuses ``--json``, with a clear message and as a usage + error. There is no data behind a colour card. A document of it would be a lie or an + empty gesture. The project gives the same clear refusal for the map, the dashboard, and + the live feed. These have no subcommand that could refuse. """ from .ui.specimen import specimen_lines - assert _state is not None # set by the callback that always runs first + assert _state is not None # the callback sets it, and it always runs first if _state.output is not OutputFormat.PLAIN: raise typer.BadParameter( "specimen has no machine-readable output — its output is the colour" @@ -699,18 +720,20 @@ def specimen_command() -> None: console.print(line) -#: Globals the emulated MeshTerm must not inherit: the emulator names its platform itself, -#: refuses a machine-readable face, and answers ``--version`` before anything runs. +#: Global options that the emulated MeshTerm must not inherit. The emulator names its own +#: platform, it refuses a machine-readable face, and it answers ``--version`` before +#: anything runs. _NOT_FORWARDED = {"platform", "json_output", "version", "help"} def _forwarded_globals(ctx: typer.Context) -> list[str]: - """The global options this invocation set, spelled again for the emulated MeshTerm. + """The global options that this run set, written again for the emulated MeshTerm. - A global is lifted ahead of the subcommand wherever it was typed (see - :func:`_globals_first`), so ``meshterm emulate picocalc-lyra --mock`` parses ``--mock`` - on the outer MeshTerm. The emulated one is a second invocation, and it needs the same - flags, so they are read back off the root's parsed values and written out again. + MeshTerm moves a global option ahead of the subcommand in all cases, wherever the user + typed it (refer to :func:`_globals_first`). Thus ``meshterm emulate picocalc-lyra + --mock`` parses ``--mock`` in the outer MeshTerm. The emulated MeshTerm is a second run, + and it needs the same flags. Thus the function reads them from the parsed values of the + root, and writes them out again. """ root = ctx.find_root() out: list[str] = [] @@ -746,20 +769,20 @@ def emulate_command( None, "--archive", help="Install the font from this downloaded release archive" ), ) -> None: - """Run MeshTerm in a window that shows a handheld's display, pixel for pixel. + """Run MeshTerm in a window that shows the display of a handheld, pixel for pixel. - Not a strict emulator: MeshTerm runs as itself on this machine, and what the window - reproduces is the device's display and keyboard — its panel, its grid, its font, the - colours it can show, the keys that drive its F-key lane (see :mod:`meshterm.emulator`). - Every global option given (``--mock``, ``--port``, ``--ble``…) is passed on to the - MeshTerm the window runs. + This is not a strict emulator. MeshTerm runs as itself on this machine. The window + reproduces the display and the keyboard of the device: its display, its grid, its font, + the colours that it can show, and the keys that drive its F-key lane (refer to + :mod:`meshterm.emulator`). The command passes each global option that the user gives + (``--mock``, ``--port``, ``--ble``…) to the MeshTerm that runs in the window. - Like ``specimen`` it has no machine-readable face — a window is no document — so it - refuses ``--json`` as a usage error. + Like ``specimen``, it has no machine-readable face, because a window is not a document. + Thus it refuses ``--json`` as a usage error. """ from .emulator.__main__ import start - assert _state is not None # set by the callback that always runs first + assert _state is not None # the callback sets it, and it always runs first if _state.output is not OutputFormat.PLAIN: raise typer.BadParameter("emulate has no machine-readable output — it opens a window") @@ -786,49 +809,52 @@ def emulate_command( def run_tool_command(tool: Tool, params: dict) -> None: - """Execute a tool from a CLI subcommand and render its result. + """Run 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. + The function builds the device connection and takes it down again in one event loop. + Thus the ``meshcore`` client is never used across loops. Args: tool: The tool to run. - params: Parameters parsed from the subcommand's options. + params: The parameters that MeshTerm parsed from the options of the subcommand. """ - assert _state is not None # set by the callback before any subcommand runs + assert _state is not None # the callback sets it before any subcommand runs try: result = asyncio.run(_drive(_execute_and_render(tool, params, _state), _state)) except DeviceSelectionError as exc: - # No companion could be picked at all: nothing was transmitted, and no retry is - # going to help until the caller attaches a device or names one. + # MeshTerm could not pick any companion. It transmitted nothing, and a retry does + # not help until the caller attaches a device or names one. raise _reported(tool, exc, exitcodes.NO_DEVICE) from exc except DeviceCommandError as exc: - # A device was reached and the operation failed. Worth retrying. + # MeshTerm reached a device, and the operation failed. A retry is useful. raise _reported(tool, exc, exitcodes.DEVICE) from exc except (DeviceConfigError, PreferenceError) as exc: - # A value we were given is wrong — a bad setting key, an out-of-range preference. - # Retrying changes nothing; the caller has to change what it asked for. + # A value that the caller gave is wrong, for example a bad setting key or a + # preference that is out of range. A retry changes nothing. The caller must change + # what it asked for. raise _reported(tool, exc, exitcodes.FAILURE) from exc except (typer.BadParameter, typer.Abort, typer.Exit): - # A tool that rejected one of its own arguments (an unresolvable `--to`, a - # malformed path) raised the same thing the parser would have. It is a usage - # error, Click already knows how to print it and what to exit with, and it is not - # a fault — so it passes through without a traceback or a pointer to the log. + # A tool rejected one of its own arguments (for example a `--to` that cannot be + # resolved, or a malformed path). It raised the same exception that the parser + # would raise. It is a usage error. Click already knows how to print it and which + # exit status to use. It is not a fault. Thus it goes through, without a traceback + # and without a pointer to the log. raise 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 reads as a clean message, not a traceback, and as a device failure. + # If the serial link drops (the device is unplugged or loses power during a + # command), a one-shot scripted run cannot recover, as the interactive menu can + # offer. But the user gets a clean message and not a traceback, and the exit status + # is a device failure. if is_connection_lost(exc): raise _reported( tool, "the connection to the device was lost (it may have been unplugged or powered off)", exitcodes.DEVICE, ) 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. + # Each other exception is a real fault. It still reaches the terminal as a traceback, + # because a person who looks at the terminal wants to see it. It also goes into the + # log. The log is the copy that stays after the terminal closes, and it is the one + # thing that is useful to attach to a bug report. get_logger().exception("%s raised an unhandled error", tool.name) _report_log_location(script.stderr_console(), _state.settings.config_dir) raise @@ -839,58 +865,60 @@ def run_tool_command(tool: Tool, params: dict) -> None: def _stdin_is_a_person() -> bool: """Whether somebody is there to answer a prompt. - The test a tool actually wants before it stops and asks: a redirected or piped stdin - means nobody is watching, and a command that blocks there has hung rather than failed. - ``tx-optimize`` used to ask ``--json`` instead — a flag about output format, which - answers a different question and answered this one wrong in both directions. + This is the test that a tool needs before it stops and asks. If stdin is redirected or + piped, nobody watches, and a command that blocks there hangs and does not fail. + ``tx-optimize`` used to check ``--json`` instead. That flag is about the output format. + It answers a different question, and it gave a wrong answer to this question in both + directions. - ``isatty`` alone is not enough on Windows, and the gap is exactly the case this exists - for. ``NUL`` is a *character device*, so a scheduled task's empty stdin answers yes — - and :func:`getpass.getpass` on Windows then reads the console directly through - ``msvcrt`` rather than through stdin, and waits forever for a keypress nobody is there - to make. ``GetConsoleMode`` succeeds only on a real console handle, which is the - question being asked. + On Windows, ``isatty`` alone is not enough, and the gap is exactly the case that this + function exists for. ``NUL`` is a character device. Thus the empty stdin of a scheduled + task answers yes. Then :func:`getpass.getpass` on Windows reads the console directly + through ``msvcrt`` and not through stdin, and it waits forever for a key press that + nobody is there to make. ``GetConsoleMode`` succeeds only on the handle of a real + console, and that is the question that the function asks. Returns: - ``True`` only where stdin is a terminal somebody could type into. + ``True`` only where stdin is a terminal that somebody can type into. """ try: if not sys.stdin.isatty(): return False - except (AttributeError, ValueError): # pragma: no cover - stdin closed or replaced + except (AttributeError, ValueError): # pragma: no cover - stdin is closed or replaced return False if sys.platform != "win32": return True - try: # pragma: no cover - the branch is Windows-only and needs a real handle + try: # pragma: no cover - the branch is for Windows only and needs a real handle import ctypes import msvcrt mode = ctypes.c_uint() handle = msvcrt.get_osfhandle(sys.stdin.fileno()) return bool(win32dll.kernel32().GetConsoleMode(handle, ctypes.byref(mode))) - except Exception: # pragma: no cover - no console, or a stubbed kernel32 + except Exception: # pragma: no cover - there is no console, or kernel32 is a stub return False def _reported(tool: Tool, problem: object, code: int) -> typer.Exit: - """Log a failure, say so on stderr, and build the exit it should end on. + """Log a failure, say so on stderr, and build the exit with which the run must end. - The message goes to stderr in the ``program: what went wrong`` shape every utility - uses, so a caller redirecting stdout still sees it and a caller parsing stdout never - has to filter it out. It carries no mark and no colour — the exit status is what says - this failed, which is why it is classified (see :mod:`meshterm.core.exitcodes`). + The message goes to stderr in the form ``program: what went wrong``, which each utility + uses. Thus a caller that redirects stdout still sees it, and a caller that parses stdout + never has to filter it out. It has no mark and no colour. The exit status shows that the + run failed, and this is the reason why the status is classified (refer to + :mod:`meshterm.core.exitcodes`). - It is logged at warning rather than error because these are the *ordinary* failures — - an absent device, a wrong password — and a log holding only crashes cannot answer - "what happened just before it". + MeshTerm logs it at the level warning and not error, because these are the ordinary + failures, for example an absent device or a wrong password. A log that holds only + crashes cannot answer the question "what happened just before it". Args: tool: The tool that failed, for the log line. - problem: The exception or message to report. + problem: The exception or the message to report. code: The status to exit with. Returns: - The :class:`typer.Exit` for the caller to raise. + The :class:`typer.Exit` that the caller must raise. """ get_logger().warning("%s failed: %s", tool.name, problem) script.stderr_console().print(f"meshterm: {problem}", style="err", highlight=False) @@ -898,15 +926,16 @@ def _reported(tool: Tool, problem: object, code: int) -> typer.Exit: async def _drive(coro, ctx: AppContext): # noqa: ANN201 - passes the awaited value through - """Await a coroutine then disconnect the device (but keep the repo open). + """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. + ctx: The application context whose device must be closed afterward. Returns: - Whatever ``coro`` returned — a :class:`~meshterm.tools.base.ToolResult` on the - scripted path, whose ``exit_code`` the caller turns into the process status. + The value that ``coro`` returned. On the scripted path, this is a + :class:`~meshterm.tools.base.ToolResult`, and the caller changes its ``exit_code`` + to the status of the process. """ try: return await coro @@ -914,32 +943,33 @@ async def _drive(coro, ctx: AppContext): # noqa: ANN201 - passes the awaited va if ctx._device is not None: try: await ctx._device.disconnect() - except Exception: # noqa: BLE001 - a dead/lost link must not crash teardown + except Exception: # noqa: BLE001 - a dead or lost link must not crash the teardown pass ctx._device = None async def _execute_and_render(tool: Tool, params: dict, ctx: AppContext) -> ToolResult: - """Run a tool, render the answer it stated, and close with what it has to say. + """Run a tool, render the answer that it stated, and close with what it has to say. - The one place a report becomes output. The tool says what happened as data and this - hands it to whichever renderer ``--json`` selected, exactly as ``exit_code`` is stated - here and turned into a process status by the caller — which is what lets a third format - be a new renderer rather than an edit to twenty tools. + This is the only place where a report becomes output. The tool states what happened as + data, and this function gives the data to the renderer that ``--json`` selected. The + ``exit_code`` works in the same way: the tool states it here, and the caller changes it + to the status of the process. Thus a third format is a new renderer, and it is not an + edit to twenty tools. - A tool's ``message`` — "✓ applied 3 changes", "✓ traced Alice" — goes to **stderr**, - like every other acknowledgement (see :meth:`~meshterm.ui.surface.PlainUi.ack`). It is - the closing line rather than the answer, so it must stay out of a redirect; it is also - the count a person at a prompt actually wanted after a command that scrolled, so - dropping it was throwing it away to protect a stream it was never going to reach. + The ``message`` of a tool ("✓ applied 3 changes", "✓ traced Alice") goes to **stderr**, + as each other acknowledgement does (refer to :meth:`~meshterm.ui.surface.PlainUi.ack`). + It is the closing line and not the answer, so it must stay out of a redirect. It is also + the count that a person at a prompt wanted after a command that scrolled. MeshTerm + once dropped it. That threw it away to protect a stream that it never reached. Args: - tool: The tool to execute. - params: Parameters for the tool. + tool: The tool to run. + params: The parameters for the tool. ctx: The application context. Returns: - The tool's :class:`ToolResult`. + The :class:`ToolResult` of the tool. """ result = await tool.execute(ctx, params) renderers.for_format(ctx.output, ctx.console).render(result.report) @@ -949,7 +979,7 @@ async def _execute_and_render(tool: Tool, params: dict, ctx: AppContext) -> Tool def _register_all() -> None: - """Register every tool's CLI subcommand with the Typer app.""" + """Register the CLI subcommand of each tool with the Typer app.""" for tool in all_tools(): tool.register_cli(app) @@ -958,17 +988,18 @@ _register_all() def _owns_its_console() -> bool: - """Whether this process is the only one on its console — i.e. it was double-clicked. + """Whether this process is the only one on its console, that is, the user double-clicked it. - 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. + Windows gives a console to a process that Explorer starts, and destroys the console when + that process ends. Thus the console shows an error message and erases it at the same + instant. If a shell starts the process, the console belongs to the shell and stays. - ``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. + ``GetConsoleProcessList`` shows the difference: it reports how many processes are + attached to this console. If the count is one, MeshTerm is alone, and it holds a window + that dies with it. Returns: - ``True`` only on Windows, and only when nothing else shares the console. + ``True`` only on Windows, and only when no other process shares the console. """ if sys.platform != "win32": return False @@ -977,7 +1008,7 @@ def _owns_its_console() -> bool: buffer = (ctypes.c_uint * 4)() count = win32dll.kernel32().GetConsoleProcessList(buffer, 4) - except Exception: # pragma: no cover - no console at all, or a stubbed kernel32 + except Exception: # pragma: no cover - there is no console at all, or kernel32 is a stub return False return count == 1 @@ -985,16 +1016,18 @@ def _owns_its_console() -> bool: 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. + After a failure, the function keeps the window open when MeshTerm owns it. If it did + not, the console would draw the error and destroy it together, and the person who must + read the error would see only a flash. This happens only on that path. A successful run + must not make anyone press a key, and a run from a shell leaves its output on the screen + in any case. """ try: - # No wildcard expansion on Windows. Click expands an argument holding `*` into - # the files it matches — even one the shell passed through quoted, which it can't - # tell apart — so `chat send --scope '*'` arrived as the working directory's file - # names. No MeshTerm argument is a file glob, and `*` is the wildcard region. + # No wildcard expansion on Windows. Click expands an argument that has `*` to the + # files that match it. It does this also when the shell passed the argument + # through in quotes, because Click cannot see the difference. Thus + # `chat send --scope '*'` arrived as the file names of the working directory. No + # argument of MeshTerm is a file glob, and `*` is the wildcard region. app(windows_expand_args=False) except SystemExit as exit_request: if exit_request.code and _owns_its_console(): @@ -1007,12 +1040,12 @@ def main() -> None: def _wait_before_the_window_closes() -> None: - """Ask for a keypress so a message stays readable, and never fail doing it.""" + """Ask for a key press so that a message stays readable. This function never fails.""" try: print() print("-- Press Enter to close this window --") input() - except Exception: # pragma: no cover - stdin closed, redirected, or gone + except Exception: # pragma: no cover - stdin is closed, redirected, or gone pass diff --git a/meshterm/context.py b/meshterm/context.py index 16c78e1..c775206 100644 --- a/meshterm/context.py +++ b/meshterm/context.py @@ -1,9 +1,9 @@ # SPDX-License-Identifier: Apache-2.0 -"""The application context: a small dependency container passed to every tool. +"""The application context: a small dependency container that MeshTerm gives to each tool. -``AppContext`` owns the shared, expensive singletons (console, settings, repository, -logger) and lazily manages the device connection so tools that don't touch the radio -never open a serial port. +``AppContext`` owns the shared singletons that are expensive to make (console, settings, +repository, logger). It manages the device connection lazily. Thus a tool that does not +use the radio never opens a serial port. """ from __future__ import annotations @@ -53,71 +53,78 @@ if TYPE_CHECKING: @dataclass(slots=True) class AppContext: - """Shared services and per-invocation state handed to tools. + """The shared services and the state of one run, which MeshTerm gives to tools. Attributes: - console: Rich console for all rendering. - settings: Loaded application settings — where things live and which device to - talk to (config directory, database, named profiles). *How MeshTerm behaves* - is ``preferences``, not this. - preferences: MeshTerm's own preferences, backed by - ``/preferences.toml`` and edited on the Preferences page. Defaults - to a set loaded from that file when not injected, and is installed as the - process-wide :func:`~meshterm.core.preferences.current` set so the render - layer — which has no context to reach through — reads the same values. - repo: Database repository. - profile: Active device profile, if one was resolved. - device_store: Store for the remembered "last known good" device. - admin_store: Store for remembered remote-node admin passwords. - advert_store: Store for per-device background-advert schedules (defaults to - ``/adverts.json`` when not injected). - remote_store: Store for remote-node admin state — cached settings and CLI - history (defaults to ``/remote.json`` when not injected). - watch_store: Store for the Watchtower — watched nodes, rules, and the alert - log (defaults to ``/watchtower.json`` when not injected). - courier_store: Store for the Courier's outbox — queued, delivered, and - given-up messages (defaults to ``/courier.json``). - mute_store: Store for muted channel notifications — the channels whose new - messages don't raise the unread badge (defaults to - ``/mutes.json`` when not injected). - region_store: Store for the regions known by name and each channel's send scope — - what a scoped flood is resolved against (defaults to - ``/regions.json`` when not injected). - channel_store: Store for channels created through MeshTerm, replayed into a device - that forgot them (a firmware-less radio bridge). Keyed by device public key; - defaults to ``/channels.json`` when not injected. - settings_store: Store for settings changed through MeshTerm, offered back on connect to - a device that forgot them (a firmware-less radio bridge). Keyed by device public - key; defaults to ``/settings.json`` when not injected. - contact_store: Store for contacts read through MeshTerm, merged back into the contact - lists for a device that forgot them (a firmware-less radio bridge). Keyed by device - public key; defaults to ``/contacts.json`` when not injected. - room_store: Store for the rooms this machine has joined — each room's password and - the access it granted (defaults to ``/rooms.json`` when not - injected). - mock: Whether the simulator device is in use. - port_override: Explicit serial port (from ``--port`` or the interactive picker), - overriding the profile. - ble_override: Explicit Bluetooth address (from ``--ble`` or the interactive picker), - selecting the BLE transport. Takes precedence over ``port_override``. - tcp_override: Explicit network address ``host:port`` (from ``--tcp`` or the - interactive picker), selecting the TCP transport. Takes precedence over + console: The Rich console for all rendering. + settings: The loaded application settings. They say where things are and which + device to talk to (config directory, database, named profiles). How MeshTerm + behaves is in ``preferences``, not here. + preferences: The preferences of MeshTerm itself. They are backed by + ``/preferences.toml``, and the user edits them on the Preferences + page. If the caller does not inject them, the context loads them from that file. + The context installs them as the process-wide + :func:`~meshterm.core.preferences.current` set. Thus the render layer has no + context to reach through, and it reads the same values. + repo: The database repository. + profile: The active device profile, if the context resolved one. + device_store: The store for the remembered "last known good" device. + admin_store: The store for the remembered admin passwords of remote nodes. + advert_store: The store for the background-advert schedules of each device (if the + caller does not inject it, the default is ``/adverts.json``). + remote_store: The store for the admin state of remote nodes: cached settings and CLI + history (if the caller does not inject it, the default is + ``/remote.json``). + watch_store: The store for the Watchtower: watched nodes, rules, and the alert log + (if the caller does not inject it, the default is + ``/watchtower.json``). + courier_store: The store for the outbox of the Courier: queued, delivered, and + given-up messages (the default is ``/courier.json``). + mute_store: The store for muted channel notifications. These are the channels whose + new messages do not raise the unread badge (if the caller does not inject it, the + default is ``/mutes.json``). + region_store: The store for the regions that have a name, and for the send scope of + each channel. MeshTerm resolves a scoped flood against it (if the caller does not + inject it, the default is ``/regions.json``). + channel_store: The store for channels that the user created through MeshTerm. The + context replays them into a device that forgot them (a radio bridge without + firmware). The store uses the public key of the device as its key. If the caller + does not inject it, the default is ``/channels.json``. + settings_store: The store for settings that the user changed through MeshTerm. + MeshTerm offers them again at connection to a device that forgot them (a radio + bridge without firmware). The store uses the public key of the device as its key. + If the caller does not inject it, the default is ``/settings.json``. + contact_store: The store for contacts that MeshTerm read. MeshTerm merges them back + into the contact lists for a device that forgot them (a radio bridge without + firmware). The store uses the public key of the device as its key. If the caller + does not inject it, the default is ``/contacts.json``. + room_store: The store for the rooms that this machine joined: the password of each + room and the access that it granted (if the caller does not inject it, the + default is ``/rooms.json``). + mock: Whether the context uses the simulator device. + port_override: An explicit serial port (from ``--port`` or the interactive picker), + which overrides the profile. + ble_override: An explicit Bluetooth address (from ``--ble`` or the interactive + picker), which selects the BLE transport. It has precedence over + ``port_override``. + tcp_override: An explicit network address ``host:port`` (from ``--tcp`` or the + interactive picker), which selects the TCP transport. It has precedence over ``ble_override`` and ``port_override``. - ble_pin: Optional BLE pairing PIN for the chosen Bluetooth device. - output: Which format a scripted run prints its answer in (see - :class:`~meshterm.ui.renderers.OutputFormat`). A tool never reads this to - decide *what* to say — it states its answer as a - :class:`~meshterm.ui.report.Report` and the CLI boundary picks the renderer — - and the two streaming commands read it only to open the right kind of stream. - interactive: Whether this run may stop and ask a person something. False for every - scripted run whose stdin is not a terminal, which is the question a tool - actually wants answered before it prompts. ``tx-optimize`` used to ask - ``--json`` instead, and a flag about *output format* cannot answer it: without - the flag, a scheduled run on a terminal would sit waiting for a password. - selected_device: The discovered device chosen for this session, when known, so it - can be remembered after a successful connection. - explicit_selection: Whether ``--port``/``--ble``/``--profile`` was passed explicitly - (which suppresses the interactive picker and auto-discovery). + ble_pin: The optional BLE pairing PIN for the chosen Bluetooth device. + output: The format in which a scripted run prints its answer (refer to + :class:`~meshterm.ui.renderers.OutputFormat`). A tool never reads this to decide + what to say. It states its answer as a :class:`~meshterm.ui.report.Report`, and + the CLI boundary picks the renderer. The two streaming commands read it only to + open the correct type of stream. + interactive: Whether this run can stop and ask a person a question. It is false for + each scripted run whose stdin is not a terminal. A tool must have the answer to + this question before it prompts. ``tx-optimize`` used to check ``--json`` + instead. A flag about the output format cannot answer it. Without the flag, a + scheduled run on a terminal would wait for a password. + selected_device: The discovered device that was chosen for this session, if it is + known. MeshTerm can remember it after a successful connection. + explicit_selection: Whether the user passed ``--port``, ``--ble``, or ``--profile`` + explicitly. This suppresses the interactive picker and auto-discovery. """ console: Console @@ -152,20 +159,22 @@ class AppContext: _active_transport: str | None = field(default=None, init=False, repr=False) _active_address: str | None = field(default=None, init=False, repr=False) _active_endpoint: str | None = field(default=None, init=False, repr=False) - #: A live ``bleak.BLEDevice`` handed to :meth:`reconnect` by the flow that just heard the - #: companion advertise again. Preferred over the startup scan's handle when reopening the - #: link, because a device that dropped and came back is a *new* peripheral to the OS and - #: the older handle no longer names it. Typed ``object`` so ``bleak`` stays optional. + #: A live ``bleak.BLEDevice`` that the flow gives to :meth:`reconnect`. The flow just heard + #: the companion advertise again. MeshTerm prefers it to the handle of the startup scan + #: when it reopens the link. A device that dropped and came back is a new peripheral to + #: the OS, and the older handle does not name it now. The type is ``object``, so that + #: ``bleak`` stays optional. _ble_handle: object | None = field(default=None, init=False, repr=False) unpair_on_exit: bool = field(default=False, init=False, repr=False) - #: Set by :meth:`announce_reboot` once a reboot command has gone out, so the reconnect - #: dialog presents the drop as a reboot in progress rather than a surprise unplug. - #: Cleared by :meth:`take_reboot` in the dialog that consumes it (see + #: :meth:`announce_reboot` sets this after a reboot command has gone out. Then the + #: reconnect dialog shows the drop as a reboot in progress, and not as a surprise unplug. + #: :meth:`take_reboot` clears it in the dialog that uses it (refer to #: :func:`meshterm.ui.menu._handle_disconnect`). reboot_in_progress: bool = field(default=False, init=False, repr=False) - #: Set alongside :attr:`reboot_in_progress`: wakes the session's disconnect watcher at - #: once, without waiting for a liveness poll to *observe* the drop — which it may never - #: do, since a board behind a USB-UART bridge keeps its port through a reboot. + #: MeshTerm sets this together with :attr:`reboot_in_progress`. It wakes the disconnect + #: watcher of the session at once. The watcher does not wait for a liveness poll to + #: observe the drop, and the poll can fail to observe it at all, because a board + #: behind a USB-UART bridge keeps its port through a reboot. _link_going_down: asyncio.Event = field(default_factory=asyncio.Event, init=False, repr=False) _resume_intent: tuple[bool, bool, bool] | None = field(default=None, init=False, repr=False) _events: EventHub | None = field(default=None, init=False, repr=False) @@ -182,11 +191,12 @@ class AppContext: _ui: Ui | None = field(default=None, init=False, repr=False) def __post_init__(self) -> None: - """Load the preferences and derive the stores' default locations, where not injected. + """Load the preferences and derive the default locations of the stores. - The preferences are also installed as the process-wide set (see - :func:`~meshterm.core.preferences.install`), because the session's renderer and the - stores built without a context still have to read them. + This applies to each item that the caller did not inject. The method also installs + the preferences as the process-wide set (refer to + :func:`~meshterm.core.preferences.install`). The renderer of the session and the + stores that are built without a context must read them. """ if self.preferences is None: self.preferences = Preferences.load(self.settings.config_dir / PREFERENCES_FILENAME) @@ -215,22 +225,22 @@ class AppContext: @property def profile_name(self) -> str | None: - """Name of the active profile, if any.""" + """The name of the active profile, if there is one.""" return self.profile.name if self.profile else None @property def is_connected(self) -> bool: - """Whether a device connection is currently open.""" + """Whether a device connection is open now.""" return self._device is not None @property def active_port(self) -> str | None: - """The serial port the current connection is open on, if any (``None`` for --mock/BLE). + """The serial port of the current connection, if there is one (``None`` for --mock/BLE). - Set when a real *serial* connection is opened so the reconnect flow knows which OS - port to wait on. ``None`` for the simulator and for BLE/TCP connections (which have no - serial port). Falls back to an explicit ``--port`` override when a connection hasn't - recorded one yet. + MeshTerm sets this when it opens a real serial connection. Then the reconnect flow + knows which OS port to wait for. It is ``None`` for the simulator and for BLE and TCP + connections, which have no serial port. If a connection has not recorded a port yet, + the value is the explicit ``--port`` override. """ if self.mock or self.active_transport in ("ble", "tcp", "spi"): return None @@ -238,11 +248,13 @@ class AppContext: @property def active_address(self) -> str | None: - """The Bluetooth address of the current BLE connection, if any (``None`` otherwise). + """The Bluetooth address of the current BLE connection, if there is one. - Set when a real *BLE* connection is opened, so post-session teardown (e.g. the quit - dialog's unpair step) can address the peripheral even after the device handle is torn - down. ``None`` for the simulator and for serial connections, which have no BLE address. + The value is ``None`` in all other cases. MeshTerm sets this when it opens a real BLE + connection. Then the teardown after the session (for example the unpair step of the + quit dialog) can address the peripheral, also after the handle of the device is + closed. It is ``None`` for the simulator and for serial connections, which have no BLE + address. """ if self.mock or self.active_transport != "ble": return None @@ -250,11 +262,12 @@ class AppContext: @property def active_endpoint(self) -> str | None: - """The network ``host:port`` of the current TCP connection, if any (``None`` otherwise). + """The network ``host:port`` of the current TCP connection, if there is one. - Set when a real *TCP* connection is opened, so the reconnect flow and header can name - the endpoint. ``None`` for the simulator and for serial/BLE connections. Falls back to - an explicit ``--tcp`` override when a connection hasn't recorded one yet. + The value is ``None`` in all other cases. MeshTerm sets this when it opens a real TCP + connection. Then the reconnect flow and the header can name the endpoint. It is + ``None`` for the simulator and for serial and BLE connections. If a connection has + not recorded an endpoint yet, the value is the explicit ``--tcp`` override. """ if self.mock or self.active_transport != "tcp": return None @@ -262,12 +275,13 @@ class AppContext: @property def active_transport(self) -> str | None: - """Which transport the current or selected connection uses. + """The transport that the current or selected connection uses. - One of ``"serial"``, ``"ble"``, ``"tcp"``, ``"spi"``, or ``None`` for the simulator. - For a real device it reflects the open connection when one exists, otherwise the - transport implied by the pending selection (an explicit ``--tcp`` selects TCP, - ``--ble`` selects BLE, ``--spi`` the SPI radio), defaulting to serial. + The value is ``"serial"``, ``"ble"``, ``"tcp"``, or ``"spi"``, or ``None`` for the + simulator. For a real device, it is the transport of the open connection if there is + one. If not, it is the transport that the pending selection means (an explicit + ``--tcp`` selects TCP, ``--ble`` selects BLE, and ``--spi`` selects the SPI radio). + The default is serial. """ if self.mock: return None @@ -280,44 +294,47 @@ class AppContext: return "ble" if self.ble_override else "serial" async def link_alive(self) -> bool: - """Whether the current device's transport link is still up (best-effort, non-invasive). + """Whether the transport link of the current device is still up. - Delegates to the connected device's :meth:`~meshterm.core.connection.Device.link_present` - — OS port enumeration for serial, the BLE client's connection flag for Bluetooth — so - the session's liveness watcher is transport-agnostic. Returns ``False`` when nothing is - connected (there is no live link), and ``True`` on any check hiccup so a transient - lookup failure never fakes a disconnect. + This is a best-effort check, and it does not disturb the link. The method calls + :meth:`~meshterm.core.connection.Device.link_present` of the connected device. For + serial, this is the enumeration of OS ports. For Bluetooth, it is the connection flag + of the BLE client. Thus the liveness watcher of the session does not depend on the + transport. The method returns ``False`` when nothing is connected, because there is no + live link. If the check fails, it returns ``True``. Thus a temporary failure of the + lookup never gives a false disconnect. """ device = self._device if device is None: return False try: return await device.link_present() - except Exception: # noqa: BLE001 - a liveness-check failure must not fake a disconnect + except Exception: # noqa: BLE001 - a failure of the liveness check must not fake a disconnect return True def announce_reboot(self) -> None: - """Declare the link down because we just told the companion to reboot. + """Declare the link down, because MeshTerm just told the companion to reboot. - Called *after* the reboot command was sent (never before: the watcher it wakes - cancels the screen that sent it, and a cancel mid-write could leave the command half - out). From then on the device is not ours to talk to until it comes back, so the - session's disconnect watcher (:meth:`link_going_down`) fires at once and the - reconnect dialog takes over — rather than hoping a liveness poll catches the drop, - which misses a quick reboot entirely where the serial port never goes away. + Call this method after MeshTerm sent the reboot command, and never before. The + watcher that it wakes cancels the screen that sent the command. If the cancel occurs + during the write, the command can be only partly sent. From then on, MeshTerm cannot + talk to the device until it comes back. Thus the disconnect watcher of the session + (:meth:`link_going_down`) fires at once, and the reconnect dialog takes over. The + alternative is to hope that a liveness poll catches the drop. The poll misses a quick + reboot completely where the serial port never goes away. """ self.reboot_in_progress = True self._link_going_down.set() async def link_going_down(self) -> None: - """Resolve once :meth:`announce_reboot` has declared the link down.""" + """Resolve when :meth:`announce_reboot` has declared the link down.""" await self._link_going_down.wait() def take_reboot(self) -> bool: - """Consume a pending reboot announcement, returning whether there was one. + """Use a pending reboot announcement, and return whether there was one. - Clears both halves, so a later, genuine disconnect goes back to the generic - wording and the next watcher waits for a real drop again. + The method clears both parts. Thus a later disconnect that is real uses the generic + wording again, and the next watcher waits for a real drop again. """ pending = self.reboot_in_progress self.reboot_in_progress = False @@ -326,11 +343,12 @@ class AppContext: @property def ui(self) -> Ui: - """The active UI surface, defaulting to the plain console surface for the CLI. + """The active UI surface. The default for the CLI is the plain console surface. - The interactive menu replaces this with a full-screen TUI surface for the session; - scripted CLI runs use the lazily-created plain surface, which prints directly. The - surface module (and prompt_toolkit) is imported lazily here to keep startup fast. + For the session, the interactive menu replaces it with a full-screen TUI surface. + Scripted CLI runs use the plain surface, which MeshTerm creates lazily, and which + prints directly. The property imports the surface module (and prompt_toolkit) lazily, + to keep the startup fast. """ if self._ui is None: from .ui.surface import PlainUi @@ -340,16 +358,17 @@ class AppContext: @ui.setter def ui(self, value: Ui) -> None: - """Install a UI surface (used by the menu to switch to the full-screen TUI).""" + """Install a UI surface. The menu uses this to change to the full-screen TUI.""" self._ui = value @property def events(self) -> EventHub: - """Return the session's always-on event hub, creating it on first use. + """Return the always-on event hub of the session, and create it at first use. - The hub owns the single device event subscription and fans events out to any - number of subscribers (the passive monitor's logging is one of them). It is - created idle here; the interactive session starts it once a device is available. + The hub owns the single subscription to the device events. It sends the events to + any number of subscribers (the logging of the passive monitor is one of them). The + property creates it idle. The interactive session starts it when a device is + available. """ if self._events is None: from .services.event_hub import EventHub @@ -359,10 +378,11 @@ class AppContext: @property def monitor(self) -> MonitorService: - """Return the session's passive-monitor service, creating it on first use. + """Return the passive-monitor service of the session, and create it at first use. - The service is built lazily so the (cheap) database read for the "total" - observation count happens only once, when monitoring is first referenced. + MeshTerm builds the service lazily. Thus the database read for the "total" count of + observations (which is cheap) happens only one time, when the code first refers to + monitoring. """ if self._monitor is None: from .services.monitor_service import MonitorService @@ -372,12 +392,13 @@ class AppContext: @property def chat(self) -> ChatService: - """Return the session's chat service, creating it on first use. + """Return the chat service of the session, and create it at first use. - The service records inbound messages to history (as a subscriber of the always-on - event hub) and owns the outbound send path and the per-conversation unread counts. - It is created idle here; the interactive session starts it once a device is - available (and the live chat screen starts it lazily otherwise). + The service stores received messages in the history (as a subscriber of the always-on + event hub). It owns the path for sent messages and the unread count of each + conversation. The property creates it idle. The interactive session starts it when a + device is available. If it is not started then, the live chat screen starts it + lazily. """ if self._chat is None: from .services.chat_service import ChatService @@ -387,11 +408,11 @@ class AppContext: @property def rooms(self) -> RoomService: - """Return the session's room service, creating it on first use. + """Return the room service of the session, and create it at first use. - The service joins room servers and remembers, for the session, what each one let us - do and when it was last heard — which is what decides whether opening a room logs - in to it again. It never transmits on its own. + The service joins room servers. For the session, it remembers what access each room + server gave us, and when MeshTerm last heard it. This decides whether the opening + of a room logs in to it again. The service never transmits on its own. """ if self._rooms is None: from .services.rooms import RoomService @@ -401,11 +422,11 @@ class AppContext: @property def adverts(self) -> AdvertScheduler: - """Return the session's background-advert scheduler, creating it on first use. + """Return the background-advert scheduler of the session, and create it at first use. - Created idle here; the interactive session starts it alongside the other - always-on services. Scripted CLI runs never start it, so a one-shot command - can't fire a background transmission. + The property creates it idle. The interactive session starts it with the other + always-on services. Scripted CLI runs never start it. Thus a one-shot command cannot + start a background transmission. """ if self._adverts is None: from .services.advert_scheduler import AdvertScheduler @@ -415,11 +436,11 @@ class AppContext: @property def watchtower(self) -> WatchtowerService: - """Return the session's Watchtower sentinel, creating it on first use. + """Return the Watchtower sentinel of the session, and create it at first use. - Created idle here; the interactive session starts it alongside the other - always-on services. It only ever listens (rules over hub events), so scripted - CLI runs simply never start it. + The property creates it idle. The interactive session starts it with the other + always-on services. It only listens (it applies rules to the events of the hub). + Scripted CLI runs never start it. """ if self._watchtower is None: from .services.watchtower import WatchtowerService @@ -429,12 +450,12 @@ class AppContext: @property def clock_sync(self) -> ClockSync: - """Return the session's on-connect clock setter, creating it on first use. + """Return the clock setter that runs at connection, and create it at first use. - Created idle here; the interactive session starts it alongside the other - always-on services, and :meth:`device` tells it about each connection as it - settles. Scripted CLI runs never start it, so a one-shot command that only reads - the radio never writes its clock. + The property creates it idle. The interactive session starts it with the other + always-on services, and :meth:`device` tells it about each connection when the + connection settles. Scripted CLI runs never start it. Thus a one-shot command that + only reads the radio never writes its clock. """ if self._clock_sync is None: from .services.clock_sync import ClockSync @@ -444,11 +465,11 @@ class AppContext: @property def courier(self) -> CourierService: - """Return the session's store-and-forward courier, creating it on first use. + """Return the store-and-forward courier of the session, and create it at first use. - Created idle here; the interactive session starts it alongside the other - always-on services. Scripted CLI runs never start the loop, so queueing a - message from a script transmits nothing until an interactive session runs. + The property creates it idle. The interactive session starts it with the other + always-on services. Scripted CLI runs never start the loop. Thus if a script queues a + message, nothing is transmitted until an interactive session runs. """ if self._courier is None: from .services.courier import CourierService @@ -458,11 +479,11 @@ class AppContext: @property def battery(self) -> BatteryService: - """Return the session's battery poller, creating it on first use. + """Return the battery poller of the session, and create it at first use. - Created idle here; the interactive session starts it alongside the other - always-on services. It only ever reads the pack (best-effort, skipping quietly - without a device), so scripted CLI runs simply never start the loop. + The property creates it idle. The interactive session starts it with the other + always-on services. It only reads the pack, as a best-effort action. If there is no + device, it skips the read without a message. Scripted CLI runs never start the loop. """ if self._battery is None: from .services.battery_service import BatteryService @@ -472,12 +493,13 @@ class AppContext: @property def devstate(self) -> DeviceState: - """Return the session's device-state cache, creating it on first use. + """Return the device-state cache of the session, and create it at first use. - Holds the stable facts screens read from the companion on open (contacts, self-info, - path-hash mode, channel slots) so navigation doesn't re-read them from the radio every - time — the chief cause of slow screen transitions over Bluetooth. Created idle here; - each getter fetches lazily. Cleared on a reconnect (see :meth:`reconnect`). + The cache holds the stable facts that screens read from the companion when they open + (contacts, self-info, path hash mode, channel slots). Thus navigation does not read + them from the radio each time. That was the main cause of slow changes of screen over + Bluetooth. The property creates it idle, and each getter reads lazily. A reconnect + clears the cache (refer to :meth:`reconnect`). """ if self._devstate is None: from .services.device_state import DeviceState @@ -487,15 +509,17 @@ class AppContext: @property def basemap_source(self) -> BasemapSource: - """Return the session's shared map tile source, built once and reused. + """Return the shared map tile source of the session. MeshTerm builds it once and reuses it. - The map, the location picker, and the Node-detail location preview all draw the - same OpenStreetMap basemap; each used to build its own :class:`BasemapSource`, so - each paid the one-off TileJSON resolve — a blocking network round-trip the first - time ``.max_zoom``/``.available`` is touched — on *every* open. Holding one source - for the session means that resolve happens once (warmed behind the menu, see - :func:`meshterm.ui.menu._warm_basemap`), and the on-disk tile cache is shared too. - Independent of the radio link, so it survives reconnects untouched. + The map, the location picker, and the location preview of Node detail all draw the + same OpenStreetMap basemap. Each of them used to build its own :class:`BasemapSource`. + Thus each of them paid the one-time TileJSON resolve at each open. The resolve is a + blocking network round trip, and it occurs when the code first uses + ``.max_zoom`` or ``.available``. If the session holds one source, the resolve + happens one time. MeshTerm warms the source behind the menu (refer to + :func:`meshterm.ui.menu._warm_basemap`), and the tile cache on the disk is shared + also. The source does not depend on the radio link. Thus a reconnect does not change + it. """ if self._basemap_source is None: from .services.basemap import BasemapSource @@ -510,24 +534,24 @@ class AppContext: @property def log(self): # type: ignore[no-untyped-def] - """The application logger.""" + """The logger of the application.""" return get_logger() def adopt_device(self, device: Device) -> None: - """Adopt an already-connected device as the session's device. + """Adopt a device that is already connected as the device of the session. - Used by the startup picker: it opens and confirms the chosen companion during its - smoke test, and hands that live connection here so :meth:`device` reuses it instead - of opening the radio a second time (many boards reset on each serial open, making a - reconnect slow and unreliable). + The startup picker uses this method. It opens the chosen companion and confirms it + during its smoke test. Then it gives that live connection to this method. Thus + :meth:`device` uses it again and does not open the radio a second time. Many boards + reset at each serial open, and this makes a reconnect slow and unreliable. Args: - device: A connected :class:`Device` to serve as this session's radio. + device: A connected :class:`Device` that serves as the radio of this session. """ self._device = device - # Record the transport and endpoint the picker opened directly, bypassing the - # resolution in ``device()`` that normally sets them — so the liveness watcher knows - # what to poll and ``reconnect`` can rebuild the same connection. + # Record the transport and the endpoint that the picker opened directly. This goes + # around the resolution in ``device()``, which normally sets them. Then the liveness + # watcher knows what to poll, and ``reconnect`` can build the same connection again. self._active_transport = getattr(device, "transport", "serial") self._active_port = getattr(device, "_port", None) self._active_address = getattr(device, "_address", None) @@ -536,53 +560,56 @@ class AppContext: ) async def _open(self, what: str) -> None: - """Open :attr:`_device`, reporting a failure to open as a *selection* failure. + """Open :attr:`_device`, and report a failure to open as a failure of selection. - The two device exit statuses answer different questions — ``NO_DEVICE`` says - nothing was transmitted and the caller should look at what is plugged in; - ``DEVICE`` says the radio was reached and the operation failed, so retrying is - reasonable. Every failure on this path is the first kind: the connection does not - exist yet, so nothing has been sent over it. It used to come back as ``DEVICE`` - with the message "the connection to the device was lost", which describes a - connection there had never been — and ``--port NOSUCHPORT`` is the most common way - to reach this line. + The two device exit statuses answer different questions. ``NO_DEVICE`` says that + MeshTerm transmitted nothing, and that the caller must look at what is plugged in. + ``DEVICE`` says that MeshTerm reached the radio and the operation failed, so a retry + is reasonable. Each failure on this path is the first type. The connection does not + exist yet, so MeshTerm sent nothing over it. The failure used to return as ``DEVICE`` + with the message "the connection to the device was lost". That message describes a + connection that never existed, and ``--port NOSUCHPORT`` is the most common way to + reach this line. - A device that failed to open is not kept. It used to stay as :attr:`_device`, so - :attr:`is_connected` said yes, the next :meth:`device` handed it back unopened, and - every tool after a failed start failed with "not connected" instead of the reason. - Dropped, the next caller tries the connection again and meets the real answer. + MeshTerm does not keep a device that failed to open. It used to stay as + :attr:`_device`. Then :attr:`is_connected` said yes, the next :meth:`device` returned + it unopened, and each tool after a failed start failed with "not connected" and not + with the reason. Now the method drops the device. Thus the next caller tries the + connection again and gets the real answer. Args: - what: The thing being opened, for the message (a port, an address, a host). + what: The thing that MeshTerm opens, for the message (a port, an address, or a + host). Raises: - DeviceSelectionError: If the connection cannot be established. + DeviceSelectionError: If MeshTerm cannot establish the connection. """ from .core.selection import DeviceSelectionError try: await self._device.connect() - except Exception as exc: # noqa: BLE001 - reclassified, then re-raised + except Exception as exc: # noqa: BLE001 - reclassified, then raised again failed, self._device = self._device, None try: - await failed.disconnect() # whatever half of it did open - except Exception: # noqa: BLE001 - best-effort; it never connected + await failed.disconnect() # the part of it that did open + except Exception: # noqa: BLE001 - a best-effort action. It never connected pass raise DeviceSelectionError(f"could not open {what}: {exc}") from exc async def device(self) -> Device: - """Return a connected :class:`Device`, opening the connection on first use. + """Return a connected :class:`Device`, and open the connection at first use. - For real hardware this resolves which serial port to use (explicit ``--port`` / - profile / remembered default / sole attached device) and, on a successful - connection, records the device as the new "last known good" default. + For real hardware, the method resolves which serial port to use (the explicit + ``--port``, the profile, the remembered default, or the only attached device). After + a successful connection, it records the device as the new "last known good" default. Returns: - The shared, connected device for this invocation. + The shared, connected device for this run. Raises: - ValueError: If no serial port can be chosen and the simulator is not enabled. - DeviceSelectionError: If discovery is ambiguous (subclass of ``ValueError``). + ValueError: If MeshTerm cannot choose a serial port and the simulator is not on. + DeviceSelectionError: If the discovery is ambiguous (a subclass of + ``ValueError``). """ if self._device is not None: return self._device @@ -596,8 +623,8 @@ class AppContext: await self._open("the simulator") return self._device - # A radio on the host's own SPI bus (``--spi``, an SPI profile, or the remembered - # default) has nothing to find: MeshTerm starts the node that answers on it. + # A radio on the SPI bus of the host (``--spi``, an SPI profile, or the remembered + # default) has nothing to find. MeshTerm starts the node that answers on it. spi = self.resolve_spi() if spi is not None: from .core.spiradio import state_dir @@ -617,9 +644,10 @@ class AppContext: await self._settle_connection() return self._device - # A network endpoint (an explicit ``--tcp``, a TCP profile, a device picked at startup, - # or the remembered TCP default) is opened directly by host:port — TCP companions - # aren't discoverable, so this remembered/explicit endpoint is the only way to reach one. + # MeshTerm opens a network endpoint directly by host:port (an explicit ``--tcp``, a + # TCP profile, a device picked at startup, or the remembered TCP default). TCP + # companions cannot be discovered, so this remembered or explicit endpoint is the only + # way to reach one. tcp_host, tcp_port = self._resolve_tcp_endpoint() if tcp_host and tcp_port: self._device = make_device( @@ -633,17 +661,18 @@ class AppContext: await self._settle_connection() return self._device - # A Bluetooth endpoint (an explicit ``--ble``, a BLE profile, a device picked at - # startup, or the remembered BLE default) is opened directly by address — no serial - # resolution, and no re-scan needed to reconnect to a known address. + # MeshTerm opens a Bluetooth endpoint directly by address (an explicit ``--ble``, a + # BLE profile, a device picked at startup, or the remembered BLE default). There is + # no serial resolution, and no new scan is necessary to reconnect to a known address. ble_address, ble_pin = self._resolve_ble_endpoint() if ble_address: - # When a scan has produced a live BLEDevice for this address, hand it to the - # connection so bleak opens it directly instead of re-discovering the address (a - # fresh internal scan that intermittently misses a slow-advertising companion). - # A remembered/explicit address has no scan result and connects by address alone. - # A handle from the reconnect watch wins over the startup picker's: it was scanned - # seconds ago, after the drop, so it names the peripheral that is on the air *now*. + # If a scan produced a live BLEDevice for this address, give it to the + # connection. Then bleak opens it directly and does not discover the address + # again. A new internal scan sometimes misses a companion that advertises slowly. + # A remembered or explicit address has no scan result, and it connects by address + # only. A handle from the reconnect watch wins over the handle of the startup + # picker. The watch scanned it seconds ago, after the drop, so it names the + # peripheral that is on the air now. selected = self.selected_device ble_device = self._ble_handle or ( selected.ble_device @@ -669,8 +698,9 @@ class AppContext: from .core.spiradio import without_radio_ports resolution = resolve_device( - # Never the radio's own GPS port: on a Cardputer Zero with nothing remembered it - # was the lone port, and a lone port is connected to without asking. + # Never the GPS port of the radio itself. On a Cardputer Zero with nothing + # remembered, it was the only port, and MeshTerm connects to an only port without + # a question. without_radio_ports(discover_devices(), self.settings.profiles), self.device_store.load(), explicit_port=self.port_override, @@ -691,18 +721,18 @@ class AppContext: def _resolve_tcp_endpoint(self) -> tuple[str | None, int | None]: """Return the ``(host, port)`` to open over TCP, or ``(None, None)`` for other transports. - Resolves a network endpoint in priority order — an explicit ``--tcp``, a TCP - :class:`~meshterm.core.config.DeviceProfile`, then the remembered TCP default — so a - network companion is honored wherever a serial/Bluetooth one would be. A malformed - endpoint yields ``(None, None)`` (the session then falls through to the other - transports rather than crashing on a typo). + The method resolves a network endpoint in this order of priority: an explicit + ``--tcp``, a TCP :class:`~meshterm.core.config.DeviceProfile`, then the remembered TCP + default. Thus a network companion is accepted in each place where a serial or + Bluetooth companion is accepted. A malformed endpoint gives ``(None, None)``. Then the + session continues with the other transports, and does not crash on a typing error. """ from .core.discovery import parse_tcp_endpoint from .core.selection import DeviceSelectionError - # An explicit ``--tcp`` or TCP profile is authoritative — a malformed value is the - # user's typo, so surface the parse error as a clean, actionable message rather than - # silently falling through to another transport. + # An explicit ``--tcp`` or a TCP profile is authoritative. A malformed value is a + # typing error of the user. Thus the method shows the parse error as a clean message + # that the user can act on, and does not silently continue with another transport. explicit_endpoint = self.tcp_override or ( self.profile.tcp_endpoint if self.profile is not None and self.profile.is_tcp else None ) @@ -711,8 +741,9 @@ class AppContext: return parse_tcp_endpoint(explicit_endpoint) except ValueError as exc: raise DeviceSelectionError(str(exc)) from exc - # A remembered TCP default only applies when nothing else was selected explicitly, and - # a corrupt stored value must never crash startup — fall through to the other transports. + # A remembered TCP default applies only when the user selected nothing else + # explicitly. A corrupt stored value must never crash the startup. In that case, + # continue with the other transports. explicit_other = ( bool(self.port_override) or bool(self.ble_override) @@ -730,18 +761,18 @@ class AppContext: host, port = parse_tcp_endpoint(remembered.target) except ValueError: return None, None - self.selected_device = None # remembered, not freshly discovered this session + self.selected_device = None # remembered, not discovered in this session return host, port return None, None def resolve_spi(self) -> SpiWiring | None: - """The wiring of the SPI radio to open, or ``None`` when another transport is meant. + """The wiring of the SPI radio to open, or ``None`` when the user means another transport. - In priority order: an SPI profile, the radio picked on the startup splash, ``--spi`` - (the one radio attached — see :meth:`_spi_for_flag`), then the remembered default when it - was an SPI radio and nothing else was named. Anything else named explicitly — a port, - an address, a host, another kind of profile — means this session is not about the SPI - radio. + The order of priority is: an SPI profile, the radio picked on the startup splash, + ``--spi`` (the one radio that is attached, refer to :meth:`_spi_for_flag`), then the + remembered default when it was an SPI radio and the user named nothing else. If the + user names anything else explicitly (a port, an address, a host, or another type of + profile), this session is not about the SPI radio. """ if self.profile is not None and self.profile.is_spi: return self.profile.spi or SpiWiring() @@ -755,22 +786,23 @@ class AppContext: remembered = self.device_store.load() if remembered is None or remembered.transport != "spi" or not remembered.target: return None - self.selected_device = None # remembered, not freshly discovered this session + self.selected_device = None # remembered, not discovered in this session return self.spi_wiring_for(remembered.target) def _spi_for_flag(self) -> SpiWiring: - """The radio a bare ``--spi`` means: the one there is, and never a guess between two. + """The radio that a bare ``--spi`` means: the one that is there, never a guess. - Nearly everyone with a radio on the SPI bus has exactly one, and for them ``--spi`` - needs nothing more — attached radios are counted first, so a single one is used - whether or not a profile names it. Only when two are attached, or (with none - attached to count) two SPI profiles are configured, is the flag ambiguous; it used - to take whichever profile ``config.toml`` listed first, which is a coin toss that - transmits. With none attached and one profile or none, the choice is made from the - configuration, so the connect attempt can say what is missing. + Nearly all users who have a radio on the SPI bus have exactly one. For them, + ``--spi`` needs nothing more. The method counts the attached radios first. Thus it + uses a single radio, if a profile names it or not. The flag is ambiguous only when two + radios are attached, or (if none is attached to count) when two SPI profiles are + configured. The flag used to take the profile that ``config.toml`` listed first. That + is a coin toss that transmits. If no radio is attached, and there is one profile or + none, the method makes the choice from the configuration. Then the connect attempt + can say what is missing. Raises: - DeviceSelectionError: When more than one radio could be meant. + DeviceSelectionError: When the user can mean more than one radio. """ from .core.selection import DeviceSelectionError from .core.spiradio import spi_radios @@ -796,12 +828,13 @@ class AppContext: ) def spi_wiring_for(self, spidev: str) -> SpiWiring: - """The wiring for the radio on ``spidev``: a profile's, a shipped board's, else the AIO's. + """The wiring for the radio on ``spidev``: of a profile, of a shipped board, or of the AIO. - A remembered or listed SPI radio is known by its device node alone, so its pins come - from whichever SPI profile is on that node; with none, from the board MeshTerm knows - that node on when this machine is that board (the Cardputer Zero's Cap); and - otherwise the defaults are the only wiring there is. + MeshTerm knows a remembered or listed SPI radio only by its device file. Thus its pins + come from the SPI profile that is on that device file. If there is no such profile, + they come from the board that MeshTerm knows for that device file, when this machine + is that board (the Cap of the Cardputer Zero). If neither applies, the defaults are + the only wiring. """ from .core.spiradio import builtin_wiring @@ -813,15 +846,16 @@ class AppContext: def _resolve_ble_endpoint(self) -> tuple[str | None, str | None]: """Return the ``(address, pin)`` to open over Bluetooth, or ``(None, None)`` for serial. - Resolves a BLE endpoint in priority order — an explicit ``--ble``, a BLE - :class:`~meshterm.core.config.DeviceProfile`, then the remembered BLE default — so a - Bluetooth companion is honored wherever a serial one would be. Returns ``(None, None)`` - when the session should fall through to serial resolution. + The method resolves a BLE endpoint in this order of priority: an explicit ``--ble``, a + BLE :class:`~meshterm.core.config.DeviceProfile`, then the remembered BLE default. + Thus a Bluetooth companion is accepted in each place where a serial companion is + accepted. It returns ``(None, None)`` when the session must continue with the serial + resolution. """ if self.ble_override: return self.ble_override, self.ble_pin # An explicit serial selection (``--port`` or a serial profile with a port) wins over a - # remembered BLE default, mirroring the serial resolution priority. + # remembered BLE default. This is the same as the priority of the serial resolution. explicit_serial = bool(self.port_override) or ( self.profile is not None and not self.profile.is_ble and bool(self.profile.port) ) @@ -831,17 +865,18 @@ class AppContext: return None, None remembered = self.device_store.load() if remembered is not None and remembered.is_ble and remembered.target: - self.selected_device = None # remembered, not freshly discovered this session + self.selected_device = None # remembered, not discovered in this session return remembered.target, self.ble_pin return None, None async def _remember_connected(self) -> None: - """Record the just-connected device as the last known good default (best-effort). + """Record the device that just connected as the last known good default. - Learns the device's mesh node name (a probe failure must not block a good - connection). Only devices discovered this session carry a - :class:`~meshterm.core.discovery.DiscoveredDevice` to remember; a bare ``--port`` or - remembered-by-address reconnect has nothing new to upsert. + This is a best-effort action. The method learns the mesh node name of the device. A + failure of the probe must not block a good connection. Only devices that MeshTerm + discovered in this session have a :class:`~meshterm.core.discovery.DiscoveredDevice` + to remember. A bare ``--port``, or a reconnect that is remembered by address, has + nothing new to upsert. """ if self.selected_device is not None: self.device_store.remember( @@ -851,12 +886,12 @@ class AppContext: ) async def _settle_connection(self) -> None: - """The steps every fresh connection gets once the link is open, in order. + """The steps that each new connection gets, in order, when the link is open. - Remember the device as the default, replay its remembered channels, and hand it to - the on-connect clock setter — which returns at once and does its work in the - background, so the connection is usable the moment this returns. Each step is - best-effort on its own; none can fail the connection. + Remember the device as the default, replay its remembered channels, and give it to + the clock setter that runs at connection. The clock setter returns at once and does + its work in the background. Thus the connection is usable when this method returns. + Each step is a best-effort action on its own. No step can make the connection fail. """ await self._remember_connected() await self._reconcile_channels() @@ -864,13 +899,15 @@ class AppContext: self.clock_sync.on_connected(self._device) async def _reconcile_channels(self) -> None: - """Replay channels remembered for this device that it isn't reporting (best-effort). + """Replay the remembered channels that the device does not report. - A firmware-less radio bridge loses its channels whenever it restarts, so the channels - you added through MeshTerm are restored into free slots on connect (see - :func:`~meshterm.core.channel_store.reconcile`). With nothing remembered for the device - this is a single identity probe, so a firmware radio pays almost nothing. A failure here - must never break connecting — it is logged and swallowed. + This is a best-effort action. A radio bridge without firmware loses its channels each + time that it restarts. Thus MeshTerm restores the channels that you added through + MeshTerm into free slots at connection (refer to + :func:`~meshterm.core.channel_store.reconcile`). If MeshTerm remembers nothing for the + device, this is a single probe of the identity. Thus a radio with firmware pays almost + nothing. A failure here must never break the connection. MeshTerm logs it and does + not raise it. """ if self._device is None or self.channel_store is None: return @@ -878,26 +915,29 @@ class AppContext: try: restored = await reconcile(self.channel_store, self._device) - except Exception as exc: # noqa: BLE001 - channel replay must not block a connection + except Exception as exc: # noqa: BLE001 - the channel replay must not block a connection self.log.debug("channels: reconcile on connect failed: %s", exc) return if restored: self.log.info("channels: restored %d remembered channel(s) to the device", restored) async def release_link(self) -> None: - """Tear down the dead connection and the services riding on it, keeping what to resume. + """Take down the dead connection and the services that use it, and keep what to resume. - The first half of :meth:`reconnect`, split out because it is also worth doing *before* - going looking for the device. Over Bluetooth that ordering is load-bearing: a companion - only advertises while nothing is connected to it, so a peripheral we are still holding - a link to — however dead we believe that link to be — stays off the air and can never - be found by the scan waiting for it to come back. Releasing first puts it back on the - air, and is harmless on the transports that don't care. + This is the first half of :meth:`reconnect`. It is a separate method, because it is + also useful to do it before the search for the device. Over Bluetooth, this order is + necessary. A companion advertises only while nothing is connected to it. If MeshTerm + still holds a link to a peripheral, the peripheral stays off the air. This is true + also when MeshTerm believes that the link is dead. Then the scan that waits for the + peripheral to come back can never find it. If MeshTerm releases the link first, the + peripheral is on the air again. This does no harm on the transports that do not need + it. - What was running is captured *once* and held across retries: this stops the services, - so a later attempt would otherwise read the now-idle flags and restore nothing. The - intent is cleared only after :meth:`reconnect` actually succeeds. Idempotent — calling - it again with nothing connected is a no-op. + The method records what was running one time, and keeps the record across retries. + The method stops the services. If a later attempt read the flags then, they would be + idle, and it would restore nothing. MeshTerm clears the intent only after + :meth:`reconnect` succeeds. The method is idempotent. If you call it again when + nothing is connected, it does nothing. """ if self._resume_intent is None: self._resume_intent = ( @@ -906,9 +946,10 @@ class AppContext: self._chat is not None and self._chat.active, ) - # Release the stale hub/service subscriptions and discard the dead device. The - # subscriptions are in-process (to the hub), so they survive the link drop and must - # be torn down explicitly before a fresh connection is opened underneath them. + # Release the old subscriptions of the hub and the services, and discard the dead + # device. The subscriptions are in the process (to the hub). Thus they survive the + # link drop, and MeshTerm must take them down explicitly before it opens a new + # connection below them. if self._monitor is not None: await self._monitor.stop() if self._chat is not None: @@ -918,44 +959,48 @@ class AppContext: if self._device is not None: try: await self._device.disconnect() - except Exception: # noqa: BLE001 - the link is already gone; best-effort + except Exception: # noqa: BLE001 - the link is already gone. This is best-effort pass self._device = None - # Facts cached against the connection that just dropped may be stale on the fresh link - # (a reboot could have changed the identity or config), so re-read them on next use. + # The facts that MeshTerm cached for the connection that just dropped can be old on + # the new link (a reboot can change the identity or the config). Thus MeshTerm reads + # them again at the next use. if self._devstate is not None: self._devstate.reset() async def reconnect(self, *, ble_device: object | None = None) -> None: - """Drop a lost device connection and rebuild it, restoring live services. + """Drop a lost device connection and build it again, and restore the live services. - Called after the companion link is detected as gone (see - :func:`~meshterm.core.connection.is_connection_lost`). Tears down the dead - connection and the services riding on it, opens a fresh connection to the same - device, then restarts whatever was running before — so passive monitoring and chat - recording resume transparently across a replug. + Call this method after MeshTerm detects that the companion link is gone (refer to + :func:`~meshterm.core.connection.is_connection_lost`). The method takes down the dead + connection and the services that use it. Then it opens a new connection to the same + device, and starts again what was running before. Thus passive monitoring and chat + recording resume without a visible break after a replug. Args: - ble_device: The live ``bleak.BLEDevice`` the caller just scanned for this address, - when it has one (see :func:`~meshterm.core.discovery.find_ble_device`). Any - previously-held handle is dropped either way: the peripheral that comes back - from a power-cycle is a new one to the OS, so the scan result the session - opened with is not merely stale but wrong, and reusing it fails the connect - on Windows while the device sits there advertising. Passing ``None`` (serial, - TCP, or a BLE retry with nothing scanned) reconnects by address alone. + ble_device: The live ``bleak.BLEDevice`` that the caller just scanned for this + address, if it has one (refer to + :func:`~meshterm.core.discovery.find_ble_device`). In each case, the method + drops any handle that it held before. The peripheral that comes back from a + power cycle is a new peripheral to the OS. Thus the scan result that the + session opened with is old and also wrong. If MeshTerm used it again, the + connect would fail on Windows while the device is there and advertises. If you + pass ``None`` (serial, TCP, or a BLE retry with nothing scanned), the method + reconnects by address only. Raises: - Exception: If a new connection could not be opened (e.g. the device is still - absent); the caller can surface it and offer to retry. + Exception: If MeshTerm cannot open a new connection (for example, the device is + still absent). The caller can show the error and offer a retry. """ self._ble_handle = ble_device await self.release_link() resume_events, resume_monitor, resume_chat = self._resume_intent - # Open a fresh connection (raises if the device still can't be reached, leaving the - # remembered intent in place for the next attempt), then restart whatever was running - # before it dropped and clear the intent now that we're back. + # Open a new connection. If MeshTerm still cannot reach the device, this raises an + # error and leaves the remembered intent in place for the next attempt. Then start + # again what was running before the drop, and clear the intent, because the link is + # back. await self.device() if resume_events: await self.events.start() @@ -964,28 +1009,29 @@ class AppContext: if resume_chat: await self.chat.start() self._resume_intent = None - # The handle has been opened; it names a link that is now live rather than a device to - # go find, so the next drop starts from a clean slate and scans afresh. + # MeshTerm opened the handle. It now names a link that is live, and not a device to + # find. Thus the next drop starts with a clean state and scans again. self._ble_handle = None async def _node_name(self) -> str: - """Return the connected device's own mesh node name, or ``""`` if unavailable.""" + """Return the mesh node name of the connected device, or ``""`` if it is not available.""" try: info = await self._device.get_self_info() - except Exception: # noqa: BLE001 - identity probe is best-effort, never fatal + except Exception: # noqa: BLE001 - the identity probe is best-effort and never fatal return "" return str(info.get("adv_name") or info.get("name") or "") async def _hardware_model(self) -> str: - """Return the connected device's firmware model string, or ``""`` if unavailable. + """Return the firmware model string of the connected device, or ``""``. - Sourced from the device-query frame — the only place the model is exposed — so a - reconnect keeps the remembered hardware column fresh. Best-effort: a probe failure or - older firmware just leaves any previously-remembered model untouched. + The value comes from the device-query frame, which is the only place that shows the + model. Thus a reconnect keeps the remembered hardware column current. This is a + best-effort action. If the probe fails, or the firmware is older, the method leaves + any model that MeshTerm remembered before as it is. """ try: info = await self._device.get_device_info() - except Exception: # noqa: BLE001 - model lookup is best-effort, never fatal + except Exception: # noqa: BLE001 - the model lookup is best-effort and never fatal return "" return str(info.get("model") or "") @@ -1012,7 +1058,7 @@ class AppContext: if self._device is not None: try: await self._device.disconnect() - except Exception: # noqa: BLE001 - a dead/lost link must not crash teardown + except Exception: # noqa: BLE001 - a dead or lost link must not crash the teardown pass self._device = None self.repo.close() diff --git a/meshterm/platforms.py b/meshterm/platforms.py index c08c5d9..542e2b9 100644 --- a/meshterm/platforms.py +++ b/meshterm/platforms.py @@ -1,20 +1,21 @@ # SPDX-License-Identifier: Apache-2.0 -"""The platform seam: a frozen spec every consumer binds to once, at boot. +"""The platform seam: a frozen spec that each consumer binds to once, at boot. -MeshTerm runs its flavours from one codebase — the regular desktop terminal, the -PicoCalc's 53-column framebuffer console, and the Cardputer Zero's 53×14 panel — with no -forked screens and no runtime layer. -A :class:`Platform` is resolved once, at process start, and held in a module-level -singleton; everything downstream (the theme, the frame compositor, the header, the -session's width handling) reads it at *its own* construction/call time and binds its -behaviour accordingly, so the render loop itself never branches on platform. +MeshTerm runs its flavours from one codebase: the regular desktop terminal, the +PicoCalc's 53-column framebuffer console, and the display of the Cardputer Zero (53×14 +cells). There are no separate screens for each flavour and no runtime layer. +A :class:`Platform` is resolved one time, at the start of the process, and kept in a +module-level singleton. Each consumer downstream (the theme, the frame compositor, the +header, the width handling of the session) reads it when that consumer is constructed or +called, and binds its behaviour then. Thus the render loop itself never branches on the +platform. Read the active platform through :func:`get_platform` (or the module-qualified -``platforms.PLATFORM``) — never ``from meshterm.platforms import PLATFORM`` at a -module's top level. That statement runs once, at import time, and copies whatever -:data:`PLATFORM` pointed to *then*; :func:`set_platform` rebinding the module global -afterwards would never reach that stale copy. A function call always re-reads the -current value, so it has no such trap. +``platforms.PLATFORM``). Never use ``from meshterm.platforms import PLATFORM`` at the top +level of a module. That statement runs one time, at import time, and copies the value +that :data:`PLATFORM` had *then*. If :func:`set_platform` rebinds the module global +afterwards, the rebinding does not reach the old copy. A function call always reads the +current value, so it does not have this problem. """ from __future__ import annotations @@ -28,145 +29,159 @@ from pathlib import Path @dataclass(frozen=True, slots=True) class Platform: - """One flavour's complete rendering/behaviour spec. + """The complete rendering and behaviour spec of one flavour. - Every field is data a consumer binds to at its own construction/call time (see the - module docstring) — there is no code path that branches on ``platform.name`` itself - outside this module and the small set of binding points documented at each field. + Each field is data that a consumer binds to when that consumer is constructed or called + (refer to the module docstring). Only this module and the binding points that each + field names branch on ``platform.name`` itself. Attributes: - name: Stable identifier — ``"regular"``, ``"picocalc-lyra"`` or ``"cardputer-zero"`` - — used for ``--platform``/``MESHTERM_PLATFORM`` matching and diagnostics. A - handheld's is its product and the variant that sets it apart from its siblings: - M5Stack makes three Cardputers and only the Zero runs Linux, and the PicoCalc - takes several cores and only the Luckfox Lyra runs MeshTerm. - readable_cols: The readability standard (CLAUDE.md's "screens stay readable at - N columns") and the dual-platform gallery's test width. - readable_rows: The row *floor* a screen designs to. PicoCalc's live console is - 53×26 (custom 6×12 font); a 6×8 font rebuild (P5) gives 53×40. Screens design - to this floor and exploit extra rows fluidly; the gallery renders picocalc-lyra at - both 26 and 40 rows. - frame_border: Whether the base screen draws inside a bordered ``Panel``. ``False`` - swaps it for a title-bar row instead (+4 cols, +1 row reclaimed) — a PicoCalc - chrome saving, wired in P4. - menu_icons: Whether a *command row* leads with a decorative icon — the main menu's - per-tool mark, an action list's ``🗑``/``🕒``, a body action's ``✎``. The label - already names the action, so the icon is a family cue, not information; where - the console can only spell it as a one-glyph stand-in the cue is worth less - than the cells it costs and reads as noise besides (``@`` for both Map and - Mesh walk, ``…`` for both Live feed and Time machine — JP, on-device, - 2026-08-11). ``False`` drops the whole lane, mark and emoji alike, and the - cells go to the label and its description. This is about *decoration* only: - a glyph that carries data — a channel's openness, a packet's class, a node's - type, a status mark on an outcome — is not a menu icon and is never dropped - (see :func:`~meshterm.ui.menus.command_label`). - dialog_margin: Total columns a floating dialog leaves to the backdrop it sits over - — half of it on each side (see - :func:`~meshterm.ui.tui.frame._dialog_layout`). The gutter is what makes a - dialog read as floating rather than as a new screen, and three columns a side - do that comfortably at 72. On the 53-column console the *same* gutter is a - twelfth of the whole display, and it costs content: a packet card's reception - row folds ``rssi`` onto a line of its own for want of two cells (JP, on-device, - 2026-08-10). Two columns a side still reads as floating and buys them back. - dialog_row_margin: Rows a floating dialog leaves to the frame around it — the header - above and the footer below, plus any blank row between them and the box. One row - of air above and below is what lets a box read as floating on a 24-row terminal - (4 in all). On the Cardputer's 14 rows every row a dialog gives up is a row it - can't show, its side gutters already say it floats, and there is no header row - above the title bar, so a tall box runs from the top row to the lane (1), - drawing over the bar — whose ends either side of the box are blanked rather than - left as fragments (see :func:`~meshterm.ui.tui.frame.composite_float`). Never - over the lane: a dialog's lane is the dialog's own, and the keys on it are named - nowhere else. - header_atoms: Which segments compose the persistent header, in the vocabulary - ``"version"``, ``"device"``, ``"badges"``, ``"pulse"``, ``"battery"``. Wired - into :func:`~meshterm.ui.menu._header` in P4; unconsumed until then. + name: The stable identifier: ``"regular"``, ``"picocalc-lyra"``, or + ``"cardputer-zero"``. MeshTerm uses it to match ``--platform`` and + ``MESHTERM_PLATFORM``, and in diagnostics. The name of a handheld is its product + and the variant that makes it different from its siblings. M5Stack makes three + Cardputers, and only the Zero runs Linux. The PicoCalc takes several cores, and + only the Luckfox Lyra runs MeshTerm. + readable_cols: The readability standard (the rule in CLAUDE.md that screens stay + readable at N columns) and the test width of the dual-platform gallery. + readable_rows: The minimum number of rows that a screen is designed for. The live + console of the PicoCalc is 53×26 (custom 6×12 font). A rebuild with a 6×8 font + (P5) gives 53×40. Screens are designed for this minimum and use extra rows when + they are there. The gallery renders picocalc-lyra at 26 rows and at 40 rows. + frame_border: Whether the base screen draws inside a ``Panel`` with a border. + ``False`` replaces the border with a title-bar row (4 more columns and 1 more + row). This saves chrome on the PicoCalc. Wired in P4. + menu_icons: Whether a command row starts with a decorative icon: the mark of each + tool in the main menu, the ``🗑`` or ``🕒`` of an action list, the ``✎`` of an + action in a body. The label already names the action, so the icon only shows + the family of the action. It gives no information. Where the console can only + spell the icon as a stand-in of one glyph, the icon is worth less than the cells + that it uses, and it also looks like noise (``@`` for both Map and Mesh walk, + ``…`` for both Live feed and Time machine, JP, on the handheld, 2026-08-11). ``False`` + removes the whole lane, the mark and the emoji. The cells go to the label and its + description. This is only about decoration. A glyph that carries data is not a + menu icon, and MeshTerm never removes it. Examples are the openness of a channel, + the class of a packet, the type of a node, and a status mark on an outcome (refer + to :func:`~meshterm.ui.menus.command_label`). + dialog_margin: The total number of columns that a floating dialog leaves to the + backdrop under it, half on each side (refer to + :func:`~meshterm.ui.tui.frame._dialog_layout`). The gutter makes the dialog look + like it floats, and not like a new screen. At 72 columns, three columns on each + side are enough. On the console of 53 columns, the same gutter is one twelfth of + the whole display, and it costs content. For example, the reception row of a + packet card puts ``rssi`` on a line of its own, because two cells are missing + (JP, on the handheld, 2026-08-10). Two columns on each side still look like floating, + and they give the cells back. + dialog_row_margin: The number of rows that a floating dialog leaves to the frame + around it. These are the header above, the footer below, and each blank row + between them and the box. One row of space above and one below make a box look + like it floats on a terminal of 24 rows (the value is 4). The Cardputer has 14 + rows. There, each row that a dialog gives up is a row that it cannot show. The + side gutters already show that the dialog floats. There is no header row above + the title bar, so a tall box runs from the top row to the lane (the value is 1). + It draws over the bar, and MeshTerm blanks the ends of the bar on each side of + the box, so that no fragments remain (refer to + :func:`~meshterm.ui.tui.frame.composite_float`). The box never draws over the + lane. The lane of a dialog belongs to the dialog, and no other place names the + keys on it. + header_atoms: The segments that make the persistent header, from this vocabulary: + ``"version"``, ``"device"``, ``"badges"``, ``"pulse"``, ``"battery"``. Wired into + :func:`~meshterm.ui.menu._header` in P4. Nothing uses it before then. header_row: Whether the persistent header has a row of its own above the frame. - Where it doesn't (the Cardputer, JP 2026-10-03), the header's atoms ride the - borderless title bar's right end, after the way out — so its badges and battery - sit in the top-right corner they always had, one row up, and every screen gets - that row back — and the wordmark becomes the main menu's title (see - :func:`~meshterm.ui.menu._menu_title`). Only a borderless frame can fold it: the - bordered panel has no bar to carry it. - lane_deck: Which F-key lane deck the footer deals, by name (see - :data:`~meshterm.ui.tui.fkeys.DECKS`), or ``""`` where the footer is each - screen's own ``footer_hint`` string instead. A deck is everything about the lane - that belongs to one keyboard: which keycodes drive its slots, where its chips sit - on the row, how they are drawn, and which of a screen's lane definitions it reads - (``Screen.picocalc_lyra_lane``, ``Screen.cardputer_zero_lane``). Each handheld names its - own, so one can change without the other following. :attr:`footer_fkeys` is the - yes/no reading of it. - width_reclaim: Whether the session may report one extra terminal column (see - :class:`~meshterm.ui.tui.session._WidthExtendedOutput`). ``True`` only *lets* - it: the column is reclaimed where the terminal's size probe hid one, which is - prompt_toolkit's Windows console output and nothing else - (:func:`~meshterm.ui.tui.session._probe_hides_last_column`). ``False`` on - PicoCalc's exact-width console, where a phantom extra column tears the frame. - ``MESHTERM_FULL_WIDTH`` remains an explicit override on top of this default. - emoji: Whether emoji icons render at all. ``False`` means every icon funnel routes - through a compact single-BMP-glyph table instead (P3), and neither half of the - emoji alignment runs — the reserve-two measurement - (:func:`~meshterm.ui.tui.emoji_width.install`) or the column pinning - (:mod:`~meshterm.ui.tui.colsnap`). Once no emoji are ever drawn, every glyph on - screen is one the console font has verified, and the stock widths are exact. - font: The glyph inventory the screen is drawn in, by name (see - :data:`~meshterm.ui.fontset.FONTS`), or ``""`` where a terminal draws whatever - it is sent. A handheld's every frame is folded down to its font at the render - boundary (:func:`~meshterm.ui.theme.fold_text`) — an accent the font lacks - stripped, an emoji turned to its compact glyph, anything else still missing a - narrow ``?`` — so nothing the panel can't draw is ever sent to it. Storage is - never touched. - truecolor: Whether the theme may use arbitrary 24-bit SGR colour. ``False`` selects - a 16-slot palette theme instead (the console's real ceiling — no per-cell RGB), - and quantizes the two scales that would otherwise spend a gradient — the - per-node key hue (:func:`~meshterm.ui.theme.node_style`) and the heard-age heat - (:func:`~meshterm.ui.widgets._recency_style`). Wired in P3. - solid_braille: Whether the console font draws a braille cell's eight dots as solid - tiles with no gap between them or between neighbouring cells — the PicoCalc's - built fonts (``scripts/picocalc-lyra``) do — so braille is a true 2×4 pixel grid, - fine enough for what must stay contiguous. It is what lets every QR code be - drawn in braille, one module a dot (:mod:`~meshterm.ui.qr`). A desktop font's - braille is dotted, and a code drawn in it scans as nothing. - url_codes: Whether a chat message's URLs are drawn as QR codes under it, side by - side (:meth:`~meshterm.ui.chat.ChatScreen._body_lines`) — so a link read on the - handheld opens on a phone without being typed out. The PicoCalc's alone: it - takes braille codes (:attr:`solid_braille`) to be small enough to sit in a - transcript at all, a quarter the area of the half blocks a desktop draws, and - it takes rows — a short link's code is 9 of them, which the Cardputer's 14 - cannot spare under every message that carries one (JP, 2026-10-01). - effects: Whether animated/decorative rendering runs at all — the braille spinner, - which drops to a ``LINE`` fallback. Cheaper on a console where a repaint is - dear. The header battery gauge is deliberately *not* behind it: its sweep is - the charging state itself and its blink is the last warning before the pack - dies, and both ride the idle repaint that happens anyway. Wired in P2/P3. - tick_s: The :class:`~prompt_toolkit.application.Application` refresh interval — the - *idle* repaint cadence only, since every screen pushes its own repaint through - ``TuiSession.invalidate`` when its data actually moves. Composing one frame was - measured at 74 ms on the PicoCalc at 53×26 (110 ms at 53×40), so a 1 Hz idle - tick alone costs 7–11% of a core doing nothing; it ticks at half that rate - there. What drifts is cosmetic — the header's per-minute pulse and its packet - counter — and none of it is legible at one-second resolution anyway. - spinner_tick_s: Seconds between frames of a "working" spinner. The single source for - every animated wait in the app. The desktop's 0.12 s is not a rate this hardware - can hold: a PicoCalc frame would claim 62% of a core at 53×26, and at 53×40 its - 110 ms of compose would leave under 10 ms of each interval for the work actually - being waited on — and before this phase's savings it did not fit at all, at - 152 ms against a 120 ms budget. It slows to a cadence the hardware can hold - comfortably while still reading as alive. - battery: Which source feeds the header's battery gauge — ``"companion"`` (read - from the connected MeshCore device) or ``"host"`` (the PicoCalc's own sysfs - ``power_supply`` driver, confirmed present in P0). Wired in P5. - own_display: Whether MeshTerm draws this display itself (:mod:`meshterm.emulator`) - rather than running in a terminal — where the app is its own terminal, nothing - about the one it was started from (a classic Windows console to move out of, a - font to offer) is its business. True on the Cardputer Zero, whose launcher gives - an app no console; and for any platform while the emulator shows it in a window. - modifier_watch: The keyboard whose Shift state the optional evdev watcher follows, - flipping the displayed F-key lane live while Shift is held — a case-insensitive - substring of the input device's name, or ``""`` to leave the watcher off. - Handhelds only: desktop terminals have no lane to flip, and the watcher needs - ``/dev/input`` access a handheld's deploy user has (the ``input`` group). Always - optional and lazy; wired in P4. + If it does not (the Cardputer, JP 2026-10-03), the atoms of the header go at the + right end of the title bar without a border, after the way out. Thus the badges + and the battery stay in the top-right corner, one row up, and each screen gets + that row back. The wordmark becomes the title of the main menu (refer to + :func:`~meshterm.ui.menu._menu_title`). Only a frame without a border can fold the + header in this way, because the panel with a border has no bar to carry it. + lane_deck: The name of the F-key lane deck that the footer deals (refer to + :data:`~meshterm.ui.tui.fkeys.DECKS`). It is ``""`` where the footer is the + ``footer_hint`` string of each screen. A deck is all the parts of the lane that + belong to one keyboard: the keycodes that drive its slots, the places of its + chips on the row, how MeshTerm draws the chips, and which lane definitions of a + screen it reads (``Screen.picocalc_lyra_lane``, ``Screen.cardputer_zero_lane``). + Each handheld names its own deck. Thus a change to one deck does not change the + other. :attr:`footer_fkeys` is the yes or no form of this field. + width_reclaim: Whether the session can report one extra terminal column (refer to + :class:`~meshterm.ui.tui.session._WidthExtendedOutput`). ``True`` only lets the + session do it. The session reclaims the column where the size probe of the + terminal hid one. This is the console output of prompt_toolkit on Windows, and + nowhere else (:func:`~meshterm.ui.tui.session._probe_hides_last_column`). + ``False`` on the console of the PicoCalc, which has the exact width, because a + phantom extra column tears the frame. ``MESHTERM_FULL_WIDTH`` is still an + explicit override of this default. + emoji: Whether emoji icons render at all. ``False`` means that each icon funnel uses + a compact table of single glyphs in the BMP instead (P3). Then neither part of the + emoji alignment runs: the reserve-two measurement + (:func:`~meshterm.ui.tui.emoji_width.install`) and the column pinning + (:mod:`~meshterm.ui.tui.colsnap`). When MeshTerm never draws emoji, each glyph on + the screen is a glyph that the console font was checked for, and the stock widths + are exact. + font: The name of the glyph inventory that the screen is drawn in (refer to + :data:`~meshterm.ui.fontset.FONTS`). It is ``""`` where a terminal draws all that + it receives. At the render boundary, MeshTerm folds each frame of a handheld down + to its font (:func:`~meshterm.ui.theme.fold_text`). It removes an accent that the + font does not have, changes an emoji to its compact glyph, and changes any other + missing character to a narrow ``?``. Thus MeshTerm never sends the display a + character that it cannot draw. The stored data does not change. + truecolor: Whether the theme can use any 24-bit SGR colour. ``False`` selects a theme + with a palette of 16 slots instead (the real limit of the console: no RGB for + each cell). It also quantizes the two scales that would use a gradient: the hue + of the key of each node (:func:`~meshterm.ui.theme.node_style`) and the heat of + the heard age (:func:`~meshterm.ui.widgets._recency_style`). Wired in P3. + solid_braille: Whether the console font draws the eight dots of a braille cell as + solid tiles, with no gap between them or between neighbouring cells. The built + fonts of the PicoCalc (``scripts/picocalc-lyra``) do this. Thus braille is a true + 2×4 pixel grid, fine enough for what must stay contiguous. Because of this, each + QR code can be drawn in braille, with one dot for each module + (:mod:`~meshterm.ui.qr`). The braille of a desktop font has gaps between the dots, + and a code that is drawn in it does not scan. + url_codes: Whether MeshTerm draws the URLs of a chat message as QR codes under the + message, side by side (:meth:`~meshterm.ui.chat.ChatScreen._body_lines`). Thus the + user can open a link that was read on the handheld on a phone, and does not type + it. This is true only on the PicoCalc. It has braille codes + (:attr:`solid_braille`), which have a quarter of the area of the half blocks that + a desktop draws, and only then are they small enough to be in a transcript. It + also has the rows. The code of a short link has 9 rows, and the 14 rows of the + Cardputer cannot spare them under each message that has a link (JP, 2026-10-01). + effects: Whether animated and decorative rendering runs at all. An example is the + braille spinner, which changes to a ``LINE`` fallback. This costs less on a + console where a paint is expensive. The battery gauge of the header is not + controlled by this field. The sweep of the gauge is the charging state itself, + and its blink is the last warning before the pack is empty. Both use the idle + paint that happens in any case. Wired in P2/P3. + tick_s: The refresh interval of the + :class:`~prompt_toolkit.application.Application`. This is only the rate of the + idle paint, because each screen pushes its own paint through + ``TuiSession.invalidate`` when its data changes. MeshTerm composed one frame in + 74 ms on the PicoCalc at 53×26 (110 ms at 53×40). Thus an idle tick of 1 Hz alone + costs 7–11% of a core that does nothing. On the PicoCalc, the tick has half that + rate. The things that then change late are only cosmetic: the pulse of the header + for each minute and its packet counter. Nobody can read them at a resolution of + one second in any case. + spinner_tick_s: The seconds between the animation steps of a "working" spinner. This + is the only source for each animated wait in the app. This hardware cannot + keep the 0.12 s of the desktop. A frame of the PicoCalc would use 62% of a core + at 53×26. At 53×40, its 110 ms of composing would leave less than 10 ms of each + interval for the work that the user waits for. Before the savings of this phase, + the frame did not fit at all: it needed 152 ms against a budget of 120 ms. The + spinner now has a rate that the hardware can keep, and it still looks alive. + battery: The source of the battery gauge in the header. ``"companion"`` reads it from + the connected MeshCore device. ``"host"`` reads it from the ``power_supply`` + driver of sysfs on the PicoCalc itself (confirmed present in P0). Wired in P5. + own_display: Whether MeshTerm itself draws this display (:mod:`meshterm.emulator`) + and does not run in a terminal. Then the app is its own terminal, and the + terminal that started it is not its business. Examples are a classic Windows + console to move out of and a font to offer. This is true on the Cardputer Zero, + because its launcher gives an app no console. It is also true on each platform + while the emulator shows the platform in a window. + modifier_watch: The keyboard whose Shift state the optional evdev watcher follows. + While Shift is held, the watcher changes the F-key lane on the screen at once. + The value is a substring of the name of the input device, and case does not + matter. ``""`` keeps the watcher off. This is for handhelds only. Desktop + terminals have no lane to flip. The watcher needs access to ``/dev/input``, which + the deploy user of a handheld has (the ``input`` group). It is always optional + and lazy. Wired in P4. """ name: str @@ -194,11 +209,12 @@ class Platform: @property def footer_fkeys(self) -> bool: - """Whether the footer is an F-key lane rather than each screen's hint string.""" + """Whether the footer is an F-key lane and not the hint string of each screen.""" return bool(self.lane_deck) -#: Exactly today's behaviour — the desktop/ssh terminal, unchanged by this seam's arrival. +#: Exactly the behaviour of the desktop or ssh terminal. The arrival of this seam did not +#: change it. REGULAR = Platform( name="regular", readable_cols=72, @@ -224,10 +240,10 @@ REGULAR = Platform( modifier_watch="", ) -#: The PicoCalc/Lyra/Calculinux framebuffer console. Field values not yet consumed by a -#: binding point (header_atoms, font, truecolor, effects beyond the -#: two P1 bindings, tick_s, battery, modifier_watch) are P2–P5's targets, recorded here -#: now so the seam exists before the flavour work lands. +#: The framebuffer console of the PicoCalc, Lyra, and Calculinux. No binding point uses +#: some of the field values yet (header_atoms, font, truecolor, effects beyond the two P1 +#: bindings, tick_s, battery, modifier_watch). They are the targets of P2 to P5. They are +#: recorded here now, so that the seam exists before the work on the flavour arrives. PICOCALC_LYRA = Platform( name="picocalc-lyra", readable_cols=53, @@ -236,9 +252,9 @@ PICOCALC_LYRA = Platform( menu_icons=False, dialog_margin=4, dialog_row_margin=4, - # JP's call (2026-08-01, post-P7 review): the handheld's header brands the app — - # "MeshTerm vX" — rather than naming the device/port (on a soldered radio the port - # never changes), and the activity pulse takes whatever room remains. + # JP's decision (2026-08-01, after the P7 review): the header of the handheld shows the + # name of the app ("MeshTerm vX") instead of the device or the port, because the port + # of a soldered radio never changes. The activity pulse takes the room that remains. header_atoms=("version", "badges", "pulse", "battery"), header_row=True, lane_deck="picocalc-lyra", @@ -256,19 +272,21 @@ PICOCALC_LYRA = Platform( modifier_watch="picocalc", ) -#: M5Stack's Cardputer Zero: a 320×170 panel drawn in 6×12 cells (53×14), under a -#: 46-key keyboard whose number keys 4–8 sit right below the display. Hardware not yet in -#: hand (2026-10-04), so this is chosen by ``--platform cardputer-zero`` / ``MESHTERM_PLATFORM`` -#: only — no device-tree auto-detection until the device reports its own model string. +#: The Cardputer Zero of M5Stack. Its display is 320×170 pixels, drawn in cells of 6×12 +#: (53×14). Under it is a keyboard of 46 keys, and the number keys 4 to 8 are right below +#: the display. The hardware was not in hand on 2026-10-04. Thus only ``--platform +#: cardputer-zero`` or ``MESHTERM_PLATFORM`` chooses this platform. There is no +#: device-tree auto-detection until the device reports its own model string. #: -#: The same 53 columns as the PicoCalc, which is why most of its flavour carries over; the -#: new constraint is rows. It has its own F-key lane deck (Fn+4…8, chips centred over their -#: keys), dealing the PicoCalc's lanes for now (JP, 2026-09-30). MeshTerm draws the panel -#: itself in RGB565 (:mod:`meshterm.emulator`), so it is not held to a 16-slot palette -#: (``truecolor``). Provisional until measured on the device: the cadences, taken from the -#: PicoCalc; and ``effects`` off. The battery is the handheld's own BQ27220 gauge, which the -#: kernel publishes as the ``power_supply`` ``bq27220-0`` (seen on the device, 2026-10-05) — -#: the pack the reader is holding, whichever radio is connected, as on the PicoCalc. +#: It has the same 53 columns as the PicoCalc, so most of the flavour of the PicoCalc +#: applies. The new constraint is the number of rows. It has its own F-key lane deck (Fn+4…8, +#: with the chips centred over their keys). For now, the deck deals the lanes of the PicoCalc +#: (JP, 2026-09-30). MeshTerm draws the display itself in RGB565 (:mod:`meshterm.emulator`), +#: so the 16-slot palette does not limit it (``truecolor``). These values are provisional +#: until MeshTerm measures them on the device: the cadences, which come from the PicoCalc, +#: and ``effects`` off. The battery is the BQ27220 gauge of the handheld itself. The kernel +#: publishes it as the ``power_supply`` ``bq27220-0`` (seen on the device, 2026-10-05). It +#: is the pack that the user holds, with any connected radio, the same as on the PicoCalc. CARDPUTER_ZERO = Platform( name="cardputer-zero", readable_cols=53, @@ -277,9 +295,10 @@ CARDPUTER_ZERO = Platform( menu_icons=False, dialog_margin=4, dialog_row_margin=1, - # No header row (JP, 2026-10-03): the badges and battery ride the title bar's right - # end, the wordmark is the main menu's title, and the pulse, which only ever had the - # header's leftover cells, goes — the dashboard draws the mesh's activity in full. + # No header row (JP, 2026-10-03). The badges and the battery go at the right end of the + # title bar, and the wordmark is the title of the main menu. The pulse is removed. It + # only ever had the cells that the header did not use, and the dashboard draws the + # activity of the mesh in full. header_atoms=("badges", "battery"), header_row=False, lane_deck="cardputer-zero", @@ -287,8 +306,8 @@ CARDPUTER_ZERO = Platform( emoji=False, font="cardputer-zero", truecolor=True, - # The emulator draws its own braille, as the PicoCalc's built fonts do: solid - # tiles, no gap between dots (meshterm/emulator/font.py, BRAILLE). + # The emulator draws its own braille, the same as the built fonts of the PicoCalc: solid + # tiles, with no gap between the dots (meshterm/emulator/font.py, BRAILLE). solid_braille=True, url_codes=False, effects=False, @@ -301,38 +320,41 @@ CARDPUTER_ZERO = Platform( _BY_NAME = {p.name: p for p in (REGULAR, PICOCALC_LYRA, CARDPUTER_ZERO)} -#: The active platform. Do not import this name directly (see the module docstring) — -#: read it through :func:`get_platform`, and change it only through :func:`set_platform`. +#: The active platform. Do not import this name directly (refer to the module docstring). +#: Read it through :func:`get_platform`, and change it only through :func:`set_platform`. PLATFORM = REGULAR def get_platform() -> Platform: """Return the active :class:`Platform`. - Safe to call from anywhere, at any time — always reflects the most recent - :func:`set_platform` call, however that module was imported. + You can call this function from anywhere, at any time. It always returns the platform of + the most recent :func:`set_platform` call, in whatever way the module was imported. """ return PLATFORM -#: Callbacks re-run on every :func:`set_platform`, so a module can bind platform-derived -#: state (a chosen theme, a swapped function implementation) once per *switch* instead of -#: re-deriving it per call in a render loop. Registered via :func:`on_platform`. +#: Callbacks that run again at each :func:`set_platform`. Thus a module can bind state that +#: comes from the platform (a chosen theme, a changed function implementation) one time for +#: each switch, and does not derive it again at each call in a render loop. Register a +#: callback with :func:`on_platform`. _BINDINGS: list[Callable[[Platform], None]] = [] def on_platform(binding: Callable[[Platform], None]) -> Callable[[Platform], None]: - """Register (and immediately run) a platform binding. + """Register a platform binding and run it at once. - The seam's principle is *bind at construction time, never per frame*: a consumer that - must swap behaviour with the platform (the theme's ``name_style`` impl, the - rasterizer's console cache) registers a binding here at its own import time. The - binding runs once right away — against the platform active *now* — and again on every - later :func:`set_platform`, so tests that swap platforms rebind automatically and the - consumer's hot path reads a plain module global. + The principle of the seam is to bind at construction time, never for each frame. A + consumer that must change its behaviour with the platform registers a binding here, at + its own import time. Examples are the ``name_style`` implementation of the theme and the + console cache of the rasterizer. The binding runs one time at once, with the platform + that is active now. It runs again at each later :func:`set_platform`. Thus tests that + change the platform bind again automatically, and the hot path of the consumer reads a + plain module global. Args: - binding: Called with the active :class:`Platform`; must be idempotent. + binding: A callback that is called with the active :class:`Platform`. It must be + idempotent. Returns: ``binding`` unchanged, so it can be used as a decorator. @@ -345,11 +367,12 @@ def on_platform(binding: Callable[[Platform], None]) -> Callable[[Platform], Non def set_platform(platform: Platform) -> None: """Install ``platform`` as the active one and re-run every registered binding. - Called exactly once by the CLI callback, before :func:`~meshterm.ui.theme.make_console` - — every other read of :func:`get_platform` in the same process happens after this. - Idempotent (setting the same platform twice is a no-op in effect) and reversible, so - tests can swap in a platform for one test and restore the previous one afterwards - (see the ``_reset_platform`` autouse fixture in ``tests/conftest.py``). + The CLI callback calls this function one time, before + :func:`~meshterm.ui.theme.make_console`. Each other read of :func:`get_platform` in the + same process happens after this call. The function is idempotent (if you set the same + platform two times, the second call has no effect) and reversible. Thus a test can set a + platform for one test and restore the previous platform afterwards (refer to the + ``_reset_platform`` autouse fixture in ``tests/conftest.py``). Args: platform: The platform to make active. @@ -362,18 +385,19 @@ def set_platform(platform: Platform) -> None: @dataclass(frozen=True, slots=True) class Resolution: - """What :func:`resolve` looked at and what it landed on. + """The inputs that :func:`resolve` examined, and the platform that it chose. - Exists so the ``meshterm platform`` diagnostic can report every input the resolution - considered, not just the final answer. + The ``meshterm platform`` diagnostic uses this class. Thus it can report each input that + the resolution considered, and not only the final answer. Attributes: - flag: The ``--platform`` value passed on the command line, if any. - env: The ``MESHTERM_PLATFORM`` environment variable's value, if set. - detected_model: The ``/proc/device-tree/model`` contents read during resolution - (trailing NUL/space stripped), or ``None`` when the file doesn't exist (every - non-Lyra machine, including every desktop and CI runner). - source: Which input decided the outcome — ``"--platform flag"``, + flag: The ``--platform`` value from the command line, if there is one. + env: The value of the ``MESHTERM_PLATFORM`` environment variable, if it is set. + detected_model: The contents of ``/proc/device-tree/model`` that the resolution read + (without the NUL and the spaces at the end). It is ``None`` when the file does + not exist (each machine that is not a Lyra, with each desktop and each CI + runner). + source: The input that decided the result: ``"--platform flag"``, ``"MESHTERM_PLATFORM env var"``, ``"auto-detect"``, or ``"default"``. platform: The resolved :class:`Platform`. """ @@ -385,23 +409,24 @@ class Resolution: platform: Platform -#: Where the Lyra reports itself in the device tree. P0 (2026-08-01) recorded a trailing -#: space before the NUL; re-verified live on-device during P1 (2026-08-01) as exactly -#: ``b"Luckfox Lyra\x00"`` — no trailing space. :func:`_read_device_tree_model` strips -#: whatever surrounds it either way, so this is a documentation correction, not a behaviour -#: change — flagged here in case a different unit/firmware revision does carry one. +#: Where the Lyra reports itself in the device tree. P0 (2026-08-01) recorded a space at the +#: end, before the NUL. During P1 (2026-08-01), a check on the handheld showed exactly +#: ``b"Luckfox Lyra\x00"``, with no space at the end. :func:`_read_device_tree_model` removes +#: all the characters around the name in both cases. Thus this is a correction of the +#: documentation and not a change of behaviour. It is noted here because a different unit +#: or firmware revision can have the space. _LYRA_MODEL = "Luckfox Lyra" _DEVICE_TREE_MODEL_PATH = Path("/proc/device-tree/model") def _read_device_tree_model() -> str | None: - """Read and normalize ``/proc/device-tree/model``, or ``None`` when it doesn't exist. + """Read and normalize ``/proc/device-tree/model``, or return ``None`` if it does not exist. - Absent on every machine that isn't running a device-tree-based Linux kernel — every - desktop, every CI runner, and (today) every ``ssh`` session onto one of those. This is - deliberately the *only* signal auto-detection consults (no terminal-size guessing), so - a small window on the desktop is never mistaken for the device. + The file is absent on each machine that does not run a Linux kernel with a device tree: + each desktop, each CI runner, and (today) each ``ssh`` session to one of these. This is + the only signal that auto-detection uses, on purpose. It does not guess from the size of + the terminal. Thus a small window on the desktop is never mistaken for the device. """ try: raw = _DEVICE_TREE_MODEL_PATH.read_bytes() @@ -411,37 +436,39 @@ def _read_device_tree_model() -> str | None: def _lookup(name: str, *, source: str) -> Platform: - """Resolve a platform name to its :class:`Platform`, or raise a clear ``ValueError``.""" + """Resolve a platform name to its :class:`Platform`, or raise a ``ValueError``.""" try: return _BY_NAME[name.strip().lower()] except KeyError: choices = ", ".join(sorted(_BY_NAME)) - # Plain ASCII: this can surface through Typer/Click's own error path (raised - # before make_console() has reconfigured stdout/stderr to UTF-8), which mangles - # non-ASCII on a legacy Windows console the way make_console's docstring warns of. + # Plain ASCII. This error can come through the own error path of Typer and Click. + # They raise it before make_console() sets stdout and stderr to UTF-8. On a legacy + # Windows console, this damages non-ASCII text, as the docstring of make_console + # warns. raise ValueError( f"unknown platform {name!r} (from {source}) - choose from: {choices}" ) from None def resolve(flag: str | None = None) -> Resolution: - """Decide which platform is active, and record what the decision looked at. + """Decide which platform is active, and record the inputs of the decision. - Resolution order: the explicit ``--platform`` flag, then the ``MESHTERM_PLATFORM`` - environment variable, then device-tree auto-detection, then :data:`REGULAR`. Only the - input that actually decides the outcome is name-validated — an env var typo is - reported when the flag isn't set, but never blocks a session that passed an explicit - (correct) flag. + The order of resolution is: the explicit ``--platform`` flag, then the + ``MESHTERM_PLATFORM`` environment variable, then device-tree auto-detection, then + :data:`REGULAR`. MeshTerm checks the name only for the input that decides the result. + If the flag is not set, a typing error in the environment variable is reported. But + the error never blocks a session that has an explicit and correct flag. Args: - flag: The ``--platform`` command-line value, if given. + flag: The ``--platform`` value from the command line, if there is one. Returns: - The full :class:`Resolution`, for both immediate use (``.platform``) and the - ``meshterm platform`` diagnostic's report. + The full :class:`Resolution`. The caller can use it at once (``.platform``), and the + report of the ``meshterm platform`` diagnostic uses it. Raises: - ValueError: The deciding input (flag or env var) named an unknown platform. + ValueError: The input that decided the result (the flag or the environment + variable) named an unknown platform. """ found = _resolve(flag) if _DRAWN_BY_MESHTERM and not found.platform.own_display: @@ -450,7 +477,7 @@ def resolve(flag: str | None = None) -> Resolution: def _resolve(flag: str | None) -> Resolution: - """:func:`resolve`'s decision, before :func:`drawn_by_meshterm` has its say.""" + """The decision of :func:`resolve`, before :func:`drawn_by_meshterm` has an effect.""" env = os.environ.get("MESHTERM_PLATFORM") or None model = _read_device_tree_model() if flag: @@ -468,20 +495,21 @@ def _resolve(flag: str | None) -> Resolution: return Resolution(flag, env, model, "default", REGULAR) -#: Set while :mod:`meshterm.emulator` runs MeshTerm in a display of its own drawing (see -#: :func:`drawn_by_meshterm`). +#: True while :mod:`meshterm.emulator` runs MeshTerm in a display that MeshTerm draws itself +#: (refer to :func:`drawn_by_meshterm`). _DRAWN_BY_MESHTERM = False @contextmanager def drawn_by_meshterm() -> Iterator[None]: - """Resolve every platform as one MeshTerm draws itself, for as long as this lasts. + """Resolve each platform as a platform that MeshTerm draws itself, while the context lasts. - The emulator shows a PicoCalc (Lyra) in a window: the platform is that device's in every - respect but one — no console draws it, MeshTerm does — so it resolves with - :attr:`Platform.own_display` set, and nothing about the terminal the emulator was - started from (a classic Windows console to leave, a font to offer) is the emulated - session's business. In-process only: nothing leaks into a child process's environment. + The emulator shows a PicoCalc (Lyra) in a window. The platform is the platform of that + device in each way but one: no console draws it, and MeshTerm does. Thus it resolves + with :attr:`Platform.own_display` set. The terminal that started the emulator is not the + business of the emulated session (for example, a classic Windows console to leave, or a + font to offer). This works only in the process. Nothing goes into the environment of a + child process. """ global _DRAWN_BY_MESHTERM previous = _DRAWN_BY_MESHTERM @@ -493,19 +521,19 @@ def drawn_by_meshterm() -> Iterator[None]: def without_emoji(platform: Platform) -> Platform: - """The same platform with its icons routed through the compact glyph table. + """The same platform, with its icons sent through the compact glyph table. - A terminal that cannot draw emoji is not a different *flavour* — the classic Windows - console has the desktop's width, colour depth and keyboard, and wants every other - thing :data:`REGULAR` says. Only the one flag moves, which is what the seam's flags - being independent is for: ``font`` stays unset, so accents and the rest of the BMP - still come through. + A terminal that cannot draw emoji is not a different flavour. The classic Windows + console has the width, the colour depth, and the keyboard of the desktop, and it needs + all the other values that :data:`REGULAR` has. Only the one flag changes. This is the + reason why the flags of the seam are independent. ``font`` stays unset, so accents and + the rest of the BMP still go through. Args: platform: The resolved platform. Returns: - ``platform`` itself when its icons are already compact, else a copy with + ``platform`` itself if its icons are already compact. If not, a copy with ``emoji`` cleared. """ return platform if not platform.emoji else replace(platform, emoji=False)