42 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
Jean-Pierre Martineau aed1d91484 MeshTerm 0.3.7
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
2026-09-19 02:37:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 2e40603a64 Satisfy the lint gates the macOS work walked past
Import order, a docstring reworded rather than have "macOS" capitalised
into something that is not the product's name, two over-long test lines,
and _tls_context moved below the logger it uses so the module keeps its
shape: docstring, imports, constants, logger, then code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 02:34:25 -04:00
Jean-Pierre MartineauandClaude Opus 5 9a58bc04bb The wordmark says bright as a colour, because bold is not brightness everywhere
On a Mac the splash drew muted, and the dithers that fade one colour into
another faded toward the wrong end.

An art editor spells brightness the DOS way: 1m lifts the foreground into the
bright bank and the spans after it inherit that. It is true of a DOS console and
of the PicoCalc's, and it is false on macOS Terminal, where bold asks for a
heavier face and leaves the colour alone. There 1;30 is not dark grey but plain
black -- and the wide mark carries seventy-nine such spans, much of it on a
black ground, so the shading and bevels flattened into the background. The
gradients went with them: a fade drawn as a bright stipple over a dim ground
loses its top end when the bright half stops being bright, and what is left
reads as a dotted pattern that has not come out right.

So the bright bank is emitted as the colour it means -- 9N, which cannot be
mistaken for a font weight, and which is how the PicoCalc's own console spells
bright anyway. Which colour is being asked for does not change, and neither does
a single cell: only the spelling, from one a terminal may read as a weight to
one that can only be a colour. Bold is now asked for nowhere in the mark.

The art files are untouched. They stay authored the way the editors that drew
them write, and the loader goes on being the place where what DOS assumed is
translated into what a terminal can be told.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 01:14:28 -04:00
Jean-Pierre MartineauandClaude Opus 5 b1f21664ff The name is what the reader came to read, so the address is what gives
Capping the address lane stopped it eating the row, but left the old priority
in place underneath: DEVICE was still sized from whatever the address had
finished taking. Turn it around. The name is sized first, in full, and the
address takes what is left.

That is the right way round because of what each lane is for. A name is what
the reader recognises their own radio by; an address is a disambiguator, and a
disambiguator does its whole job from its last ten cells -- which is already
why the cut takes the front and keeps the tail. So the address shrinks to that
floor before the name gives up a single cell, and never reserves more than it
actually wants: a lone COM3 hands its slack to the name rather than sitting in
a ten-cell lane holding four.

The name still has to stop somewhere. A USB adapter's product string runs to
forty-odd characters, and a lane sized to that pushed everything after it off
the edge -- the row ended mid-port with HARDWARE not on screen at all. So
HARDWARE keeps its readable stub out of the budget, and past that the name
ellipsizes. Where it must, the address is handed back its full width instead
of being ellipsized alongside: the name is being cut whatever happens, and
spending two more of its cells leaves one cut in the row rather than two.

On a Mac the name now shows whole at 28 cells where it had six, and the 36-cell
UUID behind it draws as its last nine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 00:56:34 -04:00
Jean-Pierre MartineauandClaude Opus 5 1933d725b0 The address lane stops eating the columns worth reading
On a Mac the splash's DEVICE column was squeezed to the width of its own
heading, and HARDWARE went with it.

The lane sized itself to the longest target with no cap, and `_name_width`
sizes DEVICE from whatever that leaves. On Windows and Linux the assumption
held -- a MAC is 17 cells, /dev/ttyUSB0 is 12 -- so nothing ever pressed on it.
macOS breaks it twice: CoreBluetooth reports no MAC at all but a per-machine
128-bit UUID, 36 cells of it, and even the ports run long (every Mac carries a
/dev/cu.Bluetooth-Incoming-Port, which is 31). One of those took 36 of the 58
cells a row has, leaving DEVICE six.

So the lane is capped and ellipsizes, which is what the name lane beside it has
always done. The cut takes the **front**: what tells two targets apart is their
tail -- usbmodem1101 from usbmodem1102, ttyUSB0 from ttyUSB1 -- while the head
they share is boilerplate, and cutting the back would draw two different ports
identically, which is the one thing a picker must never do. A CoreBluetooth
UUID reads the same from either end, so the rule that is right for ports costs
the platform that needed the cap nothing. That is also why this is not
`menus.fit_cells`, whose ellipsis keeps the head.

At 20 cells the cap is above every target the other platforms produce, so they
are untouched by it and the change shows up only where something was wrong.
DEVICE goes from 6 cells back to 18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 00:45:45 -04:00
Jean-Pierre MartineauandClaude Opus 5 730230461c The map draws no ground when the build has no certificates to trust
On a Mac the map showed nodes floating over nothing: overlays come from the
local database and arrived fine, while every base tile silently failed.

A frozen build can have no trust store. The default TLS context asks OpenSSL for
one, and on macOS and Linux that is a filesystem path baked into the
interpreter's own build -- which need not exist on a machine that never
installed that Python, which is every machine a PyInstaller bundle lands on.
Each tile fetch ended in CERTIFICATE_VERIFY_FAILED, and since the fetcher cannot
tell a refusal from empty terrain, the map drew what it had. Windows is the
exception that hid this: its default context reads the OS certificate store, so
the binary there always worked, and so did every checkout on every platform.

So the context now keeps the default when that default actually loaded
certificates, and otherwise points it at the bundle the operating system ships.
Those are the trust decisions the machine's administrator has made and the OS
keeps current; carrying our own copy would freeze a snapshot of them into every
release, and certifi -- the obvious way to do that -- is MPL-2.0, which this
project's own dependency policy refuses. `tests/test_notices.py` said so, which
is what that gate is for.

Verification is not weakened anywhere. With no bundle to be found the fetch
still verifies and still fails, because a map tile is not worth teaching the app
to skip certificate checks, and an unverified fetcher would be one import away
from being reused where it matters.

Confirmed against the 0.3.6 arm64 build on a Mac: handing the same binary the
OS bundle turned one hard failure into a fetched TileJSON and 7.3 MB of tiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:28:59 -04:00
Jean-Pierre MartineauandClaude Opus 5 71ce60713f On macOS the refusal is the pairing starting, not the pairing failing
Selecting a PIN-protected companion on a Mac reported an error, and selecting it
a second time connected -- the first attempt having quietly done the bonding.

CoreBluetooth has no pairing API: a peripheral pairs implicitly when something
touches a characteristic requiring encryption. The companion firmware puts its
UART characteristic at ENC+MITM precisely so that happens, so the unbonded
subscribe is answered with Insufficient Authentication *and* that answer is what
makes macOS raise its Passkey dialog. The GATT operation is dead by then; the
bond it began arrives seconds later, once a person has read six digits off a
device and typed them. Treating the refusal as a verdict therefore reported
failure for a pairing that was succeeding.

So on macOS the connect now waits for the dialog to be answered instead of
giving up on it, bounded so a dismissed dialog still fails in the end. Elsewhere
a rejection keeps meaning what it always did -- Windows and Linux can pair
explicitly, and do, before the link is opened.

The message that survives a dismissal no longer recommends --ble-pin there
either: macOS collects the code itself, so the flag cannot help, and naming it
sends the reader somewhere with nothing to offer. It names the dialog, and the
Bluetooth permission the terminal needs for one to appear at all.

The tests that cover a rejection now state which platform they mean, rather than
inheriting whichever runner they landed on -- the macOS leg of the matrix would
have failed on the old wording, and on a PIN it no longer forwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 22:46:10 -04:00
Jean-Pierre MartineauandClaude Opus 5 add17b49d6 A Mac can find its own companion
Three faults, one cause: macOS is the only platform that permanently presents
ports nobody attached. Every Mac carries /dev/cu.Bluetooth-Incoming-Port and
/dev/cu.debug-console, with no USB VID/PID between them.

Selection counted them. `len(devices) == 1` was therefore unreachable on a Mac --
one real board attached made three devices -- so auto-detection could never fire
there and every Mac user met "Multiple companion devices detected" on a first
run, naming two virtual ports as the companions to choose between. Which device
is plausible was already known and already printed in that very listing as
"[likely LoRa]"; it now decides with it instead of only saying it. A lone
unrecognized adapter still resolves, because an unlisted VID is a real board more
often than not, and several implausible ports no longer claim to be companions.

A refused Bluetooth scan read as silence. bleak states the case precisely and
hands over a typed reason, and that was caught as a driver hiccup and logged at
debug -- under a log that defaults to WARNING, so a denial left no trace at all
and looked exactly like an empty room. On macOS this is the routine case rather
than an exotic one: Bluetooth is granted to the terminal MeshTerm runs in, never
to MeshTerm, so an ungranted terminal scans and hears nothing. The sentence bleak
wrote is kept and shown, since it already names the remedy.

The PIN broke the connection it was for. Handed one, the meshcore transport calls
bleak's pair(), and CoreBluetooth has no pairing API -- the macOS backend raises
outright, and the library then disconnects and re-raises, so a correct PIN was
what failed. Pairing there is the OS's to run: the firmware puts its UART
characteristic at ENC+MITM, the unbonded subscribe is answered with Insufficient
Authentication, and macOS responds by running Passkey Entry and asking for the
code itself. Withholding the PIN is what lets a protected companion bond. Windows
and Linux do expose explicit pairing and keep it.

Found by running the 0.3.6 arm64 build on a Mac. Detection over SSH is a separate
thing and stays impossible: an ssh session is a Background session, and macOS
refuses to prompt for Bluetooth in one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 22:22:59 -04:00
Jean-Pierre MartineauandClaude Opus 5 fc4406ba86 Only the reader removes a discovered setting; a read may only take it to n/a
A discovered row was dropped when a read came back as anything that could not
be its value, on the reasoning that the node's answer was the row's whole
claim to exist. The reasoning holds and the cost does not.

A catalog row that misreads costs an n/a and nothing is lost: the catalog
still names the key, and the next read puts the value back. A discovered row's
key is recorded nowhere else. Deleting it throws away the one thing about this
node that only the reader could have found, and they may no longer remember
what it was -- for a reply that misread rather than a key that went away. A
truncated line, a node answering mid-reboot, a stray frame correlated to the
wrong command, and the row is gone with no way back but memory.

So a read now takes a discovered row to n/a like any other, and Del is the
only thing that removes one. The board-swap case the automatic drop was for is
still served: the row reads n/a, which is visible and says what happened, and
forgetting it is a keystroke.

A dropped packet was never the danger -- read_settings folds only a reply it
actually received, and a timeout leaves the row alone -- but the rows it can
delete were never worth the ones it tidied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:06:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 a7d201f4da A setting you use on a node's command line becomes a row on that node's page
Third-party builds carry knobs the catalog has never heard of -- EasySkyMesh's
radio.rxps and reboot.interval among them -- and MeshTerm cannot know which
build a repeater is running without asking it. Asking is the whole problem:
every question is one paced round trip, so probing for a fork's keys would
spend a sweep on every node to benefit a few, and listing them outright would
leave dead n/a rows on everyone else's page.

So nothing is probed speculatively and the catalog does not grow. The command
line is already the only place those settings can be reached, so it is now
also where they are found: a get/set the reader ran, which this node answered,
earns a row on this node's page under Extra. The round trip was one the reader
was spending anyway, and no other node's page changes.

The two verbs are not equal evidence, which the firmware settles. handleSetCmd
compares each key with its trailing space -- "radio " -- so a longer key can
never land in a shorter one's branch, and an unknown one answers "unknown
config:". handleGetCmd compares a bare prefix, so stock firmware answers
"get radio.rxps" out of the get radio branch with a perfectly plausible
910.525,62.5,7,5. A catalog row survives that because its typing rejects it; a
discovered key has no typing, so a read is trusted only when nothing shadows
the key, or when the reply differs from what the shadow last said.

A discovered row's whole claim to exist is that the node answered it, so when
it stops answering the row goes rather than earning the n/a that only a
catalog key can -- which is what tidies up after a board swap onto a reused
identity. Del forgets one without waiting for a read, and asking the key again
brings it back. Its values are fitted to the lane the catalog's rows already
need, since the value lane is one width for the whole page.

prv.key is never stored, whichever way it was asked. The firmware answers it on
the serial console only, and a reply that reached us anyway must not be the
first place a repeater's private key lands in a file.

Two things fall out: get tx at the command line no longer leaves the TX power
row showing last week's value, since catalog keys now fold into the cache the
same way, and a discovered key completes at the prompt where it was learned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:29:26 -04:00
Jean-Pierre MartineauandClaude Opus 5 be7e588c3a A Mac user is told to curl the download, not to fight Gatekeeper
The quarantine flag that stops an unsigned binary is set by the browser
that downloaded it, not by macOS. curl sets nothing, so the same file
fetched that way simply runs -- no dialog, no xattr, no signature. For a
program whose users are already at a terminal that is the normal way to
install it, so it leads the note now.

The old advice was also going stale: Sequoia removed the right-click ->
Open bypass, and since Catalina a quarantined binary started from Terminal
-- which is how the README says to start MeshTerm -- gets the same block
rather than escaping it. Both are named, xattr stays for anyone who already
used a browser, and pipx is pointed at as the route that downloads no
binary at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:38:28 -04:00
Jean-Pierre MartineauandClaude Opus 5 c7c1770d96 The Windows binary is signed by SignPath Foundation
Windows greets an unsigned one-file PyInstaller build as a stranger every
release: SmartScreen warns, and antivirus engines flag it on sight. A
signature gives Windows a publisher to accumulate trust against instead.

SignPath Foundation issue the certificate free to open-source projects and
sign on their own infrastructure, so no key exists on the runner. Their
condition is that the artifact provably came from this repository's CI,
which is why the binary is handed over as a GitHub artifact id rather than
uploaded -- SignPath reads the workflow run behind it.

Signing sits between the build's smoke test and the naming step, so the
checksum describes the file people actually download. The unsigned draft
is the one artifact the release must not attach, so it is named outside
the meshterm-* pattern the release now downloads by.

