From 4a918456518d2fa8bf55c916760028f285bb12fb Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Tue, 8 Sep 2026 01:17:26 -0400 Subject: [PATCH] A manual for the command line, and two rules it caught us breaking docs/cli.md documents every command and option, what each one prints, and what it returns -- with the output conventions and the exit-status table up front, since those are the two things a script is written against. Every command, option and flag in it was checked against the running app; the sample output is captured from the simulator rather than typed out. Writing it turned up two places the CLI still did the thing the last commit was about. A destructive command refused for want of --yes printed its refusal in red on *stdout* and exited 1: a colour and a sentence that is not the command's answer, in whatever was reading the command's answer. A missing confirmation is a bad invocation, so it now goes through the parser like any other -- nothing on stdout, the reason on stderr, exit 2. The README keeps its cookbook and its summary of the conventions, and points at the manual for the rest. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 + README.md | 4 + docs/cli.md | 806 ++++++++++++++++++++++++++++++++++++ meshterm/tools/channels.py | 10 +- meshterm/tools/config.py | 11 +- tests/test_cli_contract.py | 40 +- tests/test_device_config.py | 9 +- 7 files changed, 868 insertions(+), 16 deletions(-) create mode 100644 docs/cli.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5de4aa0..b7d1cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,10 @@ allowed to change behaviour, not just add to it. filter them out. Progress bars moved there too, and draw nothing at all when stderr is not a terminal. +- **A destructive command refused for want of `--yes`** printed its refusal in red on + *stdout* and exited `1`. A missing confirmation is a bad invocation, so it is now a usage + error like any other: nothing on stdout, the reason on stderr, exit `2`. + - **An expected failure no longer dumps a traceback.** A lost serial link, or an argument a command rejected on its own (an unresolvable `--to`), read as one line and the right exit status; the log keeps the detail, and a genuine fault still gets its full traceback. diff --git a/README.md b/README.md index c4aa3eb..e8d1de2 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,8 @@ here with its scripted equivalent, where one exists. ## CLI cookbook Every menu option is also a Typer subcommand — ideal for scripting, cron, and bots. +**[`docs/cli.md`](docs/cli.md) is the full manual**: every command and option, what each +one prints, and the exit statuses. A taste: ```bash # Trace — a trace transmits exactly once; run it again to sample more. @@ -286,6 +288,8 @@ Global options (before the subcommand): `--profile/-p`, `--port`, `--ble`, `--bl ### What the output looks like +*(The short version — [`docs/cli.md`](docs/cli.md) has the whole of it.)* + The CLI is meant to be piped, so it prints like a standard Unix utility and nothing else: plain text, no colour, no borders, and one record per line — nothing is ever wrapped, so a long line runs off the right rather than folding its fields onto a second one. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..200b57c --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,806 @@ +# The MeshTerm command line + +MeshTerm has two front ends over one codebase. The **menu** — what you get by running +`meshterm` with no arguments — is a full-screen session meant to be read by a person. The +**CLI** is meant to be read by `awk`. + +This is the manual for the second one: what it prints, what it returns, and every command +it answers to. + +- [Running it](#running-it) +- [Output conventions](#output-conventions) +- [Exit status](#exit-status) +- [Command reference](#command-reference) + - [Finding a device](#finding-a-device) + - [This node](#this-node) + - [The mesh around you](#the-mesh-around-you) + - [Messaging](#messaging) + - [Tracing](#tracing) + - [Other nodes](#other-nodes) + - [MeshTerm itself](#meshterm-itself) +- [Recipes](#recipes) +- [What has no command](#what-has-no-command) + +--- + +## Running it + +``` +meshterm [GLOBAL OPTIONS] COMMAND [ARGS] +``` + +With no command, MeshTerm launches the interactive menu instead. Global options go +**before** the command: + +| Option | What it does | +| --- | --- | +| `-p`, `--profile NAME` | Use a named device profile from `config.toml`. | +| `--port PORT` | Serial port to connect to (`COM5`, `/dev/ttyACM0`), overriding the profile. | +| `--ble ADDRESS` | Bluetooth address of a companion; selects the BLE transport. | +| `--ble-pin PIN` | Pairing PIN, if the Bluetooth companion asks for one. | +| `--tcp HOST[:PORT]` | Network address of a TCP companion; selects the TCP transport. Default port 5000. | +| `--mock` | Use the built-in simulator instead of real hardware. Nothing transmits. | +| `--db PATH` | Use this SQLite database instead of `~/.meshterm/meshterm.db`. | +| `--json` | Machine-readable output. Only `devices` and `tx-optimize` honour it; everything else is already parseable as plain text. | +| `-q`, `--quiet` | Suppress console logging entirely (the log file still records). | +| `--platform NAME` | Force the UI flavour (`regular`\|`picocalc`) instead of detecting it. | + +Commands that need a radio open one, do their work, and close it. Commands that only read +stored history (`records`) or MeshTerm's own state (`preferences`, `about`) need no device +at all and work with nothing attached. + +> **One transmission per invocation.** A trace transmits exactly once — repeaters +> penalise, and can blacklist, nodes that burst traffic. To sample more, run the command +> again with your own pacing between runs. + +--- + +## Output conventions + +The CLI prints like a standard Unix utility, and every rule below exists so its output can +be piped somewhere without being cleaned up first. + +**No colour.** Not a hue, not a bold, not a dim — stdout carries no escape sequence at all, +whether or not it is a terminal. A colour that survives into a file is noise in it. + +**No wrapping.** A record is a line. Wrapping would put half a record's fields under the +wrong headings, so nothing folds: a long line runs off the right and your terminal (or +`less -S`, or your pipe) decides what to do about it. + +**No frames.** No borders, no boxes, no rules, no titles, no legends. The command you typed +is the title. + +**Listings** are an uppercase header line followed by space-aligned records, the shape `ps` +and `df` print. Numeric columns right-align. + +```console +$ meshterm contacts +NAME TYPE HEARD PKTS KEY +"Alice" node 2026-09-07T19:58:54-04:00 12 d4e5f6a700000000000000000000000000000000000000000000000000000000 +"Yagi-Repeater" repeater 2026-09-07T19:58:53-04:00 97 a1b2c3d400000000000000000000000000000000000000000000000000000000 +``` + +**Node names are quoted.** A name can hold a space, a comma, even a quote, so a bare name +is not one field — it is however many fields the name happens to split into. Quotes and +backslashes inside a name are escaped the ordinary way (`"He said \"hi\""`), so a quoted +value reads back unambiguously. + +**Key/value blocks** are a key, the gutter, and the rest of the line as its value — the +shape `sysctl -a` prints. There is no header, and the value is *not* quoted: a line has +exactly two fields, so the value is whatever follows the key. + +```console +$ meshterm info +name MockCompanion +public_key 0000000000000000000000000000000000000000000000000000000000000000 +role companion +battery_v 4.10 +uptime_s 93784 +noise_floor_dbm -110 +``` + +A `get` prints the bare value alone, ready for `$(...)`: + +```console +$ meshterm config get tx_power +20 +``` + +**Paths** are quoted node names, each with its hash in parentheses *outside* the quotes, +joined by a bare comma — the same comma `--path` takes: + +``` +"MockCompanion" (00),"Yagi-Repeater" (a1),"Alice" (d4),"Yagi-Repeater" (a1),"MockCompanion" (00) +``` + +Your own node is a hop like any other, named and hashed — never a `★`. The hash is there +because the CLI has no colour: on screen a route's hops are told apart by their key-derived +hues, and in plain text the hash is what carries that identity. It is also what joins a +route line to the per-hop table underneath it. + +**Times are absolute** local ISO-8601 to the second — `2026-09-07T18:22:41-04:00` — never +`3h ago`. A relative age is for someone watching a screen; a script wants something it can +sort and subtract. + +**`-` is the one token for nothing** — absent, unknown, or not applicable. You learn it +once; there is no `—`, no `n/a`, no `never`, no empty cell. + +**stdout is the answer; everything else is stderr.** Errors print as +`meshterm: what went wrong`. Progress bars draw on stderr too, and draw nothing at all when +stderr is not a terminal. So `meshterm contacts > contacts.txt` puts contacts in the file +and any complaint on your screen. + +--- + +## Exit status + +The return value is half the report. + +| Code | Meaning | +| --- | --- | +| `0` | Success. | +| `1` | Failure with no more specific code — a bad value, an unreadable file, an unhandled fault. | +| `2` | Usage error: an unknown flag, a missing argument, a value the parser rejected. | +| `3` | No companion device could be selected: none attached, none matching the connection flags, or the choice was ambiguous. **Nothing was transmitted.** | +| `4` | A device was reached but the operation failed — a command error, a timeout, a link lost mid-run. Retrying is reasonable. | +| `5` | The command 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. | + +The table is printed under `meshterm --help` as well. + +`5` is the one worth building on. `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: + +```bash +if meshterm contacts > contacts.txt; then + echo "$(($(wc -l < contacts.txt) - 1)) contacts" # minus the header +elif [ $? -eq 5 ]; then + echo "no contacts yet" +else + echo "could not reach the radio" >&2 +fi +``` + +`3` and `4` split the failures a retry might fix from the ones it never will: + +```bash +meshterm trace --target Alice +case $? in + 0) echo "reachable" ;; + 5) echo "no reply — the walk ran, nothing came home" ;; + 3) echo "no radio attached" >&2; exit 1 ;; + 4) echo "radio trouble — worth retrying" >&2; exit 1 ;; + *) echo "failed" >&2; exit 1 ;; +esac +``` + +--- + +## Command reference + +### Finding a device + +#### `meshterm devices` + +List every attached serial device and in-range Bluetooth companion. Opens no radio — it +only enumerates what is attached or advertising. + +| Option | What it does | +| --- | --- | +| `--ble` / `--no-ble` | Include a Bluetooth LE scan. On by default; skipping it saves a few seconds. | + +```console +$ meshterm devices --no-ble +TARGET TRANSPORT NAME HARDWARE MESHCORE SERIAL ACTIVE +COM12 serial "USB Serial Device (COM12)" "Adafruit" maybe D030D2CCD00F08EB no +COM16 serial "USB Serial Device (COM16)" "Adafruit" maybe D8D7D4BB5E4546E0 no +COM1 serial "Communications Port (COM1)" "(Standard port types)" no - no +``` + +`TARGET` is what `--port` and `--ble` take, verbatim. `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. + +Returns `5` when nothing is found. + +--- + +### This node + +#### `meshterm info` + +The connected companion's live status: which radio this is, and how it is doing. + +```console +$ meshterm info +name MockCompanion +public_key 0000000000000000000000000000000000000000000000000000000000000000 +role companion +model MeshCore Simulator +firmware mock mock +battery_v 4.10 +storage_used_kb 128 +storage_total_kb 1024 +clock 2026-09-08T01:05:09-04:00 +clock_drift_s -125 +uptime_s 93784 +noise_floor_dbm -110 +last_rssi_dbm -62 +last_snr_db +9.5 +tx_air_s 42 +rx_air_s 360 +packets_sent 210 +packets_received 1234 +receive_errors 3 +``` + +The unit is in the key, so nothing has to be pulled back out of prose. A reading the +firmware does not answer for is **absent** rather than `-`: a missing key says "this device +does not report it", where a dash would claim it reported nothing. + +What the radio is *set to* is `config show`'s answer, not this one. + +#### `meshterm config` + +View and change every device setting. With no subcommand it runs `show`. + +| Subcommand | What it does | +| --- | --- | +| `show` | Print every setting as `key value`. | +| `get KEY` | Print one setting's value, bare. | +| `set KEY VALUE` | Change one setting. | +| `backup PATH` | Write every setting to a TOML file. | +| `restore PATH [--dry-run]` | Apply settings from a TOML backup. `--dry-run` prints the plan and changes nothing. | +| `custom KEY VALUE` | Set an experimental custom variable. | +| `channel INDEX NAME [--secret HEX]` | Configure a channel slot (see also the `channels` group). | +| `advert [--flood]` | Broadcast an advertisement. Zero-hop unless `--flood`. | +| `advert-cadence HOURS [--flood]` | How often to auto-advertise in the background. `0` disables. | +| `share` | Print this node's contact card as a `meshcore://` URI. | +| `sync-clock` | Set the device clock from this computer. | +| `export-key [--out PATH]` | Export the private key. **Sensitive.** | +| `import-key KEY_HEX --yes` | Import a private key, overwriting this node's identity. | +| `reboot --yes` | Reboot the device. | +| `factory-reset --yes` | Erase all data and reset to defaults. | + +```console +$ meshterm config show +name MockCompanion +adv_lat 0.0 +adv_lon 0.0 +device_pin •••••• +radio_freq 869.618 +radio_bw 62.5 +radio_sf 8 +radio_cr 5 +tx_power 20 +manual_add_contacts false +flood_scope "" +adv_loc_policy 0 +``` + +`show` names each setting by the key `get` and `set` take, and prints a value they will +take back — an enum's **number**, not the reader's label; `""` for an empty string; `-` for +something the firmware never reported. So a line read out of `show` can be typed straight +back in: + +```console +$ meshterm config set radio_sf 9 +$ meshterm config get radio_sf +9 +``` + +The pairing PIN stays masked here, because this is the whole-device dump — the thing that +gets redirected into a file and pasted into a bug report. Name it deliberately with +`meshterm config get device_pin`. + +Custom variables are namespaced `custom.*` in the output, since they have no spec and +`config set` will not take them (use `config custom`). + +Destructive operations are gated behind `--yes` rather than a prompt, so nothing surprising +happens in a script. Without it the command is refused as a **usage error** — nothing on +stdout, the reason on stderr, exit `2`. + +--- + +### The mesh around you + +#### `meshterm contacts` + +The contacts your device knows. + +| Option | What it does | +| --- | --- | +| `-s`, `--sort ORDER` | `heard` (default), `name`, or `packets`. | + +```console +$ meshterm contacts --sort name +NAME TYPE HEARD PKTS KEY +"Alice" node 2026-09-07T19:58:54-04:00 12 d4e5f6a700000000000000000000000000000000000000000000000000000000 +"Local-Repeater" repeater 2026-09-07T19:58:53-04:00 48 b2c3d4e500000000000000000000000000000000000000000000000000000000 +``` + +`TYPE` is the node's advertised role in words — `node`, `repeater`, `room`, `sensor`, or +`unknown`. `PKTS` is how many packets passive monitoring has overheard from it. `KEY` is +the full public key, never elided: a truncated key is not something you can hand back to +`--to` or `--path`. + +This lists *contacts only*. Your own node is not a contact — `meshterm info` reports it, in +far more detail than a row could hold. + +Returns `5` when the device knows no contacts yet. + +#### `meshterm monitor` + +Capture overheard packets in the foreground for a bounded window, then summarise it. +Transmits nothing; it only listens. Recording to history is always on anyway — this adds +the live view. + +| Option | What it does | +| --- | --- | +| `-s`, `--seconds N` | How long to capture. `0` (the default) runs until Ctrl-C. | + +```console +$ meshterm monitor --seconds 3 +TIME NODE NAME SNR_DB RSSI_DBM LAT LON +2026-09-08T01:07:25-04:00 a1b2c3d4 "Yagi-Repeater" +7.0 -101 45.50190 -73.56740 +2026-09-08T01:07:25-04:00 c3d4e5f6 "Observer-Bot" +4.9 -93 - - + +NODE NAME PKTS MEDIAN_SNR_DB BEST_SNR_DB RSSI_DBM LAST_HEARD +c3d4e5f6 "Observer-Bot" 54 +5.2 +12.4 -108 2026-09-08T01:07:33-04:00 +a1b2c3d4 "Yagi-Repeater" 48 +5.1 +12.8 -100 2026-09-08T01:07:33-04:00 +``` + +Two blocks, separated by a blank line. The **stream** is a header and then a record per +packet as it arrives; it cannot be column-aligned, because the widths are not known until +it ends, so its fields are gutter-separated and the name carries its quotes. The +**summary** is the aggregate the stream cannot give — a count, a median, a best per node — +most recently heard first. + +Returns `5` when the window heard nothing. + +#### `meshterm records` + +The record-setting walks every trace has been scored against. Reads the database; needs no +device and never transmits. + +| Option | What it does | +| --- | --- | +| `-c`, `--category ID` | One discipline: `long_haul`, `far_point`, `long_leg`, `grand_tour`, `clean_trail`, `thin_thread`, `big_loop`. | +| `-w`, `--width N` | Only records set at this per-hop hash width (`1`, `2`, or `4`). | + +```console +$ meshterm records --category long_haul --width 2 +CATEGORY WIDTH SCORE UNIT RECORDED VERSION ROUTE +long_haul 2 273.0 km 2026-08-17T18:14:28-04:00 0.2.8 "Yagi-Repeater" (a1b2),"Alice" (d4e5),"Yagi-Repeater" (a1b2) +long_haul 2 112.0 km 2026-09-03T17:46:10-04:00 0.2.8 "Local-Repeater" (b2c3),"Alice" (d4e5),"Local-Repeater" (b2c3) +``` + +`SCORE` is a bare number and `UNIT` is its own column, so the scores in a column are +comparable. A Longest-distance score prefixed `>=` is a **lower bound**: some hop on that +walk has no known position, so the distance is a floor rather than a measurement. + +Records are kept per hash width, because the width bounds both a walk's maximum length and +its collision odds — boards at different widths are measuring different games. + +Returns `5` when no records match. + +--- + +### Messaging + +#### `meshterm chat` + +| Subcommand | What it does | +| --- | --- | +| `send TEXT --to NAME` | Send a direct message. | +| `send TEXT --channel N` | Broadcast on a channel slot. | +| `history [--to NAME \| --channel N] [--limit N]` | Print a conversation's stored transcript. Default limit 50. | +| `list` | Every channel and contact with its unread count and last message. | +| `listen [-s SECONDS] [--debug]` | Tail inbound messages live. | + +Exactly one of `--to` and `--channel` is required on `send` and `history`. + +```console +$ meshterm chat send "on my way" --to Alice +acked yes +``` + +A direct message prints its `acked` state, which is the one thing the send does not already +tell you — the radio accepted it either way, and whether the peer answered is a separate +fact. A **channel** broadcast prints nothing: there is no acknowledgement on a channel, so +the exit status is the whole answer. + +```console +$ meshterm chat history --to Alice +TIME DIRECTION PEER SNR_DB TEXT +2026-09-08T01:07:41-04:00 out "Alice" - on my way +2026-09-08T01:09:02-04:00 in "Alice" +6.4 see you there +``` + +`DIRECTION` is `in` or `out`; `PEER` is always the *other* party. `TEXT` is last and +unquoted — it is the rest of the line, and a message body is the one field that can hold +absolutely anything. + +```console +$ meshterm chat list +CONVERSATION KIND UNREAD LAST_TIME LAST_TEXT +"Public" channel 0 - - +"Alice" direct 2 2026-09-07T19:58:54-04:00 see you there +``` + +`listen` prints a header and then a record per message as it arrives, the same shape +`monitor`'s stream takes. `--debug` routes the message-pull logging to stderr, for working +out whether messages are being pulled from the companion at all. + +`history`, `list` and `listen` return `5` when there is nothing to report. + +#### `meshterm channels` + +| Subcommand | What it does | +| --- | --- | +| `list` | The configured channel slots. | +| `add INDEX NAME [--secret HEX]` | Add a channel. A leading `#` makes it public (the firmware derives the key from the name); otherwise it is private, with a random key unless you give one. | +| `join INDEX NAME SECRET` | Join a channel from its name and 32-hex-character key. | +| `import INDEX URL` | Import a `meshcore://channel/add` link. | +| `share INDEX` | Print a slot's share link. | +| `clear INDEX --yes` | Clear a slot, removing the channel from the device. | + +```console +$ meshterm channels list +SLOT NAME TYPE HASH +0 "Public" public 8f +1 "brigade" private 2c +``` + +`share` prints the `meshcore://` URI alone. (The menu draws a QR code beside it; redirected +into a file that is a block of block characters wrapped around the one thing that is +actually the answer.) + +`clear` is gated behind `--yes` because a private channel's key is lost with the slot unless +it is saved elsewhere. + +`list` returns `5` when no slots are configured. + +#### `meshterm courier` + +A store-and-forward outbox: queue a message for a contact that is not reachable right now, +and it goes out the moment the contact is next heard — or at a time you name. + +| Subcommand | What it does | +| --- | --- | +| `queue CONTACT TEXT… [--at HH:MM]` | Queue a message. `--at` holds it until the next occurrence of that local time. | +| `list` | The outbox: waiting entries then finished ones. | +| `send ID` | Force one delivery attempt now, skipping the "wait until heard" check. | +| `cancel ID` | Remove a waiting entry. | +| `clear` | Drop every finished (delivered / given-up) entry. | + +```console +$ meshterm courier queue Alice "ping me when you are back" --at 18:30 +id 10 + +$ meshterm courier list +ID STATE NODE SCHEDULED FINISHED TEXT +10 waiting "Alice" 2026-09-08T18:30:00-04:00 - ping me when you are back +8 delivered "Bob" - 2026-08-11T18:10:17-04:00 Courier test! +``` + +`queue` prints the entry's **id**, which is the one fact you have to keep: it is what `send` +and `cancel` take. An entry with no `SCHEDULED` time goes as soon as the contact is heard. + +```console +$ meshterm courier send 10 +outcome delivered +``` + +`outcome` is one of `delivered`, `no ack`, `gave up`, `unknown contact`, `busy`, `gone`. + +The *sending* is done by a running interactive session's courier; a queued message sits in +the outbox until one runs. + +`list` returns `5` on an empty outbox; `cancel` and `clear` return `5` when there was +nothing to act on. + +--- + +### Tracing + +#### `meshterm trace` + +Walk the path to a target once and report what came back. + +| Option | What it does | +| --- | --- | +| `-t`, `--target NAME` | **Required.** Target node name or key prefix. | +| `-p`, `--path SPEC` | Force a route: comma-separated contact names and/or hex key prefixes, mixed freely (`3d,f2,3d`). Omit it and the device routes. | + +```console +$ meshterm trace --target Alice +target Alice +path auto +success yes +hops 3 +min_snr_db +2.6 +rtt_ms 459 +route "MockCompanion" (00),"Yagi-Repeater" (a1),"Alice" (d4),"MockCompanion" (00) + +HOP FROM TO SNR_DB +0 00 a1 +6.0 +1 a1 d4 +2.6 +2 d4 00 +5.1 +``` + +Two blocks. The first is what the walk did, `route` among the facts. The second is a record +per hop, naming its two ends by the **hash** rather than repeating the names: the route line +above is where the names are, and the hash is what joins the two blocks. `path` reads `auto` +when the device routed it — every real value there is comma-separated hex. + +A trace that never came home is **not** a failure of the command: the radio transmitted and +the walk ran. It reports `success no` and returns `5`. + +#### `meshterm trace-path` + +The other half of tracing: no target, just a route you compose by hand — out and back +whichever way you choose. It only has to end within earshot of this node. + +| Option | What it does | +| --- | --- | +| `-p`, `--path SPEC` | **Required.** The whole walk, comma-separated. | + +```console +$ meshterm trace-path --path "a1,d4,a1" +target - +path a1,d4,a1 +success yes +hops 4 +min_snr_db +2.0 +rtt_ms 458 +route "MockCompanion" (00),"Yagi-Repeater" (a1),"Alice" (d4),"Yagi-Repeater" (a1),"MockCompanion" (00) + +HOP FROM TO SNR_DB +0 00 a1 +6.0 +1 a1 d4 +2.0 +2 d4 a1 +2.4 +3 a1 00 +6.0 +``` + +`target` is `-` because a path walk has none. Walks record under a sentinel so they stay out +of the target picker's history. + +#### `meshterm tx-optimize` + +Sweep a remote node's transmit power against a target and pick the lowest level that still +gets through reliably. + +| Option | What it does | +| --- | --- | +| `-p`, `--path SPEC` | **Required.** Forced path ending at the target (`Repeater,Target`, or `3d,f2`). The hop before the target is the node being tuned. | +| `-n`, `--samples N` | Traces per TX level. Default 3. | +| `--step N` | Coarse sweep step. Default 3. | +| `--min N` / `--max N` | Bound the sweep (else the `tx_opt_min` / `tx_opt_max` preferences). | +| `--password TEXT` | Admin password for the tuned node; else the remembered one, else prompted. | +| `--apply` / `--no-apply` | Set the winner on the node. On by default. | + +```console +$ meshterm tx-optimize --path "Yagi-Repeater,Alice" --samples 5 +tuning_node Yagi-Repeater +target Alice +path a1,d4 +optimal_tx_dbm 22 +target_snr_db +4.1 +reliability 1.00 +previous_tx_dbm 27 +applied yes + +TX_DBM TARGET_SNR_DB SUCCESSES SAMPLES + 18 -1.2 2 5 + 21 +2.8 5 5 + 22 +4.1 5 5 +``` + +The winner comes first because it is what you asked for; the per-level records follow so the +choice can be checked against the measurements it was made from. This transmits many times +(paced by the `trace_cooldown_s` preference) — it is the one command that is not a single +transmission. + +Returns `5` when no trace reached the target at any level: nothing was measured, and the +node was left at its original power. + +--- + +### Other nodes + +#### `meshterm repeater-admin` + +Send one command to a remote repeater or room server over the mesh and print its reply. + +``` +meshterm repeater-admin NODE COMMAND... [--password TEXT] +``` + +| Argument / option | What it does | +| --- | --- | +| `NODE` | The remote contact's name. | +| `COMMAND...` | The text command to send, as the node's own CLI spells it. | +| `--password TEXT` | Admin password; else the one remembered from an interactive login. | + +```console +$ meshterm repeater-admin Yagi-Repeater get name +Yagi-Repeater +``` + +The reply is printed as the node sent it — its own text, and the whole answer. A node that +does not answer is not a failure (the command may well have landed), but there is nothing to +report, so it returns `5`. + +Logging in happens automatically from the remembered password; run the interactive flow once +to store one, or pass `--password`. A refused login clears the saved password and fails with +`4`. + +--- + +### MeshTerm itself + +#### `meshterm preferences` + +How MeshTerm behaves, as opposed to how the radio is configured. Reads nothing from the +companion, transmits nothing, and works with no device attached. + +| Subcommand | What it does | +| --- | --- | +| `show` | Every preference, its value, and its built-in default. | +| `get KEY` | One preference's value, bare. | +| `set KEY VALUE` | Change one preference. | +| `reset --yes` | Return every preference to its default. | + +```console +$ meshterm preferences show +PREFERENCE VALUE DEFAULT +trace_cooldown_s 5 5 +flood_advert_cooldown_s 5 60 +map_view_fraction 0.5 0.5 +history_days 365 365 +``` + +Values print in the form `preferences set` accepts back — no unit suffix on a number, +`on`/`off` for a boolean. Where `VALUE` differs from `DEFAULT`, this install has an override. + +Overrides live in `~/.meshterm/preferences.yaml`, which lists only what you have changed; +delete a line and the default takes over again. + +#### `meshterm about`, `about-author`, `discord`, `support` + +The four written pages, printed as plain text. `about` is what MeshTerm is and the terms it +ships under; `about-author` is who wrote it; `discord` is the community invite; `support` is +what keeps it going. + +```console +$ meshterm discord +Join the Discord + Questions, ideas, bug reports, and mesh talk. + + • https://discord.gg/AZwe5Uvb3S +``` + +The menu draws a QR code beside each link; the scripted face prints the link alone. + +#### `meshterm platform` + +A diagnostic for which UI flavour a given invocation resolves to, and why. + +```console +$ meshterm platform +platform regular +platform_source default +flag - +env - +device_tree_model - +icons yes +icons_source no-console +``` + +`icons` is a separate question from the flavour, and the usual reason a Windows session looks +plainer than the screenshots. + +#### `meshterm specimen` + +Print the visual-language specimen — every mark, icon, colour scale and fold on one card. + +**This is the one command that keeps its colour**, and it builds its own themed console to do +it. That is deliberate: the colour *is* the output. It is the font-and-palette acceptance +card on the PicoCalc console, and with `--platform picocalc` it previews that flavour from a +desktop. A monochrome specimen would test nothing. + +--- + +## Recipes + +**Watch a node and shout if it goes quiet.** `trace` returning `5` means the walk ran and +nothing came home. + +```bash +#!/usr/bin/env bash +target=${1:?usage: watch NODE} +meshterm trace --target "$target" > /dev/null +status=$? # capture it first: `if ! cmd` would have reset $? to 0 +if [ "$status" -eq 5 ]; then + echo "$target did not answer" | mail -s "mesh alert" me@example.com +fi +``` + +**Nightly config archive**, keeping the path the command actually wrote: + +```bash +out=$(meshterm config backup "$HOME/mesh/$(date +%F).toml") && echo "archived $out" +``` + +`config backup` prints the file it wrote, bare and one per line, so the path comes back to +the caller. + +**Fields out of a listing.** Names are quoted, so a CSV-aware reader is the safe way in; +`awk` works when you split on the quotes rather than on spaces. + +```bash +# every repeater's key +meshterm contacts | awk 'NR > 1 && $0 ~ /repeater/ { print $NF }' + +# names alone, quotes stripped +meshterm contacts | awk -F'"' 'NR > 1 { print $2 }' +``` + +**One value into a variable:** + +```bash +sf=$(meshterm config get radio_sf) +[ "$sf" -lt 9 ] && meshterm config set radio_sf 9 +``` + +**Capture a window of traffic to a file**, with the progress noise left on the terminal: + +```bash +meshterm monitor --seconds 300 > /var/log/mesh-$(date +%s).tsv +``` + +**Sample a trace politely.** A trace transmits exactly once per invocation by design; pace +the repeats yourself: + +```bash +for i in $(seq 5); do + meshterm trace --target Alice --path "3d,f2,3d" + sleep "$(meshterm preferences get trace_cooldown_s)" +done +``` + +**Queue for someone who is offline**, then chase it later: + +```bash +id=$(meshterm courier queue Alice "call me" | awk '{print $2}') +# ... later ... +meshterm courier send "$id" +``` + +**Cron-friendly quiet:** `--quiet` suppresses console logging entirely, leaving only the +command's own output and any error line. + +```cron +*/30 * * * * meshterm --quiet --profile yagi config advert --flood +``` + +--- + +## What has no command + +Some features have no scripted face, and that is the honest answer rather than a degraded +one. Each of these is a *live picture* — its meaning is where things sit on a grid, how they +move, and what colour they are — and none of that survives being turned into records. + +| Feature | Why not, and what to use instead | +| --- | --- | +| **Map** | A drawing of braille cells whose nodes are told apart by colour. Stripped of colour to match the rest of the CLI it would be unreadable; left coloured it could not be piped anywhere useful. `meshterm contacts` lists the same nodes, coordinates and all. | +| **Dashboard** | A live overview that repaints every second. `meshterm monitor` captures the same traffic as records. | +| **Live feed** | Every packet as it arrives, newest first, with a viewer behind each row. `meshterm monitor` is the scripted tail. | +| **Watchtower** | A background sentinel over starred nodes; it exists to *interrupt* a session. Build the same alarm from `meshterm trace`'s exit status (see [Recipes](#recipes)). | +| **Time machine** | Braille charts over a switchable window. The underlying history is in the database `--db` points at. | +| **Mesh walk** | An evidence graph walked one node at a time. `meshterm records` and `meshterm trace` cover the walks it is built from. | + +Everything else in the menu has a command, listed above. diff --git a/meshterm/tools/channels.py b/meshterm/tools/channels.py index 32eb564..8488630 100644 --- a/meshterm/tools/channels.py +++ b/meshterm/tools/channels.py @@ -250,12 +250,12 @@ 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) diff --git a/meshterm/tools/config.py b/meshterm/tools/config.py index d8240af..95fabdd 100644 --- a/meshterm/tools/config.py +++ b/meshterm/tools/config.py @@ -506,13 +506,18 @@ 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.") diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index d9e489d..abbb4f4 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -176,13 +176,41 @@ def test_a_bad_setting_key_is_a_plain_failure(run) -> None: # noqa: ANN001 assert result.exit_code == exitcodes.FAILURE -def test_a_failure_says_so_on_stderr_in_the_utility_shape(run) -> None: # noqa: ANN001 - """``program: what went wrong`` — so a caller redirecting stdout still sees it.""" - runner = CliRunner() - from meshterm.cli import app +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 - result = runner.invoke(app, ["--mock", "config", "get", "nosuchkey"]) - assert "meshterm: unknown setting" in result.output + +@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 + + +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 == "" # -- the map -------------------------------------------------------------------------- diff --git a/tests/test_device_config.py b/tests/test_device_config.py index 718d881..0cc8cc1 100644 --- a/tests/test_device_config.py +++ b/tests/test_device_config.py @@ -1327,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