preferences.yaml becomes preferences.toml: config.toml and the device-config backups were already TOML, and tomli/tomli-w already dependencies, so PyYAML existed for this one file and is dropped. The file keeps its shape - flat keys, only the overrides, grouped under comment headings with each entry's help and default - and one name, PREFERENCES_FILENAME, for both call sites. TOML refuses a whole document over one bad line, so a refused file is read again a line at a time: a valid line keeps its typed value, a refused one hands its right-hand side to parse_value as text. One broken hand edit still costs only its line, and a word left unquoted (log_level = DEBUG) still reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1641 lines
76 KiB
Markdown
1641 lines
76 KiB
Markdown
# 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 <version>` 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
|
|
`<something>_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 <PORT> or --ble <ADDRESS> (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.2","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 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. 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`.
|