From da05dd402fce84e7fc2289fe6167f5069fffb730 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Tue, 8 Sep 2026 00:52:48 -0400 Subject: [PATCH] Give the CLI its own voice, meant for a pipe rather than an eye The CLI had grown the menu's manners -- colour, boxes, section headings, glyph columns, a closing check mark -- and every one of them is in the way of the thing it is actually for. It now prints like a standard Unix utility: plain text, no escape sequences at all, no frames, one record per line, and nothing wrapped, so a long line runs off the right instead of folding its fields onto a second one. meshterm/ui/script.py is the whole vocabulary. Listings are an uppercase header and space-aligned records; key/value blocks are sysctl's shape, and a `get` prints the bare value alone. Names are quoted wherever they share a line, because a name can hold a space or a comma and a bare one is not a field. A route reads "Origin" (3d),"Relay" (f2),"Us" (a1) -- the hash outside the quotes, our own node a hop like any other rather than a star, since the hash is what carries the identity that colour carries on screen. Times are absolute local ISO-8601 and "-" is the one token for absent. The return value is now half the report: 0 ok, 1 failure, 2 usage, 3 no device, 4 the device was reached and the operation failed, 5 nothing to report. That last one is the point -- a command that ran fine and found nothing prints nothing and says so in $?, so a script never has to count output lines to tell an empty mesh from a full one. Errors and progress moved to stderr, and an expected failure no longer arrives as a traceback. Two things lose their scripted face rather than get a degraded one. The map is a picture whose nodes are told apart by colour: monochrome it would be unreadable, coloured it could not be piped anywhere useful, and the nodes themselves are still in `contacts`. The QR codes are a second rendering of a link the output already prints. ui.ack joins ui.note to draw the line the change needed: a note is output, an acknowledgement is reassurance a person wants and a script must filter out, so the menu prints it and the CLI drops it. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 68 ++++++ CLAUDE.md | 43 ++++ README.md | 48 +++- meshterm/cli.py | 191 +++++++++------- meshterm/core/exitcodes.py | 73 +++++++ meshterm/tools/about.py | 6 +- meshterm/tools/base.py | 34 ++- meshterm/tools/channels.py | 43 ++-- meshterm/tools/chat.py | 128 +++++++---- meshterm/tools/config.py | 134 +++++++++--- meshterm/tools/contacts.py | 63 +++++- meshterm/tools/courier.py | 69 +++--- meshterm/tools/devices.py | 113 +++++----- meshterm/tools/info.py | 137 ++++++++++-- meshterm/tools/map.py | 158 ++----------- meshterm/tools/monitor.py | 84 ++++--- meshterm/tools/preferences.py | 83 ++++++- meshterm/tools/records.py | 110 +++++++--- meshterm/tools/repeater_admin.py | 18 +- meshterm/tools/trace.py | 142 ++++++++++-- meshterm/tools/tx_optimize.py | 66 +++++- meshterm/ui/markdown.py | 27 ++- meshterm/ui/script.py | 365 +++++++++++++++++++++++++++++++ meshterm/ui/surface.py | 116 ++++------ meshterm/ui/widgets.py | 4 +- tests/test_advert.py | 4 + tests/test_channels.py | 3 + tests/test_cli_contract.py | 205 +++++++++++++++++ tests/test_config_editor.py | 4 + tests/test_courier.py | 4 + tests/test_device_config.py | 14 +- tests/test_map.py | 69 +----- tests/test_preferences.py | 3 + tests/test_script_output.py | 274 +++++++++++++++++++++++ 34 files changed, 2233 insertions(+), 670 deletions(-) create mode 100644 meshterm/core/exitcodes.py create mode 100644 meshterm/ui/script.py create mode 100644 tests/test_cli_contract.py create mode 100644 tests/test_script_output.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fc056df..5de4aa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,74 @@ allowed to change behaviour, not just add to it. ## [Unreleased] +### Changed + +- **The CLI now prints like a standard Unix utility.** It had grown the menu's manners — + colour, boxes, section headings, glyph columns, a closing ✓ — and all of that is in the + way of the thing the CLI is actually for, which is being piped into something else. + + Output is now plain text and nothing else: no escape sequences at all (not a colour, not + a bold), no borders or boxes or rules, no titles, no legends. Listings are an uppercase + header line and space-aligned records the way `ps` prints them; `info` and `config show` + are `key value` lines the way `sysctl -a` prints them; `config get` and `preferences get` + print the bare value alone, ready for `$(...)`. **Nothing wraps** — a record is a line, + and a long one runs off the right rather than folding its fields onto a second one. + +- **Node names are quoted, and paths are made of them.** A name can hold a space or a + comma, so a bare one is not a field. Every name in a listing is `"quoted"`, and a route + now reads `"Origin" (3d),"Relay" (f2),"Us" (a1)` — each node's hash in parentheses + outside the quotes, hops joined by a bare comma, our own node a hop like any other rather + than a `★`. The hash is there because the CLI has no colour: on a screen a route's hops + are told apart by their hues, and the hash is what carries that identity in plain text. + +- **Times are absolute.** `2026-09-07T18:22:41-04:00`, not `3h`. A relative age is for a + person watching a screen; a script wants something it can sort and subtract. `-` is now + the one token for a value that is absent, unknown, or does not apply. + +- **A value read out of `show` can be typed back into `set`.** The settings dump printed an + enum as `0 (off)` and an unset string as `(not set)`, neither of which `config set` would + take back. It prints the number and `""` now, and names every setting by the key + `get`/`set` use rather than by its screen label. + +- **`--help` is Click's own plain help** — no boxed Options/Commands panels, no colour, no + markup to strip out of a piped `--help`. + +### Added + +- **Documented exit statuses**, listed under every `--help`: `0` success, `1` failure, `2` + usage error, `3` no device found, `4` the device was reached but the operation failed, + `5` nothing to report. + + `5` is the one worth knowing about. A command that ran fine and found nothing — an empty + contact list, a trace that never came home, a conversation with no messages — prints + nothing and returns `5`, so a script can tell "found nothing" from "worked" without + counting output lines. `3` and `4` split the failures a retry might fix from the ones it + won't. + +### Fixed + +- **Errors go to stderr**, in the `meshterm: what went wrong` shape every utility uses, so + a caller redirecting stdout still sees them and a caller parsing stdout never has to + filter them out. Progress bars moved there too, and draw nothing at all when stderr is + not a terminal. + +- **An expected failure no longer dumps a traceback.** A lost serial link, or an argument a + command rejected on its own (an unresolvable `--to`), read as one line and the right exit + status; the log keeps the detail, and a genuine fault still gets its full traceback. + +### Removed + +- **`meshterm map`.** A map is a picture — braille cells whose meaning is their position on + a grid, and whose nodes are told apart by colour. Stripped of colour to match the rest of + the CLI it would be unreadable, and left coloured it was the one command whose output + could not be piped anywhere useful. The map stays in the menu, where it works; the located + nodes stay scriptable through `meshterm contacts`, coordinates and all. + +- **The QR codes** from `channels share`, `config share` and the About pages' scripted face. + A QR is a second rendering of a link the output already prints, drawn for a phone pointed + at a screen; redirected into a file it was a block of block characters wrapped around the + one thing that was actually the answer. The menu still draws them. + ## [0.2.8] — 2026-09-07 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index dd65c69..8fb0d1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -323,6 +323,49 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow - Radio traffic: single transmissions or a user-chosen sample count with cooldown pacing — never bursts. +### The scripted CLI + +Everything above describes the **menu**, which is read by a person. The **CLI** is read by +`awk`, and it gets its own vocabulary: `meshterm/ui/script.py` is all of it, and a scripted +surface is written through it rather than by folding a screen's renderable flat. The seam +is `PlainUi`, chosen in `cli.main_callback` by whether a subcommand was named; a tool +branches on `isinstance(ctx.ui, TuiUi)` where the two faces genuinely differ. + +- **No colour, no wrapping, no frames.** The console is `color_system=None` (no escape + sequence at all, not even a bold), 16384 cells wide so nothing folds, and every table is + box-less. `script.flatten` is the net under a shared renderable that still arrives boxed + — it makes output printable, not right. A highlight colour is reserved for the one thing + that would be unreadable without it (a route graph's emphasised path); nothing claims it + today. +- **Listings** go through `script.columns` — uppercase header line, space-aligned records, + numbers right. **Key/value blocks** go through `script.pairs` (`sysctl -a`'s shape), and + a `get` prints the bare value alone. No titles, no legends, no glyph columns: a node type + is the word `repeater`, not `▲`. +- **Names are quoted** (`script.quote`) wherever they share a line with other fields; a + `pairs` value is the rest of the line and needs none. **Paths** are `script.path`: + `"Name" (hash)` joined by a bare comma, our own node a hop like any other — never the + menu's `★`, and never an arrow. The hash carries the identity that colour carries on a + screen. +- **Times are absolute** (`script.stamp`, local ISO-8601); the relative ages and their heat + stay in the menu. **`script.NONE`** (`-`) is the one token for absent. +- **A value must round-trip.** `config show` prints what `config set` takes — an enum's + number, not its label — so a line read out can be typed back in. +- **stdout is the answer; everything else is stderr.** Errors read `meshterm: what went + wrong`, progress bars draw on stderr and go silent off a terminal, and log records go + there too (at ERROR, since an expected failure is already reported once). +- **`ui.ack` vs `ui.note`.** A *note* is output. An *acknowledgement* — "✓ device clock + set" — is reassurance a person needs and a script does not, so `ack` prints in the menu + and is dropped on the CLI. A `ToolResult.message` is menu-only for the same reason. +- **The exit status is the report** (`core/exitcodes.py`, and the `--help` epilog): 0 ok · + 1 failure · 2 usage · 3 no device · 4 device failed · 5 nothing to report. A tool that + ran fine and found nothing returns `NO_RESULT` and prints nothing; a failure is *raised*, + never returned. +- **Some features have no scripted face at all**, and that is the honest answer rather than + a degraded one: the map (a picture whose nodes are told apart by colour), the dashboard, + the live feed, the watchtower, the mesh walk. `specimen` is the deliberate exception that + keeps its colour — its output *is* the colour, and it builds its own themed console. +- `tests/test_script_output.py` and `tests/test_cli_contract.py` are the enforcement points. + ### Platforms One codebase, two flavours: **regular** (desktop/ssh, 72 cols, truecolor, emoji) and diff --git a/README.md b/README.md index 00c9eae..c4aa3eb 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ here with its scripted equivalent, where one exists. | **💬 Chat** | Live full-screen channel and direct messaging — a scrolling transcript with a pinned input line where sent and received messages stream together. Every message is logged; unread counts show in the menu header. | `meshterm chat send / history / list` | | **📻 Channels** | Create, join, reorder, mute, and share mesh channels — with QR codes and `meshcore://` share links. | `meshterm channels list / add / join / import / share / clear` | | **📨 Courier** | Store-and-forward outbox for contacts that aren't reachable yet. Queue a message; it goes out (with ack tracking and polite exponential backoff) the moment the contact is next heard, or at a scheduled time. | `meshterm courier queue / list / send / cancel / clear` | -| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. | `meshterm contacts` | +| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. The scripted listing is the contacts alone (this node is `meshterm info`'s answer, in far more detail). | `meshterm contacts` | ### 📊 Watch — what the mesh is doing, and what it did @@ -214,7 +214,7 @@ here with its scripted equivalent, where one exists. | Feature | What it does | Scripted | | --- | --- | --- | -| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | `meshterm map` (one-shot render) | +| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | — (a map is a picture; `meshterm contacts` lists the same nodes) | | **🌐 Mesh walk** | The mesh's *observed shape*, walked one node at a time: an evidence graph built from trace walks, firmware routes, overheard relay chains, and repeater neighbour tables. SNR-coloured braille edges, quality bars, Enter to walk, ⌫ to backtrack, type to find any node. The map answers *where*; the walk answers *how it hangs together*. | — | | **🎯 Trace target** | A live trace screen: pick a target and watch each trace stream in hop by hop, with running per-hop medians and reliability. Compose or force a route through specific repeaters. A trace transmits **exactly once** (repeaters can blacklist nodes that burst) — sample more by running it again. | `meshterm trace --target …` | | **👣 Trace path** | The other half of tracing: compose the whole circuit by hand — out and back whichever way you choose — and walk it. The path composer suggests each next hop from the links actually observed, strongest first, and can fetch a repeater's neighbour table over the mesh when you hold its admin password. | `meshterm trace --path …` | @@ -269,7 +269,8 @@ meshterm courier send 3 # force one delivery attempt now meshterm repeater-admin Bravo-Repeater "get name" # Device configuration: view, set, back up, restore -meshterm config # show all current settings +meshterm config # show all current settings, one `key value` per line +meshterm config get name # just the value, ready for $(...) meshterm config set radio_sf 9 # change one setting meshterm config backup node.toml # archive every setting to TOML meshterm config restore node.toml --dry-run @@ -283,6 +284,47 @@ meshterm monitor --seconds 60 Global options (before the subcommand): `--profile/-p`, `--port`, `--ble`, `--ble-pin`, `--tcp`, `--mock`, `--db`, `--json`, `--quiet/-q`. +### What the output looks like + +The CLI is meant to be piped, so it prints like a standard Unix utility and nothing else: +plain text, no colour, no borders, and one record per line — nothing is ever wrapped, so a +long line runs off the right rather than folding its fields onto a second one. + +- **Listings** are an uppercase header line and space-aligned records, `ps`-style. +- **Node names are quoted** — `"YUL Cartierville"` — so a name holding a space or a comma + is still exactly one field. +- **Paths** are quoted names with their hash in parentheses outside the quotes, joined by + a bare comma: `"Origin" (3d),"Relay" (f2),"Us" (a1)`. Our own node is a hop like any + other. The hash is what identifies a node where a screen would use colour. +- **Key/value output** (`info`, `config show`) is a key and the rest of the line as its + value; `config get` and `preferences get` print the bare value alone. +- **Times** are absolute local ISO-8601 (`2026-09-07T18:22:41-04:00`), never `3h ago`. +- **`-`** is the one token for a value that is absent, unknown, or does not apply. +- **Errors** go to stderr as `meshterm: what went wrong`; stdout carries only the answer. + Progress bars go to stderr too, and are silent when it is not a terminal. + +### Exit status + +| Code | Meaning | +| --- | --- | +| `0` | Success. | +| `1` | Failure with no more specific code — a bad value, an unreadable file, a fault. | +| `2` | Usage error: an unknown flag, a missing argument, a value the parser rejected. | +| `3` | No companion device could be selected. Nothing was transmitted. | +| `4` | A device was reached but the operation failed — a command error, a timeout, a lost link. Retrying is reasonable. | +| `5` | The command completed with **nothing to report**: an empty list, a target that never answered, a conversation with no messages. stdout is empty; this is not an error. | + +`5` is the one worth knowing about: it lets a script tell "found nothing" from "worked" +without counting output lines. + +```bash +if meshterm contacts > contacts.txt; then + echo "$(wc -l < contacts.txt) contacts" +elif [ $? -eq 5 ]; then + echo "no contacts yet" +fi +``` + ## Configuration Two files, two jobs — and you need neither to run. diff --git a/meshterm/cli.py b/meshterm/cli.py index 28e0606..6c78543 100644 --- a/meshterm/cli.py +++ b/meshterm/cli.py @@ -17,7 +17,7 @@ import typer from rich.console import Console from .context import AppContext -from .core import win32dll +from .core import exitcodes, win32dll from .core.admin_store import AdminStore from .core.config import Settings from .core.connection import DeviceCommandError, is_connection_lost @@ -31,48 +31,24 @@ from .persistence.repository import Repository from .platforms import Resolution, resolve, set_platform, without_emoji from .tools import all_tools from .tools.base import Tool, ToolResult +from .ui import script from .ui.termfont import emoji_support from .ui.theme import make_console - -def _unframe_help_panels() -> None: - """Render Typer's help/error sections as coloured headings instead of boxed panels. - - Typer's rich help gives us the colour we want — yellow usage, cyan options, green - switches — but wraps each Options/Commands/Error section in a rounded :class:`Panel`, - the boxed framing scripted output is meant to be free of. There's no constant to drop - that box, so we swap the ``Panel`` the help formatter calls for a thin stand-in that - keeps the colour and the section title (as a bold heading, indigo like the app accent, - red for errors) but no border — the same heading-over-content shape tool results use - (see :func:`~meshterm.ui.surface._deframe`). The inner tables are already box-less. - """ - import typer.rich_utils as rich_utils - from rich.console import Group - from rich.text import Text - - def _bare_panel( - renderable: object, - *, - title: object = None, - border_style: str = "", - **_: object, - ) -> Group: - style = "bold red" if border_style == "red" else "bold #818cf8" - heading = Text(str(title), style=style) if title else Text("") - return Group(Text(""), heading, renderable) # type: ignore[list-item] - - rich_utils.Panel = _bare_panel # type: ignore[assignment,misc] - - -_unframe_help_panels() +#: 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. +EXIT_STATUS_EPILOG = "Exit status: " + " · ".join( + f"{code} {meaning}" for code, meaning in exitcodes.MEANINGS.items() +) app = typer.Typer( add_completion=False, no_args_is_help=False, - # Rich help for its colour (usage, option flags, metavars), but with the boxed - # section panels flattened to plain headings by _unframe_help_panels() above — a - # splash of colour, none of the framing. - rich_markup_mode="rich", + # 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). + 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. help=( @@ -80,6 +56,7 @@ app = typer.Typer( "Supports USB, Bluetooth and TCP companion connection. " "For Windows, macOS and Linux." ), + epilog=EXIT_STATUS_EPILOG, ) # The context built by the callback and consumed by subcommands within one process. @@ -152,14 +129,25 @@ def main_callback( if db_path is not None: settings.db_path = db_path - console = make_console() + # 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. + 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. prefs = Preferences.load(settings.config_dir / "preferences.yaml") configure_logging( - console, + # 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. + script.stderr_console() if scripted else console, settings.config_dir, - level=logging.INFO, + # 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. + level=logging.ERROR if scripted else logging.INFO, file_level=level_from_name(prefs.log_level), quiet=quiet or json_output, ) @@ -453,22 +441,22 @@ def platform_command() -> None: (see :func:`meshterm.ui.termfont.emoji_support`). """ assert _platform_resolution is not None # set by the callback that always runs first + assert _state is not None # ditto r = _platform_resolution - console = make_console() - console.print(f"platform: [accent]{r.platform.name}[/accent] (source: {r.source})") - console.print(f" --platform: {r.flag or '(not passed)'}") - console.print(f" MESHTERM_PLATFORM: {r.env or '(not set)'}") - console.print(f" /proc/device-tree/model: {r.detected_model or '(unavailable)'}") - emoji = emoji_support() - verdict = "yes" if emoji.supported else "no — icons use the compact glyphs" - console.print(f"icons: [accent]{verdict}[/accent] (source: {emoji.source})") - if not emoji.supported and emoji.source == "console": - console.print( - " this is the classic Windows console, which draws no emoji whatever font\n" - " it is set to. Windows Terminal and VS Code's terminal both do — running\n" - " MeshTerm in one gets the icons back. MESHTERM_EMOJI=1 overrides this." + _state.console.print( + script.pairs( + [ + ("platform", r.platform.name), + ("platform_source", r.source), + ("flag", r.flag or script.NONE), + ("env", r.env or script.NONE), + ("device_tree_model", r.detected_model or script.NONE), + ("icons", "yes" if emoji.supported else "no"), + ("icons_source", emoji.source), + ] ) + ) @app.command(name="specimen") @@ -479,6 +467,11 @@ def specimen_command() -> None: theme and glyph machinery the TUI uses — so on the PicoCalc console it is the font/palette acceptance screen, and with ``--platform picocalc`` on a desktop it previews that flavour. See :mod:`meshterm.ui.specimen`. + + 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. """ from .ui.specimen import specimen_lines @@ -499,44 +492,83 @@ def run_tool_command(tool: Tool, params: dict) -> None: """ assert _state is not None # set by the callback before any subcommand runs try: - asyncio.run(_drive(_execute_and_render(tool, params, _state), _state)) - except (DeviceSelectionError, DeviceConfigError, DeviceCommandError, PreferenceError) as exc: - # Expected user-facing error (ambiguous/absent device, a bad config or preference - # value, or a transient command failure): show the message, not a traceback. Logged - # at warning because it is a thing that went wrong, even though it is an ordinary - # one — a log that only holds crashes cannot answer "what happened before it". - get_logger().warning("%s failed: %s", tool.name, exc) - _state.console.print(f"[err]✗[/err] {exc}") - raise typer.Exit(1) from exc + 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. + raise _reported(tool, exc, exitcodes.NO_DEVICE) from exc + except DeviceCommandError as exc: + # A device was reached and the operation failed. Worth retrying. + 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. + 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. + 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 should - # still read as a clean message, not a traceback. + # 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 is_connection_lost(exc): - get_logger().warning("%s lost the device connection: %s", tool.name, exc) - _state.console.print( - "[err]✗[/err] the connection to your device was lost " - "(it may have been unplugged or powered off)." - ) - raise typer.Exit(1) from exc + 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. get_logger().exception("%s raised an unhandled error", tool.name) - _report_log_location(_state.console, _state.settings.config_dir) + _report_log_location(script.stderr_console(), _state.settings.config_dir) raise + if result.exit_code != exitcodes.OK: + raise typer.Exit(result.exit_code) -async def _drive(coro, ctx: AppContext) -> None: +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. + + 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`). + + 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". + + Args: + tool: The tool that failed, for the log line. + problem: The exception or message to report. + code: The status to exit with. + + Returns: + The :class:`typer.Exit` for the caller to raise. + """ + get_logger().warning("%s failed: %s", tool.name, problem) + script.stderr_console().print(f"meshterm: {problem}", style="err", highlight=False) + return typer.Exit(code) + + +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). Args: coro: The coroutine to run (a tool execution or the menu loop). ctx: The application context whose device should 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. """ try: - await coro + return await coro finally: if ctx._device is not None: try: @@ -547,7 +579,12 @@ async def _drive(coro, ctx: AppContext) -> None: async def _execute_and_render(tool: Tool, params: dict, ctx: AppContext) -> ToolResult: - """Run a tool and print its closing message and artifact list. + """Run a tool and print any file it produced. + + A tool's ``message`` is not printed here. It is the menu's closing line — "✓ applied + 3 changes", "✓ traced Alice" — and on the command line it restates what the output + above it already showed and what ``$?`` already says. Where a scripted run has a fact + to report that the listing does not carry, the tool prints that fact itself. Args: tool: The tool to execute. @@ -558,10 +595,10 @@ async def _execute_and_render(tool: Tool, params: dict, ctx: AppContext) -> Tool The tool's :class:`ToolResult`. """ result = await tool.execute(ctx, params) - if result.message: - ctx.console.print(result.message) + # A produced file is a fact, and its path is the whole of it: bare, one per line, so + # `meshterm config backup out.toml` can be read by the thing that runs it. for artifact in result.artifacts: - ctx.console.print(f"[ok]●[/ok] wrote [accent]{artifact}[/accent]") + ctx.console.print(artifact, highlight=False) return result diff --git a/meshterm/core/exitcodes.py b/meshterm/core/exitcodes.py new file mode 100644 index 0000000..b50047a --- /dev/null +++ b/meshterm/core/exitcodes.py @@ -0,0 +1,73 @@ +"""The exit statuses ``meshterm`` returns, and what each one means. + +A scripted run's *return value is its report*: the CLI says what happened in plain +text on stdout and says whether it worked in ``$?``. The codes are classified rather +than a bare 0/1 so a caller can tell the failures apart without reading prose — a +timeout is worth retrying, a bad key never is. + +The set is small and closed. Anything that does not map onto one of the specific +causes below is :data:`FAILURE`; nothing invents a code of its own. + ++------+--------------+--------------------------------------------------------+ +| Code | Name | Meaning | ++======+==============+========================================================+ +| 0 | ``OK`` | The command did what it was asked. | ++------+--------------+--------------------------------------------------------+ +| 1 | ``FAILURE`` | It failed for a reason with no more specific code — | +| | | a bad configuration or preference value, an | +| | | unreadable file, an unhandled fault. | ++------+--------------+--------------------------------------------------------+ +| 2 | ``USAGE`` | The command line itself was wrong: an unknown flag, a | +| | | missing argument, a value the parser rejected. Click | +| | | returns this on its own; it is named here so the | +| | | table is complete. | ++------+--------------+--------------------------------------------------------+ +| 3 | ``NO_DEVICE``| No companion could be selected: none attached, none | +| | | matching ``--port``/``--ble``/``--tcp``, or the | +| | | choice was ambiguous. Nothing was transmitted. | ++------+--------------+--------------------------------------------------------+ +| 4 | ``DEVICE`` | A device was reached but the operation failed: a | +| | | command error, a timeout, a link lost mid-run. | +| | | Retrying is reasonable. | ++------+--------------+--------------------------------------------------------+ +| 5 | ``NO_RESULT``| The command ran and completed, and there was nothing | +| | | to report: an empty list, a target that never | +| | | answered, a conversation with no messages. stdout is | +| | | empty; this is not an error. | ++------+--------------+--------------------------------------------------------+ + +:data:`NO_RESULT` is the one that needs saying out loud, because it is the one a +script most wants and no other tool offers: ``grep`` conflates "found nothing" with +"worked", and a caller that has to count output lines to tell an empty mesh from a +full one is parsing when it could be branching. +""" + +from __future__ import annotations + +#: The command did what it was asked. +OK = 0 + +#: A failure with no more specific code — a bad value, an unreadable file, a fault. +FAILURE = 1 + +#: The command line was wrong. Click returns this itself; named here for completeness. +USAGE = 2 + +#: No companion device could be selected. Nothing was transmitted. +NO_DEVICE = 3 + +#: A device was reached but the operation failed. Retrying is reasonable. +DEVICE = 4 + +#: The command completed with nothing to report. Not an error; stdout is empty. +NO_RESULT = 5 + +#: Every code, by name, for the ``--help`` epilog and the documentation. +MEANINGS: dict[int, str] = { + OK: "success", + FAILURE: "failure", + USAGE: "usage error", + NO_DEVICE: "no device found, or the selection was ambiguous", + DEVICE: "the device was reached but the operation failed", + NO_RESULT: "nothing to report (empty result)", +} diff --git a/meshterm/tools/about.py b/meshterm/tools/about.py index ae4aa69..0c755e5 100644 --- a/meshterm/tools/about.py +++ b/meshterm/tools/about.py @@ -69,6 +69,10 @@ class _AboutTool(Tool): async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: """Print the page — only reachable from the CLI (the menu opens the screen). + Without its scannable codes: a QR is a second rendering of a link the page already + prints, drawn for a phone pointed at a screen, and redirected into a file it is a + block of block characters around nothing new. + Args: ctx: Shared application context. params: Unused beyond the injected ``_run_id``. @@ -76,7 +80,7 @@ class _AboutTool(Tool): Returns: A :class:`ToolResult` naming the page that was shown. """ - ctx.ui.show(self.page()) + ctx.ui.show(self.page().without_qr()) return ToolResult(summary={"page": self.name}) diff --git a/meshterm/tools/base.py b/meshterm/tools/base.py index 615c29e..24e0fef 100644 --- a/meshterm/tools/base.py +++ b/meshterm/tools/base.py @@ -12,6 +12,8 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any +from ..core import exitcodes + if TYPE_CHECKING: # avoid importing typer/context at module load for fast startup import typer @@ -24,13 +26,22 @@ class ToolResult: Attributes: summary: JSON-serializable summary persisted to the run record. - message: Optional human-readable closing message. + message: Optional human-readable closing message. The *menu's* closing line — a + scripted run does not print it (see + :func:`~meshterm.cli._execute_and_render`), because on the command line it + restates the output above it and the exit status below it. artifacts: Paths to any files produced (e.g. generated visualizations). + exit_code: The status a scripted run should exit with — see + :mod:`meshterm.core.exitcodes`. Left at ``OK`` by everything that worked; + set to ``NO_RESULT`` by a tool that ran fine and found nothing to report, so + a caller can tell an empty mesh from a full one without counting lines. A + *failure* is raised, not returned, so this never carries one. """ summary: dict[str, Any] = field(default_factory=dict) message: str | None = None artifacts: list[str] = field(default_factory=list) + exit_code: int = exitcodes.OK class Tool(ABC): @@ -123,7 +134,9 @@ class Tool(ABC): Raises: Exception: Re-raises any error from :meth:`run` after recording it. """ - from ..core.connection import DeviceCommandError + import typer + + from ..core.connection import DeviceCommandError, is_connection_lost from ..core.device_config import DeviceConfigError from ..core.preferences import PreferenceError from ..core.selection import DeviceSelectionError @@ -137,16 +150,27 @@ class Tool(ABC): DeviceConfigError, DeviceCommandError, PreferenceError, + # A tool rejecting one of its own arguments (an unresolvable `--to`) raises + # what the parser would have. It is a usage error, not a fault. + typer.BadParameter, + typer.Abort, + typer.Exit, ) as exc: # Expected user-facing condition (no/ambiguous device, a bad config or - # preference value, or a transient command failure): record it but don't dump - # a traceback; callers print the message cleanly. + # preference value, a bad argument, or a transient command failure): record it + # but don't dump a traceback; callers print the message cleanly. ctx.repo.finish_run(run_id, "error", {"error": str(exc)}) ctx.log.debug("run %s aborted: %s", run_id, exc) raise except Exception as exc: # noqa: BLE001 - we record then re-raise ctx.repo.finish_run(run_id, "error", {"error": str(exc)}) - ctx.log.exception("run %s failed: %s", run_id, exc) + if is_connection_lost(exc): + # The radio was unplugged or powered off mid-command. Every layer above + # already knows how to say that in one line; a stack of serial internals + # describes how the driver found out, which is not the same question. + ctx.log.warning("run %s lost the device connection: %s", run_id, exc) + else: + ctx.log.exception("run %s failed: %s", run_id, exc) raise ctx.repo.finish_run(run_id, "ok", result.summary) ctx.log.debug("run %s ok", run_id) diff --git a/meshterm/tools/channels.py b/meshterm/tools/channels.py index 40079dc..32eb564 100644 --- a/meshterm/tools/channels.py +++ b/meshterm/tools/channels.py @@ -16,10 +16,10 @@ from typing import Any import typer from rich.console import Group -from rich.table import Table from rich.text import Text from ..context import AppContext +from ..core import exitcodes from ..core.channels import ( derive_secret, normalize_secret, @@ -84,22 +84,18 @@ class ChannelsTool(Tool): async def _cli_list(self, ctx: AppContext) -> ToolResult: """List the configured channel slots.""" + from ..ui import script from ..ui.channels import read_channel_slots device = await ctx.device() slots = await read_channel_slots(device) if not slots: - ctx.ui.note("[muted]no channels configured[/muted]") - return ToolResult(summary={"channels": 0}) - table = Table(title="Channels", border_style="muted", expand=False) - table.add_column("SLOT", justify="right") - table.add_column("NAME") - table.add_column("TYPE") - table.add_column("HASH") + return ToolResult(summary={"channels": 0}, exit_code=exitcodes.NO_RESULT) + table = script.columns("SLOT", "NAME", "TYPE", "HASH", right=("SLOT",)) for slot in slots: table.add_row( str(slot.idx), - Text(slot.name, style="brand"), + script.quote(slot.name), "public" if slot.is_public else "private", slot.hash, ) @@ -119,7 +115,6 @@ class ChannelsTool(Tool): else: secret = normalize_secret(params["secret"]) if params.get("secret") else random_secret() await write_channel(ctx, device, idx, name, secret) - ctx.ui.note(f"[ok]✓[/ok] channel [brand]{name}[/brand] set on slot {idx}") self._print_share(ctx, name, secret) return ToolResult(summary={"index": idx, "name": name}) @@ -132,7 +127,6 @@ class ChannelsTool(Tool): name = str(params["name"]) secret = normalize_secret(str(params["secret"])) await write_channel(ctx, device, idx, name, secret) - ctx.ui.note(f"[ok]✓[/ok] joined [brand]{name}[/brand] on slot {idx}") return ToolResult(summary={"index": idx, "name": name}) async def _cli_import(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: @@ -146,7 +140,6 @@ class ChannelsTool(Tool): device = await ctx.device() idx = int(params["index"]) await write_channel(ctx, device, idx, name, secret) - ctx.ui.note(f"[ok]✓[/ok] imported [brand]{name}[/brand] on slot {idx}") return ToolResult(summary={"index": idx, "name": name}) async def _cli_share(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: @@ -157,8 +150,9 @@ class ChannelsTool(Tool): idx = int(params["index"]) slot = next((s for s in await read_channel_slots(device) if s.idx == idx), None) if slot is None: - ctx.ui.note(f"[warn]slot {idx} is empty[/warn]") - return ToolResult(summary={"index": idx, "shared": False}) + return ToolResult( + summary={"index": idx, "shared": False}, exit_code=exitcodes.NO_RESULT + ) self._print_share(ctx, slot.name, slot.secret) return ToolResult(summary={"index": idx, "shared": True}) @@ -176,19 +170,28 @@ class ChannelsTool(Tool): idx = int(params["index"]) slot = next((s for s in await read_channel_slots(device) if s.idx == idx), None) if slot is None: - ctx.ui.note(f"[muted]slot {idx} is already empty[/muted]") - return ToolResult(summary={"index": idx, "cleared": False}) + return ToolResult( + summary={"index": idx, "cleared": False}, exit_code=exitcodes.NO_RESULT + ) await write_channel(ctx, device, idx, "", None) # empty name => the slot reads as unused - ctx.ui.note(f"[warn]cleared channel {slot.name} on slot {idx}[/warn]") return ToolResult(summary={"index": idx, "cleared": True}) @staticmethod def _print_share(ctx: AppContext, name: str, secret: bytes) -> None: - """Render a channel's QR code and share URL into the output surface.""" + """Render a channel's share link — with its QR code in the menu, without on the CLI. + + The QR is for a phone pointed at the screen. Piped into a file it is a block of + block characters wrapped around the one thing that is actually the answer, so a + scripted run prints the link alone. + """ from ..ui.qr import qr_text + from ..ui.surface import TuiUi url = share_url(name, secret) - ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent"))) + if isinstance(ctx.ui, TuiUi): + ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent"))) + else: + ctx.ui.note(url) def register_cli(self, app: typer.Typer) -> None: """Register the ``channels`` subcommand group. @@ -198,7 +201,7 @@ class ChannelsTool(Tool): """ from ..cli import run_tool_command - channels_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode="rich") + channels_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode=None) @channels_app.command("list", help="List the configured channel slots") def _list_cmd() -> None: diff --git a/meshterm/tools/chat.py b/meshterm/tools/chat.py index 19ec448..b9fd14f 100644 --- a/meshterm/tools/chat.py +++ b/meshterm/tools/chat.py @@ -22,6 +22,7 @@ from rich.table import Table from rich.text import Text from ..context import AppContext +from ..core import exitcodes from ..core.channels import ( CHANNEL_SLOT_PROBE_CAP, DEFAULT_PUBLIC_SECRET, @@ -252,19 +253,26 @@ class ChatTool(Tool): return await self._cli_list(ctx) async def _cli_send(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: - """Send a channel or direct message and report the outcome.""" + """Send a channel or direct message and report whether it was acknowledged. + + A channel broadcast has nothing to report — there is no acknowledgement on a + channel — so it prints nothing and lets the exit status say it went out. A direct + message prints its ``acked`` state, which is the one thing the send does not + already tell the caller: the radio accepted it either way, and whether the peer + answered is a separate fact. + """ + from ..ui import script + device = await ctx.device() text = str(params["text"]) channel = params.get("channel") if channel is not None: await ctx.chat.send_channel(int(channel), text, label=f"#{channel}") - ctx.ui.note(f"[ok]✓[/ok] sent to channel [brand]{channel}[/brand]") return ToolResult(summary={"channel": channel, "sent": True}) contact = _resolve_contact(await device.get_contacts(), str(params["to"])) message = await ctx.chat.send_direct(contact, text) - state = "[ok]✓ delivered[/ok]" if message.acked else "[warn]? no ack[/warn]" - ctx.ui.note(f"[ok]✓[/ok] sent to [brand]{contact.name}[/brand] — {state}") + ctx.ui.show(script.pairs([("acked", "yes" if message.acked else "no")])) return ToolResult(summary={"to": contact.name, "acked": bool(message.acked)}) async def _cli_history(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: @@ -288,11 +296,12 @@ class ChatTool(Tool): ) label = contact.name - if not messages: - ctx.ui.note(f"[muted]no messages with {label} yet[/muted]") - else: + if messages: ctx.ui.show(_history_table(label, messages)) - return ToolResult(summary={"conversation": label, "messages": len(messages)}) + return ToolResult( + summary={"conversation": label, "messages": len(messages)}, + exit_code=exitcodes.OK if messages else exitcodes.NO_RESULT, + ) async def _cli_listen(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: """Tail inbound messages live in the console (and record them to history). @@ -309,14 +318,25 @@ class ChatTool(Tool): Returns: A :class:`ToolResult` with how many messages were seen. """ + from ..ui import script + seconds = params.get("seconds") or 0 await ctx.device() await ctx.chat.start() # begins recording inbound to history too - ctx.console.print( - "[muted]listening for messages" - f"{f' for {seconds}s' if seconds else ' — press Ctrl-C to stop'}…[/muted]" + # About the run, not part of its answer: stderr, so a redirected stdout holds + # nothing but messages. + script.stderr_console().print( + "listening for messages" + + (f" for {seconds}s" if seconds else " — press Ctrl-C to stop"), + style="muted", + highlight=False, ) seen = 0 + # One header, then a record per message. Like the monitor's tail this cannot be + # column-aligned (the widths arrive with the data), so the fields are gutter- + # separated and the two that can hold anything — the conversation and the body — + # are quoted and last respectively. + ctx.console.print("TIME CONVERSATION SNR_DB TEXT", highlight=False) def on_message(event: MeshEvent) -> None: nonlocal seen @@ -324,13 +344,14 @@ class ChatTool(Tool): if message is None: return seen += 1 - stamp = message.received_at.astimezone().strftime("%H:%M:%S") - if message.is_channel: - who = f"#{message.channel}" - else: - who = message.sender or "?" - snr = f" [muted]({message.snr:+.0f} dB)[/muted]" if message.snr is not None else "" - ctx.console.print(f"[muted]{stamp}[/muted] [accent]{who}[/accent]: {message.text}{snr}") + where = f"#{message.channel}" if message.is_channel else (message.sender or "") + fields = [ + script.stamp(message.received_at), + script.quote(where) if where else script.NONE, + script.number(message.snr, "+.1f"), + message.text, + ] + ctx.console.print((" " * script.GUTTER).join(fields), highlight=False) unsubscribe = ctx.events.subscribe(on_message, EventKind.MESSAGE) try: @@ -345,6 +366,7 @@ class ChatTool(Tool): return ToolResult( summary={"messages": seen}, message=f"[muted]stopped — heard {seen} message{'' if seen == 1 else 's'}[/muted]", + exit_code=exitcodes.OK if seen else exitcodes.NO_RESULT, ) async def _cli_list(self, ctx: AppContext) -> ToolResult: @@ -354,23 +376,31 @@ class ChatTool(Tool): contacts = await device.get_contacts() lasts = ctx.repo.last_chat_messages() - table = Table(title="Conversations", border_style="muted", expand=False) - table.add_column("CONVERSATION") - table.add_column("UNREAD", justify="right") - table.add_column("LAST MESSAGE") + from ..ui import script + rows = [*channels] + [ Conversation(label=c.name, is_channel=False, contact=c) for c in contacts ] + table = script.columns( + "CONVERSATION", "KIND", "UNREAD", "LAST_TIME", "LAST_TEXT", right=("UNREAD",) + ) for conversation in rows: last = lasts.get(conversation.key) - snippet = "" - if last is not None: - who = "you: " if last.outbound else "" - snippet = f"{who}{last.text[:40]}" - unread = ctx.chat.unread(conversation.key) - table.add_row(conversation.label, str(unread) if unread else "·", snippet) - ctx.ui.show(table) - return ToolResult(summary={"conversations": len(rows)}) + table.add_row( + script.quote(conversation.label), + "channel" if conversation.is_channel else "direct", + str(ctx.chat.unread(conversation.key)), + script.stamp(last.created_at) if last is not None else script.NONE, + # Not truncated to 40 cells the way the picker's preview is: nothing here + # wraps, so there is no reason to cut a message short. + last.text if last is not None else script.NONE, + ) + if rows: + ctx.ui.show(table) + return ToolResult( + summary={"conversations": len(rows)}, + exit_code=exitcodes.OK if rows else exitcodes.NO_RESULT, + ) def register_cli(self, app: typer.Typer) -> None: """Register the ``chat`` subcommand group. @@ -380,7 +410,7 @@ class ChatTool(Tool): """ from ..cli import run_tool_command - chat_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode="rich") + chat_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode=None) @chat_app.command("send", help="Send a message to a contact or channel") def _send_cmd( @@ -801,24 +831,34 @@ def _ago(when: Any) -> str: def _history_table(label: str, messages: list[ChatMessage]) -> Table: - """Render a conversation's stored messages as a table. + """A conversation's stored messages, oldest first, one record each. + + ``DIRECTION`` carries what the transcript wrote into its sender lane as the word + "you": which way a message went is a fact of its own, and spending the name lane on it + made our own name a value that lane could not otherwise hold. ``PEER`` is therefore + always the *other* party — the sender of an inbound message, and empty on a channel + broadcast, which has no one party it went to. + + ``TEXT`` is last and unquoted: it is the rest of the line, and a message body is the + one field that can hold absolutely anything. Args: - label: The conversation's display name. + label: The conversation's display name (the caller named it; the columns don't). messages: The messages to show, oldest-first. Returns: - A Rich :class:`Table` of time, sender, and text. + The scripted table (see :func:`meshterm.ui.script.columns`). """ - table = Table(title=f"History — {label}", border_style="muted", expand=False) - table.add_column("TIME") - table.add_column("FROM") - table.add_column("MESSAGE") + from ..ui import script + + table = script.columns("TIME", "DIRECTION", "PEER", "SNR_DB", "TEXT", right=("SNR_DB",)) for message in messages: - stamp = message.created_at.astimezone().strftime("%m-%d %H:%M") - if message.outbound: - who = Text("you", style="accent") - else: - who = Text(message.peer_name or message.peer or "?", style="brand") - table.add_row(stamp, who, message.text) + peer = message.peer_name or message.peer or "" + table.add_row( + script.stamp(message.created_at), + "out" if message.outbound else "in", + script.quote(peer) if peer else script.NONE, + script.number(message.snr, "+.1f"), + message.text, + ) return table diff --git a/meshterm/tools/config.py b/meshterm/tools/config.py index 91015bf..d8240af 100644 --- a/meshterm/tools/config.py +++ b/meshterm/tools/config.py @@ -15,17 +15,18 @@ from pathlib import Path from typing import Any import typer -from rich.table import Table from ..context import AppContext from ..core.channels import CHANNEL_SLOT_PROBE_CAP from ..core.config_io import backup_config, plan_restore, read_backup -from ..core.connection import Device +from ..core.connection import Device, DeviceCommandError from ..core.device_config import ( + SettingSpec, build_snapshot, format_value, get_spec, parse_value, + settings_by_category, ) from .base import Tool, ToolResult, register @@ -86,7 +87,7 @@ class ConfigTool(Tool): """ from ..cli import run_tool_command - config_app = typer.Typer(help=self.help, no_args_is_help=False, rich_markup_mode="rich") + config_app = typer.Typer(help=self.help, no_args_is_help=False, rich_markup_mode=None) @config_app.callback(invoke_without_command=True) def _root(ctx: typer.Context) -> None: @@ -227,17 +228,20 @@ async def apply_ops( if kind == "show": await _show(ctx, device, snapshot) elif kind == "get": + # The bare value, nothing else: the caller named the key, so repeating it back + # is one more thing for `$(meshterm config get name)` to strip off. The same + # shape `sysctl -n` and `git config --get` print. spec = get_spec(op[1]) - ctx.ui.note(f"[muted]{spec.key}[/muted] = {format_value(spec, spec.getter(snapshot))}") + ctx.ui.note(_script_value(spec, spec.getter(snapshot))) elif kind == "set": changes += await _apply_setting(ctx, device, op[1], op[2], snapshot) elif kind == "set_custom": await device.set_custom_var(op[1], op[2]) - ctx.ui.note(f"[ok]✓[/ok] custom [brand]{op[1]}[/brand] = {op[2]}") + ctx.ui.ack(f"[ok]✓[/ok] custom [brand]{op[1]}[/brand] = {op[2]}") changes += 1 elif kind == "set_channel": await device.set_channel(op[1], op[2], op[3]) - ctx.ui.note(f"[ok]✓[/ok] channel {op[1]} = [brand]{op[2]}[/brand]") + ctx.ui.ack(f"[ok]✓[/ok] channel {op[1]} = [brand]{op[2]}[/brand]") changes += 1 elif kind == "backup": artifacts.append(str(await _backup(device, snapshot, op[1]))) @@ -252,7 +256,7 @@ async def apply_ops( if public_key: ctx.advert_store.mark_sent(public_key, flood=flood) kind_label = "flood" if flood else "zero-hop" - ctx.ui.note(f"[ok]✓[/ok] {kind_label} advertisement sent") + ctx.ui.ack(f"[ok]✓[/ok] {kind_label} advertisement sent") elif kind == "advert_cadence": from ..core.advert_store import cadence_label @@ -261,13 +265,15 @@ async def apply_ops( if public_key: ctx.advert_store.set_cadence(public_key, flood=flood, hours=hours) kind_label = "flood" if flood else "zero-hop" - ctx.ui.note( + ctx.ui.ack( f"[ok]✓[/ok] background {kind_label} advert: " f"[brand]{cadence_label(hours)}[/brand]" ) changes += 1 else: # pragma: no cover - SELF_INFO always carries the key on real firmware - ctx.ui.note("[err]device reported no public key — cadence not saved[/err]") + raise DeviceCommandError( + "the device reported no public key — the advert cadence was not saved" + ) elif kind == "share": _share_contact(ctx, snapshot) elif kind == "sync_clock": @@ -277,22 +283,22 @@ async def apply_ops( epoch = int(_time.time()) await device.set_time(epoch) stamp = datetime.fromtimestamp(epoch).astimezone().strftime("%Y-%m-%d %H:%M:%S") - ctx.ui.note(f"[ok]✓[/ok] device clock set to [brand]{stamp}[/brand]") + ctx.ui.ack(f"[ok]✓[/ok] device clock set to [brand]{stamp}[/brand]") changes += 1 elif kind == "reboot": await device.reboot() - ctx.ui.note("[warn]device rebooting[/warn]") + ctx.ui.ack("[warn]device rebooting[/warn]") elif kind == "export_key": await _export_key(ctx, device, op[1] if len(op) > 1 else None, artifacts) elif kind == "import_key": await device.import_private_key(op[1]) - ctx.ui.note("[ok]✓[/ok] private key imported") + ctx.ui.ack("[ok]✓[/ok] private key imported") changes += 1 elif kind == "factory_reset": await device.factory_reset() - ctx.ui.note("[err]device factory-reset[/err]") + ctx.ui.ack("[err]device factory-reset[/err]") else: # pragma: no cover - guarded by the call sites that build ops - ctx.ui.note(f"[err]unknown config op: {kind}[/err]") + raise DeviceCommandError(f"unknown config operation: {kind}") # Drop any session-cached facts these ops may have changed, so the next screen re-reads # the truth rather than a stale copy (see meshterm.services.device_state.DeviceState). A # reboot/reconnect resets the whole cache on its own; these cover the in-place edits. @@ -328,33 +334,96 @@ async def _apply_setting( # meshterm.core.settings_store). Provenance-gated: only values changed through MeshTerm. if ctx.settings_store is not None: ctx.settings_store.remember(str(snapshot.get("public_key") or ""), key, value) - ctx.ui.note(f"[ok]✓[/ok] [brand]{key}[/brand] = {format_value(spec, value)}") + ctx.ui.ack(f"[ok]✓[/ok] [brand]{key}[/brand] = {format_value(spec, value)}") return 1 async def _show(ctx: AppContext, device: Device, snapshot: dict) -> None: - """Render the full configuration table plus custom variables.""" - from ..ui.config_editor import config_table + """Print every current setting, plus any custom variables. + + The menu shows the annotated table; a scripted run gets one ``key value`` line per + setting, keyed exactly as ``config get`` and ``config set`` name them, so a line read + out of ``show`` can be typed straight back in. The labels and descriptions the table + carries are for a reader choosing a setting, and this reader has already chosen. + + The pairing PIN stays concealed here, as it is in the table: this is the whole-device + dump, the thing that gets redirected into a file and pasted into a bug report, and the + PIN is the one value on it that lets someone else's phone onto the radio. + ``config get device_pin`` names it deliberately. + """ + from ..ui import script + from ..ui.config_editor import PIN_KEY, conceal, config_table + from ..ui.surface import TuiUi custom = await device.get_custom_vars() - ctx.ui.show(config_table(snapshot, custom)) + if isinstance(ctx.ui, TuiUi): + ctx.ui.show(config_table(snapshot, custom)) + return + + rows: list[tuple[str, str]] = [] + for _category, specs in settings_by_category(): + for spec in specs: + value = _script_value(spec, spec.getter(snapshot)) + rows.append((spec.key, conceal(value) if spec.key == PIN_KEY else value)) + # Custom variables are experimental firmware fields with no spec, so they are namespaced + # rather than mixed in — a reader can tell which lines `config set` will take. + rows.extend((f"custom.{key}", value) for key, value in sorted(custom.items())) + ctx.ui.show(script.pairs(rows)) + + +def _script_value(spec: SettingSpec, value: Any) -> str: + """A setting's value in the form ``config set`` accepts back. + + :func:`~meshterm.core.device_config.format_value` writes for a reader choosing a + setting: an enum reads ``0 (off)``, an unset string reads ``(not set)``, an unreported + one reads ``?``. None of those three survive a round trip — + :func:`~meshterm.core.device_config.parse_value` wants a bare integer for an enum, and + would take the words ``(not set)`` as the literal value of a string. So the scripted + dump prints what can be typed back: the enum's number, the empty string as ``""``, and + :data:`~meshterm.ui.script.NONE` for a value the firmware never reported (which is an + absence, not a value, and is the one line ``config set`` should not be handed back). + + Args: + spec: The setting the value belongs to. + value: Its current value, or ``None`` when the device did not report it. + + Returns: + The scripted rendering. + """ + from ..ui import script + + if value is None: + return script.NONE + if spec.value_type == "bool": + return "true" if value else "false" + if spec.value_type == "str" and value == "": + return '""' + return str(value) def _share_contact(ctx: AppContext, snapshot: dict) -> None: - """Render this node's contact card — a scannable QR code plus the raw URI.""" + """Render this node's contact card — the QR code in the menu, the URI alone on the CLI. + + The QR is for a phone pointed at the screen. Redirected into a file it is a block of + block characters around the one thing that is the answer, so a scripted run prints the + link by itself. + """ from rich.console import Group from rich.text import Text from ..ui.config_editor import contact_share_url from ..ui.qr import qr_text + from ..ui.surface import TuiUi public_key = str(snapshot.get("public_key") or "") if not public_key: - ctx.ui.note("[err]the device did not report a public key — nothing to share[/err]") - return + raise DeviceCommandError("the device did not report a public key — nothing to share") name = str(snapshot.get("name") or "this node") url = contact_share_url(name, public_key, int(snapshot.get("adv_type") or 1)) - ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent"))) + if isinstance(ctx.ui, TuiUi): + ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent"))) + else: + ctx.ui.note(url) async def _backup(device: Device, snapshot: dict, path: Path) -> Path: @@ -376,18 +445,17 @@ async def _restore( custom = await device.get_custom_vars() ops = plan_restore(backup, snapshot, custom) if not ops: - ctx.ui.note("[muted]restore: device already matches the backup.[/muted]") + ctx.ui.ack("[muted]restore: device already matches the backup.[/muted]") return 0 if dry_run: - table = Table(title="Restore preview", border_style="warn", expand=False) - table.add_column("OPERATION") - table.add_column("TARGET") - table.add_column("VALUE") + from ..ui import script + + table = script.columns("OPERATION", "TARGET", "VALUE") for op in ops: - table.add_row(op[0], str(op[1]), str(op[2] if len(op) > 2 else "")) + table.add_row(op[0], str(op[1]), str(op[2]) if len(op) > 2 else script.NONE) ctx.ui.show(table) - ctx.ui.note("[muted]dry run — nothing was changed.[/muted]") + ctx.ui.ack("[muted]dry run — nothing was changed.[/muted]") return 0 changes = 0 @@ -413,9 +481,13 @@ async def _export_key( path.parent.mkdir(parents=True, exist_ok=True) path.write_text(key_hex, encoding="utf-8") artifacts.append(str(path)) - ctx.ui.note("[warn]private key written — keep this file secret.[/warn]") + ctx.ui.ack("[warn]private key written — keep this file secret.[/warn]") else: - ctx.ui.note("[warn]private key (keep secret):[/warn] [muted]" + key_hex + "[/muted]") + # The key itself is the answer, so it is output rather than an acknowledgement: + # `meshterm config export-key > key.hex` should hold the key and nothing else. The + # warning that comes with it belongs beside it on screen, not in the file. + ctx.ui.ack("[warn]private key (keep secret):[/warn]") + ctx.ui.note(f"[muted]{key_hex}[/muted]") async def _read_channels(device: Device) -> list[dict]: diff --git a/meshterm/tools/contacts.py b/meshterm/tools/contacts.py index f61a22d..6dffe71 100644 --- a/meshterm/tools/contacts.py +++ b/meshterm/tools/contacts.py @@ -1,14 +1,26 @@ -"""The ``contacts`` tool: list this node and the contacts it knows about.""" +"""The ``contacts`` tool: list the contacts this node knows about. + +The menu opens the sortable Contacts screen, which leads with our own node; the CLI +prints the contact list alone, because our own node is not a contact — ``meshterm info`` +reports it, in far more detail than a row could hold. +""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import typer from ..context import AppContext +from ..core import exitcodes +from ..core.models import NODE_TYPE_LABELS from .base import Tool, ToolResult, register +if TYPE_CHECKING: # pragma: no cover - typing only + from rich.table import Table + + from ..core.models import Contact + @register class ContactsTool(Tool): @@ -32,7 +44,7 @@ class ContactsTool(Tool): A :class:`ToolResult` summarizing the number of known contacts. """ from ..ui.surface import TuiUi - from ..ui.widgets import ContactsSort, contacts_table + from ..ui.widgets import ContactsSort, ordered_contacts # Read through the session cache: on a busy node the contacts table is a slow # round-trip, and re-fetching it (plus self-info) on every menu visit is a chief cause @@ -63,18 +75,22 @@ class ContactsTool(Tool): # In the menu, hand the list to the interactive screen so the Ctrl+arrows re-sort it # live — its ring spans the shared contact list's four columns (hash included); on the - # scripted CLI, render the static table once in the requested order. + # scripted CLI, print the plain listing once in the requested order. if isinstance(ctx.ui, TuiUi): from ..ui.contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING from ..ui.contacts_screen import open_contacts sort = ContactsSort.from_name(sort_name, SORT_COLUMNS, SORT_OPENS_ASCENDING) await open_contacts(ctx, self_name, self_key, contacts, prefix_bytes, counts, sort) - else: - sort = ContactsSort.from_name(sort_name) - ctx.ui.show(contacts_table(self_name, self_key, contacts, prefix_bytes, counts, sort)) + return ToolResult(summary={"contacts": len(contacts)}) - return ToolResult(summary={"contacts": len(contacts)}) + sort = ContactsSort.from_name(sort_name) + if contacts: + ctx.ui.show(_listing(ordered_contacts(contacts, counts, sort), counts)) + return ToolResult( + summary={"contacts": len(contacts)}, + exit_code=exitcodes.OK if contacts else exitcodes.NO_RESULT, + ) def register_cli(self, app: typer.Typer) -> None: """Register the ``contacts`` subcommand. @@ -91,3 +107,34 @@ class ContactsTool(Tool): ), ) -> None: run_tool_command(self, {"sort": sort}) + + +def _listing(contacts: list[Contact], counts: dict[str, int]) -> Table: + """The scripted contact list: one record per contact, one line each. + + ``NAME`` is quoted so a name holding a space or a comma stays one field; ``TYPE`` is + the node's advertised role in words, where the menu draws a coloured glyph; ``HEARD`` + is an absolute local timestamp, where the menu draws a relative age in recency heat; + and ``KEY`` is the full public key, never elided — a truncated key is not something a + caller can pass back to ``--path`` or ``--to``. + + Args: + contacts: The contacts, already in the requested order. + counts: Overheard-packet tallies keyed by node id (from passive monitoring). + + Returns: + The scripted table (see :func:`meshterm.ui.script.columns`). + """ + from ..ui import script + from ..ui.widgets import _contact_pkts + + table = script.columns("NAME", "TYPE", "HEARD", "PKTS", "KEY", right=("PKTS",)) + for contact in contacts: + table.add_row( + script.quote(contact.name), + NODE_TYPE_LABELS.get(contact.node_type, "unknown"), + script.stamp(contact.last_seen), + script.number(_contact_pkts(contact, counts)), + (contact.public_key or contact.key_prefix or "").lower() or script.NONE, + ) + return table diff --git a/meshterm/tools/courier.py b/meshterm/tools/courier.py index cdbe134..f16a6a9 100644 --- a/meshterm/tools/courier.py +++ b/meshterm/tools/courier.py @@ -22,7 +22,9 @@ from typing import Any import typer from ..context import AppContext +from ..core import exitcodes from ..core.connection import DeviceCommandError +from ..ui import script from .base import Tool, ToolResult, register @@ -118,10 +120,13 @@ class CourierTool(Tool): if not_before is not None else "when the contact is next heard" ) - ctx.ui.note( + ctx.ui.ack( f"[ok]queued[/ok] #{message.ident} for [brand]{contact.name}[/brand] — " f"delivers {when} (an interactive session's courier does the sending)" ) + # The entry's id is the one fact a caller has to keep: it is what `courier send` + # and `courier cancel` take. + ctx.ui.show(script.pairs([("id", str(message.ident))])) return ToolResult( summary={"queued": message.ident, "contact": contact.name, "at": params.get("at")} ) @@ -132,45 +137,32 @@ class CourierTool(Tool): A read-only view over the stored outbox: it needs no device, so it works the same whether or not a radio is attached (the scriptable face of the outbox screen). """ - from rich.table import Table - from rich.text import Text - from ..core.courier_store import DELIVERED, QUEUED - from ..core.models import utcnow - from ..ui.courier_screen import _local_stamp, _shorten - from ..ui.widgets import _age_seconds, format_ago entries = ctx.courier_store.entries() if not entries: - ctx.ui.note("[muted]the outbox is empty[/muted]") - return ToolResult(summary={"entries": 0}) + return ToolResult(summary={"entries": 0}, exit_code=exitcodes.NO_RESULT) - table = Table(title="Courier outbox", border_style="muted", expand=False) - table.add_column("ID", justify="right") - table.add_column("STATE") - table.add_column("NODE") - table.add_column("MESSAGE") - table.add_column("WHEN") - now = utcnow() + table = script.columns( + "ID", "STATE", "NODE", "SCHEDULED", "FINISHED", "TEXT", right=("ID",) + ) for m in entries: if m.status == QUEUED: - state = Text("⏳ waiting", style="warn") - if m.not_before is not None and now < m.not_before: - when = f"scheduled {_local_stamp(m.not_before)}" - else: - when = "when next heard" + state = "waiting" elif m.status == DELIVERED: - state = Text("✓ delivered", style="ok") - when = f"delivered {format_ago(_age_seconds(m.finished or m.created))}" + state = "delivered" else: - state = Text("✗ gave up", style="err") - when = f"gave up {format_ago(_age_seconds(m.finished or m.created))}" + state = "gave up" table.add_row( str(m.ident), state, - Text(m.node_name, style="brand"), - _shorten(m.text), - Text(when, style="muted"), + script.quote(m.node_name), + # An entry with no scheduled time goes as soon as the contact is heard, + # which is not a time and so is not one here either. + script.stamp(m.not_before), + script.stamp(m.finished), + # Not shortened the way the outbox screen shortens it: nothing wraps here. + m.text, ) ctx.ui.show(table) return ToolResult(summary={"entries": len(entries)}) @@ -195,17 +187,19 @@ class CourierTool(Tool): "busy": "[muted]another delivery is in flight — try again in a moment[/muted]", "gone": "[muted]that entry is no longer waiting[/muted]", } - ctx.ui.note(notes.get(outcome, outcome)) + ctx.ui.ack(notes.get(outcome, outcome)) + # The outcome word is the answer, and it is one of a closed set the help lists. + ctx.ui.show(script.pairs([("outcome", outcome)])) return ToolResult(summary={"id": ident, "outcome": outcome}) async def _cli_cancel(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: """Remove a waiting entry from the outbox (finished ones use ``clear``).""" ident = int(params["id"]) if ctx.courier_store.cancel(ident): - ctx.ui.note(f"[warn]cancelled outbox entry #{ident}[/warn]") + ctx.ui.ack(f"[warn]cancelled outbox entry #{ident}[/warn]") return ToolResult(summary={"id": ident, "cancelled": True}) - ctx.ui.note(f"[muted]no waiting entry #{ident} to cancel[/muted]") - return ToolResult(summary={"id": ident, "cancelled": False}) + ctx.ui.ack(f"[muted]no waiting entry #{ident} to cancel[/muted]") + return ToolResult(summary={"id": ident, "cancelled": False}, exit_code=exitcodes.NO_RESULT) async def _cli_clear(self, ctx: AppContext) -> ToolResult: """Drop every finished (delivered / given-up) entry, leaving the queue untouched.""" @@ -213,10 +207,13 @@ class CourierTool(Tool): ctx.courier_store.clear_done() cleared = before - len(ctx.courier_store.entries()) if cleared: - ctx.ui.note(f"[ok]cleared {cleared} finished entr{'y' if cleared == 1 else 'ies'}[/ok]") + ctx.ui.ack(f"[ok]cleared {cleared} finished entr{'y' if cleared == 1 else 'ies'}[/ok]") else: - ctx.ui.note("[muted]no finished entries to clear[/muted]") - return ToolResult(summary={"cleared": cleared}) + ctx.ui.ack("[muted]no finished entries to clear[/muted]") + return ToolResult( + summary={"cleared": cleared}, + exit_code=exitcodes.OK if cleared else exitcodes.NO_RESULT, + ) def register_cli(self, app: typer.Typer) -> None: """Register the ``courier`` subcommand group. @@ -226,7 +223,7 @@ class CourierTool(Tool): """ from ..cli import run_tool_command - courier_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode="rich") + courier_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode=None) @courier_app.command( "queue", help="Queue a message for delivery when the contact is next heard" diff --git a/meshterm/tools/devices.py b/meshterm/tools/devices.py index f66c867..ed03d7c 100644 --- a/meshterm/tools/devices.py +++ b/meshterm/tools/devices.py @@ -17,21 +17,15 @@ import typer from rich.table import Table from ..context import AppContext +from ..core import exitcodes from ..core.discovery import discover_all, discover_devices from .base import Tool, ToolResult, register -#: "MeshCore?" table cell per discovery confidence tier, for devices we have *not* yet +#: The ``MESHCORE`` verdict per discovery confidence tier, for devices we have *not* yet #: confirmed. The USB vendor ID is only a hint — a native-USB board or a bare bridge chip #: is a "maybe", never a "yes" — so nothing is billed as MeshCore until a connection proves -#: it (see ``_confirmed_cell``). -_MAYBE_CELL: dict[str, str] = { - "board": "[warn]maybe[/warn]", - "bridge": "[warn]maybe[/warn]", - "unknown": "[muted]—[/muted]", -} - -#: "MeshCore?" cell for a device already confirmed to speak the protocol. -_CONFIRMED_CELL = "[ok]yes[/ok]" +#: it. +_MAYBE: dict[str, str] = {"board": "maybe", "bridge": "maybe", "unknown": "no"} @register @@ -88,47 +82,17 @@ class DevicesTool(Tool): return ToolResult(summary={"count": len(devices)}) if not devices: - ctx.ui.note( - "[warn]No companion devices detected.[/warn] " - "Connect one over USB or power on a Bluetooth companion nearby, or use " - "[accent]--mock[/accent] for the simulator." - ) - return ToolResult(summary={"count": 0}) + # Not an error: the scan ran and found nothing. Said on stderr so a caller + # redirecting stdout still hears it, and reported as NO_RESULT so a script can + # branch on it without matching prose. + from ..ui import script - table = Table(title="Companion devices", border_style="muted", expand=False) - table.add_column("", style="ok", no_wrap=True) # active/confirmed markers - table.add_column("PORT / ADDRESS", style="brand") - table.add_column("DEVICE") - # "Hardware" (not "Vendor"): for a confirmed device this holds the firmware's own model - # string ("Seeed Tracker T1000-E") — the only reliable source of what the box is — and - # for a merely-attached serial port it falls back to the USB vendor name ("Espressif"). - # One column spans both because a maker name and a model name are the same question: - # "what hardware is this?". A BLE device that's never connected has neither, so it's "?". - table.add_column("HARDWARE", style="muted") - table.add_column("MESHCORE?", justify="center") - table.add_column("SERIAL", style="muted") - for d in devices: - confirmed = known.get(d.stable_id) # the remembered record, if ever confirmed - is_active = d.target == active_target - marker = ("●" if is_active else "") + ("★" if confirmed else "") - device_name = d.name or d.product or d.description or "[muted]?[/muted]" - if confirmed and confirmed.node_name: # the mesh name learned on connect - device_name = f"{confirmed.node_name} [muted]({device_name})[/muted]" - # Prefer the connected-time model; fall back to the USB vendor for unconnected ports. - hardware = (confirmed.hardware_model if confirmed else "") or d.vendor_label - table.add_row( - marker, - d.target, - device_name, - hardware or "[muted]?[/muted]", - _CONFIRMED_CELL if confirmed else _MAYBE_CELL[d.confidence], - d.serial_number or "[muted]—[/muted]", + script.stderr_console().print( + "meshterm: no companion devices detected", style="warn", highlight=False ) - ctx.ui.show(table) - ctx.ui.note( - "[muted]● active ★ confirmed MeshCore device. " - "Pass --port or --ble
to select on the CLI.[/muted]" - ) + return ToolResult(summary={"count": 0}, exit_code=exitcodes.NO_RESULT) + + ctx.ui.show(_listing(devices, known, active_target)) return ToolResult(summary={"count": len(devices)}) def register_cli(self, app: typer.Typer) -> None: @@ -139,10 +103,59 @@ class DevicesTool(Tool): """ from ..cli import run_tool_command - @app.command(name=self.name, help=self.help) + @app.command( + name=self.name, + help=self.help, + # The listing used to close with a line explaining its own markers and how to + # act on a row. That is help, and this is where help goes. + epilog="Select a device with --port TARGET or --ble TARGET, using the " + "TARGET column verbatim.", + ) def _devices( ble: bool = typer.Option( True, "--ble/--no-ble", help="Include a Bluetooth LE scan (adds a few seconds)" ), ) -> None: run_tool_command(self, {"ble": ble}) + + +def _listing(devices: list, known: dict, active_target: str | None) -> Table: + """The scripted device inventory: one line per attached or advertising device. + + ``TARGET`` leads because it is the field a caller acts on — it is what ``--port`` and + ``--ble`` take, verbatim. The menu's two markers become columns of their own + (``ACTIVE``, and ``MESHCORE`` for the confirmed star), because a glyph in a margin is + something to look at rather than something to test. + + ``MESHCORE`` is three-valued and stays that way: ``yes`` only once a connection has + proved the device speaks the protocol, ``maybe`` for a USB vendor ID that suggests a + LoRa board or a bridge chip, ``no`` for anything else. A vendor ID is a hint, and the + column would be lying if it rounded one up. + + Args: + devices: The discovered devices. + known: Remembered device records, keyed by stable id. + active_target: The target this invocation is (or would be) using. + + Returns: + The scripted table (see :func:`meshterm.ui.script.columns`). + """ + from ..ui import script + + table = script.columns( + "TARGET", "TRANSPORT", "NAME", "HARDWARE", "MESHCORE", "SERIAL", "ACTIVE" + ) + for device in devices: + confirmed = known.get(device.stable_id) + name = (confirmed.node_name if confirmed else "") or device.label + hardware = (confirmed.hardware_model if confirmed else "") or device.vendor_label + table.add_row( + device.target, + device.transport, + script.quote(name), + script.quote(hardware) if hardware else script.NONE, + "yes" if confirmed else _MAYBE.get(device.confidence, "no"), + device.serial_number or script.NONE, + "yes" if device.target == active_target else "no", + ) + return table diff --git a/meshterm/tools/info.py b/meshterm/tools/info.py index 5974805..2ed13fd 100644 --- a/meshterm/tools/info.py +++ b/meshterm/tools/info.py @@ -3,6 +3,7 @@ from __future__ import annotations import time +from datetime import datetime, timezone from typing import Any import typer @@ -47,8 +48,21 @@ class InfoTool(Tool): device = await ctx.device() snapshot = await cached_snapshot(ctx, device) - custom = await device.get_custom_vars() + # On the CLI this is the *status* report and nothing else: how the radio is doing + # right now, one fact per line. What it is set to is `config show`'s answer, in + # the very keys `config get`/`config set` take, so printing the settings here as + # well would be the same table twice under two different spellings. + session = getattr(ctx.ui, "session", None) + if session is None: + from ..ui import script + + ctx.ui.show(script.pairs(await status_pairs(device, snapshot))) + return ToolResult(summary={"name": snapshot.get("name")}) + + from ..ui.device_info_screen import DeviceInfoScreen + + custom = await device.get_custom_vars() # A live status panel (role, firmware, battery, clock, radio/packet statistics) # above every current setting with a short explanation of each. The panel is read # once; only the table differs between the two states, and it is cheap to rebuild. @@ -57,20 +71,11 @@ class InfoTool(Tool): def page(reveal_pin: bool) -> Group: return Group(panel, Text(""), config_table(snapshot, custom, reveal_pin=reveal_pin)) - # In the menu the page is its own screen, because it has a key of its own: the - # pairing PIN is masked until ``p`` uncovers it. On the CLI there is no keyboard to - # press, so the dump prints as it always did — with the PIN masked there too, and - # ``meshterm config get device_pin`` as the way to name it deliberately. - session = getattr(ctx.ui, "session", None) - if session is None: - ctx.ui.show(page(False)) - else: - from ..ui.device_info_screen import DeviceInfoScreen - - await session.run_screen( - DeviceInfoScreen(session, page, title=self.title, conceals=has_pin(snapshot)) - ) - + # The page is its own screen because it has a key of its own: the pairing PIN is + # masked until ``p`` uncovers it. + await session.run_screen( + DeviceInfoScreen(session, page, title=self.title, conceals=has_pin(snapshot)) + ) return ToolResult(summary={"name": snapshot.get("name")}) def register_cli(self, app: typer.Typer) -> None: @@ -89,11 +94,34 @@ class InfoTool(Tool): # -- the status panel ----------------------------------------------------------- +async def _read_status(device: Device) -> dict[str, Any]: + """Read every live status value the firmware will answer for, best-effort. + + The one place the device is asked. Both faces build from what it returns: the menu's + panel composes prose rows out of these values, the CLI prints them one fact per line + (see :func:`_status_panel` and :func:`status_pairs`). Every read is optional — + firmware predating a query simply contributes nothing — so this works on any device. + + Args: + device: The connected device to query. + + Returns: + The merged raw readings: ``info``, ``battery``, ``stats`` and ``clock``. + """ + return { + "info": await _try(device.get_device_info) or {}, + "battery": await _try(device.get_battery) or {}, + "stats": await _try(device.get_stats) or {}, + "clock": await _try(device.get_time), + } + + async def _status_panel(device: Device, snapshot: dict) -> Panel: """Build the live status panel: role, firmware, battery, clock, and statistics. - Every read beyond the snapshot is best-effort — firmware predating a query (battery, - clock, stats) simply contributes no row — so the panel renders on any device. + The menu's face of :func:`_read_status` — each row a short phrase rather than an + atomic value, because a person reads "-110 dBm noise floor · last RSSI -62 dBm" in one + go where a script wants the three numbers apart. Args: device: The connected device to query. @@ -103,32 +131,31 @@ async def _status_panel(device: Device, snapshot: dict) -> Panel: A Rich :class:`Panel` of label/value status rows. """ rows: list[tuple[str, Text]] = [] + readings = await _read_status(device) + info, battery, stats = readings["info"], readings["battery"], readings["stats"] adv_type = snapshot.get("adv_type") if adv_type is not None: role = _ROLES.get(int(adv_type), f"type {adv_type}") rows.append(("role", Text(role, style="brand"))) - info = await _try(device.get_device_info) or {} if info.get("model"): rows.append(("model", Text(str(info["model"])))) - firmware = " ".join(str(info[k]) for k in ("ver", "fw_build") if info.get(k)) + firmware = _firmware(info) if firmware: rows.append(("firmware", Text(firmware))) - battery = await _try(device.get_battery) or {} level = battery.get("level") if level: rows.append(("battery", Text(f"{int(level) / 1000:.2f} V"))) if battery.get("total_kb"): rows.append(("storage", Text(f"{battery.get('used_kb', 0)} / {battery['total_kb']} kB"))) - clock = await _try(device.get_time) + clock = readings["clock"] if clock: stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(clock)) rows.append(("clock", Text.assemble(stamp, (f" {_drift(clock)}", "muted")))) - stats = await _try(device.get_stats) or {} if stats.get("uptime_secs") is not None: rows.append(("uptime", Text(_uptime(int(stats["uptime_secs"]))))) if stats.get("noise_floor") is not None: @@ -160,6 +187,72 @@ async def _status_panel(device: Device, snapshot: dict) -> Panel: return Panel(body, title="[accent]Status[/accent]", border_style="accent", expand=False) +def _firmware(info: dict) -> str: + """The firmware version and build joined, or ``""`` when neither was reported.""" + return " ".join(str(info[k]) for k in ("ver", "fw_build") if info.get(k)) + + +async def status_pairs(device: Device, snapshot: dict) -> list[tuple[str, str]]: + """The device's live status as scripted key/value facts. + + The CLI's face of :func:`_read_status`. Where the panel writes a phrase per row, this + writes one value per key and puts the unit in the key — ``battery_v``, ``uptime_s``, + ``noise_floor_dbm`` — so nothing has to be pulled back out of prose. A reading the + firmware did not answer for is absent rather than :data:`~meshterm.ui.script.NONE`: + these are optional queries, and a missing key says "this device does not report it" + where a dash would claim it reported nothing. + + Args: + device: The connected device to query. + snapshot: The already-built settings snapshot (for role and identity). + + Returns: + ``(key, value)`` pairs in reading order. + """ + readings = await _read_status(device) + info, battery, stats = readings["info"], readings["battery"], readings["stats"] + pairs: list[tuple[str, str]] = [] + + def add(key: str, value: Any) -> None: + """Record one fact, skipping a reading the firmware did not answer for.""" + if value is not None and value != "": + pairs.append((key, str(value))) + + # Identity first: which radio this is. Everything else it *can be set to* is + # `config show`'s answer, not this one. The name is not quoted here the way it is in + # a listing: a key/value line has exactly two fields, so the value is the rest of the + # line whatever it contains, and quotes would only be something to strip back off. + add("name", snapshot.get("name")) + add("public_key", str(snapshot.get("public_key") or "").lower() or None) + adv_type = snapshot.get("adv_type") + add("role", _ROLES.get(int(adv_type), f"type {adv_type}") if adv_type is not None else None) + + add("model", info.get("model")) + add("firmware", _firmware(info) or None) + + level = battery.get("level") + add("battery_v", f"{int(level) / 1000:.2f}" if level else None) + if battery.get("total_kb"): + add("storage_used_kb", battery.get("used_kb", 0)) + add("storage_total_kb", battery["total_kb"]) + + clock = readings["clock"] + if clock: + add("clock", datetime.fromtimestamp(clock, tz=timezone.utc).astimezone().isoformat()) + add("clock_drift_s", clock - int(time.time())) + + add("uptime_s", stats.get("uptime_secs")) + add("noise_floor_dbm", stats.get("noise_floor")) + add("last_rssi_dbm", stats.get("last_rssi")) + add("last_snr_db", f"{stats['last_snr']:+.1f}" if stats.get("last_snr") is not None else None) + add("tx_air_s", stats.get("tx_air_secs")) + add("rx_air_s", stats.get("rx_air_secs")) + add("packets_sent", stats.get("sent")) + add("packets_received", stats.get("recv")) + add("receive_errors", stats.get("recv_errors")) + return pairs + + async def _try(read) -> Any | None: """Await a device read, returning ``None`` when the firmware doesn't support it.""" try: diff --git a/meshterm/tools/map.py b/meshterm/tools/map.py index 2676ca8..05bc980 100644 --- a/meshterm/tools/map.py +++ b/meshterm/tools/map.py @@ -5,11 +5,12 @@ Nodes that share their location in adverts are collected from the passive-monito its position is known, and everything is drawn over a real street basemap rendered as Unicode braille (streets, rivers, place names — the same terminal-map idea as ``mapscii``). -In the menu it opens a full-screen, pannable/zoomable map (see -:mod:`meshterm.ui.map_screen`). On the CLI it prints a one-shot render fitted to the nodes. -Repeaters are prioritised over ordinary nodes: a distinct marker, drawn on top, listed first. -The basemap is best-effort — with no network (and no cached tiles) the nodes are plotted on a -blank grid instead, and the tool still works offline. +Menu-only: it opens a full-screen, pannable/zoomable map (see +:mod:`meshterm.ui.map_screen`) and registers no CLI subcommand, because a map is a picture +and the scripted CLI deals in facts (see :meth:`MapTool.register_cli`). Repeaters are +prioritised over ordinary nodes: a distinct marker, drawn on top. The basemap is +best-effort — with no network (and no cached tiles) the nodes are plotted on a blank grid +instead, and the tool still works offline. """ from __future__ import annotations @@ -17,9 +18,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any import typer -from rich.console import Group -from rich.table import Table -from rich.text import Text from ..context import AppContext from ..core.geo import usable_fix @@ -61,17 +59,18 @@ class MapTool(Tool): order = 10 async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: - """Gather located nodes and open the interactive map, or render a static one. + """Gather the located nodes and open the interactive map. + + Menu-only (see :meth:`register_cli`), so there is one path through here. Args: ctx: Shared application context. - params: Optional ``static`` (force one-shot render), ``width``, ``zoom``, - ``basemap`` (set ``False`` to skip fetching tiles). + params: Optional ``fraction`` — how much of the mesh to open framed on. Returns: A :class:`ToolResult` summarizing how many nodes were plotted. """ - from ..ui.surface import TuiUi + from ..ui.map_screen import open_map markers = await self._gather(ctx) if not markers: @@ -81,13 +80,7 @@ class MapTool(Tool): ) return ToolResult(summary={"located": 0}) - interactive = isinstance(ctx.ui, TuiUi) and not params.get("static") - if interactive: - from ..ui.map_screen import open_map - - await open_map(ctx, markers, fraction=_fraction(ctx, params)) - else: - await self._render_static(ctx, markers, params) + await open_map(ctx, markers, fraction=_fraction(ctx, params)) repeaters = sum(1 for m in markers if m.is_repeater and not m.is_self) self_located = any(m.is_self for m in markers) @@ -98,12 +91,6 @@ class MapTool(Tool): "nodes": len(markers) - repeaters - (1 if self_located else 0), "self_located": self_located, }, - message=None - if interactive - else ( - f"[ok]✓[/ok] mapped [brand]{len(markers)}[/brand] located nodes " - f"([accent]{repeaters}[/accent] repeaters)" - ), ) # -- marker gathering ------------------------------------------------------- @@ -112,90 +99,22 @@ class MapTool(Tool): """Collect every located node to plot (see :func:`gather_markers`).""" return await gather_markers(ctx) - # -- static (CLI) render ---------------------------------------------------- - - async def _render_static( - self, ctx: AppContext, markers: list[MapMarker], params: dict[str, Any] - ) -> None: - """Fetch tiles synchronously and print a one-shot map fitted to the nodes.""" - import asyncio - - from ..core.geo import Viewport - from ..ui.map_render import render_map - from ..ui.map_screen import basemap_source - - cell_w = int(params.get("width") or min(ctx.console.size.width - 2, 160)) - cell_h = max(12, cell_w * 4 // 13) # keep roughly the terminal's aspect - dot_w, dot_h = cell_w * 2, cell_h * 4 - - source = basemap_source(ctx) - max_zoom = await asyncio.to_thread(lambda: source.max_zoom) - coords = [(m.lat, m.lon) for m in markers] - if params.get("zoom") is not None: - from ..core.geo import BBox - - center = BBox.around(coords).center - vp = Viewport(center[0], center[1], int(params["zoom"]), dot_w, dot_h) - else: - vp = Viewport.fit( - coords, - dot_w, - dot_h, - max_zoom=max_zoom, - fraction=_fraction(ctx, params), - ) - - tiles = {} - if params.get("basemap", True): - for tile in vp.tiles(max_zoom): - tiles[tile] = await asyncio.to_thread(source.load_tile, *tile) - - lines = render_map(vp, tiles, markers) - body = Text.from_ansi("\n".join(lines)) - ctx.ui.show(Group(body, Text(""), _legend(markers))) - def register_cli(self, app: typer.Typer) -> None: - """Register the ``map`` subcommand. + """Register no CLI command — a map is a picture, and pictures are menu-only. + + Every other feature has a scripted face because its answer is a set of facts a + script can act on. A map's answer is a *drawing*: braille cells whose meaning is + their position on a grid and whose nodes are told apart by colour, which is + exactly what the scripted CLI does not have (see :mod:`meshterm.ui.script`). A + one-shot render there would be an unparseable block of glyphs, and stripping its + colour to match the rest of the CLI would make it unreadable as well. + + The located nodes themselves are still scriptable — ``meshterm contacts`` lists + every one of them, coordinates included. Args: - app: The Typer application. + app: The Typer application (untouched). """ - from ..cli import run_tool_command - - @app.command(name=self.name, help=self.help) - def _map( - width: int | None = typer.Option( - None, "--width", "-w", help="Map width in character cells" - ), - zoom: int | None = typer.Option( - None, "--zoom", "-z", help="Fixed zoom level (omit to fit the nodes)" - ), - fraction: float | None = typer.Option( - None, - "--fraction", - "-f", - min=0.0, - max=1.0, - help="Fraction of nodes to frame: the densest that many, so distant " - "outliers don't zoom the view out. 1.0 fits every node. Ignored with " - "--zoom. Defaults to the 'Opening frame' preference", - ), - basemap: bool = typer.Option( - True, "--basemap/--no-basemap", help="Draw the OpenStreetMap street basemap" - ), - ) -> None: - if fraction is not None and not 0.0 < fraction <= 1.0: - raise typer.BadParameter("--fraction must be greater than 0 and at most 1") - params: dict[str, Any] = { - "static": True, - "basemap": basemap, - "fraction": fraction, - } - if width is not None: - params["width"] = width - if zoom is not None: - params["zoom"] = zoom - run_tool_command(self, params) async def gather_markers(ctx: AppContext) -> list[MapMarker]: @@ -285,35 +204,6 @@ async def _self_marker(ctx: AppContext) -> MapMarker | None: ) -def _legend(markers: list[MapMarker]) -> Table: - """A compact legend: self → repeaters → leaf nodes, with coordinates and detail. - - Names take their key-derived hue (our own the pure-white ``you``), matching the - interactive map's labels; the glyph keeps the marker's type colour. - """ - from ..ui.map_render import _NODE, _REPEATER, _SELF - from ..ui.theme import name_style - - table = Table(box=None, padding=(0, 2, 0, 0), expand=False) - table.add_column("") - table.add_column("NODE") - table.add_column("TYPE") - table.add_column("COORDS", justify="right") - table.add_column("HEARD") - ordered = sorted(markers, key=lambda m: -m._rank()) - for m in ordered: - glyph, color = _SELF if m.is_self else (_REPEATER if m.is_repeater else _NODE) - kind = "you" if m.is_self else ("repeater" if m.is_repeater else "node") - table.add_row( - Text(glyph, style=color), - Text(m.label, style="you" if m.is_self else name_style(m.label, m.key)), - Text(kind, style="muted"), - Text(f"{m.lat:.4f}, {m.lon:.4f}", style="muted"), - Text(m.detail, style="muted"), - ) - return table - - def _signal_detail(node: object) -> str: """A compact 'N pkts · +x.x dB' reception note for a heard node, or '' if unheard.""" if node is None: diff --git a/meshterm/tools/monitor.py b/meshterm/tools/monitor.py index 79ab3b2..4ad47f5 100644 --- a/meshterm/tools/monitor.py +++ b/meshterm/tools/monitor.py @@ -22,9 +22,9 @@ from typing import Any import typer from rich.table import Table -from rich.text import Text from ..context import AppContext +from ..core import exitcodes from ..core.events import EventKind, MeshEvent from ..core.models import HeardNode, Observation from .base import Tool, ToolResult, register @@ -74,27 +74,41 @@ class MonitorTool(Tool): Returns: A :class:`ToolResult` with the window's packet and node counts. """ + from ..ui import script + seconds = int(params.get("seconds") or 0) await ctx.device() # surface connection problems before announcing the capture await ctx.monitor.start() # register history recording before the hub pumps await ctx.events.start() - ctx.console.print( - "[muted]monitoring" - f"{f' for {seconds}s' if seconds else ' — press Ctrl-C to stop'}…[/muted]" + # The announcement is about the run, not part of its answer, so it goes to stderr + # and stays out of `meshterm monitor -s 60 > packets.txt`. + script.stderr_console().print( + "monitoring" + (f" for {seconds}s" if seconds else " — press Ctrl-C to stop"), + style="muted", + highlight=False, ) seen: list[Observation] = [] + # One header for the live stream, then a record per packet as it arrives. The + # stream cannot be column-aligned — the widths are not known until it ends — so + # the fields are separated by the gutter and the name carries its quotes, which is + # what keeps the line splittable. + ctx.console.print("TIME NODE NAME SNR_DB RSSI_DBM LAT LON", highlight=False) def on_observation(event: MeshEvent) -> None: obs = event.observation if obs is None: return seen.append(obs) - stamp = obs.observed_at.astimezone().strftime("%H:%M:%S") - who = obs.name or obs.node or "?" - snr = f" [muted]{obs.snr:+.1f} dB[/muted]" if obs.snr is not None else "" - rssi = f" [muted]{obs.rssi:.0f} dBm[/muted]" if obs.rssi is not None else "" - loc = " [ok]●[/ok]" if obs.lat is not None else "" - ctx.console.print(f"[muted]{stamp}[/muted] [accent]{who}[/accent]{snr}{rssi}{loc}") + fields = [ + script.stamp(obs.observed_at), + obs.node or script.NONE, + script.quote(obs.name), + script.number(obs.snr, "+.1f"), + script.number(obs.rssi, ".0f"), + script.number(obs.lat, ".5f"), + script.number(obs.lon, ".5f"), + ] + ctx.console.print((" " * script.GUTTER).join(fields), highlight=False) unsubscribe = ctx.events.subscribe(on_observation, EventKind.OBSERVATION) try: @@ -114,14 +128,11 @@ class MonitorTool(Tool): nodes = [HeardNode.from_observations(node, group) for node, group in by_node.items()] if nodes: nodes.sort(key=lambda n: n.last_seen, reverse=True) + ctx.ui.show(script.blank()) ctx.ui.show(_heard_table(nodes)) return ToolResult( summary={"seconds": seconds, "packets": len(seen), "nodes": len(nodes)}, - message=( - f"[ok]✓[/ok] heard [brand]{len(seen)}[/brand] " - f"packet{'' if len(seen) == 1 else 's'} from " - f"[brand]{len(nodes)}[/brand] node{'' if len(nodes) == 1 else 's'}" - ), + exit_code=exitcodes.OK if seen else exitcodes.NO_RESULT, ) def register_cli(self, app: typer.Typer) -> None: @@ -142,32 +153,37 @@ class MonitorTool(Tool): def _heard_table(heard: list[HeardNode]) -> Table: - """Render the capture window's heard-node summary table. + """The capture window's per-node summary: what each node did over the whole window. + + The aggregate the live stream cannot give — a count, a median, a best — one record per + node heard, most recently heard first. Args: heard: Aggregated per-node statistics for the window. Returns: - A Rich :class:`Table` of node, packet count, SNR, RSSI, and location. + The scripted table (see :func:`meshterm.ui.script.columns`). """ - from ..ui.theme import snr_style + from ..ui import script - table = Table(title=f"Heard nodes ({len(heard)})", border_style="muted", expand=False) - table.add_column("NODE") - table.add_column("PKTS", justify="right") - table.add_column("MEDIAN SNR", justify="right") - table.add_column("BEST SNR", justify="right") - table.add_column("RSSI", justify="right") - table.add_column("LOC", justify="center") + table = script.columns( + "NODE", + "NAME", + "PKTS", + "MEDIAN_SNR_DB", + "BEST_SNR_DB", + "RSSI_DBM", + "LAST_HEARD", + right=("PKTS", "MEDIAN_SNR_DB", "BEST_SNR_DB", "RSSI_DBM"), + ) for node in heard: - label = node.name or node.node or "?" - median = node.median_snr - best = node.best_snr - median_cell = ( - Text(f"{median:+.1f}", style=snr_style(median)) if median is not None else Text("—") + table.add_row( + node.node or script.NONE, + script.quote(node.name), + str(node.count), + script.number(node.median_snr, "+.1f"), + script.number(node.best_snr, "+.1f"), + script.number(node.last_rssi, ".0f"), + script.stamp(node.last_seen), ) - best_cell = Text(f"{best:+.1f}", style=snr_style(best)) if best is not None else Text("—") - rssi_cell = f"{node.last_rssi:.0f}" if node.last_rssi is not None else "—" - loc_cell = Text("●", style="ok") if node.has_location else Text("·", style="muted") - table.add_row(str(label), str(node.count), median_cell, best_cell, rssi_cell, loc_cell) return table diff --git a/meshterm/tools/preferences.py b/meshterm/tools/preferences.py index 2f0cfdb..454a8e7 100644 --- a/meshterm/tools/preferences.py +++ b/meshterm/tools/preferences.py @@ -17,14 +17,24 @@ than sitting beside Device config. from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import typer from ..context import AppContext -from ..core.preferences import PreferenceError, format_value, get_spec +from ..core.preferences import ( + PREFERENCES, + PreferenceError, + Preferences, + PrefSpec, + format_value, + get_spec, +) from .base import Tool, ToolResult, register +if TYPE_CHECKING: # pragma: no cover - typing only + from rich.table import Table + @register class PreferencesTool(Tool): @@ -73,21 +83,28 @@ class PreferencesTool(Tool): cleanly by the CLI rather than as a traceback. """ from ..ui.preferences import preferences_table + from ..ui.surface import TuiUi prefs = ctx.preferences + scripted = not isinstance(ctx.ui, TuiUi) ops: list[tuple] = list(params.get("ops") or []) changes = 0 for op in ops: action = op[0] if action == "show": - ctx.ui.show(preferences_table(prefs, ctx.console.width)) - elif action == "get": - spec = get_spec(op[1]) - where = "changed" if prefs.is_overridden(spec.key) else "default" ctx.ui.show( - f"[accent]{spec.key}[/accent] = " - f"[brand]{format_value(spec, prefs.get(spec.key))}[/brand] ({where})" + _listing(prefs) if scripted else preferences_table(prefs, ctx.console.width) ) + elif action == "get": + # The bare value and nothing else — the caller named the key, and + # whether it is overridden is what `show`'s DEFAULT lane answers. + spec = get_spec(op[1]) + if scripted: + ctx.ui.note(_script_value(spec, prefs.get(spec.key))) + else: + where = "changed" if prefs.is_overridden(spec.key) else "default" + shown = format_value(spec, prefs.get(spec.key)) + ctx.ui.note(f"[accent]{spec.key}[/accent] = [brand]{shown}[/brand] ({where})") elif action == "set": before = prefs.get(op[1]) after = prefs.set(op[1], op[2]) @@ -118,7 +135,7 @@ class PreferencesTool(Tool): """ from ..cli import run_tool_command - prefs_app = typer.Typer(help=self.help, no_args_is_help=False, rich_markup_mode="rich") + prefs_app = typer.Typer(help=self.help, no_args_is_help=False, rich_markup_mode=None) @prefs_app.callback(invoke_without_command=True) def _root(ctx: typer.Context) -> None: @@ -155,3 +172,51 @@ class PreferencesTool(Tool): __all__ = ["PreferencesTool", "PreferenceError"] + + +def _script_value(spec: PrefSpec, value: Any) -> str: + """A preference's value in the form ``preferences set`` accepts back. + + :func:`~meshterm.core.preferences.format_value` writes for the page: a number carries + its unit (``5 s``), so a bare figure in the VALUE lane still says what it counts. That + unit is exactly what :func:`~meshterm.core.preferences.parse_value` will not take + back, so the scripted dump drops it. Booleans keep ``on``/``off`` and an enum keeps + its own key, both of which do round-trip. + + Args: + spec: The preference the value belongs to. + value: Its current value. + + Returns: + The scripted rendering. + """ + if spec.value_type == "bool": + return "on" if value else "off" + if spec.value_type in ("int", "float"): + return f"{value:g}" + return str(value) + + +def _listing(prefs: Preferences) -> Table: + """Every preference, its current value, and the built-in it would fall back to. + + Three facts per line and no fourth: the descriptions the page carries explain a + preference to someone deciding whether to change it, and they are what forces the + table to wrap. A scripted reader has decided. + + Args: + prefs: The preference set to report. + + Returns: + The scripted table (see :func:`meshterm.ui.script.columns`). + """ + from ..ui import script + + table = script.columns("PREFERENCE", "VALUE", "DEFAULT") + for spec in PREFERENCES: + table.add_row( + spec.key, + _script_value(spec, prefs.get(spec.key)), + _script_value(spec, spec.default), + ) + return table diff --git a/meshterm/tools/records.py b/meshterm/tools/records.py index 858bc46..6b19321 100644 --- a/meshterm/tools/records.py +++ b/meshterm/tools/records.py @@ -13,16 +13,20 @@ The CLI face prints the stored boards: ``meshterm records`` reads the tables so from __future__ import annotations -from typing import Any +from collections.abc import Callable +from typing import TYPE_CHECKING, Any import typer -from rich.table import Table -from rich.text import Text from ..context import AppContext +from ..core import exitcodes from ..services.records import CATEGORIES, CATEGORY_BY_ID from .base import Tool, ToolResult, register +if TYPE_CHECKING: # pragma: no cover - typing only + from ..persistence.repository import DiscoveredPath + from ..services.records import Category + @register class TrophyCaseTool(Tool): @@ -82,42 +86,45 @@ class TrophyCaseTool(Tool): Returns: A :class:`ToolResult` with the record count. """ + from ..services import trace_runner + from ..ui import script + wanted = [CATEGORY_BY_ID[category]] if category in CATEGORY_BY_ID else CATEGORIES + # Names come from stored history alone — no contacts, because this command reads + # the database and must work with no radio attached at all. + resolve = trace_runner.make_node_resolver(None, ctx.repo.node_names()) + + table = script.columns( + "CATEGORY", + "WIDTH", + "SCORE", + "UNIT", + "RECORDED", + "VERSION", + "ROUTE", + right=("WIDTH", "SCORE"), + ) total = 0 for cat in wanted: rows = ctx.repo.discoveries(cat.id, width_bytes=width) - if not rows: - continue rows.sort(key=lambda r: (r.width_bytes, r.score if cat.ascending else -r.score)) - table = Table( - title=f"{cat.icon} {cat.title} — {cat.description}", - title_justify="left", - ) - table.add_column("width", justify="right") - table.add_column("score", justify="right") - table.add_column("route") - table.add_column("recorded") - table.add_column("version") for record in rows: - score = cat.format_score(record.score) - if cat.id == "long_haul" and not record.stats.get("km_complete", True): - score = "≥ " + score table.add_row( - f"{record.width_bytes} B", - score, - record.spec, - record.discovered_at.astimezone().strftime("%b %d %Y %H:%M"), + cat.id, + str(record.width_bytes), + _score(cat, record), + cat.unit, + script.stamp(record.discovered_at), record.app_version, + _route(record, resolve), ) total += 1 + if total: ctx.ui.show(table) - if total == 0: - ctx.ui.show( - Text("no records yet — every trace that comes home is scored here", style="muted") - ) return ToolResult( summary={"records": total}, message=f"{total} record{'s' if total != 1 else ''} stored", + exit_code=exitcodes.OK if total else exitcodes.NO_RESULT, ) def register_cli(self, app: typer.Typer) -> None: @@ -150,3 +157,56 @@ class TrophyCaseTool(Tool): if width: tool_params["width"] = width run_tool_command(self, tool_params) + + +def _score(category: Category, record: DiscoveredPath) -> str: + """A record's score as a bare number, in the category's unit (which is its own column). + + ``format_score`` writes ``273.0 km`` for a reader; the unit belongs in the ``UNIT`` + column here so the ``SCORE`` column is one comparable number per row. + + The one qualifier that survives is the ``>=`` on a Longest-distance walk whose + kilometre total is incomplete — some hop on it has no known position, so the distance + is a floor rather than a measurement. Dropping it would turn a lower bound into a + claim. + + Args: + category: The discipline the record was set in. + record: The record. + + Returns: + The score, prefixed ``>=`` where it is a lower bound. + """ + if category.unit == "nodes": + value = str(int(record.score)) + elif category.unit == "dB": + value = f"{record.score:+.1f}" + else: + value = f"{record.score:.1f}" + incomplete = category.id == "long_haul" and not record.stats.get("km_complete", True) + return f">={value}" if incomplete else value + + +def _route(record: DiscoveredPath, resolve: Callable[[str], str | None]) -> str: + """A record's walk as the CLI's path line. + + Each hop is named where history knows the node and carries the hash it was actually + transmitted at — the spec's own hop, at this record's width — so the line can be read + against the ``spec`` a re-walk would take. Where the two lists disagree in length the + node id stands in, which is the only identity that hop has. + + Args: + record: The record whose walk to render. + resolve: Maps a node id to a friendly name, or ``None`` when unknown. + + Returns: + The path line (see :func:`meshterm.ui.script.path`). + """ + from ..ui import script + + spec = record.spec.split(",") if record.spec else [] + hops: list[tuple[str, str | None]] = [] + for index, node in enumerate(record.route): + addressed = spec[index] if index < len(spec) else node + hops.append((resolve(node) or node, addressed)) + return script.path(hops) diff --git a/meshterm/tools/repeater_admin.py b/meshterm/tools/repeater_admin.py index d4561c3..0d9f49f 100644 --- a/meshterm/tools/repeater_admin.py +++ b/meshterm/tools/repeater_admin.py @@ -17,8 +17,10 @@ from __future__ import annotations from typing import Any import typer +from rich.text import Text from ..context import AppContext +from ..core import exitcodes from ..core.connection import DeviceCommandError from ..core.models import LoginResult from .base import Tool, ToolResult, register @@ -91,11 +93,17 @@ class RepeaterAdminTool(Tool): command = str(params["command"]) ctx.remote_store.append_history(node, command) reply = await device.send_remote_command(node, command, timeout=10.0) - if reply is None: - ctx.ui.note("[warn]no reply — the command may still have landed[/warn]") - else: - ctx.ui.note(reply) - return ToolResult(summary={"node": name, "command": command, "replied": reply is not None}) + if reply is not None: + # A remote node's reply is its own text, and it is the whole answer: printed as + # a Text, not as markup, so a reply containing a square bracket is a reply + # containing a square bracket. + ctx.ui.show(Text(reply)) + return ToolResult( + summary={"node": name, "command": command, "replied": reply is not None}, + # Silence is not failure — the command may well have landed — but there is + # nothing to report, and a script waiting on output should know which it got. + exit_code=exitcodes.OK if reply is not None else exitcodes.NO_RESULT, + ) def register_cli(self, app: typer.Typer) -> None: """Register the ``repeater-admin`` subcommand. diff --git a/meshterm/tools/trace.py b/meshterm/tools/trace.py index 2ffda42..59a0279 100644 --- a/meshterm/tools/trace.py +++ b/meshterm/tools/trace.py @@ -32,7 +32,14 @@ from typing import TYPE_CHECKING, Any import typer from ..context import AppContext -from ..core.models import LOCAL_DEVICE_LABEL, PATH_TRACE_TARGET, Contact, TraceStats +from ..core import exitcodes +from ..core.models import ( + LOCAL_DEVICE_LABEL, + PATH_TRACE_TARGET, + Contact, + TraceResult, + TraceStats, +) from ..services import trace_runner from ..ui.widgets import stats_panel from .base import Tool, ToolResult, register @@ -397,6 +404,8 @@ async def _trace_once_cli( Returns: A :class:`ToolResult` with the trace's outcome. """ + from ..ui.surface import TuiUi + is_walk = target == PATH_TRACE_TARGET device = await ctx.device() @@ -419,9 +428,6 @@ async def _trace_once_cli( path: str | None = None if path_spec: path = trace_runner.parse_trace_path(path_spec, contacts) - ctx.ui.note(f"[muted]forcing path:[/muted] [brand]{path}[/brand]") - else: - ctx.ui.note("[muted]path: auto (device-routed)[/muted]") with ctx.ui.progress("trace") as progress: task = progress.add_task("walking the path" if is_walk else f"tracing {target}", total=1) @@ -429,18 +435,21 @@ async def _trace_once_cli( ctx.repo.record_trace(run_id, result) progress.advance(task) - # The stats panel renders route + per-hop readings for the single trace (its - # medians collapse to the readings themselves). stats = TraceStats.from_traces(target, [result]) - ctx.ui.show( - stats_panel( - stats, - device_label, - resolve, - route=result if result.success else None, - device_hash=device_hash, + if isinstance(ctx.ui, TuiUi): + # The stats panel renders route + per-hop readings for the single trace (its + # medians collapse to the readings themselves). + ctx.ui.show( + stats_panel( + stats, + device_label, + resolve, + route=result if result.success else None, + device_hash=device_hash, + ) ) - ) + else: + _print_trace(ctx, result, target, path, device_label, resolve, device_hash) summary: dict[str, Any] = { "target": target, @@ -453,15 +462,110 @@ async def _trace_once_cli( summary["path"] = path via = f" via [brand]{path}[/brand]" if path else "" if is_walk: - if result.success: - message = f"[ok]✓[/ok] the path came home{via}" - else: - message = f"[err]✗[/err] no reply{via}" + message = ( + f"[ok]✓[/ok] the path came home{via}" + if result.success + else f"[err]✗[/err] no reply{via}" + ) elif result.success: message = f"[ok]✓[/ok] traced [brand]{target}[/brand]{via}" else: message = f"[err]✗[/err] no reply from [brand]{target}[/brand]{via}" - return ToolResult(summary=summary, message=message) + # A trace that never came home is not a failure of the command — the radio did + # transmit and the walk did run. It is a walk with nothing to report, which is what + # NO_RESULT says, and it is the answer a script most often branches on. + return ToolResult( + summary=summary, + message=message, + exit_code=exitcodes.OK if result.success else exitcodes.NO_RESULT, + ) + + +def _print_trace( + ctx: AppContext, + result: TraceResult, + target: str, + path: str | None, + device_label: str, + resolve: Any, + device_hash: str | None, +) -> None: + """Print one trace as scripted facts: the walk's outcome, then its per-hop readings. + + Two blocks. The first is what the walk *did* — one fact per line, ``route`` among them + as the CLI's path line, every node named and carrying the hash it was addressed by. + The second is a record per hop, and it names its two ends by that same hash rather + than repeating the names: the route line above is where the names are, and the hash is + what joins the two blocks (it is what carries a node's identity here, the way its + colour does on a screen). + + Args: + ctx: Shared application context. + result: The trace that ran. + target: The label it was addressed to. + path: The forced route as hex, or ``None`` when the device routed it. + device_label: Our own node's name, at both ends of the walk. + resolve: Maps a hop hash to a friendly name when known. + device_hash: Our own public key, so our ends carry a hash like every other hop. + """ + from ..ui import script + + hash_bytes = result.path_hash_bytes + + def short(value: str | None) -> str | None: + """A hash at the width this trace addressed nodes by.""" + if not value: + return None + raw = value.lower().removeprefix("0x") + return raw[: hash_bytes * 2] if hash_bytes else raw + + facts: list[tuple[str, str]] = [ + ("target", target if target != PATH_TRACE_TARGET else script.NONE), + # "auto" is not a path spec, so it cannot be confused for one: every real value + # here is comma-separated hex. + ("path", path or "auto"), + ("success", "yes" if result.success else "no"), + ("hops", script.number(result.hop_count if result.success else None)), + ("min_snr_db", script.number(result.min_snr, "+.1f")), + ("rtt_ms", script.number(result.round_trip_ms, ".0f")), + ] + edges = result.edges(device_label) + if edges: + nodes = [edges[0].origin] + [edge.destination for edge in edges] + facts.append( + ( + "route", + script.path( + [ + (device_label, short(device_hash)) + if (not node or node == device_label) + else (resolve(node) or node, short(node)) + for node in nodes + ] + ), + ) + ) + ctx.ui.show(script.pairs(facts)) + + if not edges: + return + ctx.ui.show(script.blank()) + table = script.columns("HOP", "FROM", "TO", "SNR_DB", right=("HOP", "SNR_DB")) + + def end(node: str) -> str: + """A hop end as its addressed hash — our own device by its key, like any other.""" + if not node or node == device_label: + return short(device_hash) or script.NONE + return short(node) or script.NONE + + for edge in edges: + table.add_row( + str(edge.index), + end(edge.origin), + end(edge.destination), + script.number(edge.snr, "+.1f"), + ) + ctx.ui.show(table) def _last_traced_by_name( diff --git a/meshterm/tools/tx_optimize.py b/meshterm/tools/tx_optimize.py index 352284a..edaba3c 100644 --- a/meshterm/tools/tx_optimize.py +++ b/meshterm/tools/tx_optimize.py @@ -22,8 +22,9 @@ from typing import Any import typer from ..context import AppContext +from ..core import exitcodes from ..core.connection import DeviceCommandError -from ..core.models import Contact, LoginResult +from ..core.models import Contact, LoginResult, TxOptResult from ..services import trace_runner, tx_optimizer from ..ui.widgets import tx_opt_summary, tx_opt_table from .base import Tool, ToolResult, register @@ -257,6 +258,8 @@ class TxOptimizeTool(Tool): Returns: A :class:`ToolResult` with the optimum and any chart path. """ + from ..ui.surface import TuiUi + run_id = params["_run_id"] device = await ctx.device() contacts = await device.get_contacts() @@ -268,14 +271,14 @@ class TxOptimizeTool(Tool): samples = max(1, min(MAX_SAMPLES, int(params.get("samples", 3)))) if int(params.get("samples", 3)) > MAX_SAMPLES: - ctx.ui.note(f"[warn]capping at {MAX_SAMPLES} traces per level[/warn]") + ctx.ui.ack(f"[warn]capping at {MAX_SAMPLES} traces per level[/warn]") - ctx.ui.note( + ctx.ui.ack( f"[muted]logging in to[/muted] [brand]{admin_node.name}[/brand] [muted]…[/muted]" ) await self._login(ctx, admin_node, params) - ctx.ui.note( + ctx.ui.ack( f"[muted]tuning[/muted] [brand]{admin_node.name}[/brand] " f"[muted]→ target[/muted] [brand]{target_label}[/brand] " f"[muted]via {path}[/muted]" @@ -316,8 +319,11 @@ class TxOptimizeTool(Tool): if result.applied: ctx.log.info("set TX power %s on %s", result.best_tx, admin_node.name) - ctx.ui.show(tx_opt_table(result)) - ctx.ui.show(tx_opt_summary(result)) + if isinstance(ctx.ui, TuiUi): + ctx.ui.show(tx_opt_table(result)) + ctx.ui.show(tx_opt_summary(result)) + else: + _print_sweep(ctx, result, path) if no_result: restored = ( @@ -335,6 +341,9 @@ class TxOptimizeTool(Tool): ) return ToolResult( + # No level got a trace through, so nothing was measured and nothing was tuned: + # the sweep ran and has nothing to report. + exit_code=exitcodes.NO_RESULT if no_result else exitcodes.OK, summary={ "target": result.target, "admin_node": result.admin_node, @@ -524,3 +533,48 @@ def _fmt_snr(snr: float | None) -> str: A short fixed-width string like ``+5.1`` or `` n/a``. """ return f"{snr:+.1f}" if snr is not None else " n/a" + + +def _print_sweep(ctx: AppContext, result: TxOptResult, path: str) -> None: + """Print a TX sweep as scripted facts: the outcome, then every level measured. + + The winner comes first, because it is what the command was asked for and what a caller + acts on; the per-level records follow, so the choice can be checked against the + measurements it was made from. The menu's ``★`` on the winning row has no column here + — ``optimal_tx`` above the table already names it, and a mark is something to look at + rather than something to test. + + Args: + ctx: Shared application context. + result: The completed sweep. + path: The forced route the traces walked, as hex hops. + """ + from ..ui import script + + ctx.ui.show( + script.pairs( + [ + ("tuning_node", result.admin_node), + ("target", result.target), + ("path", path), + ("optimal_tx_dbm", script.number(result.best_tx)), + ("target_snr_db", script.number(result.best_snr, "+.1f")), + ("reliability", f"{result.best_success_rate:.2f}"), + ("previous_tx_dbm", script.number(result.original_tx)), + ("applied", "yes" if result.applied else "no"), + ] + ) + ) + if not result.levels: + return + ctx.ui.show(script.blank()) + lanes = ("TX_DBM", "TARGET_SNR_DB", "SUCCESSES", "SAMPLES") + table = script.columns(*lanes, right=lanes) + for level in result.sorted_by_tx(): + table.add_row( + str(level.tx_power), + script.number(level.target_snr, "+.1f"), + str(level.successes), + str(level.samples), + ) + ctx.ui.show(table) diff --git a/meshterm/ui/markdown.py b/meshterm/ui/markdown.py index d79169b..af41160 100644 --- a/meshterm/ui/markdown.py +++ b/meshterm/ui/markdown.py @@ -98,11 +98,14 @@ class Block: heading: Whether this block is one of the page's section landmarks (see :attr:`MarkdownDoc.sections`). label: The heading's plain text, for a landmark; empty otherwise. + qr: Whether this block is a ``qr`` fence's scannable code. Marked so a surface + that cannot use one can drop it (see :meth:`MarkdownDoc.without_qr`). """ renderable: RenderableType heading: bool = False label: str = "" + qr: bool = False class MarkdownDoc: @@ -124,6 +127,27 @@ class MarkdownDoc: """The page's section headings, in order — the landmarks a jump steps by.""" return tuple(block.label for block in self.blocks if block.heading) + def without_qr(self) -> MarkdownDoc: + """The same page with its scannable codes left out — and the blank line each left behind. + + A QR is a second rendering of a URL the page already prints as a link, drawn for a + phone pointed at a screen. Redirected into a file it is a block of block characters + wrapped around nothing new, so the scripted CLI takes this copy instead. + + Returns: + A new document; this one is untouched. + """ + kept: list[Block] = [] + for block in self.blocks: + if block.qr: + # The blank separator that opened the QR's own block goes with it, so + # dropping a code does not leave two blank lines where it stood. + if kept and isinstance(kept[-1].renderable, Text) and not kept[-1].renderable.plain: + kept.pop() + continue + kept.append(block) + return MarkdownDoc(kept) + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: """Render every block in order (what makes the document a plain renderable).""" for block in self.blocks: @@ -190,8 +214,9 @@ def _document(nodes: Sequence[SyntaxTreeNode]) -> list[Block]: continue heading = node.type == "heading" and node.tag == landmark label = _inline(node.children[0], heading=True).plain if heading else "" + qr = node.type == "fence" and node.info.strip() == "qr" for rendered in _block(node, indent=INDENT, depth=0): - blocks.append(Block(rendered, heading=heading, label=label)) + blocks.append(Block(rendered, heading=heading, label=label, qr=qr)) heading = False # a heading is one block; never mark a stray second return blocks diff --git a/meshterm/ui/script.py b/meshterm/ui/script.py new file mode 100644 index 0000000..e0c22dd --- /dev/null +++ b/meshterm/ui/script.py @@ -0,0 +1,365 @@ +"""The scripted CLI's output language — plain text, aligned, unadorned. + +MeshTerm has two front ends and they are read by different things. The menu is read by a +person sitting in front of it, and everything else in ``meshterm/ui/`` is built for that +reader: colour that carries meaning, glyphs that stand for concepts, frames that group. +The CLI is read by ``awk``. So it gets its own vocabulary, and this module is all of it. + +The rules, and why each one: + +* **No colour.** ``stdout`` is a pipe more often than a terminal, and a colour that + survives into a file is noise in it. The console this module builds has + ``color_system=None``, so Rich emits no escape sequence at all — not a dim, not a bold. + That is stricter than ``no_color``, which keeps the attributes and drops only the hues. + A *highlight* colour is reserved for the one thing that would be unreadable without it + (a route graph's emphasised path); nothing in the CLI draws one, and nothing claims the + colour speculatively. +* **No wrapping.** A record is a line. Wrapping turns one record into two and puts the + second one's fields under the wrong headings, so the console is made :data:`WIDTH` + cells wide — far past any real line — and every column is ``no_wrap``. Long output runs + off the right, and the terminal (or ``less -S``, or the reader's pipe) decides what to + do about it. +* **No frames.** No panel borders, no table boxes, no rules, no titles. The command the + reader typed is the title; a box around the answer is a second one. +* **Aligned columns, one header line.** ``ps`` and ``df``'s shape: an uppercase header + row, then records, fields padded apart by :data:`GUTTER` spaces. See :func:`columns`. +* **Names are quoted where they share a line.** A node name can hold a space, a comma, + even a quote, so in a listing a bare name is not a field — it is however many fields the + name happens to split into. Every name in a :func:`columns` row or a :func:`path` line + goes through :func:`quote`. A :func:`pairs` line is the exception and needs no quoting: + it has exactly two fields, so the value is the rest of the line whatever it holds, and + quotes there would only be something to strip back off. +* **Times are absolute.** ``3h`` is for a person watching a screen; a script wants + something it can sort and subtract, so every timestamp is local ISO-8601 to the second + (:func:`stamp`). The relative ages and their recency heat stay in the menu, where the + colour they are half made of still exists. +* **One token for "nothing".** :data:`NONE` — a lone ``-`` — in every column, so an + absent value never has to be told apart from an empty one, an ``—``, an ``n/a`` or a + blank. +* **No trailing whitespace.** Padding the last column to its width would put invisible + spaces at the end of every line; the console trims them on the way out + (:class:`_Trimmed`). + +Nothing here is reachable from the menu, and nothing in the menu is reachable from here: +:class:`~meshterm.ui.surface.PlainUi` is the seam, chosen in +:func:`~meshterm.cli.main_callback` by whether a subcommand was named. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from datetime import datetime +from typing import Any + +from rich.console import Console, Group, RenderableType +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +#: The scripted console's width. Wide enough that Rich never wraps or truncates anything +#: MeshTerm can produce (a 64-hex key, a full route, a preference description), leaving +#: the reader's own terminal to deal with a line longer than itself. Not ``sys.maxsize``: +#: Rich sizes a few structures against the width, and a merely enormous number costs +#: nothing where an astronomical one does. +WIDTH = 1 << 14 + +#: Spaces between columns. Two, so a padded column and its neighbour never touch and a +#: lone space is never mistaken for a separator inside an unquoted field. +GUTTER = 2 + +#: The one token for a value that is absent, unknown, or does not apply. A reader (and a +#: parser) learns it once. Never ``—``, ``n/a``, ``never``, or an empty cell. +NONE = "-" + + +def quote(value: str | None) -> str: + r"""Wrap a name in doublequotes, escaping the ones inside it. + + A node name is user-supplied text that can hold a space, a comma, or a quote, so it + is never a field on its own — quoting is what makes it one. The escaping is the + ordinary backslash convention and covers the backslash too, so the value reads back + unambiguously. + + Args: + value: The name to quote. ``None`` and the empty string both give ``""``. + + Returns: + The quoted name, e.g. ``"Yagi-Repeater"`` or ``"He said \"hi\""``. + """ + text = value or "" + return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def stamp(when: datetime | None) -> str: + """Format a timestamp as local ISO-8601 to the second, or :data:`NONE`. + + Stored times are timezone-aware UTC; a naive one is a time whose offset we do not + actually know, which is not a fact and so reads as absent — the same rule + :func:`~meshterm.ui.widgets._age_seconds` applies to ages. + + Args: + when: The instant to format. + + Returns: + e.g. ``2026-09-07T18:22:41-04:00``, or :data:`NONE`. + """ + if when is None or when.tzinfo is None: + return NONE + return when.astimezone().isoformat(timespec="seconds") + + +def number(value: Any, spec: str = "") -> str: + """Format a number, or :data:`NONE` when it is absent. + + Args: + value: The number to format; ``None`` reads as absent. + spec: An optional :func:`format` spec, e.g. ``"+.1f"``. + + Returns: + The formatted number, or :data:`NONE`. + """ + if value is None: + return NONE + return format(value, spec) if spec else str(value) + + +def columns(*headers: str, right: Sequence[str] = ()) -> Table: + """Build the scripted table: an uppercase header line, then padded records. + + The shape ``ps`` and ``df`` print — no box, no title, no edge padding, every column + sized to its widest value and separated by :data:`GUTTER` spaces, nothing wrapped and + nothing elided. Numeric lanes right-align, which is what makes a column of magnitudes + comparable by eye without changing what splitting it yields. + + Args: + *headers: The column headings, already uppercase. + right: The headings (drawn from ``headers``) whose values right-align. + + Returns: + A Rich :class:`Table` ready for ``ctx.ui.show``. + """ + table = Table( + box=None, + show_edge=False, + pad_edge=False, + expand=False, + # (top, right, bottom, left): the whole gutter hangs to the right of each cell, so + # no line opens with an indent and the last column ends where its value does. + padding=(0, GUTTER, 0, 0), + ) + wanted = set(right) + for header in headers: + table.add_column( + header, + justify="right" if header in wanted else "left", + no_wrap=True, + overflow="ignore", + ) + return table + + +def pairs(rows: Iterable[tuple[str, str]]) -> Table: + """Build the scripted key/value listing: key then value, aligned, no header line. + + What a set of facts about one thing prints as (``meshterm info``, ``config show``, + ``preferences show``) — the ``sysctl -a`` shape. Keys are the ones the matching + ``get``/``set`` subcommand takes, so a line read out of ``show`` can be typed back in. + + Args: + rows: ``(key, value)`` pairs in display order. + + Returns: + A Rich :class:`Table` with no header line. + """ + table = Table( + box=None, + show_edge=False, + show_header=False, + pad_edge=False, + expand=False, + padding=(0, GUTTER, 0, 0), + ) + table.add_column(no_wrap=True, overflow="ignore") + table.add_column(no_wrap=True, overflow="ignore") + for key, value in rows: + table.add_row(key, value) + return table + + +def path(hops: Iterable[tuple[str, str | None]]) -> str: + """Render a hop sequence as the CLI's path line. + + ``"Origin" (3d),"Relay" (f2),"Us" (a1)`` — each node's name quoted (:func:`quote`), + its hash in parentheses *outside* the quotes, hops separated by a bare comma. + + The hash is there because the CLI has no colour. On a screen a route's hops are told + apart by their key-derived hues, and matched to a route graph's one-byte labels the + same way; with the colour gone, the hash is what carries that identity. + + Our own node is a hop like any other — named and hashed, never the menu's ``★``. The + star says "you already know who this is", which is true of a reader and false of a + parser. + + The comma is the separator ``--path`` already takes, and the quoting is what keeps the + line splittable when a name contains one. + + Args: + hops: ``(label, hash)`` pairs in propagation order; a ``None`` hash prints the + name alone, for a node whose identity is genuinely unknown. + + Returns: + The joined path line, or :data:`NONE` when there are no hops. + """ + parts = [f"{quote(label)} ({value})" if value else quote(label) for label, value in hops] + return ",".join(parts) if parts else NONE + + +# -- the console --------------------------------------------------------------------- + + +class _Trimmed: + """A text stream that drops trailing whitespace from every line passing through it. + + Rich pads a table's cells to their column width, the last column's included, which + would leave invisible spaces at the end of every record. Trimming at the stream is the + one place that catches all of it — table padding, a justified :class:`Text`, a blank + line Rich decided to pad — without any renderable having to know. + + What it holds back is only the *trailing whitespace run*, never a whole line. Rich + writes a rendered row segment by segment and, on Windows, flushes after each one, so a + wrapper that waited for the newline before deciding would be asked to decide dozens of + times a row — and one that trimmed at flush would throw away the very padding that + separates two columns. Everything up to the last non-space character goes straight + through; the spaces after it wait to find out whether a newline or another column + follows. At end of stream nothing follows, and trailing whitespace is exactly what it + is then, so the held run is simply dropped. + """ + + def __init__(self, stream: Any) -> None: + """Wrap ``stream``, holding back whatever trailing whitespace has arrived.""" + self._stream = stream + self._held = "" + + def write(self, text: str) -> int: + """Write ``text``, emitting whitespace only once something follows it on the line.""" + pending = self._held + text + if "\n" in pending: + *lines, pending = pending.split("\n") + self._stream.write("".join(line.rstrip() + "\n" for line in lines)) + body = pending.rstrip() + if body: + self._stream.write(body) + self._held = pending[len(body) :] + return len(text) + + def flush(self) -> None: + """Flush the wrapped stream, keeping the held whitespace held. + + Deliberately *not* a place that emits: a flush lands between two columns as often + as at the end of a line, and it carries no information about which. + """ + self._stream.flush() + + def __getattr__(self, name: str) -> Any: + """Delegate everything else (``isatty``, ``encoding``, ...) to the real stream.""" + return getattr(self._stream, name) + + +def console(stream: Any = None) -> Console: + """Build the scripted console: no colour, no wrapping, no trailing whitespace. + + Args: + stream: The file to write to; defaults to ``sys.stdout``. + + Returns: + A :class:`~rich.console.Console` that emits plain text and nothing else. + """ + import sys + + target = sys.stdout if stream is None else stream + reconfigure = getattr(target, "reconfigure", None) + if reconfigure is not None: + try: # a name or mark outside cp1252 must not raise on a legacy Windows console + reconfigure(encoding="utf-8") + except (ValueError, OSError): # pragma: no cover - stream not reconfigurable + pass + return Console( + file=_Trimmed(target), + # Not `no_color`, which keeps bold, dim, reverse and the rest and drops only the + # hues. `color_system=None` is the one setting under which Rich emits no escape + # sequence at all. + color_system=None, + width=WIDTH, + # Rich would otherwise notice a real terminal and soft-wrap to its width. + soft_wrap=True, + highlight=False, + emoji=False, + ) + + +def stderr_console() -> Console: + """The console for everything that is *about* the run rather than part of its answer. + + stdout carries what the command was asked for and nothing else, so a progress bar, a + log line and an error message all go here instead — visible in a terminal, absent from + ``meshterm contacts > contacts.txt``. This one keeps its colour and its wrapping: a + terminal is the only thing that ever reads it. + + Returns: + A themed :class:`~rich.console.Console` writing to ``sys.stderr``. + """ + import sys + + from .theme import active_theme + + return Console(file=sys.stderr, theme=active_theme()) + + +# -- the safety net ------------------------------------------------------------------ + + +def flatten(renderable: RenderableType) -> list[RenderableType]: + """Strip framing from anything reaching the scripted console still wearing it. + + Every CLI surface is *written* plain — the tools build their output through + :func:`columns`, :func:`pairs` and :func:`path`. This is the net under that, for a + renderable shared with the menu that still arrives boxed: a :class:`Panel` gives up + its border and title and yields its body, a :class:`Table` gives up its box, title and + expansion, and a :class:`Group` is flattened member by member. + + It is deliberately not a *design*: a screen's table has the menu's columns, not the + CLI's, so passing it through here makes it printable, not right. Anything a script is + meant to read gets a plain renderer of its own. + + Args: + renderable: What a tool handed to :meth:`~meshterm.ui.surface.PlainUi.show`. + + Returns: + The unframed renderables to print, in order. + """ + if isinstance(renderable, Panel): + return flatten(renderable.renderable) + if isinstance(renderable, Table): + renderable.title = None + renderable.caption = None + renderable.box = None + renderable.show_edge = False + renderable.pad_edge = False + renderable.expand = False + renderable.padding = (0, GUTTER, 0, 0) + for column in renderable.columns: + column.no_wrap = True + column.overflow = "ignore" + column.ratio = None + return [renderable] + if isinstance(renderable, Group): + out: list[RenderableType] = [] + for child in renderable.renderables: + out.extend(flatten(child)) + return out + return [renderable] + + +def blank() -> Text: + """One empty line, for separating a listing from the block after it.""" + return Text("") diff --git a/meshterm/ui/surface.py b/meshterm/ui/surface.py index 7fe4b6f..5c68349 100644 --- a/meshterm/ui/surface.py +++ b/meshterm/ui/surface.py @@ -21,10 +21,7 @@ from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from typing import Any -from rich import box from rich.console import Console, Group, RenderableType -from rich.panel import Panel -from rich.table import Table from rich.text import Text from .tui.session import TuiSession @@ -94,6 +91,19 @@ class Ui: """Display a short line of Rich-markup text.""" raise NotImplementedError + def ack(self, markup: str) -> None: + """Acknowledge that an action was carried out — in the menu only. + + The distinction from :meth:`note` is who the line is for. A note is *output*: the + answer the caller asked for. An acknowledgement is reassurance that a thing + happened — "✓ device clock set", "✓ channel 2 = #general" — which a person watching + a screen needs and a script does not, because the exit status already said it and + the line would only be something to filter out of the real output. + + So the menu prints these and the CLI drops them (see :meth:`PlainUi.ack`). + """ + raise NotImplementedError + async def view( self, renderable: RenderableType, *, title: str = "", footer_hint: str = "" ) -> None: @@ -291,69 +301,14 @@ class Ui: raise NotImplementedError -def _heading(title: str | Text) -> Text: - """Render a Panel's or Table's hoisted title as an accent heading line. - - The ``accent`` theme style (bold indigo) is the one bit of emphasis that earns its - keep here: it marks where each block starts so a scripted run stays scannable, without - reaching for the interactive menu's framing. It must stay a bare theme name — Rich - can't combine the ``bold`` keyword with a named style in one string — but ``accent`` - already carries bold, so nothing is lost. - """ - text = title if isinstance(title, Text) else Text.from_markup(str(title)) - text.style = "accent" - return text - - -def _deframe(renderable: RenderableType) -> list[RenderableType]: - """Strip a renderable's chrome for direct terminal output. - - The scripted CLI prints straight to the terminal, with no result window to hold the - output, so a tool's heavy Rich framing — a :class:`Panel`'s border, a :class:`Table`'s - full box and centered title — is chrome between the reader and the data. This lightens - it to the middle ground: enough styling to stay legible, none of the interactive menu's - weight. A Panel becomes its title as a plain heading above its (also de-framed) body; a - Table keeps just a single rule under its header (:data:`~rich.box.SIMPLE_HEAD`) so the - columns still read, and hoists its title to the same plain heading; a :class:`Group` is - de-framed member by member. Everything else passes through untouched. - - The tables and panels are built fresh for each scripted run and discarded after - printing, so mutating them in place (clearing ``box``/``title``) is safe here. - - Args: - renderable: A renderable a tool handed to :meth:`PlainUi.show` or :meth:`view`. - - Returns: - The frame-light renderables to print, in order. - """ - if isinstance(renderable, Panel): - out: list[RenderableType] = [] - if renderable.title: - out.append(_heading(renderable.title)) - out.extend(_deframe(renderable.renderable)) - return out - if isinstance(renderable, Table): - out = [] - if renderable.title: - out.append(_heading(renderable.title)) - renderable.title = None - renderable.box = box.SIMPLE_HEAD - out.append(renderable) - return out - if isinstance(renderable, Group): - out = [] - for child in renderable.renderables: - out.extend(_deframe(child)) - return out - return [renderable] - - class PlainUi(Ui): - """CLI surface: print directly to the console; interactive prompts are unsupported. + """CLI surface: print straight to the scripted console; interactive prompts are unsupported. - Output is de-framed on the way out (see :func:`_deframe`): a scripted run prints - to the bare terminal, so a tool's Panels and boxed Tables are lightened to plain - headings and header-ruled columns — legible and easy to scan, without the framing. + The console it prints on emits no colour, wraps nothing, and trims trailing whitespace + (:func:`meshterm.ui.script.console`), and everything a tool hands over passes through + :func:`~meshterm.ui.script.flatten` on the way out, so no border, box or hoisted title + reaches stdout. That fold is the net, not the design: a surface a script is meant to + read is *written* in the CLI's own vocabulary — see :mod:`meshterm.ui.script`. """ def __init__(self, console: Console) -> None: @@ -365,27 +320,44 @@ class PlainUi(Ui): self.console = console def show(self, *renderables: RenderableType) -> None: - """Print each renderable to the console immediately, de-framed (see :func:`_deframe`).""" + """Print each renderable immediately, unframed (see :func:`~meshterm.ui.script.flatten`).""" + from . import script + for renderable in renderables: - for item in _deframe(renderable): + for item in script.flatten(renderable): self.console.print(item) def note(self, markup: str) -> None: """Print a markup line to the console immediately.""" self.console.print(markup) + def ack(self, markup: str) -> None: + """Drop it: on the command line the exit status is the acknowledgement.""" + async def view( self, renderable: RenderableType, *, title: str = "", footer_hint: str = "" ) -> None: - """Print the renderable immediately, de-framed (there is no windowing in CLI mode).""" - for item in _deframe(renderable): + """Print the renderable immediately, unframed (there is no windowing in CLI mode).""" + from . import script + + for item in script.flatten(renderable): self.console.print(item) def progress(self, title: str = "Working"): # noqa: ANN201 - """Return the Rich progress bar used for scripted runs.""" + """Return a progress bar drawn on stderr, so it never lands in piped output. + + A progress bar is a courtesy to someone watching a long command run, and noise in + anything reading the command's output. stderr is where it belongs: visible in a + terminal, absent from ``meshterm contacts > contacts.txt``. Rich also draws + nothing at all when stderr is not a terminal, so a fully redirected run is silent. + """ + from . import script from .widgets import make_progress - return make_progress(self.console) + console = script.stderr_console() + progress = make_progress(console) + progress.disable = not console.is_terminal + return progress @asynccontextmanager async def busy_overlay(self, message: str = "", *, title: str = "") -> AsyncIterator[None]: @@ -594,6 +566,10 @@ class TuiUi(Ui): """Collect a markup line for the next result window.""" self._buffer.append(Text.from_markup(markup)) + def ack(self, markup: str) -> None: + """Collect an acknowledgement — on this surface, exactly a note.""" + self.note(markup) + async def present(self, *, title: str = "") -> None: """Show everything buffered since the last present; window or popup to fit. diff --git a/meshterm/ui/widgets.py b/meshterm/ui/widgets.py index 0d4058a..549771d 100644 --- a/meshterm/ui/widgets.py +++ b/meshterm/ui/widgets.py @@ -1061,7 +1061,7 @@ class ContactsSort: self.ascending = self.opens_ascending[self.column] -def _ordered_contacts( +def ordered_contacts( contacts: list[Contact], counts: dict[str, int], sort: ContactsSort ) -> list[Contact]: """Contacts sorted per ``sort`` (our own node is pinned separately, above these). @@ -1294,7 +1294,7 @@ def contacts_table( highlighted_hash(self_key, prefix_bytes) if self_key else unknown, ) table.add_section() - for c in _ordered_contacts(contacts, counts, sort): + for c in ordered_contacts(contacts, counts, sort): secs = _age_seconds(c.last_seen) glyph, glyph_style = _NODE_GLYPHS.get(c.node_type, _DEFAULT_GLYPH) pkts = _contact_pkts(c, counts) diff --git a/tests/test_advert.py b/tests/test_advert.py index 83af39b..71ae685 100644 --- a/tests/test_advert.py +++ b/tests/test_advert.py @@ -131,6 +131,10 @@ class _NoteUi: def note(self, markup: str) -> None: self.notes.append(markup) + def ack(self, markup: str) -> None: + # The menu's answer: an acknowledgement is a note. (The scripted CLI drops it.) + self.note(markup) + async def test_scheduler_first_pass_arms_without_sending(ctx: AppContext) -> None: """The first look at a fresh device starts the countdown; nothing is transmitted.""" diff --git a/tests/test_channels.py b/tests/test_channels.py index 7c26062..0d4bdf0 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -372,6 +372,9 @@ class _ScriptedUi: def note(self, markup: str) -> None: pass + def ack(self, markup: str) -> None: + pass + async def view(self, renderable, *, title: str = "", footer_hint: str = "") -> None: # noqa: ANN001 pass diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py new file mode 100644 index 0000000..d9e489d --- /dev/null +++ b/tests/test_cli_contract.py @@ -0,0 +1,205 @@ +"""End-to-end tests for what the CLI actually prints and returns. + +:mod:`tests.test_script_output` covers the vocabulary; this covers the commands built out +of it — the real Typer app, driven against the simulator, asserting the four things a +script depends on: no escape sequences, no wrapped records, a header its records line up +under, and a documented exit status. + +Every command here is read-only or simulator-backed, so nothing transmits. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from meshterm.core import exitcodes + +#: Any ANSI escape sequence. Nothing on the CLI's stdout may match this. +_ANSI = re.compile(r"\x1b\[") + +#: The shape of a scripted path line: one or more quoted node names, each optionally +#: followed by its hash in parentheses *outside* the quotes, joined by a bare comma. This +#: is the whole grammar — a line matching it splits, whatever the names hold. +_HOP = r'"(?:[^"\\]|\\.)*"(?: \([^)]*\))?' +_PATH_LINE = re.compile(f"{_HOP}(?:,{_HOP})*") + +#: The commands whose output is a listing or a key/value block, and the fields a caller +#: should find in each. Every one runs against the mock companion or the database alone. +_LISTINGS: list[tuple[str, list[str], list[str]]] = [ + # (command, arguments, headings or keys the output must carry) + ("contacts", [], ["NAME", "TYPE", "HEARD", "PKTS", "KEY"]), + ("info", [], ["name", "public_key", "role"]), + ("config", ["show"], ["name", "radio_freq", "tx_power"]), + ("preferences", ["show"], ["PREFERENCE", "VALUE", "DEFAULT"]), + ("chat", ["list"], ["CONVERSATION", "KIND", "UNREAD", "LAST_TIME", "LAST_TEXT"]), + ("platform", [], ["platform", "icons"]), +] + + +@pytest.fixture() +def run(tmp_path: Path): # noqa: ANN201 - a closure over the runner + """Invoke the real CLI against the simulator and a scratch database.""" + from meshterm.cli import app + + runner = CliRunner() + + def invoke(*args: str): # noqa: ANN202 + return runner.invoke(app, ["--mock", "--db", str(tmp_path / "test.db"), *args]) + + return invoke + + +# -- the four rules ------------------------------------------------------------------- + + +@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS]) +def test_a_command_prints_no_escape_sequence(run, command, args, fields) -> None: # noqa: ANN001 + """Nothing is coloured: stdout is a pipe more often than a terminal.""" + result = run(command, *args) + assert result.exit_code == exitcodes.OK, result.output + assert not _ANSI.search(result.output) + + +@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS]) +def test_a_command_carries_the_fields_a_caller_looks_for(run, command, args, fields) -> None: # noqa: ANN001 + """The headings and keys are the contract; renaming one silently breaks a script.""" + result = run(command, *args) + for field in fields: + assert field in result.output, f"{command} lost {field}" + + +@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS]) +def test_a_command_draws_no_frame_and_no_rule(run, command, args, fields) -> None: # noqa: ANN001 + """No borders, no boxes, no header rules — the command typed is the title.""" + result = run(command, *args) + assert not set(result.output) & set("─│┌┐└┘├┤━┃") + + +@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS]) +def test_a_command_leaves_no_trailing_whitespace(run, command, args, fields) -> None: # noqa: ANN001 + """Column padding must not become invisible spaces at the end of every record.""" + for line in result_lines(run(command, *args)): + assert line == line.rstrip(), repr(line) + + +def result_lines(result) -> list[str]: # noqa: ANN001 + """The command's stdout as lines, with the trailing blank dropped.""" + return result.output.splitlines() + + +# -- listings ------------------------------------------------------------------------- + + +def test_contacts_quotes_every_name_and_stamps_every_time(run) -> None: # noqa: ANN001 + """A record is quoted where a name could hold a space, and timed absolutely.""" + header, *records = result_lines(run("contacts")) + assert header.split() == ["NAME", "TYPE", "HEARD", "PKTS", "KEY"] + assert records, "the simulator always has contacts" + for line in records: + assert line.startswith('"') + stamp = line.split()[-3] + assert stamp == "-" or stamp[:4].isdigit() + + +def test_contacts_never_elides_a_key(run) -> None: # noqa: ANN001 + """A truncated key is not something a caller can hand back to ``--to`` or ``--path``.""" + for line in result_lines(run("contacts"))[1:]: + assert "…" not in line and not line.endswith("...") + + +def test_a_config_line_can_be_typed_back_into_config_set(run) -> None: # noqa: ANN001 + """``show`` names each setting by the key ``get``/``set`` take, and prints its value.""" + values = dict(line.split(None, 1) for line in result_lines(run("config", "show")) if line) + assert values["name"] == "MockCompanion" + # An enum prints its number, not the reader's label: `parse_value` takes the number. + assert values["adv_loc_policy"].isdigit() + assert run("config", "get", "name").output.strip() == "MockCompanion" + + +def test_config_get_prints_the_bare_value(run) -> None: # noqa: ANN001 + """The caller named the key; repeating it back is one more thing to strip off.""" + assert run("config", "get", "tx_power").output.strip() == "20" + + +def test_preferences_get_prints_a_value_its_own_set_would_take(run) -> None: # noqa: ANN001 + """The page's ``5 s`` carries a unit that ``preferences set`` will not accept back.""" + assert run("preferences", "get", "trace_cooldown_s").output.strip() == "5" + + +# -- path lines ----------------------------------------------------------------------- + + +def test_a_trace_prints_its_route_as_the_scripted_path_line(run) -> None: # noqa: ANN001 + """Quoted names, hashes outside the quotes, comma-separated, no star for our own node.""" + result = run("trace", "--target", "Alice") + assert result.exit_code == exitcodes.OK, result.output + facts = dict(line.split(None, 1) for line in result_lines(result) if line and " " in line) + assert facts["success"] == "yes" + route = facts["route"] + assert "★" not in route and "→" not in route + assert _PATH_LINE.fullmatch(route), route + + +def test_a_trace_reports_its_per_hop_readings_under_a_header(run) -> None: # noqa: ANN001 + """The second block is a record per hop, joined to the route line by hash.""" + lines = result_lines(run("trace", "--target", "Alice")) + header = next(line for line in lines if line.startswith("HOP")) + assert header.split() == ["HOP", "FROM", "TO", "SNR_DB"] + + +# -- exit statuses -------------------------------------------------------------------- + + +def test_an_empty_result_is_its_own_status_and_prints_nothing(run) -> None: # noqa: ANN001 + """The simulator configures no channel slots: the command worked and found nothing. + + ``grep`` conflates "found nothing" with "worked"; this is the whole reason + :data:`~meshterm.core.exitcodes.NO_RESULT` exists. + """ + result = run("channels", "list") + assert result.exit_code == exitcodes.NO_RESULT + assert result.output.strip() == "" + + +def test_a_bad_flag_is_a_usage_error(run) -> None: # noqa: ANN001 + """Click's own status, named in the table so it is complete.""" + assert run("contacts", "--no-such-flag").exit_code == exitcodes.USAGE + + +def test_a_bad_setting_key_is_a_plain_failure(run) -> None: # noqa: ANN001 + """Retrying will not help: the caller has to change what it asked for.""" + result = run("config", "get", "nosuchkey") + assert result.exit_code == exitcodes.FAILURE + + +def test_a_failure_says_so_on_stderr_in_the_utility_shape(run) -> None: # noqa: ANN001 + """``program: what went wrong`` — so a caller redirecting stdout still sees it.""" + runner = CliRunner() + from meshterm.cli import app + + result = runner.invoke(app, ["--mock", "config", "get", "nosuchkey"]) + assert "meshterm: unknown setting" in result.output + + +# -- the map -------------------------------------------------------------------------- + + +def test_the_map_has_no_cli_command_at_all(run) -> None: # noqa: ANN001 + """A picture has no scripted face; the located nodes stay in ``contacts``.""" + assert run("map").exit_code == exitcodes.USAGE + + +# -- help ----------------------------------------------------------------------------- + + +def test_help_is_plain_and_names_every_exit_status(run) -> None: # noqa: ANN001 + """Click's own help: no boxed panels, no colour, and the status table under it.""" + result = run("--help") + assert not _ANSI.search(result.output) + assert not set(result.output) & set("─│╭╮╰╯") + for code in exitcodes.MEANINGS: + assert f"{code} " in result.output diff --git a/tests/test_config_editor.py b/tests/test_config_editor.py index 2380e80..e33dbbd 100644 --- a/tests/test_config_editor.py +++ b/tests/test_config_editor.py @@ -687,6 +687,10 @@ class _ScriptedUi: def note(self, markup: str) -> None: self.notes.append(markup) + def ack(self, markup: str) -> None: + # The menu's answer: an acknowledgement is a note. (The scripted CLI drops it.) + self.note(markup) + def show(self, *renderables: Any) -> None: pass diff --git a/tests/test_courier.py b/tests/test_courier.py index 942a265..e2037a9 100644 --- a/tests/test_courier.py +++ b/tests/test_courier.py @@ -229,6 +229,10 @@ class _NoteUi: def note(self, markup: str) -> None: self.notes.append(markup) + def ack(self, markup: str) -> None: + # The menu's answer: an acknowledgement is a note. (The scripted CLI drops it.) + self.note(markup) + def show(self, *renderables: object) -> None: self.shown.extend(renderables) diff --git a/tests/test_device_config.py b/tests/test_device_config.py index 600ce05..718d881 100644 --- a/tests/test_device_config.py +++ b/tests/test_device_config.py @@ -198,7 +198,7 @@ def test_contacts_table_breaks_metric_ties_by_name_ascending() -> None: from datetime import timedelta from meshterm.core.models import Contact, utcnow - from meshterm.ui.widgets import ContactsSort, _ordered_contacts + from meshterm.ui.widgets import ContactsSort, ordered_contacts same = utcnow() - timedelta(minutes=5) contacts = [ @@ -209,7 +209,7 @@ def test_contacts_table_breaks_metric_ties_by_name_ascending() -> None: def order(ascending: bool) -> list[str]: sort = ContactsSort(column="heard", ascending=ascending) - return [c.name for c in _ordered_contacts(contacts, {}, sort)] + return [c.name for c in ordered_contacts(contacts, {}, sort)] # Freshest first: the two 5-min contacts lead, ordered Alice→Charlie, then Bob (3h). assert order(ascending=True) == ["Alice", "Charlie", "Bob"] @@ -219,23 +219,25 @@ def test_contacts_table_breaks_metric_ties_by_name_ascending() -> None: async def test_contacts_tool_opens_sorted_by_heard(monkeypatch: pytest.MonkeyPatch) -> None: """The Contacts list opens most-recently-heard first — who's out there now, not a roll call.""" + from meshterm.core.models import Contact from meshterm.tools.contacts import ContactsTool from meshterm.ui import widgets captured: dict = {} + real_order = widgets.ordered_contacts - def fake_table(self_name, self_key, contacts, prefix_bytes, counts, sort): + def fake_order(contacts, counts, sort): # noqa: ANN001, ANN202 captured["sort"] = sort - return "" + return real_order(contacts, counts, sort) - monkeypatch.setattr(widgets, "contacts_table", fake_table) + monkeypatch.setattr(widgets, "ordered_contacts", fake_order) class _Devstate: async def self_info(self) -> dict: return {"name": "Us", "public_key": "aa" * 32} async def contacts(self) -> list: - return [] + return [Contact(name="Alice", public_key="d4" * 32)] async def path_hash_mode(self) -> int: return 0 diff --git a/tests/test_map.py b/tests/test_map.py index b6a543e..68b6948 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -1178,36 +1178,6 @@ def ctx(tmp_path: Path): context.repo.close() -async def test_map_tool_static_render_plots_contacts(ctx) -> None: - """The CLI path plots the device's located contacts and reports the breakdown. - - The mock companion's contacts carry two located repeaters and one located leaf node - (a fourth, Alice, has no fix), so the contact list — not the observation history — is - what fills the map. - """ - # Seed an observation whose key matches a located contact, so its reception detail is - # merged onto that contact's marker. - run_id = ctx.repo.start_run("monitor", {}) - ctx.repo.record_observation( - run_id, - Observation( - node="a1b2c3d4", - name="Yagi-Repeater", - node_type=NODE_TYPE_REPEATER, - snr=6.0, - observed_at=utcnow(), - ), - ) - result = await MapTool().run(ctx, {"static": True, "basemap": False}) - assert result.summary["located"] == 3 - assert result.summary["repeaters"] == 2 - assert result.summary["nodes"] == 1 - assert result.summary["self_located"] is False - out = ctx.console.file.getvalue() - assert out != "" - assert "1 pkts" in out # the seeded observation's detail merged onto the contact - - def test_usable_fix_rejects_out_of_range_and_null_island() -> None: """A fix is usable only in valid lat/lon range and away from the 0/0 no-GPS sentinel.""" from meshterm.core.geo import usable_fix @@ -2565,41 +2535,16 @@ def test_each_road_class_keeps_its_own_colour_through_the_per_tile_style_memo() assert mark_rgb(expected[0]) in painted, f"{cls} lost its own colour" -def _run_map_cli(monkeypatch, *args: str): - """Invoke the ``map`` subcommand with the tool itself stubbed out. +def test_the_map_registers_no_cli_command() -> None: + """The map is menu-only: a picture has no scripted face. - Returns the params dict the command would have handed :func:`run_tool_command`, - or ``None`` if the invocation failed before getting there. + Its answer is a drawing whose nodes are told apart by colour, which the scripted CLI + does not have (see :mod:`meshterm.ui.script`) — so the tool registers nothing rather + than printing an unparseable block of braille. The located nodes stay scriptable + through ``meshterm contacts``. """ import typer - from typer.testing import CliRunner - from meshterm import cli - - captured: dict = {} - monkeypatch.setattr(cli, "run_tool_command", lambda tool, params: captured.update(params)) app = typer.Typer() MapTool().register_cli(app) - result = CliRunner().invoke(app, list(args)) - return result, (captured or None) - - -def test_map_cli_fraction_defaults_to_the_preference(monkeypatch) -> None: - """No ``--fraction`` means *ask the preferences*, which is a ``None`` to pass on. - - The bounds check used to compare that ``None`` against 0.0, so the plain - ``meshterm map`` — the one invocation nobody passes a flag to — was the one that - raised. - """ - result, params = _run_map_cli(monkeypatch) - assert result.exit_code == 0, result.output - assert params is not None and params["fraction"] is None - - -@pytest.mark.parametrize("value,ok", [("0", False), ("0.5", True), ("1.0", True)]) -def test_map_cli_fraction_bounds(monkeypatch, value: str, ok: bool) -> None: - """A fraction of zero frames nothing, so it is refused; anything up to 1.0 stands.""" - result, params = _run_map_cli(monkeypatch, "--fraction", value) - assert (result.exit_code == 0) is ok, result.output - if ok: - assert params is not None and params["fraction"] == float(value) + assert app.registered_commands == [] diff --git a/tests/test_preferences.py b/tests/test_preferences.py index e696f3e..ae608fc 100644 --- a/tests/test_preferences.py +++ b/tests/test_preferences.py @@ -414,6 +414,9 @@ class _ScriptedUi: def note(self, markup: str) -> None: pass + def ack(self, markup: str) -> None: + pass + @pytest.fixture() def ctx(tmp_path: Path) -> AppContext: diff --git a/tests/test_script_output.py b/tests/test_script_output.py new file mode 100644 index 0000000..d913ddd --- /dev/null +++ b/tests/test_script_output.py @@ -0,0 +1,274 @@ +"""Tests for the scripted CLI's output language (:mod:`meshterm.ui.script`). + +The CLI is read by ``awk``, and every rule that makes that possible is testable: no +colour, no wrapping, no framing, quoted names, absolute times, one token for absent, one +documented exit status per outcome. These are the enforcement point for +:mod:`meshterm.ui.script` the way ``test_gallery`` is for the screens. +""" + +from __future__ import annotations + +import io +import re +from datetime import datetime, timedelta, timezone + +import pytest +from rich.console import Group +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from meshterm.core import exitcodes +from meshterm.ui import script + +#: Any ANSI escape sequence. Nothing the scripted console emits may match this. +_ANSI = re.compile(r"\x1b\[") + + +def _rendered(*renderables: object) -> str: + """Print ``renderables`` through the scripted console and return what came out.""" + buffer = io.StringIO() + console = script.console(buffer) + for renderable in renderables: + console.print(renderable) + console.file.flush() + return buffer.getvalue() + + +# -- quoting ------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "value,expected", + [ + ("Yagi-Repeater", '"Yagi-Repeater"'), + ("YUL Cartierville", '"YUL Cartierville"'), + ("Node, Inc", '"Node, Inc"'), + ('He said "hi"', '"He said \\"hi\\""'), + ("back\\slash", '"back\\\\slash"'), + ("", '""'), + (None, '""'), + ], +) +def test_quote_makes_any_name_exactly_one_field(value: str | None, expected: str) -> None: + """A name is quoted and its quotes and backslashes escaped, whatever it holds. + + This is what lets a listing be split: an unquoted ``YUL Cartierville`` is two fields, + and an unquoted ``Node, Inc`` is two hops of a path line. + """ + assert script.quote(value) == expected + + +def test_a_quoted_name_survives_a_round_trip_through_the_escaping() -> None: + """The escaping is the ordinary backslash convention, so the value reads back.""" + for name in ('a "quoted" name', "back\\slash", 'both\\"kinds'): + body = script.quote(name)[1:-1] + assert body.replace('\\"', '"').replace("\\\\", "\\") == name + + +# -- timestamps ---------------------------------------------------------------------- + + +def test_stamp_is_absolute_local_iso_to_the_second() -> None: + """A time is printed as something a script can sort and subtract.""" + when = datetime(2026, 9, 7, 22, 22, 41, tzinfo=timezone.utc) + text = script.stamp(when) + assert datetime.fromisoformat(text) == when + assert text.endswith(when.astimezone().strftime("%z")[:3] + ":00") + + +def test_stamp_reads_an_unknown_offset_as_absent() -> None: + """A naive datetime is a time whose offset we don't know, which is not a fact. + + The same rule :func:`~meshterm.ui.widgets._age_seconds` applies to relative ages. + """ + assert script.stamp(None) == script.NONE + assert script.stamp(datetime(2026, 9, 7, 22, 22, 41)) == script.NONE + + +def test_no_relative_age_ever_reaches_the_scripted_output() -> None: + """``3h`` is for a screen; the CLI's stamp is absolute even for a fresh time.""" + assert script.stamp(datetime.now(timezone.utc) - timedelta(seconds=5)) != "now" + + +# -- the absent token ----------------------------------------------------------------- + + +def test_one_token_stands_for_every_kind_of_absence() -> None: + """A reader (and a parser) learns ``-`` once, and never meets an em dash or an n/a.""" + assert script.NONE == "-" + assert script.number(None) == script.NONE + assert script.path([]) == script.NONE + + +def test_number_formats_with_its_spec_when_there_is_one() -> None: + """A present number is formatted; only an absent one becomes the token.""" + assert script.number(2.64, "+.1f") == "+2.6" + assert script.number(-1.5, "+.1f") == "-1.5" + assert script.number(7) == "7" + + +# -- path lines ----------------------------------------------------------------------- + + +def test_a_path_line_quotes_each_node_and_hangs_its_hash_outside_the_quotes() -> None: + """The CLI's path shape: ``"Name" (hash)``, hops joined by a bare comma.""" + line = script.path([("Alice", "3d"), ("Yagi-Repeater", "f2")]) + assert line == '"Alice" (3d),"Yagi-Repeater" (f2)' + + +def test_a_path_line_names_our_own_node_like_any_other() -> None: + """No star: the glyph says "you already know this one", which is false of a parser.""" + line = script.path([("MockCompanion", "a1"), ("Alice", "3d"), ("MockCompanion", "a1")]) + assert "★" not in line + assert line.startswith('"MockCompanion" (a1)') + assert line.endswith('"MockCompanion" (a1)') + + +def test_a_path_line_prints_a_name_alone_when_no_hash_places_it() -> None: + """An unidentified node has a name and nothing else; it does not get an empty ``()``.""" + assert script.path([("Alice", None)]) == '"Alice"' + + +def test_a_path_line_splits_even_when_a_name_holds_a_comma() -> None: + """The quoting is what makes the bare comma separator safe.""" + line = script.path([("Node, Inc", "3d"), ("Bob", "f2")]) + assert line.count(",") == 2 # the one inside the name, and the one separating hops + assert line == '"Node, Inc" (3d),"Bob" (f2)' + + +# -- the console ---------------------------------------------------------------------- + + +def test_the_scripted_console_emits_no_escape_sequence_at_all() -> None: + """Not a colour, not a bold, not a dim — ``stdout`` is a pipe more often than a screen.""" + loud = Text.assemble( + ("brand", "brand"), ("bold", "bold"), ("hex", "#ff8800"), ("reverse", "reverse") + ) + assert not _ANSI.search(_rendered(loud)) + + +def test_the_scripted_console_never_wraps_a_record_onto_a_second_line() -> None: + """A record is a line: wrapping would put half its fields under the wrong headings.""" + table = script.columns("KEY", "VALUE") + table.add_row("basemap_tilejson_url", "x" * 400) + lines = _rendered(table).splitlines() + assert len(lines) == 2 # the header and the one record + assert lines[1].endswith("x" * 400) + + +def test_the_scripted_console_leaves_no_trailing_whitespace() -> None: + """Padding the last column to its width would put invisible spaces on every line.""" + table = script.columns("NAME", "TYPE") + table.add_row(script.quote("a-long-name"), "node") + table.add_row(script.quote("b"), "repeater") + for line in _rendered(table).splitlines(): + assert line == line.rstrip(), repr(line) + + +def test_the_gutter_still_separates_columns_after_the_trimming() -> None: + """The trimmer holds trailing whitespace back; it must not eat the padding *between*. + + Rich writes a rendered row segment by segment and flushes after each one, so a + wrapper that trimmed at flush would throw away the very spaces that make the columns + columns (see :class:`meshterm.ui.script._Trimmed`). + """ + table = script.columns("NAME", "TYPE", "PKTS", right=("PKTS",)) + table.add_row(script.quote("Alice"), "node", "12") + header, record = _rendered(table).splitlines() + assert header.split() == ["NAME", "TYPE", "PKTS"] + assert record.split() == ['"Alice"', "node", "12"] + assert " " * script.GUTTER in record + + +def test_columns_align_so_every_record_splits_the_same_way() -> None: + """Every field lands in its heading's column, whatever the widths.""" + table = script.columns("NAME", "TYPE") + table.add_row(script.quote("Yagi-Repeater"), "repeater") + table.add_row(script.quote("Al"), "node") + header, *records = _rendered(table).splitlines() + starts = {line.index("re") if "repeater" in line else line.index("node") for line in records} + assert starts == {header.index("TYPE")} # every TYPE value opens under its heading + + +# -- framing -------------------------------------------------------------------------- + + +def test_a_panel_gives_up_its_border_and_its_title() -> None: + """No frames: the command the reader typed is the title.""" + out = _rendered(*script.flatten(Panel(Text("body"), title="Trace summary"))) + assert out.strip() == "body" + + +def test_a_table_gives_up_its_box_its_title_and_its_expansion() -> None: + """A shared table that still arrives boxed is unframed on the way out.""" + from rich import box + + table = Table(title="Contacts", box=box.SIMPLE_HEAD, expand=True) + table.add_column("NAME") + table.add_row("Alice") + out = _rendered(*script.flatten(table)) + assert "Contacts" not in out + assert not any(ch in out for ch in "─│┌└") + + +def test_a_group_is_unframed_member_by_member() -> None: + """Framing is stripped however deep a shared renderable nests it.""" + nested = Group(Panel(Text("one"), title="First"), Panel(Text("two"), title="Second")) + assert _rendered(*script.flatten(nested)).split() == ["one", "two"] + + +def test_no_rendered_line_is_a_box_drawing_rule() -> None: + """No borders and no header rules — a listing is its header line and its records.""" + table = script.columns("NAME", "TYPE") + table.add_row(script.quote("Alice"), "node") + assert "─" not in _rendered(table) + + +# -- key/value listings --------------------------------------------------------------- + + +def test_pairs_print_one_fact_per_line_with_no_header() -> None: + """The ``sysctl -a`` shape: a key, the gutter, and the rest of the line as its value.""" + out = _rendered(script.pairs([("name", "MockCompanion"), ("uptime_s", "93784")])) + assert [line.split(None, 1) for line in out.splitlines()] == [ + ["name", "MockCompanion"], + ["uptime_s", "93784"], + ] + + +def test_a_pairs_value_is_unquoted_because_it_is_the_rest_of_the_line() -> None: + """Quoting is for a name sharing a line with other fields; here there are only two.""" + out = _rendered(script.pairs([("name", "YUL Cartierville")])) + assert out.splitlines()[0].split(None, 1) == ["name", "YUL Cartierville"] + + +# -- exit statuses -------------------------------------------------------------------- + + +def test_every_exit_code_is_documented() -> None: + """A documented return value is half of what makes the CLI scriptable.""" + codes = { + exitcodes.OK, + exitcodes.FAILURE, + exitcodes.USAGE, + exitcodes.NO_DEVICE, + exitcodes.DEVICE, + exitcodes.NO_RESULT, + } + assert set(exitcodes.MEANINGS) == codes + assert all(meaning for meaning in exitcodes.MEANINGS.values()) + + +def test_the_codes_are_distinct_and_ok_is_zero() -> None: + """The shell's own convention, and no two outcomes share a status.""" + assert exitcodes.OK == 0 + assert len(set(exitcodes.MEANINGS)) == len(exitcodes.MEANINGS) + + +def test_the_help_epilog_names_every_status() -> None: + """A caller should not have to find the table in a README.""" + from meshterm.cli import EXIT_STATUS_EPILOG + + for code, meaning in exitcodes.MEANINGS.items(): + assert f"{code} {meaning}" in EXIT_STATUS_EPILOG