132 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
Jean-Pierre Martineau bde2a08b7b MeshTerm 0.3.6
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-17 01:34:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 899bb84830 The archive preview leads each contact with its type icon, as the Contacts list does
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 01:26:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 4ba0a29691 The archive confirm names the contact with its type icon, as the Contacts list does
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:54:16 -04:00
Jean-Pierre MartineauandClaude Opus 5 5cba667542 A locked contact's padlock leads its row, left of the type glyph
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:49:05 -04:00
Jean-Pierre MartineauandClaude Opus 5 d38aca6c3c A contact can be locked against archiving
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:35:59 -04:00
Jean-Pierre MartineauandClaude Opus 5 8076ac1081 Purge contacts is Archive contacts, because it archives them
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:26:43 -04:00
Jean-Pierre MartineauandClaude Opus 5 0b1ec94cdb A list's highlight stays in its window walking back up
Scrolled down the purge preview and walked back up, the cursor rode off
the top of the box. Two things let it, and either was enough.

A select list under a prompt reported its highlighted row a prompt's
height too far down. The row's line is read off the body as it is laid
out, which already counts the prompt, and the prompt's height was then
added on top. The frame kept that lower line in view, so walking up left
the real row above the window's top edge for as long as the walk lasted.

And at the bottom of any list with a pinned column header or section
heading, the window slides down under the pins so they cost stale lines
at its top rather than the last ones. Walking up, the highlight reaches
the window's top line before the scroll has to move, and the slide
dropped exactly that line. The slide now stops at the highlighted row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:37:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 38d9ffacc5 Every glyph that may be drawn as an emoji is reserved two cells
The curated narrow and wide sets are gone, with the probe that decided when
to apply them and the add-narrow-emoji skill that fed them. They lined up
the rows somebody had already looked at and none of the rows nobody had,
and a contact's name arrives off the air carrying whatever its owner typed.

In their place, one structural rule: a glyph built from several codepoints
(a selector sequence, a ZWJ sequence, a keycap, a flag) or a lone
text-default emoji or Regional Indicator is reserved two cells, the most a
single glyph draws. Pinned into two cells, a glyph the font draws in two
fills them and one it draws in one leaves a blank beside it, so the text
after any emoji starts in the same column on every row. Pinning alone could
only hold the lanes after it.

Two exceptions keep their one cell: Rich's single-cell ranges, which its
fast path measures without asking, and the nine text-default emoji the app
draws itself as marks. That list is closed by a test over the package's
source, so a new mark cannot slip into the reserve unnoticed.

Rich measures in three places and prompt_toolkit in a fourth, and all four
are patched from the same rule at once: Rich's segment splitter walks a
string until a whole-string width meets its cut, and a character measured
differently from the string around it is a cut that walk never meets. Every
multi-codepoint glyph is also merged into one fragment before layout, so a
mark followed by a selector is not folded into a one-cell slot.

Installed only by the full-screen app on a platform that draws emoji. The
command line keeps the stock tables, because nothing pins its output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:29:02 -04:00
Jean-Pierre MartineauandClaude Opus 5 68c528938c A glyph's reserved cells are erased before it is drawn
A pin jumps over whatever part of a glyph's reservation the font did not
cover, and nothing wrote that cell. prompt_toolkit never writes the cell
after a wide glyph either, since it counts it as the glyph's own, so a
repaint that put an emoji where two letters had been left the second letter
standing beside it on a terminal that draws the emoji in one cell. On the
fast path the row is erased first, but only in the default colours, so the
same cell was a hole in a chip's fill.

Both pins now erase the reservation in the current colours before drawing
the glyph, with ECH, which blanks cells without moving the cursor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:15:08 -04:00
Jean-Pierre MartineauandClaude Opus 5 6f7b24e790 A dialog's glyphs land where prompt_toolkit measured them, too
The rows the fast renderer writes were already pinned. Anything
prompt_toolkit lays out itself — a floating dialog, and the backdrop it
repaints — still went out through its differential renderer, which steps a
cursor of its own by each glyph's measured width and moves relatively from
there, so one glyph drawn at another width threw off every move after it.

That renderer never knows a column absolutely, so the pin is spelled
relative to the glyph instead: save the cursor, draw the glyph, restore,
step forward exactly the width the renderer is about to add. The terminal's
cursor is back in step after every glyph, and nothing is tracked that could
drift from the renderer's own bookkeeping. PinnedOutput wraps the real
terminal; the width reclaim stays outermost.

A flag's two indicators are now merged into one cell before layout, like a
ZWJ sequence, so the pair is written as one glyph rather than with a cursor
move between its halves.

Nothing is pinned on a platform that draws no emoji: the PicoCalc only ever
draws glyphs its own font has verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:07:45 -04:00
Jean-Pierre MartineauandClaude Opus 5 03befa76ac A written row says where its columns are, so a glyph can only move itself
Alignment stopped depending on the width tables being right about a glyph.
After anything whose drawn width is not certain, a row now carries an
absolute column address naming the column the app measured that glyph
into, so a mismeasured emoji costs one cell inside its own lane instead
of every lane after it and the frame's right border.

The curated narrow/wide sets keep their job — they are what makes the
reservation right, so a listed glyph gets neither a gap nor an overwrite
— but they are no longer what holds a row together. A name off the air,
which nobody can put on a list in advance, now frames flush too.

Pinning only where it is needed: the ranges the app draws in bulk and both
authorities agree are one cell (box drawing, braille, blocks, the node
marks, the chevrons) are listed, and a row holding nothing else is handed
back untouched — one scan, no walk. That list is a performance claim, not
what correctness rests on; getting an entry wrong costs what today costs.

Proved against a terminal that disagrees on purpose rather than against a
measurement, since the disagreement is the thing that cannot be probed.
Still limited to the rows the fast renderer writes itself: a frame
prompt_toolkit lays out — a dialog, and the backdrop it repaints — tracks
the cursor in its own arithmetic and is uncorrected. MESHTERM_COLUMN_SNAP=0
turns it off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:55:46 -04:00
Jean-Pierre MartineauandClaude Opus 5 839da9ab8f A doctor for the one failure the map has no words for
The map is the only networked part of MeshTerm, and every failure on that path
is logged at DEBUG while log_level defaults to WARNING: a reader whose tiles
never arrive gets a blank ground and no explanation anywhere. On macOS that is
routinely a Python with no certificate store at all, which no amount of looking
at the app can reveal.

scripts/basemap-doctor.py is the explanation. It reports the interpreter, the
trust store, the tile source and the cached-tile count, then fetches the
TileJSON and one real tile the way BasemapSource does, and turns the failure
into one verdict with its fix.

The verdict is an A/B rather than a reading: curl verifies against the OS trust
store and Python against its own, so curl succeeding where Python fails is the
app's store and nothing else, while both refusing the same certificate is TLS
interception or a genuinely bad server — one exception, opposite causes,
opposite fixes. Collapsing curl's exit codes into "failed" had it blaming the
store and the network in the same breath.

It also answers the other way a map looks wrong, since it costs nothing in the
same round trip: an unset COLORTERM is 256 colours, where the basemap's greens
and blues collide.

