30 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 2a73706cab 0.9.0 is rebuilt from today's main, so it carries today's fixes
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
GitHub release / put the version on meshterm.net (push) Canceled after 0s
JP's call: rebuild 0.9.0 rather than wait for 0.9.1. It has never been public, so the
release is recut from main: Python 3.14 inside the downloads, the corrected written
pages inside the app, companion and room server as the only names for those types, and
the legend that fits a PicoCalc.

[Unreleased] folds into the 0.9.0 entry. On a first release there is nothing for those
changes to have changed from, so they are not listed as changes; the entry gains the one
fact a reader needs, that pipx works on Python 3.10 to 3.14.

The release workflow writes the install half of the notes from its own template, which
still had the two lines fixed by hand on the first 0.9.0 page: "every Mac since 2020",
and "every file below" for a SHA256SUMS that lists only the binaries. Both are fixed at
the source. The changelog extraction also stops carrying the link definitions at the
foot of CHANGELOG.md into the notes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:38:30 -04:00
Jean-Pierre MartineauandClaude Opus 5 59eccba0a0 Every build runs on 3.14
JP builds with 3.14 now, so the workflows do too. The lint job moves from 3.10, the
packaging build job and the release's site rebuild and PyPI build from 3.12, and the
installers from 3.10. The test matrix is untouched: it still runs every version from
3.10 up, because 3.10 is still the floor.

The installers' comment said the oldest Python was chosen on purpose, for reach. The
reach that matters on Linux comes from glibc, and that comes from the runner, so the
Linux legs stay on ubuntu-22.04 and the comment now says that. The binaries carry 3.14
from the next release; 0.9.0's were built on 3.10.

The changelog gains the 3.14 entry JP wrote in another session, plus a line for the
downloads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:14:52 -04:00
Jean-Pierre MartineauandClaude Opus 5 5b7481af8a Python 3.14 is supported, and CI proves it
JP moved to 3.14. Every dependency installs on it -- the Windows Bluetooth packages
included -- and the suite passes: 2705 passed, 1 skipped, on 3.14.7 as on 3.10.

- pyproject gains the 3.14 classifier. requires-python stays >=3.10: the uConsole on
  bookworm runs 3.11, and a pip or pipx install on an older system should keep working.
- CI keeps its shape, which is Linux over the whole range and the other two runners at
  its ends. Linux gains a 3.14 leg, and 3.14 replaces 3.13 as the top end on Windows and
  macOS.
- Three tests reached a loop through asyncio.get_event_loop_policy(), which 3.14
  deprecates and 3.16 removes, and never closed the loop they made. The two countdown
  tests now make one with new_event_loop() and close it; the sweep test uses
  asyncio.run(), which does both. On 3.14 the suite is back to no warnings.

The standalone installers still bundle 3.10; moving their build to 3.14 is for 0.9.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:44:37 -04:00
Jean-Pierre MartineauandClaude Opus 5 a446da2683 A type-1 node is a companion, everywhere
0.9.0 called the same advert type three things: `companion` in info, `client` in
diagnostics, and `node` in contacts and every other screen that names a node's type.
Room servers were `room server` in two places and `room` in the rest. JP picked
companion. It is the ecosystem's word -- companion firmware, companion apps -- and the
one MeshTerm's own docs already used most. "client" now belongs to MeshTerm itself, which
the one-liner calls a MeshCore client, and "node" is the lexicon's umbrella for every type,
so a TYPE column that says "node" for one of them tells a reader nothing.

There is now one table, NODE_TYPE_LABELS, and one helper, node_type_label(), which gives
"type N" for an advert type MeshTerm does not know. info and diagnostics lose the two
role tables that had drifted apart, and the packet viewer its own copy of the fallback.

The longer names made the node-type legend 59 cells wide, which the PicoCalc's 53 would
have cropped, cutting "sensor" off. node_type_legend() now takes the width it has and
breaks between entries, never inside one, so it stays on one line on a desktop and takes
two on the PicoCalc. The four screens that draw it pass their width and take its lines.

This changes --json output, so it goes under [Unreleased] for 0.9.1. docs/cli.md's samples,
the value lists and CLAUDE.md's lexicon say companion and room server. Two tests pin the
names and the PicoCalc legend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:45:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 43973b9608 The navigation survey goes
It read as working notes between JP and the tool that wrote it -- "Question for JP"
blocks, and calls that were his to make -- rather than as a record for a stranger. The UI
consistency survey stays, and docs/README.md and that survey no longer point at the one
that went.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:25:43 -04:00
Jean-Pierre MartineauandClaude Opus 5 1ecbe9ee37 The shared Claude settings allow the gates, and nothing personal
.claude/settings.json is tracked, so it goes public with the repository and applies to
anyone who opens it in Claude Code. It carried JP's own working allowlist -- one-off test
invocations, a `python -c` wildcard, a console beep -- and "defaultMode": "dontAsk",
which would have quietly changed how Claude Code behaves for a contributor.

All of that moved to the gitignored settings.local.json on JP's machine, where Claude Code
still reads it, so nothing changes for him. What stays shared is what a contributor
actually needs to be allowed without asking: the three gates CONTRIBUTING promises,
pytest, ruff check and ruff format.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:25:43 -04:00
Jean-Pierre MartineauandClaude Opus 5 6a060a0451 One line says what MeshTerm is, everywhere
Four different one-liners were in circulation: the repository's "interactive, scriptable
CLI toolkit", the README's "full-screen terminal companion", the About page's "client that
lives in your terminal", and the package summary's "TUI-based client ... companion
connection". JP picked one:

  A full-featured TUI MeshCore client for your terminal

It now opens the README, the About page, the package summary, meshterm.__doc__ and the
`meshterm --help` header, which cli.py already says must stay the same as the summary.
The summary's second sentence loses its grammar slip on the way: "Connects to a companion
over USB, Bluetooth or TCP." CLAUDE.md opens with the same line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:37:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 5fbba275f6 The manuals describe what the code and scripts do today
Every manual was read against the code, the scripts and the upstream sources, and the
samples in docs/cli.md were recaptured under --mock where they had drifted.

The one a newcomer would hit first: config.example.toml set default_profile to a COM5
profile. Copied verbatim, as configuration.md says to, it broke every device command on a
machine with no COM5. default_profile is commented out now, so the copied file leaves
discovery working. default_tx_power goes from both, because nothing reads it.

docs/cli.md: config show has 22 settings and preferences show 21, not 20 and 8; monitor's
blocks have no blank line between them; the diagnostics sample is labelled as a real-radio
example with absolute instants and every table; an unknown key exits 1; devices, role and
records describe the values they really produce; no maintainer paths; and the "used to"
passages describe the present, since a first-time reader never saw the past.

picocalc.md: the Calculinux image carries a build stamp, so the gunzip and dd lines take
a wildcard; the front SD slot stays empty on first boot; masking wifi-kick is what stops
it, and time-sync is disabled with it; updates run the scripts from the pulled checkout
with TMPDIR set; the tested list names only what a test checks; git ships in the base
image; a bare meshterm shows the splash, and -p picocalc skips it.

uconsole.md: an AIO v2 needs MESHCORE_EN_PINS=27 whichever runtime is installed; the
bookworm paragraph states what upstream actually ships; the bridge shares the key but
keeps its own contacts and radio settings; the defaults are the US/Canada preset; Step 7
downloads what it installs; updating ends with a restart; no claim of test coverage the
suite does not have.

terminals.md, features.md, hardware.md, cookbook.md, configuration.md and the scripts
READMEs get the smaller corrections their audits found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:28:24 -04:00
Jean-Pierre MartineauandClaude Opus 5 9b2b4d784b The front door says only what is true
A sweep of the README, CONTRIBUTING and the 0.9.0 changelog entry against the running app.

- Not every screen has a subcommand: the map, dashboard, live feed, watchtower, mesh walk
  and time machine live in the menu only. "Most screens".
- Not every command speaks --json: specimen refuses it. "Nearly every".
- On Windows the download is meshterm-windows-x64.exe, not a file called meshterm.
- Apple silicon is M1 and later; Intel Macs were sold until 2023.
- The global options list gains --ble-pin, --absolute and --platform, which it missed.
- The meshcore library speaks TCP too.
- The standalone builds are single files, so their licence texts are bundled inside, not
  in an archive to extract.
- 64-bit ARM covers the uConsole; the PicoCalc is 32-bit and installs from a checkout.
- --mock keeps its own history database; the caches it shares are not "none of it".

The README, CONTRIBUTING and the PR template disagreed on when a pull request needs an
issue first. All three now say what CONTRIBUTING said: small fixes can come straight in,
anything bigger waits for a yes. CONTRIBUTING drops "or discussion", since Discussions
are off, and points bug reporters at the form, the diagnostics file and Discord. The bug
form link goes to the form rather than its YAML.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:28:24 -04:00
Jean-Pierre MartineauandClaude Opus 5 5c2933595e The written pages lose their typos
About, Author and Support are drawn in the app and copied onto meshterm.net word for word,
so a typo in them is published twice. Fixed: through, knows, built, spent, software,
mentioned, "want to pitch in", "more than welcome", a doubled "the", PayPal's capital P,
3.3 V and Wi-Fi.

A few sentences said something untrue. The radio is recorded while MeshTerm runs and is
connected, not from the moment the radio is switched on. One 18650 runs a PicoCalc and a
second roughly doubles it, as docs/picocalc.md says, where "two batteries, all day" had no
source. The maker is ClockworkPi, and the uConsole bridge serves the AIO v1 and v2. The
support page says repeaters, the mesh's own word, and names the Author page rather than
"the about pages". The AI sentence on the Author page had lost its object; it reads as
one sentence again.

Support opens with a whole sentence, because its first line becomes the page's meta
description on the site, and "MeshTerm is free." unfurled as three words. The licence URL
on About is printed once rather than twice.

The binary that shipped as 0.9.0 keeps the old text until 0.9.1; the site picks this up on
its next build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:28:24 -04:00
Jean-Pierre MartineauandClaude Opus 5 9521b28b9d The bug form lets you attach what it asks you to attach
Two things on the new-issue page would have gone wrong for the first stranger to use it.

The second contact link was named `No GitHub account? Report it in the #bugs forum
instead`, unquoted. YAML reads ` #` as the start of a comment, so GitHub would have shown
"No GitHub account? Report it in the" and nothing after it. The name is quoted now.

The diagnostics and log fields said to attach a file, and both had `render:` set. GitHub
turns attachments off on any textarea that renders, so the one required field asked for
something it would not accept. Neither field renders now; the diagnostics block is
markdown and reads fine as is, and the log field says to fence pasted lines.

Also: the "not recognised? you are on 0.3.7" paragraph goes, since nobody outside ever ran
0.3.x; the Diagnostics page's save key is named for the PicoCalc too; Log detail is
spelled the way the Preferences page shows it and says it needs a restart; the Cardputer
leaves the placeholder, since nothing else mentions one. The pull-request template now
states the same rule as CONTRIBUTING, and FUNDING points at Patreon's canonical /cw/ URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:28:24 -04:00
Jean-Pierre MartineauandClaude Opus 5 37955639c6 MeshTerm 0.9.0
The first public release. The version goes to 0.9.0 and the changelog carries one
entry, which the release job lifts verbatim as the release notes.

[Unreleased] had refilled since the last fold with one change -- --mock keeping its
invented mesh out of your real history. On a first release there is no previous
behaviour for it to have changed from, so it is folded into the entry's existing --mock
sentence, in that entry's voice, and [Unreleased] is empty again.

Reading the notes as a newcomer would, three claims were not true, and each was checked
against the running app rather than argued:

- `meshterm map` is not a command. It was the first example a stranger would type, and
  it answers "No such command". The map is a picture, so it lives in the menu; the
  example is `meshterm info` now.
- The Time Machine does not wind the map back. It is braille charts of recorded history
  over a day, a week, a month or all of it -- no map in it at all.
- Only the PicoCalc gets the handheld layout. It is chosen solely by the device tree
  reading "Luckfox Lyra"; the uConsole has no detection and runs the desktop layout on
  its 1280x720 screen, which has the room for it.

The manual's three sample outputs move from 0.3.7 to 0.9.0 with it, so the version a
reader sees in docs/cli.md matches the one they downloaded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 04:01:28 -04:00
Jean-Pierre MartineauandClaude Opus 5 f962baec58 Two files the formatter wanted, so CI is green again
2dbae7a went red on lint, and it was never ruff check -- that passed. It was
ruff format --check, on two files from the last few commits: a nested def with no blank
line above it in the session, and a sum() in test_logo that fits on one line once
joined. Both pure layout; neither touches a hand-aligned table, and ruff check stays
clean after, which is the thing this repository has been bitten by before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 04:01:28 -04:00
Jean-Pierre MartineauandClaude Opus 5 2dbae7af77 The splash slides the whole row, because the whole row is what gets cut
The device rows pinned their fixed lanes and slid only the hardware model
underneath them. The reasoning was borrowed from the Trophy case, where the
rank, date and score always fit and only the walk overflows -- there the lanes
really are the reader's place in a long list, and sliding them off would cost
the row its identity to read something the columns already told you.

This list inverts that. On a narrow terminal it is the device name and the
address that get cut, so pinning them pinned the truncation in place: the one
thing the arrow keys could not reach was the text somebody most wanted to
finish reading. A name is not a landmark when it is the thing being elided.

So no lane is pinned, and the row slides whole. That needed the scroll to be
asked for outright, because what normally turns it on is a row pinning a head
block, and none of these do any more -- `hscroll` travels from the picker
through the splash surface to the list, the same path `live` took.

The test that covered the old behaviour is rewritten rather than removed. It
was a fair test of a decision that has changed, and the argument for the new
one belongs where the old one was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 03:46:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 551a86f47a The splash surface learns the argument the session already knew
`live` went onto `TuiSession.select_startup` and nowhere else, but the picker
calls the *surface* -- `ui.select_startup`, which delegates. So MeshTerm crashed
on the startup path with a `TypeError` about an unexpected keyword, on the first
screen, every time.

The whole test suite passed through it. Every fake Ui in `test_tui.py` takes
`**kwargs`, which is the reasonable thing for a double to do and is exactly what
hid this: the picker's new argument was absorbed by a stand-in that would accept
anything, while the real surface would accept nothing of the sort. Five tests
written specifically for the rescan ran green against code that could not start.

So the double stops being infinitely accommodating. The test added here binds
what the picker sends against the real `Ui.select_startup` signature, which
raises on an unknown keyword exactly where a running MeshTerm does. It fails
against this commit's parent and passes against this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 03:37:03 -04:00
Jean-Pierre MartineauandClaude Opus 5 f9e9271b29 The connect screen keeps looking, instead of looking once
It scanned on the way in and never again, so a radio plugged in ten seconds
late was invisible until MeshTerm was restarted. The worst version of that is
the first run: the screen says no companion devices detected and offers a
network address typed by hand, or quitting. Going to fetch the cable is the
obvious thing to do and it was the one thing that did not work.

The three transports want three different answers, and the reason is cost.
Enumerating serial ports is a SetupAPI walk -- not free, but cheap enough to
repeat every couple of seconds off the event loop without anyone noticing, and
it covers the case this is really about. Bluetooth is a bounded listen, so
"keep scanning" there means keeping the radio listening for as long as the
splash is open, on the handhelds this program exists for, on a battery, on the
screen somebody leaves up while they walk away. It gets a window every fifteen
seconds instead, eight of them, and the count is ignored while the list is
still empty -- an empty splash is precisely where somebody is waiting, and
another listen is the cost of being useful. TCP is not scanned at all, because
it never was: a network companion does not advertise, and its rows are read
from the registry and the profiles, which change only when the reader changes
them.

Only a change redraws. A poll that finds the same ports says nothing, which
matters for somebody part-way through arrowing down the list: a redraw they did
not ask for, on a list that has not moved, is the screen shifting under their
hands for no reason. When it does redraw, `replace_items` keeps their place by
value rather than by index, so a device appearing above the highlight does not
carry the selection with it.

`select_startup` grew a `live` argument to make this possible -- work to do
while the list is on screen, handed a `redraw(items)` rather than the screen
itself, since a caller holding a screen could not repaint it anyway. It is
cancelled when the screen resolves, so nothing outlives the list it was
redrawing and no repaint lands on the PIN prompt or the smoke test that opens
over it.

Fixes: #13

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 03:26:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 d3f901a5a1 A bright run says so in the colour it already had
Moving brightness out of bold and into the `9N` bank settled what a span says
when it names a colour. It left unsettled what happens when a sequence changes
the intensity and names no colour at all -- which is exactly how the art
announces a bright run. `ESC[1m` had its `1` stripped, the brightness was
remembered for whichever later sequence happened to name a foreground, and the
colour already in force was never restated. The run carried on in the dim bank
wearing the `22` that had been forced in front of it.

The wide mark does this twenty times, and five more as `ESC[1;4Nm`, where the
background survived and the brightness did not. On screen it reads as pairs of
half-blocks that should fade dark into light both coming out dark, most visibly
in the run beside the copyright line.

So the standing foreground is tracked, and restated in whichever bank now
applies whenever the intensity changes without one being named. A reset takes
the standing colour with it, because it takes the terminal's too.

There were no tests over the art loader at all -- `test_theme16.py` covers the
theme's own bold-brightness rules, which is a different question -- so this adds
some. The per-sequence invariant is the one that matters: counting bright
emissions across a file proves nothing, because one `1m` brightens every later
sequence and the totals stay high while individual runs go missing (149 emitted
against 64 requested, in a file that was drawing the bug). Every `1m` that fires
with a colour standing has to leave a bright foreground behind, and now each one
is checked.

Fixes: #11

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 02:52:44 -04:00
Jean-Pierre MartineauandClaude Opus 5 cf934e781f The form stops asking for what the tool already writes down
Nine of the bug form's fourteen fields were transcription: version, host, OS,
terminal, size, platform, device model, firmware, transport. `meshterm
diagnostics --out` writes every one of them, correctly, from the machine that
has the problem, and the form went on asking a tired person to copy them out by
hand.

A field that demands precision from someone who does not have it does not
produce precision. It produces an abandoned form, or an invented answer -- a
reporter guessing at their radio's chip name because the form would not let them
past without one. A guess is worse than a blank, because it reads as a fact.

So the required set drops from nine fields to five, and not one of the five asks
for something the reporter has to look up: what happened, how often, how to
repeat it, whether `--mock` changes it, and the diagnostics block itself.

Two of the cut fields came back as optional, because the block genuinely cannot
know them. It reads the model and firmware off the radio, so a report about a
radio that will not connect -- the most common first-run report there is -- has
nothing to read them from. And `platform` only separates PicoCalc from regular,
so a uConsole and a laptop look alike to it. Both fields now say exactly when
they apply, which is rarely.

The command itself lands in 0.9.0 and the released build is 0.3.7, so both the
form and the forum say what to do when it is not recognised: name your version
and send what you can. That line comes out once nobody is running an older one.

The trailer workflow inverts the obvious design. It does not nag about commits
with no issue trailer, because most commits here legitimately close nothing and
a workflow that complains about all of them gets muted within a week. It checks
the trailers that are there: `Fixes: #42` against an issue that does not exist
is not an error in GitHub's eyes, it is silence -- the issue stays open, the
reporter is never told, and the commit looks like it did the job. That is the
failure worth a comment.

Four labels join the stock set for the states a report actually passes through:
needs-diagnostics, confirmed, needs-hardware, cannot-reproduce. They say what
state a thing is in. The hardware goes in the title, where it does not
multiply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:13:39 -04:00
Jean-Pierre MartineauandClaude Opus 5 1bd8752a7e The README leads with the demo, by linking one
The recording is the highest-leverage thing the front page can carry, and it now sits
above the screenshots rather than waiting for launch day.

It is a link, not a player, and that is forced rather than chosen. GitHub strips <video>
out of markdown and renders an mp4 behind an image as a broken image; the only thing that
plays inline is a user-attachments URL, which cannot be created until the repository is
public. A linked still works today, works while the repository is still private, and goes
on working afterwards -- camo fetches the image from meshterm.net, which has been public
all along.

The picture is the poster frame with a play mark and the running time on it, so it reads
as a video rather than as a sixth screenshot, and so a reader knows it is under two
minutes before they commit to a click. The screenshots stay underneath: a still is what
someone skimming actually reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:41:04 -04:00
Jean-Pierre MartineauandClaude Opus 5 ff86ff059f The simulator writes its own history, not yours
`--mock` is a fake radio, not a fake MeshTerm: what it adverted was recorded to
`meshterm.db` like anything a real companion said, so its four invented contacts
turned up in the mesh walk, the dashboard and the map of the mesh actually being
run. A `--mock` run that names no database now records to
`<config_dir>/meshterm-mock.db`, created on first use.

`--db` still wins — where a run records and which radio it talks to are different
questions, so an explicit path is a precedence rule and not the usage error
`--mock --port` is. `$MESHTERM_HOME` remains the only thing that isolates a run
whole: the contact and channel caches, the outbox and the remembered devices are
untouched by this, and the docs say so where they used to warn that `--mock`
recorded into the real history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:59:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 bf29f73311 The menu has six sections, and the sixth was never written down
_CATEGORY_ORDER lists six: Message, Watch, Explore, This node, Other nodes,
This app. Every table we have ever published stopped at five. So Preferences,
Diagnostics and the four written pages -- About, About the author, Join
Discord, Support -- were documented nowhere, while the CLI has answered
`meshterm about` and the rest all along.

Folding the changelog is what surfaced it: writing diagnostics into the 0.9.0
entry meant naming where it lives, and where it lives is a section the
features table did not have.

The framing is fixed too, and the fix comes from base.py's own comment rather
than from me. "Five sections named for the doing" was never quite right --
three of them name a doing (Message, Watch, Explore) and three name the scope
a setting belongs to: the radio in your hand, someone else's over the mesh,
and the program in front of you. That is why This app sorts last, and it now
reads that way in both the README summary and the catalogue.

Every command in the new table was checked against `meshterm --help` rather
than inferred from a tool's name attribute, `diagnostics --out` and the four
`preferences` subcommands included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 21:56:02 -04:00
Jean-Pierre MartineauandClaude Opus 5 1198703a08 The changelog describes one release, and it is the one that ships
[Unreleased] had been collecting since the collapse: the diagnostics page
and command, the markdown report face behind it, and the path lane
constructor. Those are not unreleased. They are inside the 0.9.0 binary, so
leaving them above the 0.9.0 heading would have shipped release notes that
describe a version nobody can download.

Folding them was not a paste. [Unreleased] is written for people who follow
the development; the 0.9.0 entry is written for someone who has not heard of
a mesh network, and says so in its own first line -- everything is new today,
so instead of a list of changes, here is what it does. So diagnostics
arrives as one bullet in that voice, about what it gives a person filing a
bug, and the internals retire: on a first public release there is nothing to
have "changed", because there is no previous version to have changed from.

[Unreleased] keeps its heading and its compare link, empty, the way Keep a
Changelog leaves it between releases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 21:55:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 55797a5bc6 The README is a front door, and the manuals moved behind it
It had grown to 580 lines, and most of what a newcomer scrolled past was
reference material: the five feature tables, the CLI cookbook with both
output faces and the exit statuses, the configuration reference, and the
package layout. None of that is what somebody reads while deciding whether
to install this.

It is 375 lines now and it does onboarding and install -- what MeshTerm is,
the screenshots, how to get it running, a quick start, and how the project
is run. The rest moved under docs/ behind a new docs/README.md index, which
GitHub renders when you browse to the folder. The index also lists the two
code surveys, which the README's own table never did.

The prose moved by extracting line ranges rather than retyping, so anything
that did not need rewording is byte-identical. All 255 relative links across
the 13 markdown files resolve, anchors included.

Two changes are not relocations. "## Development" is deleted rather than
moved, because CONTRIBUTING.md already carried every command in it plus the
blame-ignore config and the 72-column gallery gate. And the Windows console
section is summarised in Install with the full explanation in
docs/terminals.md, since a Windows user meeting empty boxes cannot afford to
miss it.

Licensing stays where it was. Acknowledgements, the map and font credits, the
dependency table and the licence are all still in the README, which is where
a redistributor looks and where SignPath Foundation will look for its credit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 21:32:57 -04:00
Jean-Pierre MartineauandClaude Opus 5 97d19a7978 The popup shows a mark, not the tags around one
message_dialog draws the message it is handed and reads its spans to pick
a border tone, so a markup string reaches it unparsed: the reader saw a
literal [ok] and [/ok] around the checkmark, and a failed write would have
been framed in the success colour, since a plain string has no spans to
read a tone from. It is a styled Text now, the way every other caller
writes one.

Only the mark is styled. That is enough for the border to read the tone,
and it leaves the path in body ink where it stays legible -- which is what
the mark is for. The reason for the failure follows it muted, on its own
line.

The test asserts on the spans rather than on the words, because the bug
was invisible to anything reading the text: the tags were *in* the string
either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 17:17:31 -04:00
Jean-Pierre MartineauandClaude Opus 5 72f72b84cd Diagnostics is prose, so it is a written page
The block is a title, a standfirst, sections and a list under each. That
is a page of writing, not a screenful of rows, and the app already knows
how to draw one: the About pages' markdown renderer. So the tool states
its report as before and a third projection turns it into markdown, which
is exactly what the report seam was built for -- no tool touched, no
report changed, one new function beside the other two faces.

Being a written page buys the two things a long block most needs and that
a hand-built screen had neither of. 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; and the PicoCalc lane picks up Sect up / Sect
down for the same reason a grouped list does. AboutPage already does all
of it, so the page is that page with one verb added.

The frame comes back with it. The bare frame was there to keep a border
off every line a mouse would drag through, which mattered while copying
off the screen was the only way out; saving is the way out now, so the
page can be a normal screen and the save can be advertised where this app
advertises verbs -- the footer hint on a desktop, the lane's one free F3
slot on the console -- instead of in the page's own body, which belongs to
the report. The answer moves to a popup with it: a path is worth reading
carefully, a write can fail, and the page underneath is unchanged and
still there when the popup goes.

Saving markdown is the same decision as drawing it. One source is what the
page renders and what the file holds, and an issue tracker renders it as
written, with no fence and no apology. Every value is a code span, which
is correctness and not taste: a Windows path is full of backslashes that
markdown reads as escapes, and an error a stranger's firmware wrote can
hold an asterisk. A value already holding a backtick gets a longer fence.

Rows are a list and not a table, measured rather than assumed -- a
markdown table's cells are cropped with an ellipsis at 53 columns, and a
bug report whose one long value is cut is missing the half that mattered.
A list item wraps and hangs under its own text at any width.

Two smaller things fell out. A block may carry a caption, the heading a
format that has headings draws above it -- plain and JSON have no use for
one and ignore it. And the host block splits in two, because a "it looks
wrong" report is about the terminal and its reader should not have to pass
the processor architecture on the way to it.

The command line is untouched: aligned records, because that face is a
stream to grep and headings are furniture on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 17:10:31 -04:00
Jean-Pierre MartineauandClaude Opus 5 41c2cf416b The profile says a path, so three launches leave one profile
Verified on the Mac, and three of the four things it taught were the
opposite of what the code assumed.

RunCommandAsShell is false. Under *true* Terminal takes the whole command
string as the name of a program and the window reads "Command not found:
MESHTERM_REOPENED=1 / Could not create a new process and open a
pseudo-tty". The key's name says otherwise; the measurement wins.

The environment rides on /usr/bin/env rather than on a VAR=value prefix,
for the same reason. env is a real executable, so the line is valid as a
bare argv and as a shell command both, and the window cannot land on that
message however Terminal decides to read the string.

Terminal names an imported settings set after the FILE, not the name key
inside it -- and re-importing the same bytes reuses that profile, while
different bytes under the same filename add "MeshTerm 1", then
"MeshTerm 2". Opening one file four times, three identical and one a word
apart, left exactly two profiles. So the command cannot live in the
profile: it carries argv and the reader's MESHTERM_* variables, and a
profile per command line would fill their settings list with junk only
they can remove. The variable part moved behind a fixed path instead --
the profile says ~/.meshterm/launch.sh and never changes, and the script
underneath says whatever this launch needs.

Three launches with three different command lines now leave one profile
named MeshTerm, the window drawing CascadiaMonoPL-Regular at 13pt on
opaque black at 80x30, with the app running in it. The reader's default
profile is still theirs, blur and all.

shellExitAction 1 while here: tidy the window away on a clean exit, keep
it on a crash, because a crash nobody sees is a bug report nobody writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 16:42:51 -04:00
Jean-Pierre MartineauandClaude Opus 5 3c4c6ec05a The block is worth attaching, not only pasting
Copying off a screen is the wrong shape for anything long: you select,
you scroll, you select again, and on the PicoCalc there is no clipboard
to select into at all. So the page saves. s writes
meshterm-diagnostics.txt into the config directory beside the log, which
is the folder "send me both" already names, and meshterm diagnostics
--out PATH does the same from a shell -- spelled the way config
export-key --out already spells it rather than inventing a second word
for writing an answer to a path.

The save key forced the one concession on the bare frame. A bare frame
has no footer and no F-key lane, so a page that could save and never said
so would be a key nobody could find, and on the PicoCalc the lane is
normally the only place an affordance is advertised at all. The page
carries its own last line instead. One trailing line after a blank row
costs a paste far less than a border costs it on every line, and a drag
that follows the block stops above it of its own accord.

Saving answers on that same line. A dialog would cover the block the
reader was about to copy, and the line has to be able to say a write
failed -- a read-only home, a full disk -- without inventing an error
screen for it. The block stays readable either way, which is the whole
reason both routes exist.

The file is always the plain block, even under --json: it is there to be
read by whoever opens the issue, and a document of the same facts helps
them less. It is rendered at a fixed wide width so no lane crops, and
with the trailing pad stripped, since Rich pads every cell to its column
and that is invisible on a terminal and noise on disk.

With --out the answer becomes the path, the trade config export-key
already makes: a caller who asked for a file wants to be told where it is,
not handed the contents they just redirected into it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 16:33:34 -04:00
Jean-Pierre MartineauandClaude Opus 5 6046b4ff0e A bug report should be one page, and a page nobody has to read first
A report that opens with "it crashed" costs three round trips before
anyone knows which MeshTerm, on which OS, in which terminal, talking to
which radio. The person who hit the bug is the one least able to answer:
half of it is resolved at boot and shown on no screen -- whether icons
draw, whether powerline separators do, which platform flavour resolved and
why. Diagnostics states all of it at once, as a page under This app and as
meshterm diagnostics.

The page is a bare frame, and that is mechanical rather than aesthetic.
The one thing it is for is getting its text out of the terminal and into
an issue, and a terminal is selected by dragging: a border is characters
on the same lines as the content, so every line of a line-wise selection
arrives with a box-drawing glyph welded to each end. A header and a footer
hint arrive as text that looks like part of the report to whoever reads
the paste. So the page draws its rows and nothing besides, the frame the
share screen already uses for the same kind of reason.

Nothing in the block is private, and that has to be a property rather than
a habit, because the whole premise is that it gets pasted in public by
somebody who did not read it. No pairing PIN, no admin password, no
channel secret, no private key, no position, no contact's name or key. The
mesh arrives as aggregate -- a row count per table and the span the
observations cover -- which is the half that explains a bug without naming
anyone the reporter talks to. Two tests hold the line from opposite sides:
one plants real secrets in the stores that hold them and asserts none came
out, and one refuses the key *names* such a field would take, so a value
the suite cannot plant is still caught by what it would arrive as.

The radio is asked and never required. A report about a companion that
will not connect is exactly the report most worth filing, so the failure
becomes connected=no with the radio's own words in error, and every other
fact still arrives.

Table counts come off sqlite_master rather than a list written down here,
so a table added later starts being reported the day it lands; the
interesting count is always the table nobody expected to be full. Only
overridden preferences are listed -- the defaults are in the source and
identical for everyone.

Both faces share one projection of a facts block, so the page cannot drift
from what the command prints: a field added to the report reaches both
without anyone remembering to. Only the layout differs, in one respect --
the scripted face crops a value too wide for its lane, which is right for
a record somebody will split on whitespace and wrong for a page whose
whole job is to be complete, so a long value hangs under its own block
instead. A path also gains the lane constructor it never had; the machine
face had always spelled one correctly and the plain face could not render
one at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 15:58:12 -04:00
Jean-Pierre MartineauandClaude Opus 5 2d31810801 MeshTerm gets its own window on a Mac, and leaves yours alone
No Mac monospace face carries the Braille Patterns block, so Terminal --
which falls back generously rather than drawing a box -- borrows the
charts and the map's terrain from a proportional face and lands them at
the wrong width. Nothing announces itself as broken; it is just crooked,
and a reader with nothing to compare against never finds out why.

Fixing it means a font, and a font means a profile. The question that
shapes this module is whose terminal it is. MeshTerm's palette is the
16-colour VT set, crude on purpose, and nobody should have to read their
mail in it -- so nothing here touches the default profile or edits their
preferences. It adds a profile named MeshTerm and opens our own window in
it; theirs keeps its translucency, and quitting leaves nothing behind.

That is also the only route that works at all. Terminal rewrites its
preferences from memory when it quits, so a file written while it runs --
and MeshTerm always runs inside it -- is discarded later. Handing Terminal
a .terminal file lets Terminal do the importing.

The profile is generated from _VT_SLOTS rather than shipped, so the window
cannot disagree with the screen it holds. The NSColor and NSFont archives
are pinned to bytes read back off a profile that was installed on a real
Mac and confirmed rendering, not to a reading of the format.

relaunch.py is the half that was never Windows-specific -- own_command,
child_environment and its _PYI trap, the wanted size -- moved out of
winterminal so both terminals share one copy.

Not yet wired into startup: the launch itself wants a Mac to confirm, and
that machine is asleep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 13:35:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 3edfaebadc The install commands name no version, so they cannot go stale
A browser download is the one route that does not work on a Mac, and the
README said so in a note *below* the commands -- which is no help to
somebody who followed the commands. macOS now leads the download section
with curl, states why in its first sentence, and Linux follows in the same
three lines.

Those three lines name no version, because the release now attaches a
version-less copy of every binary beside the stamped one. That is what
makes releases/latest/download/meshterm-macos-arm64 resolve, so the README
points at whichever release is newest instead of at one that was current
when somebody last remembered to grep the old number out of it. The
stamped files stay for anyone keeping several builds around.

And the release page itself now opens with those commands written out for
its own version, above the changelog, built from the tag -- so the links in
the example are the assets attached to the page printing them.

The release skill loses the step that re-pointed the README by hand and
gains the asset count to check instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 12:30:13 -04:00
70 changed files with 4492 additions and 2252 deletions

No files matched your search

+7 -24
View File
@@ -1,31 +1,14 @@
{
"permissions": {
"allow": [
"Bash(python -)",
"Bash(python -m pytest *)",
"Bash(.venv/Scripts/python.exe -m pytest *)",
"Bash(./.venv/Scripts/python.exe -m pytest *)",
"Bash(python -m ruff check *)",
"Bash(python -m ruff format *)",
"Bash(ruff check *)",
"Bash(ruff format *)",
"PowerShell(python -m pytest *)",
"PowerShell(.venv\\Scripts\\python.exe -m pytest *)",
"PowerShell(.\\.venv\\Scripts\\python.exe -m pytest *)",
"Bash(git add *)",
"Bash(git commit *)",
"PowerShell(git add *)",
"PowerShell(git commit *)",
"Bash(PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -c ' *)",
"Bash(python -m pytest tests/test_tui.py -q -k \"progress\")",
"Bash(python -m pytest tests/test_tui.py -q -k \"overlay or progress\")",
"PowerShell([console]::beep\\(880,150\\); Start-Sleep -Milliseconds 60; [console]::beep\\(1175,200\\))",
"Bash(PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -m pytest tests/test_tui.py -q -k \"busy_overlay or scanner or dim_color or overlay_fade\")",
"Bash(PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -m pytest -q)",
"Bash(python -c ' *)",
"Bash(grep -v \"^$\")",
"Bash(PYTHONIOENCODING=utf-8 PYTHONUTF8=1 python -c ' *)",
"Bash(python -m pytest tests/test_channels.py -q)",
"Bash(git commit -m ' *)",
"Bash(.venv/Scripts/python.exe -m meshterm --mock trace --target Alice --profile yagi)",
"Bash(echo \"=== EXIT: $? ===\")"
],
"defaultMode": "dontAsk"
"PowerShell(ruff check *)",
"PowerShell(ruff format *)"
]
}
}
+28 -19
View File
@@ -96,23 +96,22 @@ version is written**. `[project] version` in `pyproject.toml` is `dynamic` and h
reads the attribute, so the number in a built wheel cannot drift from the number in the
source. Do not add a second one.
### Then re-point the README's download commands
### The README needs nothing — deliberately
`installers.yml` names each asset `meshterm-${version}-<label><ext>`, so **the file a reader
downloads is named after the release** — and the README tells them what to type at it. Those
lines are prose, not code, so nothing rewrites them for you:
It used to. `installers.yml` names each asset `meshterm-${version}-<label><ext>`, so every
download command in the README named a version, and every release meant grepping the old
number out of five of them. That step failed quietly when it was missed: a README naming
last release's file still reads perfectly well, and only breaks for the one person who
types it.
```
grep -n '<the previous version>' README.md
```
`github-release.yml` now attaches a **version-less copy of every binary** beside the
stamped one, so `releases/latest/download/meshterm-macos-arm64` is a URL that never moves —
which is what the README and `docs/uconsole.md` use. Nothing in either names a version.
Leave them alone, and if you find yourself adding a version to an install command, add an
alias to the gather step instead.
Every hit is a download or run command (`.\meshterm-X.Y.Z-windows-x64.exe`,
`chmod +x meshterm-X.Y.Z-*`, the `xattr` quarantine note, the `MESHTERM_HOME` trial runs).
Replace the old number with the one just bumped to, and re-read each line: they must name
the file the workflow is about to publish, or the first thing a new user does fails.
Also check `docs/cli.md` for a sample document carrying a `"version"` field, which is the
live `{version}` placeholder and must show the new number too.
What *does* still carry the number: `docs/cli.md` has a sample document with a `"version"`
field, which is the live `{version}` placeholder and must show the new one.
## 5. Commit, tag, push
@@ -143,16 +142,26 @@ Five platform builds, the release job, and `website` — which rebuilds meshterm
tag so the site shows the new version — so it takes a few minutes. If only `website` fails,
the release itself is fine (it runs after publishing); the usual cause is the
`SITE_DEPLOY_KEY` secret, and once that is fixed `gh run rerun <run-id> --failed` finishes
it. **Check the notes
actually came out of the changelog** — a green run is not proof, because the fallback is a
successful step:
it.
The notes open with an install block the workflow writes from the tag — the same commands
as the README, but pinned to *this* release's own assets — and the changelog follows under
`## What changed`. **Check that second half actually came out of the changelog**, because a
green run is not proof: the fallback is a successful step.
```
gh release view vX.Y.Z --json body --jq .body | head -3
gh release view vX.Y.Z --json body --jq .body | sed -n '/## What changed/,$p' | head -5
```
If that reads "No changelog entry for X.Y.Z.", the extraction failed rather than the
changelog being empty. Fix the workflow, then repair the published notes in place with
changelog being empty.
Fourteen assets is the right count — five version-stamped binaries, five version-less
copies of them, `SHA256SUMS`, and the three licence files:
```
gh release view vX.Y.Z --json assets --jq '.assets | length'
``` Fix the workflow, then repair the published notes in place with
`gh release edit vX.Y.Z --notes-file` — the binaries are fine and the tag does not move.
If a *build* fails, the tag is already public: fix forward with a new patch version rather
+1 -1
View File
@@ -2,5 +2,5 @@
# way to chip in and this file doesn't, the two have drifted and one of them is lying.
buy_me_a_coffee: johnputer
custom:
- "https://www.patreon.com/c/MeshTerm"
- "https://www.patreon.com/cw/MeshTerm"
- "https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6"
+66 -106
View File
@@ -7,12 +7,22 @@ body:
value: |
Hopefully you never need this form.
If you do: how much of this you fill in really does decide what happens next. A
report I can act on gets worked on. A vague one usually doesn't, because I can't
do anything with it. If it isn't worth describing properly, it's probably not
worth fixing.
If you do: start by running this, and attach the file it writes.
Most of these take ten seconds each.
```
meshterm diagnostics --out bug.md
```
It answers which MeshTerm, on which machine, in which terminal, talking to which
radio over which transport and firmware — most of what I would otherwise ask one
question at a time, and the details people most often end up guessing at. Nothing
in it is private: no keys, no pairing PINs, no positions, no contacts. That is a
tested property, not a promise.
The **Diagnostics** page in the menu saves the same file (with `s`, or F3 on the
PicoCalc), next to the log.
The rest of this form asks only for what that file cannot know.
- type: textarea
id: problem
@@ -49,118 +59,62 @@ body:
validations:
required: true
- type: textarea
id: workaround
attributes:
label: Is there a way around it
description: Anything that avoids it, or gets you unstuck. Say so even if it's ugly.
- type: input
id: version
attributes:
label: MeshTerm version
description: It's on the splash screen when you start, and on the About page.
placeholder: "0.2.0"
validations:
required: true
- type: input
id: hardware
attributes:
label: What are you running MeshTerm on
description: >
Desktop, laptop, Mac, or a handheld — and if it's a handheld, which one.
placeholder: "ClockworkPi uConsole (CM4), or: desktop PC"
validations:
required: true
- type: input
id: os
attributes:
label: Which OS, exact version
placeholder: "Windows 11 24H2 / macOS 15.3 / Debian 12"
validations:
required: true
- type: input
id: terminal
attributes:
label: Which terminal, and how big
description: >
PowerShell, Windows Terminal, iTerm2, the PicoCalc console, ssh from something.
Size in columns x rows if you know it — a lot of layout bugs are really terminal
bugs.
placeholder: "Windows Terminal, 120x40"
validations:
required: true
- type: input
id: companion
attributes:
label: Which companion device
description: >
Be precise. There are a lot of these and they don't behave the same. "A Xiao"
doesn't narrow it down — "Seeed XIAO nRF52840 + Wio-SX1262" does. Leave blank if
no radio was involved.
placeholder: "Heltec V3 / RAK4631 / XIAO nRF52840 + Wio-SX1262"
- type: input
id: firmware
attributes:
label: Which firmware version is on it
placeholder: "MeshCore 1.15.0"
- type: dropdown
id: transport
attributes:
label: How is it connected
options:
- USB / serial
- Bluetooth
- TCP
- SPI-to-TCP bridge
- Something else (say so below)
- No radio — simulator / --mock
validations:
required: true
- type: dropdown
id: flavour
attributes:
label: Regular or PicoCalc
description: >
MeshTerm draws itself differently on a PicoCalc — narrower, fewer colours, a
different font. A bug in one often doesn't exist in the other, so this saves a
round of guessing.
options:
- Regular (desktop, laptop, ssh)
- PicoCalc handheld
- Not sure
validations:
required: true
- type: dropdown
id: mock
attributes:
label: Does it still happen with `meshterm --mock`
description: >
`--mock` runs the whole app against a fake radio. If the bug survives that, it's
in MeshTerm and I can chase it without hardware. If it doesn't, it's in the link
or the radio, which is a different hunt. Skip it if the bug is about connecting
in the first place.
`--mock` runs the whole app against a fake radio. If the bug survives that, it is
in MeshTerm and I can chase it without owning your hardware. If it doesn't, it is
in the link or the radio, which is a different hunt. This one question decides
which of those two jobs this is, so it is worth the minute it takes.
options:
- "Yes, still happens with --mock"
- "No, --mock is fine"
- "Haven't tried"
- "Doesn't apply"
- "Doesn't apply — the problem is connecting"
validations:
required: true
- type: textarea
id: diagnostics
attributes:
label: The diagnostics block
description: >
Attach `bug.md` by dragging it in, or paste its contents here. If you genuinely
cannot produce one, because MeshTerm will not start at all, write down why instead,
along with your version, and I will work with that.
validations:
required: true
- type: input
id: handheld
attributes:
label: Which handheld, if it is a handheld
description: >
Only if you are running on something the diagnostics file cannot name. It knows a
PicoCalc from a regular terminal, but a uConsole and a laptop look much the same to
it. Leave blank on a desktop, laptop or Mac.
placeholder: "ClockworkPi uConsole (CM4)"
- type: input
id: radio
attributes:
label: Which radio and firmware, if MeshTerm could not reach it
description: >
The diagnostics file reads the model and firmware off the radio itself, so when the
problem *is* connecting, it has nothing to read. That is the one case where I need
you to tell me. Be precise — "a Xiao" covers several boards that do not behave
alike.
placeholder: "Seeed XIAO nRF52840 + Wio-SX1262, MeshCore 1.15.0"
- type: textarea
id: error
attributes:
label: The exact error, if there was one
description: >
Copy it as text rather than photographing the screen — text can be searched, and
a photo of a terminal is hard to read. Paste the whole traceback if it crashed.
Copy it as text rather than photographing the screen — text can be searched, and a
photo of a terminal is hard to read. Paste the whole traceback if it crashed.
render: text
- type: textarea
@@ -169,10 +123,16 @@ body:
label: The log file
description: >
MeshTerm keeps one at `~/.meshterm/meshterm.log` (or wherever `MESHTERM_HOME`
points). Attach it, or paste the last twenty lines. If the problem is hard to
catch, set **Log detail** to `DEBUG` on the Preferences page, do it once more,
and send that instead — it records a great deal more.
render: text
points). Attach the file, or paste the last twenty lines inside a ``` fence. If the
problem is hard to catch, set **Log detail** to Debug on the Preferences page,
restart MeshTerm, do it once more, and send that instead. It records a great deal
more.
- type: textarea
id: workaround
attributes:
label: Is there a way around it
description: Anything that avoids it, or gets you unstuck. Say so even if it's ugly.
- type: textarea
id: extra
+5 -3
View File
@@ -4,10 +4,12 @@ contact_links:
url: https://discord.gg/AZwe5Uvb3S
about: >
Setup trouble, "is it supposed to do this", or anything you want a second opinion
on. The support forum is the place. Someone running your radio has probably hit it.
on. The #support forum is the place. Someone running your radio has probably hit it.
- name: No GitHub account? Report it on Discord instead
- name: "No GitHub account? Report it in the #bugs forum instead"
url: https://discord.gg/AZwe5Uvb3S
about: >
Drop reproducible bugs in #bugs and they'll get moved here. Being told beats
One post per bug, tagged, with the diagnostics file attached. Real ones get opened
here and your post is renamed with the issue number, so you can follow it without an
account — and something comes back to tell you when it ships. Being told beats
filing it tidily.
+3 -2
View File
@@ -4,8 +4,9 @@
## Related issue
<!-- CONTRIBUTING asks for an issue first on anything non-trivial, so a large PR with no
issue behind it will get a conversation before it gets a review. Link it here. -->
<!-- Small fixes can come straight to a PR. For anything bigger, CONTRIBUTING asks for an
issue first and a yes on it, so a large PR with no issue behind it will get a
conversation before it gets a review. Link it here. -->
## Before you mark it ready
+5 -4
View File
@@ -33,7 +33,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.10"
python-version: "3.14"
cache: pip
cache-dependency-path: pyproject.toml
@@ -64,9 +64,10 @@ jobs:
- { os: ubuntu-latest, python: "3.11" }
- { os: ubuntu-latest, python: "3.12" }
- { os: ubuntu-latest, python: "3.13" }
- { os: ubuntu-latest, python: "3.14" }
- { os: windows-latest, python: "3.10" }
- { os: windows-latest, python: "3.13" }
- { os: macos-latest, python: "3.13" }
- { os: windows-latest, python: "3.14" }
- { os: macos-latest, python: "3.14" }
steps:
- uses: actions/checkout@v4
@@ -97,7 +98,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.14"
- run: pip install build twine
+105 -7
View File
@@ -66,14 +66,45 @@ jobs:
id: gather
shell: bash
run: |
tag="${{ inputs.tag || github.ref_name }}"
version="${tag#v}"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
mkdir -p release
find artifacts -type f -exec mv {} release/ \;
ls -lh release/
# One combined checksum file is easier to verify against than five loose ones.
(cd release && cat *.sha256 > SHA256SUMS && rm -f *.sha256)
tag="${{ inputs.tag || github.ref_name }}"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
cd release
# A second, version-less copy of every binary. GitHub's evergreen download URL,
# `releases/latest/download/<name>`, resolves against an exact asset name and
# nothing else — so a link that always fetches the newest build needs a name
# that never moves. That is what lets the README, the website and anyone's
# install script say `meshterm-macos-arm64` once instead of naming a version
# that goes stale the moment the next release lands. The version-stamped file
# stays beside it: it is the one you want on disk when you keep several builds
# around, and it is what the release-notes install block points at, since that
# page is about this release rather than the latest one.
made=0
for versioned in "meshterm-$version"-*; do
case "$versioned" in *.sha256) continue ;; esac
[ -e "$versioned" ] || continue
stable="meshterm-${versioned#meshterm-$version-}"
cp "$versioned" "$stable"
sha256sum "$stable" > "$stable.sha256"
made=$((made + 1))
done
# Nothing matched means the tag and `meshterm.__version__` disagree, which is a
# broken release either way: the notes below are about to name files by version
# that the build did not produce. Fail here, where it is one line to read.
if [ "$made" -eq 0 ]; then
echo "::error::No binary named meshterm-$version-*; $tag and meshterm.__version__ disagree."
exit 1
fi
echo "$made version-less aliases made"
# One combined checksum file is easier to verify against than ten loose ones.
cat *.sha256 > SHA256SUMS && rm -f *.sha256
- name: Take the release notes from the changelog
id: notes
@@ -96,6 +127,7 @@ jobs:
awk -v v="$VERSION" '
$0 ~ "^## \\[" v "\\]" {found=1; next}
found && /^## \[/ {exit}
found && /^\[[^]]+\]: / {next}
found {print}
' CHANGELOG.md > notes.md
if ! grep -q '[^[:space:]]' notes.md; then
@@ -103,6 +135,72 @@ jobs:
fi
echo "--- notes ---"; cat notes.md
# Most people who open a release page came to install the thing, so the commands go
# above the changelog rather than in a link to a README that names some other
# version. Written here rather than kept as prose anywhere means they can only ever
# name *this* release: the tag interpolates, so the URLs are the assets attached to
# the very page they are printed on, and there is no example version to go stale.
#
# The heredoc is quoted, so the markdown's own backticks stay backticks instead of
# becoming command substitution, and the two placeholders are filled by `sed`
# afterwards. Neither a tag nor a version can contain a slash, so the substitution
# needs no escaping.
- name: Open the notes with this release's install commands
shell: bash
env:
TAG: ${{ steps.gather.outputs.tag }}
VERSION: ${{ steps.gather.outputs.version }}
run: |
cat > install.md.in <<'INSTALL_MD'
## Install
One file, no Python needed.
**macOS** — paste these into Terminal. Use `curl`, **not your browser**: a browser
flags the download and macOS then refuses to open it at all, and on Sequoia and
later right-click → Open no longer gets you past it. `curl` flags nothing, so it
simply runs.
```bash
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/download/@TAG@/meshterm-@VERSION@-macos-arm64
chmod +x meshterm
./meshterm
```
That is the build for Apple silicon Macs (M1 and later). On an Intel Mac, swap
`arm64` for `x64`.
**Linux** — the same three lines with `linux-x64`, or `linux-arm64` on a uConsole
or another 64-bit ARM handheld.
```bash
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/download/@TAG@/meshterm-@VERSION@-linux-x64
chmod +x meshterm
./meshterm
```
**Windows** — download [meshterm-@VERSION@-windows-x64.exe](https://github.com/jpmartineau/MeshTerm/releases/download/@TAG@/meshterm-@VERSION@-windows-x64.exe), then run it from Windows
Terminal or PowerShell rather than by double-clicking, so you can read anything it
says on the way up.
```powershell
.\meshterm-@VERSION@-windows-x64.exe
```
Every binary below is listed in `SHA256SUMS`. The version-less copies —
`meshterm-macos-arm64` and its four siblings — are the same binaries under a name
that never changes, for anything that wants whichever release is newest without
being told its number. [Installing with pip or pipx, and the rest of the
detail](https://github.com/jpmartineau/MeshTerm#install).
---
## What changed
INSTALL_MD
sed -e "s/@TAG@/$TAG/g" -e "s/@VERSION@/$VERSION/g" install.md.in > install.md
cat install.md notes.md > body.md
echo "--- body ---"; cat body.md
- name: Create it
env:
GH_TOKEN: ${{ github.token }}
@@ -111,7 +209,7 @@ jobs:
run: |
gh release create "$TAG" release/* \
--title "MeshTerm ${TAG#v}" \
--notes-file notes.md \
--notes-file body.md \
--verify-tag \
$PRERELEASE
echo "https://github.com/${{ github.repository }}/releases/tag/$TAG"
@@ -146,7 +244,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.14"
- name: Rebuild the site from this release
run: |
+4 -3
View File
@@ -59,9 +59,10 @@ jobs:
- uses: actions/setup-python@v5
with:
# Oldest supported version on purpose: a binary built against an older glibc
# and an older Python runs on more machines than one built against the newest.
python-version: "3.10"
# The Python the project builds with, bundled into every binary. How widely a
# Linux binary runs is set by the runner's glibc, which is why the Linux legs
# stay on ubuntu-22.04 rather than ubuntu-latest.
python-version: "3.14"
- run: pip install . pyinstaller
+84
View File
@@ -0,0 +1,84 @@
name: Issue trailers
# A commit that names an issue is the only thing connecting a fix to the person
# who reported it. GitHub resolves that link silently: `Fixes: #42` against an
# issue that does not exist is not an error, it is nothing at all -- the issue
# stays open, the reporter is never told, and the commit looks like it did the
# job. A typo costs a whole loop and announces itself nowhere.
#
# So this checks the trailers that ARE there rather than nagging about the ones
# that are not. Most commits here are internal work that legitimately closes
# nothing, and a workflow that complains about all of them gets muted in a week.
#
# Cross-repo trailers (`Fixes: owner/repo#12`) are reported but not verified:
# GITHUB_TOKEN only reaches this repository.
on:
push:
branches: [main]
permissions:
contents: write # to leave a comment on the offending commit
issues: read
jobs:
check:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps:
- name: Verify every issue named in a trailer exists
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
COMMITS: ${{ toJSON(github.event.commits) }}
run: |
set -euo pipefail
echo "$COMMITS" > /tmp/commits.json
count=$(jq 'length' /tmp/commits.json)
echo "checking $count commit(s)"
problems=0
for i in $(seq 0 $((count - 1))); do
sha=$(jq -r ".[$i].id" /tmp/commits.json)
msg=$(jq -r ".[$i].message" /tmp/commits.json)
short="${sha:0:8}"
# Trailers only: a line that starts with the keyword. A bare "#12"
# in prose is a reference someone wrote on purpose, not a claim
# that this commit closes anything.
refs=$(printf '%s\n' "$msg" \
| grep -oiE '^(fixes|closes|resolves|refs|see):[[:space:]]*([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)?#[0-9]+' \
|| true)
[ -z "$refs" ] && continue
while IFS= read -r ref; do
[ -z "$ref" ] && continue
target=$(printf '%s' "$ref" | sed -E 's/^[A-Za-z]+:[[:space:]]*//')
num="${target##*#}"
if [ "$target" != "#${num}" ]; then
repo="${target%%#*}"
echo " $short -> $target (other repository, not verified here)"
continue
fi
if state=$(gh api "repos/$REPO/issues/$num" --jq .state 2>/dev/null); then
echo " $short -> #$num ($state)"
else
echo "::error::commit $short names #$num, which does not exist in $REPO"
problems=$((problems + 1))
gh api "repos/$REPO/commits/$sha/comments" \
-f body="This commit's trailer names **#$num**, which does not exist in \`$REPO\`.
GitHub resolves a closing keyword silently, so nothing failed and nothing was linked — the issue this was meant to close is still open, and whoever reported it has not been told. Worth a follow-up commit with the right number, or a note on the issue itself." \
>/dev/null
fi
done <<< "$refs"
done
if [ "$problems" -gt 0 ]; then
echo "::warning::$problems trailer(s) pointed at issues that do not exist"
fi
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.14"
- run: pip install build twine
+76 -923
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,6 +1,6 @@
# MeshTerm — working notes for Claude
MeshTerm is a full-screen terminal companion for MeshCore LoRa mesh devices. The UI is a
MeshTerm is a full-featured TUI MeshCore client for your terminal. The UI is a
prompt_toolkit session rendering Rich content (`meshterm/ui/`), tools register into a
menu (`meshterm/tools/`), services run in the background (`meshterm/services/`), and
everything heard is recorded to SQLite (`meshterm/persistence/`).
@@ -56,7 +56,7 @@ them instead of hand-rolling:
| Term | Meaning |
|---|---|
| node | any device on the mesh — a radio broadcasting packets; roles: client, repeater, room server. The umbrella term |
| node | any device on the mesh — a radio broadcasting packets; roles: companion, repeater, room server, sensor. The umbrella term |
| contact | a node your device knows: **discovered** (heard broadcasting, not yet added) or **added** (in the contact list, messageable). Every contact is a node; not every node is a contact |
| heard | received from ("last heard", "first heard") — never "seen" in UX text |
| key | the full fixed-length value — a node's public key, a channel secret |
@@ -68,7 +68,7 @@ them instead of hand-rolling:
| Quit | leave the app (main menu, device splash) — nowhere else |
Node vs contact — the boundary: **node** is the hardware/participant sense — the map,
mesh walk, heard-nodes, relay hops, graph vertices, and node *types* (client/repeater/room/
mesh walk, heard-nodes, relay hops, graph vertices, and node *types* (companion/repeater/room server/
sensor) all speak "node". **Contact** is the saved-identity sense — the Contacts screen,
the courier recipient, anything you *address*. The reception/persistence layer
(`observations.node`, `HeardNode`, `heard_nodes()`, `trace_hops.node`) stays "node"; the
@@ -296,8 +296,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
- Status marks (single-width, themed): `✓` ok · `✗` err · `⚠`/`?` warn · `●` unread/
unacked (err) · `○` acked/empty (muted). Never `✔`, `✖`, `✅`, `❌` as status.
- Node types (shared with the map): `★` you (yellow) · `●` node · `▲` repeater ·
`■` room · `◉` sensor · `○` unknown.
- Node types (shared with the map): `★` you (yellow) · `●` companion · `▲` repeater ·
`■` room server · `◉` sensor · `○` unknown.
- Path cuts: a path line drawn as **chips** that gets cut — a lane that ran out, a row
scrolled past its edge — breaks the chip off on a half block in that chip's own fill
(`▐` ends a line, `▌` opens one; `pathline.cut_mark`/`cut_to`), so half the cell is
+9 -4
View File
@@ -25,8 +25,8 @@ this repository.
seen, etc.), UX standards for screens/dialogs/menus, and where the reusable building
blocks live in code (`ui/menus.py`, `ui/markdown.py`, `ui/widgets.py`, `ui/theme.py`).
New code should read like it always belonged here.
- For anything bigger than a small fix, open an issue first to talk it through before
writing a lot of code — saves both of us a rewrite.
- Small fixes can go straight to a pull request. For anything bigger, open an issue
first and wait for a yes before writing a lot of code — saves both of us a rewrite.
## Making a change
@@ -61,7 +61,12 @@ this repository.
Open a GitHub issue. For bugs, include what you expected, what happened, and enough to
reproduce it — device/platform, MeshCore firmware version if relevant, and steps.
The [bug form](https://github.com/jpmartineau/MeshTerm/issues/new?template=bug.yml) asks
for all of this. Attach the file that `meshterm diagnostics --out bug.md` writes. No
GitHub account? Post it in the #bugs forum on [Discord](https://discord.gg/AZwe5Uvb3S)
instead.
---
Questions about any of this, licensing included, are welcome as a GitHub issue or
discussion before you put work in.
Questions about any of this, licensing included, are welcome as a GitHub issue before you
put work in.
+125 -309
View File
@@ -1,14 +1,14 @@
# MeshTerm
**A full-screen terminal companion for [MeshCore](https://meshcore.io/) LoRa mesh
devices** — tune your radio, chat across the mesh, watch the network live, and keep a
longitudinal record of everything you overhear, all from a connected serial, Bluetooth, or
network (TCP) companion.
**A full-featured TUI [MeshCore](https://meshcore.io/) client for your terminal** — tune
your radio, chat across the mesh, watch the network live, and keep a longitudinal record
of everything you overhear, all from a connected serial, Bluetooth, or network (TCP)
companion.
MeshTerm is interactive by default: a modern, keyboard-driven TUI built on
[Rich](https://github.com/Textualize/rich) and
[prompt_toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit). It is also
fully scriptable — **every screen has a mirror `meshterm` subcommand** — so the same
fully scriptable — **most screens have a mirror `meshterm` subcommand** — so the same
capabilities drive both an evening of exploring the mesh and a cron job. Every packet the
radio overhears is recorded to a local SQLite database, so the longer you run it, the more
your mesh's history is worth.
@@ -23,8 +23,14 @@ your mesh's history is worth.
<a href="https://discord.gg/AZwe5Uvb3S"><img alt="Discord" src="https://img.shields.io/badge/chat-Discord-5865F2"></a>
</p>
> MeshTerm is a side project, not a product. Bug reports are very welcome — but please
> open an issue before writing a pull request. [More on how it's run](#how-this-project-is-run).
> MeshTerm is a side project, run by one person. Bug reports are very welcome. Small fixes
> can go straight to a pull request; for anything bigger, please open an issue first.
> [More on how it's run](#how-this-project-is-run).
<p align="center">
<a href="https://meshterm.net/#demo"><img src="https://meshterm.net/assets/demo-play.png" alt="Watch the demo: MeshTerm showing every route a channel message took to arrive" width="840"></a>
</p>
<p align="center"><sub>Under two minutes on a real mesh &mdash; <a href="https://meshterm.net/#demo">watch it on meshterm.net</a>.</sub></p>
<p align="center">
<img src="docs/screenshots/dashboard.png" alt="The dashboard: a packets-per-minute timeline over the last two hours, and every packet class counted" width="49%">
@@ -64,90 +70,92 @@ your mesh's history is worth.
### Download a build
**[⬇ Latest release](https://github.com/jpmartineau/MeshTerm/releases/latest)** — one file,
no Python needed. Windows, macOS (Intel and Apple silicon), and Linux (x64 and ARM64, so
the uConsole is covered).
One file, no Python needed — Windows, macOS (Intel and Apple silicon), and Linux (x64 and
ARM64, so the uConsole is covered). Every command below fetches the
**[⬇ latest release](https://github.com/jpmartineau/MeshTerm/releases/latest)**, whichever
one that is, so none of them goes stale.
MeshTerm is a terminal program, so **open a terminal and run it from there.** You can
double-click it and it will work, but you'll get whatever console your system picks, and
if anything goes wrong at startup the window closes before you can read why.
**Windows** — open Windows Terminal or PowerShell, `cd` to your downloads, then:
#### macOS — download it with `curl`
```powershell
.\meshterm-0.3.7-windows-x64.exe
```
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
**Don't use your browser.** A browser flags the file as downloaded, and macOS then refuses
to open it at all — on Sequoia and later, right-click → Open no longer gets you past that
either. `curl` flags nothing, so what it hands you simply runs. Open Terminal and paste:
```bash
chmod +x meshterm-0.3.7-*
./meshterm-0.3.7-*
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-macos-arm64
chmod +x meshterm
./meshterm
```
On macOS, downloading it with `curl` instead of a browser saves you a fight with Gatekeeper
— the note below has the line.
That's the build for Apple silicon Macs (M1 and later). On an **Intel** Mac, swap `arm64`
for `x64`. And if you already downloaded it with a browser, you don't have to start over:
`xattr -d com.apple.quarantine meshterm` clears the flag.
### A word about terminals, on Windows
#### Linux — the same three lines
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether you
see them is up to your *terminal*, not really your font: a modern terminal, asked for a
character its font doesn't have, quietly borrows it from another font on the machine.
That's why the app looks right in Windows Terminal, in VS Code's terminal, and on macOS
and Linux — usually with a font that contains almost none of it.
`linux-x64` on a PC, `linux-arm64` on a uConsole or another 64-bit ARM handheld.
```bash
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-linux-x64
chmod +x meshterm
./meshterm
```
#### Windows
Download
**[meshterm-windows-x64.exe](https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-windows-x64.exe)**,
then open Windows Terminal or PowerShell, `cd` to your downloads, and run it:
```powershell
.\meshterm-windows-x64.exe
```
Windows says *"Windows protected your PC"* the first time. Click **More info → Run anyway**.
Whichever you took, you now have a single file: `meshterm` on macOS and Linux,
`meshterm-windows-x64.exe` on Windows. Put it somewhere on your `PATH` (renamed to
`meshterm.exe` on Windows) and it's just `meshterm` from anywhere.
> **These builds aren't code-signed**, which is why macOS and Windows both push back the
> first time; signing costs real money on both platforms and this is a free side project.
> Every release ships a `SHA256SUMS` file if you'd rather check what you got, and
> [installing with pip or pipx](#or-install-with-pip) sidesteps the whole business, since
> nothing arrives as a downloaded binary.
>
> Each release page also carries these commands written out for **that exact version**,
> next to its own downloads.
### If your terminal draws empty boxes
MeshTerm is drawn with emoji icons, braille charts and powerline path chips, and whether
you see them is up to your *terminal* rather than your font. Windows Terminal, VS Code's
terminal, and anything modern on macOS and Linux all draw them correctly.
The classic Windows console — the black `cmd.exe` window you get from a double-click —
doesn't borrow. It draws what its one font holds and empty boxes for everything else, and
no font fixes the icons there: the only two fonts on a Windows machine with emoji in them
are proportional, and a console won't take a proportional font.
So when MeshTerm lands in that console it just **moves to Windows Terminal** — it says
so, and opens there. Nothing is installed and nothing is changed; it's one window instead
of another, and it's the whole app exactly as the screenshots show it. Windows Terminal is
already on every Windows 11 machine and is a free install on Windows 10.
Where there's no Windows Terminal to move to, MeshTerm offers the next best thing instead
— the charts and marks, without the icons. It ships
[Cascadia Mono PL](https://github.com/microsoft/cascadia-code), Microsoft's own console
font and one of the very few monospace faces that carries braille at all, and will install
it just for you: no administrator rights, nothing downloaded. If you already have Cascadia
it simply switches to it.
does not. So when MeshTerm lands there it **moves to Windows Terminal**, says so, and
opens there; nothing is installed and nothing is changed. Where there's no Windows
Terminal to move to, it offers the charts and marks without the icons instead, and will
install Microsoft's [Cascadia Mono PL](https://github.com/microsoft/cascadia-code) just
for you — no administrator rights, nothing downloaded.
Preferences → Display → Console setup turns all of this off if you'd rather stay put.
Rename it to something you don't mind typing and put it somewhere on your `PATH`, and it
becomes just `meshterm` from anywhere.
> **Your system will complain the first time, and it's right to.** These builds aren't
> code-signed — signing costs real money on both platforms and this is a free side
> project.
>
> - **macOS**: *"cannot be opened because the developer cannot be verified"* — or, on Sequoia
> and later, *"Apple could not verify…"*, and right-click → Open no longer gets past it.
> The flag that causes it is set by your **browser**, not by macOS, so the easiest answer
> is to not use one:
>
> ```bash
> curl -L -O https://github.com/jpmartineau/MeshTerm/releases/download/v0.3.7/meshterm-0.3.7-macos-arm64
> chmod +x meshterm-0.3.7-macos-arm64 && ./meshterm-0.3.7-macos-arm64
> ```
>
> `curl` marks nothing, so it simply runs — on an Intel Mac, swap `arm64` for `x64`. If it's
> already downloaded, clear the flag instead: `xattr -d com.apple.quarantine meshterm-0.3.7-*`.
> Installing with pipx (below) avoids all of this too, since nothing is downloaded as a binary.
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
>
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
[Terminals, icons, and the Windows console](docs/terminals.md) explains why any of it is
necessary.
> **Trying a build without touching your real data.** MeshTerm keeps everything in
> `~/.meshterm` — your history database, contacts, channel keys — and *every* copy of
> MeshTerm uses that same folder. Set `MESHTERM_HOME` to try one in isolation:
>
> ```bash
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.3.7-* # macOS, Linux
> MESHTERM_HOME=~/meshterm-test ./meshterm # macOS, Linux
> ```
> ```powershell
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.7-windows-x64.exe
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-windows-x64.exe
> ```
>
> Same variable if you run two radios and want them kept apart.
@@ -183,12 +191,17 @@ Setting up for development instead? That's in [CONTRIBUTING.md](CONTRIBUTING.md)
## Documentation
**[The documentation index](docs/README.md)** lists everything, including the two code
surveys kept as a historical record. The short version:
| Read | For |
| --- | --- |
| **[The command line](docs/cli.md)** | Every subcommand and option, what each prints on the plain and JSON faces, the exit statuses, recipes. |
| **[MeshTerm on the PicoCalc](docs/picocalc.md)** | Building the handheld from a stock PicoCalc: the shopping list, swapping in the Lyra, installing Calculinux and MeshTerm, the soldered LoRa radio. |
| **[MeshTerm on the uConsole](docs/uconsole.md)** | A uConsole with the AIO LoRa board: installing the bridge that fronts its SPI radio as a companion, and connecting to it. |
| [MeshTerm on hardware](docs/hardware.md) | Which of the two manuals is yours, and the two platform flavours MeshTerm draws in. |
| **[What MeshTerm does](docs/features.md)** | Every screen the menu offers, and the command that does the same job without it. |
| [The CLI cookbook](docs/cookbook.md) | Common one-liners, by the thing you're trying to do. |
| [Configuring MeshTerm](docs/configuration.md) | Preferences, device profiles, and everything kept under `~/.meshterm`. |
| [MeshTerm on hardware](docs/hardware.md) | Which handheld manual is yours — the [PicoCalc](docs/picocalc.md) build, or the [uConsole](docs/uconsole.md) bridge. |
| [How the code is laid out](docs/architecture.md) | The layering, and where a new feature goes. |
| [CONTRIBUTING.md](CONTRIBUTING.md) | The development setup and the house rules. |
| [CHANGELOG.md](CHANGELOG.md) | What changed, newest first. |
@@ -221,233 +234,47 @@ connects a uConsole's SPI LoRa board through a small bridge. The device-side scr
manuals run are in [`scripts/`](scripts/); not sure which manual is yours?
[`docs/hardware.md`](docs/hardware.md) says.
## Features
## What it does
MeshTerm's menu answers one question — *what would you like to do?* — so its five
sections are named for the doing, not the subject. Every interactive screen is listed
here with its scripted equivalent, where one exists.
The menu asks one question — *what would you like to do?* — and answers it in two halves.
Three sections name a doing: **Message** is chat, channels, the courier outbox and
contacts; **Watch** is the dashboard, the live feed, the watchtower and the time machine;
**Explore** is the map, the mesh walk, tracing and the trophy case. Three name a scope:
**This node** is the radio in your hand, **Other nodes** is someone else's over the mesh,
and **This app** is MeshTerm itself — preferences, diagnostics, and the written pages.
### 💬 Message — the people on the other end
Nearly every one of them has a mirror `meshterm` subcommand — the same registry builds
the menu and the CLI, so they can't drift apart. The live screens, such as the map and
the dashboard, are only in the menu.
| Feature | What it does | Scripted |
| --- | --- | --- |
| **💬 Chat** | Live full-screen channel and direct messaging — a scrolling transcript with a pinned input line where sent and received messages stream together. Every message is logged; unread counts show in the menu header. | `meshterm chat send / history / list` |
| **📻 Channels** | Create, join, reorder, mute, and share mesh channels — with QR codes and `meshcore://` share links. | `meshterm channels list / add / join / import / share / clear` |
| **📨 Courier** | Store-and-forward outbox for contacts that aren't reachable yet. Queue a message; it goes out (with ack tracking and polite exponential backoff) the moment the contact is next heard, or at a scheduled time. | `meshterm courier queue / list / send / cancel / clear` |
| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. The scripted listing is the contacts alone (this node is `meshterm info`'s answer, in far more detail). | `meshterm contacts` |
**[What MeshTerm does](docs/features.md)** is the full catalogue: every screen, what it
does, and its scripted equivalent where one exists.
### 📊 Watch — what the mesh is doing, and what it did
## Scripting it
| Feature | What it does | Scripted |
| --- | --- | --- |
| **📊 Dashboard** | The live mesh overview: a two-hour all-packet activity chart with a pulse line, session traffic tallies by packet class, and window RF health beside the radio's own live numbers. Repaints every second. | — |
| **📰 Live feed** | Every packet as it arrives, newest first: time, what the frame *is* (its parsed payload class), and what it is *about* — the node for an advert, the channel for a channel text, sender → recipient for a direct message or a request, the tag for a trace — with reception quality beside it. Enter opens any row in the packet viewer, route graph and all. | — |
| **🚨 Watchtower** | A passive sentinel over the nodes you star: silence alarms when a watched node goes quiet, SNR-sag warnings when reception degrades, recovery notes when it returns, and a heads-up when a never-before-seen node appears. Runs in the background all session; unacked alerts show as a header badge. | — |
| **⏳ Time machine** | Everything the recorder ever heard, as braille charts: reception volume, the median-SNR band, hour-of-day rhythm, packets and nodes per day, and first-ever arrivals — over a switchable 24h / 7d / 30d / all-time window, per node or mesh-wide. | — |
| **🎧 Monitor** | Passive recording is always on in the app. On the CLI, capture a bounded foreground window, tailing each overheard packet and summarising when it ends. It transmits nothing — it only listens. | `meshterm monitor --seconds 60` |
### 🧭 Explore — where the nodes are and how they reach each other
| Feature | What it does | Scripted |
| --- | --- | --- |
| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | — (a map is a picture; `meshterm contacts` lists the same nodes) |
| **🌐 Mesh walk** | The mesh's *observed shape*, walked one node at a time: an evidence graph built from trace walks, firmware routes, overheard relay chains, and repeater neighbour tables. SNR-coloured braille edges, quality bars, Enter to walk, ⌫ to backtrack, type to find any node. The map answers *where*; the walk answers *how it hangs together*. | — |
| **🎯 Trace target** | A live trace screen: pick a target and watch each trace stream in hop by hop, with running per-hop medians and reliability. Compose or force a route through specific repeaters. A trace transmits **exactly once** (repeaters can blacklist nodes that burst) — sample more by running it again. | `meshterm trace --target …` |
| **👣 Trace path** | The other half of tracing: compose the whole circuit by hand — out and back whichever way you choose — and walk it. The path composer suggests each next hop from the links actually observed, strongest first, and can fetch a repeater's neighbour table over the mesh when you hold its admin password. | `meshterm trace-path --path …` |
| **🏆 Trophy case** | Every trace that comes home is scored, on seven boards: longest distance, farthest node, longest single leg, most nodes (with and without revisits), weakest surviving link, and biggest enclosed loop. Records are kept per hash width, and a record walk must be a *trail* — no link crossed twice the same way. | `meshterm records` |
### 🔧 This node — the radio in your hand
| Feature | What it does | Scripted |
| --- | --- | --- |
| **📋 Device info** | The connected companion's identity and full radio configuration at a glance. | `meshterm info` |
| **🔧 Device config** | Every setting the companion firmware exposes — name, radio, client repeat, auto-add, telemetry, custom variables — *staged* for review and applied in place, plus the operations on the box itself: clock sync, TOML backup/restore, the identity key, reboot, and factory reset, each acting the moment it's confirmed, with destructive ops gated. Laid out like Repeater admin, so the radio in your hand and one over the mesh are configured the same way. | `meshterm config` / `config set <key> <value>` / `config backup` / `reboot` / … |
| **📡 Send advert** | Announce this node to the mesh — a zero-hop or flood advertisement, or share this node's contact card as a QR code. | `meshterm config advert` / `config share` |
| **🔌 Device discovery** | Enumerate serial *and* Bluetooth LE companions, pick one interactively (or auto-select the only one present), add a network (TCP) companion by host:port, and remember the last good default. A dropped link is detected live and offers to reconnect. | `meshterm devices` |
### 🗼 Other nodes — someone else's radio, over the mesh
| Feature | What it does | Scripted |
| --- | --- | --- |
| **🗼 Repeater admin** | Set up remote repeaters and room servers over the mesh: log in (remembered or prompted password), then a config-style editor speaking the node's text CLI — including repeater-only knobs (TX delay, airtime factor, advert intervals) — plus one-shot actions and a readline remote command line. | `meshterm repeater-admin <node> <command…>` |
| **📶 TX optimize** | Sweep a remote node's transmit power live — coarse, then refine, then verify — watch each level land, and decide whether to apply the winner. | `meshterm tx-optimize --path … [--apply]` |
## CLI cookbook
Nearly every menu option is also a subcommand — ideal for scripting, cron, and bots. The
live pictures stay in the menu, where their meaning is (the map, dashboard, live feed,
watchtower and mesh walk); everything else has a command, and every command speaks
`--json`. **[`docs/cli.md`](docs/cli.md) is the full manual**: every command and option,
what each one prints on both faces, and the exit statuses. A taste:
Nearly every command speaks `--json`, and the global options — `--profile/-p`, `--port`,
`--ble`, `--ble-pin`, `--tcp`, `--mock`, `--db`, `--json`, `--absolute`, `--quiet/-q`,
`--platform` — may be typed before or after the subcommand.
```bash
# Trace — a trace transmits exactly once; run it again to sample more.
# Every repeater's public key, for a script
meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
# A trace transmits exactly once; run it again to sample more
meshterm trace --target Alice --profile yagi
# Force a route through specific repeaters (MeshCore-app style): comma-separated
# contact names and/or hex key prefixes, mixed freely. Blank lets the device route.
meshterm trace --target Alice --path "3d,f2,3d"
meshterm trace --target Alice --path "3d,Bravo-Repeater,f2"
# Walk a composed circuit with no target at all — out and back your own way
meshterm trace-path --path "3d,f2,3d"
# Sweep and apply a remote node's TX power
meshterm tx-optimize --path "Bravo-Repeater,Alice" --samples 6 --step 3 --apply
# Messaging: the live transcript is a menu screen; the CLI takes a subcommand
meshterm chat send --to Alice "on my way" # direct message
meshterm chat send --channel 0 "net in 5" # channel broadcast
meshterm chat history --to Alice
meshterm chat list
# Store-and-forward: queue for a contact that's offline right now
# Messaging, and store-and-forward for a contact who isn't there yet
meshterm chat send --channel 0 "net in 5"
meshterm courier queue Alice "ping me when you're back" --at 18:30
meshterm courier list # the outbox — waiting and finished
meshterm courier send 3 # force one delivery attempt now
# Remote repeater admin (one transmission per invocation)
meshterm repeater-admin Bravo-Repeater "get name"
# Device configuration: view, set, back up, restore
meshterm config # show all current settings, one `key value` per line
meshterm config get name # just the value, ready for $(...)
meshterm config set radio_sf 9 # change one setting
meshterm config backup node.toml # archive every setting to TOML
meshterm config restore node.toml --dry-run
meshterm config advert-cadence 2 # auto-advert to neighbours every 2 h (0 = off)
meshterm config advert-cadence 24 --flood # flood the wider mesh daily
# Passive capture window (records to history; transmits nothing)
# The radio's own settings, and a passive capture window
meshterm config set radio_sf 9
meshterm monitor --seconds 60
```
Global options — `--profile/-p`, `--port`, `--ble`, `--ble-pin`, `--tcp`, `--mock`,
`--db`, `--json`, `--absolute`, `--quiet/-q` — may be typed **before or after** the
subcommand: `meshterm contacts --json` and `meshterm --json contacts` are the same run.
### Two output faces
*(The short version — [`docs/cli.md`](docs/cli.md) has the whole of it.)*
The **plain face** is for a person at a prompt. It prints like a standard Unix utility —
no colour, no borders, one record per line, nothing wrapped — and it is allowed to be
comfortable about it: listings are `ps`-style aligned records with **bare names**
(alignment is the delimiter), times are **relative ages** (`now`, `5m`, `never`), and a
route is drawn with arrows — `MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4)`.
`--absolute` swaps every age back for an ISO-8601 instant. A *path*, the spec `--path`
takes back, stays comma-separated hex. `-` is the one token for absent. Errors,
acknowledgements and progress all go to stderr, so a redirect catches only the answer.
The **JSON face** is the machine contract, and `--json` works on **every** command:
```console
$ meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
b2c3d4e500000000000000000000000000000000000000000000000000000000
a1b2c3d400000000000000000000000000000000000000000000000000000000
```
No envelope — an array for a listing, an object for a set of facts. One compact line;
`monitor` and `chat listen` stream one document per record. Values are typed, absent is
`null` and never an omitted key, and timestamps are always UTC to the second regardless of
`--absolute`. `--json` changes the rendering, never the report: same records, same exit
status.
Anything structural should go through `--json` and `jq`. The plain face is for looking at.
### Exit status
`0` success · `1` failure · `2` usage error · `3` no device found (nothing was
transmitted) · `4` the device was reached but the operation failed · `5` nothing to
report. The table with its full wording is printed under `meshterm --help` and explained
in [`docs/cli.md`](docs/cli.md#exit-status).
`5` is the one worth knowing about: it lets a script tell "found nothing" from "worked"
without counting output lines.
```bash
if meshterm contacts > contacts.txt; then
echo "$(wc -l < contacts.txt) contacts"
elif [ $? -eq 5 ]; then
echo "no contacts yet"
fi
```
## Configuration
Two files, two jobs — and you need neither to run.
**Preferences** are how MeshTerm behaves: the pause it leaves between transmissions, how
hard it retries a message, how far back it keeps history, how a map frames itself. Every one has a built-in default, so change them only where you disagree — from
the **Preferences** page in the menu (grouped, staged, saved by one action at the bottom),
or from a shell:
```console
$ meshterm preferences show # every preference, its value, its default
$ meshterm preferences set history_days 90
$ meshterm preferences reset --yes # back to the built-in defaults
```
They are kept in `~/.meshterm/preferences.toml`, which lists only what you have changed;
delete a line and the default takes over again.
**Config** is where things live and which device to talk to. MeshTerm runs with none of it
— it discovers attached serial devices and nearby Bluetooth companions, lets you pick one,
and remembers the last good default. A network (TCP) companion isn't discoverable, so reach
it with `--tcp host:port`, a TCP profile, or the picker's "add a network device" prompt.
Write a config file only to give your hardware stable aliases.
Copy [`config.example.toml`](config.example.toml) to `~/.meshterm/config.toml`:
```toml
default_profile = "s3"
connect_on_start = true # false opens the radio link lazily instead of at launch
[profiles.s3]
port = "COM5"
baudrate = 115200
default_tx_power = 20
description = "XIAO ESP32-S3 + Wio SX1262 serial companion"
# A Bluetooth LE companion: give it an `address` instead of a `port`.
[profiles.handheld]
address = "AA:BB:CC:DD:EE:FF"
# ble_pin = "123456" # only if your device requires a pairing PIN
description = "Pocket handheld over Bluetooth"
# A network (TCP) companion: give it a `host` (and optional `tcp_port`, default 5000).
[profiles.wifi]
host = "192.168.1.50"
description = "Basestation over WiFi"
```
Both live in `~/.meshterm`, along with everything else MeshTerm remembers: the SQLite
history, the outbox, the contact and channel caches, stored admin passwords, and the log.
**`$MESHTERM_HOME` moves the whole directory**, which is the way to run a second radio —
or a `--mock` session — without touching the one you use every day. `--db` moves the
database alone; the rest stays where it was. [`docs/cli.md`](docs/cli.md#where-meshterm-keeps-its-state)
lists every file.
Timestamps are stored as UTC and rendered in your local time. History older than the
`history_days` preference is pruned once at session start.
## Architecture
MeshTerm is layered so a new feature is one file in `meshterm/tools/`:
```
cli.py Typer app; no subcommand -> interactive menu
context.py AppContext (console, config, repository, device) dependency container
core/ Domain: models, preferences + device profiles, connection abstraction (+ mock)
tools/ Pluggable "menu options"; each self-registers and gets logging for free
services/ Background algorithms (monitor, trace, tx search, courier, watchtower) — no UI
persistence/ SQLite schema, repository, structured logging
ui/ Rich theme/widgets + the full-screen TUI (screens, dialogs, map, chat)
```
Adding a feature means subclassing `Tool`, decorating it with `@register`, and
implementing `run()`. The same registry builds both the CLI and the menu, and the base
class wraps every execution in a logged `runs` row automatically.
**[The command line](docs/cli.md)** is the manual — every command and option, what each
prints on both faces, and the exit statuses.
**[The CLI cookbook](docs/cookbook.md)** has more one-liners like these.
## How this project is run
@@ -455,18 +282,19 @@ MeshTerm is one person working evenings and weekends. I'd rather tell you that u
than have you guess from how long things take.
**Issues are welcome — all of them.** Bugs, questions, "is this supposed to do that". The
[bug form](.github/ISSUE_TEMPLATE/bug.yml) asks for a fair bit, and that's on purpose: how
well a problem is described really does decide whether I can do anything with it. If I can
reproduce it, I'll usually chase it. If I can't, I'm mostly guessing.
[bug form](https://github.com/jpmartineau/MeshTerm/issues/new?template=bug.yml) asks for a
fair bit, and that's on purpose: how well a problem is described really does decide whether
I can do anything with it. If I can reproduce it, I'll usually chase it. If I can't, I'm
mostly guessing.
**Ask before you write a pull request.** Open an issue first and wait for a yes. I'm not
being precious — MeshTerm has firm house rules about how screens get built (they're in
**Ask before you write a big pull request.** Small fixes can go straight to a pull
request. For anything bigger, open an issue first and wait for a yes. I'm not being
precious — MeshTerm has firm house rules about how screens get built (they're in
[CLAUDE.md](CLAUDE.md)), and I'd hate for you to spend a weekend on something I then ask
you to rewrite. A quick conversation first saves us both.
**I can't promise timelines.** Some things get fixed the same night. Some sit for a month
because life happened. If your issue goes quiet, a nudge is completely fine — it's not
rude, it's helpful.
because life happened. If your issue goes quiet, please give it a nudge. That helps me.
**Where to report things.** Either works:
@@ -484,19 +312,6 @@ Also here: [CONTRIBUTING.md](CONTRIBUTING.md) ·
[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
[CHANGELOG.md](CHANGELOG.md)
## Development
```bash
pip install -e ".[dev]"
python -m pytest -q # tests
ruff check . # lint
ruff format . # format
```
Screens are designed to stay readable at **72 columns**.
Want to send a pull request? See [CONTRIBUTING.md](CONTRIBUTING.md) first.
## Acknowledgements & attribution
MeshTerm stands on the [MeshCore](https://meshcore.io/) project — its firmware and the
@@ -523,7 +338,7 @@ short form throughout. The About page inside the app spells out the licence URL.
MeshTerm ships **Cascadia Mono PL**, © 2019–present Microsoft Corporation, redistributed
unmodified under the [SIL Open Font License 1.1](meshterm/assets/fonts/CascadiaMono-OFL.txt).
It's offered to Windows users whose console can't draw the charts — see
[A word about terminals, on Windows](#a-word-about-terminals-on-windows). Microsoft doesn't endorse MeshTerm; the font is
[Terminals, icons, and the Windows console](docs/terminals.md). Microsoft doesn't endorse MeshTerm; the font is
simply the right tool, being one of the very few monospace faces that carries the braille
block the timelines are drawn from.
@@ -534,7 +349,7 @@ respective project for the authoritative terms):
| Library | Role | License |
| --- | --- | --- |
| [meshcore](https://pypi.org/project/meshcore/) | Companion-device protocol (serial / BLE) | MIT |
| [meshcore](https://pypi.org/project/meshcore/) | Companion-device protocol (serial / BLE / TCP) | MIT |
| [pycryptodome](https://www.pycryptodome.org/) | AES/HMAC for decrypting overheard channel packets | BSD-2-Clause / Public Domain |
| [pyserial](https://github.com/pyserial/pyserial) | Serial-port enumeration and I/O | BSD-3-Clause |
| [bleak](https://github.com/hbldh/bleak) | Bluetooth LE scanning + connection | MIT |
@@ -563,7 +378,8 @@ is Microsoft's Cascadia Mono PL, redistributed unmodified under the SIL Open Fon
[CascadiaMono-OFL.txt](meshterm/assets/fonts/CascadiaMono-OFL.txt) and stays its only
license.
Each standalone build carries `LICENSE`, `NOTICE` and a generated `THIRD-PARTY-NOTICES.txt`
(every dependency's own license text) inside the archive, and the same three files sit
beside the downloads on the [releases page](https://github.com/jpmartineau/MeshTerm/releases)
so you don't have to extract one just to read them.
Each standalone build is a single file with `LICENSE`, `NOTICE` and a generated
`THIRD-PARTY-NOTICES.txt` (every dependency's own license text) bundled inside it. The same
three files are also attached to each release on the
[releases page](https://github.com/jpmartineau/MeshTerm/releases), so you can read them
without running anything.
+7 -6
View File
@@ -2,10 +2,9 @@
# Copy to ~/.meshterm/config.toml (or pass --db / --port on the CLI).
#
# How MeshTerm itself behaves — transmit cooldown, message retries, how much history it
# keeps, whether it connects at launch — is *not* here. Those are preferences: they live
# in ~/.meshterm/preferences.toml, and are edited on the Preferences page (or with
# `meshterm preferences set <key> <value>`). Every one has a built-in default, so that
# file only ever lists what you have changed.
# keeps — is *not* here. Those are preferences: they live in ~/.meshterm/preferences.toml,
# and are edited on the Preferences page (or with `meshterm preferences set <key> <value>`).
# Every one has a built-in default, so that file only ever lists what you have changed.
#
# Profiles are optional. With no profile, no --port/--ble/--tcp, MeshTerm discovers attached
# serial devices and nearby Bluetooth companions, lets you pick one (interactively) or uses the
@@ -14,7 +13,10 @@
# the picker's "add a network device" prompt. Define profiles below only to give your hardware
# stable aliases (e.g. --profile yagi, --profile handheld for Bluetooth, --profile wifi for TCP).
default_profile = "s3"
# Uncomment and name one of your own profiles below to make it the default. Left
# commented, MeshTerm finds the device itself, as described above.
# default_profile = "s3"
# Open the radio link as soon as the menu appears. False opens it lazily instead — only
# once monitoring is turned on or a tool first needs the radio.
connect_on_start = true
@@ -24,7 +26,6 @@ connect_on_start = true
[profiles.s3]
port = "COM5"
baudrate = 115200
default_tx_power = 20
description = "XIAO ESP32-S3 + Wio SX1262 serial companion"
[profiles.yagi]
+50
View File
@@ -0,0 +1,50 @@
# MeshTerm documentation
Everything that didn't belong on the front page. The [README](../README.md) is the front
door — what MeshTerm is, how to install it, and enough to get a first session running.
This folder is the rest.
## Using MeshTerm
| Read | For |
| --- | --- |
| **[What MeshTerm does](features.md)** | Every screen the menu offers, grouped the way the menu groups them, with the command that does the same job without it. |
| **[The command line](cli.md)** | The manual. Every subcommand and option, what each prints on the plain and JSON faces, the exit statuses, and recipes built on `jq`. |
| [The CLI cookbook](cookbook.md) | Common one-liners by task, for when you want the shape of a command rather than its specification. |
| [Configuring MeshTerm](configuration.md) | Preferences versus config, device profiles, and everything kept under `~/.meshterm`. |
| [Terminals, icons, and the Windows console](terminals.md) | Why the icons and charts draw on some terminals and not others, and what MeshTerm does about it on Windows. |
## Putting it on hardware
Only if you want MeshTerm running on a handheld's own screen. A MeshCore companion on
USB, Bluetooth or Wi-Fi needs none of this.
| Read | For |
| --- | --- |
| **[MeshTerm on hardware](hardware.md)** | Start here — which of the two manuals below is yours, and what the device-side scripts do. |
| [MeshTerm on the PicoCalc](picocalc.md) | A stock ClockworkPi PicoCalc to a Linux handheld with a LoRa radio soldered inside: the shopping list, the Lyra swap, Calculinux, and the radio. |
| [MeshTerm on the uConsole](uconsole.md) | A uConsole with the hackergadgets AIO LoRa board, whose SX1262 sits on the host's own SPI bus, fronted as a companion by a small bridge. |
## Reading the code
| Read | For |
| --- | --- |
| [How the code is laid out](architecture.md) | The layering, and where a new feature goes. |
| [CLAUDE.md](../CLAUDE.md) | The actual style guide — terminology, UX standards, and the reusable building blocks. Written for AI assistants, and the house rules for everyone. |
| [CONTRIBUTING.md](../CONTRIBUTING.md) | The dev install, the three gates a change has to pass, and what happens to your contribution's licensing. |
## Historical record
A survey of the code, kept because a project that writes down its own problems is easier
to trust than one that doesn't — but it is a **snapshot of a date, not a to-do list**.
Some of what it describes has been fixed since, and it does not mark which.
| Read | For |
| --- | --- |
| [UI consistency survey](survey-ui-consistency.md) | Everything else in the CLAUDE.md UX standard — six sweeps found nothing, which is the useful part. |
## Also here
`screenshots/` holds the captures the README shows and the ones
[meshterm.net](https://meshterm.net) builds its landing page from, so a file in there is
referenced from more than one place.
+23
View File
@@ -0,0 +1,23 @@
# How the code is laid out
A map of the package for anyone reading it for the first time. The house rules that
govern what goes where are in [CLAUDE.md](../CLAUDE.md), which is the real style guide;
[CONTRIBUTING.md](../CONTRIBUTING.md) has the dev install and the gates a change has to
pass.
MeshTerm is layered so a new feature is one file in `meshterm/tools/`:
```
cli.py Typer app; no subcommand -> interactive menu
context.py AppContext (console, config, repository, device) dependency container
core/ Domain: models, preferences + device profiles, connection abstraction (+ mock)
tools/ Pluggable "menu options"; each self-registers and gets logging for free
services/ Background algorithms (monitor, trace, tx search, courier, watchtower) — no UI
persistence/ SQLite schema, repository, structured logging
ui/ Rich theme/widgets + the full-screen TUI (screens, dialogs, map, chat)
```
Adding a feature means subclassing `Tool`, decorating it with `@register`, and
implementing `run()`. The same registry builds both the CLI and the menu, and the base
class wraps every execution in a logged `runs` row automatically.
+219 -70
View File
@@ -13,13 +13,11 @@ It prints two ways, and the difference is who is reading.
`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.
not move. It works on **every** command.
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.
The promise to be read by a program belongs to `--json`. The plain face makes no such
promise. If you are splitting output on whitespace to feed a script, stop: `--json` exists
so you do not have to, and the plain face may change between releases.
- [Running it](#running-it)
- [Where MeshTerm keeps its state](#where-meshterm-keeps-its-state)
@@ -57,8 +55,8 @@ With no command, MeshTerm launches the interactive menu instead.
| `--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). |
| `--mock` | Use the built-in simulator instead of real hardware. Nothing transmits, and the run records to `meshterm-mock.db` rather than your real history. |
| `--db PATH` | Use this SQLite database instead of `~/.meshterm/meshterm.db` (or `meshterm-mock.db`, under `--mock`). **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). |
@@ -97,6 +95,7 @@ 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.** |
| `meshterm-mock.db` | The same, for the simulator: where a `--mock` run records when it doesn't name a database itself. Absent until you run one. |
| `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. |
@@ -114,14 +113,16 @@ weight but none of the identity: the contact and channel caches, the outbox, the
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.
database somewhere else again. The same holds for the database `--mock` picks for itself:
it keeps the simulator's invented mesh out of your history, not out of the caches.
---
## 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
(`--mock`), in a throwaway `MESHTERM_HOME`, except the `diagnostics` sample, which says so
where it appears. 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.
@@ -140,11 +141,13 @@ Three things the simulator does that a radio does not, all visible in the sample
*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.
> **`--mock` records into its own history, not yours — but it still writes the caches.**
> The simulator is a fake radio, not a fake MeshTerm: what it adverts is written down like
> anything else. The database it writes is `meshterm-mock.db`, so its invented nodes stay
> out of your mesh walk, dashboard and map; naming a database with `--db` overrides that
> choice. The contact and channel caches, the outbox and the remembered devices are still
> the ones you use every day, so a demo that should touch nothing at all gets a home of
> its own: `MESHTERM_HOME=... meshterm --mock …`.
---
@@ -172,11 +175,10 @@ Yagi-Repeater repeater 7m - a1 45.50190,-73.56740 a1b2c3d40000000
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.
Names are not quoted. Quoting would give a script a delimiter and give every person a
line full of punctuation. Names **are escaped**: 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 —
@@ -186,11 +188,11 @@ the device clock, an appointment set with `--at`, a live capture's own `TIME` co
```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
NAME TYPE HEARD PKTS HASH LOCATION KEY
Observer-Bot companion 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 companion 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
@@ -256,12 +258,12 @@ 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
`support`), which 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:
wrong`), progress bars, log records, acknowledgements and closing messages:
```console
$ meshterm config set radio_sf 9
@@ -271,10 +273,9 @@ $ meshterm config set radio_sf 9 2>&1
```
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.
records stored") are reassurance a person needs and a script does not. Sending them to
stderr means `meshterm contacts > contacts.txt` puts contacts in the file and nothing
else, while the person at the prompt still sees their tick and their count.
---
@@ -373,7 +374,7 @@ present.
| `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`. |
| `type` | string \| null | `"companion"` \| `"repeater"` \| `"room server"` \| `"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:
@@ -424,7 +425,7 @@ 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. |
| `1` | Failure with no more specific code — an unreadable file, an unknown setting or preference key (`config set`, `preferences set`), 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. |
@@ -432,9 +433,8 @@ The return value is half the report, on both faces.
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
Two boundaries need stating. **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.
@@ -617,11 +617,11 @@ table has no room for.
| 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. |
| `transport` | string | `"serial"` \| `"ble"` \| `"tcp"`, or `"mock"` under `--mock`. |
| `port` | string \| null | The serial port, for a serial device. `""` on the `--mock` row. |
| `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. |
| `hardware` | string \| null | The confirmed hardware model, else the USB vendor label. `""` on the `--mock` row. |
| `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 | |
@@ -630,6 +630,9 @@ table has no room for.
| `remembered` | boolean | Whether it is the remembered default. |
| `active` | boolean | Whether this invocation is (or would be) using it. |
Under `--mock` there is no scan. The list is one row for the simulator: `target`
`"--mock"`, `transport` `"mock"`, `stable_id` `"mock:simulator"`.
Returns `5` when nothing is found.
---
@@ -682,10 +685,11 @@ always present:
{"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.
`role` is `"companion"` \| `"repeater"` \| `"room server"` \| `"sensor"` \| `null`, or
`"type N"` for an advert type MeshTerm does not know. 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
@@ -724,10 +728,12 @@ radio_bw 62.5
radio_sf 8
radio_cr 5
tx_power 20
client_repeat false
airtime_factor 0.0
rx_delay 0.0
manual_add_contacts false
autoadd_config 0
autoadd_max_hops 0
flood_scope ""
adv_loc_policy 0
multi_acks 0
@@ -788,7 +794,7 @@ $ meshterm config advert-cadence 6 --json
$ 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}
{"path":"node.toml","settings":22,"channels":0,"custom":0}
$ meshterm config restore node.toml --dry-run --json
{"dry_run":true,"changes":0}
$ meshterm config export-key --json
@@ -801,8 +807,8 @@ $ meshterm config share --json
`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
because the command did what it was asked. `backup`'s `path` is the file written, as you
named it (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}`.
@@ -823,20 +829,20 @@ The contacts your device knows.
```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
NAME TYPE HEARD PKTS HASH LOCATION KEY
Alice companion 7m - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000
Local-Repeater repeater 7m - b2 45.47680,-73.59900 b2c3d4e500000000000000000000000000000000000000000000000000000000
Observer-Bot companion 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
`TYPE` is the node's advertised role in words — `companion`, `repeater`, `room server`,
`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
**`HASH` is the token `--path` and `--to` take**, so it never has to be sliced out of `KEY`
by hand, where getting the width wrong would address 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.
@@ -847,7 +853,7 @@ 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}}
{"node":{"name":"Observer-Bot","key":"c3d4e5f600000000000000000000000000000000000000000000000000000000","hash":"c3","type":"companion","self":false},"heard_at":"2026-09-08T08:30:50Z","packets":null,"position":{"lat":45.488,"lon":-73.581}}
```
| Field | Type | Meaning |
@@ -876,7 +882,6 @@ TIME NODE SNR_DB RSSI_DBM LOCATION NAME
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 -
@@ -884,9 +889,10 @@ b2c3d4e5 Local-Repeater 132 +6.7 +14.9 -102 now 45.
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
Two blocks, one straight after the other: the summary starts at its own `NODE` header
line, with no blank line before it. The sample shows only the first four stream records.
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.
@@ -968,7 +974,7 @@ prints, honestly.
| `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. |
| `path` | string \| null | **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. `null` where the record stored no spec. |
| `route` | array | The `route` shape, hop-aligned with `path`. |
Returns `5` and `[]` when no records match.
@@ -1044,7 +1050,7 @@ for working out whether messages are being pulled from the companion at all.
```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}
{"kind":"direct","node":{"name":"Alice","key":"d4e5f6a700000000000000000000000000000000000000000000000000000000","hash":"d4e5f6a7","type":"companion","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}
@@ -1329,7 +1335,7 @@ single transmission.
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}]}
{"tuning_node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"target":{"name":"Alice","key":"d4e5f6a700000000000000000000000000000000000000000000000000000000","hash":"d4e5f6a7","type":"companion","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
@@ -1405,20 +1411,33 @@ runs `show`.
```console
$ meshterm preferences show
PREFERENCE VALUE DEFAULT DESCRIPTION
set_clock_on_connect off off Set the device clock from this computer when it connects
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
tx_snr_tolerance_db 1 1 Signals this close are a tie, so less power wins
watch_silence_hours 12 12 Warn when a watched node goes quiet this long
watch_alerts_kept 200 200 How many past alerts to keep
map_view_fraction 0.5 0.5 Share of nodes to fit on screen; 1 shows them all
basemap_tilejson_url https://tiles.openfreemap.org/planet https://tiles.openfreemap.org/planet Where map images come from (next launch)
history_days 365 365 Older ones are deleted; 0 keeps everything
chat_history_limit 200 200 Old messages to load when you open a chat
courier_history_kept 100 100 Finished outbox messages to keep
cli_time_format relative relative Whether the command line prints ages or timestamps
fast_render on on Faster screen updates; turn off if it looks wrong (next launch)
full_width auto auto Use the extra column some terminals hide
color_depth auto auto How many colours to send this terminal (next launch)
console_setup auto auto Move to a better terminal when this console can't draw MeshTerm
console_font 6x12 6x12 Bigger text, or more rows on the handheld's panel
log_level WARNING WARNING How much MeshTerm writes to its log file (next launch)
```
**`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.
**`DESCRIPTION`** says what each preference does, because a key's name is not its
meaning. 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
@@ -1460,7 +1479,7 @@ 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"]}
{"page":"discord","title":"Join Discord","version":"0.9.0","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 |
@@ -1476,9 +1495,139 @@ bothers to draw a QR of. `text` is the page's plain rendering, not a fold of the
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.
This sample is the one exception to [About the examples](#about-the-examples): it is an
illustrative report from a real radio over Bluetooth, because the simulator's report says
little. The field names and layout are what the command prints; the values are examples.
```console
$ meshterm diagnostics
meshterm 0.9.0
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 companion
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\you\.meshterm
db_size_kb 41280
log_level DEBUG
log_size_kb 96
runs_failed 3
first_heard 2026-01-14T19:42:10-05:00
last_heard 2026-09-21T11:03:00-04:00
TABLE ROWS
app_state 4
discovered_paths 1287
messages 3316
neighbour_reports 214
observations 418203
path_candidates 96
runs 912
schema_meta 1
trace_hops 233
traces 47
tx_samples 68
PREFERENCE VALUE
log_level DEBUG
history_days 730
```
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.9.0","install":"frozen","os":"Windows 11","…":"…","tables":[{"table":"app_state","rows":4},"…"],"preferences":[{"preference":"log_level","value":"DEBUG"},{"preference":"history_days","value":"730"}]}
```
**`--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.
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`
A diagnostic for which UI flavour a given invocation resolves to, and why.
The narrow sibling of `diagnostics`: which UI flavour a given invocation resolves to, and
why, without asking the radio anything.
```console
$ meshterm platform
+63
View File
@@ -0,0 +1,63 @@
# Configuring MeshTerm
Preferences, device profiles, and where everything is kept. You need none of it to run
MeshTerm: it discovers a companion, lets you pick one, and remembers the choice.
Two files, two jobs — and you need neither to run.
**Preferences** are how MeshTerm behaves: the pause it leaves between transmissions, how
hard it retries a message, how far back it keeps history, how a map frames itself. Every one has a built-in default, so change them only where you disagree — from
the **Preferences** page in the menu (grouped, staged, saved by one action at the bottom),
or from a shell:
```console
$ meshterm preferences show # every preference, its value, its default
$ meshterm preferences set history_days 90
$ meshterm preferences reset --yes # back to the built-in defaults
```
They are kept in `~/.meshterm/preferences.toml`, which lists only what you have changed;
delete a line and the default takes over again.
**Config** is where things live and which device to talk to. MeshTerm runs with none of it
— it discovers attached serial devices and nearby Bluetooth companions, lets you pick one,
and remembers the last good default. A network (TCP) companion isn't discoverable, so reach
it with `--tcp host:port`, a TCP profile, or the picker's "add a network device" prompt.
Write a config file only to give your hardware stable aliases.
Copy [`config.example.toml`](../config.example.toml) to `~/.meshterm/config.toml`:
```toml
# default_profile = "s3" # uncomment and name your own profile to make it the default
connect_on_start = true # false opens the radio link lazily instead of at launch
[profiles.s3]
port = "COM5"
baudrate = 115200
description = "XIAO ESP32-S3 + Wio SX1262 serial companion"
# A Bluetooth LE companion: give it an `address` instead of a `port`.
[profiles.handheld]
address = "AA:BB:CC:DD:EE:FF"
# ble_pin = "123456" # only if your device requires a pairing PIN
description = "Pocket handheld over Bluetooth"
# A network (TCP) companion: give it a `host` (and optional `tcp_port`, default 5000).
[profiles.wifi]
host = "192.168.1.50"
description = "Basestation over WiFi"
```
Both live in `~/.meshterm`, along with everything else MeshTerm remembers: the SQLite
history, the outbox, the contact and channel caches, stored admin passwords, and the log.
**`$MESHTERM_HOME` moves the whole directory**, which is the way to run a second radio —
or a `--mock` session — without touching the one you use every day. `--db` moves the
database alone; the rest stays where it was. A `--mock` run that names neither writes to
`meshterm-mock.db` beside the real history, so the simulator's invented nodes never enter
it. [`docs/cli.md`](cli.md#where-meshterm-keeps-its-state)
lists every file.
Timestamps are stored as UTC and rendered in your local time. Heard packets older than the
`history_days` preference are deleted when monitoring starts: once in each menu session,
and at the start of each `meshterm monitor` run. Nothing else is pruned.
+109
View File
@@ -0,0 +1,109 @@
# The CLI cookbook
Common one-liners, by the thing you are trying to do. [The command line](cli.md) is the
full manual — every command, every option, both output faces, the exit statuses — and its
own [Recipes](cli.md#recipes) section goes further than this one, into `jq` pipelines and
shell loops.
Nearly every menu option is also a subcommand — ideal for scripting, cron, and bots. The
live pictures stay in the menu, where their meaning is (the map, dashboard, live feed,
watchtower and mesh walk); everything else has a command, and every command speaks
`--json`. **[`docs/cli.md`](cli.md) is the full manual**: every command and option,
what each one prints on both faces, and the exit statuses. A taste:
```bash
# Trace — a trace transmits exactly once; run it again to sample more.
meshterm trace --target Alice --profile yagi
# Force a route through specific repeaters (MeshCore-app style): comma-separated
# contact names and/or hex key prefixes, mixed freely. Blank lets the device route.
meshterm trace --target Alice --path "3d,f2,3d"
meshterm trace --target Alice --path "3d,Bravo-Repeater,f2"
# Walk a composed circuit with no target at all — out and back your own way
meshterm trace-path --path "3d,f2,3d"
# Sweep and apply a remote node's TX power
meshterm tx-optimize --path "Bravo-Repeater,Alice" --samples 6 --step 3 --apply
# Messaging: the live transcript is a menu screen; the CLI takes a subcommand
meshterm chat send --to Alice "on my way" # direct message
meshterm chat send --channel 0 "net in 5" # channel broadcast
meshterm chat history --to Alice
meshterm chat list
# Store-and-forward: queue for a contact that's offline right now
meshterm courier queue Alice "ping me when you're back" --at 18:30
meshterm courier list # the outbox — waiting and finished
meshterm courier send 3 # force one delivery attempt now
# Remote repeater admin (one transmission per invocation). --password is needed
# until an interactive login has stored one.
meshterm repeater-admin Bravo-Repeater "get name" --password YOUR-ADMIN-PASSWORD
# Device configuration: view, set, back up, restore
meshterm config # show all current settings, one `key value` per line
meshterm config get name # just the value, ready for $(...)
meshterm config set radio_sf 9 # change one setting
meshterm config backup node.toml # archive every setting to TOML
meshterm config restore node.toml --dry-run
meshterm config advert-cadence 2 # auto-advert to neighbours every 2 h (0 = off)
meshterm config advert-cadence 24 --flood # flood the wider mesh daily
# Passive capture window (records to history; transmits nothing)
meshterm monitor --seconds 60
```
Global options — `--profile/-p`, `--port`, `--ble`, `--ble-pin`, `--tcp`, `--mock`,
`--db`, `--json`, `--absolute`, `--quiet/-q`, `--platform` — may be typed **before or
after** the subcommand: `meshterm contacts --json` and `meshterm --json contacts` are the
same run.
## Two output faces
*(The short version — [`docs/cli.md`](cli.md) has the whole of it.)*
The **plain face** is for a person at a prompt. It prints like a standard Unix utility —
no colour, no borders, one record per line, nothing wrapped except the four written
pages (`about`, `about-author`, `discord`, `support`), which wrap at 72 cells — and it is
allowed to be comfortable about it: listings are `ps`-style aligned records with **bare names**
(alignment is the delimiter), times are **relative ages** (`now`, `5m`, `never`), and a
route is drawn with arrows — `MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4)`.
`--absolute` swaps every age back for an ISO-8601 instant. A *path*, the spec `--path`
takes back, stays comma-separated hex. `-` is the one token for absent. Errors,
acknowledgements and progress all go to stderr, so a redirect catches only the answer.
The **JSON face** is the machine contract, and `--json` works on **every** command:
```console
$ meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
b2c3d4e500000000000000000000000000000000000000000000000000000000
a1b2c3d400000000000000000000000000000000000000000000000000000000
```
No envelope — an array for a listing, an object for a set of facts. One compact line;
`monitor` and `chat listen` stream one document per record. Values are typed, absent is
`null` and never an omitted key, and timestamps are always UTC to the second regardless of
`--absolute`. `--json` changes the rendering, never the report: same records, same exit
status.
Anything structural should go through `--json` and `jq`. The plain face is for looking at.
## Exit status
`0` success · `1` failure · `2` usage error · `3` no device found (nothing was
transmitted) · `4` the device was reached but the operation failed · `5` nothing to
report. The table with its full wording is printed under `meshterm --help` and explained
in [`docs/cli.md`](cli.md#exit-status).
`5` is the one worth knowing about: it lets a script tell "found nothing" from "worked"
without counting output lines.
```bash
if meshterm contacts > contacts.txt; then
echo "$(($(wc -l < contacts.txt) - 1)) contacts" # minus the header
elif [ $? -eq 5 ]; then
echo "no contacts yet"
fi
```
+71
View File
@@ -0,0 +1,71 @@
# What MeshTerm does
Every screen the menu offers, and the command that does the same job without it. This is
the catalogue; [the command line](cli.md) is the manual for the scripted half of it.
MeshTerm's menu asks one question — *what would you like to do?* — and its six sections
answer it in two halves. The first three name a **doing**: Message, Watch, Explore. The
last three name the **scope** a setting belongs to: This node, the radio in your hand;
Other nodes, someone else's over the mesh; and This app, the program in front of you.
Every interactive screen is listed here with its scripted equivalent, where one exists.
## 💬 Message — the people on the other end
| Feature | What it does | Scripted |
| --- | --- | --- |
| **💬 Chat** | Live full-screen channel and direct messaging — a scrolling transcript with a pinned input line where sent and received messages stream together. Every message is logged; unread counts show in the menu header. | `meshterm chat send / history / list` |
| **📻 Channels** | Create, join, reorder, mute, and share mesh channels — with QR codes and `meshcore://` share links. | `meshterm channels list / add / join / import / share / clear` |
| **📨 Courier** | Store-and-forward outbox for contacts that aren't reachable yet. Queue a message; it goes out (with ack tracking and polite exponential backoff) the moment the contact is next heard, or at a scheduled time. | `meshterm courier queue / list / send / cancel / clear` |
| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. The scripted listing is the contacts alone (this node is `meshterm info`'s answer, in far more detail). | `meshterm contacts` |
## 📊 Watch — what the mesh is doing, and what it did
| Feature | What it does | Scripted |
| --- | --- | --- |
| **📊 Dashboard** | The live mesh overview: a two-hour all-packet activity chart with a pulse line, session traffic tallies by packet class, and window RF health beside the radio's own live numbers. Repaints every second. | — |
| **📰 Live feed** | Every packet as it arrives, newest first: time, what the frame *is* (its parsed payload class), and what it is *about* — the node for an advert, the channel for a channel text, sender → recipient for a direct message or a request, the tag for a trace — with reception quality beside it. Enter opens any row in the packet viewer, route graph and all. | — |
| **🚨 Watchtower** | A passive sentinel over the nodes you star: silence alarms when a watched node goes quiet, SNR-sag warnings when reception degrades, recovery notes when it returns, and a heads-up when a never-before-seen node appears. Runs in the background all session; unacked alerts show as a header badge. | — |
| **⏳ Time machine** | Everything the recorder ever heard, as braille charts: reception volume, the median-SNR band, hour-of-day rhythm, packets and nodes per day, and first-ever arrivals — over a switchable 24h / 7d / 30d / all-time window (on the PicoCalc the windows stop at 30d), per node or mesh-wide. | — |
| **🎧 Monitor** | Passive recording is always on in the app. On the CLI, capture a bounded foreground window, tailing each overheard packet and summarising when it ends. It transmits nothing — it only listens. | `meshterm monitor --seconds 60` |
## 🧭 Explore — where the nodes are and how they reach each other
| Feature | What it does | Scripted |
| --- | --- | --- |
| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | — (a map is a picture; `meshterm contacts` lists the same nodes) |
| **🌐 Mesh walk** | The mesh's *observed shape*, walked one node at a time: an evidence graph built from trace walks, firmware routes, overheard relay chains, and repeater neighbour tables. SNR-coloured braille edges, quality bars, Enter to walk, ⌫ to backtrack, type to find any node. The map answers *where*; the walk answers *how it hangs together*. | — |
| **🎯 Trace target** | A live trace screen: pick a target and watch each trace stream in hop by hop, with running per-hop medians and reliability. Compose or force a route through specific repeaters. A trace transmits **exactly once** (repeaters can blacklist nodes that burst) — sample more by running it again. | `meshterm trace --target …` |
| **👣 Trace path** | The other half of tracing: compose the whole circuit by hand — out and back whichever way you choose — and walk it. The path composer suggests each next hop from the links actually observed, strongest first, and can fetch a repeater's neighbour table over the mesh when you hold its admin password. | `meshterm trace-path --path …` |
| **🏆 Trophy case** | Every trace that comes home is scored, on seven boards: longest distance, farthest node, longest single leg, most nodes (with and without revisits), weakest surviving link, and biggest enclosed loop. Records are kept per hash width, and a record walk must be a *trail* — no link crossed twice the same way. | `meshterm records` |
## 🔧 This node — the radio in your hand
| Feature | What it does | Scripted |
| --- | --- | --- |
| **📋 Device info** | The connected companion's identity and full radio configuration at a glance. | `meshterm info` |
| **🔧 Device config** | Every setting the companion firmware exposes — name, radio, client repeat, auto-add, telemetry, custom variables — *staged* for review and applied in place, plus the operations on the box itself: clock sync, TOML backup/restore, the identity key, reboot, and factory reset, each acting the moment it's confirmed, with destructive ops gated. Laid out like Repeater admin, so the radio in your hand and one over the mesh are configured the same way. | `meshterm config` / `config set <key> <value>` / `config backup` / `config reboot` / … |
| **📡 Send advert** | Announce this node to the mesh — a zero-hop or flood advertisement, or share this node's contact card as a QR code. | `meshterm config advert` / `config share` |
| **🔌 Devices** | Not a menu screen. The companion is picked on the startup splash, which enumerates serial *and* Bluetooth LE companions (or auto-selects the only one present), adds a network (TCP) companion by host:port, and remembers the last good default. A dropped link is detected live and offers to reconnect. `meshterm devices` lists what discovery finds. | `meshterm devices` |
## 🗼 Other nodes — someone else's radio, over the mesh
| Feature | What it does | Scripted |
| --- | --- | --- |
| **🗼 Repeater admin** | Set up remote repeaters and room servers over the mesh: log in (remembered or prompted password), then a config-style editor speaking the node's text CLI — including repeater-only knobs (TX delay, airtime factor, advert intervals) — plus one-shot actions and a readline remote command line. | `meshterm repeater-admin <node> <command…>` |
| **📶 TX optimize** | Sweep a remote node's transmit power live — coarse, then refine, then verify — watch each level land, and decide whether to apply the winner. The scripted form applies the winner unless told not to. | `meshterm tx-optimize --path … [--no-apply]` |
## ⚙ This app — the program in front of you
The last scope, and the one nothing on the mesh can answer. Preferences leads it because
it is the only row here that *changes* MeshTerm rather than describing it; the written
pages close it.
| Feature | What it does | Scripted |
| --- | --- | --- |
| **⚙ Preferences** | How MeshTerm behaves, in eight groups: Device, Sending, TX optimize, Watchtower, Map, History, Display and Diagnostics. Grouped and staged, saved by one action at the bottom, and written to `preferences.toml` as only the values you changed. [Configuring MeshTerm](configuration.md) has the detail. | `meshterm preferences show / set / reset` |
| **🩺 Diagnostics** | Everything a bug report opens with, in one block: which build this is and how it was installed, the OS, the terminal and its size, the boot-time verdicts that show on no screen (icons, powerline, platform flavour and why), the radio and its link, and how much history is stored. Nothing private — no keys, passwords, positions or contact names; the mesh is described as per-table counts and the span they cover. `s` saves it beside the log for attaching. | `meshterm diagnostics [--out PATH]` |
| **📖 About MeshTerm** | What MeshTerm is, where it came from, and the terms it ships under — including the map and font credits and the licence URL. | `meshterm about` |
| **👤 About the author** | The person behind MeshTerm, on the mesh and off it. | `meshterm about-author` |
| **🔗 Join Discord** | The community server: one invite link, and a QR of it for a phone to read. | `meshterm discord` |
| **💰 Support MeshTerm** | What keeps MeshTerm going, and the ways — paid and unpaid — to help it along. | `meshterm support` |
+3 -3
View File
@@ -36,10 +36,10 @@ directly, or a pump in front of it, never both.
| File | Runs on | What it does |
| --- | --- | --- |
| [`picocalc/calculinux-setup.sh`](../scripts/picocalc/calculinux-setup.sh) | the Lyra (root) | One-time bring-up: python packages, the Wi-Fi boot-scan kick, a boot clock sync (there is no RTC), the deploy user, the checkout, the venv and `pip install -e .`, the login PATH, and the console font. Idempotent. |
| [`picocalc/calculinux-console-font-6x12.sh`](../scripts/picocalc/calculinux-console-font-6x12.sh) | the Lyra (root) | Just the console font: builds, installs and persists the 512-glyph **meshterm** PSF (braille, node marks, rounded frame corners, the list cursor) and restores the stock 16-slot palette. Calls the 6×8 build below when it sits alongside. Run it alone after a MeshTerm update. |
| [`picocalc/calculinux-console-font-6x12.sh`](../scripts/picocalc/calculinux-console-font-6x12.sh) | the Lyra (root) | Just the console font: builds, installs and persists the 512-glyph **meshterm** PSF (braille, node marks, rounded frame corners, the list cursor) and restores the stock 16-slot palette. Calls the 6×8 build below when it sits alongside. Run it alone after a MeshTerm update, from the pulled checkout (`/home/meshterm/MeshTerm/scripts/picocalc/`) rather than the copy made at install time. |
| [`picocalc/calculinux-console-font-6x8.sh`](../scripts/picocalc/calculinux-console-font-6x8.sh) | the Lyra (root) | The shorter-cell companion font, **meshterm8** (6×8 → 53×40), for anyone who wants more rows than the 6×12 default gives. Its base bitmap is the kernel's `font_6x8`, so this one file and the PSF it builds are **GPL-2.0-only** ([`LICENSE.GPL-2.0`](../scripts/picocalc/LICENSE.GPL-2.0) sits beside it); it reads the donor/alias/keeper tables out of the script above rather than keeping a second copy. Pick between the two fonts from MeshTerm's Preferences page. |
| [`picocalc/calculinux-wifi-set.sh`](../scripts/picocalc/calculinux-wifi-set.sh) | the Lyra (sudo) | Interactive Wi-Fi join: prompts for an SSID and passphrase, writes iwd's credentials, connects now. This is how a network *becomes* known to the boot kick. |
| [`picocalc/calculinux-radio-bridge.sh`](../scripts/picocalc/calculinux-radio-bridge.sh) | the Lyra (root) | **Unfinished, unsupported.** An early UART-to-TCP pump, superseded by `xiao-radio/lyra-setup.sh`; its header describes routing it does not contain. See the manual before reading it. |
| [`picocalc/calculinux-radio-bridge.sh`](../scripts/picocalc/calculinux-radio-bridge.sh) | the Lyra (root) | **Unfinished, unsupported.** An early UART-to-TCP pump: it serves `/dev/ttyS1` on TCP port 5000 and writes a `radio` TCP profile. It does not route UART1 to the header pads. Superseded by `xiao-radio/lyra-setup.sh`, which does the routing and points MeshTerm at the serial port directly. |
| [`picocalc/xiao-radio/`](../scripts/picocalc/xiao-radio/) | see below | Everything for the XIAO nRF52840 + Wio-SX1262 radio wired to the Lyra's UART1; its [bench card](../scripts/picocalc/xiao-radio/README.md) has the wiring table. The firmware is MeshCore, MIT-licensed — [`LICENSE.MeshCore`](../scripts/picocalc/xiao-radio/LICENSE.MeshCore) sits beside the patch. |
| [`xiao-radio/build-firmware.sh`](../scripts/picocalc/xiao-radio/build-firmware.sh) | dev machine | Clones and patches MeshCore, builds the `Xiao_nrf52_companion_radio_serial` environment, emits the `.uf2`. |
| [`xiao-radio/flash.py`](../scripts/picocalc/xiao-radio/flash.py) | dev machine | Flashes the `.uf2` to the XIAO over its UF2 bootloader, refusing the wrong board or SoftDevice; prints the serial-DFU command when the bootloader offers no drive. |
@@ -54,7 +54,7 @@ MeshTerm resolves one frozen **platform** at boot and draws everything through i
**regular** platform is a desktop or ssh terminal: 72 readable columns, truecolour, emoji,
a footer hint line. The **picocalc** platform is a framebuffer console: 53 columns, no
emoji, an F-key lane instead of a hint line, and sixteen colour slots rather than a colour
space — nothing loses its colour there, the node hue and the recency heat gradient
space. Nothing loses its colour there: the node hue and the recency heat gradient
quantise to those slots instead. It is detected from the device tree on the Lyra, and
`--platform picocalc` or `MESHTERM_PLATFORM=picocalc` forces it anywhere, which is how you
preview the handheld layout on a desktop:
+83 -57
View File
@@ -65,9 +65,9 @@ a radio in the meantime.
| Item | Product / what to search for | Approx. price | Link | Note |
| --- | --- | --- | --- | --- |
| PicoCalc kit | ClockworkPi PicoCalc | $89 | [clockworkpi.com/product-page/picocalc](https://www.clockworkpi.com/product-page/picocalc) | Comes with a Raspberry Pi Pico 1H, screen, keyboard, shell, hex key and a 32 GB SD card. **18650 batteries are not included.** |
| Luckfox Lyra | "Luckfox Lyra 128MB, pre-soldered header, no NAND" | $20–25 | [luckfox.com/Luckfox-Lyra](https://www.luckfox.com/Luckfox-Lyra) | Buy the **plain 128 MB RAM, Pico-form-factor** variant, **with the header pre-soldered**. See the note below — this is the one part of the list where the wrong SKU won't work. |
| Luckfox Lyra | "Luckfox Lyra 128MB, pre-soldered header, no NAND" | $20–25 | [luckfox.com/Luckfox-Lyra](https://www.luckfox.com/Luckfox-Lyra) | Buy the **plain 128 MB RAM, Pico-form-factor** variant, **with the header pre-soldered**. Other RAM sizes have incompatible pinouts, so this is the one part of the list where the wrong SKU won't work. |
| microSD card | 16 GB+, Class 10 or better | $6–10 | — | The PicoCalc's stock 32 GB card works fine — it's more than the 8 GB minimum. You can reuse it instead of buying a new one. |
| Two 18650 batteries | e.g. Samsung 30Q, Molicel P26A, Sony VTC6 (examples, not endorsements) | $10–20 | — | **Unprotected**, flat-top or button-top, Ø18 × 65–69 mm. See [the battery note](#a-note-on-batteries) below. |
| An 18650 battery | e.g. Samsung 30Q, Molicel P26A, Sony VTC6 (examples, not endorsements) | $5–10 | — | **Unprotected**, flat-top or button-top, Ø18 × 65–69 mm. One is enough to run; a second is optional (below). See [the battery note](#a-note-on-batteries) below. |
| USB Wi-Fi dongle | TP-Link TL-WN725N (an RTL8188EU nano dongle), or search the **chipset name**: RTL8192CU, R8712U, RTL8188EU | $5–10 | [amazon.ca/dp/B008IFXQFU](https://www.amazon.ca/dp/B008IFXQFU) | The TL-WN725N is the dongle this guide was built with. Must run at **3.3 V** — see [Step 3](#step-3--attach-the-wi-fi-dongle). Only needed for setup and updates; MeshTerm itself runs offline. |
| MX1.25 4-pin pigtail | "MX1.25 4P cable with leads" (a pack of pre-crimped pigtails, 10–15 cm) | $2–4 | — | One end of the DIY USB lead in [Step 3](#step-3--attach-the-wi-fi-dongle). Pre-crimped, because MX1.25 contacts need a crimper you don't otherwise own. |
| USB-A female socket | "USB 2.0 type A female socket, solder / through-hole" | $2–3 | — | The other end of the DIY lead. A ready-made [MX1.25 4P to USB-A cable](https://spotpear.com/shop/Luckfox-Lyra-MX1.25-4P-To-USB-A-Cable.html) (about $1, 30 cm) works too, but it is a lot of cable to fold into the shell. |
@@ -101,7 +101,7 @@ a radio in the meantime.
| Foam tape | To secure the XIAO board inside the shell once it's wired in. |
Approximate total: **$130–150** for the machine and radio together, before batteries — most
of that is the two boards ($89 + ~$22) and the radio kit ($13.49).
of that is the PicoCalc kit ($89), the Lyra (~$22) and the radio kit ($13.49).
**What you do NOT need:**
@@ -213,8 +213,8 @@ machine it describes. Read this section before you open the shell.
Follow ClockworkPi's own
[assembly guidelines](https://github.com/clockworkpi/PicoCalc/blob/master/Clockwork_PicoCalc_Assembly_Guidelines.pdf)
(a PDF in the [PicoCalc repository](https://github.com/clockworkpi/PicoCalc), which also
has a wiki) to put the kit together: mainboard, screen, keyboard, speakers and shell. Insert your two
18650 batteries, watching the `+`/`-` marks in the battery compartment.
has a wiki) to put the kit together: mainboard, screen, keyboard, speakers and shell. Insert your 18650
battery (or two), watching the `+`/`-` marks in the battery compartment.
⚠ Two of the warnings in [Three ways to break it](#three-ways-to-break-it) apply here: tape
the screen's edges with Kapton before you close the shell and check it sits square, and
@@ -311,14 +311,17 @@ Don't power the device on yet — Wi-Fi is set up later, once Calculinux is inst
### Step 4 — Write the image to the microSD
On your PC, download the latest `calculinux-image-luckfox-lyra.rootfs.wic.gz` from the
[Calculinux releases page](https://github.com/Calculinux/meta-calculinux/releases).
On your PC, download `calculinux-image-luckfox-lyra.rootfs-<timestamp>.wic.gz` from the
newest release on the
[Calculinux releases page](https://github.com/Calculinux/meta-calculinux/releases). Every
Calculinux release is marked **Pre-release**, so there is no "Latest" badge to follow; take
the newest pre-release.
Decompress it:
```bash
# on the PC
gunzip calculinux-image-luckfox-lyra.rootfs.wic.gz
gunzip calculinux-image-luckfox-lyra.rootfs-*.wic.gz
```
(The Calculinux installation page mentions `unxz`, but the file is a `.gz`, so use
@@ -331,11 +334,11 @@ Write the resulting `.wic` file to your microSD card. Three options:
- **`dd`**, on Linux/macOS. Check the device name first — writing to the wrong device
destroys its contents:
```bash
# on the PC
lsblk # find your SD card's device, e.g. /dev/sdb
sudo dd if=calculinux-image-luckfox-lyra.rootfs.wic of=/dev/sdX bs=4M status=progress conv=fsync
```
```bash
# on the PC
lsblk # find your SD card's device, e.g. /dev/sdb
sudo dd if=calculinux-image-luckfox-lyra.rootfs-*.wic of=/dev/sdX bs=4M status=progress conv=fsync
```
- **Rufus**, on Windows — select the device and the image, choose the **MBR** partition
scheme, and start.
@@ -345,6 +348,9 @@ This erases everything already on the card. Once it finishes, eject it and put i
from its own slot; the front slot is only reachable once MeshTerm (or anything else) is
already running on Linux.
Leave the front slot **empty** for the first boot, as Calculinux's own instructions warn: a
second card there with duplicate partition labels leaves the system read-only.
### Step 5 — First boot
With the card in the Lyra, power the PicoCalc on. Give it 30–60 seconds — the first boot is
@@ -417,30 +423,29 @@ the network, so this is a one-time step; to change networks later, run `uwific`
### Step 8 — Copy the setup scripts to the device
You need this repository's `scripts/` directory on the Lyra — its `picocalc/` folder is the
part the Lyra runs. Three ways to get it there, in order of ease:
part the Lyra runs. Two ways to get it there, once Wi-Fi is up:
- **`scp` from your PC**, once Wi-Fi is up. Find the Lyra's IP address with `ip addr show
wlan0` on the device, then from your PC:
- **`scp` from your PC**. Find the Lyra's IP address with `ip addr show wlan0` on the
device, then from your PC:
```bash
# on the PC, from your MeshTerm checkout
scp -r scripts root@<lyra-ip>:/root/
```
```bash
# on the PC, from your MeshTerm checkout
scp -r scripts root@<lyra-ip>:/root/
```
If root is refused over SSH, copy as the `pico` user (password `calc`) instead, then
move the directory as root on the device:
```bash
# on the PC
scp -r scripts pico@<lyra-ip>:/home/pico/
# on the PicoCalc, as root
mv /home/pico/scripts /root/
```
```bash
# on the PC
scp -r scripts pico@<lyra-ip>:/home/pico/
# on the PicoCalc, as root
mv /home/pico/scripts /root/
```
- **The microSD card.** Pull the card, copy `scripts/` onto it from your PC's card reader,
put it back.
- **`git clone` on the device.** Only once `git` is installed, and the setup script is what
installs it — so this is a route for later updates, not the first copy.
- **`git clone` on the device.** `git` ships in the Calculinux base image, so as root you
can run `git clone https://github.com/jpmartineau/MeshTerm` and use the `scripts/`
folder inside the clone.
`scp` is the recommended route.
@@ -452,30 +457,40 @@ cd scripts/picocalc
sh calculinux-setup.sh
```
It's idempotent — safe to re-run — and it checks before it acts. Two things it can't figure
out on its own, both covered by environment variables read at the top of the script:
It's idempotent — safe to re-run — and it checks before it acts. It reads these
environment variables at the top of the script:
| Knob | Default | Effect |
| --- | --- | --- |
| `DEPLOY_USER` | `meshterm` | the login MeshTerm runs under |
| `TIMEZONE` | `America/Toronto` | any IANA zone |
| `TIMEZONE` | `America/Toronto` | any IANA zone. Set it if you are not on Eastern time. |
| `REPO_URL` | the project's HTTPS URL | cloned when there's no deploy key |
| `REPO_SSH` | the project's SSH URL | cloned when there is one |
| `KEY_PATH` | `/home/$DEPLOY_USER/.ssh/id_ed25519` | a read-only GitHub deploy key, if you want SSH instead of HTTPS |
| `WIFI_SSID`, `WIFI_PSK` | unset | set both and the script writes `iwd`'s credentials for that network, as an alternative to Step 7 |
You don't need to set any of these for a normal install — the defaults clone MeshTerm's
public repository over plain HTTPS, which needs no credential at all.
The defaults clone MeshTerm's public repository over plain HTTPS, which needs no credential
at all. The one you are likely to need is `TIMEZONE`, if you live outside Eastern time:
```bash
# on the PicoCalc, as root
TIMEZONE=America/Vancouver sh calculinux-setup.sh
```
The script installs only the Americas time-zone data, so a zone elsewhere may not take. If
it cannot set the zone, it warns and the clock stays on UTC.
What it does, one line per phase:
- **opkg packages** — installs `python3-modules`, `python3-pip`, `git`, `kbd`, because
Calculinux ships a stripped Python missing `pip`, `venv` and several stdlib modules.
- **opkg packages** — installs `python3-modules` and `python3-pip` (and `git` and `kbd`,
if the image lacks them), because Calculinux ships a stripped Python missing `pip`,
`venv` and several stdlib modules.
- **Wi-Fi kick** — installs a boot-time service that re-scans until the dongle joins the
network `iwd` already knows, because on a cold boot the dongle's firmware finishes
loading after `iwd`'s first scan and the link would otherwise never come up.
- **Time sync** — installs a boot-time service that sets the clock over the network, since
this board has no battery-backed real-time clock.
- **Timezone** — sets `$TIMEZONE` (Eastern by default).
- **Timezone** — sets `$TIMEZONE` (`America/Toronto` by default).
- **Deploy user** — creates the `meshterm` login and puts it in the groups it needs.
- **Clone** — checks out MeshTerm to `/home/meshterm/MeshTerm`.
- **venv and install** — builds a Python virtual environment and `pip install -e .`s
@@ -493,9 +508,10 @@ password yet, so set one first:
passwd meshterm
```
> **With no Wi-Fi dongle attached, the next boot takes about six minutes** to get past the
> Wi-Fi kick, which keeps trying before it gives up. Nothing is broken. If the device is
> going to live offline, see [Day-to-day](#day-to-day) for how to switch the kick off.
> **With no Wi-Fi dongle attached, the next boot can take up to about eleven minutes.**
> The Wi-Fi kick keeps trying for about six before it gives up, and the time sync then
> waits up to five more for a network route. Nothing is broken. If the device is going to
> live offline, see [Day-to-day](#day-to-day) for how to switch both off.
### Step 10 — Log in as meshterm
@@ -521,7 +537,7 @@ Then look at the whole visual language on one card:
meshterm specimen
```
There are two console fonts available — a 6×12 one (53×26 rows) and a 6×8 one (53×40 rows,
There are two console fonts available — a 6×12 one (53 × 26) and a 6×8 one (53 × 40,
more rows but no Cyrillic glyphs). Try both from *Preferences → Display → Console font* —
picking one previews it immediately, and the screen repaints at the new row count so you can
see the choice before you keep it.
@@ -776,8 +792,9 @@ default_tx_power = 22
description = "XIAO nRF52840 + SX1262 via Lyra UART1 on GP4/GP5"
```
If a `config.toml` already exists, it prints those lines instead of touching your file —
paste them in yourself.
If a `config.toml` already exists, it leaves your file untouched and prints only the
`default_profile` line, the `[profiles.picocalc]` heading and the `port`. Copy the whole
block above into your file yourself.
### Step 16 — Run it
@@ -788,7 +805,8 @@ Log out and back in as `meshterm` (so the `dialout` group membership takes effec
meshterm
```
Because `default_profile` is set, a bare `meshterm` connects straight to the radio. You
A bare `meshterm` opens on the **Select a companion device** splash, with the `picocalc`
profile listed as a row; pick it. To skip the splash, run `meshterm -p picocalc`. You
should see your node come up under whatever name the firmware advertises — both the device
page and the dashboard header show it.
@@ -815,39 +833,47 @@ through the Lyra's own USB-C at the back — see
sudo poweroff
```
**Updating MeshTerm.** Either re-run the setup script from root (it pulls the repository,
reinstalls, and rebuilds the console font):
**Updating MeshTerm.** The copy of `scripts/` you made in Step 8 goes stale as soon as
the checkout moves on, so run the scripts inside the checkout instead. Either pull and
re-run the setup script from root (it reinstalls and rebuilds the console font):
```bash
# on the PicoCalc, as root
sh calculinux-setup.sh
su - meshterm -c 'git -C ~/MeshTerm pull'
sh /home/meshterm/MeshTerm/scripts/picocalc/calculinux-setup.sh
```
or, as the `meshterm` user, do just the update:
```bash
# on the PicoCalc, as meshterm
cd ~/MeshTerm && git pull && .venv/bin/pip install -e .
cd ~/MeshTerm && git pull && TMPDIR=$HOME/tmp .venv/bin/pip install -e .
```
If an update adds new glyphs to the console font, rebuild it on its own afterwards:
```bash
# on the PicoCalc, as root
sh calculinux-console-font-6x12.sh
sh /home/meshterm/MeshTerm/scripts/picocalc/calculinux-console-font-6x12.sh
```
**Changing Wi-Fi.** Run `uwific` as root, as in [Step 7](#step-7--join-wi-fi). The boot-time
kick picks up the new network on its own; it only needs `iwd` to know it.
**The six-minute boot without a dongle.** If the device is going to run permanently offline,
the Wi-Fi kick is wasted time on every boot. Disable it:
**The slow boot without a dongle.** If the device is going to run permanently offline,
the Wi-Fi kick and the time sync are wasted time on every boot. Disabling the kick is not
enough, because the time sync pulls it back in, so mask it. Then disable the time sync,
which would otherwise still wait up to five minutes for a network:
```bash
# on the PicoCalc, as root
systemctl disable --now wifi-kick.service
systemctl mask --now wifi-kick.service
systemctl disable --now time-sync.service
```
To undo it later, `systemctl unmask wifi-kick.service` and
`systemctl enable time-sync.service`.
**The console font restores itself.** MeshTerm saves whatever font the console was using
before it starts, and puts it back when it exits — on a normal quit and on a crash alike.
You don't need to do anything to keep the shell's own font intact.
@@ -876,10 +902,10 @@ You don't need to do anything to keep the shell's own font intact.
This whole path — from a stock PicoCalc kit through a running radio — has been exercised on
one maintainer's bench: one Luckfox Lyra, one PicoCalc, one XIAO + Wio-SX1262 pair. It
works. Some of it is also checked in code: a test parses the font script and pins it to
MeshTerm's glyph inventory, the profile keys the scripts write are tested, the pinned
MeshCore commit exists, the patch's two hunks are still needed on `dev`, and the nRF52840
UF2 family id and both XIAO bootloader ids match Adafruit's board files.
works. One part is also checked in code: a test (`tests/test_theme16.py`) parses the font
script and pins it to MeshTerm's glyph inventory. The pinned MeshCore commit, the need for
the patch's two hunks on `dev`, and the nRF52840 UF2 family id and XIAO bootloader ids
against Adafruit's board files were checked by hand when this was written.
The rest is one bench's findings, and a second device may disagree:
-485
View File
@@ -1,485 +0,0 @@
# Navigation survey — every exception to "Esc pops the stack"
> **A historical record.** This was a snapshot of the code on the date below, kept
> because a project that writes down its own problems is easier to trust than one
> that doesn't. Some of what's here has since been fixed and some hasn't; nothing in
> it is a to-do list. Read it as "what this looked like then", not "what is wrong now".
Written 2026-08-22 against `main` @ `4b29248`. A catalogue, not a change: nothing here had
been fixed at the time of writing. Each finding says where it is, what it does, and what
makes it a question.
**Resolved since:** findings **E** and **§10** — 2026-08-29. Exit rows were retired
app-wide: `back_rows()` is gone, `exit_rows()` draws nothing while clean, and every
caller's three-way Back test collapsed to `is CANCEL` / `is None`. What is kept is stated
in CLAUDE.md.
**Resolved 2026-08-30 — A, B, C, and the shortcut that made B possible.** Navigation is
a strict stack now; the rules are in CLAUDE.md under *Navigation — the stack*.
- **A1/A2/A3.** A2 is the rule, and it grew a second half. A hub stays pushed for the
whole visit (`session.stay` / `Visit.result`), so the *object* — not a `default=`
restore — carries the cursor, the sort, the scroll and the filter. Where the rows are
genuinely data (the editors, the outbox, the watchtower) they are swapped in place with
`SelectScreen.replace_items`, which follows the highlighted row by value and clamps to
its position when that row is gone. A1's vanishing-row problem is answered by that
clamp, everywhere, rather than by watchtower's one hand-written case. A screen is
rebuilt only when the rows it was holding a place in no longer exist — a purge, a
removal, a record count that moved — and each such site says so.
- **B1/B2/B3.** They no longer flatten. Both hand-built escapes existed because a deep
stack had no way out; **^W** is the way out, so trace ↔ trophy case and node detail's
peers all nest, and Esc from any of them is one pop back onto the screen that opened
them. The trace screen takes an `open_trophy_case` callback instead of resolving a
sentinel its opener had to interpret, which is also what lets the two open each other.
- **C.** All five backdrop re-pushes are gone, and so are the six hand-rolled
push/await/pop loops the survey paired them with — the same need, spelled one way.
Repeater admin's was the worst of them: a second, identical `SelectScreen` drawn as a
static stand-in for the real one. `admin_node_visit` keeps the real list up instead.
- **F.** Still the one place the stack is cleared rather than unwound, and it now also
disarms a pending ^W: a disconnect has already abandoned the flow that unwind was for.
**Resolved 2026-08-30 (second pass) — D, plus H and I, found by re-running the survey.**
D is closed by peeling *everywhere*: the select list and the path composer now clear a
standing filter on the first Esc and leave on the second, as the map and the walk always
did, and the footer swaps its trailing verb to `Esc clear` while that is true. Two
exceptions the first survey missed are closed with it — see **H** and **I** below.
**Still open: G** (`popup` declared by hand). It is not navigation-visible: one tool
floats over the menu, the rest replace it, and the result presenter separately infers the
same thing from content.
The point of the survey is that MeshTerm's navigation is *almost* uniform. The exceptions
are few enough to list exhaustively, which means they can be decided one at a time rather
than discovered one at a time.
---
## 0. Exceptions H and I — found 2026-08-30
Two more shapes, neither in the original catalogue, both closed the day they were found.
**H. A picker gathered in `prompt_params` pops before the screen it feeds.** A tool's
`prompt_params` is a *one-shot*: whatever it opens resolves and pops before `run` is
called. Three tools picked something there and then opened a screen, so the screen sat
directly on the main menu and Esc from it skipped the list it had just been chosen from.
| Site | What Esc did | Now |
|---|---|---|
| Trace target | sweep → **main menu**, skipping the target list | picker stays; Esc lands on the row it was launched from |
| TX optimize | sweep → **main menu**; and the target list → **main menu**, not the node list | both pickers stay, nested; Esc walks the flow back one list at a time |
| Chat | landed on a picker, but a rebuilt one (filter lost, cursor by `default=`) | one picker for the visit, rows swapped in place |
The fix is A2's rule applied one level up: the loop that owns the picker moves into `run`,
and `prompt_params` returns only the marker for the interactive path. Trace's picker also
re-reads its `TRACED` lane after each walk (`ContactListScreen.update_rows`), because that
is the column the list is sorted by and a returning trace changes it.
*Revised 2026-09-12 — TX optimize.* Keeping both pickers pushed under the sweep was the
wrong reading of the rule for a **popup**: a popup asks, confirms or informs, and then it is
gone — it is not a place to walk back to, and two of them stacked read as two places. The
two picks are now one floating list that turns its page (`menus.run_wizard`, `… · step 1
of 2`): Esc on the second step turns back to the first with the picked node still
highlighted, Esc on the first leaves, and the box is popped before the sweep opens — so Esc
from the sweep lands on the main menu, the sweep being the whole visit. It also *draws* as
a popup: the menu is popped while a tool runs, and a floating screen that is the only frame
on the stack used to be painted as the full-frame background (`_base_index`), so the first
of the two pickers never looked like a question at all. `stay(dialog=True)` now puts the
blank base under a popup's visit that `_run_dialog_screen` puts under a one-shot. Trace's
picker is a full-screen contact list (a place), and stays as it is.
*Same day — Repeater admin.* The same shape, one popup instead of two: the node picker was
a hub under the password prompt and the admin page. It is now a floating one-shot
(`session.select(floating=True)`, `admin_picker.pick_admin_node`), gone once answered; the
admin page opens over the main menu and Esc from it lands there. A refused login re-asks
the question with the same node highlighted. `admin_node_visit` is deleted — nothing keeps
a popup pushed any more.
*Same day — the base under a popup.* The blank `ScrollScreen` that `_run_dialog_screen`
pushed on an empty stack showed nothing behind the box. A dialog is smaller than the frame,
so what shows around it is the page it was reached from: `TuiSession._floated` now pushes
the **root** (the main menu, popped while a tool runs but still declared for ^W), and the
reconnect dialog draws over it too. A blank base survives only for a session with no root.
*Same day — the trophy case is a place.* A bare `SelectScreen` floats by default, and the
browser was full-frame from the menu only by the compositor's fallback; opened from a
trace it came up as a box. It declares `floating = False` now, like the contact list.
*Same day — Trace target.* The third of the three pickers finding H kept pushed, and the
one the previous note said would stay: JP's call is that it is a question too. The contact
list floats over the menu as a one-shot (`session.run_dialog`, public now), the trace screen
opens over the menu, Esc from it lands there; tracing another node is opening the tool
again. The typed fallback floats the same way. `ContactListScreen` stays a full-screen place
everywhere else.
*Same day — a record is a page.* `RecordDialog` floated a 62-column card over the trophy
case; the reader is *in* a record (reading, scrolling, arming a trace), so it is
`RecordScreen`, full-frame, Esc back onto the browser.
**I. An entry chain of prompts was not a stack.** Five flows asked two or more things in a
row, and Esc on any of them abandoned the whole flow rather than stepping back one — a
mistyped 32-hex channel key cost the name typed before it. `menus.run_steps` now runs a
chain as a stack; a trailing Cancel/verb confirm is deliberately left as a decision rather
than a step. Sites: `channels._join_with_key`, `channels._edit`,
`config_editor._stage_custom_var`, `courier._queue_flow`, `courier._pick_schedule`.
Also closed in the same pass: the channel detail was the last screen rebuilt per round
(A1's `default=cursor`, now `stay` + `replace_items`), which left `_menu_round` with no
session path at all and shrank it to the sessionless select-and-dispatch it always was
underneath — the last of §5's hand-rolled loops.
---
## 1. The machinery, in one screenful
Three primitives, all in [`ui/tui/`](../meshterm/ui/tui/):
| Call | What it does |
|---|---|
| `session.push(screen)` | screen becomes the top of the stack and is drawn |
| `session.pop(screen)` | that screen leaves the stack |
| `screen.resolve(value)` | the screen's future completes; the awaiting caller decides what happens next |
`session.run_screen(screen)` is push + await + pop as one call — the normal way to show a
screen. `Screen.handle("escape")` resolves with the `CANCEL` sentinel
([`screen.py:243`](../meshterm/ui/tui/screen.py#L243)); every screen inherits that unless it
overrides `handle`.
So the app has no "navigator". A screen never decides where the user goes — it resolves a
value and its *caller* decides. Every exception below is a caller deciding something other
than "pop and carry on".
---
## 2. What "normal" looks like
The reference flow is the main menu ([`menu.py:466-559`](../meshterm/ui/menu.py#L466)):
1. build the row list
2. `push` the menu
3. await a choice
4. `pop` the menu in a `finally`
5. run whatever was chosen
6. loop — rebuilding the menu, **re-highlighting the row just used** (`default=last_selection`)
Point 6 is the part worth naming: *returning from a thing lands you on the thing you
returned from.* It is the behaviour every list should have, and most do.
---
## 3. Exception A — cursor position on re-entry
**The single largest inconsistency in the app.** Three groups, three behaviours:
### A1. Restores the cursor — the intended behaviour (7 sites)
| Screen | Where | How |
|---|---|---|
| Main menu | [`menu.py:502`](../meshterm/ui/menu.py#L502) | `default=last_selection` |
| Channels (manager) | [`channels.py:214`](../meshterm/ui/channels.py#L214) | `default=highlight` |
| Channels (one channel) | [`channels.py:728`](../meshterm/ui/channels.py#L728) | `default=cursor` |
| Device config editor | [`config_editor.py:161`](../meshterm/ui/config_editor.py#L161) | `default=cursor` |
| Device actions | [`config_editor.py:688`](../meshterm/ui/config_editor.py#L688) | `default=cursor` |
| Repeater admin | [`repeater_admin.py:172`](../meshterm/ui/repeater_admin.py#L172) | `default=cursor` |
| Courier outbox | [`courier_screen.py:120`](../meshterm/ui/courier_screen.py#L120) | `default=cursor` |
| Watchtower | [`watchtower_screen.py:137`](../meshterm/ui/watchtower_screen.py#L137) | `cursor = choice` |
Watchtower has the most careful version of it — it *drops* the restore when the chosen row
is about to vanish ([`watchtower_screen.py:145-146`](../meshterm/ui/watchtower_screen.py#L145)):
```python
elif choice == _CLEAR:
store.clear_acked()
cursor = None # the row itself disappears
```
That refinement exists in exactly one place. Anywhere else a row can disappear, the restore
either silently fails to match (harmless — falls back to the top) or lands on whatever row
slid into the vacated position (not harmless).
### A2. Reuses the screen object, so it keeps everything (4 sites)
Contacts states the principle outright
([`contacts_screen.py:200-201`](../meshterm/ui/contacts_screen.py#L200)):
> One screen for the whole visit: re-running it keeps the highlight (and any sort or
> filter) on the row the user just opened a detail for, rather than snapping to the top.
Also chat, live feed, dashboard. Strictly better than A1 — it preserves sort and filter too,
not just the cursor — and it costs less code.
Contacts also shows the honest exception: after a purge changes the data, it deliberately
*does* rebuild ([`contacts_screen.py:217-219`](../meshterm/ui/contacts_screen.py#L217)), and
says why.
### A3. Rebuilds and loses your place (3 sites) — **the actual finding**
| Screen | Where | What is lost |
|---|---|---|
| Trophy case | [`records_screen.py:851`](../meshterm/ui/records_screen.py#L851) | cursor — a fresh `SelectScreen` per round, no `default=` |
| Time Machine picker | [`timemachine_screen.py:1323`](../meshterm/ui/timemachine_screen.py#L1323) | cursor (sort *is* carried) |
| Node detail | [`node_detail_screen.py:1360`](../meshterm/ui/node_detail_screen.py#L1360) | cursor **and the open tab** |
Node detail is the sharpest case. `NodeDetailScreen.__init__` sets `self._tab_index = 0`
([`node_detail_screen.py:446`](../meshterm/ui/node_detail_screen.py#L446)) and the opener
constructs a new one every iteration. So: open a contact → Tab to **Routes** → pick a route
→ Enter to trace it → come back → **you are on Info, at the top**. The route you were
working through is two keypresses away again, every time.
All three are a one-line fix in the A2 direction (hoist the construction out of the loop).
> **Question for JP:** is A2 the rule? If so these three are bugs. If a *deliberate* reset
> is wanted anywhere, Contacts' purge branch is the model for how to say so.
---
## 4. Exception B — hand-offs that flatten the stack instead of nesting
Two places let one screen hand off to a *peer* screen, and both deliberately unwind first so
the user does not end up deep in a stack they have to climb.
**B1. Trace → Trophy case.** A trace that sets a record raises a dialog offering the trophy
case. Choosing it resolves the sentinel `OPEN_TROPHY_CASE`
([`trace_screen.py:128`](../meshterm/ui/trace_screen.py#L128)), the trace screen comes down,
and only then does the caller open records
([`trace_screen.py:2193-2199`](../meshterm/ui/trace_screen.py#L2193)):
> the trace screen (and every prompt over it) is already down, so the trophy case opens
> over a clean stack and Esc from it unwinds straight to the main menu — never back into
> this session.
**B2. Trophy case → Trace.** The mirror image, and the comment is even more explicit
([`records_screen.py:905-909`](../meshterm/ui/records_screen.py#L905)):
> the browser does not reopen behind it: when the trace screen closes, this whole flow
> returns, landing the user on the main menu instead of a trophy-case → trace →
> trophy-case stack that takes many Escs to climb out of.
These two are the same rule discovered twice: **a hand-off between peers flattens; only a
sub-view nests.** It is not written down anywhere.
**B3. Node detail → Trace / Map / Time Machine / Share** ([`node_detail_screen.py:1372-1397`](../meshterm/ui/node_detail_screen.py#L1372))
follows the same rule *without saying so*, and that is where §3's A3 comes from. Because
`run_screen` pops on resolve, node detail is already down when the peer opens — so the peer
gets a clean stack, exactly as B1 and B2 arrange by hand. But unlike B1/B2, node detail then
**comes back**, by constructing a fresh screen at the top of the loop.
So the three sites agree on flattening and differ on what happens afterwards:
| | flattens before the peer | returns afterwards | keeps its state |
|---|---|---|---|
| B1 Trace → Trophy case | yes, deliberately | no — lands on the menu | n/a |
| B2 Trophy case → Trace | yes, deliberately | no — lands on the menu | n/a |
| B3 Node detail → peers | yes, as a side effect of `run_screen` | **yes** | **no** |
B3 is the only one that re-enters, and re-entering through a constructor is what costs the
tab and the cursor. That makes A3-on-node-detail a *consequence* of this shape, not an
independent bug: fixing it means either hoisting the screen out of the loop (keep the object,
keep the state) or nesting properly (keep it pushed while the peer runs).
> **Question for JP:** "flatten before a peer opens" looks like the settled rule — three for
> three. What is unsettled is whether a hub screen should *nest* instead, so it never has to
> be rebuilt. Node detail is the only hub, so this is really one decision about one screen.
---
## 5. Exception C — the backdrop re-push
`run_screen` pops the screen when it resolves. But a dialog raised straight afterwards needs
something behind it, or it draws over a blank frame. So five sites pop, then immediately
re-push the same screen as a static backdrop:
| Site | Screen re-pushed as backdrop |
|---|---|
| [`records_screen.py:867`](../meshterm/ui/records_screen.py#L867) | trophy case, behind the discipline picker / delete confirms |
| [`contacts_screen.py:210`](../meshterm/ui/contacts_screen.py#L210) | contacts, behind the purge picker |
| [`repeater_admin.py:108`](../meshterm/ui/repeater_admin.py#L108) | node picker, behind the login prompt |
| [`menu.py:910-911`](../meshterm/ui/menu.py#L910) | base screen, behind the disconnect dialog |
| [`tx_screen.py:885`](../meshterm/ui/tx_screen.py#L885) | flight screen |
Records has the fullest explanation ([`records_screen.py:862-866`](../meshterm/ui/records_screen.py#L862)).
This is a **workaround for an API shape**, repeated five times. The screens that avoid it
(channels, config editor, repeater admin's own menu, courier, watchtower, main menu) all
drive `push`/`await future`/`pop` by hand instead of calling `run_screen`, precisely so the
screen stays up while sub-prompts float.
> **Question for JP:** would a `session.run_screen(screen, keep_pushed=True)` — or a
> `session.backdrop(screen)` context manager — retire both the five re-pushes and the six
> hand-rolled push/await/pop loops? They are the same need spelled two ways.
---
## 6. Exception D — what Esc peels before it leaves
Four screens carry a live find-as-you-type filter and draw it through the one
`render.query_line`. **Esc means two different things across them:**
| Screen | Esc with a filter active | ⌫ with a filter active |
|---|---|---|
| Map | clears the filter, stays ([`map_screen.py:920-925`](../meshterm/ui/map_screen.py#L920)) | — |
| Mesh walk | clears the filter, stays ([`walk_screen.py:430-436`](../meshterm/ui/walk_screen.py#L430)) | deletes a char, then pops the trail ([`walk_screen.py:449-456`](../meshterm/ui/walk_screen.py#L449)) |
| **Select list** | **leaves the screen, filter and all** ([`select.py:773-774`](../meshterm/ui/tui/select.py#L773)) | deletes a char |
| **Path composer** | **leaves the screen, filter and all** ([`path_composer.py:717-718`](../meshterm/ui/path_composer.py#L717)) | deletes a char, then pops a hop |
So on the map, typing `hub` then Esc shows you the map again. On a contact list, typing
`hub` then Esc drops you back to the menu. Same affordance, same glyphs, opposite outcome —
and the select list is by far the most-used of the four.
Chat is a fifth member of the family with a different peelable thing
([`chat.py:624-629`](../meshterm/ui/chat.py#L624)):
```python
if self._selected is not None:
self._clear_selection() # first Esc unpicks; next leaves the chat
```
so chat peels, and its comment reads as though peeling were the house rule.
**Score: 3 screens peel, 2 do not.** Worth noting the peel is *not* free — on a screen where
Esc peels, leaving a filtered list takes two Escs, which is the cost JP is weighing on exit
rows elsewhere.
> **Question for JP:** peel everywhere, or nowhere? The current split is not defensible as
> a distinction between spatial and list screens, because the path composer is a list and
> does not peel, while chat is not a filter and does.
---
## 7. Exception E — six spellings of "the user pressed Back"
`back_rows(value)` takes the value its Back row resolves with. Call sites, verbatim:
| Value passed | Sites |
|---|---|
| *(nothing — `None`)* | [`admin_picker.py:49`](../meshterm/ui/admin_picker.py#L49), [`:77`](../meshterm/ui/admin_picker.py#L77), [`config_editor.py:617`](../meshterm/ui/config_editor.py#L617), [`:790`](../meshterm/ui/config_editor.py#L790), [`:1025`](../meshterm/ui/config_editor.py#L1025), [`courier_screen.py:242`](../meshterm/ui/courier_screen.py#L242), [`watchtower_screen.py:225`](../meshterm/ui/watchtower_screen.py#L225), [`:362`](../meshterm/ui/watchtower_screen.py#L362) |
| explicit `None` | [`tx_optimize.py:439`](../meshterm/tools/tx_optimize.py#L439), [`contacts_screen.py:263`](../meshterm/ui/contacts_screen.py#L263) |
| `"__back__"` | [`tools/chat.py:187`](../meshterm/tools/chat.py#L187), [`records_screen.py:790`](../meshterm/ui/records_screen.py#L790) |
| `_BACK` | [`channels.py:619`](../meshterm/ui/channels.py#L619), [`:683`](../meshterm/ui/channels.py#L683), [`contacts_screen.py:152`](../meshterm/ui/contacts_screen.py#L152) |
| `_CANCEL` | [`config_editor.py:736`](../meshterm/ui/config_editor.py#L736) |
| a tuple shaped like the screen's other values | [`records_screen.py:850`](../meshterm/ui/records_screen.py#L850) `("back", None, 0, None)`, [`trace_screen.py:2012`](../meshterm/ui/trace_screen.py#L2012) `("back", None)` |
The cost lands on the callers, which have to test every way at once:
```python
# contacts_screen.py:204
if chosen is CANCEL or chosen is None or chosen == _BACK:
# records_screen.py:858
if picked is CANCEL or picked is None or picked[0] == "back":
```
Note `config_editor` names its Back sentinel `_CANCEL` — the one word CLAUDE.md reserves for
dialogs, used here for a row that says "Back".
> **Suggestion:** `back_rows()` could default its value to `CANCEL` itself, so a Back row and
> Esc resolve *identically* and every caller's three-way test collapses to `is CANCEL`. The
> tuple-shaped ones exist only because their screens type their values as tuples; those could
> take `CANCEL` too.
**Resolved 2026-08-29** — by removal rather than by defaulting: with no Back row there is
no Back value, and every caller now tests `is CANCEL` / `is None` alone. All six spellings
are gone, `channels._BACK` and `contacts_screen._BACK` with them.
This tied directly into the exit-row question — see §10.
---
## 8. Exception F — the stack reset
One site clears the whole stack: [`menu.py:824`](../meshterm/ui/menu.py#L824), when the device
link drops mid-session. The watcher cancels the menu worker, `session.reset()` drops whatever
screens it left behind, and the reconnect dialog opens over a clean frame.
This is the only place the stack is cleared rather than unwound. It relies on every screen
being pop-safe in a `finally`, which is documented at
[`session.py:388-395`](../meshterm/ui/tui/session.py#L388). Correct as written — noted here
because it is the one code path where a screen can vanish without its caller resolving it,
and anything holding state across a `run_screen` boundary would silently lose it.
---
## 9. Exception G — one tool floats, twenty-two replace
`Tool.popup` ([`tools/base.py:67`](../meshterm/tools/base.py#L67)) decides whether the menu
stays pushed while the tool runs. **Exactly one tool sets it:** `advert`
([`tools/advert.py:33`](../meshterm/tools/advert.py#L33)).
Every other tool pops the menu, runs full-screen, and the menu is rebuilt afterwards. Which
is right for a screen — but several tools are *also* dialog-sized in practice, and the result
presenter already makes exactly this call at the other end
([`surface.py:603-610`](../meshterm/ui/surface.py#L603)): a short text-only result floats as
a dialog, anything bigger opens a window.
So the app decides "float or replace" twice, by two different mechanisms, one hand-declared
per tool and one measured from the content.
> **Question for JP:** should `popup` be inferred the way the result presentation is, or is
> one declared flag on one tool the whole of the need?
---
## 10. Where the exit-row question actually lands
> **Resolved 2026-08-29.** Acted on in full, along the middle position this section
> proposed — wider, in the end, than the 19 `back_rows()` sites: the four hand-drawn Back
> rows went too (`ReorderScreen`'s clean-state row, the live feed's pinned foot row, node
> detail's tail action, and the `trace`/`tx` screens' action-list rows). Kept: the
> `exit_rows` discard pair, `ReorderScreen`'s dirty-state pair, the two **Quit** rows, and
> the new-record dialog's `Close` button — each for the reason named below. The gallery
> now asserts the *absence* of an exit row on every screen, on both platforms. What
> follows is the survey as written.
JP's note — *"remove the cancel and close actions at the bottom of pages; Esc is quicker and
available on all platforms; keep it where there is a confirm or cancel situation"* — was
acted on for the one unambiguous case (the path composer's `Cancel` row, which literally ran
`super().handle("escape")`; removed in `4b29248`). The rest of the territory:
**Already clean.** The three About pages have no exit row at all — prose, Esc, done. Titles
carry no emoji. Footer hints are 100% compliant on width, Esc-last, and verb choice (56
literal hints audited).
**Rows that are exactly Esc, still standing:**
- `back_rows()` — the app-wide **Back** row, 19 sites. Two of them carry the comment
`# a visible exit beside Esc` ([`courier_screen.py:242`](../meshterm/ui/courier_screen.py#L242),
[`watchtower_screen.py:225`](../meshterm/ui/watchtower_screen.py#L225)) — i.e. the row exists
*because* it duplicates Esc, which is precisely the premise now in question.
- `ReorderScreen`'s Back row ([`select.py:931`](../meshterm/ui/tui/select.py#L931)) —
`super().handle("escape") # Back resolves CANCEL, same as Esc`.
- Live feed's Back row, reached by scrolling past the oldest packet
([`livefeed_screen.py:41-44`](../meshterm/ui/livefeed_screen.py#L41)).
Removing **Back** app-wide is a bigger decision than the path composer's Cancel, because:
- it is written into CLAUDE.md as a binding standard ("the only exit word on rows");
- `exit_rows()` builds on it — an editor with staged changes shows `✓ Apply n staged changes`
over `✗ Back — discard staged changes`, where Back is *not* redundant: it names a
consequence Esc does not;
- on PicoCalc, Esc is a real key, so the platform argument does not add anything there.
**A middle position worth considering:** keep Back exactly where it says something Esc does
not (the `exit_rows` discard case), drop it where it says nothing (the plain `back_rows`
case). That is roughly 3 sites kept, 16 dropped — and §7's suggestion (Back resolves `CANCEL`)
would make the drop mechanical rather than a per-caller edit.
**Buttons that are exactly Esc:** only one — the `Close` button on the new-record dialog
([`trace_screen.py:639`](../meshterm/ui/trace_screen.py#L639)). Left alone deliberately: it
is the *default*, so Enter dismisses safely, and this dialog appears unbidden after a trace.
Dropping it would leave `Trophy case` as the default and make a stray Enter navigate away.
Every other `Cancel` in the app is one half of a real two-way choice.
---
## 11. Summary — the shortlist
| # | Finding | Sites | Weight | Status |
|---|---|---|---|---|
| H | A picker gathered in `prompt_params` pops before the screen it feeds | 3 | **high** — Esc skipped a whole list | **done** 08-30 |
| I | An entry chain of prompts abandons instead of stepping back | 5 | medium — loses typing, never surprises | **done** 08-30 |
| A3 | Screen rebuilt per round, losing cursor (and node detail's tab) | 3 | **high** — felt on every visit | **done** 08-30 |
| D | Esc peels the filter on 3 screens, leaves on 2 | 5 | **high** — same keys, opposite result | **done** 08-30 |
| E | Six spellings of the Back value; callers test three at once | 19 | medium — invisible to users, costly in code | done 08-29 |
| C | Pop-then-re-push backdrop workaround | 5 | medium — an API gap, repeated | **done** 08-30 |
| B | Peer hand-offs all flatten, but the rule is unwritten and node detail pays for it | 3 | medium | **done** 08-30 |
| G | `popup` declared once by hand while the result presenter infers it | 1 | low | open |
| A1 | Cursor restore doesn't handle a vanishing row (except watchtower) | 7 | low | **done** 08-30 |
| F | `session.reset()` on disconnect — correct, noted for completeness | 1 | none | kept |
+2 -3
View File
@@ -5,9 +5,8 @@
> that doesn't. Some of what's here has since been fixed and some hasn't; nothing in
> it is a to-do list. Read it as "what this looked like then", not "what is wrong now".
Written 2026-08-22 against `main` @ `4b29248`. Companion to
[`survey-navigation.md`](survey-navigation.md), which covers navigation exceptions; this one
covers everything else in the CLAUDE.md UX standard. A catalogue, not a change.
Written 2026-08-22 against `main` @ `4b29248`. It covers the CLAUDE.md UX standard apart
from navigation. A catalogue, not a change.
**The short version: the standard is very well kept.** Six sweeps found nothing at all;
what remains is nine small, specific things. That is the useful result — it means the list
+37
View File
@@ -0,0 +1,37 @@
# Terminals, icons, and the Windows console
Why MeshTerm looks right in some terminals and draws empty boxes in others, what it does
about it on Windows, and how to turn all of that off.
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether you
see them is up to your *terminal*, not really your font: a modern terminal, asked for a
character its font doesn't have, quietly borrows it from another font on the machine.
That's why the app looks right in Windows Terminal, in VS Code's terminal, and on macOS
and Linux — usually with a font that contains almost none of it. The exception is macOS's
Terminal app, where the charts may draw misaligned: no Mac monospace font has braille, so
it is borrowed from a proportional one at the wrong width.
The classic Windows console — the black `cmd.exe` window a double-click opens on
Windows 10 — doesn't borrow. (Windows 11, 22H2 and later, opens Windows Terminal by
default instead.) It draws what its one font holds and empty boxes for everything else, and
no font fixes the icons there: the only two fonts on a Windows machine with emoji in them
are proportional, and a console won't take a proportional font.
So when MeshTerm lands in that console it just **moves to Windows Terminal** — it says
so, and opens there. Nothing is installed and nothing is changed; it's one window instead
of another, and it's the whole app exactly as the screenshots show it. Windows Terminal is
already on every Windows 11 machine and is a free install on Windows 10.
Where there's no Windows Terminal to move to, and the console's font can't already draw
the charts, MeshTerm offers the next best thing instead — the charts and marks, without
the icons. It ships
[Cascadia Mono PL](https://github.com/microsoft/cascadia-code), Microsoft's own console
font and one of the very few monospace faces that carries braille at all, and will install
it just for you: no administrator rights, nothing downloaded. If you already have a font
that can draw the charts (Cascadia, or another such as Iosevka), it offers to switch to
that one instead. Either way it asks first (`[y/N]`), and the answer defaults to no.
Preferences → Display → Console setup turns all of this off if you'd rather stay put.
Where MeshTerm keeps its state, and how to point a trial run somewhere else, is in
[Configuring MeshTerm](configuration.md).
+79 -33
View File
@@ -71,6 +71,8 @@ The bridge assumes the AIO's wiring by default:
| Bandwidth | 62.5 kHz |
| Coding rate | 4/5 |
The radio settings (frequency, spreading factor, bandwidth, coding rate) are MeshCore's
US/Canada preset. Anywhere else, set the `MESHCORE_*` variables to your region's preset.
Every one of these is overridable with a `MESHCORE_*` environment variable if your board or
region differs — see [Hardware knobs](#hardware-knobs).
@@ -92,8 +94,17 @@ The bridge supports both and prefers the newer one when it finds both installed.
**MeshTerm itself** — either:
- the Linux ARM64 one-file release from
[the latest release page](https://github.com/jpmartineau/MeshTerm/releases/latest), or
- the Linux ARM64 one-file release — three lines, no Python at all:
```bash
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-linux-arm64
chmod +x meshterm
./meshterm
```
(That URL always fetches whichever release is newest; the
[release page](https://github.com/jpmartineau/MeshTerm/releases/latest) has the
version-stamped files and their checksums.) Or:
- an install from the repository with `pipx` (Python 3.10 or newer). Plain `pip install`
into the system Python is refused on bookworm and trixie; `pipx` (`sudo apt install pipx`)
gives MeshTerm a virtual environment of its own.
@@ -136,16 +147,18 @@ and `spi` and `gpio` in your `groups` output.
Which route you take depends on your Debian release:
- **trixie**, with `meshcore-uconsole` installed from the vendor's package: you already
- **trixie**, with the `meshcore-uconsole` package (by cwill747) installed: you already
have a working runtime (Python 3.13-based). Skip to [Step 3](#step-3--get-the-bridge).
- **bookworm**: the `meshcore-uconsole` 1.12.0+ package is built for Python 3.13 and will
**not run** — its virtualenv points at an interpreter bookworm doesn't have. Don't take
the upgrade if apt offers it. Instead give the bridge a runtime of its own:
- **bookworm**: the trixie `.deb` of `meshcore-uconsole` 1.12.0 is built on Python 3.13
and needs glibc 2.38, so it will not install on bookworm, and the APT repository offers
nothing for bookworm. Upstream's GitHub Releases also carry a separate bookworm `.deb`
built on Python 3.11; it has not been tested with the bridge. The documented route is to
give the bridge a runtime of its own:
```bash
python3 -m venv ~/.local/share/meshterm-spi-bridge/venv
~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]"
```
```bash
python3 -m venv ~/.local/share/meshterm-spi-bridge/venv
~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]"
```
This is also the route to take if you'd rather not install the `meshcore-uconsole` package
at all.
@@ -206,8 +219,11 @@ If the first three checks all pass, you're ready to run it.
./meshterm-spi-bridge --run
```
This runs the preflight again, prints it, then starts the bridge in this terminal. A
healthy startup logs something like:
This runs the preflight again and prints it. Before it starts the bridge, it asks whether
to add a `bridge` profile to your MeshTerm config
(`` Add a 'bridge' profile to /home/you/.meshterm/config.toml so you can just run `meshterm`? [y/N] ``).
Either answer is fine; Step 6 asks again if you say no. Then it starts the bridge in this
terminal. A healthy startup logs something like:
```
node runtime openhop_core 1.1.3 (/home/you/.local/share/meshterm-spi-bridge/venv/bin/python)
@@ -221,7 +237,8 @@ READY — connect with: meshterm --tcp 127.0.0.1:5000
The `radio config` line lists every setting as `key=value`, so the one above is
shortened; yours is longer.
Leave it running, and in another terminal on the same machine try:
Once MeshTerm is installed ([Step 7](#step-7--install-meshterm-and-connect)), you can test
the bridge this way: leave it running, and in another terminal on the same machine run:
```bash
meshterm --tcp 127.0.0.1:5000
@@ -272,8 +289,9 @@ Menu option **4) Show status** prints the same information from inside the scrip
Install MeshTerm on the uConsole. The simplest way is the Linux ARM64 one-file release:
```bash
chmod +x meshterm-*-linux-arm64
sudo mv meshterm-*-linux-arm64 /usr/local/bin/meshterm
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-linux-arm64
chmod +x meshterm
sudo mv meshterm /usr/local/bin/meshterm
```
Or install it from the repository with `pipx`:
@@ -284,7 +302,8 @@ pipx install git+https://github.com/jpmartineau/MeshTerm
pipx ensurepath
```
If you accepted the profile in Step 6, your `config.toml` now has:
If you accepted the profile in Step 5 or 6, `~/.meshterm/config.toml` now has this block
(the bridge always writes to that path, even if you set `MESHTERM_HOME`):
```toml
[profiles.bridge]
@@ -305,13 +324,16 @@ or, without a profile:
meshterm --tcp 127.0.0.1:5000
```
You're connected when the device page or dashboard shows a node name (`uConsole` by
You're connected when **Device info** or the dashboard shows a node name (`uConsole` by
default) instead of a connection error. MeshTerm reports the device model as
`pyMC-spi-bridge` — that's how you tell a bridged node from a real companion.
**Identity.** If the `meshcore-console` GUI's package is present, the bridge loads *that*
package's identity key, so MeshTerm and the GUI are the same node — same contacts, same
public key. On a library-only install, the bridge mints and keeps its own identity key
package's identity key, so MeshTerm and the GUI share the same public key. Contacts and
radio settings are the bridge's own: it keeps contacts in
`~/.local/share/meshterm-spi-bridge/contacts.json`, and builds its radio settings from the
`MESHCORE_*` variables and its defaults, never from the GUI's saved settings. Set the
`MESHCORE_*` variables to match what the GUI uses. On a library-only install, the bridge mints and keeps its own identity key
under `~/.local/share/meshterm-spi-bridge/identity.key`.
**Only one program may use the radio at a time.** Never run the bridge and the
@@ -323,7 +345,15 @@ radio.
## Updating and uninstalling
To update, pull or re-download `meshterm-spi-bridge` and reinstall the service (menu option
2 again — it overwrites the copy in `~/.local/bin/`).
2 again — it overwrites the copy in `~/.local/bin/`). The running service keeps the old
code until you restart it:
```bash
systemctl --user restart meshterm-spi-bridge.service
```
Option 2 also rewrites the unit file, so any `Environment=` lines you added to it by hand
are lost. Put them in a drop-in instead, as [Hardware knobs](#hardware-knobs) shows.
To remove the bridge, run the menu and choose **3) Uninstall the service**. This stops and
removes the `systemd --user` unit and the installed script copy. It does **not** touch
@@ -335,16 +365,31 @@ that block by hand if you no longer want it.
## Hardware knobs
The defaults match the hackergadgets uConsole AIO, so a stock AIO needs none of these. They
exist for a revision or a board that differs: export what differs before launching the
bridge, or put the lines in the service's environment.
The pin defaults match the hackergadgets uConsole AIO v1, which needs none of these. The
**AIO v2 needs `MESHCORE_EN_PINS=27`**, whether or not the `meshcore-console` package is
installed. The radio settings default to the US/Canada preset. For a foreground run, export
what differs before launching the bridge. For the service, add a drop-in, which survives a
reinstall:
```bash
systemctl --user edit meshterm-spi-bridge.service
```
and put the variables under a `[Service]` heading, one `Environment=` line each:
```ini
[Service]
Environment=MESHCORE_EN_PINS=27
```
Then `systemctl --user restart meshterm-spi-bridge.service`.
| Variable | What it sets |
| --- | --- |
| `MESHCORE_BUS_ID`, `MESHCORE_CS_ID`, `MESHCORE_CS_PIN` | which SPI bus, device and chip-select pin the radio is on |
| `MESHCORE_RESET_PIN`, `MESHCORE_BUSY_PIN`, `MESHCORE_IRQ_PIN` | the three control lines |
| `MESHCORE_FREQUENCY`, `MESHCORE_TX_POWER` | frequency in Hz, power in dBm |
| `MESHCORE_SPREADING_FACTOR`, `MESHCORE_BANDWIDTH`, `MESHCORE_CODING_RATE` | the modem preset — all three must match the mesh you are joining |
| `MESHCORE_SPREADING_FACTOR`, `MESHCORE_BANDWIDTH`, `MESHCORE_CODING_RATE` | the modem preset — all three must match the mesh you are joining. Whole numbers only: bandwidth in Hz (`62500`), coding rate as the denominator (`5` for 4/5). A decimal or `4/5` stops the bridge at startup. |
| `MESHCORE_TXEN_PIN`, `MESHCORE_RXEN_PIN`, `MESHCORE_EN_PINS` | the RF-switch and power-enable lines a board may need (`-1` for none; `EN_PINS` is a comma list — the AIO v2 wants `27`) |
| `MESHCORE_USE_DIO2_RF`, `MESHCORE_USE_DIO3_TCXO`, `MESHCORE_IS_WAVESHARE` | whether DIO2 drives the RF switch and DIO3 the TCXO (both on for the AIO), and a flag for a different vendor's wiring the runtime knows about |
| `MESHCORE_GPIO_CHIP`, `MESHCORE_USE_GPIOD_BACKEND`, `MESHCORE_PREAMBLE_LENGTH` | which gpiochip, whether to drive it through `gpiod`, and the LoRa preamble |
@@ -352,10 +397,11 @@ bridge, or put the lines in the service's environment.
The bridge passes a knob only when the runtime's radio constructor accepts it, so the same
environment works under `pymc_core` 1.0.x, which lacks the newer ones. Where the
`meshcore-console` package is installed, the bridge builds the radio through *its* hardware
configuration, which reads the same names plus a few more of its own — see that package's
documentation. On a library-only install the table above is the whole of it, so an AIO v2
without that package present may not power its radio.
`meshcore-console` package is installed, the bridge builds the radio through that
package's environment reader, which reads the same names plus a few more of its own (see
that package's documentation) and ignores the GUI's saved presets. On a library-only
install the table above is the whole of it. Either way, an AIO v2 without
`MESHCORE_EN_PINS=27` will not power its radio.
---
@@ -366,10 +412,10 @@ without that package present may not power its radio.
| `node runtime` fails in the preflight | Did you build the venv in [Step 2](#step-2--install-the-radio-runtime)? Is `MESHTERM_PYMC_PYTHON` pointing at the right interpreter, if set? |
| `SPI device` shows "no /dev/spidev* found" | Is `dtoverlay=spi1-1cs` in `/boot/firmware/config.txt`? Did you reboot after adding it? |
| `SPI device` or `GPIO chip` shows a permission error | Are you in the `spi` and `gpio` groups (`groups`)? Did you log out and back in after `usermod`? |
| Preflight warns "radio in use — meshcore-console is running" | Close the GUI before running or installing the bridge. |
| Preflight warns "port 5000 busy" | Something else is already listening there — check `systemctl --user status meshterm-spi-bridge.service` for an already-running instance. |
| Bridge starts but `radio.begin()` fails after 4 attempts | GPIO lines may still be held by a session that just closed — wait a few seconds and try again. |
| MeshTerm connects but direct messages to an old contact say "not found" | Restart the bridge — contacts are restored from its snapshot file at startup; if the snapshot is missing, the contact needs to be heard again. |
| Preflight warns `radio in use meshcore-console is running:` | Close the GUI before running or installing the bridge. |
| Preflight warns `port 5000 busy something is already on 127.0.0.1:5000` | Something other than the bridge service is listening there (an active service is reported as `bridge service running` instead). Usually it is a foreground `--run` in another terminal; stop that one. |
| Bridge exits with `bridge failed: GPIO pins still busy after retries` | The bridge retries `radio.begin()` four times only when the GPIO lines are busy. They may still be held by a session that just closed — wait a few seconds and try again. |
| MeshTerm connects but direct messages to an old contact say "not found" | Contacts are restored from the bridge's snapshot file at startup. If the contact is not in it, wait until the contact is heard again. |
| Reboot, factory reset, or setting a device PIN silently does nothing | Expected — the radio library has no handler for these three, whatever runtime you're on. |
| Traces or the live feed don't show up | These rely on compatibility shims the bridge installs (or, under `openhop_core`, on the library's own equivalents). Check the bridge's log for lines starting `compat:` to confirm they're wired. |
@@ -381,7 +427,7 @@ This has been verified on a bookworm uConsole with `openhop-core` 1.1.3: the rad
contacts are restored across a restart, and `info` and `contacts` answer correctly over the
TCP connection. The older `pymc-core` 1.0.x path, with all three of the bridge's
compatibility shims active, has been exercised in full, including traces and the live feed.
Every interface the bridge calls in both runtimes is checked by MeshTerm's test suite.
MeshTerm's test suite does not cover the bridge.
What has not been confirmed:
+3 -3
View File
@@ -1,12 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
"""MeshTerm is a full-featured TUI-based MeshCore client for your terminal.
"""MeshTerm is a full-featured TUI MeshCore client for your terminal.
Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Linux.
Connects to a companion over USB, Bluetooth or TCP. For Windows, macOS and Linux.
"""
from datetime import datetime
__version__ = "0.3.7"
__version__ = "0.9.0"
__author__ = "Jean-Pierre Martineau"
#: The year MeshTerm was first published; the copyright span starts here.
+15 -15
View File
@@ -4,11 +4,11 @@
## What it is
**MeshTerm** is a full-featured **MeshCore** client that lives in your terminal. Plug in a companion device over USB, pair one over Bluetooth, or reach one over TCP. Windows, macOS, and Linux. Free and open-source.
**MeshTerm** is a full-featured TUI **MeshCore** client for your terminal. Plug in a companion device over USB, pair one over Bluetooth, or reach one over TCP. Windows, macOS, and Linux. Free and open-source.
Nearly everything MeshTerm can do thru its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The live pictures stay in the TUI, where they belong — the map, the dashboard, the live feed, the watchtower, the mesh walk — and everything else has a command. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks! Every command prints for a person by default, and speaks JSON with `--json` when a program is reading!
Nearly everything MeshTerm can do through its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The live pictures stay in the TUI, where they belong — the map, the dashboard, the live feed, the watchtower, the mesh walk — and everything else has a command. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks! Every command prints for a person by default, and speaks JSON with `--json` when a program is reading!
Everything the radio overhears is recorded to a local database from the moment it's turned on — adverts, telemetry, signal quality, positions, etc. Features like maps, dashboard, and route graphs read back from it, so the longer it runs, the more it know about the mesh!
While MeshTerm is running and connected to your radio, everything the radio overhears is recorded to a local database — adverts, telemetry, signal quality, positions, etc. Features like maps, dashboard, and route graphs read back from it, so the longer it runs, the more it knows about the mesh!
## Where it came from
@@ -18,29 +18,29 @@ I wanted something I could run from my computer. I wanted it to run on Windows,
## Platform-specific developments
In addition to generic Terminal setup, I wanted it to run on two devices I had lying around on my nerdcave.
In addition to a generic terminal setup, I wanted it to run on two devices I had lying around in my nerdcave.
### Clockwork uConsole
MeshTerm is compatible with the **HackerGadgets AIO board v1**. The **AIO** board has just a raw LoRa radio, meaning it can't run MeshCore firmware. MeshTerm includes a script to run the mesh node in software, and expose it using a TCP port which MeshTerm can connect to.
### ClockworkPi uConsole
MeshTerm is compatible with the **HackerGadgets AIO board (v1 or v2)**. The **AIO** board has just a raw LoRa radio, meaning it can't run MeshCore firmware. MeshTerm includes a script to run the mesh node in software, and expose it using a TCP port which MeshTerm can connect to.
MeshTerm feels right at home on the **uConsole**. Running MeshTerm on it makes it feel like a piece of gear straight out of a William Gibson novel.
### Clockwork PicoCalc
### ClockworkPi PicoCalc
Getting MeshTerm to run on the **PicoCalc** is a bit more involved, but totally worth it:
- Swap out the **Raspberry Pi Pico** for a **Luckfox Lyra** to run **Calculinux**.
- Add a compatible 3.3v USB wifi dongle (e.g.: **TP-Link TL-WN725N N150 Nano**) to the **Lyra**'s internal USB port.
- Add a LoRa device. Here's a few options:
- Add a compatible 3.3 V USB Wi-Fi dongle (e.g. **TP-Link TL-WN725N N150 Nano**) to the **Lyra**'s internal USB port.
- Add a LoRa device. Here are two options:
- Connect a companion via USB-C;
- add **Seeed Studio XIAO nRF52840 + Wio-SX1262** internally, soldered to GPIO connectors' legs.
- In order to use the XIAO nRF52840 via GPIO, we do need to use a modified firmware. A **PR** *https://github.com/meshcore-dev/MeshCore/pull/3191* has been submitted to MeshCore, but it might take a while for it to go through.
- In order to use the XIAO nRF52840 via GPIO, a modified firmware is needed. A **PR** *https://github.com/meshcore-dev/MeshCore/pull/3191* has been submitted to MeshCore, but it might take a while for it to go through.
The UX is a bit different on the the PicoCalc's 16-color 53-column screen layout, but it's been tailored to make good use of the available space and the PicoCalc's F-keys. The result is, in my humble opinion, the best MeshCore experience you can get on a portable device. With two 18650 batteries, it'll run all day.
The UX is a bit different on the PicoCalc's 16-colour 53-column screen layout, but it's been tailored to make good use of the available space and the PicoCalc's F-keys. The result is, in my humble opinion, the best MeshCore experience you can get on a portable device. It runs on one 18650 battery, and a second one roughly doubles the runtime.
## What the future holds
MeshTerm will evolve over time, it is a work in progress.
MeshTerm will evolve over time. It is a work in progress.
I've build MeshTerm for myself — I find that's when I produce my best work — but I sincerely hope it will be appreciated by others.
I've built MeshTerm for myself — I find that's when I produce my best work — but I sincerely hope it will be appreciated by others.
If you'd like a specific feature, pitch your idea on the Discord!
@@ -49,9 +49,9 @@ If you'd like a specific feature, pitch your idea on the Discord!
MeshTerm ships under the **Apache License, Version 2.0** — free to use,
change, and redistribute, commercially or otherwise, as long as the
LICENSE and NOTICE files travel with it. The full terms:
**apache.org/licenses/LICENSE-2.0** *https://www.apache.org/licenses/LICENSE-2.0*.
*https://www.apache.org/licenses/LICENSE-2.0*.
The licence covers the code, not the name: **MeshTerm and its logo are
The licence does not cover the name: **MeshTerm and its logo are
trademarks the author keeps**, so a fork goes out under a name of its own.
The donate link in this app supports this project and its original
author — a fork that keeps soliciting through it, without pointing it at
+4 -4
View File
@@ -1,7 +1,7 @@
## Who am I IRL
I'm Jean-Pierre Martineau. I live in Montréal, Québec, Canada.
I'm Jean-Pierre Martineau. I live in Montréal, Québec, Canada.
Data expert by day and tinkerer by night, I've spend my career designing solutions and writing code for various industries, from video-games to public transit. I've always had an unquenchable curiosity that drove me to pursue personal projects during my free time. MeshCore has been the focal point of my nights and weekends as of lately.
Data expert by day and tinkerer by night, I've spent my career designing solutions and writing code for various industries, from video games to public transit. I've always had an unquenchable curiosity that drove me to pursue personal projects during my free time. MeshCore has been the focal point of my nights and weekends lately.
## Who am I on the mesh
@@ -9,11 +9,11 @@ I operate **YUL-Cartierville** and **YUL-Upper-Salaberry** repeaters in Montréa
## On the subject of AI assisted development
As a computer scientist, I find AI to be a powerful amplifier, not a substitute—of one's capabilities. For me, it's been a vector of productivity, allowing me to grasp something which would have been out of reach. I can spend my time ideating and let the machine take care of the nitty-gritty. Don't be fooled, sculpting MeshTerm into its current form still took a *lot* of work. Every nook and cranny of the app has been designed, tested, and iterated by me. Hundreds of hours of my own time, augmented by what I learned throughout my sofware developer career.
As a computer scientist, I find AI to be a powerful amplifier of one's capabilities. For me, it's been a vector of productivity, allowing me to grasp something which would have been out of reach. I can spend my time ideating and let the machine take care of the nitty-gritty. Don't be fooled: sculpting MeshTerm into its current form still took a *lot* of work. Every nook and cranny of the app has been designed, tested, and iterated by me. It took hundreds of hours of my own time, augmented by what I learned throughout my software developer career.
MeshTerm would *not* exist without the use of AI tools.
One detail: the about pages were hand-written by me, and the ANSI art splash screen logo was done by hand — no AI — using **Moebius**.
One detail: these pages were hand-written by me, and the ANSI art splash screen logo was done by hand — no AI — using **Moebius**.
## How to reach me
+8 -8
View File
@@ -1,14 +1,14 @@
## Why it needs support
**MeshTerm** is free.
**MeshTerm** is free and open source, and it will stay that way.
The project is a labour of love. I've poured hundreds of hours of my free time into it. Evenings, late nights, weekends, even some of my vacation time!
As I mentionned in the about pages, this project relies on AI tools to accelerate development. Without them, MeshTerm would absolutely not exist. Subscriptions to these AI tools are a recurring expense!
As I mentioned on the Author page, this project relies on AI tools to accelerate development. Without them, MeshTerm would absolutely not exist. Subscriptions to these AI tools are a recurring expense!
Routers, companion devices, handhelds, antennas, and batteries aren't super expensive individually, but it all adds up quickly!
Repeaters, companion devices, handhelds, antennas, and batteries aren't super expensive individually, but it all adds up quickly!
If you want pitch in and contribute financially, you're more that welcome to do so. Any help will be greatly appreciated and put to good use.
If you want to pitch in and contribute financially, you're more than welcome to do so. Any help will be greatly appreciated and put to good use.
## Chip in
@@ -20,7 +20,7 @@ If you want pitch in and contribute financially, you're more that welcome to do
https://buymeacoffee.com/johnputer
```
### Donate via Paypal
### Donate via PayPal
- *https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6*
@@ -30,17 +30,17 @@ https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6
### Become a patron
- *https://www.patreon.com/c/MeshTerm*
- *https://www.patreon.com/cw/MeshTerm*
```qr
https://www.patreon.com/c/MeshTerm
https://www.patreon.com/cw/MeshTerm
```
## Other ways to help
- Spread the word and let others know how awesome **MeshTerm** is!
- Join the Discord community!
- Give this project a Star **⭐** on **GitHub**!
- Give this project a Star **⭐** on **GitHub**!
- Open an issue on **GitHub** when you encounter a bug or problem!
*https://github.com/jpmartineau/MeshTerm*
+19 -5
View File
@@ -22,7 +22,7 @@ from . import __version__
from .context import AppContext
from .core import exitcodes, win32dll
from .core.admin_store import AdminStore
from .core.config import Settings
from .core.config import MOCK_DB_FILENAME, Settings
from .core.connection import DeviceCommandError, is_connection_lost
from .core.device_config import DeviceConfigError
from .core.device_store import DeviceStore
@@ -121,8 +121,8 @@ app = typer.Typer(
# The one description, same as the package summary and the PyPI page. See
# `meshterm.__doc__` — if this drifts from that, one of them is lying.
help=(
"MeshTerm is a full-featured TUI-based MeshCore client for your terminal. "
"Supports USB, Bluetooth and TCP companion connection. "
"MeshTerm is a full-featured TUI MeshCore client for your terminal. "
"Connects to a companion over USB, Bluetooth or TCP. "
"For Windows, macOS and Linux."
),
epilog=EXIT_STATUS_EPILOG,
@@ -175,7 +175,9 @@ def main_callback(
help="Network address host[:port] of a TCP companion (selects the TCP transport)",
),
mock: bool = typer.Option(False, "--mock", help="Use the built-in simulator"),
db_path: Path | None = typer.Option(None, "--db", help="SQLite database path"),
db_path: Path | None = typer.Option(
None, "--db", help="SQLite database path (--mock records to meshterm-mock.db)"
),
json_output: bool = typer.Option(False, "--json", help="Machine-readable output"),
absolute: bool = typer.Option(
False, "--absolute", help="Print absolute timestamps instead of relative ages"
@@ -198,7 +200,8 @@ def main_callback(
ble_pin: Optional BLE pairing PIN for the Bluetooth companion.
tcp: Explicit network address ``host[:port]``, selecting the TCP transport.
mock: Whether to use the simulator instead of real hardware.
db_path: Override the database location.
db_path: Override the database location. Without it, a ``--mock`` run records to
the simulator's own database rather than the real history.
json_output: Print the answer as JSON instead of aligned text.
absolute: Print times as ISO-8601 instants rather than relative ages, for this run.
quiet: Suppress console logging (file logging continues).
@@ -232,6 +235,17 @@ def main_callback(
settings = Settings.load()
if db_path is not None:
settings.db_path = db_path
elif mock:
# The simulator is a fake radio, not a fake MeshTerm: everything it adverts was
# written to the real history like anything a companion said, and its four
# invented contacts then turned up in the mesh walk, the dashboard and the map of
# the mesh you actually run. A pretend radio gets a pretend history —
# ``<config_dir>/meshterm-mock.db``, created on first use — while ``--db`` still
# wins, because naming a database is a deliberate claim about where a run records.
# ``$MESHTERM_HOME`` remains the only thing that isolates a run whole: the contact
# and channel caches, the outbox and the remembered devices live beside the
# database either way (see docs/cli.md).
settings.db_path = settings.config_dir / MOCK_DB_FILENAME
# Two front ends, two consoles, and which one this process gets is settled here — a
# named subcommand is a scripted run, so it prints on the plain, colourless,
+12 -1
View File
@@ -34,6 +34,17 @@ else: # pragma: no cover - exercised only on 3.10
#: can move it between cases.
CONFIG_DIR_ENV = "MESHTERM_HOME"
#: The history's filename inside the config directory, when ``config.toml`` and ``--db``
#: both leave it alone.
DB_FILENAME = "meshterm.db"
#: The simulator's history, kept beside the real one and used by ``--mock`` runs that did
#: not name a database themselves. ``--mock`` is a fake radio, not a fake MeshTerm: what
#: the simulator adverts is recorded like anything a real companion said, so without a
#: database of its own its four invented contacts turn up in the mesh walk, the dashboard
#: and the map of the mesh you actually run.
MOCK_DB_FILENAME = "meshterm-mock.db"
def default_config_dir() -> Path:
"""Return the directory MeshTerm uses for config and data.
@@ -138,7 +149,7 @@ class Settings:
def __post_init__(self) -> None:
"""Derive dependent paths that were not explicitly provided."""
if self.db_path is None:
self.db_path = self.config_dir / "meshterm.db"
self.db_path = self.config_dir / DB_FILENAME
def resolve_profile(self, name: str | None) -> DeviceProfile | None:
"""Look up a profile by name, falling back to the default profile.
+238
View File
@@ -0,0 +1,238 @@
# SPDX-License-Identifier: Apache-2.0
"""What MeshTerm is running on, read once so a bug report does not have to be interviewed.
Every value here answers a question that otherwise costs a round trip with whoever
reported the problem: which build of MeshTerm, on which OS, in which terminal, how wide,
with which colour depth. None of it is about the mesh and none of it is private — this
module deliberately knows nothing about contacts, keys, positions or messages, so the
block it feeds can be pasted in public without anyone having to read it first.
Two probes are **ladders over environment variables** rather than protocol queries, which
is a concession worth naming: a terminal has no portable way to be asked who it is. The
one sequence that comes close (``CSI > q``) needs a live tty, a reply that may never
arrive, and a timeout budget on a path that must never hang. So each terminal is
identified by the variable it sets about *itself* — which is the same ladder
:func:`~meshterm.ui.termfont.detect_terminal_font` already climbs for the font — and
anything unrecognised falls back to ``TERM`` rather than to a guess.
"""
from __future__ import annotations
import os
import platform
import shutil
import sys
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
#: The first Windows 11 build. Windows 11 still reports its release as ``"10"`` through
#: every documented API, so the build number is the only thing that separates the two.
_WINDOWS_11_BUILD = 22000
#: Terminals that announce themselves, by the variable each one sets. Ordered most to
#: least specific: an emulator hosting another (VS Code's terminal, a multiplexer) sets
#: its own marker *and* inherits the outer one, so the inner identity has to win.
#:
#: ``TERM_PROGRAM`` is checked separately, between these and the ``TERM`` fallback,
#: because its *value* is the name — it identifies terminals this table never has to list.
_TERMINAL_VARS: tuple[tuple[str, str], ...] = (
("WT_SESSION", "Windows Terminal"),
("ConEmuPID", "ConEmu"),
("TERMINAL_EMULATOR", ""), # JetBrains IDEs put the name in the value
("KITTY_WINDOW_ID", "kitty"),
("ALACRITTY_WINDOW_ID", "Alacritty"),
("KONSOLE_VERSION", "Konsole"),
("GNOME_TERMINAL_SCREEN", "GNOME Terminal"),
("VTE_VERSION", "VTE-based"),
)
#: What ``TERM_PROGRAM`` calls terminals whose own name is not what a person calls them.
#: Only the ones that genuinely differ; every other value passes through as it is, which
#: is what keeps this from growing into a catalogue of every terminal there is.
_TERM_PROGRAM_NAMES: dict[str, str] = {
"Apple_Terminal": "Terminal.app",
"vscode": "VS Code",
}
@dataclass(frozen=True, slots=True)
class Host:
"""The machine and the Python running MeshTerm.
Attributes:
install: How this copy was installed — ``"frozen"`` (a one-file build, which has
its own bootloader, its own relaunch rules and no source tree to read),
``"source"`` (a checkout, so a commit can be asked for), or ``"package"``
(a wheel from an index). The three fail in different ways and the reporter
usually does not know which one they have.
os: The flavour and its version as a person writes it — ``Windows 11``,
``macOS 15.6``, ``Debian GNU/Linux 12 (bookworm)``.
os_build: The exact build under that name: a Windows build number, or the kernel
release everywhere else. What separates two hosts that call themselves the
same thing.
arch: The processor architecture, which is what tells an arm64 build from an
x86_64 one when a binary misbehaves.
python: The interpreter version, three parts.
"""
install: str
os: str
os_build: str
arch: str
python: str
@dataclass(frozen=True, slots=True)
class Terminal:
"""The terminal MeshTerm is drawing into, and what it can draw.
Attributes:
program: The emulator's name, or ``TERM``'s value where nothing identified
itself, or ``None`` when even that is unset.
term: The ``TERM`` value, verbatim. Kept beside ``program`` rather than folded
into it: a terminal lying about ``TERM`` is itself a common cause of a
rendering report.
colorterm: The ``COLORTERM`` value, which is how a terminal claims truecolor and
the first thing to check when a screen arrives in the wrong colours.
cols: The terminal's width in cells.
rows: The terminal's height in cells.
over_ssh: Whether this is an ssh session. It changes what is knowable — the font
lives on the far client — and it is why several other verdicts here read
``unknown`` rather than wrong.
"""
program: str | None
term: str | None
colorterm: str | None
cols: int
rows: int
over_ssh: bool
def install_kind() -> str:
"""How this copy of MeshTerm got here: ``frozen``, ``source`` or ``package``.
A frozen build is unambiguous — the bootloader sets the flag. Past that, the question
is whether there is a repository above the package: a checkout can be asked for its
commit and can carry uncommitted work, and a wheel can do neither.
Returns:
One of ``"frozen"``, ``"source"`` or ``"package"``.
"""
if getattr(sys, "frozen", False):
return "frozen"
# meshterm/core/hostinfo.py -> meshterm/core -> meshterm -> the tree that holds it
if (Path(__file__).resolve().parents[2] / ".git").exists():
return "source"
return "package"
def host() -> Host:
"""Read the machine's own facts.
Returns:
The :class:`Host` block.
"""
name, build = _os_name_and_build()
return Host(
install=install_kind(),
os=name,
os_build=build,
arch=platform.machine() or "unknown",
python=platform.python_version(),
)
def terminal(environ: Mapping[str, str] | None = None) -> Terminal:
"""Read the terminal's own facts.
Args:
environ: The environment to inspect (defaults to ``os.environ``), injectable so
the ladder can be tested without a terminal of the right kind to hand.
Returns:
The :class:`Terminal` block.
"""
env = os.environ if environ is None else environ
size = shutil.get_terminal_size()
return Terminal(
program=terminal_program(env),
term=env.get("TERM") or None,
colorterm=env.get("COLORTERM") or None,
cols=size.columns,
rows=size.lines,
over_ssh=bool(env.get("SSH_TTY") or env.get("SSH_CONNECTION")),
)
def terminal_program(environ: Mapping[str, str] | None = None) -> str | None:
"""Name the terminal emulator from what it says about itself.
The ladder: the terminals in :data:`_TERMINAL_VARS` that set a marker variable, then
``TERM_PROGRAM`` (whose *value* is the name, so it covers terminals this module has
never heard of), then ``TERM`` as the last honest thing there is to say.
Args:
environ: The environment to inspect (defaults to ``os.environ``).
Returns:
The terminal's name, or ``None`` when nothing in the environment names one.
"""
env = os.environ if environ is None else environ
for var, name in _TERMINAL_VARS:
value = env.get(var)
if value:
return name or value
program = env.get("TERM_PROGRAM")
if program:
return _TERM_PROGRAM_NAMES.get(program, program)
return env.get("TERM") or None
def _os_name_and_build() -> tuple[str, str]:
"""The OS as a person writes it, and the exact build under that name."""
system = platform.system()
if system == "Windows":
return _windows()
if system == "Darwin":
version = platform.mac_ver()[0]
return (f"macOS {version}" if version else "macOS"), platform.release()
if system == "Linux":
return (_pretty_name() or "Linux"), platform.release()
return (system or "unknown"), platform.release()
def _windows() -> tuple[str, str]:
"""Windows by the name it is sold under, and its build number.
``platform.win32_ver`` reports release ``"10"`` on Windows 11 as well — the rename
never reached the version APIs — so the build number is what decides which one this
is. The build is also the useful half of the pair: it is what a fix is shipped
against.
"""
release, version, _service_pack, _kind = platform.win32_ver()
try:
build = int(version.rsplit(".", 1)[-1])
except ValueError:
build = 0
name = "Windows 11" if build >= _WINDOWS_11_BUILD else f"Windows {release or '?'}"
return name, version or platform.version()
def _pretty_name() -> str | None:
"""The distribution's own name for itself, from ``/etc/os-release``.
The one place a Linux flavour is stated by the system rather than inferred, and the
reason a report can say *Calculinux* or *Raspberry Pi OS* instead of ``Linux``.
A host without the file (or without permission to read it) simply has no answer.
"""
try:
text = Path("/etc/os-release").read_text(encoding="utf-8")
except OSError:
return None
for line in text.splitlines():
key, _, value = line.partition("=")
if key == "PRETTY_NAME":
return value.strip().strip('"') or None
return None
+413
View File
@@ -0,0 +1,413 @@
# SPDX-License-Identifier: Apache-2.0
"""Reopening the session in a Terminal window that looks like MeshTerm.
macOS Terminal is not the classic Windows console's problem over again. It falls back
generously — ask it for a glyph its font lacks and it finds one somewhere — so nothing here
is ever a box. The trouble is *what* it finds: no Mac monospace face carries the Braille
Patterns block at all (SF Mono, Menlo, Monaco and Courier all measure zero), so the block
the charts and the map's terrain are drawn from is borrowed from a **proportional** face
and arrives at the wrong width. The screen is not empty, it is crooked, which is worse —
nothing announces itself as broken and the reader has nothing to compare it against.
The fix is a font, and a font means a Terminal profile that names it. Which raises the
question this module exists to answer carefully: **whose terminal is it?**
MeshTerm's palette is the 16-colour VT set (:data:`~meshterm.ui.theme._VT_SLOTS`) — a
deliberately crude, deliberately CGA-ish thing that the app wears on purpose. Nobody should
have to live in it to read their mail. So this module never touches the reader's default
profile and never edits their preferences: it *adds* a profile named MeshTerm, and opens
MeshTerm's own window in it. Their Terminal keeps whatever they chose, down to its
translucency; ours is opaque and crude in a window of its own, and quitting leaves nothing
behind.
That also sidesteps a trap worth recording, because it costs an evening to find. Terminal
rewrites ``~/Library/Preferences/com.apple.Terminal.plist`` **from memory when it quits**,
so anything written into that file while Terminal is running is silently discarded later —
and MeshTerm is always running inside it. Nothing here writes that file. A ``.terminal``
file is handed to Terminal and *Terminal* does the importing, which is the one path that
works from inside a live session.
The profile is generated rather than shipped, from the same theme constant the app draws
itself with, so the window can never disagree with the screen it is holding.
"""
from __future__ import annotations
import os
import plistlib
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
from .relaunch import REOPENED_ENV, own_command, wanted_size
#: The name the profile is installed under, and the name the reader sees in Terminal's
#: settings list. Stable: renaming it would strand the copy already on their machine.
PROFILE_NAME = "MeshTerm"
#: The bundled face, by its PostScript name — which is what an ``NSFont`` archive holds,
#: and not the family name Font Book shows. Installed by :func:`install_font` from the copy
#: in the package; see :mod:`meshterm.core.consolefont` for the Windows half of the same
#: idea.
FONT_FACE = "CascadiaMonoPL-Regular"
#: Points. Terminal's own default is 11; 13 is a size the braille charts read cleanly at on
#: a Retina panel without the map losing rows.
FONT_SIZE = 13.0
#: Where a user-installed font goes on macOS. No administrator rights, no installer, and
#: the reader can undo it by dragging the file out of Font Book.
USER_FONT_DIR = Path("~/Library/Fonts").expanduser()
#: Terminal's key for each palette slot, in :data:`~meshterm.ui.theme._VT_SLOTS` order —
#: the standard ANSI 16, dim bank then bright bank.
_ANSI_KEYS: tuple[str, ...] = (
"ANSIBlackColor",
"ANSIRedColor",
"ANSIGreenColor",
"ANSIYellowColor",
"ANSIBlueColor",
"ANSIMagentaColor",
"ANSICyanColor",
"ANSIWhiteColor",
"ANSIBrightBlackColor",
"ANSIBrightRedColor",
"ANSIBrightGreenColor",
"ANSIBrightYellowColor",
"ANSIBrightBlueColor",
"ANSIBrightMagentaColor",
"ANSIBrightCyanColor",
"ANSIBrightWhiteColor",
)
def _archive(objects: list[object]) -> bytes:
"""One ``NSKeyedArchiver`` document, as Terminal stores every colour and font.
Terminal keeps these as nested binary plists inside the profile — an archived
``NSColor`` or ``NSFont`` rather than a number and a string, because the profile is
written by AppKit rather than for us. The shape is small and fixed, so it is built
here rather than reached for through a dependency.
Args:
objects: The ``$objects`` array, with ``$null`` already at index 0.
Returns:
The encoded archive. Binary, because :class:`plistlib.UID` has no XML spelling.
"""
return plistlib.dumps(
{
"$archiver": "NSKeyedArchiver",
"$objects": objects,
"$top": {"root": plistlib.UID(1)},
"$version": 100000,
},
fmt=plistlib.FMT_BINARY,
)
def ns_color(rgb: str) -> bytes:
"""An archived ``NSColor`` for a ``#rrggbb`` string.
Args:
rgb: The colour, as the theme spells it.
Returns:
The archive Terminal expects in a colour key.
"""
channels = (int(rgb[index : index + 2], 16) / 255 for index in (1, 3, 5))
# Calibrated RGB, space-separated to six places and NUL-terminated: AppKit's own
# spelling, matched exactly because Terminal parses it rather than tolerating it.
packed = " ".join(f"{value:.6f}" for value in channels).encode("ascii") + b"\x00"
return _archive(
[
"$null",
{"$class": plistlib.UID(2), "NSColorSpace": 1, "NSRGB": packed},
{"$classes": ["NSColor", "NSObject"], "$classname": "NSColor"},
]
)
def ns_font(face: str = FONT_FACE, size: float = FONT_SIZE) -> bytes:
"""An archived ``NSFont``.
Args:
face: The PostScript name of the face.
size: The size in points.
Returns:
The archive Terminal expects in the ``Font`` key.
"""
return _archive(
[
"$null",
{
"$class": plistlib.UID(3),
"NSName": plistlib.UID(2),
"NSSize": float(size),
"NSfFlags": 16,
},
face,
{"$classes": ["NSFont", "NSObject"], "$classname": "NSFont"},
]
)
def launch_command() -> str:
"""The shell line that starts this session again, for the profile to run.
Terminal starts the command from a **fresh login shell**, which is why this is a
string rather than an argv and why it carries what it needs explicitly. Nothing of the
current process's environment survives the hop — good news for the ``_PYI*`` variables
a frozen build must not pass on (see
:func:`~meshterm.core.relaunch.child_environment`, which exists for the Windows side
where they *do* survive), and bad news for a ``MESHTERM_HOME`` the reader set for this
invocation alone, which the README itself suggests doing. So every ``MESHTERM_*``
variable currently set is carried across, and the reopened-session marker with them.
The variables ride on ``/usr/bin/env`` rather than on a ``VAR=value`` prefix, and that
is not a stylistic choice. Terminal's own handling of the string is not something to
lean on: measured on macOS 26, the same command line runs under ``RunCommandAsShell``
*false* and fails under *true*, where Terminal takes the whole thing as the name of a
program and puts this on the screen::
Command not found: MESHTERM_REOPENED=1
Could not create a new process and open a pseudo-tty
``env`` is a real executable, so it is a valid argv *and* a valid shell line, and the
window cannot land on that message however Terminal decides to read it. ``env`` execs
the program in its own place, so nothing lingers behind it either — the window holds
MeshTerm and nothing else, and closing MeshTerm does not drop to a prompt.
Returns:
A command line Terminal can run with or without a shell.
"""
carried = {name: value for name, value in os.environ.items() if name.startswith("MESHTERM_")}
carried[REOPENED_ENV] = "1"
assignments = " ".join(
f"{name}={shlex.quote(value)}" for name, value in sorted(carried.items())
)
program = " ".join(shlex.quote(part) for part in own_command())
return f"/usr/bin/env {assignments} {program}"
def build_profile(command: str | None = None) -> dict[str, object]:
"""The MeshTerm profile, as Terminal's preferences hold it.
Every colour comes from :data:`~meshterm.ui.theme._VT_SLOTS`, so the window and the
app it is holding cannot disagree — change the theme and this follows on the next
write. The window size is
:func:`~meshterm.core.relaunch.wanted_size`'s, the same one Windows Terminal is asked
for.
Args:
command: A shell line for the window to run on open, or ``None`` for a profile
that just sits in the list waiting to be chosen.
Returns:
The profile dictionary, ready to write as a ``.terminal`` file.
"""
from ..ui.theme import _VT_SLOTS
cols, rows = wanted_size()
profile: dict[str, object] = {
"name": PROFILE_NAME,
"type": "Window Settings",
"ProfileCurrentVersion": 2.07,
"Font": ns_font(),
"FontAntialias": True,
"FontWidthSpacing": 1.0,
# Opaque on purpose. A braille raster over a translucent ground is unreadable, and
# a translucent profile is a perfectly reasonable thing for the reader to have --
# which is the whole argument for MeshTerm having a window of its own.
"BackgroundColor": ns_color("#000000"),
"TextColor": ns_color("#aaaaaa"),
"TextBoldColor": ns_color("#ffffff"),
"SelectionColor": ns_color("#555555"),
"CursorColor": ns_color("#55ffff"),
"CursorType": 0,
"BlinkText": False,
# Bold is bold, not a jump to the bright bank. The theme states brightness as a
# colour (see the wordmark's own note); letting Terminal promote it as well would
# land a bank too high.
"UseBrightBold": False,
"columnCount": cols,
"rowCount": rows,
"ScrollbackLines": 10000,
"ShouldLimitScrollback": 0,
"ShowActiveProcessInTitle": True,
"ShowDimensionsInTitle": False,
"ShowWindowSettingsNameInTitle": False,
# Close the window when MeshTerm exits cleanly, and keep it when it does not --
# a crash the reader never sees is a bug report nobody can write.
"shellExitAction": 1,
}
for key, (_slot, _label, rgb) in zip(_ANSI_KEYS, _VT_SLOTS, strict=True):
profile[key] = ns_color(rgb)
if command is not None:
profile["CommandString"] = command
# False, measured. Under *true* Terminal takes the whole string as a program name
# and the window says "Command not found: ..."; under false it runs. The opposite
# of what the key's name suggests, which is why it is written down.
profile["RunCommandAsShell"] = False
return profile
def launcher_path() -> Path:
"""The little script the profile runs, beside the profile itself."""
from .config import default_config_dir
return default_config_dir() / "launch.sh"
def write_launcher() -> Path:
"""Write the launcher, and return where it landed.
This exists for one reason, and it is the finding that shaped this module. Terminal
names an imported settings set **after the file** — ``MeshTerm.terminal`` becomes the
profile "MeshTerm" — and re-importing the *same bytes* reuses that profile, while
importing **different** bytes under the same filename creates "MeshTerm 1", then
"MeshTerm 2". Measured, by opening one file four times: three identical imports left
one profile, and the fourth, one word different, left two.
So the command cannot live in the profile. It carries ``sys.argv`` and the reader's
``MESHTERM_*`` variables, which differ between ``meshterm`` and ``meshterm --mock``,
and a profile per command line would fill the reader's settings list with junk that
only they can remove.
Moving the variable part behind a fixed path fixes it exactly: the profile says
``~/.meshterm/launch.sh`` and never changes, and this file underneath it says whatever
this launch happens to need.
Returns:
The path written, executable.
"""
path = launcher_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"#!/bin/sh\n"
"# Written by MeshTerm each time it reopens itself. Safe to delete.\n"
f"exec {launch_command()}\n",
encoding="utf-8",
)
path.chmod(0o755)
return path
def profile_path() -> Path:
"""Where the generated ``.terminal`` file is kept.
In MeshTerm's own directory rather than the Desktop: it is ours, it is the one place
that already survives an upgrade, and it moves with ``MESHTERM_HOME``.
"""
from .config import default_config_dir
return default_config_dir() / f"{PROFILE_NAME}.terminal"
def write_profile(*, command: str | None = None) -> Path:
"""Write the profile out, and return where it landed.
Args:
command: A shell line for the window to run, or ``None`` for a bare profile.
Returns:
The path written.
"""
path = profile_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(plistlib.dumps(build_profile(command), fmt=plistlib.FMT_XML))
return path
def font_installed() -> bool:
"""Whether the bundled face is already in the reader's font directory."""
from .consolefont import BUNDLED_FONT
return (USER_FONT_DIR / BUNDLED_FONT.name).is_file()
def install_font() -> bool:
"""Copy the bundled face into the reader's own font directory.
The one thing here that writes outside MeshTerm's own folder, which is why it is the
one thing worth asking about: a single file, in the reader's own Library, no
administrator rights, no installer, removable by dragging it out of Font Book. It
changes nothing about how their Terminal looks — it only makes the face available for
a profile to name.
Safe to repeat: an existing copy is left alone.
Returns:
Whether the font is installed and usable, including when it already was.
"""
from .consolefont import BUNDLED_FONT
if sys.platform != "darwin" or not BUNDLED_FONT.is_file():
return False
target = USER_FONT_DIR / BUNDLED_FONT.name
if target.is_file():
return True
try:
USER_FONT_DIR.mkdir(parents=True, exist_ok=True)
shutil.copy2(BUNDLED_FONT, target)
except OSError:
return False
return True
def in_apple_terminal() -> bool:
"""Whether this session is drawing into macOS Terminal.
iTerm2, Ghostty, kitty, WezTerm and VS Code all set ``TERM_PROGRAM`` to their own
name, and several of them have both truecolor and a font the reader chose deliberately
— none of them wants anything this module offers.
"""
return sys.platform == "darwin" and os.environ.get("TERM_PROGRAM") == "Apple_Terminal"
def available() -> bool:
"""Whether reopening in MeshTerm's own window is a thing worth doing here.
``False`` in a session that was itself reopened, which is what keeps a relaunch from
chaining into another one: the profile's own command carries the marker, because
Terminal starts it from a fresh shell that inherits nothing from us.
"""
return in_apple_terminal() and not os.environ.get(REOPENED_ENV)
def reopen() -> bool:
"""Open MeshTerm in a window using its own profile, and report whether it started.
``open`` hands the file to Terminal, and **Terminal** imports the profile and opens the
window — the only route that works from inside a live session, since Terminal discards
external edits to its preferences when it quits.
The new window is not a child in any meaningful sense: the caller exits immediately
afterwards and the two never talk.
Two files, not one: the launcher carries what varies and the profile points at it, so
the profile's bytes are the same on every launch and Terminal reuses the one settings
set instead of adding a numbered copy. :func:`write_launcher` has the measurement.
Returns:
Whether ``open`` was launched. ``False`` leaves the caller exactly where it was,
so a failure here is a detour and never a dead end.
"""
if not available():
return False
try:
launcher = write_launcher()
path = write_profile(command=str(launcher))
except OSError:
return False
try:
subprocess.Popen( # noqa: S603 - the argv is ours, not the reader's
["/usr/bin/open", str(path)],
close_fds=True,
)
except OSError:
return False
return True
+12 -2
View File
@@ -48,14 +48,24 @@ NODE_TYPE_ROOM = 3
NODE_TYPE_SENSOR = 4
#: Human-readable label for each advert type, for legends and summaries.
#: The one name for each advert type, everywhere MeshTerm prints one and in every
#: `--json` answer that carries a role or a type. A type-1 node is a companion: the radio
#: someone talks through, the same kind as the one in your hand.
NODE_TYPE_LABELS = {
NODE_TYPE_CHAT: "node",
NODE_TYPE_CHAT: "companion",
NODE_TYPE_REPEATER: "repeater",
NODE_TYPE_ROOM: "room",
NODE_TYPE_ROOM: "room server",
NODE_TYPE_SENSOR: "sensor",
}
def node_type_label(node_type: int | None) -> str | None:
"""The name for an advert type, ``type N`` for one MeshTerm does not know, None for none."""
if node_type is None:
return None
return NODE_TYPE_LABELS.get(int(node_type), f"type {node_type}")
class LoginResult(Enum):
"""How an admin login ended — and, crucially, whether the node was there to end it.
+104
View File
@@ -0,0 +1,104 @@
# SPDX-License-Identifier: Apache-2.0
"""What every relaunch of MeshTerm needs, whichever terminal it is relaunching into.
MeshTerm reopens itself when the terminal it was started from cannot draw it, and there
are two such terminals for two unrelated reasons. The classic Windows console has no font
fallback at all, so an icon it lacks is a box and no font choice fixes it
(:mod:`meshterm.core.winterminal`). macOS Terminal falls back generously — too generously:
no Mac monospace face carries the Braille Patterns block, so the charts are borrowed from a
*proportional* face and land at the wrong width (:mod:`meshterm.core.macterminal`). One
host shows nothing, the other shows something crooked.
The destinations could hardly be less alike. The act is the same one: work out the command
that would start this session again, hand it an environment a fresh launch can survive, and
let the caller exit. So it is written once, here, and each platform module supplies only
the part that is genuinely its own — which terminal, and how to ask it.
"""
from __future__ import annotations
import os
import sys
#: Set in the reopened session, so a relaunch can never chain into another one. Each
#: platform's own gate would usually refuse a second pass anyway — Windows Terminal is not
#: a classic console; a window opened from MeshTerm's profile is already in it — but this
#: is the belt to those braces, and it costs one environment variable.
REOPENED_ENV = "MESHTERM_REOPENED"
def own_command() -> list[str]:
"""The command that would start this same session again.
A frozen build is its own executable and takes the arguments as they were given. A
development or ``pip`` install is reached through its interpreter, because the console
script's own path is not something the receiving terminal can be relied on to resolve.
Returns:
The program and its arguments, ready to hand to a terminal.
"""
if getattr(sys, "frozen", False):
return [sys.executable, *sys.argv[1:]]
return [sys.executable, "-m", "meshterm", *sys.argv[1:]]
def child_environment() -> dict[str, str]:
"""The environment for a fresh launch of MeshTerm, minus this bundle's bookkeeping.
A PyInstaller one-file build runs in two stages: the executable unpacks itself into a
temporary directory and then runs itself again as a child, the two halves coordinating
through ``_PYI*`` environment variables. Those must not reach a *new* launch of the
executable. The bootloader would find them, conclude it is the second stage of a launch
it never made, check that its parent is the same program — find the terminal instead —
and abort::
[PYI-33368:ERROR] Security validation failure: parent process has different
executable!
Which is the whole session gone, in a brand new window holding a message nobody can act
on, while the window that offered the move has already closed. Removing them makes the
new process a first stage, which is what it actually is.
Returns:
The environment to start the new process with.
"""
environment = dict(os.environ)
environment[REOPENED_ENV] = "1"
if not getattr(sys, "frozen", False):
return environment
for key in [key for key in environment if key.startswith("_PYI")]:
del environment[key]
environment.pop("_MEIPASS2", None) # the name older bootloaders used
# The bootloader points the loader at the unpacked bundle and parks the caller's own
# value beside it. The new process unpacks its own copy, so it wants the original.
for name in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"):
original = environment.pop(f"{name}_ORIG", None)
if original is not None:
environment[name] = original
else:
environment.pop(name, None)
return environment
def wanted_size() -> tuple[int, int]:
"""The window to ask for, as ``(cols, rows)``.
Asked for, because a window MeshTerm opens should be one MeshTerm fits in. A session
that lands in whatever size the reader's shell happens to launch at can arrive too
small for the app, and the failure is quiet and cosmetic: the startup wordmark is 71
columns, so at 70 it silently swaps to the narrow one drawn for the PicoCalc, and a
short window truncates every description onto the pager.
The numbers come from the platform's own stated minimum plus a margin, so they follow
it rather than repeating it. A terminal is free to clamp to what the display can show,
so this is a request and not a demand — on a small screen the app degrades exactly as
it would have anyway.
Returns:
The columns and rows to ask the terminal for.
"""
from ..platforms import get_platform
platform = get_platform()
return platform.readable_cols + 8, platform.readable_rows + 6
+17 -74
View File
@@ -30,10 +30,15 @@ import shutil
import subprocess
import sys
#: Set in the reopened session, so a relaunch can never chain into another one. Windows
#: Terminal is not a classic console and so would not qualify anyway — this is the belt to
#: that braces, and it costs one environment variable.
REOPENED_ENV = "MESHTERM_REOPENED"
from .relaunch import REOPENED_ENV, child_environment, own_command, wanted_size
__all__ = [
"REOPENED_ENV",
"available",
"child_environment",
"own_command",
"reopen",
]
def available() -> str | None:
@@ -47,60 +52,6 @@ def available() -> str | None:
return shutil.which("wt.exe")
def own_command() -> list[str]:
"""The command that would start this same session again.
A frozen build is its own executable and takes the arguments as they were given. A
development or ``pip`` install is reached through its interpreter, because the console
script's own path is not something ``wt`` can be relied on to resolve.
Returns:
The program and its arguments, ready to hand to Windows Terminal.
"""
if getattr(sys, "frozen", False):
return [sys.executable, *sys.argv[1:]]
return [sys.executable, "-m", "meshterm", *sys.argv[1:]]
def child_environment() -> dict[str, str]:
"""The environment for a fresh launch of MeshTerm, minus this bundle's bookkeeping.
A PyInstaller one-file build runs in two stages: the executable unpacks itself into a
temporary directory and then runs itself again as a child, the two halves coordinating
through ``_PYI*`` environment variables. Those must not reach a *new* launch of the
executable. The bootloader would find them, conclude it is the second stage of a launch
it never made, check that its parent is the same program — find Windows Terminal
instead — and abort::
[PYI-33368:ERROR] Security validation failure: parent process has different
executable!
Which is the whole session gone, in a brand new window holding a message nobody can act
on, while the window that offered the move has already closed. Removing them makes the
new process a first stage, which is what it actually is.
Returns:
The environment to start the new process with.
"""
environment = dict(os.environ)
environment[REOPENED_ENV] = "1"
if not getattr(sys, "frozen", False):
return environment
for key in [key for key in environment if key.startswith("_PYI")]:
del environment[key]
environment.pop("_MEIPASS2", None) # the name older bootloaders used
# The bootloader points the loader at the unpacked bundle and parks the caller's own
# value beside it. The new process unpacks its own copy, so it wants the original.
for name in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"):
original = environment.pop(f"{name}_ORIG", None)
if original is not None:
environment[name] = original
else:
environment.pop(name, None)
return environment
def reopen() -> bool:
"""Start this session again in Windows Terminal, in the same directory.
@@ -148,20 +99,12 @@ def reopen() -> bool:
def _wanted_size() -> str:
"""The window to ask Windows Terminal for, as ``cols,rows``.
Asked for, because a window MeshTerm opens should be one MeshTerm fits in. Without
``-w new`` the session lands as a *tab* in whatever window happens to be open and
inherits its size; without ``--size`` a new window takes the reader's global launch
size, which is a setting about their shell and not about this app. Either way the app
can arrive in a window too small for it, and the failure is quiet and cosmetic: the
startup wordmark is 71 columns, so at 70 it silently swaps to the narrow one drawn for
the PicoCalc, and a short window truncates every description onto the pager.
The numbers come from the platform's own stated minimum plus a margin, so they follow
it rather than repeating it. Windows Terminal clamps to what the display can show, so
this is a request and not a demand — on a small screen the app degrades exactly as it
would have anyway.
Asked for, because without ``-w new`` the session lands as a *tab* in whatever window
happens to be open and inherits its size, and without ``--size`` a new window takes the
reader's global launch size, which is a setting about their shell and not about this
app. The dimensions themselves are
:func:`~meshterm.core.relaunch.wanted_size`'s, shared with every other terminal
MeshTerm reopens itself in; only the spelling is Windows Terminal's.
"""
from ..platforms import get_platform
platform = get_platform()
return f"{platform.readable_cols + 8},{platform.readable_rows + 6}"
cols, rows = wanted_size()
return f"{cols},{rows}"
+63
View File
@@ -1068,6 +1068,69 @@ class Repository:
row = self._conn.execute("SELECT COUNT(*) AS n FROM observations").fetchone()
return int(row["n"]) if row else 0
def table_counts(self) -> dict[str, int]:
"""Return a row count for every table in the database, by name.
Read off ``sqlite_master`` rather than a list written out here, which is the whole
point: a table added to the schema starts being reported the day it lands, and a
table removed stops, with nothing to keep in step. The one thing this must never
become is a curated set of the counts somebody thought were interesting — the
shape of a database is the aggregate, and the interesting one is always the table
nobody expected to be full.
Every value is a *count*. Nothing here reads a row, so the result says how much
history a report's author has without saying a word about who they talk to.
Returns:
Table name to row count, alphabetically, SQLite's own internal tables
excluded (they describe the file format, not this app's data).
"""
names = [
str(row["name"])
for row in self._conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name NOT LIKE 'sqlite_%' ORDER BY name"
).fetchall()
]
counts: dict[str, int] = {}
for name in names:
# The names come from sqlite_master, so they are this database's own tables
# and not caller input; quoted anyway, because an identifier cannot be bound.
row = self._conn.execute(f'SELECT COUNT(*) AS n FROM "{name}"').fetchone()
counts[name] = int(row["n"]) if row else 0
return counts
def failed_run_count(self) -> int:
"""Return how many recorded tool runs ended in an error.
The single most useful number a reporter can hand over: it says whether this
install has been failing quietly for weeks or broke for the first time today,
and it costs one query rather than an interview.
Returns:
The number of ``runs`` rows whose status is ``error``.
"""
row = self._conn.execute("SELECT COUNT(*) AS n FROM runs WHERE status = 'error'").fetchone()
return int(row["n"]) if row else 0
def observation_span(self) -> tuple[datetime | None, datetime | None]:
"""Return the first and last times anything was overheard.
How much mesh this database has actually seen. A report whose span is an hour and
a report whose span is a year describe different installs, and the difference
explains a class of "it works here" before anyone else has to ask for it.
Returns:
``(first, last)`` as aware UTC times, or ``(None, None)`` when nothing has
ever been heard.
"""
row = self._conn.execute(
"SELECT MIN(observed_at) AS first, MAX(observed_at) AS last FROM observations"
).fetchone()
if row is None or row["first"] is None:
return None, None
return _as_when(row["first"]), _as_when(row["last"])
def recent_observations(self, *, since: datetime, limit: int = 4000) -> list[Observation]:
"""Return raw stored observations in a recent window, oldest first.
+530
View File
@@ -0,0 +1,530 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``diagnostics`` tool: everything a bug report needs, in one block worth pasting.
A report that opens with "it crashed" costs two or three round trips before anyone knows
which MeshTerm, which OS, which terminal, which radio — and the person who hit the bug is
usually the one least able to answer, because half of it is resolved at boot and never
shown. This states all of it at once.
**What it deliberately does not say.** The block is designed to be pasted in public by
somebody who has not read it, so nothing that identifies a person or unlocks anything may
be in it: no pairing PIN, no admin password, no channel secret, no private key, no
position, and no contact's name or key. The mesh is described in **aggregate** — how many
rows of each kind, and the span of time they cover — which is the half that explains a bug
(a database with four observations and one with four hundred thousand fail differently)
without naming anyone the reporter talks to. :func:`~meshterm.core.hostinfo.host` and
:meth:`~meshterm.persistence.repository.Repository.table_counts` hold that line on their
own side, so it cannot be crossed here by accident.
**One report, three faces**, which is the whole reason this is a report and not a
``print``: the menu draws it as a written page (see :mod:`meshterm.ui.diagnostics`) and
saves it as markdown, ``meshterm diagnostics`` prints the same facts as aligned records for
anyone who would rather redirect than read, and ``--json`` hands them to whatever files the
issue. The document face is the one that leaves this machine, so it is the one written in
the format an issue tracker already renders.
The device is asked but never required. A report about a radio that will not connect is
exactly the report that most needs to be filed, so a failure to reach it becomes a fact in
the block (``connected``/``error``) rather than an error that replaces it.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from ..context import AppContext
from ..core.models import node_type_label
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Report
#: What the saved file is called, in the config directory beside the log it sits next to
#: in a bug report. Named for the app rather than just ``diagnostics.md`` because the one
#: thing that happens to this file is being moved somewhere else and attached, and by then
#: the directory it came from is no longer saying what it is.
DIAGNOSTICS_FILENAME = "meshterm-diagnostics.md"
@register
class DiagnosticsTool(Tool):
"""What MeshTerm, this machine, this terminal and this radio actually are."""
name = "diagnostics"
title = "Diagnostics"
icon = "🩺"
help = "Version, host, terminal and device facts for a bug report"
category = "This app"
order = 7 # after Preferences (the row that changes MeshTerm), before the pages
def register_cli(self, app: Any) -> None:
"""Register the command, with the one option that turns the answer into a file.
``--out`` is spelled the way ``config export-key --out`` already spells it, rather
than inventing a second word for writing an answer to a path. Plain redirection
still works and is not replaced by it — what the option adds is a file whose
columns are laid out at full width instead of at whatever the terminal happened to
be, which is the difference between a readable attachment and a folded one.
"""
import typer
from ..cli import run_tool_command
@app.command(name=self.name, help=self.help)
def _command(
out: Path | None = typer.Option(
None, "--out", help="Write the block to this file instead of printing it"
),
) -> None:
run_tool_command(self, {"out": out})
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
"""Open the page in the menu; returning ``None`` completes with no run row.
The same shape the About pages use — and the same screen, since the page *is* one:
the menu's whole interaction is the reading of it, and a run row recording that
somebody looked at their own version number would be a line in the log for every
glance. Saving from the page is likewise not a run.
Args:
ctx: Shared application context.
Returns:
Always ``None`` in the menu; the CLI never reaches this.
"""
from ..ui.diagnostics import open_diagnostics_page
report = await build_report(ctx)
await open_diagnostics_page(ctx, markdown_source(report), save_path=default_path(ctx))
return None
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""State the diagnostics, or write them to a file — only reachable from the CLI.
With ``--out`` the answer *becomes the path*, the same trade ``config export-key``
makes: a caller who asked for a file wants to be told where it is, not handed the
contents they just redirected into it.
Args:
ctx: Shared application context.
params: ``out`` — a destination path, or ``None`` to print the block.
Returns:
A :class:`ToolResult` carrying the block, or the path it was written to.
"""
report = await build_report(ctx)
out = params.get("out")
if out is None:
return ToolResult(summary={"tool": self.name}, report=report)
written = write_markdown(markdown_source(report), out)
ctx.ui.ack("[ok]✓[/ok] diagnostics written — attach it to the report.")
return ToolResult(
summary={"tool": self.name, "path": str(written)},
artifacts=[str(written)],
report=(_written_to(written),),
)
async def build_report(ctx: AppContext) -> Report:
"""Gather every diagnostic fact, as the report both faces render.
Seven blocks in the order a reader needs them: what this program is, what it is running
on, what it is drawing into, what radio it found, how much history it has, what that
history is made of, and what has been changed from the defaults. Each carries a
``caption``, which is what the markdown face turns into the page's ``##`` landmarks.
The five :class:`~meshterm.ui.report.Facts` blocks merge into one flat JSON object and
the two :class:`~meshterm.ui.report.Listing` blocks keep a key of their own — which is
also why the table counts are a listing, since a table named after a fact would
otherwise be able to collide with it.
Args:
ctx: Shared application context.
Returns:
The seven-block report.
"""
return (
_app_facts(),
_host_facts(),
_terminal_facts(),
await _device_facts(ctx),
_mesh_facts(ctx),
_table_listing(ctx),
_preference_listing(ctx),
)
def markdown_source(report: Report, *, when: datetime | None = None) -> str:
"""The whole block as a markdown document — the page's source, and the file's contents.
Markdown because both places this goes already speak it: the menu draws it through
:func:`~meshterm.ui.markdown.render_markdown`, the same renderer behind the About
pages, so the page gets real ``##`` landmarks that pin as it scrolls and that its
section jumps step by; and an issue tracker renders it as written, with no fence and
no apology. The command line keeps its aligned records — that face is a stream a
person greps, and headings are furniture on it.
Args:
report: The blocks to write out.
when: The moment to stamp in the colophon (defaults to now, local). Injectable so
the same report can be rendered twice and compared.
Returns:
Markdown source, ready for :func:`~meshterm.ui.markdown.render_markdown` or a file.
"""
from .. import __version__
from ..ui.renderers import markdown_blocks
stamp = (when or datetime.now().astimezone()).strftime("%Y-%m-%d %H:%M")
return (
# The `#` title carries what the frame around it cannot — which is the same reason
# `about.md` has one and the other three written pages do not. The screen is already
# headed "Diagnostics"; repeating that would spend the page's first line saying
# nothing, while the version belongs at the top of a file somebody will open later.
# Emphasis makes it the muted qualifier beside the name.
f"# MeshTerm diagnostics *v{__version__}*\n\n"
# The standfirst sits flush and muted under the title, and says the one thing a
# reader needs before reading any of it: this is safe to hand over.
"Everything a bug report opens with. Nothing here is private.\n\n"
f"{markdown_blocks(report)}\n\n"
"---\n\n"
f"Taken {stamp}.\n"
)
def default_path(ctx: AppContext) -> Path:
"""Where the page saves when nobody named a place: beside the log, in the config dir.
The config directory rather than the working directory, which in the menu is wherever
the reader happened to launch from and is not somewhere they can be told to look. It
is also where the log already lives, so "send me both" names one folder.
"""
return ctx.settings.config_dir / DIAGNOSTICS_FILENAME
def write_markdown(source: str, path: Path | str) -> Path:
"""Write a markdown document to ``path``, creating its directory.
**Always markdown**, whatever face the run was printing. This file exists to be
attached to an issue and read there, and an issue tracker renders markdown; anyone who
wants the machine face can redirect ``--json`` and already has a better name for that
file than this one.
Args:
source: The document, from :func:`markdown_source`.
path: The destination.
Returns:
The path written.
Raises:
OSError: If the directory cannot be made or the file cannot be written.
"""
written = Path(path)
written.parent.mkdir(parents=True, exist_ok=True)
written.write_text(source, encoding="utf-8")
return written
def _written_to(written: Path) -> Any:
"""The answer a ``--out`` run gives: the path, alone on the plain face."""
from ..ui import fields
from ..ui.report import BARE, Facts
return Facts(
key="saved",
fields=(fields.path("path", "path"),),
values={"path": written},
shape=BARE,
bare="path",
)
def _app_facts() -> Any:
"""What this program is: version, how it was installed, and where it keeps things."""
from .. import __version__
from ..core import hostinfo
from ..ui import fields
from ..ui.report import Facts
return Facts(
key="meshterm",
caption="Build",
fields=(
fields.word("meshterm", "meshterm"),
fields.word("install", "install"),
),
values={"meshterm": __version__, "install": hostinfo.install_kind()},
)
def _host_facts() -> Any:
"""The machine underneath: which OS, which build, which processor, which Python."""
from ..core import hostinfo
from ..ui import fields
from ..ui.report import Facts
machine = hostinfo.host()
return Facts(
key="host",
caption="Host",
fields=(
fields.word("os", "os"),
fields.word("os_build", "os_build"),
fields.word("arch", "arch"),
fields.word("python", "python"),
),
values={
"os": machine.os,
"os_build": machine.os_build,
"arch": machine.arch,
"python": machine.python,
},
)
def _terminal_facts() -> Any:
"""The terminal, and every verdict about it resolved once at boot.
Its own section rather than more host facts, because these are what a "it looks wrong"
report is actually about, and a reader skimming for them should not have to pass the
processor architecture on the way. Whether icons draw, whether the path widget's
separators draw, which platform flavour resolved and *why* — each is decided from the
environment before the first frame and none of it is visible on any screen.
"""
from ..core import hostinfo
from ..platforms import get_platform
from ..ui import fields
from ..ui.report import Facts
from ..ui.termfont import detect_terminal_font, emoji_support, powerline_support
term = hostinfo.terminal()
emoji = emoji_support()
powerline = powerline_support()
font = detect_terminal_font()
return Facts(
key="terminal",
caption="Terminal",
fields=(
fields.word("terminal", "terminal"),
fields.word("term", "term"),
fields.word("colorterm", "colorterm"),
fields.word("terminal_size", "terminal_size"),
fields.flag("over_ssh", "over_ssh"),
fields.word("platform", "platform"),
fields.flag("icons", "icons"),
fields.word("icons_source", "icons_source"),
fields.word("font", "font"),
fields.word("powerline", "powerline"),
),
values={
"terminal": term.program,
"term": term.term,
"colorterm": term.colorterm,
"terminal_size": f"{term.cols}x{term.rows}",
"over_ssh": term.over_ssh,
"platform": get_platform().name,
"icons": emoji.supported,
"icons_source": emoji.source,
# The face, and which terminal it was read from — an unidentified terminal
# (or an ssh session, where the font lives on the far client) has no answer,
# and saying so is worth more than naming a font nobody here can see.
"font": f"{font.face} ({font.source})" if font else None,
# Level and source together: `none (font:windows-terminal)` is a measurement
# and `unknown (ssh)` is an admission, and they lead somewhere different.
"powerline": f"{powerline.level} ({powerline.source})",
},
)
async def _device_facts(ctx: AppContext) -> Any:
"""The radio: what it is, how it is attached, and what its link is set to.
Best-effort by design. Every value here is ``None`` on a machine whose radio is
unplugged, asleep, or refusing to pair — and ``connected: false`` with the failure's
own words in ``error`` is a better first line for that bug report than a command that
refused to produce one.
"""
from ..ui import fields
from ..ui.report import Facts
values: dict[str, Any] = dict.fromkeys(
(
"connected",
"transport",
"port",
"device_role",
"device_model",
"firmware",
"radio_freq_mhz",
"radio_bw_khz",
"radio_sf",
"radio_cr",
"tx_power_dbm",
"error",
)
)
values["connected"] = False
try:
device = await ctx.device()
snapshot = await _snapshot(ctx, device)
info = await _device_info(device)
adv_type = snapshot.get("adv_type")
values.update(
connected=True,
transport="mock" if ctx.mock else (ctx.active_transport or "serial"),
port=ctx.active_port,
device_role=node_type_label(adv_type),
device_model=info.get("model") or None,
firmware=" ".join(str(info[k]) for k in ("ver", "fw_build") if info.get(k)) or None,
radio_freq_mhz=snapshot.get("radio_freq"),
radio_bw_khz=snapshot.get("radio_bw"),
radio_sf=snapshot.get("radio_sf"),
radio_cr=snapshot.get("radio_cr"),
tx_power_dbm=snapshot.get("tx_power"),
)
except Exception as exc: # noqa: BLE001 - any failure to reach the radio is the fact
values["error"] = str(exc) or exc.__class__.__name__
return Facts(
key="device",
caption="Radio",
fields=(
fields.flag("connected", "connected"),
fields.word("transport", "transport"),
fields.word("port", "port"),
fields.word("device_role", "device_role"),
fields.word("device_model", "device_model"),
fields.word("firmware", "firmware"),
fields.decimal("radio_freq_mhz", "radio_freq_mhz", ".4f"),
fields.decimal("radio_bw_khz", "radio_bw_khz", ".2f"),
fields.integer("radio_sf", "radio_sf"),
fields.integer("radio_cr", "radio_cr"),
fields.integer("tx_power_dbm", "tx_power_dbm"),
fields.free("error", "error"),
),
values=values,
)
def _mesh_facts(ctx: AppContext) -> Any:
"""How much history this install holds, and where it holds it.
Counts and a span, never a row. The span is the fact that sizes every other number
here: a thousand observations over two years and a thousand in an afternoon describe
two different installs, and only one of them has a radio worth suspecting.
"""
from ..core.preferences import current
from ..persistence.logging import log_path
from ..ui import fields
from ..ui.report import Facts
preferences = ctx.preferences or current()
db_path = ctx.settings.db_path
first, last = ctx.repo.observation_span()
return Facts(
key="mesh",
caption="Storage",
fields=(
fields.path("config_dir", "config_dir"),
fields.integer("db_size_kb", "db_size_kb"),
fields.word("log_level", "log_level"),
fields.integer("log_size_kb", "log_size_kb"),
fields.integer("runs_failed", "runs_failed"),
fields.instant("first_heard", "first_heard"),
fields.instant("last_heard", "last_heard"),
),
values={
"config_dir": ctx.settings.config_dir,
"db_size_kb": _size_kb(db_path),
"log_level": preferences.log_level,
# How much the log holds, so a maintainer asking for it knows what they are
# asking for — and so a file still at zero says the level never let it write.
"log_size_kb": _size_kb(log_path(ctx.settings.config_dir)),
"runs_failed": ctx.repo.failed_run_count(),
"first_heard": first,
"last_heard": last,
},
)
def _table_listing(ctx: AppContext) -> Any:
"""Every table in the database and how many rows it holds.
A listing rather than more facts, for two reasons. The keys are discovered from the
schema rather than declared, so a table named after a fact could otherwise collide
with it in the merged JSON object; and this genuinely is a set of records — one per
table — which is the shape a reader and a parser both already know.
"""
from ..ui import fields
from ..ui.report import Column, Listing
counts = ctx.repo.table_counts()
return Listing(
key="tables",
caption="Stored rows",
columns=(fields.word("table", "TABLE"), Column(key="rows", lanes=(_rows_lane(),))),
rows=[{"table": name, "rows": n} for name, n in counts.items()],
)
def _preference_listing(ctx: AppContext) -> Any:
"""The preferences that differ from their defaults — the whole list, and only those.
An override is the only part of the preference set that can explain anything: the
defaults are in the source, the same for everyone, and dumping all forty would bury
the two the reporter actually changed.
"""
from ..core.preferences import current
from ..ui import fields
from ..ui.report import Listing
preferences = ctx.preferences or current()
return Listing(
key="preferences",
caption="Changed preferences",
columns=(fields.word("preference", "PREFERENCE"), fields.free("value", "VALUE")),
rows=[
{"preference": key, "value": str(value)}
for key, value in preferences.overrides().items()
],
)
def _rows_lane() -> Any:
"""The row-count lane: right-aligned, because a column of magnitudes only compares aligned."""
from ..ui.report import Lane
return Lane(header="ROWS", render=lambda v: str(v if v is not None else 0), align="right")
def _size_kb(path: Any) -> int | None:
"""A file's size in whole kilobytes, or ``None`` when it is not there to measure."""
if path is None:
return None
try:
return round(path.stat().st_size / 1024)
except OSError:
return None
async def _snapshot(ctx: AppContext, device: Any) -> dict:
"""The device's settings snapshot, or an empty one when the read fails."""
from ..ui.config_editor import cached_snapshot
try:
return await cached_snapshot(ctx, device)
except Exception: # noqa: BLE001 - a settings read that fails leaves the radio facts blank
return {}
async def _device_info(device: Any) -> dict:
"""The device's self-description, or an empty one when the firmware does not answer."""
try:
return await device.get_device_info() or {}
except Exception: # noqa: BLE001 - firmware predating the query contributes nothing
return {}
+3 -5
View File
@@ -14,14 +14,12 @@ from rich.text import Text
from ..context import AppContext
from ..core.connection import Device
from ..core.models import node_type_label
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Column, Facts
#: MeshCore advert-type byte -> human-readable node role.
_ROLES = {1: "companion", 2: "repeater", 3: "room server", 4: "sensor"}
@register
class InfoTool(Tool):
@@ -141,7 +139,7 @@ async def _status_panel(device: Device, snapshot: dict) -> Panel:
adv_type = snapshot.get("adv_type")
if adv_type is not None:
role = _ROLES.get(int(adv_type), f"type {adv_type}")
role = node_type_label(adv_type)
rows.append(("role", Text(role, style="brand")))
if info.get("model"):
@@ -239,7 +237,7 @@ async def status_facts(device: Device, snapshot: dict) -> Facts:
# `config show`'s answer, not this one.
"name": snapshot.get("name"),
"public_key": str(snapshot.get("public_key") or "").lower() or None,
"role": _ROLES.get(int(adv_type), f"type {adv_type}") if adv_type is not None else None,
"role": node_type_label(adv_type),
"model": info.get("model"),
"firmware": _firmware(info) or None,
"battery_v": round(int(level) / 1000, 2) if level else None,
+87 -13
View File
@@ -28,6 +28,7 @@ is connected to again.
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Mapping
from typing import TYPE_CHECKING
@@ -40,6 +41,8 @@ from ..core.device_store import DeviceStore, RememberedDevice
from ..core.discovery import (
DEFAULT_TCP_PORT,
DiscoveredDevice,
discover_ble_devices,
discover_devices,
parse_tcp_endpoint,
serial_device,
tcp_device,
@@ -298,6 +301,27 @@ async def _smoke_test(
return info
#: How often the splash re-enumerates serial ports. `comports()` is a SetupAPI/sysfs
#: walk rather than a free read, so it runs off the event loop -- but it is cheap enough
#: to repeat at this rate without anyone noticing, and it covers the common case: a USB
#: radio plugged in after the splash appeared.
_POLL_S = 2.0
#: Bluetooth is not cheap in the same way. Each scan is a bounded listen, so keeping one
#: running means keeping the radio listening for as long as this screen is open -- and
#: this screen is the one a reader leaves up while they go and find a cable, on a handheld,
#: on a battery. So it gets a duty cycle rather than a loop: a window this often, and only
#: while it is still plausible somebody is waiting on one.
_BLE_EVERY_S = 15.0
_BLE_WINDOW_S = 4.0
#: How many windows to run before giving up on finding a companion nobody has turned on.
#: Once something *is* listed the reader has what they came for; while the list is still
#: empty the count is ignored, because an empty splash is exactly where somebody is
#: waiting and the cost of another listen is the cost of being useful.
_BLE_WINDOWS = 8
async def prompt_device(
ui: Ui,
devices: list[DiscoveredDevice],
@@ -327,11 +351,24 @@ async def prompt_device(
leave the picker without selecting one — by pressing Esc or choosing the Quit row —
which the caller treats as a request to exit.
"""
scanned = list(devices)
# Serial and Bluetooth are kept apart because they are refreshed on different clocks:
# a poll replaces everything attached, a Bluetooth window replaces everything in range,
# and one must not wipe the other's findings.
wired = [d for d in devices if not d.is_ble]
wireless = [d for d in devices if d.is_ble]
#: The row the *next* redraw should open on, when the pass just finished moved the list
#: under the reader (see :func:`_after_hiding`). Cleared as soon as it is spent.
focus: DiscoveredDevice | None = None
while True:
def assemble() -> tuple[
list[DiscoveredDevice], list, RememberedDevice | None, dict[str, RememberedDevice]
]:
"""The list as it stands this instant: what is there, less what is hidden.
Built in one place because two callers need it now -- the loop below, and the
rescan that redraws the rows under the reader while the splash is open.
"""
scanned = wired + wireless
remembered = store.load()
# The full registry (not just the single last device) so *every* confirmed companion
# can be named, highlighted, and sorted to the top — keyed by stable_id. Reloaded each
@@ -353,7 +390,42 @@ async def prompt_device(
# only feedback hiding needs.
hidden = store.hidden_ids()
# In display order, so a shortcut acting on a row can say what the row *after* it is.
ordered = _order([d for d in listed if d.stable_id not in hidden], registry)
order = _order([d for d in listed if d.stable_id not in hidden], registry)
items = _build_items(order, remembered, registry, hidden=len(hidden))
return order, items, remembered, registry
async def keep_looking(redraw: Callable[[list], None]) -> None:
"""Re-enumerate while the splash is up, and redraw when the answer changes.
The screen used to scan once on the way in and never again, so a radio plugged in
ten seconds late was invisible until MeshTerm was restarted -- and the screen that
said so offered only a network address typed by hand, or quitting. Fetching the
cable is the obvious thing to do and it was the one thing that did not work.
Only a change redraws. A poll that finds the same ports is silence, which matters
because the reader may be part-way through arrowing down the list.
"""
windows = 0
waited = 0.0
while True:
await asyncio.sleep(_POLL_S)
before = {d.stable_id for d in wired + wireless}
# ``comports()`` blocks; off the event loop so the splash stays live.
wired[:] = await asyncio.to_thread(discover_devices)
waited += _POLL_S
if waited >= _BLE_EVERY_S and (windows < _BLE_WINDOWS or not wired + wireless):
waited = 0.0
windows += 1
wireless[:] = await discover_ble_devices(_BLE_WINDOW_S)
if {d.stable_id for d in wired + wireless} != before:
_, rows, _, _ = assemble()
redraw(rows)
while True:
ordered, items, remembered, registry = assemble()
# Preselect the remembered "last known good" device when it is currently attached/in
# range — unless the last pass asked for a particular row, which a hide does so the
# highlight lands where the vanished row was rather than jumping back to the default.
@@ -362,16 +434,17 @@ async def prompt_device(
default = focus if focus in ordered else default
focus = None
# Build the rows once so the same list can be redrawn as the backdrop behind a
# removal confirm (so it floats over the picker rather than replacing it).
items = _build_items(ordered, remembered, registry, hidden=len(hidden))
chosen = await ui.select_startup(
"Select a companion device",
items,
default=default,
banner=load_logo(),
keys=_SHORTCUTS,
key_hint=_shortcut_hint(len(hidden)),
key_hint=_shortcut_hint(len(store.hidden_ids())),
live=keep_looking,
# Said outright because the rows no longer imply it: scrolling is switched on by
# a row pinning a head block, and none of these do any more.
hscroll=True,
)
# Esc (``None``) and the Quit row both mean "leave the picker" — surface that to the
# caller as ``None`` so it can exit the program instead of continuing device-less.
@@ -879,11 +952,12 @@ def _build_items(
row.append_text(cell)
row.append(" " * max(0, type_w - cell.cell_len))
row.append(" ")
# Everything up to here is the row's fixed lanes — the reader's place in the list —
# so it is pinned and only what follows slides under ←→ (see Choice.hscroll_from).
# What follows is the hardware model and its tag, which is the part that runs long:
# a firmware model string is as long as its vendor felt like making it.
head = row.cell_len
# Nothing is pinned here, so ←→ slide the whole row. The Trophy case pins its
# rank/date/score lanes because those always fit and only the walk overflows, which
# makes them the reader's place in a long list. This list inverts that: on a narrow
# terminal it is the device name and the address that get cut, so pinning them would
# pin the truncation in place and leave the one thing ←→ could not reach being the
# text somebody most wants to finish reading.
row.append(_pad(_hardware_label(device, registry), hardware_w), style="muted")
# Only devices we've actually confirmed are billed as MeshCore companions; a USB
# vendor ID (or a BLE advert) is a sort hint, not a claim. A bare serial bridge earns
@@ -903,7 +977,7 @@ def _build_items(
# Only a network device opts into Delete-to-remove: it's listed solely from its
# remembered endpoint, so forgetting it is the only way it leaves the picker. A scanned
# serial/BLE device would just reappear, so Delete stays inert on those rows.
items.append(Choice(title=row, value=device, deletable=device.is_tcp, hscroll_from=head))
items.append(Choice(title=row, value=device, deletable=device.is_tcp))
items.extend(_action_rows())
return items
+170
View File
@@ -0,0 +1,170 @@
# SPDX-License-Identifier: Apache-2.0
"""The Diagnostics page: the bug-report block, drawn as one of the app's written pages.
It is prose-shaped rather than screen-shaped — a title, a standfirst, sections, and a list
under each — so it is markdown, and it goes through the renderer the About pages already
use (:mod:`meshterm.ui.markdown`). That is not a presentational choice made twice: the same
document is what gets **saved**, and an issue tracker renders markdown as written. One
source, one look, on screen and in the issue.
Being a written page buys the two things a long block most needs and neither of which a
hand-built screen had: 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; and the
PicoCalc's F-key lane picks up ``Sect ↑``/``Sect ↓`` for the same reason a grouped list
does. :class:`~meshterm.ui.about.AboutPage` already does all of it, so this is that page
with one verb added.
The verb is **save** (:data:`SAVE_KEY`), advertised where the platform advertises verbs —
in the footer hint on a desktop, on the free F3 slot of the lane on the console — and never
in the page's own body, which belongs to the report. It answers in a popup, because the
answer is a path the reader may need to read carefully and because a write can fail; the
page underneath is unchanged and still there when the popup goes.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from rich.text import Text
from .about import AboutPage
from .markdown import render_markdown
if TYPE_CHECKING: # pragma: no cover - typing only
from ..context import AppContext
from .tui.session import TuiSession
#: The key that writes the page to a file. A bare letter, which a read-only page can
#: afford because it has no filter and no field for one to mean anything else in.
SAVE_KEY = "s"
#: The action both the key and the lane's chip dispatch, so the chip is not a second
#: implementation of the key.
SAVE_ACTION = "save"
class DiagnosticsPage(AboutPage):
"""The written diagnostics page, plus the one verb that writes it to a file.
Everything about how it *reads* is :class:`~meshterm.ui.about.AboutPage`: the pager,
the sticky ``##`` headings, the section jumps, the lane that dims when there is nothing
to page. What is added is the save, on the one slot the About pages leave free.
"""
def __init__(self, source: str, *, session: TuiSession, save_path: Path) -> None:
"""Show ``source`` as the page, saving to ``save_path`` on demand.
Args:
source: The markdown document, from
:func:`~meshterm.tools.diagnostics.markdown_source`.
session: The session, for the popup the save answers in.
save_path: Where :data:`SAVE_KEY` writes.
"""
super().__init__("Diagnostics", render_markdown(source))
self._source = source
self._session = session
self._save_path = save_path
self._saving = False
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The read-only page's keys, with the save between the pager and Esc.
Navigation first, then the action, then Esc — the standard sentence shape. The
pager atom comes and goes with :attr:`~Screen.content_overflows`, as it does on
every read-only page, so the line never names a key that would do nothing.
"""
scroll = "↑↓ PgUp/PgDn scroll · " if self.content_overflows else ""
return f"{scroll}{SAVE_KEY} save · Esc back"
@property
def fkey_lane(self):
"""The About page's lane, with ``Save`` on the slot it leaves free.
F1/F2 are the section pair and F4/F5 the pager, so F3 is the one free primary slot
— and on this platform the lane is the *only* place a bare letter can advertise
itself at all, which is what makes the chip load-bearing rather than decorative.
"""
from .tui.fkeys import FPair
lane = list(super().fkey_lane)
lane[2] = FPair("Save", SAVE_ACTION)
return lane
def handle(self, action: str, data: str = "") -> None:
"""Save on the key or the chip; scroll, jump or leave on everything else."""
if action == SAVE_ACTION or (action == "text" and data.lower() == SAVE_KEY):
self._begin_save()
return
super().handle(action, data)
def _begin_save(self) -> None:
"""Write the file and report it in a popup, off the key handler.
``run_detached`` because a screen's ``handle`` is synchronous and the popup is a
screen: the flow can only be *started* here, and the task it starts sits outside
the navigation chain (see :meth:`~meshterm.ui.tui.session.TuiSession.run_detached`).
One save at a time, so a held key cannot stack popups over each other.
"""
if self._saving:
return
self._saving = True
async def run() -> None:
try:
await self._session.message_dialog(self._write(), title="Diagnostics")
finally:
self._saving = False
self._session.run_detached(run())
def _write(self) -> Text:
"""Write the document, and say what happened as the popup's message.
A :class:`~rich.text.Text` with real spans rather than a markup string: the dialog
draws the message as given and reads its *spans* to pick a border tone (see
``session._message_border``), so markup left as text would both print its own tags
and leave a failure framed in the success colour.
Only the **mark** is styled — ``✓`` or ``✗``, the app's own ok/err pair — which is
enough for the border to read the tone and leaves the path itself in body ink,
where it stays legible. That is what the mark is for.
"""
from ..tools.diagnostics import write_markdown
message = Text()
try:
written = write_markdown(self._source, self._save_path)
except OSError as exc:
# A read-only home, a full disk, a path that is not there. The page itself is
# untouched and still readable, which is the fallback that matters.
message.append("✗ ", style="err")
message.append(f"could not save to\n{self._save_path}\n\n")
message.append(exc.strerror or str(exc), style="muted")
return message
message.append("✓ ", style="ok")
message.append(f"saved to\n{written}")
return message
async def open_diagnostics_page(ctx: AppContext, source: str, *, save_path: Path) -> None:
"""Open the Diagnostics page full-screen and hold it until the reader backs out.
Args:
ctx: The shared application context (must be running the interactive TUI).
source: The markdown document to show and to save.
save_path: Where the page's save key writes.
Raises:
RuntimeError: If called outside the interactive menu (no full-screen session).
"""
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
raise RuntimeError("the Diagnostics page is only available in the menu")
session = ctx.ui.session
await session.run_screen(DiagnosticsPage(source, session=session, save_path=save_path))
__all__ = ["SAVE_ACTION", "SAVE_KEY", "DiagnosticsPage", "open_diagnostics_page"]
+20
View File
@@ -332,6 +332,21 @@ def name(key: str, header: str) -> Column:
return Column(key=key, lanes=(Lane(header=header, render=script.name),))
def path(key: str, header: str) -> Column:
"""A place on this filesystem: a config directory, a database, a file just written.
Its own constructor rather than :func:`word` because the *typed* value is a
:class:`~pathlib.Path` and nothing else in the lane vocabulary turns one into text —
``word`` hands it straight to Rich, which cannot render it. The machine face was
already right: :func:`~meshterm.ui.report.normalise` has always spelled a Path as its
string, so this closes the plain half of a shape that was only ever half-declared.
Printed as the host writes it, separators and all, since the one thing a reader does
with a path is paste it back into their own shell.
"""
return Column(key=key, lanes=(Lane(header=header, render=_path_cell),))
def free(key: str, header: str) -> Column:
"""Free text a stranger filled in: escaped so it can never end its own record.
@@ -443,6 +458,11 @@ def _rendered_cell(value: Rendered | None) -> str:
return script.NONE if value is None else value.text
def _path_cell(value: Any) -> str:
"""A path as its own text, or the absent token where there is none."""
return script.NONE if value is None else str(value)
def _position_cell(where: Position | None) -> str:
"""One position's cell: the pair, or the absent token."""
return script.NONE if where is None else script.location(where.lat, where.lon)
+18 -3
View File
@@ -180,6 +180,7 @@ def _state_intensity(row: str) -> str:
"""
out: list[str] = []
bright = False
standing: str | None = None # the dim-bank foreground currently in force
at = 0
while at < len(row):
found = _SGR.match(row, at)
@@ -193,17 +194,31 @@ def _state_intensity(row: str) -> str:
says = [p for p in parts if p in ("", "0", "1", "22")]
for part in says:
bright = part == "1"
if part in ("", "0"):
standing = None # a reset takes the foreground with it
# Whether *this* sequence's own foreground is bright: what it says, else what stands.
here = bright
fg = next((p for p in parts if _DIM_FG.fullmatch(p)), None)
if fg is None:
# No foreground to restate. A bare "1" has nothing left to do once brightness
# travels as a colour, and dropping it keeps bold off glyphs a terminal may
# redraw at another weight; a reset still has to go through.
# A bare "1" has nothing left to say once brightness travels as a colour, and
# dropping it keeps bold off glyphs a terminal may redraw at another weight.
kept = [p for p in parts if p != "1"]
# But the brightness it was announcing applies to the colour already in force,
# not only to the next one named. `ESC[1m` is how the art says "everything after
# this is bright", and with the "1" gone that run would carry on in the dim bank
# -- two spans meant to read dark then light both coming out dark. So the
# standing foreground is restated in whichever bank now applies.
if standing is not None and says:
if here:
kept.append(str(int(standing) + 60))
else:
kept.append(standing)
if "22" not in kept:
kept.insert(0, "22")
if kept:
out.append("\x1b[" + ";".join(kept) + "m")
else:
standing = fg
rewritten = []
for part in parts:
if part == "1":
+2 -2
View File
@@ -58,7 +58,7 @@ from .pathline import (
path_line,
)
from .theme import snr_style
from .tui.render import crop_cells, render_to_ansi
from .tui.render import crop_cells, render_lines, render_to_ansi
from .tui.screen import ListWindow, Screen
from .widgets import (
NameKeyResolver,
@@ -293,7 +293,7 @@ class MessagePathsScreen(Screen):
style="faint",
)
lines.append(render_to_ansi(caption, width, no_wrap=True))
lines.append(render_to_ansi(node_type_legend(), width, no_wrap=True))
lines.extend(render_lines(node_type_legend(width=width), width, no_wrap=True))
lines.extend(revisit)
lines.append("")
+1 -1
View File
@@ -950,7 +950,7 @@ class NodeDetailScreen(Screen):
while lines and not _SGR.sub("", lines[0]).strip():
lines.pop(0)
if rv.legend:
lines.extend(render_lines(node_type_legend(), width, no_wrap=True))
lines.extend(render_lines(node_type_legend(width=width), width, no_wrap=True))
self._stage_memo = (stage_key, lines)
return lines
+3 -4
View File
@@ -50,7 +50,7 @@ from rich.table import Table
from rich.text import Text
from ..core.channels import decrypt_channel_text, identify_channel
from ..core.models import NODE_TYPE_LABELS, Observation, utcnow
from ..core.models import Observation, node_type_label, utcnow
from ..services.trace_runner import NodeResolver
from .marks import SELF_MARK, UNKNOWN_MARK
from .menus import SEP_COMPACT, SEP_ROOMY
@@ -677,7 +677,7 @@ class PacketViewer(Screen):
lines.extend(graph)
caption = Text("origin → you · labels = hash byte", style="faint")
lines.append(render_to_ansi(caption, width, no_wrap=True))
lines.append(render_to_ansi(node_type_legend(), width, no_wrap=True))
lines.extend(render_lines(node_type_legend(width=width), width, no_wrap=True))
if tail:
lines.append("")
lines.extend(self._grid_lines(tail, width, label_w))
@@ -748,8 +748,7 @@ class PacketViewer(Screen):
who.append_text(highlighted_hash(entry.node, self._prefix_bytes))
rows.append(("from", who))
if entry.node_type is not None:
label = NODE_TYPE_LABELS.get(entry.node_type, f"type {entry.node_type}")
rows.append(("type", Text(label)))
rows.append(("type", Text(node_type_label(entry.node_type))))
if entry.kind == "packet":
rows.extend(self._packet_rows(entry))
+1 -1
View File
@@ -714,7 +714,7 @@ class RecordScreen(Screen):
lines = self._graph_lines(width)
caption = Text("you → … → you · labels = hash byte", style="faint")
lines.append(render_to_ansi(caption, width, no_wrap=True))
lines.append(render_to_ansi(node_type_legend(), width, no_wrap=True))
lines.extend(render_lines(node_type_legend(width=width), width, no_wrap=True))
# The walk itself, on THE path widget, across the card's whole width. No ``route``
# label lane: the line under a route graph captioned "you → … → you" is the route,
+97 -8
View File
@@ -33,6 +33,7 @@ compartment inside it.
from __future__ import annotations
import json
import re
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from enum import Enum
@@ -61,6 +62,101 @@ class OutputFormat(str, Enum):
JSON = "json"
def markdown_blocks(report: Report | None) -> str:
"""Project a report as markdown sections — the third face, and the only *document* one.
The plain face is a stream of records and the machine face is a document for a
program; this one is a page for a person to read, on a screen through
:func:`~meshterm.ui.markdown.render_markdown` and in an issue tracker through nothing
at all. It is where :attr:`~meshterm.ui.report.Block.caption` finally means something:
markdown has headings, and a heading is what a reader navigates by.
Every value is written as **inline code**, and that is correctness rather than taste. A
Windows path is full of backslashes, which markdown reads as escapes, and an error
message a stranger's firmware wrote can hold an asterisk or a bracket. Inside a code
span none of it is interpreted, so the value that arrives is the value that left — the
same reason :func:`~meshterm.ui.script.name` quotes what it cannot vouch for. A value
already holding a backtick gets a longer fence rather than a broken one.
Rows are a **list**, not a table, and that was measured rather than assumed: a markdown
table's cells are cropped with an ellipsis when the frame is narrow, and a diagnostics
page whose one long value is cut is missing the half that mattered. A list item wraps
and hangs under its own text at any width.
Args:
report: The blocks to project, or ``None`` for nothing at all.
Returns:
Markdown source: one ``##`` section per captioned block, blank-line separated.
"""
sections: list[str] = []
for block in report or ():
rows = _markdown_rows(block)
head = f"## {block.caption}\n\n" if block.caption else ""
# A section with nothing in it still says so. An absence is a fact here — "no
# preferences changed" is something a maintainer can act on, where a section
# silently missing only raises the question of whether it was ever built.
sections.append(head + ("\n".join(rows) if rows else "*none*"))
return "\n\n".join(sections)
def _markdown_rows(block: Block) -> list[str]:
"""One markdown list item per row of a block."""
if isinstance(block, Facts):
return [f"- **{key}** — {_code(value)}" for key, value in facts_pairs(block)]
lanes = block.lanes()
if not lanes:
return []
items: list[str] = []
for row in block.rows:
# The first lane names the record and the rest describe it, which is what a
# two-lane listing (a table and its count, a preference and its value) wants and
# what a wider one degrades to gracefully.
cells = [lane.render(row.get(column.key)) for column, lane in lanes]
described = " · ".join(_code(cell) for cell in cells[1:])
items.append(f"- **{cells[0]}** — {described}" if described else f"- **{cells[0]}**")
return items
def _code(value: str) -> str:
"""One value as a markdown code span, fenced long enough to survive its own backticks."""
if not value:
return ""
longest = max((len(run) for run in re.findall(r"`+", value)), default=0)
fence = "`" * (longest + 1)
# A span that starts or ends with a backtick needs a space the parser then eats.
pad = " " if value.startswith("`") or value.endswith("`") else ""
return f"{fence}{pad}{value}{pad}{fence}"
def facts_pairs(block: Facts) -> list[tuple[str, str]]:
"""Project a :class:`Facts` block into its ``(key, value)`` text rows.
The plain face's half of a facts block, lifted out of :class:`PlainRenderer` because
one surface in the menu wants the same rows laid out differently: the Diagnostics page
hangs a long value under its key rather than cropping it, since a cropped path in a
bug report is worse than a wrapped one. Sharing the projection is what keeps the two
from drifting — a field added to a report appears on both without anyone remembering.
Laying the rows out is the caller's business; turning a typed value into text is not,
and that stays here, in the declared :class:`~meshterm.ui.report.Lane`.
Args:
block: The facts block to project.
Returns:
One ``(header, cell)`` pair per lane, in the order the block declares them.
"""
rows: list[tuple[str, str]] = []
for column in block.fields:
value = block.values.get(column.key)
if block.omit_absent and (value is None or value == ""):
continue
for lane in column.lanes:
rows.append((lane.header, lane.render(value)))
return rows
class Renderer:
"""Turns a report into bytes. One subclass per format."""
@@ -157,14 +253,7 @@ class PlainRenderer(Renderer):
# As a Text, not a markup string: this is a private key, a share URL or a
# remote node's own reply, and Rich would read a bracket in one as a style tag.
return Text(column.lanes[0].render(value))
rows: list[tuple[str, str]] = []
for column in block.fields:
value = block.values.get(column.key)
if block.omit_absent and (value is None or value == ""):
continue
for lane in column.lanes:
rows.append((lane.header, lane.render(value)))
return script.pairs(rows)
return script.pairs(facts_pairs(block))
@contextmanager
def stream(self, listing: Listing) -> Iterator[Any]:
+8
View File
@@ -155,6 +155,13 @@ class Block:
Attributes:
key: The name this block goes under in a multi-block document.
caption: The heading a format that *has* headings draws above this block. The
plain face has none — a heading over a key/value block is furniture on a
line-oriented face, which is why ``config show`` drops even its column
headers — and the machine face has ``key`` instead. The markdown projection
makes it a ``##`` section, which is what gives a written page the landmarks
its section jumps and its sticky headings step by. Empty means the block is
drawn without one.
plain_only: Whether this block is a rendering for a person that the machine face
has no use for. The one case is a stream's closing summary: ``monitor`` prints
a per-node aggregate under its live capture, and a consumer holding every
@@ -164,6 +171,7 @@ class Block:
"""
key: str
caption: str = ""
plain_only: bool = False
+13 -1
View File
@@ -18,7 +18,7 @@ only), so :class:`PlainUi` leaves them unsupported.
from __future__ import annotations
from collections.abc import AsyncIterator, Callable, Mapping
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
from contextlib import asynccontextmanager
from typing import Any
@@ -222,9 +222,15 @@ class Ui:
footer_hint: str | None = None,
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
hscroll: bool | None = None,
) -> Any:
"""Choose one item on a chromeless startup splash; ``None`` if skipped.
``live`` is work to run while the splash is up, handed a ``redraw(items)`` that
swaps the rows under the reader and repaints -- the device picker's rescan. It is
cancelled when the splash resolves.
``keys`` declares bare-key shortcuts the splash answers, resolving with a
:class:`~meshterm.ui.tui.select.KeyRequest` (the picker's hide/show-all pair), and
``key_hint`` names them per highlighted row so the footer only advertises a key
@@ -516,6 +522,8 @@ class PlainUi(Ui):
footer_hint: str | None = None,
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
hscroll: bool | None = None,
) -> Any:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
@@ -779,6 +787,8 @@ class TuiUi(Ui):
footer_hint: str | None = None,
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
hscroll: bool | None = None,
) -> Any:
"""Delegate to the session's chromeless startup select splash."""
return await self.session.select_startup(
@@ -789,6 +799,8 @@ class TuiUi(Ui):
footnote=footnote,
keys=keys,
key_hint=key_hint,
live=live,
hscroll=hscroll,
**({} if footer_hint is None else {"footer_hint": footer_hint}),
)
+1
View File
@@ -695,6 +695,7 @@ _GLYPH_MAP: dict[str, str] = {
"📶": "≥", # TX power sweep — the power ramp
"🔧": "⚙", # config (parameter concept)
"📋": "i", # info
"🩺": "?", # diagnostics — the question a diagnosis answers (🎭 shares the mark)
"📰": "…", # live feed — a stream of items
"🎧": "≈", # monitor — listening to the waveform
"🗼": "▲", # repeater admin — the repeater mark itself
+32 -3
View File
@@ -15,8 +15,8 @@ from __future__ import annotations
import asyncio
import os
import sys
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
from contextlib import asynccontextmanager, nullcontext
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
from contextlib import asynccontextmanager, nullcontext, suppress
from typing import Any
from prompt_toolkit.application import Application
@@ -917,6 +917,8 @@ class TuiSession:
footer_hint: str = "↑↓ move · Enter select · Esc bye",
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
hscroll: bool | None = None,
) -> Any:
"""Show a chromeless select splash (banner above a content-sized box).
@@ -931,6 +933,19 @@ class TuiSession:
removal key advertises itself exactly where it acts (see
:attr:`~meshterm.ui.tui.select.SelectScreen.footer_hint`).
``live`` is work to do *while* the list is up — the device picker's rescan, which
keeps looking for a companion the whole time the splash is open. It is handed one
function, ``redraw(items)``, which swaps the rows under the reader and repaints;
the screen itself stays in here, since a caller holding one could not repaint it
anyway (``invalidate`` is the session's, not the screen's). It runs as a task for
exactly as long as the screen does and is cancelled when the screen resolves, so
nothing outlives the list it was redrawing and no repaint lands on a dialog that
has since opened over it.
``hscroll`` overrules what the rows imply: a splash whose rows pin no head block
still wants ←→ to read a long row to its end, and a row that pins nothing cannot
ask for that on its own (see :class:`~meshterm.ui.tui.select.SelectScreen`).
``keys`` hands the list bare-key shortcuts — which a splash can afford precisely
because it does not filter, so every letter is free (see :class:`SelectScreen`) —
and ``key_hint`` says what they are called on the row the highlight is standing on,
@@ -948,6 +963,7 @@ class TuiSession:
filterable=False,
keys=keys,
key_hint=key_hint,
hscroll=hscroll,
)
screen.chrome = False
screen.banner = banner
@@ -959,7 +975,20 @@ class TuiSession:
# ⇧H only appears at all once something is hidden, which is exactly the state where
# a reader needs to be told how to undo it, and by then they have already found h.
screen.spare_hint_atoms = ("←→ scroll", "h hide")
result = await self.run_screen(screen)
def redraw(new_items: list) -> None:
"""Swap the rows and repaint. The reader keeps their place, by value."""
screen.replace_items(new_items)
self.invalidate()
worker = asyncio.ensure_future(live(redraw)) if live else None
try:
result = await self.run_screen(screen)
finally:
if worker is not None:
worker.cancel()
with suppress(asyncio.CancelledError):
await worker
return None if result is CANCEL else result
async def confirm_startup(
+24 -8
View File
@@ -11,6 +11,7 @@ from itertools import pairwise
from typing import TYPE_CHECKING
from rich import box
from rich.cells import cell_len
from rich.console import Console, Group
from rich.panel import Panel
from rich.progress import (
@@ -1140,24 +1141,39 @@ def _sort_header(label: str, column: str, sort: ContactsSort) -> str:
return f"[cursor]{label} {triangle}[/]"
def node_type_legend(indent: str = "") -> Text:
"""The one-line key to the node-type marks: ``★ you ▲ repeater ● node …``.
def node_type_legend(indent: str = "", width: int | None = None) -> Text:
"""The key to the node-type marks: ``★ you ▲ repeater ● companion …``.
Every glyph in its shared map colour (see :data:`NODE_GLYPHS`), each named muted after
it. THE legend for any surface that draws typed node markers — the contacts list under its
table, the route graph under its lanes — so one glyph means one thing app-wide.
One line where it fits. Where it does not — the full names run to 59 cells, and the
PicoCalc has 53 — it breaks onto a second line between entries, never inside one, so
a mark is never parted from its name and no name is cropped.
Args:
indent: Leading spaces to sit the legend under a table or graph body.
width: The cells available. None keeps the legend on one line.
"""
legend = Text(indent)
legend.append(SELF_MARK[0], style=SELF_MARK[1])
legend.append(" you", style="muted")
entries = [Text.assemble((SELF_MARK[0], SELF_MARK[1]), (" you", "muted"))]
for node_type in (NODE_TYPE_REPEATER, NODE_TYPE_CHAT, NODE_TYPE_ROOM, NODE_TYPE_SENSOR):
glyph, color = NODE_GLYPHS[node_type]
legend.append(" ")
legend.append(glyph, style=color)
legend.append(f" {NODE_TYPE_LABELS[node_type]}", style="muted")
entries.append(Text.assemble((glyph, color), (f" {NODE_TYPE_LABELS[node_type]}", "muted")))
gap = " "
legend = Text(indent)
line = cell_len(indent)
for i, entry in enumerate(entries):
size = cell_len(entry.plain)
if i and width is not None and line + len(gap) + size > width:
legend.append("\n" + indent)
line = cell_len(indent)
elif i:
legend.append(gap)
line += len(gap)
legend.append_text(entry)
line += size
return legend
+3 -1
View File
@@ -12,7 +12,7 @@ build-backend = "hatchling.build"
# wheel shipping the `meshterm/` package under this different name.
name = "mesh-term"
dynamic = ["version"]
description = "MeshTerm is a full-featured TUI-based MeshCore client for your terminal. Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Linux."
description = "MeshTerm is a full-featured TUI MeshCore client for your terminal. Connects to a companion over USB, Bluetooth or TCP. For Windows, macOS and Linux."
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Jean-Pierre Martineau" }]
@@ -31,6 +31,8 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.14",
"Topic :: Communications :: Ham Radio",
"Topic :: System :: Networking",
]
+1 -1
View File
@@ -5,7 +5,7 @@ Repo-side tools. None of these ship in the wheel.
| Script | What it is for |
|---|---|
| `basemap-doctor.py` | Why a user's map has no basemap — certificates, DNS, proxy, or colour depth |
| `picocalc/` | Calculinux device setup: console font, palette, Wi-Fi, radio bridge |
| `picocalc/` | Calculinux device setup: console font, palette, Wi-Fi; `xiao-radio/` is the XIAO radio kit (firmware build, flashing, UART setup) |
| `uconsole/` | The uConsole SPI bridge service |
## basemap-doctor.py
+1 -1
View File
@@ -4,7 +4,7 @@ A Seeed XIAO nRF52840 + Wio-SX1262 wired to the Luckfox Lyra's UART1, running Me
companion firmware on `Serial1`, so MeshTerm on the PicoCalc opens `/dev/ttyS1` like any
other serial companion. No BLE, no USB host, no SD-slot sacrifice.
**The step-by-step guide is [`docs/picocalc.md`](../../../docs/picocalc.md), Phase 3** — parts,
**The step-by-step guide is [`docs/picocalc.md`, Phase 3](../../../docs/picocalc.md#phase-3--add-the-radio)** — parts,
the firmware build and the patch explained, flashing, wiring, the Lyra setup, and what has
only been proven on one bench. What the register poke does is in `uart1-mux.py`'s own
header. This page is the bench card.
+47
View File
@@ -707,6 +707,53 @@ def test_a_closing_message_goes_to_stderr_too(run) -> None: # noqa: ANN001
assert "records stored" in result.stderr
# -- the simulator's own history ------------------------------------------------------
def test_mock_records_into_a_database_of_its_own(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A pretend radio gets a pretend history, so a demo run leaves the real mesh alone.
The simulator is a fake radio, not a fake MeshTerm: what it adverts used to be written
to ``meshterm.db`` like anything a companion said, and its invented contacts then sat
in the mesh walk, the dashboard and the map of the mesh actually being run. The run
still records — the history is how a ``--mock`` session's own tools read back what it
heard — just not there.
"""
from meshterm.cli import app
home = tmp_path / "home"
monkeypatch.setenv("MESHTERM_HOME", str(home))
result = CliRunner().invoke(app, ["--mock", "info"])
assert result.exit_code == exitcodes.OK, result.output
assert (home / "meshterm-mock.db").exists()
assert not (home / "meshterm.db").exists()
def test_an_explicit_db_still_wins_under_mock(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``--db`` names one exact database, which is a claim the simulator does not override.
Unlike ``--port``, it is not a contradictory one: where a run records and which radio
it talks to are different questions, so this is a precedence rule and not a usage
error. It is also what the suite's own ``run`` fixture depends on.
"""
from meshterm.cli import app
home = tmp_path / "home"
monkeypatch.setenv("MESHTERM_HOME", str(home))
named = tmp_path / "named.db"
result = CliRunner().invoke(app, ["--mock", "--db", str(named), "info"])
assert result.exit_code == exitcodes.OK, result.output
assert named.exists()
assert not (home / "meshterm-mock.db").exists()
assert not (home / "meshterm.db").exists()
# -- devices under --mock -------------------------------------------------------------
+16 -8
View File
@@ -140,20 +140,28 @@ def test_the_box_does_not_resize_as_it_counts() -> None:
def test_the_countdown_resolves_itself_at_zero() -> None:
"""Reaching zero is the dialog's own answer: the wait is over, the action goes ahead."""
dialog = CountdownDialog("Advert", 5.0)
dialog.future = asyncio.get_event_loop_policy().new_event_loop().create_future()
dialog.set_remaining(3.0)
assert not dialog.future.done()
dialog.set_remaining(0.0)
assert dialog.future.done() and dialog.future.result() is True
loop = asyncio.new_event_loop()
try:
dialog.future = loop.create_future()
dialog.set_remaining(3.0)
assert not dialog.future.done()
dialog.set_remaining(0.0)
assert dialog.future.done() and dialog.future.result() is True
finally:
loop.close()
@pytest.mark.parametrize("key", ["enter", "escape"])
def test_either_key_abandons_the_wait(key: str) -> None:
"""There is nothing to choose between, so Enter and Esc both mean cancel."""
dialog = CountdownDialog("Advert", 30.0)
dialog.future = asyncio.get_event_loop_policy().new_event_loop().create_future()
dialog.handle(key)
assert dialog.future.done() and dialog.future.result() is CANCEL
loop = asyncio.new_event_loop()
try:
dialog.future = loop.create_future()
dialog.handle(key)
assert dialog.future.done() and dialog.future.result() is CANCEL
finally:
loop.close()
# -- the wait --------------------------------------------------------------------
+582
View File
@@ -0,0 +1,582 @@
# SPDX-License-Identifier: Apache-2.0
"""The Diagnostics block: what it says, what it refuses to say, and how it says it.
The privacy tests here are the load-bearing ones. This block exists to be pasted in public
by somebody who has not read it, so "no secret is in it" has to be a property of the
feature rather than a habit of whoever last edited it. Two tests hold that line from
different sides: one plants real secrets in the stores that hold them and asserts none
came out, and one refuses the *names* such a field would go under — so a value the suite
has no way to plant is still caught by the key it would arrive as.
"""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
import pytest
from rich.console import Console
from rich.text import Text
from meshterm.context import AppContext
from meshterm.core import hostinfo
from meshterm.core.admin_store import AdminStore
from meshterm.core.channel_store import ChannelStore
from meshterm.core.config import Settings
from meshterm.core.device_store import DeviceStore
from meshterm.core.models import Contact
from meshterm.core.preferences import Preferences
from meshterm.persistence.repository import Repository
from meshterm.tools.diagnostics import build_report, markdown_source
from meshterm.ui import fields
from meshterm.ui.about import AboutPage
from meshterm.ui.markdown import render_markdown
from meshterm.ui.renderers import (
JsonRenderer,
PlainRenderer,
facts_pairs,
markdown_blocks,
)
from meshterm.ui.report import Facts, Listing
#: Planted into the stores that really hold this kind of value. Each is the kind of thing
#: the block must never carry: one administers a repeater, one decrypts a channel.
_ADMIN_PASSWORD = "correct-horse-battery"
_CHANNEL_SECRET = "8f" * 16
#: Key *segments* no field in this block may be built from — matched on the underscore
#: parts of a key rather than as substrings, so ``platform`` is not read as a latitude.
#: The first row names a secret and the second names a person or a place; ``key`` and
#: ``name`` close the set, because the block identifies no node at all, not even ours.
_FORBIDDEN_SEGMENTS = frozenset(
{
"password",
"passphrase",
"secret",
"private",
"privkey",
"pin",
"token",
"credential",
"contact",
"contacts",
"lat",
"latitude",
"lon",
"longitude",
"position",
"message",
"messages",
"key",
"name",
}
)
@pytest.fixture
def ctx(tmp_path: Path) -> AppContext:
"""A context on a throwaway home, with a secret planted in the stores that hold them."""
settings = Settings(config_dir=tmp_path)
admin_store = AdminStore(tmp_path / "admin.json")
admin_store.remember(
Contact(name="Hilltop-Repeater", public_key="3d" * 32, key_prefix="3d63c642"),
_ADMIN_PASSWORD,
)
channel_store = ChannelStore(tmp_path / "channels.json")
channel_store.remember("ab" * 32, 1, "Lakeside", bytes.fromhex(_CHANNEL_SECRET))
return AppContext(
console=Console(),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "device.json"),
admin_store=admin_store,
channel_store=channel_store,
preferences=Preferences(tmp_path / "preferences.toml", {"log_level": "DEBUG"}),
mock=True,
)
# --- what it must never say -----------------------------------------------------------
def test_no_planted_secret_reaches_either_face(ctx: AppContext) -> None:
"""A remembered admin password and a channel secret appear in neither face.
The point of the feature: a reporter pastes this into a public issue without reading
it first.
"""
report = _report(ctx)
for secret in (_ADMIN_PASSWORD, _CHANNEL_SECRET):
assert secret not in _plain(report)
assert secret not in _machine(report)
def test_no_field_is_named_after_a_secret_or_a_person(ctx: AppContext) -> None:
"""No key in the block reads like a credential, a position or somebody's identity.
The durable half of the guarantee. A value this suite cannot plant — a PIN read live
off a radio, a private key exported on demand — still has to arrive under a name, and
every name a secret would plausibly take is refused here.
"""
for key in _keys(_report(ctx)):
offending = _FORBIDDEN_SEGMENTS & set(key.lower().split("_"))
assert not offending, f"{key!r} is named after {', '.join(sorted(offending))}"
def test_the_mesh_is_described_only_in_aggregate(ctx: AppContext) -> None:
"""The mesh reaches the block as counts, never as a row anybody could be named in.
Every table is *counted*, and the count is an integer: the moment a listing starts
carrying a row out of one of those tables, a contact's name or a message body is one
edit away from the clipboard.
"""
tables = _flat(_report(ctx))["tables"]
assert tables, "the schema should always have tables to count"
assert all(set(row) == {"table", "rows"} for row in tables)
assert all(isinstance(row["rows"], int) for row in tables)
# --- what it must say -----------------------------------------------------------------
def test_states_the_facts_a_bug_report_opens_with(ctx: AppContext) -> None:
"""Version, install shape, host, terminal and device all reach the block."""
from meshterm import __version__
values = _flat(_report(ctx))
assert values["meshterm"] == __version__
assert values["install"] in {"frozen", "source", "package"}
assert values["os"] and values["python"] and values["arch"]
assert "x" in values["terminal_size"]
assert values["platform"] in {"regular", "picocalc"}
# The simulator answers like a radio, so the device half is populated rather than
# skipped: this is the shape a real connected run produces.
assert values["connected"] is True
assert values["transport"] == "mock"
assert values["firmware"]
def test_an_unreachable_radio_becomes_a_fact_not_a_failure(
ctx: AppContext, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A radio that will not connect leaves the block intact and says why.
The report about a radio that never connects is exactly the one most worth filing, so
the device read is best-effort: the failure lands in ``error`` and every other fact
still arrives.
"""
async def refuse(self):
raise RuntimeError("no companion answered on COM7")
monkeypatch.setattr(AppContext, "device", refuse)
values = _flat(_report(ctx))
assert values["connected"] is False
assert "COM7" in values["error"]
assert values["firmware"] is None
assert values["meshterm"] # the rest of the block is untouched
def test_only_overridden_preferences_are_listed(ctx: AppContext) -> None:
"""The preference listing carries the disagreements with the defaults, and only those.
A dump of all forty would bury the one the reporter actually changed; the defaults are
in the source and identical for everyone.
"""
assert _flat(_report(ctx))["preferences"] == [{"preference": "log_level", "value": "DEBUG"}]
# --- how it says it -------------------------------------------------------------------
def test_the_page_and_the_command_line_state_the_same_facts(ctx: AppContext) -> None:
"""Every key/value the page draws is one the scripted face prints, in the same words.
The two faces share :func:`~meshterm.ui.renderers.facts_pairs` precisely so a field
added to the report reaches both without anyone remembering to, and this is what would
catch the page growing a projection of its own.
"""
report = _report(ctx)
printed = _plain(report)
for block in report:
if isinstance(block, Facts):
for key, value in facts_pairs(block):
assert key in printed
assert value in printed
def test_the_page_is_a_written_page_with_landmarks(ctx: AppContext) -> None:
"""The page is markdown through the About-page renderer, and its sections are landmarks.
The sections are the point of the format: a heading pins to the top row while its own
rows scroll under it, the section jumps step by them, and the PicoCalc lane earns its
``Sect up``/``Sect down`` pair for the same reason a grouped list does.
"""
page = _page(ctx)
assert isinstance(page, AboutPage)
assert page.bare is False
assert {"Build", "Host", "Terminal", "Radio", "Storage"} <= set(page._doc.sections)
def test_the_save_is_advertised_on_both_platforms(ctx: AppContext) -> None:
"""The verb is named in the footer hint and on the lane's one free slot.
Never in the page's body, which belongs to the report — and never nowhere, which is
what a bare frame would have forced. F1/F2 are the section pair and F4/F5 the pager,
so F3 is the slot the About pages leave free.
"""
from meshterm.ui.diagnostics import SAVE_ACTION, SAVE_KEY
page = _page(ctx)
assert f"{SAVE_KEY} save" in page.footer_hint
assert page.footer_hint.endswith("Esc back")
assert page.fkey_lane[2] is not None
assert page.fkey_lane[2].label == "Save"
assert page.fkey_lane[2].action == SAVE_ACTION
# --- saving it ------------------------------------------------------------------------
def test_the_saved_file_is_the_document_the_page_shows(ctx: AppContext, tmp_path: Path) -> None:
"""The file holds the very markdown the page was built from — one source, two places."""
from meshterm.tools.diagnostics import markdown_source, write_markdown
source = markdown_source(_report(ctx))
written = write_markdown(source, tmp_path / "out.md")
assert written.read_text(encoding="utf-8") == source
assert source.startswith("# MeshTerm diagnostics")
def test_saving_creates_the_directory_it_needs(ctx: AppContext, tmp_path: Path) -> None:
"""A destination whose parent does not exist yet is made rather than refused."""
from meshterm.tools.diagnostics import markdown_source, write_markdown
written = write_markdown(markdown_source(_report(ctx)), tmp_path / "a" / "b" / "out.md")
assert written.is_file()
def test_the_key_and_the_chip_both_save_and_answer_in_a_popup(
ctx: AppContext, tmp_path: Path
) -> None:
"""Either affordance writes the file and reports the path in an acknowledgement popup.
The chip dispatches the same action the key does, so it is not a second implementation
of the same verb.
"""
from meshterm.ui.diagnostics import SAVE_ACTION, SAVE_KEY
for trigger, target in ((("text", SAVE_KEY), "key.md"), ((SAVE_ACTION, ""), "chip.md")):
path = tmp_path / target
session = _Session()
page = _page(ctx, session=session, save_path=path)
page.handle(*trigger)
assert path.is_file()
assert len(session.messages) == 1
assert "\u2713" in session.messages[0]
assert str(path) in session.messages[0]
def test_a_save_that_fails_is_reported_not_raised(ctx: AppContext, tmp_path: Path) -> None:
"""An unwritable destination reports itself and leaves the page readable.
The page is still there to be read and copied off, which is the fallback that matters,
so a failed write must never take it down. The mark is also what turns the popup red.
"""
from meshterm.ui.diagnostics import SAVE_KEY
# A path whose "directory" is an existing file: mkdir and write both refuse, on every
# OS, without needing permissions the suite cannot count on.
blocker = tmp_path / "blocker"
blocker.write_text("not a directory", encoding="utf-8")
session = _Session()
page = _page(ctx, session=session, save_path=blocker / "inside" / "out.md")
page.handle("text", SAVE_KEY)
assert "\u2717" in session.messages[0]
assert "could not save" in session.messages[0]
assert "Build" in _body(page) # the page itself is untouched
def test_the_popup_carries_its_tone_as_spans_not_as_markup(ctx: AppContext, tmp_path: Path) -> None:
"""The message is a styled Text, so no tag is ever printed and the border reads right.
A markup *string* reaches this dialog unparsed: the reader sees the literal tags and a
failure is framed in the success colour, because the border is picked from the
message's spans. The mark carries the tone; nothing else needs to.
"""
from meshterm.ui.diagnostics import SAVE_KEY
from meshterm.ui.tui.session import _message_border
for target, expected in ((tmp_path / "ok.md", "accent"), (_blocked(tmp_path), "err")):
session = _Session(keep=True)
page = _page(ctx, session=session, save_path=target)
page.handle("text", SAVE_KEY)
message = session.shown[0]
assert isinstance(message, Text)
assert "[/" not in message.plain and "[ok]" not in message.plain
assert _message_border(message) == expected
def test_a_held_save_key_does_not_stack_popups(ctx: AppContext, tmp_path: Path) -> None:
"""One save at a time: a second press while one is in flight is ignored."""
from meshterm.ui.diagnostics import SAVE_KEY
session = _Session(defer=True)
page = _page(ctx, session=session, save_path=tmp_path / "out.md")
page.handle("text", SAVE_KEY)
page.handle("text", SAVE_KEY)
assert len(session.started) == 1
def test_another_letter_falls_through_to_the_pager(ctx: AppContext, tmp_path: Path) -> None:
"""Any other letter is the scroll handler's, not the save's."""
session = _Session()
page = _page(ctx, session=session, save_path=tmp_path / "out.md")
page.handle("text", "q")
assert not (tmp_path / "out.md").exists()
assert session.messages == []
# --- the markdown face ------------------------------------------------------------------
#: A value that is markdown in every direction: backslashes markdown reads as escapes,
#: asterisks and underscores it reads as emphasis. None of it may be reinterpreted.
_HOSTILE = r"C:\Users\*jp*\_meshterm_"
def test_every_value_survives_markdown_untouched() -> None:
"""Values are code spans, so a path's backslashes and stars arrive as themselves.
A path, or an error message a stranger's firmware wrote, is not markdown and must never
be silently read as it. A value already holding a backtick gets a longer fence.
"""
source = markdown_blocks(
(
Facts(
key="x",
caption="X",
fields=(fields.word("p", "p"), fields.word("q", "q")),
values={"p": _HOSTILE, "q": "a `backtick` inside"},
),
)
)
drawn = _render(render_markdown(source))
assert _HOSTILE in drawn
assert "a `backtick` inside" in drawn
def test_a_caption_becomes_the_section_heading() -> None:
"""A block's caption is its ``##``; a block without one is drawn with no heading."""
assert markdown_blocks((Facts(key="x", caption="Radio", fields=()),)).startswith("## Radio")
assert not markdown_blocks((Facts(key="x", fields=()),)).startswith("#")
def test_an_empty_section_says_so_rather_than_vanishing() -> None:
"""An absence is a fact here: "no preferences changed" is something to act on.
The plain face drops an empty listing because a record stream has nothing to say about
one. A document does — a section silently missing only raises the question of whether
it was ever built.
"""
empty = Listing(key="preferences", caption="Changed preferences", columns=(), rows=[])
assert markdown_blocks((empty,)).endswith("*none*")
assert "none" in _render(render_markdown(markdown_blocks((empty,))))
# --- the host probe -------------------------------------------------------------------
@pytest.mark.parametrize(
"environ,expected",
[
({"WT_SESSION": "abc", "TERM": "xterm"}, "Windows Terminal"),
({"TERM_PROGRAM": "Apple_Terminal", "TERM": "xterm-256color"}, "Terminal.app"),
({"TERM_PROGRAM": "vscode"}, "VS Code"),
# A terminal this module has never heard of still names itself, because
# TERM_PROGRAM's *value* is the name — which is why that rung exists at all.
({"TERM_PROGRAM": "WezTerm"}, "WezTerm"),
({"TERM": "linux"}, "linux"),
({}, None),
],
)
def test_the_terminal_is_identified_from_what_it_says_about_itself(
environ: dict, expected: str | None
) -> None:
"""Each rung of the ladder, down to the honest ``None`` when nothing names one."""
assert hostinfo.terminal_program(environ) == expected
def test_an_inner_terminal_wins_over_the_one_hosting_it() -> None:
"""A marker variable beats ``TERM_PROGRAM``, because the inner emulator is the answer.
VS Code's terminal inherits the outer terminal's environment and adds its own marker;
reporting the outer one would name a terminal that is not drawing anything.
"""
env = {"WT_SESSION": "abc", "TERM_PROGRAM": "vscode"}
assert hostinfo.terminal_program(env) == "Windows Terminal"
def test_ssh_is_read_from_either_variable() -> None:
"""Being over ssh is a fact the block states, because it changes what is knowable."""
assert hostinfo.terminal({"SSH_TTY": "/dev/pts/0"}).over_ssh is True
assert hostinfo.terminal({"SSH_CONNECTION": "10.0.0.2 51000 10.0.0.1 22"}).over_ssh is True
assert hostinfo.terminal({}).over_ssh is False
def test_the_host_block_is_populated_on_whatever_runs_the_suite() -> None:
"""Every host field has an answer, on any OS CI runs this on."""
machine = hostinfo.host()
assert machine.install in {"frozen", "source", "package"}
assert machine.os and machine.arch and machine.python
assert machine.python.count(".") == 2
# --- the repository aggregates --------------------------------------------------------
def test_table_counts_are_discovered_from_the_schema(tmp_path: Path) -> None:
"""Every table is counted, and SQLite's own internal tables are not.
Read off ``sqlite_master`` so a table added to the schema starts being reported the day
it lands. A curated list would go stale silently, and the interesting count is always
the table nobody expected to be full.
"""
counts = Repository(tmp_path / "meshterm.db").table_counts()
assert {"runs", "observations", "messages", "traces"} <= set(counts)
assert not any(name.startswith("sqlite_") for name in counts)
assert all(isinstance(n, int) for n in counts.values())
def test_the_observation_span_is_absent_until_something_is_heard(tmp_path: Path) -> None:
"""An empty database has no span, rather than a span of nothing."""
repo = Repository(tmp_path / "meshterm.db")
assert repo.observation_span() == (None, None)
assert repo.failed_run_count() == 0
# --- helpers ---------------------------------------------------------------------------
def _report(ctx: AppContext):
"""Build the report, as both faces do."""
return asyncio.run(build_report(ctx))
def _flat(report) -> dict:
"""Every fact in the report, flattened the way the JSON face flattens it."""
flat: dict = {}
for block in report:
if isinstance(block, Facts):
flat.update(block.values)
else:
flat[block.key] = list(block.rows)
return flat
def _keys(report) -> list[str]:
"""Every key the block declares, facts and listing columns alike."""
keys: list[str] = []
for block in report:
if isinstance(block, Facts):
keys.extend(column.key for column in block.fields)
else:
keys.extend(column.key for column in block.columns)
return keys
def _plain(report) -> str:
"""The whole block as the plain face writes it."""
console = _console(200)
with console.capture() as captured:
PlainRenderer(console).render(report)
return captured.get()
def _machine(report) -> str:
"""The machine face's document, as text."""
console = _console(10_000)
with console.capture() as captured:
JsonRenderer(console).render(report)
return captured.get()
def _render(renderable) -> str:
"""A renderable as plain text at the standard width."""
console = _console(72)
with console.capture() as captured:
console.print(renderable)
return captured.get()
def _console(width: int) -> Console:
"""A capture console with nothing between the content and the string."""
return Console(width=width, color_system=None, highlight=False, markup=False)
def _blocked(tmp_path: Path) -> Path:
"""A destination no OS will accept: a "directory" that is really a file.
Refused by both the mkdir and the write, everywhere, without needing permissions the
suite cannot count on.
"""
blocker = tmp_path / "blocker"
if not blocker.exists():
blocker.write_text("not a directory", encoding="utf-8")
return blocker / "inside" / "out.md"
def _strip(line: str) -> str:
"""One rendered line with its SGR runs removed."""
return re.sub(r"\x1b\[[0-9;]*m", "", line)
class _Session:
"""A session stand-in that runs the save flow inline and records the popup.
``run_detached`` is where the real session hands a key handler's flow to the event
loop; here it simply runs it, so a test can press a key and assert on what the reader
would have been shown. ``defer`` holds the coroutine instead, for the one test that
needs a save still in flight.
"""
def __init__(self, *, defer: bool = False, keep: bool = False) -> None:
"""Start with nothing shown and nothing started."""
self.messages: list[str] = []
self.shown: list[object] = []
self.started: list[object] = []
self._defer = defer
self._keep = keep
def invalidate(self) -> None:
"""A repaint request, which a test has no screen to repaint."""
def run_detached(self, work):
"""Run the flow now (or bank it, with ``defer``)."""
self.started.append(work)
if self._defer:
work.close()
return None
return asyncio.run(work)
async def message_dialog(self, message, *, title: str = "") -> None:
"""Record what the acknowledgement popup would have said."""
self.messages.append(str(message))
if self._keep:
self.shown.append(message)
def _page(ctx: AppContext, *, session: _Session | None = None, save_path: Path | None = None):
"""The page over this context's report, saving wherever the caller wants."""
from meshterm.ui.diagnostics import DiagnosticsPage
target = save_path or (ctx.settings.config_dir / "meshterm-diagnostics.md")
return DiagnosticsPage(
markdown_source(_report(ctx)), session=session or _Session(), save_path=target
)
def _body(page) -> str:
"""The page's drawn rows as plain text."""
return "\n".join(_strip(line) for line in page.render_body(100))
+82
View File
@@ -984,6 +984,87 @@ def _join_discord(cols: int, rows: int) -> Screen:
return AboutPage("Join Discord", join_discord())
def _diagnostics(cols: int, rows: int) -> Screen:
"""The Diagnostics page, carrying the widest values a real one can.
A deep Windows config directory and a connection error in the radio's own words are the
two fields with no natural bound, and both are here on purpose: a markdown list item
hangs its wrapped text under itself at any width, and that is what has to survive 53
columns.
"""
from meshterm.ui.diagnostics import DiagnosticsPage
return DiagnosticsPage(
_diagnostics_source(),
session=_GallerySession(cols, rows),
save_path=Path("C:/Users/somebody/.meshterm/meshterm-diagnostics.md"),
)
def _diagnostics_source() -> str:
"""A representative diagnostics document, built the way the tool builds one."""
from datetime import datetime
from meshterm.tools.diagnostics import markdown_source
from meshterm.ui import fields
from meshterm.ui.report import Column, Facts, Lane, Listing
report = (
Facts(
key="meshterm",
caption="Build",
fields=(fields.word("meshterm", "meshterm"), fields.word("install", "install")),
values={"meshterm": "0.3.7", "install": "frozen"},
),
Facts(
key="terminal",
caption="Terminal",
fields=(
fields.word("terminal", "terminal"),
fields.word("terminal_size", "terminal_size"),
fields.flag("over_ssh", "over_ssh"),
fields.word("powerline", "powerline"),
),
values={
"terminal": "Windows Terminal",
"terminal_size": "53x26",
"over_ssh": True,
"powerline": "none (font:windows-terminal)",
},
),
Facts(
key="device",
caption="Radio",
fields=(
fields.flag("connected", "connected"),
fields.path("config_dir", "config_dir"),
fields.free("error", "error"),
),
values={
"connected": False,
"config_dir": Path("C:/Users/somebody/AppData/Roaming/meshterm/profiles/handheld"),
"error": "no companion answered on COM7 within 10s; the port is open elsewhere",
},
),
Listing(
key="tables",
caption="Stored rows",
columns=(
fields.word("table", "TABLE"),
Column(key="rows", lanes=(Lane(header="ROWS", render=str, align="right"),)),
),
rows=[{"table": "observations", "rows": 418_203}, {"table": "runs", "rows": 91}],
),
Listing(
key="preferences",
caption="Changed preferences",
columns=(fields.word("preference", "PREFERENCE"), fields.free("value", "VALUE")),
rows=[{"preference": "log_level", "value": "DEBUG"}],
),
)
return markdown_source(report, when=datetime(2026, 9, 19, 17, 0))
def _share_qr(cols: int, rows: int) -> Screen:
"""The share screen: a contact card's code and link on a bare frame (the widest QR)."""
from meshterm.ui.qr import QrScreen
@@ -1032,6 +1113,7 @@ _ENTRIES: list[_Entry] = [
_Entry("join_discord", _join_discord),
_Entry("support_project", _support_project),
_Entry("share_qr", _share_qr),
_Entry("diagnostics", _diagnostics),
]
#: (platform, cols, rows) combos every entry above renders under. PicoCalc gets both its
+1 -1
View File
@@ -1209,7 +1209,7 @@ def test_sweep_screen_new_sweep_clears_the_old_evidence() -> None:
assert "TX 19" not in body and "-2.0" not in body
await task
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(go())
asyncio.run(go())
def test_sweep_screen_escape_resolves() -> None:
+116
View File
@@ -0,0 +1,116 @@
# SPDX-License-Identifier: Apache-2.0
"""The splash art's colour rewriting: brightness said as a colour, never as bold.
The art is DOS-era ANSI, where `1m` lifts the foreground into the bright bank and every
span after it inherits that. Neither half of that survives the trip here: rows are handed
to Rich one at a time, so nothing inherits, and on macOS Terminal bold asks for a heavier
face and leaves the colour alone. So `_state_intensity` restates brightness as the
aixterm bright bank (`9N`), which can only ever mean a colour.
These pin the part that is easy to get subtly wrong — a sequence that changes the
intensity while naming no colour at all, which is exactly how the art announces a bright
run. Both splash files are full of bare `1m`, and dropping one silently draws a whole run
in the dim bank: two spans meant to read dark then light both come out dark.
"""
from __future__ import annotations
import re
from pathlib import Path
from meshterm.ui.logo import _state_intensity
_SPLASH = Path(__file__).resolve().parent.parent / "meshterm" / "assets" / "splash"
_SGR = re.compile(r"\x1b\[([0-9;]*)m")
def _params(text: str) -> list[list[str]]:
"""Every SGR sequence in `text`, split into its parameters.
Matched as whole tokens rather than by substring: a pattern like ``[0-9;]*1`` also
matches the trailing digit of ``31``, which would read a perfectly ordinary red as a
request for bold.
"""
return [found.group(1).split(";") for found in _SGR.finditer(text)]
def _brights(text: str) -> int:
"""How many sequences in `text` name a bright-bank foreground."""
return sum(any(p.isdigit() and 90 <= int(p) <= 97 for p in parts) for parts in _params(text))
# -- a bare intensity change ----------------------------------------------------------
def test_bare_bright_lifts_the_colour_already_in_force() -> None:
"""`1m` on its own brightens the standing foreground, not just the next one named."""
out = _state_intensity("\x1b[0;37mdim\x1b[1mbright")
assert "\x1b[97m" in out, out.replace("\x1b", "ESC")
def test_bare_bright_carries_a_background_without_losing_the_foreground() -> None:
"""`1;41m` names a background and no foreground; the standing one still brightens."""
out = _state_intensity("\x1b[0;32mgreen\x1b[1;41mbright on red")
assert "\x1b[41;92m" in out, out.replace("\x1b", "ESC")
def test_returning_to_dim_restates_the_colour_in_the_dim_bank() -> None:
"""`22m` has the same problem in reverse: the run would stay in the bright bank."""
out = _state_intensity("\x1b[0;31mred\x1b[1mbright\x1b[22mdim again")
assert out.endswith("dim again")
assert "\x1b[22;31m" in out, out.replace("\x1b", "ESC")
def test_a_reset_takes_the_standing_colour_with_it() -> None:
"""After `0m` there is no colour in force, so a later `1m` has nothing to restate."""
out = _state_intensity("\x1b[0;36mcyan\x1b[0m\x1b[1mplain")
tail = out.split("plain")[0].split("\x1b[0m")[-1]
assert _brights(tail) == 0, out.replace("\x1b", "ESC")
def test_a_named_foreground_is_still_rewritten_in_place() -> None:
"""The original behaviour: `1;33m` becomes `93m`, with the bold dropped."""
out = _state_intensity("\x1b[0m\x1b[1;33myellow")
assert "\x1b[93m" in out, out.replace("\x1b", "ESC")
# -- the art itself -------------------------------------------------------------------
def test_no_splash_row_asks_a_terminal_for_bold() -> None:
"""Bold is a font weight on macOS Terminal, so none may reach the screen."""
for art in sorted(_SPLASH.glob("*.ans")):
raw = art.read_bytes().decode("cp437", errors="replace")
for number, line in enumerate(raw.split("\n"), start=1):
for parts in _params(_state_intensity(line)):
assert "1" not in parts, f"{art.name}:{number} still asks for bold"
def test_no_bright_run_in_the_art_is_silently_dropped() -> None:
"""A `1m` with a colour standing must leave a bright-bank foreground behind.
Counting brights across a file cannot catch this -- one `1m` brightens every later
sequence, so the totals stay comfortably high while individual runs go missing. The
invariant is per sequence: walk the source keeping track of whether a colour is in
force, and every sequence that turns brightness on while one is must produce a
bright-bank emission in the rewritten row.
"""
for art in sorted(_SPLASH.glob("*.ans")):
raw = art.read_bytes().decode("cp437", errors="replace")
for number, line in enumerate(raw.split("\n"), start=1):
standing, owed = None, 0
for parts in _params(line):
for part in parts:
if part in ("", "0"):
standing = None
elif re.fullmatch(r"3[0-7]", part):
standing = part
if "1" in parts and standing is not None:
owed += 1
if owed:
assert _brights(_state_intensity(line)) >= owed, (
f"{art.name}:{number} turns brightness on {owed} time(s) with a "
f"colour standing, but the rewrite emits "
f"{_brights(_state_intensity(line))} bright foreground(s)"
)
+214
View File
@@ -0,0 +1,214 @@
# SPDX-License-Identifier: Apache-2.0
"""The macOS Terminal profile: what it says, and what it refuses to touch.
The archive shapes here were not invented. They were read back off a profile installed on
a real Mac and confirmed rendering — so the encoder is pinned to bytes that are known to
work, rather than to a reading of Apple's format.
"""
from __future__ import annotations
import plistlib
import sys
from pathlib import Path
import pytest
from meshterm.core import macterminal
from meshterm.core.relaunch import REOPENED_ENV
from meshterm.ui.theme import _VT_SLOTS
def test_color_archive_is_the_shape_appkit_writes() -> None:
"""A colour decodes to a calibrated-RGB ``NSColor``, six places and NUL-terminated."""
decoded = plistlib.loads(macterminal.ns_color("#aa0000"))
assert decoded["$archiver"] == "NSKeyedArchiver"
assert decoded["$version"] == 100000
colour = decoded["$objects"][1]
assert colour["NSColorSpace"] == 1
assert colour["NSRGB"] == b"0.666667 0.000000 0.000000\x00"
assert decoded["$objects"][2]["$classname"] == "NSColor"
def test_font_archive_names_the_bundled_face() -> None:
"""The font archive carries the PostScript name, not the family name."""
decoded = plistlib.loads(macterminal.ns_font())
assert macterminal.FONT_FACE in decoded["$objects"]
assert decoded["$objects"][1]["NSSize"] == macterminal.FONT_SIZE
assert decoded["$objects"][3]["$classname"] == "NSFont"
def test_every_palette_slot_comes_from_the_theme() -> None:
"""The window cannot disagree with the screen it is holding.
Sixteen keys, in the dim-then-bright order Terminal expects, each one the theme's own
hex. A slot changed in :data:`~meshterm.ui.theme._VT_SLOTS` lands here on the next
write, which is the reason the profile is generated rather than shipped.
"""
profile = macterminal.build_profile()
assert len(macterminal._ANSI_KEYS) == len(_VT_SLOTS) == 16
for key, (_slot, _label, rgb) in zip(macterminal._ANSI_KEYS, _VT_SLOTS, strict=True):
assert profile[key] == macterminal.ns_color(rgb), key
def test_the_window_is_opaque_and_bold_is_not_brightness() -> None:
"""The two settings the profile exists to guarantee, beside the font."""
profile = macterminal.build_profile()
assert profile["BackgroundColor"] == macterminal.ns_color("#000000")
assert "BackgroundBlur" not in profile
assert profile["UseBrightBold"] is False
# Tidy itself away on a clean exit, stay put on a crash the reader needs to read.
assert profile["shellExitAction"] == 1
def test_the_profile_is_the_same_bytes_whatever_the_command_line() -> None:
"""The finding that shaped the module, pinned.
Terminal names an imported settings set after the *file*, reuses it when the bytes
match, and adds "MeshTerm 1" when they do not. So the profile must not carry anything
that varies between launches — the launcher path is fixed, and the varying command
lives in the file it points at.
"""
import plistlib
plain = plistlib.dumps(macterminal.build_profile("/Users/x/.meshterm/launch.sh"))
again = plistlib.dumps(macterminal.build_profile("/Users/x/.meshterm/launch.sh"))
assert plain == again
# And what actually goes in is a path, never an argv or an environment.
profile = macterminal.build_profile("/Users/x/.meshterm/launch.sh")
assert profile["CommandString"] == "/Users/x/.meshterm/launch.sh"
assert "--mock" not in str(profile["CommandString"])
assert "MESHTERM_" not in str(profile["CommandString"])
def test_the_launcher_carries_the_command_and_is_executable(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""What varies lives here instead, regenerated on every launch."""
monkeypatch.setenv("MESHTERM_HOME", str(tmp_path / "home"))
monkeypatch.setattr(sys, "argv", ["meshterm", "--mock"])
path = macterminal.write_launcher()
body = path.read_text(encoding="utf-8")
assert path.name == "launch.sh"
assert body.startswith("#!/bin/sh")
assert "exec /usr/bin/env " in body
assert body.rstrip().endswith("--mock")
if sys.platform != "win32": # NTFS has no execute bit for chmod to set
assert path.stat().st_mode & 0o111
def test_a_bare_profile_runs_nothing() -> None:
"""Without a command it is an entry in the list, not a launcher."""
profile = macterminal.build_profile()
assert "CommandString" not in profile
assert "RunCommandAsShell" not in profile
def test_the_profile_round_trips_as_a_terminal_file() -> None:
"""What is written is what Terminal reads: a plist, with the nested archives intact."""
written = plistlib.dumps(macterminal.build_profile("true"), fmt=plistlib.FMT_XML)
reloaded = plistlib.loads(written)
assert reloaded["name"] == macterminal.PROFILE_NAME
assert reloaded["type"] == "Window Settings"
assert reloaded["CommandString"] == "true"
assert plistlib.loads(reloaded["ANSIRedColor"])["$objects"][1]["NSColorSpace"] == 1
def test_launch_command_carries_meshterm_variables_and_the_marker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Terminal starts the command from a fresh shell, so what it needs travels with it."""
monkeypatch.setenv("MESHTERM_HOME", "/Users/someone/meshterm test")
monkeypatch.delenv(REOPENED_ENV, raising=False)
monkeypatch.setattr(sys, "argv", ["meshterm", "--mock"])
command = macterminal.launch_command()
assert f"{REOPENED_ENV}=1" in command
# Quoted, because the reader's path may contain a space and the shell would split it.
assert "MESHTERM_HOME='/Users/someone/meshterm test'" in command
assert command.endswith("--mock")
# A real executable, not a `VAR=value` shell prefix: measured on macOS 26, Terminal
# reads the string as a bare program name under one setting and as a shell line under
# the other, and a prefix puts "Command not found: MESHTERM_REOPENED=1" on the screen.
assert command.startswith("/usr/bin/env ")
def test_an_unrelated_variable_is_not_carried(monkeypatch: pytest.MonkeyPatch) -> None:
"""The hop is not a chance to launder the whole environment into a new shell."""
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "nope")
assert "AWS_SECRET_ACCESS_KEY" not in macterminal.launch_command()
def test_a_reopened_session_never_reopens_again(monkeypatch: pytest.MonkeyPatch) -> None:
"""The guard that keeps a relaunch from chaining, since the profile's command sets it."""
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setenv("TERM_PROGRAM", "Apple_Terminal")
monkeypatch.setenv(REOPENED_ENV, "1")
assert macterminal.available() is False
assert macterminal.reopen() is False
@pytest.mark.parametrize("program", ["iTerm.app", "ghostty", "vscode", "WezTerm"])
def test_another_terminal_is_left_alone(program: str, monkeypatch: pytest.MonkeyPatch) -> None:
"""Nothing is offered to a terminal that already has truecolor and a chosen font."""
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.delenv(REOPENED_ENV, raising=False)
monkeypatch.setenv("TERM_PROGRAM", program)
assert macterminal.in_apple_terminal() is False
assert macterminal.available() is False
def test_nothing_happens_off_macos(monkeypatch: pytest.MonkeyPatch) -> None:
"""Every entry point is inert on the platforms this module is not about."""
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setenv("TERM_PROGRAM", "Apple_Terminal")
assert macterminal.in_apple_terminal() is False
assert macterminal.available() is False
assert macterminal.reopen() is False
assert macterminal.install_font() is False
def test_the_profile_is_written_under_meshterm_home(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""It lives in MeshTerm's own directory, and follows ``MESHTERM_HOME`` when set."""
monkeypatch.setenv("MESHTERM_HOME", str(tmp_path / "home"))
path = macterminal.write_profile()
assert path == tmp_path / "home" / "MeshTerm.terminal"
assert plistlib.loads(path.read_bytes())["name"] == "MeshTerm"
def test_the_readers_preferences_are_never_named(tmp_path, monkeypatch) -> None:
"""Terminal discards external edits to its prefs on quit, so nothing here writes them.
A guard against the obvious future shortcut rather than against today's code: the
whole design rests on handing Terminal a file and letting it do the importing.
"""
source = (macterminal.__file__).replace(".pyc", ".py")
with open(source, encoding="utf-8") as handle:
body = handle.read().split('"""', 2)[2]
assert "com.apple.Terminal.plist" not in body
assert "Library/Preferences" not in body
assert "defaults" not in body.replace("default_config_dir", "")
def test_the_font_goes_to_the_users_own_library() -> None:
"""No administrator rights, and undoable from Font Book."""
assert macterminal.USER_FONT_DIR == Path.home() / "Library" / "Fonts"
+16 -5
View File
@@ -184,8 +184,8 @@ def test_main_menu_sections_answer_the_menus_own_question() -> None:
someone else's over the mesh, or MeshTerm itself.
This app is the last of the three scopes and the reason it sorts last: it holds the
preferences that change the program and the pages that describe it, neither of which
is a thing to do *on the mesh*.
preferences that change the program, the diagnostics that state what it currently is,
and the pages that describe it — none of which is a thing to do *on the mesh*.
"""
from meshterm.tools import load_all_tools
from meshterm.tools.base import _CATEGORY_ORDER, all_tools
@@ -203,11 +203,22 @@ def test_main_menu_sections_answer_the_menus_own_question() -> None:
"Explore": ["map", "walk", "trace", "trace-path", "records"],
"This node": ["info", "config", "advert"],
"Other nodes": ["repeater-admin", "tx-optimize"],
"This app": ["preferences", "about", "about-author", "discord", "support"],
"This app": [
"preferences",
"diagnostics",
"about",
"about-author",
"discord",
"support",
],
}
# No section is so big it stops being a grouping (the old Mesh bucket held seven of
# nineteen rows), and none is a bucket of one.
assert all(2 <= len(names) <= 5 for names in sections.values())
# nineteen rows), and none is a bucket of one. This app is the one at six: it runs
# mutable, then live, then fixed — the row that *changes* MeshTerm, the row that states
# what MeshTerm is *right now* on this machine, then the four pages that say what
# MeshTerm is in general. Six of twenty-four rows is still a grouping; seven of
# nineteen was not.
assert all(2 <= len(names) <= 6 for names in sections.values())
#: Two lexicon icons the terminal genuinely draws in different widths — the whole reason
+15
View File
@@ -169,6 +169,21 @@ def test_record_dialog_shows_the_node_type_legend() -> None:
assert "★ you" in body and "▲ repeater" in body and "◉ sensor" in body
def test_record_dialog_legend_breaks_between_entries_on_a_picocalc_width() -> None:
"""At 53 cells the legend takes two lines, and no mark is parted from its name.
The full names run to 59 cells on one line, so cropping would cut "sensor" off the
PicoCalc; breaking inside an entry would leave a glyph at the end of one line and its
name at the start of the next.
"""
screen = _dialog(_record())
screen.handle("tab") # the Route tab
body = _plain(screen.render_body(53))
for entry in ("★ you", "▲ repeater", "● companion", "■ room server", "◉ sensor"):
assert entry in body
assert all(cell_len(line) <= 53 for line in body.splitlines())
def test_record_graph_marks_a_repeater_relay_with_its_triangle() -> None:
"""A relay whose type is a repeater draws ▲, not the generic ● dot."""
dialog = _dialog(_record(), type_of=lambda h: 2 if h == HUB_ID else None)
+199 -6
View File
@@ -3344,8 +3344,18 @@ def test_a_hint_too_long_for_its_box_drops_atoms_rather_than_its_tail() -> None:
)
def test_the_splash_scrolls_the_hardware_column_of_the_row_it_is_on() -> None:
"""←→ read a long firmware model to its end; the lanes in front of it stay put."""
def test_the_splash_scrolls_the_whole_row_it_is_on() -> None:
"""←→ move the entire row, lanes included, rather than sliding the tail under them.
The Trophy case pins its rank/date/score lanes because those always fit and only the
walk overflows, so they are the reader's place in a long list. This list inverts that:
on a narrow terminal it is the device name and the address that get cut, so pinning
them would pin the truncation in place and the one thing ←→ could not reach would be
the text somebody most wants to finish reading.
Scrolling has to be asked for outright here, because it is a row pinning a head block
that normally turns it on and no row does now.
"""
from meshterm.core.device_store import RememberedDevice
from meshterm.core.discovery import serial_device
from meshterm.platforms import PICOCALC, REGULAR, set_platform
@@ -3367,17 +3377,22 @@ def test_the_splash_scrolls_the_hardware_column_of_the_row_it_is_on() -> None:
for platform, width in ((REGULAR, 66), (PICOCALC, 47)):
set_platform(platform)
items = _build_items([radio], registry[radio.stable_id], registry)
screen = SelectScreen("Select a companion device", items, filterable=False)
screen = SelectScreen(
"Select a companion device", items, filterable=False, hscroll=True
)
row = next(it for it in items if isinstance(it, Choice))
assert row.hscroll_from > 0 # the fixed lanes are pinned...
assert row.hscroll_from < width # ...and leave something to scroll into
assert not row.hscroll_from, "no lane is pinned on the splash any more"
before = _plain(screen.render_body(width)[1])
for _ in range(3):
screen.handle("right")
after = _plain(screen.render_body(width)[1])
assert before != after, platform.name
assert after.startswith(before[: row.hscroll_from - 4]), "the lanes moved"
# The head moved with everything else: what was at the left edge is gone.
assert not after.startswith(before[:8]), f"the lanes stayed put on {platform.name}"
# And the name is what scrolled out of view, which is the point -- it is the
# part a narrow terminal cuts, so it has to be reachable.
assert "Wardriver" in before
finally:
set_platform(REGULAR)
@@ -3425,3 +3440,181 @@ def test_a_shortcut_is_only_honoured_where_a_letter_is_free() -> None:
filtering.handle("text", "h")
assert len(resolved) == 1 # nothing new resolved
assert filtering._filter == "h" # the letter went to the query, where it belongs
# -- the splash keeps looking ---------------------------------------------------------
def _drive_picker(tmp_path, monkeypatch, *, serial_rounds, ble_rounds=None, hide=None):
"""Run the picker's rescan against a scripted sequence of scans.
The fake UI stands in for the splash being open: it is handed the ``live`` callable
and runs it until the script is exhausted, recording every redraw. Discovery is
replaced rather than mocked at the port level, because what is under test is the
rescan's behaviour -- when it redraws, when it keeps quiet -- and not pyserial.
"""
import asyncio
from meshterm.core.device_store import DeviceStore
from meshterm.ui import device_picker
monkeypatch.setattr(device_picker, "_POLL_S", 0.001)
monkeypatch.setattr(device_picker, "_BLE_EVERY_S", 0.0025)
monkeypatch.setattr(device_picker, "_BLE_WINDOW_S", 0.0)
serial = list(serial_rounds)
ble = list(ble_rounds or [[]])
calls = {"serial": 0, "ble": 0}
def _serial():
calls["serial"] += 1
return list(serial[min(calls["serial"] - 1, len(serial) - 1)])
async def _ble(_timeout):
calls["ble"] += 1
return list(ble[min(calls["ble"] - 1, len(ble) - 1)])
monkeypatch.setattr(device_picker, "discover_devices", _serial)
monkeypatch.setattr(device_picker, "discover_ble_devices", _ble)
store = DeviceStore(tmp_path / "devices.json")
for stable in hide or []:
store.hide(stable)
redraws: list = []
class _Ui:
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, live=None, **_kw
):
task = asyncio.ensure_future(live(redraws.append))
# Long enough for the script to run several times over at this cadence.
await asyncio.sleep(0.05)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
return None
async def _never(_device):
raise AssertionError("verify should not run when selection is skipped")
asyncio.run(device_picker.prompt_device(_Ui(), list(serial[0]), store, _never))
return redraws, calls
def _labels(items) -> list[str]:
return [
(it.title.plain if hasattr(it.title, "plain") else str(it.title))
for it in items
if isinstance(it, Choice)
]
def test_splash_redraws_when_a_device_is_plugged_in(tmp_path, monkeypatch) -> None:
"""A radio attached after the splash opened appears without restarting MeshTerm."""
from meshterm.core.discovery import DiscoveredDevice
first = DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)
later = DiscoveredDevice(port="COM9", product="RAK4631", vid=0x239A)
redraws, _ = _drive_picker(tmp_path, monkeypatch, serial_rounds=[[first], [first, later]])
assert redraws, "the splash never redrew after the device appeared"
assert any("COM9" in label for label in _labels(redraws[-1]))
def test_splash_stays_quiet_when_nothing_changed(tmp_path, monkeypatch) -> None:
"""A poll that finds the same ports redraws nothing.
This is the half that matters for somebody mid-way through arrowing down the list:
a redraw they did not ask for, on a list that has not changed, is the screen moving
under their hands for no reason.
"""
from meshterm.core.discovery import DiscoveredDevice
same = [DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)]
redraws, calls = _drive_picker(tmp_path, monkeypatch, serial_rounds=[same])
assert calls["serial"] > 1, "the rescan never ran"
assert redraws == []
def test_splash_notices_a_device_going_away(tmp_path, monkeypatch) -> None:
"""Unplugging removes the row, which is the same question asked backwards."""
from meshterm.core.discovery import DiscoveredDevice
one = DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)
redraws, _ = _drive_picker(tmp_path, monkeypatch, serial_rounds=[[one], []])
assert redraws, "the splash never redrew after the device went away"
assert not any("COM5" in label for label in _labels(redraws[-1]))
def test_a_rescan_does_not_unhide_what_the_reader_hid(tmp_path, monkeypatch) -> None:
"""A refresh is not ⇧H. Whatever `h` hid stays hidden when the list is rebuilt."""
from meshterm.core.discovery import DiscoveredDevice
kept = DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)
buried = DiscoveredDevice(port="COM7", product="FT232R USB UART", vid=0x0403)
later = DiscoveredDevice(port="COM9", product="RAK4631", vid=0x239A)
redraws, _ = _drive_picker(
tmp_path,
monkeypatch,
serial_rounds=[[kept, buried], [kept, buried, later]],
hide=[buried.stable_id],
)
assert redraws, "the splash never redrew"
labels = _labels(redraws[-1])
assert any("COM9" in label for label in labels)
assert not any("COM7" in label for label in labels)
def test_bluetooth_is_not_scanned_on_every_poll(tmp_path, monkeypatch) -> None:
"""Serial is cheap and polled; Bluetooth is a listen and gets a duty cycle.
Each BLE scan keeps the radio listening, and this is the screen somebody leaves open
on a battery-powered handheld while they go and find a cable -- so it must not be in
the poll loop.
"""
from meshterm.core.discovery import DiscoveredDevice
same = [DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)]
_, calls = _drive_picker(tmp_path, monkeypatch, serial_rounds=[same])
assert calls["serial"] > calls["ble"], (
f"bluetooth scanned {calls['ble']} time(s) against {calls['serial']} serial poll(s)"
)
def test_the_picker_only_passes_arguments_the_splash_surface_accepts(tmp_path) -> None:
"""Every keyword `prompt_device` sends must exist on `Ui.select_startup`.
This is the shape of bug a test double hides. `prompt_device` calls the *surface*,
which delegates to the session; adding an argument to the session alone crashes the
startup path and nothing else, because every fake Ui in this file absorbs unknown
keywords with `**kwargs` and is perfectly happy. So the fake here binds what it is
given against the real signature, and a keyword the surface has never heard of raises
exactly where a running MeshTerm would.
"""
import inspect
from meshterm.core.device_store import DeviceStore
from meshterm.ui.device_picker import prompt_device
from meshterm.ui.surface import Ui
signature = inspect.signature(Ui.select_startup)
class _Ui:
async def select_startup(self, title, items, **kw): # noqa: ANN001, ANN003, ANN201
signature.bind(self, title, items, **kw) # TypeError on an unknown keyword
return None
async def _never(_device):
raise AssertionError("verify should not run when selection is skipped")
store = DeviceStore(tmp_path / "devices.json")
asyncio.run(prompt_device(_Ui(), [], store, _never))
+11
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
from rich.style import Style
from rich.text import Text
from meshterm.core.models import node_type_label
from meshterm.ui.pathline import (
POWERLINE_ROUND_CLOSE,
POWERLINE_ROUND_OPEN,
@@ -276,3 +277,13 @@ def test_name_chip_falls_back_to_the_bare_name_without_powerline(powerline) -> N
assert chip.plain == "Alice" # exactly what an arrow-mode path line makes of one hop
assert str(next(iter(chip.spans)).style) == node_style("a1b2") # still its own hue
def test_every_advert_type_has_one_name() -> None:
"""Type 1 is a companion everywhere: info, diagnostics, contacts and the legend agree."""
assert node_type_label(1) == "companion"
assert node_type_label(2) == "repeater"
assert node_type_label(3) == "room server"
assert node_type_label(4) == "sensor"
assert node_type_label(9) == "type 9" # an advert type MeshTerm does not know yet
assert node_type_label(None) is None