Both steps skip wholesale until SIGNPATH_ORGANIZATION_ID is set, so the
Windows binary keeps building unsigned in the meantime and a fork with no
SignPath project of its own is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:02:10 -04:00
85 changed files with 5866 additions and 2234 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
+109 -7
View File
@@ -55,6 +55,10 @@ jobs:
# Every platform's artifact, flattened into one directory of files to attach.
- uses: actions/download-artifact@v4
with:
# The build uploads one artifact nobody should ever download: the unsigned
# Windows binary it hands to SignPath. Everything meant for the release page is
# named `meshterm-…`, so the pattern is the filter.
pattern: meshterm-*
path: artifacts
merge-multiple: true
@@ -62,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
@@ -92,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
@@ -99,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 }}
@@ -107,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"
@@ -142,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: |
+48 -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
@@ -118,6 +119,50 @@ jobs:
"$GITHUB_WORKSPACE/$bin" contacts --help > /dev/null
echo "runs, finds its assets, and its tools registered"
# The Windows binary is code-signed, so Windows has a publisher to recognise
# instead of an unknown file it has never seen before — SmartScreen's warning eases
# as that publisher's downloads accumulate, and antivirus engines, which flag
# unsigned PyInstaller one-file builds on sight, are markedly less trigger-happy.
# The certificate belongs to SignPath Foundation, who issue it free to open-source
# projects and sign on their own infrastructure; nothing secret exists on this
# runner, and there is no key here to leak. Its condition is that the artifact
# provably came from this repository's CI, which is why the binary is handed over
# as a GitHub artifact rather than uploaded: SignPath reads the workflow run behind
# that artifact id and refuses anything it cannot trace to an approved branch.
#
# Signed between the build's own smoke test and the naming step, so what gets
# verified is the build and what gets checksummed is the signed file — a checksum
# taken before signing would describe a binary nobody ever downloads.
#
# Both legs are skipped wholesale until the account exists (the variable is unset
# until then), so this file builds unsigned Windows binaries in the meantime rather
# than failing, and a fork with no SignPath project of its own is unaffected.
- name: Hand the Windows binary to SignPath
id: unsigned
if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != ''
uses: actions/upload-artifact@v4
with:
# Deliberately not `meshterm-*`: the release workflow attaches every artifact
# matching that prefix, and this one is the unsigned draft.
name: unsigned-${{ matrix.label }}
path: packaging/dist/meshterm.exe
if-no-files-found: error
- name: Sign it
if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != ''
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
project-slug: meshterm
signing-policy-slug: release-signing
artifact-configuration-slug: exe
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
# The signed file comes back where the unsigned one was built, under the same
# name, so every step after this one is untouched by any of it.
wait-for-completion: true
output-artifact-directory: packaging/dist
- name: Name it after the version and the platform
shell: bash
run: |
+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 -852
View File
@@ -2,858 +2,82 @@
Notable changes to MeshTerm, newest first.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the version
numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — with the caveat
SemVer itself makes for a leading zero: while the major version is `0`, a **minor** bump is
allowed to change behaviour, not just add to it.
<!--
At 0.9.0 — the first public release — everything below collapses into a single entry
reading "first public release", with the headline features under it. Nobody arriving at
a project on its launch day wants a changelog of the fortnight before it: the versions
under 0.9.0 were never released anywhere, and their entries are notes to ourselves about
getting ready. Keep the dates and the tags; replace the prose.
-->
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — with
one caveat SemVer makes for a leading zero: while the major version is still `0`, a
**minor** bump is allowed to change how things behave, not just add to them.
## [Unreleased]
## [0.3.6] — 2026-09-17
### Added
- **A contact can be locked.** Lock contact on a contact's page keeps it from ever being
archived: the archive sweep counts it protected, and the page hides Archive until you
unlock it. Locked contacts show a padlock ahead of their node type icon in the Contacts
list — 🔒, or the console font's own padlock on the PicoCalc. The lock is kept by
MeshTerm, not the radio, and reading the device never clears it.
- **Set clock on connect.** A new preference in the Device group sets the radio's clock from
this computer's every time MeshTerm connects to it — at launch, when you connect later,
and after a reconnect, since a dropped link is exactly when a board with no real-time
clock may have rebooted and lost the time. It is off by default, runs in the background,
and records the drift it corrected only in the log. Scripted commands never write the
clock.
- **Two installation manuals, one per handheld.** [`docs/picocalc.md`](docs/picocalc.md)
takes a stock ClockworkPi PicoCalc from a shopping list to a Linux handheld running
MeshTerm with a LoRa radio soldered inside — the Lyra swap, writing Calculinux, the setup
script, building and flashing the XIAO firmware (the patch it still needs, explained
hunk by hunk), the wiring, and the Lyra setup. [`docs/uconsole.md`](docs/uconsole.md)
installs the SPI bridge on a uConsole with the AIO LoRa board and connects MeshTerm to
it. Both are step-by-step in plain language and self-contained, each ending on its own
list of what has only been proven on one bench; [`docs/hardware.md`](docs/hardware.md)
shrinks to the page that says which manual is yours, and the README's new
**Documentation** table points at all of it.
- **A basemap doctor for a map that stays blank.** Every failure on the map's one network
path is logged below the default log level, so tiles that never arrive leave a blank
ground and no explanation. [`scripts/basemap-doctor.py`](scripts/README.md), run on the
machine with the problem, reports the Python MeshTerm runs under, its certificate store,
the tile source, and the terminal's colour depth, then fetches a real tile and names the
cause with its fix: a Python with an empty certificate store (a python.org install on
macOS, until `Install Certificates.command` is run), the network in between, or a
terminal at 256 colours, where the basemap's greens and blues collide.
### Changed
- **Purge contacts is Archive contacts**, because archiving is what it does: every contact
it takes off the device stays in MeshTerm and can be restored. The row, its screens, its
confirm button, and its progress bar all say Archive, and the row wears the 💾 that
archiving one contact already wore, not the red 🗑 a deletion gets.
- **The archive preview shows the evidence, one kind to a column.** Its rows led on a
percentile and followed it with the reasons a contact ranked low, three columns under one
header, so a message count, an age, and a hop count traded places from row to row. Each
column now holds one measurement all the way down: `HEARD` and `PKTS` first, drawn as the
Contacts list draws them, then the `MSGS` and `HOPS` the sweep adds. The percentile is
gone, since the list's order already is the ranking. Every contact leads with its node
type icon, as in the Contacts list, and ←→ scrolls the columns under a name that stays
put. Archiving a single contact from its page names it the same way in the confirm.
- **`scripts/` is sorted by handheld.** The PicoCalc's Calculinux bring-up, its two
console-font builds, the GPL-2.0 text that belongs beside the 6×8 one, and the
`xiao-radio/` firmware kit all live under `scripts/picocalc/`; the SPI bridge is
`scripts/uconsole/meshterm-spi-bridge`. The index of what every file does and which
machine runs it is a section of [`docs/hardware.md`](docs/hardware.md) now, beside the
table that says which manual is yours, and the folder's own README keeps only a pointer
to each handheld's folder and the basemap doctor. Every path in the manuals, the notices,
the tests, and the scripts' own headers follows.
- **The 6×12 console-font script carries its size in its name**,
`calculinux-console-font-6x12.sh`, like its 6×8 sibling always did. Same script, every
reference updated.
- A tool that fails says so in an amber ⚠ popup, which wraps a long explanation instead of
opening the full-screen result window for it.
- The About page opens on a new description of what MeshTerm is and what it plugs into.
### Fixed
- **Reading a big contact table no longer times out.** The library under MeshTerm gave a
whole contacts read one five-second deadline, so a companion holding a few hundred
contacts failed every read with "no event received", however healthy the radio. The wait
is now for the gap between records — each record restarts it, and the read takes as long
as the table does — so a timeout means the companion really stopped answering. An error
reply meant for some other command no longer ends the read either.
- **An emoji no longer knocks a row out of line.** Terminals disagree about how wide they
draw an emoji, and a contact's name arrives off the air carrying whatever its owner typed,
so one emoji in a name could shift every column after it, and the screen's right border,
by a cell. Every glyph that may be drawn as an emoji now gets two cells, and each is drawn
at the column MeshTerm measured for it — in lists, in dialogs, and on the screens behind
them — so one drawn narrow leaves a blank beside it rather than moving anything else, and
a repaint no longer leaves a stale letter standing next to it. A name cut to fit its
column is cut between glyphs, never through one, and a newline or an escape in a name no
longer ends its row early. `MESHTERM_COLUMN_SNAP=0` turns the placement off.
- **Hops in near-identical colours stay separate chips.** Where a path is drawn as coloured
chips, two neighbours of the same colour cut a wedge of bare background out of the route,
and two of nearly the same colour were joined by a chevron nobody could see. Both are now
divided by a thin chevron in a shade of the first chip's own colour, so the route runs on
unbroken and the join still shows. "Nearly the same" is measured the way the eye sees
colour, in OKLab, rather than as a hue gap, which judged the greens and the cyans very
differently.
- Walking back up a scrolled list no longer lets the highlight ride off the top of its box.
## [0.3.5] — 2026-09-14
### Added
- A release now rebuilds [meshterm.net](https://meshterm.net) from the tag, so the site
carries the version it links to.
### Changed
- **The purge preview counts in columns.** The target ladder used to describe each rung in a
phrase — "keeps 45 · archives 12" — so comparing two rungs meant finding the numbers inside
two sentences. Keeps and archives are right-aligned columns now under a pinned
`TARGET / KEEPS / ARCHIVES` header, one lane measured across both sections so the numbers
run straight down the ladder; a rung that archives nothing shows a muted 0. The reasons a
contact ranks low sit in three lanes of their own under one header, instead of running on
from wherever the previous reason ended.
- The PayPal link on the Support page, and in the repository's funding file, is PayPal's own
donate address rather than a shortener redirect, so a reader can see where it goes.
### Fixed
- **A name with a skin tone or a cut-off joiner no longer pulls its row out of line.** Two
real contact names showed it: one carries a skin-tone modifier, which one width table
counted as two cells and the terminal draws as none, so the row reserved four cells for a
two-cell glyph; the other is a flag sequence cut at the name's byte limit just past its
joiner, which swallowed the padding space after it and left the name a cell short. A
modifier now measures zero, and a joiner joins only a pictograph. The purge preview, the
Contacts list and the chat picker all pad names through that one measurement.
## [0.3.4] — 2026-09-13
### Fixed
- **The macOS installers build again.** 0.3.3's release never published: both macOS legs
stopped at the new third-party notices step, which refuses to bundle a dependency whose
wheel carries no licence text — and on macOS bleak's Bluetooth backend pulls in two such
wheels, `pyobjc-core` and `pyobjc-framework-libdispatch`. Their MIT text is vendored
beside pyserial's and pywinrt's now, verified against the wheels themselves, and a test
checks every vendored path on every platform so a gap that only one runner can hit is
caught here first. The 0.3.3 tag stands with no release behind it; this is the same
code with its installers.
## [0.3.3] — 2026-09-13
### Added
- **`meshterm --version` answers**, and under `--mock` the `devices` command lists the
simulator as the one device and scans nothing — it used to walk the serial ports and
switch the Bluetooth radio on, on the first command a stranger without a radio would try.
- **[`docs/hardware.md`](docs/hardware.md)** is the one manual for putting MeshTerm on a
PicoCalc or a uConsole: the Calculinux bring-up, the console font, the XIAO UART radio from
parts to a verified link, the SPI bridge, and a closing list of what has only ever been
proven on one bench. The two script READMEs are short indexes pointing at it.
- **The standalone installers carry their own licences now.** Each one-file build
bundles `LICENSE`, `NOTICE` and a generated `THIRD-PARTY-NOTICES.txt` — every
MIT/BSD/PSF dependency's own licence text, gathered at build time from what that
build actually installed — at the root of the archive, and the same three files ride
beside the downloads on the GitHub release page.
- **The map credits OpenStreetMap in its own bottom-right corner.** The full-screen map is
arrived at with `© OpenMapTiles · Data from OpenStreetMap` there — the corner a paper map
puts it in — and the first pan, zoom, reframe or find keystroke collapses it to
`© OpenStreetMap`, which is a complete credit in its own right, so nothing is ever taken
back. It costs no chrome at all: no title atom, no footer character, no body row. On a
desktop terminal it is set into the panel's bottom border rule, right-justified, the way
a title sits in the top one, so the drawing keeps every cell it had; on the PicoCalc,
whose frame has no bottom rule to set anything into, it rides the right end of the map's
own last row, where a pan can move the ground out from under it. The node page's location
preview carries the short form throughout, being a picture rather than a place. The
licence and its URL live where they can be read — the About page, which also now names
**OpenMapTiles**, as OpenFreeMap asks and neither the page nor the README did.
- **Every Python file names its licence with an SPDX identifier** on its first line (second, after a shebang), and `tests/test_spdx.py` keeps a new file from landing without one. Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all.
- **The XIAO firmware patch carries MeshCore's MIT notice beside it** as `scripts/xiao-radio/LICENSE.MeshCore`, and the console-font scripts state that the shared generator block is offered under GPL-2.0-only as well as Apache-2.0, which is what lets the GPL-2.0-only 6×8 script run it.
- **The SPI radio bridge runs on `openhop_core`.** The library the uConsole's software
node runs on was renamed from `pymc_core` in 2026, and the current `meshcore-uconsole`
package depends on the new name. `meshterm-spi-bridge` now drives either runtime,
preferring the newer, and two of its three compatibility shims stand down under it —
openhop_core accepts long ACK payloads and pushes completed trace replies itself; only
the raw RX-log push is still the bridge's. On a bookworm uConsole, where the trixie
package cannot run, the bridge takes a venv of its own with `openhop-core[hardware]`
and still uses the GUI's identity, so it stays the same node. Verified on the device:
radio up, 159 contacts restored, `info` and `contacts` answered over TCP.
- **The PicoCalc picks its console font, and MeshTerm hands the console back.** A *Console
font* row under Display on the Preferences page offers `6x12 (53x26)` — the font the
device boots in — and `6x8 (53x40)`, fourteen more rows of the same glyph inventory in a
shorter cell. Picking one is the preview: the console loads it there and then, and the
page repaints at the new height (the kernel raises SIGWINCH on a font change), so the
choice is looked at before it is kept. Apply keeps it; leaving and discarding puts the
saved font back. The 6×8 base has no Cyrillic, so a node named in Cyrillic draws as
boxes on it.
A virtual terminal has one font for the whole console, so switching MeshTerm's switches
the shell's. It saves whatever font was loaded before the first paint and reloads it on
the way out — from the session's teardown and from an `atexit` hook, so quitting, an
unwind and a crash all leave the console as they found it. Off the handheld, off a real
VT, or with no `setfont`, none of it happens and the log says which. The row is drawn on
the PicoCalc only, while the value round-trips through `preferences.toml` everywhere.
The 6×8 font's build moved into `scripts/calculinux-console-font-6x8.sh`, on its own
because its base bitmap is the Linux kernel's `font_6x8` and therefore GPL-2.0 — marked
as such, with the licence beside it, and reading the donor, alias and keeper tables out
of the 6×12 script rather than keeping a second copy of them. Running the 6×12 script
still builds both.
### Changed
- **Preferences are kept in `preferences.toml`.** The same file as before — only what you
have changed, grouped and commented the way the Preferences page reads — in the format
`config.toml` and the device-config backups already use, so every file you edit by hand
speaks one syntax and MeshTerm no longer needs PyYAML. A `preferences.yaml` from an
earlier build is not read: set those preferences again, or copy the lines across as
`key = value` with text in quotes. A line TOML refuses costs only that line, and a word
left unquoted (`log_level = DEBUG`) is still understood.
- **A trophy case record is a page, not a card floating over the list.** It fills the frame
like the browser under it and takes the node page's shape: a header line that reads as a
sentence — the discipline's mark, its standing and its score — over three tabs. *Info* is
the record's stats, identity first, with the page's actions at its foot; *Route* is the walk
two ways, the route graph and the route line, where Enter traces the path on show; *Area*
is the ground the walk covered, drawn across the whole stage with every pin labelled by its
hash byte so a pin and the graph node under it match by eye. Tab and Shift+Tab switch, F3 on
the PicoCalc. The trophy case itself fills the frame whichever way it is opened, from the
menu or from a trace.
- **A QR code is white on black, and when it is the answer it is the whole screen.** A camera
reads contrast, and light-on-dark is what a scanner expects of a screen, so every code
MeshTerm draws is pure white on pure black on both themes. A channel's share link and a
contact card are no longer a titled popup with an instruction over the code: they are a
bare frame with nothing on it but the code and the URL it encodes, and Esc leaves.
- **A question is a popup, and it is gone before the page it asked for opens.** The trace
target, the repeater admin's node and the TX optimize link used to stay pushed as full
screens under the page they led to, so Esc out of a trace or an admin page landed back on
the list it was picked from — two places where there is one. Each now floats over the main
menu, is popped before its page opens, and Esc from the page lands on the menu. The TX
optimize link is one box that turns its page (`… · step 1 of 2`): Esc on the second step
turns back with the picked node still highlighted. A refused admin login re-asks with the
same node highlighted.
- The three delay settings — TX delay, direct TX delay and RX delay — show, stage and send at
one decimal on both the repeater admin and Device config pages; Device config's RX delay
had no rounding at all.
- The source distribution ships the package, its tests and the manual, not
the repository: the Claude skills, the GitHub config, the PyInstaller spec and the device
scripts are out of it.
### Fixed
- **A share code's bottom-left finder was one cell skewed**, so a camera could not lock on.
Rich's centre justify strips a row's trailing spaces before padding it, so a code row whose
right edge was light modules landed a column off its neighbours. A code is now moved across
a line one way only, the same indent in front of every row.
## [0.3.2] — 2026-09-11
### Added
- **Device config holds every setting the companion firmware has, and the device actions
with them.** Clock sync, backup and restore, the identity key, reboot and factory reset
were a menu entry of their own; they are an Actions section on the Device config page
now, and the page takes the repeater admin page's shape — full-screen, titled with the
node's name and a staged count, with Apply sending in place one value at a time and
leaving a value the device refused staged beside its reason.
The settings were checked against the firmware itself rather than the library that
talks to it. New are client repeat, the auto-add hop limit and a row for each custom
variable the device reports, and the firmware's own bounds apply: a PIN of 0 or six
digits, path-hash modes 0–2, 150–2500 MHz, 7–500 kHz, TX power from −9 dBm.
- **Both editor pages pick a location on the map.** A "Pick location on map…" row sits
above separate latitude and longitude rows, opens the map on the position as staged — or
as the device last reported it — and stages both coordinates at once. Device config's
single Location row and its Pick / Type / Clear dialog are gone.
### Changed
- **Repeater admin speaks the firmware's own CLI, every setting of it.** The catalog had
been written from convention, and the firmware disagreed: the delays are floats (0.5
showed as 0, and typing it back was refused), bandwidth, spreading factor and coding rate
are one `radio` setting rather than three keys, the guest password is readable, and
`path.hash.mode` and twenty-odd others were missing. It is transcribed from the firmware
source now. Read settings leaves every row answered — a value, `empty`, or `n/a` where
the node's firmware lacks the setting — and `^R` (F3 on the PicoCalc) re-reads the
highlighted row alone.
- Repeater admin fills the frame like Device config, instead of floating over the node
picker it was opened from.
- **A packet's relay chain is drawn as the middle of a route.** The `via` field names the
repeaters that forwarded a frame and neither the node that sent it nor the one that
heard it, yet its ribbon opened and closed square — the mark for *the route began and
ended here*. Both ends wear the chevron now, as the TX sweep's composed relays do, and
arrow mode says the same with a leading and a trailing `→`. Under the chain, the note
about which node the reception described gives way to the hop count.
### Fixed
- **Retuning a companion's radio switched off its relaying.** The firmware reads the radio
command's trailing client-repeat byte as *off* when it is missing, and nothing sent it.
Every radio edit restates it now.
- The location picker opens at once. It waited on the radio for the contact list first,
and a busy companion held it shut for twenty-odd seconds only to open with no contacts
anyway; it opens on what is cached now and adds the rest when the radio answers.
- A repeater setting its firmware lacks reads `n/a`, rather than the `?` that says it was
never asked.
- Every icon-led list starts its labels in the same cell. Some icons draw one cell wide and
most emoji two, so a row led by `↻`, `⌨` or `🗑` started its words a column early; each
list pads its icons out to the widest one now.
- Neither editor page slides its rows sideways any more — a long row ends at the edge.
## [0.3.1] — 2026-09-09
### Added
- **Devices you never want to connect to can be hidden from the splash.** A machine with a
debug probe, a programmer and a USB adapter soldered into it listed all three on every
start, in front of the one radio you came for. `h` drops the highlighted device and
remembers it, `⇧H` brings every hidden one back, and both keys are named in the footer
only where they would act.
Hiding is a listing choice and nothing else — a hidden device is still remembered, still
resolvable by `--port`, and shows itself again the moment it is connected to. The
highlight stays where the row was rather than jumping back to the remembered default, so
a run of adapters clears with a run of presses.
### Changed
- **The chat picker's conversation lane is measured against the names the list actually
holds**, instead of a flat 22 cells. Every cell past the longest name was padding in
front of a short one, taken straight out of the last-message preview — the only run on
the row with something to say, and on the PicoCalc's 53 columns it left the message 12
cells. The preview keeps what it saves: 31 cells to 37 on a 72-column terminal, 12 to 18
on the console. What the cap costs is the tail of a long name, so `←→` now slides the
message under the pinned columns to read one past its own edge.
- **Channels shows the wait instead of announcing the result.** Every action banked a
"created X" note that surfaced only once the manager closed — by which time the refreshed
list had already shown the result — and the notes accumulated: at three changes in one
visit the outcome outgrew the acknowledgement popup and came back as the full-frame
result window instead, so the same visit reported itself two different ways depending on
how much had been done in it. Every device action now reports while it runs, under a
modal busy card, and the count stays in the run log.
- Renaming a channel no longer closes the page it renamed. The channel is still there and
that is still its page, so the new name and key are read back into the title and the
rows, and the reader stays put. Only clearing it closes it, which is the one case where
what the page was about is gone.
- `Esc` on the device splash says **bye** rather than quit — the splash is the door, and
nothing has been started there to quit out of.
### Fixed
- **macOS and Linux were drawing the whole app in 256 colours.** prompt_toolkit picks the
colour depth per output class, and its two classes disagree: the Windows one returns
truecolor outright, the VT100 one returns 8-bit for every `TERM` but `linux`. Nothing
ever passed a depth, so the same build drawing the same theme was 24-bit on Windows and
quantized to the 216-colour cube everywhere else — silently, which is why it went
unnoticed. The app looked fine, just flatter.
It costs exactly what this palette is made of. The seven heat steps and the per-node hue
wheel are close pastels chosen to be told apart; snapped to the cube, neighbours collide
and an identity stops being distinguishable by hue. `COLORTERM` is not consulted by
prompt_toolkit at all, so a terminal announcing truecolor the conventional way was still
handed 256.
The resolver only ever raises the verdict. A terminal that cannot be shown to do better
keeps precisely the depth it had, because guessing 24-bit at a terminal without it costs
not a duller palette but the colour entirely. A 16-slot console is never promoted — the
theme addresses its palette by index, and RGB has nowhere to land there.
- **Adding a channel could write straight over one that was already there.** The free-slot
probe stops for two very different reasons and returned the same short list either way:
off the end of the configured slots it is the truth, but a read that simply failed left
whatever it had — and that was cached for the session, an empty one reading exactly like
a device with no channels, a truncated one making the next free slot look free with a
channel sitting in it. The probe now says whether it finished, an unfinished one is
answered but not kept, and every add confirms the slot is empty before anything is
written to it.
- Keys tapped during a slow channel write were landing in the filter of the list
underneath, which then came back showing nothing. The busy card is modal, so the press
dies on the card instead.
- **Channels at 53 columns.** The column header was a hand-built string with no idea of the
render width: it ran to 54 cells and wrapped, costing a content row out of twenty-six and
drawing the pinned landmark twice. The command rows hand-padded their icons, so the
one-cell marks started their labels a column left of the two-cell ones. The empty-message
count was `·`, which in that same row is also the status separator and what the console
folds 🔕 to — a muted channel with no messages drew it twice, two cells apart, meaning
different things; it is `○` now, the app's empty mark. And the detail title was a 42-cell
parenthesised blob that filled the PicoCalc's whole title bar, leaving nowhere to say Esc
leaves.
- Every channel edit paid up to 64 device round-trips before the screen could redraw. The
chat service's slot map was walking the radio itself, unbounded, skipping empty slots all
the way to the 64-slot cap on firmware that never rejects an index — on top of the
manager's own re-read. It builds from the probe that was being made anyway.
- A channel write that landed and then raised never invalidated what it had made stale, so
a slot list and a chat slot map outlived a layout that had already moved. A reorder that
drops partway now says so at once, since the reader is about to be looking straight at
the half-applied layout, and the standard Public channel can no longer be added twice by
a second press already on its way.
- Two of the Trophy case's seven discipline marks started their titles a column left of
their siblings: 🛣 and 🕸 sit outside Emoji_Presentation, so Rich and wcwidth both measure
them at one cell where the other five measure two. The marks now pad out to the widest of
the seven, measured against what the platform will actually draw rather than written
down.
- A route that has finished no longer ends on the chevron that means "the path runs on past
the edge" — it ends square, and the cell that frees up goes back to the route.
- **A fully charged pack redrew the activity sparkline twice a minute.** The charger cuts
out at full, the pack settles back to 99, the charger restarts, and the companion reports
that flip every poll for as long as the thing is plugged in. The gauge is pinned to the
header's right edge and the pulse takes whatever is left, so the fourth cell `100%`
needed came off the sparkline and redrew the whole activity history at a new scale, on a
device sitting still. A full pack reads `100` now, with no sign: three cells, exactly as
the `99%` it keeps flipping back to, and the one reading whose `%` can be inferred.
- On the PicoCalc every channel row in the chat picker sat a cell to the left of every
direct row, with the header over neither — the marker lane padded to a two-cell channel
glyph that the console font draws in one.
- The device splash's own header wrapped at 53 columns, and its DEVICE lane sized itself to
the longest name — which for a USB adapter is a forty-character product string, pushing
HARDWARE off the edge entirely. The name lane yields now, and `←→` reads the rest of a
long model string with the lanes in front of it pinned.
## [0.3.0] — 2026-09-08
The command line was rebuilt around the fact that it has **two readers**, and that trying
to serve both with one stream had been making it worse at each.
### Added
- **`--json`, on every command.** It used to be honoured by two of twenty and silently
ignored by the rest, so `meshterm --json contacts | jq` failed while MeshTerm reported
success. It now covers all 49 subcommands: an array for a listing, an object for a set of
facts, one compact line per document, and no envelope — `contacts --json | jq '.[].node.name'`
reads the way it looks.
Values are typed (`20`, not `"20"`), absent is `null` and never an omitted key,
timestamps are UTC RFC 3339 to the second so string comparison is time comparison, and a
numeric field carries its unit in its key (`snr_db`, `uptime_s`, `rtt_ms`). A key is never
truncated — a short id is a `hash`, because a truncated key cannot go back into `--to`.
`monitor` and `chat listen` emit one document per record as they arrive, all the same
shape.
`--json` changes the rendering and never the report: the same exit status, the same
records. An empty result prints its empty document **and still exits `5`**.
- **A documented exit status per outcome**, listed under every `--help`: `0` success, `1`
failure, `2` usage error, `3` no device, `4` the device was reached but the operation
failed, `5` nothing to report.
`5` is the one worth knowing about. A command that ran fine and found nothing — an empty
contact list, a trace that never came home — returns it, so a script can tell "found
nothing" from "worked" without counting lines.
- **`--absolute`**, restoring ISO-8601 timestamps for a run, over a new `cli_time_format`
preference. JSON is always absolute UTC regardless: a local offset is a fact about the
machine that ran the command, not about the event.
- **A global option may be typed anywhere.** `meshterm contacts --json` and
`meshterm --json contacts` are the same run. The first is what everyone types and it used
to fail with a bare usage error.
- **`contacts` gains `HASH` and `LOCATION`.** The hash is the token `--path` takes; you used
to slice it out of `KEY` by hand. `preferences show` gains `DESCRIPTION` back, and `info`
glosses its second-valued readings (`uptime_s 93784 (1d 2h)`).
- **[`docs/cli.md`](docs/cli.md)**, a manual for the whole thing: both faces of every
command, where MeshTerm keeps its state, what each failure looks like, and recipes. Every
sample in it is captured from a real run rather than written.
### Changed
- **The plain face is for a person now, and says so.** It had grown the menu's manners —
colour, boxes, section headings, glyph columns, a closing ✓ — and then briefly overcorrected
into something only `awk` could love. Machine-readability is `--json`'s job, so the plain
face is free to be read: no colour and no frames, still, but **alignment is the delimiter**
and names are bare rather than `"quoted"`.
- **A time is an age** — `now`, `5m`, `3h`, `never` — because "recently?" is the question you
typed the command to ask. An absolute instant survives where the instant *is* the fact: the
device clock, an appointment, a live capture's own `TIME`.
- **A route is drawn and a path is typed.** A route reads
`Yagi-Repeater (a1) → Alice (d4) → MockCompanion (00)`; a `--path` spec stays the
comma-joined hex it has to be, because it is the one line that round-trips. Our own node is
a hop like any other — the menu's `★` says "you already know who this is", which is true of
the reader and false of whoever opens the file later.
- **A value read out of `show` can be typed back into `set`.** The settings dump printed an
enum as `0 (off)` and an unset string as `(not set)`, neither of which `config set` would
take back.
- **`ui.ack` and a tool's closing message go to stderr** rather than being dropped. stderr
keeps the promise the dropping was made to keep — a redirect catches only the answer —
while giving the person at the prompt back their ✓ and their count.
- **`--help` is Click's own plain help**, with no boxed panels and no markup to strip out of
a pipe.
### Fixed
- **A node name could not be printed safely.** A node broadcasts its own name, and Rich read
`[...]` in one as console markup: a node called `[bold]Loud` printed as `Loud` — silently
no longer the string that identifies it — and one called `[/]Bob` raised `MarkupError` and
took the command down, then took the error handler down with it while reporting the crash.
- **A control character in a name reached stdout.** Escaping covered the newline, the tab and
the return and let **ESC** through, which put a live colour run into the caller's file — the
one thing "no escape sequences" exists to prevent. BEL was dropped in silence, so the
printed name was not the advertised one, and U+2028 ended the record like a newline. It is
now every C0 and C1 control plus the Unicode separators.
- **Numeric columns never right-aligned.** Rich's `Text.wrap` returns early on
`overflow="ignore"`, above the justify step, so asking for one threw the other away — while
the standard, the code's own docstring and the manual all promised alignment.
- **`config show` printed an empty string as `""`**, which `config set` read as those two
characters. Feeding a dump back replaced every empty setting with a pair of quote marks, and
the next dump looked identical, so nothing ever said so. It also masked an **unreported**
PIN behind bullets, telling the reader the radio was PIN-locked when it was not.
- **`meshterm about` drew the rule above its colophon at console width** — one 48KB line in a
4KB page.
- **`config export-key > key.hex`** wrote a file with `[muted]` tags around the key that was
supposed to be its whole content.
- **Failures were classified wrong in six places.** Nothing transmitted is not a device
failure: an unopenable `--port` is `3` rather than `4` with a message about a connection
there had never been, and a missing `--yes`, an unknown `--sort`/`--category`/`--profile`,
a missing admin password, an unknown contact and a bad `--at` are all usage errors. A
`--category` typo used to come back as `5` — indistinguishable from a real empty result —
and an unknown `--profile` fell through to whichever radio was attached, which is the one
wrong answer that puts a packet on the air.
- **Errors go to stderr**, in the `meshterm: what went wrong` shape, and stop folding at 80
columns — a sentence broken across three lines is a sentence `grep` cannot find. Progress
bars moved there too and draw nothing off a terminal.
- **An expected failure no longer dumps a traceback.** A lost serial link, or an argument a
command rejected on its own, reads as one line and the right exit status.
### Removed
- **`meshterm map`.** A map is a picture — braille cells whose meaning is their position, and
whose nodes are told apart by colour. Stripped of colour it would be unreadable; left
coloured it was the one command whose output could not be piped anywhere useful. The map
stays in the menu, and the located nodes are scriptable through `contacts`, which now
carries their coordinates.
- **The QR codes** from `channels share`, `config share` and the About pages' scripted face.
A QR is a second rendering of a link the output already prints, drawn for a phone pointed at
a screen; redirected into a file it was a block of block characters wrapped around the one
thing that was actually the answer. The menu still draws them.
## [0.2.8] — 2026-09-07
### Fixed
- **The window MeshTerm opened for itself was too small, and it showed.** The startup
wordmark is 71 columns wide; below that the splash quietly swaps to the narrow one drawn
for the PicoCalc — so a desktop session came up wearing the handheld's mark, with every
menu description truncated onto the pager underneath it.
MeshTerm was opening a *tab* in whatever Windows Terminal window happened to be around,
inheriting its size (64 columns on the machine this was found on). It now asks for a
window of its own, sized from the platform's own minimum plus a margin. Windows Terminal
clamps that to what the display can show, so a small screen degrades exactly as before.
- The tab it opens is titled **MeshTerm**, not the executable's full path — which on a
downloaded build was a line of Downloads folder.
## [0.2.7] — 2026-09-07
### Changed
- **MeshTerm moves to Windows Terminal rather than asking to.** It still says so, and
still tells you how to stop it, but it doesn't put the question. The classic console
can't draw a single icon whatever font it's given, so there was only one sensible
answer — and it was being asked of someone who hadn't seen the app yet and had nothing
to judge it by.
Installing a **font** still asks, because that writes a file to your machine. Moving
window doesn't. Preferences → Display → Console setup stops both.
## [0.2.6] — 2026-09-07
### Fixed
- **Accepting "reopen in Windows Terminal" killed the session** in the downloadable
builds. A new window opened, printed `Security validation failure: parent process has
different executable!`, and exited — while the window that made the offer had already
closed. Worse than never offering.
A one-file build unpacks itself and re-runs itself, the two halves coordinating through
private environment variables. Those were being handed to the new copy, which concluded
it was the second half of a launch it never made, checked that its parent was the same
program, found Windows Terminal instead, and stopped. They are stripped now.
It worked perfectly when run from source, which is the whole lesson: the packaged build
is its own platform.
## [0.2.5] — 2026-09-07
### Fixed
- **Every icon was a box in the classic Windows console.** That console — which is what a
double-clicked build still gets — draws no emoji at all, whatever font it is set to, so
the menu came up as rows of replacement characters.
MeshTerm now works out which console it is running in and, on that one, draws its icons
through the compact single-character table it already keeps for the PicoCalc: `☼` for an
advert, `¶` for a message, `★` for the trophy case. Nothing else about the desktop
layout changes — same width, same colours, same accented names. Running in Windows
Terminal or VS Code's terminal keeps the emoji, and `MESHTERM_EMOJI=1` or `=0` forces
the matter either way. `meshterm platform` reports which it chose and why.
### Added
- **MeshTerm offers to reopen itself in Windows Terminal.** The classic Windows console —
what you get from a double-click — can't show the emoji icons at all, whatever font it
is given: the only two Windows fonts with emoji in them are proportional, and a console
won't take one. A terminal is the only thing that fixes it, so MeshTerm now offers to
move there. One keypress, nothing installed, nothing changed — and you get the app as
it was drawn: icons, charts, path chips and all.
- **MeshTerm can install a font its charts will actually draw in** — for anyone who stays
in the classic console. That console does no font fallback at all, and none of the fonts
it offers can draw braille, which every timeline and activity graph in MeshTerm is made
of. Neither can Hack Nerd Font, JetBrains Mono, Fira Code, Source Code Pro, or even
DejaVu Sans Mono; the usual advice works only because most terminals quietly fall back
to a proportional font.
So MeshTerm now ships Cascadia Mono PL — Microsoft's console font, SIL OFL — and on
that console offers to use it. If you already have Cascadia (every Windows 11 machine,
and anyone with Windows Terminal) it just switches to it. If not, it offers to install
it for you: no administrator rights, nothing downloaded, 723 KB. Saying no explains
what saying no looks like and asks once more; a second no is remembered and never
raised again. Preferences → Display → Console setup changes your mind about either
offer.
## [0.2.4] — 2026-09-07
### Fixed
- **MeshTerm crashed on startup on Windows.** Building the screen raised
`ctypes.ArgumentError: expected LP__CSBI instance instead of pointer to
CONSOLE_SCREEN_BUFFER_INFO`, and on a double-clicked build the traceback and the window
it was printed in were destroyed together.
`ctypes.windll.kernel32` is a cache: every caller in a process gets the same object, and
each function on it is a shared attribute. The emoji-width probe declared
`GetConsoleScreenBufferInfo` as taking a pointer to *its* copy of the screen-buffer
struct — which rewrote the signature for prompt_toolkit, which then called that same
function with a pointer to *its* copy. Same layout, different class; ctypes refused.
Nothing in MeshTerm reaches for `ctypes.windll` any more. Five call sites now take
private handles from `core/win32dll.py`, so a signature declared for one purpose is
invisible to everything else in the process.
### Changed
- The log-detail preference offers the plain level names — Error, Warning, Info, Debug —
rather than descriptions of them. Someone being talked through a problem is told "set it
to debug", not "set it to everything".
## [0.2.3] — 2026-09-07
### Changed
- The log is a plain text file, `~/.meshterm/meshterm.log`, one readable line per record
with the traceback under the line that raised it. It was JSON Lines on the theory that
something would replay it; nothing ever did, and the job it actually has is being opened
by a person who has hit a problem.
- It rotates, at 2 MB across three files. It used to grow without limit — a real one
reached 11 MB in ten weeks, which is a lot of disk on a handheld and an unreasonable
thing to attach to an issue.
- **How much it keeps is a preference** — `log_level`, under a new Diagnostics group,
defaulting to `WARNING`. The file now holds the problems rather than a narration of a
working session: of the last twenty thousand records in a real log, twelve were a
warning or an error.
### Added
- One interactive MeshTerm per data directory. A second one is turned away with the way
out in the message, rather than quietly overwriting the first one's contacts and
settings. The lock is held by the operating system, so a crash releases it — a process
id in a file would leave a lock nobody could explain.
- Failures are written down. Every unhandled error reaches the log with its traceback and
the terminal says where to find it; expected errors go in as warnings, because a log
that only holds crashes cannot answer "what happened just before". The bug report form
asks for the file.
- The window stays open after a crash when MeshTerm owns it. Double-clicking the
executable on Windows gives the process its own console, which is destroyed the instant
the process ends — so an error was drawn and erased together and all anyone saw was a
flash. It now waits for a keypress, but only on that path: a run from a shell leaves its
output on screen already, and a successful run should not make anyone press a key.
- A readable answer when there is no Windows console. Git Bash, MSYS and Cygwin are not
Windows consoles, and prompt_toolkit's own error says so in the language of its
internals, arriving as a traceback that reads like a broken app rather than the
instruction it is.
### Fixed
- All ten stores write through one helper, and the file each writes to first is named
for the process writing it. They shared a fixed `.tmp` name, so two copies of MeshTerm
saving at the same moment took turns inside one scratch file and then each renamed
whatever was in it over the real one. The rename was always atomic; what was being
renamed was not.
## [0.2.2] — 2026-09-07
### Fixed
- **The downloadable builds were missing every tool.** They started, printed help and drew
the specimen card, and had a menu with nothing in it but Quit — no contacts, no map, no
trace, no chat. Tools are found at runtime with `pkgutil.iter_modules`, which finds
nothing inside a frozen bundle, so none of them registered. The packaging now collects
them, and the smoke tests run a tool-provided subcommand instead of only `--help`, which
a gutted build passes perfectly well.
### Added
- `MESHTERM_HOME` points config and data somewhere other than `~/.meshterm`. Every copy of
MeshTerm shared one directory, so trying a downloaded build meant letting it open the
same history database as the one you use — the valuable half of an install, with no way
to keep a second copy away from it. Also useful for a portable install, or two radios
kept apart.
## [0.2.1] — 2026-09-07
### Fixed
- The formatter is pinned. `ruff>=0.4` meant CI installed a newer ruff than a laptop had,
and the two disagreed about whether the tree was clean — 0.16 formats Python blocks
inside Markdown and 0.15 doesn't, so the first CI run went red on a file that was fine
locally. An unpinned formatter isn't a gate.
- `docs/` is out of ruff's reach. The survey documents quote code as it looked on a past
date, in fragments that were never whole programs; a formatter rewriting a quotation
edits the record.
- The installer builds only run for `main`. Every Dependabot branch that bumped an action
touched the workflow file, matched its path filter, and started a five-platform build.
### Changed
- The distribution is named `mesh-term`. PyPI prohibits `meshterm` (pypi/support#12162),
and the name you install is the only thing that changes: the import package and the
command are both still `meshterm`.
- `pyproject.toml` carries the metadata a stranger sees on a package page — project URLs,
trove classifiers, and the licence as a PEP 639 expression.
### Added
- Continuous integration. Lint once, tests across seven OS and Python combinations, and a
packaging job that installs the built wheel into a clean environment and runs the command
from outside the source tree.
- A release workflow with no automatic trigger, which publishes nothing until a human runs
it on purpose and types the version.
- A security policy, a code of conduct, issue and pull request templates, weekly
dependency updates, and the funding links the app's own support page already offers.
- A Releases page. Pushing a version tag builds the binaries and attaches them, with a
combined `SHA256SUMS` and release notes taken from this file. It stays inside GitHub
and is not the PyPI publish, which remains a separate and deliberate act.
- Standalone builds. A PyInstaller spec and a workflow that builds a single runnable
file for Windows, macOS (Intel and Apple silicon) and Linux (x64 and ARM64), checks
each one runs from outside the source tree, and writes a checksum beside it. Nothing
is published; the files are workflow artifacts until someone attaches them to a
release.
## [0.2.0] — 2026-09-07
The first version to carry a changelog. Everything before it is in the commit log and is
deliberately not reconstructed here.
### Added
- This file.
### Changed
- The project description now says what MeshTerm is, how it reaches a radio, and which
systems it runs on, instead of naming a category. It is the PyPI summary and the package
docstring, which no longer disagree.
- The version is written in exactly one place, `meshterm/__init__.py`. The distribution
metadata reads it from there rather than repeating it, so the number on the splash screen
and the number in a built wheel cannot drift apart.
- The basemap user agent identifies the real repository and reports the running version.
It previously sent a bare `https://github.com/` and a hardcoded `0.1` to OpenFreeMap,
which is the only handle a tile operator has on a client.
- `macOS` and `Bluetooth` are spelled one way across the project. `BLE` stays where it is
an identifier — the transport name, the config keys, the CLI.
- `meshterm --help` opens with the same description as the package summary and the
PyPI page, instead of a fourth wording of its own.
### Fixed
- The three commands `CONTRIBUTING.md` asks contributors to run all pass:
`python -m pytest -q`, `ruff check .`, and `ruff format --check .`. The lint gate had been
reporting 2222 errors and the format gate 154 files, so a first contributor could not tell
their own diff from the background.
- Two `TYPE_CHECKING` imports the test suite referenced but never imported, on paths that
happened never to run.
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.6...HEAD
[0.3.6]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.6
[0.3.5]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.5
[0.3.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.4
[0.3.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.3
[0.3.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.2
[0.3.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.1
[0.3.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.0
[0.2.8]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.8
[0.2.7]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.7
[0.2.6]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.6
[0.2.5]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.5
[0.2.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.4
[0.2.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.3
[0.2.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.2
[0.2.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.1
[0.2.0]: https://github.com/jpmartineau/MeshTerm/tree/v0.2.0
## [0.9.0] — 2026-09-22
**The first public release.** Everything in MeshTerm is new today, so instead of a list of
changes, here is what it does.
MeshTerm is a program you run in a terminal to work with a **MeshCore** radio — one of the
small LoRa boards people use to build long-range mesh networks. A mesh like that needs no
internet, no phone signal, and nobody in the middle: the radios pass messages along to each
other until they arrive. You plug one into your computer, start MeshTerm, and it shows you
what the mesh is doing.
### What you can do with it
- **Run it two ways, and it behaves the same either way.** Type `meshterm` on its own and
you get a full-screen menu you drive with the arrow keys. Type a command instead —
`meshterm contacts`, `meshterm info` — and it prints an answer and exits, which is what you
want in a script. Add `--json` to nearly any command and the answer comes back as data
for another program to read.
- **It listens all the time, and writes down what it hears.** Radios on a mesh announce
themselves. From the moment yours is connected, MeshTerm notes down every announcement it
overhears: which radio it was, how strong the signal was, and where it said it was. All of
it is saved on your own computer. That is why the lists and charts can show you not only
what is happening now, but what was happening last Tuesday.
- **You can see where everyone is.** There is a street map, drawn in the terminal, with the
radios on it. You can pan it, zoom it, and search it. There is also a Time Machine: charts of
everything you have ever overheard, so you can see when a radio usually talks and how
well you have heard it, over a day, a week, a month or all of it.
- **You can talk to people.** Group channels and one-to-one messages, both live. If someone
is out of range right now, the courier holds your message and delivers it when they come
back. Channels can be shared as a QR code someone else scans, or as a link.
- **You can measure the mesh, not just guess at it.** Trace a message's route and see which
hop is the weak one. Sweep your transmit power from coarse to fine to find the lowest
setting that still gets through. Walk a map of how the whole mesh actually hangs together,
which is often not how you thought it did.
- **It talks to your radio however your radio talks.** USB cable, Bluetooth, or over the
network. It finds the ones it can find on its own and asks you to pick. If a connection
drops it reconnects by itself. No radio yet? `meshterm --mock` runs the whole program
against a simulated mesh, and records what it hears in a history database of its own,
apart from your real one.
- **It runs on the small machines too.** The PicoCalc gets a layout of its own, built
for its 53-column screen, with its own fonts, colours and on-screen key labels. The
uConsole has room for the full desktop layout, and runs that.
- **It works with no internet.** Map tiles are kept on disk once fetched, and when there is
no network at all the map falls back to a plain grid. Nothing about MeshTerm needs to
phone home.
- **When something goes wrong, it can describe itself.** `meshterm diagnostics`, or the
Diagnostics page in the menu, puts everything a bug report opens with in one block: which
build this is and how it was installed, the operating system, the terminal and how big it
is, and whether the radio answered. It names nothing private — no keys, no passwords, no
positions, no contacts — and describes your mesh as counts rather than names. One keypress
saves it to a file you can attach to an issue.
### Getting it
One file to download, with nothing else to install, for Windows, macOS (both Intel and
Apple silicon) and Linux (including 64-bit ARM, which covers the uConsole). If you would
rather use Python, `pipx install` works too, on Python 3.10 to 3.14. The README has the
exact commands.
MeshTerm is free and open source under the Apache 2.0 licence. The name and the logo are
not covered by the licence (see `NOTICE`), so a fork is welcome under its own name.
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.9.0...HEAD
[0.9.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.9.0
+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.
+126 -296
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,76 +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.6-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.6-*
./meshterm-0.3.6-*
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-macos-arm64
chmod +x meshterm
./meshterm
```
### A word about terminals, on Windows
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.
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 — the same three lines
`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"*. Clear it with
> `xattr -d com.apple.quarantine meshterm-0.3.6-*` and run it again.
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
>
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
[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.6-* # macOS, Linux
> MESHTERM_HOME=~/meshterm-test ./meshterm # macOS, Linux
> ```
> ```powershell
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.6-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.
@@ -169,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. |
@@ -207,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
@@ -441,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:
@@ -470,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
@@ -509,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.
@@ -520,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 |
@@ -549,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.6","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.6"
__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.
+81 -1
View File
@@ -145,6 +145,16 @@ _BLE_CONNECT_ATTEMPTS = 2
#: Pause between BLE link-open attempts (seconds), giving the OS radio a beat to settle.
_BLE_CONNECT_RETRY_DELAY_S = 1.0
#: Attempts, and the wait between them, while macOS finishes a pairing it has just begun.
#: An unbonded subscribe to the companion's authenticated characteristic is what *triggers*
#: Passkey Entry there, so the very failure we catch is the signal that the OS has raised
#: its dialog — and the person now has to read a 6-digit code off the device and type it.
#: Giving up at once meant a correct PIN produced an error and only the *second* attempt
#: worked, the first having quietly done the bonding. Sized to cover a human typing a code
#: rather than a radio settling, and bounded so a cancelled dialog still fails in the end.
_BLE_MACOS_PAIRING_ATTEMPTS = 5
_BLE_MACOS_PAIRING_DELAY_S = 6.0
TX_POWER_MIN = 1
TX_POWER_MAX = 22
@@ -1224,8 +1234,55 @@ class MeshCoreDevice(Device):
# Clear it, pair with the PIN, and retry the connect exactly once before giving up.
if allow_repair and await self._pair_ble_windows(force=True):
return await self._open_ble(mesh_core, allow_repair=False)
# On macOS the same rejection means the opposite thing: it is not the end of a
# pairing attempt but the *start* of one, because touching the authenticated
# characteristic is the only way to make CoreBluetooth pair at all. The OS is
# putting its Passkey dialog up as we unwind. Wait for the person to answer it.
if allow_repair and sys.platform == "darwin":
return await self._open_ble_after_macos_pairing(mesh_core, exc)
raise DeviceAuthenticationError(self._ble_auth_message()) from exc
async def _open_ble_after_macos_pairing(self, mesh_core, cause: BaseException): # type: ignore[no-untyped-def]
"""Re-open the link while macOS is running the Passkey dialog it just raised.
CoreBluetooth exposes no pairing API — Apple's model is that a peripheral pairs
*implicitly* when something touches a characteristic that requires encryption. The
companion firmware puts its UART characteristic at ENC+MITM precisely so that
happens (``SECMODE_ENC_WITH_MITM`` on nRF52, ``ESP_GATT_PERM_*_ENC_MITM`` on
ESP32), so the subscribe fails with "Insufficient Authentication" *and* that
failure is what makes macOS ask for the code. The GATT operation is already dead by
then; the bond it started arrives seconds later, once a human has typed six digits.
Failing there reported an error for a pairing that was in fact succeeding, and the
device connected on the next attempt — the first having silently done the work. So
retry rather than give up, for long enough to cover the typing.
Args:
mesh_core: The imported ``meshcore.MeshCore`` class.
cause: The authentication failure that opened the dialog, chained onto the
final error if the pairing never completes.
Returns:
The connected ``MeshCore`` client, or ``None`` if a later attempt opened the
transport but the peripheral never answered the identity handshake.
Raises:
DeviceAuthenticationError: If every attempt was still refused — the dialog was
dismissed, or the code entered was wrong.
"""
for attempt in range(_BLE_MACOS_PAIRING_ATTEMPTS):
await asyncio.sleep(_BLE_MACOS_PAIRING_DELAY_S)
try:
return await self._open_ble(mesh_core, allow_repair=False)
except DeviceAuthenticationError:
_log.debug(
"BLE bond with %s not established yet (attempt %d/%d)",
self._address,
attempt + 1,
_BLE_MACOS_PAIRING_ATTEMPTS,
)
raise DeviceAuthenticationError(self._ble_auth_message()) from cause
async def _create_ble_with_retry(self, mesh_core): # type: ignore[no-untyped-def]
"""Open the owned BLE client, retrying the transport-level failures that are transient.
@@ -1305,7 +1362,20 @@ class MeshCoreDevice(Device):
"""
from meshcore import BLEConnection
connection = BLEConnection(address=self._address, device=self._ble_device, pin=self._pin)
# Withhold the PIN on macOS. Handed one, ``BLEConnection.connect`` calls bleak's
# ``client.pair()``, and CoreBluetooth has no pairing API at all — the macOS
# backend raises ``NotImplementedError`` outright — whereupon the library
# disconnects and re-raises, so supplying a *correct* PIN is what breaks the
# connection. Apple's model is that pairing is the OS's to run, not ours: the
# companion firmware puts its UART characteristic at ENC+MITM
# (``SECMODE_ENC_WITH_MITM`` on nRF52, ``ESP_GATT_PERM_*_ENC_MITM`` on ESP32), so
# the unbonded subscribe below is answered with "Insufficient Authentication",
# and macOS reacts by running Passkey Entry and prompting for the code itself.
# Saying nothing here is therefore what *lets* a PIN-protected companion bond;
# the OS keeps the bond, and later connections need no PIN. Windows and Linux do
# expose explicit pairing, and keep it (see :meth:`_pair_ble_windows`).
pin = None if sys.platform == "darwin" else self._pin
connection = BLEConnection(address=self._address, device=self._ble_device, pin=pin)
mc = mesh_core(
connection,
default_timeout=self._connect_timeout,
@@ -1632,6 +1702,16 @@ class MeshCoreDevice(Device):
before an authenticated characteristic can be subscribed.
"""
where = self._address or "the selected Bluetooth device"
if sys.platform == "darwin":
# macOS collects the code itself, in its own dialog, so --ble-pin is not the
# remedy here and naming it would send the reader somewhere that cannot help.
return (
f"{where} was not paired. macOS asks for the pairing code in its own dialog "
"rather than through MeshTerm — enter the 6-digit code shown on the device "
"(or in the MeshCore app) when it appears, and the bond is remembered for "
"next time. If no dialog appeared, check that Bluetooth is allowed for this "
"terminal in System Settings > Privacy & Security > Bluetooth."
)
if self._pin:
return (
f"{where} rejected the Bluetooth PIN — it needs pairing and the PIN provided "
+39
View File
@@ -53,6 +53,32 @@ BLE_NAME_PREFIX = "MeshCore"
#: advertisement, short enough not to stall the startup splash noticeably.
BLE_SCAN_TIMEOUT_S = 5.0
try: # bleak >= 3 only; the floor in pyproject is 0.22, so this must not be assumed
from bleak.exc import BleakBluetoothNotAvailableError as _BleakNotAvailable
_BLE_NOT_AVAILABLE: tuple[type[BaseException], ...] = (_BleakNotAvailable,)
except Exception: # noqa: BLE001 - older bleak, or bleak absent entirely
# An empty tuple in ``except`` matches nothing, so older installs fall straight through
# to the broad handler and behave exactly as they did before.
_BLE_NOT_AVAILABLE = ()
#: Why the last BLE scan could not run, when the cause is something the reader can act on
#: (Bluetooth off; on macOS, permission not granted to the terminal running us). ``None``
#: when the scan worked — including when it simply heard nothing, which is not a fault.
_ble_unavailable: str | None = None
def ble_unavailable_reason() -> str | None:
"""Why the last BLE scan was refused, if it was, as a sentence fit to show the reader.
Returns:
The reason the most recent :func:`discover_ble_devices` could not scan, or ``None``
if it ran — whether or not it found anything. Reset at the start of every scan, so
it always describes the latest attempt rather than a stale one.
"""
return _ble_unavailable
#: Default seconds to watch for one *known* companion's advertisement while waiting for it to
#: come back (see :func:`find_ble_device`). Deliberately longer than the startup scan: nothing
#: is waiting on it, the watch ends the instant the device is heard, and a companion that just
@@ -393,6 +419,9 @@ async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[Disc
Returns:
Discovered BLE companions (deduplicated by address), sorted by name.
"""
global _ble_unavailable
_ble_unavailable = None
try:
from bleak import BleakScanner
except ImportError: # bleak not installed → BLE simply unavailable
@@ -400,6 +429,16 @@ async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[Disc
try:
found = await BleakScanner.discover(timeout=timeout, return_adv=True)
except _BLE_NOT_AVAILABLE as exc:
# A *state the reader can fix* — Bluetooth switched off, or (on macOS, routinely)
# the terminal running us has not been granted Bluetooth. That is not the "no
# adapter / driver hiccup" the broad catch below is for, and returning [] silently
# made it indistinguishable from "nothing is nearby": the one message that would
# have told the user what to do was thrown away at debug level, under a log that
# defaults to WARNING. Keep the sentence bleak wrote — it already names the remedy.
_ble_unavailable = str(getattr(exc, "args", [None])[0] or exc)
_log.warning("BLE unavailable: %s", _ble_unavailable)
return []
except Exception as exc: # noqa: BLE001 - no adapter / OS Bluetooth off / driver hiccup
_log.debug("BLE scan unavailable: %s", exc)
return []
+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
+144
View File
@@ -28,6 +28,13 @@ three of its shapes only make sense against that source:
``af`` both read and write ``airtime_factor``, so writing one stales the other
(:attr:`RemoteSetting.overlaps`).
The catalog is not the whole page, though: it is what *any* MeshCore build might expose,
and a node can have settings beyond it (a third-party firmware's own knobs). Those are never
listed here and never probed — they are learned one node at a time from commands the reader
actually ran (:func:`parse_setting_command`, :func:`discovered_setting`), which is why
:attr:`RemoteSetting.discovered` exists and why :func:`learnable_from_get` has to care that
``get`` matches keys by prefix and ``set`` does not.
Replies are parsed *loosely* on purpose: repeater firmware answers tersely and has changed
its phrasing across versions (``"> 20"``, ``"tx: 20"``, ``"OK - repeat is now ON"``), so
values are extracted rather than pattern-matched. Firmware without a given key answers
@@ -118,6 +125,9 @@ class RemoteSetting:
part: This setting's position within its composite's comma-joined value.
overlaps: Keys that are another spelling of the same firmware value, whose cached
reading a write to this one makes stale.
discovered: Whether this setting is not in the catalog at all, but was learned from
a command the reader ran on *one node's* command line (see
:func:`discovered_setting`). Never true of a catalog entry.
"""
key: str
@@ -139,6 +149,7 @@ class RemoteSetting:
composite: str = ""
part: int = 0
overlaps: tuple[str, ...] = ()
discovered: bool = False
@property
def get_command(self) -> str:
@@ -671,6 +682,134 @@ def composite_members(composite: str) -> list[RemoteSetting]:
return sorted((s for s in REPEATER_SETTINGS if s.composite == composite), key=lambda s: s.part)
#: The category a discovered setting sorts under, and the heading its section takes.
DISCOVERED_CATEGORY = "Extra"
#: The description lane's text for a discovered setting. The catalog has no explanation to
#: give — all it knows is that this node answered the key — so the row says exactly that, and
#: says it short: the lane is shared with descriptions written to fit a 72-column screen.
DISCOVERED_HELP = "Found on this node"
#: Keys whose value is never written to disk, however it was asked for. ``prv.key`` is the
#: node's own identity: the firmware answers it on the serial console only (``sender_timestamp
#: == 0`` in ``handleGetCmd``), it is a fact rather than a setting, and a reply that reached
#: us anyway must not be the first place a repeater's private key lands in a file. The
#: command line still runs the command and still prints the answer; only the remembering
#: stops here.
NEVER_STORED = frozenset({"prv.key"})
@dataclass(frozen=True, slots=True)
class SettingCommand:
"""A remote-CLI command recognised as a read or a write of one named setting.
Attributes:
verb: ``"get"`` or ``"set"``.
key: The setting's key, as the reader spelled it.
value: What a ``set`` writes; ``""`` for a ``get``.
"""
verb: str
key: str
value: str = ""
def parse_setting_command(command: str) -> SettingCommand | None:
"""Read one typed CLI command as a setting read/write, or ``None`` if it is neither.
The grammar is the filter, and deliberately so: ``get <key>`` and ``set <key> <value>``
are the firmware's own way of saying *this is a setting*, so recognising them costs no
list of what to exclude. Every top-level verb — ``reboot``, ``advert``, ``clock sync``,
``start ota``, ``powersaving``, ``setperm`` — simply isn't this shape and falls out.
A key is held to the spelling the firmware's own keys use, so a mistyped line can't
invent one.
"""
parts = command.strip().split()
if len(parts) < 2 or parts[0] not in ("get", "set"):
return None
key = parts[1]
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9._]*", key):
return None
if parts[0] == "get":
return SettingCommand("get", key) if len(parts) == 2 else None
value = command.strip()[len("set") :].strip()[len(key) :].strip()
return SettingCommand("set", key, value) if value else None
def get_match_keys() -> list[str]:
"""Every spelling a firmware ``get`` matches on, longest first.
A composite's members share their composite's key (``get radio``); verb-shaped settings
aren't ``get`` keys at all.
"""
keys = {s.composite or s.key for s in REPEATER_SETTINGS if s.readable and not s.verb}
return sorted(keys, key=len, reverse=True)
def shadowing_key(key: str) -> str | None:
"""The catalog ``get`` key that would answer a ``get key`` on firmware lacking ``key``.
``handleGetCmd`` compares with ``memcmp(config, "radio", 5)`` — no trailing space — so a
key it doesn't know falls into the branch of any *shorter* key that prefixes it, and
answers with that one's value. ``get radio.rxps`` on stock firmware comes back
``> 910.525,62.5,7,5``: not an error, and not a value of the key that was asked for.
``handleSetCmd`` matches ``"radio "`` *with* the space, so a write has no such shadow.
"""
return next((k for k in get_match_keys() if key != k and key.startswith(k)), None)
def learnable_from_get(key: str, reply: str, known: Mapping[str, str]) -> bool:
"""Whether a ``get key`` reply really came from ``key`` and not from a shorter sibling.
Only asked of a key the catalog doesn't have, where there is no typing to reject a
wrong-shaped answer with. The reply is trusted when nothing shadows the key
(:func:`shadowing_key`), or when it cannot be the shadow's answer — it doesn't parse as
the shadow's type, or it parses to something other than what the shadow last said. A
shadow whose own value was never read leaves the question unanswerable, and an
unanswerable question is not a discovery.
Args:
key: The key the reader asked for.
reply: What the node answered.
known: The node's last-read values, keyed as the cache keys them.
"""
shadow = shadowing_key(key)
if shadow is None:
return True
members = composite_members(shadow)
if not members:
spec = get_setting(shadow)
members = [spec] if spec is not None else []
if not members: # pragma: no cover - every get key is a catalog key
return True
parsed = {m.key: parse_reply_value(m, reply) for m in members}
if any(value is None for value in parsed.values()):
return True # the reply isn't even shaped like the shadow's answer
if any(known.get(k) is None for k in parsed):
return False # it could be the shadow's answer, and there is nothing to compare
return any(known[k] != value for k, value in parsed.items())
def discovered_setting(key: str) -> RemoteSetting:
"""A catalog entry for a key the catalog hasn't got, which one node said it has.
Everything downstream of the catalog — the read plan, the reply parser, the editor's
lanes, staging, :func:`write_plan` — speaks :class:`RemoteSetting`, so a discovered key
becomes one rather than growing a second path beside it. What it carries is only what
was actually learned: the key as the node spells it, and a string value. No range, no
words, no options — the firmware never said, and guessing is how a row would start
lying about a node whose build we have never seen.
"""
return RemoteSetting(
key=key,
label=key,
help=DISCOVERED_HELP,
category=DISCOVERED_CATEGORY,
kind="str",
discovered=True,
)
def reply_is_error(reply: str) -> bool:
"""Whether a reply text reads as the firmware refusing the command.
@@ -884,6 +1023,11 @@ def write_plan(pending: Mapping[str, str], known: Mapping[str, str]) -> list[Wri
else:
joined = ",".join(str(values[m.key]) for m in members)
writes.append(Write(f"set {spec.composite} {joined}", dict(values))) # type: ignore[arg-type]
# A staged key the catalog hasn't got is a discovered one (nothing else can stage a
# row), and it is written the only way anything knows how: its own plain `set`.
for key, value in pending.items():
if get_setting(key) is None:
writes.append(Write(f"set {key} {value}", {key: value}))
return writes
+22 -2
View File
@@ -35,11 +35,16 @@ class CachedValue:
supported: ``False`` when the node answered the read with an error — its firmware
has no such setting (a board without a front-end module, a build without a
bridge). Kept apart from *never read*, which is no entry at all.
discovered: ``True`` for a setting the catalog hasn't got, learned from a command
the reader ran on *this node's* command line. Such a row reads ``n/a`` like any
other when the node stops answering it, and is removed only by hand: its key is
recorded nowhere else, so a misread reply must not be able to delete it.
"""
value: str
read_at: datetime | None
supported: bool = True
discovered: bool = False
class RemoteStore:
@@ -84,7 +89,10 @@ class RemoteStore:
except ValueError:
read_at = None
out[key] = CachedValue(
value=str(entry.get("value", "")), read_at=read_at, supported=supported
value=str(entry.get("value", "")),
read_at=read_at,
supported=supported,
discovered=bool(entry.get("discovered", False)),
)
return out
@@ -92,6 +100,10 @@ class RemoteStore:
"""Cache one setting's value for ``node``, stamped now."""
self._put(node, key, {"value": value, "read_at": utcnow().isoformat()})
def remember_discovered(self, node: Contact, key: str, value: str) -> None:
"""Cache a setting the catalog hasn't got, which ``node`` has just proved it has."""
self._put(node, key, {"value": value, "read_at": utcnow().isoformat(), "discovered": True})
def remember_unsupported(self, node: Contact, key: str) -> None:
"""Record that ``node`` answered a read of ``key`` with an error, stamped now."""
self._put(node, key, {"unsupported": True, "read_at": utcnow().isoformat()})
@@ -104,10 +116,18 @@ class RemoteStore:
self._write(records)
def _put(self, node: Contact, key: str, entry: dict) -> None:
"""Store one setting's cache entry for ``node``."""
"""Store one setting's cache entry for ``node``, keeping it discovered if it was.
A discovered row is refreshed by the same reads and writes as any other — through
:meth:`remember_setting` — and that must not quietly demote it to a catalog row it
has no entry for, which would leave a row nothing draws.
"""
records = self._load_all()
record = records.setdefault(admin_key(node), {})
settings = record.setdefault("settings", {})
previous = settings.get(key)
if isinstance(previous, dict) and previous.get("discovered"):
entry = {**entry, "discovered": True}
settings[key] = entry
self._write(records)
+39 -4
View File
@@ -112,9 +112,17 @@ def resolve_device(
3. ``explicit_port`` (an explicit ``--port``).
4. A TCP profile's ``host:port``, or a serial profile's ``port``.
5. The remembered "last known good" device, if it is currently attached/in range.
6. The single attached device, if exactly one is present.
6. The single *likely-LoRa* device, if exactly one is present — a port whose USB
vendor marks it a board or bridge, or a BLE/TCP endpoint. Ports that look like
nothing in particular are ignored at this step, because a platform may present
some unconditionally (every Mac carries two) and they would otherwise make the
count ambiguous forever.
7. The single attached device, if exactly one is present and *none* looked likely —
so an unrecognized-but-real adapter still resolves.
Otherwise a :class:`DeviceSelectionError` is raised listing the candidates.
Otherwise a :class:`DeviceSelectionError` is raised listing the candidates: the
likely ones where there are any, else everything attached under a message that does
not claim they are companions.
Args:
devices: Currently discovered devices (serial and/or BLE).
@@ -155,7 +163,23 @@ def resolve_device(
if match is not None:
return Resolution(match.target, match, "remembered", match.transport)
if len(devices) == 1:
# Prefer the devices that actually look like companions. macOS is why this matters:
# every Mac permanently presents /dev/cu.Bluetooth-Incoming-Port and
# /dev/cu.debug-console, which carry no USB VID/PID and so score "unknown". Counting
# them put `len(devices) == 1` out of reach there — a Mac with exactly one real board
# attached saw three devices and refused to choose, so auto-detection could never fire
# on that platform and every Mac user met "Multiple companion devices detected" on a
# first run. Which device is plausible is already known, and already printed in the
# listing below as "[likely LoRa]"; this decides with it instead of only saying it.
likely = [d for d in devices if d.is_likely_lora]
if len(likely) == 1:
return Resolution(likely[0].target, likely[0], "only", likely[0].transport)
# Nothing recognizable: fall back to the whole list rather than narrowing to nothing,
# so a lone *unrecognized* adapter — a UART bridge carrying a VID we don't list, which
# is a real board more often than not — still connects exactly as it always has.
if not likely and len(devices) == 1:
return Resolution(devices[0].target, devices[0], "only", devices[0].transport)
if not devices:
@@ -164,9 +188,20 @@ def resolve_device(
"or pass --port / --ble explicitly, or use --mock for the simulator."
)
if not likely:
# Several ports attached, none of them plausible. "Multiple companion devices" is
# simply false here: on a bare Mac it names two virtual ports that are not
# companions at all, and then tells the reader to choose one of them.
raise DeviceSelectionError(
"No companion devices detected among the attached ports.\n"
f"{_format_device_list(devices)}\n"
"Connect a companion device (USB or Bluetooth), or pass --port / --ble "
"explicitly if one of these is your companion, or use --mock for the simulator."
)
raise DeviceSelectionError(
"Multiple companion devices detected and no default to fall back on.\n"
f"{_format_device_list(devices)}\n"
f"{_format_device_list(likely)}\n"
"Choose one with --port <PORT> or --ble <ADDRESS> (or run 'meshterm devices' to "
"inspect them). The chosen device is remembered as the default after it connects."
)
+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.
+56 -1
View File
@@ -44,12 +44,14 @@ from __future__ import annotations
import json
import logging
import os
import ssl
import threading
import time
import urllib.error
import urllib.request
from collections import OrderedDict
from collections.abc import Container
from functools import lru_cache
from pathlib import Path
from typing import NamedTuple
@@ -109,6 +111,59 @@ _RESOLVE_RETRY_SECONDS = 30.0
_log = logging.getLogger(__name__)
#: Where the operating system keeps its CA bundle, most specific first. Only consulted when
#: the default context came up empty (see :func:`_tls_context`) — macOS and Alpine put it at
#: ``/etc/ssl/cert.pem``, Debian and Ubuntu at ``ca-certificates.crt``, Fedora and RHEL under
#: ``/etc/pki``, openSUSE at ``ca-bundle.pem``. Files rather than a bundled copy on purpose:
#: these are the trust decisions the *machine's administrator* has made, kept current by the
#: OS, and shipping our own would quietly freeze a snapshot of them into every release.
_CA_BUNDLES = (
"/etc/ssl/cert.pem",
"/etc/ssl/certs/ca-certificates.crt",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/ca-bundle.pem",
)
@lru_cache(maxsize=1)
def _tls_context() -> ssl.SSLContext | None:
"""The TLS context for tile fetches, with a CA bundle found if the default has none.
A frozen build can have no trust store to consult. Python's default context asks
OpenSSL for one, and on macOS and Linux that is a *filesystem path baked into the
interpreter's own build* — which need not exist on a machine that never installed that
Python, which is every machine a PyInstaller bundle lands on. Every tile fetch then
fails with ``CERTIFICATE_VERIFY_FAILED``; and because a refused fetch is
indistinguishable from empty terrain, the map drew no ground under the nodes and said
nothing about why. Windows is the exception that hid it for so long: its default
context reads the OS certificate store, so the binary there always worked.
So: keep the default when it actually loaded certificates, and otherwise point it at
the bundle the OS ships (:data:`_CA_BUNDLES`). Verification is never weakened — a map
tile is not worth teaching the app to skip certificate checks, and an unverified
fetcher here would be one import away from being reused somewhere it matters.
Returns:
A context to pass to ``urlopen``, or ``None`` to accept urllib's default — which
is right whenever that default already works, and is never worse than the failure
it replaces.
"""
try:
context = ssl.create_default_context()
if context.get_ca_certs():
return None # the platform store answered; leave urllib exactly as it was
for path in _CA_BUNDLES:
if os.path.isfile(path):
context.load_verify_locations(cafile=path)
_log.debug("no default CA store; verifying against %s", path)
return context
_log.debug("no CA bundle found in %s — TLS will likely fail", ", ".join(_CA_BUNDLES))
return None
except Exception as exc: # noqa: BLE001 - a broken store must not take the map with it
_log.debug("could not build a TLS context, using urllib's default: %s", exc)
return None
class _Response(NamedTuple):
"""One HTTP GET's outcome: whether the server answered, and what it said.
@@ -478,7 +533,7 @@ class BasemapSource:
"""
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
with urllib.request.urlopen(req, timeout=self._timeout, context=_tls_context()) as resp:
body = resp.read()
declared = (resp.headers.get("Content-Length") or "").strip()
# A flaky link can end a read early without raising. A body short of its
+19 -1
View File
@@ -18,7 +18,13 @@ import typer
from ..context import AppContext
from ..core import exitcodes
from ..core.discovery import TRANSPORT_MOCK, DiscoveredDevice, discover_all, discover_devices
from ..core.discovery import (
TRANSPORT_MOCK,
DiscoveredDevice,
ble_unavailable_reason,
discover_all,
discover_devices,
)
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
@@ -73,6 +79,18 @@ class DevicesTool(Tool):
active = ctx.selected_device
active_target = ctx.ble_override or ctx.port_override or (active.target if active else None)
# A refused Bluetooth scan is not the same as a quiet one, and the difference is
# invisible in the listing — both simply lack BLE rows. Say so whether or not
# anything else was found: a serial board being present does not make a companion
# the reader expected over Bluetooth any less missing.
blocked = ble_unavailable_reason()
if blocked:
from ..ui import script
script.stderr_console().print(
f"meshterm: Bluetooth not scanned — {blocked}", style="warn", highlight=False
)
if not devices:
# Not an error: the scan ran and found nothing. Said on stderr so a caller
# redirecting stdout still hears it, and reported as NO_RESULT so a script can
+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,
+169 -32
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,
@@ -153,6 +156,36 @@ def _where(device: DiscoveredDevice) -> str:
return device.target
def _fit_target(text: str, width: int) -> str:
"""Fit a connection target into ``width`` cells, keeping its **end**.
Deliberately not :func:`~meshterm.ui.menus.fit_cells`, which keeps the head: what tells
two targets apart is their *tail* — ``usbmodem1101`` from ``usbmodem1102``,
``ttyUSB0`` from ``ttyUSB1`` — while the head they share (``/dev/cu.``) is boilerplate.
Cutting the front is the cut that leaves the column able to do its one job in a picker,
and cutting the back would render two different ports identically. A CoreBluetooth UUID
is equally identifiable from either end, so the rule that is right for ports costs the
platform that needed the cap nothing.
Args:
text: The target to fit.
width: The cells available. Text already inside it is returned unchanged, so the
platforms whose targets already fit the lane are untouched.
Returns:
The target, or its tail behind a leading ``…``, measuring at most ``width`` cells.
"""
if cell_len(text) <= width:
return text
keep = max(0, width - 1) # the leading ellipsis costs a cell
tail = ""
for ch in reversed(text):
if cell_len(tail) + cell_len(ch) > keep:
break
tail = ch + tail
return "…" + tail
def _hardware_label(device: DiscoveredDevice, registry: dict[str, RememberedDevice]) -> str:
"""The HARDWARE column text: the remembered firmware model, else the USB vendor.
@@ -268,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],
@@ -297,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
@@ -323,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.
@@ -332,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.
@@ -670,34 +773,66 @@ async def _remove_network_device(
#: Below this a model string says nothing at all, and the lane is better spent on the name.
_HARDWARE_MIN = 10
#: The narrowest the PORT / ADDRESS lane is worth shrinking to before DEVICE has to give.
#: This is the lane that adapts: a target is how you tell two similar rows apart, and ten
#: cells still does that (the tail of a UUID, the end of a port path), while a *name* is
#: what the reader actually came to read. macOS is why any of this is needed —
#: CoreBluetooth reports no MAC at all but a per-machine 36-cell UUID, and even its ports
#: run long (every Mac carries a 31-cell ``/dev/cu.Bluetooth-Incoming-Port``) — where a MAC
#: is 17 and ``/dev/ttyUSB0`` is 12, so no other platform ever presses on the row at all.
_ADDRESS_MIN = 10
def _name_width(
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice], other_lanes: int
) -> int:
"""The DEVICE lane's width: the longest name, capped so HARDWARE keeps a readable column.
A USB adapter's own product string runs to forty-odd characters ("CP2102 USB to UART
Bridge Controller"), and the lane used to size itself to whichever of those was longest.
On a 53-column console that pushed every other lane off the right-hand edge — the row
ended mid-port, and the HARDWARE column that says what the thing *is* was not on screen
at all, nor reachable by scrolling, because the pinned head was already wider than the
box. So the name is the lane that gives: it ellipsizes, and the columns after it survive.
def _lane_widths(
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice]
) -> tuple[int, int]:
"""The DEVICE and PORT / ADDRESS widths: the name in full, the address taking the rest.
The row has a fixed budget, and something has to give when the two lanes together
outrun it. **The address gives first.** A name is what the reader came to read and what
they recognise their own radio by; an address is a disambiguator, and a disambiguator
does its whole job from its last ten cells (:func:`_fit_target` keeps the tail for
exactly this reason). So DEVICE is sized to the longest name it has, and ADDRESS takes
whatever is left over, down to :data:`_ADDRESS_MIN`.
Only once the address is down to that floor does the name start ellipsizing, and it
still must: a USB adapter's own product string runs to forty-odd characters ("CP2102
USB to UART Bridge Controller"), and a lane sized to *that* pushed everything after it
off the right-hand edge — the row ended mid-port and the HARDWARE column that says what
the thing actually *is* was not on screen, nor reachable by scrolling, because the
pinned head was already wider than the box. HARDWARE keeps :data:`_HARDWARE_MIN` out of
the budget so it always has a readable stub; it is the row's scrolling tail, so beyond
that stub it is free to run long.
Args:
devices: The devices being listed.
registry: Confirmed companions, for their remembered names.
other_lanes: Cells the PORT and TYPE lanes take between them.
Returns:
The name lane's width in cells.
``(name_w, port_w)`` in cells.
"""
platform = get_platform()
# What the splash's box gives a row: the terminal, less the gutter it floats over, less
# its own border and padding, less the pointer and star columns the row leads with.
content = platform.readable_cols - platform.dialog_margin - 4 - 4
fixed = other_lanes + 6 # the three two-cell gaps between the four lanes
longest = max(cell_len(_display_name(d, registry)) for d in devices)
return max(len("DEVICE"), min(longest, content - fixed - _HARDWARE_MIN))
# ...less the three two-cell gaps, the TYPE badge, and HARDWARE's readable stub.
budget = content - 6 - len("TYPE") - _HARDWARE_MIN
name_natural = max(cell_len(_display_name(d, registry)) for d in devices)
port_natural = max(cell_len(_where(d)) for d in devices)
# Never reserve more room for the address than it actually wants: a lone "COM3" should
# hand its slack to the name rather than sit in a ten-cell lane holding four cells.
floor = min(port_natural, _ADDRESS_MIN)
name_w = max(len("DEVICE"), min(name_natural, budget - floor))
port_w = max(floor, min(port_natural, budget - name_w))
if name_w < name_natural and port_w < port_natural:
# The name is being ellipsized whatever happens, so spending two more of its cells
# to keep the address whole trades one cut for none rather than adding a second.
port_w = min(port_natural, budget - len("DEVICE"))
name_w = max(len("DEVICE"), budget - port_w)
return name_w, port_w
def _order(
@@ -765,12 +900,13 @@ def _build_items(
port_label = (
"PORT / ADDRESS" if has_serial and has_address else "ADDRESS" if has_address else "PORT"
)
port_w = max(cell_len(_where(d)) for d in devices)
port_w = max(port_w, len(port_label))
# The TYPE column holds a small transport badge (at most 3 cells); its heading is wider,
# so the four-cell "TYPE" label sets the column width and every badge pads out to it.
type_w = len("TYPE")
name_w = _name_width(devices, registry, port_w + type_w)
# DEVICE in full, ADDRESS taking what is left (see _lane_widths). The heading does not
# set a floor here the way it used to: the lane may end up narrower than "PORT / ADDRESS"
# spells, and column_header already carries the short form to fall back on.
name_w, port_w = _lane_widths(devices, registry)
hardware_w = max(cell_len(_hardware_label(d, registry)) for d in devices)
hardware_w = max(hardware_w, len("HARDWARE"))
@@ -806,7 +942,7 @@ def _build_items(
style="device.known" if is_known else "",
)
row.append(" ")
row.append(_pad(_where(device), port_w), style="muted")
row.append(_pad(_fit_target(_where(device), port_w), port_w), style="muted")
row.append(" ")
# The TYPE badge marks the transport: a plug emoji for serial, or the Bluetooth rune
# on its blue badge for BLE. It's built with its own colours, then the column is
@@ -816,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
@@ -840,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)
+58 -15
View File
@@ -158,16 +158,29 @@ def _rewrap(line: str, width: int) -> list[str]:
def _state_intensity(row: str) -> str:
"""Rewrite the row's colour changes so every one says whether it is bright.
"""Rewrite the row's colour changes so brightness is stated as a colour, not as bold.
An art editor spells brightness the DOS way — ``1m`` lifts the bank and each span after
it inherits that — which reads correctly only while the escapes stay one unbroken
stream. They don't here: a row is handed to Rich on its own, and the console draws bold
*as* the bright bank, so a span that never mentions intensity is read against whatever
happened to be set. Saying it outright changes no colour; it just stops one drifting.
An art editor spells brightness the DOS way — ``1m`` lifts the foreground into the
bright bank, and each span after it inherits that. Two things then have to be settled
before the row can be drawn anywhere else.
The first is that the inheritance does not survive: a row is handed to Rich on its own,
so a span that never mentions intensity is read against whatever the *previous row* left
set. That is resolved by working the state out here and saying it outright.
The second is that **bold is not brightness everywhere**. It is on the PicoCalc console
and on a DOS one; it is not on macOS Terminal, where bold asks for a heavier face and
leaves the colour alone. There ``1;30`` is not dark grey but plain black — and this art
leans on it, seventy-nine spans of the wide mark being bold-black, much of that on a
black ground. The mark came out muted, and the ``░▒▓`` dithers that fade one colour into
another faded toward the wrong end. So brightness is emitted as the colour it means:
``9N`` (the aixterm bright bank), which needs no bold and is also how the PicoCalc's own
console spells bright. Nothing about *which* colour is being asked for changes; only the
spelling, from one a terminal may read as a font weight to one that can only be a colour.
"""
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)
@@ -177,16 +190,46 @@ def _state_intensity(row: str) -> str:
continue
params = found.group()[2:-1]
parts = params.split(";") if params else [""]
for part in parts: # what this sequence leaves the intensity set to
if part in ("", "0", "22"):
bright = False
elif part == "1":
bright = True
dim = any(_DIM_FG.fullmatch(p) for p in parts)
if dim and not any(p in _SAYS_INTENSITY for p in parts):
out.append("\x1b[" + ";".join(["1" if bright else "22"] + parts) + "m")
# What this sequence leaves the intensity set to, for the spans that follow it.
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:
# 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:
out.append(found.group())
standing = fg
rewritten = []
for part in parts:
if part == "1":
continue # brightness is in the colour now
if part == fg:
rewritten.append(str(int(fg) + 60) if here else fg)
else:
rewritten.append(part)
if not here and "22" not in rewritten:
rewritten.insert(0, "22") # say dim outright; never inherit a bank
out.append("\x1b[" + ";".join(rewritten) + "m")
at = found.end()
return "".join(out)
+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,
+15 -4
View File
@@ -13,8 +13,9 @@ The input line behaves like a shell:
:class:`~meshterm.core.remote_store.RemoteStore`) so last week's incantation is one
keystroke away next session.
* **Tab** completes the current text against the known repeater commands — the fixed
verbs plus every ``get``/``set`` spelling in the settings catalog — and the best
match previews muted, ghost-text style, after the cursor.
verbs, every ``get``/``set`` spelling in the settings catalog, and any key *this node*
taught us here on an earlier visit — and the best match previews muted, ghost-text
style, after the cursor.
* **Enter** sends. The transcript keeps every exchange of the session, newest at the
bottom, and the view follows the prompt as it grows.
@@ -25,7 +26,7 @@ the radio, the history store, and the reply plumbing) and feeds replies back thr
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Iterable
from rich.console import Group
from rich.text import Text
@@ -70,6 +71,7 @@ class RemoteCliScreen(Screen):
history: list[str],
send: Callable[[str], None],
session: object,
extra_keys: Iterable[str] = (),
) -> None:
"""Create the command line over a node's persisted history.
@@ -78,6 +80,9 @@ class RemoteCliScreen(Screen):
history: The node's prior commands, oldest first (recalled with ↑).
send: Commits one command (owner-guarded; no-op while one is flying).
session: The running TUI session (for repaints).
extra_keys: Settings *this node* has that the catalog hasn't, found here on an
earlier visit. They complete like any other key: the prompt is where they
were learned, so it is where they should be one Tab away.
"""
super().__init__()
self.title = f"Command line — {node_label}"
@@ -89,7 +94,13 @@ class RemoteCliScreen(Screen):
self._history = list(history)
self._recall: int | None = None # index into history while ↑/↓ browse it
self._draft = "" # what was typed before recall began, restored on ↓ past the end
self._completions = known_commands()
self._completions = sorted(
{
*known_commands(),
*(f"get {key}" for key in extra_keys),
*(f"set {key} " for key in extra_keys),
}
)
self.busy = False
self._spinner = Spinner()
self._prompt_line = 0 # body index of the input line (pinned into view)
+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]:
+244 -16
View File
@@ -21,6 +21,16 @@ Beyond the settings, the action rows cover the box itself — advert, clock sync
admin password, reboot, each behind its own floating confirmation — and the **Command
line** opens the readline-style remote CLI (:mod:`meshterm.ui.remote_cli`) for anything
the catalog doesn't spell.
That command line is also where the page *grows*. Third-party builds carry settings the
catalog has never heard of, and MeshTerm can't know which build a node is running without
asking it — every question being one paced round trip, and every guessed row a dead ``n/a``
on everyone else's page. So nothing is probed speculatively: a ``get``/``set`` the reader
ran here, which *this node* answered, earns a row on *this node's* page under **Extra**
(:func:`learn_from_cli`). The round trip was one the reader was spending anyway, the catalog
doesn't grow, and no other node's page changes. A row is only ever removed by hand
(``Del``): a key that stops answering reads ``n/a`` like any other, because a misread reply
would otherwise delete the one thing about this node nobody else can restore.
"""
from __future__ import annotations
@@ -36,12 +46,18 @@ from rich.text import Text
from ..core.models import Contact
from ..core.remote_config import (
DISCOVERED_CATEGORY,
NEVER_STORED,
REPEATER_SETTINGS,
RemoteSetting,
composite_members,
composite_reads,
discovered_setting,
get_setting,
learnable_from_get,
normalize_value,
parse_reply_value,
parse_setting_command,
range_hint,
read_plan,
reply_is_error,
@@ -54,6 +70,7 @@ from .menus import (
PICK_LOCATION_LABEL,
confirm_discard,
exit_rows,
fit_cells,
icon_lane,
lane_header,
lane_row,
@@ -63,7 +80,7 @@ from .menus import (
)
from .trace_screen import TracingDialog
from .tui import Choice, Separator
from .tui.select import SelectScreen, splice_hint
from .tui.select import DeleteRequest, SelectScreen, splice_hint
from .tui.spinner import Spinner, spinner_interval
if TYPE_CHECKING:
@@ -88,6 +105,50 @@ _CANCEL = "__cancel__"
#: The footer atom naming the chord that re-reads the highlighted setting.
READ_ONE_HINT = "^R read"
#: The footer atom naming the key that drops a discovered row (surfaced on those rows only).
FORGET_HINT = "Del forget"
#: The narrowest the value lane may be squeezed to for a discovered setting — a floor, so a
#: page whose catalog rows are all ``?`` still shows something of what the node answered.
_EXTRA_VALUE_MIN = 14
def _extra_value(text: str, width: int) -> str:
"""One discovered value, fitted for the lane: whitespace collapsed, a long reply cut.
A discovered setting's value is whatever the node said, and a build that answers a whole
diagnostic line (``desired=off effective=off supported=yes …``) would otherwise set the
value lane's width — one width, for every row on the page — and push each setting's
description off the edge. The row is cut here instead of the page being reshaped around
it; the command line is where the whole reply is read.
"""
flat = " ".join(text.split())
return fit_cells(flat, width) if cell_len(flat) > width else flat
def _spec_for(key: str, cache: dict) -> RemoteSetting | None:
"""The setting ``key`` names on this node: the catalog's, or its own discovered one."""
spec = get_setting(key)
if spec is not None:
return spec
cached = cache.get(key)
return discovered_setting(key) if cached is not None and cached.discovered else None
def _discovered_specs(cache: dict) -> list[RemoteSetting]:
"""This node's discovered settings, in key order — rows the catalog knows nothing about."""
return [discovered_setting(key) for key, cached in sorted(cache.items()) if cached.discovered]
def _all_specs(cache: dict) -> list[RemoteSetting]:
"""Every setting this node's page draws: the catalog, plus what it taught us itself."""
return [*REPEATER_SETTINGS, *_discovered_specs(cache)]
def _extra_keys(cache: dict) -> frozenset[str]:
"""The keys on this node's page that came from the node rather than from the catalog."""
return frozenset(key for key, cached in cache.items() if cached.discovered)
@dataclass(frozen=True, slots=True)
class ReadOne:
@@ -121,6 +182,9 @@ class AdminMenu(SelectScreen):
floating = False
#: The keys on this node's page that the catalog hasn't got, refreshed with the rows.
extra_keys: frozenset[str] = frozenset()
def __init__(self, title: str, items: list, **kwargs: Any) -> None:
"""Build the page's list, its rows ending at the edge rather than sliding under ←→.
@@ -134,14 +198,28 @@ class AdminMenu(SelectScreen):
kwargs.setdefault("hscroll", False)
super().__init__(title, items, **kwargs)
def _readable_key(self) -> str | None:
"""The highlighted row's setting key, when it is a setting the node can be asked."""
def _row_key(self) -> str | None:
"""The highlighted row's setting key, or ``None`` on an action row."""
current = self._current_choice()
if current is None or not isinstance(current.value, str):
return None
spec = get_setting(current.value)
return current.value
def _readable_key(self) -> str | None:
"""The highlighted row's setting key, when it is a setting the node can be asked."""
key = self._row_key()
if key is None:
return None
if key in self.extra_keys:
return key # a discovered row is a string setting, and always re-readable
spec = get_setting(key)
return spec.key if spec is not None and spec.readable else None
def _forgettable_key(self) -> str | None:
"""The highlighted row's key, when it is a discovered row Del can drop."""
key = self._row_key()
return key if key is not None and key in self.extra_keys else None
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The list's own hint, plus the read atom while a readable setting is highlighted."""
@@ -150,16 +228,26 @@ class AdminMenu(SelectScreen):
@property
def fkey_lane(self):
"""The list's lane with ``Read`` on F3 — dim on a row there is nothing to read.
"""The list's lane with ``Read`` on F3 and ``Forget`` behind it — dim where inert.
F1/F2 are this grouped list's section jumps and F4/F5 the pager; F3 is the slot a
select list spends on its own verb. Dim rather than empty on an action row: reading
is a thing on this screen, just not for the row the cursor is on.
select list spends on its own verb. Read and Forget are the same slot's two halves
because they are the same subject — *this row* — and they are never both live: a
catalog row can be read and not forgotten, and only a discovered row can be dropped.
Dim rather than empty on an action row: both are things on this screen, just not for
the row the cursor is on.
"""
from .tui.fkeys import FPair
lane = list(super().fkey_lane)
lane[2] = FPair("Read", "retry", enabled=self._readable_key() is not None)
lane[2] = FPair(
"Read",
"retry",
opp_label="Forget",
opp_action="delete",
enabled=self._readable_key() is not None,
opp_enabled=self._forgettable_key() is not None,
)
return lane
def handle(self, action: str, data: str = "") -> None:
@@ -292,7 +380,9 @@ async def _admin_session(ctx: AppContext, device: Device, node: Contact) -> dict
title,
items,
footer_hint="↑↓ move · type to filter · Enter select · Esc back",
delete_hint=FORGET_HINT,
)
menu.extra_keys = _extra_keys(cache)
async with session.stay(menu) as visit:
while True:
choice = await visit.result()
@@ -304,13 +394,17 @@ async def _admin_session(ctx: AppContext, device: Device, node: Contact) -> dict
continue
return {"node": node.name, "applied": applied}
if isinstance(choice, ReadOne):
spec = get_setting(choice.key)
spec = _spec_for(choice.key, cache)
if spec is not None:
await read_settings(ctx, device, node, [spec])
elif isinstance(choice, DeleteRequest):
await _forget_discovered(ctx, node, str(choice.value), pending)
elif choice == _APPLY:
applied += await _apply(ctx, device, node, pending)
elif choice == _READ:
await read_settings(ctx, device, node, REPEATER_SETTINGS)
# This node's page, not the catalog: a discovered row is refreshed by the
# same sweep as everything else, and reads n/a if it stops answering.
await read_settings(ctx, device, node, _all_specs(cache))
elif choice == _LOCATION:
await _stage_location(ctx, cache, pending)
elif choice == _CLI:
@@ -349,7 +443,7 @@ async def _admin_session(ctx: AppContext, device: Device, node: Contact) -> dict
danger=True,
)
else: # a setting key
spec = get_setting(str(choice))
spec = _spec_for(str(choice), cache)
if spec is not None and not spec.writable:
# A read-only fact has nothing to stage; asking again is all Enter can do.
await read_settings(ctx, device, node, [spec])
@@ -359,6 +453,7 @@ async def _admin_session(ctx: AppContext, device: Device, node: Contact) -> dict
# the values are the ones the action just produced.
cache = ctx.remote_store.settings(node)
title, items = _menu_items(node, cache, pending)
menu.extra_keys = _extra_keys(cache)
menu.replace_items(items, title=title)
@@ -383,6 +478,25 @@ def _menu_items(node: Contact, cache: dict, pending: dict[str, str]) -> tuple[st
rows.append((spec.label, _value_text(spec, cache, pending), spec.help, spec.key))
sections.append((category, rows))
# What this node taught us itself, last and in its own section — absent entirely on a
# node that has taught us nothing, which is every node until someone asks it something.
# Its values are fitted to the lane the catalog's rows already need: the value lane is
# one width for the whole page, so an unbounded reply here would push every setting's
# description right, off the edge of a 72-column screen, and reshape a page the reader
# opened to read the catalog.
extra = _discovered_specs(cache)
if extra:
budget = max(
_EXTRA_VALUE_MIN,
max((cell_len(value.plain) for _, rows in sections for _, value, _, _ in rows)),
)
sections.append(
(
DISCOVERED_CATEGORY,
[(s.label, _value_text(s, cache, pending, budget), s.help, s.key) for s in extra],
)
)
label_w = max(cell_len(label) for _, rows in sections for label, _, _, _ in rows)
value_w = max(cell_len(value.plain) for _, rows in sections for _, value, _, _ in rows)
@@ -394,7 +508,13 @@ def _menu_items(node: Contact, cache: dict, pending: dict[str, str]) -> tuple[st
items.append(section_heading(category))
for label, value, help_text, key in rows:
items.append(
Choice(title=lane_row(label, value, help_text, label_w, value_w), value=key)
Choice(
title=lane_row(label, value, help_text, label_w, value_w),
value=key,
# Only a discovered row can be dropped: a catalog row that goes away
# would come straight back on the next paint, the catalog still naming it.
deletable=category == DISCOVERED_CATEGORY,
)
)
# ↻ and ⌨ are one cell where 📡 🕒 🔐 🔄 are two, so the column is measured once and
@@ -424,7 +544,9 @@ def _menu_items(node: Contact, cache: dict, pending: dict[str, str]) -> tuple[st
return title, items
def _value_text(spec: RemoteSetting, cache: dict, pending: dict[str, str]) -> Text:
def _value_text(
spec: RemoteSetting, cache: dict, pending: dict[str, str], width: int = _EXTRA_VALUE_MIN
) -> Text:
"""One setting's VALUE lane: what the node last said, and any staged arrow.
Four states, each its own word: the value; ``empty`` for a string the node holds blank;
@@ -441,10 +563,14 @@ def _value_text(spec: RemoteSetting, cache: dict, pending: dict[str, str]) -> Te
value = Text("n/a", style="muted")
elif cached.value == "":
value = Text("empty", style="muted")
elif spec.discovered:
value = Text(_extra_value(cached.value, width))
else:
value = Text(spec.display(cached.value))
if spec.key in pending:
value.append(f" → {spec.display(pending[spec.key])}", style="warn")
staged = pending[spec.key]
shown = _extra_value(staged, width) if spec.discovered else spec.display(staged)
value.append(f" → {shown}", style="warn")
return value
@@ -453,7 +579,7 @@ def _value_text(spec: RemoteSetting, cache: dict, pending: dict[str, str]) -> Te
async def _stage_setting(ctx: AppContext, key: str, cache: dict, pending: dict[str, str]) -> None:
"""Prompt for one setting's new value and stage it (nothing is sent yet)."""
spec = get_setting(key)
spec = _spec_for(key, cache)
if spec is None or not spec.writable: # pragma: no cover - menu offers only real keys
return
cached = cache.get(key)
@@ -508,6 +634,38 @@ async def _stage_setting(ctx: AppContext, key: str, cache: dict, pending: dict[s
pending[key] = value
async def _forget_discovered(
ctx: AppContext, node: Contact, key: str, pending: dict[str, str]
) -> None:
"""Drop one discovered row from this node's page, behind a confirm.
This is the *only* way a discovered row is removed. Letting a read drop one — a key the
node no longer answers is real enough, after a board swap onto a reused identity — would
mean a single misread reply silently deleting the row: a truncated line, a node answering
mid-reboot, a stray frame correlated to the wrong command. A catalog row costs an ``n/a``
when that happens and nothing is lost, because the catalog still names the key; a
discovered row's key is known only here, and the reader may not remember what it was. So
the same ``n/a`` is all a read may do, and removing is a decision.
It is a red confirm like any other single-record delete, though nothing on the node
changes: what goes is what MeshTerm remembers, and asking the key again brings it back.
"""
from .tui import CANCEL
choice = await ctx.ui.dialog(
f"Stop showing {key} on {node.name}? Nothing on the node changes — "
"ask it for the setting again and the row comes back.",
[("Cancel", CANCEL), ("Forget", "forget")],
title="Forget setting",
default=1,
destructive=True,
)
if choice != "forget":
return
pending.pop(key, None)
ctx.remote_store.forget_setting(node, key)
async def _stage_location(ctx: AppContext, cache: dict, pending: dict[str, str]) -> None:
"""Pick the node's advertised location on the map and stage both coordinates it sets.
@@ -598,8 +756,11 @@ async def _apply(ctx: AppContext, device: Device, node: Contact, pending: dict[s
remember_reply(ctx, node, fills, reply)
writes = write_plan(pending, _known_values(ctx, node))
# The catalog plus this node's own discovered rows: a staged key that only this node
# has is still a row with a label to name in the outcome, and a write to count.
specs = _all_specs(ctx.remote_store.settings(node))
for i, write in enumerate(writes, start=1):
staged = [s for s in REPEATER_SETTINGS if s.key in write.values and s.key in pending]
staged = [s for s in specs if s.key in write.values and s.key in pending]
names = ", ".join(f"{s.label} = {s.display(write.values[s.key])}" for s in staged)
if write.missing:
unread = ", ".join(s.label for s in map(get_setting, write.missing) if s)
@@ -675,6 +836,71 @@ def remember_reply(ctx: AppContext, node: Contact, fills: list[RemoteSetting], r
return got
def learn_from_cli(ctx: AppContext, node: Contact, command: str, reply: str) -> None:
"""Fold what one command-line exchange proved about ``node`` into its page.
The command line is the only place a setting outside the catalog can be reached, so it
is also the only place one can be *found*: a ``get``/``set`` this node answered is proof
the key is there, and the row it earns costs no round trip nobody asked for. The catalog
doesn't grow — a key learned here belongs to this node alone (see
:func:`~meshterm.core.remote_config.discovered_setting`), and every other node's page is
what it always was.
Three cases, in the order they are tested:
* A **composite's own key** (``get radio``) fills all four of its fields, exactly as a
read of any one of them does.
* A **catalog key** folds into the cache the way the sweep or an Apply would — which is
also how ``get tx`` at the command line stopped leaving the TX power row stale.
* Anything else is **discovered**, and the two verbs are not equally good evidence.
``handleSetCmd`` matches a key with its trailing space and refuses an unknown one
outright, so an accepted write is proof. ``handleGetCmd`` matches on a bare prefix and
will answer a key it hasn't got out of a shorter key's branch, so a read is proof only
when the reply cannot be that shorter key's
(:func:`~meshterm.core.remote_config.learnable_from_get`).
``prv.key`` is never stored, whichever way it was asked
(:data:`~meshterm.core.remote_config.NEVER_STORED`).
"""
parsed = parse_setting_command(command)
if parsed is None or parsed.key in NEVER_STORED:
return
members = composite_members(parsed.key)
spec = _spec_for(parsed.key, ctx.remote_store.settings(node))
if parsed.verb == "get":
if members:
remember_reply(ctx, node, members, reply)
elif spec is not None:
remember_reply(ctx, node, [spec], reply)
elif not reply_is_error(reply) and learnable_from_get(
parsed.key, reply, _known_values(ctx, node)
):
found = discovered_setting(parsed.key)
value = parse_reply_value(found, reply)
if value is not None:
ctx.remote_store.remember_discovered(node, found.key, value)
return
if reply_is_error(reply):
return # a refused write says nothing about the key, and nothing about its value
if members:
for member in members:
value = parse_reply_value(member, parsed.value)
if value is not None:
ctx.remote_store.remember_setting(node, member.key, value)
return
if spec is not None and not spec.discovered:
value = parse_reply_value(spec, parsed.value)
if value is None:
return # sent in words the catalog can't read back; leave the row as it was
ctx.remote_store.remember_setting(node, spec.key, value)
for other in spec.overlaps:
ctx.remote_store.forget_setting(node, other)
return
ctx.remote_store.remember_discovered(node, parsed.key, parsed.value)
async def read_settings(
ctx: AppContext, device: Device, node: Contact, specs: Iterable[RemoteSetting]
) -> None:
@@ -870,6 +1096,7 @@ async def _command_line(ctx: AppContext, device: Device, node: Contact) -> None:
if reply is None:
screen.failed(f"no reply within {_REPLY_TIMEOUT_S:.0f} s")
else:
learn_from_cli(ctx, node, command, reply)
screen.reply(reply)
screen = RemoteCliScreen(
@@ -877,6 +1104,7 @@ async def _command_line(ctx: AppContext, device: Device, node: Contact) -> None:
history=ctx.remote_store.history(node),
send=send,
session=session,
extra_keys=_extra_keys(ctx.remote_store.settings(node)),
)
async def animate() -> None:
+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.
+24 -1
View File
@@ -23,6 +23,7 @@ no radio, no real address, and no real pairing code is involved.
from __future__ import annotations
import asyncio
import sys
import pytest
@@ -200,8 +201,12 @@ def test_a_good_connect_hands_the_client_over_still_open() -> None:
assert client.cx.link_open is True
def test_the_connection_is_built_from_this_device_s_own_endpoint() -> None:
def test_the_connection_is_built_from_this_device_s_own_endpoint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Address, discovered ``BLEDevice`` and PIN all reach the connection we construct."""
# Pinned off macOS, where the PIN is deliberately withheld (see the test below).
monkeypatch.setattr(sys, "platform", "win32")
_FakeMeshCore.reset("ok")
sentinel = object()
dev = MeshCoreDevice(
@@ -221,6 +226,24 @@ def test_the_connection_is_built_from_this_device_s_own_endpoint() -> None:
assert (client.default_timeout, client.auto_reconnect) == (7.5, False)
def test_the_pin_is_withheld_on_macos(monkeypatch: pytest.MonkeyPatch) -> None:
"""No PIN is handed to macOS: supplying one is what *breaks* the connection there.
``BLEConnection.connect`` responds to a PIN by calling bleak's ``pair()``, and
CoreBluetooth has no pairing API — the macOS backend raises outright, whereupon the
library disconnects and re-raises. Pairing there is the OS's to run, prompted by the
unbonded subscribe, so saying nothing is what lets a protected companion bond at all.
"""
monkeypatch.setattr(sys, "platform", "darwin")
_FakeMeshCore.reset("ok")
dev = MeshCoreDevice(transport="ble", address=_ADDR, pin="000000")
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
assert _FakeTransport.last.pin is None
assert dev._pin == "000000" # still remembered — it is the platform that can't use it
def test_a_cancelled_handshake_still_closes_the_link() -> None:
"""A probe's ``wait_for`` expiring mid-handshake must not leak what it cancelled.
+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))
+54
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import asyncio
import io
import sys
from pathlib import Path
from types import SimpleNamespace
@@ -194,6 +195,10 @@ def _fake_ble_stack(monkeypatch: pytest.MonkeyPatch, *outcomes):
async def test_create_ble_translates_auth_error_to_pin_guidance(monkeypatch) -> None:
"""A raw GATT auth rejection becomes a DeviceAuthenticationError that names the PIN fix."""
# Pinned off macOS: there the same rejection starts an OS-run pairing and is retried
# rather than reported, and the advice is the system dialog rather than --ble-pin.
# Without this the test would assert Windows/Linux wording on the macOS CI runner.
monkeypatch.setattr(sys, "platform", "win32")
# No PIN supplied -> tell the user to pass one. The subclass lets the interactive picker
# catch "needs a PIN" specifically, while the CLI still catches it as DeviceCommandError.
fake = _fake_ble_stack(monkeypatch, BleakGATTProtocolError("Insufficient Authentication"))
@@ -302,6 +307,8 @@ async def test_create_ble_gives_up_after_the_retry(monkeypatch) -> None:
async def test_create_ble_never_retries_an_auth_rejection(monkeypatch) -> None:
"""A PIN/bond rejection is translated on the first attempt — never looped by the retry."""
# Off macOS, where a rejection is the *start* of an OS-run pairing and is waited on.
monkeypatch.setattr(sys, "platform", "win32")
fake = _fake_ble_stack(monkeypatch, BleakGATTProtocolError("Insufficient Authentication"))
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
await _disable_windows_pairing(dev)
@@ -310,6 +317,53 @@ async def test_create_ble_never_retries_an_auth_rejection(monkeypatch) -> None:
assert len(fake.built) == 1
async def test_create_ble_waits_for_macos_to_finish_pairing(monkeypatch) -> None:
"""On macOS the first auth rejection is the pairing *starting*, so the connect waits.
Touching the companion's authenticated characteristic is the only way to make
CoreBluetooth pair at all, so the rejection and the Passkey dialog are the same event.
Giving up there reported an error for a pairing that was succeeding, and the device
connected only when the user selected it a second time.
"""
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(connection, "_BLE_MACOS_PAIRING_DELAY_S", 0)
# Refused twice while the dialog is up, then the bond lands and the subscribe works.
fake = _fake_ble_stack(
monkeypatch,
BleakGATTProtocolError("Insufficient Authentication"),
BleakGATTProtocolError("Insufficient Authentication"),
"handshake-ok",
)
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
await _disable_windows_pairing(dev)
result = await dev._create_ble(fake)
assert result is fake.built[2] # the attempt made after the bond is what the caller gets
assert len(fake.built) == 3
assert fake.built[0].disconnect_calls == 1 # each refused link was released, not stranded
assert fake.built[1].disconnect_calls == 1
assert fake.built[2].disconnect_calls == 0 # the live one is handed over open
async def test_create_ble_gives_up_when_macos_pairing_is_dismissed(monkeypatch) -> None:
"""A dialog nobody answers still fails in the end, and says where the code is asked for."""
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(connection, "_BLE_MACOS_PAIRING_DELAY_S", 0)
refusals = [BleakGATTProtocolError("Insufficient Authentication")] * (
connection._BLE_MACOS_PAIRING_ATTEMPTS + 1
)
fake = _fake_ble_stack(monkeypatch, *refusals)
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
await _disable_windows_pairing(dev)
with pytest.raises(connection.DeviceAuthenticationError) as excinfo:
await dev._create_ble(fake)
assert len(fake.built) == connection._BLE_MACOS_PAIRING_ATTEMPTS + 1 # bounded, not endless
# --ble-pin cannot help on macOS: the OS collects the code, so don't send the reader there.
assert "--ble-pin" not in str(excinfo.value)
assert "System Settings" in str(excinfo.value)
async def test_connect_reports_an_unanswered_handshake_and_closes_it(monkeypatch) -> None:
"""Transport up but no identity reply: a clean "not a companion", link still released."""
fake = _fake_ble_stack(monkeypatch, None)
+89 -3
View File
@@ -263,6 +263,50 @@ async def test_discover_ble_survives_no_adapter(monkeypatch: pytest.MonkeyPatch)
monkeypatch.setattr(bleak, "BleakScanner", _BoomScanner, raising=False)
assert await discovery.discover_ble_devices(timeout=0.0) == []
# A hiccup is not something the reader can act on, so it leaves no reason behind.
assert discovery.ble_unavailable_reason() is None
async def test_discover_ble_records_a_refusal_the_user_can_fix(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A denied scan keeps bleak's sentence instead of reading as "nothing nearby".
On macOS this is the routine case, not an exotic one: Bluetooth is granted to the
terminal running MeshTerm, so an ungranted terminal — and every SSH session, which
macOS refuses to even prompt in — scans successfully and hears nothing at all.
"""
import bleak
import meshterm.core.discovery as discovery
not_available = pytest.importorskip("bleak.exc").BleakBluetoothNotAvailableError
class _DeniedScanner:
@staticmethod
async def discover(timeout: float, return_adv: bool):
raise not_available("Bluetooth access is denied by the user", 2)
monkeypatch.setattr(bleak, "BleakScanner", _DeniedScanner, raising=False)
assert await discovery.discover_ble_devices(timeout=0.0) == []
reason = discovery.ble_unavailable_reason()
assert reason is not None and "denied" in reason
# And it describes the latest attempt, never a stale one.
monkeypatch.setattr(bleak, "BleakScanner", _empty_scanner(), raising=False)
assert await discovery.discover_ble_devices(timeout=0.0) == []
assert discovery.ble_unavailable_reason() is None
def _empty_scanner():
"""A scanner that works and simply hears nothing — the "not a fault" case."""
class _Quiet:
@staticmethod
async def discover(timeout: float, return_adv: bool):
return {}
return _Quiet
# -- device store --------------------------------------------------------------
@@ -496,15 +540,57 @@ def test_resolve_bad_tcp_endpoint_raises() -> None:
def test_resolve_ambiguous_raises(tmp_path: Path) -> None:
"""Multiple devices with no usable default raise a guidance error."""
"""Two plausible companions with no usable default raise a guidance error."""
devices = [
DiscoveredDevice("COM3", serial_number="SN1"),
DiscoveredDevice("COM4", serial_number="SN2"),
DiscoveredDevice("COM3", vid=0x303A, pid=0x1001, serial_number="SN1"),
DiscoveredDevice("COM4", vid=0x10C4, pid=0xEA60, serial_number="SN2"),
]
with pytest.raises(DeviceSelectionError, match="Multiple companion devices"):
resolve_device(devices, None)
def test_resolve_ignores_implausible_ports_when_one_board_is_present() -> None:
"""One real board among ports that look like nothing still resolves to the board.
This is the macOS case: every Mac permanently presents two virtual ``/dev/cu.*``
ports with no USB VID/PID, so counting them meant a Mac with a single companion
attached saw three devices and refused to choose — auto-detection could never fire
on that platform.
"""
board = DiscoveredDevice("/dev/cu.usbmodem1101", vid=0x303A, pid=0x1001)
devices = [
board,
DiscoveredDevice("/dev/cu.Bluetooth-Incoming-Port"),
DiscoveredDevice("/dev/cu.debug-console"),
]
res = resolve_device(devices, None)
assert res.target == board.target and res.source == "only"
def test_resolve_lone_unrecognized_device_still_resolves() -> None:
"""A single adapter we can't place still connects — an unlisted VID is usually real."""
lone = DiscoveredDevice("COM9", vid=0x1234, pid=0x0001)
assert not lone.is_likely_lora
res = resolve_device([lone], None)
assert res.target == "COM9" and res.source == "only"
def test_resolve_several_implausible_ports_does_not_call_them_companions() -> None:
"""Ports that look like nothing are not reported as "multiple companion devices".
A bare Mac with no companion attached reaches here with its two virtual ports, and
being told it has several companions — and asked to pick one of them — is the least
true answer available.
"""
devices = [
DiscoveredDevice("/dev/cu.Bluetooth-Incoming-Port"),
DiscoveredDevice("/dev/cu.debug-console"),
]
with pytest.raises(DeviceSelectionError, match="No companion devices detected among") as err:
resolve_device(devices, None)
assert "Multiple companion devices" not in str(err.value)
def test_resolve_no_devices_raises() -> None:
"""No devices at all raises a clear error mentioning --mock."""
with pytest.raises(DeviceSelectionError, match="No companion devices"):
+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"
+80 -2
View File
@@ -767,7 +767,7 @@ def test_basemap_sends_its_user_agent_on_every_fetch(tmp_path: Path) -> None:
src = _offline_source(tmp_path / "cache")
seen: list[urllib.request.Request] = []
def _urlopen(req: urllib.request.Request, timeout: float | None = None):
def _urlopen(req: urllib.request.Request, timeout: float | None = None, context=None):
seen.append(req)
raise urllib.error.URLError("no network in tests")
@@ -782,6 +782,84 @@ def test_basemap_sends_its_user_agent_on_every_fetch(tmp_path: Path) -> None:
assert seen[0].get_header("User-agent") == basemap_mod._USER_AGENT
def test_basemap_keeps_the_default_tls_context_when_it_works() -> None:
"""A working platform trust store is left alone — urllib's default is passed through."""
from meshterm.services import basemap as basemap_mod
basemap_mod._tls_context.cache_clear()
try:
assert basemap_mod._tls_context() is None
finally:
basemap_mod._tls_context.cache_clear()
def test_basemap_finds_a_ca_bundle_when_the_default_store_is_empty(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""An empty default store falls back to the OS CA bundle instead of failing every fetch.
THE macOS bug: a frozen build asks OpenSSL for a trust store at a filesystem path baked
into the interpreter's own build, which need not exist on a machine that never
installed that Python — which is every machine a PyInstaller bundle lands on. Fetches
then failed with ``CERTIFICATE_VERIFY_FAILED``, and because a refused fetch is
indistinguishable from empty terrain the map drew no ground under the nodes and said
nothing. Windows hid it by reading the OS certificate store in its default context.
"""
import ssl
from meshterm.services import basemap as basemap_mod
# The file only has to exist: loading is recorded below rather than really parsed,
# so the test pins *which path is chosen* without depending on a PEM being around.
bundle = tmp_path / "cert.pem"
bundle.write_text("# stand-in for the OS bundle\n", encoding="utf-8")
loaded: list[str] = []
class _EmptyStore(ssl.SSLContext):
def get_ca_certs(self, binary_form: bool = False): # type: ignore[override]
return []
def load_verify_locations(self, cafile=None, capath=None, cadata=None): # type: ignore[override]
loaded.append(cafile or "")
monkeypatch.setattr(
ssl, "create_default_context", lambda *a, **k: _EmptyStore(ssl.PROTOCOL_TLS_CLIENT)
)
monkeypatch.setattr(basemap_mod, "_CA_BUNDLES", (str(tmp_path / "nope.pem"), str(bundle)))
basemap_mod._tls_context.cache_clear()
try:
ctx = basemap_mod._tls_context()
assert ctx is not None, "no context built despite a readable bundle"
assert loaded == [str(bundle)] # skipped the missing path, used the present one
assert ctx.verify_mode == ssl.CERT_REQUIRED # never verified away to make it work
finally:
basemap_mod._tls_context.cache_clear()
def test_basemap_never_disables_verification(monkeypatch: pytest.MonkeyPatch) -> None:
"""With no bundle anywhere, the fetch still verifies — it fails rather than trusts."""
import ssl
from meshterm.services import basemap as basemap_mod
class _EmptyStore(ssl.SSLContext):
def get_ca_certs(self, binary_form: bool = False): # type: ignore[override]
return []
monkeypatch.setattr(
ssl, "create_default_context", lambda *a, **k: _EmptyStore(ssl.PROTOCOL_TLS_CLIENT)
)
monkeypatch.setattr(basemap_mod, "_CA_BUNDLES", ("/nonexistent/ca.pem",))
basemap_mod._tls_context.cache_clear()
try:
assert basemap_mod._tls_context() is None # urllib's default, still verifying
finally:
basemap_mod._tls_context.cache_clear()
def test_basemap_user_agent_needs_no_installed_distribution() -> None:
"""Importing the module must not depend on package metadata being on disk.
@@ -1127,7 +1205,7 @@ def test_basemap_http_get_separates_an_answer_from_silence(tmp_path: Path) -> No
return False
def _fake(result):
def _urlopen(_req, timeout=None): # noqa: ANN001
def _urlopen(_req, timeout=None, context=None): # noqa: ANN001
if isinstance(result, Exception):
raise result
return result
+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)
+204
View File
@@ -23,12 +23,16 @@ from meshterm.core.connection import MockDevice
from meshterm.core.device_store import DeviceStore
from meshterm.core.models import Contact, utcnow
from meshterm.core.remote_config import (
DISCOVERED_HELP,
REPEATER_SETTINGS,
SettingCommand,
composite_reads,
discovered_setting,
get_setting,
known_commands,
normalize_value,
parse_reply_value,
parse_setting_command,
range_hint,
read_plan,
reply_is_error,
@@ -780,3 +784,203 @@ def test_admin_actions_start_every_label_in_the_same_cell() -> None:
starts[label] = cell_len(plain[: plain.index(label)])
assert set(starts) == set(labels)
assert len(set(starts.values())) == 1, f"a label starts a column early: {starts}"
# --- settings discovered on the command line -------------------------------------------
def test_only_get_and_set_are_read_as_settings() -> None:
"""The CLI's own grammar is the filter; every top-level verb falls out of it.
Nothing is excluded by name: ``reboot``, ``advert`` and ``powersaving`` are simply not
``get <key>`` or ``set <key> <value>``, and ``setperm`` only starts with the letters.
"""
assert parse_setting_command("get radio.rxps") == SettingCommand("get", "radio.rxps")
assert parse_setting_command(" set reboot.interval 24 ") == SettingCommand(
"set", "reboot.interval", "24"
)
assert parse_setting_command("set owner.info a b c") == SettingCommand(
"set", "owner.info", "a b c"
)
for command in (
"reboot",
"advert",
"clock sync",
"powersaving on",
"setperm ab12 rw",
"start ota",
"get", # no key
"set tx", # no value
"get ../etc", # not a key's spelling
):
assert parse_setting_command(command) is None, command
def test_a_write_the_node_accepts_is_proof_the_key_is_there(tui_ctx) -> None:
"""``set`` teaches a row: the firmware matches its keys exactly and refuses the rest.
``handleSetCmd`` compares ``"radio "`` *with* the trailing space, so a longer key can
never land in a shorter one's branch, and an unknown one answers ``unknown config:``.
"""
repeater_admin.learn_from_cli(tui_ctx, NODE, "set radio.rxps balanced", "OK")
cached = tui_ctx.remote_store.settings(NODE)["radio.rxps"]
assert (cached.value, cached.discovered) == ("balanced", True)
repeater_admin.learn_from_cli(tui_ctx, NODE, "set nonesuch 1", "unknown config: nonesuch")
assert "nonesuch" not in tui_ctx.remote_store.settings(NODE)
def test_a_read_answered_out_of_a_shorter_keys_branch_is_no_discovery(tui_ctx) -> None:
"""``get`` matches on a bare prefix, so a plausible reply can belong to another key.
Stock firmware answers ``get radio.rxps`` from the ``get radio`` branch. With no typing
to reject it — a discovered key has none — the guard is that the reply is exactly what
the shadowing key last said.
"""
for key, value in (("freq", "910.525"), ("bw", "62.5"), ("sf", "7"), ("cr", "5")):
tui_ctx.remote_store.remember_setting(NODE, key, value)
repeater_admin.learn_from_cli(tui_ctx, NODE, "get radio.rxps", "> 910.525,62.5,7,5")
assert "radio.rxps" not in tui_ctx.remote_store.settings(NODE)
repeater_admin.learn_from_cli(tui_ctx, NODE, "get radio.rxps", "> desired=off level=2")
assert tui_ctx.remote_store.settings(NODE)["radio.rxps"].value == "desired=off level=2"
def test_a_read_nothing_shadows_is_taken_at_its_word(tui_ctx) -> None:
"""No catalog key prefixes ``reboot.interval``, so its reply can only be its own."""
repeater_admin.learn_from_cli(tui_ctx, NODE, "get reboot.interval", "> 24")
assert tui_ctx.remote_store.settings(NODE)["reboot.interval"].value == "24"
repeater_admin.learn_from_cli(tui_ctx, NODE, "get nonesuch", "??: nonesuch")
assert "nonesuch" not in tui_ctx.remote_store.settings(NODE)
def test_an_unread_shadow_leaves_the_question_unanswerable(tui_ctx) -> None:
"""A shadowed key whose shadow was never read is not discovered: we cannot tell."""
repeater_admin.learn_from_cli(tui_ctx, NODE, "get radio.rxps", "> 910.525,62.5,7,5")
assert "radio.rxps" not in tui_ctx.remote_store.settings(NODE)
def test_the_private_key_is_never_written_to_disk(tui_ctx, tmp_path: Path) -> None:
"""``get prv.key`` prints in the transcript and goes no further."""
secret = "ab" * 32
repeater_admin.learn_from_cli(tui_ctx, NODE, "get prv.key", "> " + secret)
assert "prv.key" not in tui_ctx.remote_store.settings(NODE)
written = [p for p in tmp_path.rglob("*.json") if secret in p.read_text(encoding="utf-8")]
assert written == []
def test_a_catalog_key_read_on_the_command_line_refreshes_its_row(tui_ctx) -> None:
"""The cache is the page, so the command line fills it exactly as a read does.
``get tx`` used to leave the TX power row showing what it said last week, and
``get radio`` answered four rows that none of them heard about.
"""
repeater_admin.learn_from_cli(tui_ctx, NODE, "get tx", "> 20")
repeater_admin.learn_from_cli(tui_ctx, NODE, "get radio", "> 869.525,250,10,6")
repeater_admin.learn_from_cli(tui_ctx, NODE, "set txdelay 1.5", "OK")
cache = tui_ctx.remote_store.settings(NODE)
assert cache["tx"].value == "20"
assert (cache["freq"].value, cache["sf"].value) == ("869.525", "10")
assert cache["txdelay"].value == "1.5"
assert not any(cached.discovered for cached in cache.values())
def test_a_write_stales_the_other_spelling_from_the_command_line_too(tui_ctx) -> None:
"""``set dutycycle`` at the prompt drops the cached ``af``, as an Apply would."""
tui_ctx.remote_store.remember_setting(NODE, "af", "1.00")
repeater_admin.learn_from_cli(tui_ctx, NODE, "set dutycycle 40", "OK - 40.0%")
cache = tui_ctx.remote_store.settings(NODE)
assert cache["dutycycle"].value == "40.0" and "af" not in cache
def test_no_reply_can_remove_a_discovered_row(tui_ctx) -> None:
"""A read may only ever take a discovered row to ``n/a``; removing it is a decision.
Its key is recorded nowhere but here, so a reply that misreads — a truncated line, a
node answering mid-reboot, a frame correlated to the wrong command — must not be able
to delete the row. A catalog row costs nothing when that happens, the catalog still
naming the key; this one would cost the reader something only they can restore.
"""
spec = discovered_setting("radio.rxps")
tui_ctx.remote_store.remember_discovered(NODE, "radio.rxps", "off")
repeater_admin.remember_reply(tui_ctx, NODE, [spec], "> balanced")
cached = tui_ctx.remote_store.settings(NODE)["radio.rxps"]
assert (cached.value, cached.discovered) == ("balanced", True)
repeater_admin.remember_reply(tui_ctx, NODE, [spec], "??: radio.rxps")
cache = tui_ctx.remote_store.settings(NODE)
assert cache["radio.rxps"].discovered and not cache["radio.rxps"].supported
assert repeater_admin._value_text(spec, cache, {}).plain == "n/a"
assert "radio.rxps" in repeater_admin._extra_keys(cache), "still the reader's to forget"
def test_discovered_rows_draw_last_in_their_own_section_and_are_deletable() -> None:
"""A node's own settings sit under ``Extra`` — and only they answer Del."""
from meshterm.ui.tui import Choice
plain = {"txdelay": CachedValue("0.5", utcnow())}
catalog_rows = [
item
for item in repeater_admin._menu_items(NODE, plain, {})[1]
if isinstance(item, Choice) and item.deletable
]
assert catalog_rows == [], "no catalog row may be forgotten"
cache = dict(plain, **{"radio.rxps": CachedValue("balanced", utcnow(), discovered=True)})
items = repeater_admin._menu_items(NODE, cache, {})[1]
deletable = [i for i in items if isinstance(i, Choice) and i.deletable]
assert [i.value for i in deletable] == ["radio.rxps"]
rows = [i for i in items if isinstance(i, Choice) and isinstance(i.value, str)]
settings_rows = [r for r in rows if repeater_admin.get_setting(r.value) or r.deletable]
assert settings_rows[-1].value == "radio.rxps", "the node's own settings come last"
assert DISCOVERED_HELP in settings_rows[-1].title.plain
def test_a_staged_discovered_value_is_written_with_its_own_set() -> None:
"""A key the catalog hasn't got is still written the one way anything can write it."""
writes = write_plan({"radio.rxps": "balanced", "txdelay": "1.5"}, {})
assert [w.command for w in writes] == ["set txdelay 1.5", "set radio.rxps balanced"]
def test_a_long_discovered_value_cannot_widen_the_whole_page() -> None:
"""The value lane is one width for every row, so a node's own setting may not set it.
Some builds answer ``get radio.rxps`` with a whole diagnostic line. Unbounded, that one
row would push every catalog setting's description right and off a 72-column screen —
reshaping a page the reader opened to read the catalog.
"""
from rich.cells import cell_len
from meshterm.ui.tui import Choice
def widest(cache) -> int:
items = repeater_admin._menu_items(NODE, cache, {})[1]
return max(
cell_len(i.title.plain)
for i in items
if isinstance(i, Choice) and hasattr(i.title, "plain")
)
catalog = {
"tx": CachedValue("20", utcnow()),
"owner.info": CachedValue("Jean-Pierre, Montreal", utcnow()),
}
chatty = dict(
catalog,
**{
"radio.rxps": CachedValue(
"desired=off effective=off supported=yes level=0 preamble=16 rx=0 sleep=0",
utcnow(),
discovered=True,
)
},
)
assert widest(chatty) == widest(catalog)
fitted = repeater_admin._extra_value("a value far too long for its lane", 14)
assert cell_len(fitted) == 14
assert repeater_admin._extra_value(" balanced\n", 14) == "balanced"
+38
View File
@@ -504,6 +504,44 @@ def test_every_mark_is_as_wide_as_its_name_says() -> None:
assert logo_width(_rows(name)) == declared, f"{name} is not {declared} cells wide"
def test_the_mark_says_bright_as_a_colour_not_as_bold() -> None:
"""No span of the wordmark depends on the terminal reading bold as brightness.
An art editor spells brightness the DOS way, ``1m`` lifting the foreground into the
bright bank. That is true of the PicoCalc console and of a DOS one -- and false on
macOS Terminal, where bold asks for a heavier face and leaves the colour alone. There
``1;30`` is not dark grey but plain black, and the wide mark carries seventy-nine such
spans, much of it on a black ground: the mark drew muted, and the dithers that fade one
colour into another faded toward the wrong end. So the loader emits the bright bank as
the colour it means (``9N``), which cannot be mistaken for a font weight.
"""
import re
from meshterm.ui.logo import _NAMED_WIDTH, _rows, _variants, logo_width
sgr = re.compile(chr(27) + r"\[([0-9;]*)m")
for name in _variants():
rows = _rows(name)
codes = [m.group(1) for row in rows for m in sgr.finditer(row)]
assert codes, f"{name} carries no colour at all"
params = [p for code in codes for p in code.split(";")]
# Nothing asks for bold: brightness travels as 90-97 instead.
assert "1" not in params, f"{name} still leans on bold for brightness"
assert any(p.isdigit() and 90 <= int(p) <= 97 for p in params), (
f"{name} lost its bright spans entirely"
)
# A foreground on the dim bank still says so outright, so it cannot inherit a bank
# from whatever the row above left set (each row reaches Rich on its own).
for code in codes:
parts = code.split(";")
if any(re.fullmatch(r"3[0-7]", p) for p in parts):
assert "22" in parts, f"{name} has a dim foreground that never says so: {code}"
# And the spelling change moves no cells: the art measures exactly as it did.
assert logo_width(rows) == int(_NAMED_WIDTH.search(name).group(1))
def test_the_mark_the_console_picks_stays_inside_its_font() -> None:
"""The wordmark obeys the glyph contract every other screen does.
+254 -6
View File
@@ -1323,6 +1323,61 @@ def test_device_picker_builds_aligned_columns(tmp_path) -> None:
assert device_rows[0].index("COM5") == device_rows[1].index("/dev/ttyUSB0")
def test_device_picker_address_lane_is_the_one_that_adapts() -> None:
"""DEVICE shows its name in full; PORT / ADDRESS takes whatever is left.
A name is what the reader came to read and recognises their own radio by; an address
is a disambiguator, and does that job from its last ten cells. macOS is what forced
the question — CoreBluetooth reports no MAC but a per-machine 36-cell UUID, and even
its ports run long — which sized the lane to 36 and left DEVICE the width of its own
heading.
"""
from rich.cells import cell_len
from meshterm.core.discovery import DiscoveredDevice
from meshterm.ui.device_picker import _ADDRESS_MIN, _fit_target, _lane_widths
name = "MeshCore-Johnputer Wardriver"
uuid = "12345678-1234-1234-1234-123456789ABC"
mac_devices = [
DiscoveredDevice(transport="ble", address="C6:B6:26:BC:7F:09", name=name, product=name),
DiscoveredDevice("COM3", product="Some Adapter", vid=0x1234),
]
uuid_devices = [
DiscoveredDevice(transport="ble", address=uuid, name=name, product=name),
DiscoveredDevice("/dev/cu.Bluetooth-Incoming-Port"),
]
# The name is shown whole on both, and a 36-cell address does not shrink it: the lane
# that gives is the address, and it never drops below the floor that keeps it useful.
for devices in (mac_devices, uuid_devices):
name_w, port_w = _lane_widths(devices, {})
assert name_w == cell_len(name)
assert port_w >= _ADDRESS_MIN
assert _lane_widths(mac_devices, {})[0] == _lane_widths(uuid_devices, {})[0]
# A target that fits is untouched, and a long one keeps its tail -- which is what tells
# two of them apart, and the whole reason the cut takes the front.
_, port_w = _lane_widths(uuid_devices, {})
assert _fit_target("COM3", port_w) == "COM3"
assert _fit_target(uuid, port_w).endswith("9ABC")
assert cell_len(_fit_target(uuid, port_w)) <= port_w
assert _fit_target("/dev/cu.usbmodem1101", 14) != _fit_target("/dev/cu.usbmodem1102", 14)
# Nothing is hoarded: a lone short port hands its slack back to the name.
short = [DiscoveredDevice("COM5", product="Wio SX1262", vid=0x2886)]
assert _lane_widths(short, {})[1] == cell_len("COM5")
# And where the name cannot fit whatever happens, the address is left whole rather
# than ellipsized alongside it -- one cut in the row instead of two.
sprawling = [
DiscoveredDevice("/dev/ttyUSB0", product="CP2102 USB to UART Bridge Controller", vid=0x10C4)
]
name_w, port_w = _lane_widths(sprawling, {})
assert port_w == cell_len("/dev/ttyUSB0")
assert name_w < cell_len("CP2102 USB to UART Bridge Controller")
def test_device_picker_names_and_sorts_known_devices(tmp_path) -> None:
"""A confirmed device shows its node name (in white), sorts to the top, and marks type."""
from meshterm.core.device_store import DeviceStore
@@ -3289,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
@@ -3312,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)
@@ -3370,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