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) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-08 00:52:48 -04:00
1 parent 2790159d04
commit da05dd402f
34 files changed
+2233 -670

No files matched your search

+68
View File
@@ -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
+43
View File
@@ -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
+45 -3
View File
@@ -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.
+114 -77
View File
@@ -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
+73
View File
@@ -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)",
}
+5 -1
View File
@@ -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})
+29 -5
View File
@@ -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)
+23 -20
View File
@@ -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:
+84 -44
View File
@@ -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
+103 -31
View File
@@ -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]:
+55 -8
View File
@@ -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
+33 -36
View File
@@ -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"
+63 -50
View File
@@ -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 <PORT> or --ble <ADDRESS> 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
+115 -22
View File
@@ -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:
+24 -134
View File
@@ -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:
+50 -34
View File
@@ -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
+74 -9
View File
@@ -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
+85 -25
View File
@@ -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)
+13 -5
View File
@@ -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.
+123 -19
View File
@@ -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(
+60 -6
View File
@@ -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)
+26 -1
View File
@@ -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
+365
View File
@@ -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("")
+46 -70
View File
@@ -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.
+2 -2
View File
@@ -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)
+4
View File
@@ -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."""
+3
View File
@@ -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
+205
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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)
+8 -6
View File
@@ -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
+7 -62
View File
@@ -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 == []
+3
View File
@@ -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:
+274
View File
@@ -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