Started under the wrong Python it finds the installed meshterm command, reads
its shebang and hands itself over, so nobody has to know which of their three
Pythons the app went into. By subprocess, not execve, which on Windows is not a
real exec and segfaults under a redirect — and stdout is flushed first, or the
banner loses the report it introduces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:05:07 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 af25de98cc The seam blurs under 0.06, the ladder's final verdict
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 01:54:41 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 c2d76e3697 The seam blurs under 0.05, between the two ladder verdicts
Tighter (0.035) left wedges a reader had to hunt for, looser (0.10) blurred pairs
whose point still read plainly. Set by eye on the same ladder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 01:51:35 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 3aa2916846 The seam blurs under 0.10 again: the tighter 0.035 left wedges a reader had to hunt for
Set by eye on the same ladder: at 0.10 the solid point is found at one cell without
looking for it, and the safe side of the line is the blur, since a wrong blur still
draws a legible seam and a wrong interlock draws none. The shade stays at 0.10.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 01:50:08 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 2b0f42d1cc The seam blurs under 0.035 and shades by 0.10, set by eye on the ladder
SEAM_BLUR drops from 0.10 to 0.035: on a ladder of the spectrum's own pairs drawn
both ways, the solid point stays findable at one cell down to a little under two
noticeable differences, so the interlock is the ordinary seam and the thin chevron
is for fills that are the same or as good as (a mirrored leg, a keyless run, the
greens' nearest neighbours). SEAM_SHADE drops from 0.15 to 0.10: a lightness step
is what a thin line is seen by, so it needs nothing like the blur distance to read,
and 0.10 is a clear line that is still plainly the chip's own colour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 01:43:47 -04:00
Jean-Pierre MartineauandClaude Opus 5 ac0c86d5d3 A contacts dump ends on silence, not on a deadline it outgrew
The library arms one five-second future for the whole table and never re-arms
it, so a node holding four hundred contacts fails every read with "no event
received" however healthy the radio is. What means "the companion stopped
answering" is a gap between records, so that is what is timed now: each record
restarts the clock and the dump may take as long as it takes. An uncorrelated
ERROR mid-stream is somebody else's command and no longer kills the read.

A tool that failed is a caution rather than a loss, so it takes the amber tone
and the popup wraps it instead of handing a long sentence to the full-frame
result window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 00:55:18 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 1838ee3768 A blurred seam is the chip's own fill shaded, and "blurred" is measured by eye in OKLab
The thin chevron between two chips the reader can't tell apart is now drawn in the
previous chip's own fill stepped SEAM_SHADE darker (lighter for a dark fill) on the
fill ahead, so nothing on the ribbon is a colour that isn't a chip's. The step is
measured from whichever fill is further along, so the mark clears the fill it sits
on by the full shade however close the two are.

Whether two fills blur is now a perceptual distance, not a hue gap: ui/oklab.py is
the reference OKLab transform (from_hex, to_hex, distance, shaded), and the seam
interlocks only where the fills sit SEAM_BLUR (0.10) or more apart. The hue rule
mis-sized the wheel badly: sixteen first-byte steps measure 0.026 across the greens
and 0.165 across the cyans, so it blurred too little on one side and too much on the
other, and an sRGB distance would only have restated the hue gap, a fixed byte step
being a near-constant 55-60 of 8-bit RGB all the way round. 0.10 is five noticeable
differences, the typical 0x10 pair with a little to spare, erring toward the thin
chevron because a wrong blur still draws a legible seam and a wrong interlock draws
none. The greys need no exemption any more: the keyless grey and the faded slate sit
0.18 apart and keep their interlock on the metric alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 00:30:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 282334c761 The purge preview shows the evidence in lanes of its own kind
The preview's rows led on a PCTL lane and followed it with WHY IT RANKS LOW, one
header over three lanes that held a contact's n-th weakest reason — so a message
count, an age, a packet tally and a hop count moved between columns from row to
row and nothing in a column meant the same thing twice. The lanes are fixed by
kind now: HEARD and PKTS first, in the Contacts list's own order and drawn in its
own grammar (the column age under recency heat, a tally muted, a faint dash for
none), then the two the sweep adds, MSGS and HOPS. Each is measured across the
victims and headed by a word right-aligned over its own values.

The percentile goes with the phrases. A rank is the score's reading of itself, so
a lane of them restated the order the list was already in; what an audit needs is
the measurements, which is what a reader can check against what they know. The
row pins its name out of the horizontal scroll instead — the evidence is what a
narrow terminal cuts, and the name is what says whose evidence it is. ScoredContact
drops the reasons it was the only consumer of.

A lane also stops trusting a name to be ordinary text. A name is written by whoever
owns the radio, so fit_cells — THE lane fitter, and every column's — now cuts
between glyphs rather than codepoints and folds what no terminal can draw: cutting
an emoji sequence in half stranded a joiner that swallowed the ellipsis appended
after it, leaving the lane a cell short of what was drawn and every column right of
the name starting late, and a newline or an escape in a name ended the row mid-lane
whatever it measured. Both are settled in emoji_width, which already owns what this
terminal really does with these codepoints.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 00:05:23 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 1da1c9db0d A blurred chip seam is the thin chevron in ink, not a wedge cut out of the route
Where two consecutive chips share a fill or sit under SEAM_HUE_GAP apart, the seam
was the solid point drawn bare, so the page cut a wedge out of the ribbon: a run of
near hues read as dashes, and on a light terminal the wedge was a hole. It is now
the thin chevron (U+E0B1, core powerline set) in the chip's ink on the fill ahead,
powerline's own mark for a join inside one colour. The ribbon runs on unbroken, the
join is a line drawn on it, and it wears no fill of its own so it can't be taken
for a chip; on a faded leg it takes the dim ink and recedes with what it joins.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 00:04:15 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 2404c5286c A chip seam falls back to the page when two hues are too close to tell apart
Two consecutive chips with near-identical fills — first key bytes under 0x10 apart
on the hue wheel, measured circularly — used to interlock on a chevron the eye
couldn't find, one shade of green laid on the next. They now take the same exception
an exact match does: the point drops its background and the page cuts the wedge.
The wheel is recovered from the fill itself, so an override colour and a console
slot are placed on it too; the greys (keyless, dim, our own neutral) blend only with
their exact selves, since two distinct greys still read as two chips.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 23:48:23 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 6569bd8b89 The About page uses the Oxford comma
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 23:33:28 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 27f910b3d6 The About page keeps its bold on the two names
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 23:24:07 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 5a325fbbe9 The About page opens on where MeshTerm lives and what it plugs into, in JP's words
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 23:21:10 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 12941b8f3c scripts/ is sorted by handheld: picocalc/ and uconsole/, indexed from the hardware manual
The Calculinux bring-up, both console-font builds, the GPL-2.0 text that belongs
beside the 6x8 one, and the xiao-radio firmware kit move under scripts/picocalc/;
the SPI bridge is scripts/uconsole/meshterm-spi-bridge. The folder's own README is
gone: its per-file index is now a section of docs/hardware.md, next to the table
that says which manual is yours. Every path in the manuals, NOTICE, CLAUDE.md, the
tests, the package docstrings and the scripts' own headers follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 22:36:49 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 657cc93691 The companion's clock is set from this computer when it connects, if a preference asks
A new "Set clock on connect" preference (Device group, off by default) has the
interactive session write the host time to the radio once per connection — at
launch, on a deferred connect, and after a reconnect, since a link drop is exactly
when a board without a real-time clock may have rebooted and lost the time. The
set runs in a background task off the connect path and reports only in the log,
with the drift it corrected; a refused write costs a warning line, never the
session. Scripted CLI runs never start the service, so a one-shot command that
only reads the radio never writes its clock.

The device-side step is the one the Device config screen's Sync clock action
already ran inline in apply_ops; it is lifted into services/clock_sync.set_clock
and the action and the service both call it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 21:11:35 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 5747c120ac The uConsole manual is about the AIO board and says so; the Waveshare HAT is gone from it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:23:19 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 c75ad9a5ed hardware.md is the page that says which manual is yours; the manuals stand alone
docs/hardware.md shrinks to a router: which guide for which device, and the
two platform flavours. Everything the guides were still reaching into it for
moved home — the bridge's environment knobs into uconsole.md, and the
verified/unverified lists into each manual's closing section, split by
device (the PicoCalc's gains the MX1.25 pin order and the 5 V path, both
inferred rather than measured on a Lyra).

The PicoCalc manual gets the Wi-Fi kick back — it works and stays; only the
join script is going. The uConsole manual opens on the same kind of
disclaimer as the PicoCalc's, minus the hardware-handling parts: a LoRa
transmitter, a Linux shell, the guide may be wrong or stale, your call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:21:09 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 49a609fabf The 6x12 console-font script carries its size in its name, like the 6x8 one
scripts/calculinux-console-font.sh is now calculinux-console-font-6x12.sh.
Every reference follows: the setup script's hand-off, the 6x8 script's
SHARED default and comments, the theme16 test that parses it, the module
docstrings, the two manuals, the scripts index and CLAUDE.md. The font files
it builds keep their names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:00:46 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 4c5773191c Both manuals ask to be read through once before anything is bought or opened
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 01:49:05 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 9a62e20ffe The PicoCalc manual no longer mentions the Wi-Fi scripts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 01:41:59 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 18517f88db The back panel may need a filed cutout and a snipped grille to close over the Lyra and its Wi-Fi lead
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 01:31:36 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 2725215484 The disclaimer says the guide may be wrong or stale, and checking it is the reader's job
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 01:27:14 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 22e3642ca5 The PicoCalc manual opens on a disclaimer, and the radio wires are soldered from inside the case
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 01:24:25 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 4afb70378a One USB-C cable on the shopping list, not two
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 01:20:54 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 92f6f20dd3 The screen and screw-post warnings say the author broke both
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 01:17:10 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 06fbe0313b The PicoCalc manual names the three ways builders break the kit, each behind a warning
A "Three ways to break it" chapter before Phase 1: the bare glass screen that
cracks when the mainboard flexes (do the Pico swap with the board out of the
case, on a padded surface, and tape the panel's edges with Kapton), the brass
screw posts that snap out of the shell when a screw is forced, and the charge
control that powering the core board's own USB-C bypasses (forum members
measured cells at 4.46 V and 4.74 V after powering the Pico's port). Steps 1
and 2 and the day-to-day section now follow it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 01:11:10 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 8edc8e7b55 The Wi-Fi lead is soldered, not bought: pinout, a 3.3 V check, and Kapton on the list
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 00:56:42 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 a43f2bf3ad The Wi-Fi dongle hangs off the Lyra's MX1.25 USB host socket, not its USB-C
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 00:46:08 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 cab05b282a The PicoCalc shopping list names the Wi-Fi dongle the guide was built with
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 00:42:14 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 8533d9a9e8 MeshTerm on the PicoCalc is a manual from the shopping list to a working radio
docs/picocalc.md starts from a stock PicoCalc with its Pico and ends with a
Linux handheld running MeshTerm with a LoRa radio soldered inside: a priced
shopping list, the Lyra swap, writing Calculinux, the serial console, Wi-Fi,
the setup script phase by phase, then PlatformIO, the firmware build with the
patch explained hunk by hunk and a by-hand route for when it stops applying,
flashing, the four wires, the Lyra setup, day-to-day use and troubleshooting.

The README gains a Documentation table and points the handheld paragraph at
the two manuals; the script indexes and the changelog follow. hardware.md
stays as the reference behind both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 23:38:21 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 eb96d6b449 MeshTerm on the uConsole is its own step-by-step manual
docs/uconsole.md walks a uConsole with the AIO LoRa board from requirements
to a running bridge and a connected MeshTerm: the system prep, the radio
runtime (and the bookworm/trixie split), the preflight, a foreground run,
the user service, the profile, updating, troubleshooting, and what has been
tested. Plain language, one step at a time; docs/hardware.md stays as the
reference behind it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 23:34:03 -04:00
Jean-Pierre Martineau ce4ff0f57e MeshTerm 0.3.5
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-14 01:50:09 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 e9261569a2 The PayPal donation link is PayPal's own donate address, not a shortener
The Support page's text and QR code and the repository's funding file all pointed at a
tinyurl redirect, which a reader could not audit; they now carry the hosted-button address
itself. The page still renders on both platforms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 00:56:16 -04:00
Jean-Pierre MartineauandClaude Opus 5 fda25aaa33 Emoji width: a skin tone takes no cell, and a stranded joiner joins nothing
Two real contact names pulled the purge preview's reason lanes out of line, and the
Contacts list and chat picker would do the same, since all three pad names through one
measurement.

"Tech-Info 👨🏻‍💻" carries a skin tone. Rich measures the modifier zero, as the
terminal draws it; wcwidth gives it two, so prompt_toolkit reserved four cells for a
two-cell glyph. The cache now measures a modifier zero at either calibration, which lets
prompt_toolkit fold it into the cell before it the way it folds a variation selector,
and the cluster merge carries it inside a joined run.

"That's So Fetch 🏴‍" is a pirate flag cut at the name's byte limit, just past its
joiner. Both authorities folded whatever followed a joiner into the glyph, and here
that is the lane's padding space, so the name measured a cell short. A joiner now joins
only a pictograph. On the width-2 path Rich's own loop is wrapped for this one case; its
table is still left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 00:15:36 -04:00
Jean-Pierre MartineauandClaude Opus 5 5128cb23e2 The purge counts in columns, and each reason starts in its own
The target ladder described every rung in a phrase, "keeps 45 · archives 12", so
comparing two rungs meant finding the numbers inside two sentences. The counts are
columns now, right-aligned under a pinned TARGET / KEEPS / ARCHIVES header like the
editors' lane header, one rung lane measured across both sections so the numbers run
straight down the whole ladder. Keeps still leads. A rung that archives nothing shows
a muted 0.

The preview joined its reasons with " · ", so the second and third began wherever the
first happened to end. They sit in three lanes measured against the victim list, and
remeasured when a spared row leaves, under the one WHY IT RANKS LOW header: a lane holds
a contact's n-th weakest reason, whose kind varies from row to row, so a word per lane
would claim a kind the column does not keep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 00:15:21 -04:00
Jean-Pierre MartineauandClaude Opus 5 e71aa65ab9 The map credit tests read the bottom rule's corners in either shape Rich draws them
Rich draws a panel's corners rounded, and square only where it judges the console
legacy Windows, which a captured pytest run on Windows is. The credit tests looked for
the square corner, so they passed here and failed on every Linux and macOS runner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 22:59:26 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 4445c3aaf9 The README shows the five screenshots, the PicoCalc on its own under the four captures
Dashboard, map, trace and the Message paths dialog two by two, each with an alt text that
says what it shows, and the PicoCalc photo alone beneath them, downscaled to 1200 px wide so
the README does not load a 7 MB camera frame. The placeholder comment is gone with its job.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 18:31:04 -04:00
Jean-Pierre MartineauandClaude Opus 5 85792c1390 A release puts its version on meshterm.net
github-release.yml gains a website job: once the release is published it rebuilds the meshterm.net site from the tagged tree and pushes it back with a deploy key scoped to that one repository. The release skill says what to do when only that job fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:08:34 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 bca1c83dcd MeshTerm 0.3.4
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
The macOS installers of 0.3.3 never built: the notices step refused pyobjc-core and
pyobjc-framework-libdispatch, whose wheels ship no licence text. Their MIT text is vendored
now, and a test checks every vendored path on every platform.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 15:50:53 -04:00
Jean-Pierre Martineau 48f975b33b MeshTerm 0.3.3
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
2026-09-13 15:44:44 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 8a1bb875a7 On a bordered frame the map's credit moves into the bottom rule, out of the map
The credit was stamped over the right end of the drawing's own last row on both
platforms. That is the traditional corner and it is where a frame with no bottom
border has to put it, but where there *is* a bottom border there is already a rule
under the body doing nothing except closing the box — and a caption set into it
costs the picture nothing at all. JP, looking at the desktop map: "that way it's
not in the map."

So a screen may now hand the frame a bottom_caption, which _panel_box sets into the
bottom rule right-justified through Rich's own subtitle machinery — landing as
"└──… © OpenStreetMap ─┘", one rule cell before the corner, the mirror of the way a
title sits in the top rule. The rule's other occupant keeps its place: the clip
arrows still lead, centred when they are alone and joined by the caption, right, when
both are wanted, because dropping either would be the frame deciding something
neither screen asked of it. The caption joins the base box memo's key as well — the
credit collapses on a keystroke that need not change a single body line, and a memo
blind to it would go on serving a rule the reader has already grown out of.

The map asks attribution for both halves and tests nothing itself: rule_caption gives
the caption where the platform's frame has a rule, map_body stamps the drawing where
it hasn't, and exactly one of them draws. Which it is, is bound once at platform-switch
time rather than asked per paint.

Nothing about the PicoCalc changes — same strings, same corner, same collapse, and the
minimap still stamps on both platforms, having no frame of its own to set anything
into. JP read that form on the device's own panel on 13 September and signed it off as
it stands; the docstring now says so, so nobody goes tidying it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:47:28 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 8916544bfb CLAUDE.md states what every file and every build carries: the SPDX line, the map credit, and what NOTICE is for
The three rules the licensing pass settled, pinned where the other binding standards live so
a new file, a new map surface or a new NOTICE paragraph is held to them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:21:13 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 3e80074dbf The standalone builds carry their own licences, and everyone else's
The five installers bundled Cascadia's font licence and nothing else --
not MeshTerm's own LICENSE and NOTICE, not the MIT/BSD/PSF notices
Apache-2.0 and those licences all want kept with a redistributed copy.
packaging/notices.py walks the mesh-term distribution's actual runtime
dependency closure through importlib.metadata (markers evaluated for
this platform, the dev extra excluded), gathers each package's licence
text, and fails the build loudly if one is missing rather than shipping
a silent gap -- which is exactly what it found: pyserial and the nine
winrt-* packages bleak's Windows backend pulls in all declare a licence
but ship no licence file in their wheel at all. Their real text, pulled
from each project's own repository, is vendored under
packaging/vendored-licenses/ for those two documented, verified gaps;
anything else missing still stops the build.

The spec generates THIRD-PARTY-NOTICES.txt fresh into the build
directory on every run and bundles it at the archive root alongside
LICENSE and NOTICE. installers.yml uploads the same three files once,
from the linux-x64 leg, so they land beside the downloads on the
release page too, without extracting an installer just to read them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:18:35 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 6d7be118b3 The map credits OpenStreetMap on the map, and shrinks the credit once it is used
Only the About page and the README said where the streets came from, which is not
where anyone looking at a map would look for it. OSMF's Attribution Guideline asks
for the credit in a corner of the map itself, visible without having to interact
with it, and lets an interactive map collapse it once the reader pans or zooms so
long as the licence stays findable — an "About option in a menu" is its own worked
example, and we have one.

So the full-screen map is arrived at with "© OpenMapTiles · Data from
OpenStreetMap" — OpenFreeMap's required line, less the half it calls optional — in
its bottom-right corner, and the first key it handles collapses that to "©
OpenStreetMap", which OSMF names as an acceptable form outright. The OSM credit
therefore never actually goes away; the collapse only spends OpenMapTiles' half of
the line. It costs the app no chrome at all: it is stamped over the right end of
the drawing's own bottom row at paint time, so no title atom, no footer character,
no body line, and no reveal key to buy a chip on a full F-key lane. Where the
PicoCalc echoes a find query over that same row the two share it, query left and
credit right, and the query is the half that gives way.

The node page's location preview takes the short form throughout: it is a picture
on somebody else's page rather than a map they went to look at, it has no pan or
zoom for a collapse to trigger on, and the short form is complete standing still.

The About page and the README now name OpenMapTiles too, which neither did, and
the page says out loud that the URL it prints is where the map's corner points —
nothing on a framebuffer console is clickable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:15:19 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 e7fb14bed0 The SPDX gate fails on a file it cannot read instead of skipping it, and the changelog says licence
A silent skip would let an unmarked file through, which is the one thing the test exists to
catch. The two new changelog lines now read in the file's own voice, and a docstring in the
logging tests is wrapped under the line limit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:15:06 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 ea5b6b2b62 Changelog entries for the MeshCore notice, the dual-licensed generator and the SPDX headers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:19 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 37cb6c7c84 The pull-request template says out loud that opening a PR accepts CONTRIBUTING's terms
The relicensing grant binds by conduct only if the contributor had a fair chance to see it, and the template mentioned CONTRIBUTING only inside an HTML comment the rendered PR never shows. One visible checkbox at the top of the checklist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:19 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 f16a7c4738 The shared font generator is offered under GPL-2.0-only too, so the 6x8 script may run it
The GPL-2.0-only 6x8 script executes the generator block out of the Apache-2.0 6x12 script, and GPLv2 and Apache-2.0 do not mix inside one program. Both files are the author's, so the block between the BEGIN/END markers is additionally available under GPL-2.0-only for that use; both headers and the hardware manual say so. No code changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 d078e2379f The XIAO firmware patch carries MeshCore's MIT notice beside it
meshcore-uart1.patch quotes upstream lines and build-firmware.sh produces a MeshCore binary, so the licence those copies travel under now sits beside them as scripts/xiao-radio/LICENSE.MeshCore, verbatim from upstream, and is named from the bench card and the hardware manual.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:17 -04:00
Jean-Pierre MartineauandClaude Opus 5 266aca08fb Preferences are kept in TOML, like every other file a person edits by hand
preferences.yaml becomes preferences.toml: config.toml and the device-config
backups were already TOML, and tomli/tomli-w already dependencies, so PyYAML
existed for this one file and is dropped. The file keeps its shape - flat
keys, only the overrides, grouped under comment headings with each entry's
help and default - and one name, PREFERENCES_FILENAME, for both call sites.

TOML refuses a whole document over one bad line, so a refused file is read
again a line at a time: a valid line keeps its typed value, a refused one
hands its right-hand side to parse_value as text. One broken hand edit still
costs only its line, and a word left unquoted (log_level = DEBUG) still reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 12:45:22 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 ec21d03c03 The SPI bridge drives openhop_core, and stands its shims down where the library caught up
pymc_core was renamed openhop_core in 2026 and the current meshcore-uconsole
package depends on the new name, so a bridge that only knew the old one failed
its own preflight on a fresh install. It now finds either runtime, newest first
(an interpreter with openhop_core beats an earlier candidate with pymc_core;
MESHTERM_PYMC_PYTHON still overrides), imports through the name it found, and
lets two of its three compat shims stand down under openhop_core, which accepts
long ACK payloads and pushes completed trace replies itself; pushing a trace
twice was the alternative. The raw RX-log push is still the bridge's.

What the port found on the device shaped the rest. The 1.12.0 .deb is built for
trixie against Python 3.13 and cannot run on a bookworm uConsole, so the bridge
looks first in a venv of its own (~/.local/share/meshterm-spi-bridge/venv, made
with openhop-core[hardware]) and, without the GUI's helpers there, configures
the radio itself with the uConsole AIO preset rather than the runtime's
Waveshare defaults (TX-enable on pin 6, no DIO2 switch, no TCXO -- deaf on the
AIO), every MESHCORE_* knob the GUI reads honoured and passed only where the
constructor accepts it. It also loads the GUI's identity key first, so a bridge
in its own venv is still the same node. Verified on the uConsole: openhop-core
1.1.3, radio up, 159 contacts restored, info and contacts answered over TCP,
and the boot service now runs from that venv. The manual and the index say all
of this instead of the rename warning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 01:29:28 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 097d2ef495 Under --mock, devices lists the simulator and scans nothing
--mock promises a session with no real hardware, and a scan was the one thing
the devices tool did regardless: it walked the serial ports and switched the
Bluetooth radio on, which broke the promise on the first command a stranger
without a radio would try, and which the test sweep had to work around with
--no-ble. Now the simulator is the inventory under --mock: one row with the
flag itself as its target, since that is how this device is selected, and a
stable id of its own. The sweep's workaround is gone and a contract test
replaces both discovery entry points with ones that fail if reached.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 01:16:27 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 356ca6a94a The widgets CLAUDE.md names as THE widgets have public names
A leading underscore says "internal to this file, don't import me", and the
column-age formatter and the node glyph table wore one while seventeen modules
imported them and CLAUDE.md named them as the one place each concept lives.
format_age sits beside format_ago now (prose age, column age), NODE_GLYPHS and
DEFAULT_GLYPH beside them, age_seconds with the ages. A mechanical rename, 140
references across 24 files, CLAUDE.md's three lines with them so the guide and
the code move as one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 01:13:18 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 c974085384 The README holds four screenshot slots, named for what each one shows
Four image links under docs/screenshots/ (dashboard, map, trace, picocalc)
with a comment above them saying what to capture, so taking the pictures is
the only step left. They render as soon as the files exist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:58:10 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 646a0b7b7c Picking a console font is its own preview, and a discard puts the saved one back
The Preferences page loads the font the moment its value is picked, rather than
after Apply: a VT has one font for the whole screen, so the only honest way to
show what the 6x8 build looks like is to load it, and the page is the preview,
repainting at the new row count on the kernel's SIGWINCH. The page tracks what
the console is drawn in right now; staging the saved value again reloads it at
once, Apply keeps whatever is showing (the tool's re-apply after the write then
finds nothing to change), and leaving with a discard reloads the saved font.
Off the handheld the service says no once at page open and is asked nothing
more. Four tests drive the page through each path with the service stubbed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:40:55 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 3b67c6813e The PicoCalc's console font is a preference, and the shell gets its own back
A Linux VT loads one font for the whole console, so asking for the 6x8 build
(53x40, fourteen rows more) changes what the shell is drawn in too. The new
console_font preference (6x12 default, 6x8 the alternative, offered on the
PicoCalc only, round-tripping through preferences.yaml on every platform)
therefore comes with services/consolefont.py: remember the font the shell had
with `setfont -O`, apply the one the preference names, and restore it on every
way out -- the menu's finally and an atexit hook, since ^Q and an unhandled
error leave by other doors. Verified on the device that setfont -O captures
the current font with its unicode table and that feeding it back reproduces it
exactly, with no sudo, because the VT is the process's own controlling
terminal. Only the interactive menu switches; a scripted run never touches the
console it was invited into. Changing the preference on its page applies live.

Shipping both fonts needed the licence untangled: the 6x8 base is the Linux
kernel's font_6x8 bitmap, GPL-2.0, so its build moves into
calculinux-console-font-6x8.sh under that licence with the GPLv2 text beside it
in scripts/LICENSE.GPL-2.0. The shared generator stays in the 6x12 script
between BEGIN/END markers and the 6x8 script executes that block, so the marks,
donors and aliases the font contract pins exist in one place. The produced PSF
is a data file the kernel console reads; MeshTerm never links or bundles it, and
NOTICE and the hardware manual say so. PrefSpec gains a platforms gate for the
page; the labels use an ASCII x because U+00D7 is not in the console font.
The 6x8 base has no Cyrillic; the manual says so and the owner accepts it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:52:54 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 ffd2649a35 Shared logic sits in the layer that reads it, and the CLI answers --version
A pre-release architecture and QA pass, applied as one change because the moves
share files and only the whole set was gate-tested (2480 passed, ruff clean).

Three new modules pull logic down out of layers that should not own it:
core/channel_probe.py (ChannelSlot, read_channel_slots, probe_channel_slots)
so services/device_state.py no longer imports a screen; services/markers.py
(gather_markers, with MapMarker a core/models dataclass) so two screens stop
importing a tool; core/tracing.py (trace_timeout, path_hash_flags) so
core/connection.py no longer imports services. The routing-prefix width is
DeviceState.routing_prefix_bytes(), replacing five private cross-module imports.

One definition per thing: is_path_hash, the NodeResolver/NameKeyResolver
aliases, TILE_RETRY_SECONDS, haversine_km, marks.SELF_MARK, MENTION; nine
private names that crossed module boundaries got public ones and menus.__all__
lists what other modules import. A preference's default is written once
(ALERT_CAP, DONE_CAP, DEFAULT_VIEW_FRACTION read the registry), the stranded
PREFERENCES doc comment sits on its subject, and three docstrings stop naming
questionary, a library dropped long ago.

The command line gains --version (eager, exit 0, documented, tested) and drops
the -p short form on trace, trace-path and tx-optimize: the root lifts -p as
--profile before a leaf ever sees it, so the leaf's could never work, and a
derived test now checks every leaf against every root short option.
tx-optimize with a one-hop --path is a usage error, exit 2, because nothing
transmitted is not a device failure. Log handlers are closed before they are
cleared (197 unclosed-file warnings gone), chat's fire-and-forget tasks keep a
reference, coroutines use get_running_loop(), the never-awaited MapScreen._load
in a test is awaited, and the CLI sweep passes --no-ble to devices so a test
run stops turning on the Bluetooth radio three times (the suite is 40 % faster).
The about page lists every dependency, and the release skill bumps the README's
download names, which still said 0.2.8.

apply_ops stays in tools/: it and its helpers build report blocks and open two
screens, so moving it to core/ would invert core over ui. The widgets CLAUDE.md
names by their private spellings (_format_age, _NODE_GLYPHS) keep them until
CLAUDE.md is edited alongside.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 19:19:45 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 8b80a88f03 The sdist is the package's source, not the repository's
Hatchling's default sdist is everything git does not ignore, which packed the
Claude skills, the GitHub configuration, the PyInstaller spec, the device-side
scripts and the developer environment template into an archive whose only job
is to build and test the wheel. An exclude list keeps the package, the tests
and their fixture, the manual and the files a build reads. The build backend
floor is hatchling 1.26, the first to write the PEP 639 licence fields into the
metadata; an older one built a wheel that said nothing about the licence.
Workflow comments that reasoned from a private repo's metered minutes are
reworded for the public one, a skill's usage line stops naming one machine's
venv, and the scheduler's lock file is ignored in the tree rather than only in
one clone's info/exclude.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 19:02:02 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 8d3f5d8ed3 MeshTerm on hardware: one manual for the PicoCalc, the XIAO radio and the SPI bridge
docs/hardware.md is the single place a reader goes to put MeshTerm on a
PicoCalc or a uConsole, written in the shape of the command-line manual: what
the three targets are and which exclude each other, why the console font is
built on the device, every phase of the Calculinux bring-up and its knobs, the
XIAO UART radio from parts to a verified link, the SPI bridge with an honest
note that it was written against pymc_core 1.0.x while today's uConsole stack
depends on the renamed openhop-core, and a closing list of what has only ever
been proven on one bench. scripts/README.md and xiao-radio/README.md become
short indexes pointing at it; the old ones were stale in four places (trace
through the bridge works, the firmware env is _serial, two scripts were
unlisted). README links the manual, gains the two dependency rows it lacked
(markdown-it-py, PyYAML) and a fixed fonts anchor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 19:02:02 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 9ebc4bd2be The device scripts say what they do, and the bring-up no longer needs a deploy key
A read-through of scripts/ ahead of the public release, applying only what could
be verified from the code. The setup script clones over public HTTPS when no
deploy key sits at KEY_PATH and keeps the SSH path when one does, and its
timezone is a TIMEZONE knob rather than a hardcoded city. The flasher recognises
the plain XIAO nRF52840's bootloader (PID 0x0044) as well as the Sense's
(0x0045), both from Adafruit's board.h. The mux unit was After= the very target
that WantedBy= pulls it into, an ordering cycle; it now orders on basic.target,
which still wants a check on the device. The console-font header stopped
claiming a default run installs /etc/vtrgb (it removes the custom remap), and
states that the font it builds is an OFL-1.1 Terminus derivative that must not
be called Terminus and that the optional 6x8 candidate embeds a GPL-2.0 kernel
bitmap. The radio-bridge header describes the four phases the script actually
runs instead of a pin-routing phase it never contained. Personal remarks in
comments are reworded impersonally.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 19:02:02 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 72fc0665c5 A record's header reads as a sentence, and the bar says only Record
"🛣 Longest distance — 5th place: 112.0 km": the discipline written the way its board
heading writes it — discipline_label, the mark padded to the measured lane so a
one-cell 🛣 and a two-cell 🎯 start their titles in the same cell — then the standing
and the metric. The bare mark-and-number it replaces was dry, and it had to lean on the
title bar for the discipline; now the header names the record and the bar names the
kind of page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 13:38:40 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 6e84c7fe54 A record's metric heads the page, above the tabs, under its discipline's mark
The node page's identity header, for a record: one line above the strip carrying the
discipline's own mark (the one its board heading wears) and the score in the
discipline's unit, which is the one thing the record is — the title bar already names
the discipline and the standing. The score lane that repeated it on Info is gone, and
the metric's spelling is one function, record_score, shared with the board's score
lane so a partial Longest-distance walk reads ≥ in both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 13:22:45 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 aae3d3f0fe A record's tabs are Info, Route and Area, organized like the node page
Info is the record's stats with the page's actions at its foot, the cursor opening on
Trace; Route is the walk two ways — THE route graph, its key, the route line — with
Trace this path as its one row, so Enter there walks the route on show, the node page's
rule for its Routes tab; Area is the ground drawing, unchanged. Each tab's stage is
composed once per width and cached, and the footer names only what the tab answers: a
lone action row earns no ↑↓ atom. The gallery gains the Route tab as a specimen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 13:13:01 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 ea8ae61812 A record is a tabbed page: Stats, then the walk's ground on an Area tab of its own
The node page's shape (widgets.tab_strip, Tab/Shift+Tab, the next tab on F3): Stats is
the record read — its stats, identity first, then the route two ways — with the action
rows at its foot; Area is the ground drawing across the whole stage the viewport leaves
under the strip, so the shape gets every row and column the page has rather than the
block a stacked layout could spare it. The Area tab is offered only for a walk with a
drawable shape; a record without one keeps a lone-tab heading and nothing to switch. A
page too narrow to draw on says so instead of squeezing.

widgets.tab_air is the strip's platform-bound air, hoisted from the node page so both
tabbed screens spend it the same way. The gallery gains the Area tab as a specimen on
both platforms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 04:00:22 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 197af90c8e A share code is centred as a block, never justified — the bottom finder was skewed
Rich's centre justify strips each row's trailing spaces before it pads it, so a
code row whose right edge was light modules lost cells and landed a column off
its neighbours; the bottom-left finder square came out one cell skewed, and a
camera could not lock on. The About pages' codes were never centred, which is
why only the full-screen share codes broke.

A code is now moved across a line one way only: the same indent in front of
every row, computed once from the fitted code's width. The test pins that all
rows share a width and every finder starts in the same column.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 03:29:46 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 4b605c1da7 The record page reads by its sections, and the ground drawing spends the page's width
Three body headings — Stats, Area walked, Route — each with the note that used to be a
faint caption under its block, so a page this tall can be skimmed by its landmarks. The
record's identity leads its stats: the spec walked and when it was set, then what the
walk measured. The area drawing takes the page's whole width and rows in step with it,
a quarter of the columns (half as tall as wide in braille dots), so a wider page is a
bigger shape rather than a padded one; rows a flat shape leaves blank are still
trimmed, so a tall box costs it nothing.

widgets.body_heading is THE form for a heading inside a page's body — accent title,
muted note — hoisted from the Time Machine's private copy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 03:10:01 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 b56987b24b A QR code is white on black, and the whole screen when it is the answer
A camera reads contrast, and a light-on-dark code is what a scanner expects
of a screen, so every code MeshTerm draws is now pure white ink on a pure
black field (the theme's `qr` style, both ends named on both themes), never
black on white.

The share views — a channel's share link, a contact card, the CLI's share
commands run in the menu — are no longer a titled popup with an instruction
over the code. They are `QrScreen`, a **bare frame**: `Screen.bare` and
`frame.compose_bare` draw the body alone on blank rows, with no header, no
footer, no title bar, no box, no hint and no F-key lane, and nothing on it
but the code and the URL it encodes, sat a little above centre. Esc leaves.

The code fits itself to the frame every paint, width and rows both: the
standard fit first, then a lighter error level, then a narrower quiet zone,
before it would ever be cut — a contact card is 57 cells and 29 rows at the
standard fit, the PicoCalc is 53 across and a regular terminal 24 tall. When
code and URL can't share the frame the code is whole at the top and the URL
a page down. The frame states its height before it asks for the body, which
is what lets the screen size to it.

A `qr` fence in a written page is the one exception and keeps its place in
the prose — the About pages' codes flip to white on black like the rest.

Found on the way: the PicoCalc's SGR quantizer matched a colour only when it
was a whole sequence, and Rich writes a style's foreground and background as
one sequence (`ESC[38;2;…;48;2;…m`), so a white-on-black module reached the
console in truecolor. It now walks every sequence's parameter list.

The standard is written into CLAUDE.md under Screens and lists; the gallery
carries the share screen as a specimen on both platforms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 03:05:34 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 121f75c2a0 A record's ground is drawn under the stats, each pin labelled with its hash byte
The area drawing rode beside the stats in a third of the width, which squeezed the
shape and left no cell for a label on any pin. It is a block of its own now, between
the stat lanes and the route graph — page-wide up to a cap, a fixed eight rows, north
up, the shape against the left edge under the left-aligned stats — and every hop's pin
carries its first hash byte, placed the way the map places a name (beside the pin,
clear of the drawn lines where it can, dropped rather than overprinting), so a pin and
the graph node under it match by eye. The star stays bare: it already says who that is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 02:50:50 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 07a566d49d A trophy case record is a page, not a card floating over the list
The reader is in a record — reading its stats, scrolling the route, arming a trace from
it — so it fills the frame like the browser under it and Esc reads back. It used to
float as a 62-column card, which read as a question over the list rather than as the
page it is. RecordDialog is RecordScreen, full-frame, and the reading width it asked for
as a dialog goes with the name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 02:35:52 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 6bdfa26160 The trace target picker is a popup, gone before the trace screen opens
The third lead-in picker kept pushed as a hub, and the one the ledger said would stay:
a contact list under the trace screen, so Esc out of a trace landed on the list — two
places where the trace is the whole visit. It is a question, and asks like the other
two: the contact list floats over the menu as a one-shot (run_dialog, public now; the
list stays a full-screen place everywhere else), the trace screen opens over the menu,
and Esc from it lands there. The typed fallback floats the same way instead of filling
the frame.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 02:25:17 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 83e2aa80ae The trophy case fills the frame whichever way in
A bare SelectScreen floats by default, and the browser was full-frame from the menu
only because nothing sat under it; opened from a trace, over the still-pushed trace
screen, the same page came up as a content-sized box. It is a place, not a question,
and declares so like the contact list does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 02:21:42 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 1f34d35d2a Survey ledger: the base under a popup is the menu
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 02:16:13 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 b1dbbe5a5a A popup with nothing under it floats over the menu, never over an empty frame
The menu is popped while a tool runs, so a lead-in question — the TX optimize link, the
node to manage, the password that follows it — had a blank base pushed under it to draw
as a box at all. A dialog is smaller than the frame, and what shows around it should be
the page it was reached from: the root the menu already declares for ^W is now the base
_floated pushes on an empty stack, and the reconnect dialog draws over it too, so the
menu loop stops forgetting its root on the way out. A session with no root declared
keeps the blank fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 02:16:01 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 13f0c647de The navigation standard says what a popup is: a question, not a place
Two flows were rebuilt on it today and nothing had it written down — the stack is for
places, a popup informs, confirms or asks and is then gone, and a question draws as a
box even as the only frame on the stack. The entry-chain rule gains its list form,
run_wizard, one popup turning its page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 02:05:49 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 a8b69efa73 The repeater admin's node picker is a popup, gone before the admin page opens
The same shape TX optimize just lost, with one popup instead of two: the node list was
kept pushed as a hub under the password prompt and the admin page, so Esc from the page
landed on the list it was picked from — two places where there is one. A popup asks and
is gone: the pick is now a floating one-shot (session.select gains text's floating
flag, so a lead-in question draws as a box even as the only frame on the stack), the
admin page opens over the main menu, and a refused login re-asks the question with the
same node highlighted. admin_node_visit is deleted; nothing keeps a popup pushed now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 02:05:21 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 222679c5a3 The stepped dialog draws as a popup, not as the frame
The menu is popped while a tool runs, so a floating screen that is the only frame on the
stack was painted as the full-frame background — the TX optimize link dialog opened as
a page and only its sub-prompts looked like popups. The blank base _run_dialog_screen
puts under a one-shot dialog is now one context manager, and stay(dialog=True) puts the
same base under a popup's whole visit, which is what run_wizard asks for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 01:58:38 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 930596130f The TX optimize link is picked in one dialog that turns its page, gone before the sweep
Two pickers stacked one over the other, each kept pushed under the sweep as a hub, read
as two places to be — and a popup is not a place: it asks, confirms or informs, then it
is gone. The node to tune and the node to measure at are now one floating list that turns
its page ("… · 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 screen opens, so Esc from the sweep lands on the main menu.

menus.run_wizard is the dialog form of run_steps — the same stack semantics drawn in one
box — and SelectScreen.turn_page is what it turns on: the opposite claim from
replace_items, since a page turned is a new question and neither the typed filter nor the
highlight rides along. A step with nothing to list (the typed hex prefix when the tuned
node is the only contact) runs as an awaitable floated over the box's last page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 01:52:37 -04:00
Jean-Pierre MartineauandClaude Opus 5 e24e41178a The delay settings round to one decimal, on the repeater admin and device config
TX delay, direct TX delay and RX delay show, stage and send at one place instead
of two. Device config's RX delay had no rounding at all, so SettingSpec gains
the same decimals field the remote catalog carries, applied on parse and display.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 01:19:30 -04:00
310 changed files with 17962 additions and 5601 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 *)"
]
}
}
-107
View File
@@ -1,107 +0,0 @@
---
name: add-narrow-emoji
description: Fix a MeshTerm row whose layout an emoji breaks — a right border pulled a column short, or a glyph overrunning its lane — because the terminal draws it in a different number of cells than Rich/prompt_toolkit measure. Works in both directions: a glyph drawn narrower than measured joins the narrow allowlist, one drawn wider joins the wide set. Classifies the emoji (lone codepoint, country flag, VS16 sequence, or ZWJ/other cluster), says whether it is already listed, and adds it the correct way. Use whenever an emoji "fucks up the layout", notches a panel border, or smears a row.
---
# Adding an emoji to MeshTerm's terminal-aligned width sets
MeshTerm aligns Rich's and prompt_toolkit's glyph widths with how the terminal
*actually draws* an emoji, so a row frames flush instead of notching its right border a
column short or overrunning its lane. The machinery lives in
[emoji_width.py](../../..//meshterm/ui/tui/emoji_width.py); the design is in that module's
docstring and in the `glyph-width-alignment` memory. Background: the terminal's
narrow/wide split is **per-glyph and font-driven** — there is no rule and no way to probe
it (a cursor probe reads the PTY, not the renderer) — so the exceptions are two
hand-curated sets, extended one confirmed glyph at a time. This skill does that one
addition correctly.
## When to run
An emoji in a row breaks its layout, in either of two ways:
- **Border pulled in** (a blank sits between the border and the frame edge), or a smear to
the right of the glyph — the terminal draws it in *fewer* cells than the authorities
reserve. → the narrow allowlist (`_DEFAULT_NARROW_LONE`).
- **The row overruns**, pushing what follows a column right — the terminal draws it in
*more* cells than the authorities reserve. → the wide set (`_DEFAULT_WIDE_BASE`). In
practice this only happens to a **VS16 sequence** the narrow verdict got wrong; a *bare*
codepoint both authorities call one cell is measured correctly and must be left alone.
Step 1 tells you which; it also tells you when the glyph is **already listed**, which is
the commonest answer to "please add this one".
## Steps
1. **Classify the emoji.** Run, from the repo root:
The interpreter below is `$DEV_VENV_PYTHON` from `.dev.env` at the repo root (gitignored;
`.dev.env.example` shows the shape) — read it first.
```
$DEV_VENV_PYTHON .claude/skills/add-narrow-emoji/classify.py <emoji>
```
It prints the codepoints, how each authority measures it now (`Rich now`, `pt now`)
and after the fix (`Rich fixed`, `pt fixed`), the class, and the action. `pt now` is
the number that matters — prompt_toolkit places the panel border.
2. **Act on the class.** The machinery runs in *both* directions — a glyph drawn narrower
than measured joins `_DEFAULT_NARROW_LONE`, one drawn wider joins `_DEFAULT_WIDE_BASE`
— and the classifier names which, including "already listed", the commonest answer:
- **already listed** (either set): nothing to add. If a row still misaligns, some
*other* glyph on it is the culprit — classify that one.
- **lone wide codepoint** (e.g. `👋`, `👍`): the narrow-allowlist case. Only add it **if
the terminal draws it in one cell** — confirm visually first. Append the character to
`_DEFAULT_NARROW_LONE` in [emoji_width.py](../../..//meshterm/ui/tui/emoji_width.py):
`_DEFAULT_NARROW_LONE = "👋👍"`. (For a throwaway session test instead of a code
change, set `MESHTERM_NARROW_EMOJI="👋👍"` in the environment.)
- **lone narrow codepoint, bare** (e.g. `🛣` U+1F6E3, `🕸` U+1F578): **no change**, and
resist the pull to "fix" it. Both authorities say one because the codepoint is outside
Emoji_Presentation, and with no VS16 asking for emoji presentation the font draws a
one-cell *text* glyph — they are right. Forcing it into `_DEFAULT_WIDE_BASE` reserves a
cell the terminal never draws and pulls the row's border a column **in**; that is
exactly what listing `🛣` did to the Trophy case, while `🕸` — same class, one board
down, never listed — framed flush throughout. Both authorities already agreeing is not
a bug to correct.
- **VS16 emoji-presentation sequence** (e.g. `☀️`): **handled by default** (the variation
selector is skipped, so the base is measured alone). But the renderer is not uniform —
if *this* one paints two cells wide, add its **base** codepoint to `_DEFAULT_WIDE_BASE`,
as `🛩️` (U+1F6E9) needed. Keying on the base makes the bare form ride along.
- **flag (Regional Indicator pair)** (e.g. `🇨🇦`): **already handled** as a whole
category — no entry. Confirm `pt fixed` is 2 and stop. Never list an individual flag:
indicators are shared, so it would half-fix that flag's neighbours and skip the rest.
- **ZWJ sequence / keycap / skin-tone / other cluster** (e.g. `👨‍👩‍👧`, `1️⃣`, `👋🏽`):
**not covered** by the lone/flag mechanism — resizing its parts individually would
misalign it. Do not force it into either set; note it for bespoke handling.
3. **Extend the tests.** In [tests/test_emoji_width.py](../../..//tests/test_emoji_width.py),
add the new glyph to `test_rich_cell_len_narrows_only_allowlisted_lone_emoji` and
`test_pt_cache_narrows_only_allowlisted_lone_emoji` (both authorities → one cell for a
narrow addition, two for a wide one), and to the width-1 calibrate test. Keep a
known-wide emoji (`📡`) asserted at two, so a future over-broad change is caught.
4. **Run the tests:**
```
$DEV_VENV_PYTHON -m pytest tests/test_emoji_width.py -q
```
5. **Confirm visually.** The fix can't be reproduced headlessly — a non-interactive run
keeps Rich's defaults, and CI can't see the renderer. Ask the person on the affected
terminal to send a chat message with the glyph and confirm the border is now flush and
nothing shifted. Only then is the addition trustworthy.
6. **Commit** with a message naming the glyph and why (see the repo's commit style).
## Guardrails
- **Never narrow a glyph the terminal draws two wide** (a menu icon like `📡`, `💬`), and
never widen one it draws in one cell. Each set only helps glyphs mismeasured in its own
direction; a wrong entry *introduces* the misalignment it was meant to remove. When
unsure, leave it out.
- **One glyph, confirmed, at a time.** An unlisted emoji is no worse than before (the
`erase_down` full-repaint still prevents a narrow one's smear); a wrongly-listed one
breaks a border. Bias to omission.
- **Do not touch Rich/prompt_toolkit width globally.** A blanket emoji remap has been tried
and reverted twice — it wrecks the menu icons the terminal genuinely draws two wide. The
curated allowlist and the flag category are the only sanctioned levers.
-141
View File
@@ -1,141 +0,0 @@
r"""Diagnose one emoji for the MeshTerm terminal-aligned width machinery.
Given an emoji on the command line, report its codepoints and how the three width
authorities measure it (wcwidth, Rich, prompt_toolkit), then classify it and print the
exact action the `add-narrow-emoji` skill should take. Run from the repo root:
d:\\vibe\\MeshTerm\\.venv\\Scripts\\python.exe .claude\\skills\\add-narrow-emoji\\classify.py 👋
The point is to remove the guesswork the skill's judgement call depends on: a lone wide
codepoint gets an allowlist entry, a flag is already handled by category, and a ZWJ /
keycap / modifier cluster is *not* covered and must not be shoehorned in.
"""
from __future__ import annotations
import pathlib
import sys
# The chat border fix only ever engages on a terminal that draws emoji narrow; measure
# there or trust the maintainer's eyes. This script just classifies — it never patches.
sys.stdout.reconfigure(encoding="utf-8") # print emoji safely on a cp1252 console
# Run from anywhere: put the repo root (four levels up) on the path so `meshterm` imports.
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3]))
import rich.cells as rc # noqa: E402
from prompt_toolkit.utils import get_cwidth # noqa: E402
from wcwidth import wcwidth # noqa: E402
from meshterm.ui.tui.emoji_width import ( # noqa: E402
_DEFAULT_NARROW_LONE,
_DEFAULT_WIDE_BASE,
_is_regional_indicator,
_make_cell_len,
_make_pt_cache,
)
_ZWJ = "‍"
_VS16 = "️"
_NARROW = frozenset(_DEFAULT_NARROW_LONE)
_WIDE = frozenset(_DEFAULT_WIDE_BASE) - frozenset((_ZWJ, _VS16))
def _base(emoji: str) -> str:
"""The emoji's base codepoint — what the wide set is keyed on (selectors dropped)."""
others = [c for c in emoji if c not in (_VS16, _ZWJ)]
return others[0] if len(others) == 1 else ""
def _classify(emoji: str) -> tuple[str, str]:
"""Return ``(label, action)`` for ``emoji`` — what it is and what the skill should do."""
if not emoji:
return "empty", "Nothing to classify — pass an emoji as the argument."
# Already curated? Say so first — it is the commonest answer to "please add this one",
# and every branch below would otherwise re-derive advice for a decision already made.
base = _base(emoji)
if base and base in _WIDE:
return (
"lone codepoint drawn WIDE — already listed",
"ALREADY IN _DEFAULT_WIDE_BASE (both authorities measure it 2, bare and with its "
"VS16 selector). Nothing to add. If a row still misaligns, the culprit is another "
"glyph on it — classify that one instead.",
)
if len(emoji) == 1 and emoji in _NARROW:
return (
"lone codepoint drawn NARROW — already listed",
"ALREADY IN _DEFAULT_NARROW_LONE (both authorities measure it 1). Nothing to add.",
)
if len(emoji) == 2 and all(_is_regional_indicator(c) for c in emoji):
return (
"flag (Regional Indicator pair)",
"ALREADY HANDLED as a category — no allowlist entry. Verify pt width is 2 below; "
"if so there is nothing to add. Never list a single flag (it half-fixes its "
"neighbours that share an indicator).",
)
if _ZWJ in emoji:
return (
"ZWJ sequence (multi-glyph cluster)",
"NOT covered by the lone/flag mechanism (a family, a profession, a flag-with-tag). "
"Do not add it to either set — the parts would be resized individually and "
"misalign. Flag this to a maintainer for bespoke handling.",
)
if len(emoji) == 1:
if wcwidth(emoji) == 2:
return (
"lone wide codepoint",
"IF your terminal draws it in ONE cell, add it to _DEFAULT_NARROW_LONE in "
"meshterm/ui/tui/emoji_width.py (or MESHTERM_NARROW_EMOJI for a session test). "
"If your terminal draws it TWO wide, leave it — narrowing would break its border.",
)
return (
"lone narrow codepoint (bare, text presentation)",
"NO CHANGE — and resist the pull to 'fix' it. Both authorities measure it one "
"because it sits outside Emoji_Presentation, and with no VS16 asking for emoji "
"presentation the font draws a one-cell text glyph: they are right. Forcing it "
"to two reserves a cell the terminal never draws and pulls the row's border a "
"column IN — the mistake U+1F6E3 🛣 cost the Trophy case. If this row really is "
"misaligned, another glyph on it is the culprit; classify that one.",
)
if base and _VS16 in emoji:
return (
"VS16 emoji-presentation sequence",
"HANDLED BY DEFAULT (the variation selector is skipped, so the base is measured "
"alone and the narrow-VS16 verdict applies). If your terminal draws THIS one two "
"cells wide anyway — the renderer is not uniform — add its BASE codepoint to "
"_DEFAULT_WIDE_BASE (or MESHTERM_WIDE_EMOJI), as U+1F6E9 🛩️ needed.",
)
return (
"multi-codepoint cluster (keycap / skin-tone / other)",
"NOT a plain lone codepoint — the lone/flag mechanism does not cover it. Flag this to a "
"maintainer rather than adding parts to either set.",
)
def main(argv: list[str]) -> int:
if len(argv) != 2:
print("usage: classify.py <emoji>")
return 2
emoji = argv[1]
label, action = _classify(emoji)
print(f"emoji : {emoji}")
print("codepoints : " + " ".join(f"U+{ord(c):04X}" for c in emoji))
print(f"grapheme : {len(emoji)} codepoint(s)")
print(f"wcwidth : {sum(max(0, wcwidth(c)) for c in emoji)} (per char: "
+ ", ".join(str(wcwidth(c)) for c in emoji) + ")")
print(f"Rich now : {rc.cell_len(emoji)}")
print(f"pt now : {get_cwidth(emoji)} <- the authority that places the panel border")
# "fixed" = what the width-1 calibration path already produces today, both sets applied.
print(f"Rich fixed : {_make_cell_len(_NARROW, _WIDE)(emoji)}")
print(f"pt fixed : {_make_pt_cache(_NARROW, _WIDE)[emoji]}")
print()
print(f"class : {label}")
print(f"action : {action}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+36 -5
View File
@@ -96,6 +96,23 @@ 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.
### The README needs nothing — deliberately
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.
`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.
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
```
@@ -121,16 +138,30 @@ gh run watch --exit-status
gh release view vX.Y.Z
```
Five platform builds plus the release job, so it takes a few minutes. **Check the notes
actually came out of the changelog** — a green run is not proof, because the fallback is a
successful step:
Five platform builds, the release job, and `website` — which rebuilds meshterm.net from the
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.
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
+2 -2
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://tinyurl.com/SupportMeshTerm"
- "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.
+4 -2
View File
@@ -4,11 +4,13 @@
## 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
- [ ] I've read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md) and agree its licensing terms apply to this contribution
- [ ] `python -m pytest -q` passes, including the dual-platform gallery gate
- [ ] `ruff check .` and `ruff format --check .` are clean
- [ ] New screens, rows and hints go through the helpers in `ui/menus.py`, and follow the
+11 -12
View File
@@ -18,7 +18,7 @@ permissions:
contents: read
# A second push while the first is still running makes the first answer obsolete.
# Cancel it — on a private repo those are metered minutes spent on a stale commit.
# Cancel it — a run on a stale commit is a queue slot and a wait spent on nothing.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
@@ -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
@@ -54,21 +54,20 @@ jobs:
# and one red leg cancelling its siblings hides the rest until the next push.
fail-fast: false
matrix:
# Shaped for a *private* repo, where minutes are metered and billed by a
# multiplier: Linux 1x, Windows 2x, macOS 10x. So Linux carries the full
# Python range and the costly runners cover the ends of it, which is where a
# version-dependent break actually shows up.
#
# Once the repo is public, standard runners are unmetered: widen this to every
# OS times every version and stop thinking about it.
# Linux carries the full Python range and the slower-to-schedule runners
# cover the ends of it, which is where a version-dependent break actually
# shows up. It was shaped this way when the repository was private and
# minutes were metered; on a public one they are not, so this can widen to
# every OS times every version whenever the wait for a full run is worth it.
include:
- { os: ubuntu-latest, python: "3.10" }
- { 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
@@ -99,7 +98,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.14"
- run: pip install build twine
+168 -9
View File
@@ -1,8 +1,8 @@
# Puts the downloadable builds on the repository's Releases page.
#
# Nothing here leaves GitHub. While the repository is private the release is private too,
# visible and downloadable only by people who can see the repo. This is *not* the PyPI
# publish — that is `release.yml`, it is a separate act, and it does not run from here.
# Nothing here leaves GitHub: a release is exactly as visible as the repository it sits
# on. This is *not* the PyPI publish — that is `release.yml`, it is a separate act, and it
# does not run from here.
#
# Triggered by pushing a version tag, which is the moment a release exists in git anyway:
#
@@ -11,6 +11,11 @@
#
# The binaries are built by `installers.yml`, called rather than copied, so the files
# people download are made exactly the way the ones CI checks on every push are.
#
# Once the release exists, `website` puts it on meshterm.net: it rebuilds that site from
# the tagged tree — the landing and About pages carry the version — and pushes the result
# to jpmartineau/meshterm.net, which GitHub Pages serves. It writes with a deploy key
# scoped to that one repository (secret SITE_DEPLOY_KEY); that repo's README makes one.
name: GitHub release
on:
@@ -50,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
@@ -57,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
@@ -87,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
@@ -94,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 }}
@@ -102,7 +209,59 @@ 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"
website:
name: put the version on meshterm.net
needs: publish
# A pre-release stays out of "Latest", so it stays off the landing page too.
if: ${{ !inputs.prerelease }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check for the deploy key
env:
SITE_DEPLOY_KEY: ${{ secrets.SITE_DEPLOY_KEY }}
run: |
if [ -z "$SITE_DEPLOY_KEY" ]; then
echo "::error::SITE_DEPLOY_KEY is not set; the meshterm.net README says how to make it."
exit 1
fi
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
path: meshterm
- uses: actions/checkout@v4
with:
repository: jpmartineau/meshterm.net
ssh-key: ${{ secrets.SITE_DEPLOY_KEY }}
path: site
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Rebuild the site from this release
run: |
python -m pip install --quiet -r site/requirements.txt
python site/build.py --meshterm meshterm
- name: Publish it
working-directory: site
env:
TAG: ${{ inputs.tag || github.ref_name }}
run: |
git add -A
if git diff --cached --quiet; then
echo "meshterm.net already shows ${TAG#v}."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898341+github-actions[bot]@users.noreply.github.com"
git commit -m "MeshTerm ${TAG#v}"
git push
+86 -10
View File
@@ -14,12 +14,12 @@ on:
# Callable, so the release workflow builds these binaries rather than keeping a second
# copy of the matrix that would drift from this one.
workflow_call:
# Only when the packaging itself changes. Three operating systems is not something to
# spend on every commit while the repository is private and minutes are metered.
# Only when the packaging itself changes. A five-platform build takes the better part
# of half an hour, and a commit that touched nothing it freezes has nothing to learn
# from one; the release build is what proves the binaries.
push:
# `main` only. Without this, every Dependabot branch that touches this file — which
# is every action bump — starts a five-platform build, and three of those legs are
# billed at 2x and 10x.
# is every action bump — starts a five-platform build of its own.
branches: [main]
paths:
- "packaging/**"
@@ -50,18 +50,19 @@ jobs:
# fail, it queues forever and holds the whole run open behind it.)
- { os: macos-15-intel, label: macos-x64, ext: "" }
- { os: ubuntu-22.04, label: linux-x64, ext: "" }
# For the uConsole and other 64-bit ARM handhelds. ARM runners are free on
# public repositories; on a private one this leg may sit unclaimed until the
# repo is public. Drop the line if it blocks you.
# For the uConsole and other 64-bit ARM handhelds. The ARM runners are the
# public-repository ones; a private fork of this file may find the leg sits
# unclaimed, and can drop the line.
- { os: ubuntu-22.04-arm, label: linux-arm64, ext: "" }
steps:
- uses: actions/checkout@v4
- 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
@@ -69,6 +70,37 @@ jobs:
working-directory: packaging
run: pyinstaller --clean --noconfirm meshterm.spec
# `packaging/meshterm.spec` regenerates `THIRD-PARTY-NOTICES.txt` fresh on every
# leg's own build (see `packaging/notices.py`) and bundles it, along with LICENSE
# and NOTICE, inside that leg's own installer -- so each downloaded binary always
# carries an accurate copy of what it actually contains. This step is a separate
# convenience: the same three files land on the release page itself, next to the
# downloads, so nobody has to extract an installer just to read a license. One
# copy serves every platform there (LICENSE/NOTICE never vary, and the runtime
# dependency closures differ only in a handful of platform-specific packages), so
# it rides on this leg alone rather than being uploaded -- and attached to the
# release -- five identical-in-substance times over. linux-x64 was picked
# arbitrarily among the five; nothing here is Linux-specific.
- name: Upload the licences for the release page
if: matrix.label == 'linux-x64'
shell: bash
run: |
# `build/<specname>/` is PyInstaller's own workpath layout (see the spec's
# comment on `workpath`), not something worth this step knowing by heart
# twice -- copy it flat, next to LICENSE and NOTICE, so the artifact (and,
# via `find`, the release assets built from it) is three plain files.
cp packaging/build/meshterm/THIRD-PARTY-NOTICES.txt .
- uses: actions/upload-artifact@v4
if: matrix.label == 'linux-x64'
with:
name: meshterm-licenses
path: |
LICENSE
NOTICE
THIRD-PARTY-NOTICES.txt
if-no-files-found: error
- name: Check it actually runs
shell: bash
run: |
@@ -87,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
+4 -3
View File
@@ -12,6 +12,7 @@ dist/
# planning that names hardware and unshipped ideas.
!.claude/
.claude/settings.local.json
.claude/scheduled_tasks.lock
.claude/plans/
.claude/housekeeping.md
# ...and except the GitHub configuration, which only works where GitHub looks for
@@ -30,6 +31,6 @@ output/
# addresses, logins, the local venv path). .dev.env.example is tracked and shows the shape.
.dev.env
# Built radio firmware (scripts/xiao-radio/build-firmware.sh writes it here)
scripts/xiao-radio/*.uf2
scripts/xiao-radio/_meshcore-build/
# Built radio firmware (scripts/picocalc/xiao-radio/build-firmware.sh writes it here)
scripts/picocalc/xiao-radio/*.uf2
scripts/picocalc/xiao-radio/_meshcore-build/
+76 -587
View File
@@ -2,593 +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.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.2...HEAD
[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
+72 -15
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/`).
@@ -16,6 +16,26 @@ Commit directly on `main` — do not create topic branches unless explicitly ask
is a solo repo; branches just create divergence to merge back later). Commit freely, but
never `git push` unless explicitly asked in that moment.
## Licensing — what every file and every build carries
- **Every Python file opens with `# SPDX-License-Identifier: Apache-2.0`** (line 2 after a
shebang). `tests/test_spdx.py` is the gate. Anything under another licence lives beside
its own text and says so in its header — the GPL-2.0-only console-font script, the
MeshCore MIT notice beside the XIAO patch — and never enters the distributed package.
- **The map credits OpenStreetMap in the map's own bottom-right corner**
(`ui/attribution.py`): the full OpenFreeMap line on arrival, `© OpenStreetMap` after the
first keystroke, `muted`, on both platforms and in the same words. *Which* corner is the
frame's, not the drawing's, wherever the frame has one — **regular** sets the credit into
the panel's **bottom border rule**, right-justified, one rule cell before the corner, the
way a title sits in the top rule (`Screen.bottom_caption` → `frame._panel_box`), so the
map's cells are untouched; **picocalc**, whose frame has no bottom rule at all, stamps it
over the right end of the drawing's last row instead, overprinting what it covers. The
minimap always stamps. The split is bound once via `platforms.on_platform`, never asked
per frame. Never a title atom, never a footer character, never a key. The licence and URL
live on the About page.
- The name and the wordmark are trademarks reserved in `NOTICE`, which Apache §4(d) makes
every fork carry; `NOTICE` holds only what must travel with a redistribution.
## UX standards
These are binding. Every new screen, dialog, row, or hint follows them; when you touch an
@@ -28,7 +48,7 @@ them instead of hand-rolling:
the language below).
- `ui/widgets.py` — `highlighted_hash` (THE key widget — shows a key, lights its hash),
`format_ago` (prose ages),
`_format_age` (column ages), `channel_glyph`, `_NODE_GLYPHS`, heard-age heat colouring.
`format_age` (column ages), `channel_glyph`, `NODE_GLYPHS`, heard-age heat colouring.
- `ui/theme.py` — `name_style`/`node_style` (per-node hues, hash-derived), `snr_style`,
the `you` white.
@@ -36,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 |
@@ -48,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
@@ -58,14 +78,14 @@ sortable list you pick from is `contactlist.py` (`ContactListScreen`/`ContactRow
Setting vs preference — the other boundary, three-way and never blurred: a **setting** is
the *radio's* (`core/device_config.py`, read live from the companion, edited on Device
config); a **preference** is *MeshTerm's own behaviour* (`core/preferences.py`, defaults in
code, overrides in `preferences.yaml`, edited on the Preferences page — `ctx.preferences`,
code, overrides in `preferences.toml`, edited on the Preferences page — `ctx.preferences`,
or `preferences.current()` where there is no context to reach through); and **config** is
machine setup — paths and device profiles (`core/config.py`, `config.toml`, a text editor
only). A behaviour value belongs in the preference registry, not as a module constant and
not in `config.toml`: a code constant a reader can't reach is a preference nobody has.
Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now ago"),
`_format_age` for aligned columns ("now", "5m").
`format_age` for aligned columns ("now", "5m").
### Navigation — the stack
@@ -85,11 +105,26 @@ deliberately, one at a time, and say why in the code.
refreshes in place with `SelectScreen.replace_items`, which follows the highlighted row
by value and keeps the filter. Rebuilding the screen is for when the rows it was holding
a place in are genuinely gone (a purge, a delete) — and then say so.
- **A popup is not a place.** The stack is for *places* — full-frame screens the reader
is *in* (a menu, a map, a list that is the tool's own page, an editor). A popup
*informs, confirms, or asks*, and then it is gone: it is never kept pushed as a hub
under the screen it was gathering for, so Esc from that screen lands where the tool
was launched from, not on a picker. The test is whether the reader is in it or being
asked something — a `SelectScreen` can be either (Channels is a place; "node to
manage" is a question). A question **draws as a box** even when it is the only frame
on the stack (the main menu is popped while a tool runs, and a lone floating screen
would otherwise be painted full-frame): `session.select(floating=True)`,
`session.text(floating=True)`, `session.stay(screen, dialog=True)` — all through
`TuiSession._floated`, which puts a blank base under it.
- **An entry chain is a stack too.** A flow that asks two or more things in a row runs
through `menus.run_steps`: each step gets the answers so far (to label itself, and to
offer its own previous answer as its default) and returns `None` to step back, so Esc
undoes one step instead of the whole flow. A trailing Cancel/verb confirm is still a
decision, not a step — its Cancel abandons.
decision, not a step — its Cancel abandons. Where the steps are *lists to pick from*,
the chain is **one popup that turns its page** — `menus.run_wizard` over `WizardPage`s
(`SelectScreen.turn_page`; title `Feature — subject · step 1 of 2`), never two popups
stacked. Esc on a later page turns back one, with that page's previous answer
highlighted; the box is popped before whatever the answers were for opens.
- **Esc peels before it leaves.** A screen carrying a find-as-you-type filter treats the
typed query as the most recent thing the reader entered: the first Esc clears it and the
screen stays, the second leaves. One rule on all four (`SelectScreen` and everything
@@ -157,6 +192,21 @@ deliberately, one at a time, and say why in the code.
the query in its footer hint draws the body line only where the footer isn't drawn
(`Platform.footer_fkeys`), never both: the query must be visible on every platform, and
twice on none.
- **A QR code is white on black, and when it is the answer it is the whole screen.**
Modules are pure white ink on a pure black field whatever theme the terminal runs
(`qr._STYLE`, both ends named) — a camera reads contrast, light-on-dark is what a
scanner expects of a screen — and never black on white. A share screen
(`qr.share_screen` → `qr.QrScreen`: the channel share, the contact card, the CLI's
share commands in the menu) is a **bare frame**: `Screen.bare` / `frame.compose_bare`
draw the body alone on blank rows — no header, no footer, no title bar, no box, no
hint, no F-key lane, no instruction — with nothing on it but the code and the URL it
encodes, sat a little above centre; Esc leaves, as everywhere. The code **fits itself
to the frame every paint**, width and rows both: a lighter error level, then a
narrower quiet zone, before it would ever be cut (a contact card is 57 cells and 29
rows at the standard fit; the PicoCalc is 53 across and a regular terminal 24 tall),
and when code and URL can't share the frame the code is whole at the top and the URL
a page down. The one exception is a ` ```qr ` fence in a written page, where the code
is an illustration drawn in the prose where the fence is.
### Written pages
@@ -246,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
@@ -261,8 +311,14 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
that differ from row to row. As a chip it is the map's yellow star on neutral dark
grey, padded like every other chip.
- Chip seams are **one** interlocked chevron (previous fill on next). Two chips of the
same fill are the exception the interlock can't draw: the point drops its background so
the page cuts the wedge. An elision breaks the ribbon rather than joining it — bare `⋯`
same fill are the exception the interlock can't draw — and so are two fills the eye
can't tell apart: under `pathline.SEAM_BLUR` apart in OKLab (`ui/oklab.py`, THE
perceptual colour distance — never a hue gap, never sRGB, both of which mis-size the
greens against the cyans). There the seam is the **thin** chevron (`POWERLINE_THIN`)
in the previous chip's own fill shaded `SEAM_SHADE` darker (lighter for a dark fill),
drawn on the next, so the ribbon runs on unbroken and the join is a line the chip draws
on itself, never a wedge of page cut out of the route and never a third colour. An
elision breaks the ribbon rather than joining it — bare `⋯`
on the page between a closing point and the next chip's notch, no fill, no padding.
- The ribbon's **outer ends**: the chevron means *the route continues*, so an end that is
the route's own never wears one. It ends **square** — nothing appended, the last chip's
@@ -277,7 +333,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
to a whole route). Arrow mode spells the same claim with the separator alone, a leading
or trailing `→` with no hop on the far side of it.
- Concept icons: 📡 advert · 🕒 clock/sync · 🔄 reboot · 💾 backup · 📂 restore ·
🔑 channel/credential key · 🔐 identity/auth secret · 🗑 clear/delete · ✎ compose/edit ·
🔑 channel/credential key · 🔐 identity/auth secret ·
🔒 lock (a locked contact, a private channel) · 🔓 unlock · 🗑 clear/delete · ✎ compose/edit ·
⚙ parameter · `#` count · ▶ run · ⚡ explore/probe · ★ best/winner · ⭐ watch ·
📤 send now · 📨 courier/queue · 💬 chat · 🔔 notify · 🔕 mute (notifications off) ·
📱 QR · 🔗 link · ↻ re-read · ↕ reorder · ⇄ reverse (flip a path's direction) ·
@@ -399,7 +456,7 @@ splitting safe has gone, because splitting is the machine face's job now.
quoting stays and always will (`script._escaped`): a node broadcasts its own name and a
stranger fills in a message body, and neither may end the record it sits in.
- **A time is an age** — `now`, `5m`, `3h`, `never` (`script.age`, delegating to
`widgets._format_age` so the two faces cannot drift). An absolute instant survives where
`widgets.format_age` so the two faces cannot drift). An absolute instant survives where
the instant *is* the fact (`script.stamp`, `fields.instant`): the device clock, an
appointment set with `--at`, a live capture's own `TIME`, every column under `--absolute`.
`--absolute` is an override of the `cli_time_format` **preference**, not a switch beside
@@ -495,7 +552,7 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
`MapCanvas.to_ansi_lines`) — but the fold is the safety net, not the design.
- The glyph contract is `ui/fontset.py` — the installed console font's exact codepoint
inventory, device-verified. A character outside it is a test failure, not a tofu box
found on-device. The font itself is built by `scripts/calculinux-console-font.sh`;
found on-device. The font itself is built by `scripts/picocalc/calculinux-console-font-6x12.sh`;
the two files move in the same commit.
- The 16-slot palette is `theme._VT_SLOTS` (programmed via `/etc/vtrgb`; same script).
`MESH_THEME_16` speaks `color(0..15)` only; backgrounds stop at slot 7. Both themes
@@ -506,7 +563,7 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
base style into every span, so a silent one changes colour inside a selected row. Same
trap outside the theme: `MapCanvas` drops emphasis entirely where bold is brightness,
since its colours arrive quantized and it can't know which bank they landed in, and the
fold's quantizer (`theme._nearest_slot_sgr`) states intent for it — a dim slot leaves as
fold's quantizer (`theme._nearest_slot_params`) states intent for it — a dim slot leaves as
`22;3N`, never a bare `3N`, because `9N` is *how* the console spells bright and adjacent
art spans (the wordmark's bevels, a raster's neighbouring cells) reset nothing between
them, so a bare one inherits the intensity bit and lands a bank too high mid-row.
+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.
+8
View File
@@ -25,6 +25,14 @@ project and is not authorized to do so.
--------------------------------------------------------------------------------
Device script: scripts/picocalc/calculinux-console-font-6x8.sh embeds the Linux kernel's
font_6x8 bitmap (lib/fonts/font_6x8.c) and is therefore licensed GPL-2.0-only,
as is the console font it builds; its terms are included beside it as
scripts/picocalc/LICENSE.GPL-2.0 and are its only license -- the Apache License above
does not apply to it. Nothing GPL-2.0 reaches the MeshTerm package: that script
is excluded from the distribution, runs on the device, and produces a PSF data
file the Linux console driver loads, which MeshTerm neither links nor bundles.
Bundled font: meshterm/assets/fonts/CascadiaMonoPL.ttf is Cascadia Mono PL,
Copyright (c) 2019 - Present, Microsoft Corporation, with Reserved Font Name
Cascadia Code. It is redistributed here unmodified under the SIL Open Font
+167 -301
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,14 +23,27 @@ 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).
<!--
Screenshots welcome here. Drop PNGs into docs/ and reference them, e.g.:
![Dashboard](docs/dashboard.png)
![Mesh map](docs/map.png)
-->
<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%">
<img src="docs/screenshots/map.png" alt="The map: repeaters named over an OpenStreetMap basemap of the Lachine Canal" width="49%">
</p>
<p align="center">
<img src="docs/screenshots/trace.png" alt="A trace: the route it walked drawn as chips, then the SNR of every hop" width="49%">
<img src="docs/screenshots/path.png" alt="Message paths: every route a channel message was heard over, drawn on the ground" width="49%">
</p>
<p align="center">
<img src="docs/screenshots/picocalc.png" alt="MeshTerm on the PicoCalc, a 53-column console with a radio wired inside" width="38%">
</p>
<p align="center"><sub>The dashboard, the map, a trace, the paths a message took — and the whole thing on a PicoCalc.</sub></p>
---
@@ -57,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.2.8-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.2.8-*
./meshterm-0.2.8-*
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.2.8-*` 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.2.8-* # macOS, Linux
> MESHTERM_HOME=~/meshterm-test ./meshterm # macOS, Linux
> ```
> ```powershell
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.8-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.
@@ -160,6 +189,22 @@ question is still being sorted out ([why](https://github.com/pypi/support/issues
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. |
| **[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. |
## Quick start
```bash
@@ -182,236 +227,54 @@ meshterm --mock --platform picocalc
meshterm specimen
```
Handheld bring-up (fonts, palette, boot services) lives in
`scripts/calculinux-setup.sh` + `scripts/calculinux-console-font.sh`.
Putting MeshTerm on a handheld has its own manuals. **[MeshTerm on the
PicoCalc](docs/picocalc.md)** takes a stock PicoCalc from the shopping list to a Linux
handheld with a LoRa radio soldered inside; **[MeshTerm on the uConsole](docs/uconsole.md)**
connects a uConsole's SPI LoRa board through a small bridge. The device-side scripts both
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.yaml`, 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
@@ -419,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:
@@ -448,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
@@ -469,17 +320,25 @@ MeshTerm stands on the [MeshCore](https://meshcore.io/) project — its firmware
### Map data
The street map is rendered from **© OpenStreetMap contributors** data, served as vector
tiles by [OpenFreeMap](https://openfreemap.org/). OpenStreetMap data is available under the
[Open Database License (ODbL)](https://www.openstreetmap.org/copyright). Tiles are decoded
with a small built-in reader for the [Mapbox Vector Tile](https://github.com/mapbox/vector-tile-spec)
format.
tiles by [OpenFreeMap](https://openfreemap.org/) on the unmodified
**© OpenMapTiles** [schema](https://openmaptiles.org/). OpenStreetMap data is available
under the [Open Database License (ODbL)](https://www.openstreetmap.org/copyright). Tiles
are decoded with a small built-in reader for the
[Mapbox Vector Tile](https://github.com/mapbox/vector-tile-spec) format.
Both map surfaces carry the credit themselves. The full-screen map opens with
`© OpenMapTiles · Data from OpenStreetMap` in its bottom-right corner and collapses it to
`© OpenStreetMap` once you pan, zoom or type — set into the panel's bottom border rule on
a desktop terminal, so the drawing itself keeps every cell, and on the map's own last row
on the PicoCalc, whose frame has no bottom rule. The node page's location preview shows the
short form throughout. The About page inside the app spells out the licence URL.
### Bundled font
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 fonts](#a-word-about-fonts). 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.
@@ -490,15 +349,16 @@ 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 |
| [Typer](https://typer.tiangolo.com/) | Scripted CLI (subcommands mirror the menu) | MIT |
| [Rich](https://github.com/Textualize/rich) | Console rendering | MIT |
| [prompt_toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) | Full-screen interactive TUI | BSD-3-Clause |
| [markdown-it-py](https://github.com/executablebooks/markdown-it-py) | CommonMark parser behind the written About pages | MIT |
| [Segno](https://github.com/heuer/segno) | Pure-Python QR codes (channel share links) | BSD-3-Clause |
| [tomli](https://github.com/hukkin/tomli) / [tomli-w](https://github.com/hukkin/tomli-w) | TOML config read/write | MIT |
| [tomli](https://github.com/hukkin/tomli) / [tomli-w](https://github.com/hukkin/tomli-w) | TOML config, preferences and device-config backups | MIT |
## License
@@ -517,3 +377,9 @@ is Microsoft's Cascadia Mono PL, redistributed unmodified under the SIL Open Fon
1.1, which travels with it as
[CascadiaMono-OFL.txt](meshterm/assets/fonts/CascadiaMono-OFL.txt) and stays its only
license.
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.yaml, 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.
+236 -82
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)
@@ -51,13 +49,14 @@ With no command, MeshTerm launches the interactive menu instead.
| Option | What it does |
| --- | --- |
| `--version` | Print `meshterm <version>` and exit `0`, before anything else is opened. |
| `-p`, `--profile NAME` | Use a named device profile from `config.toml`. |
| `--port PORT` | Serial port to connect to (`COM5`, `/dev/ttyACM0`), overriding the profile. |
| `--ble ADDRESS` | Bluetooth address of a companion; selects the BLE transport. |
| `--ble-pin PIN` | Pairing PIN, if the Bluetooth companion asks for one. |
| `--tcp HOST[:PORT]` | Network address of a TCP companion; selects the TCP transport. Default port 5000. |
| `--mock` | Use the built-in simulator instead of real hardware. Nothing transmits. |
| `--db PATH` | Use this SQLite database instead of `~/.meshterm/meshterm.db`. **It moves the database and nothing else** — see [Where MeshTerm keeps its state](#where-meshterm-keeps-its-state). |
| `--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). |
@@ -70,8 +69,8 @@ for. A bare `--` stops the lifting, the standard way to say the rest is data, an
`--help` is never lifted: `meshterm contacts --help` stays the *contacts* help.
Commands that need a radio open one, do their work, and close it. Commands that only read
stored history (`records`) or MeshTerm's own state (`preferences`, the written pages) need
no device at all and work with nothing attached.
stored history (`records`) or MeshTerm's own state (`preferences`, `platform`, `specimen`,
the written pages) need no device at all and work with nothing attached.
> **One transmission per invocation.** A trace transmits exactly once — repeaters
> penalise, and can blacklist, nodes that burst traffic. To sample more, run the command
@@ -96,8 +95,9 @@ 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.yaml` | Your overrides of MeshTerm's own behaviour. Lists only what you changed. |
| `preferences.toml` | Your overrides of MeshTerm's own behaviour. Lists only what you changed. |
| `courier.json` | The outbox — queued messages, waiting and finished. |
| `admin.json` | Remembered repeater-admin passwords. |
| `devices.json` | The remembered default companion, and every device proven to speak the protocol. |
@@ -113,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.
@@ -139,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 …`.
---
@@ -171,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 —
@@ -185,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
@@ -255,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
@@ -270,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.
---
@@ -372,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:
@@ -423,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. |
@@ -431,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.
@@ -583,11 +584,14 @@ repeat](#the-shapes-that-repeat), the table names the shape rather than restatin
#### `meshterm devices`
List every attached serial device and in-range Bluetooth companion. Opens no radio — it
only enumerates what is attached or advertising.
only enumerates what is attached or advertising. Under `--mock` it enumerates nothing at
all: the simulator is the one device, listed with `--mock` as its target, and neither the
serial ports nor the Bluetooth radio are touched — a session that promised no real
hardware keeps the promise here too.
| Option | What it does |
| --- | --- |
| `--ble` / `--no-ble` | Include a Bluetooth LE scan. On by default; skipping it saves a few seconds. |
| `--ble` / `--no-ble` | Include a Bluetooth LE scan. On by default; skipping it saves a few seconds. Moot under `--mock`. |
```console
$ meshterm devices --no-ble
@@ -613,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 | |
@@ -626,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.
---
@@ -678,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
@@ -720,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
@@ -784,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
@@ -797,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}`.
@@ -819,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.
@@ -843,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 |
@@ -872,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 -
@@ -880,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.
@@ -964,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.
@@ -984,7 +994,7 @@ The live transcript is a menu screen; from the command line, pick a subcommand.
| `send TEXT --channel N` | Broadcast on a channel slot. |
| `history [--to NAME \| --channel N] [--limit N]` | Print a conversation's stored transcript. Default limit 50. |
| `list` | Every channel and contact with its unread count and last message. |
| `listen [-s SECONDS] [--debug]` | Tail inbound messages live. |
| `listen [-s SECONDS] [--debug]` | Tail inbound messages live. `-s 0` (the default) runs until Ctrl-C. |
Exactly one of `--to` and `--channel` is required on `send` and `history`.
@@ -1040,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}
@@ -1201,7 +1211,7 @@ Walk the path to a target once and report what came back.
| Option | What it does |
| --- | --- |
| `-t`, `--target NAME` | **Required.** Target node name or key prefix. |
| `-p`, `--path SPEC` | Force a route: comma-separated contact names and/or hex hashes, mixed freely (`3d,f2,3d`). Omit it and the device routes. |
| `--path SPEC` | Force a route: comma-separated contact names and/or hex hashes, mixed freely (`3d,f2,3d`). Omit it and the device routes. |
`--target` is required, so `meshterm trace --path …` alone is a usage error — the
target-free form is [`trace-path`](#meshterm-trace-path).
@@ -1238,7 +1248,7 @@ whichever way you choose. It only has to end within earshot of this node.
| Option | What it does |
| --- | --- |
| `-p`, `--path SPEC` | **Required.** The whole walk, comma-separated. |
| `--path SPEC` | **Required.** The whole walk, comma-separated. |
```console
$ meshterm trace-path --path "a1,d4,a1"
@@ -1288,7 +1298,7 @@ gets through reliably.
| Option | What it does |
| --- | --- |
| `-p`, `--path SPEC` | **Required.** Forced path ending at the target (`Repeater,Target`, or `3d,f2`). The hop before the target is the node being tuned. |
| `--path SPEC` | **Required.** Forced path ending at the target (`Repeater,Target`, or `3d,f2`). The hop before the target is the node being tuned. |
| `-n`, `--samples N` | Traces per TX level. Default 3. |
| `--step N` | Coarse sweep step. Default 3. |
| `--min N` / `--max N` | Bound the sweep (else the `tx_opt_min` / `tx_opt_max` preferences). |
@@ -1325,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
@@ -1388,7 +1398,8 @@ CLI grammar, and a document that pretended to would be inventing structure. `rep
#### `meshterm preferences`
How MeshTerm behaves, as opposed to how the radio is configured. Reads nothing from the
companion, transmits nothing, and works with no device attached.
companion, transmits nothing, and works with no device attached. With no subcommand it
runs `show`.
| Subcommand | What it does |
| --- | --- |
@@ -1400,22 +1411,35 @@ companion, transmits nothing, and works with no device attached.
```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.yaml` in [MeshTerm's state
Overrides live in `preferences.toml` in [MeshTerm's state
directory](#where-meshterm-keeps-its-state), which lists only what you have changed; delete
a line and the default takes over again.
@@ -1433,7 +1457,7 @@ wanted it would be rendering a settings screen. A boolean prints `on`/`off` plai
a mapping; every other `.value | tostring` goes straight back in.
`set` and `reset --yes` answer `{"changes":…,"applied":[…],"path":…}`, `path` being the
`preferences.yaml` that was written.
`preferences.toml` that was written.
#### `meshterm about`, `about-author`, `discord`, `support`
@@ -1455,7 +1479,7 @@ each link; the scripted face prints the link alone.
**`--json`:**
```json
{"page":"discord","title":"Join Discord","version":"0.2.8","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 |
@@ -1471,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` |
+70
View File
@@ -0,0 +1,70 @@
# MeshTerm on hardware
MeshTerm talks to a **node** — a MeshCore radio that speaks the companion protocol over
serial, Bluetooth LE or TCP. Plug a companion board into USB, or pair one over Bluetooth,
and none of this page applies: MeshTerm finds it, and the [README](../README.md) and the
[command line manual](cli.md) are all you need.
This page is for the hardware that *doesn't* present a node that way. Each such device has
its own step-by-step manual, and this page only tells you which one is yours.
## Which guide is yours
| I have… | Read |
| --- | --- |
| a ClockworkPi **PicoCalc**, stock or already running Calculinux on a Luckfox Lyra, and I want MeshTerm on its own screen | **[MeshTerm on the PicoCalc](picocalc.md)** — from the shopping list to a working machine |
| …and I want a **radio** inside it | the same manual, [Phase 3](picocalc.md#phase-3--add-the-radio) — a XIAO nRF52840 + Wio-SX1262 soldered to the Lyra's UART |
| a ClockworkPi **uConsole** with the hackergadgets AIO board — an SX1262 on the host's own SPI bus | **[MeshTerm on the uConsole](uconsole.md)** — the bridge that fronts that chip as a companion |
| a MeshCore companion on USB, BLE or Wi-Fi | nothing here — the [README](../README.md) |
The two targets have nothing in common but MeshTerm itself. The device-side scripts each
manual runs live in [`scripts/`](../scripts/), one folder per handheld; the
[index below](#the-scripts) says what every file does and which machine it runs on. Read a
script before you run it; most run as root and change the machine they run on.
## The scripts
[`scripts/`](../scripts/) holds the device-side helpers, one folder per manual:
[`picocalc/`](../scripts/picocalc/) for the PicoCalc — bringing up Calculinux on the Lyra,
building its console font, and (under [`xiao-radio/`](../scripts/picocalc/xiao-radio/))
giving it a MeshCore radio over UART — and [`uconsole/`](../scripts/uconsole/) for the
bridge that fronts the uConsole's SPI-attached LoRa chip as a companion. The manual is the
walkthrough; each script's own header says what it assumes about the machine and why each
step is the way it is. On the Lyra, only one program may hold `/dev/ttyS1` — MeshTerm
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, 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: 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. |
| [`xiao-radio/meshcore-uart1.patch`](../scripts/picocalc/xiao-radio/meshcore-uart1.patch) | — | The two firmware hunks: the companion on nRF52 `Serial1`, and I²C moved off the UART pads. Not upstream yet. |
| [`xiao-radio/lyra-setup.sh`](../scripts/picocalc/xiao-radio/lyra-setup.sh) | the Lyra (root) | Boot-persistent UART1 → GP4/GP5 mux service, `dialout` membership, and a default MeshTerm serial profile for `/dev/ttyS1`. |
| [`xiao-radio/uart1-mux.py`](../scripts/picocalc/xiao-radio/uart1-mux.py) | the Lyra (root) | The matrix-IO register poke that routes UART1 to the header pads, run once per boot by the service above. |
| [`uconsole/meshterm-spi-bridge`](../scripts/uconsole/meshterm-spi-bridge) | the radio host (your user) | Runs a mesh node in software over an SPI-attached SX1262 (uConsole AIO, Waveshare HATs) and serves the companion protocol on `127.0.0.1:5000`. Setup menu, preflight, optional `systemd --user` service. Runs on `openhop_core` (preferred) or its predecessor `pymc_core`; see the manual for the venv it wants on a bookworm uConsole. |
## The regular and picocalc platforms
MeshTerm resolves one frozen **platform** at boot and draws everything through it. The
**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
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:
```bash
meshterm --mock --platform picocalc # the whole app, simulated radio, handheld layout
meshterm specimen # the visual language on one card
meshterm platform # which platform this terminal resolved to
```
What that platform needs from the console — a font with the glyphs the interface draws,
and the stock sixteen-slot palette — is installed by the PicoCalc manual's setup script;
the details are in that manual and in the scripts' own headers.
+940
View File
@@ -0,0 +1,940 @@
# MeshTerm on the PicoCalc
You start with a stock ClockworkPi PicoCalc — the kit that ships with a Raspberry Pi Pico —
and a short shopping list. You end with a Linux handheld: MeshTerm running full-screen on
the PicoCalc's own display and keyboard, a LoRa radio soldered inside, and no laptop needed
to use it day to day. There are three phases: build the machine (swap the Pico for a Linux
board), install the OS and MeshTerm, and add the radio. Budget an afternoon for the first
two phases, most of it spent waiting for a toolchain to download; the radio phase needs a
soldering iron and can be done later — MeshTerm's `--mock` mode runs perfectly well without
a radio in the meantime.
> ⚠ **Read this first.** This build takes the PicoCalc apart, swaps its core board, and
> solders to it. It assumes you can already solder cleanly, use a multimeter, and work with
> small electronics without forcing anything. There is a real risk of damaging the
> hardware if a step goes wrong — a cracked screen, a snapped shell post, a dead board, or
> an overcharged lithium cell — and some of that damage is not repairable. You do this at
> your own risk.
>
> **Read the whole manual through once before you buy anything or open anything.** Several
> steps only make sense in light of a later one — where the SD card goes, what must be out
> of the case before a board is pressed in, which USB port is never to be powered — and the
> shopping list is easier to get right when you know what each part is for.
>
> This is a guide, not a prescription. Despite the author's best efforts it may contain
> errors, and parts of it will go out of date as boards, images and firmware change. It is
> up to you to check every fact in it against your own hardware and the current sources
> before you act on it, and every decision along the way is yours. The author accepts no
> responsibility for any damage, loss or injury that results from following this guide,
> and nothing in it is a warranty that your parts, your tools or your hands will behave the
> way the author's did. If you are not comfortable with any step, stop and get help from
> someone who is.
- [Shopping list](#shopping-list)
- [Before you start](#before-you-start)
- [Three ways to break it](#three-ways-to-break-it)
- [Phase 1 — Build the machine](#phase-1--build-the-machine)
- [Step 1 — Assemble the PicoCalc](#step-1--assemble-the-picocalc)
- [Step 2 — Swap the Pico for the Lyra](#step-2--swap-the-pico-for-the-lyra)
- [Step 3 — Attach the Wi-Fi dongle](#step-3--attach-the-wi-fi-dongle)
- [Phase 2 — Install Calculinux and MeshTerm](#phase-2--install-calculinux-and-meshterm)
- [Step 4 — Write the image to the microSD](#step-4--write-the-image-to-the-microsd)
- [Step 5 — First boot](#step-5--first-boot)
- [Step 6 — Open the serial console](#step-6--open-the-serial-console)
- [Step 7 — Join Wi-Fi](#step-7--join-wi-fi)
- [Step 8 — Copy the setup scripts to the device](#step-8--copy-the-setup-scripts-to-the-device)
- [Step 9 — Run calculinux-setup.sh](#step-9--run-calculinux-setupsh)
- [Step 10 — Log in as meshterm](#step-10--log-in-as-meshterm)
- [Phase 3 — Add the radio](#phase-3--add-the-radio)
- [Step 11 — Install PlatformIO](#step-11--install-platformio)
- [Step 12 — Build the firmware](#step-12--build-the-firmware)
- [Step 13 — Flash the XIAO](#step-13--flash-the-xiao)
- [Step 14 — Wire the radio](#step-14--wire-the-radio)
- [Step 15 — Set up the Lyra](#step-15--set-up-the-lyra)
- [Step 16 — Run it](#step-16--run-it)
- [Day-to-day](#day-to-day)
- [Troubleshooting](#troubleshooting)
- [What has been tested](#what-has-been-tested)
---
## Shopping list
### The machine
| 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**. 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. |
| 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. |
### The radio
| Item | Product / what to search for | Approx. price | Link | Note |
| --- | --- | --- | --- | --- |
| XIAO nRF52840 + Wio-SX1262 kit | "XIAO nRF52840 & Wio-SX1262 Kit for Meshtastic", SKU 102010710 | $13.49 | [seeedstudio.com](https://www.seeedstudio.com/XIAO-nRF52840-Wio-SX1262-Kit-for-Meshtastic-p-6400.html) | Includes the XIAO nRF52840, the Wio-SX1262 LoRa board (stacked and wired together) and an antenna. Covers both 868 and 915 MHz. Plain or Sense XIAO both work. **Do not** buy the "-N" variant — it has no antenna connector. |
### Tools and consumables
| Item | Note |
| --- | --- |
| Soldering iron, fine tip, and solder | For the four radio wires. |
| Hookup wire, 26–30 AWG, four short lengths | TX and RX get crossed — see [Step 14](#step-14--wire-the-radio). |
| 2.5 mm hex key | Comes with the PicoCalc kit. |
| USB-C data cable | For the serial console, and later for flashing the XIAO. A charge-only cable won't do; it has to carry data. |
| microSD card reader | For writing the Calculinux image from your PC. |
| Small side cutters | For trimming wire ends, and a section of the case grille if the Wi-Fi lead needs the room. |
| Small needle file | To open the back panel's USB-C cutout a little if the Lyra's connector doesn't clear it. |
| Kapton (polyimide) tape | Wraps the XIAO stack and the DIY USB lead so nothing bare touches the board or the shell. Heat-proof, thin, and it peels off cleanly. |
| A PC (Windows/macOS/Linux) with Python 3 and git | Runs the image-writing, firmware-build and flashing steps. |
### Optional
| Item | Why |
| --- | --- |
| A second 18650 battery | The holder takes two; one is enough to run, two roughly double your runtime. |
| Powered USB hub | If you want the Wi-Fi dongle and something else on the Lyra's single USB host socket at once, or a 5 V dongle. |
| 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 PicoCalc kit ($89), the Lyra (~$22) and the radio kit ($13.49).
**What you do NOT need:**
- A level shifter. Every signal in this build — the Lyra's header, the XIAO, the SX1262 — is
3.3 V.
- A separate USB-to-serial adapter. The PicoCalc mainboard already has one built in.
- Bluetooth anything. Neither the Lyra nor this radio path uses it.
- A new SD card, if your stock 32 GB card is handy — it's well over the 8 GB minimum
Calculinux asks for.
### A note on batteries
ClockworkPi has not published an official battery spec for the PicoCalc — a
[still-open GitHub issue](https://github.com/clockworkpi/PicoCalc/issues/6) asking for one
has gone unanswered. What's known from the kit itself: the holder takes two 18650 cells, one
cell is enough to power the board, and the physical envelope is about Ø18 mm × 65–69 mm,
which rules out most *protected* cells — the protection circuit adds length that doesn't
fit. Unprotected flat-top or button-top cells both work; the PicoCalc mainboard has its own
charge circuit built in. Buy from a reputable brand rather than a cheap unbranded listing —
Samsung 30Q, Molicel P26A and Sony VTC6 are commonly recommended examples, not an
endorsement of any one of them.
---
## Before you start
Work happens on two machines, and it helps to keep straight which is which:
- **Your PC** writes the Calculinux image to the microSD card, builds the radio firmware,
and flashes the XIAO. Nothing in these three jobs touches the PicoCalc itself.
- **The PicoCalc** (really, the Luckfox Lyra inside it) does everything else — setup,
Wi-Fi, running MeshTerm — either over the serial console from your PC or on its own
screen and keyboard. Both work identically; the serial console is just easier for pasting
long commands.
The device has two logins you'll use for different things: **`root`**, which you use once
to run the setup script and fix Wi-Fi, and **`meshterm`**, the unprivileged account
MeshTerm actually runs under day to day.
Calculinux is a console-only Linux — no desktop, no window manager. That's deliberate: the
PicoCalc's screen and keyboard are a terminal, and MeshTerm is a full-screen terminal
program built for exactly that.
---
## Three ways to break it
The PicoCalc is a kit, and its forum has a well-worn list of the ways people have damaged
theirs. Three of them apply directly to this build, and the first two are not theoretical:
the author of this guide **cracked a screen and snapped screw posts** building the very
machine it describes. Read this section before you open the shell.
> ⚠ **The screen is extremely fragile.** It is a bare glass panel with no frame of its own,
> and when the case is closed it sits directly against the mainboard. Anything that flexes
> the mainboard flexes the glass: pushing a board into the Pico socket, prying one out,
> closing the shell with the screen not sitting square in its opening, or pressing a key
> while the halves are being screwed together. Forum members have cracked screens doing
> each of those, and the swap in [Step 2](#step-2--swap-the-pico-for-the-lyra) is the
> classic one — pressing a new board into the socket with the mainboard still in the case
> is enough on its own. That is exactly how the screen in this guide's own PicoCalc broke.
> ([Before replacing the Pico](https://forum.clockworkpi.com/t/before-replacing-the-pico-read-this-to-avoid-cracked-screen/16666),
> [Avoid breaking your screen](https://forum.clockworkpi.com/t/avoid-breaking-your-screen/18805))
>
> What to do instead:
>
> - **Never push a board into the socket, or pull one out, with the mainboard in the case.**
> Unplug the display's ribbon cable (the FPC) from the mainboard, lift the mainboard out,
> and do the swap on a flat, padded surface — a folded microfibre cloth is ideal. Ease a
> board out a little at each end in turn, never by levering one end.
> - **Secure the screen with Kapton tape** along its edges so it cannot shift in the
> opening while you work and while the case is closed. Kapton is the right tape: thin,
> it stays put, and it peels off cleanly. Don't use anything that sets hard.
> - **Before the back goes on, check the screen sits flush and square** in the front half's
> opening with no pressure on any corner. If the mainboard doesn't sit perfectly flush on
> the front half, stop and find out why before you add the back.
> - Handle the panel as little as possible, and keep your fingers off the keyboard while
> closing the shell.
> ⚠ **Don't overtighten the screws.** The shell's screw posts are brass inserts moulded
> into plastic, and forcing a screw snaps the post out of the shell — a repair that needs
> epoxy and never quite comes back
> ([Broken screw thread](https://forum.clockworkpi.com/t/broken-screw-thread/20726)).
> This guide's author snapped several on the first assembly; it takes less force than you
> would think.
> Turn each screw until it just seats, then stop. If a screw won't go in easily, the halves
> aren't aligned, or something inside is in the way; more force is never the answer.
> ⚠ **Never power the Lyra through its own USB-C while the batteries are in.** The
> PicoCalc charges its cells only through the **mainboard's** USB-C port, under the control
> of its power-management chip (an AXP2101). Power fed in through the core board's USB port
> — the Pico's originally, the Lyra's now — arrives on the header's system-power pin and
> bypasses that charge control. Forum members who powered the Pico's port directly measured
> their cells at **4.46 V and 4.74 V** afterwards, well over the 4.2 V a lithium cell is
> allowed to reach
> ([New PicoCalc — battery charging](https://forum.clockworkpi.com/t/new-picocalc-battery-charging/22330)).
> An overcharged cell is a fire risk, not just a worn one.
>
> Nothing in this guide needs the Lyra's USB-C: the serial console, charging and power all
> use the mainboard's port on the side of the case, and the Wi-Fi dongle uses the 4-pin
> socket on top of the Lyra. If you ever must plug the Lyra's USB-C into a PC — to
> reflash it with Luckfox's tools, say — **take both batteries out first.**
---
## Phase 1 — Build the machine
### Step 1 — Assemble the PicoCalc
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 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
turn the screws only until they seat. You will open the shell again in the next step, so
there is no reason to make the screws tight now.
Power it on once with the **stock Pico still in place**. You should see the stock BASIC
firmware boot on the screen, and the keyboard should respond. This confirms the screen,
keyboard and battery are all good before you start swapping boards.
Power it back off.
### Step 2 — Swap the Pico for the Lyra
Remove the back of the shell with the hex key.
⚠ **Do the swap with the mainboard out of the case.** This is the step that cracks screens
(see [Three ways to break it](#three-ways-to-break-it)). Unplug the display's ribbon cable
from the mainboard, lift the mainboard out, and lay it on a padded surface. Only then ease
the Raspberry Pi Pico out of its socket, a little at each end in turn — **keep it**, you're
just setting it aside.
If your Luckfox Lyra has SPI NAND on board (a "Lyra B"), erase it first — the boot ROM
otherwise ignores the SD card entirely and boots the on-board flash instead. See
[Calculinux's hardware requirements page](https://calculinux.org/getting-started/hardware-requirements/)
for how.
Seat the Lyra into the same socket the Pico came out of, in the same orientation — its
USB-C port at the end where the Pico's USB port was, so it lines up with the cutout in the
back of the shell. Press it down fully with the mainboard still on the padded surface; a
Lyra that isn't fully seated won't boot. Then reconnect the ribbon cable and set the
mainboard back into the front half, checking the screen sits square before anything is
screwed down.
You should see the Lyra sitting flat on the socket with no gap at either end, and, once
the mainboard is back in, its USB-C port centred in the back-panel cutout.
The cutout was made for the Pico's micro-USB, and the Lyra's USB-C connector is a
different shape. Depending on the connector Luckfox fitted to your board, the back panel
may not close over it. If it catches, open the hole a little with a small needle file,
testing the fit as you go, until the panel sits down without pressing on the board.
Leave the back off for now. The microSD card goes into the **Lyra's own slot** in
[Step 4](#step-4--write-the-image-to-the-microsd), and that slot is easier to reach with the
board exposed.
### Step 3 — Attach the Wi-Fi dongle
The Lyra has two USB connectors. The USB-C on its edge is for power and flashing and is
not used here. The **tiny 4-pin socket on top of the board** (an MX1.25 connector, next to
the two buttons by the USB-C end) is the USB host. The Wi-Fi dongle hangs off it through a
short lead you make yourself: a ready-made cable is 30 cm long and a struggle to fold into
the shell, and you have the soldering iron out for the radio anyway.
**Make the lead.** Solder the four wires of an MX1.25 pigtail to a USB-A female socket,
keeping the run short — 5 to 8 cm is enough to reach a spot where the dongle sits flat.
The pigtail's pins are in the standard USB order, which the pre-made cable's wire colours
follow:
| MX1.25 pin | USB-A socket pin | Signal | Pigtail wire colour (usually) |
| --- | --- | --- | --- |
| 1 | 1 | VBUS (**3.3 V** on this board) | red |
| 2 | 2 | D− | white |
| 3 | 3 | D+ | green |
| 4 | 4 | GND | black |
USB-A socket pins are numbered 1 to 4 across the connector's opening: VBUS on one edge,
GND on the other, the data pair between them. Colours on a pigtail are a convention, not a
promise, so check pin 1 before you plug anything in: with the Lyra powered, the socket's
pin 1 reads **3.3 V** against a GND header pin and the pin at the other end reads 0 V.
Swapping VBUS and GND is the one mistake that damages the dongle.
Once soldered, wrap the socket's solder side and the exposed wire in Kapton tape. Plug the
MX1.25 end into the socket on the Lyra — it is keyed and only goes in one way — and the
Wi-Fi dongle into the USB-A end. Lead and dongle stay inside the shell; tuck them where
they don't press on the board.
The MX1.25 socket sits on top of the Lyra, and the lead leaves it upward, which is where
the back panel's grille is. The wire may interfere with the grille when the back goes on.
If it does, snip a small section out of the grille with the side cutters where the wire
passes and shape the edge until nothing presses on the plug or the wire. Better to remove a
sliver of plastic than to close the case against a connector.
> **The Lyra's USB port is 3.3 V, not 5 V.** A standard 5 V USB Wi-Fi dongle will not work
> and may damage the board. Stick to the tested chipsets — RTL8192CU, R8712U, or RTL8188EU
> — which is why the shopping list names a known dongle (the TP-Link TL-WN725N) and
> otherwise tells you to search by chipset rather than by brand.
Don't power the device on yet — Wi-Fi is set up later, once Calculinux is installed.
---
## Phase 2 — Install Calculinux and MeshTerm
### Step 4 — Write the image to the microSD
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
```
(The Calculinux installation page mentions `unxz`, but the file is a `.gz`, so use
`gunzip`. 7-Zip works on any platform if you'd rather not use the command line.)
Write the resulting `.wic` file to your microSD card. Three options:
- **Balena Etcher** (recommended, all platforms) — open it, select the `.wic` file, select
your SD card, click Flash.
- **`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
```
- **Rufus**, on Windows — select the device and the image, choose the **MBR** partition
scheme, and start.
This erases everything already on the card. Once it finishes, eject it and put it in the
**Luckfox Lyra's own microSD slot** — not the PicoCalc's front-facing slot. The Lyra boots
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
slower than later ones. You should see boot messages scroll on the PicoCalc's own screen,
ending in a `Calculinux GNU/Linux` login prompt.
Log in:
```
login: root
Password: root
```
Change the password immediately:
```bash
# on the PicoCalc, as root
passwd
```
### Step 6 — Open the serial console
The PicoCalc's own screen and keyboard work fine, so you can skip this step. The serial
console makes pasting the longer commands in this guide much easier, though, so it is
recommended.
The PicoCalc mainboard's USB-C port carries a USB-to-UART bridge (a CH340 chip). Plug a
USB-C cable from that port into your PC. It shows up as:
- `/dev/ttyUSB0` on Linux (you may need to be in the `dialout` group),
- a `COM` port on Windows,
- `/dev/cu.usbserial-*` on macOS.
Settings: **1500000 8N1**, no flow control. Using `pyserial`'s `miniterm`:
```bash
# on the PC
pip install pyserial
python3 -m serial.tools.miniterm /dev/ttyUSB0 1500000
```
minicom and PuTTY work too, at the same settings. To leave `miniterm`, press **Ctrl+]**.
You should see the same login prompt you saw on the PicoCalc's screen, now in your PC's
terminal.
### Step 7 — Join Wi-Fi
Calculinux manages Wi-Fi with `iwd`. The easiest way is its text-mode picker: run `uwific`,
highlight your network, press Enter and type the passphrase (`Q` quits). Or do the same
from the command line:
```bash
# on the PicoCalc, as root
iwctl station wlan0 scan
iwctl station wlan0 get-networks
iwctl station wlan0 connect "<your SSID>"
```
It'll prompt for the passphrase. Check you're online:
```bash
ip addr show wlan0
ping -c 3 1.1.1.1
```
You should see an `inet` address on `wlan0`, and replies from the ping. `iwd` remembers
the network, so this is a one-time step; to change networks later, run `uwific` again.
### 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. Two ways to get it there, once Wi-Fi is up:
- **`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/
```
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/
```
- **`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.
### Step 9 — Run calculinux-setup.sh
```bash
# on the PicoCalc, as root, with scripts/ copied over
cd scripts/picocalc
sh calculinux-setup.sh
```
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. 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 |
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` 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` (`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
MeshTerm into it.
- **Login PATH** — puts that venv's `bin` on the `meshterm` user's PATH.
- **Console font** — builds and installs the console font MeshTerm's UI needs (braille
charts, node glyphs, rounded corners).
This takes a few minutes on a good connection — most of it is `pip install` compiling
things. It ends with `done` and tells you to log in as `meshterm`. That user has no
password yet, so set one first:
```bash
# on the PicoCalc, as root
passwd meshterm
```
> **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
Log out of `root`, and log in as `meshterm` with the password you just set. Try the app:
```bash
# on the PicoCalc, as meshterm
meshterm --mock
```
You should see MeshTerm's full-screen interface come up at **53 columns**, with a simulated
radio behind it, drawing braille charts and node glyphs cleanly — no empty tofu boxes. Along
the bottom you should see the **F-key lane**: five labelled chips instead of the footer hint
line a desktop terminal shows.
Have a look around. **Esc** backs out of any screen, and **Quit** on the main menu leaves
the app.
Then look at the whole visual language on one card:
```bash
# on the PicoCalc, as meshterm
meshterm specimen
```
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.
---
## Phase 3 — Add the radio
This phase can wait. Everything above already gives you a working MeshTerm in `--mock`
mode. Come back to this when you have the soldering iron out.
### Step 11 — Install PlatformIO
On your PC:
```bash
# on the PC
pip install platformio
```
(or use the
[official installer script](https://docs.platformio.org/en/latest/core/installation/methods/installer-script.html)
if you'd rather not touch your system Python). Confirm `pio` is on your PATH:
```bash
# on the PC
pio --version
```
The first firmware build downloads the nRF52 toolchain — several hundred megabytes — so do
this somewhere with a decent connection.
### Step 12 — Build the firmware
```bash
# on the PC (Git Bash or WSL on Windows)
cd scripts/picocalc/xiao-radio
sh build-firmware.sh
```
You should see it clone MeshCore, apply a patch, build, and finish with:
```
DONE. Firmware: /path/to/scripts/picocalc/xiao-radio/meshcore-xiao-radio.uf2
Next: put the XIAO in bootloader (double-tap reset) and run: python flash.py
```
Here's what it did, so you could do it by hand if you needed to:
1. Cloned [MeshCore](https://github.com/meshcore-dev/MeshCore) into
`scripts/picocalc/xiao-radio/_meshcore-build`.
2. Checked out the pinned commit `e9edfc8e` on the `dev` branch — the commit this patch is
known to apply cleanly to and build against.
3. Applied `meshcore-uart1.patch`.
4. Built the `Xiao_nrf52_companion_radio_serial` PlatformIO environment:
`pio run -e Xiao_nrf52_companion_radio_serial`.
5. Converted the resulting `.hex` to a `.uf2` with MeshCore's own converter, using the
nRF52840 UF2 family id:
`python bin/uf2conv/uf2conv.py firmware.hex -c -f 0xADA52840 -o meshcore-xiao-radio.uf2`.
#### What the patch changes, and why it is still needed
`meshcore-uart1.patch` touches two files, and both hunks are required:
- **`examples/companion_radio/main.cpp`.** MeshCore's companion-over-serial code declares
`HardwareSerial companion_serial(1)` — a numbered constructor. On the Adafruit nRF52 core,
`HardwareSerial` is an abstract base class with no numbered constructor at all, so this
line simply doesn't compile for the XIAO nRF52840. The patch adds an `#if
defined(NRF52_PLATFORM)` branch that binds `companion_serial` to `Serial1` instead — the
concrete UART object the nRF52 core always provides. `Uart::setPins(rx, tx)`, called later
in the same file, takes the same arguments either way, so nothing else needs to change.
- **`variants/xiao_nrf52/platformio.ini`.** This adds a new
`Xiao_nrf52_companion_radio_serial` build environment that puts the companion serial link
on pins **D6 (TX)** and **D7 (RX)**. It also **moves I²C off those same two pads**, onto
two internal pins, 16 and 17, that nothing in this build uses. This second change is the one that matters: the
stock `Xiao_nrf52` environment maps I²C onto D6/D7 by default, and both the board's
`begin()` and the sensor code start the I²C bus on them automatically. Without the move,
I²C seizes the UART pins at boot — the wiring is correct, the firmware builds and runs,
and the serial link is still completely silent, because something else already owns the
pins.
Both changes have been submitted upstream to MeshCore, but as of this writing they are
**not merged**, and there's no sign that will happen soon — upstream `dev` still declares
the bare `HardwareSerial(1)` with no nRF52 branch, and the XIAO's `platformio.ini` still has
no `_serial` companion environment. Building with the patch is the normal route here, not a
stopgap; when the patch does land, the `git apply` step (and the patch file itself) can be
deleted.
#### Building by hand
If you want to track newer MeshCore instead of the pinned commit:
```bash
# on the PC
git clone https://github.com/meshcore-dev/MeshCore.git
cd MeshCore
git checkout dev # or MESHCORE_COMMIT=dev sh build-firmware.sh
git apply /path/to/scripts/picocalc/xiao-radio/meshcore-uart1.patch
pio run -e Xiao_nrf52_companion_radio_serial
python bin/uf2conv/uf2conv.py .pio/build/Xiao_nrf52_companion_radio_serial/firmware.hex \
-c -f 0xADA52840 -o meshcore-xiao-radio.uf2
```
If `git apply` fails — upstream has moved and the patch context no longer lines up — the two
hunks are small enough to make by hand:
1. In `examples/companion_radio/main.cpp`, find the line
`HardwareSerial companion_serial(1);` and replace it with:
```cpp
#if defined(NRF52_PLATFORM)
Uart& companion_serial = Serial1;
#else
HardwareSerial companion_serial(1);
#endif
```
2. At the end of `variants/xiao_nrf52/platformio.ini`, add a new environment:
```ini
[env:Xiao_nrf52_companion_radio_serial]
extends = Xiao_nrf52
board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld
board_upload.maximum_size = 708608
build_flags =
${Xiao_nrf52.build_flags}
-I examples/companion_radio/ui-orig
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D OFFLINE_QUEUE_SIZE=256
-D QSPIFLASH=1
-D SERIAL_TX=D6
-D SERIAL_RX=D7
-D PIN_WIRE_SCL=16
-D PIN_WIRE_SDA=17
build_unflags =
-D PIN_WIRE_SCL=D6
-D PIN_WIRE_SDA=D7
build_src_filter = ${Xiao_nrf52.build_src_filter}
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-orig/*.cpp>
lib_deps =
${Xiao_nrf52.lib_deps}
densaugeo/base64 @ ~1.4.0
```
Only the pinned commit (`e9edfc8e`) is known to build cleanly with this patch — a newer
`dev` may need small further adjustments.
### Step 13 — Flash the XIAO
Plug the **XIAO's own USB-C port** into your PC — it doesn't matter yet whether it's also
wired to the Lyra. Then:
```bash
# on the PC
python flash.py
```
It first tries a 1200-baud "touch" over USB serial to reset the XIAO into its bootloader —
this only ever targets Seeed's own USB vendor id, so it won't disturb another nRF52840 board
on the same bench. If that doesn't take, **double-tap the XIAO's reset button** and run it
again.
Once it finds the bootloader, it checks two things before writing anything:
- **`INFO_UF2.TXT`'s `Model` field** must say a XIAO — a drive letter isn't an identity, and
another board could have inherited the letter your XIAO just gave up.
- **`SoftDevice` must not be `6.1.1`.** This firmware links for SoftDevice S140 v7 (app at
`0x27000`); a S140 6.1.1 bootloader wants the app at `0x26000` and would flash successfully
and then simply never boot.
`--force` overrides both checks, if you're certain. A clean flash ends with:
```
DONE. XIAO flashed and rebooting into the radio firmware.
```
**If no UF2 drive ever appears**, that's not necessarily wrong — some XIAO bootloaders
expose only a serial port, no mass-storage drive at all. `flash.py` recognises this case and
prints the fallback command instead of failing silently:
```bash
# on the PC, from your MeshCore checkout
pio run -e Xiao_nrf52_companion_radio_serial -t upload --upload-port <bootloader port>
```
That command ends in `Device programmed.` and the XIAO reboots itself into the radio
firmware.
Afterwards, the XIAO's USB serial port goes **silent** to companion frames — the companion
protocol now lives on D6/D7, not on USB. That silence is correct; it doesn't mean the flash
failed.
### Step 14 — Wire the radio
Four wires, TX and RX crossed:
| XIAO pad | → | Lyra header | Physical pin | Carries |
| --- | --- | --- | --- | --- |
| **D7** (Serial1 RX) | → | **GP4** | pin **6** | Lyra UART1 **TX** → XIAO RX |
| **D6** (Serial1 TX) | → | **GP5** | pin **7** | XIAO **TX** → Lyra UART1 RX |
| **GND** | → | **GND** | pin **8** | common ground |
| **3V3** | → | **3V3 OUT** | pin **36** | power, so it runs without USB |
> **The Lyra does not use Raspberry Pi Pico GPIO numbering.** The 40-pin header is
> physically Pico-shaped, but each pad's function is Luckfox's own RK3506 mapping: GP4 is
> `gpio0-0` and GP5 is `gpio0-1`. Don't reach for a Pico pinout for anything else on this
> header. Every signal here is 3.3 V, and there is **no 5 V rail** on this board — power the
> XIAO from 3V3 only.
With the PicoCalc powered off and the back of the shell removed, solder the four wires
between the XIAO's pads and the Lyra's header pins as above. The Lyra can stay seated in
its socket: the tops of its header pins are reachable from inside the case, so solder to
those. Keep the iron on each pin only as long as it takes, and don't press down on the
board. Attach the LoRa antenna to the Wio-SX1262's u.FL connector before you
power anything back on — running the radio without an antenna attached can damage it.
Wrap the XIAO + Wio-SX1262 stack in Kapton tape so no pad can touch the Lyra, the
mainboard or a battery, then tuck it into the shell wherever it fits without straining the
wires; a strip of foam tape holds it in place against the inside of the case.
### Step 15 — Set up the Lyra
With the wiring done and the shell closed back up, copy `scripts/` to the device if you
haven't already (Step 8), then:
```bash
# on the PicoCalc, as root
sh scripts/picocalc/xiao-radio/lyra-setup.sh # MT_USER=meshterm by default
```
It does three idempotent things:
1. Installs `uart1-mux.py` as `/usr/local/bin/uart1-radio-mux.py`, run by a oneshot
`uart1-radio-mux.service` on every boot. This is the part that actually makes
`/dev/ttyS1` reach the header pins — Calculinux enables the UART1 controller but never
wires it to a physical pad on its own, and the RK3506's flexible pin matrix needs a
direct register poke to route it onto GP4/GP5 instead.
2. Adds `$MT_USER` to the `dialout` group, so it can open the serial port. **This needs a
fresh login before it takes effect.**
3. Writes a MeshTerm profile, *only if none exists yet*, at
`/home/$MT_USER/.meshterm/config.toml`:
```toml
default_profile = "picocalc"
[profiles.picocalc]
port = "/dev/ttyS1"
baudrate = 115200
transport = "serial"
default_tx_power = 22
description = "XIAO nRF52840 + SX1262 via Lyra UART1 on GP4/GP5"
```
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
Log out and back in as `meshterm` (so the `dialout` group membership takes effect), then:
```bash
# on the PicoCalc, as meshterm
meshterm
```
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.
If you want to prove the wiring itself before suspecting anything else, there's a deeper
check: open `/dev/ttyS1` at 115200 as root and send a raw `APP_START` frame (`3c 0e 00 01`,
seven zero bytes, then a name) — a live radio answers with a framed `05`, `SELF_INFO`. The
command byte and the reply code are the companion protocol's; the `<`/`>` and
little-endian-length framing around them come from the firmware and are worth confirming
against it. It's a lower-level check than running MeshTerm and mostly useful for isolating
a wiring problem.
---
## Day-to-day
**Charging.** Only through the mainboard's USB-C port on the side of the case. ⚠ Never
through the Lyra's own USB-C at the back — see
[Three ways to break it](#three-ways-to-break-it).
**Powering off.** Shut down cleanly rather than pulling the batteries:
```bash
# on the PicoCalc
sudo poweroff
```
**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
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 && 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 /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 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 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.
---
## Troubleshooting
| Symptom | Check |
| --- | --- |
| Nothing on the PicoCalc's screen at all | Is the SD card in the **Lyra's own slot**, not the PicoCalc's front slot? Is a "Lyra B" (SPI NAND) erased? Is it the **128 MB RAM, Pico-form-factor** variant — other RAM sizes have incompatible pinouts? |
| Wi-Fi never comes up | Is the dongle one of the tested chipsets (RTL8192CU / R8712U / RTL8188EU)? Remember the Lyra's USB port is **3.3 V** — a 5 V dongle won't work. Give it the full six minutes on a cold boot before assuming it's stuck. |
| `pip install` fails with `ENOSPC` | `/tmp` is a small RAM disk. `calculinux-setup.sh` already sets `TMPDIR=$HOME/tmp` for its own install; if you're running `pip` by hand, do the same. |
| `meshterm: command not found` after setup | Log out and back in — the PATH line is added to `~/.profile`, which only takes effect on a fresh login shell. |
| Tofu boxes instead of braille charts or node glyphs | The console font script hasn't run, or didn't persist. Re-run `sh calculinux-console-font-6x12.sh`, and check `/etc/vconsole.conf` has a `FONT=` line. |
| `meshterm` can't open `/dev/ttyS1` | Is the `meshterm` user in `dialout` (`groups`)? Group changes need a fresh login to take effect. |
| Port opens but the radio never answers | Re-flash with the **`_serial`** environment, not `_ble` or `_usb` — only it defines `SERIAL_RX`/`SERIAL_TX`. The XIAO's USB serial should be silent once it's running the radio firmware; that's expected, not a fault. |
| Still silent with wiring confirmed | Confirm the firmware actually carries the I²C remap (`PIN_WIRE_SCL=16`, `PIN_WIRE_SDA=17`) — a build without the patch's second hunk looks identical until you check this. |
| Flashed fine, XIAO never comes back | Wrong board, or the wrong SoftDevice — `INFO_UF2.TXT` must report a XIAO and **S140 v7**, not `6.1.1`. `flash.py` refuses to flash either mismatch without `--force`. |
| `flash.py` finds no UF2 drive | Expected on a CDC-only bootloader — use the `pio … -t upload --upload-port` command it prints instead. |
| Nothing on `/dev/ttyS1` after a reboot | `systemctl status uart1-radio-mux`, then `journalctl -b -u uart1-radio-mux` — the pin-mux poke has to run every boot, and a failed oneshot says why. |
---
## What has been tested
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. 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:
- **The RK3506 register map** in `uart1-mux.py` — the two register blocks, the UART1 signal
ids, matrix mode, and that `gpio0-0`/`gpio0-1` are header GP4 and GP5 — was probed live
on one board, not read from a reference manual.
- **That the Calculinux kernel permits those `/dev/mem` writes**, and that the mux oneshot
really runs at every boot.
- **The header pin numbers** 6, 7, 8 and 36 for GP4, GP5, GND and 3V3. The Pico-side
numbering is right; the carrier's wiring is a bench observation.
- **The MX1.25 socket's pin order.** Taken from the wire colours of Luckfox's own cable,
which is why [Step 3](#step-3--attach-the-wi-fi-dongle) has you check pin 1 with a meter.
- **The 5 V path through the Lyra's USB-C.** The mechanism is read off the mainboard
schematic and two forum measurements on the Pico; nobody has measured it with a Lyra.
Treat the warning as the safe assumption it is.
- **The Calculinux specifics the scripts assert**: the stripped Python and the package
names that fix it, which overlays are writable, that `/tmp` is a small RAM tmpfs, the
dongle's cold-boot race with `iwd`, the opkg feed's occasional 404, that the stock
Terminus console font is installed, and that `kbd` brings `setvtrgb`.
- **That this board has no RTC.** Only the script says so; the clock-sync phase follows.
- **The XIAO bootloader that exposed no mass storage**, and the S140 v7 / `0x27000`
requirement — public references agree, but neither is a primary source.
- **Whether the firmware patch still applies at MeshCore `dev` HEAD.** Only the pinned
commit is known to build.
- **MeshCore's serial framing** around the raw `APP_START` check in
[Step 16](#step-16--run-it): the command byte and reply code are from the protocol
documentation; the framing bytes are not.
- **The framebuffer console's 512-glyph cap.** Well known, taken as given, not re-measured.
If you build this and something disagrees with what's written here, that's worth
reporting — a second board is the only way to learn which parts of this were about the
hardware and which were about the one unit it was built on.
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

-444
View File
@@ -1,444 +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.
**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).
+441
View File
@@ -0,0 +1,441 @@
# MeshTerm on the uConsole
A ClockworkPi uConsole with the hackergadgets AIO LoRa board has no companion
microcontroller and no firmware to flash — the LoRa chip sits straight on the host's SPI
bus. So a small bridge runs the mesh node in software on the uConsole itself and puts the
standard MeshCore companion protocol in front of it on a local TCP port. MeshTerm then
connects to that port like it would any network companion. By the end of this guide you'll
have the bridge running as a background service and MeshTerm talking to it.
> ⚠ **Read this first.** This guide edits your uConsole's boot configuration, adds your
> user to hardware groups, installs a service under your account, and then keys a LoRa
> transmitter. It assumes you are comfortable at a Linux shell and know which frequency
> plan and power limits apply where you live — what the radio does on air is your
> responsibility, not the bridge's. There is a real risk of leaving the system, the radio
> or the mesh around you in a worse state if a step goes wrong. You do this at your own
> risk.
>
> **Read the whole manual through once before you start.** Which route you take in
> Step 2 depends on your Debian release, and whether the GUI is running changes what the
> preflight and the service will do — both are easier to get right knowing the later steps.
>
> This is a guide, not a prescription. Despite the author's best efforts it may contain
> errors, and parts of it will go out of date as packages, runtimes and boards change. It
> is up to you to check every fact in it against your own hardware and the current sources
> before you act on it, and every decision along the way is yours. The author accepts no
> responsibility for any damage, loss or injury that results from following this guide.
- [What you need](#what-you-need)
- [Step 1 — Prepare the system](#step-1--prepare-the-system)
- [Step 2 — Install the radio runtime](#step-2--install-the-radio-runtime)
- [Step 3 — Get the bridge](#step-3--get-the-bridge)
- [Step 4 — Run the preflight](#step-4--run-the-preflight)
- [Step 5 — Run it once in the foreground](#step-5--run-it-once-in-the-foreground)
- [Step 6 — Install it as a service](#step-6--install-it-as-a-service)
- [Step 7 — Install MeshTerm and connect](#step-7--install-meshterm-and-connect)
- [Updating and uninstalling](#updating-and-uninstalling)
- [Hardware knobs](#hardware-knobs)
- [Troubleshooting](#troubleshooting)
- [What this has been tested on](#what-this-has-been-tested-on)
---
## What you need
**Hardware:**
| Part | Notes |
| --- | --- |
| ClockworkPi **uConsole** (CM4 module) | Any uConsole with a Compute Module 4. |
| hackergadgets **AIO** expansion board | The board this guide is written for and the one we recommend: an SX1262 on SPI bus 1, plus GPS, an RTL-SDR and a USB hub you don't need here. The bridge's defaults are its wiring. |
| An antenna for your region's LoRa band | Required. |
Installing the AIO board into the uConsole is out of scope here — follow
[hackergadgets' own setup guide](https://hackergadgets.com/pages/hackergadgets-uconsole-rtl-sdr-lora-gps-rtc-usb-hub-all-in-one-extension-board-setup-guide)
for that part.
The bridge assumes the AIO's wiring by default:
| Setting | Default |
| --- | --- |
| SPI bus | 1 |
| Reset pin | 25 |
| Busy pin | 24 |
| IRQ pin | 26 |
| TX-enable pin | none |
| DIO2 (RF switch) | on |
| DIO3 (TCXO) | on |
| Frequency | 910.525 MHz |
| TX power | 22 dBm |
| Spreading factor | 7 |
| 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).
**Software:**
- Raspberry Pi OS / Debian **bookworm** or **trixie** on the uConsole, 64-bit.
- Python 3 (the system one is fine — the bridge finds whichever interpreter has the radio
library, and re-execs itself under it).
- A `/dev/spidev*` node. This needs `dtoverlay=spi1-1cs` in `/boot/firmware/config.txt`.
- `/dev/gpiochip0`, and membership of the `spi` and `gpio` groups so you can read and write
both without root.
- Optionally, the [`meshcore-uconsole`](https://github.com/cwill747/meshcore-uconsole)
package (it brings the `meshcore-console` GUI and a radio runtime). Whether you can use it depends on your Debian release — see
[Step 2](#step-2--install-the-radio-runtime).
The radio library that actually drives the chip was renamed in 2026: `pymc_core` became
`openhop_core`. PyPI's `pymc-core` stopped at 1.0.12; `openhop-core` carries on from 1.1.x.
The bridge supports both and prefers the newer one when it finds both installed.
**MeshTerm itself** — either:
- 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.
---
## Step 1 — Prepare the system
Enable the SPI overlay. Edit `/boot/firmware/config.txt` and add:
```
dtoverlay=spi1-1cs
```
Add yourself to the groups that own the SPI and GPIO device nodes:
```bash
sudo usermod -aG spi,gpio $USER
```
Reboot so both the overlay and the group membership take effect:
```bash
sudo reboot
```
After it comes back, check the device nodes exist and that you can use them:
```bash
ls -l /dev/spidev* /dev/gpiochip0
groups
```
You should see `/dev/spidev1.0` (or another `spidev*` node) and `/dev/gpiochip0` listed,
and `spi` and `gpio` in your `groups` output.
---
## Step 2 — Install the radio runtime
Which route you take depends on your Debian release:
- **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 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]"
```
This is also the route to take if you'd rather not install the `meshcore-uconsole` package
at all.
You'll confirm which runtime the bridge actually found in [Step 4](#step-4--run-the-preflight) —
its first check line names the interpreter and the runtime.
---
## Step 3 — Get the bridge
`scripts/uconsole/meshterm-spi-bridge` is a single, self-contained Python file — no install step of
its own. Either clone the MeshTerm repo:
```bash
git clone https://github.com/jpmartineau/MeshTerm
cd MeshTerm/scripts/uconsole
```
or download just the one file:
```bash
curl -L -o meshterm-spi-bridge \
https://raw.githubusercontent.com/jpmartineau/MeshTerm/main/scripts/uconsole/meshterm-spi-bridge
```
Make it executable:
```bash
chmod +x meshterm-spi-bridge
```
---
## Step 4 — Run the preflight
```bash
./meshterm-spi-bridge --check
```
This prints a `Prerequisite check:` block with one line per check. What each line means and
what to do if it fails:
| Check | Meaning if it fails | Fix |
| --- | --- | --- |
| `node runtime` | Neither `openhop_core` nor `pymc_core` is importable by any interpreter the bridge tried. | Build the venv from [Step 2](#step-2--install-the-radio-runtime), or set `MESHTERM_PYMC_PYTHON` to a python that has one. |
| `SPI device` | No `/dev/spidev*` node exists, or it exists but you can't read/write it. | No node at all: add `dtoverlay=spi1-1cs` to `/boot/firmware/config.txt` and reboot. Node present but no permission: `sudo usermod -aG spi $USER`, then log out and back in. |
| `GPIO chip` | `/dev/gpiochip0` is missing or not read/write for you. | `sudo usermod -aG gpio $USER`, then log out and back in. |
| `bridge service` / `radio in use` / `port 5000 busy` / `radio is free` | Tells you whether something already holds the radio: the bridge's own boot service, a running `meshcore-console`, or something else already listening on the TCP port. | Only one program may use the radio at a time — see [Step 7](#step-7--install-meshterm-and-connect). If `meshcore-console` is running, close it before running the bridge. |
If the first three checks all pass, you're ready to run it.
---
## Step 5 — Run it once in the foreground
```bash
./meshterm-spi-bridge --run
```
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)
radio config bus_id=1 reset_pin=25 busy_pin=24 irq_pin=26 frequency=910525000 tx_power=22
radio.begin() attempt 1/4
radio ready
node identity ab12cd34ef567890… (hash 0xab, name 'uConsole')
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.
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
```
Back in the bridge's terminal, press **Ctrl+C** to stop it.
---
## Step 6 — Install it as a service
Run the bridge with no flags to get the interactive menu:
```bash
./meshterm-spi-bridge
```
Choose **2) Install as a service**. This:
- copies the script to `~/.local/bin/meshterm-spi-bridge`,
- writes a `systemd --user` unit at
`~/.config/systemd/user/meshterm-spi-bridge.service` that restarts on failure and starts
with your session,
- enables and starts it now,
- tries to enable lingering for your user, so the service comes up at boot even before you
log in. If it can't, it prints the command to run yourself:
```bash
sudo loginctl enable-linger $USER
```
It also offers to add a `[profiles.bridge]` block to your MeshTerm `config.toml` — say yes,
it's the profile you'll use in [Step 7](#step-7--install-meshterm-and-connect).
Check on it any time:
```bash
systemctl --user status meshterm-spi-bridge.service
journalctl --user -u meshterm-spi-bridge.service -f
```
Menu option **4) Show status** prints the same information from inside the script.
---
## Step 7 — Install MeshTerm and connect
Install MeshTerm on the uConsole. The simplest way is the Linux ARM64 one-file release:
```bash
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`:
```bash
sudo apt install pipx
pipx install git+https://github.com/jpmartineau/MeshTerm
pipx ensurepath
```
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]
host = "127.0.0.1"
tcp_port = 5000
description = "Local SPI radio via meshterm-spi-bridge"
```
Connect with:
```bash
meshterm -p bridge
```
or, without a profile:
```bash
meshterm --tcp 127.0.0.1:5000
```
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 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
`meshcore-console` GUI at the same time — whichever started second will fail to open the
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/`). 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
`meshcore-console`, the radio library, your node identity, or your saved contacts — those
stay put. If you'd added `[profiles.bridge]` to your MeshTerm config, it tells you to remove
that block by hand if you no longer want it.
---
## Hardware knobs
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. 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 |
| `MESHTERM_PYMC_PYTHON` | force a specific interpreter instead of letting the bridge discover one |
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 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.
---
## Troubleshooting
| Symptom | Check |
| --- | --- |
| `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 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. |
---
## What this has been tested on
This has been verified on a bookworm uConsole with `openhop-core` 1.1.3: the radio comes up,
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.
MeshTerm's test suite does not cover the bridge.
What has not been confirmed:
- **The bridge under `openhop_core` beyond `info` and `contacts`.** Startup, identity,
contact restore and those two reads were exercised; a trace, a message and the live feed
under the new runtime were not, though the library's own trace push is what the bridge
now relies on for the first.
- **Everything on-air** — the per-hop trace semantics the bridge reassembles and the
acknowledgement bytes newer firmware appends after the CRC. The code is internally
consistent with the library's API; the wire behaviour was seen on one mesh, against the
radios in range of one bench.
+4 -3
View File
@@ -1,11 +1,12 @@
"""MeshTerm is a full-featured TUI-based MeshCore client for your terminal.
# SPDX-License-Identifier: Apache-2.0
"""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.2"
__version__ = "0.9.0"
__author__ = "Jean-Pierre Martineau"
#: The year MeshTerm was first published; the copyright span starts here.
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Enable ``python -m meshterm``."""
from .cli import main
+22 -19
View File
@@ -4,11 +4,11 @@
## What it is
**MeshTerm** is a full-featured **MeshCore** client for the terminal. It talks to companion devices running the stock firmware over serial, Bluetooth, or TCP.
**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,21 +49,24 @@ 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
itself, isn't affiliated with MeshTerm.
Built on **MeshCore** *https://meshcore.io/* — its firmware and companion
library — with Rich, prompt_toolkit, Typer, bleak, pyserial, pycryptodome
and Segno, each under its own terms.
library — with Rich, prompt_toolkit, Typer, bleak, pyserial, pycryptodome,
markdown-it-py, tomli/tomli-w and Segno, each under its own terms.
Map data **© OpenStreetMap contributors**, served as vector tiles by
**OpenFreeMap** *https://openfreemap.org/*, used under the
**ODbL** *https://www.openstreetmap.org/copyright*.
**OpenFreeMap** *https://openfreemap.org/* on the **© OpenMapTiles** schema
*https://openmaptiles.org/*, used under the **ODbL**
*https://www.openstreetmap.org/copyright*. That last address is where the map's
own corner credit points — nothing on a console is clickable, so the link is
spelled out here instead.
---
+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
+10 -10
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,27 +20,27 @@ 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://tinyurl.com/SupportMeshTerm*
- *https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6*
```qr
https://tinyurl.com/SupportMeshTerm
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*
+59 -7
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Typer entry point.
Global options are parsed once in the callback, which builds the shared
@@ -17,15 +18,16 @@ import typer
from rich.console import Console
from typer.core import TyperGroup
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
from .core.instancelock import InstanceBusy, hold_instance_lock
from .core.preferences import PreferenceError, Preferences
from .core.preferences import PREFERENCES_FILENAME, PreferenceError, Preferences
from .core.selection import DeviceSelectionError
from .persistence.logging import configure_logging, get_logger, level_from_name, log_path
from .persistence.repository import Repository
@@ -119,13 +121,28 @@ 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,
)
def _print_version(value: bool) -> None:
"""Print the version and stop, the moment ``--version`` is seen.
Eager, so it answers before the callback opens a database, resolves a platform or looks
for a radio — the one question that is true of the program rather than of a run. Bare
``meshterm <version>``: the caller asked for a version, and a script reading it back has
one field to take. Exits ``0``, because knowing the version is a success.
"""
if not value:
return
script.console().print(f"meshterm {__version__}", highlight=False)
raise typer.Exit(exitcodes.OK)
# The context built by the callback and consumed by subcommands within one process.
_state: AppContext | None = None
@@ -137,6 +154,13 @@ _platform_resolution: Resolution | None = None
@app.callback(invoke_without_command=True)
def main_callback(
ctx: typer.Context,
version: bool = typer.Option(
False,
"--version",
help="Print the version and exit",
callback=_print_version,
is_eager=True,
),
profile: str | None = typer.Option(None, "--profile", "-p", help="Device profile"),
port: str | None = typer.Option(None, "--port", help="Serial port override"),
ble: str | None = typer.Option(
@@ -151,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"
@@ -167,13 +193,15 @@ def main_callback(
Args:
ctx: The Click/Typer context.
version: Print the version and exit (handled eagerly by :func:`_print_version`).
profile: Named device profile to use.
port: Explicit serial port, overriding the profile.
ble: Explicit Bluetooth address, selecting the BLE transport.
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).
@@ -207,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,
@@ -216,7 +255,7 @@ def main_callback(
console = script.console() if scripted else make_console()
# Loaded before logging is configured, because how much goes in the file is one of
# them — and handed to the context afterwards so the file is not read twice.
prefs = Preferences.load(settings.config_dir / "preferences.yaml")
prefs = Preferences.load(settings.config_dir / PREFERENCES_FILENAME)
if absolute:
# An override of the preference rather than a second switch beside it: *how
# MeshTerm behaves* is a preference by this project's own rule, and a flag that
@@ -284,6 +323,17 @@ def main_callback(
if _offer_a_console_that_can_draw_meshterm(console, prefs):
return
from .services import consolefont as vt_console_font
# The PicoCalc's whole console shares one font, so asking for the 6x8 build changes
# what the *shell* is drawn in too. Save the reader's font before the first paint
# and put it back on the way out — from the `finally` here, and from an `atexit`
# remember() arms, since ^Q and an unhandled error leave by other doors. This is
# the menu's boundary only: a scripted run prints into a console it was invited
# into and never touches its font.
saved_font = vt_console_font.remember()
vt_console_font.apply(prefs.get("console_font"))
# The interactive session is the one that holds the stores in memory and rewrites
# them whole, so it is the one that claims the directory. One-shot subcommands are
# brief and mostly read; blocking `meshterm contacts` because a menu is open in
@@ -309,6 +359,8 @@ def main_callback(
get_logger().exception("the interactive session raised an unhandled error")
_report_log_location(console, settings.config_dir)
raise
finally:
vt_console_font.restore(saved_font)
def _offer_a_console_that_can_draw_meshterm(console: Console, prefs: Preferences) -> bool:
+39 -9
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The application context: a small dependency container passed to every tool.
``AppContext`` owns the shared, expensive singletons (console, settings, repository,
@@ -22,7 +23,7 @@ from .core.courier_store import CourierStore
from .core.device_store import DeviceStore
from .core.discovery import DiscoveredDevice, discover_devices
from .core.mute_store import MuteStore
from .core.preferences import Preferences
from .core.preferences import PREFERENCES_FILENAME, Preferences
from .core.preferences import install as install_preferences
from .core.remote_store import RemoteStore
from .core.selection import resolve_device
@@ -37,6 +38,7 @@ if TYPE_CHECKING:
from .services.basemap import BasemapSource
from .services.battery_service import BatteryService
from .services.chat_service import ChatService
from .services.clock_sync import ClockSync
from .services.courier import CourierService
from .services.device_state import DeviceState
from .services.event_hub import EventHub
@@ -55,7 +57,7 @@ class AppContext:
talk to (config directory, database, named profiles). *How MeshTerm behaves*
is ``preferences``, not this.
preferences: MeshTerm's own preferences, backed by
``<config_dir>/preferences.yaml`` and edited on the Preferences page. Defaults
``<config_dir>/preferences.toml`` and edited on the Preferences page. Defaults
to a set loaded from that file when not injected, and is installed as the
process-wide :func:`~meshterm.core.preferences.current` set so the render
layer — which has no context to reach through — reads the same values.
@@ -154,6 +156,7 @@ class AppContext:
_chat: ChatService | None = field(default=None, init=False, repr=False)
_adverts: AdvertScheduler | None = field(default=None, init=False, repr=False)
_watchtower: WatchtowerService | None = field(default=None, init=False, repr=False)
_clock_sync: ClockSync | None = field(default=None, init=False, repr=False)
_courier: CourierService | None = field(default=None, init=False, repr=False)
_battery: BatteryService | None = field(default=None, init=False, repr=False)
_devstate: DeviceState | None = field(default=None, init=False, repr=False)
@@ -168,7 +171,7 @@ class AppContext:
stores built without a context still have to read them.
"""
if self.preferences is None:
self.preferences = Preferences.load(self.settings.config_dir / "preferences.yaml")
self.preferences = Preferences.load(self.settings.config_dir / PREFERENCES_FILENAME)
install_preferences(self.preferences)
if self.advert_store is None:
self.advert_store = AdvertStore(self.settings.config_dir / "adverts.json")
@@ -357,6 +360,21 @@ class AppContext:
self._watchtower = WatchtowerService(self)
return self._watchtower
@property
def clock_sync(self) -> ClockSync:
"""Return the session's on-connect clock setter, creating it on first use.
Created idle here; the interactive session starts it alongside the other
always-on services, and :meth:`device` tells it about each connection as it
settles. Scripted CLI runs never start it, so a one-shot command that only reads
the radio never writes its clock.
"""
if self._clock_sync is None:
from .services.clock_sync import ClockSync
self._clock_sync = ClockSync(self)
return self._clock_sync
@property
def courier(self) -> CourierService:
"""Return the session's store-and-forward courier, creating it on first use.
@@ -514,8 +532,7 @@ class AppContext:
self._active_port = None
self._active_address = None
await self._open(f"TCP companion {tcp_host}:{tcp_port}")
await self._remember_connected()
await self._reconcile_channels()
await self._settle_connection()
return self._device
# A Bluetooth endpoint (an explicit ``--ble``, a BLE profile, a device picked at
@@ -548,8 +565,7 @@ class AppContext:
self._active_port = None
self._active_endpoint = None
await self._open(f"BLE companion {ble_address}")
await self._remember_connected()
await self._reconcile_channels()
await self._settle_connection()
return self._device
resolution = resolve_device(
@@ -567,8 +583,7 @@ class AppContext:
self._active_address = None
self._active_endpoint = None
await self._open(f"serial port {resolution.port}")
await self._remember_connected()
await self._reconcile_channels()
await self._settle_connection()
return self._device
def _resolve_tcp_endpoint(self) -> tuple[str | None, int | None]:
@@ -655,6 +670,19 @@ class AppContext:
hardware_model=await self._hardware_model(),
)
async def _settle_connection(self) -> None:
"""The steps every fresh connection gets once the link is open, in order.
Remember the device as the default, replay its remembered channels, and hand it to
the on-connect clock setter — which returns at once and does its work in the
background, so the connection is usable the moment this returns. Each step is
best-effort on its own; none can fail the connection.
"""
await self._remember_connected()
await self._reconcile_channels()
if self._device is not None:
self.clock_sync.on_connected(self._device)
async def _reconcile_channels(self) -> None:
"""Replay channels remembered for this device that it isn't reporting (best-effort).
@@ -791,6 +819,8 @@ class AppContext:
await self._battery.aclose()
if self._watchtower is not None:
await self._watchtower.aclose()
if self._clock_sync is not None:
await self._clock_sync.aclose()
if self._adverts is not None:
await self._adverts.aclose()
if self._monitor is not None:
+1
View File
@@ -1 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
"""Core domain layer: models, configuration, and the device connection abstraction."""
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for remembered remote-node admin passwords.
The remote-admin optimizer logs in to a repeater to tune its TX power; storing the
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for the background-advert schedule.
MeshTerm announces the connected node on a cadence — a zero-hop (direct) advert for the
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""THE way MeshTerm replaces a file it owns: all of the new contents, or none of them.
Every store under ``core/`` keeps its state in one file that it rewrites whole. Writing
+166
View File
@@ -0,0 +1,166 @@
# SPDX-License-Identifier: Apache-2.0
"""Reading the device's channel slots back off the radio.
One configured channel slot (:class:`ChannelSlot`) and the two reads that discover them.
Every derived property on the slot delegates to :mod:`meshterm.core.channels`, which owns the
key/identity maths; this module is only the device-facing half — the scan, and what it means
when the scan stops.
It lives in :mod:`meshterm.core` rather than beside the channel manager screen because the
screen is not the only reader: the device-state session cache
(:class:`~meshterm.services.device_state.DeviceState`) warms the probe, the ``channels`` and
``chat`` tools read slots for their CLI faces, and a service must never have to reach up into
``ui/`` for a device read. It is a separate module from :mod:`meshterm.core.channels` because
it needs :class:`~meshterm.core.connection.Device`, and ``connection`` already imports
``channels`` — keeping the maths free of the transport is what keeps that direction one-way.
"""
from __future__ import annotations
from dataclasses import dataclass
from .channels import (
CHANNEL_SLOT_EMPTY_RUN,
CHANNEL_SLOT_PROBE_CAP,
channel_hash,
channel_identity,
full_channel_hash,
is_name_derived,
is_public_channel,
)
from .connection import Device, DeviceCommandError, is_connection_lost
from .models import Conversation
@dataclass(slots=True)
class ChannelSlot:
"""One configured channel slot read back from the device.
Attributes:
idx: The 0-based slot index.
name: The channel's name.
secret: The channel's 16-byte shared secret.
"""
idx: int
name: str
secret: bytes
@property
def is_name_derived(self) -> bool:
"""Whether this channel's key is reproducible from its name (so it needn't be stored)."""
return is_name_derived(self.name, self.secret)
@property
def is_public(self) -> bool:
"""Whether this channel is public (shared meshwide) rather than a private one.
Covers both name-derived channels and the firmware's fixed-key default ``Public``.
"""
return is_public_channel(self.name, self.secret)
@property
def hash(self) -> str:
"""The channel's two-character hash fingerprint (the leading byte MeshCore shows)."""
return channel_hash(self.secret)
@property
def full_hash(self) -> str:
"""The channel's complete ``sha256(secret)`` digest; its first byte is :attr:`hash`."""
return full_channel_hash(self.secret)
@property
def identity(self) -> str:
"""The channel's slot-independent identity, used to key its chat history."""
return channel_identity(self.name, self.secret)
@property
def conversation(self) -> Conversation:
"""A :class:`~meshterm.core.models.Conversation` for opening this channel in chat."""
return Conversation(
label=self.name,
is_channel=True,
channel_idx=self.idx,
channel_id=self.identity,
secret=self.secret,
)
async def read_channel_slots(device: Device) -> list[ChannelSlot]:
"""Probe the channel slots and return the configured ones, in index order.
The scan stops as soon as the firmware rejects a slot index, so it reads exactly the
slots a well-behaved device has regardless of its capacity. Firmware that never rejects
an out-of-range index (it answers every slot with an empty payload instead of raising)
would otherwise walk all :data:`CHANNEL_SLOT_PROBE_CAP` slots on every read; a run of
:data:`CHANNEL_SLOT_EMPTY_RUN` consecutive empty slots ends the scan on such firmware.
That is safe because the manager packs channels from slot 0 up, so an unbroken empty run
that long means every configured channel has already been seen (see the constant's note).
Args:
device: The connected device to query.
Returns:
One :class:`ChannelSlot` per configured slot (an empty slot is skipped).
"""
slots, _complete = await probe_channel_slots(device)
return slots
async def probe_channel_slots(device: Device) -> tuple[list[ChannelSlot], bool]:
"""The probe behind :func:`read_channel_slots`, reporting whether it *finished*.
The scan stops for two very different reasons and the plain list cannot tell them
apart. Off the end of the configured slots (a rejected index, or a long enough run of
empty ones) the answer is complete and worth keeping. A read that simply *failed* —
a timeout, a link hiccup, and then every subsequent read failing too because the
firmware's reply no longer matches the slot asked for — ends the scan early with
whatever it happened to have, which is not the device's layout and must not be cached
or acted on as if it were: an empty one reads as "no channels configured", and a
truncated one makes the next free slot look free when a channel is sitting in it.
The two endings raise different things, and that is what separates them: the firmware
*answering* "no such slot" surfaces as a plain rejection, while a link that stopped
answering surfaces as a timeout, a :class:`~meshterm.core.connection.DeviceCommandError`
(whose whole subject is a companion that did not reply in time), or one of the dropped-
link signatures :func:`~meshterm.core.connection.is_connection_lost` knows. Only the
first ending is a layout. A read that fails for some fourth reason is treated as a
rejection, which is the safe way round: the list is used but the cache re-probes.
Args:
device: The connected device to query.
Returns:
The slots read, and whether the scan ran to a clean end.
"""
slots: list[ChannelSlot] = []
empty_run = 0
for idx in range(CHANNEL_SLOT_PROBE_CAP):
try:
payload = await device.get_channel(idx)
except Exception as exc: # noqa: BLE001 - a rejected index, or a read that failed
return slots, not _read_failed(exc)
if payload and payload.get("channel_name"):
empty_run = 0
slots.append(
ChannelSlot(
idx=idx,
name=str(payload["channel_name"]),
secret=bytes(payload.get("channel_secret") or b"\x00" * 16),
)
)
else:
empty_run += 1
if empty_run >= CHANNEL_SLOT_EMPTY_RUN:
break # off the end of a never-rejecting firmware; nothing more to find
return slots, True
def _read_failed(exc: BaseException) -> bool:
"""Whether ``exc`` means the *read* failed, rather than the firmware refusing a slot.
A refusal is the probe's ordinary ending and leaves a complete list behind it. A
failure leaves a short one that looks exactly the same — which is the whole reason
this distinction has to be drawn somewhere (see :func:`probe_channel_slots`).
"""
return isinstance(exc, (TimeoutError, DeviceCommandError)) or is_connection_lost(exc)
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for channels created through MeshTerm, so they survive a device that forgets.
Most companions keep their own channel table in firmware, so MeshTerm reads channels live from
+9 -1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Pure channel logic: secret derivation, MeshCore share-URL round-tripping, and decrypt.
A MeshCore channel is a name plus a 16-byte shared secret. *Public* channels (whose name
@@ -35,6 +36,13 @@ from urllib.parse import parse_qs, quote, urlsplit
#: be followed by ``": "`` — conservative enough to leave ``http://…`` and ``note:x`` alone.
SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,19}):[ \t]+(.*)$", re.DOTALL)
#: Matches an ``@[Name]`` mention token, as the reply flow primes into the compose line. A
#: transcript renders each as a bare ``@Name`` coloured in that sender's hue instead of
#: showing the literal brackets, and the CLI's history face lifts the same names out — so the
#: token lives here with the rest of the message-body parsing rather than in either renderer.
#: Name is 1–20 non-``]`` characters.
MENTION = re.compile(r"@\[([^\]]{1,20})\]")
def split_channel_sender(text: str) -> tuple[str | None, str]:
"""Split a channel message into ``(sender_name, body)`` when it carries a name prefix.
@@ -74,7 +82,7 @@ MAX_CHANNELS = 8
CHANNEL_SLOT_PROBE_CAP = 64
#: A run of this many consecutive *empty* slots ends a *configured-channel* scan (see
#: :func:`meshterm.ui.channels.read_channel_slots`) on firmware that never rejects an
#: :func:`meshterm.core.channel_probe.read_channel_slots`) on firmware that never rejects an
#: out-of-range index — some builds answer every index with an empty payload instead of
#: raising, so the scan would otherwise walk all :data:`CHANNEL_SLOT_PROBE_CAP` slots on
#: every read. The channel manager packs channels from slot 0 up within the stock
+13 -1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Machine setup and named device profiles.
Settings are read from a TOML file (default ``~/.meshterm/config.toml``) and may
@@ -33,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.
@@ -137,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.
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""TOML backup and restore for device configuration.
A backup captures every registry setting's current value plus experimental custom
+185 -20
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Device connection abstraction.
Services and tools depend only on the :class:`Device` interface, never on the
@@ -41,6 +42,7 @@ from .models import (
advert_time,
utcnow,
)
from .tracing import path_hash_flags, trace_timeout
if TYPE_CHECKING:
from .discovery import DiscoveredDevice
@@ -143,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
@@ -704,7 +716,7 @@ class Device(ABC):
timeout: Seconds to wait for the trace reply. ``None`` (the default) sizes the
wait to the route: a trace has to travel the whole path out and back, so a
long walk is given proportionally longer to come home
(:func:`~meshterm.services.trace_runner.trace_timeout`).
(:func:`~meshterm.core.tracing.trace_timeout`).
Returns:
A :class:`TraceResult`; ``success`` is ``False`` on timeout.
@@ -1222,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.
@@ -1303,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,
@@ -1630,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 "
@@ -1784,18 +1866,29 @@ class MeshCoreDevice(Device):
return {}
return dict(getattr(result, "payload", {}) or {})
async def _contacts_payload(self, mc, *, retries: int = 3, delay: float = 0.5) -> dict: # noqa: ANN001
"""Fetch the raw contacts map, retrying the transient "no event" blip.
#: How long the contacts stream may go quiet before a read is called failed (seconds).
#: The library's own ``get_contacts`` arms one future for the *whole* dump and never
#: re-arms it, so a table that takes longer than its five seconds to stream fails with
#: "no event received" however healthy the radio is — which is what a node holding four
#: hundred contacts does over Bluetooth. What actually means "the companion stopped
#: answering" is a gap *between* records, so that is what this times: the dump may take
#: as long as it takes, for as long as it keeps arriving.
_CONTACTS_IDLE_S = 6.0
The companion intermittently fails to emit the contacts event in time and
returns an error event instead of data (``no event received during contacts
retrieval``). That's a recoverable timing hiccup, so retry a few times with a
short backoff before surfacing a clean, actionable error.
async def _contacts_payload(
self, mc, *, retries: int = 3, delay: float = 0.5, idle: float | None = None
) -> dict: # noqa: ANN001
"""Fetch the raw contacts map, retrying a read that stalls or is refused.
The companion intermittently refuses or drops a contacts request — a recoverable
timing hiccup — so the read is attempted a few times with a short backoff before a
clean, actionable error is raised. One attempt is :meth:`_stream_contacts`.
Args:
mc: The connected ``MeshCore`` client.
retries: Number of extra attempts after the first.
delay: Seconds to wait between attempts.
idle: Seconds of silence that end an attempt (default :data:`_CONTACTS_IDLE_S`).
Returns:
The contacts payload mapping (possibly empty).
@@ -1803,13 +1896,12 @@ class MeshCoreDevice(Device):
Raises:
DeviceCommandError: If every attempt fails to retrieve contacts.
"""
gap = self._CONTACTS_IDLE_S if idle is None else idle
reason = ""
for attempt in range(retries + 1):
event = await mc.commands.get_contacts()
if event is None or not getattr(event, "is_error", lambda: False)():
return dict(getattr(event, "payload", {}) or {})
payload = getattr(event, "payload", {}) or {}
reason = str(payload.get("reason", payload))
payload, reason = await self._stream_contacts(mc, gap)
if payload is not None:
return payload
if attempt < retries:
await asyncio.sleep(delay)
raise DeviceCommandError(
@@ -1818,6 +1910,85 @@ class MeshCoreDevice(Device):
"retry, or power-cycle/reconnect the radio if it persists."
)
async def _stream_contacts(self, mc, idle: float) -> tuple[dict | None, str]: # noqa: ANN001
"""Run one contacts read, ending it on *silence* rather than on a deadline.
The contacts table arrives as one ``NEXT_CONTACT`` frame per record and a closing
``CONTACTS`` frame holding the whole map. A dump is therefore not one answer that is
either late or on time, it is a stream, and the only thing that distinguishes a slow
big table from a radio that has stopped talking is how long it has been since the
last record. So each record restarts the clock and only ``idle`` seconds of quiet
end the attempt — which is the whole of the fix for a four-hundred-contact node,
whose dump simply takes longer than any fixed deadline the library would allow it.
Args:
mc: The connected ``MeshCore`` client.
idle: Seconds of silence that end the attempt.
Returns:
``(payload, "")`` on success, or ``(None, reason)`` describing how it ended.
"""
from meshcore import EventType
loop = asyncio.get_running_loop()
finished: asyncio.Future = loop.create_future()
arrived = asyncio.Event()
seen = False
refusal = ""
def on_record(event) -> None: # noqa: ANN001 - meshcore Event
nonlocal seen
seen = True
arrived.set()
def on_end(event) -> None: # noqa: ANN001 - meshcore Event
if not finished.done():
finished.set_result(event)
def on_error(event) -> None: # noqa: ANN001 - meshcore Event
# An ERROR frame carries no request id, so it is only ours while nothing else
# can have earned it: before the first record it is this request being refused
# (a companion too busy to serve a dump answers ERR_CODE_BAD_STATE), and after
# one it belongs to whatever else is on the link — a battery poll, a courier
# send — where treating it as ours is how a read that was working stopped.
nonlocal refusal
payload = getattr(event, "payload", {}) or {}
refusal = str(payload.get("reason", payload))
if not seen and not finished.done():
finished.set_result(None)
subscriptions = [
mc.subscribe(EventType.NEXT_CONTACT, on_record),
mc.subscribe(EventType.CONTACTS, on_end),
mc.subscribe(EventType.ERROR, on_error),
]
try:
await mc.commands.get_contacts_async()
while not finished.done():
arrived.clear()
ticking = asyncio.ensure_future(arrived.wait())
try:
await asyncio.wait(
{finished, ticking},
timeout=idle,
return_when=asyncio.FIRST_COMPLETED,
)
finally:
ticking.cancel()
if not finished.done() and not arrived.is_set():
break
finally:
for subscription in subscriptions:
subscription.unsubscribe()
event = finished.result() if finished.done() else None
if event is not None:
return dict(getattr(event, "payload", {}) or {}), ""
if refusal and not seen:
return None, refusal
if seen:
return None, "no event received during contacts retrieval — it stopped partway"
return None, "no event received during contacts retrieval"
async def get_contacts(self) -> list[Contact]: # noqa: D102 - inherited docstring
mc = self._require()
payload = await self._contacts_payload(mc)
@@ -1924,8 +2095,6 @@ class MeshCoreDevice(Device):
async def admin_login(self, node: Contact, password: str) -> LoginResult: # noqa: D102
from meshcore import EventType
from ..services.trace_runner import trace_timeout
mc = self._require()
pub = self._node_pubkey(node)
loop = asyncio.get_running_loop()
@@ -2159,11 +2328,9 @@ class MeshCoreDevice(Device):
transmit_gate.mark()
from meshcore import EventType # local import keeps mock path dependency-free
from ..services.trace_runner import path_hash_flags, trace_timeout
mc = self._require()
tag = random.randint(0, 0xFFFFFFFF)
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
started = loop.time()
# A trace packet has no destination field — it walks an explicit path of
@@ -2286,8 +2453,6 @@ class MeshCoreDevice(Device):
``(path_bytes, flags)`` to walk, or ``None`` only when the contact is
unknown or carries no public key to address.
"""
from ..services.trace_runner import path_hash_flags
payload = await self._contacts_payload(mc)
needle = target.casefold()
for name, info in payload.items():
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
r"""Installing and selecting a console font on Windows, for one user, without admin.
The classic Windows console does no font fallback: a character its configured font lacks
+48 -82
View File
@@ -1,9 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
"""Rating contacts by how much they are worth a slot in the device's contact table.
A companion's contact table is finite, and a mesh fills it with whatever adverts happen to
arrive — so a radio left running long enough ends up holding mostly nodes heard once, in
passing, from four hops away, while there is no room left to *discover* anyone new. The
Contacts screen's bulk sweep (see :func:`~meshterm.ui.purge_screen.purge_contacts`) is how
Contacts screen's bulk sweep (see :func:`~meshterm.ui.sweep_screen.archive_contacts`) is how
that table gets its headroom back, and this module is the judgement it runs on: one score
per contact, so the sweep can take the weakest and leave the ones you would actually miss.
@@ -17,7 +18,7 @@ must not have.
contacts on a real mesh advertise no location, plenty are never overheard as a relayed
packet so have no hop count, and a channel poster whose name matches no contact cannot be
attributed at all. If "we don't know" resolved to ``0.0``, every one of those would sink to
the bottom *together*, and the sweep would purge by how much metadata a node happens to
the bottom *together*, and the sweep would archive by how much metadata a node happens to
broadcast rather than by how much it is worth. So a term that cannot be computed returns
``None`` and is filled with the **population median** of the contacts that could compute it
(:func:`_fill_unknowns`) — the contact lands exactly where an average peer would on that
@@ -34,17 +35,19 @@ before this history began) has no arrival time to decay from and is protected ou
rather than being punished for MeshTerm's own ignorance — see :data:`PROTECT_UNOBSERVED`.
**Some contacts are never candidates.** A score is a heuristic and a heuristic must not
overrule an explicit choice, so :func:`protection_for` short-circuits four cases before any
arithmetic: a watched node, anyone you have sent a direct message to, a repeater whose
admin credentials are stored, and the unobserved contact above. They are still scored and
still ranked — they are real contacts and the percentile scale is the whole population —
but they are never swept.
overrule an explicit choice, so :func:`protection_for` short-circuits five cases before any
arithmetic: a contact you locked, a watched node, anyone you have sent a direct message to,
a repeater whose admin credentials are stored, and the unobserved contact above. They are
still scored and still ranked — they are real contacts and the percentile scale is the whole
population — but they are never swept.
**What is displayed is the percentile, never the score.** A raw ``47.3`` means nothing
without the distribution it came from; a contact's *rank against your other contacts* is
self-calibrating, needs no legend, and stays comparable when the weights change. So
:class:`ScoredContact` carries :attr:`~ScoredContact.percentile` and the UI shows only that
(see :func:`percentile_rank`).
**The score is never displayed.** A raw ``47.3`` means nothing without the distribution it
came from. What this module hands a screen instead is a *rank* — the list comes back ordered,
and :attr:`~ScoredContact.percentile` places one contact in the field without a legend and
without going stale when the weights change (see :func:`percentile_rank`). What a screen draws
beside a contact is neither: the archive preview shows the :class:`ContactSignals` themselves, a
lane per measured kind, because the thing a reader can check against what they know is the
evidence, not this module's reading of it.
"""
from __future__ import annotations
@@ -57,6 +60,10 @@ from statistics import median
from .geo import haversine_km
from .models import Contact
#: Protection reason: the reader locked this contact from its page. The most explicit claim
#: there is — a lock says nothing but "never archive this one" — so it is checked first.
PROTECT_LOCKED = "locked"
#: Protection reason: the node is starred in the Watchtower. An explicit pin outranks every
#: heuristic in this module — the user already said this one matters.
PROTECT_WATCHED = "watched"
@@ -66,22 +73,29 @@ PROTECT_WATCHED = "watched"
#: undone by a score. (Inbound-only traffic does *not* protect — anyone can message you.)
PROTECT_MESSAGED = "messaged"
#: Protection reason: a repeater or room server whose admin password is stored. Purging it
#: Protection reason: a repeater or room server whose admin password is stored. Archiving it
#: costs the login, which is a far larger loss than a contact slot is a gain.
PROTECT_ADMIN = "admin"
#: Protection reason: MeshTerm has never heard this contact, so every evidence term is
#: empty for a reason that says nothing about the node. It came from the device's own table
#: (or was added by hand) before this history began; sweeping it would be purging by how
#: (or was added by hand) before this history began; sweeping it would be archiving by how
#: long MeshTerm has been running rather than by anything the contact did.
PROTECT_UNOBSERVED = "unobserved"
#: The order protections are reported in when a contact qualifies for several — most
#: deliberate first, so the preview explains a row by the strongest claim on it.
PROTECTION_ORDER = (PROTECT_WATCHED, PROTECT_MESSAGED, PROTECT_ADMIN, PROTECT_UNOBSERVED)
PROTECTION_ORDER = (
PROTECT_LOCKED,
PROTECT_WATCHED,
PROTECT_MESSAGED,
PROTECT_ADMIN,
PROTECT_UNOBSERVED,
)
#: How a protection reads in the UI, keyed by the constants above.
PROTECTION_LABELS = {
PROTECT_LOCKED: "locked",
PROTECT_WATCHED: "watched",
PROTECT_MESSAGED: "you messaged",
PROTECT_ADMIN: "admin login",
@@ -178,6 +192,8 @@ class ContactSignals:
which is what :data:`PROTECT_UNOBSERVED` keys on.
watched: Whether the node is starred in the Watchtower.
has_admin: Whether admin credentials are stored for it.
locked: Whether the reader locked the contact against archiving (see
:meth:`~meshterm.core.contact_store.ContactStore.set_locked`).
"""
node: str
@@ -193,26 +209,29 @@ class ContactSignals:
known_days: float | None = None
watched: bool = False
has_admin: bool = False
locked: bool = False
@dataclass(frozen=True)
class ScoredContact:
"""One contact's standing among the others: its score, its percentile, and why.
"""One contact's standing among the others: its score, its rank, and the evidence for both.
Attributes:
contact: The contact this describes.
signals: The evidence the score was computed from.
score: The weighted sum plus any newcomer grace. Never displayed — see
:attr:`percentile`, and the module docstring for why.
percentile: Percentile rank within the scored population, ``0``–``100``. **This is
the only number the UI shows**: it is self-calibrating (a mesh of 30 contacts
and one of 300 both read the same way), needs no legend, and stays meaningful
if the weights are ever retuned.
percentile: Percentile rank within the scored population, ``0``–``100``. The
score's one publishable reading: self-calibrating (a mesh of 30 contacts and
one of 300 both read the same way), needing no legend, and still meaningful if
the weights are ever retuned.
protection: Why this contact can never be swept, or ``None`` if it can. One of the
``PROTECT_*`` constants.
reasons: Short lowercase phrases naming what this contact's standing rests on —
its weakest lanes when it is a purge candidate, so the preview row explains
itself without the reader having to know the formula.
The **signals** are what a screen shows, not the score or its terms: the archive preview
draws one lane per measured kind straight off :attr:`signals` (see
:mod:`~meshterm.ui.sweep_screen`), so a reader auditing a sweep reads the evidence in
the same units it was gathered in rather than a phrase this module chose for them.
"""
contact: Contact
@@ -220,7 +239,6 @@ class ScoredContact:
score: float
percentile: int
protection: str | None = None
reasons: tuple[str, ...] = ()
@property
def protected(self) -> bool:
@@ -374,6 +392,7 @@ def protection_for(signals: ContactSignals) -> str | None:
One of the ``PROTECT_*`` constants, or ``None``.
"""
claims = {
PROTECT_LOCKED: signals.locked,
PROTECT_WATCHED: signals.watched,
PROTECT_MESSAGED: signals.dm_outbound > 0,
PROTECT_ADMIN: signals.has_admin,
@@ -394,7 +413,7 @@ def protection_for(signals: ContactSignals) -> str | None:
#: unknown.
_MIN_LOCATED = 4
#: The term names, in the order a :class:`ScoredContact` reports its reasons.
#: The term names, in the order the weighted sum walks them.
_TERM_NAMES = ("dm", "recency", "volume", "channel", "hops", "distance")
@@ -448,54 +467,6 @@ def percentile_rank(score: float, population: Sequence[float]) -> int:
return int(round(100.0 * (below + 0.5 * equal) / len(population)))
def _reasons(
signals: ContactSignals, terms: dict[str, float], weights: ScoreWeights
) -> tuple[str, ...]:
"""Name what this contact's standing rests on, weakest lane first.
The preview has one line per contact and the reader should not have to know the formula
to audit a sweep — so each row says, in plain words, the two things that put it where it
is. Phrases are drawn from the *measured* signals rather than from the term values, so
"3 packets" is the actual tally and not a normalised fraction.
"""
phrases: list[tuple[float, str]] = []
contribution = {
"dm": terms["dm"] * weights.dm,
"recency": terms["recency"] * weights.recency,
"volume": terms["volume"] * weights.volume,
"hops": terms["hops"] * weights.hops,
}
if signals.dm_total:
phrases.append((contribution["dm"], f"{signals.dm_total} messages"))
else:
phrases.append((0.0, "never messaged"))
if signals.heard_age_days is None:
phrases.append((0.0, "never heard"))
else:
phrases.append((contribution["recency"], _age_phrase(signals.heard_age_days)))
packets = signals.packets
phrases.append((contribution["volume"], f"{packets} pkt{'' if packets == 1 else 's'}"))
if signals.hops is not None:
hops = signals.hops
label = "direct" if hops < 0.5 else f"{hops:g} hops"
phrases.append((contribution["hops"], label))
phrases.sort(key=lambda pair: pair[0])
return tuple(phrase for _, phrase in phrases[:3])
def _age_phrase(days: float) -> str:
"""A last-heard age in the fewest words: ``today`` / ``4d`` / ``6w`` / ``9mo`` / ``2y``."""
if days < 1:
return "today"
if days < 14:
return f"{int(days)}d"
if days < 60:
return f"{int(days / 7)}w"
if days < 365:
return f"{int(days / 30)}mo"
return f"{days / 365:.0f}y"
def rank_contacts(
contacts: Sequence[Contact],
signals: dict[str, ContactSignals],
@@ -529,7 +500,7 @@ def rank_contacts(
gathered: list[ContactSignals] = []
for contact in contacts:
node = _node_id(contact)
node = node_id(contact)
found = signals.get(node) or ContactSignals(node=node)
gathered.append(replace(found, distance_km=_self_distance(contact, self_lat, self_lon)))
@@ -549,7 +520,6 @@ def rank_contacts(
filled = {name: _fill_unknowns(column) for name, column in columns.items()}
scores: list[float] = []
per_contact_terms: list[dict[str, float]] = []
for index, sig in enumerate(gathered):
terms = {name: filled[name][index] for name in _TERM_NAMES}
total = (
@@ -561,7 +531,6 @@ def rank_contacts(
+ terms["distance"] * weights.distance
+ term_grace(sig, weights)
)
per_contact_terms.append(terms)
scores.append(total)
ranked = [
@@ -571,11 +540,8 @@ def rank_contacts(
score=score,
percentile=percentile_rank(score, scores),
protection=protection_for(sig),
reasons=_reasons(sig, terms, weights),
)
for contact, sig, score, terms in zip(
contacts, gathered, scores, per_contact_terms, strict=True
)
for contact, sig, score in zip(contacts, gathered, scores, strict=True)
]
ranked.sort(key=lambda scored: (-scored.score, scored.contact.name.casefold()))
return ranked
@@ -590,7 +556,7 @@ def sweep_candidates(ranked: Sequence[ScoredContact], keep: int) -> list[ScoredC
protections against the target instead would mean starring a node quietly deepened the
next sweep, which is the opposite of what a star is for. The picker's rungs therefore
report their own real removal counts rather than arithmetic on the table size (see
:func:`~meshterm.ui.purge_screen.purge_contacts`).
:func:`~meshterm.ui.sweep_screen.archive_contacts`).
Args:
ranked: The full ranking from :func:`rank_contacts`, strongest first.
@@ -607,7 +573,7 @@ def sweep_candidates(ranked: Sequence[ScoredContact], keep: int) -> list[ScoredC
return list(reversed(victims))
def _node_id(contact: Contact) -> str:
def node_id(contact: Contact) -> str:
"""The 12-hex canonical id a contact's history is keyed by (see ``observations.node``)."""
ident = contact.public_key or contact.key_prefix or ""
return ident.lower().removeprefix("0x")[:12]
+57 -21
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for contacts heard through MeshTerm, so they survive a device that forgets them.
Most companions keep their contact table in firmware, so MeshTerm reads contacts live from the
@@ -33,6 +34,13 @@ it back. So a restore is one write, and the chat screen already performs it on d
send is rejected for an unknown recipient (see
:class:`~meshterm.core.connection.ContactNotOnDeviceError`).
A live contact can also be **locked**, which is the reader's own word that it stays: a
locked contact is never a sweep candidate (see
:data:`~meshterm.core.contact_score.PROTECT_LOCKED`) and its page offers no Archive. The flag
lives here rather than on the device because the firmware has no such field — it is
MeshTerm's claim about the contact, kept beside the other one it makes (the archive stamp),
and a device read never clears it.
The one place to be careful is a firmware-less bridge, whose contact table is RAM-only and
for which this store *is* the memory: archiving there removes the contact from the lists for
real, and only a restore brings it back.
@@ -46,7 +54,7 @@ contact set actually changes, and flushes atomically.
from __future__ import annotations
import json
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from .atomicwrite import write_atomically
@@ -96,6 +104,9 @@ class RememberedContact:
while it is a live contact. An archived contact is remembered in full but kept
*out* of :func:`merge_contacts`, so it stops occupying a device slot without
being forgotten — and the stamp is what lets a list say how long ago it went.
locked: Whether the reader locked this contact against archiving. MeshTerm's own
state, not the device's, so :meth:`ContactStore.remember_all` carries it across
every fresh read of the contact rather than resetting it.
"""
public_key: str
@@ -105,6 +116,7 @@ class RememberedContact:
lat: float | None = None
lon: float | None = None
archived_at: int | None = None
locked: bool = False
@property
def archived(self) -> bool:
@@ -218,7 +230,11 @@ class ContactStore:
if not contact.public_key:
continue # unaddressable — nothing to remember it by
remembered = RememberedContact.from_contact(contact)
if current.get(remembered.public_key) != remembered:
known = current.get(remembered.public_key)
if known is not None and known.locked:
# The lock is ours, not the device's: a fresh read knows nothing about it.
remembered = replace(remembered, locked=True)
if known != remembered:
current[remembered.public_key] = remembered
changed = True
if changed:
@@ -255,16 +271,7 @@ class ContactStore:
dev = _norm(device_pubkey)
if not dev or not contact.public_key:
return # unaddressable — there would be nothing to restore it by
remembered = RememberedContact.from_contact(contact)
entry = RememberedContact(
public_key=remembered.public_key,
name=remembered.name,
node_type=remembered.node_type,
last_advert=remembered.last_advert,
lat=remembered.lat,
lon=remembered.lon,
archived_at=int(when),
)
entry = replace(RememberedContact.from_contact(contact), archived_at=int(when))
current = dict(self._state.get(dev, {}))
if current.get(entry.public_key) == entry:
return
@@ -294,19 +301,45 @@ class ContactStore:
if entry is None or not entry.archived:
return None
current = dict(contacts)
current[key] = RememberedContact(
public_key=entry.public_key,
name=entry.name,
node_type=entry.node_type,
last_advert=entry.last_advert,
lat=entry.lat,
lon=entry.lon,
archived_at=None,
)
current[key] = replace(entry, archived_at=None)
self._state[dev] = current
self._save()
return entry
def locked_keys(self, device_pubkey: str) -> frozenset[str]:
"""The full keys (lowercase hex) of every contact locked on this device."""
remembered = self._state.get(_norm(device_pubkey), {}).values()
return frozenset(c.public_key for c in remembered if c.locked)
def is_locked(self, device_pubkey: str, contact_pubkey: str) -> bool:
"""Whether one contact is locked against archiving on this device."""
entry = self._state.get(_norm(device_pubkey), {}).get(_norm(contact_pubkey))
return entry is not None and entry.locked
def set_locked(self, device_pubkey: str, contact: Contact, locked: bool) -> None:
"""Lock or unlock one contact, upserting it so a contact the store never saw can be.
Only a live contact is locked — the lock exists to keep a contact *off* the archive
path, so locking an archived one would claim something about a contact that has
already gone. Persists only a real change.
Args:
device_pubkey: The device's own public key.
contact: The contact to lock or unlock, addressed by its full key.
locked: The state to leave it in.
"""
dev = _norm(device_pubkey)
if not dev or not contact.public_key:
return # unaddressable — nothing to hang the flag on
current = dict(self._state.get(dev, {}))
key = _norm(contact.public_key)
known = current.get(key) or RememberedContact.from_contact(contact)
if known.archived or known.locked == locked:
return
current[key] = replace(known, locked=locked)
self._state[dev] = current
self._save()
def forget(self, device_pubkey: str, contact_pubkey: str) -> None:
"""Drop one remembered contact; persist only a real change."""
dev = _norm(device_pubkey)
@@ -349,6 +382,8 @@ def _contact_to_json(contact: RememberedContact) -> dict:
entry["lon"] = contact.lon
if contact.archived_at is not None:
entry["archived_at"] = contact.archived_at
if contact.locked:
entry["locked"] = True
return entry
@@ -368,6 +403,7 @@ def _contact_from_json(entry: object) -> RememberedContact | None:
lat=_opt_float(entry.get("lat")),
lon=_opt_float(entry.get("lon")),
archived_at=_opt_int(entry.get("archived_at")),
locked=entry.get("locked") is True,
)
+3 -1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for the Courier: the store-and-forward outbox.
The Courier (see :mod:`meshterm.services.courier`) queues direct messages for contacts
@@ -28,7 +29,8 @@ DELIVERED = "delivered"
GAVE_UP = "gave-up"
#: How many finished (delivered / given-up) entries are kept for the screen's history —
#: the code's default behind the ``courier_history_kept`` preference.
#: *the* default behind the ``courier_history_kept`` preference, which the registry names
#: rather than re-types (:data:`meshterm.core.preferences.PREFERENCES`).
DONE_CAP = 100
+10
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Declarative registry of every settable device configuration value.
Each :class:`SettingSpec` describes one tunable setting: how to read its current value
@@ -53,6 +54,9 @@ class SettingSpec:
the connected hardware actually supports.
max_length: For ``str`` values, the longest accepted string (protocol field
widths, e.g. the flood scope's 31-byte name slot).
decimals: For ``float`` values, the fixed decimal places a value is shown with and
rounded to on parse, so what is applied is what the row showed; ``None`` keeps
the value as given.
validate: A last rule for a typed, in-range value, given the snapshot when there
is one: it returns the complaint, or ``None``. For what a bound can't state —
the PIN's "zero or six digits", relaying only on an allowed frequency.
@@ -73,6 +77,7 @@ class SettingSpec:
strict_choices: bool = True
max_key: str | None = None
max_length: int | None = None
decimals: int | None = None
validate: Callable[[Any, dict | None], str | None] | None = None
@@ -137,6 +142,8 @@ def _parse_typed(spec: SettingSpec, raw: Any, snapshot: dict | None) -> Any:
try:
if spec.value_type == "float":
value: Any = float(text)
if spec.decimals is not None:
value = round(value, spec.decimals)
else: # int or enum
value = int(text, 0) if isinstance(raw, str) else int(raw)
except (ValueError, TypeError) as exc:
@@ -199,6 +206,8 @@ def format_value(spec: SettingSpec, value: Any) -> str:
return "true" if value else "false"
if spec.value_type == "enum" and spec.choices is not None and value in spec.choices:
return f"{value} ({spec.choices[value]})"
if spec.value_type == "float" and spec.decimals is not None:
return f"{float(value):.{spec.decimals}f}"
return str(value)
@@ -694,6 +703,7 @@ DEVICE_SETTINGS: list[SettingSpec] = [
"float",
minimum=0.0,
maximum=20.0,
decimals=1,
getter=_get("rx_delay"),
apply=_tuning_apply("rx_delay"),
),
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for confirmed companion devices.
Every device that has ever spoken the MeshCore protocol to us successfully is remembered
+48
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Companion-device discovery across transports (serial + Bluetooth LE + TCP).
Discovery enumerates the companion kinds that can be *found* automatically:
@@ -35,6 +36,10 @@ _log = logging.getLogger(__name__)
TRANSPORT_SERIAL = "serial"
TRANSPORT_BLE = "ble"
TRANSPORT_TCP = "tcp"
#: The built-in simulator, as a device inventory reports it under ``--mock``. Never
#: discovered — there is nothing to find — but a session that promised no real hardware
#: still has exactly one device, and this is what it is called.
TRANSPORT_MOCK = "mock"
#: Default TCP port assumed when a network companion is named as a bare host with no port.
#: 5000 is the conventional MeshCore companion-over-TCP / ``meshcored`` listen port.
@@ -48,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
@@ -144,6 +175,8 @@ class DiscoveredDevice:
"""
if self.is_tcp:
return f"{self.host}:{self.tcp_port}"
if self.transport == TRANSPORT_MOCK:
return "--mock" # the flag is the target: it is how this device is selected
return self.address or self.port if self.is_ble else self.port
@property
@@ -159,6 +192,8 @@ class DiscoveredDevice:
"""
if self.is_tcp:
return f"tcp:{self.host}:{self.tcp_port}"
if self.transport == TRANSPORT_MOCK:
return "mock:simulator"
if self.is_ble:
return f"ble:{(self.address or self.name or '').lower()}"
if self.serial_number:
@@ -384,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
@@ -391,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 []
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Typed events carried by the always-on mesh event hub.
These are the domain-level events that flow from the connected companion device out to
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The exit statuses ``meshterm`` returns, and what each one means.
A scripted run's *return value is its report*: the CLI says what happened in plain
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""What an overheard frame is *addressed to* — the packet body nobody else parses.
The companion's RX packet log hands every overheard frame to the meshcore library, which
+7 -2
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Pure geographic helpers for the terminal map: Web Mercator, tiles, and viewports.
This module is deliberately I/O-free and rendering-free so it can be unit-tested without a
@@ -33,7 +34,9 @@ TILE_PX = 256
#: :mod:`meshterm.ui.map_screen` for it dragged the whole map stack (and, through the
#: basemap's tile fetcher, ``urllib.request`` → ``http.client`` → ``ssl``) into every run,
#: including runs that never open a map. This module is pure arithmetic and already on the
#: boot path, so the constant is free here.
#: boot path, so the constant is free here. It is also what the ``map_view_fraction``
#: preference defaults to — the registry names this constant rather than re-typing the
#: number, so the code default and the preference default are one value.
DEFAULT_VIEW_FRACTION = 0.5
@@ -87,7 +90,9 @@ def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a))
# Clamped because rounding can push ``a`` a hair past 1 for near-antipodal points, and
# ``asin`` of that raises a domain error rather than returning half the planet.
return 2 * EARTH_RADIUS_KM * math.asin(min(1.0, math.sqrt(a)))
def _central_points(
+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
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""One interactive MeshTerm per data directory, and a clear answer when you want two.
Every copy of MeshTerm on a machine reads and writes the same ``~/.meshterm`` — a
+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
+61 -2
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Domain models shared across services, tools, persistence, and visualizations.
These are plain dataclasses with no I/O dependencies so they can be constructed by the
@@ -8,10 +9,25 @@ from __future__ import annotations
import statistics
from collections import Counter
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
#: Maps a hop's raw key-prefix hash to a display label (a contact name when known, or the
#: hash itself when not). ``None`` passes through, being our own device. THE alias for the
#: name side of a hop, shared by everything that builds one
#: (:func:`~meshterm.services.trace_runner.make_node_resolver`) and everything that takes
#: one (every screen that draws a route).
NodeResolver = Callable[[str | None], str | None]
#: Maps a display name back to the node's key — as full as we hold one: a public key, a key
#: prefix, or a stored node id — or ``None`` for a name no known node carries. The colour
#: side of :data:`NodeResolver`: where that turns hex into names, this turns a bare name back
#: into the key its palette hue derives from
#: (:func:`~meshterm.services.trace_runner.make_name_key_resolver`).
NameKeyResolver = Callable[[str], str | None]
#: Label used for our own (local) device when framing a trace path's endpoints.
LOCAL_DEVICE_LABEL = "us"
@@ -32,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.
@@ -177,6 +203,39 @@ class Contact:
return self.node_type == NODE_TYPE_REPEATER
@dataclass(slots=True)
class MapMarker:
"""One mesh node to overlay on the map.
Data, not drawing: a located node with the little the map needs to place and label it.
It lives here rather than beside the renderer because it is assembled well below the UI
— :func:`~meshterm.services.markers.gather_markers` builds the list from contacts and
observations, and three separate surfaces draw it.
Attributes:
label: Node name shown beside the marker.
lat: Latitude in decimal degrees.
lon: Longitude in decimal degrees.
is_repeater: Whether the node is a repeater (prioritised marker).
is_self: Whether this is our own node (highlighted).
detail: Extra text for the CLI legend (e.g. ``"18 pkts · +6.0 dB"``).
key: The node's key hex (as full as the caller holds), seeding the label's
key-derived hue; ``None`` leaves the label the muted no-key grey.
"""
label: str
lat: float
lon: float
is_repeater: bool = False
is_self: bool = False
detail: str = ""
key: str | None = None
def _rank(self) -> int:
"""Draw order: self on top of repeaters on top of leaf nodes."""
return 2 if self.is_self else (1 if self.is_repeater else 0)
@dataclass(slots=True)
class NeighbourInfo:
"""One entry of a remote repeater's neighbour table, fetched over the mesh.
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for muted channel notifications.
Some channels carry traffic you want *recorded* but not *notified* about — an automated
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""A tiny, dependency-free Mapbox Vector Tile (MVT) decoder.
Vector tiles are Protocol-Buffer messages (spec: github.com/mapbox/vector-tile-spec). Rather
+149 -42
View File
@@ -1,4 +1,5 @@
"""Declarative registry of MeshTerm's own preferences, and the YAML file they live in.
# SPDX-License-Identifier: Apache-2.0
"""Declarative registry of MeshTerm's own preferences, and the TOML file they live in.
The distinction this module draws is between the three kinds of "setting" the app already
had and never named apart:
@@ -22,9 +23,11 @@ away from that default, so the file is a short list of your disagreements with t
built-in behaviour rather than a snapshot that silently pins every value forever. Delete a
key (or use *Reset to defaults*) and the code's default takes over again.
The file is ``<config_dir>/preferences.yaml``, written grouped and commented so it reads
the way the page does. Reads are tolerant: an unknown key, a malformed value, or an
unparseable file costs the override, never the session.
The file is ``<config_dir>/preferences.toml``, written flat, grouped and commented so it
reads the way the page does — TOML, like ``config.toml`` and the device-config backups,
so every file a person edits by hand speaks one syntax. Reads are tolerant: an unknown
key, a malformed value, or a line TOML refuses costs that one override, never the rest of
the file and never the session.
Two access paths, deliberately:
@@ -37,20 +40,29 @@ Two access paths, deliberately:
from __future__ import annotations
import sys
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .atomicwrite import write_atomically
from .watch_store import DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
from .courier_store import DONE_CAP
from .geo import DEFAULT_VIEW_FRACTION
from .watch_store import ALERT_CAP, DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
if sys.version_info >= (3, 11):
import tomllib
else: # pragma: no cover - exercised only on 3.10
import tomli as tomllib
#: Display groups, in the order the page and the file present them. The order is the
#: order a session happens in — what MeshTerm puts on the air, how loud, what it watches
#: for, how it draws the world, what it keeps, and how it paints. Nothing sorts
#: alphabetically: a reader looking for "how long before I give up on a message" should
#: not have to know it starts with a D.
#: order a session happens in — what MeshTerm does to the radio as the link opens, what
#: it puts on the air, how loud, what it watches for, how it draws the world, what it
#: keeps, and how it paints. Nothing sorts alphabetically: a reader looking for "how long
#: before I give up on a message" should not have to know it starts with a D.
GROUPS: tuple[str, ...] = (
"Device",
"Sending",
"TX optimize",
"Watchtower",
@@ -75,7 +87,7 @@ class PrefSpec:
Attributes:
key: Canonical key — the attribute name on :class:`Preferences`, and the key in
the YAML file.
the TOML file.
label: Human-friendly name, as the page's SETTING lane shows it.
help: One-line description, as the page's DESCRIPTION lane shows it.
group: One of :data:`GROUPS`.
@@ -89,6 +101,14 @@ class PrefSpec:
relaunch: Whether the new value only takes hold next launch. Woven into the
description rather than shown as its own mark — it is a caveat about one
preference, not a status the reader scans a column for.
platforms: Which :class:`~meshterm.platforms.Platform` names the preference is
*offered* on, or ``None`` (the default) for all of them. A preference about
hardware only one flavour has — the PicoCalc's console font — would be a row
answering a question the desktop cannot ask, so the page leaves it out there.
Only the page: the key stays in the registry on every platform, so a file
written on the handheld still parses, keeps its value and saves back out on a
desktop rather than being silently dropped by a machine that merely cannot
act on it.
"""
key: str
@@ -102,12 +122,24 @@ class PrefSpec:
maximum: float | None = None
unit: str = ""
relaunch: bool = False
platforms: frozenset[str] | None = None
@property
def description(self) -> str:
"""The DESCRIPTION lane's text: the help, plus the relaunch caveat where it applies."""
return f"{self.help} (next launch)" if self.relaunch else self.help
def offered_on(self, platform: str) -> bool:
"""Whether the editor draws a row for this preference on ``platform``.
Args:
platform: A :attr:`~meshterm.platforms.Platform.name`.
Returns:
``True`` unless :attr:`platforms` names a set this platform is not in.
"""
return self.platforms is None or platform in self.platforms
#: The two languages the plain CLI can print a time in. Named for what the reader sees
#: rather than for the mechanism: an *age* is how long ago, a *timestamp* is when.
@@ -140,12 +172,6 @@ _COLOR_DEPTH_CHOICES: dict[str, str] = {
"16": "16",
}
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
#: Preferences page, a key in the YAML file, a ``preferences get``/``set`` CLI face, and a
#: default — nothing else has to follow.
#: What the log file keeps. The plain level names, which are what every other tool calls
#: these — someone being talked through a problem is being told "set it to debug", not
#: "set it to everything".
#: What MeshTerm may offer when it lands in a console that cannot draw it. Only ever
#: consulted on the classic Windows console, the one host with no font fallback of its own.
_CONSOLE_SETUP_CHOICES: dict[str, str] = {
@@ -153,6 +179,19 @@ _CONSOLE_SETUP_CHOICES: dict[str, str] = {
"off": "Leave it alone",
}
#: The two console fonts ``scripts/picocalc/calculinux-console-font-6x12.sh`` (and its 6x8
#: companion) build for the PicoCalc's framebuffer panel. Each is named by its cell and by the
#: screen that cell buys, because choosing a font here is choosing how much fits — not a bitmap.
#: Plain ASCII ``x`` and not ``×``: the multiplication sign is not one of the 512 glyphs
#: the console font carries, so it would draw as a tofu box on the one platform this
#: preference is offered on. The value maps to a file in
#: :data:`meshterm.services.consolefont.FONT_FILES`, which is where a filename belongs —
#: a path on one device is not something a reader picks.
_CONSOLE_FONT_CHOICES: dict[str, str] = {"6x12": "6x12 (53x26)", "6x8": "6x8 (53x40)"}
#: What the log file keeps. The plain level names, which are what every other tool calls
#: these — someone being talked through a problem is being told "set it to debug", not
#: "set it to everything".
_LOG_LEVEL_CHOICES: dict[str, str] = {
"ERROR": "Error",
"WARNING": "Warning",
@@ -161,7 +200,24 @@ _LOG_LEVEL_CHOICES: dict[str, str] = {
}
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
#: Preferences page, a key in the TOML file, a ``preferences get``/``set`` CLI face, and a
#: default — nothing else has to follow. A default that a module already states as its
#: code-level behaviour is named, never re-typed: one source, so changing it cannot leave
#: the constant and the registry disagreeing about what MeshTerm does.
PREFERENCES: tuple[PrefSpec, ...] = (
# --- Device ------------------------------------------------------------------
PrefSpec(
key="set_clock_on_connect",
label="Set clock on connect",
help="Set the device clock from this computer when it connects",
group="Device",
value_type="bool",
# Off: writing to a radio nobody asked to be written to is the owner's call. The
# set is silent (the log records it) and happens once per connection — see
# :mod:`meshterm.services.clock_sync`.
default=False,
),
# --- Sending -----------------------------------------------------------------
PrefSpec(
key="trace_cooldown_s",
@@ -245,7 +301,7 @@ PREFERENCES: tuple[PrefSpec, ...] = (
help="How many past alerts to keep",
group="Watchtower",
value_type="int",
default=200,
default=ALERT_CAP,
minimum=10,
maximum=5000,
),
@@ -256,7 +312,7 @@ PREFERENCES: tuple[PrefSpec, ...] = (
help="Share of nodes to fit on screen; 1 shows them all",
group="Map",
value_type="float",
default=0.5,
default=DEFAULT_VIEW_FRACTION,
minimum=0.1,
maximum=1.0,
),
@@ -297,7 +353,7 @@ PREFERENCES: tuple[PrefSpec, ...] = (
help="Finished outbox messages to keep",
group="History",
value_type="int",
default=100,
default=DONE_CAP,
minimum=10,
maximum=5000,
),
@@ -361,6 +417,20 @@ PREFERENCES: tuple[PrefSpec, ...] = (
default="auto",
choices=_CONSOLE_SETUP_CHOICES,
),
PrefSpec(
key="console_font",
label="Console font",
help="Bigger text, or more rows on the handheld's panel",
group="Display",
value_type="enum",
# The 6x12 Terminus derivative, because it is the font the device boots with and
# the one every screen's row budget is designed to (Platform.readable_rows is 26).
# The 6x8 build is the same glyph inventory in a shorter cell — fourteen more rows
# for anyone who would rather see more of a list than read it comfortably.
default="6x12",
choices=_CONSOLE_FONT_CHOICES,
platforms=frozenset({"picocalc"}),
),
# --- Diagnostics ---------------------------------------------------------------
PrefSpec(
key="log_level",
@@ -398,13 +468,21 @@ def get_spec(key: str) -> PrefSpec:
raise PreferenceError(f"unknown preference: {key!r}") from None
def by_group() -> list[tuple[str, list[PrefSpec]]]:
def by_group(platform: str | None = None) -> list[tuple[str, list[PrefSpec]]]:
"""Every preference grouped for display, in :data:`GROUPS` order.
Args:
platform: A :attr:`~meshterm.platforms.Platform.name` to draw the page for, which
drops the preferences that platform is not offered (see
:meth:`PrefSpec.offered_on`). ``None`` — the default, and what the file writer
uses — keeps every preference, so an override made on one flavour survives
being loaded and saved on another.
Returns:
``(group, specs)`` pairs; a group holding no preferences is omitted.
"""
grouped = [(g, [s for s in PREFERENCES if s.group == g]) for g in GROUPS]
kept = [s for s in PREFERENCES if platform is None or s.offered_on(platform)]
grouped = [(g, [s for s in kept if s.group == g]) for g in GROUPS]
return [(g, specs) for g, specs in grouped if specs]
@@ -444,15 +522,15 @@ def parse_value(spec: PrefSpec, raw: Any) -> Any:
if raw in choices and not isinstance(raw, bool):
return raw
if isinstance(raw, bool):
# YAML 1.1 reads a bare ``yes``/``no`` (and ``on``/``off``) as a *boolean*, so a
# hand-edited file hands us ``True`` where the choice is spelled "yes". We quote
# ours on the way out, but someone typing the obvious thing should still be
# understood: map the boolean back to whichever spelling this spec offers.
# A hand edit that answers a yes/no choice with a TOML boolean
# (``full_width = true``) hands us ``True`` where the choice is spelled "yes".
# We quote ours on the way out, but someone typing the obvious thing should
# still be understood: map the boolean back to whichever spelling this offers.
for choice in choices:
if str(choice).lower() in (_TRUE if raw else _FALSE):
return choice
# Text arriving from the CLI or a hand-edited file: match a choice by its own
# spelling, so `preferences set watch_silence_hours 6` and a YAML `6` land alike.
# spelling, so `preferences set watch_silence_hours 6` and a TOML `6` land alike.
for choice in choices:
if text.lower() == str(choice).lower():
return choice
@@ -508,6 +586,9 @@ def range_hint(spec: PrefSpec) -> str:
# --- the store ----------------------------------------------------------------
#: The file's name inside the config directory — one spelling for every place that opens it.
PREFERENCES_FILENAME = "preferences.toml"
#: The file's opening comment. It says the one thing a reader hand-editing the file needs
#: to know — that absence means "use the default", so deleting a line is how a preference
#: is undone — and points at the two neighbouring kinds of setting so nobody looks for a
@@ -520,11 +601,36 @@ _HEADER = """\
# Preferences page) hands the value back to the code. `meshterm preferences show` prints
# every preference, what it is set to, and what it defaults to.
#
# One `key = value` per line: text in quotes ("DEBUG"), numbers and true/false bare.
#
# Device profiles, the database location, and the rest of the machine setup stay in
# config.toml; the radio's own settings live on the radio, under Device config.
"""
def _salvage(text: str) -> dict[str, Any]:
"""Read a file TOML refused as a document, one ``key = value`` line at a time.
MeshTerm writes the file flat — no tables, one entry to a line — so a line is a whole
entry and can be judged on its own. A line TOML accepts keeps its typed value. One it
refuses keeps its right-hand side as text, which is exactly what :func:`parse_value`
takes from the command line, so the likeliest hand edit of all — a word left unquoted,
``log_level = DEBUG`` — is still understood. Whatever the spec then refuses is dropped
by :meth:`Preferences.update`, the same as any other bad value.
"""
data: dict[str, Any] = {}
for line in text.splitlines():
entry = line.strip()
if not entry or entry.startswith(("#", "[")) or "=" not in entry:
continue
try:
data.update(tomllib.loads(entry))
except tomllib.TOMLDecodeError:
key, _, value = entry.partition("=")
data[key.strip()] = value.split("#", 1)[0].strip()
return data
class Preferences:
"""The effective preferences: the built-in defaults, with the file's overrides on top.
@@ -554,21 +660,27 @@ class Preferences:
@classmethod
def load(cls, path: Path) -> Preferences:
"""Read ``path``, returning all-defaults for a missing, empty, or corrupt file.
"""Read ``path``, returning all-defaults for a missing, empty, or unreadable file.
TOML refuses a whole document over one bad line, and a hand edit should cost the
line it is on, not the file: a document TOML refuses is read again a line at a
time (:func:`_salvage`), keeping every entry that still makes sense.
Args:
path: The YAML file to read.
path: The TOML file to read.
Returns:
The loaded preferences, bound to ``path`` for a later :meth:`save`.
"""
import yaml
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, ValueError, yaml.YAMLError):
text = path.read_text(encoding="utf-8")
except (OSError, ValueError):
return cls(path)
return cls(path, data if isinstance(data, Mapping) else None)
try:
data: dict[str, Any] = tomllib.loads(text)
except tomllib.TOMLDecodeError:
data = _salvage(text)
return cls(path, data)
@property
def path(self) -> Path | None:
@@ -656,14 +768,14 @@ class Preferences:
self._values.clear()
return count
def as_yaml(self) -> str:
def as_toml(self) -> str:
"""Render the current overrides as the file's text: header, then grouped entries.
Each group holding an override gets its own comment heading, and each entry its
help line and its default above it, so the file reads the way the page does
rather than as a bare map someone has to look up elsewhere.
"""
import yaml
import tomli_w
lines = [_HEADER]
for group, specs in by_group():
@@ -672,12 +784,7 @@ class Preferences:
continue
lines.append(f"# --- {group} ---")
for spec in present:
entry = yaml.safe_dump(
{spec.key: self._values[spec.key]},
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
).rstrip("\n")
entry = tomli_w.dumps({spec.key: self._values[spec.key]}).rstrip("\n")
lines.append(f"# {spec.help}")
lines.append(f"# default: {format_value(spec, spec.default)}")
lines.append(entry)
@@ -695,7 +802,7 @@ class Preferences:
"""
if self._path is None:
raise RuntimeError("these preferences have no file to save to")
write_atomically(self._path, self.as_yaml())
write_atomically(self._path, self.as_toml())
#: The set installed for this process. Starts as a defaults-only, file-less instance so
+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
+149 -4
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The remote-node settings catalog: what a repeater can be asked and told over the mesh.
Repeaters and room servers are administered through their text CLI — ``get``/``set``
@@ -27,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
@@ -117,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
@@ -138,6 +149,7 @@ class RemoteSetting:
composite: str = ""
part: int = 0
overlaps: tuple[str, ...] = ()
discovered: bool = False
@property
def get_command(self) -> str:
@@ -328,7 +340,7 @@ REPEATER_SETTINGS: tuple[RemoteSetting, ...] = (
label="TX delay",
category="Repeating",
kind="float",
decimals=2,
decimals=1,
minimum=0,
maximum=2,
help="Delay factor before rebroadcasting a flood packet",
@@ -338,7 +350,7 @@ REPEATER_SETTINGS: tuple[RemoteSetting, ...] = (
label="Direct TX delay",
category="Repeating",
kind="float",
decimals=2,
decimals=1,
minimum=0,
maximum=2,
help="Delay factor before forwarding a directed packet",
@@ -348,7 +360,7 @@ REPEATER_SETTINGS: tuple[RemoteSetting, ...] = (
label="RX delay",
category="Repeating",
kind="float",
decimals=2,
decimals=1,
minimum=0,
maximum=20,
help="Base delay before acting on a received packet",
@@ -670,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.
@@ -748,7 +888,7 @@ def _format_number(spec: RemoteSetting, value: float) -> str:
def normalize_value(spec: RemoteSetting, raw: str) -> str:
"""A validated typed value in the form the cache stores and the firmware is sent.
TX delay entered as ``0.5`` becomes ``0.50``: rounded to the setting's decimals, and
TX delay entered as ``0.33`` becomes ``0.3``: rounded to the setting's decimals, and
spelled exactly as a read of the same value would be, so staging what the node already
holds unstages rather than queueing a no-op ``set``.
"""
@@ -883,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
+23 -2
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for remote-node admin state: last-read settings and CLI history.
Reading a repeater's configuration costs one paced mesh round trip per value, so the
@@ -34,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:
@@ -83,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
@@ -91,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()})
@@ -103,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)
+40 -4
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Non-interactive device selection: turn discovery + memory into a chosen port.
This is the logic used on the scripted CLI path (and as the fallback when the interactive
@@ -111,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).
@@ -154,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:
@@ -163,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."
)
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for device settings changed through MeshTerm, so they survive a forgetful device.
Most companions keep their configuration in firmware, so MeshTerm reads settings live from the
+82
View File
@@ -0,0 +1,82 @@
# SPDX-License-Identifier: Apache-2.0
"""The arithmetic a trace is sized and flagged by.
Two pure functions and the four constants behind them: how a per-hop path-hash width is
encoded into the trace ``flags`` byte, and how long to wait for a trace's reply given the
route it has to walk. Both are protocol arithmetic with no device, no persistence and no
rendering, and :class:`~meshterm.core.connection.MeshCoreDevice` needs them on the send path —
they live here so ``core`` never has to reach up into ``services`` for them. The trace
*runner* (:mod:`meshterm.services.trace_runner`) re-exports both under their own names, which
is where every other caller still reads them from.
"""
from __future__ import annotations
def path_hash_flags(width_bytes: int) -> int | None:
"""Return the trace ``flags`` value that encodes a per-hop path-hash width.
The trace subsystem encodes the hash size as ``1 << (flags & 3)`` on both the
send and receive sides (``send_trace`` and the ``TRACE_DATA`` reader), so only
widths of 1, 2, 4, or 8 bytes are representable. This is independent of, and
differs from, the ``mode = size - 1`` encoding used for *contact routing*
(``out_path_hash_mode``).
Args:
width_bytes: Path-hash width in bytes.
Returns:
The flags value (the exponent ``s``), or ``None`` if the width is not a
representable power of two.
"""
for s in range(4):
if (1 << s) == width_bytes:
return s
return None
#: Fixed overhead in a trace's reply-wait budget, in seconds: our own transmit, the far
#: endpoint's turnaround, and our receive-side decode — the cost that doesn't grow with
#: the route.
TRACE_TIMEOUT_BASE_S = 4.0
#: Per-hop allowance added to the budget, in seconds. Each entry in the walked path is one
#: relay transmission, and this covers its packet airtime, the repeater's processing, and
#: the randomised transmit backoff MeshCore adds so relays don't collide.
TRACE_TIMEOUT_PER_HOP_S = 1.6
#: Upper bound on the reply-wait, in seconds, so a route that never comes home still
#: surrenders the trace (and the session) in bounded time rather than scaling without end.
TRACE_TIMEOUT_CEILING_S = 30.0
#: Fallback budget, in seconds, when the hop count can't be known ahead of the send — a
#: path-less flood to a contact we hold no route for. This is the historical flat value,
#: kept for exactly the case where we can't size the walk.
TRACE_TIMEOUT_FLOOD_S = 10.0
def trace_timeout(hop_count: int) -> float:
"""Return the reply-wait budget, in seconds, for a trace walking ``hop_count`` hops.
A trace is a *single* packet that has to travel the whole path — out to the far hop
and back over the mirrored return leg — before its reply reaches us, so the wait has
to grow with the route. A flat budget sized for a neighbour cuts a long walk off
mid-flight: the packet completes the circuit on the mesh, but our wait has already
expired, so we log a phantom "no reply" for a route that actually worked. The budget
is a fixed base (:data:`TRACE_TIMEOUT_BASE_S`) plus a per-hop allowance
(:data:`TRACE_TIMEOUT_PER_HOP_S`) for each relay transmission, capped at
:data:`TRACE_TIMEOUT_CEILING_S`.
``hop_count`` is the number of hops in the *transmitted* path, which already includes
the mirrored return leg (see
:meth:`~meshterm.core.connection.MeshCoreDevice._trace_path_to_contact`), so it maps
one-to-one onto relay transmissions and must not be doubled here.
Args:
hop_count: Hops in the walked path (return leg included). ``0`` (or less) means the
count is unknown — a path-less flood — and yields :data:`TRACE_TIMEOUT_FLOOD_S`.
Returns:
The number of seconds to wait for the trace reply.
"""
if hop_count <= 0:
return TRACE_TIMEOUT_FLOOD_S
budget = TRACE_TIMEOUT_BASE_S + TRACE_TIMEOUT_PER_HOP_S * hop_count
return min(budget, TRACE_TIMEOUT_CEILING_S)
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The shared transmit clock: how long ago we last put something on the air.
MeshTerm has always paced its own bursts — a trace waits between samples, a TX sweep
+4 -2
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for the Watchtower: watched nodes, their rules, and the alert log.
The Watchtower (see :mod:`meshterm.services.watchtower`) is a passive sentinel: the user
@@ -37,8 +38,9 @@ DEFAULT_SILENCE_HOURS = 12
#: The stored value meaning "this rule is off".
OFF = 0
#: How many alerts the log retains (oldest dropped first) — the code's default behind
#: the ``watch_alerts_kept`` preference.
#: How many alerts the log retains (oldest dropped first) — *the* default behind the
#: ``watch_alerts_kept`` preference, which the registry names rather than re-types
#: (:data:`meshterm.core.preferences.PREFERENCES`), so the two can never disagree.
ALERT_CAP = 200
#: Minimum seconds between disk flushes for the packet-driven last-heard updates.
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Private handles on the Windows DLLs, because ``ctypes.windll`` is shared with everyone.
``ctypes.windll.kernel32`` is a cache. Every caller in the process gets the *same*
+18 -74
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Reopening the session in Windows Terminal, which is where MeshTerm looks like itself.
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether any
@@ -29,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:
@@ -46,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.
@@ -147,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}"
+1
View File
@@ -1 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence layer: SQLite schema, repository, and structured logging."""
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""SQLite database bootstrap and schema.
The schema is intentionally generic: a central ``runs`` table records every tool
+21 -3
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Logging setup: a plain text file you can open, and a console that stays out of the way.
Two sinks. The console handler is Rich-formatted and shows what a person watching the
@@ -61,7 +62,7 @@ def level_from_name(name: str, *, fallback: int = logging.WARNING) -> int:
Args:
name: A level name, in any case.
fallback: Used when the name is unrecognised — a hand-edited ``preferences.yaml``
fallback: Used when the name is unrecognised — a hand-edited ``preferences.toml``
with a typo in it should lose some log detail, not stop the app from starting.
Returns:
@@ -97,7 +98,7 @@ def configure_logging(
"""
logger = logging.getLogger(_LOGGER_NAME)
logger.setLevel(logging.DEBUG)
logger.handlers.clear()
_drop_handlers(logger)
logger.propagate = False
if not quiet:
@@ -161,12 +162,29 @@ def _quiet_library_console(file_handler: logging.Handler) -> None:
for name in _LIBRARY_LOGGERS:
lib = logging.getLogger(name)
lib.handlers.clear()
_drop_handlers(lib)
lib.setLevel(logging.INFO)
lib.propagate = False
lib.addHandler(file_handler)
def _drop_handlers(logger: logging.Logger) -> None:
"""Detach ``logger``'s handlers, closing each on the way out.
Clearing the list alone drops the *reference* and leaves the file open: every
reconfigure — a reconnect in the app, each in-process CLI invocation under test — leaked
another descriptor on ``meshterm.log``, which on Windows is also a lock on a file the
rotation then wants to rename. A handler that refuses to close is dropped anyway: the
point of this call is that the old handlers stop receiving records.
"""
for handler in list(logger.handlers):
try:
handler.close()
except Exception: # noqa: BLE001 - a handler we are discarding cannot fail the app
pass
logger.handlers.clear()
def get_logger(name: str | None = None) -> logging.Logger:
"""Return a child of the application logger.
+64
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Repository: the single gateway between the app and the database.
Tools and services never issue SQL directly; they call typed methods here. This keeps
@@ -1067,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.
+6 -4
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The platform seam: a frozen spec every consumer binds to once, at boot.
MeshTerm runs two flavours from one codebase — the regular desktop terminal and the
@@ -74,10 +75,11 @@ class Platform:
this is ``False`` there. ``MESHTERM_FULL_WIDTH`` remains an explicit override
on top of this default.
emoji: Whether emoji icons render at all. ``False`` means every icon funnel routes
through a compact single-BMP-glyph table instead (P3), and the emoji-width
calibration probe (:func:`~meshterm.ui.tui.emoji_width.calibrate`) is skipped
outright — there is nothing to calibrate once no emoji are ever drawn, and the
probe writes escape sequences the console font can't shape anyway.
through a compact single-BMP-glyph table instead (P3), and neither half of the
emoji alignment runs — the reserve-two measurement
(:func:`~meshterm.ui.tui.emoji_width.install`) or the column pinning
(:mod:`~meshterm.ui.tui.colsnap`). Once no emoji are ever drawn, every glyph on
screen is one the console font has verified, and the stock widths are exact.
ascii_fold: Whether names and message bodies are NFKD-folded (accents stripped) at
the render boundary before display. Storage is never touched. Wired in P3.
truecolor: Whether the theme may use arbitrary 24-bit SGR colour. ``False`` selects
+5 -3
View File
@@ -1,6 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
"""Algorithm layer: pure logic for tracing, TX search, and path exploration.
Services depend on the :class:`~meshterm.core.connection.Device` interface and domain
models only. They contain no UI or persistence code, so they are straightforward to unit
test against the simulator.
Services may depend on :mod:`meshterm.core` (the :class:`~meshterm.core.connection.Device`
interface and the domain models), on the persistence layer's *types*, and on the app
context; never on ``ui/`` or ``tools/``. Nothing here renders, so every service is
straightforward to unit test against the simulator.
"""
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The background-advert scheduler: keep this node announced without anyone asking.
A quiet session-long task that sends a zero-hop (direct) advert and a flood advert on
+65 -1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Fetch and cache OpenStreetMap vector tiles for the terminal map.
This is the only networked part of the map. It resolves a tile source's (versioned) tile-URL
@@ -43,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
@@ -69,6 +72,14 @@ DEFAULT_TILEJSON_URL = "https://tiles.openfreemap.org/planet"
#: the tile path is no place to discover either.
_USER_AGENT = f"MeshTerm/{__version__} (+https://github.com/jpmartineau/MeshTerm; mesh node map)"
#: How long a tile the source gave no answer about is left alone before it is asked for
#: again. Long enough that a genuinely offline map isn't retrying every visible tile on a
#: loop, short enough that a Wi-Fi blip costs a few seconds of missing streets rather than
#: the rest of the session. It lives here, beside the source that failed to answer, because
#: both surfaces that draw tiles wait out the same cooldown (see
#: :meth:`meshterm.ui.map_screen.MapScreen._load` and the minimap's).
TILE_RETRY_SECONDS = 20.0
#: Fallback max tile zoom if the TileJSON doesn't declare one (OpenFreeMap serves 14).
_DEFAULT_MAX_ZOOM = 14
@@ -100,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.
@@ -469,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
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The battery poller: keep the status bar's fuel gauge current, quietly.
A session-long task that reads the connected companion's battery voltage on a slow
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The chat service: message persistence, unread tracking, and the send path.
Like the passive monitor, this service is not a listener in its own right — the always-on
+170
View File
@@ -0,0 +1,170 @@
# SPDX-License-Identifier: Apache-2.0
"""Set the companion's clock from this computer — by hand from Device config, or on connect.
Most MeshCore boards carry no real-time clock: a power cycle puts them back at whatever
epoch the firmware boots with, and every message they then send is stamped in a year the
mesh does not agree on. :func:`set_clock` is the one device-side step that corrects it —
read what the radio thinks the time is, write ours, and hand back the drift that was
corrected, the fact worth keeping and the one the set destroys by succeeding. The Device
config screen's *Sync clock* action (``config sync-clock`` on the command line) calls it
and acknowledges on screen; :class:`ClockSync` calls the same function on its own, once
per connection, when the ``set_clock_on_connect`` preference asks for it (off by default —
writing to a radio that nobody asked to be written to is a choice the owner makes).
The automatic set is a background step, not a startup step: the connect path hands the
device over and carries on, and the write happens in a task of its own so a slow or silent
firmware never holds the menu. It reports in the **log**, never on screen.
Once per *connection*, keyed by the device object's identity like the advert scheduler's
key probe: a reconnect after a link drop is a fresh :class:`~meshterm.core.connection.Device`
and gets its own set, because a drop is exactly when the board may have rebooted and lost
its clock again. The interactive session starts the service; scripted CLI runs never do,
so a one-shot command cannot change a radio it was only asked to read.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..context import AppContext
from ..core.connection import Device
@dataclass(frozen=True, slots=True)
class ClockSet:
"""What one clock set did.
Attributes:
epoch: The UNIX time written to the device.
drift_s: How far the device's clock was from ours *before* the set (device minus
host, seconds), or ``None`` where the firmware would not report its clock.
"""
epoch: int
drift_s: int | None
@property
def set_at(self) -> datetime:
"""The written instant, in this computer's local time."""
return datetime.fromtimestamp(self.epoch).astimezone()
@property
def stamp(self) -> str:
"""The written instant as the acknowledgement and the log print it."""
return self.set_at.strftime("%Y-%m-%d %H:%M:%S")
async def set_clock(device: Device) -> ClockSet:
"""Set ``device``'s clock to this computer's, returning what was corrected.
The read of the device's own clock before the write is optional — the drift is a
nicety, and a firmware that will not answer still gets its clock set.
Args:
device: A connected device.
Returns:
The instant written and the drift it corrected.
Raises:
Whatever the device raises when the write itself fails.
"""
try:
before = await device.get_time()
except Exception: # noqa: BLE001 - optional read; the drift is a nicety
before = None
epoch = int(time.time())
await device.set_time(epoch)
return ClockSet(epoch=epoch, drift_s=None if before is None else before - epoch)
class ClockSync:
"""Owns the on-connect clock set for an interactive session.
Interact through :meth:`start`, :meth:`stop`/:meth:`aclose`, and
:meth:`on_connected`, which the context calls as each connection settles.
"""
def __init__(self, ctx: AppContext) -> None:
"""Initialize an idle service bound to an application context.
Args:
ctx: The shared application context, read for the preference at each
connection and for the logger. Nothing is touched until :meth:`start`.
"""
self._ctx = ctx
self._armed = False
self._task: asyncio.Task | None = None
# Identity of the device object last set, so one connection is set once even if
# the connect path reports it more than once.
self._synced: int | None = None
@property
def active(self) -> bool:
"""Whether the service is armed to act on connections."""
return self._armed
async def start(self) -> None:
"""Arm the service, setting the clock at once if a device is already connected.
Idempotent. The startup picker may have adopted a live connection before any
service started, so the connection that already exists counts as the first one.
"""
if self._armed:
return
self._armed = True
device = self._ctx._device
if device is not None:
self.on_connected(device)
async def stop(self) -> None:
"""Disarm, cancelling a set still in flight. Idempotent."""
self._armed = False
task, self._task = self._task, None
if task is not None and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def aclose(self) -> None:
"""Stop at session end (an alias for :meth:`stop`)."""
await self.stop()
def on_connected(self, device: Device) -> None:
"""Note a settled connection and, if the preference asks, set its clock in the background.
Returns at once; the set runs in its own task. A no-op while the service is not
started (scripted runs), while the preference is off, and for a device this
session has already set.
Args:
device: The device the context just finished connecting.
"""
if not self._armed or not self._ctx.preferences.set_clock_on_connect:
return
if self._synced == id(device):
return
self._synced = id(device)
self._task = asyncio.ensure_future(self._sync(device))
async def _sync(self, device: Device) -> None:
"""Run :func:`set_clock` and put the outcome in the log."""
log = self._ctx.log
try:
done = await set_clock(device)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - best-effort; the link is not ours to break
log.warning("clock sync: could not set the device clock: %s", exc)
return
if done.drift_s is None:
log.info("clock sync: device clock set to %s (drift unknown)", done.stamp)
else:
log.info("clock sync: device clock set to %s (was %+d s off)", done.stamp, done.drift_s)
+221
View File
@@ -0,0 +1,221 @@
# SPDX-License-Identifier: Apache-2.0
"""The PicoCalc's console font: remember what was there, switch, put it back.
The handheld draws to a Linux virtual terminal, and a VT loads exactly one font for the
whole console — there is no per-application font the way a desktop terminal emulator has
one. So MeshTerm asking for its 6x8 build (53x40, fourteen more rows) means changing the
font the *shell* is using, and leaving it changed would be MeshTerm walking off with the
reader's console. Hence the shape of this module: **remember** the font the shell had,
**apply** the one the preference names, and **restore** on every way out.
``setfont -O`` writes the current font out with its unicode table, and feeding that file
straight back to ``setfont`` reproduces it exactly — verified on the device. No ``sudo``
is involved: the VT is the process's controlling terminal and is owned by the logged-in
user, which is the whole reason this can be a preference rather than a setup step.
``-C`` is deliberately never passed, so every call targets that controlling VT and not
whichever tty number happens to be first.
Everything here is cosmetic and everything here fails quietly. A missing ``setfont``, a
read-only state directory, an ssh session that is not a VT at all, a non-zero exit — each
is one debug line and a no-op return, because a font is never a reason for the app not to
start. :func:`applies` is the single gate all three verbs ask first, so "this is not that
machine" is answered in one place.
Distinct from :mod:`meshterm.core.consolefont`, which installs a *TrueType* face for the
classic Windows console. Same words, opposite platforms: that one is a Win32 font
resource, this one is a PSF bitmap the Linux kernel's console driver reads.
"""
from __future__ import annotations
import atexit
import os
import re
import shutil
import subprocess
from pathlib import Path
from ..core.config import default_config_dir
from ..persistence.logging import get_logger
from ..platforms import get_platform
#: Where ``scripts/picocalc/calculinux-console-font-6x12.sh`` and its 6x8 companion install
#: the fonts.
FONT_DIR = Path("/usr/share/consolefonts")
#: One preference value -> the file the build script writes for it. This map lives beside
#: the code that runs ``setfont`` rather than in the preference registry: a reader picks a
#: cell size and a row count, not a path on a filesystem only one machine has.
FONT_FILES: dict[str, str] = {"6x12": "meshterm.psf.gz", "6x8": "meshterm8.psf.gz"}
#: The preference this module acts on, named once so the caller and the registry agree.
PREFERENCE_KEY = "console_font"
#: What :func:`remember` writes under the MeshTerm home. A plain ``.psf``: ``setfont -O``
#: writes an uncompressed PSF, and the file is transient anyway (:func:`restore` deletes
#: it), so it is never gzipped and never versioned.
SAVED_NAME = "console-font-before.psf"
#: A Linux virtual terminal, which is the only kind of console ``setfont`` can talk to.
#: ``/dev/pts/3`` (an ssh session or a terminal emulator on the same device) is not one,
#: and ``setfont`` against it would either fail or change a console nobody is looking at.
_VT_NAME = re.compile(r"^/dev/tty[0-9]+$")
#: Seconds before a ``setfont`` call is given up on. It is a sub-millisecond ioctl in
#: practice; the timeout is here so a wedged binary cannot hold the app's startup.
_TIMEOUT_S = 5.0
def _controlling_vt() -> str | None:
"""The name of stdin's terminal when it is a Linux VT, else ``None``.
``os.ttyname`` does not exist on Windows and raises on a redirected stdin, which are
both simply "not a VT" as far as this module is concerned.
"""
try:
name = os.ttyname(0)
except (AttributeError, OSError, ValueError):
return None
return name if _VT_NAME.match(name) else None
def applies() -> bool:
"""Whether switching the console font is a thing this session can do at all.
Three conditions, each a different reason to leave the console alone: the PicoCalc is
the only platform whose fonts this module knows; stdin must be the Linux VT whose font
would change (an ssh session's pty has no font of its own); and ``setfont`` must
exist, which on the device means the ``kbd`` package the font build already needs.
Returns:
``True`` when all three hold. Otherwise ``False``, with one debug line naming
which one did not — the log is where a font that "did nothing" gets explained.
"""
platform = get_platform()
if platform.name != "picocalc":
get_logger().debug("console font: leaving it alone — platform is %s", platform.name)
return False
if _controlling_vt() is None:
get_logger().debug("console font: leaving it alone — stdin is not a Linux VT")
return False
if shutil.which("setfont") is None:
get_logger().debug("console font: leaving it alone — no setfont on PATH")
return False
return True
def remember() -> Path | None:
"""Save the console's current font, and arm its restore.
The saved file is the *shell's* font — whatever was loaded before MeshTerm started,
which is usually the 6x12 default but is whatever the reader last chose — so the
restore puts back what they had rather than what we assume they had.
The restore is registered with :mod:`atexit` here as well as being run from the
session's ``finally``, because the ways out of a full-screen app are many (^Q, an
unhandled exception, the menu's own unwind) and a console left in the wrong font is
only noticed after the app is gone. Restoring twice is harmless: the first one deletes
the file and the second finds nothing to do.
Returns:
The path the font was written to, to hand back to :func:`restore`, or ``None``
when there was nothing to remember (see :func:`applies`) or the write failed.
"""
if not applies():
return None
path = default_config_dir() / SAVED_NAME
try:
path.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
get_logger().debug("console font: cannot make %s: %s", path.parent, exc)
return None
if not _setfont("-O", str(path)):
return None
atexit.register(restore, path)
return path
def apply(choice: str) -> bool:
"""Load the font one preference value names.
Args:
choice: A :data:`FONT_FILES` key — the ``console_font`` preference's value.
Returns:
Whether ``setfont`` ran and succeeded. ``False`` is the ordinary answer on every
machine that is not the handheld, and never an error the caller has to handle.
"""
if not applies():
return False
name = FONT_FILES.get(str(choice))
if name is None:
get_logger().debug("console font: no file for %r; leaving the console alone", choice)
return False
# ``as_posix``: this is a path on the handheld's filesystem whatever machine the
# string is built on, and a developer's Windows box would otherwise spell it with
# backslashes on its way into a test.
return _setfont((FONT_DIR / name).as_posix())
def restore(saved: Path | None) -> None:
"""Put back the font :func:`remember` saved, then drop the file.
Idempotent by construction — the file is deleted whether or not loading it worked, so
a second call (the ``atexit`` one, after the session's ``finally`` already ran) finds
nothing and does nothing.
Args:
saved: What :func:`remember` returned. ``None`` means there was nothing to do.
"""
if saved is None:
return
try:
present = saved.is_file()
except OSError: # pragma: no cover - a stat that fails is a file we can't use
present = False
if present and shutil.which("setfont") is not None:
_setfont(str(saved))
try:
saved.unlink(missing_ok=True)
except OSError as exc: # pragma: no cover - a file we wrote should be ours to remove
get_logger().debug("console font: cannot remove %s: %s", saved, exc)
def _setfont(*args: str) -> bool:
"""Run ``setfont`` with ``args``, logging anything it has to say about failing.
Never ``-C``: with no tty named, ``setfont`` acts on its controlling terminal, which
is the console the reader is actually looking at.
Returns:
``True`` only on a clean exit.
"""
argv = ["setfont", *args]
try:
done = subprocess.run( # noqa: S603 - a fixed binary name with our own arguments
argv, capture_output=True, text=True, timeout=_TIMEOUT_S, check=False
)
except (OSError, subprocess.SubprocessError) as exc:
get_logger().debug("console font: %s failed: %s", " ".join(argv), exc)
return False
if done.returncode != 0:
get_logger().debug(
"console font: %s exited %d: %s",
" ".join(argv),
done.returncode,
(done.stderr or "").strip() or "(no output)",
)
return False
return True
__all__ = [
"FONT_DIR",
"FONT_FILES",
"PREFERENCE_KEY",
"SAVED_NAME",
"applies",
"apply",
"remember",
"restore",
]
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The Courier: store-and-forward delivery for the outbox.
A session-long background loop over the :class:`~meshterm.core.courier_store.
+33 -11
View File
@@ -1,11 +1,13 @@
# SPDX-License-Identifier: Apache-2.0
"""The session's device-state cache: read the radio once, reuse it everywhere.
Opening a screen used to re-read the same stable facts from the companion every time —
the contacts table, the node's own self-info, the path-hash routing width, the configured
channel slots, the channel-slot capacity. Over Bluetooth each of those is a full
request→reply round-trip, and the channel-slot reads dominate: ``get_contacts`` on a busy
node (hundreds of contacts), the slot probe (:func:`~meshterm.ui.channels.read_channel_slots`)
and the capacity probe (:meth:`~meshterm.core.connection.Device.channel_capacity`) each walk
node (hundreds of contacts), the slot probe
(:func:`~meshterm.core.channel_probe.read_channel_slots`) and the capacity probe
(:meth:`~meshterm.core.connection.Device.channel_capacity`) each walk
the slot table one index at a time and are measured in *seconds* on firmware that never
rejects an out-of-range index. Firing them on every navigation is what made moving between
screens feel like it stalled.
@@ -43,10 +45,11 @@ from dataclasses import replace
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from ..core.channel_probe import ChannelSlot, probe_channel_slots
if TYPE_CHECKING:
from ..context import AppContext
from ..core.models import Contact
from ..ui.channels import ChannelSlot
#: How long a cached contacts list is served before a read triggers a background refresh
#: (seconds). Contacts only grow as the mesh advertises, and the list is a naming/addressing
@@ -204,7 +207,7 @@ class DeviceState:
So the three are merged by taking the latest, here — once, at the one point every
screen fetches contacts through, so heard lanes, recency sorts, silence alerts and
the purge ladder all see the same honest value. Taking the *latest* rather than
the archive ladder all see the same honest value. Taking the *latest* rather than
preferring either side means a device time still stands whenever the firmware caught
an advert we did not record (monitoring off, app not running); we only override it
with proof of a later reception. A contact we have truly never heard stays ``None``
@@ -286,26 +289,45 @@ class DeviceState:
self._path_hash_mode = int(await device.get_path_hash_mode())
return self._path_hash_mode
async def routing_prefix_bytes(self) -> int:
"""The device's path-hash width in bytes, or 0 when unknowable.
What a surface that *highlights* hashes actually wants: how many leading bytes of a
key the mesh routes on, which is what :func:`~meshterm.ui.widgets.highlighted_hash`
lights. Best-effort, because every caller reads stored history and must work with no
radio at all — an unreachable device (or firmware that doesn't report the mode) just
leaves every hash un-highlighted rather than failing the screen.
Returns:
The hash width in bytes (1–4), or 0 when the mode could not be read.
"""
try:
if not (self._ctx.is_connected or self._ctx.settings.connect_on_start):
return 0
mode = await self.path_hash_mode()
except Exception: # noqa: BLE001 - optional read; absence just skips highlighting
return 0
return (mode + 1) if isinstance(mode, int) and 0 <= mode <= 3 else 0
# -- channel slots (held until the channel editor invalidates them) --------
async def channel_slots(self) -> list[ChannelSlot]:
"""Return the configured channel slots, cached until a channel edit invalidates them.
The underlying probe (:func:`~meshterm.ui.channels.read_channel_slots`) walks every
slot index on the firmware, which is one of the slowest reads on a screen open — so it
The underlying probe (:func:`~meshterm.core.channel_probe.read_channel_slots`) walks
every slot index on the firmware, which is one of the slowest reads on a screen open — so it
is well worth reading once. It is best-effort itself (an unsupported firmware yields an
empty list rather than raising), but a probe that ended early because a *read failed*
is answered and then dropped rather than cached — see :func:`~meshterm.ui.channels.
probe_channel_slots` for why a short list is not a layout.
is answered and then dropped rather than cached — see
:func:`~meshterm.core.channel_probe.probe_channel_slots` for why a short list is not a
layout.
Returns:
One :class:`~meshterm.ui.channels.ChannelSlot` per configured slot, in index order.
One :class:`~meshterm.core.channel_probe.ChannelSlot` per slot, in index order.
"""
if self._channels is None:
async with self._channels_lock:
if self._channels is None:
from ..ui.channels import probe_channel_slots
device = await self._ctx.device()
slots, complete = await probe_channel_slots(device)
if complete:
+2 -1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The always-on mesh event hub.
The hub is the single seam between the raw companion-device event stream and everything
@@ -205,7 +206,7 @@ class EventHub:
Returns:
The matching :class:`MeshEvent`, or ``None`` if ``timeout`` elapsed first.
"""
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
future: asyncio.Future = loop.create_future()
def handler(event: MeshEvent) -> None:
+137
View File
@@ -0,0 +1,137 @@
# SPDX-License-Identifier: Apache-2.0
"""Collecting the mesh's located nodes into map markers.
One list, three surfaces: the ``map`` tool's full-screen map, the node page's preview minimap,
and the editors' pick-a-location map all plot the same mesh, so they all come through
:func:`gather_markers`. It sits in ``services/`` because it is an assembly job over the
device's contacts and the observation history — the layer below anything that draws — and
because two screens and one tool need it, none of which may import from the others.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from ..core.geo import usable_fix
from ..core.models import NODE_TYPE_REPEATER, MapMarker
if TYPE_CHECKING: # pragma: no cover - typing only
from ..context import AppContext
async def gather_markers(ctx: AppContext, *, wait: bool = True) -> list[MapMarker]:
"""Collect every located node to plot: the device's contacts, plus our own node.
The companion's **contact list** is the authoritative source for a node's name,
type (repeater vs. leaf), and advertised position — the passive-monitor
observations only carry a location for the rare node that broadcasts one in an
advert. So contacts drive the markers, and each contact is enriched with signal
detail from the observations when we've overheard it directly.
Args:
ctx: Shared application context.
wait: Whether to wait on the radio for contacts and our own position the session
has not cached yet. ``False`` builds the markers from what is already in hand —
the cache and our own history — and never makes a round trip, for a surface
whose nodes are only context (the location picker): a companion refusing the
contacts read can otherwise hold it closed for twenty-odd seconds.
"""
observed = {n.node: n for n in ctx.repo.heard_nodes() if n.node}
markers: list[MapMarker] = []
seen: set[str] = set()
contacts = await _contacts(ctx) if wait else (ctx.devstate.peek_contacts() or [])
for contact in contacts:
if not contact.has_location or not usable_fix(contact.lat, contact.lon):
continue
key = contact.key_prefix or (contact.public_key or "")[:12]
if key:
seen.add(key)
markers.append(
MapMarker(
label=contact.name or key or "?",
lat=float(contact.lat),
lon=float(contact.lon),
is_repeater=contact.is_repeater,
detail=_signal_detail(observed.get(key)),
key=contact.public_key or contact.key_prefix or None,
)
)
# A node we overheard advertising a location but that isn't in our contacts.
for node in observed.values():
if not node.has_location or node.node in seen:
continue
if not usable_fix(node.lat, node.lon):
continue
markers.append(
MapMarker(
label=node.name or node.node or "?",
lat=float(node.lat),
lon=float(node.lon),
is_repeater=node.is_repeater,
detail=_signal_detail(node),
key=node.public_key or node.node or None,
)
)
if wait:
self_marker = await _self_marker(ctx)
else:
info = ctx.devstate.peek_self_info()
self_marker = _marker_for_self(info) if info is not None else None
if self_marker is not None:
markers.append(self_marker)
return markers
async def _contacts(ctx: AppContext) -> list:
"""Fetch the device's contacts, best-effort (an unreachable radio yields none)."""
try:
return await ctx.devstate.contacts()
except Exception: # noqa: BLE001 - the map still works from observations alone
return []
async def _self_marker(ctx: AppContext) -> MapMarker | None:
"""Build a marker for our own node from the device, if its location is known."""
try:
info = await ctx.devstate.self_info()
except Exception: # noqa: BLE001 - the map is useful without our own position
return None
return _marker_for_self(info)
def _marker_for_self(info: dict) -> MapMarker | None:
"""Our own node's marker from a self-info payload, or ``None`` where it has no fix."""
lat, lon = _as_float(info.get("adv_lat")), _as_float(info.get("adv_lon"))
if lat is None or lon is None or not usable_fix(lat, lon):
return None # a device with no fix reports 0/0 (or nonsense out-of-range)
return MapMarker(
label=str(info.get("name") or "this node"),
lat=lat,
lon=lon,
is_repeater=info.get("adv_type") == NODE_TYPE_REPEATER,
is_self=True,
key=str(info.get("public_key") or "") or None,
)
def _signal_detail(node: object) -> str:
"""A compact 'N pkts · +x.x dB' reception note for a heard node, or '' if unheard."""
if node is None:
return ""
detail = f"{node.count} pkts" # type: ignore[attr-defined]
if node.median_snr is not None: # type: ignore[attr-defined]
detail += f" · {node.median_snr:+.1f} dB" # type: ignore[attr-defined]
return detail
def _as_float(value: object) -> float | None:
"""Best-effort float conversion, returning ``None`` on missing/garbage values."""
if value is None:
return None
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Recover the paths a chat message rode in on, from the stored packet log.
The radio's RX log records every frame it decodes — including the relay path each one
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The optional Shift-state watcher behind the PicoCalc F-key lane's live flip.
The PicoCalc keyboard's MCU translates Shift+F1..F5 into plain F6–F10 keycodes, so the
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff. Show more