# 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 **command line** is the other one, and this is its manual: what it prints, what it returns, and every command it answers to. It prints two ways, and the difference is who is reading. - The **plain face** is for somebody at a prompt who typed a command to find something out. It is a Unix utility's output — one record per line, no colour, no frames — and it is allowed to be *comfortable*: names are bare, times read `5m` rather than `2026-09-08T04:26:00-04:00`, and a route is drawn with arrows. - The **JSON face** (`--json`) is for a program, often on another machine and often later. It is the contract: typed values, absent spelled `null`, timestamps in UTC, keys that do not move. It works on **every** command — it used to reach two out of twenty. The CLI used to make one promise to both of them — that it was written to be read by `awk` — and every rule followed from that. That promise now belongs to `--json`, which keeps it properly, and the plain face is released from it. If you are splitting output on whitespace to feed a script, stop: `--json` exists so you do not have to, and the plain face is no longer holding still for you. - [Running it](#running-it) - [Where MeshTerm keeps its state](#where-meshterm-keeps-its-state) - [About the examples](#about-the-examples) - [The plain face](#the-plain-face) - [The JSON face](#the-json-face) - [Exit status](#exit-status) - [When it goes wrong](#when-it-goes-wrong) - [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. | Option | What it does | | --- | --- | | `--version` | Print `meshterm ` and exit `0`, before anything else is opened. | | `-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`. **It moves the database and nothing else** — see [Where MeshTerm keeps its state](#where-meshterm-keeps-its-state). | | `--json` | Print the answer as JSON instead of aligned text. | | `--absolute` | Print times as ISO-8601 instants rather than relative ages, for this run. | | `-q`, `--quiet` | Suppress console logging entirely (the log file still records). | | `--platform NAME` | Force the UI flavour (`regular`\|`picocalc`) instead of detecting it. | **A global option may be typed anywhere** — before the command or after it. `meshterm --json contacts` and `meshterm contacts --json` are the same run, because `-o json` goes after the verb in every tool that has one and `--json` is the first thing anyone reaches for. A bare `--` stops the lifting, the standard way to say the rest is data, and `--help` is never lifted: `meshterm contacts --help` stays the *contacts* help. 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`, `platform`, `specimen`, the written pages) 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. `tx-optimize` is the one deliberate exception, > and it paces itself. --- ## Where MeshTerm keeps its state Everything MeshTerm remembers lives in one directory. On Linux and macOS that is `~/.meshterm`; on Windows it is `%USERPROFILE%\.meshterm`. **`$MESHTERM_HOME` overrides it**, and it is the only thing that moves the *whole* of it: ```bash MESHTERM_HOME=/tmp/mesh-scratch meshterm contacts ``` The directory is read fresh on every run rather than cached, so two radios can be run out of two directories from the same shell. | File | What it holds | | --- | --- | | `meshterm.db` | The history: every packet overheard, every trace walked, every message. **The one file `--db` moves.** | | `config.toml` | Machine setup — device profiles, where the database lives. Yours to write; MeshTerm only reads it. | | `preferences.toml` | Your overrides of MeshTerm's own behaviour. Lists only what you changed. | | `courier.json` | The outbox — queued messages, waiting and finished. | | `admin.json` | Remembered repeater-admin passwords. | | `devices.json` | The remembered default companion, and every device proven to speak the protocol. | | `contacts.json`, `channels.json`, `settings.json` | Per-device caches of what the radio last told us, so a screen opens without a round-trip. | | `adverts.json` | Per-device background-advert schedules (`config advert-cadence` writes here). | | `mutes.json`, `watchtower.json`, `remote.json` | Muted channels; watched nodes and their alerts; per-node remote-admin cache and CLI history. | | `tilecache/` | Downloaded basemap tiles. | | `meshterm.log` | The log file. | | `.lock` | The single-instance lock. | **`--db` alone does not isolate a run.** It moves the database, which is most of the weight but none of the identity: the contact and channel caches, the outbox, the stored admin passwords and the remembered device all live beside it in the config directory and would still be the ones you use every day. To run against a scratch state — a test, a demo, a second radio — set `MESHTERM_HOME`, and set `--db` inside it if you want the database somewhere else again. --- ## About the examples **Every sample below was captured from a real run against the built-in simulator** (`--mock`), in a throwaway `MESHTERM_HOME`. None of it is typed out by hand. The simulator answers as a companion would — it has four contacts (`Alice`, `Yagi-Repeater`, `Local-Repeater`, `Observer-Bot`), it adverts, it replies to traces — so the shapes are real even where the numbers are invented. Three things the simulator does that a radio does not, all visible in the samples: - **Each invocation opens a fresh radio.** A setting written by one command, or a channel slot filled by one, is not there for the next — so `config show` always reports the defaults and `channels list` always answers `5`. On real hardware the radio remembers. - **`PKTS` in `contacts` stays `-`.** The overheard tally is keyed by the node id passive monitoring stores (four bytes here) and the contact row is addressed at the device's path-hash width (one byte), so the two do not meet. Against real hardware at a matching width they do. - **A device-routed `trace` invents its hop names.** `trace --target NAME` with no `--path` comes back through the simulator's router carrying placeholder hops; a *forced* path (`--path "a1,d4"`) resolves properly. The forced form is what the samples use, and what to build a golden file from. > **`--mock` records into your real history unless you tell it not to.** The simulator is > a fake radio, not a fake MeshTerm: what it adverts is written to the database and the > caches like anything else, and invented nodes then turn up in the mesh walk, the > dashboard and the map. Give it its own home — `MESHTERM_HOME=... meshterm --mock …` — > or its own database with `--db`, and it leaves your mesh alone. --- ## The plain face Still a Unix utility's output: one record per line, nothing folds, stdout carries the answer and nothing else. What has gone is every rule that existed *only* to make splitting safe, because splitting is `--json`'s job now. **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 frames.** No borders, no boxes, no rules, no titles, no legends. The command you typed is the title. **Alignment is the delimiter, and names are bare.** A listing is an uppercase header line and space-aligned records, the shape `ps` and `df` print. Numeric columns right-align. ```console $ meshterm contacts NAME TYPE HEARD PKTS HASH LOCATION KEY Observer-Bot node 7m - c3 45.48800,-73.58100 c3d4e5f600000000000000000000000000000000000000000000000000000000 Local-Repeater repeater 7m - b2 45.47680,-73.59900 b2c3d4e500000000000000000000000000000000000000000000000000000000 Yagi-Repeater repeater 7m - a1 45.50190,-73.56740 a1b2c3d400000000000000000000000000000000000000000000000000000000 Alice node 7m - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000 ``` Names used to be quoted, so that `awk` could split on the quote and get one field out of a name holding a space. That bought one reader a delimiter and cost every other reader a line full of punctuation. The **escaping under the quoting stays and always will**: a node broadcasts its own name and a stranger fills in a message body, so neither may hold a raw control character or end the record it sits in. **A time is an age**, because "recently?" is the question a listing is opened to answer: `now`, `5m`, `3h`, `never`. An absolute instant survives where the instant *is* the fact — the device clock, an appointment set with `--at`, a live capture's own `TIME` column. **`--absolute` turns every age back into an instant**, local ISO-8601 to the second: ```console $ meshterm --absolute contacts NAME TYPE HEARD PKTS HASH LOCATION KEY Observer-Bot node 2026-09-08T04:30:50-04:00 - c3 45.48800,-73.58100 c3d4e5f600000000000000000000000000000000000000000000000000000000 Local-Repeater repeater 2026-09-08T04:30:49-04:00 - b2 45.47680,-73.59900 b2c3d4e500000000000000000000000000000000000000000000000000000000 Yagi-Repeater repeater 2026-09-08T04:30:49-04:00 - a1 45.50190,-73.56740 a1b2c3d400000000000000000000000000000000000000000000000000000000 Alice node 2026-09-08T04:30:48-04:00 - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000 ``` The flag is an **override of a preference**, not a switch beside one: `cli_time_format` is `relative` or `absolute`, and `meshterm preferences set cli_time_format absolute` makes it the default for every run. `--absolute` sets it for this run and never saves it. (How MeshTerm behaves is a preference by this project's own rule; a flag that bypassed the registry would be a behaviour nobody could find.) **A route is drawn and a path is typed.** They are different things and they now look different. Two lines out of a trace's facts block: ``` path a1,d4 route MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4) → MockCompanion (00) ``` A **path** is a *spec* — the hop list you compose and force, and the one line on either face that round-trips, so it stays comma-separated hex and nothing creeps into it. Paste it straight back into `--path`. A **route** is the concrete sequence a walk actually took; it is read, not typed, so it reads like what it is. A hop is `Name (hash)`, or whichever half is known, never an empty `()`. **Our own node is a hop like any other**, named and hashed — never the menu's star. The star says "you already know who this is", which is true of the person watching and false of whoever opens the file afterwards. 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, and what joins a route line to the per-hop table under it. **Key/value blocks** are a key, the gutter, and the rest of the line as its value — the shape `sysctl -a` prints. No header; the value is whatever follows the key. ```console $ meshterm info name MockCompanion public_key 0000000000000000000000000000000000000000000000000000000000000000 role companion battery_v 4.10 uptime_s 93784 (1d 2h) noise_floor_dbm -110 ``` A reading whose raw number is the fact but whose *meaning* is a duration carries a gloss in parentheses. `93784` is what the key promises and what a caller wants; `(1d 2h)` is what a person actually asked. Neither has to be parsed out of the other. A `get` prints the bare value alone, ready for `$(...)`, because the caller named the key: ```console $ meshterm config get tx_power 20 ``` **`-` is the one token for absent** — unknown, or not applicable. You learn it once; there is no em dash, no `n/a`, no empty cell. **`never` is different**: it is a *value*, the fact that a node has not been heard yet. And a node type stays the word `repeater` — a monochrome glyph would need a legend, and the CLI has no legends. **A live stream pins its lanes.** `monitor` and `chat listen` cannot measure a column they have not seen yet, so their headings are fixed in advance. 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. **No wrapping, with one exception.** A record is a line; wrapping would put half a record's fields under the wrong headings, so nothing folds and a long line runs off the right. The exception is the four written pages (`about`, `about-author`, `discord`, `support`), which now wrap at 72 cells. The no-wrap rule exists to protect a record's fields; a paragraph has no fields, and unwrapped it is a 600-cell line no terminal can read. **stdout is the answer; everything else is stderr** — errors (`meshterm: what went wrong`), progress bars, log records, and now the two things that used to be thrown away: ```console $ meshterm config set radio_sf 9 $ meshterm config set radio_sf 9 2>&1 ✓ radio_sf = 9 ✓ applied 1 change ``` An **acknowledgement** ("✓ device clock set") and a command's closing **message** ("0 records stored") are reassurance a person needs and a script does not. They used to be dropped on the CLI entirely. Sending them to stderr keeps the promise the dropping was made to keep — `meshterm contacts > contacts.txt` still puts contacts in the file and nothing else — while giving the person at the prompt back their tick and their count. --- ## The JSON face `--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 this reads like what it looks like: ```console $ meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key' b2c3d4e500000000000000000000000000000000000000000000000000000000 a1b2c3d400000000000000000000000000000000000000000000000000000000 ``` A command whose plain face prints two blocks (`trace`, `tx-optimize`) is one object, each listing under its own key (`edges`, `levels`) and the facts merged at the top level. **One compact line, newline-terminated.** UTF-8 with no ASCII escaping, so a name in Cyrillic stays a name. Keys come in the order the report declares them, never sorted — a document reads down the page in the order its plain sibling does. `jq .` re-indents at no cost; nothing can un-stream a document pretty-printed across 400 lines. **A stream is one document per record, all the same shape.** `monitor` and `chat listen` emit newline-delimited JSON as it arrives, flushed. There is no `begin` or `end` line and no discriminator field: every reader would pay for one and only a stream would use it. A capture survives truncation — you lose the last line, not the file — and a run ended by Ctrl-C, which is how `--seconds 0` is *designed* to end, still leaves everything it heard. **Absent is `null`, never an omitted key.** Every field a command declares appears in every document, carrying `null` when the value is absent, unknown, or does not apply. `null` is the counterpart of the plain face's `-` and, like it, is learned once. An empty collection is `[]` or `{}`. An empty string is `""` and is a *value*, distinct from `null`. A whole object is `null` where its parts are meaningless apart: `"position": null`, not `{"lat": null, "lon": null}`. And `unknown` becomes `null` — a consumer already has one spelling for "nothing here". **Values are typed.** `9`, not `"9"`. `false`, not `"false"`. `yes`/`no` in plain (`acked`, `applied`, `success`, `active`) is `true`/`false` here. A message body is raw and unescaped, newlines and all, because a JSON string is not a line. **Timestamps are UTC, RFC 3339, `Z`, to the second** — `"2026-09-08T08:30:50Z"`, twenty characters, so string comparison is time comparison. A field holding one is named `_at`: `heard_at`, `observed_at`, `created_at`, `scheduled_at`. **`--absolute` does not touch this.** A local offset is a fact about the machine that ran the command, not about the event, and two hosts asking the same radio the same question must produce the same document. **A numeric field carries its unit in its key** — `snr_db`, `rssi_dbm`, `uptime_s`, `rtt_ms`, `battery_v` — never in its value, and a number carries its own sign (`5.1` and `-1.5`, never the plain column's `+5.1`). **A key is never truncated**: a truncated key cannot go back into `--to` or `--path`, so a short id is a `hash` and says so. **`--json` changes the rendering, never the report.** Same exit status, same records. An empty result prints its empty document and **still exits 5**: ```console $ meshterm channels list --json [] $ echo $? 5 ``` **"Prints nothing" is never a legitimate JSON answer.** A command whose plain face is silent still says what it did: ```console $ meshterm channels join 3 brigade 4ed883aaded6433c6606c9930a65044c $ meshterm channels join 3 brigade 4ed883aaded6433c6606c9930a65044c --json {"channel":{"slot":3,"name":"brigade","public":false,"hash":"5a"},"secret":"4ed883aaded6433c6606c9930a65044c","url":"meshcore://channel/add?name=brigade&secret=4ed883aaded6433c6606c9930a65044c"} ``` **A failure prints nothing on stdout.** The sentence stays on stderr and the classification stays in `$?`. There is no error document to tell apart from a result, and `--json 2>/dev/null` still behaves — see [When it goes wrong](#when-it-goes-wrong). **A command with no data face refuses, loudly.** `meshterm specimen --json` 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 — there is no document an interactive session can emit. ### The shapes that repeat Defined once here; the per-command tables below name them rather than restating them. **`node`** — a mesh participant's identity, and nothing else. All five keys always present. ```json {"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1","type":"repeater","self":false} ``` | Field | Type | Meaning | | --- | --- | --- | | `name` | string \| null | The name the node advertises. Never quoted, never truncated. | | `key` | string \| null | The full public key, lowercase hex, 64 characters. `null` where only a hash was ever heard. | | `hash` | string \| null | The short derived id, at the width the surrounding surface addressed the node by. Never `key` cut short. | | `type` | string \| null | `"node"` \| `"repeater"` \| `"room"` \| `"sensor"` \| `null`. | | `self` | boolean | `true` for our own node. What the menu's star says and the plain face deliberately does not. | Reception facts — when heard, how strong, where — belong to the **row**, not to the node: they differ per surface, and a node object carrying them would mean something different in each one. So a row embeds `node` under its own key rather than flattening it, and `jq '.[].node.key'` reads the same on contacts, courier and monitor. > **Identity is thin where it comes from history.** A node MeshTerm only ever overheard > has `key: null` and `type: null`, and `name` is whatever the resolver could find. To > correlate across commands, join on `hash` **plus** the width it was addressed at, not on > `key`. **`position`** — `{"lat":45.5019,"lon":-73.5674}`, decimal degrees as stored (unrounded; the plain face's five decimals are a column-width concession). The whole object is `null` where the node has shared no location. **`channel`** — `{"slot":0,"name":"Public","public":true,"hash":"11"}`. `slot` is what every `channels` subcommand's `INDEX` takes; `public` is the plain face's `TYPE` column as a boolean. **The secret is never in a `channel` object** — it appears only where the caller asked for it, under its own key. **`setting`** — one device setting or preference, the same shape `show` and `get` both use. ```json {"key":"radio_sf","value":8,"type":"int","label":null,"redacted":false} ``` `type` is `"int"` \| `"float"` \| `"bool"` \| `"str"` \| `"enum"`. `value` is the typed current value — an enum is its **number**, so `.value | tostring` is what `set` takes back. `label` is the reader's word for an enum's number, else `null`. `redacted` is `true` where the value is deliberately withheld, and a redacted row's `value` is `null` — a mask string is not a value. Preference rows add `default` and `overridden`. **`route` and `edges`** — a `route` is an array of `node` objects in propagation order, our own node a hop like any other; `null` when nothing came home, never `[]`. An `edge` is one directed link, `{"index":0,"from":{node},"to":{node},"snr_db":6.0}`, with the SNR measured arriving at `to`. Where the plain face names an edge's ends by hash so the reader can join the hop table to the route line above it, JSON embeds the whole node at both ends: the join is structural here, and each edge should be readable on its own. --- ## Exit status The return value is half the report, on both faces. | Code | Meaning | | --- | --- | | `0` | Success. | | `1` | Failure with no more specific code — an unreadable file, an unhandled fault. | | `2` | Usage error: an unknown flag, a missing argument, a value the parser rejected, a confirmation not given. | | `3` | No companion device could be selected: none attached, the named one could not be opened, 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. This is not an error. | The table is printed under `meshterm --help` as well. Two boundaries are worth stating because both used to sit in the wrong place. **Nothing transmitted is not a device failure**: a value the tool refuses before it opens the radio is a usage error, so a missing `--yes`, a bad `--sort` or `--category`, an unknown `--profile`, an outbox id that does not exist, and `tx-optimize` with no password available are all `2`. And **a named connection that will not open is `3`, not `4`**: a `--port` that is not there means no device was selected, and nothing went out. `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 ``` --- ## When it goes wrong A failure is **raised, never returned**: nothing lands on stdout, one sentence lands on stderr, and the classification lands in `$?`. That holds on both faces — `--json` does not wrap a failure in a document, because a consumer that can see `$?` does not need one and a consumer that cannot would have to tell an error document from a result. **No radio.** Nothing was transmitted; the exit is `3` whichever way the device was named. ```console $ meshterm --port NOSUCHPORT99 info meshterm: could not open serial port NOSUCHPORT99: could not open port 'NOSUCHPORT99': FileNotFoundError(2, 'The system cannot find the file specified.', None, 2) $ echo $? 3 ``` With nothing named and more than one candidate attached, the same `3` arrives as a list and the way out of it: ```console $ meshterm info meshterm: Multiple companion devices detected and no default to fall back on. • COM12 (serial) — USB Serial Device (COM12) [likely LoRa] • COM16 (serial) — USB Serial Device (COM16) [likely LoRa] • COM26 (serial) — USB Serial Device (COM26) [likely LoRa] • COM1 (serial) — Communications Port (COM1) Choose one with --port or --ble
(or run 'meshterm devices' to inspect them). The chosen device is remembered as the default after it connects. ``` **A value the parser rejects** is `2`, and it names the choices rather than making you find them: ```console $ meshterm contacts --sort bogus Usage: meshterm contacts [OPTIONS] Try 'meshterm contacts --help' for help. Error: Invalid value: --sort must be one of: name, heard, packets (got 'bogus') ``` **A confirmation not given** is the same class, because nothing has happened yet: ```console $ meshterm config reboot Usage: meshterm config reboot [OPTIONS] Try 'meshterm config reboot --help' for help. Error: Invalid value: reboot restarts the device. Re-run with --yes to confirm. $ echo $? 2 ``` Destructive operations gate behind `--yes` rather than a prompt, so nothing surprising happens in a script. **A refusal** is also `2` — the caller asked for something the command does not have: ```console $ meshterm specimen --json Usage: meshterm specimen [OPTIONS] Try 'meshterm specimen --help' for help. Error: Invalid value: specimen has no machine-readable output — its output is the colour ``` **The radio answered and the operation failed** is `4`, and it says what to do next: ```console $ meshterm tx-optimize --path "Yagi-Repeater,Alice" --password wrong logging in to Yagi-Repeater … meshterm: admin login to 'Yagi-Repeater' failed (wrong password?). The saved password was cleared; re-run to enter a new one. $ echo $? 4 ``` **Nothing to report** is `5` and is not a failure at all. stdout is empty on the plain face and carries the empty document under `--json`; anything MeshTerm wants to say about it — `0 records stored` — goes to stderr like every other acknowledgement. ```console $ meshterm records $ echo $? 5 $ meshterm records --json [] ``` Under `--json`, none of this changes: same statuses, same stderr, and stdout carries the document on `0` and `5` and nothing at all on `1` through `4`. ```console $ meshterm contacts --sort bogus --json Usage: meshterm contacts [OPTIONS] Try 'meshterm contacts --help' for help. Error: Invalid value: --sort must be one of: name, heard, packets (got 'bogus') $ echo $? 2 ``` An **unhandled fault** exits `1` with its traceback in the log file. Everything above is an expected failure, reported once. --- ## Command reference Each entry gives what the command does, its options, what the plain face prints, and what `--json` returns. Where a JSON field is one of the [shapes that repeat](#the-shapes-that-repeat), the table names the shape rather than restating it. ### 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. Under `--mock` it enumerates nothing at all: the simulator is the one device, listed with `--mock` as its target, and neither the serial ports nor the Bluetooth radio are touched — a session that promised no real hardware keeps the promise here too. | Option | What it does | | --- | --- | | `--ble` / `--no-ble` | Include a Bluetooth LE scan. On by default; skipping it saves a few seconds. Moot under `--mock`. | ```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 COM26 serial USB Serial Device (COM26) Espressif maybe F85B1BA5EAD0 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. **`--json`** is an array of targets in discovery order, carrying five facts the plain table has no room for. ```json {"target":"COM12","transport":"serial","port":"COM12","address":null,"label":"USB Serial Device (COM12)","hardware":"Adafruit","meshcore":"maybe","confidence":"board","serial_number":"D030D2CCD00F08EB","stable_id":"sn:D030D2CCD00F08EB","confirmed":false,"remembered":false,"active":false} ``` | Field | Type | Meaning | | --- | --- | --- | | `target` | string | What `--port` / `--ble` take, verbatim. | | `transport` | string | `"serial"` \| `"ble"` \| `"tcp"`. | | `port` | string \| null | The serial port, for a serial device. | | `address` | string \| null | The Bluetooth address, for a BLE device. | | `label` | string | The OS's name for the device, or the confirmed node name where we have one. | | `hardware` | string \| null | The confirmed hardware model, else the USB vendor label. | | `meshcore` | string | `"yes"` \| `"maybe"` \| `"no"`. Not a boolean — rounding a hint up to `true` would be a lie. | | `confidence` | string | `"board"` \| `"bridge"` \| `"unknown"` — what a `"maybe"` was derived from. | | `serial_number` | string \| null | | | `stable_id` | string | The identity the device store keys on (`"sn:D030…"`, `"port:COM1"`). | | `confirmed` | boolean | Whether this device is stored as a proven companion. | | `remembered` | boolean | Whether it is the remembered default. | | `active` | boolean | Whether this invocation is (or would be) using it. | 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_at 2026-09-08T04:24:41-04:00 clock_drift_s -125 (2m 5s slow) uptime_s 93784 (1d 2h) noise_floor_dbm -110 last_rssi_dbm -62 last_snr_db +9.5 tx_air_s 42 (42s) rx_air_s 360 (6m) 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 whose number is a **duration** carries a gloss beside it: `93784 (1d 2h)`. The number is what the key promises and what a caller wants; the parenthesis is what a person actually asked. `clock_at` is the *device's* clock and `clock_drift_s` is device minus host, signed. On the plain face `clock_at` is one of the instants that survives a relative-age default, because a clock reading whose whole point is what time the device thinks it is would say nothing as `now`. 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. **`--json`** is one object with the same keys, minus the glosses, and with every key always present: ```json {"name":"MockCompanion","public_key":"0000000000000000000000000000000000000000000000000000000000000000","role":"companion","model":"MeshCore Simulator","firmware":"mock mock","battery_v":4.1,"storage_used_kb":128,"storage_total_kb":1024,"clock_at":"2026-09-08T08:24:42Z","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} ``` `role` is `"companion"` \| `"repeater"` \| `"room server"` \| `"sensor"` \| `null`. This is the one place the two faces disagree about absence on purpose: plain omits a key the firmware never answered for, JSON writes `null`, because a variable key set costs every consumer a lookup guard on every field. `info` does not embed a `node` object — it is our own node in far more detail than the shape carries, and `name` / `public_key` here are its whole identity. What the radio is *set to* is `config show`'s answer, not this one. Never returns `5`. #### `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 airtime_factor 0.0 rx_delay 0.0 manual_add_contacts false autoadd_config 0 flood_scope "" adv_loc_policy 0 multi_acks 0 telemetry_mode_base 0 telemetry_mode_loc 0 telemetry_mode_env 0 path_hash_mode 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, and the write says what it replaced: ```console $ meshterm config get radio_sf 8 $ meshterm config set radio_sf 9 --json {"changes":1,"applied":[{"key":"radio_sf","previous":8,"value":9}]} ``` (Read back with `config get` against real hardware and it answers `9`. The simulator these examples run against opens a fresh radio per invocation, so a setting written by one command is not there for the next.) 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`, which is the one place it is not withheld. Custom variables are namespaced `custom.*` in the output, since they have no spec and `config set` will not take them (use `config custom`). **`--json`.** `show` is an array of `setting` objects in the same order; `get` is one. ```console $ meshterm config get tx_power --json {"key":"tx_power","value":20,"type":"int","label":null,"redacted":false} ``` Three shapes are kept apart: `flood_scope` is `""` (an empty *value*), `device_pin` is `null` with `"redacted":true` (withheld on purpose — a mask string is not a value), and a setting the firmware never reported is `null` with `"redacted":false`. Every write emits what it did, where the plain face is silent and only the stderr acknowledgement says so: ```console $ meshterm config set radio_sf 10 --json {"changes":1,"applied":[{"key":"radio_sf","previous":8,"value":10}]} $ meshterm config custom foo bar --json {"changes":1,"applied":[{"key":"custom.foo","previous":null,"value":"bar"}]} $ meshterm config channel 2 brigade --json {"changes":1,"channel":{"slot":2,"name":"brigade","public":true,"hash":"b5"}} $ meshterm config advert --json {"sent":true,"flood":false} $ meshterm config advert-cadence 6 --json {"changes":1,"flood":false,"hours":6} $ meshterm config sync-clock --json {"changes":1,"set_at":"2026-09-08T08:29:27Z","drift_s":-125} $ meshterm config backup node.toml --json {"path":"D:\\vibe\\MeshTerm\\node.toml","settings":20,"channels":0,"custom":0} $ meshterm config restore node.toml --dry-run --json {"dry_run":true,"changes":0} $ meshterm config export-key --json {"private_key":"1111111111111111111111111111111111111111111111111111111111111111","path":null} $ meshterm config reboot --yes --json {"rebooted":true} $ meshterm config share --json {"url":"meshcore://contact/add?name=MockCompanion&public_key=0000000000000000000000000000000000000000000000000000000000000000&type=1","name":"MockCompanion","public_key":"0000000000000000000000000000000000000000000000000000000000000000","type":1} ``` `previous` is `null` where the snapshot held no prior value; `changes` is `0` and `applied` is `[]` where the value already was what was asked for, and the exit stays `0` because the command did what it was asked. `backup`'s `path` is the file actually written (the plain face prints it bare, so a caller can keep it). `export-key` carries a private key, exactly as the plain face does — that is what the command was asked for, and no extra gate is introduced here. `factory-reset --yes` answers `{"factory_reset":true}`. The QR code the menu draws beside `share` has no JSON face and never will: it is a second rendering of `url`. --- ### 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 HASH LOCATION KEY Alice node 7m - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000 Local-Repeater repeater 7m - b2 45.47680,-73.59900 b2c3d4e500000000000000000000000000000000000000000000000000000000 Observer-Bot node 7m - c3 45.48800,-73.58100 c3d4e5f600000000000000000000000000000000000000000000000000000000 Yagi-Repeater repeater 7m - a1 45.50190,-73.56740 a1b2c3d400000000000000000000000000000000000000000000000000000000 ``` `TYPE` is the node's advertised role in words — `node`, `repeater`, `room`, `sensor`, or `unknown`. `HEARD` is a relative age, because "recently?" is what this listing is opened to answer; `--absolute` turns it into an instant. `PKTS` is how many packets passive monitoring has overheard. **`HASH` is the token `--path` and `--to` take.** It used to have to be sliced out of `KEY` by hand, and getting the width wrong meant addressing a different node. It is exactly the device's path-hash width. **`LOCATION`** is a fact the model 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. This lists *contacts only*. Your own node is not a contact — `meshterm info` reports it, in far more detail than a row could hold. **`--json`** is an array of rows, each embedding the `node` shape. ```json {"node":{"name":"Observer-Bot","key":"c3d4e5f600000000000000000000000000000000000000000000000000000000","hash":"c3","type":"node","self":false},"heard_at":"2026-09-08T08:30:50Z","packets":null,"position":{"lat":45.488,"lon":-73.581}} ``` | Field | Type | Meaning | | --- | --- | --- | | `node` | object | The `node` shape. `hash` is at the device's path-hash width. | | `heard_at` | timestamp \| null | When the contact was last heard. `null` is the plain face's `never`. | | `packets` | integer \| null | Packets passive monitoring has overheard. `null` where none were, which is distinct from `0`. | | `position` | object \| null | The `position` shape. | Returns `5` and `[]` 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 2 TIME NODE SNR_DB RSSI_DBM LOCATION NAME 2026-09-08T04:26:00-04:00 a1b2c3d4 +7.0 -101 45.50190,-73.56740 Yagi-Repeater 2026-09-08T04:26:00-04:00 b2c3d4e5 +9.5 -98 45.47680,-73.59900 Local-Repeater 2026-09-08T04:26:00-04:00 c3d4e5f6 +4.9 -93 - Observer-Bot 2026-09-08T04:26:00-04:00 d4e5f6a7 +7.4 -86 - Alice NODE NAME PKTS MEDIAN_SNR_DB BEST_SNR_DB RSSI_DBM HEARD LOCATION d4e5f6a7 Alice 149 +6.9 +14.1 -93 now - c3d4e5f6 Observer-Bot 148 +5.7 +13.0 -108 now - b2c3d4e5 Local-Repeater 132 +6.7 +14.9 -102 now 45.47680,-73.59900 a1b2c3d4 Yagi-Repeater 132 +6.0 +13.6 -90 now 45.50190,-73.56740 ``` Two blocks, separated by a blank line. The **stream** is a header and then a record per packet as it arrives; its lanes are pinned in advance, because a capture cannot measure a column it has not seen yet, and `TIME` is an absolute instant rather than an age — on a live tail every row would otherwise read `now`. The **summary** is the aggregate the stream cannot give: a count, a median, a best per node, most recently heard first. **`--json` is the stream and only the stream.** One document per packet, every line the same shape, and **no summary document at the end**: ```json {"observed_at":"2026-09-08T08:30:40Z","node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"kind":"advert","snr_db":7.0,"rssi_dbm":-101.3,"position":{"lat":45.5019,"lon":-73.5674},"path":null} ``` | Field | Type | Meaning | | --- | --- | --- | | `observed_at` | timestamp | When the packet arrived. | | `node` | object | The `node` shape. `hash` is the stored node id, four bytes wide. | | `kind` | string | The packet class — `"advert"`, `"telemetry"`, `"packet"`, `"message"`, `"ack"`. | | `snr_db` | number \| null | | | `rssi_dbm` | number \| null | | | `position` | object \| null | The `position` shape. | | `path` | array of strings \| null | The relay chain the packet arrived by, one lowercase-hex hash per hop, in propagation order. `[]` is a direct reception; `null` means the packet class carries no path at all. This is the one place `path` is an array — it is a *received* relay chain rather than a composed spec. | **This is the one place a whole block appears on one face and not the other, and it is deliberate.** The per-node summary is a convenience for a person watching a capture end. 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 stream would cost every consumer a discriminator it would never otherwise need. So the aggregate stays on the plain face, and JSON hands you what it was derived from: ```console $ meshterm monitor --seconds 60 --json > capture.ndjson $ jq -s -c 'group_by(.node.hash)[] | {hash: .[0].node.hash, name: (map(.node.name) | map(select(. != null)) | first), packets: length, best_snr_db: (map(.snr_db) | max)}' capture.ndjson {"hash":"a1b2c3d4","name":"Yagi-Repeater","packets":32,"best_snr_db":12.8} {"hash":"b2c3d4e5","name":"Local-Repeater","packets":32,"best_snr_db":14.9} {"hash":"c3d4e5f6","name":"Observer-Bot","packets":36,"best_snr_db":12.2} {"hash":"d4e5f6a7","name":"Alice","packets":36,"best_snr_db":13.0} ``` The "monitoring for 2s" announcement and the closing count go to stderr on both faces. 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`). | The listing is `CATEGORY WIDTH SCORE UNIT RECORDED VERSION ROUTE`. `SCORE` is a bare number and `UNIT` is its own column, so the scores in a column are comparable; a score prefixed `>=` is a **lower bound**, meaning some hop on that walk has no known position and 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. ```console $ meshterm records $ echo $? 5 ``` Records are discovered by the menu's walking, so a database that has only ever been driven from the command line has none — which is what the simulator these examples run against prints, honestly. **`--json`** is an array carrying two facts the columns cannot hold. | Field | Type | Meaning | | --- | --- | --- | | `category` | string | The discipline id, as `--category` takes it. | | `hash_bytes` | integer | The per-hop hash width the walk was transmitted at — the plain `WIDTH`, under the contract's word for the concept. | | `score` | number | The bare score, unqualified. | | `unit` | string | `"km"` \| `"nodes"` \| `"dB"` \| `"km²"`. | | `lower_bound` | boolean | **JSON only.** `true` where the score is a floor — the plain face's `>=` prefix, extracted, so a consumer comparing scores need not string-match a qualifier off the front of its own data. | | `recorded_at` | timestamp | | | `version` | string | The MeshTerm version that discovered it. | | `path` | string | **JSON only.** The transmitted spec, comma-separated hex — what a caller re-walks with. It has no column because a route line already runs off the right. | | `route` | array | The `route` shape, hop-aligned with `path`. | Returns `5` and `[]` when no records match. --- ### Messaging #### `meshterm chat` `meshterm chat` on its own is not interactive — it prints this group's help and exits `2`. The live transcript is a menu screen; from the command line, pick a subcommand. | 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. `-s 0` (the default) runs until Ctrl-C. | 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 DIR PEER SNR_DB TEXT now out Alice - on my way now out Alice - see you there $ meshterm --absolute chat history --to Alice TIME DIR PEER SNR_DB TEXT 2026-09-08T04:27:37-04:00 out Alice - on my way 2026-09-08T04:27:38-04:00 out Alice - see you there ``` `DIR` is `in` or `out`; `PEER` is always the *other* party. `TEXT` is last and is the rest of the line — a message body is the one field that can hold absolutely anything, so nothing may follow it. ```console $ meshterm chat list CONVERSATION KIND UNREAD LAST LAST_TEXT Public channel 0 never - Yagi-Repeater direct 0 never - Local-Repeater direct 0 never - Observer-Bot direct 0 never - Alice direct 0 never - ``` ```console $ meshterm chat listen -s 3 TIME PEER SNR_DB TEXT 2026-09-08T04:30:44-04:00 b2c3d4e5 +4.9 hello from Local-Repeater #1 2026-09-08T04:30:44-04:00 c3d4e5f6 -0.6 hello from Observer-Bot #2 2026-09-08T04:30:45-04:00 d4e5f6a7 +3.9 hello from Alice #3 ``` `listen` pins its lanes and stamps each row with an instant, the same shape `monitor`'s stream takes and for the same reason. `--debug` routes the message-pull logging to stderr, for working out whether messages are being pulled from the companion at all. **`--json`.** `send` reports what it sent and to whom: ```console $ meshterm chat send "see you there" --to Alice --json {"kind":"direct","node":{"name":"Alice","key":"d4e5f6a700000000000000000000000000000000000000000000000000000000","hash":"d4e5f6a7","type":"node","self":false},"channel":null,"sent":true,"acked":true} $ meshterm chat send "net in 5" --channel 0 --json {"kind":"channel","node":null,"channel":{"slot":0,"name":"#0","public":true,"hash":null},"sent":true,"acked":null} ``` `kind` is `"direct"` \| `"channel"` and says which of `node` / `channel` is populated. `acked` is `null` on a channel, not `false`: there is no acknowledgement to have, and that is exactly what one absence token is for — it is also why the plain face prints nothing there, having no way to say it. `history` is an array, oldest first: ```json {"created_at":"2026-09-08T08:27:37Z","direction":"out","node":{"name":"Alice","key":null,"hash":"d4e5f6a7","type":null,"self":false},"channel":null,"snr_db":null,"text":"on my way","acked":true} ``` | Field | Type | Meaning | | --- | --- | --- | | `created_at` | timestamp | | | `direction` | string | `"in"` \| `"out"`. | | `node` | object \| null | The *other* party, for a direct conversation. | | `channel` | object \| null | The `channel` shape, for a channel conversation. | | `snr_db` | number \| null | Reception SNR; `null` on an outbound message. | | `text` | string | The body, raw and unescaped. | | `acked` | boolean \| null | Delivery state of an outbound direct message; `null` inbound and on a channel. | `list` is an array of conversations, channels first, each carrying `kind`, `channel`, `node`, `unread`, `last_message_at` and `last_text`. `listen` is a stream of the same row shape as `history`, with **`received_at`** in place of `created_at`: ```json {"received_at":"2026-09-08T08:30:47Z","node":{"name":null,"key":null,"hash":"b2c3d4e5","type":null,"self":false},"channel":null,"snr_db":4.9,"text":"hello from Local-Repeater #1","direction":"in","acked":null} ``` An inbound message carries only the sender's key prefix, so its `node` is thinner than the same node in `contacts` — `name` is whatever the resolver could find and `key` is `null`. Join on `hash`. `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. | `list` prints `SLOT NAME TYPE HASH`, one record per configured slot, and returns `5` when the device has none. (The simulator these examples run against has none, so there is no captured listing to show; each `--mock` invocation opens a fresh radio, and a slot written by one run is not there for the next.) ```console $ meshterm channels add 1 brigade meshcore://channel/add?name=brigade&secret=2c70ca416304d520b60a28d9db35d6b3 ``` `add` and `share` print the `meshcore://` URI alone, because the caller asked to be given something to pass on. `join` and `import` print nothing: those two were *handed* the key, and reading it back to them is not an answer. (The menu draws a QR code beside the link; 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. **`--json`.** `list` is an array whose rows are **flat** — this is the one listing whose record *is* the shared shape rather than carrying one, so a row is `{"slot":0,"name":"Public","type":"public","hash":"11"}`, with `type` the plain column's word rather than the `channel` shape's `public` boolean. Nesting a `channel` key under every row would make `jq '.[].channel.name'` out of a listing whose every column is already the channel. All four writing subcommands emit the same document, each knowing the key it wrote: ```console $ meshterm channels add 2 "#news" --json {"channel":{"slot":2,"name":"#news","public":true,"hash":"03"},"secret":"ecadb1a7d803db8958bea1302ca6e8be","url":"meshcore://channel/add?name=%23news&secret=ecadb1a7d803db8958bea1302ca6e8be"} $ meshterm channels clear 1 --yes --json {"slot":1,"cleared":false} $ meshterm channels share 1 --json {"channel":null,"secret":null,"url":null} ``` `share` on an empty slot, and `clear` on one already empty, are exit `5` with the nulls and `"cleared":false` — nothing to report, not a failure. #### `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 "call me when you land" --at 18:30 id 1 $ meshterm courier list ID STATE NODE SCHEDULED FINISHED TEXT 1 waiting Alice 2026-09-08T18:30:00-04:00 - call me when you land ``` `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. `SCHEDULED` and `FINISHED` stay **absolute even without `--absolute`**. An appointment is one of the places the instant *is* the fact: "18:30" is what you asked for and what will happen, where "in 14h" is a restatement that goes stale while you read it. The *sending* is done by a running interactive session's courier; a queued message sits in the outbox until one runs. `courier send ID` is the way to force an attempt from a script. **`--json`.** ```console $ meshterm courier list --json [{"id":1,"state":"waiting","node":{"name":"Alice","key":null,"hash":"d4e5f6a70000","type":null,"self":false},"scheduled_at":"2026-09-08T22:30:00Z","finished_at":null,"text":"call me when you land"}] $ meshterm courier cancel 1 --json {"id":1,"cancelled":true} $ meshterm courier clear --json {"cleared":0} ``` `state` is `"waiting"` \| `"delivered"` \| `"gave up"`. `queue` answers with `id`, `node`, `scheduled_at` and `text`; `send ID` answers `{"id":…,"outcome":…}`, where `outcome` is one of `delivered`, `no ack`, `gave up`, `unknown contact`, `busy`, `gone` — kept verbatim, spaces and all, so the two faces say the same word. `list` returns `5` on an empty outbox; `cancel` and `clear` return `5` when there was nothing to act on. A contact the device does not know, a contact with no key to address, and an `--at` that is not a clock time are all **usage errors** (`2`): the argument is wrong, nothing was queued, and nothing went out over the air. --- ### 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. | | `--path SPEC` | Force a route: comma-separated contact names and/or hex hashes, mixed freely (`3d,f2,3d`). Omit it and the device routes. | `--target` is required, so `meshterm trace --path …` alone is a usage error — the target-free form is [`trace-path`](#meshterm-trace-path). ```console $ meshterm trace --target Alice --path "a1,d4" target Alice path a1,d4 success yes hops 3 min_snr_db +2.0 rtt_ms 123 route MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4) → MockCompanion (00) HOP FROM TO SNR_DB 0 00 a1 +6.0 1 a1 d4 +2.0 2 d4 00 +6.0 ``` Two blocks. The first is what the walk did, with the drawn `route` among the facts and the typed `path` beside it — `path` reads `auto` when the device routed, and every other value there is the comma-separated hex you can paste straight back into `--path`. The second is a record per hop, naming its two ends by **hash** rather than repeating the names: the route line above is where the names are, and the hash is what joins the two blocks. 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 | | --- | --- | | `--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. **`--json`** is one object for both commands, the facts at the top level and the hop table under `edges`. | Field | Type | Meaning | | --- | --- | --- | | `target` | string \| null | The label the trace was addressed to. `null` for a path walk. | | `path` | string \| null | The forced spec as hex, or `null` when the device routed (the plain face's `auto`). | | `success` | boolean | Whether a reply came home. | | `hops` | integer \| null | | | `min_snr_db` | number \| null | The bottleneck hop's SNR. | | `rtt_ms` | number \| null | | | `tx_dbm` | integer \| null | TX power in effect when the trace ran, if known. **No plain column** — the facts block has no room. | | `hash_bytes` | integer \| null | The per-hop hash width the walk addressed nodes at. Present when a path was forced. | | `route` | array \| null | The `route` shape. `null` when nothing came home. | | `edges` | array | The `edge` shape, one per hop, in walk order. `[]` when nothing came home. | ```console $ meshterm trace-path --path "a1,d4" --json | jq -r '.edges | min_by(.snr_db) | "\(.from.hash) -> \(.to.hash) \(.snr_db) dB"' a1 -> d4 2.0 dB ``` #### `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 | | --- | --- | | `--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 2 --password admin tuning_node Yagi-Repeater target Alice path a1b2c3d4,d4e5f6a7 optimal_tx_dbm 19 target_snr_db +8.7 reliability 1.00 previous_tx_dbm 20 applied yes TX_DBM TARGET_SNR_DB SUCCESSES SAMPLES 18 +6.6 2 2 19 +8.7 4 4 20 +9.1 2 2 21 +7.6 2 2 24 +4.0 2 2 27 -9.9 2 2 28 -15.1 1 2 ``` 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. **`--json`** puts the levels under `levels` and upgrades the two bare names to `node` objects, which matters most here: the two nodes are told apart by nothing else on the line. ```json {"tuning_node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"target":{"name":"Alice","key":"d4e5f6a700000000000000000000000000000000000000000000000000000000","hash":"d4e5f6a7","type":"node","self":false},"path":"a1b2c3d4,d4e5f6a7","optimal_tx_dbm":19,"target_snr_db":8.7,"reliability":1.0,"previous_tx_dbm":20,"applied":true,"levels":[{"tx_dbm":18,"target_snr_db":6.6,"successes":2,"samples":2},{"tx_dbm":19,"target_snr_db":8.7,"successes":4,"samples":4},{"tx_dbm":20,"target_snr_db":9.1,"successes":2,"samples":2},{"tx_dbm":21,"target_snr_db":7.6,"successes":2,"samples":2},{"tx_dbm":24,"target_snr_db":4.0,"successes":2,"samples":2},{"tx_dbm":27,"target_snr_db":-9.9,"successes":2,"samples":2},{"tx_dbm":28,"target_snr_db":-15.1,"successes":1,"samples":2}]} ``` `reliability` is a fraction in `[0, 1]`, not a formatted `"1.00"`. `levels` is ordered by `tx_dbm` ascending, as the plain table is; the menu's star on the winning row has no column here, because `optimal_tx_dbm` names it. With no password available and nowhere to ask for one, this is a **usage error** (`2`) — nothing was transmitted. A password that is refused is a device error (`4`), and the saved one is cleared. 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 --password admin > Yagi-Repeater ``` The reply is printed as the node sent it — its own text, and the whole answer, echoed prompt and all. 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` — the node answered, and answered no. An **absent** password, and a node name no contact matches, are usage errors (`2`): nothing was transmitted in either case, which is the line `3` and `4` are drawn on. **`--json`:** ```json {"node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"command":"get name","reply":"> Yagi-Repeater"} ``` `reply` is the node's own text, whole and verbatim — raw, unescaped, multi-line where the node sent multiple lines. It is **not** parsed: MeshTerm does not know the remote node's CLI grammar, and a document that pretended to would be inventing structure. `reply` is `null` on the exit-`5` no-answer path. --- ### 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. With no subcommand it runs `show`. | Subcommand | What it does | | --- | --- | | `show` | Every preference, its value, its built-in default, and what it does. | | `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 DESCRIPTION trace_cooldown_s 5 5 Wait this long before transmitting again flood_advert_cooldown_s 60 60 Extra wait before another mesh-wide advert direct_message_soft_retries 0 0 Times to resend a message that gets no reply tx_opt_min 18 18 Weakest power it will try tx_opt_max 28 28 Strongest power it will try map_view_fraction 0.5 0.5 Share of nodes to fit on screen; 1 shows them all history_days 365 365 Older ones are deleted; 0 keeps everything cli_time_format relative relative Whether the command line prints ages or timestamps ``` **`DESCRIPTION` is back**, because a key's name is not its meaning and the alternative was opening the menu to find out what one does. 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 `preferences.toml` in [MeshTerm's state directory](#where-meshterm-keeps-its-state), which lists only what you have changed; delete a line and the default takes over again. **`--json`** is an array of `setting` objects carrying `default` and `overridden`: ```console $ meshterm preferences get trace_cooldown_s --json {"key":"trace_cooldown_s","value":5.0,"type":"float","label":null,"redacted":false,"default":5.0,"overridden":false} ``` `overridden` is what the plain face makes the reader derive by comparing two columns. `DESCRIPTION` has no JSON counterpart — it is prose for a person, and a consumer that wanted it would be rendering a settings screen. A boolean prints `on`/`off` plain and is `true`/`false` here, which is the one type whose round-trip through `preferences set` needs a mapping; every other `.value | tostring` goes straight back in. `set` and `reset --yes` answer `{"changes":…,"applied":[…],"path":…}`, `path` being the `preferences.toml` that was written. #### `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 ``` These four are the one place the CLI wraps, at 72 cells. The menu draws a QR code beside each link; the scripted face prints the link alone. **`--json`:** ```json {"page":"discord","title":"Join Discord","version":"0.3.7","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]} ``` | Field | Type | Meaning | | --- | --- | --- | | `page` | string | `"about"` \| `"about-author"` \| `"discord"` \| `"support"`. | | `title` | string | The page's own title. | | `version` | string | The version the page's `{version}` placeholder resolved to. | | `text` | string | The whole page as the plain face prints it, newlines and all, raw. | | `links` | array of strings | Every URL the page carries, in document order. | `links` is the one machine-actionable thing on a prose page, and the one thing the menu bothers to draw a QR of. `text` is the page's plain rendering, not a fold of the markdown into nested JSON — a structured markdown tree would be a second rendering rather than data, and nothing would consume it. #### `meshterm diagnostics` Everything a bug report opens with, in one block: which MeshTerm, on which host, in which terminal, talking to which radio, over how much stored history. It is the answer to the three or four questions that otherwise get asked one at a time in an issue thread, and the person who hit the bug is usually the one least able to answer them — half of it is resolved at boot and shown on no screen. ```console $ meshterm diagnostics meshterm 0.3.7 install frozen os Windows 11 os_build 10.0.26100 arch AMD64 python 3.13.1 terminal Windows Terminal term - colorterm truecolor terminal_size 120x30 over_ssh no platform regular icons yes icons_source windows-terminal font Cascadia Mono PL (windows-terminal) powerline full (font:windows-terminal) connected yes transport ble port - device_role client device_model Heltec V3 firmware v1.7.1 1c3f9a2 radio_freq_mhz 906.8750 radio_bw_khz 250.00 radio_sf 10 radio_cr 5 tx_power_dbm 20 error - config_dir C:\Users\jp\.meshterm db_size_kb 41280 log_level WARNING log_size_kb 96 runs_failed 3 first_heard 8mo last_heard now TABLE ROWS observations 418203 runs 912 traces 47 PREFERENCE VALUE log_level DEBUG map_style terrain ``` The seven groups are the page's seven sections — build, host, terminal, radio, storage, stored rows, changed preferences. The command line shows them as blank-line-separated records, because that face is a stream to grep; the page and the saved file show them as markdown headings, because that one is a document to read. **The radio is asked but never required.** A report about a companion that will not connect is exactly the report most worth filing, so a failure to reach it becomes `connected no` with the radio's own words in `error`, and every other fact still arrives. **Only overridden preferences are listed.** The defaults are in the source and identical for everyone; the two the reporter changed are the two that can explain anything. **The mesh is described in aggregate and only in aggregate** — a row count per table, and the span of time the observations cover. The counts come off the schema rather than a list written down somewhere, so a table added later starts being reported the day it lands. **Nothing in the block is private.** No pairing PIN, no admin password, no channel secret, no private key, no position, and no contact's name or key. That is a property of the feature and not a habit: the block is designed to be pasted in public by somebody who has not read it, and `tests/test_diagnostics.py` plants real secrets in every store that holds one and fails if any of them — or a field merely *named* like one — reaches either face. Under `--json`, the five fact blocks merge into one flat object and the two listings keep a key of their own: ```json {"meshterm":"0.3.7","install":"frozen","os":"Windows 11","…":"…","tables":[{"table":"observations","rows":418203}],"preferences":[{"preference":"log_level","value":"DEBUG"}]} ``` **`--out PATH` writes it to a file instead of printing it**, and the answer becomes the path — the same trade `config export-key --out` makes, since a caller who asked for a file wants to be told where it is rather than handed the contents they just redirected into it. ```console $ meshterm diagnostics --out meshterm-diagnostics.md ✓ diagnostics written — attach it to the report. /home/jp/meshterm-diagnostics.md ``` **The file is markdown**, whichever face the run was printing, because an issue tracker renders markdown as written — no fence, no apology. Every value is a code span, so a Windows path's backslashes and a firmware error's asterisks arrive as themselves rather than as markdown. Redirection still works and is not replaced; `--out` is what gives you the document rather than the record stream. In the menu this is the **Diagnostics** page under *This app*, and it is one of the app's **written pages** — the same markdown renderer behind the About pages, drawing the same document the file holds. That buys what a long block most needs: its `##` headings are landmarks, so each pins to the top row while its own section scrolls under it and `^PgUp`/`^PgDn` step section by section. `s` saves, advertised in the footer hint on a desktop and on the lane's free **F3** slot on the PicoCalc — never in the page's body, which belongs to the report. It writes `meshterm-diagnostics.md` into the config directory beside the log, and answers in a popup naming the path. A write that fails says so in the same popup, in red, and leaves the page readable. #### `meshterm platform` The narrow sibling of `diagnostics`: which UI flavour a given invocation resolves to, and why, without asking the radio anything. ```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. Under `--json` the same keys, with `icons` a boolean: ```json {"platform":"regular","platform_source":"default","flag":null,"env":null,"device_tree_model":null,"icons":true,"icons_source":"no-console"} ``` #### `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. It is also the one command that **refuses `--json`**, as a usage error (exit `2`). There is no data face behind a colour card, and a document of it would be a lie or an empty gesture. --- ## Recipes Structure goes through `--json` and `jq`; the plain face is for looking at. **Every repeater's key.** ```bash meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key' ``` **Name and last-heard, tab-separated**, for a spreadsheet or `column -t`: ```bash meshterm contacts --json | jq -r '.[] | [.node.name, .heard_at] | @tsv' ``` **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 ``` **The bottleneck hop of a walk**, which is the number a route argument is usually about: ```bash meshterm trace-path --path "a1,d4" --json \ | jq -r '.edges | min_by(.snr_db) | "\(.from.hash) -> \(.to.hash) \(.snr_db) dB"' ``` **A route as one line**, our own node included, the way the plain face draws it: ```bash meshterm trace-path --path "a1,d4,a1" --json \ | jq -r '.route | map(.name // .hash) | join(" -> ")' ``` **Capture a window of traffic and aggregate it yourself.** The plain face's summary block has no JSON counterpart on purpose; the records it was computed from are all there. ```bash meshterm monitor --seconds 300 --json > capture.ndjson jq -s -c 'group_by(.node.hash)[] | {hash: .[0].node.hash, name: (map(.node.name) | map(select(. != null)) | first), packets: length, best_snr_db: (map(.snr_db) | max)}' capture.ndjson ``` **One value into a variable.** `get` prints the bare value because you named the key: ```bash sf=$(meshterm config get radio_sf) [ "$sf" -lt 9 ] && meshterm config set radio_sf 9 ``` **Nightly config archive**, keeping the path the command actually wrote: ```bash out=$(meshterm config backup "$HOME/mesh/$(date +%F).toml") && echo "archived $out" ``` **Queue for someone who is offline**, then chase it later: ```bash id=$(meshterm courier queue Alice "call me" --json | jq -r .id) # ... later ... meshterm courier send "$id" ``` **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 ``` **Let MeshTerm advertise, rather than cron.** Background adverts are MeshTerm's own job, not the firmware's, and the cadence is a policy you set once: ```bash meshterm config advert-cadence 3 # zero-hop, to neighbours, every 3 h meshterm config advert-cadence 6 --flood # mesh-wide, every 6 h ``` A running MeshTerm session sends them on that schedule. **Do not put `config advert --flood` in a tight cron slot**: the cadence picker's floor for a flood is 3 h, and a `*/30` line would be six times tighter than the app will ever let a person choose. A repeater that decides you are bursting can blacklist you. **Cron-friendly quiet.** `--quiet` suppresses console logging entirely, leaving only the command's own output and any error line. Cron's `PATH` is nearly empty, so give it the absolute path: ```cron 0 * * * * /usr/local/bin/meshterm --quiet --profile yagi contacts --json > /var/log/mesh/contacts.json ``` --- ## 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 — and every one of them answers to `--json`.