Compare commits
No files matched your search
+180
-2
@@ -15,7 +15,184 @@ allowed to change behaviour, not just add to it.
|
||||
getting ready. Keep the dates and the tags; replace the prose.
|
||||
-->
|
||||
|
||||
## [Unreleased]
|
||||
## [0.3.0] — 2026-09-08
|
||||
|
||||
The command line was rebuilt around the fact that it has **two readers**, and that trying
|
||||
to serve both with one stream had been making it worse at each.
|
||||
|
||||
### Added
|
||||
|
||||
- **`--json`, on every command.** It used to be honoured by two of twenty and silently
|
||||
ignored by the rest, so `meshterm --json contacts | jq` failed while MeshTerm reported
|
||||
success. It now covers all 49 subcommands: an array for a listing, an object for a set of
|
||||
facts, one compact line per document, and no envelope — `contacts --json | jq '.[].node.name'`
|
||||
reads the way it looks.
|
||||
|
||||
Values are typed (`20`, not `"20"`), absent is `null` and never an omitted key,
|
||||
timestamps are UTC RFC 3339 to the second so string comparison is time comparison, and a
|
||||
numeric field carries its unit in its key (`snr_db`, `uptime_s`, `rtt_ms`). A key is never
|
||||
truncated — a short id is a `hash`, because a truncated key cannot go back into `--to`.
|
||||
`monitor` and `chat listen` emit one document per record as they arrive, all the same
|
||||
shape.
|
||||
|
||||
`--json` changes the rendering and never the report: the same exit status, the same
|
||||
records. An empty result prints its empty document **and still exits `5`**.
|
||||
|
||||
- **A documented exit status per outcome**, listed under every `--help`: `0` success, `1`
|
||||
failure, `2` usage error, `3` no device, `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 — returns it, so a script can tell "found
|
||||
nothing" from "worked" without counting lines.
|
||||
|
||||
- **`--absolute`**, restoring ISO-8601 timestamps for a run, over a new `cli_time_format`
|
||||
preference. JSON is always absolute UTC regardless: a local offset is a fact about the
|
||||
machine that ran the command, not about the event.
|
||||
|
||||
- **A global option may be typed anywhere.** `meshterm contacts --json` and
|
||||
`meshterm --json contacts` are the same run. The first is what everyone types and it used
|
||||
to fail with a bare usage error.
|
||||
|
||||
- **`contacts` gains `HASH` and `LOCATION`.** The hash is the token `--path` takes; you used
|
||||
to slice it out of `KEY` by hand. `preferences show` gains `DESCRIPTION` back, and `info`
|
||||
glosses its second-valued readings (`uptime_s 93784 (1d 2h)`).
|
||||
|
||||
- **[`docs/cli.md`](docs/cli.md)**, a manual for the whole thing: both faces of every
|
||||
command, where MeshTerm keeps its state, what each failure looks like, and recipes. Every
|
||||
sample in it is captured from a real run rather than written.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The plain face is for a person now, and says so.** It had grown the menu's manners —
|
||||
colour, boxes, section headings, glyph columns, a closing ✓ — and then briefly overcorrected
|
||||
into something only `awk` could love. Machine-readability is `--json`'s job, so the plain
|
||||
face is free to be read: no colour and no frames, still, but **alignment is the delimiter**
|
||||
and names are bare rather than `"quoted"`.
|
||||
|
||||
- **A time is an age** — `now`, `5m`, `3h`, `never` — because "recently?" is the question you
|
||||
typed the command to ask. An absolute instant survives where the instant *is* the fact: the
|
||||
device clock, an appointment, a live capture's own `TIME`.
|
||||
|
||||
- **A route is drawn and a path is typed.** A route reads
|
||||
`Yagi-Repeater (a1) → Alice (d4) → MockCompanion (00)`; a `--path` spec stays the
|
||||
comma-joined hex it has to be, because it is the one line that round-trips. Our own node is
|
||||
a hop like any other — the menu's `★` says "you already know who this is", which is true of
|
||||
the reader and false of whoever opens the file later.
|
||||
|
||||
- **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.
|
||||
|
||||
- **`ui.ack` and a tool's closing message go to stderr** rather than being dropped. stderr
|
||||
keeps the promise the dropping was made to keep — a redirect catches only the answer —
|
||||
while giving the person at the prompt back their ✓ and their count.
|
||||
|
||||
- **`--help` is Click's own plain help**, with no boxed panels and no markup to strip out of
|
||||
a pipe.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A node name could not be printed safely.** A node broadcasts its own name, and Rich read
|
||||
`[...]` in one as console markup: a node called `[bold]Loud` printed as `Loud` — silently
|
||||
no longer the string that identifies it — and one called `[/]Bob` raised `MarkupError` and
|
||||
took the command down, then took the error handler down with it while reporting the crash.
|
||||
|
||||
- **A control character in a name reached stdout.** Escaping covered the newline, the tab and
|
||||
the return and let **ESC** through, which put a live colour run into the caller's file — the
|
||||
one thing "no escape sequences" exists to prevent. BEL was dropped in silence, so the
|
||||
printed name was not the advertised one, and U+2028 ended the record like a newline. It is
|
||||
now every C0 and C1 control plus the Unicode separators.
|
||||
|
||||
- **Numeric columns never right-aligned.** Rich's `Text.wrap` returns early on
|
||||
`overflow="ignore"`, above the justify step, so asking for one threw the other away — while
|
||||
the standard, the code's own docstring and the manual all promised alignment.
|
||||
|
||||
- **`config show` printed an empty string as `""`**, which `config set` read as those two
|
||||
characters. Feeding a dump back replaced every empty setting with a pair of quote marks, and
|
||||
the next dump looked identical, so nothing ever said so. It also masked an **unreported**
|
||||
PIN behind bullets, telling the reader the radio was PIN-locked when it was not.
|
||||
|
||||
- **`meshterm about` drew the rule above its colophon at console width** — one 48KB line in a
|
||||
4KB page.
|
||||
|
||||
- **`config export-key > key.hex`** wrote a file with `[muted]` tags around the key that was
|
||||
supposed to be its whole content.
|
||||
|
||||
- **Failures were classified wrong in six places.** Nothing transmitted is not a device
|
||||
failure: an unopenable `--port` is `3` rather than `4` with a message about a connection
|
||||
there had never been, and a missing `--yes`, an unknown `--sort`/`--category`/`--profile`,
|
||||
a missing admin password, an unknown contact and a bad `--at` are all usage errors. A
|
||||
`--category` typo used to come back as `5` — indistinguishable from a real empty result —
|
||||
and an unknown `--profile` fell through to whichever radio was attached, which is the one
|
||||
wrong answer that puts a packet on the air.
|
||||
|
||||
- **Errors go to stderr**, in the `meshterm: what went wrong` shape, and stop folding at 80
|
||||
columns — a sentence broken across three lines is a sentence `grep` cannot find. Progress
|
||||
bars moved there too and draw nothing off a terminal.
|
||||
|
||||
- **An expected failure no longer dumps a traceback.** A lost serial link, or an argument a
|
||||
command rejected on its own, reads as one line and the right exit status.
|
||||
|
||||
### Removed
|
||||
|
||||
- **`meshterm map`.** A map is a picture — braille cells whose meaning is their position, and
|
||||
whose nodes are told apart by colour. Stripped of colour it would be unreadable; left
|
||||
coloured it was the one command whose output could not be piped anywhere useful. The map
|
||||
stays in the menu, and the located nodes are scriptable through `contacts`, which now
|
||||
carries their coordinates.
|
||||
|
||||
- **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
|
||||
|
||||
- **The window MeshTerm opened for itself was too small, and it showed.** The startup
|
||||
wordmark is 71 columns wide; below that the splash quietly swaps to the narrow one drawn
|
||||
for the PicoCalc — so a desktop session came up wearing the handheld's mark, with every
|
||||
menu description truncated onto the pager underneath it.
|
||||
|
||||
MeshTerm was opening a *tab* in whatever Windows Terminal window happened to be around,
|
||||
inheriting its size (64 columns on the machine this was found on). It now asks for a
|
||||
window of its own, sized from the platform's own minimum plus a margin. Windows Terminal
|
||||
clamps that to what the display can show, so a small screen degrades exactly as before.
|
||||
|
||||
- The tab it opens is titled **MeshTerm**, not the executable's full path — which on a
|
||||
downloaded build was a line of Downloads folder.
|
||||
|
||||
## [0.2.7] — 2026-09-07
|
||||
|
||||
### Changed
|
||||
|
||||
- **MeshTerm moves to Windows Terminal rather than asking to.** It still says so, and
|
||||
still tells you how to stop it, but it doesn't put the question. The classic console
|
||||
can't draw a single icon whatever font it's given, so there was only one sensible
|
||||
answer — and it was being asked of someone who hadn't seen the app yet and had nothing
|
||||
to judge it by.
|
||||
|
||||
Installing a **font** still asks, because that writes a file to your machine. Moving
|
||||
window doesn't. Preferences → Display → Console setup stops both.
|
||||
|
||||
## [0.2.6] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Accepting "reopen in Windows Terminal" killed the session** in the downloadable
|
||||
builds. A new window opened, printed `Security validation failure: parent process has
|
||||
different executable!`, and exited — while the window that made the offer had already
|
||||
closed. Worse than never offering.
|
||||
|
||||
A one-file build unpacks itself and re-runs itself, the two halves coordinating through
|
||||
private environment variables. Those were being handed to the new copy, which concluded
|
||||
it was the second half of a launch it never made, checked that its parent was the same
|
||||
program, found Windows Terminal instead, and stopped. They are stripped now.
|
||||
|
||||
It worked perfectly when run from source, which is the whole lesson: the packaged build
|
||||
is its own platform.
|
||||
|
||||
## [0.2.5] — 2026-09-07
|
||||
|
||||
@@ -218,7 +395,8 @@ deliberately not reconstructed here.
|
||||
- Two `TYPE_CHECKING` imports the test suite referenced but never imported, on paths that
|
||||
happened never to run.
|
||||
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.2.4...HEAD
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.0...HEAD
|
||||
[0.3.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.0
|
||||
[0.2.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.4
|
||||
[0.2.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.3
|
||||
[0.2.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.2
|
||||
|
||||
@@ -323,6 +323,138 @@ 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 command line — one answer, two faces
|
||||
|
||||
Everything above describes the **menu**, which is read by a person sitting in front of it.
|
||||
The command line has two readers and it no longer pretends they are the same one: the
|
||||
**plain face** is for somebody at a prompt who typed a command to find something out, and
|
||||
the **JSON face** (`--json`) is for a program, often on another machine and often later.
|
||||
|
||||
The seam that makes two faces possible is the rule everything else here hangs off:
|
||||
|
||||
> **A tool states its answer as data. A renderer turns data into bytes. The CLI boundary
|
||||
> picks the renderer.**
|
||||
|
||||
`exit_code` already worked this way — the tool states it, the boundary turns it into a
|
||||
process status, no tool calls `sys.exit`. The answer travels the same way now. A tool that
|
||||
*prints* its answer has given it away: it is rendered and gone by the time anything could
|
||||
offer it in another format, which is why `--json` reached two commands out of twenty and
|
||||
stopped, each one growing an `if ctx.json_output:` branch above the rendering that restated
|
||||
the whole answer in a dialect nobody else could reuse.
|
||||
|
||||
- `ui/report.py` — `Listing` (records) and `Facts` (one thing, key by key). **A row holds
|
||||
the typed value** (`6.0`, `None`, a `datetime`), never a formatted cell; each `Column`
|
||||
carries both projections, a plain `Lane` and a JSON function. A `Facts` block also covers
|
||||
the one-scalar answer, through `shape=BARE` (`config get` prints its value alone because
|
||||
the caller named the key) and the acknowledgement, through `shape=SILENT`.
|
||||
- `ui/fields.py` — **the shared shapes, one constructor per concept**: `node`, `channel`,
|
||||
`position`, `route`, `spec`, `when`, `instant`, `snr`, `flag`, `free`. A listing that
|
||||
mentions a node asks for `fields.node`, so every document speaks the same five-key object
|
||||
and every listing names its lanes the same way — by construction, not by review.
|
||||
`fields.node` is the one that earns the design: **one machine key expands to as many
|
||||
plain columns as the surface has room for**, four in `contacts` and one in a hop table.
|
||||
- `ui/renderers.py` — `PlainRenderer` and `JsonRenderer`, chosen by `ctx.output`.
|
||||
**A third format is a new renderer and nothing else**: no tool is touched, no report
|
||||
changes. `ctx.output` is an `OutputFormat`, never a boolean, because the boolean was
|
||||
already being asked questions it could not answer.
|
||||
- `ToolResult.report` is `None` for the menu path and for a feature with no scripted face
|
||||
(the map, the dashboard). It is **not** `summary`: that is the run log's record of what an
|
||||
invocation did, written to the `runs` table on every execution, and folding a five-hour
|
||||
`monitor` capture into it would be the price of one field fewer.
|
||||
- **`ui/surface.py` is untouched by all of this.** `PlainUi`/`TuiUi` is the *menu-vs-terminal*
|
||||
split and stays exactly what it is; a report is **returned**, not shown, so `ctx.ui` never
|
||||
grows a `report()` method and `TuiUi` never sees one.
|
||||
- **Streaming is a second, narrower seam** and deliberately not the general path:
|
||||
`monitor` and `chat listen` run until a window closes, so their answer cannot be a value
|
||||
handed back at the end. They open `renderers.stream(...)` and emit one typed row per
|
||||
record. Two commands use it; anything that can build a whole report builds one.
|
||||
|
||||
#### The plain face — for a person at a prompt
|
||||
|
||||
It is still a Unix utility's output — one record per line, no colour, no frames, stdout is
|
||||
the answer, the exit status is half the report — but every rule that existed *only* to make
|
||||
splitting safe has gone, because splitting is the machine face's job now.
|
||||
`meshterm/ui/script.py` is the whole vocabulary and carries the reasoning; the rules:
|
||||
|
||||
- **Alignment is the delimiter.** A name is bare (`script.name`). The *escaping* under the
|
||||
quoting stays and always will (`script._escaped`): a node broadcasts its own name and a
|
||||
stranger fills in a message body, and neither may end the record it sits in.
|
||||
- **A time is an age** — `now`, `5m`, `3h`, `never` (`script.age`, delegating to
|
||||
`widgets._format_age` so the two faces cannot drift). An absolute instant survives where
|
||||
the instant *is* the fact (`script.stamp`, `fields.instant`): the device clock, an
|
||||
appointment set with `--at`, a live capture's own `TIME`, every column under `--absolute`.
|
||||
`--absolute` is an override of the `cli_time_format` **preference**, not a switch beside
|
||||
it — behaviour belongs in the registry.
|
||||
- **A route is drawn and a path is typed.** `script.route` joins hops with ` → `; a hop is
|
||||
`Name (hash)`, or whichever half is known, never an empty `()`. `script.spec` is the
|
||||
comma-joined hex `--path` takes back, and it is the one line on either face that
|
||||
round-trips, so nothing creeps into it. **Our own node is a hop like any other** — the
|
||||
menu's `★` says "you already know who this is", which is true of the reader and false of
|
||||
whoever opens the file afterwards.
|
||||
- **`unknown` is a word and `-` is an absence.** `script.NONE` is the one token for absent;
|
||||
`never` is a *value* (a node not yet heard is a fact). A node type stays the word
|
||||
`repeater` — a monochrome `▲` would need a legend and the CLI has no legends.
|
||||
- **A live stream pins its lanes** (`script.stream`), because a capture cannot measure
|
||||
columns it has not seen. A value wider than its lane overruns and pushes the row right;
|
||||
nothing is elided, and the one unbounded field goes **last** so it can push nothing.
|
||||
- **A value that round-trips is untouchable.** `config show` prints what `config set` takes
|
||||
— an enum's number, `""` for an empty string — and gains nothing cosmetic. `fields.rendered`
|
||||
carries the typed value beside the text its own spec produced, so neither face parses the
|
||||
other's output.
|
||||
- **No wrapping, with one exception**: the four written pages (`about`, `about-author`,
|
||||
`discord`, `support`) draw on a `script.PAGE_WIDTH` console. The no-wrap rule exists so a
|
||||
*record* is never split with its fields under the wrong headings; a paragraph has no
|
||||
fields, and unwrapped it is a 600-cell line no terminal can read. **Never wrap a listing.**
|
||||
- **stdout is the answer; everything else is stderr** — errors (`meshterm: what went
|
||||
wrong`), progress bars, log records, `ui.ack`, and a `ToolResult.message`. The last two
|
||||
used to be dropped; stderr keeps the promise the dropping was made to keep (a redirect
|
||||
catches only the answer) while giving the person at the prompt back their ✓ and their
|
||||
count. `script.stderr_console()` stops wrapping off a terminal, because a sentence folded
|
||||
at 80 columns is a sentence `grep` cannot find.
|
||||
|
||||
#### The JSON face — the machine contract
|
||||
|
||||
`--json` prints **the answer itself**, and the report a caller needs is still `$?`.
|
||||
|
||||
- **No envelope.** An array for a listing, an object for a set of facts, so
|
||||
`contacts --json | jq '.[].node.name'` reads what it looks like. A multi-block report is
|
||||
one object: each `Listing` under its own key, each `Facts` merged at the top level.
|
||||
- **One compact line, `\n`-terminated**, UTF-8 with no ASCII escaping, keys in the order the
|
||||
report declares them. A stream is one document per record, **all the same shape** — no
|
||||
discriminator, because every reader would pay for it and only a stream would use it.
|
||||
- **Absent is `null`, never an omitted key**, and typed values throughout: `9` not `"9"`,
|
||||
`false` not `"false"`, a body raw and unescaped. `unknown` becomes `null`; a consumer
|
||||
already has one spelling for "nothing here".
|
||||
- **Timestamps are UTC, RFC 3339, `Z`, to the second** — twenty characters, so string
|
||||
comparison is time comparison. `--absolute` does not touch this: a local offset is a fact
|
||||
about the machine that ran the command, not about the event.
|
||||
- **A numeric field carries its unit in its key** (`snr_db`, `uptime_s`, `rtt_ms`), never in
|
||||
its value, and a key/hash is lowercase hex. **A key is never truncated** — a truncated key
|
||||
cannot go back into `--to` or `--path`, so a short id is a `hash`.
|
||||
- **`--json` changes the rendering, never the report.** Same exit status, same records. An
|
||||
empty result prints its empty document (`[]`, or the object with its `null`s) **and still
|
||||
exits 5**; a failure prints **nothing on stdout** and keeps its sentence on stderr.
|
||||
- **"Prints nothing" is never a legitimate JSON answer.** A command whose plain face is
|
||||
silent (`config advert`, `channels join`, `courier cancel`) still emits what it did.
|
||||
- **A command with no data face refuses, loudly.** `--json specimen` is a usage error (exit
|
||||
2): its output *is* the colour, and a document of it would be a lie or an empty gesture.
|
||||
So is `--json` with no subcommand. The map, the dashboard, the live feed, the watchtower
|
||||
and the mesh walk have no subcommand to refuse from, and that stays the honest answer
|
||||
rather than a degraded one.
|
||||
|
||||
#### 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`; a failure is *raised*, never returned. Two classifications worth restating
|
||||
because both were wrong somewhere: **nothing transmitted is not a device failure** (a bad
|
||||
argument is exit 2), and **a rendering flag never moves the status**.
|
||||
|
||||
`tests/test_report.py` is the enforcement point for the seam, `tests/test_script_output.py`
|
||||
for the plain vocabulary, and `tests/test_cli_contract.py` for the commands — including a
|
||||
derived sweep that runs **every** registered command twice, once plain and once under
|
||||
`--json`, and pins that the two agree about the exit status.
|
||||
|
||||
### Platforms
|
||||
|
||||
One codebase, two flavours: **regular** (desktop/ssh, 72 cols, truecolor, emoji) and
|
||||
|
||||
@@ -68,14 +68,14 @@ if anything goes wrong at startup the window closes before you can read why.
|
||||
**Windows** — open Windows Terminal or PowerShell, `cd` to your downloads, then:
|
||||
|
||||
```powershell
|
||||
.\meshterm-0.2.5-windows-x64.exe
|
||||
.\meshterm-0.2.8-windows-x64.exe
|
||||
```
|
||||
|
||||
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
|
||||
|
||||
```bash
|
||||
chmod +x meshterm-0.2.5-*
|
||||
./meshterm-0.2.5-*
|
||||
chmod +x meshterm-0.2.8-*
|
||||
./meshterm-0.2.8-*
|
||||
```
|
||||
|
||||
### A word about terminals, on Windows
|
||||
@@ -91,20 +91,19 @@ doesn't borrow. It draws what its one font holds and empty boxes for everything
|
||||
no font fixes the icons there: the only two fonts on a Windows machine with emoji in them
|
||||
are proportional, and a console won't take a proportional font.
|
||||
|
||||
So when MeshTerm lands in that console it offers to move: **reopen in Windows Terminal**,
|
||||
one keypress, nothing installed and nothing changed. That's the whole app, exactly as the
|
||||
screenshots show it. Windows Terminal is already on every Windows 11 machine and is a free
|
||||
install on Windows 10.
|
||||
So when MeshTerm lands in that console it just **moves to Windows Terminal** — it says
|
||||
so, and opens there. Nothing is installed and nothing is changed; it's one window instead
|
||||
of another, and it's the whole app exactly as the screenshots show it. Windows Terminal is
|
||||
already on every Windows 11 machine and is a free install on Windows 10.
|
||||
|
||||
Stay in the classic console and MeshTerm offers the next best thing instead — the charts
|
||||
and marks, without the icons. It ships
|
||||
Where there's no Windows Terminal to move to, MeshTerm offers the next best thing instead
|
||||
— the charts and marks, without the icons. It ships
|
||||
[Cascadia Mono PL](https://github.com/microsoft/cascadia-code), Microsoft's own console
|
||||
font and one of the very few monospace faces that carries braille at all, and will install
|
||||
it just for you: no administrator rights, nothing downloaded. If you already have Cascadia
|
||||
it simply switches to it.
|
||||
|
||||
You can decline both and MeshTerm still runs. Preferences → Display → Console setup is
|
||||
where you change your mind.
|
||||
Preferences → Display → Console setup turns all of this off if you'd rather stay put.
|
||||
|
||||
Rename it to something you don't mind typing and put it somewhere on your `PATH`, and it
|
||||
becomes just `meshterm` from anywhere.
|
||||
@@ -114,7 +113,7 @@ becomes just `meshterm` from anywhere.
|
||||
> project.
|
||||
>
|
||||
> - **macOS**: *"cannot be opened because the developer cannot be verified"*. Clear it with
|
||||
> `xattr -d com.apple.quarantine meshterm-0.2.5-*` and run it again.
|
||||
> `xattr -d com.apple.quarantine meshterm-0.2.8-*` and run it again.
|
||||
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
|
||||
>
|
||||
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
|
||||
@@ -124,10 +123,10 @@ becomes just `meshterm` from anywhere.
|
||||
> MeshTerm uses that same folder. Set `MESHTERM_HOME` to try one in isolation:
|
||||
>
|
||||
> ```bash
|
||||
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.2.5-* # macOS, Linux
|
||||
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.2.8-* # macOS, Linux
|
||||
> ```
|
||||
> ```powershell
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.5-windows-x64.exe
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.8-windows-x64.exe
|
||||
> ```
|
||||
>
|
||||
> Same variable if you run two radios and want them kept apart.
|
||||
@@ -199,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
|
||||
|
||||
@@ -215,10 +214,10 @@ 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 …` |
|
||||
| **👣 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 --path …` |
|
||||
| **🏆 Trophy case** | Every trace that comes home is scored, on seven boards: longest distance, farthest node, longest single leg, most nodes (with and without revisits), weakest surviving link, and biggest enclosed loop. Records are kept per hash width, and a record walk must be a *trail* — no link crossed twice the same way. | `meshterm records` |
|
||||
|
||||
### 🔧 This node — the radio in your hand
|
||||
@@ -240,7 +239,11 @@ here with its scripted equivalent, where one exists.
|
||||
|
||||
## CLI cookbook
|
||||
|
||||
Every menu option is also a Typer subcommand — ideal for scripting, cron, and bots.
|
||||
Nearly every menu option is also a subcommand — ideal for scripting, cron, and bots. The
|
||||
live pictures stay in the menu, where their meaning is (the map, dashboard, live feed,
|
||||
watchtower and mesh walk); everything else has a command, and every command speaks
|
||||
`--json`. **[`docs/cli.md`](docs/cli.md) is the full manual**: every command and option,
|
||||
what each one prints on both faces, and the exit statuses. A taste:
|
||||
|
||||
```bash
|
||||
# Trace — a trace transmits exactly once; run it again to sample more.
|
||||
@@ -251,11 +254,13 @@ meshterm trace --target Alice --profile yagi
|
||||
meshterm trace --target Alice --path "3d,f2,3d"
|
||||
meshterm trace --target Alice --path "3d,Bravo-Repeater,f2"
|
||||
|
||||
# Walk a composed circuit with no target at all — out and back your own way
|
||||
meshterm trace-path --path "3d,f2,3d"
|
||||
|
||||
# Sweep and apply a remote node's TX power
|
||||
meshterm tx-optimize --path "Bravo-Repeater,Alice" --samples 6 --step 3 --apply
|
||||
|
||||
# Messaging: live chat in the menu, or scripted from the CLI
|
||||
meshterm chat # interactive: pick a conversation, chat live
|
||||
# Messaging: the live transcript is a menu screen; the CLI takes a subcommand
|
||||
meshterm chat send --to Alice "on my way" # direct message
|
||||
meshterm chat send --channel 0 "net in 5" # channel broadcast
|
||||
meshterm chat history --to Alice
|
||||
@@ -270,7 +275,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
|
||||
@@ -281,8 +287,56 @@ meshterm config advert-cadence 24 --flood # flood the wider mesh daily
|
||||
meshterm monitor --seconds 60
|
||||
```
|
||||
|
||||
Global options (before the subcommand): `--profile/-p`, `--port`, `--ble`, `--ble-pin`,
|
||||
`--tcp`, `--mock`, `--db`, `--json`, `--quiet/-q`.
|
||||
Global options — `--profile/-p`, `--port`, `--ble`, `--ble-pin`, `--tcp`, `--mock`,
|
||||
`--db`, `--json`, `--absolute`, `--quiet/-q` — may be typed **before or after** the
|
||||
subcommand: `meshterm contacts --json` and `meshterm --json contacts` are the same run.
|
||||
|
||||
### Two output faces
|
||||
|
||||
*(The short version — [`docs/cli.md`](docs/cli.md) has the whole of it.)*
|
||||
|
||||
The **plain face** is for a person at a prompt. It prints like a standard Unix utility —
|
||||
no colour, no borders, one record per line, nothing wrapped — and it is allowed to be
|
||||
comfortable about it: listings are `ps`-style aligned records with **bare names**
|
||||
(alignment is the delimiter), times are **relative ages** (`now`, `5m`, `never`), and a
|
||||
route is drawn with arrows — `MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4)`.
|
||||
`--absolute` swaps every age back for an ISO-8601 instant. A *path*, the spec `--path`
|
||||
takes back, stays comma-separated hex. `-` is the one token for absent. Errors,
|
||||
acknowledgements and progress all go to stderr, so a redirect catches only the answer.
|
||||
|
||||
The **JSON face** is the machine contract, and `--json` works on **every** command:
|
||||
|
||||
```console
|
||||
$ meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
|
||||
b2c3d4e500000000000000000000000000000000000000000000000000000000
|
||||
a1b2c3d400000000000000000000000000000000000000000000000000000000
|
||||
```
|
||||
|
||||
No envelope — an array for a listing, an object for a set of facts. One compact line;
|
||||
`monitor` and `chat listen` stream one document per record. Values are typed, absent is
|
||||
`null` and never an omitted key, and timestamps are always UTC to the second regardless of
|
||||
`--absolute`. `--json` changes the rendering, never the report: same records, same exit
|
||||
status.
|
||||
|
||||
Anything structural should go through `--json` and `jq`. The plain face is for looking at.
|
||||
|
||||
### Exit status
|
||||
|
||||
`0` success · `1` failure · `2` usage error · `3` no device found (nothing was
|
||||
transmitted) · `4` the device was reached but the operation failed · `5` nothing to
|
||||
report. The table with its full wording is printed under `meshterm --help` and explained
|
||||
in [`docs/cli.md`](docs/cli.md#exit-status).
|
||||
|
||||
`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
|
||||
|
||||
@@ -332,6 +386,13 @@ host = "192.168.1.50"
|
||||
description = "Basestation over WiFi"
|
||||
```
|
||||
|
||||
Both live in `~/.meshterm`, along with everything else MeshTerm remembers: the SQLite
|
||||
history, the outbox, the contact and channel caches, stored admin passwords, and the log.
|
||||
**`$MESHTERM_HOME` moves the whole directory**, which is the way to run a second radio —
|
||||
or a `--mock` session — without touching the one you use every day. `--db` moves the
|
||||
database alone; the rest stays where it was. [`docs/cli.md`](docs/cli.md#where-meshterm-keeps-its-state)
|
||||
lists every file.
|
||||
|
||||
Timestamps are stored as UTC and rendered in your local time. History older than the
|
||||
`history_days` preference is pruned once at session start.
|
||||
|
||||
|
||||
+1635
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,7 @@ Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Lin
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
__version__ = "0.2.5"
|
||||
__version__ = "0.3.0"
|
||||
__author__ = "Jean-Pierre Martineau"
|
||||
|
||||
#: The year MeshTerm was first published; the copyright span starts here.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
**MeshTerm** is a full-featured **MeshCore** client for the terminal. It talks to companion devices running the stock firmware over serial, Bluetooth, or TCP.
|
||||
|
||||
Everything MeshTerm can do thru its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks!
|
||||
Nearly everything MeshTerm can do thru its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The live pictures stay in the TUI, where they belong — the map, the dashboard, the live feed, the watchtower, the mesh walk — and everything else has a command. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks! Every command prints for a person by default, and speaks JSON with `--json` when a program is reading!
|
||||
|
||||
Everything the radio overhears is recorded to a local database from the moment it's turned on — adverts, telemetry, signal quality, positions, etc. Features like maps, dashboard, and route graphs read back from it, so the longer it runs, the more it know about the mesh!
|
||||
|
||||
|
||||
+311
-98
@@ -15,9 +15,10 @@ from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from typer.core import TyperGroup
|
||||
|
||||
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 +32,90 @@ 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 renderers, script
|
||||
from .ui.renderers import OutputFormat
|
||||
from .ui.termfont import emoji_support
|
||||
from .ui.theme import make_console
|
||||
|
||||
#: The exit-status table, printed under every ``--help``. A documented return value is
|
||||
#: half of what makes the CLI scriptable (the other half is :mod:`meshterm.ui.script`),
|
||||
#: and a caller should not have to find it in a README.
|
||||
EXIT_STATUS_EPILOG = "Exit status: " + " · ".join(
|
||||
f"{code} {meaning}" for code, meaning in exitcodes.MEANINGS.items()
|
||||
)
|
||||
|
||||
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.
|
||||
class _GlobalOptionsAnywhere(TyperGroup):
|
||||
"""A group whose own options may be typed after the subcommand as well as before.
|
||||
|
||||
Click binds an option to whatever command declares it, so a global declared on the
|
||||
callback is a parse error one word later: ``meshterm contacts --json`` failed with a
|
||||
bare usage error while ``meshterm --json contacts`` worked. That is the wrong way
|
||||
round for an output-format flag — ``-o json`` goes *after* the verb in every tool that
|
||||
has one, and ``--json`` is the first thing anyone will reach for here.
|
||||
|
||||
So the group's own options are lifted to the front before Click sees them, and both
|
||||
orders mean the same run. ``--`` stops the lifting, which is the standard way to say
|
||||
the rest is data; ``--help`` is deliberately never lifted, since ``contacts --help``
|
||||
must stay the *contacts* help rather than silently becoming the program's.
|
||||
"""
|
||||
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]
|
||||
def parse_args(self, ctx: typer.Context, args: list[str]) -> list[str]:
|
||||
"""Lift this group's options out of ``args``, then parse as Click normally would."""
|
||||
return super().parse_args(ctx, _globals_first(self, args))
|
||||
|
||||
|
||||
_unframe_help_panels()
|
||||
def _globals_first(group: TyperGroup, args: list[str]) -> list[str]:
|
||||
"""``args`` with ``group``'s own options moved ahead of the subcommand.
|
||||
|
||||
An option is told from an argument by carrying ``is_flag`` — Typer vendors Click, so
|
||||
there is no importable ``click.Option`` to test against, and the attribute is the same
|
||||
question asked without reaching into a private package.
|
||||
|
||||
Args:
|
||||
group: The command whose options count as global.
|
||||
args: The argument list as typed.
|
||||
|
||||
Returns:
|
||||
A reordered list. The relative order of the options, and of everything else, is
|
||||
preserved, so a repeated flag resolves exactly as Click would resolve it.
|
||||
"""
|
||||
takes_value: dict[str, bool] = {}
|
||||
for param in group.params:
|
||||
if getattr(param, "is_flag", None) is None or param.name == "help":
|
||||
continue
|
||||
for opt in (*param.opts, *param.secondary_opts):
|
||||
takes_value[opt] = not param.is_flag
|
||||
|
||||
lifted: list[str] = []
|
||||
kept: list[str] = []
|
||||
index = 0
|
||||
while index < len(args):
|
||||
token = args[index]
|
||||
if token == "--":
|
||||
kept.extend(args[index:])
|
||||
break
|
||||
name = token.split("=", 1)[0]
|
||||
if name not in takes_value:
|
||||
kept.append(token)
|
||||
index += 1
|
||||
elif "=" in token or not takes_value[name] or index + 1 >= len(args):
|
||||
lifted.append(token)
|
||||
index += 1
|
||||
else:
|
||||
lifted.extend(args[index : index + 2])
|
||||
index += 2
|
||||
return lifted + kept
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
cls=_GlobalOptionsAnywhere,
|
||||
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 +123,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.
|
||||
@@ -109,6 +153,9 @@ def main_callback(
|
||||
mock: bool = typer.Option(False, "--mock", help="Use the built-in simulator"),
|
||||
db_path: Path | None = typer.Option(None, "--db", help="SQLite database path"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Machine-readable output"),
|
||||
absolute: bool = typer.Option(
|
||||
False, "--absolute", help="Print absolute timestamps instead of relative ages"
|
||||
),
|
||||
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress console logging"),
|
||||
platform: str | None = typer.Option(
|
||||
None,
|
||||
@@ -127,7 +174,8 @@ def main_callback(
|
||||
tcp: Explicit network address ``host[:port]``, selecting the TCP transport.
|
||||
mock: Whether to use the simulator instead of real hardware.
|
||||
db_path: Override the database location.
|
||||
json_output: Request machine-readable output from tools.
|
||||
json_output: Print the answer as JSON instead of aligned text.
|
||||
absolute: Print times as ISO-8601 instants rather than relative ages, for this run.
|
||||
quiet: Suppress console logging (file logging continues).
|
||||
platform: Explicit ``--platform`` override; see :func:`meshterm.platforms.resolve`.
|
||||
"""
|
||||
@@ -148,22 +196,55 @@ def main_callback(
|
||||
active = without_emoji(active)
|
||||
set_platform(active)
|
||||
|
||||
if mock and (port or ble or tcp):
|
||||
# One flag says "pretend", the other names one exact radio, and `--mock` used to
|
||||
# win silently — so a scheduled `--port COM7 --mock info` reported on a simulator
|
||||
# while reading as though it had reached the radio. Two contradictory claims about
|
||||
# which device to talk to is a bad command line, not a precedence question.
|
||||
named = "--port" if port else ("--ble" if ble else "--tcp")
|
||||
raise typer.BadParameter(f"--mock and {named} name different devices; pass one")
|
||||
|
||||
settings = Settings.load()
|
||||
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")
|
||||
if absolute:
|
||||
# An override of the preference rather than a second switch beside it: *how
|
||||
# MeshTerm behaves* is a preference by this project's own rule, and a flag that
|
||||
# bypassed the registry would be a behaviour a reader could not find. Set, never
|
||||
# saved — it is a claim about this run.
|
||||
prefs.set("cli_time_format", "absolute")
|
||||
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,
|
||||
)
|
||||
|
||||
if profile is not None and settings.resolve_profile(profile) is None:
|
||||
# Falling through to ordinary discovery would pick whichever radio is attached, so
|
||||
# a typo in a scheduled `--profile yagi config advert` transmits from the wrong
|
||||
# node rather than failing. Naming a profile is a claim about *which* device, and
|
||||
# an unkeepable claim is a bad argument.
|
||||
known = ", ".join(sorted(settings.profiles)) or "none are defined"
|
||||
raise typer.BadParameter(f"no device profile named {profile!r} (known: {known})")
|
||||
|
||||
app_ctx = AppContext(
|
||||
preferences=prefs,
|
||||
console=console,
|
||||
@@ -177,7 +258,10 @@ def main_callback(
|
||||
ble_override=ble,
|
||||
tcp_override=tcp,
|
||||
ble_pin=ble_pin,
|
||||
json_output=json_output,
|
||||
output=OutputFormat.JSON if json_output else OutputFormat.PLAIN,
|
||||
# Whether a tool may stop and ask, which is a different question from what its
|
||||
# output looks like. A pipe on stdin means nobody is there to answer.
|
||||
interactive=_stdin_is_a_person(),
|
||||
explicit_selection=(
|
||||
profile is not None or port is not None or ble is not None or tcp is not None
|
||||
),
|
||||
@@ -186,6 +270,12 @@ def main_callback(
|
||||
ctx.call_on_close(app_ctx.repo.close)
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
if json_output:
|
||||
# There is no document an interactive session can emit, and launching the
|
||||
# full-screen menu for a caller that asked for JSON would hang whatever was
|
||||
# waiting to parse it.
|
||||
raise typer.BadParameter("--json needs a subcommand; the menu has no document")
|
||||
|
||||
from .ui.menu import run_menu
|
||||
|
||||
# Before prompt_toolkit takes the screen: this can change the console's own font,
|
||||
@@ -222,16 +312,21 @@ def main_callback(
|
||||
|
||||
|
||||
def _offer_a_console_that_can_draw_meshterm(console: Console, prefs: Preferences) -> bool:
|
||||
"""On the classic Windows console, offer the reader something better than it.
|
||||
"""On the classic Windows console, get the reader somewhere better than it.
|
||||
|
||||
Two offers, in the order of how much they buy. **Windows Terminal** first, because it
|
||||
is the only one that gets the whole app: emoji icons included, which no font can put
|
||||
on the classic console — the only Windows fonts carrying emoji are proportional, and
|
||||
a console will not take one. Then, for a reader who stays, **a font** that can at
|
||||
least draw the charts and the marks.
|
||||
Two remedies, and they are not asked the same way.
|
||||
|
||||
**Windows Terminal** first, and simply done. It is the only thing that gets the whole
|
||||
app — emoji icons included, which no font can put on that console, the only Windows
|
||||
fonts carrying any being proportional and a console taking none of them. Moving there
|
||||
installs nothing and changes nothing, so there is one sensible answer to a question
|
||||
that would be put to someone who has not seen the app yet and cannot judge it.
|
||||
|
||||
**A font** second, only where the move was not possible, and that one *is* asked —
|
||||
it writes a file onto someone's machine, which is theirs to agree to.
|
||||
|
||||
Args:
|
||||
console: The console to ask on.
|
||||
console: The console to report and ask on.
|
||||
prefs: The preference set, read for a previous refusal and written on a new one.
|
||||
|
||||
Returns:
|
||||
@@ -241,24 +336,30 @@ def _offer_a_console_that_can_draw_meshterm(console: Console, prefs: Preferences
|
||||
|
||||
if not classic_console() or prefs.get("console_setup") == "off":
|
||||
return False
|
||||
if _offer_windows_terminal(console):
|
||||
if _move_to_windows_terminal(console):
|
||||
return True
|
||||
_offer_a_font_this_console_can_draw(console, prefs)
|
||||
return False
|
||||
|
||||
|
||||
def _offer_windows_terminal(console: Console) -> bool:
|
||||
"""Offer to reopen the session in Windows Terminal, where the app looks like itself.
|
||||
def _move_to_windows_terminal(console: Console) -> bool:
|
||||
"""Reopen the session in Windows Terminal, where the app can draw all of itself.
|
||||
|
||||
Asked with yes as the default, which is not how the font offer is asked: this one
|
||||
changes nothing on the machine — no install, no setting, one new window — and it is
|
||||
the only answer that gets the reader the app as it was drawn.
|
||||
Done rather than offered. The classic console cannot show a single icon whatever it is
|
||||
given, so "reopen there?" is a question with one sensible answer, asked of someone who
|
||||
has not seen the app yet and so cannot judge it — and asked at the worst moment, before
|
||||
anything has been drawn. Moving costs nothing and changes nothing: no install, no
|
||||
setting, one window instead of another.
|
||||
|
||||
It is announced rather than silent, and the announcement names the way to stop it,
|
||||
because a program that opens a window you did not ask for should at least say so.
|
||||
``console_setup`` set to ``off`` keeps the session here for good.
|
||||
|
||||
Args:
|
||||
console: The console to ask on.
|
||||
console: The console to report on.
|
||||
|
||||
Returns:
|
||||
Whether Windows Terminal was started and this session should stand down.
|
||||
Whether Windows Terminal took over and this session should stand down.
|
||||
"""
|
||||
from .core import winterminal
|
||||
|
||||
@@ -266,17 +367,12 @@ def _offer_windows_terminal(console: Console) -> bool:
|
||||
return False
|
||||
|
||||
console.print()
|
||||
console.print("[accent]•[/accent] This is the classic Windows console, and it can only")
|
||||
console.print(" draw part of MeshTerm — no icons, and no charts.")
|
||||
console.print("[accent]•[/accent] Opening in [accent]Windows Terminal[/accent] — this console")
|
||||
console.print(" can't draw MeshTerm's icons or charts.")
|
||||
console.print("[muted] Preferences → Display → Console setup keeps it here instead.[/muted]")
|
||||
console.print()
|
||||
console.print(" [accent]Windows Terminal[/accent] draws all of it, and you have it.")
|
||||
console.print(" MeshTerm can reopen itself there now. Nothing is installed")
|
||||
console.print(" and nothing is changed; it is just a better window.")
|
||||
console.print()
|
||||
if not _asks_yes(console, " Reopen in Windows Terminal? [Y/n] ", default=True):
|
||||
return False
|
||||
|
||||
if winterminal.reopen():
|
||||
console.print("[ok]✓[/ok] Reopening in Windows Terminal.")
|
||||
get_logger().info("reopened the session in Windows Terminal")
|
||||
return True
|
||||
console.print("[warn]⚠[/warn] Windows Terminal would not start. Staying here.")
|
||||
@@ -446,23 +542,38 @@ def platform_command() -> None:
|
||||
the flavour and the usual reason a Windows session looks plainer than the screenshots
|
||||
(see :func:`meshterm.ui.termfont.emoji_support`).
|
||||
"""
|
||||
assert _platform_resolution is not None # set by the callback that always runs first
|
||||
r = _platform_resolution
|
||||
console = make_console()
|
||||
console.print(f"platform: [accent]{r.platform.name}[/accent] (source: {r.source})")
|
||||
console.print(f" --platform: {r.flag or '(not passed)'}")
|
||||
console.print(f" MESHTERM_PLATFORM: {r.env or '(not set)'}")
|
||||
console.print(f" /proc/device-tree/model: {r.detected_model or '(unavailable)'}")
|
||||
from .ui import fields
|
||||
from .ui.report import Facts
|
||||
|
||||
assert _platform_resolution is not None # set by the callback that always runs first
|
||||
assert _state is not None # ditto
|
||||
r = _platform_resolution
|
||||
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."
|
||||
renderers.for_format(_state.output, _state.console).render(
|
||||
(
|
||||
Facts(
|
||||
key="platform",
|
||||
fields=(
|
||||
fields.word("platform", "platform"),
|
||||
fields.word("platform_source", "platform_source"),
|
||||
fields.word("flag", "flag"),
|
||||
fields.word("env", "env"),
|
||||
fields.word("device_tree_model", "device_tree_model"),
|
||||
fields.flag("icons", "icons"),
|
||||
fields.word("icons_source", "icons_source"),
|
||||
),
|
||||
values={
|
||||
"platform": r.platform.name,
|
||||
"platform_source": r.source,
|
||||
"flag": r.flag or None,
|
||||
"env": r.env or None,
|
||||
"device_tree_model": r.detected_model or None,
|
||||
"icons": emoji.supported,
|
||||
"icons_source": emoji.source,
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.command(name="specimen")
|
||||
@@ -473,9 +584,26 @@ 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.
|
||||
|
||||
It is also the one command that *refuses* ``--json``, loudly and as a usage error.
|
||||
There is no data behind a colour card, so a document of it would be either a lie or an
|
||||
empty gesture — and a loud refusal is the same answer this project already gives for
|
||||
the map, the dashboard and the live feed, which simply have no subcommand to refuse
|
||||
from.
|
||||
"""
|
||||
from .ui.specimen import specimen_lines
|
||||
|
||||
assert _state is not None # set by the callback that always runs first
|
||||
if _state.output is not OutputFormat.PLAIN:
|
||||
raise typer.BadParameter(
|
||||
"specimen has no machine-readable output — its output is the colour"
|
||||
)
|
||||
|
||||
console = make_console()
|
||||
for line in specimen_lines():
|
||||
console.print(line)
|
||||
@@ -493,44 +621,119 @@ 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 _stdin_is_a_person() -> bool:
|
||||
"""Whether somebody is there to answer a prompt.
|
||||
|
||||
The test a tool actually wants before it stops and asks: a redirected or piped stdin
|
||||
means nobody is watching, and a command that blocks there has hung rather than failed.
|
||||
``tx-optimize`` used to ask ``--json`` instead — a flag about output format, which
|
||||
answers a different question and answered this one wrong in both directions.
|
||||
|
||||
``isatty`` alone is not enough on Windows, and the gap is exactly the case this exists
|
||||
for. ``NUL`` is a *character device*, so a scheduled task's empty stdin answers yes —
|
||||
and :func:`getpass.getpass` on Windows then reads the console directly through
|
||||
``msvcrt`` rather than through stdin, and waits forever for a keypress nobody is there
|
||||
to make. ``GetConsoleMode`` succeeds only on a real console handle, which is the
|
||||
question being asked.
|
||||
|
||||
Returns:
|
||||
``True`` only where stdin is a terminal somebody could type into.
|
||||
"""
|
||||
try:
|
||||
if not sys.stdin.isatty():
|
||||
return False
|
||||
except (AttributeError, ValueError): # pragma: no cover - stdin closed or replaced
|
||||
return False
|
||||
if sys.platform != "win32":
|
||||
return True
|
||||
try: # pragma: no cover - the branch is Windows-only and needs a real handle
|
||||
import ctypes
|
||||
import msvcrt
|
||||
|
||||
mode = ctypes.c_uint()
|
||||
handle = msvcrt.get_osfhandle(sys.stdin.fileno())
|
||||
return bool(win32dll.kernel32().GetConsoleMode(handle, ctypes.byref(mode)))
|
||||
except Exception: # pragma: no cover - no console, or a stubbed kernel32
|
||||
return False
|
||||
|
||||
|
||||
def _reported(tool: Tool, problem: object, code: int) -> typer.Exit:
|
||||
"""Log a failure, say so on stderr, and build the exit it should end on.
|
||||
|
||||
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:
|
||||
@@ -541,7 +744,18 @@ 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, render the answer it stated, and close with what it has to say.
|
||||
|
||||
The one place a report becomes output. The tool says what happened as data and this
|
||||
hands it to whichever renderer ``--json`` selected, exactly as ``exit_code`` is stated
|
||||
here and turned into a process status by the caller — which is what lets a third format
|
||||
be a new renderer rather than an edit to twenty tools.
|
||||
|
||||
A tool's ``message`` — "✓ applied 3 changes", "✓ traced Alice" — goes to **stderr**,
|
||||
like every other acknowledgement (see :meth:`~meshterm.ui.surface.PlainUi.ack`). It is
|
||||
the closing line rather than the answer, so it must stay out of a redirect; it is also
|
||||
the count a person at a prompt actually wanted after a command that scrolled, so
|
||||
dropping it was throwing it away to protect a stream it was never going to reach.
|
||||
|
||||
Args:
|
||||
tool: The tool to execute.
|
||||
@@ -552,10 +766,9 @@ async def _execute_and_render(tool: Tool, params: dict, ctx: AppContext) -> Tool
|
||||
The tool's :class:`ToolResult`.
|
||||
"""
|
||||
result = await tool.execute(ctx, params)
|
||||
renderers.for_format(ctx.output, ctx.console).render(result.report)
|
||||
if result.message:
|
||||
ctx.console.print(result.message)
|
||||
for artifact in result.artifacts:
|
||||
ctx.console.print(f"[ok]●[/ok] wrote [accent]{artifact}[/accent]")
|
||||
ctx.ui.ack(result.message)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
+42
-6
@@ -30,6 +30,7 @@ from .core.settings_store import SettingsStore
|
||||
from .core.watch_store import WatchStore
|
||||
from .persistence.logging import get_logger
|
||||
from .persistence.repository import Repository
|
||||
from .ui.renderers import OutputFormat
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .services.advert_scheduler import AdvertScheduler
|
||||
@@ -91,7 +92,16 @@ class AppContext:
|
||||
interactive picker), selecting the TCP transport. Takes precedence over
|
||||
``ble_override`` and ``port_override``.
|
||||
ble_pin: Optional BLE pairing PIN for the chosen Bluetooth device.
|
||||
json_output: Whether tools should emit machine-readable output.
|
||||
output: Which format a scripted run prints its answer in (see
|
||||
:class:`~meshterm.ui.renderers.OutputFormat`). A tool never reads this to
|
||||
decide *what* to say — it states its answer as a
|
||||
:class:`~meshterm.ui.report.Report` and the CLI boundary picks the renderer —
|
||||
and the two streaming commands read it only to open the right kind of stream.
|
||||
interactive: Whether this run may stop and ask a person something. False for every
|
||||
scripted run whose stdin is not a terminal, which is the question a tool
|
||||
actually wants answered before it prompts. ``tx-optimize`` used to ask
|
||||
``--json`` instead, and a flag about *output format* cannot answer it: without
|
||||
the flag, a scheduled run on a terminal would sit waiting for a password.
|
||||
selected_device: The discovered device chosen for this session, when known, so it
|
||||
can be remembered after a successful connection.
|
||||
explicit_selection: Whether ``--port``/``--ble``/``--profile`` was passed explicitly
|
||||
@@ -118,7 +128,8 @@ class AppContext:
|
||||
ble_override: str | None = None
|
||||
tcp_override: str | None = None
|
||||
ble_pin: str | None = None
|
||||
json_output: bool = False
|
||||
output: OutputFormat = OutputFormat.PLAIN
|
||||
interactive: bool = True
|
||||
selected_device: DiscoveredDevice | None = None
|
||||
explicit_selection: bool = False
|
||||
_device: Device | None = field(default=None, init=False, repr=False)
|
||||
@@ -439,6 +450,31 @@ class AppContext:
|
||||
getattr(device, "endpoint", None) if (self._active_transport == "tcp") else None
|
||||
)
|
||||
|
||||
async def _open(self, what: str) -> None:
|
||||
"""Open :attr:`_device`, reporting a failure to open as a *selection* failure.
|
||||
|
||||
The two device exit statuses answer different questions — ``NO_DEVICE`` says
|
||||
nothing was transmitted and the caller should look at what is plugged in;
|
||||
``DEVICE`` says the radio was reached and the operation failed, so retrying is
|
||||
reasonable. Every failure on this path is the first kind: the connection does not
|
||||
exist yet, so nothing has been sent over it. It used to come back as ``DEVICE``
|
||||
with the message "the connection to the device was lost", which describes a
|
||||
connection there had never been — and ``--port NOSUCHPORT`` is the most common way
|
||||
to reach this line.
|
||||
|
||||
Args:
|
||||
what: The thing being opened, for the message (a port, an address, a host).
|
||||
|
||||
Raises:
|
||||
DeviceSelectionError: If the connection cannot be established.
|
||||
"""
|
||||
from .core.selection import DeviceSelectionError
|
||||
|
||||
try:
|
||||
await self._device.connect()
|
||||
except Exception as exc: # noqa: BLE001 - reclassified, then re-raised
|
||||
raise DeviceSelectionError(f"could not open {what}: {exc}") from exc
|
||||
|
||||
async def device(self) -> Device:
|
||||
"""Return a connected :class:`Device`, opening the connection on first use.
|
||||
|
||||
@@ -462,7 +498,7 @@ class AppContext:
|
||||
self._active_port = None
|
||||
self._active_address = None
|
||||
self._active_endpoint = None
|
||||
await self._device.connect()
|
||||
await self._open("the simulator")
|
||||
return self._device
|
||||
|
||||
# A network endpoint (an explicit ``--tcp``, a TCP profile, a device picked at startup,
|
||||
@@ -477,7 +513,7 @@ class AppContext:
|
||||
self._active_endpoint = f"{tcp_host}:{tcp_port}"
|
||||
self._active_port = None
|
||||
self._active_address = None
|
||||
await self._device.connect()
|
||||
await self._open(f"TCP companion {tcp_host}:{tcp_port}")
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
return self._device
|
||||
@@ -511,7 +547,7 @@ class AppContext:
|
||||
self._active_address = ble_address
|
||||
self._active_port = None
|
||||
self._active_endpoint = None
|
||||
await self._device.connect()
|
||||
await self._open(f"BLE companion {ble_address}")
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
return self._device
|
||||
@@ -530,7 +566,7 @@ class AppContext:
|
||||
self._active_port = resolution.port
|
||||
self._active_address = None
|
||||
self._active_endpoint = None
|
||||
await self._device.connect()
|
||||
await self._open(f"serial port {resolution.port}")
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
return self._device
|
||||
|
||||
@@ -96,6 +96,14 @@ def parse_value(spec: SettingSpec, raw: Any, snapshot: dict | None = None) -> An
|
||||
"""
|
||||
text = str(raw).strip()
|
||||
if spec.value_type == "str":
|
||||
# `config show` prints an empty string as the two characters `""`, because a key
|
||||
# with nothing after it reads as a truncated line rather than as an empty value.
|
||||
# A value the dump prints has to be one `set` takes back (CLAUDE.md, "A value must
|
||||
# round-trip"), so the quotes come off here — otherwise feeding a dump back in
|
||||
# silently replaces every empty setting with a pair of quote marks, and the next
|
||||
# dump looks identical, so nothing ever tells you.
|
||||
if text == '""':
|
||||
text = ""
|
||||
if spec.max_length is not None and len(text) > spec.max_length:
|
||||
raise DeviceConfigError(
|
||||
f"{spec.key}: must be at most {spec.max_length} characters, got {len(text)}"
|
||||
|
||||
@@ -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)",
|
||||
}
|
||||
@@ -109,6 +109,10 @@ class PrefSpec:
|
||||
return f"{self.help} (next launch)" if self.relaunch else self.help
|
||||
|
||||
|
||||
#: The two languages the plain CLI can print a time in. Named for what the reader sees
|
||||
#: rather than for the mechanism: an *age* is how long ago, a *timestamp* is when.
|
||||
_CLI_TIME_CHOICES: dict[str, str] = {"relative": "ages", "absolute": "timestamps"}
|
||||
|
||||
#: The silence-rule choices, drawn from the Watchtower's own ring so the two never drift.
|
||||
_SILENCE_CHOICES: dict[int, str] = {
|
||||
**{h: f"{h} h" for h in SILENCE_CHOICES_H},
|
||||
@@ -131,7 +135,7 @@ _WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
|
||||
#: What MeshTerm may offer when it lands in a console that cannot draw it. Only ever
|
||||
#: consulted on the classic Windows console, the one host with no font fallback of its own.
|
||||
_CONSOLE_SETUP_CHOICES: dict[str, str] = {
|
||||
"ask": "Ask",
|
||||
"auto": "Automatic",
|
||||
"off": "Leave it alone",
|
||||
}
|
||||
|
||||
@@ -284,6 +288,19 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
maximum=5000,
|
||||
),
|
||||
# --- Display -----------------------------------------------------------------
|
||||
PrefSpec(
|
||||
key="cli_time_format",
|
||||
label="Command-line times",
|
||||
help="Whether the command line prints ages or timestamps",
|
||||
group="Display",
|
||||
value_type="enum",
|
||||
# Relative, because a person at a prompt asking "heard recently?" should not have
|
||||
# to subtract an ISO instant from `date` to find out. `--absolute` overrides this
|
||||
# for one run, and the JSON face is UTC either way — it is read somewhere else and
|
||||
# often later, where a relative age has nothing to be relative to.
|
||||
default="relative",
|
||||
choices=_CLI_TIME_CHOICES,
|
||||
),
|
||||
PrefSpec(
|
||||
key="fast_render",
|
||||
label="Fast redraw",
|
||||
@@ -305,13 +322,15 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
PrefSpec(
|
||||
key="console_setup",
|
||||
label="Console setup",
|
||||
help="Offer a better terminal or font when this console can't draw MeshTerm",
|
||||
help="Move to a better terminal when this console can't draw MeshTerm",
|
||||
group="Display",
|
||||
value_type="enum",
|
||||
# "ask", not "always": changing the font of a window someone else opened is not
|
||||
# ours to do unasked. Declining twice writes "off" here, which is how the offer
|
||||
# stops being made rather than being made every launch.
|
||||
default="ask",
|
||||
# Reopening in Windows Terminal is done, not asked: the classic console cannot
|
||||
# draw a single icon whatever font it is given, so there is only one sensible
|
||||
# answer and the reader has not seen the app yet to judge it. Installing a *font*
|
||||
# still asks, because that writes to their machine. Declining that twice writes
|
||||
# "off" here, which turns both off for good.
|
||||
default="auto",
|
||||
choices=_CONSOLE_SETUP_CHOICES,
|
||||
),
|
||||
# --- Diagnostics ---------------------------------------------------------------
|
||||
|
||||
@@ -61,6 +61,45 @@ def own_command() -> list[str]:
|
||||
return [sys.executable, "-m", "meshterm", *sys.argv[1:]]
|
||||
|
||||
|
||||
def child_environment() -> dict[str, str]:
|
||||
"""The environment for a fresh launch of MeshTerm, minus this bundle's bookkeeping.
|
||||
|
||||
A PyInstaller one-file build runs in two stages: the executable unpacks itself into a
|
||||
temporary directory and then runs itself again as a child, the two halves coordinating
|
||||
through ``_PYI*`` environment variables. Those must not reach a *new* launch of the
|
||||
executable. The bootloader would find them, conclude it is the second stage of a launch
|
||||
it never made, check that its parent is the same program — find Windows Terminal
|
||||
instead — and abort::
|
||||
|
||||
[PYI-33368:ERROR] Security validation failure: parent process has different
|
||||
executable!
|
||||
|
||||
Which is the whole session gone, in a brand new window holding a message nobody can act
|
||||
on, while the window that offered the move has already closed. Removing them makes the
|
||||
new process a first stage, which is what it actually is.
|
||||
|
||||
Returns:
|
||||
The environment to start the new process with.
|
||||
"""
|
||||
environment = dict(os.environ)
|
||||
environment[REOPENED_ENV] = "1"
|
||||
if not getattr(sys, "frozen", False):
|
||||
return environment
|
||||
|
||||
for key in [key for key in environment if key.startswith("_PYI")]:
|
||||
del environment[key]
|
||||
environment.pop("_MEIPASS2", None) # the name older bootloaders used
|
||||
# The bootloader points the loader at the unpacked bundle and parks the caller's own
|
||||
# value beside it. The new process unpacks its own copy, so it wants the original.
|
||||
for name in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"):
|
||||
original = environment.pop(f"{name}_ORIG", None)
|
||||
if original is not None:
|
||||
environment[name] = original
|
||||
else:
|
||||
environment.pop(name, None)
|
||||
return environment
|
||||
|
||||
|
||||
def reopen() -> bool:
|
||||
"""Start this session again in Windows Terminal, in the same directory.
|
||||
|
||||
@@ -74,17 +113,54 @@ def reopen() -> bool:
|
||||
terminal = available()
|
||||
if terminal is None:
|
||||
return False
|
||||
environment = dict(os.environ, **{REOPENED_ENV: "1"})
|
||||
try:
|
||||
# `--` ends wt's own option parsing, so MeshTerm's flags reach MeshTerm rather
|
||||
# than being read as Windows Terminal's (verified: `--mock` and `--profile x`
|
||||
# arrive intact). `-d` keeps the working directory, which relative paths given
|
||||
# on the command line depend on.
|
||||
subprocess.Popen( # noqa: S603 - the argv is ours, not the reader's
|
||||
[terminal, "-d", os.getcwd(), "--", *own_command()],
|
||||
env=environment,
|
||||
[
|
||||
terminal,
|
||||
"-w",
|
||||
"new",
|
||||
"--size",
|
||||
_wanted_size(),
|
||||
# Otherwise the tab is titled with the executable's full path, which on a
|
||||
# downloaded build is a line of Downloads folder. The icon is not ours to
|
||||
# set: Windows Terminal takes that from a profile, and a bare command line
|
||||
# is not one, so it gets the generic console glyph.
|
||||
"--title",
|
||||
"MeshTerm",
|
||||
"-d",
|
||||
os.getcwd(),
|
||||
"--",
|
||||
*own_command(),
|
||||
],
|
||||
env=child_environment(),
|
||||
close_fds=True,
|
||||
)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _wanted_size() -> str:
|
||||
"""The window to ask Windows Terminal for, as ``cols,rows``.
|
||||
|
||||
Asked for, because a window MeshTerm opens should be one MeshTerm fits in. Without
|
||||
``-w new`` the session lands as a *tab* in whatever window happens to be open and
|
||||
inherits its size; without ``--size`` a new window takes the reader's global launch
|
||||
size, which is a setting about their shell and not about this app. Either way the app
|
||||
can arrive in a window too small for it, and the failure is quiet and cosmetic: the
|
||||
startup wordmark is 71 columns, so at 70 it silently swaps to the narrow one drawn for
|
||||
the PicoCalc, and a short window truncates every description onto the pager.
|
||||
|
||||
The numbers come from the platform's own stated minimum plus a margin, so they follow
|
||||
it rather than repeating it. Windows Terminal clamps to what the display can show, so
|
||||
this is a request and not a demand — on a small screen the app degrades exactly as it
|
||||
would have anyway.
|
||||
"""
|
||||
from ..platforms import get_platform
|
||||
|
||||
platform = get_platform()
|
||||
return f"{platform.readable_cols + 8},{platform.readable_rows + 6}"
|
||||
@@ -101,8 +101,14 @@ def configure_logging(
|
||||
logger.propagate = False
|
||||
|
||||
if not quiet:
|
||||
# markup=False: a log line carries whatever the device said — a node name, a
|
||||
# firmware error — and Rich would read `[...]` in it as a style tag. That made the
|
||||
# *error path* the one most likely to fail: reporting a fault on a node called
|
||||
# `[/]Bob` raised MarkupError from inside the handler, replacing the diagnostic
|
||||
# with a traceback about the diagnostic. No log call in the package marks up its
|
||||
# message, so parsing them buys nothing.
|
||||
rich_handler = RichHandler(
|
||||
console=console, rich_tracebacks=True, show_path=False, markup=True
|
||||
console=console, rich_tracebacks=True, show_path=False, markup=False
|
||||
)
|
||||
rich_handler.setLevel(level)
|
||||
logger.addHandler(rich_handler)
|
||||
|
||||
+91
-3
@@ -26,6 +26,7 @@ from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..ui.markdown import MarkdownDoc
|
||||
from ..ui.report import Facts
|
||||
|
||||
|
||||
class _AboutTool(Tool):
|
||||
@@ -67,7 +68,11 @@ class _AboutTool(Tool):
|
||||
return None
|
||||
|
||||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Print the page — only reachable from the CLI (the menu opens the screen).
|
||||
"""State 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.
|
||||
@@ -76,8 +81,10 @@ class _AboutTool(Tool):
|
||||
Returns:
|
||||
A :class:`ToolResult` naming the page that was shown.
|
||||
"""
|
||||
ctx.ui.show(self.page())
|
||||
return ToolResult(summary={"page": self.name})
|
||||
return ToolResult(
|
||||
summary={"page": self.name},
|
||||
report=(_written_page(self.name, self.title, self.page().for_script()),),
|
||||
)
|
||||
|
||||
|
||||
@register
|
||||
@@ -150,3 +157,84 @@ class SupportProjectTool(_AboutTool):
|
||||
from ..ui.about import support_project
|
||||
|
||||
return support_project()
|
||||
|
||||
|
||||
def _written_page(name: str, title: str, doc: MarkdownDoc) -> Facts:
|
||||
"""One prose page, stated once for both faces.
|
||||
|
||||
**Wrapped**, and that is the one deliberate exception to "no line wrapping". The rule
|
||||
exists so a *record* is never split across two lines with its fields landing under the
|
||||
wrong headings; a paragraph has no records and no headings, and left unwrapped these
|
||||
four pages print as single six-hundred-cell lines no terminal can read. So the page is
|
||||
drawn on a console :data:`~meshterm.ui.script.PAGE_WIDTH` cells wide — a fixed width,
|
||||
not the terminal's, so the same paragraph reads the same piped, redirected, or shipped
|
||||
as the document's ``text``.
|
||||
|
||||
``links`` is the one machine-actionable thing on a page of prose, and the one thing the
|
||||
menu bothers to draw a QR of. ``text`` is the page's plain rendering rather than a fold
|
||||
of the markdown into nested JSON: a structured markdown tree would be a second
|
||||
rendering, not data, and nothing would consume it.
|
||||
"""
|
||||
from .. import __version__
|
||||
from ..ui import fields
|
||||
from ..ui.report import BARE, Column, Facts
|
||||
|
||||
text = _rendered_page(doc)
|
||||
return Facts(
|
||||
key="page",
|
||||
fields=(
|
||||
fields.word("page", "page"),
|
||||
fields.word("title", "title"),
|
||||
fields.word("version", "version"),
|
||||
fields.word("text", "text"),
|
||||
Column(key="links"),
|
||||
),
|
||||
values={
|
||||
"page": name,
|
||||
"title": title,
|
||||
# Duplicated from the package on purpose: a page's own facts are part of its
|
||||
# content, and this is the version its `{version}` placeholder resolved to.
|
||||
"version": __version__,
|
||||
"text": text,
|
||||
"links": _links(text),
|
||||
},
|
||||
shape=BARE,
|
||||
bare="text",
|
||||
)
|
||||
|
||||
|
||||
def _rendered_page(doc: MarkdownDoc) -> str:
|
||||
"""The page as plain text, folded to the fixed page width."""
|
||||
import io
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from ..ui import script
|
||||
|
||||
buffer = io.StringIO()
|
||||
Console(
|
||||
file=buffer,
|
||||
width=script.PAGE_WIDTH,
|
||||
color_system=None,
|
||||
highlight=False,
|
||||
emoji=False,
|
||||
markup=False,
|
||||
).print(doc)
|
||||
return "\n".join(line.rstrip() for line in buffer.getvalue().splitlines()).strip("\n")
|
||||
|
||||
|
||||
def _links(text: str) -> list[str]:
|
||||
"""Every URL the page carries, in document order, de-duplicated.
|
||||
|
||||
Read off the rendered page rather than the markdown source, so a link written as a
|
||||
reference and a link written inline both count once and both read as what the reader
|
||||
sees. Trailing punctuation is not part of a URL and is trimmed.
|
||||
"""
|
||||
import re
|
||||
|
||||
seen: list[str] = []
|
||||
for match in re.findall(r"https?://[^\s<>()\[\]]+", text):
|
||||
url = match.rstrip(".,;:*_")
|
||||
if url not in seen:
|
||||
seen.append(url)
|
||||
return seen
|
||||
+45
-5
@@ -12,10 +12,13 @@ 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
|
||||
|
||||
from ..context import AppContext
|
||||
from ..ui.report import Report
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -24,13 +27,37 @@ 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.
|
||||
report: **The answer**, as data (see :mod:`meshterm.ui.report`). A scripted run
|
||||
states it here and the CLI boundary hands it to a renderer, exactly as it
|
||||
already does with ``exit_code`` — the tool says what happened, and something
|
||||
else turns that into bytes or into a process status. It used to be printed
|
||||
instead, which meant the answer was gone by the time anything could ask for it
|
||||
in another format, which is why ``--json`` reached two commands and stopped.
|
||||
|
||||
``None`` for the menu path and for a feature with no scripted face at all (the
|
||||
map, the dashboard), which keep working unchanged.
|
||||
|
||||
Not folded into ``summary``: that is the *run log's* record of what this
|
||||
invocation did, written to the ``runs`` table on every execution including a
|
||||
menu run. Putting a five-hour ``monitor`` capture in SQLite would be the price
|
||||
of one field fewer.
|
||||
"""
|
||||
|
||||
summary: dict[str, Any] = field(default_factory=dict)
|
||||
message: str | None = None
|
||||
artifacts: list[str] = field(default_factory=list)
|
||||
exit_code: int = exitcodes.OK
|
||||
report: Report | None = None
|
||||
|
||||
|
||||
class Tool(ABC):
|
||||
@@ -123,7 +150,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 +166,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)
|
||||
|
||||
+153
-46
@@ -12,16 +12,18 @@ everything to do with configuring the slots themselves.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
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 import exitcodes
|
||||
from ..core.channels import (
|
||||
channel_hash,
|
||||
derive_secret,
|
||||
is_public_channel,
|
||||
normalize_secret,
|
||||
parse_share_url,
|
||||
random_secret,
|
||||
@@ -29,6 +31,9 @@ from ..core.channels import (
|
||||
)
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Facts, Listing
|
||||
|
||||
|
||||
@register
|
||||
class ChannelsTool(Tool):
|
||||
@@ -88,23 +93,11 @@ class ChannelsTool(Tool):
|
||||
|
||||
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")
|
||||
for slot in slots:
|
||||
table.add_row(
|
||||
str(slot.idx),
|
||||
Text(slot.name, style="brand"),
|
||||
"public" if slot.is_public else "private",
|
||||
slot.hash,
|
||||
)
|
||||
ctx.ui.show(table)
|
||||
return ToolResult(summary={"channels": len(slots)})
|
||||
return ToolResult(
|
||||
summary={"channels": len(slots)},
|
||||
report=(_channel_listing(slots),),
|
||||
exit_code=exitcodes.OK if slots else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
async def _cli_add(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Add a public, private-random, or explicitly-keyed channel on a slot."""
|
||||
@@ -119,9 +112,11 @@ 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})
|
||||
self._show_qr(ctx, share_url(name, secret))
|
||||
return ToolResult(
|
||||
summary={"index": idx, "name": name},
|
||||
report=(_written(idx, name, secret),),
|
||||
)
|
||||
|
||||
async def _cli_join(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Join a channel from a name and an explicit key."""
|
||||
@@ -132,8 +127,10 @@ 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})
|
||||
return ToolResult(
|
||||
summary={"index": idx, "name": name},
|
||||
report=(_written(idx, name, secret, shown=False),),
|
||||
)
|
||||
|
||||
async def _cli_import(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Import a channel from a ``meshcore://channel/add`` link."""
|
||||
@@ -146,21 +143,34 @@ 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})
|
||||
return ToolResult(
|
||||
summary={"index": idx, "name": name},
|
||||
report=(_written(idx, name, secret, shown=False),),
|
||||
)
|
||||
|
||||
async def _cli_share(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Print a channel's share link and QR code."""
|
||||
"""Print a channel's share link, with its QR code in the menu.
|
||||
|
||||
The one command that names a channel's key deliberately, so it is the one place
|
||||
the secret and the share URL leave the app at all — every other channel document
|
||||
carries the identity and stops there.
|
||||
"""
|
||||
from ..ui.channels import read_channel_slots
|
||||
|
||||
device = await ctx.device()
|
||||
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})
|
||||
self._print_share(ctx, slot.name, slot.secret)
|
||||
return ToolResult(summary={"index": idx, "shared": True})
|
||||
return ToolResult(
|
||||
summary={"index": idx, "shared": False},
|
||||
report=(_written(idx, None, None, shown=False),),
|
||||
exit_code=exitcodes.NO_RESULT,
|
||||
)
|
||||
self._show_qr(ctx, share_url(slot.name, slot.secret))
|
||||
return ToolResult(
|
||||
summary={"index": idx, "shared": True},
|
||||
report=(_written(idx, slot.name, slot.secret),),
|
||||
)
|
||||
|
||||
async def _cli_clear(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Clear a channel slot, removing the channel from the device.
|
||||
@@ -176,19 +186,30 @@ 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},
|
||||
report=(_cleared(idx, 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})
|
||||
return ToolResult(
|
||||
summary={"index": idx, "cleared": True},
|
||||
report=(_cleared(idx, 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."""
|
||||
from ..ui.qr import qr_text
|
||||
def _show_qr(ctx: AppContext, url: str) -> None:
|
||||
"""Draw a channel's share link as a QR code — in the menu, and only there.
|
||||
|
||||
url = share_url(name, secret)
|
||||
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
|
||||
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 states the link and nothing else.
|
||||
"""
|
||||
from ..ui.qr import qr_text
|
||||
from ..ui.surface import TuiUi
|
||||
|
||||
if isinstance(ctx.ui, TuiUi):
|
||||
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
"""Register the ``channels`` subcommand group.
|
||||
@@ -198,7 +219,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:
|
||||
@@ -247,12 +268,98 @@ class ChannelsTool(Tool):
|
||||
),
|
||||
) -> None:
|
||||
if not yes:
|
||||
typer.secho(
|
||||
"Refusing: clearing a slot removes the channel (a private channel's "
|
||||
"key is lost unless you have it saved). Re-run with --yes to confirm.",
|
||||
fg="red",
|
||||
# A usage error, in the parser's own words and under its own status — not a
|
||||
# red line printed onto whatever is reading this command's output.
|
||||
raise typer.BadParameter(
|
||||
"clearing a slot removes the channel (a private channel's key is lost "
|
||||
"unless you have it saved). Re-run with --yes to confirm."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
run_tool_command(self, {"cli_action": "clear", "index": index})
|
||||
|
||||
app.add_typer(channels_app, name=self.name)
|
||||
|
||||
|
||||
def _channel_listing(slots: list) -> Listing:
|
||||
"""The configured slots, one record each.
|
||||
|
||||
The one listing whose record *is* a shared shape rather than carrying one: a row that
|
||||
nested its only field under a ``channel`` key would make ``jq '.[].channel.name'``
|
||||
out of a listing whose every column is already the channel.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
return Listing(
|
||||
key="channels",
|
||||
columns=(
|
||||
fields.integer("slot", "SLOT"),
|
||||
fields.name("name", "NAME"),
|
||||
fields.word("type", "TYPE"),
|
||||
fields.hexid("hash", "HASH"),
|
||||
),
|
||||
rows=[
|
||||
{
|
||||
"slot": slot.idx,
|
||||
"name": slot.name,
|
||||
"type": "public" if slot.is_public else "private",
|
||||
"hash": slot.hash,
|
||||
}
|
||||
for slot in slots
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _written(idx: int, name: str | None, secret: bytes | None, *, shown: bool = True) -> Facts:
|
||||
"""What a slot now holds, after ``add``, ``join``, ``import`` or ``share``.
|
||||
|
||||
All four write or read the same thing and all four know the key they wrote, so all
|
||||
four state the same shape. The plain face prints the share URL alone where the caller
|
||||
asked to be given something to pass on (``add``, ``share``) and nothing at all where
|
||||
it did not (``join``, ``import``): those two were handed the key, and reading it back
|
||||
to them is not an answer.
|
||||
|
||||
Args:
|
||||
idx: The slot index.
|
||||
name: The channel name, or ``None`` for a slot that turned out to be empty.
|
||||
secret: The 16-byte key, or ``None`` for an empty slot.
|
||||
shown: Whether the plain face prints the URL.
|
||||
|
||||
Returns:
|
||||
The facts block.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.fields import ChannelRef
|
||||
from ..ui.report import BARE, SILENT, Facts
|
||||
|
||||
channel = None
|
||||
if name is not None and secret is not None:
|
||||
public = is_public_channel(name, secret)
|
||||
channel = ChannelRef(slot=idx, name=name, public=public, hash=channel_hash(secret))
|
||||
return Facts(
|
||||
key="channel",
|
||||
fields=(
|
||||
fields.channel("channel"),
|
||||
fields.hexid("secret", "secret"),
|
||||
fields.word("url", "url"),
|
||||
),
|
||||
values={
|
||||
"channel": channel,
|
||||
"secret": secret.hex() if secret else None,
|
||||
"url": share_url(name, secret) if name is not None and secret is not None else None,
|
||||
},
|
||||
shape=BARE if shown else SILENT,
|
||||
bare="url",
|
||||
)
|
||||
|
||||
|
||||
def _cleared(idx: int, cleared: bool) -> Facts:
|
||||
"""What ``channels clear`` did — nothing plain, since the status already said it."""
|
||||
from ..ui import fields
|
||||
from ..ui.report import SILENT, Facts
|
||||
|
||||
return Facts(
|
||||
key="cleared",
|
||||
fields=(fields.integer("slot", "slot"), fields.flag("cleared", "cleared")),
|
||||
values={"slot": idx, "cleared": cleared},
|
||||
shape=SILENT,
|
||||
)
|
||||
+285
-67
@@ -18,26 +18,31 @@ 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 ..core.channels import (
|
||||
CHANNEL_SLOT_PROBE_CAP,
|
||||
DEFAULT_PUBLIC_SECRET,
|
||||
channel_hash,
|
||||
channel_identity,
|
||||
is_public_channel,
|
||||
)
|
||||
from ..core.connection import Device
|
||||
from ..core.events import EventKind, MeshEvent
|
||||
from ..core.models import (
|
||||
NODE_TYPE_CHAT,
|
||||
NODE_TYPE_LABELS,
|
||||
ChatMessage,
|
||||
Contact,
|
||||
Conversation,
|
||||
is_direct_messageable,
|
||||
)
|
||||
from ..services.trace_runner import NameKeyResolver, make_name_key_resolver
|
||||
from ..ui import renderers
|
||||
from ..ui.chat import _MENTION, _split_channel_sender
|
||||
from ..ui.fields import ChannelRef, NodeRef
|
||||
from ..ui.menus import Lane, column_header, fit_cells, section_heading
|
||||
from ..ui.theme import name_style
|
||||
from ..ui.tui import Choice, DeleteRequest, Separator
|
||||
@@ -46,6 +51,7 @@ from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..ui.channels import ChannelSlot
|
||||
from ..ui.report import Facts, Listing
|
||||
|
||||
#: How many recent messages ``chat history`` prints by default.
|
||||
_HISTORY_LIMIT = 50
|
||||
@@ -252,20 +258,30 @@ 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.
|
||||
"""
|
||||
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})
|
||||
return ToolResult(
|
||||
summary={"channel": channel, "sent": True},
|
||||
report=(_sent(channel=int(channel)),),
|
||||
)
|
||||
|
||||
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}")
|
||||
return ToolResult(summary={"to": contact.name, "acked": bool(message.acked)})
|
||||
return ToolResult(
|
||||
summary={"to": contact.name, "acked": bool(message.acked)},
|
||||
report=(_sent(contact=contact, acked=bool(message.acked)),),
|
||||
)
|
||||
|
||||
async def _cli_history(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Print a conversation's stored transcript."""
|
||||
@@ -288,11 +304,11 @@ class ChatTool(Tool):
|
||||
)
|
||||
label = contact.name
|
||||
|
||||
if not messages:
|
||||
ctx.ui.note(f"[muted]no messages with {label} yet[/muted]")
|
||||
else:
|
||||
ctx.ui.show(_history_table(label, messages))
|
||||
return ToolResult(summary={"conversation": label, "messages": len(messages)})
|
||||
return ToolResult(
|
||||
summary={"conversation": label, "messages": len(messages)},
|
||||
report=(_transcript(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,12 +325,18 @@ 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
|
||||
|
||||
@@ -324,27 +346,39 @@ 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}")
|
||||
channel = (
|
||||
_channel_ref(message.channel, f"#{message.channel}", None)
|
||||
if message.is_channel
|
||||
else None
|
||||
)
|
||||
emit(
|
||||
{
|
||||
"received_at": message.received_at,
|
||||
"conversation": channel.name if channel else (message.sender or None),
|
||||
"direction": "in",
|
||||
"node": None if message.is_channel else NodeRef(hash=message.sender),
|
||||
"channel": channel,
|
||||
"snr_db": message.snr,
|
||||
"text": message.text,
|
||||
"acked": None,
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribe = ctx.events.subscribe(on_message, EventKind.MESSAGE)
|
||||
try:
|
||||
if seconds:
|
||||
await asyncio.sleep(seconds)
|
||||
else:
|
||||
await asyncio.Event().wait() # until Ctrl-C / cancellation
|
||||
except (KeyboardInterrupt, asyncio.CancelledError): # pragma: no cover - interactive
|
||||
pass
|
||||
finally:
|
||||
unsubscribe()
|
||||
with renderers.stream(ctx, _live_messages()) as emit:
|
||||
unsubscribe = ctx.events.subscribe(on_message, EventKind.MESSAGE)
|
||||
try:
|
||||
if seconds:
|
||||
await asyncio.sleep(seconds)
|
||||
else:
|
||||
await asyncio.Event().wait() # until Ctrl-C / cancellation
|
||||
except (KeyboardInterrupt, asyncio.CancelledError): # pragma: no cover - interactive
|
||||
pass
|
||||
finally:
|
||||
unsubscribe()
|
||||
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 +388,14 @@ 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")
|
||||
rows = [*channels] + [
|
||||
Conversation(label=c.name, is_channel=False, contact=c) for c in contacts
|
||||
]
|
||||
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)})
|
||||
return ToolResult(
|
||||
summary={"conversations": len(rows)},
|
||||
report=(_conversations(ctx, rows, lasts),),
|
||||
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 +405,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(
|
||||
@@ -634,7 +659,7 @@ def _picker_header(width: int) -> str:
|
||||
[
|
||||
Lane("CONVERSATION", _LABEL_WIDTH + 2),
|
||||
Lane("UNREAD", _BADGE_WIDTH + 2),
|
||||
Lane("AGE", _AGE_WIDTH + 2),
|
||||
Lane("TIME", _AGE_WIDTH + 2),
|
||||
Lane(("LAST MESSAGE", "LAST MSG", "LAST")),
|
||||
],
|
||||
width,
|
||||
@@ -800,25 +825,218 @@ def _ago(when: Any) -> str:
|
||||
return _format_age(_age_seconds(when))
|
||||
|
||||
|
||||
def _history_table(label: str, messages: list[ChatMessage]) -> Table:
|
||||
"""Render a conversation's stored messages as a table.
|
||||
def _channel_ref(idx: int | None, label: str, secret: bytes | None) -> ChannelRef:
|
||||
"""One channel as the shared shape, from whatever the surface knows about it.
|
||||
|
||||
Args:
|
||||
label: The conversation's display name.
|
||||
messages: The messages to show, oldest-first.
|
||||
|
||||
Returns:
|
||||
A Rich :class:`Table` of time, sender, and text.
|
||||
A transcript row and a live message know a slot and a label but not the key, so
|
||||
``public`` is read off the name where the secret is not in hand — which is what the
|
||||
leading ``#`` means and how a public channel is written everywhere else in the app.
|
||||
"""
|
||||
table = Table(title=f"History — {label}", border_style="muted", expand=False)
|
||||
table.add_column("TIME")
|
||||
table.add_column("FROM")
|
||||
table.add_column("MESSAGE")
|
||||
return ChannelRef(
|
||||
slot=int(idx or 0),
|
||||
name=label,
|
||||
public=is_public_channel(label, secret) if secret else label.startswith("#"),
|
||||
hash=channel_hash(secret) if secret else None,
|
||||
)
|
||||
|
||||
|
||||
def _message_columns() -> tuple:
|
||||
"""One transcript row's columns, shared by ``history`` and the ``listen`` stream.
|
||||
|
||||
``DIR`` carries what the menu's transcript writes 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.
|
||||
|
||||
``TEXT`` is last and escaped: it is the rest of the line, and "the rest of the line"
|
||||
stops being true the moment a body holds a newline — after which the remainder reads
|
||||
as a second record with an empty time. The document carries the body raw, a JSON string
|
||||
holding a newline natively.
|
||||
"""
|
||||
from ..ui import fields
|
||||
|
||||
return (
|
||||
fields.when("created_at", "TIME"),
|
||||
fields.word("direction", "DIR"),
|
||||
# One column for whichever end the conversation had; the document keeps the two
|
||||
# apart, because a parser cannot tell a channel from a contact by its label.
|
||||
fields.plain_only(fields.name("conversation", "PEER")),
|
||||
fields.node("node", lanes=()),
|
||||
fields.channel("channel", lanes=()),
|
||||
fields.snr("snr_db", "SNR_DB"),
|
||||
fields.free("text", "TEXT"),
|
||||
fields.hidden("acked"),
|
||||
)
|
||||
|
||||
|
||||
def _transcript(messages: list[ChatMessage]) -> Listing:
|
||||
"""A conversation's stored messages, oldest first, one record each."""
|
||||
from ..ui.report import Listing
|
||||
|
||||
rows = []
|
||||
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)
|
||||
return table
|
||||
channel = (
|
||||
_channel_ref(message.channel_idx, message.peer_name or f"#{message.channel_idx}", None)
|
||||
if message.is_channel
|
||||
else None
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"created_at": message.created_at,
|
||||
"direction": "out" if message.outbound else "in",
|
||||
"conversation": message.peer_name or message.peer or None,
|
||||
"node": None
|
||||
if message.is_channel
|
||||
else NodeRef(name=message.peer_name, hash=message.peer),
|
||||
"channel": channel,
|
||||
"snr_db": message.snr,
|
||||
"text": message.text,
|
||||
"acked": message.acked,
|
||||
}
|
||||
)
|
||||
return Listing(
|
||||
key="messages",
|
||||
columns=_message_columns(),
|
||||
rows=rows,
|
||||
order=("TIME", "DIR", "PEER", "SNR_DB", "TEXT"),
|
||||
)
|
||||
|
||||
|
||||
def _live_messages() -> Listing:
|
||||
"""The shape ``chat listen`` streams: what arrived, as it arrived.
|
||||
|
||||
``TIME`` is absolute here where the transcript's is an age, and for the reason the
|
||||
whole time rule turns on: every row of a live tail would read ``now``, which is no
|
||||
information at all. There is no ``DIR`` lane — everything a tail hears came in — and
|
||||
the body goes last, so the one field with no width can never push a lane.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
def pin(column, width: int): # noqa: ANN001, ANN202 - one column in, one column out
|
||||
return replace(column, lanes=tuple(replace(lane, width=width) for lane in column.lanes))
|
||||
|
||||
return Listing(
|
||||
key="messages",
|
||||
columns=(
|
||||
pin(fields.instant("received_at", "TIME"), 25),
|
||||
pin(fields.plain_only(fields.name("conversation", "PEER")), 16),
|
||||
fields.node("node", lanes=()),
|
||||
fields.channel("channel", lanes=()),
|
||||
pin(fields.snr("snr_db", "SNR_DB"), 6),
|
||||
fields.free("text", "TEXT"),
|
||||
fields.hidden("direction"),
|
||||
fields.hidden("acked"),
|
||||
),
|
||||
order=("TIME", "PEER", "SNR_DB", "TEXT"),
|
||||
)
|
||||
|
||||
|
||||
def _conversations(ctx: AppContext, rows: list, lasts: dict) -> Listing:
|
||||
"""Every channel and contact, with the last thing said in each.
|
||||
|
||||
``LAST`` is an age rather than an instant: a conversation list is scanned for what is
|
||||
warm, and ``never`` is a real answer — the conversation exists and nothing has been
|
||||
said in it.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
records = []
|
||||
for conversation in rows:
|
||||
last = lasts.get(conversation.key)
|
||||
channel = (
|
||||
_channel_ref(conversation.channel_idx, conversation.label, conversation.secret)
|
||||
if conversation.is_channel
|
||||
else None
|
||||
)
|
||||
contact = getattr(conversation, "contact", None)
|
||||
records.append(
|
||||
{
|
||||
"kind": "channel" if conversation.is_channel else "direct",
|
||||
"conversation": conversation.label,
|
||||
"channel": channel,
|
||||
"node": None
|
||||
if conversation.is_channel
|
||||
else NodeRef(
|
||||
name=conversation.label,
|
||||
key=(getattr(contact, "public_key", "") or "").lower() or None,
|
||||
hash=(getattr(contact, "key_prefix", "") or "").lower() or None,
|
||||
type=NODE_TYPE_LABELS.get(getattr(contact, "node_type", None)),
|
||||
),
|
||||
"unread": ctx.chat.unread(conversation.key),
|
||||
"last_message_at": last.created_at if last is not None else 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 — but a body that
|
||||
# holds a newline would still end the record, so it is folded flat.
|
||||
"last_text": last.text if last is not None else None,
|
||||
}
|
||||
)
|
||||
return Listing(
|
||||
key="conversations",
|
||||
columns=(
|
||||
fields.word("kind", "KIND"),
|
||||
fields.plain_only(fields.name("conversation", "CONVERSATION")),
|
||||
fields.channel("channel", lanes=()),
|
||||
fields.node("node", lanes=()),
|
||||
fields.integer("unread", "UNREAD"),
|
||||
fields.when("last_message_at", "LAST", absent="never"),
|
||||
fields.free("last_text", "LAST_TEXT"),
|
||||
),
|
||||
rows=records,
|
||||
order=("CONVERSATION", "KIND", "UNREAD", "LAST", "LAST_TEXT"),
|
||||
)
|
||||
|
||||
|
||||
def _sent(
|
||||
*,
|
||||
contact: object | None = None,
|
||||
channel: int | None = None,
|
||||
acked: bool | None = None,
|
||||
) -> Facts:
|
||||
"""What one ``chat send`` did.
|
||||
|
||||
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. A channel broadcast prints nothing, because there *is* no
|
||||
acknowledgement on a channel — and the document says that in the one way the plain
|
||||
face never could, with ``null`` rather than ``false``.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import PAIRS, SILENT, Facts
|
||||
|
||||
node = (
|
||||
NodeRef(
|
||||
name=contact.name,
|
||||
key=(getattr(contact, "public_key", "") or "").lower() or None,
|
||||
hash=(getattr(contact, "key_prefix", "") or "").lower() or None,
|
||||
type=NODE_TYPE_LABELS.get(getattr(contact, "node_type", None)),
|
||||
)
|
||||
if contact is not None
|
||||
else None
|
||||
)
|
||||
return Facts(
|
||||
key="sent",
|
||||
fields=(
|
||||
# `acked` is the only lane: the caller named the recipient and the radio
|
||||
# accepting the message is what a zero exit already says, so the one fact
|
||||
# left to report is whether the peer answered.
|
||||
fields.hidden("kind"),
|
||||
fields.node("node", lanes=()),
|
||||
fields.channel("channel", lanes=()),
|
||||
fields.hidden("sent"),
|
||||
fields.flag("acked", "acked"),
|
||||
),
|
||||
values={
|
||||
"kind": "channel" if channel is not None else "direct",
|
||||
"node": node,
|
||||
"channel": (
|
||||
_channel_ref(channel, f"#{channel}", None) if channel is not None else None
|
||||
),
|
||||
"sent": True,
|
||||
"acked": acked,
|
||||
},
|
||||
shape=SILENT if channel is not None else PAIRS,
|
||||
)
|
||||
+435
-70
@@ -12,23 +12,27 @@ management, factory reset) through the same executor.
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, 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
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Block, Facts, Listing, Report
|
||||
|
||||
|
||||
@register
|
||||
class ConfigTool(Tool):
|
||||
@@ -70,11 +74,16 @@ class ConfigTool(Tool):
|
||||
device = await ctx.device()
|
||||
ops: list[tuple] = list(params.get("ops") or [])
|
||||
snapshot = await build_snapshot(device)
|
||||
changes, artifacts = await apply_ops(ctx, device, snapshot, ops)
|
||||
changes, artifacts, report = await apply_ops(ctx, device, snapshot, ops)
|
||||
|
||||
plural = "" if changes == 1 else "s"
|
||||
message = f"[ok]✓[/ok] applied [brand]{changes}[/brand] change{plural}" if changes else None
|
||||
return ToolResult(summary={"changes": changes}, message=message, artifacts=artifacts)
|
||||
return ToolResult(
|
||||
summary={"changes": changes},
|
||||
message=message,
|
||||
artifacts=artifacts,
|
||||
report=report or None,
|
||||
)
|
||||
|
||||
# -- CLI --------------------------------------------------------------------
|
||||
|
||||
@@ -86,7 +95,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:
|
||||
@@ -201,7 +210,7 @@ class ConfigTool(Tool):
|
||||
|
||||
async def apply_ops(
|
||||
ctx: AppContext, device: Device, snapshot: dict, ops: list[tuple]
|
||||
) -> tuple[int, list[str]]:
|
||||
) -> tuple[int, list[str], Report]:
|
||||
"""Execute a list of configuration operation tuples against ``device``.
|
||||
|
||||
This is the single executor for every config operation, used both by
|
||||
@@ -217,32 +226,51 @@ async def apply_ops(
|
||||
ops: Operation tuples (see the module docstring).
|
||||
|
||||
Returns:
|
||||
A ``(changes, artifacts)`` pair: the number of value changes applied and any
|
||||
file paths produced.
|
||||
A ``(changes, artifacts, report)`` triple: the number of value changes applied,
|
||||
any file paths produced, and the blocks stating what happened (empty for the
|
||||
menu, which shows its own acknowledgements).
|
||||
"""
|
||||
changes = 0
|
||||
artifacts: list[str] = []
|
||||
report: list[Any] = []
|
||||
applied: list[dict[str, Any]] = []
|
||||
for op in ops:
|
||||
kind = op[0]
|
||||
if kind == "show":
|
||||
await _show(ctx, device, snapshot)
|
||||
block = await _show(ctx, device, snapshot)
|
||||
if block is not None:
|
||||
report.append(block)
|
||||
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. The document keeps the key,
|
||||
# the type and an enum's label, none of which the bare form has room for.
|
||||
spec = get_spec(op[1])
|
||||
ctx.ui.note(f"[muted]{spec.key}[/muted] = {format_value(spec, spec.getter(snapshot))}")
|
||||
report.append(_one_setting(spec, spec.getter(snapshot)))
|
||||
elif kind == "set":
|
||||
changes += await _apply_setting(ctx, device, op[1], op[2], snapshot)
|
||||
change = await _apply_setting(ctx, device, op[1], op[2], snapshot)
|
||||
if change is not None:
|
||||
applied.append(change)
|
||||
changes += 1
|
||||
elif kind == "set_custom":
|
||||
previous = (await device.get_custom_vars()).get(op[1])
|
||||
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]}")
|
||||
applied.append({"key": f"custom.{op[1]}", "previous": previous, "value": 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
|
||||
report.append(_channel_written(op[1], op[2], op[3]))
|
||||
elif kind == "backup":
|
||||
artifacts.append(str(await _backup(device, snapshot, op[1])))
|
||||
written, counts = await _backup(device, snapshot, op[1])
|
||||
artifacts.append(str(written))
|
||||
report.append(_backed_up(written, counts))
|
||||
elif kind == "restore":
|
||||
changes += await _restore(ctx, device, snapshot, op[1], op[2])
|
||||
done, blocks = await _restore(ctx, device, snapshot, op[1], op[2])
|
||||
changes += done
|
||||
report.extend(blocks)
|
||||
elif kind == "advert":
|
||||
flood = len(op) > 1 and bool(op[1])
|
||||
await device.send_advert(flood)
|
||||
@@ -252,7 +280,8 @@ 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")
|
||||
report.append(_acted("advert", sent=True, flood=flood))
|
||||
elif kind == "advert_cadence":
|
||||
from ..core.advert_store import cadence_label
|
||||
|
||||
@@ -261,38 +290,57 @@ 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
|
||||
report.append(_acted("cadence", changes=1, flood=flood, hours=hours))
|
||||
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)
|
||||
report.append(_share_contact(ctx, snapshot))
|
||||
elif kind == "sync_clock":
|
||||
import time as _time
|
||||
from datetime import datetime
|
||||
|
||||
epoch = int(_time.time())
|
||||
drift = await _try_clock(device)
|
||||
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
|
||||
report.append(
|
||||
_acted(
|
||||
"clock",
|
||||
changes=1,
|
||||
set_at=datetime.fromtimestamp(epoch).astimezone(),
|
||||
# What the clock was off by *before* the set: the fact worth logging,
|
||||
# and the one this command destroys by succeeding.
|
||||
drift_s=None if drift is None else drift - epoch,
|
||||
)
|
||||
)
|
||||
elif kind == "reboot":
|
||||
await device.reboot()
|
||||
ctx.ui.note("[warn]device rebooting[/warn]")
|
||||
ctx.ui.ack("[warn]device rebooting[/warn]")
|
||||
report.append(_acted("reboot", rebooted=True))
|
||||
elif kind == "export_key":
|
||||
await _export_key(ctx, device, op[1] if len(op) > 1 else None, artifacts)
|
||||
report.append(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
|
||||
report.append(_acted("imported", changes=1, imported=True))
|
||||
elif kind == "factory_reset":
|
||||
await device.factory_reset()
|
||||
ctx.ui.note("[err]device factory-reset[/err]")
|
||||
ctx.ui.ack("[err]device factory-reset[/err]")
|
||||
report.append(_acted("factory_reset", factory_reset=True))
|
||||
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}")
|
||||
if applied:
|
||||
report.append(_applied(applied))
|
||||
# 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.
|
||||
@@ -305,22 +353,25 @@ async def apply_ops(
|
||||
ctx.devstate.invalidate_config() # self-info fields + path-hash mode
|
||||
if "set_channel" in kinds:
|
||||
ctx.devstate.invalidate_channels()
|
||||
return changes, artifacts
|
||||
return changes, artifacts, tuple(report)
|
||||
|
||||
|
||||
async def _apply_setting(
|
||||
ctx: AppContext, device: Device, key: str, raw: Any, snapshot: dict
|
||||
) -> int:
|
||||
) -> dict[str, Any]:
|
||||
"""Parse, apply and record one setting; keep ``snapshot`` consistent.
|
||||
|
||||
The local snapshot is updated with the new value so a later coupled change in the
|
||||
same batch (e.g. another radio field) is rebuilt from current values.
|
||||
|
||||
Returns:
|
||||
``1`` (a change was applied).
|
||||
The change, as ``{"key", "previous", "value"}``. The *previous* value is read off
|
||||
the snapshot before it is overwritten: a caller managing a machine's configuration
|
||||
wants to know what moved, and it is gone the moment this returns.
|
||||
"""
|
||||
spec = get_spec(key)
|
||||
value = parse_value(spec, raw, snapshot)
|
||||
previous = spec.getter(snapshot)
|
||||
await spec.apply(device, value, snapshot)
|
||||
snapshot[key] = value
|
||||
# Remember what we set, keyed by the device's own public key, so a forgetful device (a
|
||||
@@ -328,94 +379,268 @@ 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)}")
|
||||
return 1
|
||||
ctx.ui.ack(f"[ok]✓[/ok] [brand]{key}[/brand] = {format_value(spec, value)}")
|
||||
return {"key": key, "previous": previous, "value": value}
|
||||
|
||||
|
||||
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
|
||||
async def _show(ctx: AppContext, device: Device, snapshot: dict) -> Listing | None:
|
||||
"""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 fields, script
|
||||
from ..ui.config_editor import PIN_KEY, conceal, config_table
|
||||
from ..ui.report import Listing
|
||||
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 None
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for _category, specs in settings_by_category():
|
||||
for spec in specs:
|
||||
value = spec.getter(snapshot)
|
||||
printed = _script_value(spec, value)
|
||||
redacted = spec.key == PIN_KEY and printed != script.NONE
|
||||
masked = conceal(printed, absent=script.NONE) if spec.key == PIN_KEY else printed
|
||||
rows.append(
|
||||
{
|
||||
"key": spec.key,
|
||||
# A masked PIN is *withheld*, which is not a value: the document says
|
||||
# so with `redacted` and writes `null`, rather than shipping six
|
||||
# bullets a consumer would have to recognise as a mask.
|
||||
"value": fields.Rendered(None if redacted else value, masked),
|
||||
"type": spec.value_type,
|
||||
"label": (spec.choices or {}).get(value) if spec.value_type == "enum" else None,
|
||||
"redacted": redacted,
|
||||
}
|
||||
)
|
||||
# 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(
|
||||
{
|
||||
"key": f"custom.{key}",
|
||||
"value": fields.Rendered(value, value),
|
||||
"type": "str",
|
||||
"label": None,
|
||||
"redacted": False,
|
||||
}
|
||||
for key, value in sorted(custom.items())
|
||||
)
|
||||
return Listing(
|
||||
key="settings",
|
||||
columns=_setting_columns(),
|
||||
rows=rows,
|
||||
# An array to a parser and a `sysctl -a` block to a reader: a header line over two
|
||||
# columns whose keys *are* the answer would be furniture.
|
||||
headed=False,
|
||||
)
|
||||
|
||||
|
||||
def _share_contact(ctx: AppContext, snapshot: dict) -> None:
|
||||
"""Render this node's contact card — a scannable QR code plus the raw URI."""
|
||||
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) -> Facts:
|
||||
"""State this node's contact card — with the QR code drawn in the menu.
|
||||
|
||||
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 states the
|
||||
link by itself. There is no machine face for the code either, and never will be: it is
|
||||
a second rendering of ``url``.
|
||||
"""
|
||||
from rich.console import Group
|
||||
from rich.text import Text
|
||||
|
||||
from ..ui import fields
|
||||
from ..ui.config_editor import contact_share_url
|
||||
from ..ui.qr import qr_text
|
||||
from ..ui.report import BARE, Facts
|
||||
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")))
|
||||
adv_type = int(snapshot.get("adv_type") or 1)
|
||||
url = contact_share_url(name, public_key, adv_type)
|
||||
if isinstance(ctx.ui, TuiUi):
|
||||
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
|
||||
return Facts(
|
||||
key="share",
|
||||
fields=(
|
||||
fields.word("url", "url"),
|
||||
fields.word("name", "name"),
|
||||
fields.hexid("public_key", "public_key"),
|
||||
fields.integer("type", "type"),
|
||||
),
|
||||
values={"url": url, "name": name, "public_key": public_key.lower(), "type": adv_type},
|
||||
shape=BARE,
|
||||
bare="url",
|
||||
)
|
||||
|
||||
|
||||
async def _backup(device: Device, snapshot: dict, path: Path) -> Path:
|
||||
"""Write a TOML backup of the current configuration."""
|
||||
async def _backup(device: Device, snapshot: dict, path: Path) -> tuple[Path, dict[str, int]]:
|
||||
"""Write a TOML backup of the current configuration.
|
||||
|
||||
Returns:
|
||||
The path written and how much went into it, counted the way the file counts —
|
||||
a setting the firmware never reported is not in the backup and is not in the tally.
|
||||
"""
|
||||
from ..core.device_config import DEVICE_SETTINGS
|
||||
|
||||
custom = await device.get_custom_vars()
|
||||
channels = await _read_channels(device)
|
||||
return backup_config(Path(path), snapshot, custom, channels)
|
||||
written = backup_config(Path(path), snapshot, custom, channels)
|
||||
counts = {
|
||||
"settings": sum(1 for spec in DEVICE_SETTINGS if spec.getter(snapshot) is not None),
|
||||
"channels": len(channels),
|
||||
"custom": len(custom),
|
||||
}
|
||||
return written, counts
|
||||
|
||||
|
||||
async def _restore(
|
||||
ctx: AppContext, device: Device, snapshot: dict, path: Path, dry_run: bool
|
||||
) -> int:
|
||||
) -> tuple[int, list[Block]]:
|
||||
"""Apply (or preview) a backup file against the current configuration.
|
||||
|
||||
The plan is the answer on a dry run and a record on a real one, so the same listing
|
||||
serves both — drawn for a reader only when nothing was changed, since a run that *did*
|
||||
change things already said so, setting by setting, on stderr.
|
||||
|
||||
Returns:
|
||||
The number of changes applied (always ``0`` for a dry run).
|
||||
The number of changes applied (always ``0`` for a dry run), and the blocks stating
|
||||
what was planned or done.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import SILENT, Facts, Listing
|
||||
|
||||
backup = read_backup(Path(path))
|
||||
custom = await device.get_custom_vars()
|
||||
ops = plan_restore(backup, snapshot, custom)
|
||||
|
||||
def plan(applied: list[tuple], *, drawn: bool) -> list[Block]:
|
||||
listing = Listing(
|
||||
key="operations",
|
||||
columns=(
|
||||
fields.word("operation", "OPERATION"),
|
||||
fields.word("target", "TARGET"),
|
||||
fields.word("value", "VALUE"),
|
||||
),
|
||||
rows=[
|
||||
{
|
||||
"operation": op[0],
|
||||
"target": str(op[1]),
|
||||
"value": str(op[2]) if len(op) > 2 else None,
|
||||
}
|
||||
for op in applied
|
||||
],
|
||||
plain_only=not drawn,
|
||||
)
|
||||
facts = Facts(
|
||||
key="restore",
|
||||
fields=(fields.flag("dry_run", "dry_run"), fields.integer("changes", "changes")),
|
||||
values={"dry_run": dry_run, "changes": 0 if dry_run else len(applied)},
|
||||
shape=SILENT,
|
||||
)
|
||||
return [facts, listing]
|
||||
|
||||
if not ops:
|
||||
ctx.ui.note("[muted]restore: device already matches the backup.[/muted]")
|
||||
return 0
|
||||
ctx.ui.ack("[muted]restore: device already matches the backup.[/muted]")
|
||||
return 0, plan([], drawn=False)
|
||||
|
||||
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")
|
||||
for op in ops:
|
||||
table.add_row(op[0], str(op[1]), str(op[2] if len(op) > 2 else ""))
|
||||
ctx.ui.show(table)
|
||||
ctx.ui.note("[muted]dry run — nothing was changed.[/muted]")
|
||||
return 0
|
||||
ctx.ui.ack("[muted]dry run — nothing was changed.[/muted]")
|
||||
return 0, plan(ops, drawn=True)
|
||||
|
||||
changes = 0
|
||||
for op in ops:
|
||||
if op[0] == "set":
|
||||
changes += await _apply_setting(ctx, device, op[1], op[2], snapshot)
|
||||
await _apply_setting(ctx, device, op[1], op[2], snapshot)
|
||||
changes += 1
|
||||
elif op[0] == "set_custom":
|
||||
await device.set_custom_var(op[1], op[2])
|
||||
changes += 1
|
||||
elif op[0] == "set_channel":
|
||||
await device.set_channel(op[1], op[2], op[3])
|
||||
changes += 1
|
||||
return changes
|
||||
return changes, plan(ops, drawn=False)
|
||||
|
||||
|
||||
async def _export_key(
|
||||
ctx: AppContext, device: Device, out: Path | None, artifacts: list[str]
|
||||
) -> None:
|
||||
"""Export the private key, to a file if ``out`` is given, else to the console."""
|
||||
) -> Facts:
|
||||
"""Export the private key, to a file if ``out`` is given, else as the answer itself.
|
||||
|
||||
Either way the plain face prints **one bare line** — the key, or the path it was
|
||||
written to — because ``config export-key > key.hex`` is expected to hold the key and
|
||||
nothing else, and a caller that asked for a file wants the name of it. The warning
|
||||
that comes with either belongs beside it on screen, not in the file, so it goes to
|
||||
stderr with every other acknowledgement.
|
||||
|
||||
This document carries a private key when one was asked for. That is what the command
|
||||
is, exactly as on the plain face, and no extra gate is introduced here.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import BARE, Facts
|
||||
|
||||
key_hex = await device.export_private_key()
|
||||
written: Path | None = None
|
||||
if out is not None:
|
||||
path = Path(out)
|
||||
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]")
|
||||
written = Path(out)
|
||||
written.parent.mkdir(parents=True, exist_ok=True)
|
||||
written.write_text(key_hex, encoding="utf-8")
|
||||
artifacts.append(str(written))
|
||||
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]")
|
||||
ctx.ui.ack("[warn]private key (keep secret):[/warn]")
|
||||
return Facts(
|
||||
key="key",
|
||||
fields=(fields.hexid("private_key", "private_key"), fields.word("path", "path")),
|
||||
values={
|
||||
"private_key": None if written else key_hex,
|
||||
"path": str(written) if written else None,
|
||||
},
|
||||
shape=BARE,
|
||||
bare="path" if written else "private_key",
|
||||
)
|
||||
|
||||
|
||||
async def _read_channels(device: Device) -> list[dict]:
|
||||
@@ -434,13 +659,153 @@ async def _read_channels(device: Device) -> list[dict]:
|
||||
def _require_yes(yes: bool, what: str) -> None:
|
||||
"""Abort a destructive CLI command unless ``--yes`` was passed.
|
||||
|
||||
Raised as a :class:`typer.BadParameter` rather than printed and exited: a missing
|
||||
confirmation *is* a usage error, so it belongs on stderr in the parser's own words and
|
||||
under the parser's own status (see :mod:`meshterm.core.exitcodes`). Printing it in red
|
||||
on stdout put a colour, and a sentence that is not the command's answer, into whatever
|
||||
was reading the command's answer.
|
||||
|
||||
Args:
|
||||
yes: Whether the user passed ``--yes``.
|
||||
what: Human-readable description of the consequence.
|
||||
|
||||
Raises:
|
||||
typer.Exit: With code 1 if confirmation was not given.
|
||||
typer.BadParameter: If confirmation was not given.
|
||||
"""
|
||||
if not yes:
|
||||
typer.secho(f"Refusing: {what}. Re-run with --yes to confirm.", fg="red")
|
||||
raise typer.Exit(1)
|
||||
raise typer.BadParameter(f"{what}. Re-run with --yes to confirm.")
|
||||
|
||||
|
||||
def _setting_columns() -> tuple:
|
||||
"""One setting row's columns, shared by ``config show`` and ``config get``."""
|
||||
from ..ui import fields
|
||||
|
||||
return (
|
||||
fields.word("key", "key"),
|
||||
fields.rendered("value", "value"),
|
||||
fields.hidden("type"),
|
||||
fields.hidden("label"),
|
||||
fields.hidden("redacted"),
|
||||
)
|
||||
|
||||
|
||||
def _one_setting(spec: SettingSpec, value: Any) -> Facts:
|
||||
"""One setting: the bare value plain, the whole object in the document.
|
||||
|
||||
``config get device_pin`` is the one place the PIN is named deliberately, so it is
|
||||
**not** redacted here — the caller asked for that key by name, which is a different
|
||||
act from dumping every setting into a file.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import BARE, Facts
|
||||
|
||||
return Facts(
|
||||
key="setting",
|
||||
fields=_setting_columns(),
|
||||
values={
|
||||
"key": spec.key,
|
||||
"value": fields.Rendered(value, _script_value(spec, value)),
|
||||
"type": spec.value_type,
|
||||
"label": (spec.choices or {}).get(value) if spec.value_type == "enum" else None,
|
||||
"redacted": False,
|
||||
},
|
||||
shape=BARE,
|
||||
bare="value",
|
||||
)
|
||||
|
||||
|
||||
def _applied(applied: list[dict[str, Any]]) -> Facts:
|
||||
"""What a ``set`` changed — nothing plain, a record for whoever is logging it.
|
||||
|
||||
The exit status is the plain answer and the acknowledgement on stderr is the reassurance;
|
||||
neither is something a script can read back later to find out *what moved*.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import SILENT, Column, Facts
|
||||
|
||||
return Facts(
|
||||
key="applied",
|
||||
fields=(fields.integer("changes", "changes"), Column(key="applied")),
|
||||
values={"changes": len(applied), "applied": applied},
|
||||
shape=SILENT,
|
||||
)
|
||||
|
||||
|
||||
def _channel_written(idx: int, name: str, secret: bytes | None) -> Facts:
|
||||
"""What ``config channel`` wrote into a slot."""
|
||||
from ..core.channels import channel_hash, derive_secret, is_public_channel
|
||||
from ..ui import fields
|
||||
from ..ui.fields import ChannelRef
|
||||
from ..ui.report import SILENT, Facts
|
||||
|
||||
key = secret or derive_secret(name)
|
||||
return Facts(
|
||||
key="channel",
|
||||
fields=(fields.integer("changes", "changes"), fields.channel("channel", lanes=())),
|
||||
values={
|
||||
"changes": 1,
|
||||
"channel": ChannelRef(
|
||||
slot=idx,
|
||||
name=name,
|
||||
public=is_public_channel(name, key),
|
||||
hash=channel_hash(key),
|
||||
),
|
||||
},
|
||||
shape=SILENT,
|
||||
)
|
||||
|
||||
|
||||
def _backed_up(written: Path, counts: dict[str, int]) -> Facts:
|
||||
"""What ``config backup`` wrote, and how much of it.
|
||||
|
||||
The plain face prints the path bare — it is what the caller keeps, and what the thing
|
||||
that ran the command reads next.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import BARE, Facts
|
||||
|
||||
return Facts(
|
||||
key="backup",
|
||||
fields=(
|
||||
fields.word("path", "path"),
|
||||
fields.integer("settings", "settings"),
|
||||
fields.integer("channels", "channels"),
|
||||
fields.integer("custom", "custom"),
|
||||
),
|
||||
values={"path": str(written), **counts},
|
||||
shape=BARE,
|
||||
bare="path",
|
||||
)
|
||||
|
||||
|
||||
def _acted(key: str, **facts: Any) -> Facts:
|
||||
"""An action, stated as data: nothing plain, a document for whoever wanted a record.
|
||||
|
||||
Every one of these prints nothing on the plain face today and should keep doing so —
|
||||
``config advert`` says all it has to say in its exit status. But "prints nothing" is an
|
||||
answer a person can act on and a program cannot, so the machine face gets the fact.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import SILENT, Column, Facts
|
||||
|
||||
def column(name: str, value: Any) -> Column:
|
||||
if isinstance(value, bool):
|
||||
return fields.flag(name, name)
|
||||
if isinstance(value, int) or value is None:
|
||||
return fields.integer(name, name)
|
||||
return Column(key=name)
|
||||
|
||||
return Facts(
|
||||
key=key,
|
||||
fields=tuple(column(name, value) for name, value in facts.items()),
|
||||
values=dict(facts),
|
||||
shape=SILENT,
|
||||
)
|
||||
|
||||
|
||||
async def _try_clock(device: Device) -> int | None:
|
||||
"""The device's current clock, or ``None`` where the firmware will not answer."""
|
||||
try:
|
||||
return await device.get_time()
|
||||
except Exception: # noqa: BLE001 - optional read; the drift is a nicety
|
||||
return None
|
||||
@@ -1,14 +1,25 @@
|
||||
"""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 ..core.models import Contact
|
||||
from ..ui.report import Listing
|
||||
|
||||
|
||||
@register
|
||||
class ContactsTool(Tool):
|
||||
@@ -32,7 +43,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 +74,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)
|
||||
listing = _listing(ordered_contacts(contacts, counts, sort), counts, prefix_bytes)
|
||||
return ToolResult(
|
||||
summary={"contacts": len(contacts)},
|
||||
report=(listing,),
|
||||
exit_code=exitcodes.OK if contacts else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
"""Register the ``contacts`` subcommand.
|
||||
@@ -83,6 +98,7 @@ class ContactsTool(Tool):
|
||||
app: The Typer application.
|
||||
"""
|
||||
from ..cli import run_tool_command
|
||||
from ..ui.widgets import ContactsSort
|
||||
|
||||
@app.command(name=self.name, help=self.help)
|
||||
def _contacts(
|
||||
@@ -90,4 +106,71 @@ class ContactsTool(Tool):
|
||||
"heard", "--sort", "-s", help="Order contacts by: heard, name, packets"
|
||||
),
|
||||
) -> None:
|
||||
# `ContactsSort.from_name` falls back to the first column for an unknown name,
|
||||
# deliberately, so a saved preference written by an older build cannot wedge
|
||||
# the menu's list. On the command line the same silence hides a typo: the
|
||||
# caller asked for one order and got another, with nothing said.
|
||||
if sort not in ContactsSort.names():
|
||||
choices = ", ".join(ContactsSort.names())
|
||||
raise typer.BadParameter(f"--sort must be one of: {choices} (got {sort!r})")
|
||||
run_tool_command(self, {"sort": sort})
|
||||
|
||||
|
||||
def _listing(contacts: list[Contact], counts: dict[str, int], prefix_bytes: int) -> Listing:
|
||||
"""The contact list, stated once for both faces.
|
||||
|
||||
``TYPE`` is the node's advertised role in words, where the menu draws a coloured glyph:
|
||||
a monochrome ``▲`` would need a legend and the CLI has no legends. ``HEARD`` is a
|
||||
relative age, because "recently?" is the question this listing is opened to answer.
|
||||
``HASH`` is the token ``--path`` and ``--to`` take — it used to have to be sliced out
|
||||
of ``KEY`` by hand — and ``LOCATION`` is a fact the model has always carried and no
|
||||
column had room for. ``KEY`` stays full and last, so it runs off the right harmlessly
|
||||
and is never elided: a truncated key is not something a caller can hand back.
|
||||
|
||||
Args:
|
||||
contacts: The contacts, already in the requested order.
|
||||
counts: Overheard-packet tallies keyed by node id (from passive monitoring).
|
||||
prefix_bytes: The device's path-hash width, which is what makes ``HASH`` the
|
||||
token a forced path addresses this node by rather than an arbitrary slice.
|
||||
|
||||
Returns:
|
||||
The listing.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
from ..ui.widgets import _contact_pkts
|
||||
|
||||
rows = []
|
||||
for contact in contacts:
|
||||
key = (contact.public_key or "").lower()
|
||||
rows.append(
|
||||
{
|
||||
"node": fields.NodeRef(
|
||||
name=contact.name,
|
||||
key=key or None,
|
||||
hash=(key or contact.key_prefix.lower())[: prefix_bytes * 2] or None,
|
||||
type=NODE_TYPE_LABELS.get(contact.node_type),
|
||||
),
|
||||
"heard_at": contact.last_seen,
|
||||
"packets": _contact_pkts(contact, counts),
|
||||
"position": (
|
||||
fields.Position(contact.lat, contact.lon) if contact.has_location else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return Listing(
|
||||
key="contacts",
|
||||
columns=(
|
||||
fields.node(
|
||||
"node",
|
||||
lanes=(("name", "NAME"), ("type", "TYPE"), ("hash", "HASH"), ("key", "KEY")),
|
||||
),
|
||||
fields.when("heard_at", "HEARD", absent="never"),
|
||||
fields.integer("packets", "PKTS"),
|
||||
fields.position(),
|
||||
),
|
||||
rows=rows,
|
||||
# The node's four lanes are not contiguous: what a reader scans for — who, what,
|
||||
# how recently, how much — comes first, and the two long hex fields go right.
|
||||
order=("NAME", "TYPE", "HEARD", "PKTS", "HASH", "LOCATION", "KEY"),
|
||||
)
|
||||
+175
-60
@@ -17,14 +17,18 @@ of the outbox screen's own Send-now, Cancel, and Clear-finished actions.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core.connection import DeviceCommandError
|
||||
from ..core import exitcodes
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..core.courier_store import QueuedMessage
|
||||
from ..ui.report import Facts, Listing
|
||||
|
||||
|
||||
@register
|
||||
class CourierTool(Tool):
|
||||
@@ -97,17 +101,19 @@ class CourierTool(Tool):
|
||||
name = str(params["contact"])
|
||||
needle = name.casefold()
|
||||
contact = next((c for c in contacts if c.name.casefold() == needle), None)
|
||||
# The named contact, not the radio: nothing is queued and nothing is sent, so
|
||||
# these are bad arguments (2) rather than device failures (4).
|
||||
if contact is None:
|
||||
raise DeviceCommandError(f"unknown contact: {name!r}")
|
||||
raise typer.BadParameter(f"unknown contact: {name!r}")
|
||||
key = contact_watch_key(contact)
|
||||
if key is None:
|
||||
raise DeviceCommandError(f"{name!r} has no usable key to address")
|
||||
raise typer.BadParameter(f"{name!r} has no usable key to address")
|
||||
|
||||
not_before = None
|
||||
if params.get("at"):
|
||||
not_before = parse_clock(str(params["at"]))
|
||||
if not_before is None:
|
||||
raise DeviceCommandError(
|
||||
raise typer.BadParameter(
|
||||
f"--at wants a clock time like 07:00, not {params['at']!r}"
|
||||
)
|
||||
message = ctx.courier_store.queue(
|
||||
@@ -118,62 +124,27 @@ 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)"
|
||||
)
|
||||
return ToolResult(
|
||||
summary={"queued": message.ident, "contact": contact.name, "at": params.get("at")}
|
||||
summary={"queued": message.ident, "contact": contact.name, "at": params.get("at")},
|
||||
report=(_queued(message, contact.name, not_before),),
|
||||
)
|
||||
|
||||
async def _cli_list(self, ctx: AppContext) -> ToolResult:
|
||||
"""Print the outbox — waiting entries then finished ones — as a table.
|
||||
"""State the outbox — waiting entries then finished ones.
|
||||
|
||||
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})
|
||||
|
||||
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()
|
||||
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"
|
||||
elif m.status == DELIVERED:
|
||||
state = Text("✓ delivered", style="ok")
|
||||
when = f"delivered {format_ago(_age_seconds(m.finished or m.created))}"
|
||||
else:
|
||||
state = Text("✗ gave up", style="err")
|
||||
when = f"gave up {format_ago(_age_seconds(m.finished or m.created))}"
|
||||
table.add_row(
|
||||
str(m.ident),
|
||||
state,
|
||||
Text(m.node_name, style="brand"),
|
||||
_shorten(m.text),
|
||||
Text(when, style="muted"),
|
||||
)
|
||||
ctx.ui.show(table)
|
||||
return ToolResult(summary={"entries": len(entries)})
|
||||
return ToolResult(
|
||||
summary={"entries": len(entries)},
|
||||
report=(_outbox(entries),),
|
||||
exit_code=exitcodes.OK if entries else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
async def _cli_send(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Force one delivery attempt for a waiting entry, right now.
|
||||
@@ -183,7 +154,10 @@ class CourierTool(Tool):
|
||||
"""
|
||||
ident = int(params["id"])
|
||||
if ctx.courier_store.get(ident) is None:
|
||||
raise DeviceCommandError(f"no outbox entry #{ident}")
|
||||
# A bad argument, not a device failure: the outbox is a local file, nothing was
|
||||
# transmitted, and no retry will make an id exist. Exit 4 told a caller to try
|
||||
# again; exit 2 tells it to fix the command, which is the truth.
|
||||
raise typer.BadParameter(f"no outbox entry #{ident} (see `courier list`)")
|
||||
outcome = await ctx.courier.attempt_now(ident)
|
||||
notes = {
|
||||
"delivered": "[ok]✓ delivered[/ok] — acknowledged by the contact",
|
||||
@@ -195,17 +169,27 @@ 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))
|
||||
return ToolResult(summary={"id": ident, "outcome": outcome})
|
||||
ctx.ui.ack(notes.get(outcome, outcome))
|
||||
return ToolResult(
|
||||
summary={"id": ident, "outcome": outcome},
|
||||
report=(_attempted(ident, 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]")
|
||||
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"[warn]cancelled outbox entry #{ident}[/warn]")
|
||||
return ToolResult(
|
||||
summary={"id": ident, "cancelled": True},
|
||||
report=(_did(("id", ident), ("cancelled", True)),),
|
||||
)
|
||||
ctx.ui.ack(f"[muted]no waiting entry #{ident} to cancel[/muted]")
|
||||
return ToolResult(
|
||||
summary={"id": ident, "cancelled": False},
|
||||
report=(_did(("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 +197,14 @@ 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},
|
||||
report=(_did(("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 +214,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"
|
||||
@@ -268,3 +256,130 @@ class CourierTool(Tool):
|
||||
run_tool_command(self, {"cli_action": "clear"})
|
||||
|
||||
app.add_typer(courier_app, name=self.name)
|
||||
|
||||
|
||||
def _recipient(name: str | None, node_key: str | None) -> object:
|
||||
"""The outbox's addressee as the shared node shape.
|
||||
|
||||
The outbox stores a name and the 12-hex id it addresses, so three of the five fields
|
||||
are honestly ``null`` — and the id goes in ``hash`` rather than ``key``, because that
|
||||
is what it is. A key is the full value and is never truncated by this app's choosing;
|
||||
calling a six-byte prefix one would hand a caller something ``--to`` cannot take.
|
||||
"""
|
||||
from ..ui.fields import NodeRef
|
||||
|
||||
return NodeRef(name=name, hash=(node_key or "").lower() or None)
|
||||
|
||||
|
||||
def _queued(message: QueuedMessage, name: str, not_before: object) -> Facts:
|
||||
"""What ``courier queue`` put in the outbox.
|
||||
|
||||
The plain face prints the **id and nothing else**, because the id is the one fact a
|
||||
caller has to keep — it is what ``send`` and ``cancel`` take — and everything else on
|
||||
the line is what they just typed. The document carries the rest, because a caller
|
||||
logging the queue has no other record of it.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
|
||||
return Facts(
|
||||
key="queued",
|
||||
fields=(
|
||||
fields.integer("id", "id"),
|
||||
fields.node("node", lanes=()),
|
||||
fields.hidden("scheduled_at"),
|
||||
fields.hidden("text"),
|
||||
),
|
||||
values={
|
||||
"id": message.ident,
|
||||
"node": _recipient(name, message.node_key),
|
||||
"scheduled_at": not_before,
|
||||
"text": message.text,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _outbox(entries: list) -> Listing:
|
||||
"""The outbox, waiting entries then finished ones.
|
||||
|
||||
The deliberate asymmetry between the two time columns: ``SCHEDULED`` is an
|
||||
*appointment* the caller set with ``--at``, so it stays a wall-clock instant, and
|
||||
``FINISHED`` is an age, because "how long ago did it go" is what anyone reading a
|
||||
delivered row wants. The mixed widths are the price of both columns being right, and
|
||||
``--absolute`` makes them uniform for anyone who dislikes it.
|
||||
"""
|
||||
from ..core.courier_store import DELIVERED, QUEUED
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
rows = []
|
||||
for m in entries:
|
||||
if m.status == QUEUED:
|
||||
state = "waiting"
|
||||
elif m.status == DELIVERED:
|
||||
state = "delivered"
|
||||
else:
|
||||
state = "gave up"
|
||||
rows.append(
|
||||
{
|
||||
"id": m.ident,
|
||||
"state": state,
|
||||
"node": _recipient(m.node_name, m.node_key),
|
||||
# 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.
|
||||
"scheduled_at": m.not_before,
|
||||
"finished_at": m.finished,
|
||||
# Not shortened the way the outbox screen shortens it: nothing wraps here.
|
||||
"text": m.text,
|
||||
}
|
||||
)
|
||||
return Listing(
|
||||
key="outbox",
|
||||
columns=(
|
||||
fields.integer("id", "ID"),
|
||||
fields.word("state", "STATE"),
|
||||
fields.node("node", lanes=(("name", "NODE"),)),
|
||||
fields.instant("scheduled_at", "SCHEDULED"),
|
||||
fields.when("finished_at", "FINISHED"),
|
||||
fields.free("text", "TEXT"),
|
||||
),
|
||||
rows=rows,
|
||||
)
|
||||
|
||||
|
||||
def _attempted(ident: int, outcome: str) -> Facts:
|
||||
"""What one forced delivery attempt came to.
|
||||
|
||||
``outcome`` is one of the closed set the help lists, kept verbatim — spaces and all —
|
||||
so both faces say the same word and a script matching ``no ack`` matches what a person
|
||||
reads.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
|
||||
return Facts(
|
||||
key="attempt",
|
||||
fields=(fields.hidden("id"), fields.word("outcome", "outcome")),
|
||||
values={"id": ident, "outcome": outcome},
|
||||
)
|
||||
|
||||
|
||||
def _did(*facts: tuple[str, object]) -> Facts:
|
||||
"""An acknowledgement as data: nothing plain, a document for whoever is logging.
|
||||
|
||||
``cancel`` and ``clear`` say everything they have to say in their exit status, which
|
||||
is the right plain answer — but a caller that wants to record *what* was cancelled has
|
||||
nowhere to read it, and that is what the document is for.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import SILENT, Facts
|
||||
|
||||
def column(key: str, value: object): # noqa: ANN202 - one of two shapes
|
||||
return fields.flag(key, key) if isinstance(value, bool) else fields.integer(key, key)
|
||||
|
||||
return Facts(
|
||||
key="outcome",
|
||||
fields=tuple(column(key, value) for key, value in facts),
|
||||
values=dict(facts),
|
||||
shape=SILENT,
|
||||
)
|
||||
+103
-78
@@ -11,27 +11,23 @@ the prompt shown when the menu launches (which smoke-tests the choice before con
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
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
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Listing
|
||||
|
||||
#: 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
|
||||
@@ -46,7 +42,7 @@ class DevicesTool(Tool):
|
||||
menu_visible = False # CLI-only: a startup diagnostic with no place in a connected session
|
||||
|
||||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Enumerate serial devices and render them as a table.
|
||||
"""Enumerate attached and advertising devices, and state them as a listing.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
@@ -62,74 +58,25 @@ class DevicesTool(Tool):
|
||||
active = ctx.selected_device
|
||||
active_target = ctx.ble_override or ctx.port_override or (active.target if active else None)
|
||||
|
||||
if ctx.json_output:
|
||||
import json
|
||||
|
||||
payload = [
|
||||
{
|
||||
"transport": d.transport,
|
||||
"port": d.port,
|
||||
"address": d.address,
|
||||
"target": d.target,
|
||||
"label": d.label,
|
||||
"vendor": d.vendor_label,
|
||||
"model": (known[d.stable_id].hardware_model if d.stable_id in known else ""),
|
||||
"likely_lora": d.is_likely_lora,
|
||||
"confidence": d.confidence,
|
||||
"confirmed": d.stable_id in known,
|
||||
"serial_number": d.serial_number,
|
||||
"stable_id": d.stable_id,
|
||||
"remembered": remembered is not None and remembered.matches(d),
|
||||
"active": d.target == active_target,
|
||||
}
|
||||
for d in devices
|
||||
]
|
||||
ctx.console.print_json(json.dumps(payload))
|
||||
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. The empty listing still reaches the
|
||||
# machine face as `[]`, which is a document a consumer can read — where an
|
||||
# empty stdout is a parse error it would have to tell apart from a real one.
|
||||
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": len(devices)},
|
||||
report=(_listing(devices, known, remembered, active_target),),
|
||||
# Changed from the 0 this used to return under --json: a rendering flag has no
|
||||
# business changing the report, and the plain path has always said 5 here.
|
||||
exit_code=exitcodes.OK if devices else exitcodes.NO_RESULT,
|
||||
)
|
||||
return ToolResult(summary={"count": len(devices)})
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
"""Register the ``devices`` subcommand (inventory only; no connection).
|
||||
@@ -139,10 +86,88 @@ 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, remembered: object, active_target: str | None) -> Listing:
|
||||
"""The device inventory, stated once for both faces.
|
||||
|
||||
``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 — which is why the machine face carries the
|
||||
same three words rather than the boolean it used to.
|
||||
|
||||
Args:
|
||||
devices: The discovered devices.
|
||||
known: Remembered device records, keyed by stable id.
|
||||
remembered: The remembered default device, if there is one.
|
||||
active_target: The target this invocation is (or would be) using.
|
||||
|
||||
Returns:
|
||||
The listing.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
rows = []
|
||||
for device in devices:
|
||||
confirmed = known.get(device.stable_id)
|
||||
rows.append(
|
||||
{
|
||||
"target": device.target,
|
||||
"transport": device.transport,
|
||||
"port": device.port,
|
||||
"address": device.address,
|
||||
"label": (confirmed.node_name if confirmed else "") or device.label,
|
||||
"hardware": (confirmed.hardware_model if confirmed else "") or device.vendor_label,
|
||||
"meshcore": "yes" if confirmed else _MAYBE.get(device.confidence, "no"),
|
||||
"confidence": device.confidence,
|
||||
"serial_number": device.serial_number,
|
||||
"stable_id": device.stable_id,
|
||||
"confirmed": confirmed is not None,
|
||||
"remembered": remembered is not None and remembered.matches(device),
|
||||
"active": device.target == active_target,
|
||||
}
|
||||
)
|
||||
return Listing(
|
||||
key="devices",
|
||||
columns=(
|
||||
fields.word("target", "TARGET"),
|
||||
fields.word("transport", "TRANSPORT"),
|
||||
# The two halves `target` is one of, kept apart for a caller that has to know
|
||||
# which transport it is holding without parsing the target for a colon.
|
||||
fields.hidden("port"),
|
||||
fields.hidden("address"),
|
||||
fields.name("label", "NAME"),
|
||||
fields.name("hardware", "HARDWARE"),
|
||||
fields.word("meshcore", "MESHCORE"),
|
||||
# What the "maybe" was derived from. A person reading the column has the
|
||||
# vendor label beside it and can see for themselves; a program cannot.
|
||||
fields.hidden("confidence"),
|
||||
fields.word("serial_number", "SERIAL"),
|
||||
fields.hidden("stable_id"),
|
||||
fields.hidden("confirmed"),
|
||||
fields.hidden("remembered"),
|
||||
fields.flag("active", "ACTIVE"),
|
||||
),
|
||||
rows=rows,
|
||||
)
|
||||
+178
-23
@@ -3,7 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
from rich.panel import Panel
|
||||
@@ -13,6 +15,9 @@ from ..context import AppContext
|
||||
from ..core.connection import Device
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Column, Facts
|
||||
|
||||
#: MeshCore advert-type byte -> human-readable node role.
|
||||
_ROLES = {1: "companion", 2: "repeater", 3: "room server", 4: "sensor"}
|
||||
|
||||
@@ -47,8 +52,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:
|
||||
return ToolResult(
|
||||
summary={"name": snapshot.get("name")},
|
||||
report=(await status_facts(device, snapshot),),
|
||||
)
|
||||
|
||||
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 +75,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 +98,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_facts`). 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 +135,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 +191,130 @@ 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_facts(device: Device, snapshot: dict) -> Facts:
|
||||
"""The device's live status as one set of 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 number never
|
||||
carries a unit in its value, on either face.
|
||||
|
||||
The three second-valued readings and the clock drift carry a **gloss** beside the
|
||||
figure: ``uptime_s 93784 (1d 2h)``. This is the one key/value block that may, and
|
||||
the reason is that it is the one with no ``get`` and no ``set`` behind it — there is no
|
||||
round trip to protect, the key still says what the number counts, and ``93784`` is a
|
||||
reading nobody can hold in their head. The document carries the number alone, having
|
||||
nowhere useful to put a phrase.
|
||||
|
||||
A reading the firmware did not answer for is **left out** of the plain block rather
|
||||
than dashed: these are optional queries, and a missing key says "this device does not
|
||||
report it" where a dash would claim it reported nothing. The document keeps the key and
|
||||
writes ``null`` — see :attr:`~meshterm.ui.report.Facts.omit_absent` for why the two
|
||||
faces disagree here on purpose.
|
||||
|
||||
Args:
|
||||
device: The connected device to query.
|
||||
snapshot: The already-built settings snapshot (for role and identity).
|
||||
|
||||
Returns:
|
||||
The facts block.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
|
||||
readings = await _read_status(device)
|
||||
info, battery, stats = readings["info"], readings["battery"], readings["stats"]
|
||||
|
||||
level = battery.get("level")
|
||||
adv_type = snapshot.get("adv_type")
|
||||
clock = readings["clock"]
|
||||
values: dict[str, Any] = {
|
||||
# Identity first: which radio this is. Everything it *can be set to* is
|
||||
# `config show`'s answer, not this one.
|
||||
"name": snapshot.get("name"),
|
||||
"public_key": str(snapshot.get("public_key") or "").lower() or None,
|
||||
"role": _ROLES.get(int(adv_type), f"type {adv_type}") if adv_type is not None else None,
|
||||
"model": info.get("model"),
|
||||
"firmware": _firmware(info) or None,
|
||||
"battery_v": round(int(level) / 1000, 2) if level else None,
|
||||
"storage_used_kb": battery.get("used_kb", 0) if battery.get("total_kb") else None,
|
||||
"storage_total_kb": battery.get("total_kb"),
|
||||
"clock_at": (
|
||||
datetime.fromtimestamp(clock, tz=timezone.utc).astimezone() if clock else None
|
||||
),
|
||||
"clock_drift_s": (clock - int(time.time())) if clock else None,
|
||||
"uptime_s": stats.get("uptime_secs"),
|
||||
"noise_floor_dbm": stats.get("noise_floor"),
|
||||
"last_rssi_dbm": stats.get("last_rssi"),
|
||||
"last_snr_db": stats.get("last_snr"),
|
||||
"tx_air_s": stats.get("tx_air_secs"),
|
||||
"rx_air_s": stats.get("rx_air_secs"),
|
||||
"packets_sent": stats.get("sent"),
|
||||
"packets_received": stats.get("recv"),
|
||||
"receive_errors": stats.get("recv_errors"),
|
||||
}
|
||||
return Facts(
|
||||
key="info",
|
||||
fields=(
|
||||
fields.word("name", "name"),
|
||||
fields.hexid("public_key", "public_key"),
|
||||
fields.word("role", "role"),
|
||||
fields.word("model", "model"),
|
||||
fields.word("firmware", "firmware"),
|
||||
fields.decimal("battery_v", "battery_v", ".2f"),
|
||||
fields.integer("storage_used_kb", "storage_used_kb"),
|
||||
fields.integer("storage_total_kb", "storage_total_kb"),
|
||||
fields.instant("clock_at", "clock_at"),
|
||||
_glossed("clock_drift_s", _drift_gloss),
|
||||
_glossed("uptime_s", _span_gloss),
|
||||
fields.integer("noise_floor_dbm", "noise_floor_dbm"),
|
||||
fields.integer("last_rssi_dbm", "last_rssi_dbm"),
|
||||
fields.snr("last_snr_db", "last_snr_db"),
|
||||
_glossed("tx_air_s", _span_gloss),
|
||||
_glossed("rx_air_s", _span_gloss),
|
||||
fields.integer("packets_sent", "packets_sent"),
|
||||
fields.integer("packets_received", "packets_received"),
|
||||
fields.integer("receive_errors", "receive_errors"),
|
||||
),
|
||||
values=values,
|
||||
omit_absent=True,
|
||||
)
|
||||
|
||||
|
||||
def _glossed(key: str, gloss: Callable[[int], str]) -> Column:
|
||||
"""A seconds reading printed as its own number with a readable span beside it."""
|
||||
from ..ui import script
|
||||
from ..ui.report import Column, Lane
|
||||
|
||||
def cell(value: Any) -> str:
|
||||
if value is None:
|
||||
return script.NONE
|
||||
return f"{value} ({gloss(int(value))})"
|
||||
|
||||
return Column(key=key, lanes=(Lane(header=key, render=cell),))
|
||||
|
||||
|
||||
def _span_gloss(seconds: int) -> str:
|
||||
"""``93784`` → ``1d 2h``: the figure a person can hold in their head."""
|
||||
from ..ui import script
|
||||
|
||||
return script.duration(seconds)
|
||||
|
||||
|
||||
def _drift_gloss(seconds: int) -> str:
|
||||
"""The device clock's error, in the direction a reader would say it."""
|
||||
from ..ui import script
|
||||
|
||||
if abs(seconds) < 2:
|
||||
return "in sync"
|
||||
return f"{script.duration(seconds)} {'fast' if seconds > 0 else 'slow'}"
|
||||
|
||||
|
||||
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:
|
||||
|
||||
+166
-55
@@ -18,17 +18,21 @@ summarizes the window when it ends.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
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 ..core.events import EventKind, MeshEvent
|
||||
from ..core.models import HeardNode, Observation
|
||||
from ..core.models import NODE_TYPE_LABELS, HeardNode, Observation
|
||||
from ..ui import renderers
|
||||
from ..ui.fields import NodeRef, Position
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Listing
|
||||
|
||||
|
||||
@register
|
||||
class MonitorTool(Tool):
|
||||
@@ -74,13 +78,18 @@ 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] = []
|
||||
|
||||
@@ -89,39 +98,54 @@ class MonitorTool(Tool):
|
||||
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}")
|
||||
emit(
|
||||
{
|
||||
"observed_at": obs.observed_at,
|
||||
"node": NodeRef(
|
||||
name=obs.name,
|
||||
key=(obs.public_key or "").lower() or None,
|
||||
hash=obs.node,
|
||||
type=NODE_TYPE_LABELS.get(obs.node_type),
|
||||
),
|
||||
"kind": obs.kind,
|
||||
"snr_db": obs.snr,
|
||||
"rssi_dbm": obs.rssi,
|
||||
"position": _position(obs.lat, obs.lon),
|
||||
"path": _relays(obs.path),
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribe = ctx.events.subscribe(on_observation, EventKind.OBSERVATION)
|
||||
try:
|
||||
if seconds:
|
||||
await asyncio.sleep(seconds)
|
||||
else:
|
||||
await asyncio.Event().wait() # until Ctrl-C / cancellation
|
||||
except (KeyboardInterrupt, asyncio.CancelledError): # pragma: no cover - interactive
|
||||
pass
|
||||
finally:
|
||||
unsubscribe()
|
||||
await ctx.monitor.stop() # close the run row so the capture is a full record
|
||||
with renderers.stream(ctx, _live_packets()) as emit:
|
||||
unsubscribe = ctx.events.subscribe(on_observation, EventKind.OBSERVATION)
|
||||
try:
|
||||
if seconds:
|
||||
await asyncio.sleep(seconds)
|
||||
else:
|
||||
await asyncio.Event().wait() # until Ctrl-C / cancellation
|
||||
except (KeyboardInterrupt, asyncio.CancelledError): # pragma: no cover - interactive
|
||||
pass
|
||||
finally:
|
||||
unsubscribe()
|
||||
await ctx.monitor.stop() # close the run row so the capture is a full record
|
||||
|
||||
by_node: dict[str, list[Observation]] = {}
|
||||
for obs in seen:
|
||||
by_node.setdefault(obs.node, []).append(obs)
|
||||
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(_heard_table(nodes))
|
||||
nodes.sort(key=lambda n: n.last_seen, reverse=True)
|
||||
# The window is over, so its facts are worth saying — on stderr, where everything
|
||||
# about a run goes, keeping the packets a caller redirected the run for on their own.
|
||||
script.stderr_console().print(
|
||||
f"captured {len(seen)} packet{'' if len(seen) == 1 else 's'} "
|
||||
f"from {len(nodes)} node{'' if len(nodes) == 1 else 's'}"
|
||||
+ (f" in {seconds}s" if seconds else ""),
|
||||
style="muted",
|
||||
highlight=False,
|
||||
)
|
||||
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'}"
|
||||
),
|
||||
report=(_heard(nodes),),
|
||||
exit_code=exitcodes.OK if seen else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
@@ -141,33 +165,120 @@ class MonitorTool(Tool):
|
||||
run_tool_command(self, {"seconds": seconds})
|
||||
|
||||
|
||||
def _heard_table(heard: list[HeardNode]) -> Table:
|
||||
"""Render the capture window's heard-node summary table.
|
||||
def _position(lat: float | None, lon: float | None) -> Position | None:
|
||||
"""A reception's shared position, or ``None`` where the packet carried none."""
|
||||
return Position(lat, lon) if lat is not None and lon is not None else None
|
||||
|
||||
|
||||
def _relays(path: str | None) -> list[str] | None:
|
||||
"""A packet's relay chain as hashes, in propagation order.
|
||||
|
||||
Hashes rather than the shared node shape, and that is the honest answer: nothing
|
||||
resolves a relay to a node at reception time, so a node object per hop would be four
|
||||
``null``s wrapped round a hash. ``[]`` is a direct reception (zero hops); ``None``
|
||||
means the packet class carries no path at all, which is a different fact.
|
||||
"""
|
||||
if path is None:
|
||||
return None
|
||||
return [hop for hop in path.split(",") if hop]
|
||||
|
||||
|
||||
def _live_packets() -> Listing:
|
||||
"""The shape the live capture streams: one record per packet, as it lands.
|
||||
|
||||
``TIME`` is absolute where a listing's times are ages, and for the reason the whole
|
||||
rule turns on: every row of a live tail would read ``now``. The **name goes last**, so
|
||||
the one field with no width cannot push a lane — which is what finally let the two
|
||||
streams have columns at all, and with them the quoting could go.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
def pin(column, width: int): # noqa: ANN001, ANN202 - one column in, one column out
|
||||
return replace(column, lanes=tuple(replace(lane, width=width) for lane in column.lanes))
|
||||
|
||||
node = fields.node("node", lanes=(("hash", "NODE"), ("name", "NAME")))
|
||||
node = replace(
|
||||
node,
|
||||
lanes=(replace(node.lanes[0], width=8), node.lanes[1]),
|
||||
)
|
||||
return Listing(
|
||||
key="observations",
|
||||
columns=(
|
||||
pin(fields.instant("observed_at", "TIME"), 25),
|
||||
node,
|
||||
# The packet class, which the plain stream has never had room for. New
|
||||
# surface: the simulator does not exercise every class, so a consumer should
|
||||
# treat an unfamiliar word as a word rather than an error.
|
||||
fields.hidden("kind"),
|
||||
pin(fields.snr("snr_db", "SNR_DB"), 6),
|
||||
pin(fields.decimal("rssi_dbm", "RSSI_DBM", ".0f"), 8),
|
||||
pin(fields.position(), 19),
|
||||
fields.hidden("path"),
|
||||
),
|
||||
order=("TIME", "NODE", "SNR_DB", "RSSI_DBM", "LOCATION", "NAME"),
|
||||
)
|
||||
|
||||
|
||||
def _heard(heard: list[HeardNode]) -> Listing:
|
||||
"""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.
|
||||
|
||||
Drawn **for a person only**. Every figure in it is computable from the records that
|
||||
scrolled past above it, and emitting it as a second *shape* of line in the middle of an
|
||||
otherwise homogeneous NDJSON stream would cost every consumer a discriminator it would
|
||||
never otherwise need.
|
||||
|
||||
Args:
|
||||
heard: Aggregated per-node statistics for the window.
|
||||
|
||||
Returns:
|
||||
A Rich :class:`Table` of node, packet count, SNR, RSSI, and location.
|
||||
The listing.
|
||||
"""
|
||||
from ..ui.theme import snr_style
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
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")
|
||||
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("—")
|
||||
)
|
||||
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
|
||||
return Listing(
|
||||
key="heard",
|
||||
columns=(
|
||||
fields.node("node", lanes=(("hash", "NODE"), ("name", "NAME"))),
|
||||
fields.integer("packets", "PKTS"),
|
||||
fields.snr("median_snr_db", "MEDIAN_SNR_DB"),
|
||||
fields.snr("best_snr_db", "BEST_SNR_DB"),
|
||||
fields.decimal("last_rssi_dbm", "RSSI_DBM", ".0f"),
|
||||
fields.when("last_heard_at", "HEARD"),
|
||||
fields.position(),
|
||||
),
|
||||
rows=[
|
||||
{
|
||||
"node": NodeRef(
|
||||
name=node.name,
|
||||
key=(node.public_key or "").lower() or None,
|
||||
hash=node.node,
|
||||
type=NODE_TYPE_LABELS.get(node.node_type),
|
||||
),
|
||||
"packets": node.count,
|
||||
"median_snr_db": node.median_snr,
|
||||
"best_snr_db": node.best_snr,
|
||||
"last_rssi_dbm": node.last_rssi,
|
||||
"last_heard_at": node.last_seen,
|
||||
"position": _position(node.lat, node.lon),
|
||||
}
|
||||
for node in heard
|
||||
],
|
||||
order=(
|
||||
"NODE",
|
||||
"NAME",
|
||||
"PKTS",
|
||||
"MEDIAN_SNR_DB",
|
||||
"BEST_SNR_DB",
|
||||
"RSSI_DBM",
|
||||
"HEARD",
|
||||
"LOCATION",
|
||||
),
|
||||
plain_only=True,
|
||||
)
|
||||
+168
-10
@@ -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 ..ui.report import Block, Facts, Listing
|
||||
|
||||
|
||||
@register
|
||||
class PreferencesTool(Tool):
|
||||
@@ -73,31 +83,50 @@ 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 [])
|
||||
blocks: list[Block] = []
|
||||
applied: list[dict[str, Any]] = []
|
||||
changes = 0
|
||||
for op in ops:
|
||||
action = op[0]
|
||||
if action == "show":
|
||||
ctx.ui.show(preferences_table(prefs, ctx.console.width))
|
||||
if scripted:
|
||||
blocks.append(_listing(prefs))
|
||||
else:
|
||||
ctx.ui.show(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. The
|
||||
# document keeps both, since a parser has no lane to read them off.
|
||||
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})"
|
||||
)
|
||||
if scripted:
|
||||
blocks.append(_one(prefs, spec))
|
||||
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])
|
||||
if after != before:
|
||||
changes += 1
|
||||
applied.append({"key": op[1], "previous": before, "value": after})
|
||||
elif action == "reset":
|
||||
was = {s.key: prefs.get(s.key) for s in PREFERENCES if prefs.is_overridden(s.key)}
|
||||
changes += prefs.reset()
|
||||
applied.extend(
|
||||
{"key": key, "previous": before, "value": prefs.get(key)}
|
||||
for key, before in was.items()
|
||||
)
|
||||
|
||||
if changes:
|
||||
prefs.save()
|
||||
if scripted and any(op[0] in ("set", "reset") for op in ops):
|
||||
blocks.append(_written(applied, prefs))
|
||||
|
||||
plural = "" if changes == 1 else "s"
|
||||
message = (
|
||||
@@ -106,7 +135,9 @@ class PreferencesTool(Tool):
|
||||
if changes
|
||||
else None
|
||||
)
|
||||
return ToolResult(summary={"changes": changes}, message=message)
|
||||
return ToolResult(
|
||||
summary={"changes": changes}, message=message, report=tuple(blocks) or None
|
||||
)
|
||||
|
||||
# -- CLI --------------------------------------------------------------------
|
||||
|
||||
@@ -118,7 +149,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 +186,130 @@ 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 _row(prefs: Preferences, spec: PrefSpec) -> dict[str, Any]:
|
||||
"""One preference as a report row, in the shared setting shape.
|
||||
|
||||
``overridden`` is the fact the plain face makes a reader derive by comparing two
|
||||
columns; a document that carried the same two numbers and left the comparison to the
|
||||
consumer would be handing over homework it has the answer to.
|
||||
"""
|
||||
from ..ui.fields import Rendered
|
||||
|
||||
value = prefs.get(spec.key)
|
||||
return {
|
||||
"key": spec.key,
|
||||
"value": Rendered(value, _script_value(spec, value)),
|
||||
"type": spec.value_type,
|
||||
# The reader's word for an enum's own key — never what `set` takes, which is the
|
||||
# key itself and rides in `value`.
|
||||
"label": (spec.choices or {}).get(value) if spec.value_type == "enum" else None,
|
||||
"redacted": False,
|
||||
"default": Rendered(spec.default, _script_value(spec, spec.default)),
|
||||
"overridden": prefs.is_overridden(spec.key),
|
||||
"help": spec.description,
|
||||
}
|
||||
|
||||
|
||||
def _columns() -> tuple:
|
||||
"""The preference row's columns, shared by ``show`` and ``get``."""
|
||||
from ..ui import fields
|
||||
|
||||
return (
|
||||
fields.word("key", "PREFERENCE"),
|
||||
fields.rendered("value", "VALUE"),
|
||||
fields.hidden("type"),
|
||||
fields.hidden("label"),
|
||||
fields.hidden("redacted"),
|
||||
fields.rendered("default", "DEFAULT"),
|
||||
fields.hidden("overridden"),
|
||||
fields.note("help", "DESCRIPTION"),
|
||||
)
|
||||
|
||||
|
||||
def _listing(prefs: Preferences) -> Listing:
|
||||
"""Every preference, its value, its built-in default, and what it is for.
|
||||
|
||||
``DESCRIPTION`` comes straight from :attr:`PrefSpec.help` — the text is already
|
||||
written, one short line each, and as the *last* column it can never disturb an
|
||||
alignment or wrap. It used to be cut for parser hygiene, which is a bargain the plain
|
||||
face no longer has to make: a listing is read by a person now, and a person choosing a
|
||||
preference is exactly who that sentence was written for.
|
||||
|
||||
Args:
|
||||
prefs: The preference set to report.
|
||||
|
||||
Returns:
|
||||
The listing.
|
||||
"""
|
||||
from ..ui.report import Listing
|
||||
|
||||
return Listing(
|
||||
key="preferences",
|
||||
columns=_columns(),
|
||||
rows=[_row(prefs, spec) for spec in PREFERENCES],
|
||||
order=("PREFERENCE", "VALUE", "DEFAULT", "DESCRIPTION"),
|
||||
)
|
||||
|
||||
|
||||
def _one(prefs: Preferences, spec: PrefSpec) -> Facts:
|
||||
"""One preference: the bare value plain, the whole setting object in the document."""
|
||||
from ..ui.report import BARE, Facts
|
||||
|
||||
return Facts(
|
||||
key="preference",
|
||||
fields=_columns(),
|
||||
values=_row(prefs, spec),
|
||||
shape=BARE,
|
||||
bare="value",
|
||||
)
|
||||
|
||||
|
||||
def _written(applied: list[dict[str, Any]], prefs: Preferences) -> Facts:
|
||||
"""What ``set`` or ``reset`` changed — nothing plain, a record for whoever wants one.
|
||||
|
||||
The plain face says it in the exit status and in the acknowledgement on stderr, which
|
||||
is the whole answer for a person. A caller managing a machine's configuration wants to
|
||||
know *what moved*, and had no way to ask.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import SILENT, Column, Facts
|
||||
|
||||
return Facts(
|
||||
key="applied",
|
||||
fields=(
|
||||
fields.integer("changes", "changes"),
|
||||
Column(key="applied"),
|
||||
fields.word("path", "path"),
|
||||
),
|
||||
values={
|
||||
"changes": len(applied),
|
||||
"applied": applied,
|
||||
"path": str(prefs.path) if prefs.path else None,
|
||||
},
|
||||
shape=SILENT,
|
||||
)
|
||||
+148
-37
@@ -13,16 +13,23 @@ 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 dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from ..context import AppContext
|
||||
from ..services.records import CATEGORIES, CATEGORY_BY_ID
|
||||
from ..core import exitcodes
|
||||
from ..services.records import CATEGORIES, CATEGORY_BY_ID, Category
|
||||
from ..ui.script import NONE
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..persistence.repository import DiscoveredPath
|
||||
from ..ui.fields import NodeRef
|
||||
from ..ui.report import Column
|
||||
|
||||
|
||||
@register
|
||||
class TrophyCaseTool(Tool):
|
||||
@@ -72,7 +79,7 @@ class TrophyCaseTool(Tool):
|
||||
def _show_records(
|
||||
self, ctx: AppContext, *, category: str | None, width: int | None
|
||||
) -> ToolResult:
|
||||
"""Print the stored record boards (no device, no transmissions).
|
||||
"""State the stored record boards (no device, no transmissions).
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
@@ -82,42 +89,62 @@ class TrophyCaseTool(Tool):
|
||||
Returns:
|
||||
A :class:`ToolResult` with the record count.
|
||||
"""
|
||||
from ..services import trace_runner
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
wanted = [CATEGORY_BY_ID[category]] if category in CATEGORY_BY_ID else CATEGORIES
|
||||
total = 0
|
||||
# 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())
|
||||
|
||||
rows: list[dict] = []
|
||||
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"),
|
||||
record.app_version,
|
||||
found = ctx.repo.discoveries(cat.id, width_bytes=width)
|
||||
found.sort(key=lambda r: (r.width_bytes, r.score if cat.ascending else -r.score))
|
||||
for record in found:
|
||||
floor = cat.id == "long_haul" and not record.stats.get("km_complete", True)
|
||||
rows.append(
|
||||
{
|
||||
"category": cat.id,
|
||||
"hash_bytes": record.width_bytes,
|
||||
"score": _Score(record.score, cat, floor),
|
||||
"unit": cat.unit,
|
||||
"lower_bound": floor,
|
||||
"recorded_at": record.discovered_at,
|
||||
"version": record.app_version,
|
||||
"path": record.spec or None,
|
||||
"route": _route(record, resolve),
|
||||
}
|
||||
)
|
||||
total += 1
|
||||
ctx.ui.show(table)
|
||||
if total == 0:
|
||||
ctx.ui.show(
|
||||
Text("no records yet — every trace that comes home is scored here", style="muted")
|
||||
)
|
||||
listing = Listing(
|
||||
key="records",
|
||||
columns=(
|
||||
fields.word("category", "CATEGORY"),
|
||||
# The contract's word for the concept. The plain heading stays WIDTH,
|
||||
# which is what a column of 1/2/4 reads as.
|
||||
fields.integer("hash_bytes", "WIDTH"),
|
||||
_score_column(),
|
||||
fields.word("unit", "UNIT"),
|
||||
# `>=273.0` is not a number, and a consumer comparing scores must be able
|
||||
# to see the qualifier without string-matching a prefix off its own data.
|
||||
fields.hidden("lower_bound"),
|
||||
fields.when("recorded_at", "RECORDED"),
|
||||
fields.word("version", "VERSION"),
|
||||
# The transmitted spec, hop-aligned with the route beside it. It has no
|
||||
# column — a route line already runs off the right — but it is what a
|
||||
# caller re-walks with, so the document carries it.
|
||||
fields.hidden("path"),
|
||||
fields.route(),
|
||||
),
|
||||
rows=rows,
|
||||
order=("CATEGORY", "WIDTH", "SCORE", "UNIT", "RECORDED", "VERSION", "ROUTE"),
|
||||
)
|
||||
return ToolResult(
|
||||
summary={"records": total},
|
||||
message=f"{total} record{'s' if total != 1 else ''} stored",
|
||||
summary={"records": len(rows)},
|
||||
message=f"{len(rows)} record{'s' if len(rows) != 1 else ''} stored",
|
||||
report=(listing,),
|
||||
exit_code=exitcodes.OK if rows else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
@@ -146,7 +173,91 @@ class TrophyCaseTool(Tool):
|
||||
) -> None:
|
||||
tool_params: dict[str, Any] = {}
|
||||
if category:
|
||||
# An unknown id used to select *every* discipline, so a misspelled
|
||||
# `--category long_hual` came back as "no records" — exit 5, which is
|
||||
# exactly what a real empty result looks like. A closed set refuses.
|
||||
if category not in CATEGORY_BY_ID:
|
||||
choices = ", ".join(CATEGORY_BY_ID)
|
||||
raise typer.BadParameter(
|
||||
f"--category must be one of: {choices} (got {category!r})"
|
||||
)
|
||||
tool_params["category"] = category
|
||||
if width:
|
||||
tool_params["width"] = width
|
||||
run_tool_command(self, tool_params)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Score:
|
||||
"""A record's score, and the one thing about it a bare number cannot carry.
|
||||
|
||||
``format_score`` writes ``273.0 km`` for a reader; the unit is its own column here so
|
||||
the ``SCORE`` column is one comparable number per row. What has to travel *with* the
|
||||
number 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, and dropping the qualifier would turn a lower bound into a claim.
|
||||
|
||||
The plain face wears the prefix; the document keeps the number a number and says
|
||||
``lower_bound`` beside it, because a consumer comparing scores cannot be asked to
|
||||
string-match a prefix off the front of its own data.
|
||||
"""
|
||||
|
||||
value: float
|
||||
category: Category
|
||||
lower_bound: bool
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""The score as the SCORE column prints it, qualifier and all."""
|
||||
if self.category.unit == "nodes":
|
||||
drawn = str(int(self.value))
|
||||
elif self.category.unit == "dB":
|
||||
drawn = f"{self.value:+.1f}"
|
||||
else:
|
||||
drawn = f"{self.value:.1f}"
|
||||
return f">={drawn}" if self.lower_bound else drawn
|
||||
|
||||
|
||||
def _score_column() -> Column:
|
||||
"""The SCORE column: the qualified figure plain, the bare number in the document."""
|
||||
from ..ui.report import Column, Lane
|
||||
|
||||
return Column(
|
||||
key="score",
|
||||
lanes=(
|
||||
Lane(
|
||||
header="SCORE",
|
||||
render=lambda s: s.text if s is not None else NONE,
|
||||
align="right",
|
||||
),
|
||||
),
|
||||
json=lambda s: None if s is None else s.value,
|
||||
)
|
||||
|
||||
|
||||
def _route(record: DiscoveredPath, resolve: Callable[[str], str | None]) -> list[NodeRef]:
|
||||
"""A record's walk as the shared route shape.
|
||||
|
||||
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 ``path`` 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.
|
||||
|
||||
Our own node never appears in a record's walk: a discovery is scored on what it
|
||||
reached, and the two ends of it are somebody else's.
|
||||
|
||||
Args:
|
||||
record: The record whose walk to render.
|
||||
resolve: Maps a node id to a friendly name, or ``None`` when unknown.
|
||||
|
||||
Returns:
|
||||
The hops in propagation order.
|
||||
"""
|
||||
from ..ui.fields import NodeRef
|
||||
|
||||
spec = record.spec.split(",") if record.spec else []
|
||||
hops: list[NodeRef] = []
|
||||
for index, node in enumerate(record.route):
|
||||
addressed = spec[index] if index < len(spec) else node
|
||||
hops.append(NodeRef(name=resolve(node), hash=addressed))
|
||||
return hops
|
||||
@@ -14,15 +14,19 @@ per invocation, the trace tool's rule.
|
||||
|
||||
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.connection import DeviceCommandError
|
||||
from ..core.models import LoginResult
|
||||
from ..core.models import NODE_TYPE_LABELS, Contact, LoginResult
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Facts
|
||||
|
||||
|
||||
@register
|
||||
class RepeaterAdminTool(Tool):
|
||||
@@ -67,12 +71,16 @@ class RepeaterAdminTool(Tool):
|
||||
contacts = await device.get_contacts()
|
||||
name = str(params["node"])
|
||||
node = next((c for c in contacts if c.name == name), None)
|
||||
# Both of these are the *argument* being wrong, and nothing has gone out over
|
||||
# the air when either fires — so they are usage errors (2), not device failures
|
||||
# (4), whose promise to a caller is that a retry is worth trying. `tx-optimize`
|
||||
# already answered the identical question this way for the identical condition.
|
||||
if node is None:
|
||||
raise DeviceCommandError(f"unknown contact: {name!r}")
|
||||
raise typer.BadParameter(f"unknown contact: {name!r}")
|
||||
|
||||
password = params.get("password") or ctx.admin_store.get(node)
|
||||
if not password:
|
||||
raise DeviceCommandError(
|
||||
raise typer.BadParameter(
|
||||
f"no admin password for {name!r}; pass --password or run the "
|
||||
"interactive flow once to store it."
|
||||
)
|
||||
@@ -91,11 +99,13 @@ 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})
|
||||
return ToolResult(
|
||||
report=(_answered(node, command, reply),),
|
||||
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.
|
||||
@@ -117,3 +127,41 @@ class RepeaterAdminTool(Tool):
|
||||
if password is not None:
|
||||
tool_params["password"] = password
|
||||
run_tool_command(self, tool_params)
|
||||
|
||||
|
||||
def _answered(node: Contact, command: str, reply: str | None) -> Facts:
|
||||
"""What a remote node said back.
|
||||
|
||||
``reply`` is the node's own text, whole and verbatim — the plain face prints it bare
|
||||
(as text and never as markup, so a reply holding a square bracket is a reply holding a
|
||||
square bracket), and the document carries it raw, multi-line where the node sent
|
||||
several lines.
|
||||
|
||||
It is deliberately **not parsed**. MeshTerm does not know the remote node's CLI
|
||||
grammar, and a document that pretended to would be inventing structure a consumer
|
||||
would then depend on.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.fields import NodeRef
|
||||
from ..ui.report import BARE, Facts
|
||||
|
||||
return Facts(
|
||||
key="remote",
|
||||
fields=(
|
||||
fields.node("node", lanes=()),
|
||||
fields.hidden("command"),
|
||||
fields.word("reply", "reply"),
|
||||
),
|
||||
values={
|
||||
"node": NodeRef(
|
||||
name=node.name,
|
||||
key=(node.public_key or "").lower() or None,
|
||||
hash=(node.key_prefix or "").lower() or None,
|
||||
type=NODE_TYPE_LABELS.get(node.node_type),
|
||||
),
|
||||
"command": command,
|
||||
"reply": reply,
|
||||
},
|
||||
shape=BARE,
|
||||
bare="reply",
|
||||
)
|
||||
+145
-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,22 @@ 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,
|
||||
)
|
||||
)
|
||||
)
|
||||
report = None
|
||||
if not isinstance(ctx.ui, TuiUi):
|
||||
report = _trace_report(result, target, path, device_label, resolve, device_hash)
|
||||
|
||||
summary: dict[str, Any] = {
|
||||
"target": target,
|
||||
@@ -453,15 +463,131 @@ 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,
|
||||
report=report,
|
||||
exit_code=exitcodes.OK if result.success else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
|
||||
def _trace_report(
|
||||
result: TraceResult,
|
||||
target: str,
|
||||
path: str | None,
|
||||
device_label: str,
|
||||
resolve: Any,
|
||||
device_hash: str | None,
|
||||
) -> tuple:
|
||||
"""State one trace: 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 a drawn 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). The document embeds the whole node at both ends instead, because a
|
||||
structural join needs no key and each edge should be readable on its own.
|
||||
|
||||
Our own node is a hop like any other, named and hashed. The menu draws it as ``★``
|
||||
because a reader never has to be told which node is theirs; this line is as often read
|
||||
out of a file by somebody who was not at the prompt when it ran.
|
||||
|
||||
Args:
|
||||
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.
|
||||
|
||||
Returns:
|
||||
The report's blocks.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.fields import NodeRef
|
||||
from ..ui.report import Facts, Listing
|
||||
|
||||
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
|
||||
|
||||
def node(label: str) -> NodeRef:
|
||||
"""One end of a hop as the shared node shape."""
|
||||
if not label or label == device_label:
|
||||
return NodeRef(name=device_label, hash=short(device_hash), is_self=True)
|
||||
return NodeRef(name=resolve(label) or None, hash=short(label))
|
||||
|
||||
edges = result.edges(device_label)
|
||||
route = None
|
||||
if edges:
|
||||
route = [node(edges[0].origin)] + [node(edge.destination) for edge in edges]
|
||||
|
||||
facts = Facts(
|
||||
key="trace",
|
||||
fields=(
|
||||
fields.word("target", "target"),
|
||||
fields.spec(),
|
||||
fields.flag("success", "success"),
|
||||
fields.integer("hops", "hops"),
|
||||
fields.snr("min_snr_db", "min_snr_db"),
|
||||
fields.decimal("rtt_ms", "rtt_ms", ".0f"),
|
||||
# New surface on the machine face: the model has always carried the TX power
|
||||
# the walk ran at, and the plain facts block has no room for it.
|
||||
fields.hidden("tx_dbm"),
|
||||
# What `hash` means on this walk's nodes, so a consumer can join two traces
|
||||
# that addressed the same node at different widths.
|
||||
fields.hidden("hash_bytes"),
|
||||
fields.route("route", "route"),
|
||||
),
|
||||
values={
|
||||
"target": target if target != PATH_TRACE_TARGET else None,
|
||||
"path": path,
|
||||
"success": result.success,
|
||||
"hops": result.hop_count if result.success else None,
|
||||
"min_snr_db": result.min_snr,
|
||||
"rtt_ms": result.round_trip_ms,
|
||||
"tx_dbm": result.tx_power,
|
||||
"hash_bytes": hash_bytes,
|
||||
"route": route,
|
||||
},
|
||||
)
|
||||
hops = Listing(
|
||||
key="edges",
|
||||
columns=(
|
||||
fields.integer("index", "HOP"),
|
||||
fields.node("from", lanes=(("hash", "FROM"),)),
|
||||
fields.node("to", lanes=(("hash", "TO"),)),
|
||||
fields.snr("snr_db", "SNR_DB"),
|
||||
),
|
||||
rows=[
|
||||
{
|
||||
"index": edge.index,
|
||||
"from": node(edge.origin),
|
||||
"to": node(edge.destination),
|
||||
"snr_db": edge.snr,
|
||||
}
|
||||
for edge in edges
|
||||
],
|
||||
)
|
||||
return (facts, hops)
|
||||
|
||||
|
||||
def _last_traced_by_name(
|
||||
|
||||
+116
-10
@@ -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 NODE_TYPE_LABELS, 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,12 @@ 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))
|
||||
report = None
|
||||
if isinstance(ctx.ui, TuiUi):
|
||||
ctx.ui.show(tx_opt_table(result))
|
||||
ctx.ui.show(tx_opt_summary(result))
|
||||
else:
|
||||
report = _sweep_report(result, path, admin_node, contacts)
|
||||
|
||||
if no_result:
|
||||
restored = (
|
||||
@@ -335,6 +342,10 @@ class TxOptimizeTool(Tool):
|
||||
)
|
||||
|
||||
return ToolResult(
|
||||
report=report,
|
||||
# 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,
|
||||
@@ -354,6 +365,13 @@ class TxOptimizeTool(Tool):
|
||||
) -> str:
|
||||
"""Find the admin password: explicit flag, remembered, or an interactive prompt.
|
||||
|
||||
The question this asks is **"is anyone there to answer?"**, and it used to ask
|
||||
``--json`` instead — a flag about what the output looks like, which cannot answer
|
||||
it and answered it wrong in both directions: with the flag a piped run failed
|
||||
cleanly, and without it a scheduled run on a terminal sat waiting for a password
|
||||
nobody was going to type. ``ctx.interactive`` reads stdin, which is where the
|
||||
answer actually is.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
admin_node: The node we're about to log in to.
|
||||
@@ -363,13 +381,15 @@ class TxOptimizeTool(Tool):
|
||||
The password to log in with.
|
||||
|
||||
Raises:
|
||||
DeviceCommandError: If no password is available and we can't prompt (JSON mode).
|
||||
typer.BadParameter: If no password is available and nobody can be asked for
|
||||
one. A usage error rather than a device failure: nothing was transmitted,
|
||||
and no retry helps until the command line carries the password.
|
||||
"""
|
||||
password = params.get("password") or ctx.admin_store.get(admin_node)
|
||||
if password:
|
||||
return str(password)
|
||||
if ctx.json_output:
|
||||
raise DeviceCommandError(
|
||||
if not ctx.interactive:
|
||||
raise typer.BadParameter(
|
||||
f"no admin password for {admin_node.name!r}; pass --password or run once "
|
||||
"interactively to store it."
|
||||
)
|
||||
@@ -377,7 +397,7 @@ class TxOptimizeTool(Tool):
|
||||
# sweep screen), so the surface here is PlainUi — no screen stack, no floating.
|
||||
entered = await ctx.ui.text(f"Admin password for {admin_node.name}:", password=True)
|
||||
if not entered:
|
||||
raise DeviceCommandError("an admin password is required to tune a remote node.")
|
||||
raise typer.BadParameter("an admin password is required to tune a remote node.")
|
||||
return entered
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
@@ -524,3 +544,89 @@ 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 _sweep_report(
|
||||
result: TxOptResult, path: str, admin_node: Contact, contacts: list[Contact]
|
||||
) -> tuple:
|
||||
"""State a TX sweep: 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_dbm`` above the table already names it, and a mark is something to look
|
||||
at rather than something to test.
|
||||
|
||||
The two nodes become the shared node shape, and here that matters more than anywhere
|
||||
else: on the plain line they are told apart by nothing but their names, and a caller
|
||||
correlating a sweep with a trace has no key to join on.
|
||||
|
||||
Args:
|
||||
result: The completed sweep.
|
||||
path: The forced route the traces walked, as hex hops.
|
||||
admin_node: The node whose TX power was tuned.
|
||||
contacts: The contact list, for placing the target by name.
|
||||
|
||||
Returns:
|
||||
The report's blocks.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.fields import NodeRef
|
||||
from ..ui.report import Facts, Listing
|
||||
|
||||
def node(name: str) -> NodeRef | None:
|
||||
"""One end of the tuned link as the shared node shape."""
|
||||
contact = next((c for c in contacts if c.name == name), None)
|
||||
if contact is None:
|
||||
return NodeRef(name=name) if name else None
|
||||
return NodeRef(
|
||||
name=contact.name,
|
||||
key=(contact.public_key or "").lower() or None,
|
||||
hash=(contact.key_prefix or "").lower() or None,
|
||||
type=NODE_TYPE_LABELS.get(contact.node_type),
|
||||
)
|
||||
|
||||
facts = Facts(
|
||||
key="tx_optimize",
|
||||
fields=(
|
||||
fields.node("tuning_node", lanes=(("name", "tuning_node"),)),
|
||||
fields.node("target", lanes=(("name", "target"),)),
|
||||
fields.spec(),
|
||||
fields.integer("optimal_tx_dbm", "optimal_tx_dbm"),
|
||||
fields.snr("target_snr_db", "target_snr_db"),
|
||||
fields.decimal("reliability", "reliability", ".2f"),
|
||||
fields.integer("previous_tx_dbm", "previous_tx_dbm"),
|
||||
fields.flag("applied", "applied"),
|
||||
),
|
||||
values={
|
||||
"tuning_node": node(result.admin_node) or NodeRef(name=admin_node.name),
|
||||
"target": node(result.target),
|
||||
"path": path,
|
||||
"optimal_tx_dbm": result.best_tx,
|
||||
"target_snr_db": result.best_snr,
|
||||
# A fraction in [0, 1], not a formatted "1.00": the plain column rounds for
|
||||
# the eye and the document keeps the number a number.
|
||||
"reliability": result.best_success_rate,
|
||||
"previous_tx_dbm": result.original_tx,
|
||||
"applied": result.applied,
|
||||
},
|
||||
)
|
||||
levels = Listing(
|
||||
key="levels",
|
||||
columns=(
|
||||
fields.integer("tx_dbm", "TX_DBM"),
|
||||
fields.snr("target_snr_db", "TARGET_SNR_DB"),
|
||||
fields.integer("successes", "SUCCESSES"),
|
||||
fields.integer("samples", "SAMPLES"),
|
||||
),
|
||||
rows=[
|
||||
{
|
||||
"tx_dbm": level.tx_power,
|
||||
"target_snr_db": level.target_snr,
|
||||
"successes": level.successes,
|
||||
"samples": level.samples,
|
||||
}
|
||||
for level in result.sorted_by_tx()
|
||||
],
|
||||
)
|
||||
return (facts, levels)
|
||||
@@ -306,7 +306,7 @@ PIN_KEY = "device_pin"
|
||||
_PIN_WIDTH = len(str(get_spec(PIN_KEY).maximum))
|
||||
|
||||
|
||||
def conceal(value: str) -> str:
|
||||
def conceal(value: str, *, absent: str = "?") -> str:
|
||||
"""``value`` as a row of :data:`_PIN_WIDTH` mask bullets.
|
||||
|
||||
A value the device could not report is not a secret, it is an absence: ``format_value``
|
||||
@@ -314,8 +314,16 @@ def conceal(value: str) -> str:
|
||||
something to reveal when there is nothing. So only a real value is concealed, and
|
||||
:func:`has_pin` asks the same question the other way round — a page with nothing to
|
||||
conceal advertises no key for it.
|
||||
|
||||
Args:
|
||||
value: The already-rendered value to mask.
|
||||
absent: How *this* surface writes "the device never reported one". The menu's
|
||||
token is ``?``; the scripted CLI's is :data:`~meshterm.ui.script.NONE`. The
|
||||
rule is the same on both and the glyph is not, so the caller names it — a
|
||||
``conceal`` that knew only the menu's token masked the CLI's absence too, and
|
||||
six bullets told the reader the radio was PIN-locked when it was not.
|
||||
"""
|
||||
return value if value == "?" else MASK_MARK * _PIN_WIDTH
|
||||
return value if value == absent else MASK_MARK * _PIN_WIDTH
|
||||
|
||||
|
||||
def has_pin(snapshot: dict) -> bool:
|
||||
@@ -995,7 +1003,7 @@ async def _run_now(
|
||||
"""
|
||||
from ..tools.config import apply_ops
|
||||
|
||||
changes, artifacts = await apply_ops(ctx, device, snapshot, ops)
|
||||
changes, artifacts, _report = await apply_ops(ctx, device, snapshot, ops)
|
||||
for artifact in artifacts:
|
||||
ctx.ui.note(f"[ok]●[/ok] wrote [accent]{artifact}[/accent]")
|
||||
await ctx.ui.present(title=title)
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
"""The shared shapes a report is built out of — one constructor per concept.
|
||||
|
||||
A node means the same thing in ``contacts``, ``monitor``, ``courier list``, ``trace`` and
|
||||
``records``, and it should *look* the same in all five and parse the same in all five. The
|
||||
way to guarantee that is not review, it is construction: every listing that mentions a
|
||||
node asks this module for the column, so there is exactly one definition of what a node's
|
||||
plain lanes are called and what its machine object holds.
|
||||
|
||||
Each constructor returns one :class:`~meshterm.ui.report.Column`, which carries both
|
||||
projections of one typed value. The typed value is what a row holds — a :class:`NodeRef`,
|
||||
a :class:`Position`, a :class:`~datetime.datetime`, a bare float — never a formatted cell,
|
||||
so the plain face can print ``+5.1`` and the machine face ``5.1`` without either one
|
||||
parsing the other's output back.
|
||||
|
||||
The interesting one is :func:`node`, and it is what earns the design: **one machine key
|
||||
expands to as many plain columns as the surface has room for.** ``contacts`` spends four
|
||||
(a name, a role, a hash, a key), ``monitor``'s live stream spends two, a trace's per-hop
|
||||
table spends one — and all four documents carry the same five-key object, because the
|
||||
lanes are a rendering choice and the shape is not.
|
||||
|
||||
Two absences, kept apart on purpose. ``None`` is *we do not know*, and it reads as
|
||||
:data:`~meshterm.ui.script.NONE` plain and ``null`` in JSON. A word standing in for an
|
||||
absence is not the same thing: ``unknown`` in a ``TYPE`` column is a word a reader
|
||||
understands, so the plain face keeps it, while the document writes ``null`` — because a
|
||||
consumer already has one spelling for "there is nothing here" and a second one costs it a
|
||||
special case.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from . import script
|
||||
from .report import Column, Lane, normalise
|
||||
|
||||
# -- the shared value shapes -----------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NodeRef:
|
||||
"""A mesh participant's identity, and nothing else.
|
||||
|
||||
The most repeated concept in either face. *Identity only*: when a node was heard, how
|
||||
strong it came in, where it says it is — those belong to the **row**, because they
|
||||
differ per surface and a node object that carried them would mean something different
|
||||
in every one.
|
||||
|
||||
Attributes:
|
||||
name: The name the node advertises, or ``None`` where none was ever heard —
|
||||
including where a surface substitutes a bare hash for one.
|
||||
key: The full public key, lowercase hex, **never truncated**: a key cut short is
|
||||
not something a caller can hand back to ``--to`` or ``--path``. ``None`` where
|
||||
only a hash was ever heard.
|
||||
hash: The short derived id, at the width the surrounding surface addressed the
|
||||
node by — never ``key`` shortened by this module's own choosing.
|
||||
type: The advertised role in the lexicon's words (``node``, ``repeater``,
|
||||
``room``, ``sensor``), or ``None`` where the advert never said.
|
||||
is_self: Whether this is our own node. The plain face draws it like any other hop,
|
||||
which is the point: "you already know who this is" is true of the person at
|
||||
the prompt and false of whoever reads the file afterwards. The machine face
|
||||
carries it as ``self``, because a parser has no way to work it out at all.
|
||||
"""
|
||||
|
||||
name: str | None = None
|
||||
key: str | None = None
|
||||
hash: str | None = None
|
||||
type: str | None = None
|
||||
is_self: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Position:
|
||||
"""A shared location, in decimal degrees.
|
||||
|
||||
Unrounded in the document, as stored: the plain face's five decimals are a column-width
|
||||
concession and a consumer should not inherit one.
|
||||
"""
|
||||
|
||||
lat: float
|
||||
lon: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ChannelRef:
|
||||
"""One channel slot's identity. The secret is deliberately not in it.
|
||||
|
||||
A channel object travels through listings and send receipts, and a key that rode along
|
||||
in all of them would be a key printed by commands nobody asked to print one. It
|
||||
appears only where the caller explicitly asked (``channels share``), under its own
|
||||
field, beside this.
|
||||
|
||||
Attributes:
|
||||
slot: The 0-based index every ``channels`` subcommand's ``INDEX`` takes.
|
||||
name: The channel name.
|
||||
public: Whether the key is derived from the name (a ``#`` channel or a firmware
|
||||
default) rather than secret.
|
||||
hash: The channel hash, lowercase hex.
|
||||
"""
|
||||
|
||||
slot: int
|
||||
name: str
|
||||
public: bool
|
||||
hash: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rendered:
|
||||
"""A typed value beside the one plain rendering only its own registry can produce.
|
||||
|
||||
Almost every cell is derivable from its value alone, which is what lets a
|
||||
:class:`~meshterm.ui.report.Lane` be a function of one argument. A *setting* is the
|
||||
exception: what ``config set`` takes back for a given key is a question only that key's
|
||||
spec can answer — an enum wants its number, an empty string wants ``""``, a preference
|
||||
drops the unit its page shows — and the spec is per row, not per column.
|
||||
|
||||
So the row carries both halves, computed once where the spec is in hand. The plain
|
||||
face prints ``text`` and the document emits ``value``, and neither has to parse the
|
||||
other's output to get at what it needs, which is the same rule the rest of this module
|
||||
keeps by other means.
|
||||
|
||||
Attributes:
|
||||
value: The typed value, or ``None`` where the source never reported one.
|
||||
text: What the plain face prints, in the form the matching ``set`` accepts back.
|
||||
"""
|
||||
|
||||
value: Any
|
||||
text: str
|
||||
|
||||
|
||||
def node_json(ref: NodeRef | None) -> dict[str, Any] | None:
|
||||
"""One node as its machine object, or ``null`` where there is no node at all."""
|
||||
if ref is None:
|
||||
return None
|
||||
return {
|
||||
"name": ref.name or None,
|
||||
"key": (ref.key or None) and ref.key.lower(),
|
||||
"hash": (ref.hash or None) and ref.hash.lower(),
|
||||
"type": ref.type or None,
|
||||
"self": ref.is_self,
|
||||
}
|
||||
|
||||
|
||||
def position_json(where: Position | None) -> dict[str, Any] | None:
|
||||
"""One position as its machine object, or ``null`` where none was ever shared.
|
||||
|
||||
The whole object is ``null`` rather than a pair of ``null``s: a latitude without a
|
||||
longitude is not half a location, it is no location.
|
||||
"""
|
||||
return None if where is None else {"lat": where.lat, "lon": where.lon}
|
||||
|
||||
|
||||
def channel_json(channel: ChannelRef | None) -> dict[str, Any] | None:
|
||||
"""One channel as its machine object, or ``null`` for an empty slot."""
|
||||
if channel is None:
|
||||
return None
|
||||
return {
|
||||
"slot": channel.slot,
|
||||
"name": channel.name,
|
||||
"public": channel.public,
|
||||
"hash": (channel.hash or None) and channel.hash.lower(),
|
||||
}
|
||||
|
||||
|
||||
# -- the constructors ------------------------------------------------------------------
|
||||
|
||||
#: How each of a node's plain lanes reads its cell. A surface picks the ones it has room
|
||||
#: for; the machine object is the same five keys either way.
|
||||
_NODE_LANES = {
|
||||
"name": lambda ref: script.name(ref.name if ref else None),
|
||||
# The word, not a glyph: `▲` without its colour would need a legend and the CLI has no
|
||||
# legends. `unknown` is a word a reader acts on; the document writes `null`.
|
||||
"type": lambda ref: ref.type if ref and ref.type else "unknown",
|
||||
"hash": lambda ref: ((ref.hash or "").lower() if ref else "") or script.NONE,
|
||||
"key": lambda ref: ((ref.key or "").lower() if ref else "") or script.NONE,
|
||||
}
|
||||
|
||||
|
||||
def node(key: str = "node", *, lanes: Sequence[tuple[str, str]] = (("name", "NAME"),)) -> Column:
|
||||
"""One node: the shared five-key object, and whichever plain lanes fit.
|
||||
|
||||
Args:
|
||||
key: The machine key, and the key the row holds its :class:`NodeRef` at.
|
||||
lanes: ``(part, header)`` pairs naming the plain columns to draw, in order. The
|
||||
parts are ``name``, ``type``, ``hash`` and ``key``. A surface with one column
|
||||
to spend draws the name; a per-hop table draws the hash, because that is what
|
||||
joins it to the route line above it.
|
||||
|
||||
Returns:
|
||||
The column.
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=tuple(Lane(header=header, render=_NODE_LANES[part]) for part, header in lanes),
|
||||
json=node_json,
|
||||
)
|
||||
|
||||
|
||||
def position(key: str = "position", header: str = "LOCATION") -> Column:
|
||||
"""A shared location: one plain column, one machine object.
|
||||
|
||||
One column and not two. A coordinate pair is one fact to a reader, it is what goes
|
||||
into a map's search box verbatim, and two columns of ``-`` for every node that has
|
||||
never shared a position is worse than one.
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=_position_cell),),
|
||||
json=position_json,
|
||||
)
|
||||
|
||||
|
||||
def channel(
|
||||
key: str = "channel",
|
||||
*,
|
||||
lanes: Sequence[tuple[str, str]] = (("name", "NAME"),),
|
||||
) -> Column:
|
||||
"""One channel slot: the shared four-key object, and whichever plain lanes fit.
|
||||
|
||||
Args:
|
||||
key: The machine key.
|
||||
lanes: ``(part, header)`` pairs; the parts are ``slot``, ``name``, ``type`` and
|
||||
``hash``. ``type`` is the plain face's word for the ``public`` boolean —
|
||||
``public``/``private`` reads where ``true``/``false`` would need the heading
|
||||
to say which way round it runs.
|
||||
"""
|
||||
parts = {
|
||||
"slot": lambda c: script.number(c.slot if c else None),
|
||||
"name": lambda c: script.name(c.name if c else None),
|
||||
"type": lambda c: ("public" if c.public else "private") if c else script.NONE,
|
||||
"hash": lambda c: ((c.hash or "").lower() if c else "") or script.NONE,
|
||||
}
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=tuple(Lane(header=header, render=parts[part]) for part, header in lanes),
|
||||
json=channel_json,
|
||||
)
|
||||
|
||||
|
||||
def when(key: str, header: str, *, absent: str = script.NONE) -> Column:
|
||||
"""A time the reader reads as an age: ``5m``, ``3h``, ``never``.
|
||||
|
||||
The default form for every time in a listing, because "recently?" is the question the
|
||||
reader typed the command to ask and an ISO instant makes them do arithmetic to answer
|
||||
it. ``--absolute`` swaps the whole language back for a run; the document is UTC
|
||||
regardless, since it is read somewhere else and often later.
|
||||
|
||||
Args:
|
||||
key: The machine key. It ends in ``_at`` by convention — a JSON key is a type hint
|
||||
for a parser, and the suffix is the hint.
|
||||
header: The plain heading. It must name the *fact* — ``HEARD``, ``RECORDED`` —
|
||||
rather than the form, because ``--absolute`` changes the form underneath it
|
||||
and a column headed ``AGE`` holding an ISO instant is a heading that lies.
|
||||
absent: What an unknown time reads as plain. ``never`` where the row exists and
|
||||
the event has not happened (a node not yet heard, a conversation with nothing
|
||||
said in it); the default token where the row has no such time at all.
|
||||
|
||||
Returns:
|
||||
The column.
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=lambda v: _time(v, absent=absent), align="right"),),
|
||||
)
|
||||
|
||||
|
||||
def instant(key: str, header: str) -> Column:
|
||||
"""A time that *is* the fact, printed absolutely whatever ``--absolute`` says.
|
||||
|
||||
Three surfaces want this and each for the same reason — the instant is the answer, not
|
||||
a way of saying how long ago something was. The device's own clock. An appointment set
|
||||
with ``--at``, which is a wall-clock time the caller chose. And a live capture's own
|
||||
``TIME`` column, every row of which would otherwise read ``now``.
|
||||
"""
|
||||
return Column(key=key, lanes=(Lane(header=header, render=script.stamp),))
|
||||
|
||||
|
||||
def snr(key: str, header: str) -> Column:
|
||||
"""A signal-to-noise reading: ``+5.1`` plain, ``5.1`` in the document.
|
||||
|
||||
The plain sign is a column convention — a lane of readings only reads as a ladder when
|
||||
every one of them declares its direction. A number in JSON carries its own sign and a
|
||||
leading ``+`` would make it a string.
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=lambda v: script.number(v, "+.1f"), align="right"),),
|
||||
json=lambda v: None if v is None else round(float(v), 1),
|
||||
)
|
||||
|
||||
|
||||
def integer(key: str, header: str) -> Column:
|
||||
"""A whole number, right-aligned so a column of them is comparable by eye."""
|
||||
return Column(key=key, lanes=(Lane(header=header, render=script.number, align="right"),))
|
||||
|
||||
|
||||
def decimal(key: str, header: str, spec: str = ".1f") -> Column:
|
||||
"""A measured number, formatted for the column and left unrounded in the document."""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=lambda v: script.number(v, spec), align="right"),),
|
||||
)
|
||||
|
||||
|
||||
def flag(key: str, header: str) -> Column:
|
||||
"""A boolean: ``yes``/``no`` for a reader, ``true``/``false`` for a parser.
|
||||
|
||||
``None`` is a third answer and stays one — ``acked`` on a channel message is not
|
||||
``false``, it is *there is no acknowledgement on a channel*, and the two are different
|
||||
things to anyone counting delivery failures.
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=_flag_cell),),
|
||||
)
|
||||
|
||||
|
||||
def word(key: str, header: str) -> Column:
|
||||
"""A member of a closed set — a state, a role, an outcome — as its own lowercase word.
|
||||
|
||||
Both faces say the same word, so ``courier send``'s ``no ack`` reads the same in a
|
||||
document as it does on the line the help lists it on.
|
||||
"""
|
||||
return Column(key=key, lanes=(Lane(header=header, render=lambda v: v or script.NONE),))
|
||||
|
||||
|
||||
def name(key: str, header: str) -> Column:
|
||||
"""A bare name that is not a node's — a channel label, a conversation, a device."""
|
||||
return Column(key=key, lanes=(Lane(header=header, render=script.name),))
|
||||
|
||||
|
||||
def free(key: str, header: str) -> Column:
|
||||
"""Free text a stranger filled in: escaped so it can never end its own record.
|
||||
|
||||
Always the last lane in a listing, because it is the one field with no width. The
|
||||
document carries it **raw and unshortened** — a JSON string holds a newline natively,
|
||||
so the escaping is a plain-text necessity rather than something to inflict on a
|
||||
consumer.
|
||||
"""
|
||||
return Column(key=key, lanes=(Lane(header=header, render=script.text),))
|
||||
|
||||
|
||||
def route(key: str = "route", header: str = "ROUTE") -> Column:
|
||||
"""A walked hop sequence: drawn with arrows plain, an array of nodes in the document.
|
||||
|
||||
The row holds :class:`NodeRef` objects in propagation order, so the document's hops are
|
||||
the same objects a listing's rows carry and a consumer's node handler is written once.
|
||||
``None`` where nothing came home — never ``[]``, which would claim a walk of no hops.
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(
|
||||
Lane(
|
||||
header=header,
|
||||
render=lambda hops: (
|
||||
script.route([(h.name, h.hash) for h in hops]) if hops else script.NONE
|
||||
),
|
||||
),
|
||||
),
|
||||
json=lambda hops: None if hops is None else [node_json(hop) for hop in hops],
|
||||
)
|
||||
|
||||
|
||||
def spec(key: str = "path", header: str = "path") -> Column:
|
||||
"""A forced path exactly as the radio was given it: ``a1,d4,a1``, or absent.
|
||||
|
||||
The one line on either face that round-trips into ``--path``, so it keeps its commas
|
||||
and grows nothing. ``None`` means the device routed the walk itself: the plain face
|
||||
says ``auto`` because a reader needs a word there, and the document says ``null``
|
||||
because ``auto`` is not a path spec and R3 already has a token for "there is no spec
|
||||
here".
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=lambda v: v if v else "auto"),),
|
||||
)
|
||||
|
||||
|
||||
def rendered(key: str, header: str) -> Column:
|
||||
"""A setting's value: what ``set`` takes back plain, and the typed value in the document.
|
||||
|
||||
Takes a :class:`Rendered` (see there for why this one concept needs both halves in the
|
||||
row). The round-trip claim is the plain face's whole design for these commands —
|
||||
``config show > f`` and feeding ``f`` back in must work — and the document's typed
|
||||
value is the machine face's largest single gain over it: ``9`` and not ``"9"``.
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=_rendered_cell),),
|
||||
json=lambda r: None if r is None else r.value,
|
||||
)
|
||||
|
||||
|
||||
def hexid(key: str, header: str) -> Column:
|
||||
"""A hash or a key on its own, lowercase, never truncated and never elided."""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=lambda v: (v or "").lower() or script.NONE),),
|
||||
json=lambda v: (v or None) and str(v).lower(),
|
||||
)
|
||||
|
||||
|
||||
def note(key: str, header: str) -> Column:
|
||||
"""Prose for a reader, with no machine face at all.
|
||||
|
||||
A preference's DESCRIPTION is the case: it explains a preference to somebody deciding
|
||||
whether to change it, and there is nothing in it a program would branch on. Declaring
|
||||
it as a column rather than smuggling it in keeps the listing's two faces stated in one
|
||||
place.
|
||||
"""
|
||||
return Column(key=key, lanes=(Lane(header=header, render=script.text),), json=None)
|
||||
|
||||
|
||||
def plain_only(column: Column) -> Column:
|
||||
"""The same concept, drawn for a reader and left out of the document.
|
||||
|
||||
For a lane the machine face says *better* somewhere else rather than not at all. A
|
||||
transcript's ``PEER`` is the case: one column holding whichever of a channel or a
|
||||
contact the conversation had, because a reader has the conversation in front of them —
|
||||
where the document keeps ``channel`` and ``node`` apart, since a parser has to know
|
||||
which of the two it is holding and cannot tell from the label.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
return replace(column, json=None)
|
||||
|
||||
|
||||
def hidden(key: str) -> Column:
|
||||
"""A fact the document carries and the plain face has no room for.
|
||||
|
||||
The mirror of :func:`note`. ``config show`` prints a setting's key and its value —
|
||||
that is what a reader came for — while the document also says what type it is, what an
|
||||
enum's number is called, and whether the value was withheld.
|
||||
"""
|
||||
return Column(key=key)
|
||||
|
||||
|
||||
def _rendered_cell(value: Rendered | None) -> str:
|
||||
"""One setting's cell: the text its own spec produced, or the absent token."""
|
||||
return script.NONE if value is None else value.text
|
||||
|
||||
|
||||
def _position_cell(where: Position | None) -> str:
|
||||
"""One position's cell: the pair, or the absent token."""
|
||||
return script.NONE if where is None else script.location(where.lat, where.lon)
|
||||
|
||||
|
||||
def _flag_cell(value: bool | None) -> str:
|
||||
"""One boolean's cell, keeping ``None`` as an absence rather than a ``no``."""
|
||||
return script.NONE if value is None else ("yes" if value else "no")
|
||||
|
||||
|
||||
def _time(value: Any, *, absent: str) -> str:
|
||||
"""Render a time in whichever language this run speaks (see :func:`when`).
|
||||
|
||||
``absent`` survives the switch to timestamps, because it is not a rendering choice:
|
||||
``never`` says the event has not happened, and ``--absolute`` was asked for a different
|
||||
*form* of time, not for one fewer fact.
|
||||
"""
|
||||
if not isinstance(value, datetime) and value is not None:
|
||||
return normalise(value) # pragma: no cover - a row holding something else is a bug
|
||||
if value is None:
|
||||
return absent
|
||||
return script.stamp(value) if _absolute() else script.age(value, absent=absent)
|
||||
|
||||
|
||||
def _absolute() -> bool:
|
||||
"""Whether this run prints absolute times.
|
||||
|
||||
Read from the preference registry rather than a module flag, because *how MeshTerm
|
||||
behaves* is a preference by this project's own rule — and ``--absolute`` is then
|
||||
simply that preference overridden for one run (see
|
||||
:func:`~meshterm.cli.main_callback`), rather than a second switch saying the same
|
||||
thing in a place a reader cannot find.
|
||||
"""
|
||||
from ..core.preferences import current
|
||||
|
||||
return str(current().cli_time_format) == "absolute"
|
||||
+38
-1
@@ -98,11 +98,18 @@ 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.for_script`).
|
||||
rule: Whether this block is a ``---`` horizontal rule. Marked for the same
|
||||
reason: a rule is drawn to the full width of whatever prints it, and the
|
||||
scripted console is 16384 cells wide.
|
||||
"""
|
||||
|
||||
renderable: RenderableType
|
||||
heading: bool = False
|
||||
label: str = ""
|
||||
qr: bool = False
|
||||
rule: bool = False
|
||||
|
||||
|
||||
class MarkdownDoc:
|
||||
@@ -124,6 +131,34 @@ 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 for_script(self) -> MarkdownDoc:
|
||||
"""The same page with what only a screen can use left out, and no gap where it stood.
|
||||
|
||||
Two blocks are drawn for an eye and are worse than useless in a pipe:
|
||||
|
||||
* 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.
|
||||
* A **rule** is sized to the console it is printed on, and the scripted console is
|
||||
:data:`~meshterm.ui.script.WIDTH` cells wide — so the ``---`` above a colophon
|
||||
arrives as one 16384-character line, tens of kilobytes of ``─`` in a page of
|
||||
prose. It is also a frame, which the scripted CLI does not draw. The blank line
|
||||
on each side already says what the rule said.
|
||||
|
||||
Returns:
|
||||
A new document; this one is untouched.
|
||||
"""
|
||||
kept: list[Block] = []
|
||||
for block in self.blocks:
|
||||
if block.qr or block.rule:
|
||||
# The blank separator that opened the dropped block goes with it, so
|
||||
# taking one out 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 +225,10 @@ 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"
|
||||
rule = node.type == "hr"
|
||||
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, rule=rule))
|
||||
heading = False # a heading is one block; never mark a stray second
|
||||
return blocks
|
||||
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""The two faces of one answer: a renderer turns a report into bytes.
|
||||
|
||||
A tool states its answer as a :mod:`~meshterm.ui.report`; this module is where that
|
||||
becomes output, and the CLI boundary picks which one runs (see
|
||||
:func:`~meshterm.cli.main_callback`). Nothing above here knows what a gutter or a comma is,
|
||||
and nothing below here knows what a contact is. That separation is the whole point: a
|
||||
third format later — YAML, CSV, whatever somebody needs — is a new class in this file and
|
||||
not an edit to twenty tools.
|
||||
|
||||
**Plain** (:class:`PlainRenderer`) is for a person at a prompt: aligned columns, ages, a
|
||||
route drawn with arrows, and a ``sysctl -a`` block for a set of facts about one thing. It
|
||||
is written in the vocabulary of :mod:`meshterm.ui.script`, which is where the reasons for
|
||||
each of its rules live.
|
||||
|
||||
**JSON** (:class:`JsonRenderer`) is for a program, often on another machine and often
|
||||
later. It prints **the answer itself** — an array for a listing, an object for a set of
|
||||
facts — compact, one line, UTF-8, no ASCII escaping, keys in the order the report declares
|
||||
them. There is no wrapper: ``meshterm contacts --json | jq '.[].node.name'`` reads what it
|
||||
looks like it reads, and the report a caller needs is still ``$?``, exactly as it is
|
||||
without the flag. So a failure prints **nothing at all** on stdout — the ``meshterm: what
|
||||
went wrong`` line stays on stderr where every utility puts it, and stdout holding a valid
|
||||
document is then a reliable signal that the command worked.
|
||||
|
||||
Two rules cover a report of more than one block, and between them they produce the
|
||||
documents ``trace`` and ``tx-optimize`` are specified with. A **single-block** report *is*
|
||||
its block's document. A **multi-block** report is one object: each :class:`Listing` under
|
||||
its own key, and each :class:`Facts` merged in at the top level — a facts block is the set
|
||||
of facts about the one thing the command answered, so it is the document rather than a
|
||||
compartment inside it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
from . import script
|
||||
from .report import BARE, SILENT, Block, Facts, Lane, Listing, Report
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..context import AppContext
|
||||
|
||||
|
||||
class OutputFormat(str, Enum):
|
||||
"""The formats a scripted run can print its answer in.
|
||||
|
||||
An enum rather than the ``--json`` boolean it replaces, because the boolean was
|
||||
already being asked questions it could not answer — ``tx-optimize`` read it as "may I
|
||||
prompt?" — and because a third member here costs one renderer and no tool changes at
|
||||
all. ``--json`` stays the documented spelling and is sugar for this.
|
||||
"""
|
||||
|
||||
PLAIN = "plain"
|
||||
JSON = "json"
|
||||
|
||||
|
||||
class Renderer:
|
||||
"""Turns a report into bytes. One subclass per format."""
|
||||
|
||||
def render(self, report: Report | None) -> None:
|
||||
"""Print a completed report, or nothing at all when there is none."""
|
||||
raise NotImplementedError
|
||||
|
||||
@contextmanager
|
||||
def stream(self, listing: Listing) -> Iterator[Any]:
|
||||
"""Open a live listing whose rows arrive one at a time.
|
||||
|
||||
The narrow second seam, and it exists because a stream has no end to return from:
|
||||
``monitor`` and ``chat listen`` run until a window closes or somebody interrupts
|
||||
them, so their answer cannot be a value handed back at the end. Two commands use
|
||||
it. Nothing else should — a command that can build a whole report should, because
|
||||
a report is the thing every format already knows how to read.
|
||||
|
||||
Args:
|
||||
listing: The shape of the records to come — its columns, its lane order, and
|
||||
(for the plain face) the width each lane is pinned to. Its ``rows`` are
|
||||
ignored; rows arrive through the yielded callable.
|
||||
|
||||
Yields:
|
||||
A callable taking one typed row mapping.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class PlainRenderer(Renderer):
|
||||
"""The face a person reads: aligned text on the scripted console.
|
||||
|
||||
Attributes:
|
||||
console: The plain console (see :func:`meshterm.ui.script.console`).
|
||||
"""
|
||||
|
||||
def __init__(self, console: Console) -> None:
|
||||
"""Bind the renderer to the console its output goes to."""
|
||||
self.console = console
|
||||
|
||||
def render(self, report: Report | None) -> None:
|
||||
"""Draw each block in order, one blank line between them.
|
||||
|
||||
The separator is counted off what was *drawn* rather than off the block index: a
|
||||
block can render to nothing (a listing with no rows, an acknowledgement whose plain
|
||||
answer is its exit status), and a blank line above the first visible block would be
|
||||
a line of output the command did not have.
|
||||
"""
|
||||
drawn_any = False
|
||||
for block in report or ():
|
||||
drawn = self._block(block)
|
||||
if drawn is None:
|
||||
continue
|
||||
if drawn_any:
|
||||
self.console.print(script.blank())
|
||||
self.console.print(drawn)
|
||||
drawn_any = True
|
||||
|
||||
def _block(self, block: Block) -> Any:
|
||||
"""The renderable for one block, or ``None`` where it prints nothing."""
|
||||
if isinstance(block, Listing):
|
||||
return self._listing(block) if block.rows else None
|
||||
if isinstance(block, Facts):
|
||||
return self._facts(block)
|
||||
raise TypeError(f"unrenderable block: {block!r}") # pragma: no cover - closed set
|
||||
|
||||
def _listing(self, block: Listing) -> Any:
|
||||
"""A listing as its header line and its padded records."""
|
||||
lanes = block.lanes()
|
||||
table = script.columns(
|
||||
*(lane.header for _, lane in lanes),
|
||||
right=[lane.header for _, lane in lanes if lane.align == "right"],
|
||||
)
|
||||
# `config show` is an array to a parser and a `sysctl -a` block to a reader, so it
|
||||
# keeps the columns and drops the heading row. Same table either way — the padding,
|
||||
# the no-wrap and the crop are what make a record splittable, and none of them is
|
||||
# the header's doing.
|
||||
table.show_header = block.headed
|
||||
for row in block.rows:
|
||||
table.add_row(*(lane.render(row.get(column.key)) for column, lane in lanes))
|
||||
return table
|
||||
|
||||
def _facts(self, block: Facts) -> Any:
|
||||
"""A facts block as a key/value listing, one bare value, or nothing."""
|
||||
if block.shape == SILENT:
|
||||
return None
|
||||
if block.shape == BARE:
|
||||
column = next(c for c in block.fields if c.key == block.bare)
|
||||
value = block.values.get(column.key)
|
||||
if value is None:
|
||||
# The bare form prints the value, and there is no value: a node that never
|
||||
# answered, a slot with nothing in it. A lone `-` there would be a line of
|
||||
# output where the exit status is the whole report.
|
||||
return None
|
||||
# As a Text, not a markup string: this is a private key, a share URL or a
|
||||
# remote node's own reply, and Rich would read a bracket in one as a style tag.
|
||||
return Text(column.lanes[0].render(value))
|
||||
rows: list[tuple[str, str]] = []
|
||||
for column in block.fields:
|
||||
value = block.values.get(column.key)
|
||||
if block.omit_absent and (value is None or value == ""):
|
||||
continue
|
||||
for lane in column.lanes:
|
||||
rows.append((lane.header, lane.render(value)))
|
||||
return script.pairs(rows)
|
||||
|
||||
@contextmanager
|
||||
def stream(self, listing: Listing) -> Iterator[Any]:
|
||||
"""Print the header line, then one pinned-lane record per row as it arrives."""
|
||||
lanes = listing.lanes()
|
||||
pinned = script.stream(
|
||||
*((lane.header, _lane_width(lane)) for _, lane in lanes),
|
||||
right=[lane.header for _, lane in lanes if lane.align == "right"],
|
||||
)
|
||||
self.console.print(pinned.header, highlight=False)
|
||||
|
||||
def row(record: Mapping[str, Any]) -> None:
|
||||
cells = [lane.render(record.get(column.key)) for column, lane in lanes]
|
||||
self.console.print(pinned.record(*cells), highlight=False)
|
||||
|
||||
yield row
|
||||
|
||||
|
||||
class JsonRenderer(Renderer):
|
||||
"""The face a program reads: one compact document, or one per streamed record.
|
||||
|
||||
Attributes:
|
||||
console: The plain console, used only for its stream — the document is written
|
||||
through :func:`json.dumps` rather than by Rich, so nothing can pad, wrap,
|
||||
highlight or otherwise adorn it.
|
||||
"""
|
||||
|
||||
def __init__(self, console: Console) -> None:
|
||||
"""Bind the renderer to the console its output goes to."""
|
||||
self.console = console
|
||||
|
||||
def render(self, report: Report | None) -> None:
|
||||
"""Print the report as one document, or nothing when there is no report at all."""
|
||||
if report is None:
|
||||
return
|
||||
blocks = [block for block in report if not block.plain_only]
|
||||
if not blocks:
|
||||
return
|
||||
self._write(self._document(blocks))
|
||||
|
||||
def _document(self, blocks: Sequence[Block]) -> Any:
|
||||
"""Fold the blocks into the one value this command answers with."""
|
||||
if len(blocks) == 1:
|
||||
return self._value(blocks[0])
|
||||
document: dict[str, Any] = {}
|
||||
for block in blocks:
|
||||
if isinstance(block, Facts):
|
||||
document.update(self._facts(block))
|
||||
else:
|
||||
document[block.key] = self._value(block)
|
||||
return document
|
||||
|
||||
def _value(self, block: Block) -> Any:
|
||||
"""One block's document: an array for a listing, an object for a set of facts."""
|
||||
if isinstance(block, Listing):
|
||||
return [self._row(block, row) for row in block.rows]
|
||||
if isinstance(block, Facts):
|
||||
return self._facts(block)
|
||||
raise TypeError(f"unrenderable block: {block!r}") # pragma: no cover - closed set
|
||||
|
||||
@staticmethod
|
||||
def _row(block: Listing, row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""One record, with every declared key present (``null`` where it is absent)."""
|
||||
return {
|
||||
column.key: column.json(row.get(column.key))
|
||||
for column in block.columns
|
||||
if column.json is not None
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _facts(block: Facts) -> dict[str, Any]:
|
||||
"""One facts block as an object, keeping every declared key.
|
||||
|
||||
``omit_absent`` is a *plain* concession — ``info`` leaves out a reading the
|
||||
firmware does not answer for, so a dash never claims the radio reported nothing.
|
||||
A consumer cannot act on that distinction and pays a membership test on every
|
||||
field for it, so the document keeps the key and writes ``null``.
|
||||
"""
|
||||
return {
|
||||
column.key: column.json(block.values.get(column.key))
|
||||
for column in block.fields
|
||||
if column.json is not None
|
||||
}
|
||||
|
||||
def _write(self, document: Any) -> None:
|
||||
"""Write one compact document and terminate it with a newline."""
|
||||
line = json.dumps(
|
||||
document,
|
||||
# A node name in Cyrillic is a node name, not a run of `\uXXXX`. The scripted
|
||||
# console already reconfigures stdout to UTF-8 for exactly this class of
|
||||
# character.
|
||||
ensure_ascii=False,
|
||||
# Compact and one line, so a stream and a one-shot answer are read the same
|
||||
# way and `jq .` can re-indent for a person at no cost. Nothing can un-stream
|
||||
# a document that was pretty-printed across four hundred lines.
|
||||
separators=(",", ":"),
|
||||
)
|
||||
self.console.file.write(line + "\n")
|
||||
self.console.file.flush()
|
||||
|
||||
@contextmanager
|
||||
def stream(self, listing: Listing) -> Iterator[Any]:
|
||||
"""Print one document per record, flushed, as each arrives.
|
||||
|
||||
Every line is the same shape, which is what makes the stream readable without a
|
||||
discriminator: a consumer reads line by line and never has to ask which kind of
|
||||
record it is holding. The closing per-node summary the plain face draws is marked
|
||||
``plain_only`` for that reason — it is derivable from the records above it, and a
|
||||
second shape mid-stream would cost every reader a branch it otherwise never needs.
|
||||
"""
|
||||
|
||||
def row(record: Mapping[str, Any]) -> None:
|
||||
self._write(self._row(listing, record))
|
||||
|
||||
yield row
|
||||
|
||||
|
||||
def for_format(output: OutputFormat, console: Console) -> Renderer:
|
||||
"""The renderer for one output format.
|
||||
|
||||
Args:
|
||||
output: What the caller asked for.
|
||||
console: The console to print on.
|
||||
|
||||
Returns:
|
||||
The renderer.
|
||||
"""
|
||||
return JsonRenderer(console) if output is OutputFormat.JSON else PlainRenderer(console)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def stream(ctx: AppContext, listing: Listing) -> Iterator[Any]:
|
||||
"""Open a live listing on whichever renderer this run is using.
|
||||
|
||||
Args:
|
||||
ctx: The application context, for its output format and console.
|
||||
listing: The stream's shape (see :meth:`Renderer.stream`).
|
||||
|
||||
Yields:
|
||||
A callable taking one typed row mapping.
|
||||
"""
|
||||
with for_format(ctx.output, ctx.console).stream(listing) as row:
|
||||
yield row
|
||||
|
||||
|
||||
def _lane_width(lane: Lane) -> int:
|
||||
"""How wide a streamed lane is pinned.
|
||||
|
||||
A live capture cannot size its columns the way :func:`~meshterm.ui.script.columns`
|
||||
does, because it has not seen the records yet. So each lane declares the width it is
|
||||
pinned to, and a value wider than that overruns and pushes the rest of its row right
|
||||
rather than being cut — which is why the one unbounded field goes last (see
|
||||
:class:`meshterm.ui.script.Lanes`).
|
||||
"""
|
||||
return max(len(lane.header), lane.width)
|
||||
@@ -0,0 +1,243 @@
|
||||
"""What a tool answers with, stated as data before anything decides how it looks.
|
||||
|
||||
A scripted run used to say its answer by *printing* it: ``Tool.run`` called
|
||||
``ctx.ui.show(<a table built by ui/script.py>)`` and returned a :class:`ToolResult`
|
||||
carrying only bookkeeping. By the time the CLI boundary saw that result the contacts had
|
||||
already gone down the pipe as a space-aligned table, and there was nothing left to hand a
|
||||
second format. That is exactly why ``--json`` reached two commands out of twenty and
|
||||
stopped: each one that wanted it grew an ``if ctx.json_output:`` branch *above* the
|
||||
rendering, restating the whole answer in a second dialect nobody else could reuse.
|
||||
|
||||
``exit_code`` already showed the shape that works — the tool states it, the boundary turns
|
||||
it into a process status, and no tool has ever called :func:`sys.exit`. This module lets
|
||||
the answer travel the same way:
|
||||
|
||||
**A tool states its answer as data. A renderer turns data into bytes. The CLI
|
||||
boundary picks the renderer** (see :mod:`meshterm.ui.renderers`).
|
||||
|
||||
A report is a short sequence of blocks, and there are only two kinds because the plain
|
||||
face proved only two are needed — a :class:`Listing` (records under a header line) and
|
||||
:class:`Facts` (one thing described key by key). A :class:`Facts` block covers the
|
||||
one-scalar answer too, through :data:`BARE`: ``config get`` prints its value alone
|
||||
*because the caller named the key*, while the machine face still gets the object with the
|
||||
key, the type and the label in it. Splitting that into a third block type would have
|
||||
bought a second name for the same data.
|
||||
|
||||
**A row holds the typed value.** ``6.0``, ``None``, a :class:`~datetime.datetime` — never
|
||||
a formatted cell. Each :class:`Column` carries both projections instead: the plain
|
||||
:class:`Lane` that turns the value into text, and the JSON function that normalises it.
|
||||
One typed row, two projections, and a third format later is a third function rather than a
|
||||
third row-builder. :mod:`meshterm.ui.fields` builds the columns for the concepts the whole
|
||||
app shares — a node, a time, an SNR, a route — so every listing speaks the same shapes by
|
||||
construction rather than by review.
|
||||
|
||||
Nothing here renders. A block does not know what a gutter is, and a column does not know
|
||||
whether anyone will ever ask it for JSON.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
#: How a :class:`Facts` block reaches the *plain* face. The machine face is the same
|
||||
#: object either way — this only chooses what a person at a prompt is shown.
|
||||
PAIRS = "pairs"
|
||||
#: One named field's value, alone, with no key in front of it. What ``config get`` prints:
|
||||
#: the caller named the key, so repeating it back is one more thing to strip off.
|
||||
BARE = "bare"
|
||||
#: Nothing at all. An acknowledgement — ``config advert``, ``channels join`` — whose plain
|
||||
#: report is its exit status. The machine face still gets a document, because "prints
|
||||
#: nothing" is an answer a person can act on and a program cannot.
|
||||
SILENT = "silent"
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
"""The last-resort plain projection: the value as text, or the absent token."""
|
||||
from . import script
|
||||
|
||||
return script.NONE if value is None else str(value)
|
||||
|
||||
|
||||
def normalise(value: Any) -> Any:
|
||||
"""A typed value in its machine form: JSON-ready, and the same on every host.
|
||||
|
||||
The one place the contract's cross-cutting value rules are applied, so a command
|
||||
cannot forget one:
|
||||
|
||||
* a **time** is UTC, RFC 3339, ``Z``-suffixed, to the second — always twenty
|
||||
characters, so string comparison is time comparison. A local offset is a fact about
|
||||
the machine that ran the command rather than about the event, and two hosts asking
|
||||
the same radio the same question must produce the same document. A *naive* time is
|
||||
one whose offset we do not actually know, which is not a fact, so it reads as absent
|
||||
exactly as it does on the plain face.
|
||||
* an **enum** is its value, not its Python name.
|
||||
* a **path** is its text.
|
||||
* everything else travels as it is, typed: ``9`` and not ``"9"``, ``false`` and not
|
||||
``"false"``, ``None`` and not the plain face's ``-``.
|
||||
|
||||
Args:
|
||||
value: The typed value from a report row.
|
||||
|
||||
Returns:
|
||||
The value in a form :mod:`json` can write.
|
||||
"""
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
return None
|
||||
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
if isinstance(value, Enum):
|
||||
return normalise(value.value)
|
||||
if isinstance(value, Path):
|
||||
return str(value)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [normalise(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Lane:
|
||||
"""One plain-text column, drawn from its :class:`Column`'s typed value.
|
||||
|
||||
A lane is the *reader's* half of a column. Several may share one column where the
|
||||
plain face spends more room on a concept than the machine face does — a node is one
|
||||
JSON object and up to four plain columns (its name, its role, its hash, its key),
|
||||
which is the whole reason a column owns a list of these rather than one header string.
|
||||
|
||||
Attributes:
|
||||
header: The uppercase heading, and the field a caller greps for. Renaming one
|
||||
silently breaks a script, so it is as much a contract as the exit status.
|
||||
render: Turns the column's typed value into this lane's cell.
|
||||
align: ``"right"`` for a lane of magnitudes — a column of numbers or ages is only
|
||||
comparable by eye once the digits line up.
|
||||
width: The cells this lane is pinned to in a *live* listing, where the widths
|
||||
cannot be measured because the records have not arrived yet. Ignored by an
|
||||
ordinary listing, which sizes every lane to its widest value.
|
||||
"""
|
||||
|
||||
header: str
|
||||
render: Callable[[Any], str] = _text
|
||||
align: str = "left"
|
||||
width: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Column:
|
||||
"""One concept in a report, with a projection for each face.
|
||||
|
||||
Attributes:
|
||||
key: The name this concept goes under: the JSON key, and the key a row's mapping
|
||||
holds its typed value at.
|
||||
lanes: The plain columns this concept draws — usually one. **Empty means the
|
||||
concept has no plain face**, which is how ``config show`` carries a setting's
|
||||
type and enum label into the document while printing the two fields a reader
|
||||
wants.
|
||||
json: The typed value's machine projection, or ``None`` where the concept has no
|
||||
machine face — the mirror of an empty ``lanes``, and what a preference's
|
||||
DESCRIPTION is: prose for whoever is choosing a value, carrying nothing a
|
||||
program would branch on. Defaults to :func:`normalise`, which handles the
|
||||
scalars; a shared shape (a node, a position) passes its own.
|
||||
"""
|
||||
|
||||
key: str
|
||||
lanes: tuple[Lane, ...] = ()
|
||||
json: Callable[[Any], Any] | None = normalise
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Block:
|
||||
"""One part of an answer.
|
||||
|
||||
Attributes:
|
||||
key: The name this block goes under in a multi-block document.
|
||||
plain_only: Whether this block is a rendering for a person that the machine face
|
||||
has no use for. The one case is a stream's closing summary: ``monitor`` prints
|
||||
a per-node aggregate under its live capture, and a consumer holding every
|
||||
record of that capture can compute the same thing — where a second *shape* of
|
||||
line in the middle of an otherwise homogeneous NDJSON stream costs every
|
||||
reader a discriminator it would otherwise never need.
|
||||
"""
|
||||
|
||||
key: str
|
||||
plain_only: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Listing(Block):
|
||||
"""Records under a header line: a listing plain, an array of objects in JSON.
|
||||
|
||||
Attributes:
|
||||
columns: The concepts each record carries, in machine-face order.
|
||||
rows: One mapping per record, keyed by ``Column.key`` and holding **typed**
|
||||
values.
|
||||
order: The lane headers in plain draw order. Empty means "as declared", which is
|
||||
what most listings want; a listing states it where the two orders genuinely
|
||||
differ — ``contacts`` puts HEARD and PKTS between a node's TYPE and its HASH,
|
||||
and ``monitor`` puts the one unbounded field (a node's name) last so it can
|
||||
never push a column.
|
||||
headed: Whether the plain face draws the header line. ``config show`` is the one
|
||||
listing that does not: it is an array of settings to a parser and a
|
||||
``sysctl -a`` block to a reader, and a header line over two columns whose keys
|
||||
are the answer would be furniture.
|
||||
"""
|
||||
|
||||
columns: tuple[Column, ...]
|
||||
rows: Sequence[Mapping[str, Any]] = ()
|
||||
order: tuple[str, ...] = ()
|
||||
headed: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject an ``order`` that does not name this listing's own lanes.
|
||||
|
||||
A typo here draws a listing with a column missing and no complaint, which is the
|
||||
one failure a header line cannot show. Cheaper to raise at construction.
|
||||
"""
|
||||
if not self.order:
|
||||
return
|
||||
known = {lane.header for column in self.columns for lane in column.lanes}
|
||||
if set(self.order) != known or len(self.order) != len(known):
|
||||
raise ValueError(f"{self.key}: order must name every lane exactly once")
|
||||
|
||||
def lanes(self) -> list[tuple[Column, Lane]]:
|
||||
"""The plain columns in draw order, each with the column it reads from."""
|
||||
pairs = [(column, lane) for column in self.columns for lane in column.lanes]
|
||||
if not self.order:
|
||||
return pairs
|
||||
by_header = {lane.header: (column, lane) for column, lane in pairs}
|
||||
return [by_header[header] for header in self.order]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Facts(Block):
|
||||
"""One thing, described key by key: a ``sysctl -a`` block plain, an object in JSON.
|
||||
|
||||
Attributes:
|
||||
fields: The concepts, in the order both faces present them.
|
||||
values: The typed value per ``Column.key``.
|
||||
shape: :data:`PAIRS`, :data:`BARE` or :data:`SILENT` — what the *plain* face
|
||||
prints. The machine face is the object either way.
|
||||
bare: With :data:`BARE`, the one field whose value is printed alone.
|
||||
omit_absent: Whether a field with no value is left out of the plain block
|
||||
entirely. Only ``info`` wants this, and deliberately: its readings are
|
||||
optional device queries, so a missing key says "this radio does not report
|
||||
it" where a dash would claim it reported nothing. The machine face keeps every
|
||||
declared key and writes ``null``, because "does not report" and "reported
|
||||
nothing" are the same fact to a caller and a variable key set costs every
|
||||
consumer a membership test on every field.
|
||||
"""
|
||||
|
||||
fields: tuple[Column, ...]
|
||||
values: Mapping[str, Any] = field(default_factory=dict)
|
||||
shape: str = PAIRS
|
||||
bare: str = ""
|
||||
omit_absent: bool = False
|
||||
|
||||
|
||||
#: A tool's whole answer. Deliberately a bare sequence rather than a class of its own:
|
||||
#: there is nothing a report *does*, and the two rules about how several blocks combine
|
||||
#: belong to the renderer that combines them (see :mod:`meshterm.ui.renderers`).
|
||||
Report = tuple[Block, ...]
|
||||
@@ -0,0 +1,640 @@
|
||||
"""The plain CLI's output language — text for a person at a prompt, aligned and unadorned.
|
||||
|
||||
MeshTerm has two front ends and a third face. 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. ``--json`` is read by
|
||||
a program, often somewhere else and often later. This module is the third: **someone at a
|
||||
prompt who typed a command to find something out, and wants the answer legible in one
|
||||
glance.**
|
||||
|
||||
That is a change of premise. The plain face used to be a serialisation format a person
|
||||
could squint at, and several of its rules existed only to make *splitting* safe — quoted
|
||||
names, absolute timestamps, a comma between path hops. Splitting is the machine face's job
|
||||
now (see :mod:`meshterm.ui.report` and :mod:`meshterm.ui.renderers`), so those rules were
|
||||
paying rent they could not afford. What survives is everything that makes the output a
|
||||
Unix utility's output, and one new rule in place of the quoting.
|
||||
|
||||
* **Alignment is the delimiter.** A column that lines up is already one field to the eye,
|
||||
so a name is bare (:func:`name`). The *escaping* under the quoting is not gone and never
|
||||
can be (:func:`_escaped`): a name is broadcast by its own node and a message body is
|
||||
filled in by a stranger, and neither may end the record it sits in.
|
||||
* **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.
|
||||
* **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``. The one deliberate
|
||||
exception is a *written page* (``about``, ``support``), which is drawn on a console
|
||||
:data:`PAGE_WIDTH` cells wide instead: a paragraph is not a record, it has no headings
|
||||
to land under, and unwrapped it is a single 600-cell line no terminal can read.
|
||||
* **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`.
|
||||
* **A time is an age.** ``5m``, ``3h``, ``never`` — the same ladder the menu's columns use
|
||||
(:func:`age`), because "how long ago" is the question a person is actually asking. An
|
||||
absolute instant survives where the instant *is* the fact: the device clock, a scheduled
|
||||
appointment, a live capture's own clock (:func:`stamp`). ``--absolute`` swaps the whole
|
||||
language back for anyone who wants it, and the machine face is absolute UTC regardless.
|
||||
* **A route is drawn, a path is typed.** The lexicon already splits these: a *path* is an
|
||||
ordered hop spec you compose or force, a *route* is the concrete node sequence a walk
|
||||
took. So a path keeps its commas (:func:`spec`) because it round-trips into ``--path``,
|
||||
and a route takes arrows (:func:`route`) because it is a picture and visibly is not a
|
||||
spec. Rendering both with commas promised a round trip only one of them has.
|
||||
* **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.
|
||||
``never`` is a *value*, not an absence: a node that has never been heard is a fact.
|
||||
* **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 dataclasses import dataclass
|
||||
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 = "-"
|
||||
|
||||
#: The separator between a route's hops, spaced — the same ``→`` the menu's path lines
|
||||
#: draw with (:data:`meshterm.ui.pathline._ARROW`), and device-verified present in the
|
||||
#: PicoCalc console font. It carries the direction the packet travelled, and it is what
|
||||
#: makes the quoting unnecessary: a comma inside a node's name can no longer be read as a
|
||||
#: hop boundary.
|
||||
ARROW = " → "
|
||||
|
||||
#: How wide a *written page* wraps. Not the terminal's width: the same paragraph must read
|
||||
#: the same when it is piped, redirected, or shipped as the machine face's ``text`` field,
|
||||
#: and 72 is the width every screen in this app is already held to.
|
||||
PAGE_WIDTH = 72
|
||||
|
||||
|
||||
#: The characters with a readable escape of their own. Everything else that cannot travel
|
||||
#: in a record falls through to ``\xNN``/``\uNNNN`` below.
|
||||
_BREAKS = {"\\": "\\\\", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
||||
|
||||
#: Codepoints that must never reach stdout unescaped, as a *category* rather than a list:
|
||||
#: every C0 and C1 control, plus the two Unicode separators a terminal also breaks a line
|
||||
#: on. A four-entry table caught the newline and the tab and let ESC through — and an ESC
|
||||
#: inside a node name is a live colour run written into the caller's file, which is the
|
||||
#: one thing "no colour, not a single escape sequence" exists to prevent. A BEL was worse:
|
||||
#: silently dropped, so the printed name was not the advertised one.
|
||||
_UNPRINTABLE = frozenset([*range(0x00, 0x20), 0x7F, *range(0x80, 0xA0), 0x2028, 0x2029])
|
||||
|
||||
|
||||
def _escaped(text: str) -> str:
|
||||
r"""``text`` with everything that cannot travel inside one record folded into an escape.
|
||||
|
||||
A record is a line and a field is a run of printable cells; this is what makes both
|
||||
true of a value that arrived over the air. Named escapes where one reads (``\n``),
|
||||
numeric escapes everywhere else, and the backslash doubled so the whole thing reads
|
||||
back unambiguously.
|
||||
|
||||
This is what survived the retirement of the quoting. The quotes made a name *one
|
||||
field* for a splitter, and alignment does that now; nothing else can stop a name
|
||||
holding a newline from ending its own record early.
|
||||
"""
|
||||
out: list[str] = []
|
||||
for ch in text:
|
||||
if ch in _BREAKS:
|
||||
out.append(_BREAKS[ch])
|
||||
elif ord(ch) in _UNPRINTABLE:
|
||||
out.append(f"\\x{ord(ch):02x}" if ord(ch) < 0x100 else f"\\u{ord(ch):04x}")
|
||||
else:
|
||||
out.append(ch)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def name(value: str | None) -> str:
|
||||
r"""A node's name as a field: bare and escaped, or :data:`NONE` when it has none.
|
||||
|
||||
The quotes are gone. They existed so a name holding a space or a comma stayed one
|
||||
field for whatever was splitting the line; the splitter reads JSON now, and in plain
|
||||
text the column alignment already says where a field ends. What survives is the
|
||||
escaping — a node broadcasts its own name, so every name here is remote data, and a
|
||||
newline in one must still not end the record it sits in.
|
||||
|
||||
An empty name is still :data:`NONE` rather than an empty cell: ``""`` says *called
|
||||
nothing* where the token says *never said*, and those are different facts. It is also
|
||||
the distinction the machine face needs — an absent name is ``null``, an empty one is
|
||||
``""`` — and it cannot be recovered from a field that flattened both.
|
||||
|
||||
Args:
|
||||
value: The name, or ``None``/empty where the node never supplied one.
|
||||
|
||||
Returns:
|
||||
The escaped name, or :data:`NONE`.
|
||||
"""
|
||||
return _escaped(value) if value else NONE
|
||||
|
||||
|
||||
def text(value: str | None) -> str:
|
||||
r"""Free text as a field that can end a record without breaking it.
|
||||
|
||||
For the field a listing puts last because it *is* the rest of the line — a message
|
||||
body, a preference's description. "The rest of the line" and "anything at all" are not
|
||||
compatible: a body holding a newline ended its record early and left the remainder
|
||||
indented under the other columns, reading as a second record with an empty ``TIME``.
|
||||
A body is also the field a stranger fills in, so that is not a hypothetical shape of
|
||||
message.
|
||||
|
||||
Args:
|
||||
value: The free text. ``None`` gives :data:`NONE`.
|
||||
|
||||
Returns:
|
||||
The text with its line breaks and tabs escaped.
|
||||
"""
|
||||
return NONE if value is None else _escaped(value)
|
||||
|
||||
|
||||
def stamp(when: datetime | None) -> str:
|
||||
"""Format a timestamp as local ISO-8601 to the second, or :data:`NONE`.
|
||||
|
||||
Kept for the times where the instant *is* the fact rather than a way of saying how
|
||||
long ago something was: the device's own clock, an appointment set with ``--at``, a
|
||||
live capture's per-row time (every row of which would otherwise read ``now``), and
|
||||
every column under ``--absolute``.
|
||||
|
||||
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 age(when: datetime | None, *, absent: str = NONE) -> str:
|
||||
"""How long ago ``when`` was: ``now``, ``5m``, ``3h``, ``2d``, ``4w``.
|
||||
|
||||
The default time form, and the reason is the premise change: a person at a prompt
|
||||
reading ``2026-09-07T19:58:53-04:00`` is doing arithmetic to answer "recently?", which
|
||||
is the question they typed the command to ask. Delegates to
|
||||
:func:`~meshterm.ui.widgets._format_age` so the two faces cannot drift apart.
|
||||
|
||||
Args:
|
||||
when: The instant to age. A naive datetime has no offset we know, so it reads as
|
||||
absent, exactly as :func:`stamp` treats it.
|
||||
absent: What an unknown time reads as. A heard-age passes ``"never"`` — a node
|
||||
that has never been heard is a fact, not a missing field — and everything else
|
||||
takes the default token, which says this row has no such time at all. The same
|
||||
distinction :func:`name` draws between ``-`` and an empty name, one axis over.
|
||||
|
||||
Returns:
|
||||
The age, or ``absent``.
|
||||
"""
|
||||
from .widgets import _age_seconds, _format_age
|
||||
|
||||
seconds = _age_seconds(when)
|
||||
return absent if seconds is None else _format_age(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)
|
||||
|
||||
|
||||
#: The units :func:`duration` counts in, largest first.
|
||||
_UNITS: tuple[tuple[int, str], ...] = ((86400, "d"), (3600, "h"), (60, "m"), (1, "s"))
|
||||
|
||||
|
||||
def duration(seconds: float | None) -> str:
|
||||
"""A span of seconds as something readable: ``93784`` → ``1d 2h``, ``360`` → ``6m``.
|
||||
|
||||
Two units at most and always adjacent, so the magnitude arrives at a glance and the
|
||||
precision never outruns what anyone would act on. Used only as a *gloss* beside the
|
||||
raw figure (``uptime_s 93784 (1d 2h)``), never in place of it — the key says what
|
||||
the number counts, and a caller reading the key must still find a number under it.
|
||||
|
||||
Args:
|
||||
seconds: The span. ``None`` reads as absent; the sign is dropped, since a caller
|
||||
glossing a signed reading says which way in its own words.
|
||||
|
||||
Returns:
|
||||
The span in one or two units, or :data:`NONE`.
|
||||
"""
|
||||
if seconds is None:
|
||||
return NONE
|
||||
whole = int(abs(seconds))
|
||||
amounts: list[tuple[int, str]] = []
|
||||
for size, unit in _UNITS:
|
||||
amounts.append((whole // size, unit))
|
||||
whole %= size
|
||||
lead = next((i for i, (amount, _) in enumerate(amounts) if amount), len(amounts) - 1)
|
||||
parts = [f"{amounts[lead][0]}{amounts[lead][1]}"]
|
||||
if lead + 1 < len(amounts) and amounts[lead + 1][0]:
|
||||
parts.append(f"{amounts[lead + 1][0]}{amounts[lead + 1][1]}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def location(lat: float | None, lon: float | None) -> str:
|
||||
"""A shared position as one field: ``45.50190,-73.56740``, or :data:`NONE`.
|
||||
|
||||
One column rather than two. A coordinate pair is one fact to a reader, it is what goes
|
||||
into a map's search box verbatim, and two columns of ``-`` for every contact that has
|
||||
never shared a position is worse than one.
|
||||
|
||||
Args:
|
||||
lat: Latitude in decimal degrees.
|
||||
lon: Longitude in decimal degrees.
|
||||
|
||||
Returns:
|
||||
The pair to five decimals, or :data:`NONE` when either half is missing.
|
||||
"""
|
||||
if lat is None or lon is None:
|
||||
return NONE
|
||||
return f"{lat:.5f},{lon:.5f}"
|
||||
|
||||
|
||||
def route(hops: Iterable[tuple[str | None, str | None]]) -> str:
|
||||
"""A walked hop sequence, drawn: ``Yagi-Repeater (a1) → Alice (d4) → Yagi (a1)``.
|
||||
|
||||
A *route* is what a walk actually did, so it is a picture and not a spec — which is
|
||||
why it takes :data:`ARROW` and not the comma ``--path`` accepts. Rendering it with
|
||||
commas made it look pasteable, which it is not: the names are in it, and it is a
|
||||
round trip nothing has.
|
||||
|
||||
The hash rides on every hop and always. It is the join key to the per-hop table below
|
||||
a trace, the token ``--path`` takes, and the disambiguator for two contacts sharing a
|
||||
name. Dropping it "where the name is unique" would make one line's grammar depend on
|
||||
another line's content.
|
||||
|
||||
Our own node is a hop like any other — named and hashed. The menu draws it as ``★``
|
||||
because a reader never has to be told which node is theirs; here the line is as often
|
||||
read out of a file by someone who was not at the prompt when it ran.
|
||||
|
||||
Args:
|
||||
hops: ``(label, hash)`` pairs in propagation order. A hop with both halves reads
|
||||
``Name (hash)``; a hop with one half is that half alone, never an empty
|
||||
``()`` — which is how the menu's :class:`~meshterm.ui.pathline.PathLine`
|
||||
labels an unresolved hop too.
|
||||
|
||||
Returns:
|
||||
The joined route, or :data:`NONE` when there are no hops.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for label, value in hops:
|
||||
if label and value:
|
||||
parts.append(f"{_escaped(label)} ({value})")
|
||||
else:
|
||||
parts.append(_escaped(label) if label else (value or NONE))
|
||||
return ARROW.join(parts) if parts else NONE
|
||||
|
||||
|
||||
def spec(hops: Iterable[str]) -> str:
|
||||
"""A forced path as the radio was given it: ``a1,d4,a1``.
|
||||
|
||||
The other half of the split :func:`route` names. This is the *spec* — comma-separated
|
||||
hashes, exactly what ``--path`` takes back — so it is the one line on either face that
|
||||
round-trips, and it must never grow a name, an arrow or a space.
|
||||
|
||||
Args:
|
||||
hops: The hop hashes in propagation order.
|
||||
|
||||
Returns:
|
||||
The comma-joined spec, or :data:`NONE` when there are no hops.
|
||||
"""
|
||||
parts = [hop for hop in hops if hop]
|
||||
return ",".join(parts) if parts else NONE
|
||||
|
||||
|
||||
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; an *age* lane aligns the
|
||||
same way, so the ladder from ``now`` to ``4w`` reads down the column.
|
||||
|
||||
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,
|
||||
# "crop", not "ignore". Both refuse to elide — neither ever writes the "…"
|
||||
# that would make a key or a route unusable to the caller — but Rich's
|
||||
# ``Text.wrap`` returns early on "ignore" (text.py: ``if overflow ==
|
||||
# "ignore": lines.append(line); continue``) and that early return is *above*
|
||||
# the justify step, so "ignore" silently discards ``justify="right"``. The
|
||||
# two settings differ only past the console's 16384 cells, where "crop" cuts
|
||||
# and "ignore" also cuts; they differ on every line before that, where only
|
||||
# "crop" aligns.
|
||||
overflow="crop",
|
||||
)
|
||||
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``,
|
||||
``trace``) — 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="crop")
|
||||
table.add_column(no_wrap=True, overflow="crop")
|
||||
for key, value in rows:
|
||||
table.add_row(key, value)
|
||||
return table
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Lanes:
|
||||
"""A fixed-width column layout for output whose widths arrive with the data.
|
||||
|
||||
:func:`columns` sizes each lane to its widest value, which it can only do once it has
|
||||
seen every record. A live capture has no "once": ``monitor`` prints a row the instant
|
||||
a packet lands, and the row after it may be twice as wide. Those two streams used to
|
||||
be gutter-joined with no alignment at all, which is why a name in one still carried
|
||||
its quotes — nothing else said where the field ended.
|
||||
|
||||
Pinning the lanes up front fixes it properly. A value wider than its lane **overruns
|
||||
and pushes the rest of that row right** rather than being elided: one row out of many
|
||||
is simply wider, and nothing lies about what it holds. Put the one unbounded field
|
||||
(a node's name, a message body) last and it cannot push anything at all.
|
||||
|
||||
Attributes:
|
||||
widths: ``(header, width)`` per lane, in order.
|
||||
right: The headers whose cells right-align.
|
||||
"""
|
||||
|
||||
widths: tuple[tuple[str, int], ...]
|
||||
right: frozenset[str] = frozenset()
|
||||
|
||||
@property
|
||||
def header(self) -> str:
|
||||
"""The one header line, printed before the first record."""
|
||||
return self.record(*(header for header, _ in self.widths))
|
||||
|
||||
def record(self, *cells: str) -> str:
|
||||
"""One record, padded into the lanes and joined by the gutter."""
|
||||
out: list[str] = []
|
||||
for (header, width), cell in zip(self.widths, cells, strict=True):
|
||||
out.append(cell.rjust(width) if header in self.right else cell.ljust(width))
|
||||
return (" " * GUTTER).join(out).rstrip()
|
||||
|
||||
|
||||
def stream(*widths: tuple[str, int], right: Sequence[str] = ()) -> Lanes:
|
||||
"""Build a fixed-lane layout for a live stream (see :class:`Lanes`).
|
||||
|
||||
Args:
|
||||
*widths: ``(header, width)`` per lane, in order.
|
||||
right: The headings whose values right-align.
|
||||
|
||||
Returns:
|
||||
The layout, which prints its own header and formats each record.
|
||||
"""
|
||||
return Lanes(widths=widths, right=frozenset(right))
|
||||
|
||||
|
||||
# -- 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,
|
||||
# A node broadcasts its own name, so every name on this console is remote data.
|
||||
# Rich reads `[...]` as a style tag: `[bold]Loud` printed as `Loud` — silently
|
||||
# corrupted, and no longer the string that identifies the node — while `[/]Bob`
|
||||
# raised MarkupError and took the whole command down with it. Nothing the scripted
|
||||
# CLI prints is ever marked up, so the parser has nothing to do here but misfire.
|
||||
markup=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, an acknowledgement and an error message all go here instead — visible in a
|
||||
terminal, absent from ``meshterm contacts > contacts.txt``. This one keeps its colour,
|
||||
because a terminal is usually the only thing that reads it.
|
||||
|
||||
**Off a terminal it stops wrapping**, and that is the point of the width below. Rich
|
||||
falls back to 80 cells when it cannot measure the destination, so ``2> errors.log``
|
||||
used to hard-wrap every message at 80 — and ``grep 'not already connected'`` then
|
||||
found nothing, because the sentence it was looking for had been broken across three
|
||||
lines by the logger. In a real terminal the width is left alone: a progress bar sized
|
||||
to 16384 cells is not a progress bar.
|
||||
|
||||
Returns:
|
||||
A themed :class:`~rich.console.Console` writing to ``sys.stderr``.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from .theme import active_theme
|
||||
|
||||
reconfigure = getattr(sys.stderr, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
try: # the ✓ on an acknowledgement must not depend on the machine's code page
|
||||
reconfigure(encoding="utf-8")
|
||||
except (ValueError, OSError): # pragma: no cover - stream not reconfigurable
|
||||
pass
|
||||
try:
|
||||
wraps = bool(sys.stderr.isatty())
|
||||
except (AttributeError, ValueError): # pragma: no cover - a closed or exotic stream
|
||||
wraps = False
|
||||
return Console(
|
||||
file=sys.stderr,
|
||||
theme=active_theme(),
|
||||
markup=False,
|
||||
emoji=False,
|
||||
width=None if wraps else WIDTH,
|
||||
)
|
||||
|
||||
|
||||
# -- 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 — a tool states its answer as a
|
||||
:mod:`~meshterm.ui.report` and the plain renderer builds it through :func:`columns`
|
||||
and :func:`pairs`. 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.
|
||||
|
||||
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 = "crop"
|
||||
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("")
|
||||
+91
-72
@@ -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,23 @@ 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.
|
||||
|
||||
Both surfaces show them; what differs is *where*. The menu prints them in its
|
||||
result window. The CLI prints them on **stderr** (see :meth:`PlainUi.ack`), which
|
||||
keeps the promise the dropping was made to keep — ``meshterm contacts > f`` still
|
||||
catches only the answer — while giving the person at the prompt back the ✓ and the
|
||||
count they were losing to a rule written for a redirect they were not doing.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def view(
|
||||
self, renderable: RenderableType, *, title: str = "", footer_hint: str = ""
|
||||
) -> None:
|
||||
@@ -291,69 +305,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 +324,83 @@ 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)
|
||||
"""Print a markup line to the console immediately, as plain text.
|
||||
|
||||
The parameter is markup — that is the shared surface's contract, and forty-odd
|
||||
callers write it — but the scripted console does not interpret markup: a node
|
||||
broadcasts its own name, and Rich would read ``[...]`` in one as a style tag. So
|
||||
the tags are resolved *here* rather than by the console, and what reaches stdout
|
||||
is the text they were wrapping. Printing them raw put ``[muted]`` and ``[/muted]``
|
||||
around the private key that ``config export-key > key.hex`` is supposed to be the
|
||||
whole content of.
|
||||
|
||||
Malformed markup — which is what a *name* holding a bracket looks like — is not an
|
||||
error to report but a string to print, so it falls through verbatim.
|
||||
"""
|
||||
from rich.errors import MarkupError
|
||||
from rich.markup import render
|
||||
|
||||
try:
|
||||
self.console.print(render(markup).plain)
|
||||
except MarkupError:
|
||||
self.console.print(markup)
|
||||
|
||||
def ack(self, markup: str) -> None:
|
||||
"""Print it on stderr, where everything *about* the run goes.
|
||||
|
||||
These used to be dropped, and the reason was sound as far as it went: an
|
||||
acknowledgement is not the answer, and a caller redirecting stdout must catch only
|
||||
the answer. But stdout is not the only stream — putting them on stderr honours that
|
||||
rule exactly, and a person watching a five-minute ``config restore`` gets the
|
||||
setting-by-setting ✓ they had no way to see.
|
||||
|
||||
The markup is resolved here rather than by the console, as :meth:`note` explains,
|
||||
and a *name* holding a square bracket is not an error to report but a string to
|
||||
print — so malformed markup falls through verbatim.
|
||||
"""
|
||||
from rich.errors import MarkupError
|
||||
from rich.markup import render
|
||||
|
||||
from . import script
|
||||
|
||||
try:
|
||||
line: Text | str = render(markup)
|
||||
except MarkupError:
|
||||
line = markup
|
||||
script.stderr_console().print(line, highlight=False)
|
||||
|
||||
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 +609,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.
|
||||
|
||||
|
||||
+12
-2
@@ -1037,6 +1037,16 @@ class ContactsSort:
|
||||
columns: tuple[str, ...] = _SORT_COLUMNS
|
||||
opens_ascending: dict[str, bool] = field(default_factory=lambda: _SORT_OPENS_ASCENDING)
|
||||
|
||||
@classmethod
|
||||
def names(cls, columns: tuple[str, ...] = _SORT_COLUMNS) -> tuple[str, ...]:
|
||||
"""The orders :meth:`from_name` accepts — what a caller may legitimately ask for.
|
||||
|
||||
:meth:`from_name` answers "which sort is this?" and forgives anything it does not
|
||||
recognise. A surface that wants to *refuse* an unknown order needs the set itself,
|
||||
which is this.
|
||||
"""
|
||||
return columns
|
||||
|
||||
@classmethod
|
||||
def from_name(
|
||||
cls,
|
||||
@@ -1061,7 +1071,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 +1304,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)
|
||||
|
||||
@@ -18,6 +18,7 @@ always see the colours the app really emits.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Callable, Iterable, Iterator
|
||||
|
||||
@@ -87,6 +88,33 @@ def _reset_transmit_gate() -> Iterator[None]:
|
||||
transmit_clock().reset()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_config_dir(tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]:
|
||||
"""No test may reach the developer's own ``~/.meshterm``.
|
||||
|
||||
``$MESHTERM_HOME`` moves the whole directory — the history, the contact and channel
|
||||
caches, the outbox, the stored admin passwords, the device profiles, the preferences
|
||||
and the log. ``--db`` moves only the first of those, which is why isolating a CLI test
|
||||
with ``--db`` alone was not isolation at all: a synthetic contact written by a probe
|
||||
sat in the real cache and made ``test_cli_contract`` fail on one machine and pass on
|
||||
every other, and every run appended to the developer's real ``meshterm.log`` and read
|
||||
their real ``log_level``.
|
||||
|
||||
Autouse and unconditional, because the exposure is not the CLI's: anything that
|
||||
touches ``Settings.load()``, a preference, a store or the logger has it. A test that
|
||||
wants a *populated* directory sets the variable itself; ``monkeypatch.setenv`` inside
|
||||
a test still wins over this.
|
||||
"""
|
||||
home = tmp_path_factory.mktemp("meshterm-home")
|
||||
previous = os.environ.get("MESHTERM_HOME")
|
||||
os.environ["MESHTERM_HOME"] = str(home)
|
||||
yield
|
||||
if previous is None:
|
||||
os.environ.pop("MESHTERM_HOME", None)
|
||||
else:
|
||||
os.environ["MESHTERM_HOME"] = previous
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_platform() -> Iterator[None]:
|
||||
"""Every test starts and ends on :data:`~meshterm.platforms.REGULAR`.
|
||||
|
||||
@@ -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."""
|
||||
@@ -190,7 +194,7 @@ async def test_advert_cadence_op_writes_the_store(ctx: AppContext) -> None:
|
||||
device = await ctx.device()
|
||||
ctx._ui = _NoteUi() # type: ignore[assignment]
|
||||
snapshot = dict(await device.get_self_info())
|
||||
changes, _ = await apply_ops(
|
||||
changes, _artifacts, _report = await apply_ops(
|
||||
ctx, device, snapshot, [("advert_cadence", True, 168), ("advert_cadence", False, OFF)]
|
||||
)
|
||||
assert changes == 2
|
||||
|
||||
@@ -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,660 @@
|
||||
"""End-to-end tests for what the CLI actually prints and returns.
|
||||
|
||||
:mod:`tests.test_script_output` covers the vocabulary and :mod:`tests.test_report` the
|
||||
seam; this covers the commands built out of both — the real Typer app, driven against the
|
||||
simulator, asserting the things a caller depends on: no escape sequences, no wrapped
|
||||
records, a header its records line up under, a documented exit status — and, under
|
||||
``--json``, a document that parses and a status that did not change because of a rendering
|
||||
flag.
|
||||
|
||||
Every command here is read-only or simulator-backed, so nothing transmits.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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\[")
|
||||
|
||||
#: A Rich console-markup tag. The scripted console no longer *interprets* markup — a node
|
||||
#: name is remote data and `[bold]` in one is a name, not a style — which means a tag
|
||||
#: MeshTerm writes itself is no longer consumed either, and lands in the caller's pipe.
|
||||
_MARKUP = re.compile(r"\[/?(?:ok|err|warn|muted|accent|brand|bold|dim|reverse)\b[^\]]*\]")
|
||||
|
||||
#: The shape of a drawn route: one or more hops, each a bare name with its hash in
|
||||
#: parentheses (or one of the two halves alone, where history knows only one), joined by
|
||||
#: the spaced arrow. This is the whole grammar, and the arrow is what makes it one — a
|
||||
#: name holding a comma no longer needs quoting to stay one hop.
|
||||
_HOP = r"[^\n→]+?"
|
||||
_ROUTE_LINE = re.compile(f"{_HOP}(?: → {_HOP})*")
|
||||
|
||||
#: The shape of a path *spec*: comma-joined lowercase hex, exactly what ``--path`` takes
|
||||
#: back. The one line on either face that round-trips, so nothing may creep into it.
|
||||
_PATH_SPEC = re.compile(r"[0-9a-f]+(?:,[0-9a-f]+)*")
|
||||
|
||||
#: 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", "HASH", "LOCATION", "KEY"]),
|
||||
("info", [], ["name", "public_key", "role"]),
|
||||
("config", ["show"], ["name", "radio_freq", "tx_power"]),
|
||||
("preferences", ["show"], ["PREFERENCE", "VALUE", "DEFAULT", "DESCRIPTION"]),
|
||||
("chat", ["list"], ["CONVERSATION", "KIND", "UNREAD", "LAST", "LAST_TEXT"]),
|
||||
("platform", [], ["platform", "icons"]),
|
||||
]
|
||||
|
||||
|
||||
def _leaf_commands() -> list[tuple[str, ...]]:
|
||||
"""Every registered subcommand path, walked out of the real Typer app.
|
||||
|
||||
Derived rather than listed: a hand-kept roster covers whatever was reworked the day it
|
||||
was written, and a command added afterwards inherits none of it. Two rule violations
|
||||
shipped inside commands the roster did not name — a 16384-cell rule in ``about`` and
|
||||
markup tags in ``config export-key``.
|
||||
"""
|
||||
from typer.main import get_command
|
||||
|
||||
from meshterm.cli import app
|
||||
|
||||
def walk(command, prefix: tuple[str, ...] = ()) -> list[tuple[str, ...]]: # noqa: ANN001
|
||||
found: list[tuple[str, ...]] = []
|
||||
for name, sub in sorted(getattr(command, "commands", {}).items()):
|
||||
here = (*prefix, name)
|
||||
found.extend(walk(sub, here) if getattr(sub, "commands", None) else [here])
|
||||
return found
|
||||
|
||||
return walk(get_command(app))
|
||||
|
||||
|
||||
#: Commands that own the terminal or run until interrupted: they have no one-shot output.
|
||||
_NOT_ONE_SHOT = {("monitor",), ("chat", "listen"), ("tx-optimize",)}
|
||||
|
||||
#: The one command whose output *is* its colour, and says so (:mod:`meshterm.ui.specimen`).
|
||||
_KEEPS_ITS_COLOUR = {("specimen",)}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # noqa: ANN201 - a closure
|
||||
"""Invoke the real CLI against the simulator, in a config directory of its own.
|
||||
|
||||
``--db`` moves the history and *nothing else*: the contact cache, the outbox, the
|
||||
channel cache, the stored admin passwords and any device profiles all live in the
|
||||
config directory regardless. So a suite that set only ``--db`` read whatever was in
|
||||
the developer's own ``~/.meshterm`` — which is how a contact named ``[/]Bob``, planted
|
||||
by hand on one machine, could crash this suite on that machine and pass everywhere
|
||||
else. ``$MESHTERM_HOME`` is the override that moves the whole directory, and it is
|
||||
the one a test wants.
|
||||
"""
|
||||
from meshterm.cli import app
|
||||
|
||||
monkeypatch.setenv("MESHTERM_HOME", str(tmp_path / "home"))
|
||||
runner = CliRunner()
|
||||
|
||||
def invoke(*args: str): # noqa: ANN202
|
||||
return runner.invoke(app, ["--mock", "--db", str(tmp_path / "test.db"), *args])
|
||||
|
||||
return invoke
|
||||
|
||||
|
||||
#: Commands that refuse a machine-readable face outright, and say so as a usage error.
|
||||
_REFUSES_JSON = {("specimen",)}
|
||||
|
||||
|
||||
# -- 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.stdout)
|
||||
|
||||
|
||||
@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.stdout, 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.stdout) & 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.
|
||||
|
||||
``result.output`` is a third buffer that *both* streams copy into, so a rule about
|
||||
stdout asserted against it passes when the field it wants only ever appeared on
|
||||
stderr — and fails for the wrong reason the moment anything logs.
|
||||
"""
|
||||
return result.stdout.splitlines()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"leaf",
|
||||
[c for c in _leaf_commands() if c not in _NOT_ONE_SHOT | _KEEPS_ITS_COLOUR],
|
||||
ids=lambda leaf: "-".join(leaf),
|
||||
)
|
||||
def test_every_registered_command_obeys_the_rules(run, leaf) -> None: # noqa: ANN001
|
||||
"""The rules belong to the CLI, not to the six commands somebody listed by hand.
|
||||
|
||||
Run bare, a command that needs arguments fails harmlessly as a usage error and still
|
||||
proves it wrote nothing awkward on the way out; a command that can answer without
|
||||
arguments is exercised for real. That is where both shipped violations were — the
|
||||
``---`` in ``about`` drawn as a rule the width of a 16384-cell console, and the
|
||||
``[muted]`` tags around what ``config export-key`` exists to emit.
|
||||
"""
|
||||
out = run(*leaf).stdout
|
||||
assert not _ANSI.search(out), f"{' '.join(leaf)} put an escape sequence on stdout"
|
||||
assert not set(out) & set("─│┌┐└┘├┤━┃╭╮╰╯"), f"{' '.join(leaf)} drew a frame or a rule"
|
||||
assert not _MARKUP.search(out), f"{' '.join(leaf)} printed a console markup tag"
|
||||
for line in out.splitlines():
|
||||
assert line == line.rstrip(), f"{' '.join(leaf)} padded a line: {line!r}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"leaf",
|
||||
[c for c in _leaf_commands() if c not in _NOT_ONE_SHOT | _KEEPS_ITS_COLOUR],
|
||||
ids=lambda leaf: "-".join(leaf),
|
||||
)
|
||||
def test_every_registered_command_answers_json_that_parses(run, leaf) -> None: # noqa: ANN001
|
||||
"""``--json`` is a rendering, so it belongs to the CLI rather than to a list of tools.
|
||||
|
||||
It reached two commands out of twenty and stopped, because each one that wanted it had
|
||||
to restate its whole answer above the rendering. The same sweep that keeps the plain
|
||||
rules honest now runs every command a second time and reads what comes back: **every
|
||||
line on stdout is one complete document**, on success and on empty alike, and a
|
||||
command that fails writes nothing there at all.
|
||||
"""
|
||||
result = run("--json", *leaf)
|
||||
for line in result.stdout.splitlines():
|
||||
json.loads(line) # raises, with the offending line, if anything else got out
|
||||
if result.exit_code in (exitcodes.OK, exitcodes.NO_RESULT):
|
||||
assert result.stdout.strip(), f"{' '.join(leaf)} exited {result.exit_code} silently"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"leaf",
|
||||
[c for c in _leaf_commands() if c not in _NOT_ONE_SHOT | _KEEPS_ITS_COLOUR],
|
||||
ids=lambda leaf: "-".join(leaf),
|
||||
)
|
||||
def test_the_two_faces_never_disagree_about_the_exit_status(run, leaf) -> None: # noqa: ANN001
|
||||
"""``--json`` changes the rendering, never the report.
|
||||
|
||||
The status is the report — it is what a caller branches on — and a flag about *how the
|
||||
answer looks* has no business moving it. This is the cross-check that stops the two
|
||||
faces drifting the way they did before: ``devices --json`` used to exit ``0`` where the
|
||||
plain path said ``5``, so a script could not tell an empty scan from a full one by
|
||||
asking the same question twice.
|
||||
"""
|
||||
assert run("--json", *leaf).exit_code == run(*leaf).exit_code, " ".join(leaf)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("leaf", sorted(_REFUSES_JSON), ids=lambda leaf: "-".join(leaf))
|
||||
def test_a_command_whose_output_is_the_colour_refuses_json_loudly(run, leaf) -> None: # noqa: ANN001
|
||||
"""``specimen``'s output *is* the colour; a document of it would be an empty gesture.
|
||||
|
||||
A loud refusal is the honest answer, and it is the same one this project already gives
|
||||
for the map, the dashboard and the live feed — which simply have no subcommand to
|
||||
refuse from. It is a usage error, so stdout stays empty and the sentence goes where
|
||||
every other failure's does.
|
||||
"""
|
||||
result = run("--json", *leaf)
|
||||
assert result.exit_code == exitcodes.USAGE
|
||||
assert result.stdout == ""
|
||||
assert "machine-readable" in result.stderr
|
||||
|
||||
|
||||
def test_the_menu_has_no_document_and_says_so(run) -> None: # noqa: ANN001
|
||||
"""``meshterm --json`` with no subcommand would launch a full-screen session.
|
||||
|
||||
There is no document an interactive session can emit, and starting one would hang
|
||||
whatever was waiting to parse it.
|
||||
"""
|
||||
result = run("--json")
|
||||
assert result.exit_code == exitcodes.USAGE
|
||||
assert result.stdout == ""
|
||||
|
||||
|
||||
def test_the_sweep_actually_covers_the_whole_command_surface() -> None:
|
||||
"""A derived list is only a guarantee while it is still finding the commands.
|
||||
|
||||
If the walk breaks — a Typer upgrade moving ``commands``, say — every parametrised
|
||||
case silently becomes zero cases and the sweep passes by covering nothing.
|
||||
"""
|
||||
leaves = _leaf_commands()
|
||||
assert len(leaves) > 40, f"only found {len(leaves)} commands: {leaves}"
|
||||
for expected in [("about",), ("config", "export-key"), ("contacts",), ("trace",)]:
|
||||
assert expected in leaves, expected
|
||||
|
||||
|
||||
def test_a_global_option_may_be_typed_after_the_subcommand(run) -> None: # noqa: ANN001
|
||||
"""``meshterm contacts --json`` is what a person types, so it has to be what works.
|
||||
|
||||
Click binds an option to whatever command declares it, so a global declared on the
|
||||
callback is a parse error one word later. For an output format that is the wrong way
|
||||
round — ``-o json`` goes after the verb in every tool that has one — and the failure
|
||||
was a bare usage error with no hint that the flag simply needed moving.
|
||||
"""
|
||||
after = run("contacts", "--json")
|
||||
before = run("--json", "contacts")
|
||||
assert after.exit_code == exitcodes.OK, after.output
|
||||
assert after.stdout == before.stdout
|
||||
|
||||
|
||||
def test_a_global_option_taking_a_value_moves_with_its_value(run) -> None: # noqa: ANN001
|
||||
"""Lifting ``--db`` to the front must take the path with it, not orphan it."""
|
||||
assert run("contacts", "--sort", "name", "--json").exit_code == exitcodes.OK
|
||||
|
||||
|
||||
def test_lifting_a_global_never_steals_a_subcommands_own_help(run) -> None: # noqa: ANN001
|
||||
"""``contacts --help`` must stay the *contacts* help, not silently become the app's.
|
||||
|
||||
``--help`` is on every command, so a rule that lifts "the group's options" would take
|
||||
it too and answer a different question than the one asked.
|
||||
"""
|
||||
contacts_help = run("contacts", "--help").stdout
|
||||
assert "contacts [OPTIONS]" in contacts_help
|
||||
assert "--sort" in contacts_help # a contacts option, absent from the app's own help
|
||||
app_help = run("--help").stdout
|
||||
assert "COMMAND [ARGS]" in app_help
|
||||
assert "--sort" not in app_help
|
||||
|
||||
|
||||
def test_a_time_column_is_headed_by_its_fact_not_by_its_format(run) -> None: # noqa: ANN001
|
||||
"""``--absolute`` changes what is under the heading, so the heading must survive it.
|
||||
|
||||
A column headed ``AGE`` holding ``2026-09-08T04:18:25-04:00`` is a heading that lies —
|
||||
and every other time column already names the fact (``HEARD``, ``RECORDED``), which
|
||||
reads correctly whichever form the run asked for.
|
||||
"""
|
||||
run("chat", "send", "--to", "Alice", "hello")
|
||||
relative = result_lines(run("chat", "history", "--to", "Alice"))[0].split()
|
||||
absolute = result_lines(run("chat", "history", "--to", "Alice", "--absolute"))[0].split()
|
||||
assert relative == absolute
|
||||
assert "AGE" not in relative
|
||||
|
||||
|
||||
# -- listings -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_contacts_names_every_node_bare_and_ages_every_time(run) -> None: # noqa: ANN001
|
||||
"""The two rules that reversed, checked on the listing they were reversed for.
|
||||
|
||||
A name is bare — the alignment is what makes it one field now — and ``HEARD`` is an
|
||||
age, because "heard recently?" is the question this listing is opened to ask and an ISO
|
||||
instant makes the reader do arithmetic to answer it. ``--absolute`` puts the instants
|
||||
back, for anyone who wants them.
|
||||
"""
|
||||
header, *records = result_lines(run("contacts"))
|
||||
assert header.split() == ["NAME", "TYPE", "HEARD", "PKTS", "HASH", "LOCATION", "KEY"]
|
||||
assert records, "the simulator always has contacts"
|
||||
for line in records:
|
||||
assert not line.startswith('"')
|
||||
age = line.split()[2]
|
||||
assert age in ("now", "never") or age[-1] in "mhdw"
|
||||
|
||||
# `--absolute` asks for a different *form* of time, not for one fewer fact, so
|
||||
# `never` survives it: a node that has never been heard has no instant to print.
|
||||
absolute = result_lines(run("--absolute", "contacts"))[1:]
|
||||
for line in absolute:
|
||||
stamp = line.split()[2]
|
||||
assert stamp in ("-", "never") 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_every_setting_show_prints_is_one_set_takes_back(run) -> None: # noqa: ANN001
|
||||
"""The round-trip claim, checked on every setting rather than on the two easy ones.
|
||||
|
||||
``config show > f`` and feeding ``f`` back in is the obvious thing to do with a dump,
|
||||
and the failure mode is silent: an empty string printed as ``""`` used to parse back
|
||||
as *the two quote characters*, and the next dump looked identical, so nothing ever
|
||||
said the setting had been replaced. A refusal is fine here — you cannot set a value
|
||||
the firmware never reported — but a value that parses to something else is not.
|
||||
"""
|
||||
from meshterm.core import device_config as dc
|
||||
from meshterm.tools.config import _script_value
|
||||
|
||||
probes = {
|
||||
"str": ["", "Yagi-Repeater", "a name with spaces"],
|
||||
"bool": [True, False],
|
||||
"int": [0, 1, 12],
|
||||
"float": [0.0, 3.5],
|
||||
}
|
||||
checked = 0
|
||||
for _category, specs in dc.settings_by_category():
|
||||
for spec in specs:
|
||||
if spec.value_type == "enum" and spec.choices:
|
||||
values: list = list(spec.choices)
|
||||
else:
|
||||
values = probes.get(spec.value_type, [])
|
||||
for value in values:
|
||||
printed = _script_value(spec, value)
|
||||
try:
|
||||
parsed = dc.parse_value(spec, printed, {})
|
||||
except dc.DeviceConfigError:
|
||||
continue # a loud refusal is honest; a wrong value is not
|
||||
checked += 1
|
||||
assert parsed == value, f"{spec.key}: show printed {printed!r} -> {parsed!r}"
|
||||
assert checked > 40, f"only {checked} round-trips exercised"
|
||||
|
||||
|
||||
def test_an_unreported_pin_is_not_masked_as_though_one_were_set(run) -> None: # noqa: ANN001
|
||||
"""Bullets say "there is a secret here". An absence must not borrow that claim.
|
||||
|
||||
``conceal`` already declines to mask the *menu's* absence token for exactly this
|
||||
reason, but the scripted token is ``-``, which it had never been shown — so a radio
|
||||
that had never reported a PIN dumped six bullets, and the reader (or the bug report
|
||||
they pasted it into) read that as PIN-locked.
|
||||
"""
|
||||
from meshterm.ui import script
|
||||
from meshterm.ui.config_editor import MASK_MARK, conceal
|
||||
|
||||
# The rule at the boundary where it broke: a real value is masked, an absence is not,
|
||||
# and each surface says which glyph it writes an absence with.
|
||||
assert conceal("1234", absent=script.NONE) == MASK_MARK * 6
|
||||
assert conceal(script.NONE, absent=script.NONE) == script.NONE
|
||||
assert conceal("?") == "?"
|
||||
|
||||
# And the two surfaces must never disagree about the same radio: `show` wears bullets
|
||||
# only where `get` has something to conceal.
|
||||
values = dict(
|
||||
line.split(None, 1) for line in result_lines(run("config", "show")) if " " in line
|
||||
)
|
||||
got = run("config", "get", "device_pin").output.strip()
|
||||
assert (MASK_MARK in values["device_pin"]) == (got != script.NONE), (
|
||||
f"show says {values['device_pin']!r} while get says {got!r}"
|
||||
)
|
||||
|
||||
|
||||
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_draws_its_route_with_arrows_and_keeps_its_path_in_commas(run) -> None: # noqa: ANN001
|
||||
"""The sharpest of the reversals, and the reason is the project's own lexicon.
|
||||
|
||||
A *path* is a spec you compose and can paste back into ``--path``, so it stays
|
||||
comma-separated and round-trippable. A *route* is what a walk actually did; it is not a
|
||||
spec, and rendering it with commas promised a round trip it does not have. Our own node
|
||||
is still named like any other hop — the menu's ``★`` says "you already know who this
|
||||
is", which is true of the reader and false of whoever opens the file later.
|
||||
"""
|
||||
result = run("trace-path", "--path", "a1,d4,a1")
|
||||
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
|
||||
assert "→" in route
|
||||
assert _ROUTE_LINE.fullmatch(route), route
|
||||
|
||||
assert _PATH_SPEC.fullmatch(facts["path"]), facts["path"]
|
||||
assert "→" not in facts["path"]
|
||||
|
||||
|
||||
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-path", "--path", "a1,d4,a1"))
|
||||
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_and_never_on_stdout(run) -> None: # noqa: ANN001
|
||||
"""``program: what went wrong`` — so a caller parsing stdout never has to filter it."""
|
||||
result = run("config", "get", "nosuchkey")
|
||||
assert result.stdout == ""
|
||||
assert "meshterm: unknown setting" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
("config", "reboot"),
|
||||
("config", "factory-reset"),
|
||||
("config", "import-key", "00" * 32),
|
||||
("channels", "clear", "1"),
|
||||
("preferences", "reset"),
|
||||
],
|
||||
ids=["reboot", "factory-reset", "import-key", "channels-clear", "preferences-reset"],
|
||||
)
|
||||
def test_a_destructive_command_without_yes_is_a_silent_usage_error(run, args) -> None: # noqa: ANN001
|
||||
"""A missing confirmation *is* a bad argument, and it says so where errors go.
|
||||
|
||||
These used to print a red refusal on stdout and exit 1 — a colour and a sentence that
|
||||
is not the command's answer, in whatever was reading the command's answer.
|
||||
"""
|
||||
result = run(*args)
|
||||
assert result.exit_code == exitcodes.USAGE
|
||||
assert result.stdout == ""
|
||||
assert "--yes" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args,flag",
|
||||
[
|
||||
(("contacts", "--sort", "bogus"), "--sort"),
|
||||
(("records", "--category", "long_hual"), "--category"),
|
||||
],
|
||||
ids=["contacts-sort", "records-category"],
|
||||
)
|
||||
def test_a_value_outside_a_closed_set_is_refused_rather_than_ignored(run, args, flag) -> None: # noqa: ANN001
|
||||
"""A typo in a closed-set option used to be answered with a different question.
|
||||
|
||||
``--sort bogus`` quietly sorted by the default, and ``--category long_hual`` quietly
|
||||
selected *every* discipline — which then came back as exit ``5``, the code that means
|
||||
a real empty result. A caller branching on ``5`` could not tell "there are no records"
|
||||
from "you misspelled the discipline", which is the worst answer a closed set can give.
|
||||
"""
|
||||
result = run(*args)
|
||||
assert result.exit_code == exitcodes.USAGE
|
||||
assert result.stdout == ""
|
||||
assert flag in result.stderr
|
||||
|
||||
|
||||
def test_a_profile_that_names_nothing_is_refused_before_anything_transmits(run) -> None: # noqa: ANN001
|
||||
"""``--profile`` is a claim about *which* radio, and an unkeepable one is not a default.
|
||||
|
||||
An unknown name used to fall through to ordinary discovery, so a scheduled
|
||||
``--profile yagi config advert`` with a typo transmitted from whichever companion
|
||||
happened to be attached — the one failure mode where being quietly wrong puts a packet
|
||||
on the air.
|
||||
"""
|
||||
result = run("--profile", "nosuchprofile", "info")
|
||||
assert result.exit_code == exitcodes.USAGE
|
||||
assert result.stdout == ""
|
||||
assert "nosuchprofile" in result.stderr
|
||||
|
||||
|
||||
def test_a_port_that_will_not_open_is_no_device_not_a_device_failure(run) -> None: # noqa: ANN001
|
||||
"""The two device statuses answer different questions, and this one answered wrong.
|
||||
|
||||
``3`` says nothing was transmitted — look at what is plugged in. ``4`` says the radio
|
||||
was reached and the operation failed, so a retry is reasonable. An unopenable
|
||||
``--port`` reported ``4``, with the message "the connection to the device was lost"
|
||||
describing a connection there had never been. The manual's own ``case $?`` recipe
|
||||
branches on exactly this split.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from meshterm.cli import app
|
||||
|
||||
result = CliRunner().invoke(app, ["--port", "NOSUCHPORT99", "info"])
|
||||
assert result.exit_code == exitcodes.NO_DEVICE
|
||||
assert "could not open" in result.stderr
|
||||
assert "lost" not in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
("repeater-admin", "Nobody", "get", "name"),
|
||||
("courier", "queue", "Nobody", "hi"),
|
||||
("courier", "queue", "Alice", "hi", "--at", "25:99"),
|
||||
],
|
||||
ids=["unknown-admin-node", "unknown-courier-contact", "bad-at-time"],
|
||||
)
|
||||
def test_a_bad_argument_is_never_reported_as_a_device_failure(run, args) -> None: # noqa: ANN001
|
||||
"""``4`` promises a caller that the radio answered and a retry is worth trying.
|
||||
|
||||
Each of these is the *argument* being wrong, decided before a word goes over the air —
|
||||
so a caller that retried on ``4`` would retransmit nothing, forever. ``tx-optimize``
|
||||
already answered the identical question the identical way for a missing password;
|
||||
these three were the ones that had not caught up.
|
||||
"""
|
||||
result = run(*args)
|
||||
assert result.exit_code == exitcodes.USAGE, result.output
|
||||
assert result.stdout == ""
|
||||
|
||||
|
||||
def test_a_node_that_answers_no_is_still_a_device_failure(run) -> None: # noqa: ANN001
|
||||
"""The other side of the line: the radio was reached, so a retry is a real option.
|
||||
|
||||
A refused admin login is not a bad argument — the password may simply have changed —
|
||||
and reclassifying the arguments above must not drag this one along with them.
|
||||
"""
|
||||
result = run("repeater-admin", "Yagi-Repeater", "get", "name", "--password", "wrongpw")
|
||||
assert result.exit_code == exitcodes.DEVICE, result.output
|
||||
|
||||
|
||||
def test_an_empty_result_writes_nothing_to_stdout_at_all(run) -> None: # noqa: ANN001
|
||||
"""Not a "no channels configured" line: the status carries it, so the pipe stays clean."""
|
||||
result = run("channels", "list")
|
||||
assert result.exit_code == exitcodes.NO_RESULT
|
||||
assert result.stdout == ""
|
||||
|
||||
|
||||
def test_mock_and_an_explicit_radio_are_a_bad_command_line(run) -> None: # noqa: ANN001
|
||||
"""One flag says "pretend", the other names one exact radio, and both cannot be true.
|
||||
|
||||
``--mock`` used to win silently, so a scheduled ``--port COM7 --mock info`` reported on
|
||||
a simulator while reading as though it had reached the radio. Two contradictory claims
|
||||
about which device to talk to is a bad command line, not a precedence question.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from meshterm.cli import app
|
||||
|
||||
for flag, value in (("--port", "COM7"), ("--ble", "AA:BB"), ("--tcp", "host:1")):
|
||||
result = CliRunner().invoke(app, ["--mock", flag, value, "info"])
|
||||
assert result.exit_code == exitcodes.USAGE, flag
|
||||
assert result.stdout == ""
|
||||
assert flag in result.stderr
|
||||
|
||||
|
||||
def test_an_acknowledgement_reaches_the_person_without_reaching_the_pipe(run) -> None: # noqa: ANN001
|
||||
"""``ack`` used to be dropped on the CLI, and the reason only covered half the case.
|
||||
|
||||
An acknowledgement is not the answer, and a caller redirecting stdout must catch only
|
||||
the answer — but stdout is not the only stream. stderr honours that rule exactly while
|
||||
giving a person watching a five-minute ``config restore`` the setting-by-setting ✓ they
|
||||
had no way to see.
|
||||
"""
|
||||
result = run("config", "set", "tx_power", "20")
|
||||
assert result.exit_code == exitcodes.OK
|
||||
assert result.stdout == ""
|
||||
assert "tx_power" in result.stderr
|
||||
|
||||
|
||||
def test_a_closing_message_goes_to_stderr_too(run) -> None: # noqa: ANN001
|
||||
"""A closing count is for a person, not a record for a parser.
|
||||
|
||||
It restates what the listing above already showed and what ``$?`` already says, so it
|
||||
has no business in a redirect — and it is exactly what someone wants after a command
|
||||
that scrolled past them.
|
||||
"""
|
||||
result = run("records")
|
||||
assert result.exit_code == exitcodes.NO_RESULT
|
||||
assert result.stdout == ""
|
||||
assert "records stored" in result.stderr
|
||||
|
||||
|
||||
# -- 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
|
||||
|
||||
|
||||
+25
-6
@@ -13,6 +13,7 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from meshterm.core import exitcodes
|
||||
from meshterm.core.courier_store import DONE_CAP, QUEUED, CourierStore
|
||||
from meshterm.core.models import ChatMessage, Contact, utcnow
|
||||
from meshterm.core.watch_store import WatchStore
|
||||
@@ -229,6 +230,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)
|
||||
|
||||
@@ -269,7 +274,11 @@ async def test_cli_list_renders_waiting_and_finished(tmp_path: Path) -> None:
|
||||
|
||||
result = await CourierTool().run(ctx, {"cli_action": "list"})
|
||||
assert result.summary == {"entries": 2}
|
||||
assert ctx.ui.shown # a table was rendered
|
||||
# The outbox is *stated*, not printed: the tool hands back the rows and the CLI
|
||||
# boundary picks a renderer for them (see meshterm.ui.report).
|
||||
listing = result.report[0]
|
||||
assert sorted(row["state"] for row in listing.rows) == ["delivered", "waiting"]
|
||||
assert sorted(row["text"] for row in listing.rows) == ["landed", "still waiting"]
|
||||
|
||||
|
||||
async def test_cli_list_empty_notes_and_counts_zero(tmp_path: Path) -> None:
|
||||
@@ -279,7 +288,10 @@ async def test_cli_list_empty_notes_and_counts_zero(tmp_path: Path) -> None:
|
||||
ctx = _ToolCtx(tmp_path)
|
||||
result = await CourierTool().run(ctx, {"cli_action": "list"})
|
||||
assert result.summary == {"entries": 0}
|
||||
assert ctx.ui.shown == []
|
||||
assert result.exit_code == exitcodes.NO_RESULT
|
||||
# An empty listing, not an absent one: the plain face draws nothing from it and the
|
||||
# machine face gets `[]`, which is a document a consumer can read.
|
||||
assert result.report[0].rows == []
|
||||
|
||||
|
||||
async def test_cli_cancel_removes_a_waiting_entry(tmp_path: Path) -> None:
|
||||
@@ -308,13 +320,20 @@ async def test_cli_send_forces_one_attempt(tmp_path: Path) -> None:
|
||||
assert result.summary == {"id": entry.ident, "outcome": "delivered"}
|
||||
|
||||
|
||||
async def test_cli_send_unknown_entry_errors(tmp_path: Path) -> None:
|
||||
"""Sending a non-existent entry is a clean device-command error, not a crash."""
|
||||
from meshterm.core.connection import DeviceCommandError
|
||||
async def test_cli_send_unknown_entry_is_a_bad_argument(tmp_path: Path) -> None:
|
||||
"""An id nobody has is a *usage* error, and it used to claim the device had failed.
|
||||
|
||||
The outbox is a local file: nothing was transmitted, and no retry will make an id
|
||||
exist. Exit 4 tells a caller "the radio was reached and the operation failed, try
|
||||
again" — which sent a scheduled sender into a retry loop over a typo. Exit 2 says fix
|
||||
the command, which is the truth.
|
||||
"""
|
||||
import typer
|
||||
|
||||
from meshterm.tools.courier import CourierTool
|
||||
|
||||
ctx = _ToolCtx(tmp_path)
|
||||
with pytest.raises(DeviceCommandError):
|
||||
with pytest.raises(typer.BadParameter):
|
||||
await CourierTool().run(ctx, {"cli_action": "send", "id": 999})
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1325,8 +1327,13 @@ def test_uptime_renders_compact_units() -> None:
|
||||
|
||||
|
||||
def test_require_yes_blocks_without_confirmation() -> None:
|
||||
"""Destructive CLI commands abort unless --yes is passed."""
|
||||
with pytest.raises(typer.Exit):
|
||||
"""Destructive CLI commands abort unless --yes is passed.
|
||||
|
||||
As a *usage* error: a missing confirmation is a bad invocation, so it exits under the
|
||||
parser's own status and prints in the parser's own words on stderr, rather than a red
|
||||
line of its own on stdout (see :mod:`meshterm.core.exitcodes`).
|
||||
"""
|
||||
with pytest.raises(typer.BadParameter, match="--yes"):
|
||||
_require_yes(False, "factory reset erases all data")
|
||||
_require_yes(True, "factory reset erases all data") # does not raise
|
||||
|
||||
|
||||
+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 == []
|
||||
@@ -181,8 +181,14 @@ async def test_the_trace_target_picker_stays_pushed_under_the_live_screen(monkey
|
||||
monkeypatch.setattr("meshterm.ui.trace_screen.open_trace", fake_open_trace)
|
||||
|
||||
async def main() -> None:
|
||||
# The picker is pushed by `_run_live` itself, so the keystroke cannot be
|
||||
# queued before the call: sending it blind raced the screen that reads it,
|
||||
# and lost about one full-suite run in six. `_screen_at` exists for exactly
|
||||
# this — wait until the picker is on the stack, *then* press Enter on it.
|
||||
running = asyncio.create_task(TraceTool()._run_live(ctx))
|
||||
await _screen_at(session, 1)
|
||||
inp.send_text(ENTER) # Enter on the leading row commits it as the target
|
||||
result = await TraceTool()._run_live(ctx)
|
||||
result = await running
|
||||
assert result.summary == {"sessions": 1, "traces": 2}
|
||||
|
||||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||||
|
||||
@@ -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,437 @@
|
||||
"""Tests for the seam between a tool's answer and its rendering.
|
||||
|
||||
:mod:`meshterm.ui.report` is where a tool states what it found, :mod:`meshterm.ui.fields`
|
||||
is where the shapes it states it in are defined once, and :mod:`meshterm.ui.renderers` is
|
||||
where that becomes bytes. The claim the whole design rests on is that **one typed row
|
||||
projects two ways** — so almost every test here builds one report and asserts both faces
|
||||
of it, because a rule that holds in only one of them is exactly the drift this replaced.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from meshterm.ui import fields, script
|
||||
from meshterm.ui.renderers import JsonRenderer, OutputFormat, PlainRenderer, for_format
|
||||
from meshterm.ui.report import BARE, SILENT, Column, Facts, Lane, Listing
|
||||
|
||||
_WHEN = datetime(2026, 9, 7, 22, 22, 41, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _plain(*blocks) -> str: # noqa: ANN002 - blocks vary per test
|
||||
"""Render a report through the plain face and return what came out."""
|
||||
buffer = io.StringIO()
|
||||
PlainRenderer(script.console(buffer)).render(blocks)
|
||||
buffer.flush()
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _json(*blocks): # noqa: ANN002, ANN202 - blocks vary per test
|
||||
"""Render a report through the machine face and return the parsed document."""
|
||||
buffer = io.StringIO()
|
||||
JsonRenderer(script.console(buffer)).render(blocks)
|
||||
lines = [line for line in buffer.getvalue().splitlines() if line]
|
||||
assert len(lines) == 1, lines
|
||||
return json.loads(lines[0])
|
||||
|
||||
|
||||
def _contacts(rows) -> Listing: # noqa: ANN001 - rows vary per test
|
||||
"""A listing in the shape ``contacts`` uses: a node, an age and a count."""
|
||||
return Listing(
|
||||
key="contacts",
|
||||
columns=(
|
||||
fields.node(
|
||||
"node",
|
||||
lanes=(("name", "NAME"), ("type", "TYPE"), ("hash", "HASH"), ("key", "KEY")),
|
||||
),
|
||||
fields.when("heard_at", "HEARD", absent="never"),
|
||||
fields.integer("packets", "PKTS"),
|
||||
),
|
||||
rows=rows,
|
||||
order=("NAME", "TYPE", "HEARD", "PKTS", "HASH", "KEY"),
|
||||
)
|
||||
|
||||
|
||||
_ALICE = {
|
||||
"node": fields.NodeRef(name="Alice", key="d4" * 32, hash="d4", type="node"),
|
||||
"heard_at": _WHEN,
|
||||
"packets": 12,
|
||||
}
|
||||
|
||||
|
||||
# -- one row, two faces ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_one_typed_row_reaches_both_faces_without_either_parsing_the_other() -> None:
|
||||
"""The claim the seam exists for: a row holds the value, not somebody's rendering.
|
||||
|
||||
The plain face writes ``+5.1`` because a column of readings only compares by eye once
|
||||
every one declares its sign; the document writes ``5.1`` because a number carries its
|
||||
own. Neither could be derived from the other without a parser, which is what the two
|
||||
hand-written ``if ctx.json_output:`` branches were.
|
||||
"""
|
||||
block = Listing(key="hops", columns=(fields.snr("snr_db", "SNR_DB"),), rows=[{"snr_db": 5.14}])
|
||||
assert _plain(block).splitlines()[1].strip() == "+5.1"
|
||||
assert _json(block) == [{"snr_db": 5.1}]
|
||||
|
||||
|
||||
def test_a_node_is_one_object_however_many_columns_it_is_worth() -> None:
|
||||
"""One machine key, four plain lanes — the thing :func:`fields.node` earns.
|
||||
|
||||
Every listing that mentions a node asks for the same column, so ``jq '.[].node.key'``
|
||||
reads the same on contacts, courier and monitor and a consumer's node handler is
|
||||
written once. What differs is only how much room the surface has for it.
|
||||
"""
|
||||
header, record = _plain(_contacts([_ALICE])).splitlines()
|
||||
assert header.split() == ["NAME", "TYPE", "HEARD", "PKTS", "HASH", "KEY"]
|
||||
assert len(record.split()) == len(header.split())
|
||||
assert "Alice" in record and "d4" * 32 in record
|
||||
assert _json(_contacts([_ALICE]))[0]["node"] == {
|
||||
"name": "Alice",
|
||||
"key": "d4" * 32,
|
||||
"hash": "d4",
|
||||
"type": "node",
|
||||
"self": False,
|
||||
}
|
||||
|
||||
|
||||
def test_a_listing_may_draw_its_lanes_in_an_order_the_document_does_not_use() -> None:
|
||||
"""``contacts`` puts HEARD and PKTS between a node's TYPE and its HASH.
|
||||
|
||||
The lane order is a reading decision — the fields a person scans first go left — and
|
||||
the key order is the document's. Tying them together would have meant choosing which
|
||||
face to make worse.
|
||||
"""
|
||||
header = _plain(_contacts([_ALICE])).splitlines()[0].split()
|
||||
assert header.index("HEARD") < header.index("HASH")
|
||||
assert list(_json(_contacts([_ALICE]))[0]) == ["node", "heard_at", "packets"]
|
||||
|
||||
|
||||
def test_a_listing_refuses_an_order_that_does_not_name_its_own_lanes() -> None:
|
||||
"""A typo here draws a listing with a column missing and no complaint.
|
||||
|
||||
That is the one failure a header line cannot show — the records still line up, under
|
||||
the wrong headings — so it is caught where it is written instead.
|
||||
"""
|
||||
with pytest.raises(ValueError, match="every lane"):
|
||||
Listing(
|
||||
key="x",
|
||||
columns=(fields.integer("a", "A"), fields.integer("b", "B")),
|
||||
order=("A", "NOPE"),
|
||||
)
|
||||
|
||||
|
||||
# -- absences --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_two_faces_spell_absence_in_their_own_alphabets() -> None:
|
||||
"""``-`` is learned once by a reader; ``null`` is what a parser already has.
|
||||
|
||||
Same fact, and neither face has to know the other's token — which is what stopped the
|
||||
plain ``-`` from ever reaching a document as a string.
|
||||
"""
|
||||
empty = {"node": None, "heard_at": None, "packets": None}
|
||||
record = _plain(_contacts([empty])).splitlines()[1]
|
||||
assert record.split() == ["-", "unknown", "never", "-", "-", "-"]
|
||||
assert _json(_contacts([empty]))[0] == {"node": None, "heard_at": None, "packets": None}
|
||||
|
||||
|
||||
def test_a_word_standing_in_for_an_absence_stays_a_word_only_on_the_plain_face() -> None:
|
||||
"""``unknown`` is a word a reader acts on; a consumer already has ``null`` for it.
|
||||
|
||||
Carrying both would give a program two spellings for one fact and a special case to
|
||||
remember on every field.
|
||||
"""
|
||||
row = {"node": fields.NodeRef(hash="a1")}
|
||||
block = Listing(key="n", columns=(fields.node("node", lanes=(("type", "TYPE"),)),), rows=[row])
|
||||
assert _plain(block).splitlines()[1].strip() == "unknown"
|
||||
assert _json(block)[0]["node"]["type"] is None
|
||||
|
||||
|
||||
def test_a_never_heard_node_is_a_fact_and_an_undated_row_is_an_absence() -> None:
|
||||
"""Two different things, and the age lane says which without a second column.
|
||||
|
||||
A node that has never been heard *has* a heard-age; a row with no such time at all
|
||||
does not. Flattening both onto ``-`` would have made "we have not heard from it" and
|
||||
"this kind of row has no heard time" the same reading.
|
||||
"""
|
||||
assert script.age(None, absent="never") == "never"
|
||||
assert script.age(None) == script.NONE
|
||||
|
||||
|
||||
def test_info_leaves_out_a_reading_the_radio_does_not_answer_for_and_json_keeps_the_key() -> None:
|
||||
"""The one deliberate disagreement between the faces, and why it is deliberate.
|
||||
|
||||
A missing plain key says "this radio does not report it", where a dash would claim it
|
||||
reported nothing — a distinction a person can act on. A consumer cannot, and a
|
||||
variable key set costs it a membership test on every field, so the document keeps the
|
||||
key and writes ``null``.
|
||||
"""
|
||||
block = Facts(
|
||||
key="info",
|
||||
fields=(fields.word("model", "model"), fields.integer("uptime_s", "uptime_s")),
|
||||
values={"model": None, "uptime_s": 42},
|
||||
omit_absent=True,
|
||||
)
|
||||
assert _plain(block).split() == ["uptime_s", "42"]
|
||||
assert _json(block) == {"model": None, "uptime_s": 42}
|
||||
|
||||
|
||||
# -- facts, bare and silent ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_get_prints_its_value_alone_and_still_documents_the_key() -> None:
|
||||
"""The caller named the key, so repeating it back is one more thing to strip off.
|
||||
|
||||
``$(meshterm config get name)`` needs no stripping — an argument entirely about shell
|
||||
substitution, which a JSON consumer is not doing, so the document keeps the key, the
|
||||
type and the label the bare form threw away.
|
||||
"""
|
||||
block = Facts(
|
||||
key="setting",
|
||||
fields=(
|
||||
fields.word("key", "key"),
|
||||
fields.word("value", "value"),
|
||||
fields.hidden("type"),
|
||||
),
|
||||
values={"key": "tx_power", "value": "20", "type": "int"},
|
||||
shape=BARE,
|
||||
bare="value",
|
||||
)
|
||||
assert _plain(block).strip() == "20"
|
||||
assert _json(block) == {"key": "tx_power", "value": "20", "type": "int"}
|
||||
|
||||
|
||||
def test_a_bare_answer_with_nothing_in_it_prints_nothing_at_all() -> None:
|
||||
"""A node that never answered, a slot with nothing in it — and no lone dash.
|
||||
|
||||
The bare form prints *the value*, so where there is none the exit status is the whole
|
||||
report and stdout stays empty. The document still carries the shape, with ``null``
|
||||
where the answer would have been, because a consumer branching on it needs the key.
|
||||
"""
|
||||
block = Facts(
|
||||
key="remote",
|
||||
fields=(fields.word("command", "command"), fields.word("reply", "reply")),
|
||||
values={"command": "get name", "reply": None},
|
||||
shape=BARE,
|
||||
bare="reply",
|
||||
)
|
||||
assert _plain(block) == ""
|
||||
assert _json(block) == {"command": "get name", "reply": None}
|
||||
|
||||
|
||||
def test_an_acknowledgement_prints_nothing_and_is_still_a_document() -> None:
|
||||
"""Printing nothing is an answer a person can act on and a program cannot.
|
||||
|
||||
``config advert`` says everything it has to say in its exit status, and that is the
|
||||
right plain answer. A caller that needs to log *what was sent* has nowhere to read it,
|
||||
which is what the document is for.
|
||||
"""
|
||||
block = Facts(
|
||||
key="advert",
|
||||
fields=(fields.flag("sent", "sent"), fields.flag("flood", "flood")),
|
||||
values={"sent": True, "flood": False},
|
||||
shape=SILENT,
|
||||
)
|
||||
assert _plain(block) == ""
|
||||
assert _json(block) == {"sent": True, "flood": False}
|
||||
|
||||
|
||||
# -- several blocks --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_single_block_report_is_its_block_and_nothing_around_it() -> None:
|
||||
"""``meshterm contacts --json | jq '.[].node.name'`` reads what it looks like.
|
||||
|
||||
There is no envelope: the report a caller needs is ``$?``, exactly as it is without
|
||||
the flag, so nothing has to be reached through to get at the answer.
|
||||
"""
|
||||
assert isinstance(_json(_contacts([_ALICE])), list)
|
||||
|
||||
|
||||
def test_several_blocks_make_one_object_with_the_facts_at_its_top_level() -> None:
|
||||
"""A trace is a set of facts *and* a per-hop table, and the document says so.
|
||||
|
||||
The facts block is the set of facts about the one thing the command answered, so it is
|
||||
the document rather than a compartment inside it; a listing needs a name and takes its
|
||||
own key.
|
||||
"""
|
||||
facts = Facts(
|
||||
key="trace", fields=(fields.flag("success", "success"),), values={"success": True}
|
||||
)
|
||||
edges = Listing(key="edges", columns=(fields.integer("index", "HOP"),), rows=[{"index": 0}])
|
||||
assert _json(facts, edges) == {"success": True, "edges": [{"index": 0}]}
|
||||
assert _plain(facts, edges).splitlines() == ["success yes", "", "HOP", " 0"]
|
||||
|
||||
|
||||
def test_a_block_drawn_only_for_a_person_never_reaches_the_document() -> None:
|
||||
"""``monitor``'s closing per-node summary is derivable from the records above it.
|
||||
|
||||
Emitting it as a second *shape* of line mid-stream would cost every reader a
|
||||
discriminator it otherwise never needs, so the plain face keeps the aggregate and the
|
||||
stream stays homogeneous.
|
||||
"""
|
||||
facts = Facts(
|
||||
key="window", fields=(fields.integer("packets", "packets"),), values={"packets": 2}
|
||||
)
|
||||
summary = Listing(
|
||||
key="heard",
|
||||
columns=(fields.integer("packets", "PKTS"),),
|
||||
rows=[{"packets": 2}],
|
||||
plain_only=True,
|
||||
)
|
||||
assert "PKTS" in _plain(facts, summary)
|
||||
assert _json(facts, summary) == {"packets": 2}
|
||||
|
||||
|
||||
def test_a_block_that_prints_nothing_leaves_no_blank_line_where_it_stood() -> None:
|
||||
"""The separator is counted off what was drawn, not off the block index.
|
||||
|
||||
``config restore --dry-run`` states its outcome silently and its plan as a listing, and
|
||||
counting positions instead opened the command with an empty line — output it did not
|
||||
have, in whatever was reading it.
|
||||
"""
|
||||
silent = Facts(
|
||||
key="restore", fields=(fields.flag("dry_run", "dry_run"),), values={}, shape=SILENT
|
||||
)
|
||||
plan = Listing(
|
||||
key="operations",
|
||||
columns=(fields.word("operation", "OPERATION"),),
|
||||
rows=[{"operation": "set"}],
|
||||
)
|
||||
assert _plain(silent, plan).splitlines()[0] == "OPERATION"
|
||||
|
||||
|
||||
# -- the document's own rules ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_document_is_one_compact_line_that_never_escapes_a_name() -> None:
|
||||
r"""A node name in Cyrillic is a node name, not a run of ``\\uXXXX``.
|
||||
|
||||
One line, so a stream and a one-shot answer are read the same way and ``jq .`` can
|
||||
re-indent for a person at no cost. Nothing can un-stream a document that was
|
||||
pretty-printed across four hundred lines.
|
||||
"""
|
||||
buffer = io.StringIO()
|
||||
row = {"node": fields.NodeRef(name="Наблюдатель", hash="c3")}
|
||||
block = Listing(key="n", columns=(fields.node("node"),), rows=[row])
|
||||
JsonRenderer(script.console(buffer)).render((block,))
|
||||
out = buffer.getvalue()
|
||||
assert out.endswith("\n") and out.count("\n") == 1
|
||||
assert "Наблюдатель" in out
|
||||
assert ", " not in out and ": " not in out
|
||||
|
||||
|
||||
def test_every_time_in_a_document_is_utc_to_the_second() -> None:
|
||||
"""Twenty characters, always, so string comparison is time comparison.
|
||||
|
||||
A local offset is a fact about the machine that ran the command rather than about the
|
||||
event, and two hosts asking the same radio the same question must produce the same
|
||||
document.
|
||||
"""
|
||||
stamped = _json(_contacts([_ALICE]))[0]["heard_at"]
|
||||
assert stamped == "2026-09-07T22:22:41Z"
|
||||
assert len(stamped) == 20
|
||||
|
||||
|
||||
def test_a_time_whose_offset_we_do_not_know_is_absent_on_both_faces() -> None:
|
||||
"""A naive datetime is not a fact, and neither face may invent an offset for it."""
|
||||
naive = {"node": None, "heard_at": datetime(2026, 9, 7, 22, 22, 41), "packets": None}
|
||||
assert "2026" not in _plain(_contacts([naive]))
|
||||
assert _json(_contacts([naive]))[0]["heard_at"] is None
|
||||
|
||||
|
||||
def test_a_concept_may_have_a_face_on_only_one_side() -> None:
|
||||
"""Both directions, and each has a real call site.
|
||||
|
||||
A preference's DESCRIPTION is prose for whoever is choosing a value and carries
|
||||
nothing a program would branch on; a setting's declared type is the reverse — a fact
|
||||
the document wants and a reader dumping ``config show`` does not.
|
||||
"""
|
||||
block = Listing(
|
||||
key="prefs",
|
||||
columns=(
|
||||
fields.word("key", "PREFERENCE"),
|
||||
fields.note("help", "DESCRIPTION"),
|
||||
fields.hidden("type"),
|
||||
),
|
||||
rows=[{"key": "trace_cooldown_s", "help": "Wait this long", "type": "int"}],
|
||||
)
|
||||
assert "Wait this long" in _plain(block)
|
||||
assert _json(block) == [{"key": "trace_cooldown_s", "type": "int"}]
|
||||
|
||||
|
||||
# -- streams ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stream_listing() -> Listing:
|
||||
"""A live listing in the shape ``monitor`` uses: pinned lanes, the name last."""
|
||||
return Listing(
|
||||
key="observations",
|
||||
columns=(
|
||||
fields.instant("observed_at", "TIME"),
|
||||
fields.node("node", lanes=(("hash", "NODE"), ("name", "NAME"))),
|
||||
),
|
||||
order=("TIME", "NODE", "NAME"),
|
||||
)
|
||||
|
||||
|
||||
def test_a_stream_pins_its_lanes_because_the_widths_arrive_with_the_data() -> None:
|
||||
"""A live capture has no "once every record is in", which is when columns are sized.
|
||||
|
||||
Both streams used to be gutter-joined with no alignment at all, which is why a name in
|
||||
one still carried its quotes — nothing else said where the field ended.
|
||||
"""
|
||||
buffer = io.StringIO()
|
||||
listing = _stream_listing()
|
||||
with PlainRenderer(script.console(buffer)).stream(listing) as row:
|
||||
row({"observed_at": _WHEN, "node": fields.NodeRef(name="Alice", hash="d4e5f6a7")})
|
||||
row({"observed_at": _WHEN, "node": fields.NodeRef(name="A Very Long Node Name", hash="c3")})
|
||||
header, first, second = buffer.getvalue().splitlines()
|
||||
assert header.split() == ["TIME", "NODE", "NAME"]
|
||||
assert first.index("d4e5f6a7") == second.index("c3")
|
||||
|
||||
|
||||
def test_a_streamed_document_is_one_record_per_line_all_the_same_shape() -> None:
|
||||
"""A consumer reads line by line and never has to ask which kind of record it holds."""
|
||||
buffer = io.StringIO()
|
||||
listing = _stream_listing()
|
||||
with JsonRenderer(script.console(buffer)).stream(listing) as row:
|
||||
row({"observed_at": _WHEN, "node": fields.NodeRef(hash="d4")})
|
||||
row({"observed_at": _WHEN, "node": fields.NodeRef(hash="a1")})
|
||||
documents = [json.loads(line) for line in buffer.getvalue().splitlines()]
|
||||
assert [set(d) for d in documents] == [{"observed_at", "node"}] * 2
|
||||
assert [d["node"]["hash"] for d in documents] == ["d4", "a1"]
|
||||
|
||||
|
||||
# -- the boundary ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_format_picks_the_renderer_and_nothing_else_does() -> None:
|
||||
"""One choice, made once, at the one place that knows what the caller asked for."""
|
||||
console = script.console(io.StringIO())
|
||||
assert isinstance(for_format(OutputFormat.PLAIN, console), PlainRenderer)
|
||||
assert isinstance(for_format(OutputFormat.JSON, console), JsonRenderer)
|
||||
|
||||
|
||||
def test_a_tool_with_no_scripted_face_renders_nothing_at_all() -> None:
|
||||
"""The map and the dashboard state no report, and both renderers must be fine with it."""
|
||||
assert _plain() == ""
|
||||
buffer = io.StringIO()
|
||||
JsonRenderer(script.console(buffer)).render(None)
|
||||
assert buffer.getvalue() == ""
|
||||
|
||||
|
||||
def test_a_lane_and_a_column_are_separable_so_a_third_format_needs_neither() -> None:
|
||||
"""The point of the split, stated as a test: a format is a function of the typed row.
|
||||
|
||||
A renderer that wanted CSV would read the same columns and the same rows and write its
|
||||
own cells. Nothing about the report knows a gutter, a comma or a brace.
|
||||
"""
|
||||
column = Column(key="n", lanes=(Lane(header="N", render=lambda v: f"<{v}>"),))
|
||||
assert column.lanes[0].render(3) == "<3>"
|
||||
assert column.json(3) == 3
|
||||
@@ -0,0 +1,568 @@
|
||||
"""Tests for the plain CLI's output language (:mod:`meshterm.ui.script`).
|
||||
|
||||
The plain face is read by a person at a prompt — ``--json`` carries the machine now — and
|
||||
every rule that makes it legible is testable: no colour, no wrapping, no framing, bare
|
||||
names that still cannot break a record, relative ages, arrows for a route and commas for a
|
||||
path, 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, and :mod:`tests.test_report` is for the seam above them.
|
||||
"""
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# -- names ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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"),
|
||||
],
|
||||
)
|
||||
def test_a_name_is_bare_because_alignment_is_the_delimiter(value: str, expected: str) -> None:
|
||||
"""The quotes are retired; a column that lines up is already one field to the eye.
|
||||
|
||||
They existed so a name holding a space or a comma stayed one field for whatever was
|
||||
splitting the line. The splitter reads JSON now, and in plain text the padding says
|
||||
where a field ends — so a reader gets back the six cells the quotes were spending on
|
||||
every row, and a name reads as the name the node broadcast.
|
||||
"""
|
||||
assert script.name(value) == expected
|
||||
|
||||
|
||||
def test_a_name_still_cannot_end_the_record_it_sits_in() -> None:
|
||||
"""The escaping under the quoting stays, and it is the half that was load-bearing.
|
||||
|
||||
A node broadcasts its own name, so every name here is remote data. Nothing about
|
||||
alignment stops a newline, a tab or an ESC in one from ending the record early — and
|
||||
an ESC inside a name is a live colour run written into the caller's file, which is the
|
||||
one thing "not a single escape sequence" exists to prevent.
|
||||
"""
|
||||
assert script.name("two\nlines") == "two\\nlines"
|
||||
assert script.name("tabbed\there") == "tabbed\\there"
|
||||
assert script.name("bell\a") == "bell\\x07"
|
||||
assert "\x1b" not in script.name("esc\x1b[31m")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body,expected",
|
||||
[
|
||||
("line one\nline two", "line one\\nline two"),
|
||||
("tabbed\there", "tabbed\\there"),
|
||||
("carriage\rreturn", "carriage\\rreturn"),
|
||||
("back\\slash", "back\\\\slash"),
|
||||
('a "quoted" body', 'a "quoted" body'),
|
||||
("plain", "plain"),
|
||||
],
|
||||
)
|
||||
def test_a_message_body_can_never_end_its_own_record(body: str, expected: str) -> None:
|
||||
"""``TEXT`` is the rest of the line, and "the rest of the line" has to stay one line.
|
||||
|
||||
A body holding a newline used to end its record early and leave the remainder indented
|
||||
under the other columns, reading as a second record with an empty ``TIME``. The body is
|
||||
also the one field a stranger fills in, so this is not a hypothetical message. Quotes
|
||||
inside it are left alone: nothing wraps it, so nothing needs escaping from.
|
||||
"""
|
||||
assert script.text(body) == expected
|
||||
assert "\n" not in script.text(body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("absent", [None, ""])
|
||||
def test_a_node_that_never_gave_a_name_reads_as_absent_not_as_empty(absent: str | None) -> None:
|
||||
"""``""`` says "called nothing"; ``-`` says "never said". They are different facts.
|
||||
|
||||
Seven of the ten places that printed a name made the first claim by accident, so the
|
||||
same unnamed node read as ``""`` in one listing and ``-`` in the next. One helper now
|
||||
answers it, and the distinction is the one the machine face needs too — an absent name
|
||||
is ``null``, an empty one is ``""``, and a field that flattened both cannot say which
|
||||
it meant.
|
||||
"""
|
||||
assert script.name(absent) == script.NONE
|
||||
assert script.name("Alice") == "Alice"
|
||||
|
||||
|
||||
# -- 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_a_time_is_an_age_unless_the_instant_is_itself_the_fact() -> None:
|
||||
"""The rule that reversed, and the reason it reversed.
|
||||
|
||||
A person at a prompt reading ``2026-09-07T19:58:53-04:00`` is doing arithmetic to
|
||||
answer "recently?", which is the question they typed the command to ask. So an age is
|
||||
the default now. :func:`~meshterm.ui.script.stamp` survives for the three places the
|
||||
instant *is* the answer — the device clock, an appointment set with ``--at``, and a
|
||||
live capture's own ``TIME`` column, every row of which would otherwise read ``now`` —
|
||||
and for every column under ``--absolute``.
|
||||
"""
|
||||
fresh = datetime.now(timezone.utc) - timedelta(seconds=5)
|
||||
assert script.age(fresh) == "now"
|
||||
assert script.stamp(fresh)[:4].isdigit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"seconds,expected",
|
||||
[
|
||||
(0, "now"),
|
||||
(59, "now"),
|
||||
(60, "1m"),
|
||||
(3599, "59m"),
|
||||
(3600, "1h"),
|
||||
(86400, "1d"),
|
||||
(604800, "1w"),
|
||||
],
|
||||
)
|
||||
def test_the_age_ladder_steps_where_a_person_would_step(seconds: int, expected: str) -> None:
|
||||
"""``now``, ``5m``, ``3h``, ``2d``, ``4w`` — the menu's own column ladder.
|
||||
|
||||
Delegated to :func:`~meshterm.ui.widgets._format_age` rather than re-derived, so the
|
||||
two faces of the same age cannot drift apart.
|
||||
"""
|
||||
assert script.age(datetime.now(timezone.utc) - timedelta(seconds=seconds)) == expected
|
||||
|
||||
|
||||
def test_an_age_reads_an_unknown_offset_as_absent_exactly_as_a_stamp_does() -> None:
|
||||
"""A naive datetime is a time whose offset we don't know, which is not a fact."""
|
||||
assert script.age(None) == script.NONE
|
||||
assert script.age(datetime(2026, 9, 7, 22, 22, 41)) == script.NONE
|
||||
|
||||
|
||||
def test_never_is_a_value_and_the_absent_token_is_not() -> None:
|
||||
"""A node that has never been heard is a *fact*; a row with no such time is not.
|
||||
|
||||
Flattening both onto ``-`` made "we have not heard from it" and "this kind of row has
|
||||
no heard time" read the same, which is the distinction the column exists to draw.
|
||||
"""
|
||||
assert script.age(None, absent="never") == "never"
|
||||
assert script.age(None) == script.NONE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"seconds,expected",
|
||||
[(42, "42s"), (360, "6m"), (125, "2m 5s"), (93784, "1d 2h"), (3600, "1h"), (-125, "2m 5s")],
|
||||
)
|
||||
def test_a_duration_reads_in_two_units_at_most(seconds: int, expected: str) -> None:
|
||||
"""``93784`` is a number nobody can hold in their head; ``1d 2h`` is the same fact.
|
||||
|
||||
Two adjacent units, largest first, so the magnitude arrives at a glance and the
|
||||
precision never outruns what anyone would act on. Only ever a gloss beside the raw
|
||||
figure — the key says what the number counts, and a caller reading the key must still
|
||||
find a number under it.
|
||||
"""
|
||||
assert script.duration(seconds) == expected
|
||||
|
||||
|
||||
def test_a_location_is_one_field_because_it_is_one_fact() -> None:
|
||||
"""A coordinate pair is what goes into a map's search box, verbatim.
|
||||
|
||||
Two columns would also mean two ``-`` for every node that has never shared a position,
|
||||
which is worse than one.
|
||||
"""
|
||||
assert script.location(45.50190, -73.56740) == "45.50190,-73.56740"
|
||||
assert script.location(45.5, None) == script.NONE
|
||||
assert script.location(None, None) == script.NONE
|
||||
|
||||
|
||||
# -- the absent token -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_one_token_stands_for_every_kind_of_absence() -> None:
|
||||
"""A reader learns ``-`` once, and never meets an em dash, an ``n/a`` or a blank.
|
||||
|
||||
``never`` is the one word that is *not* an absence: it is a value, and the age lane is
|
||||
where it is spelled (see the ladder tests above).
|
||||
"""
|
||||
assert script.NONE == "-"
|
||||
assert script.number(None) == script.NONE
|
||||
assert script.route([]) == script.NONE
|
||||
assert script.spec([]) == 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"
|
||||
|
||||
|
||||
# -- routes and paths ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_route_is_drawn_with_arrows_and_a_path_keeps_its_commas() -> None:
|
||||
"""The lexicon's own split, finally drawn: a *route* is walked, a *path* is typed.
|
||||
|
||||
Both used to be comma-separated, which made a route look like something you could
|
||||
paste back into ``--path``. You cannot — the names are in it — so that comma was
|
||||
promising a round trip only the spec has. The arrow says "this is a picture", and
|
||||
carries the direction the packet travelled while it is there.
|
||||
"""
|
||||
assert (
|
||||
script.route([("Alice", "3d"), ("Yagi-Repeater", "f2")])
|
||||
== "Alice (3d) → Yagi-Repeater (f2)"
|
||||
)
|
||||
assert script.spec(["a1", "d4", "a1"]) == "a1,d4,a1"
|
||||
|
||||
|
||||
def test_a_route_names_our_own_node_like_any_other_hop() -> None:
|
||||
"""No star, and the reason is who reads the line.
|
||||
|
||||
The menu draws our own node as ``★`` because a reader never has to be told which node
|
||||
is theirs. A route line is as often read out of a file, by somebody who was not at the
|
||||
prompt when it ran — and by then the star names nothing.
|
||||
"""
|
||||
line = script.route([("MockCompanion", "00"), ("Alice", "3d"), ("MockCompanion", "00")])
|
||||
assert "★" not in line
|
||||
assert line.startswith("MockCompanion (00)")
|
||||
assert line.endswith("MockCompanion (00)")
|
||||
|
||||
|
||||
def test_a_route_hop_never_shows_an_empty_pair_of_parentheses() -> None:
|
||||
"""Half an identity prints as the half there is — a name alone, or a hash alone.
|
||||
|
||||
Which is how the menu's own :class:`~meshterm.ui.pathline.PathLine` labels an
|
||||
unresolved hop, and the reason both halves are optional: history often knows one.
|
||||
"""
|
||||
assert script.route([("Alice", None)]) == "Alice"
|
||||
assert script.route([(None, "3d")]) == "3d"
|
||||
assert script.route([(None, None)]) == script.NONE
|
||||
|
||||
|
||||
def test_the_arrow_is_what_makes_the_quoting_unnecessary() -> None:
|
||||
"""The claim moved: the quotes made a comma safe, and now nothing has to.
|
||||
|
||||
A comma inside a node's name can no longer be read as a hop boundary, because the hop
|
||||
boundary is not a comma any more.
|
||||
"""
|
||||
line = script.route([("Node, Inc", "3d"), ("Bob", "f2")])
|
||||
assert line == "Node, Inc (3d) → Bob (f2)"
|
||||
assert line.count("→") == 1
|
||||
|
||||
|
||||
def test_a_spec_is_the_one_line_on_either_face_that_round_trips() -> None:
|
||||
"""It is what ``--path`` takes back, so it grows no name, no arrow and no space."""
|
||||
assert script.spec(["a1b2", "d4e5", "a1b2"]) == "a1b2,d4e5,a1b2"
|
||||
assert " " not in script.spec(["a1", "d4"])
|
||||
|
||||
|
||||
# -- live streams ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_stream_pins_its_lanes_because_it_cannot_measure_them() -> None:
|
||||
"""A live capture cannot size its columns, because it has not seen the records yet.
|
||||
|
||||
:func:`~meshterm.ui.script.columns` sizes each lane to its widest value once every
|
||||
record is in. ``monitor`` prints a row the instant a packet lands, and the row after it
|
||||
may be twice as wide — so those two streams were gutter-joined with no alignment at
|
||||
all, which is the last thing the quoting was holding up.
|
||||
"""
|
||||
lanes = script.stream(("TIME", 19), ("NODE", 8), ("SNR_DB", 6), right=("SNR_DB",))
|
||||
assert lanes.header == "TIME NODE SNR_DB"
|
||||
assert lanes.record("2026-09-08T02:25:45", "a1b2c3d4", "+7.0") == (
|
||||
"2026-09-08T02:25:45 a1b2c3d4 +7.0"
|
||||
)
|
||||
|
||||
|
||||
def test_a_streamed_value_wider_than_its_lane_overruns_rather_than_lying() -> None:
|
||||
"""One row out of many is simply wider, and nothing is elided.
|
||||
|
||||
Which is why the one unbounded field — a node's name, a message body — goes last: it
|
||||
can then push nothing at all.
|
||||
"""
|
||||
lanes = script.stream(("NODE", 8), ("NAME", 6))
|
||||
assert lanes.record("a1", "A Very Long Node Name") == "a1 A Very Long Node Name"
|
||||
|
||||
|
||||
# -- 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))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
["[bold]Loud", "[/]Bob", "[red]a[/red]", ":fire:Hot", "[[weird]]", "100% [done]"],
|
||||
)
|
||||
def test_a_node_name_is_never_read_as_rich_markup(name: str) -> None:
|
||||
"""A node broadcasts its own name, so every name printed here is remote data.
|
||||
|
||||
Rich reads ``[...]`` as a style tag unless told not to. That made a name into two
|
||||
different bugs: ``[bold]Loud`` printed as ``Loud`` — silently corrupted, and no longer
|
||||
the string that identifies the node to whoever reads the output — and ``[/]Bob`` raised
|
||||
``MarkupError`` and took the whole command down. ``:fire:`` is the same hazard one
|
||||
parser over. Neither is hypothetical: a name is the one field a stranger controls.
|
||||
"""
|
||||
table = script.columns("NAME", "TYPE")
|
||||
table.add_row(script.name(name), "node")
|
||||
record = _rendered(table).splitlines()[1]
|
||||
assert record.startswith(script.name(name)), record
|
||||
|
||||
|
||||
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.name("a-long-name"), "node")
|
||||
table.add_row(script.name("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.name("Alice"), "node", "12")
|
||||
header, record = _rendered(table).splitlines()
|
||||
assert header.split() == ["NAME", "TYPE", "PKTS"]
|
||||
assert record.split() == ["Alice", "node", "12"]
|
||||
# Spelled out, not written as `" " * script.GUTTER`: an assertion that reads the
|
||||
# constant it is checking narrows to `" " in record` when the gutter narrows, which
|
||||
# is exactly the state the gutter exists to prevent.
|
||||
assert "Alice node" 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.name("Yagi-Repeater"), "repeater")
|
||||
table.add_row(script.name("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
|
||||
|
||||
|
||||
def test_a_numeric_column_really_right_aligns() -> None:
|
||||
"""A column of magnitudes is only comparable by eye if the digits line up.
|
||||
|
||||
Rich's ``Text.wrap`` returns early on ``overflow="ignore"`` — and that early return
|
||||
sits *above* the justify step, so asking for ``overflow="ignore"`` throws
|
||||
``justify="right"`` away without a word. Every ``right=`` in the CLI was a no-op
|
||||
until the columns asked for ``"crop"``, which refuses to elide just as firmly and
|
||||
still aligns. Checking where a field *starts* cannot see this; only its end can.
|
||||
"""
|
||||
table = script.columns("NAME", "PKTS", right=("PKTS",))
|
||||
table.add_row(script.name("Alice"), "1174")
|
||||
table.add_row(script.name("Bob"), "3")
|
||||
header, *records = _rendered(table).splitlines()
|
||||
assert len({len(line) for line in records}) == 1, records
|
||||
assert {len(line) for line in records} == {len(header)}
|
||||
|
||||
|
||||
def test_no_column_elides_however_long_its_value_runs() -> None:
|
||||
"""A key cut short is not something a caller can hand back to ``--to`` or ``--path``.
|
||||
|
||||
The guard against eliding and the guard against mis-alignment are the same setting,
|
||||
so a change to one silently moves the other; both are pinned here.
|
||||
"""
|
||||
table = script.columns("KEY", "PKTS", right=("PKTS",))
|
||||
table.add_row("d4" * 32, "7")
|
||||
out = _rendered(table)
|
||||
assert "…" not in out
|
||||
assert "d4" * 32 in out
|
||||
|
||||
|
||||
def test_the_trimmer_keeps_the_gutter_when_the_stream_flushes_between_columns() -> None:
|
||||
"""Rich's legacy-Windows renderer writes a row one segment at a time, flushing each.
|
||||
|
||||
``LegacyWindowsTerm.write_text`` is ``write(text); flush()``, so on that path a flush
|
||||
lands *between two columns* as often as at the end of a line. A wrapper that emptied
|
||||
its held run at flush destroyed every gutter in real use while the in-process tests —
|
||||
which see one write and one flush per ``print`` — went on passing. That is not a
|
||||
hypothetical: it shipped, and this drives the stream the way that renderer does.
|
||||
"""
|
||||
buffer = io.StringIO()
|
||||
stream = script._Trimmed(buffer)
|
||||
for segment in ("Alice", " ", "node", " ", "12", "\n"):
|
||||
stream.write(segment)
|
||||
stream.flush()
|
||||
assert buffer.getvalue() == "Alice node 12\n"
|
||||
|
||||
|
||||
def test_the_trimmer_still_drops_the_run_that_ends_a_line() -> None:
|
||||
"""The other half of the rule: holding padding back is only right if it is still cut.
|
||||
|
||||
Held whitespace is emitted when something follows it on the line, and dropped at the
|
||||
newline and at the end of the stream — otherwise every record would end in the
|
||||
invisible padding of its last column.
|
||||
"""
|
||||
buffer = io.StringIO()
|
||||
stream = script._Trimmed(buffer)
|
||||
for segment in ("row", " ", "\n", "next", " "):
|
||||
stream.write(segment)
|
||||
stream.flush()
|
||||
assert buffer.getvalue() == "row\nnext"
|
||||
|
||||
|
||||
def test_a_value_wider_than_the_console_is_cut_rather_than_elided() -> None:
|
||||
"""Past :data:`~meshterm.ui.script.WIDTH` cells something gives; it must not be a "…".
|
||||
|
||||
An elision is indistinguishable from a value that really ends there, so a key read out
|
||||
of a listing would go back into ``--to`` subtly wrong. A 64-hex key never reaches the
|
||||
overflow path at all, so only an over-width value tests the guard that stands there.
|
||||
"""
|
||||
table = script.columns("KEY")
|
||||
table.add_row("d" * (script.WIDTH + 500))
|
||||
assert "…" not in _rendered(table)
|
||||
|
||||
|
||||
def test_an_error_stays_greppable_when_stderr_is_not_a_terminal() -> None:
|
||||
"""A sentence broken across three lines is a sentence nothing can find.
|
||||
|
||||
Rich falls back to 80 cells when it cannot measure the destination, so
|
||||
``2> errors.log`` hard-wrapped every message — and ``grep 'not already connected'``
|
||||
then found nothing, because the words it was looking for had a newline in the middle
|
||||
of them. In a real terminal the width is left alone: a progress bar sized to 16384
|
||||
cells is not a progress bar.
|
||||
"""
|
||||
import sys
|
||||
|
||||
buffer = io.StringIO()
|
||||
stderr, sys.stderr = sys.stderr, buffer
|
||||
try:
|
||||
console = script.stderr_console()
|
||||
console.print("meshterm: " + "the connection was not already connected " * 6)
|
||||
finally:
|
||||
sys.stderr = stderr
|
||||
assert len(buffer.getvalue().splitlines()) == 1
|
||||
|
||||
|
||||
# -- 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.name("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_the_exit_statuses_are_the_numbers_a_caller_hard_codes() -> None:
|
||||
"""``case $? in 3)`` is written against the digits, so the digits are the contract.
|
||||
|
||||
Stated as literals on purpose. Asserting ``set(MEANINGS) == {OK, FAILURE, …}``, or
|
||||
``len(set(d)) == len(d)``, cannot fail however the constants are renumbered: a dict
|
||||
has no duplicate keys, so two statuses colliding *removes* a row and both sides of
|
||||
the comparison lose the same one together. Renumbering ``NO_DEVICE`` to ``4`` left
|
||||
that whole family of assertions green while status 3 vanished from ``--help``.
|
||||
"""
|
||||
assert (exitcodes.OK, exitcodes.FAILURE, exitcodes.USAGE) == (0, 1, 2)
|
||||
assert (exitcodes.NO_DEVICE, exitcodes.DEVICE, exitcodes.NO_RESULT) == (3, 4, 5)
|
||||
assert sorted(exitcodes.MEANINGS) == [0, 1, 2, 3, 4, 5]
|
||||
assert all(meaning for meaning in exitcodes.MEANINGS.values())
|
||||
|
||||
|
||||
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
|
||||
@@ -224,7 +224,7 @@ async def test_apply_setting_remembers_through_the_config_executor(ctx) -> None:
|
||||
pubkey = (await device.get_self_info())["public_key"]
|
||||
snapshot = await build_snapshot(device)
|
||||
|
||||
changes, _ = await apply_ops(
|
||||
changes, _artifacts, _report = await apply_ops(
|
||||
ctx, device, snapshot, [("set", "name", "Ops-Node"), ("set", "tx_power", "18")]
|
||||
)
|
||||
assert changes == 2
|
||||
|
||||
@@ -73,3 +73,104 @@ def test_the_program_name_is_never_passed_on_as_an_argument(
|
||||
monkeypatch.setattr(sys, "executable", r"C:\meshterm.exe")
|
||||
monkeypatch.setattr(sys, "argv", [r"C:\some\other\name.exe"])
|
||||
assert winterminal.own_command() == [r"C:\meshterm.exe"]
|
||||
|
||||
|
||||
def test_the_bundles_own_bookkeeping_never_reaches_the_new_process(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The bug the frozen build found, and the only one the source build could not.
|
||||
|
||||
A PyInstaller one-file executable unpacks itself and re-runs itself, the two halves
|
||||
talking through ``_PYI*`` variables. Passing those on made the reopened MeshTerm think
|
||||
it was the second half of a launch it never made; it checked its parent, found Windows
|
||||
Terminal, and killed itself with a bootloader error in a window that had just opened.
|
||||
"""
|
||||
monkeypatch.setattr(sys, "frozen", True, raising=False)
|
||||
monkeypatch.setenv("_PYI_ARCHIVE_FILE", r"C:\Downloads\meshterm.exe")
|
||||
monkeypatch.setenv("_PYI_PARENT_PROCESS_LEVEL", "0")
|
||||
monkeypatch.setenv("_MEIPASS2", r"C:\Temp\_MEI123")
|
||||
monkeypatch.setenv("MESHTERM_HOME", r"C:\mine")
|
||||
|
||||
environment = winterminal.child_environment()
|
||||
|
||||
assert not [key for key in environment if key.startswith("_PYI")]
|
||||
assert "_MEIPASS2" not in environment
|
||||
assert environment["MESHTERM_HOME"] == r"C:\mine" # the reader's own settings survive
|
||||
assert environment[winterminal.REOPENED_ENV] == "1"
|
||||
|
||||
|
||||
def test_a_source_run_keeps_the_environment_it_was_given(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Nothing is stripped when there is no bundle, so a dev run is passed through whole.
|
||||
|
||||
``LD_LIBRARY_PATH`` is the one to watch: unfrozen it is the reader's, and dropping it
|
||||
would change how the new process loads its libraries for no reason at all.
|
||||
"""
|
||||
monkeypatch.delattr(sys, "frozen", raising=False)
|
||||
monkeypatch.setenv("LD_LIBRARY_PATH", "/opt/mine/lib")
|
||||
assert winterminal.child_environment()["LD_LIBRARY_PATH"] == "/opt/mine/lib"
|
||||
|
||||
|
||||
def test_the_loader_path_the_bootloader_replaced_is_put_back(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The bootloader points the loader at its own unpacked copy and parks the original.
|
||||
|
||||
The new process unpacks a bundle of its own, so it wants the value the reader started
|
||||
with — and where there was none, none.
|
||||
"""
|
||||
monkeypatch.setattr(sys, "frozen", True, raising=False)
|
||||
monkeypatch.setenv("LD_LIBRARY_PATH", "/tmp/_MEI999")
|
||||
monkeypatch.setenv("LD_LIBRARY_PATH_ORIG", "/opt/mine/lib")
|
||||
monkeypatch.setenv("DYLD_LIBRARY_PATH", "/tmp/_MEI999")
|
||||
monkeypatch.delenv("DYLD_LIBRARY_PATH_ORIG", raising=False)
|
||||
|
||||
environment = winterminal.child_environment()
|
||||
|
||||
assert environment["LD_LIBRARY_PATH"] == "/opt/mine/lib"
|
||||
assert "LD_LIBRARY_PATH_ORIG" not in environment
|
||||
assert "DYLD_LIBRARY_PATH" not in environment
|
||||
|
||||
|
||||
def test_the_window_asked_for_fits_the_startup_wordmark() -> None:
|
||||
"""The size request exists because a too-small window fails quietly, not loudly.
|
||||
|
||||
The startup wordmark is 71 columns; below that the splash silently swaps to the narrow
|
||||
one drawn for the PicoCalc, which is how this was found — a desktop session showing the
|
||||
handheld's mark. The request has to clear that, not merely the platform's minimum.
|
||||
"""
|
||||
from meshterm.platforms import REGULAR, set_platform
|
||||
from meshterm.ui.logo import load_logo, logo_width
|
||||
|
||||
set_platform(REGULAR)
|
||||
cols, rows = (int(part) for part in winterminal._wanted_size().split(","))
|
||||
|
||||
assert logo_width(load_logo(cols)) == 71, "the window asked for would get the narrow mark"
|
||||
assert cols >= REGULAR.readable_cols
|
||||
assert rows >= REGULAR.readable_rows
|
||||
|
||||
|
||||
def test_the_session_gets_its_own_window_at_that_size(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Both halves matter, and each was a separate way to arrive somewhere too small.
|
||||
|
||||
Without ``-w new`` the session lands as a tab in whatever window is already open and
|
||||
inherits its size — measured at 64 columns on a real machine, which is why the wrong
|
||||
mark appeared. Without ``--size`` a new window takes the reader's global launch size,
|
||||
a setting about their shell rather than about this app.
|
||||
"""
|
||||
started: list[list[str]] = []
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.delenv(winterminal.REOPENED_ENV, raising=False)
|
||||
monkeypatch.setattr(winterminal.shutil, "which", lambda _: r"C:\wt.exe")
|
||||
monkeypatch.setattr(
|
||||
winterminal.subprocess, "Popen", lambda argv, **_: started.append(argv) or None
|
||||
)
|
||||
|
||||
assert winterminal.reopen() is True
|
||||
argv = started[0]
|
||||
assert argv[:3] == [r"C:\wt.exe", "-w", "new"]
|
||||
assert "--size" in argv
|
||||
assert argv[argv.index("--size") + 1] == winterminal._wanted_size()
|
||||
# `--` must still separate wt's options from MeshTerm's, or `--mock` becomes wt's.
|
||||
assert "--" in argv and argv.index("--") > argv.index("--size")
|
||||
Reference in new issue
Block a user