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:
1 parent
2790159d04
commit
da05dd402f
34 files changed
+2233
-670
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)",
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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 == []
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user