61 Commits
Author SHA1 Message Date
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
103 changed files with 7402 additions and 2422 deletions

No files matched your search

-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.
-144
View File
@@ -1,144 +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:
$DEV_VENV_PYTHON .claude/skills/add-narrow-emoji/classify.py 👋
(``$DEV_VENV_PYTHON`` is the interpreter named in ``.dev.env``; ``.dev.env.example`` shows
the shape.)
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))
+5 -1
View File
@@ -139,7 +139,11 @@ 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
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. **Check the notes
actually came out of the changelog** — a green run is not proof, because the fallback is a
successful step:
+1 -1
View File
@@ -3,4 +3,4 @@
buy_me_a_coffee: johnputer
custom:
- "https://www.patreon.com/c/MeshTerm"
- "https://tinyurl.com/SupportMeshTerm"
- "https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6"
+61
View File
@@ -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
@@ -106,3 +115,55 @@ jobs:
--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.12"
- 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
+44
View File
@@ -118,6 +118,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: |
+3 -3
View File
@@ -31,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/
+204 -1
View File
@@ -17,6 +17,206 @@ allowed to change behaviour, not just add to it.
## [Unreleased]
## [0.3.7] — 2026-09-19
### Added
- **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, and MeshTerm cannot know
which firmware a repeater runs without asking — and every question is a paced round trip,
so probing for a fork's keys would spend a sweep on every node to benefit a few. Nothing
is probed speculatively and the catalog does not grow. Instead a `get` or `set` you ran,
which that node answered, earns a row under **Extra** on that node's page: the round trip
was one you were spending anyway, and no other node's page changes. Only the reader
removes a discovered row — a later read that comes back empty takes it to `n/a` rather
than deleting the evidence that it exists.
- **The Windows binary is signed** by SignPath Foundation, who issue certificates free to
open-source projects, so it no longer arrives as an unknown publisher.
### Fixed
- **MeshTerm finds your companion on a Mac.** Every Mac permanently presents two virtual
serial ports — `/dev/cu.Bluetooth-Incoming-Port` and `/dev/cu.debug-console` — that carry
no USB vendor id, and they were counted as candidates. A Mac with exactly one real board
attached therefore saw three devices and refused to choose, so automatic detection could
never fire there and every Mac user met *"Multiple companion devices detected"* on a first
run, naming two ports that are not companions as the ones to pick between. Which device is
plausible was already known, and already printed in that listing; it now decides with it. A
lone unrecognised adapter still connects, and several implausible ports no longer claim to
be companions.
- **A PIN-protected Bluetooth companion connects on the first attempt on macOS**, instead of
reporting an error and working when you selected it again. CoreBluetooth has no pairing
API: a peripheral pairs *implicitly* when something touches a characteristic that requires
encryption, which is exactly what the companion's firmware demands — so the refusal
MeshTerm was treating as a verdict is in fact macOS *starting* to pair, and its Passkey
dialog goes up as the attempt unwinds. The connection now waits for that dialog to be
answered, bounded so a dismissed one still fails. Elsewhere a rejection keeps meaning what
it always did.
- **The map draws its ground again on the macOS and Linux downloads.** A standalone build can
have no certificate store to consult — the default one names a path baked into the
interpreter's own build, which need not exist on a machine that never installed that
Python — so every tile fetch failed TLS verification. Because a refused fetch is
indistinguishable from empty terrain, the map drew nodes over blank ground and said nothing
about why; Windows hid it for so long because it reads the operating system's certificate
store. The fetch now falls back to the CA bundle the OS ships. Verification is never
weakened: with no bundle to be found it still verifies, and still fails.
- **The splash wordmark draws in its own colours on macOS.** The art spells brightness the
way DOS did, with bold lifting a colour into the bright bank — true on a DOS console and on
the PicoCalc, false on macOS Terminal, where bold asks for a heavier typeface and leaves
the colour alone. The mark came out muted and its dithered gradients faded toward the wrong
end. Brightness is now emitted as the colour it means, which cannot be mistaken for a font
weight. Nothing about the art changed, and no cell moved.
- **The device list shows the device.** The startup splash sized its PORT / ADDRESS column to
the longest target and gave the name whatever was left, which held while a Bluetooth
address was a 17-character MAC. macOS reports no MAC at all but a 36-character per-machine
UUID, and that squeezed the DEVICE column down to the width of its own heading. The name is
now sized first and shown in full; the address takes what is left and ellipsizes from the
front, keeping the end that tells two targets apart.
### Changed
- **The macOS download note leads with `curl`.** The flag that makes Gatekeeper refuse an
unsigned binary is set by the *browser* that downloaded it, not by macOS, so fetching it
with `curl` sidesteps the dialog entirely — and for a program whose users are already at a
terminal, that is the normal way to install it. The old advice was also going stale:
Sequoia removed the right-click → Open bypass, and since Catalina a quarantined binary
started from Terminal gets the same block rather than escaping it.
## [0.3.6] — 2026-09-17
### Added
- **A contact can be locked.** Lock contact on a contact's page keeps it from ever being
archived: the archive sweep counts it protected, and the page hides Archive until you
unlock it. Locked contacts show a padlock ahead of their node type icon in the Contacts
list — 🔒, or the console font's own padlock on the PicoCalc. The lock is kept by
MeshTerm, not the radio, and reading the device never clears it.
- **Set clock on connect.** A new preference in the Device group sets the radio's clock from
this computer's every time MeshTerm connects to it — at launch, when you connect later,
and after a reconnect, since a dropped link is exactly when a board with no real-time
clock may have rebooted and lost the time. It is off by default, runs in the background,
and records the drift it corrected only in the log. Scripted commands never write the
clock.
- **Two installation manuals, one per handheld.** [`docs/picocalc.md`](docs/picocalc.md)
takes a stock ClockworkPi PicoCalc from a shopping list to a Linux handheld running
MeshTerm with a LoRa radio soldered inside — the Lyra swap, writing Calculinux, the setup
script, building and flashing the XIAO firmware (the patch it still needs, explained
hunk by hunk), the wiring, and the Lyra setup. [`docs/uconsole.md`](docs/uconsole.md)
installs the SPI bridge on a uConsole with the AIO LoRa board and connects MeshTerm to
it. Both are step-by-step in plain language and self-contained, each ending on its own
list of what has only been proven on one bench; [`docs/hardware.md`](docs/hardware.md)
shrinks to the page that says which manual is yours, and the README's new
**Documentation** table points at all of it.
- **A basemap doctor for a map that stays blank.** Every failure on the map's one network
path is logged below the default log level, so tiles that never arrive leave a blank
ground and no explanation. [`scripts/basemap-doctor.py`](scripts/README.md), run on the
machine with the problem, reports the Python MeshTerm runs under, its certificate store,
the tile source, and the terminal's colour depth, then fetches a real tile and names the
cause with its fix: a Python with an empty certificate store (a python.org install on
macOS, until `Install Certificates.command` is run), the network in between, or a
terminal at 256 colours, where the basemap's greens and blues collide.
### Changed
- **Purge contacts is Archive contacts**, because archiving is what it does: every contact
it takes off the device stays in MeshTerm and can be restored. The row, its screens, its
confirm button, and its progress bar all say Archive, and the row wears the 💾 that
archiving one contact already wore, not the red 🗑 a deletion gets.
- **The archive preview shows the evidence, one kind to a column.** Its rows led on a
percentile and followed it with the reasons a contact ranked low, three columns under one
header, so a message count, an age, and a hop count traded places from row to row. Each
column now holds one measurement all the way down: `HEARD` and `PKTS` first, drawn as the
Contacts list draws them, then the `MSGS` and `HOPS` the sweep adds. The percentile is
gone, since the list's order already is the ranking. Every contact leads with its node
type icon, as in the Contacts list, and ←→ scrolls the columns under a name that stays
put. Archiving a single contact from its page names it the same way in the confirm.
- **`scripts/` is sorted by handheld.** The PicoCalc's Calculinux bring-up, its two
console-font builds, the GPL-2.0 text that belongs beside the 6×8 one, and the
`xiao-radio/` firmware kit all live under `scripts/picocalc/`; the SPI bridge is
`scripts/uconsole/meshterm-spi-bridge`. The index of what every file does and which
machine runs it is a section of [`docs/hardware.md`](docs/hardware.md) now, beside the
table that says which manual is yours, and the folder's own README keeps only a pointer
to each handheld's folder and the basemap doctor. Every path in the manuals, the notices,
the tests, and the scripts' own headers follows.
- **The 6×12 console-font script carries its size in its name**,
`calculinux-console-font-6x12.sh`, like its 6×8 sibling always did. Same script, every
reference updated.
- A tool that fails says so in an amber ⚠ popup, which wraps a long explanation instead of
opening the full-screen result window for it.
- The About page opens on a new description of what MeshTerm is and what it plugs into.
### Fixed
- **Reading a big contact table no longer times out.** The library under MeshTerm gave a
whole contacts read one five-second deadline, so a companion holding a few hundred
contacts failed every read with "no event received", however healthy the radio. The wait
is now for the gap between records — each record restarts it, and the read takes as long
as the table does — so a timeout means the companion really stopped answering. An error
reply meant for some other command no longer ends the read either.
- **An emoji no longer knocks a row out of line.** Terminals disagree about how wide they
draw an emoji, and a contact's name arrives off the air carrying whatever its owner typed,
so one emoji in a name could shift every column after it, and the screen's right border,
by a cell. Every glyph that may be drawn as an emoji now gets two cells, and each is drawn
at the column MeshTerm measured for it — in lists, in dialogs, and on the screens behind
them — so one drawn narrow leaves a blank beside it rather than moving anything else, and
a repaint no longer leaves a stale letter standing next to it. A name cut to fit its
column is cut between glyphs, never through one, and a newline or an escape in a name no
longer ends its row early. `MESHTERM_COLUMN_SNAP=0` turns the placement off.
- **Hops in near-identical colours stay separate chips.** Where a path is drawn as coloured
chips, two neighbours of the same colour cut a wedge of bare background out of the route,
and two of nearly the same colour were joined by a chevron nobody could see. Both are now
divided by a thin chevron in a shade of the first chip's own colour, so the route runs on
unbroken and the join still shows. "Nearly the same" is measured the way the eye sees
colour, in OKLab, rather than as a hue gap, which judged the greens and the cyans very
differently.
- Walking back up a scrolled list no longer lets the highlight ride off the top of its box.
## [0.3.5] — 2026-09-14
### Added
- A release now rebuilds [meshterm.net](https://meshterm.net) from the tag, so the site
carries the version it links to.
### Changed
- **The purge preview counts in columns.** The target ladder used to describe each rung in a
phrase — "keeps 45 · archives 12" — so comparing two rungs meant finding the numbers inside
two sentences. Keeps and archives are right-aligned columns now under a pinned
`TARGET / KEEPS / ARCHIVES` header, one lane measured across both sections so the numbers
run straight down the ladder; a rung that archives nothing shows a muted 0. The reasons a
contact ranks low sit in three lanes of their own under one header, instead of running on
from wherever the previous reason ended.
- The PayPal link on the Support page, and in the repository's funding file, is PayPal's own
donate address rather than a shortener redirect, so a reader can see where it goes.
### Fixed
- **A name with a skin tone or a cut-off joiner no longer pulls its row out of line.** Two
real contact names showed it: one carries a skin-tone modifier, which one width table
counted as two cells and the terminal draws as none, so the row reserved four cells for a
two-cell glyph; the other is a flag sequence cut at the name's byte limit just past its
joiner, which swallowed the padding space after it and left the name a cell short. A
modifier now measures zero, and a joiner joins only a pictograph. The purge preview, the
Contacts list and the chat picker all pad names through that one measurement.
## [0.3.4] — 2026-09-13
### Fixed
@@ -710,7 +910,10 @@ deliberately not reconstructed here.
- 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.4...HEAD
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.7...HEAD
[0.3.7]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.7
[0.3.6]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.6
[0.3.5]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.5
[0.3.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.4
[0.3.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.3
[0.3.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.2
+11 -4
View File
@@ -311,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
@@ -327,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) ·
@@ -545,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
+2 -2
View File
@@ -25,10 +25,10 @@ project and is not authorized to do so.
--------------------------------------------------------------------------------
Device script: scripts/calculinux-console-font-6x8.sh embeds the Linux kernel's
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/LICENSE.GPL-2.0 and are its only license -- the Apache License above
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.
+46 -22
View File
@@ -26,22 +26,18 @@ your mesh's history is worth.
> 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).
<!--
Screenshots to take — PNG, a 72-column window, the dark theme — saved under
docs/screenshots/ with these names, and the four links below come alive:
dashboard.png the dashboard on a live mesh: heard-age heat, the braille timelines
map.png the map zoomed on a handful of nodes, basemap under them
trace.png a trace result with its route drawn as chips
picocalc.png a photo of the app on the PicoCalc's 53x26 console
-->
<p align="center">
<img src="docs/screenshots/dashboard.png" alt="The dashboard" width="49%">
<img src="docs/screenshots/map.png" alt="The mesh map" width="49%">
<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 and the route it walked" width="49%">
<img src="docs/screenshots/picocalc.png" alt="MeshTerm on the PicoCalc" width="49%">
<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>
---
@@ -79,16 +75,19 @@ 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:
```powershell
.\meshterm-0.3.4-windows-x64.exe
.\meshterm-0.3.7-windows-x64.exe
```
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
```bash
chmod +x meshterm-0.3.4-*
./meshterm-0.3.4-*
chmod +x meshterm-0.3.7-*
./meshterm-0.3.7-*
```
On macOS, downloading it with `curl` instead of a browser saves you a fight with Gatekeeper
— the note below has the line.
### A word about terminals, on Windows
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether you
@@ -123,8 +122,19 @@ becomes just `meshterm` from anywhere.
> code-signed — signing costs real money on both platforms and this is a free side
> project.
>
> - **macOS**: *"cannot be opened because the developer cannot be verified"*. Clear it with
> `xattr -d com.apple.quarantine meshterm-0.3.4-*` and run it again.
> - **macOS**: *"cannot be opened because the developer cannot be verified"* — or, on Sequoia
> and later, *"Apple could not verify…"*, and right-click → Open no longer gets past it.
> The flag that causes it is set by your **browser**, not by macOS, so the easiest answer
> is to not use one:
>
> ```bash
> curl -L -O https://github.com/jpmartineau/MeshTerm/releases/download/v0.3.7/meshterm-0.3.7-macos-arm64
> chmod +x meshterm-0.3.7-macos-arm64 && ./meshterm-0.3.7-macos-arm64
> ```
>
> `curl` marks nothing, so it simply runs — on an Intel Mac, swap `arm64` for `x64`. If it's
> already downloaded, clear the flag instead: `xattr -d com.apple.quarantine meshterm-0.3.7-*`.
> Installing with pipx (below) avoids all of this too, since nothing is downloaded as a binary.
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
>
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
@@ -134,10 +144,10 @@ becomes just `meshterm` from anywhere.
> MeshTerm uses that same folder. Set `MESHTERM_HOME` to try one in isolation:
>
> ```bash
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.3.4-* # macOS, Linux
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.3.7-* # macOS, Linux
> ```
> ```powershell
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.4-windows-x64.exe
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.7-windows-x64.exe
> ```
>
> Same variable if you run two radios and want them kept apart.
@@ -171,6 +181,17 @@ 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
| Read | For |
| --- | --- |
| **[The command line](docs/cli.md)** | Every subcommand and option, what each prints on the plain and JSON faces, the exit statuses, recipes. |
| **[MeshTerm on the PicoCalc](docs/picocalc.md)** | Building the handheld from a stock PicoCalc: the shopping list, swapping in the Lyra, installing Calculinux and MeshTerm, the soldered LoRa radio. |
| **[MeshTerm on the uConsole](docs/uconsole.md)** | A uConsole with the AIO LoRa board: installing the bridge that fronts its SPI radio as a companion, and connecting to it. |
| [MeshTerm on hardware](docs/hardware.md) | Which of the two manuals is yours, and the two platform flavours MeshTerm draws in. |
| [CONTRIBUTING.md](CONTRIBUTING.md) | The development setup and the house rules. |
| [CHANGELOG.md](CHANGELOG.md) | What changed, newest first. |
## Quick start
```bash
@@ -193,9 +214,12 @@ meshterm --mock --platform picocalc
meshterm specimen
```
Putting MeshTerm on that handheld — the console font, the boot services, a soldered radio for
it, and the bridge that fronts a uConsole's SPI LoRa chip as a companion — is
[`scripts/`](scripts/), and **[`docs/hardware.md`](docs/hardware.md) is its manual**.
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
+1 -1
View File
@@ -1460,7 +1460,7 @@ each link; the scripted face prints the link alone.
**`--json`:**
```json
{"page":"discord","title":"Join Discord","version":"0.3.4","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.3.7","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
```
| Field | Type | Meaning |
+51 -660
View File
@@ -1,679 +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 and none of this document
applies: MeshTerm finds it, and the [command line manual](cli.md) is all you need.
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 is the manual for the hardware that *doesn't* present a node that way, and for the
handheld whose console can't draw the interface until you build it a font. Everything under
[`scripts/`](../scripts/) is here: what each script does, what it assumes about the machine
it runs on, why it does it the way it does, and what has only ever been proven on one bench.
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.
The scripts are small, POSIX `sh` or stdlib Python, and meant to be read before they are
run. They are also *device-side* — they change the machine they run on, most of them as
root — so read the section for your target first and run the script second.
- [Three targets, and which one is yours](#three-targets-and-which-one-is-yours)
- [The regular and picocalc platforms](#the-regular-and-picocalc-platforms)
- [Calculinux bring-up](#calculinux-bring-up)
- [Before you run it](#before-you-run-it)
- [What the nine phases do](#what-the-nine-phases-do)
- [The console font on its own](#the-console-font-on-its-own)
- [Joining a network by hand](#joining-a-network-by-hand)
- [Re-running, and undoing](#re-running-and-undoing)
- [A real radio for the PicoCalc: XIAO nRF52840 over UART](#a-real-radio-for-the-picocalc-xiao-nrf52840-over-uart)
- [Parts and wiring](#parts-and-wiring)
- [Build the firmware](#build-the-firmware)
- [Flash the XIAO](#flash-the-xiao)
- [Set up the Lyra](#set-up-the-lyra)
- [Run it, and check the link](#run-it-and-check-the-link)
- [Troubleshooting](#troubleshooting)
- [The SPI radio bridge](#the-spi-radio-bridge)
- [Before you start: the runtime rename](#before-you-start-the-runtime-rename)
- [Setting it up](#setting-it-up)
- [Identity, and one program at a time](#identity-and-one-program-at-a-time)
- [Hardware knobs](#hardware-knobs)
- [What works through the bridge](#what-works-through-the-bridge)
- [The unfinished UART-to-TCP pump](#the-unfinished-uart-to-tcp-pump)
- [What is verified and what is not](#what-is-verified-and-what-is-not)
---
## Three targets, and which one is yours
There are three independent jobs in `scripts/`, and they belong to different machines.
## Which guide is yours
| I have… | Read |
| --- | --- |
| a ClockworkPi **PicoCalc** with a Luckfox Lyra running Calculinux, and I want MeshTerm on its own screen | [Calculinux bring-up](#calculinux-bring-up) |
| …and I want a **radio** in it | [XIAO nRF52840 over UART](#a-real-radio-for-the-picocalc-xiao-nrf52840-over-uart) |
| a **uConsole** with the hackergadgets AIO, or a Pi with a Waveshare LoRa HAT — an SX1262 on the host's own SPI bus | [The SPI radio bridge](#the-spi-radio-bridge) |
| a MeshCore companion on USB, BLE or WiFi | nothing here — the [main README](../README.md) |
| 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 PicoCalc target and the SPI target have nothing in common but MeshTerm itself. The
console font applies only to the PicoCalc's framebuffer console; the SPI bridge runs a mesh
node in software on a Debian host and never touches a console font. Do not run one target's
scripts on the other's machine.
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.
Within the PicoCalc target the two radio routes are mutually exclusive, and the reason is
the kernel's, not MeshTerm's: **only one program may hold `/dev/ttyS1`.** Either MeshTerm
opens the UART directly, which is what [`lyra-setup.sh`](../scripts/xiao-radio/lyra-setup.sh)
configures and what you want on the device itself, or a pump holds the UART and fronts it on
TCP for other machines — never both at once.
## 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. |
| [`picocalc/calculinux-console-font-6x8.sh`](../scripts/picocalc/calculinux-console-font-6x8.sh) | the Lyra (root) | The shorter-cell companion font, **meshterm8** (6×8 → 53×40), for anyone who wants more rows than the 6×12 default gives. Its base bitmap is the kernel's `font_6x8`, so this one file and the PSF it builds are **GPL-2.0-only** ([`LICENSE.GPL-2.0`](../scripts/picocalc/LICENSE.GPL-2.0) sits beside it); it reads the donor/alias/keeper tables out of the script above rather than keeping a second copy. Pick between the two fonts from MeshTerm's Preferences page. |
| [`picocalc/calculinux-wifi-set.sh`](../scripts/picocalc/calculinux-wifi-set.sh) | the Lyra (sudo) | Interactive Wi-Fi join: prompts for an SSID and passphrase, writes iwd's credentials, connects now. This is how a network *becomes* known to the boot kick. |
| [`picocalc/calculinux-radio-bridge.sh`](../scripts/picocalc/calculinux-radio-bridge.sh) | the Lyra (root) | **Unfinished, unsupported.** An early UART-to-TCP pump, superseded by `xiao-radio/lyra-setup.sh`; its header describes routing it does not contain. See the manual before reading it. |
| [`picocalc/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 spec at boot and draws everything through it. The
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 the PicoCalc's framebuffer console, and
every number in it comes from the panel: 53 columns, 26 rows on the 6×12 console font (40
on the 6×8 candidate), no panel border, no emoji anywhere, an F-key lane instead of a hint
line, and sixteen colour slots rather than a colour space — the RGB565 panel is not the
limit, the Linux VT layer is. Nothing loses its colour for being on the console; the node
hue and the recency heat gradient **quantise** to those slots rather than switching rules.
The platform is detected when `/proc/device-tree/model` reads exactly `Luckfox Lyra`, and
`--platform picocalc` or `MESHTERM_PLATFORM=picocalc` forces it anywhere — which is how you
preview the handheld layout on a desktop, alongside `meshterm specimen` for the whole
visual language on one card.
The console font is the part that needs building, and it is built **on the device** by
[`calculinux-console-font.sh`](../scripts/calculinux-console-font.sh) rather than shipped as
a binary. Its only inputs are the stock Terminus console font the Calculinux image already
carries and `python3`; every added glyph is generated geometry in the script itself. So
nothing binary lives in the repo, and the base font is whatever your image installed rather
than whatever a maintainer's was. What it adds is what the bare console cannot draw and
MeshTerm cannot do without: the 256 braille cells every chart and map is made of, eighteen
marks (`●` `◉` `★` `✓` `✗` `▶` `❯` `▸` `⚠` `⌫` `⚙` `↻` among them), and rounded frame
corners aliased onto the square ones.
The budget is the interesting constraint. The framebuffer console caps a font at **512
glyphs**, and braille alone is 256 of them on top of the base font's 256 — so the budget is
full before a single mark is drawn. Each mark is therefore drawn *into a donor slot*: a
pictograph MeshTerm never emits, whose Unicode entry is repointed at the new bitmap. PSF
Unicode tables alias several codepoints onto one glyph, so a careless donation takes a
bystander with it — donating `π` once erased a Cyrillic letter that shared its slot. The
script aborts if a donor is missing, and after the build it verifies a list of keepers with
`π` and `пе` as canaries. A repo test parses the script's own mark, alias and donor tables
and pins them to `meshterm/ui/fontset.py`, the device-verified inventory of the installed
font: the script and the inventory move in the same commit or the test fails.
> **Licensing.** The installed 6×12 font is a **derivative of Terminus**, which is under the
> SIL Open Font Licence 1.1 with "Terminus" as a Reserved Font Name — so the output is named
> `meshterm`, which is exactly what the OFL requires of a renamed derivative, and it must not
> be called Terminus. Nothing Terminus itself is in this repo. The 6×8 companion is
> different: its base bitmap is the Linux kernel's `font_6x8`, which is **GPL-2.0**, embedded
> base64 in the script. So that material lives in one file of its own,
> [`calculinux-console-font-6x8.sh`](../scripts/calculinux-console-font-6x8.sh), marked
> `SPDX-License-Identifier: GPL-2.0-only` with the licence beside it as
> [`scripts/LICENSE.GPL-2.0`](../scripts/LICENSE.GPL-2.0) — and that is the end of the
> awkwardness. Nothing GPL-2.0 enters the Apache-2.0 program: the script is excluded from
> the Python distribution, it runs **on the device**, and what it produces is a PSF **data
> file the kernel's console driver loads** with `setfont`. MeshTerm never links it, never
> bundles it and never redistributes it; it names a path on the console's own filesystem.
> The two scripts share one copy of the donor, alias and keeper tables — the 6×8 one reads
> them out of the 6×12 one, between its `shared generator` marker lines — so a glyph added
> in one place lands in both fonts. To permit this sharing, the shared generator block in
> the 6×12 script (Apache-2.0) is additionally offered under GPL-2.0-only by its source
> file. The XIAO firmware patch and builder reference MeshCore, which is under the MIT
> License — see [`scripts/xiao-radio/LICENSE.MeshCore`](../scripts/xiao-radio/LICENSE.MeshCore).
---
## Calculinux bring-up
[`calculinux-setup.sh`](../scripts/calculinux-setup.sh) turns a freshly flashed Calculinux
SD card into a machine that runs MeshTerm as a dedicated user, with Wi-Fi that survives a
cold boot, a clock that is correct, and a console font. It runs **on the Lyra, as root**,
and the serial console is a fine place to run it from. It is idempotent: every phase checks
before it acts.
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
# on the Lyra, as root, with scripts/ copied over
sh calculinux-setup.sh
passwd meshterm # the script deliberately sets no password
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
```
Then log in as that user and look at it:
```bash
meshterm --mock # the interface, with a simulated radio
meshterm specimen # the visual language on one card
```
### Before you run it
The script assumes Calculinux as shipped — Yocto, systemd, a read-only root with writable
overlays, `opkg` reachable over the network — and that `sh`, `su`, `useradd` or busybox
`adduser`, `systemctl`, `timedatectl`, `sed`, `grep`, `ssh-keyscan`, `git` and `kbd` are
present. It installs `git` and `kbd` anyway rather than trusting the base image.
One thing it cannot embed, and one choice it makes by looking:
- **Wi-Fi credentials.** The boot-time kick only nudges a network `iwd` already knows.
Either export `WIFI_SSID` and `WIFI_PSK` before running, and the script writes iwd's PSK
file (mode 600, secrets staying in your environment and never in the repo), or join once
by hand with [`calculinux-wifi-set.sh`](#joining-a-network-by-hand).
- **How the clone gets in.** MeshTerm is a public repository, so this needs no credential at
all: with no key file at `$KEY_PATH` the phase clones `$REPO_URL` over public HTTPS and
says so. Put a read-only GitHub deploy key there (mode 600) and it clones `$REPO_SSH` over
SSH instead — pinning github.com's host key with `ssh-keyscan` first so the clone never
blocks on a prompt, and baking the key into the checkout's `core.sshCommand` so later
pulls need no environment. Either way an existing checkout at
`/home/$DEPLOY_USER/MeshTerm` is pulled `--ff-only` rather than replaced.
| Knob | Default | Effect |
| --- | --- | --- |
| `DEPLOY_USER` | `meshterm` | the login MeshTerm runs under; its home holds the checkout |
| `TIMEZONE` | `America/Toronto` | any IANA zone; see phase 4 for what happens without tzdata |
| `REPO_URL` | the project's HTTPS URL | cloned when there is no deploy key |
| `REPO_SSH` | the project's SSH URL | cloned when there is one |
| `KEY_PATH` | `/home/$DEPLOY_USER/.ssh/id_ed25519` | the read-only deploy key, if you want the SSH route |
| `WIFI_SSID`, `WIFI_PSK` | empty | set **both** to have iwd's credentials written for you |
### What the nine phases do
Each phase exists because of something that went wrong on the real device, and the *why* is
the part worth reading.
| Phase | What it does, and why |
| --- | --- |
| **1. opkg packages** | Installs `python3-modules`, `python3-pip`, `git`, `kbd`. Calculinux ships a stripped Python 3.13 — no `pip`, no `venv`, no `ensurepip`, and missing `sqlite3`, `ctypes`, `curses` and more — so MeshTerm cannot even import until `python3-modules` restores the interpreter. `/usr` is a writable overlay, so the install persists. |
| **2. Wi-Fi kick** | Writes `/etc/wifi-kick.sh` and a oneshot `wifi-kick.service`. On cold boot the USB RTL8188EU's firmware finishes loading *after* iwd's first scans, so iwd's own scans come back empty and `wlan0` sits UP/NO-CARRIER forever. The kick waits for the interface, then runs **raw `iw dev wlan0 scan`** in rounds until a lease appears. Raw `iw` and not `iwctl scan`: a completed raw scan fills the mac80211 scan results and iwd then associates by itself, whereas `iwctl scan` makes iwd hold the device and the raw scan fails busy. |
| **3. Clock sync** | Writes `/etc/time-sync.sh` and a oneshot ordered after the kick. This board has no battery-backed RTC, so every cold boot starts at the kernel's build epoch — fatal for "heard" ordering and message timestamps. It waits for a real route, then tries `chronyd`, busybox `ntpd`, `sntp`, and finally an HTTPS `Date:` header, and writes the hardware clock best-effort. It always exits 0. |
| **4. Timezone** | Sets `$TIMEZONE` — `America/Toronto` unless you say otherwise — through `timedatectl` once tzdata is installed, and falls back to a POSIX `TZ` rule in `/etc/environment` and the deploy user's `~/.profile` when it is not, because glibc honours a POSIX rule with no zoneinfo database at all and the opkg feed index has been seen to 404. The fallback rule is only known for the default zone; another zone on a device with no tzdata is set through `timedatectl` alone, with a warning. |
| **5. Deploy user** | Creates `$DEPLOY_USER` and puts it in `wheel` for sudo and in `input`, `dialout` and `video`. `input` is not optional: the picocalc platform watches modifier keys directly, and `dialout` is what lets MeshTerm open the radio. Group changes need a fresh login. |
| **6. Clone** | The checkout at `/home/$DEPLOY_USER/MeshTerm`, pulled `--ff-only` if it is already there. See [Before you run it](#before-you-run-it). |
| **7. venv and install** | `python3 -m venv ~/MeshTerm/.venv`, then `pip install -e .` with **`TMPDIR=$HOME/tmp`** — `/tmp` is a small RAM tmpfs and pip's C builds hit ENOSPC in it. |
| **8. Login PATH** | Appends the venv's `bin` to the deploy user's `~/.profile` once, so `meshterm` is just a word. |
| **9. Console font** | Hands off to `calculinux-console-font.sh` if it sits alongside, and otherwise tells you to run it yourself. |
> **A Lyra with no Wi-Fi dongle attached takes about six minutes to boot past the kick**,
> which waits a minute for the interface and then burns thirty rounds before exiting 0 — and
> the clock sync is ordered after it. Nothing is broken; it is a oneshot giving a slow radio
> every chance. Disable `wifi-kick.service` if the device is permanently wired or offline.
### The console font on its own
The font script stands alone, which is what you want after a MeshTerm update that adds a
glyph:
```bash
sh calculinux-console-font.sh
```
It builds both fonts — the 6×8 one by handing off to
[`calculinux-console-font-6x8.sh`](../scripts/calculinux-console-font-6x8.sh) beside it, or
telling you it is missing; `SKIP_6X8=1` builds only the default — applies the 6×12 one live
with `setfont` on `/dev/tty1`, and persists it as `FONT=meshterm` in `/etc/vconsole.conf` so
systemd loads it at boot. If `setfont` fails it aborts *before* persisting, so you are never
left with a font that only exists in `vconsole.conf`. A 6×8 build that fails is a warning
and nothing more: the default is installed either way.
**Choosing between them is a preference, not a command.** *Display → Console font* on the
Preferences page offers `6x12 (53x26)` and `6x8 (53x40)`, and it is drawn on the PicoCalc
only — it is a row about hardware no desktop has, though the value round-trips through
`preferences.toml` on any machine. Picking one is the preview: the console loads it
immediately and the page repaints at the new row count (the kernel raises SIGWINCH on a
font change), so you look at the choice before you keep it. *Apply* keeps it; leaving the
page and discarding puts the saved font back.
MeshTerm puts the font back when it exits. Before the first paint it saves the console's
current font with `setfont -O ~/.meshterm/console-font-before.psf` — whatever the *shell*
was in, not whatever we assume it was — and reloads that file on the way out, from the
session's own teardown and from an `atexit` hook, so ^Q and a crash both leave the console
as they found it. Then the file is deleted. No `sudo` anywhere: the VT is the process's
controlling terminal and belongs to the logged-in user. Every part of this is optional and
silent — off the PicoCalc, off a real VT (an ssh session is a pty, not a console), or with
no `setfont` on `PATH`, it does nothing and says so once in the log. A permanent choice
still goes in `vconsole.conf` by hand: `FONT=meshterm8` for 53×40, `FONT=meshterm` for 53×26.
> **The 6×8 font has no Cyrillic.** Its base is CP437, where the 6×12 Terminus base is not,
> so a node named in Cyrillic draws as tofu boxes on it. That is a known and accepted cost
> of the fourteen extra rows, not a bug to report.
The same script owns the sixteen colour slots. A **default run restores the stock kernel VT
palette** and removes any previous remap: it disables and deletes the `meshterm-vtrgb`
boot unit, `/etc/vtrgb` and the applier, programmes the stock table with `setvtrgb`, and
then writes the sixteen `OSC P` sequences to the tty as well. Both, and not just the
escape-sequence reset, because a previous `setvtrgb` overwrote the kernel's *default*
colormap — so a reset alone would faithfully restore the custom colours you were trying to
get rid of. `MESHTERM_CUSTOM_PALETTE=1` installs the archived custom remap instead, as
`/etc/vtrgb` plus a oneshot that re-applies it after `systemd-vconsole-setup`; a repo test
pins that remap to the palette table in `meshterm/ui/theme.py`. There is no uninstall of
the fonts themselves — delete the two `.psf.gz` files and the `FONT=` line.
### Joining a network by hand
[`calculinux-wifi-set.sh`](../scripts/calculinux-wifi-set.sh) is how a network *becomes*
known, which is the half the boot kick cannot do. It lists known networks and a fresh scan,
prompts for an SSID and a passphrase with the echo off, writes iwd's PSK file for it, and
connects now. It re-execs itself under `sudo` if you are not root.
```bash
sh calculinux-wifi-set.sh
```
It uses `iwctl` where the boot kick deliberately uses raw `iw`, and that is not a
contradiction: the kick's problem is a cold-boot firmware race with iwd's scanner, and this
is an interactive join on a machine that is already up.
### Re-running, and undoing
Re-running is safe and is the normal way to update: the packages, the user, the checkout,
the venv and the PATH line are all checked first, the two helper scripts and their units are
rewritten with identical content, `pip install -e .` runs again cheaply, and the fonts are
rebuilt. Two things to know. Re-running under a **different `DEPLOY_USER` creates a second
user and a second checkout** rather than moving anything, so a device provisioned under one
name must keep being given that name. And there is **no uninstall** of any of it: to undo,
disable `wifi-kick.service` and `time-sync.service`, delete the two helper scripts, drop the
`FONT=` line from `/etc/vconsole.conf`, and remove the user.
---
## A real radio for the PicoCalc: XIAO nRF52840 over UART
The PicoCalc has no radio and no USB host to hang a companion off, so the radio is soldered
to the header: a Seeed XIAO nRF52840 with a Wio-SX1262, running MeshCore's companion
firmware on its own second serial port, wired to the Lyra's UART1. MeshTerm then opens
`/dev/ttyS1` like any other serial companion — no bridge process, no BLE, no SD slot
sacrificed.
```
MeshTerm ──serial──> /dev/ttyS1 ──UART1 (GP4/GP5)──> XIAO Serial1 (D6/D7) ──> SX1262 ──))) mesh
(on the Lyra) 115200 8N1 matrix-IO mux companion firmware
```
Three steps on two machines: **build** the firmware and **flash** the XIAO from your dev
box, then **set up the Lyra** as root.
### Parts and wiring
- A **Seeed XIAO nRF52840**, plain or Sense, stacked with the **Wio-SX1262** LoRa module.
The SX1262 uses XIAO pins D1–D5 and D8–D10 and is already wired inside the kit.
- A **Luckfox Lyra** in Pico form factor (RK3506, SD boot) running Calculinux in a
ClockworkPi PicoCalc.
- 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-compatible and each pad's function is Luckfox's own RK3506 mapping: GP4 is
> `gpio0-0` and GP5 is `gpio0-1`. Don't read a Pico pinout for anything else here. Every
> signal is 3.3 V, so no level shifter, and there is **no 5 V rail** on this board — power
> the XIAO from 3V3 only.
### Build the firmware
On your dev machine, with `git` and PlatformIO (`pip install platformio`):
```bash
cd scripts/xiao-radio
sh build-firmware.sh
```
It clones MeshCore into `./_meshcore-build` (override with `MESHCORE_DIR`), checks out the
pinned commit **`e9edfc8e`** on the `dev` branch (override with `MESHCORE_COMMIT`), applies
[`meshcore-uart1.patch`](../scripts/xiao-radio/meshcore-uart1.patch), builds the
`Xiao_nrf52_companion_radio_serial` environment, and converts the result into
`meshcore-xiao-radio.uf2` beside the script. The `.uf2` is a build product and is not
committed.
The patch is two hunks, and both are required:
- **Teach the existing companion-over-UART path to build on nRF52.** MeshCore has had the
`SERIAL_RX`/`SERIAL_TX` companion interface and per-board `*_companion_radio_serial`
environments for a while — the ESP32-S3 twin of this board uses the *same* D6/D7 pads —
but the shared code declares `HardwareSerial companion_serial(1)`, and on the Adafruit
nRF52 core `HardwareSerial` is abstract with no numbered constructor. `Serial1` is the
concrete `Uart` that core always defines and `Uart::setPins(rx, tx)` takes the same
arguments in the same order, so one reference fixes the build and the call site is
untouched.
- **Move I²C off D6/D7.** The base `Xiao_nrf52` environment maps the I²C bus onto exactly
those two pads, and both `XiaoNrf52Board::begin()` and `sensors.begin()` start `Wire` on
them — so the I²C peripheral seizes the UART pins and the link stays dead with perfect
wiring and a correct build. The new environment moves I²C to the internal IMU pins 16 and
17. This was the single reason the link stayed silent through every other fix.
Both changes are upstream-shaped and have been submitted to MeshCore. **They are not merged
yet**: as of 2026-09-12, upstream `dev` still carries the bare `HardwareSerial` declaration
with no nRF52 branch, and `variants/xiao_nrf52/platformio.ini` still has no
`companion_radio_serial` environment. So the patch step stays for now, and when it lands the
patch and the `git apply` line can both be deleted. To track upstream instead of the pin,
run with `MESHCORE_COMMIT=dev`; if the patch no longer applies, its two hunks are small
enough to re-apply by hand. Only the pinned commit is known to build.
### Flash the XIAO
Plug the **XIAO's own USB-C** into your computer — it can stay wired to the Lyra — and:
```bash
python flash.py
```
It pulses the application port at 1200 baud, the Arduino-style touch that drops the board
into its bootloader, then looks for a UF2 drive and copies the firmware onto it. It only
ever touches Seeed's USB vendor id, never Adafruit's, so another nRF52840 board on the same
bench is left alone. If the touch doesn't take, **double-tap the XIAO's reset button** and
run it again.
Two things it refuses to guess. A drive letter is not an identity — when one board leaves
the USB bus another can inherit its letter — so it reads `INFO_UF2.TXT` and declines a
volume whose model is not a XIAO, and declines **SoftDevice S140 6.1.1**, which wants the
application at `0x26000` while this firmware links for S140 v7 at `0x27000` (it would flash
and then not boot). `--force` overrides both, if you know better than it does.
**If no UF2 drive appears at all, that is not necessarily a fault.** On the bench where this
was developed the bootloader came up as a serial port with no mass-storage interface
whatsoever, so there was nothing to copy a file onto. `flash.py` recognises the bootloader's
serial port — on the plain XIAO and on the Sense — and prints the serial-DFU command to run
from your MeshCore checkout instead:
```bash
pio run -e Xiao_nrf52_companion_radio_serial -t upload --upload-port <bootloader port>
```
That is the route that worked: it 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 now lives on D6/D7, not on USB. That silence is correct.
### Set up the Lyra
Copy `scripts/` to the Lyra and, as root:
```bash
sh scripts/xiao-radio/lyra-setup.sh # MT_USER=meshterm by default
```
It does three things. It installs [`uart1-mux.py`](../scripts/xiao-radio/uart1-mux.py) as
`/usr/local/bin/uart1-radio-mux.py` behind a oneshot `uart1-radio-mux.service` that runs on
every boot; it adds `$MT_USER` to `dialout` (a fresh login is needed before that counts);
and, **only if the file is absent**, it writes a MeshTerm profile:
```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` is already there it prints those lines for you to paste rather than
touching your configuration. Because `default_profile` is set, a bare `meshterm` connects
straight to the radio.
The mux service is the interesting half. **Calculinux enables the UART1 controller but wires
it to no pad**, which is why `/dev/ttyS1` exists and yet nothing ever comes out of it. The
RK3506's matrix IO can carry UART1 on almost any pad, so `uart1-mux.py` makes three 32-bit
writes through a one-page `/dev/mem` mapping: two RMIO signal-select words that put UART1-TX
on `gpio0-0` and UART1-RX on `gpio0-1`, and one PMU IOC word that switches those two pins
into matrix mode. The writes are idempotent, which is why a oneshot at boot is enough, and
they need root and a kernel that permits `/dev/mem` writes to that range. Choosing those two
pads is what saves the front SD-card slot: UART1's *default* pads belong to SPI1, which
drives the slot, so the mux avoids them entirely.
> The register addresses, the signal ids and the claim that `gpio0-0`/`gpio0-1` are header
> GP4/GP5 come from the maintainer's live probing of one board. They are **not verified
> against an RK3506 reference manual**, and neither is the assumption that this kernel allows
> those `/dev/mem` writes. See [What is verified and what is
> not](#what-is-verified-and-what-is-not).
### Run it, and check the link
```bash
meshterm
```
Your node should come up with whatever name the firmware adverts — the device page and the
dashboard header both show it. If you want to prove the wire itself before blaming anything
else, route the mux, open `/dev/ttyS1` at 115200 as root and send an `APP_START` frame
(`3c 0e 00 01`, seven zero bytes, then a name); a live radio answers with a framed `05`,
`SELF_INFO`. The frame's command byte and reply code are the companion protocol's; the
`<`/`>` plus little-endian length framing around them is from the firmware and is worth
confirming against it rather than taking from here.
### Troubleshooting
| Symptom | Check |
| --- | --- |
| `meshterm` can't open the port | Is `MT_USER` in `dialout`? (`groups`) — it needs a fresh login after setup. |
| Port opens but nothing is there | Re-flash with the **`_serial`** environment, not `_ble` or `_usb`; only that one defines `SERIAL_RX`/`SERIAL_TX`. The XIAO's USB serial should be *silent* to companion frames. |
| Still silent, wiring confirmed | Confirm the firmware carries the **I²C remap** (`PIN_WIRE_SCL=16`, `PIN_WIRE_SDA=17`). This is the failure that looks like bad wiring and isn't. |
| Flashed fine, board never comes back | Wrong board, or the wrong SoftDevice — `INFO_UF2.TXT` must say a XIAO and **S140 v7**, not 6.1.1. `flash.py` refuses both without `--force`. |
| `flash.py` finds no UF2 drive | Expected on a CDC-only bootloader. Use the `pio … -t upload --upload-port` line it prints. |
| Nothing on `/dev/ttyS1` after a reboot | `systemctl status uart1-radio-mux` first, then `journalctl -b -u uart1-radio-mux` — the poke must run every boot, and a oneshot that failed says why. |
| MeshTerm says the radio is unplugged | Use a MeshTerm build with the soldered-UART liveness fix: pyserial's port enumeration doesn't list platform-bus ports like `/dev/ttyS1`, so liveness stats the device node instead. It is in this repo. |
---
## The SPI radio bridge
On some boards the LoRa chip hangs straight off the host's SPI bus — the ClockworkPi
uConsole with the hackergadgets AIO, Waveshare LoRa HATs, and similar. There is **no
companion microcontroller and no firmware to flash**, so there is no node for MeshTerm to
talk to: the mesh node itself has to run in software on the host, through the `openhop_core`
library — `pymc_core` until its 2026 rename, and on the uConsole it arrives with the
`meshcore-uconsole` package, alongside its own `meshcore-console` GUI.
[`scripts/meshterm-spi-bridge`](../scripts/meshterm-spi-bridge) fills that gap. It runs the
software node and puts the **standard companion protocol in front of it on a local TCP
port**, so
```bash
meshterm --tcp 127.0.0.1:5000
```
connects exactly as if a companion were plugged in. One file is both halves: a setup menu
that runs under any Python 3, and the bridge process itself, which the menu re-execs under
whichever interpreter has the radio library. That split is deliberate — the library lives in
a packaged virtual environment, and you should never need to know that.
It runs on the radio host as **your normal user, never root**: the node identity and the
service are per-user.
### Before you start: which runtime you have
The library was renamed in 2026: the `pyMC_core` project became `openhop_core`, PyPI's
`pymc-core` stopped at 1.0.12, and `openhop-core` carries on from 1.1.x. The bridge drives
**either**, and prefers the newer one when both are installed. Its preflight names the
runtime it found beside the interpreter it will run under:
```
[ ok ] node runtime /home/you/.local/share/meshterm-spi-bridge/venv/bin/python (openhop_core)
```
Under `openhop_core` two of the bridge's three compatibility shims stand down, because the
library now does their work itself — it accepts the longer ACK payloads modern firmware
sends, and it pushes a completed trace reply to the client. The bridge logs each as
`not needed` at startup. The third, the raw RX-log push that feeds MeshTerm's live feed, is
still the bridge's, and still logs `compat:` when it is wired.
**Which runtime you get depends on your Debian release.** The `meshcore-uconsole` package
from 1.12.0 is built for *trixie* against Python 3.13; on a *bookworm* uConsole (Python
3.11) that package will not run — its virtual environment points at an interpreter the
system does not have — and apt will offer it anyway. Do not take the upgrade. Give the
bridge a runtime of its own instead, in the directory the preflight looks in first:
```bash
python3 -m venv ~/.local/share/meshterm-spi-bridge/venv
~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]"
```
The GUI keeps its own stack, and the bridge still loads the GUI's identity key from
`~/.local/share/meshcore-uconsole/identity.key`, so it is the same node either way. Without
the `meshcore_console` helpers in that venv the bridge configures the radio itself, with
the uConsole AIO preset (SPI bus 1, reset 25, busy 24, IRQ 26, no TX-enable pin, the DIO2
RF switch and the DIO3 TCXO on, 910.525 MHz) and every `MESHCORE_*` override the GUI
honours — see [Hardware knobs](#hardware-knobs). Verified on a bookworm uConsole with
`openhop-core` 1.1.3: the radio initialises, the contacts come back, and MeshTerm reads
`info` and `contacts` over the TCP port. On a pre-rename install — `pymc-core` 1.0.x —
everything below applies as written, all three shims included.
### Setting it up
```bash
./meshterm-spi-bridge # the interactive setup menu
./meshterm-spi-bridge --check # run the prerequisite checks and exit
./meshterm-spi-bridge --run # run the bridge now, in this terminal
```
The preflight is worth running first. It finds the interpreter that has the radio library,
checks that a `/dev/spidev*` node and `/dev/gpiochip0` are readable and writable by you —
the uConsole needs `dtoverlay=spi1-1cs` in `/boot/firmware/config.txt` for the first, and
membership of the `spi` and `gpio` groups for both — and tells you if the GUI is holding the
radio or if something is already listening on the port.
The menu then offers to **run the bridge in the foreground** (Ctrl+C returns to the menu),
to **install it as a service**, to **uninstall** it, or to show its status. Installing copies
the script to `~/.local/bin/` and writes a `systemd --user` unit that restarts on failure and
starts with your session; it also asks systemd to enable lingering for your user, which is
what makes the service come up at boot rather than at your first login, and prints the `sudo`
command if it cannot. Either way it offers to add a MeshTerm profile for you:
```toml
[profiles.bridge]
host = "127.0.0.1"
tcp_port = 5000
description = "Local SPI radio via meshterm-spi-bridge"
```
It sets no `default_profile`, so a bare `meshterm` offers the bridge in the picker rather
than connecting to it behind your back. Uninstalling removes the service and the copied
script and nothing else: it never touches the `meshcore-console` or radio-library packages,
and it deliberately leaves your identity and contact files, and the lingering setting, where
they are.
### Identity, and one program at a time
**Only one program may use the radio at a time.** Run *either* the `meshcore-console` GUI or
this bridge, never both — the preflight warns you when the GUI is up, and refuses to start a
second bridge over a running service.
Which node you are depends on what is installed. Where the `meshcore-console` stack is
present the bridge loads that stack's identity key, so **MeshTerm is the same node as the
GUI** and your contacts and your public key follow you between them. On a library-only
install it mints its own key the first time and persists it under
`~/.local/share/meshterm-spi-bridge/`, so the node is stable across restarts. Contacts are
snapshotted to a file in that same directory on every change, because the library's contact
store is memory-only — without that, direct messages to a contact you added before a restart
came back "not found".
### Hardware knobs
The defaults match the hackergadgets uConsole AIO: SPI bus 1, chip-select 0, reset on 25,
busy on 24, IRQ on 26, 910.525 MHz, 22 dBm, SF7, 62.5 kHz, coding rate 4/5. For another SPI
board, export what differs before launching.
| Variable | What it sets |
| --- | --- |
| `MESHCORE_BUS_ID`, `MESHCORE_CS_ID`, `MESHCORE_CS_PIN` | which SPI bus, device and chip-select pin the radio is on |
| `MESHCORE_RESET_PIN`, `MESHCORE_BUSY_PIN`, `MESHCORE_IRQ_PIN` | the three control lines |
| `MESHCORE_FREQUENCY`, `MESHCORE_TX_POWER` | frequency in Hz, power in dBm |
| `MESHCORE_SPREADING_FACTOR`, `MESHCORE_BANDWIDTH`, `MESHCORE_CODING_RATE` | the modem preset — all three must match the mesh you are joining |
| `MESHCORE_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 uConsole), and the Waveshare HAT's own wiring |
| `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 direct path 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. Under the
`meshcore_console` helpers the GUI's own configuration reads the same names.
Where the `meshcore-console` stack is installed, the bridge builds the radio through *its*
hardware configuration, which reads a longer list of variables — transmit/receive enable
pins, the GPIO chip, the Waveshare and DIO options, and the enable pins an AIO v2 needs to
power its radio at all. Those come from that package's documentation. On a library-only
install the table above is the whole of it, so an AIO v2 without that package present may
not power its radio.
### What works through the bridge
The bridge exposes the companion protocol as the radio library implements it, plus two
pushes of its own. In MeshTerm's terms: messaging, contacts, channels, adverts, device
configuration, traces and the live packet feed all work. Three things do not, because the
library has no handler for them — **reboot and factory reset have no effect, and a device
PIN cannot be set**. MeshTerm reports the device model as `pyMC-spi-bridge`, which is a
useful way to tell a bridged node from a real companion in the logs.
Traces and the live feed reach MeshTerm through compatibility shims the bridge installs on
the library: it subscribes to raw packets to feed the live feed, reassembles a completed
trace reply and pushes it, and relaxes an acknowledgement length check that newer firmware
trips by appending bytes after the CRC. None of that is MeshTerm's protocol being bent —
`pymc_core` simply never registered those two pushes. `openhop_core` has since taken the
ACK relaxation and the trace push upstream, and under it the bridge installs only the raw
RX-log shim, logging the other two as `not needed`. Their **on-air behaviour was validated
on one bench**, against the radios that were in range of it.
---
## The unfinished UART-to-TCP pump
[`calculinux-radio-bridge.sh`](../scripts/calculinux-radio-bridge.sh) is a work in progress
and is not a supported route. It was written to put the UART-attached XIAO radio on a TCP
port so other machines could reach it, before the direct-serial path above existed, and it
is superseded by `lyra-setup.sh` for on-device use. As it stands its own header describes a
pin-routing phase that the body does not contain, on a theory of which pads carry UART1 that
`uart1-mux.py` later replaced; it binds to every interface by default, unauthenticated; and
it writes a competing default profile into the same `config.toml` that `lyra-setup.sh`
writes. Read it if you want the shape of a UART-to-TCP pump — a single-client pump is the
right shape, since companion frames from two clients would interleave — but don't run it
expecting a finished tool.
---
## What is verified and what is not
Most of what this manual explains is checked in code: the platform spec, the font and
palette contracts (a test parses the font script itself and pins it to MeshTerm's glyph
inventory), the profile keys the scripts write, the soldered-UART liveness fix, and every
interface the SPI bridge calls in `pymc_core` 1.0.12 and in `openhop_core` 1.1.3 (the
latter on a live uConsole: radio up, contacts restored, `info` and `contacts` answered over
TCP). The upstream firmware state is checked
too: the pinned MeshCore commit exists, the patch's two hunks are still needed on `dev`
today, and the nRF52840 UF2 family id and both XIAO bootloader ids match Adafruit's board
files.
The rest is one maintainer's bench, reported honestly rather than dressed up. None of the
following is confirmable from code in this repository, 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. 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 does run at every boot.
- **The PicoCalc header pin numbers** 6, 7, 8 and 36 for GP4, GP5, GND and 3V3 on the Lyra
carrier. The Pico-side numbering is right; the carrier's wiring is a bench observation.
- **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 rtl8xxxu
cold-boot race with iwd, the opkg feed's occasional 404, the absolute `ip` and `iw` paths,
that the stock Terminus console font is installed, and that `kbd` brings `setvtrgb`.
- **That this board has no RTC.** Only the script says so, and everything about the clock
phase follows from it.
- **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.** It is unmerged; only
the pinned commit is known to build.
- **MeshCore's serial framing** around the raw `APP_START` check above. The command byte and
the `SELF_INFO` reply code are verified against the protocol documentation; the framing
bytes are not.
- **Everything on-air about the SPI bridge** — the per-hop trace semantics it reassembles and
the acknowledgement bytes "newer firmware" appends. The code is internally consistent with
the library's API; the wire behaviour was seen on one mesh.
- **The bridge under `openhop_core` beyond `info` and `contacts`.** Startup, identity,
contact restore and those two reads were exercised on a uConsole; 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.
- **The framebuffer console's 512-glyph cap.** Well known, taken as given, not re-measured
here.
If you have the hardware and any of it is wrong on your bench, that is worth an issue — the
scripts encode a set of discoveries, and a second device is the only way to learn which of
them were about the hardware and which were about one unit.
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.
+914
View File
@@ -0,0 +1,914 @@
# 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**. See the note below — this is the one part of the list where the wrong SKU won't work. |
| microSD card | 16 GB+, Class 10 or better | $6–10 | — | The PicoCalc's stock 32 GB card works fine — it's more than the 8 GB minimum. You can reuse it instead of buying a new one. |
| Two 18650 batteries | e.g. Samsung 30Q, Molicel P26A, Sony VTC6 (examples, not endorsements) | $10–20 | — | **Unprotected**, flat-top or button-top, Ø18 × 65–69 mm. See [the battery note](#a-note-on-batteries) below. |
| 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 two boards ($89 + ~$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 two
18650 batteries, 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 the latest `calculinux-image-luckfox-lyra.rootfs.wic.gz` from the
[Calculinux releases page](https://github.com/Calculinux/meta-calculinux/releases).
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.
### 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. Three ways to get it there, in order of ease:
- **`scp` from your PC**, once Wi-Fi is up. Find the Lyra's IP address with `ip addr show
wlan0` on the device, then from your PC:
```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/
```
- **The microSD card.** Pull the card, copy `scripts/` onto it from your PC's card reader,
put it back.
- **`git clone` on the device.** Only once `git` is installed, and the setup script is what
installs it — so this is a route for later updates, not the first copy.
`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. Two things it can't figure
out on its own, both covered by environment variables read at the top of the script:
| Knob | Default | Effect |
| --- | --- | --- |
| `DEPLOY_USER` | `meshterm` | the login MeshTerm runs under |
| `TIMEZONE` | `America/Toronto` | any IANA zone |
| `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 |
You don't need to set any of these for a normal install — the defaults clone MeshTerm's
public repository over plain HTTPS, which needs no credential at all.
What it does, one line per phase:
- **opkg packages** — installs `python3-modules`, `python3-pip`, `git`, `kbd`, because
Calculinux ships a stripped Python missing `pip`, `venv` and several stdlib modules.
- **Wi-Fi kick** — installs a boot-time service that re-scans until the dongle joins the
network `iwd` already knows, because on a cold boot the dongle's firmware finishes
loading after `iwd`'s first scan and the link would otherwise never come up.
- **Time sync** — installs a boot-time service that sets the clock over the network, since
this board has no battery-backed real-time clock.
- **Timezone** — sets `$TIMEZONE` (Eastern by default).
- **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 takes about six minutes** to get past the
> Wi-Fi kick, which keeps trying before it gives up. Nothing is broken. If the device is
> going to live offline, see [Day-to-day](#day-to-day) for how to switch the kick off.
### 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 rows) and a 6×8 one (53×40 rows,
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 prints those lines instead of touching your file —
paste them in 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
```
Because `default_profile` is set, a bare `meshterm` connects straight to the radio. 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.** Either re-run the setup script from root (it pulls the repository,
reinstalls, and rebuilds the console font):
```bash
# on the PicoCalc, as root
sh calculinux-setup.sh
```
or, as the `meshterm` user, do just the update:
```bash
# on the PicoCalc, as meshterm
cd ~/MeshTerm && git pull && .venv/bin/pip install -e .
```
If an update adds new glyphs to the console font, rebuild it on its own afterwards:
```bash
# on the PicoCalc, as root
sh calculinux-console-font-6x12.sh
```
**Changing Wi-Fi.** Run `uwific` as root, as in [Step 7](#step-7--join-wi-fi). The boot-time
kick picks up the new network on its own; it only needs `iwd` to know it.
**The six-minute boot without a dongle.** If the device is going to run permanently offline,
the Wi-Fi kick is wasted time on every boot. Disable it:
```bash
# on the PicoCalc, as root
systemctl disable --now wifi-kick.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. Some of it is also checked in code: a test parses the font script and pins it to
MeshTerm's glyph inventory, the profile keys the scripts write are tested, the pinned
MeshCore commit exists, the patch's two hunks are still needed on `dev`, and the nRF52840
UF2 family id and both XIAO bootloader ids match Adafruit's board files.
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

+395
View File
@@ -0,0 +1,395 @@
# 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 |
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 from
[the latest release page](https://github.com/jpmartineau/MeshTerm/releases/latest), 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 `meshcore-uconsole` installed from the vendor's package: you already
have a working runtime (Python 3.13-based). Skip to [Step 3](#step-3--get-the-bridge).
- **bookworm**: the `meshcore-uconsole` 1.12.0+ package is built for Python 3.13 and will
**not run** — its virtualenv points at an interpreter bookworm doesn't have. Don't take
the upgrade if apt offers it. Instead give the bridge a runtime of its own:
```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, prints it, then 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.
Leave it running, and in another terminal on the same machine try:
```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
chmod +x meshterm-*-linux-arm64
sudo mv meshterm-*-linux-arm64 /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 6, your `config.toml` now has:
```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 the device page or dashboard shows a node name (`uConsole` by
default) instead of a connection error. MeshTerm reports the device model as
`pyMC-spi-bridge` — that's how you tell a bridged node from a real companion.
**Identity.** If the `meshcore-console` GUI's package is present, the bridge loads *that*
package's identity key, so MeshTerm and the GUI are the same node — same contacts, same
public key. On a library-only install, the bridge mints and keeps its own identity key
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/`).
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 defaults match the hackergadgets uConsole AIO, so a stock AIO needs none of these. They
exist for a revision or a board that differs: export what differs before launching the
bridge, or put the lines in the service's environment.
| Variable | What it sets |
| --- | --- |
| `MESHCORE_BUS_ID`, `MESHCORE_CS_ID`, `MESHCORE_CS_PIN` | which SPI bus, device and chip-select pin the radio is on |
| `MESHCORE_RESET_PIN`, `MESHCORE_BUSY_PIN`, `MESHCORE_IRQ_PIN` | the three control lines |
| `MESHCORE_FREQUENCY`, `MESHCORE_TX_POWER` | frequency in Hz, power in dBm |
| `MESHCORE_SPREADING_FACTOR`, `MESHCORE_BANDWIDTH`, `MESHCORE_CODING_RATE` | the modem preset — all three must match the mesh you are joining |
| `MESHCORE_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 *its* hardware
configuration, which reads the same names plus a few more of its own — see that package's
documentation. On a library-only install the table above is the whole of it, so an AIO v2
without that package present may not power its radio.
---
## 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 else is already listening there — check `systemctl --user status meshterm-spi-bridge.service` for an already-running instance. |
| Bridge starts but `radio.begin()` fails after 4 attempts | GPIO lines may still be held by a session that just closed — wait a few seconds and try again. |
| MeshTerm connects but direct messages to an old contact say "not found" | Restart the bridge — contacts are restored from its snapshot file at startup; if the snapshot is missing, the contact needs to be heard again. |
| 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.
Every interface the bridge calls in both runtimes is checked by MeshTerm's test suite.
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.
+1 -1
View File
@@ -6,7 +6,7 @@ Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Lin
from datetime import datetime
__version__ = "0.3.4"
__version__ = "0.3.7"
__author__ = "Jean-Pierre Martineau"
#: The year MeshTerm was first published; the copyright span starts here.
+1 -1
View File
@@ -4,7 +4,7 @@
## 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 **MeshCore** client that lives in your terminal. Plug in a companion device over USB, pair one over Bluetooth, or reach one over TCP. Windows, macOS, and Linux. Free and open-source.
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!
+2 -2
View File
@@ -22,10 +22,10 @@ https://buymeacoffee.com/johnputer
### 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
+35 -6
View File
@@ -38,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
@@ -155,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)
@@ -358,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.
@@ -515,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
@@ -549,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(
@@ -568,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]:
@@ -656,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).
@@ -792,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:
+181 -12
View File
@@ -145,6 +145,16 @@ _BLE_CONNECT_ATTEMPTS = 2
#: Pause between BLE link-open attempts (seconds), giving the OS radio a beat to settle.
_BLE_CONNECT_RETRY_DELAY_S = 1.0
#: Attempts, and the wait between them, while macOS finishes a pairing it has just begun.
#: An unbonded subscribe to the companion's authenticated characteristic is what *triggers*
#: Passkey Entry there, so the very failure we catch is the signal that the OS has raised
#: its dialog — and the person now has to read a 6-digit code off the device and type it.
#: Giving up at once meant a correct PIN produced an error and only the *second* attempt
#: worked, the first having quietly done the bonding. Sized to cover a human typing a code
#: rather than a radio settling, and bounded so a cancelled dialog still fails in the end.
_BLE_MACOS_PAIRING_ATTEMPTS = 5
_BLE_MACOS_PAIRING_DELAY_S = 6.0
TX_POWER_MIN = 1
TX_POWER_MAX = 22
@@ -1224,8 +1234,55 @@ class MeshCoreDevice(Device):
# Clear it, pair with the PIN, and retry the connect exactly once before giving up.
if allow_repair and await self._pair_ble_windows(force=True):
return await self._open_ble(mesh_core, allow_repair=False)
# On macOS the same rejection means the opposite thing: it is not the end of a
# pairing attempt but the *start* of one, because touching the authenticated
# characteristic is the only way to make CoreBluetooth pair at all. The OS is
# putting its Passkey dialog up as we unwind. Wait for the person to answer it.
if allow_repair and sys.platform == "darwin":
return await self._open_ble_after_macos_pairing(mesh_core, exc)
raise DeviceAuthenticationError(self._ble_auth_message()) from exc
async def _open_ble_after_macos_pairing(self, mesh_core, cause: BaseException): # type: ignore[no-untyped-def]
"""Re-open the link while macOS is running the Passkey dialog it just raised.
CoreBluetooth exposes no pairing API — Apple's model is that a peripheral pairs
*implicitly* when something touches a characteristic that requires encryption. The
companion firmware puts its UART characteristic at ENC+MITM precisely so that
happens (``SECMODE_ENC_WITH_MITM`` on nRF52, ``ESP_GATT_PERM_*_ENC_MITM`` on
ESP32), so the subscribe fails with "Insufficient Authentication" *and* that
failure is what makes macOS ask for the code. The GATT operation is already dead by
then; the bond it started arrives seconds later, once a human has typed six digits.
Failing there reported an error for a pairing that was in fact succeeding, and the
device connected on the next attempt — the first having silently done the work. So
retry rather than give up, for long enough to cover the typing.
Args:
mesh_core: The imported ``meshcore.MeshCore`` class.
cause: The authentication failure that opened the dialog, chained onto the
final error if the pairing never completes.
Returns:
The connected ``MeshCore`` client, or ``None`` if a later attempt opened the
transport but the peripheral never answered the identity handshake.
Raises:
DeviceAuthenticationError: If every attempt was still refused — the dialog was
dismissed, or the code entered was wrong.
"""
for attempt in range(_BLE_MACOS_PAIRING_ATTEMPTS):
await asyncio.sleep(_BLE_MACOS_PAIRING_DELAY_S)
try:
return await self._open_ble(mesh_core, allow_repair=False)
except DeviceAuthenticationError:
_log.debug(
"BLE bond with %s not established yet (attempt %d/%d)",
self._address,
attempt + 1,
_BLE_MACOS_PAIRING_ATTEMPTS,
)
raise DeviceAuthenticationError(self._ble_auth_message()) from cause
async def _create_ble_with_retry(self, mesh_core): # type: ignore[no-untyped-def]
"""Open the owned BLE client, retrying the transport-level failures that are transient.
@@ -1305,7 +1362,20 @@ class MeshCoreDevice(Device):
"""
from meshcore import BLEConnection
connection = BLEConnection(address=self._address, device=self._ble_device, pin=self._pin)
# Withhold the PIN on macOS. Handed one, ``BLEConnection.connect`` calls bleak's
# ``client.pair()``, and CoreBluetooth has no pairing API at all — the macOS
# backend raises ``NotImplementedError`` outright — whereupon the library
# disconnects and re-raises, so supplying a *correct* PIN is what breaks the
# connection. Apple's model is that pairing is the OS's to run, not ours: the
# companion firmware puts its UART characteristic at ENC+MITM
# (``SECMODE_ENC_WITH_MITM`` on nRF52, ``ESP_GATT_PERM_*_ENC_MITM`` on ESP32), so
# the unbonded subscribe below is answered with "Insufficient Authentication",
# and macOS reacts by running Passkey Entry and prompting for the code itself.
# Saying nothing here is therefore what *lets* a PIN-protected companion bond;
# the OS keeps the bond, and later connections need no PIN. Windows and Linux do
# expose explicit pairing, and keep it (see :meth:`_pair_ble_windows`).
pin = None if sys.platform == "darwin" else self._pin
connection = BLEConnection(address=self._address, device=self._ble_device, pin=pin)
mc = mesh_core(
connection,
default_timeout=self._connect_timeout,
@@ -1632,6 +1702,16 @@ class MeshCoreDevice(Device):
before an authenticated characteristic can be subscribed.
"""
where = self._address or "the selected Bluetooth device"
if sys.platform == "darwin":
# macOS collects the code itself, in its own dialog, so --ble-pin is not the
# remedy here and naming it would send the reader somewhere that cannot help.
return (
f"{where} was not paired. macOS asks for the pairing code in its own dialog "
"rather than through MeshTerm — enter the 6-digit code shown on the device "
"(or in the MeshCore app) when it appears, and the bond is remembered for "
"next time. If no dialog appeared, check that Bluetooth is allowed for this "
"terminal in System Settings > Privacy & Security > Bluetooth."
)
if self._pin:
return (
f"{where} rejected the Bluetooth PIN — it needs pairing and the PIN provided "
@@ -1786,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).
@@ -1805,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(
@@ -1820,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)
+45 -80
View File
@@ -4,7 +4,7 @@
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.
@@ -18,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
@@ -35,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
@@ -58,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"
@@ -67,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",
@@ -179,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
@@ -194,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
@@ -221,7 +239,6 @@ class ScoredContact:
score: float
percentile: int
protection: str | None = None
reasons: tuple[str, ...] = ()
@property
def protected(self) -> bool:
@@ -375,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,
@@ -395,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")
@@ -449,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],
@@ -550,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 = (
@@ -562,7 +531,6 @@ def rank_contacts(
+ terms["distance"] * weights.distance
+ term_grace(sig, weights)
)
per_contact_terms.append(terms)
scores.append(total)
ranked = [
@@ -572,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
@@ -591,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.
+56 -21
View File
@@ -34,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.
@@ -47,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
@@ -97,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
@@ -106,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:
@@ -219,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:
@@ -256,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
@@ -295,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)
@@ -350,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
@@ -369,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,
)
+39
View File
@@ -53,6 +53,32 @@ BLE_NAME_PREFIX = "MeshCore"
#: advertisement, short enough not to stall the startup splash noticeably.
BLE_SCAN_TIMEOUT_S = 5.0
try: # bleak >= 3 only; the floor in pyproject is 0.22, so this must not be assumed
from bleak.exc import BleakBluetoothNotAvailableError as _BleakNotAvailable
_BLE_NOT_AVAILABLE: tuple[type[BaseException], ...] = (_BleakNotAvailable,)
except Exception: # noqa: BLE001 - older bleak, or bleak absent entirely
# An empty tuple in ``except`` matches nothing, so older installs fall straight through
# to the broad handler and behave exactly as they did before.
_BLE_NOT_AVAILABLE = ()
#: Why the last BLE scan could not run, when the cause is something the reader can act on
#: (Bluetooth off; on macOS, permission not granted to the terminal running us). ``None``
#: when the scan worked — including when it simply heard nothing, which is not a fault.
_ble_unavailable: str | None = None
def ble_unavailable_reason() -> str | None:
"""Why the last BLE scan was refused, if it was, as a sentence fit to show the reader.
Returns:
The reason the most recent :func:`discover_ble_devices` could not scan, or ``None``
if it ran — whether or not it found anything. Reset at the start of every scan, so
it always describes the latest attempt rather than a stale one.
"""
return _ble_unavailable
#: Default seconds to watch for one *known* companion's advertisement while waiting for it to
#: come back (see :func:`find_ble_device`). Deliberately longer than the startup scan: nothing
#: is waiting on it, the watch ends the instant the device is heard, and a companion that just
@@ -393,6 +419,9 @@ async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[Disc
Returns:
Discovered BLE companions (deduplicated by address), sorted by name.
"""
global _ble_unavailable
_ble_unavailable = None
try:
from bleak import BleakScanner
except ImportError: # bleak not installed → BLE simply unavailable
@@ -400,6 +429,16 @@ async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[Disc
try:
found = await BleakScanner.discover(timeout=timeout, return_adv=True)
except _BLE_NOT_AVAILABLE as exc:
# A *state the reader can fix* — Bluetooth switched off, or (on macOS, routinely)
# the terminal running us has not been granted Bluetooth. That is not the "no
# adapter / driver hiccup" the broad catch below is for, and returning [] silently
# made it indistinguishable from "nothing is nearby": the one message that would
# have told the user what to do was thrown away at debug level, under a log that
# defaults to WARNING. Keep the sentence bleak wrote — it already names the remedy.
_ble_unavailable = str(getattr(exc, "args", [None])[0] or exc)
_log.warning("BLE unavailable: %s", _ble_unavailable)
return []
except Exception as exc: # noqa: BLE001 - no adapter / OS Bluetooth off / driver hiccup
_log.debug("BLE scan unavailable: %s", exc)
return []
+20 -7
View File
@@ -57,11 +57,12 @@ 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",
@@ -178,9 +179,9 @@ _CONSOLE_SETUP_CHOICES: dict[str, str] = {
"off": "Leave it alone",
}
#: The two console fonts ``scripts/calculinux-console-font.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.
#: 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
@@ -205,6 +206,18 @@ _LOG_LEVEL_CHOICES: dict[str, str] = {
#: 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",
+144
View File
@@ -28,6 +28,13 @@ three of its shapes only make sense against that source:
``af`` both read and write ``airtime_factor``, so writing one stales the other
(:attr:`RemoteSetting.overlaps`).
The catalog is not the whole page, though: it is what *any* MeshCore build might expose,
and a node can have settings beyond it (a third-party firmware's own knobs). Those are never
listed here and never probed — they are learned one node at a time from commands the reader
actually ran (:func:`parse_setting_command`, :func:`discovered_setting`), which is why
:attr:`RemoteSetting.discovered` exists and why :func:`learnable_from_get` has to care that
``get`` matches keys by prefix and ``set`` does not.
Replies are parsed *loosely* on purpose: repeater firmware answers tersely and has changed
its phrasing across versions (``"> 20"``, ``"tx: 20"``, ``"OK - repeat is now ON"``), so
values are extracted rather than pattern-matched. Firmware without a given key answers
@@ -118,6 +125,9 @@ class RemoteSetting:
part: This setting's position within its composite's comma-joined value.
overlaps: Keys that are another spelling of the same firmware value, whose cached
reading a write to this one makes stale.
discovered: Whether this setting is not in the catalog at all, but was learned from
a command the reader ran on *one node's* command line (see
:func:`discovered_setting`). Never true of a catalog entry.
"""
key: str
@@ -139,6 +149,7 @@ class RemoteSetting:
composite: str = ""
part: int = 0
overlaps: tuple[str, ...] = ()
discovered: bool = False
@property
def get_command(self) -> str:
@@ -671,6 +682,134 @@ def composite_members(composite: str) -> list[RemoteSetting]:
return sorted((s for s in REPEATER_SETTINGS if s.composite == composite), key=lambda s: s.part)
#: The category a discovered setting sorts under, and the heading its section takes.
DISCOVERED_CATEGORY = "Extra"
#: The description lane's text for a discovered setting. The catalog has no explanation to
#: give — all it knows is that this node answered the key — so the row says exactly that, and
#: says it short: the lane is shared with descriptions written to fit a 72-column screen.
DISCOVERED_HELP = "Found on this node"
#: Keys whose value is never written to disk, however it was asked for. ``prv.key`` is the
#: node's own identity: the firmware answers it on the serial console only (``sender_timestamp
#: == 0`` in ``handleGetCmd``), it is a fact rather than a setting, and a reply that reached
#: us anyway must not be the first place a repeater's private key lands in a file. The
#: command line still runs the command and still prints the answer; only the remembering
#: stops here.
NEVER_STORED = frozenset({"prv.key"})
@dataclass(frozen=True, slots=True)
class SettingCommand:
"""A remote-CLI command recognised as a read or a write of one named setting.
Attributes:
verb: ``"get"`` or ``"set"``.
key: The setting's key, as the reader spelled it.
value: What a ``set`` writes; ``""`` for a ``get``.
"""
verb: str
key: str
value: str = ""
def parse_setting_command(command: str) -> SettingCommand | None:
"""Read one typed CLI command as a setting read/write, or ``None`` if it is neither.
The grammar is the filter, and deliberately so: ``get <key>`` and ``set <key> <value>``
are the firmware's own way of saying *this is a setting*, so recognising them costs no
list of what to exclude. Every top-level verb — ``reboot``, ``advert``, ``clock sync``,
``start ota``, ``powersaving``, ``setperm`` — simply isn't this shape and falls out.
A key is held to the spelling the firmware's own keys use, so a mistyped line can't
invent one.
"""
parts = command.strip().split()
if len(parts) < 2 or parts[0] not in ("get", "set"):
return None
key = parts[1]
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9._]*", key):
return None
if parts[0] == "get":
return SettingCommand("get", key) if len(parts) == 2 else None
value = command.strip()[len("set") :].strip()[len(key) :].strip()
return SettingCommand("set", key, value) if value else None
def get_match_keys() -> list[str]:
"""Every spelling a firmware ``get`` matches on, longest first.
A composite's members share their composite's key (``get radio``); verb-shaped settings
aren't ``get`` keys at all.
"""
keys = {s.composite or s.key for s in REPEATER_SETTINGS if s.readable and not s.verb}
return sorted(keys, key=len, reverse=True)
def shadowing_key(key: str) -> str | None:
"""The catalog ``get`` key that would answer a ``get key`` on firmware lacking ``key``.
``handleGetCmd`` compares with ``memcmp(config, "radio", 5)`` — no trailing space — so a
key it doesn't know falls into the branch of any *shorter* key that prefixes it, and
answers with that one's value. ``get radio.rxps`` on stock firmware comes back
``> 910.525,62.5,7,5``: not an error, and not a value of the key that was asked for.
``handleSetCmd`` matches ``"radio "`` *with* the space, so a write has no such shadow.
"""
return next((k for k in get_match_keys() if key != k and key.startswith(k)), None)
def learnable_from_get(key: str, reply: str, known: Mapping[str, str]) -> bool:
"""Whether a ``get key`` reply really came from ``key`` and not from a shorter sibling.
Only asked of a key the catalog doesn't have, where there is no typing to reject a
wrong-shaped answer with. The reply is trusted when nothing shadows the key
(:func:`shadowing_key`), or when it cannot be the shadow's answer — it doesn't parse as
the shadow's type, or it parses to something other than what the shadow last said. A
shadow whose own value was never read leaves the question unanswerable, and an
unanswerable question is not a discovery.
Args:
key: The key the reader asked for.
reply: What the node answered.
known: The node's last-read values, keyed as the cache keys them.
"""
shadow = shadowing_key(key)
if shadow is None:
return True
members = composite_members(shadow)
if not members:
spec = get_setting(shadow)
members = [spec] if spec is not None else []
if not members: # pragma: no cover - every get key is a catalog key
return True
parsed = {m.key: parse_reply_value(m, reply) for m in members}
if any(value is None for value in parsed.values()):
return True # the reply isn't even shaped like the shadow's answer
if any(known.get(k) is None for k in parsed):
return False # it could be the shadow's answer, and there is nothing to compare
return any(known[k] != value for k, value in parsed.items())
def discovered_setting(key: str) -> RemoteSetting:
"""A catalog entry for a key the catalog hasn't got, which one node said it has.
Everything downstream of the catalog — the read plan, the reply parser, the editor's
lanes, staging, :func:`write_plan` — speaks :class:`RemoteSetting`, so a discovered key
becomes one rather than growing a second path beside it. What it carries is only what
was actually learned: the key as the node spells it, and a string value. No range, no
words, no options — the firmware never said, and guessing is how a row would start
lying about a node whose build we have never seen.
"""
return RemoteSetting(
key=key,
label=key,
help=DISCOVERED_HELP,
category=DISCOVERED_CATEGORY,
kind="str",
discovered=True,
)
def reply_is_error(reply: str) -> bool:
"""Whether a reply text reads as the firmware refusing the command.
@@ -884,6 +1023,11 @@ def write_plan(pending: Mapping[str, str], known: Mapping[str, str]) -> list[Wri
else:
joined = ",".join(str(values[m.key]) for m in members)
writes.append(Write(f"set {spec.composite} {joined}", dict(values))) # type: ignore[arg-type]
# A staged key the catalog hasn't got is a discovered one (nothing else can stage a
# row), and it is written the only way anything knows how: its own plain `set`.
for key, value in pending.items():
if get_setting(key) is None:
writes.append(Write(f"set {key} {value}", {key: value}))
return writes
+22 -2
View File
@@ -35,11 +35,16 @@ class CachedValue:
supported: ``False`` when the node answered the read with an error — its firmware
has no such setting (a board without a front-end module, a build without a
bridge). Kept apart from *never read*, which is no entry at all.
discovered: ``True`` for a setting the catalog hasn't got, learned from a command
the reader ran on *this node's* command line. Such a row reads ``n/a`` like any
other when the node stops answering it, and is removed only by hand: its key is
recorded nowhere else, so a misread reply must not be able to delete it.
"""
value: str
read_at: datetime | None
supported: bool = True
discovered: bool = False
class RemoteStore:
@@ -84,7 +89,10 @@ class RemoteStore:
except ValueError:
read_at = None
out[key] = CachedValue(
value=str(entry.get("value", "")), read_at=read_at, supported=supported
value=str(entry.get("value", "")),
read_at=read_at,
supported=supported,
discovered=bool(entry.get("discovered", False)),
)
return out
@@ -92,6 +100,10 @@ class RemoteStore:
"""Cache one setting's value for ``node``, stamped now."""
self._put(node, key, {"value": value, "read_at": utcnow().isoformat()})
def remember_discovered(self, node: Contact, key: str, value: str) -> None:
"""Cache a setting the catalog hasn't got, which ``node`` has just proved it has."""
self._put(node, key, {"value": value, "read_at": utcnow().isoformat(), "discovered": True})
def remember_unsupported(self, node: Contact, key: str) -> None:
"""Record that ``node`` answered a read of ``key`` with an error, stamped now."""
self._put(node, key, {"unsupported": True, "read_at": utcnow().isoformat()})
@@ -104,10 +116,18 @@ class RemoteStore:
self._write(records)
def _put(self, node: Contact, key: str, entry: dict) -> None:
"""Store one setting's cache entry for ``node``."""
"""Store one setting's cache entry for ``node``, keeping it discovered if it was.
A discovered row is refreshed by the same reads and writes as any other — through
:meth:`remember_setting` — and that must not quietly demote it to a catalog row it
has no entry for, which would leave a row nothing draws.
"""
records = self._load_all()
record = records.setdefault(admin_key(node), {})
settings = record.setdefault("settings", {})
previous = settings.get(key)
if isinstance(previous, dict) and previous.get("discovered"):
entry = {**entry, "discovered": True}
settings[key] = entry
self._write(records)
+39 -4
View File
@@ -112,9 +112,17 @@ def resolve_device(
3. ``explicit_port`` (an explicit ``--port``).
4. A TCP profile's ``host:port``, or a serial profile's ``port``.
5. The remembered "last known good" device, if it is currently attached/in range.
6. The single attached device, if exactly one is present.
6. The single *likely-LoRa* device, if exactly one is present — a port whose USB
vendor marks it a board or bridge, or a BLE/TCP endpoint. Ports that look like
nothing in particular are ignored at this step, because a platform may present
some unconditionally (every Mac carries two) and they would otherwise make the
count ambiguous forever.
7. The single attached device, if exactly one is present and *none* looked likely —
so an unrecognized-but-real adapter still resolves.
Otherwise a :class:`DeviceSelectionError` is raised listing the candidates.
Otherwise a :class:`DeviceSelectionError` is raised listing the candidates: the
likely ones where there are any, else everything attached under a message that does
not claim they are companions.
Args:
devices: Currently discovered devices (serial and/or BLE).
@@ -155,7 +163,23 @@ def resolve_device(
if match is not None:
return Resolution(match.target, match, "remembered", match.transport)
if len(devices) == 1:
# Prefer the devices that actually look like companions. macOS is why this matters:
# every Mac permanently presents /dev/cu.Bluetooth-Incoming-Port and
# /dev/cu.debug-console, which carry no USB VID/PID and so score "unknown". Counting
# them put `len(devices) == 1` out of reach there — a Mac with exactly one real board
# attached saw three devices and refused to choose, so auto-detection could never fire
# on that platform and every Mac user met "Multiple companion devices detected" on a
# first run. Which device is plausible is already known, and already printed in the
# listing below as "[likely LoRa]"; this decides with it instead of only saying it.
likely = [d for d in devices if d.is_likely_lora]
if len(likely) == 1:
return Resolution(likely[0].target, likely[0], "only", likely[0].transport)
# Nothing recognizable: fall back to the whole list rather than narrowing to nothing,
# so a lone *unrecognized* adapter — a UART bridge carrying a VID we don't list, which
# is a real board more often than not — still connects exactly as it always has.
if not likely and len(devices) == 1:
return Resolution(devices[0].target, devices[0], "only", devices[0].transport)
if not devices:
@@ -164,9 +188,20 @@ def resolve_device(
"or pass --port / --ble explicitly, or use --mock for the simulator."
)
if not likely:
# Several ports attached, none of them plausible. "Multiple companion devices" is
# simply false here: on a bare Mac it names two virtual ports that are not
# companions at all, and then tells the reader to choose one of them.
raise DeviceSelectionError(
"No companion devices detected among the attached ports.\n"
f"{_format_device_list(devices)}\n"
"Connect a companion device (USB or Bluetooth), or pass --port / --ble "
"explicitly if one of these is your companion, or use --mock for the simulator."
)
raise DeviceSelectionError(
"Multiple companion devices detected and no default to fall back on.\n"
f"{_format_device_list(devices)}\n"
f"{_format_device_list(likely)}\n"
"Choose one with --port <PORT> or --ble <ADDRESS> (or run 'meshterm devices' to "
"inspect them). The chosen device is remembered as the default after it connects."
)
+5 -4
View File
@@ -75,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
+56 -1
View File
@@ -44,12 +44,14 @@ from __future__ import annotations
import json
import logging
import os
import ssl
import threading
import time
import urllib.error
import urllib.request
from collections import OrderedDict
from collections.abc import Container
from functools import lru_cache
from pathlib import Path
from typing import NamedTuple
@@ -109,6 +111,59 @@ _RESOLVE_RETRY_SECONDS = 30.0
_log = logging.getLogger(__name__)
#: Where the operating system keeps its CA bundle, most specific first. Only consulted when
#: the default context came up empty (see :func:`_tls_context`) — macOS and Alpine put it at
#: ``/etc/ssl/cert.pem``, Debian and Ubuntu at ``ca-certificates.crt``, Fedora and RHEL under
#: ``/etc/pki``, openSUSE at ``ca-bundle.pem``. Files rather than a bundled copy on purpose:
#: these are the trust decisions the *machine's administrator* has made, kept current by the
#: OS, and shipping our own would quietly freeze a snapshot of them into every release.
_CA_BUNDLES = (
"/etc/ssl/cert.pem",
"/etc/ssl/certs/ca-certificates.crt",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/ca-bundle.pem",
)
@lru_cache(maxsize=1)
def _tls_context() -> ssl.SSLContext | None:
"""The TLS context for tile fetches, with a CA bundle found if the default has none.
A frozen build can have no trust store to consult. Python's default context asks
OpenSSL for one, and on macOS and Linux that is a *filesystem path baked into the
interpreter's own build* — which need not exist on a machine that never installed that
Python, which is every machine a PyInstaller bundle lands on. Every tile fetch then
fails with ``CERTIFICATE_VERIFY_FAILED``; and because a refused fetch is
indistinguishable from empty terrain, the map drew no ground under the nodes and said
nothing about why. Windows is the exception that hid it for so long: its default
context reads the OS certificate store, so the binary there always worked.
So: keep the default when it actually loaded certificates, and otherwise point it at
the bundle the OS ships (:data:`_CA_BUNDLES`). Verification is never weakened — a map
tile is not worth teaching the app to skip certificate checks, and an unverified
fetcher here would be one import away from being reused somewhere it matters.
Returns:
A context to pass to ``urlopen``, or ``None`` to accept urllib's default — which
is right whenever that default already works, and is never worse than the failure
it replaces.
"""
try:
context = ssl.create_default_context()
if context.get_ca_certs():
return None # the platform store answered; leave urllib exactly as it was
for path in _CA_BUNDLES:
if os.path.isfile(path):
context.load_verify_locations(cafile=path)
_log.debug("no default CA store; verifying against %s", path)
return context
_log.debug("no CA bundle found in %s — TLS will likely fail", ", ".join(_CA_BUNDLES))
return None
except Exception as exc: # noqa: BLE001 - a broken store must not take the map with it
_log.debug("could not build a TLS context, using urllib's default: %s", exc)
return None
class _Response(NamedTuple):
"""One HTTP GET's outcome: whether the server answered, and what it said.
@@ -478,7 +533,7 @@ class BasemapSource:
"""
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
with urllib.request.urlopen(req, timeout=self._timeout, context=_tls_context()) as resp:
body = resp.read()
declared = (resp.headers.get("Content-Length") or "").strip()
# A flaky link can end a read early without raising. A body short of its
+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)
+2 -1
View File
@@ -39,7 +39,8 @@ from ..core.config import default_config_dir
from ..persistence.logging import get_logger
from ..platforms import get_platform
#: Where ``scripts/calculinux-console-font.sh`` and its 6x8 companion install the fonts.
#: 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
+1 -1
View File
@@ -207,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``
+1 -1
View File
@@ -135,7 +135,7 @@ class ChatTool(Tool):
# scroll nor the erase atom is written into the base: the select list splices
# each in exactly when its key would act — ←→ only while the highlighted row
# overflows, Del only on a thread that has history to lose — and the three of
# them together are what has to stay inside 72 cells (the purge preview's list
# them together are what has to stay inside 72 cells (the archive preview's list
# is the same arithmetic). `Del erase` is that budget's doing: the row already
# names the conversation, so the atom spends its cells on the verb, and `erase`
# is the word the app uses elsewhere for taking data away.
+7 -18
View File
@@ -29,6 +29,7 @@ from ..core.device_config import (
parse_value,
settings_by_category,
)
from ..services.clock_sync import set_clock
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
@@ -306,23 +307,19 @@ async def apply_ops(
elif kind == "share":
report.append(await _share_contact(ctx, snapshot))
elif kind == "sync_clock":
import time as _time
from datetime import datetime
epoch = int(_time.time())
drift = await _try_clock(device)
await device.set_time(epoch)
stamp = datetime.fromtimestamp(epoch).astimezone().strftime("%Y-%m-%d %H:%M:%S")
ctx.ui.ack(f"[ok]✓[/ok] device clock set to [brand]{stamp}[/brand]")
# The same step the on-connect service runs in the background
# (:mod:`meshterm.services.clock_sync`); here it is acknowledged and reported.
done = await set_clock(device)
ctx.ui.ack(f"[ok]✓[/ok] device clock set to [brand]{done.stamp}[/brand]")
changes += 1
report.append(
_acted(
"clock",
changes=1,
set_at=datetime.fromtimestamp(epoch).astimezone(),
set_at=done.set_at,
# What the clock was off by *before* the set: the fact worth logging,
# and the one this command destroys by succeeding.
drift_s=None if drift is None else drift - epoch,
drift_s=done.drift_s,
)
)
elif kind == "reboot":
@@ -802,11 +799,3 @@ def _acted(key: str, **facts: Any) -> Facts:
values=dict(facts),
shape=SILENT,
)
async def _try_clock(device: Device) -> int | None:
"""The device's current clock, or ``None`` where the firmware will not answer."""
try:
return await device.get_time()
except Exception: # noqa: BLE001 - optional read; the drift is a nicety
return None
+19 -1
View File
@@ -18,7 +18,13 @@ import typer
from ..context import AppContext
from ..core import exitcodes
from ..core.discovery import TRANSPORT_MOCK, DiscoveredDevice, discover_all, discover_devices
from ..core.discovery import (
TRANSPORT_MOCK,
DiscoveredDevice,
ble_unavailable_reason,
discover_all,
discover_devices,
)
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
@@ -73,6 +79,18 @@ class DevicesTool(Tool):
active = ctx.selected_device
active_target = ctx.ble_override or ctx.port_override or (active.target if active else None)
# A refused Bluetooth scan is not the same as a quiet one, and the difference is
# invisible in the listing — both simply lack BLE rows. Say so whether or not
# anything else was found: a serial board being present does not make a companion
# the reader expected over Bluetooth any less missing.
blocked = ble_unavailable_reason()
if blocked:
from ..ui import script
script.stderr_console().print(
f"meshterm: Bluetooth not scanned — {blocked}", style="warn", highlight=False
)
if not devices:
# Not an error: the scan ran and found nothing. Said on stderr so a caller
# redirecting stdout still hears it, and reported as NO_RESULT so a script can
+3 -3
View File
@@ -1,9 +1,9 @@
# SPDX-License-Identifier: Apache-2.0
"""The Archived contacts list: what the sweep took off the device, and when.
A purge (see :mod:`~meshterm.ui.purge_screen`) removes a contact from the *device* — the
scarce resource — and keeps it here in full. This screen is where those contacts live: the
same sortable, filterable lane layout as the Contacts list, in its own lane set
An archive sweep (see :mod:`~meshterm.ui.sweep_screen`) removes a contact from the *device*
— the scarce resource — and keeps it here in full. This screen is where those contacts live:
the same sortable, filterable lane layout as the Contacts list, in its own lane set
(``NAME · ARCHIVED · KEY``, :data:`~meshterm.ui.contactlist.ARCHIVED_LANES`), with its own
sort ring over exactly those three columns.
+63 -14
View File
@@ -32,7 +32,7 @@ from rich.text import Text
from .marks import SELF_MARK
from .menus import fit_cells
from .theme import name_style
from .theme import glyph, name_style
from .tui.select import Choice, SelectScreen, Separator
from .widgets import (
DEFAULT_GLYPH,
@@ -122,6 +122,35 @@ TRACE_LANES: tuple[ContactLane, ...] = (TRACED_LANE, HEARD_LANE, PKTS_LANE)
#: about; when it left is.
ARCHIVED_LANES: tuple[ContactLane, ...] = (ARCHIVED_LANE,)
#: The padlock a locked contact's row leads with (see :attr:`ContactRow.locked`) — the
#: private-channel ``🔒`` of the icon lexicon, which is the same claim (this one is closed to
#: something), and on the PicoCalc the font's own hand-drawn padlock ``⚿`` through
#: :func:`~meshterm.ui.theme.glyph`. An unlocked contact draws nothing: the mark is an
#: exception worth noticing, and a lane of open padlocks would bury the few that aren't.
_LOCK_ICON = "🔒"
def _lock_lane_width(rows: list[ContactRow]) -> int:
"""The lock column's width in cells: this platform's padlock and its gap, or ``0``.
The column sits left of the type glyph and exists only while some row is locked, so a
list with no locks spends no cells on it — on the PicoCalc's 53 those are a name's cells.
"""
if not any(row.locked for row in rows):
return 0
return cell_len(glyph(_LOCK_ICON)) + 1
def _lock_cell(row: ContactRow, lock_w: int) -> str:
"""A row's lock column: the padlock and its gap when locked, blank otherwise."""
if not lock_w:
return ""
if not row.locked:
return " " * lock_w
mark = glyph(_LOCK_ICON)
return mark + " " * (lock_w - cell_len(mark))
#: The muted ``(you)`` tag on the own-node lane (see :func:`_lane`), its width folded into
#: the name lane's content sizing so the tag never truncates.
_YOU_TAG = " (you)"
@@ -204,6 +233,8 @@ class ContactRow:
Ignored by lists that don't declare the lane.
archived_at: When this contact was archived off the device (aware UTC) — fills the
``ARCHIVED`` lane (:data:`ARCHIVED_LANES`). Ignored by lists without it.
locked: Whether the contact is locked against archiving. Leads the row with a
padlock, left of the type glyph; ``False`` draws nothing there.
you: Whether this is our own node: the ``★`` marker, the pure-white ``you`` name
style with a muted ``(you)`` tag, faint ``—`` heard/packet lanes (we never
overhear ourselves), pinned above the sorted block whatever the sort.
@@ -217,11 +248,15 @@ class ContactRow:
count: int | None = None
last_traced: datetime | None = None
archived_at: datetime | None = None
locked: bool = False
you: bool = False
def _header(
name_w: int, sort: ContactsSort, lanes: tuple[ContactLane, ...] = DEFAULT_LANES
name_w: int,
sort: ContactsSort,
lanes: tuple[ContactLane, ...] = DEFAULT_LANES,
lock_w: int = 0,
) -> Text:
"""Column labels over the contact lanes (see :func:`_lane`).
@@ -258,7 +293,8 @@ def _header(
if not last:
header.append(" " * (_GAP - 2), style="muted")
header = Text(" ", style="muted") # pointer (2) + the row's type glyph and gap (2)
# The pointer (2), the lock column when the list has one, the type glyph and its gap (2).
header = Text(" " * (4 + lock_w), style="muted")
column(header, "NAME", "name", name_w)
for lane in lanes:
# Right-aligned label over a right-aligned value, so a narrow field's header sits
@@ -291,6 +327,7 @@ def _you_lane(
prefix_bytes: int,
hash_w: int,
lanes: tuple[ContactLane, ...] = DEFAULT_LANES,
lock_w: int = 0,
) -> Text:
"""Our own node's lane — laid out exactly like :func:`_lane`'s regular rows.
@@ -301,7 +338,7 @@ def _you_lane(
reads a faint ``—``**, whatever the list declares: we never overhear, trace, or archive
ourselves, so none of those has a value to show and each says so identically.
"""
text = Text(no_wrap=True, overflow="ellipsis")
text = Text(" " * lock_w, no_wrap=True, overflow="ellipsis") # we are never locked
text.append(SELF_MARK[0], style=SELF_MARK[1])
text.append(" ")
# The name lane, exactly name_w cells: the name (white) with a snug muted "(you)" tag —
@@ -332,11 +369,14 @@ def _lane(
prefix_bytes: int,
hash_w: int,
lanes: tuple[ContactLane, ...] = DEFAULT_LANES,
lock_w: int = 0,
) -> Text:
"""One contact as fixed, colour-coded lanes under :func:`_header`'s columns.
The type glyph leads (the app's shared marker palette), so the mark reads ``▲`` for a
repeater, ``■`` for a room, ``◉`` for a sensor, ``●`` for a plain node. The name takes
A locked contact's padlock leads, in a column of its own (``lock_w`` cells, zero when
no row in the list is locked). The type glyph follows (the app's shared marker palette),
so the mark reads ``▲`` for a repeater, ``■`` for a room, ``◉`` for a sensor, ``●`` for a
plain node. The name takes
the contact's hash-derived palette hue (a nameless contact's ``unknown`` placeholder
stays muted — colour marks a name, and the hash lane already carries the identity) and
the flexing name lane (``name_w`` cells). Each declared lane follows in order, drawn by
@@ -351,12 +391,13 @@ def _lane(
hash_w: The flexing key lane's width in cells (a short key pads out to it; see
:func:`~meshterm.ui.widgets.highlighted_hash`).
lanes: The middle lanes to draw between the name and the key.
lock_w: The lock column's width in cells (see :func:`_lock_lane_width`).
"""
if row.you:
return _you_lane(row, name_w, prefix_bytes, hash_w, lanes)
glyph, glyph_style = NODE_GLYPHS.get(row.node_type, DEFAULT_GLYPH)
text = Text(no_wrap=True, overflow="ellipsis")
text.append(glyph, style=glyph_style)
return _you_lane(row, name_w, prefix_bytes, hash_w, lanes, lock_w)
mark, mark_style = NODE_GLYPHS.get(row.node_type, DEFAULT_GLYPH)
text = Text(_lock_cell(row, lock_w), no_wrap=True, overflow="ellipsis")
text.append(mark, style=mark_style)
text.append(" ")
text.append(
fit_cells(row.name or "unknown", name_w),
@@ -465,7 +506,7 @@ class ContactListScreen(SelectScreen):
Built on ``super()``'s lane rather than the bare default, so a contact list that
carries section headings (the Time Machine's whole-mesh lead, the Contacts screen's
purge tail) keeps the section jumps the select list promotes onto F1/F2.
archive tail) keeps the section jumps the select list promotes onto F1/F2.
"""
from .tui.fkeys import FPair
@@ -536,13 +577,16 @@ class ContactListScreen(SelectScreen):
"""The lead rows, the sort-aware column header, the contact lanes, then any tail rows.
Own-node rows lead the lanes and stay first whatever the sort: only the block
below them reorders (see :func:`_ordered`). Tail rows (a purge action, the exit
below them reorders (see :func:`_ordered`). Tail rows (an archive action, the exit
group) close the list, past every contact whatever the sort.
"""
items: list = list(self._lead)
# The lane names lead the contacts as their landmark, so they pin overhead while the
# list scrolls — a row deep in the sort can still be read off its columns.
items.append(Separator(_header(self._name_w, self._sort, self._lanes), heading=True))
lock_w = _lock_lane_width(self._contact_rows)
items.append(
Separator(_header(self._name_w, self._sort, self._lanes, lock_w), heading=True)
)
pinned = [row for row in self._contact_rows if row.you]
rest = [row for row in self._contact_rows if not row.you]
for row in (*pinned, *_ordered(rest, self._sort, self._lanes)):
@@ -554,6 +598,7 @@ class ContactListScreen(SelectScreen):
self._prefix_bytes,
self._hash_w,
self._lanes,
lock_w,
),
row.value,
)
@@ -626,7 +671,11 @@ class ContactListScreen(SelectScreen):
adds its gapped width to the fixed lead, so a list that drops two lanes (the
Archived one) hands both back to the key.
"""
lead = _LEAD + sum(_GAP + lane.width for lane in self._lanes)
lead = (
_LEAD
+ _lock_lane_width(self._contact_rows)
+ sum(_GAP + lane.width for lane in self._lanes)
)
name_cap = max(_NAME_MIN, width - lead - _HASH_MIN)
name_w = max(_NAME_MIN, min(self._widest_name(), name_cap))
hash_w = max(_HASH_MIN, width - lead - name_w)
+66 -32
View File
@@ -11,15 +11,15 @@ and backing out of that page returns to the list right where it stood. The one-s
(``meshterm contacts --sort …``) still renders the static
:func:`~meshterm.ui.widgets.contacts_table`; only the menu gets the live list.
Below the contacts sits the list's own maintenance action — **Purge contacts** — the bulk
Below the contacts sits the list's own maintenance action — **Archive contacts** — the bulk
counterpart, which exists because a companion's contact table is finite and a busy mesh
fills it with nodes heard once in passing until there is no room left to discover anyone
new. It ranks the whole table (see :mod:`~meshterm.core.contact_score`) and archives the
weakest off the device while MeshTerm keeps them; the flow, its ladder and its preview live
in :mod:`~meshterm.ui.purge_screen`. Our own node, pinned above the list, is never a
in :mod:`~meshterm.ui.sweep_screen`. Our own node, pinned above the list, is never a
candidate — it isn't in the device's contact table.
What a sweep took is never hidden: **View archived contacts** sits directly under the purge
What a sweep took is never hidden: **View archived contacts** sits directly under the archive action
action and opens the archived list (see :mod:`~meshterm.ui.archived_screen`) — its own
screen, in the same lane layout, sorted on its own ``NAME · ARCHIVED · KEY`` columns. It
earns a screen rather than a section at the foot of this one because it is a different list
@@ -42,7 +42,7 @@ from typing import TYPE_CHECKING
from .archived_screen import open_archived
from .contactlist import ContactListScreen, ContactRow
from .menus import icon_lane, marked_label
from .purge_screen import purge_contacts
from .sweep_screen import archive_contacts
from .tui import Choice, Separator
from .tui.screen import CANCEL
from .widgets import ContactsSort, contact_packets
@@ -57,13 +57,13 @@ YOU = ("you",)
#: The contact-list tail sentinels (distinct from any :class:`~meshterm.core.models.Contact`
#: and from :data:`YOU`): the sweep, and the way in to what it has already taken.
_PURGE = ("purge",)
_ARCHIVE = ("archive",)
_ARCHIVED = ("archived",)
#: Every mark the tail's maintenance rows lead with — the set the icon column is measured
#: over (see :func:`~meshterm.ui.menus.icon_lane`). Declared rather than inferred so the
#: column is one width for the whole tail whichever of the two rows a given visit draws.
_TAIL_ICONS = ("🗑", "📂")
_TAIL_ICONS = ("💾", "📂")
class ContactsScreen(ContactListScreen):
@@ -83,6 +83,7 @@ class ContactsScreen(ContactListScreen):
counts: dict[str, int],
sort: ContactsSort,
archived: int = 0,
locked: frozenset[str] = frozenset(),
) -> None:
"""Create the contacts screen over already-fetched contact data.
@@ -100,36 +101,28 @@ class ContactsScreen(ContactListScreen):
``View archived contacts`` row and shows the count on it, so the tally is
visible without opening the list; zero draws no row, since a screen should
not offer a way into an empty list.
locked: The full keys (lowercase hex) of the contacts locked against archiving;
each of those rows closes its name lane on a padlock.
"""
rows = [ContactRow(value=YOU, name=self_name, key=self_key, you=True)]
for c in contacts:
rows.append(
ContactRow(
# The contact itself is the row's identity, so Enter hands the whole
# record straight to the detail page — no re-lookup by name/key.
value=c,
name=c.name,
key=c.public_key,
node_type=c.node_type,
last_seen=c.last_seen,
count=contact_packets(c, counts),
)
)
self._self_name, self._self_key = self_name, self_key
self._contacts, self._counts = contacts, counts
rows = self._contact_rows_for(locked)
# The maintenance actions close the list, past every contact whatever the sort: a
# blank spacer, then the err-tinted sweep (a `…` — it opens further prompts), then
# the way in to what the sweep has already taken. The two sit together because they
# are the two halves of one idea, and the archived row is *under* the purge for the
# same reason: it is where the purge's output went.
# One icon column for both rows: 🗑 draws a single cell where 📂 draws two, so an
# unpadded mark would start the purge row's label a column left of the other's.
# blank spacer, then the sweep (a `…` — it opens further prompts), then the way in
# to what the sweep has already taken. The two sit together because they are the
# two halves of one idea, and the archived row is *under* the sweep for the same
# reason: it is where the sweep's output went. The sweep wears the single-contact
# Archive action's 💾 and no red: it archives, and red is kept for data loss.
# One icon column for both rows, measured rather than assumed, so a mark narrower
# than its neighbour can't start its row's label a column left of the other's.
lane = icon_lane(_TAIL_ICONS)
tail: list = []
if contacts:
tail = [
Separator(" "),
Choice(
title=marked_label("🗑", "Purge contacts…", "err", lane=lane),
value=_PURGE,
title=marked_label("💾", "Archive contacts…", "", lane=lane),
value=_ARCHIVE,
),
]
if archived:
@@ -147,6 +140,34 @@ class ContactsScreen(ContactListScreen):
tail=tail,
)
def _contact_rows_for(self, locked: frozenset[str]) -> list[ContactRow]:
"""Our own node, then every contact, each marked locked or not by ``locked``."""
rows = [ContactRow(value=YOU, name=self._self_name, key=self._self_key, you=True)]
for c in self._contacts:
rows.append(
ContactRow(
# The contact itself is the row's identity, so Enter hands the whole
# record straight to the detail page — no re-lookup by name/key.
value=c,
name=c.name,
key=c.public_key,
node_type=c.node_type,
last_seen=c.last_seen,
count=contact_packets(c, self._counts),
locked=(c.public_key or "").lower().removeprefix("0x") in locked,
)
)
return rows
def refresh_locks(self, locked: frozenset[str]) -> None:
"""Redraw the padlocks in place after a detail page may have locked or unlocked one.
A lock changes a lane, not which contacts exist, so this is the
:meth:`~meshterm.ui.contactlist.ContactListScreen.update_rows` case rather than a
rebuild: the cursor, the sort, the scroll and a typed filter all stay where they were.
"""
self.update_rows(self._contact_rows_for(locked))
async def open_contacts(
ctx: AppContext,
@@ -160,7 +181,7 @@ async def open_contacts(
"""Open the interactive contacts list; Enter opens Node detail, Esc leaves.
The list stays pushed for the whole visit, so whichever node's detail page Enter commits
(or the purge flow, or the archived list, that the tail actions open) nests *above* it
(or the archive flow, or the archived list, that the tail actions open) nests *above* it
and Esc from there is one pop back onto the row it was opened from — same sort, same
filter, same scroll. Anything that changes which contacts the device holds — a sweep
here, a single archive, restore or delete on a detail page, or a restore made from
@@ -198,6 +219,7 @@ async def open_contacts(
counts,
sort,
archived=_archived_count(ctx, self_key),
locked=_locked_keys(ctx, self_key),
)
screen = build()
@@ -208,13 +230,13 @@ async def open_contacts(
chosen = await visit.result()
if chosen is CANCEL or chosen is None: # Esc
return
if chosen == _PURGE:
# The purge ladder, its preview and its confirm all float over the list,
if chosen == _ARCHIVE:
# The archive ladder, its preview and its confirm all float over the list,
# which is already the backdrop. A sweep that archived anything
# invalidates the contacts cache, so the list is re-read and rebuilt —
# the one deliberate reset in this screen's life, because the rows it was
# keeping a place in are genuinely gone from the device.
if await purge_contacts(ctx, self_key):
if await archive_contacts(ctx, self_key):
rebuild = True
break
continue
@@ -234,6 +256,9 @@ async def open_contacts(
if await open_node_detail(ctx, None if chosen == YOU else chosen):
rebuild = True
break
# The page may have locked or unlocked the contact without ending the visit;
# the store answers from memory, so the padlocks are simply redrawn.
screen.refresh_locks(_locked_keys(ctx, self_key))
if not rebuild:
return
contacts = await ctx.devstate.contacts()
@@ -247,3 +272,12 @@ def _archived_count(ctx: AppContext, self_key: str) -> int:
if store is None or not dev_pub:
return 0
return len(store.archived(dev_pub))
def _locked_keys(ctx: AppContext, self_key: str) -> frozenset[str]:
"""The keys of the contacts locked on this device — which rows draw a padlock."""
store = getattr(ctx, "contact_store", None)
dev_pub = (self_key or "").lower().removeprefix("0x")
if store is None or not dev_pub:
return frozenset()
return store.locked_keys(dev_pub)
+82 -19
View File
@@ -153,6 +153,36 @@ def _where(device: DiscoveredDevice) -> str:
return device.target
def _fit_target(text: str, width: int) -> str:
"""Fit a connection target into ``width`` cells, keeping its **end**.
Deliberately not :func:`~meshterm.ui.menus.fit_cells`, which keeps the head: what tells
two targets apart is their *tail* — ``usbmodem1101`` from ``usbmodem1102``,
``ttyUSB0`` from ``ttyUSB1`` — while the head they share (``/dev/cu.``) is boilerplate.
Cutting the front is the cut that leaves the column able to do its one job in a picker,
and cutting the back would render two different ports identically. A CoreBluetooth UUID
is equally identifiable from either end, so the rule that is right for ports costs the
platform that needed the cap nothing.
Args:
text: The target to fit.
width: The cells available. Text already inside it is returned unchanged, so the
platforms whose targets already fit the lane are untouched.
Returns:
The target, or its tail behind a leading ``…``, measuring at most ``width`` cells.
"""
if cell_len(text) <= width:
return text
keep = max(0, width - 1) # the leading ellipsis costs a cell
tail = ""
for ch in reversed(text):
if cell_len(tail) + cell_len(ch) > keep:
break
tail = ch + tail
return "…" + tail
def _hardware_label(device: DiscoveredDevice, registry: dict[str, RememberedDevice]) -> str:
"""The HARDWARE column text: the remembered firmware model, else the USB vendor.
@@ -670,34 +700,66 @@ async def _remove_network_device(
#: Below this a model string says nothing at all, and the lane is better spent on the name.
_HARDWARE_MIN = 10
#: The narrowest the PORT / ADDRESS lane is worth shrinking to before DEVICE has to give.
#: This is the lane that adapts: a target is how you tell two similar rows apart, and ten
#: cells still does that (the tail of a UUID, the end of a port path), while a *name* is
#: what the reader actually came to read. macOS is why any of this is needed —
#: CoreBluetooth reports no MAC at all but a per-machine 36-cell UUID, and even its ports
#: run long (every Mac carries a 31-cell ``/dev/cu.Bluetooth-Incoming-Port``) — where a MAC
#: is 17 and ``/dev/ttyUSB0`` is 12, so no other platform ever presses on the row at all.
_ADDRESS_MIN = 10
def _name_width(
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice], other_lanes: int
) -> int:
"""The DEVICE lane's width: the longest name, capped so HARDWARE keeps a readable column.
A USB adapter's own product string runs to forty-odd characters ("CP2102 USB to UART
Bridge Controller"), and the lane used to size itself to whichever of those was longest.
On a 53-column console that pushed every other lane off the right-hand edge — the row
ended mid-port, and the HARDWARE column that says what the thing *is* was not on screen
at all, nor reachable by scrolling, because the pinned head was already wider than the
box. So the name is the lane that gives: it ellipsizes, and the columns after it survive.
def _lane_widths(
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice]
) -> tuple[int, int]:
"""The DEVICE and PORT / ADDRESS widths: the name in full, the address taking the rest.
The row has a fixed budget, and something has to give when the two lanes together
outrun it. **The address gives first.** A name is what the reader came to read and what
they recognise their own radio by; an address is a disambiguator, and a disambiguator
does its whole job from its last ten cells (:func:`_fit_target` keeps the tail for
exactly this reason). So DEVICE is sized to the longest name it has, and ADDRESS takes
whatever is left over, down to :data:`_ADDRESS_MIN`.
Only once the address is down to that floor does the name start ellipsizing, and it
still must: a USB adapter's own product string runs to forty-odd characters ("CP2102
USB to UART Bridge Controller"), and a lane sized to *that* pushed everything after it
off the right-hand edge — the row ended mid-port and the HARDWARE column that says what
the thing actually *is* was not on screen, nor reachable by scrolling, because the
pinned head was already wider than the box. HARDWARE keeps :data:`_HARDWARE_MIN` out of
the budget so it always has a readable stub; it is the row's scrolling tail, so beyond
that stub it is free to run long.
Args:
devices: The devices being listed.
registry: Confirmed companions, for their remembered names.
other_lanes: Cells the PORT and TYPE lanes take between them.
Returns:
The name lane's width in cells.
``(name_w, port_w)`` in cells.
"""
platform = get_platform()
# What the splash's box gives a row: the terminal, less the gutter it floats over, less
# its own border and padding, less the pointer and star columns the row leads with.
content = platform.readable_cols - platform.dialog_margin - 4 - 4
fixed = other_lanes + 6 # the three two-cell gaps between the four lanes
longest = max(cell_len(_display_name(d, registry)) for d in devices)
return max(len("DEVICE"), min(longest, content - fixed - _HARDWARE_MIN))
# ...less the three two-cell gaps, the TYPE badge, and HARDWARE's readable stub.
budget = content - 6 - len("TYPE") - _HARDWARE_MIN
name_natural = max(cell_len(_display_name(d, registry)) for d in devices)
port_natural = max(cell_len(_where(d)) for d in devices)
# Never reserve more room for the address than it actually wants: a lone "COM3" should
# hand its slack to the name rather than sit in a ten-cell lane holding four cells.
floor = min(port_natural, _ADDRESS_MIN)
name_w = max(len("DEVICE"), min(name_natural, budget - floor))
port_w = max(floor, min(port_natural, budget - name_w))
if name_w < name_natural and port_w < port_natural:
# The name is being ellipsized whatever happens, so spending two more of its cells
# to keep the address whole trades one cut for none rather than adding a second.
port_w = min(port_natural, budget - len("DEVICE"))
name_w = max(len("DEVICE"), budget - port_w)
return name_w, port_w
def _order(
@@ -765,12 +827,13 @@ def _build_items(
port_label = (
"PORT / ADDRESS" if has_serial and has_address else "ADDRESS" if has_address else "PORT"
)
port_w = max(cell_len(_where(d)) for d in devices)
port_w = max(port_w, len(port_label))
# The TYPE column holds a small transport badge (at most 3 cells); its heading is wider,
# so the four-cell "TYPE" label sets the column width and every badge pads out to it.
type_w = len("TYPE")
name_w = _name_width(devices, registry, port_w + type_w)
# DEVICE in full, ADDRESS taking what is left (see _lane_widths). The heading does not
# set a floor here the way it used to: the lane may end up narrower than "PORT / ADDRESS"
# spells, and column_header already carries the short form to fall back on.
name_w, port_w = _lane_widths(devices, registry)
hardware_w = max(cell_len(_hardware_label(d, registry)) for d in devices)
hardware_w = max(hardware_w, len("HARDWARE"))
@@ -806,7 +869,7 @@ def _build_items(
style="device.known" if is_known else "",
)
row.append(" ")
row.append(_pad(_where(device), port_w), style="muted")
row.append(_pad(_fit_target(_where(device), port_w), port_w), style="muted")
row.append(" ")
# The TYPE badge marks the transport: a plug emoji for serial, or the Bluetooth rune
# on its blue badge for BLE. It's built with its own colours, then the column is
+1 -1
View File
@@ -2,7 +2,7 @@
"""The PicoCalc console font's codepoint inventory — the glyph contract.
The PicoCalc panel draws through the kernel console with one 512-glyph PSF font
(``meshterm.psf.gz``, built by ``scripts/calculinux-console-font.sh``): the stock
(``meshterm.psf.gz``, built by ``scripts/picocalc/calculinux-console-font-6x12.sh``): the stock
Terminus 6×12 base (a Cyrillic-coverage table — note there are **no accented Latin
letters**), the full braille block, MeshTerm's own marks drawn into donor slots, and a
few aliases. A character outside this set renders as a blank on the panel, so *nothing
+43 -15
View File
@@ -158,13 +158,25 @@ def _rewrap(line: str, width: int) -> list[str]:
def _state_intensity(row: str) -> str:
"""Rewrite the row's colour changes so every one says whether it is bright.
"""Rewrite the row's colour changes so brightness is stated as a colour, not as bold.
An art editor spells brightness the DOS way — ``1m`` lifts the bank and each span after
it inherits that — which reads correctly only while the escapes stay one unbroken
stream. They don't here: a row is handed to Rich on its own, and the console draws bold
*as* the bright bank, so a span that never mentions intensity is read against whatever
happened to be set. Saying it outright changes no colour; it just stops one drifting.
An art editor spells brightness the DOS way — ``1m`` lifts the foreground into the
bright bank, and each span after it inherits that. Two things then have to be settled
before the row can be drawn anywhere else.
The first is that the inheritance does not survive: a row is handed to Rich on its own,
so a span that never mentions intensity is read against whatever the *previous row* left
set. That is resolved by working the state out here and saying it outright.
The second is that **bold is not brightness everywhere**. It is on the PicoCalc console
and on a DOS one; it is not on macOS Terminal, where bold asks for a heavier face and
leaves the colour alone. There ``1;30`` is not dark grey but plain black — and this art
leans on it, seventy-nine spans of the wide mark being bold-black, much of that on a
black ground. The mark came out muted, and the ``░▒▓`` dithers that fade one colour into
another faded toward the wrong end. So brightness is emitted as the colour it means:
``9N`` (the aixterm bright bank), which needs no bold and is also how the PicoCalc's own
console spells bright. Nothing about *which* colour is being asked for changes; only the
spelling, from one a terminal may read as a font weight to one that can only be a colour.
"""
out: list[str] = []
bright = False
@@ -177,16 +189,32 @@ def _state_intensity(row: str) -> str:
continue
params = found.group()[2:-1]
parts = params.split(";") if params else [""]
for part in parts: # what this sequence leaves the intensity set to
if part in ("", "0", "22"):
bright = False
elif part == "1":
bright = True
dim = any(_DIM_FG.fullmatch(p) for p in parts)
if dim and not any(p in _SAYS_INTENSITY for p in parts):
out.append("\x1b[" + ";".join(["1" if bright else "22"] + parts) + "m")
# What this sequence leaves the intensity set to, for the spans that follow it.
says = [p for p in parts if p in ("", "0", "1", "22")]
for part in says:
bright = part == "1"
# Whether *this* sequence's own foreground is bright: what it says, else what stands.
here = bright
fg = next((p for p in parts if _DIM_FG.fullmatch(p)), None)
if fg is None:
# No foreground to restate. A bare "1" has nothing left to do once brightness
# travels as a colour, and dropping it keeps bold off glyphs a terminal may
# redraw at another weight; a reset still has to go through.
kept = [p for p in parts if p != "1"]
if kept:
out.append("\x1b[" + ";".join(kept) + "m")
else:
out.append(found.group())
rewritten = []
for part in parts:
if part == "1":
continue # brightness is in the colour now
if part == fg:
rewritten.append(str(int(fg) + 60) if here else fg)
else:
rewritten.append(part)
if not here and "22" not in rewritten:
rewritten.insert(0, "22") # say dim outright; never inherit a bank
out.append("\x1b[" + ";".join(rewritten) + "m")
at = found.end()
return "".join(out)
+18 -8
View File
@@ -48,7 +48,7 @@ from .tui import (
Separator,
TuiSession,
)
from .tui.emoji_width import calibrate as calibrate_emoji_width
from .tui.emoji_width import install as install_emoji_widths
from .tui.spinner import spinner_interval
from .widgets import battery_cell
@@ -379,13 +379,13 @@ async def run_menu(ctx: AppContext) -> None:
Args:
ctx: The shared application context.
"""
# Measure how this terminal renders emoji and align Rich to it, before
# prompt_toolkit takes over the screen. This keeps every panel border — and
# every chat bubble — aligned regardless of the terminal's emoji widths. Skipped
# outright on a platform that never draws emoji (PicoCalc): there is nothing to
# calibrate, and the probe writes escape sequences the console font can't shape.
# Reserve two cells for every glyph that may be drawn as an emoji, in every width
# authority, before prompt_toolkit lays out its first frame: the session pins each such
# glyph into its reservation, so rows line up whatever the terminal's font does with it.
# Skipped outright on a platform that never draws emoji (PicoCalc), where every glyph is
# one its own font has verified and the stock widths are already exact.
if get_platform().emoji:
calibrate_emoji_width()
install_emoji_widths()
header_cache: dict = {}
session = TuiSession(header=lambda cols: _header(ctx, header_cache, cols))
@@ -399,6 +399,7 @@ async def run_menu(ctx: AppContext) -> None:
# Stop history + chat recording (closing their run records), the background
# advert scheduler, and the always-on event hub, even on an unexpected exit.
await ctx.adverts.aclose()
await ctx.clock_sync.aclose()
await ctx.monitor.aclose()
await ctx.chat.aclose()
await ctx.events.aclose()
@@ -821,6 +822,10 @@ async def _resume_monitor(ctx: AppContext) -> None:
await ctx.adverts.start()
# The Watchtower only listens (rules over hub events), so it too is safe from launch.
await ctx.watchtower.start()
# The clock setter acts only when a connection settles (and at once on one the startup
# picker already adopted), only when its preference is on, and only in the background;
# it never opens the radio and never touches the screen — it reports in the log.
await ctx.clock_sync.start()
# The courier drains the outbox on its own paced schedule; each pass checks for a
# connected device and skips quietly without one, exactly like the advert scheduler.
await ctx.courier.start()
@@ -943,7 +948,12 @@ async def _run_selection(ctx: AppContext, name: str) -> None:
if is_connection_lost(exc):
ctx.ui.discard() # drop the half-built output; the watcher will prompt to reconnect
return
ctx.ui.note(f"[err]✗ {title} failed:[/err] {exc}")
# A tool that failed is a *caution*, not a loss: the run is over, nothing it was
# going to do has been half-done, and the reader's next move is to try again. So it
# takes the amber tone rather than the reserved red — the same tier a danger dialog
# uses — and the message dialog reads that tone off the note to frame itself
# (see :func:`~meshterm.ui.tui.session._message_border`).
ctx.ui.note(f"[warn]⚠ {title} failed:[/warn] {exc}")
await ctx.ui.present(title=title)
return
+15 -6
View File
@@ -51,6 +51,7 @@ from rich.text import Text
from ..platforms import get_platform
from .theme import glyph
from .tui import Choice, Separator
from .tui.emoji_width import cut_cells, drawable
if TYPE_CHECKING:
from ..context import AppContext
@@ -619,6 +620,17 @@ def fit_cells(text: str, width: int, *, align: str = "left") -> str:
truncation are measured in display cells (wide glyphs count 2), so a name carrying
an emoji or CJK character can't skew the lanes the way ``str.ljust`` would.
A lane's text is very often a **node's name**, which is written by whoever owns the
radio and arrives over the air, so two more things are settled here rather than by
every caller: whatever a terminal cannot draw is folded to a space
(:func:`~meshterm.ui.tui.emoji_width.drawable` — a newline or an escape in a name
would end the row mid-lane, and no width table can measure that away), and the cut
falls **between** glyphs (:func:`~meshterm.ui.tui.emoji_width.cut_cells`), never
inside one. A cut a codepoint at a time slices an emoji sequence in half, and the
stranded joiner it leaves swallows the ellipsis appended after it — the lane then
measures one cell short of what the terminal draws, and every column to its right
starts late.
Args:
text: The text to fit.
width: The exact cell width to return.
@@ -627,13 +639,10 @@ def fit_cells(text: str, width: int, *, align: str = "left") -> str:
Returns:
A string measuring exactly ``width`` cells.
"""
if not text.isprintable():
text = drawable(text)
if cell_len(text) > width:
kept = ""
for ch in text:
if cell_len(kept + ch) > width - 1:
break
kept += ch
text = kept + "…"
text = cut_cells(text, width - 1) + "…"
pad = " " * max(0, width - cell_len(text))
return pad + text if align == "right" else text + pad
+105 -25
View File
@@ -25,7 +25,7 @@ the node itself, in full:
the rest), ``Time machine``, ``Share contact`` — a popup contact card (QR code +
``meshcore://`` link, see :func:`~meshterm.ui.config_editor.show_contact_card`),
offered whenever the node's full key is known — and, last, ``Remove contact``: the
single-contact counterpart to the Contacts list's bulk purge (see
single-contact counterpart to the Contacts list's bulk archive (see
:mod:`~meshterm.ui.contacts_screen`), dropping *this* node from the device's contact
table behind a red confirm. It is the one thing on the page that changes anything, so
it sits at the foot of the actions, and committing it closes the page — the contact it
@@ -446,10 +446,7 @@ class NodeDetailScreen(Screen):
#: The action rows' icon column, in cells — measured once over every mark this page
#: can draw, so a one-cell ``🗑`` pads out to its two-cell siblings and every label
#: starts in the same column. Zero on a platform that draws no icon lane at all.
self._icon_lane = menus.icon_lane(
action.glyph
for action in (*self._info_actions, *([trace_action] if trace_action else []))
)
self._icon_lane = self._measure_icon_lane()
self._tab_index = 0
self._row_index = 0
#: The highlighted route on the Routes tab (drives the graph); tracks the cursor as
@@ -574,6 +571,31 @@ class NodeDetailScreen(Screen):
return 0
return 2 if self._tabs[self._tab_index].kind == "info" else 0
def _measure_icon_lane(self) -> int:
"""The action rows' icon column over every mark currently on the page."""
trace = [self._trace_action] if self._trace_action else []
return menus.icon_lane(action.glyph for action in (*self._info_actions, *trace))
def replace_info_actions(self, actions: list[_Action]) -> None:
"""Swap the Info tab's action rows in place, keeping the cursor on the row it was on.
For an action that changes what the page offers without ending the visit — locking a
contact turns its row into Unlock and withdraws Archive. The highlight follows the
committed row by its :attr:`_Action.key` family (``lock`` and ``unlock`` are one
row), so the reader's next press lands where their last one did.
"""
focus = self._focusables()
current = focus[self._row_index % len(focus)][1] if focus else None
was = getattr(current, "key", None)
self._info_actions = list(actions)
self._icon_lane = self._measure_icon_lane()
family = {"lock": "unlock", "unlock": "lock"}
for index, (_kind, payload) in enumerate(self._focusables()):
key = getattr(payload, "key", None)
if key is not None and key in (was, family.get(was or "")):
self._row_index = index
break
def handle(self, action: str, data: str = "") -> None:
"""Answer one key press on the page.
@@ -1089,8 +1111,15 @@ async def open_node_detail(
on the Info tab, and the page offers exactly the ones that make sense — a live contact
can be archived or deleted, an archived one restored or deleted.
**Locking is management that stays.** A live contact can be locked against archiving
(see :meth:`~meshterm.core.contact_store.ContactStore.set_locked`): the sweep then never
counts it a candidate, and this page withdraws its Archive row for as long as the lock
holds. The toggle rewrites the page's action rows in place rather than ending the visit
— nothing about the contact the page describes has gone — and the list underneath redraws
its padlock when the reader comes back to it.
``manage`` is how a caller says the page is being opened to *look*, not to act. The
purge preview passes ``False``: a screen whose whole job is choosing what to archive
archive preview passes ``False``: a screen whose whole job is choosing what to archive
should not also hand out a second, singular way to archive — or a delete — from inside
its own candidate list. The rule is that management verbs belong to the list a contact
actually lives in, never to a page opened out of a list that is about to act on it
@@ -1100,7 +1129,8 @@ async def open_node_detail(
ctx: The shared application context (must be running the interactive TUI).
contact: The contact to detail, or ``None`` for our own node (an identity-and-ledger
page — we never overhear ourselves, so there is no reception history to show).
manage: Whether to offer the contact-management actions (archive, restore, delete).
manage: Whether to offer the contact-management actions (lock, archive, restore,
delete).
``False`` draws none of them, and the page can then only ever return ``False``.
Returns:
@@ -1328,18 +1358,41 @@ async def open_node_detail(
and contact is not None
and bool(contact.public_key or contact.key_prefix)
)
if manageable:
# The constructive verb first, then the destructive one, so the row a mistaken press
# is likeliest to land on is the recoverable one. Archive and restore are the two
# halves of one reversible move, and take the ``💾``/``📂`` pair the lexicon already
# gives put-it-away and bring-it-back; only a contact with a full key can be written
# back to the device, so a prefix-only contact is never offered the archive that
# would strand it.
store = getattr(ctx, "contact_store", None)
dev_pub = self_key.lower().removeprefix("0x")
whole_key = contact is not None and len(contact.public_key.lower().removeprefix("0x")) == 64
# A lock is MeshTerm's own mark, hung on the full key in the contact store, and only a
# live contact carries one — it exists to keep a contact off the archive path.
lockable = manageable and not archived and whole_key and store is not None and bool(dev_pub)
base_actions = list(info_actions)
def management(locked: bool) -> list[_Action]:
"""The page's action rows, closing on the management group for this lock state."""
rows = list(base_actions)
if not manageable:
return rows
# The lock first: it changes nothing on the device and ends nothing, so it is the
# safest row in the group to land on. Then the constructive verb, then the
# destructive one, so the row a mistaken press is likeliest to land on is the
# recoverable one. Archive and restore are the two halves of one reversible move,
# and take the ``💾``/``📂`` pair the lexicon already gives put-it-away and
# bring-it-back; only a contact with a full key can be written back to the device,
# so a prefix-only contact is never offered the archive that would strand it — and
# a locked one is never offered it at all, which is what the lock is for.
if lockable:
if locked:
rows.append(_Action("unlock", "🔓", "", "Unlock contact"))
else:
rows.append(_Action("lock", "🔒", "", "Lock contact"))
if archived:
info_actions.append(_Action("restore", "📂", "ok", "Restore to device"))
elif len((contact.public_key or "").lower().removeprefix("0x")) == 64:
info_actions.append(_Action("archive", "💾", "", "Archive contact"))
info_actions.append(_Action("remove", "🗑", "err", "Delete contact…"))
rows.append(_Action("restore", "📂", "ok", "Restore to device"))
elif whole_key and not locked:
rows.append(_Action("archive", "💾", "", "Archive contact"))
rows.append(_Action("remove", "🗑", "err", "Delete contact…"))
return rows
locked = bool(lockable and store.is_locked(dev_pub, contact.public_key))
info_actions = management(locked)
try:
adv_type = int(node_type) if node_type is not None else 1
except (TypeError, ValueError):
@@ -1410,6 +1463,13 @@ async def open_node_detail(
if await _restore_archived(ctx, contact, self_key, label):
contact_removed = True
break
elif action in ("lock", "unlock"):
# No confirm and no notice: the lock is reversible from the very row that set
# it, and the row turning into its opposite is the acknowledgement.
assert contact is not None and store is not None # only offered when lockable
locked = action == "lock"
store.set_locked(dev_pub, contact, locked)
screen.replace_info_actions(management(locked))
elif action == "archive":
assert contact is not None
if await _archive_contact(ctx, contact, self_key, label):
@@ -1451,7 +1511,7 @@ async def _archive_contact(ctx: AppContext, contact: Contact, self_key: str, lab
"""Confirm and archive one contact off the device; ``True`` once it is gone from the radio.
The single-contact counterpart to the bulk sweep (see
:func:`~meshterm.ui.purge_screen.purge_contacts`), doing exactly what the sweep does to
:func:`~meshterm.ui.sweep_screen.archive_contacts`), doing exactly what the sweep does to
each of its victims: remove from the device, then record it in the cross-session store
with an archive stamp so nothing is actually lost.
@@ -1478,10 +1538,30 @@ async def _archive_contact(ctx: AppContext, contact: Contact, self_key: str, lab
assert isinstance(ctx.ui, TuiUi) # guaranteed by open_node_detail
session = ctx.ui.session
# The contact is named as the Contacts list names it: the type mark in its own colour,
# then the name in its key-derived hue (a bare id standing in for a nameless one is
# ``node.unknown``). The prose carries the amber span by span rather than as the
# prompt's base style, whose bold Rich would merge into the mark.
glyph, glyph_style = NODE_GLYPHS.get(contact.node_type, DEFAULT_GLYPH)
prompt = Text()
prompt.append("Archive ", style="warn")
prompt.append(f"{glyph} ", style=glyph_style)
prompt.append(
label,
style=(
name_style(label, contact.public_key or contact.key_prefix)
if contact.name
else "node.unknown"
),
)
prompt.append(
"? It comes off this device's contact list, freeing a slot for a new one. MeshTerm "
"keeps it — with its key, reception history and messages — and you can restore it "
"at any time.",
style="warn",
)
if not await ctx.ui.dialog(
f"Archive {label}? It comes off this device's contact list, freeing a slot for a "
"new one. MeshTerm keeps it — with its key, reception history and messages — and "
"you can restore it at any time.",
prompt,
[("Cancel", False), ("Archive", True)],
title="Archive contact",
default=1,
@@ -1515,7 +1595,7 @@ async def _restore_archived(ctx: AppContext, contact: Contact, self_key: str, la
"""Write an archived contact back onto the device; ``True`` once it is live again.
The inverse of the Contacts sweep, one contact at a time (see
:mod:`~meshterm.ui.purge_screen`). The device write comes **first** and the store's
:mod:`~meshterm.ui.sweep_screen`). The device write comes **first** and the store's
archive mark is cleared only once it succeeded — a failed write must never leave a
contact listed as live on a radio that doesn't hold it, which would be a row you cannot
message and cannot restore.
@@ -1560,7 +1640,7 @@ async def _remove_contact(ctx: AppContext, contact: Contact, self_key: str, labe
"""Confirm and drop one contact from the device; ``True`` once it is gone.
The single-contact counterpart to the Contacts list's bulk sweep (see
:func:`~meshterm.ui.purge_screen.purge_contacts`) — but a *deletion* where that one
:func:`~meshterm.ui.sweep_screen.archive_contacts`) — but a *deletion* where that one
archives: this is the way to make MeshTerm forget a node entirely, so it removes the
contact in both places, because the list a screen sees is the *union* of the two: the device's
own contact table, and the contacts MeshTerm remembers for that device (see
@@ -1641,7 +1721,7 @@ async def _remove_contact(ctx: AppContext, contact: Contact, self_key: str, labe
ctx.contact_store.forget(dev_pub, contact.public_key)
# The device's table just changed under the session cache; the list we return to re-reads.
ctx.devstate.invalidate_contacts()
# No success notice: unlike the purge — whose outcome is a count nobody could predict —
# No success notice: unlike the archive sweep — whose outcome is a count nobody could predict —
# this one is self-evident. The page closes on the contact it detailed and the list
# behind it comes back without the row, which says it better than a dialog to dismiss.
return True
+110
View File
@@ -0,0 +1,110 @@
# SPDX-License-Identifier: Apache-2.0
"""OKLab — the perceptual colour space the UI measures and shades ``#rrggbb`` values in.
Where the app has to ask a question about two colours *as the eye sees them* — are these
two chip fills the same to a reader, what is a slightly darker version of this one — it
asks here, not in RGB and not by hue. sRGB is a device encoding: a fixed step in it is a
different size to the eye in every region of the wheel (a step across the greens is a
whisper, the same step across the cyans a shout), and a hue-only comparison is worse,
blind to lightness and saturation altogether. OKLab (Björn Ottosson, 2020) is built so
that Euclidean distance approximates perceived difference and so that changing ``L``
alone changes lightness and nothing else, which is exactly the two operations wanted.
Everything is the reference transform, no dependencies: linear sRGB → LMS → cube root →
Lab. ``L`` runs 0 (black) to 1 (white); ``a``/``b`` are the two chroma axes, roughly
±0.4 at the sRGB gamut's edge. A distance of about 0.02 is a just-noticeable difference
between two large patches.
"""
from __future__ import annotations
import math
#: An OKLab triple ``(L, a, b)``.
Lab = tuple[float, float, float]
def _linear(channel: float) -> float:
"""The sRGB transfer function, 0–1 encoded → 0–1 linear."""
return channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4
def _encoded(channel: float) -> float:
"""The inverse: 0–1 linear → 0–1 sRGB encoded."""
return 12.92 * channel if channel <= 0.0031308 else 1.055 * channel ** (1 / 2.4) - 0.055
def from_hex(colour: str) -> Lab:
"""``#rrggbb`` → OKLab.
Args:
colour: A six-digit hex colour, ``#`` optional, either case.
Raises:
ValueError: If the string is not six hex digits.
"""
raw = colour.removeprefix("#")
if len(raw) != 6:
raise ValueError(f"not a #rrggbb colour: {colour!r}")
r, g, b = (_linear(int(raw[i : i + 2], 16) / 255) for i in (0, 2, 4))
l_ = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) ** (1 / 3)
m_ = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) ** (1 / 3)
s_ = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) ** (1 / 3)
return (
0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
)
def to_hex(lab: Lab) -> str:
"""OKLab → ``#rrggbb``, each channel clipped to the sRGB gamut.
Clipping is the honest answer for the one caller that can leave the gamut — a
saturated fill darkened at constant chroma pushes a channel below zero — because the
clipped colour is the nearest the terminal can show, and it stays the right hue to
the eye at the shifts the app asks for.
"""
lightness, a, b = lab
l_ = lightness + 0.3963377774 * a + 0.2158037573 * b
m_ = lightness - 0.1055613458 * a - 0.0638541728 * b
s_ = lightness - 0.0894841775 * a - 1.2914855480 * b
lms = (l_**3, m_**3, s_**3)
linear = (
4.0767416621 * lms[0] - 3.3077115913 * lms[1] + 0.2309699292 * lms[2],
-1.2684380046 * lms[0] + 2.6097574011 * lms[1] - 0.3413193965 * lms[2],
-0.0041960863 * lms[0] - 0.7034186147 * lms[1] + 1.7076147010 * lms[2],
)
return "#" + "".join(f"{round(_encoded(min(1.0, max(0.0, c))) * 255):02x}" for c in linear)
def distance(first: str, second: str) -> float:
"""Perceived difference between two ``#rrggbb`` colours: Euclidean distance in OKLab.
``0`` for the same colour; about ``0.02`` at the threshold of noticing between two
large patches; ``1`` is black to white.
"""
return math.dist(from_hex(first), from_hex(second))
def shaded(colour: str, by: float, *, floor: float | None = None) -> str:
"""``colour`` moved ``by`` in lightness, away from whichever end it is nearer.
A light colour comes back darker and a dark one lighter, at the same chroma, so the
result is recognisably *the same colour, shaded* rather than a different one — the
version of a fill that draws a line on itself.
Args:
colour: The ``#rrggbb`` to shade.
by: The lightness shift, in OKLab ``L`` (``0.1`` is a clear step, ``0.25`` bold).
floor: If given, the shift starts from this lightness rather than the colour's
own where that is further along — a caller guaranteeing the result clears a
*second* colour's lightness by ``by`` as well as this one's passes that
colour's ``L``. Read as "at least this far from both".
"""
lightness, a, b = from_hex(colour)
if lightness >= 0.5:
start = lightness if floor is None else min(lightness, floor)
return to_hex((start - by, a, b))
start = lightness if floor is None else max(lightness, floor)
return to_hex((start + by, a, b))
+82 -18
View File
@@ -15,13 +15,17 @@ drawn*, so every surface the survey found can eventually route through it:
chevron: the oh-my-posh look, at one cell a seam. The chip fill is the node's
hash-derived hue (:func:`~meshterm.ui.theme.node_style`), our own node the map's yellow
``★`` on a neutral dark grey, a faded hop dark slate, a keyless hop grey. Two chips that
land on the *same* fill — a mirrored return leg, a stretch of keyless greys — take the
one exception the interlock can't draw, dropping the background so the page cuts the
wedge (:meth:`PathLine._seam`). Hops elided out of the middle break the ribbon instead
of joining it: the mark sits bare on the page between a closing point and the next
chip's notch (:func:`elision_hop`), because a filled ``⋯`` chip would read as a node by
that name. A chip caught by a *cut* — a lane that ran out, a line scrolled past its
edge — breaks off on a half block in its own fill
land on the *same* fill — a mirrored return leg, a stretch of keyless greys — or on two
fills too close to tell apart (under :data:`SEAM_BLUR` apart in OKLab, the perceptual
distance :mod:`~meshterm.ui.oklab` measures) take the one exception the interlock can't
draw: the seam is the *thin* chevron instead, the previous fill shaded a step darker
(lighter, for a dark fill) and drawn on the next, so the ribbon runs on unbroken and the
join is a line the chip draws on itself (:meth:`PathLine._seam`). Hops elided out of
the middle break the
ribbon instead of joining it: the mark sits bare on the page between a closing point and
the next chip's notch (:func:`elision_hop`), because a filled ``⋯`` chip would read as a
node by that name. A chip caught by a *cut* — a lane that ran out, a line scrolled past
its edge — breaks off on a half block in its own fill
(:func:`cut_mark`), and that crack is what says the segment continues; only chips
crack, an arrow line still ellipsizes. A line's two *outer* ends read the same way:
a path that begins at its origin and ends at its destination opens and closes flat (a
@@ -85,17 +89,24 @@ from rich.measure import Measurement
from rich.text import Text
from ..core.models import LOCAL_DEVICE_LABEL
from . import oklab
from .marks import SELF_MARK
from .termfont import powerline_enabled, powerline_full
from .theme import active_theme, node_style
#: The powerline solid right-pointing triangle (U+E0B0) — the only glyph the widget
#: draws, seam and closing edge alike, deliberately from the *core* set so every
#: recommended font qualifies (the rounded caps at U+E0B4+ exist only in full Nerd
#: Font patches). A path only ever flows one way, so the point only ever faces right;
#: what changes is whether it lands on a field (a seam) or on the page (an edge).
#: The powerline solid right-pointing triangle (U+E0B0) — the glyph the widget draws
#: at every seam and closing edge, deliberately from the *core* set so every recommended
#: font qualifies (the rounded caps at U+E0B4+ exist only in full Nerd Font patches). A
#: path only ever flows one way, so the point only ever faces right; what changes is
#: whether it lands on a field (a seam) or on the page (an edge).
POWERLINE_SEP = "\ue0b0"
#: The thin right-pointing chevron (U+E0B1), the solid point's outline sibling in the
#: same core set — powerline's own mark for a join *inside* one colour. It draws the seam
#: between two chips whose fills the eye can't tell apart (:meth:`PathLine._seam`): the
#: previous fill, shaded, drawn on the next, where a solid point would have vanished.
POWERLINE_THIN = "\ue0b1"
#: The rounded caps (U+E0B6 opening, U+E0B4 closing) that finish a path's two outer
#: ends as a lozenge. These live in the *extended* block, which only a full Nerd Font
#: patch carries (:func:`~meshterm.ui.termfont.powerline_full`) — a terminal with just
@@ -249,6 +260,48 @@ def _style_hex(style: str) -> str | None:
return None
#: How far apart two chip fills must sit, as the eye measures it, for the seam between
#: them to interlock: closer than this and the solid point would be laid on a field it
#: can't be told from, so the seam is the thin chevron instead (:meth:`PathLine._seam`).
#: The unit is OKLab distance (:func:`~meshterm.ui.oklab.distance`), where ``0.02`` is a
#: just-noticeable difference between two large patches and ``1`` is black to white.
#:
#: The rule it replaced was first key bytes under ``0x10`` apart on the hue wheel (JP,
#: 2026-09-15), which measures ``0.087`` at the median hue but ``0.026`` across the greens
#: and ``0.165`` across the cyans: the wheel is not uniform to the eye, so a hue gap
#: blurred too little there and too much here, and an sRGB distance would only have
#: restated the hue gap (a fixed byte step is a near-constant ``55``–``60`` of 8-bit RGB
#: all the way round). The value was then set by eye on a ladder of the spectrum's own
#: pairs drawn both ways (JP, 2026-09-16), and it sits where the solid point is not just
#: detectable at one cell but *found* without looking for it: ``0.06``, three noticeable
#: differences. Tighter (``0.035``) left wedges a reader had to hunt for; looser
#: (``0.10``) blurred pairs whose point still read plainly; ``0.05`` was a shade too
#: tight. ``0x10`` of green is ``0.026`` and blurs; ``0x10`` of red is ``0.124`` and
#: doesn't; the two greys, ``0.18`` apart, keep their interlock on the metric alone.
SEAM_BLUR = 0.06
#: How far the thin chevron's colour steps from the fill it is drawn in, in OKLab ``L``
#: — a lightness shift alone, so it reads as *that chip's colour, shaded* rather than as
#: a third colour on the line. The step is taken from the previous fill but measured from
#: whichever of the two fills is further along (:func:`~meshterm.ui.oklab.shaded`'s
#: ``floor``), so the mark clears the fill it actually sits on by this much even when the
#: two differ by everything :data:`SEAM_BLUR` allows. A lightness step is what a thin line
#: is seen by (the eye resolves detail in luminance, not in chroma), so it needs nothing
#: like the blur distance to read: ``0.10`` is five noticeable differences, a clear line
#: that is still plainly the chip's own colour, chosen on the same ladder (JP, 2026-09-16).
SEAM_SHADE = 0.10
def _fills_blur(before: str, after: str) -> bool:
"""Whether two fills are the same to the eye: identical, or under :data:`SEAM_BLUR`."""
return before == after or oklab.distance(before, after) < SEAM_BLUR
def _seam_ink(before: str, after: str) -> str:
"""The thin chevron's colour: ``before`` shaded :data:`SEAM_SHADE` past both fills."""
return oklab.shaded(before, SEAM_SHADE, floor=oklab.from_hex(after)[0])
#: A chip's fill, as it appears in a rendered span's style — the ``on #rrggbb`` half.
#: Only :meth:`PathLine._chip` sets a background, which is what makes this the test for
#: "is there a chip here": a seam's point, a notch, and every arrow-mode span carry a
@@ -960,12 +1013,23 @@ class PathLine:
to spend two cells leaving a sliver of page between every pair, insurance against
a collision that the palette makes rare).
Two fills that *do* land the same are the exception the interlock cannot draw —
Two fills the eye can't tell apart are the exception the interlock cannot draw —
a chevron in its own background is no chevron — and they are not always bad luck:
a mirrored return leg is a run of identically faded hops by construction, and so
is a stretch of keyless greys. There the point drops its background and the page
shows through the wedge instead, which is the one thing that still separates two
blocks of one colour. One cell either way.
is a stretch of keyless greys. Nor is "can't tell apart" the same as "identical":
two hues a few steps apart on the wheel draw a point the eye can't find either,
one shade of green on the next, so the test is a perceptual distance under
:data:`SEAM_BLUR` (:func:`_fills_blur`), not equality and not a hue gap.
There the seam is the **thin** chevron, :data:`POWERLINE_THIN`, in the previous
chip's own fill shaded a step darker — lighter, for a dark fill — and drawn on
the fill ahead (:func:`_seam_ink`, JP, 2026-09-16): powerline's own mark for a
join inside one colour, and the chip it belongs to drawing a line on itself, so
the ribbon runs on unbroken and nothing on it is a colour that isn't a chip's.
The solid point drawn bare used to cut a wedge of page out of the route here (a
run of near hues read as dashes, and on a light terminal the wedge was a hole),
and a chevron in the dark chip ink read as a mark laid on the route rather than a
seam in it. One cell either way.
Args:
before: The fill the point is drawn in (the previous chip's).
@@ -974,8 +1038,8 @@ class PathLine:
Returns:
The seam's single styled cell.
"""
if before == after:
return Text(POWERLINE_SEP, style=before) # fg only — the page cuts the wedge
if _fills_blur(before, after):
return Text(POWERLINE_THIN, style=f"{_seam_ink(before, after)} on {after}")
return Text(POWERLINE_SEP, style=f"{before} on {after}")
@staticmethod
+15 -4
View File
@@ -13,8 +13,9 @@ The input line behaves like a shell:
:class:`~meshterm.core.remote_store.RemoteStore`) so last week's incantation is one
keystroke away next session.
* **Tab** completes the current text against the known repeater commands — the fixed
verbs plus every ``get``/``set`` spelling in the settings catalog — and the best
match previews muted, ghost-text style, after the cursor.
verbs, every ``get``/``set`` spelling in the settings catalog, and any key *this node*
taught us here on an earlier visit — and the best match previews muted, ghost-text
style, after the cursor.
* **Enter** sends. The transcript keeps every exchange of the session, newest at the
bottom, and the view follows the prompt as it grows.
@@ -25,7 +26,7 @@ the radio, the history store, and the reply plumbing) and feeds replies back thr
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Iterable
from rich.console import Group
from rich.text import Text
@@ -70,6 +71,7 @@ class RemoteCliScreen(Screen):
history: list[str],
send: Callable[[str], None],
session: object,
extra_keys: Iterable[str] = (),
) -> None:
"""Create the command line over a node's persisted history.
@@ -78,6 +80,9 @@ class RemoteCliScreen(Screen):
history: The node's prior commands, oldest first (recalled with ↑).
send: Commits one command (owner-guarded; no-op while one is flying).
session: The running TUI session (for repaints).
extra_keys: Settings *this node* has that the catalog hasn't, found here on an
earlier visit. They complete like any other key: the prompt is where they
were learned, so it is where they should be one Tab away.
"""
super().__init__()
self.title = f"Command line — {node_label}"
@@ -89,7 +94,13 @@ class RemoteCliScreen(Screen):
self._history = list(history)
self._recall: int | None = None # index into history while ↑/↓ browse it
self._draft = "" # what was typed before recall began, restored on ↓ past the end
self._completions = known_commands()
self._completions = sorted(
{
*known_commands(),
*(f"get {key}" for key in extra_keys),
*(f"set {key} " for key in extra_keys),
}
)
self.busy = False
self._spinner = Spinner()
self._prompt_line = 0 # body index of the input line (pinned into view)
+244 -16
View File
@@ -21,6 +21,16 @@ Beyond the settings, the action rows cover the box itself — advert, clock sync
admin password, reboot, each behind its own floating confirmation — and the **Command
line** opens the readline-style remote CLI (:mod:`meshterm.ui.remote_cli`) for anything
the catalog doesn't spell.
That command line is also where the page *grows*. Third-party builds carry settings the
catalog has never heard of, and MeshTerm can't know which build a node is running without
asking it — every question being one paced round trip, and every guessed row a dead ``n/a``
on everyone else's page. So nothing is probed speculatively: a ``get``/``set`` the reader
ran here, which *this node* answered, earns a row on *this node's* page under **Extra**
(:func:`learn_from_cli`). The round trip was one the reader was spending anyway, the catalog
doesn't grow, and no other node's page changes. A row is only ever removed by hand
(``Del``): a key that stops answering reads ``n/a`` like any other, because a misread reply
would otherwise delete the one thing about this node nobody else can restore.
"""
from __future__ import annotations
@@ -36,12 +46,18 @@ from rich.text import Text
from ..core.models import Contact
from ..core.remote_config import (
DISCOVERED_CATEGORY,
NEVER_STORED,
REPEATER_SETTINGS,
RemoteSetting,
composite_members,
composite_reads,
discovered_setting,
get_setting,
learnable_from_get,
normalize_value,
parse_reply_value,
parse_setting_command,
range_hint,
read_plan,
reply_is_error,
@@ -54,6 +70,7 @@ from .menus import (
PICK_LOCATION_LABEL,
confirm_discard,
exit_rows,
fit_cells,
icon_lane,
lane_header,
lane_row,
@@ -63,7 +80,7 @@ from .menus import (
)
from .trace_screen import TracingDialog
from .tui import Choice, Separator
from .tui.select import SelectScreen, splice_hint
from .tui.select import DeleteRequest, SelectScreen, splice_hint
from .tui.spinner import Spinner, spinner_interval
if TYPE_CHECKING:
@@ -88,6 +105,50 @@ _CANCEL = "__cancel__"
#: The footer atom naming the chord that re-reads the highlighted setting.
READ_ONE_HINT = "^R read"
#: The footer atom naming the key that drops a discovered row (surfaced on those rows only).
FORGET_HINT = "Del forget"
#: The narrowest the value lane may be squeezed to for a discovered setting — a floor, so a
#: page whose catalog rows are all ``?`` still shows something of what the node answered.
_EXTRA_VALUE_MIN = 14
def _extra_value(text: str, width: int) -> str:
"""One discovered value, fitted for the lane: whitespace collapsed, a long reply cut.
A discovered setting's value is whatever the node said, and a build that answers a whole
diagnostic line (``desired=off effective=off supported=yes …``) would otherwise set the
value lane's width — one width, for every row on the page — and push each setting's
description off the edge. The row is cut here instead of the page being reshaped around
it; the command line is where the whole reply is read.
"""
flat = " ".join(text.split())
return fit_cells(flat, width) if cell_len(flat) > width else flat
def _spec_for(key: str, cache: dict) -> RemoteSetting | None:
"""The setting ``key`` names on this node: the catalog's, or its own discovered one."""
spec = get_setting(key)
if spec is not None:
return spec
cached = cache.get(key)
return discovered_setting(key) if cached is not None and cached.discovered else None
def _discovered_specs(cache: dict) -> list[RemoteSetting]:
"""This node's discovered settings, in key order — rows the catalog knows nothing about."""
return [discovered_setting(key) for key, cached in sorted(cache.items()) if cached.discovered]
def _all_specs(cache: dict) -> list[RemoteSetting]:
"""Every setting this node's page draws: the catalog, plus what it taught us itself."""
return [*REPEATER_SETTINGS, *_discovered_specs(cache)]
def _extra_keys(cache: dict) -> frozenset[str]:
"""The keys on this node's page that came from the node rather than from the catalog."""
return frozenset(key for key, cached in cache.items() if cached.discovered)
@dataclass(frozen=True, slots=True)
class ReadOne:
@@ -121,6 +182,9 @@ class AdminMenu(SelectScreen):
floating = False
#: The keys on this node's page that the catalog hasn't got, refreshed with the rows.
extra_keys: frozenset[str] = frozenset()
def __init__(self, title: str, items: list, **kwargs: Any) -> None:
"""Build the page's list, its rows ending at the edge rather than sliding under ←→.
@@ -134,14 +198,28 @@ class AdminMenu(SelectScreen):
kwargs.setdefault("hscroll", False)
super().__init__(title, items, **kwargs)
def _readable_key(self) -> str | None:
"""The highlighted row's setting key, when it is a setting the node can be asked."""
def _row_key(self) -> str | None:
"""The highlighted row's setting key, or ``None`` on an action row."""
current = self._current_choice()
if current is None or not isinstance(current.value, str):
return None
spec = get_setting(current.value)
return current.value
def _readable_key(self) -> str | None:
"""The highlighted row's setting key, when it is a setting the node can be asked."""
key = self._row_key()
if key is None:
return None
if key in self.extra_keys:
return key # a discovered row is a string setting, and always re-readable
spec = get_setting(key)
return spec.key if spec is not None and spec.readable else None
def _forgettable_key(self) -> str | None:
"""The highlighted row's key, when it is a discovered row Del can drop."""
key = self._row_key()
return key if key is not None and key in self.extra_keys else None
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The list's own hint, plus the read atom while a readable setting is highlighted."""
@@ -150,16 +228,26 @@ class AdminMenu(SelectScreen):
@property
def fkey_lane(self):
"""The list's lane with ``Read`` on F3 — dim on a row there is nothing to read.
"""The list's lane with ``Read`` on F3 and ``Forget`` behind it — dim where inert.
F1/F2 are this grouped list's section jumps and F4/F5 the pager; F3 is the slot a
select list spends on its own verb. Dim rather than empty on an action row: reading
is a thing on this screen, just not for the row the cursor is on.
select list spends on its own verb. Read and Forget are the same slot's two halves
because they are the same subject — *this row* — and they are never both live: a
catalog row can be read and not forgotten, and only a discovered row can be dropped.
Dim rather than empty on an action row: both are things on this screen, just not for
the row the cursor is on.
"""
from .tui.fkeys import FPair
lane = list(super().fkey_lane)
lane[2] = FPair("Read", "retry", enabled=self._readable_key() is not None)
lane[2] = FPair(
"Read",
"retry",
opp_label="Forget",
opp_action="delete",
enabled=self._readable_key() is not None,
opp_enabled=self._forgettable_key() is not None,
)
return lane
def handle(self, action: str, data: str = "") -> None:
@@ -292,7 +380,9 @@ async def _admin_session(ctx: AppContext, device: Device, node: Contact) -> dict
title,
items,
footer_hint="↑↓ move · type to filter · Enter select · Esc back",
delete_hint=FORGET_HINT,
)
menu.extra_keys = _extra_keys(cache)
async with session.stay(menu) as visit:
while True:
choice = await visit.result()
@@ -304,13 +394,17 @@ async def _admin_session(ctx: AppContext, device: Device, node: Contact) -> dict
continue
return {"node": node.name, "applied": applied}
if isinstance(choice, ReadOne):
spec = get_setting(choice.key)
spec = _spec_for(choice.key, cache)
if spec is not None:
await read_settings(ctx, device, node, [spec])
elif isinstance(choice, DeleteRequest):
await _forget_discovered(ctx, node, str(choice.value), pending)
elif choice == _APPLY:
applied += await _apply(ctx, device, node, pending)
elif choice == _READ:
await read_settings(ctx, device, node, REPEATER_SETTINGS)
# This node's page, not the catalog: a discovered row is refreshed by the
# same sweep as everything else, and reads n/a if it stops answering.
await read_settings(ctx, device, node, _all_specs(cache))
elif choice == _LOCATION:
await _stage_location(ctx, cache, pending)
elif choice == _CLI:
@@ -349,7 +443,7 @@ async def _admin_session(ctx: AppContext, device: Device, node: Contact) -> dict
danger=True,
)
else: # a setting key
spec = get_setting(str(choice))
spec = _spec_for(str(choice), cache)
if spec is not None and not spec.writable:
# A read-only fact has nothing to stage; asking again is all Enter can do.
await read_settings(ctx, device, node, [spec])
@@ -359,6 +453,7 @@ async def _admin_session(ctx: AppContext, device: Device, node: Contact) -> dict
# the values are the ones the action just produced.
cache = ctx.remote_store.settings(node)
title, items = _menu_items(node, cache, pending)
menu.extra_keys = _extra_keys(cache)
menu.replace_items(items, title=title)
@@ -383,6 +478,25 @@ def _menu_items(node: Contact, cache: dict, pending: dict[str, str]) -> tuple[st
rows.append((spec.label, _value_text(spec, cache, pending), spec.help, spec.key))
sections.append((category, rows))
# What this node taught us itself, last and in its own section — absent entirely on a
# node that has taught us nothing, which is every node until someone asks it something.
# Its values are fitted to the lane the catalog's rows already need: the value lane is
# one width for the whole page, so an unbounded reply here would push every setting's
# description right, off the edge of a 72-column screen, and reshape a page the reader
# opened to read the catalog.
extra = _discovered_specs(cache)
if extra:
budget = max(
_EXTRA_VALUE_MIN,
max((cell_len(value.plain) for _, rows in sections for _, value, _, _ in rows)),
)
sections.append(
(
DISCOVERED_CATEGORY,
[(s.label, _value_text(s, cache, pending, budget), s.help, s.key) for s in extra],
)
)
label_w = max(cell_len(label) for _, rows in sections for label, _, _, _ in rows)
value_w = max(cell_len(value.plain) for _, rows in sections for _, value, _, _ in rows)
@@ -394,7 +508,13 @@ def _menu_items(node: Contact, cache: dict, pending: dict[str, str]) -> tuple[st
items.append(section_heading(category))
for label, value, help_text, key in rows:
items.append(
Choice(title=lane_row(label, value, help_text, label_w, value_w), value=key)
Choice(
title=lane_row(label, value, help_text, label_w, value_w),
value=key,
# Only a discovered row can be dropped: a catalog row that goes away
# would come straight back on the next paint, the catalog still naming it.
deletable=category == DISCOVERED_CATEGORY,
)
)
# ↻ and ⌨ are one cell where 📡 🕒 🔐 🔄 are two, so the column is measured once and
@@ -424,7 +544,9 @@ def _menu_items(node: Contact, cache: dict, pending: dict[str, str]) -> tuple[st
return title, items
def _value_text(spec: RemoteSetting, cache: dict, pending: dict[str, str]) -> Text:
def _value_text(
spec: RemoteSetting, cache: dict, pending: dict[str, str], width: int = _EXTRA_VALUE_MIN
) -> Text:
"""One setting's VALUE lane: what the node last said, and any staged arrow.
Four states, each its own word: the value; ``empty`` for a string the node holds blank;
@@ -441,10 +563,14 @@ def _value_text(spec: RemoteSetting, cache: dict, pending: dict[str, str]) -> Te
value = Text("n/a", style="muted")
elif cached.value == "":
value = Text("empty", style="muted")
elif spec.discovered:
value = Text(_extra_value(cached.value, width))
else:
value = Text(spec.display(cached.value))
if spec.key in pending:
value.append(f" → {spec.display(pending[spec.key])}", style="warn")
staged = pending[spec.key]
shown = _extra_value(staged, width) if spec.discovered else spec.display(staged)
value.append(f" → {shown}", style="warn")
return value
@@ -453,7 +579,7 @@ def _value_text(spec: RemoteSetting, cache: dict, pending: dict[str, str]) -> Te
async def _stage_setting(ctx: AppContext, key: str, cache: dict, pending: dict[str, str]) -> None:
"""Prompt for one setting's new value and stage it (nothing is sent yet)."""
spec = get_setting(key)
spec = _spec_for(key, cache)
if spec is None or not spec.writable: # pragma: no cover - menu offers only real keys
return
cached = cache.get(key)
@@ -508,6 +634,38 @@ async def _stage_setting(ctx: AppContext, key: str, cache: dict, pending: dict[s
pending[key] = value
async def _forget_discovered(
ctx: AppContext, node: Contact, key: str, pending: dict[str, str]
) -> None:
"""Drop one discovered row from this node's page, behind a confirm.
This is the *only* way a discovered row is removed. Letting a read drop one — a key the
node no longer answers is real enough, after a board swap onto a reused identity — would
mean a single misread reply silently deleting the row: a truncated line, a node answering
mid-reboot, a stray frame correlated to the wrong command. A catalog row costs an ``n/a``
when that happens and nothing is lost, because the catalog still names the key; a
discovered row's key is known only here, and the reader may not remember what it was. So
the same ``n/a`` is all a read may do, and removing is a decision.
It is a red confirm like any other single-record delete, though nothing on the node
changes: what goes is what MeshTerm remembers, and asking the key again brings it back.
"""
from .tui import CANCEL
choice = await ctx.ui.dialog(
f"Stop showing {key} on {node.name}? Nothing on the node changes — "
"ask it for the setting again and the row comes back.",
[("Cancel", CANCEL), ("Forget", "forget")],
title="Forget setting",
default=1,
destructive=True,
)
if choice != "forget":
return
pending.pop(key, None)
ctx.remote_store.forget_setting(node, key)
async def _stage_location(ctx: AppContext, cache: dict, pending: dict[str, str]) -> None:
"""Pick the node's advertised location on the map and stage both coordinates it sets.
@@ -598,8 +756,11 @@ async def _apply(ctx: AppContext, device: Device, node: Contact, pending: dict[s
remember_reply(ctx, node, fills, reply)
writes = write_plan(pending, _known_values(ctx, node))
# The catalog plus this node's own discovered rows: a staged key that only this node
# has is still a row with a label to name in the outcome, and a write to count.
specs = _all_specs(ctx.remote_store.settings(node))
for i, write in enumerate(writes, start=1):
staged = [s for s in REPEATER_SETTINGS if s.key in write.values and s.key in pending]
staged = [s for s in specs if s.key in write.values and s.key in pending]
names = ", ".join(f"{s.label} = {s.display(write.values[s.key])}" for s in staged)
if write.missing:
unread = ", ".join(s.label for s in map(get_setting, write.missing) if s)
@@ -675,6 +836,71 @@ def remember_reply(ctx: AppContext, node: Contact, fills: list[RemoteSetting], r
return got
def learn_from_cli(ctx: AppContext, node: Contact, command: str, reply: str) -> None:
"""Fold what one command-line exchange proved about ``node`` into its page.
The command line is the only place a setting outside the catalog can be reached, so it
is also the only place one can be *found*: a ``get``/``set`` this node answered is proof
the key is there, and the row it earns costs no round trip nobody asked for. The catalog
doesn't grow — a key learned here belongs to this node alone (see
:func:`~meshterm.core.remote_config.discovered_setting`), and every other node's page is
what it always was.
Three cases, in the order they are tested:
* A **composite's own key** (``get radio``) fills all four of its fields, exactly as a
read of any one of them does.
* A **catalog key** folds into the cache the way the sweep or an Apply would — which is
also how ``get tx`` at the command line stopped leaving the TX power row stale.
* Anything else is **discovered**, and the two verbs are not equally good evidence.
``handleSetCmd`` matches a key with its trailing space and refuses an unknown one
outright, so an accepted write is proof. ``handleGetCmd`` matches on a bare prefix and
will answer a key it hasn't got out of a shorter key's branch, so a read is proof only
when the reply cannot be that shorter key's
(:func:`~meshterm.core.remote_config.learnable_from_get`).
``prv.key`` is never stored, whichever way it was asked
(:data:`~meshterm.core.remote_config.NEVER_STORED`).
"""
parsed = parse_setting_command(command)
if parsed is None or parsed.key in NEVER_STORED:
return
members = composite_members(parsed.key)
spec = _spec_for(parsed.key, ctx.remote_store.settings(node))
if parsed.verb == "get":
if members:
remember_reply(ctx, node, members, reply)
elif spec is not None:
remember_reply(ctx, node, [spec], reply)
elif not reply_is_error(reply) and learnable_from_get(
parsed.key, reply, _known_values(ctx, node)
):
found = discovered_setting(parsed.key)
value = parse_reply_value(found, reply)
if value is not None:
ctx.remote_store.remember_discovered(node, found.key, value)
return
if reply_is_error(reply):
return # a refused write says nothing about the key, and nothing about its value
if members:
for member in members:
value = parse_reply_value(member, parsed.value)
if value is not None:
ctx.remote_store.remember_setting(node, member.key, value)
return
if spec is not None and not spec.discovered:
value = parse_reply_value(spec, parsed.value)
if value is None:
return # sent in words the catalog can't read back; leave the row as it was
ctx.remote_store.remember_setting(node, spec.key, value)
for other in spec.overlaps:
ctx.remote_store.forget_setting(node, other)
return
ctx.remote_store.remember_discovered(node, parsed.key, parsed.value)
async def read_settings(
ctx: AppContext, device: Device, node: Contact, specs: Iterable[RemoteSetting]
) -> None:
@@ -870,6 +1096,7 @@ async def _command_line(ctx: AppContext, device: Device, node: Contact) -> None:
if reply is None:
screen.failed(f"no reply within {_REPLY_TIMEOUT_S:.0f} s")
else:
learn_from_cli(ctx, node, command, reply)
screen.reply(reply)
screen = RemoteCliScreen(
@@ -877,6 +1104,7 @@ async def _command_line(ctx: AppContext, device: Device, node: Contact) -> None:
history=ctx.remote_store.history(node),
send=send,
session=session,
extra_keys=_extra_keys(ctx.remote_store.settings(node)),
)
async def animate() -> None:
+58 -19
View File
@@ -32,25 +32,53 @@ Validator = Callable[[str], "bool | str"]
#: Buffered output no taller than this many lines qualifies for the message dialog —
#: the OK popup :meth:`TuiUi.present` shows instead of a full result window. Kept small:
#: a dialog is an acknowledgement, not a reading surface.
#: a dialog is an acknowledgement, not a reading surface. It counts the lines the tool
#: *emitted*, before any wrapping: one sentence is one outcome however long it runs.
_DIALOG_MAX_LINES = 3
#: ... and no line may be wider than this many cells, so the popup stays a tidy box on a
#: typical terminal and never has to wrap. Anything wider (a long path, a verbose error)
#: falls back to the scrollable result window, which wraps properly.
_DIALOG_MAX_CELLS = 76
#: ... and no taller than this once wrapped to the box's width, which is what the box
#: actually has to draw. The two counts differ only for a long line, and that is the case
#: worth bounding: a three-line outcome stays a popup, while a note long enough to be a
#: page of reading opens the scrollable window it belongs in.
_DIALOG_MAX_WRAPPED = 8
#: What :class:`~meshterm.ui.tui.prompt.ButtonDialog` spends on chrome around its widest
#: line — its own ``dialog_width`` margin (panel padding, border, breathing room). Taken
#: off the platform's readable width, it leaves the cells a message may actually use.
_DIALOG_CHROME_CELLS = 12
def _dialog_max_cells() -> int:
"""The popup-qualifying width for the active platform.
def _dialog_wrap_cells() -> int:
"""How wide a line may be inside the message popup, on the active platform.
The regular platform keeps the long-standing 76. On the PicoCalc the dialog frame
itself is capped at ``cols - 6 = 47`` outer (43 inner), so a line qualifying at 76
would wrap inside the box it was promoted into — the gate shrinks to match.
A dialog sizes to its widest line and the compositor caps that to the terminal, so a
line long enough to need the cap is a line clipped on a readable-width screen. Wrapping
to what fits *there* is what lets a long outcome — the sentence explaining why a read
failed — stay a popup at all rather than commandeering the whole frame.
"""
from ..platforms import get_platform
return _DIALOG_MAX_CELLS if get_platform().frame_border else 43
platform = get_platform()
return max(24, platform.readable_cols - platform.dialog_margin - _DIALOG_CHROME_CELLS)
def _wrapped(lines: list[Text], width: int) -> list[Text]:
"""Re-flow ``lines`` to ``width`` cells, keeping every span they carry.
A blank line wraps to nothing, and a blank line is content in an outcome block, so it
is kept as itself. The wrap leaves the break's trailing space on the line it ended,
which the dialog would then centre the line around — so each one is trimmed.
"""
from .tui.render import _console
console = _console(width)
out: list[Text] = []
for line in lines:
parts = list(line.wrap(console, width)) or [Text("")]
for part in parts:
part.rstrip() # in place; Rich's rstrip returns nothing
out.extend(parts)
return out
class _NullBusy:
@@ -62,13 +90,19 @@ class _NullBusy:
def _collapse_to_message(buffered: list[RenderableType]) -> Text | None:
"""Collapse small, text-only buffered output into one dialog message.
"""Collapse small, text-only buffered output into one wrapped dialog message.
This is the gate for :meth:`TuiUi.present`'s popup upgrade: output qualifies only
when everything buffered is plain note text (:class:`Text` — a table or panel from
``show()`` disqualifies the lot) totalling at most :data:`_DIALOG_MAX_LINES` lines,
none wider than :data:`_DIALOG_MAX_CELLS` cells. Styling (the ``[ok]``/``[err]``
markup on outcome notes) is preserved in the joined message.
``show()`` disqualifies the lot) totalling at most :data:`_DIALOG_MAX_LINES` emitted
lines, and at most :data:`_DIALOG_MAX_WRAPPED` once re-flowed to the box's width.
Styling (the ``[ok]``/``[warn]`` markup on outcome notes) is preserved in the joined
message, which is what lets the dialog frame take its tone from it.
The width is a **wrap**, not a rejection. A one-sentence outcome is an outcome to
acknowledge however long it runs, and sending it to the scrollable window for being
140 cells wide made the least interesting results the ones that took over the screen —
a tool that failed before it produced anything most of all.
Args:
buffered: The renderables collected since the last present.
@@ -84,7 +118,8 @@ def _collapse_to_message(buffered: list[RenderableType]) -> Text | None:
lines.extend(item.split("\n") or [item])
if len(lines) > _DIALOG_MAX_LINES:
return None
if any(line.cell_len > _dialog_max_cells() for line in lines):
lines = _wrapped(lines, _dialog_wrap_cells())
if len(lines) > _DIALOG_MAX_WRAPPED:
return None
return Text("\n").join(lines)
@@ -295,7 +330,7 @@ class Ui:
async def dialog(
self,
prompt: str,
prompt: str | Text,
buttons: list[tuple[str, Any]],
*,
title: str = "",
@@ -313,6 +348,10 @@ class Ui:
sibling. Buttons follow the platform-dialog convention: the safe way out sits on
the left and the committing action on the right, which is also the sensible
``default`` so Enter commits it while Esc always backs out.
A pre-styled :class:`~rich.text.Text` prompt is drawn as it is, so it can name a
node in its own colours; the tier then themes only the border, and the prompt's
prose carries the tier's colour itself.
"""
raise NotImplementedError
@@ -599,7 +638,7 @@ class PlainUi(Ui):
async def dialog(
self,
prompt: str,
prompt: str | Text,
buttons: list[tuple[str, Any]],
*,
title: str = "",
@@ -870,7 +909,7 @@ class TuiUi(Ui):
async def dialog(
self,
prompt: str,
prompt: str | Text,
buttons: list[tuple[str, Any]],
*,
title: str = "",
@@ -13,9 +13,10 @@ the pushed Contacts list:
computed from the actual ranking rather than from arithmetic on the table size, because
protected contacts are never victims.
2. **Read the list, and edit it.** Every contact the sweep would take, weakest first, with
its percentile and the two or three facts that put it there — for the age rungs too,
since knowing how the contacts an age threshold caught actually *rank* is exactly the
check that threshold cannot do for itself. ``Delete`` drops a row from the list, so a
the evidence beside it in lanes — how lately it was heard, what it has sent, whether you
ever messaged it, how far out it sits. For the age rungs too, since seeing what the
contacts an age threshold caught have actually *done* is exactly the check that threshold
cannot do for itself. ``Delete`` drops a row from the list, so a
contact the reader wants to keep is spared without abandoning the whole sweep and picking
a shallower rung; ``←→`` scroll a row too wide for the terminal. Enter on a contact opens
its detail page to *look* at, with the page's own contact-management verbs withheld (see
@@ -24,15 +25,20 @@ the pushed Contacts list:
candidate list. It ends in the Apply/Back pair that
:func:`~meshterm.ui.menus.exit_rows` draws for staged changes, because that is what this
is: a choice with a cost on both sides, not an exit.
3. **Confirm.** An amber Cancel/Purge dialog, not the red typed gate a deletion gets: this
3. **Confirm.** An amber Cancel/Archive dialog, not the red typed gate a deletion gets: this
is reversible. Every contact keeps its key, its reception history and its transcripts,
the Archived list is one row away on the Contacts screen, and a restore is a single
write. Saving the red for what cannot be undone is what keeps the red meaning anything.
**Only the percentile is ever shown.** A raw score means nothing without the distribution
it came from, and would need a legend the moment the weights were ever retuned; a contact's
rank against your other contacts explains itself. See :mod:`~meshterm.core.contact_score`
for the scoring itself, which is pure and lives in core.
**The preview shows the evidence, never the verdict.** Not the score — a weighted sum means
nothing without the distribution it came from, and would need a legend the moment the weights
were retuned. Not the percentile either, which led the row for one round: a rank is the
score's own reading of itself, so a lane of them restated the order the list was already in
and spent five columns saying "trust me". What an audit needs is the measurements, each in a
lane of its own kind — headed like the Contacts list heads the two they share — so a column
means one thing all the way down and the reader can check the sweep against what they know
rather than against its arithmetic. See :mod:`~meshterm.core.contact_score` for the scoring
itself, which is pure, lives in core, and still ranks the list.
**Archived, not deleted.** The sweep removes each contact from the *device* — freeing the
slot, which is the whole point — and records it in the cross-session store with an archive
@@ -50,9 +56,11 @@ from __future__ import annotations
import math
import time
from dataclasses import replace
from collections.abc import Callable
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING
from rich.cells import cell_len
from rich.text import Text
from ..core.connection import ContactNotOnDeviceError
@@ -62,19 +70,20 @@ from ..core.contact_score import (
sweep_candidates,
)
from ..core.models import Contact
from .menus import Lane, column_header, fit_cells, menu_rows, section_heading
from .menus import Lane, column_header, fit_cells, section_heading
from .theme import name_style
from .tui import Choice, Separator
from .tui.screen import CANCEL
from .widgets import DEFAULT_GLYPH, NODE_GLYPHS, _recency_style, format_age
if TYPE_CHECKING:
from ..context import AppContext
#: The keep-the-strongest rungs the target picker offers, as a share of the *sweepable*
#: contacts. Percentages rather than absolute counts because the score itself is only ever
#: read as a percentile: a rung and the lane it filters against then speak one language, and
#: the ladder means the same thing on a table of thirty and a table of three hundred without
#: a single value needing to be retuned. Coarse at the aggressive end — the difference
#: contacts. Percentages rather than absolute counts because a share is the one form the
#: ladder can state once and have mean the same thing on a table of thirty and a table of
#: three hundred — the ranking underneath is a rank, so a count would have to be retuned
#: for every mesh and a share never does. Coarse at the aggressive end — the difference
#: between keeping 90% and 75% is a tidy-up, between 50% and 25% a decision.
KEEP_RUNGS: tuple[int, ...] = (90, 75, 60, 50, 25)
@@ -88,7 +97,7 @@ _DAY = 86400
#: The last-heard rungs, ``(label, min_age_seconds)``. A contact qualifies when its age is
#: *at least* that long; a never-heard contact is excluded here and swept only by
#: :data:`_NEVER`, so an age rung can't quietly take a contact that simply hasn't adverted
#: yet. The same ladder the age-only purge offered before this flow absorbed it.
#: yet. The same ladder the age-only sweep offered before this flow absorbed it.
AGE_RUNGS: tuple[tuple[str, int], ...] = (
("Not heard in 1 week", 7 * _DAY),
("Not heard in 1 month", 30 * _DAY),
@@ -100,95 +109,218 @@ AGE_RUNGS: tuple[tuple[str, int], ...] = (
#: The age-rung value meaning the never-heard bucket (contacts with no advert time at all).
_NEVER = -1
#: Percentile lane width, plus its gap — three digits and two cells of air.
_PCTL_W = 5
#: The gap between two lanes, in cells — the air that keeps a right-aligned value clear of
#: the one before it, and what a lane's declared width carries on top of its content.
_GAP = 2
#: Name lane width in the preview, plus its gap. Wide enough for the 20-odd bytes a
#: MeshCore advert name runs to, and narrow enough to leave the reasons lane readable at
#: MeshCore advert name runs to, and narrow enough to leave the evidence lanes readable at
#: the PicoCalc's 53 columns.
_NAME_W = 22
#: The type-mark lane ahead of the name: the node's glyph and its space, which is how the
#: Contacts list leads every row (see :func:`~meshterm.ui.contactlist._lane`).
_MARK_W = 2
def _pctl_style(percentile: int) -> str:
"""The style a percentile is drawn in — the same quality reading ``snr_style`` gives SNR.
Three bands rather than a gradient: the heat ramp is spoken for (it colours *ages*, and
a second gradient in a neighbouring lane would read as the same scale), and a percentile
in a purge preview only ever answers one question — is this contact near the bottom?
def _count_cell(tally: int | None) -> tuple[str, str]:
"""A tally lane's value: the number muted, or a faint ``—`` where there is nothing.
The Contacts list's ``count`` reading exactly (see
:func:`~meshterm.ui.contactlist._lane_cell`), clamp included — a runaway tally widens no
column — because the reader arrived here from that list and must not have to learn a
second grammar for the same fact.
"""
if percentile >= 66:
return "ok"
if percentile >= 33:
return "warn"
return "err"
if not tally:
return "—", "faint"
return f"{min(tally, 99999)}", "muted"
def _pctl_cell(percentile: int) -> Text:
"""One right-aligned percentile lane: the number alone, coloured by band."""
return Text(f"{percentile:>3} ", style=_pctl_style(percentile))
def _heard_cell(scored: ScoredContact) -> tuple[str, str]:
"""The last-heard lane: the column age under recency heat, or a cold ``never``.
:func:`~meshterm.ui.widgets.format_age` and the heat the Contacts list colours its own
``HEARD`` lane with — one grammar for one fact, whichever screen is asking it.
"""
secs = age_seconds(scored)
return format_age(secs), _recency_style(secs)
def _preview_header(width: int) -> str:
"""The preview's pinned column header, fitted to the terminal."""
def _hops_cell(scored: ScoredContact) -> tuple[str, str]:
"""The distance lane: ``direct`` for a neighbour, else the mean hop count it arrives by."""
hops = scored.signals.hops
if hops is None:
return "—", "faint"
return ("direct" if hops < 0.5 else f"{hops:g}"), "muted"
@dataclass(frozen=True)
class _Evidence:
"""One evidence lane: the word over it, and how to read a contact's value for it.
A declaration rather than a branch, like :class:`~meshterm.ui.contactlist.ContactLane`
next door: the header builder, the row builder and the width arithmetic all walk the
same tuple, so a lane is added — or reordered — in one place.
Attributes:
label: The header word. Right-aligned over its lane, where its digits will land.
read: The contact's value for this lane, as ``(text, style)``.
"""
label: str
read: Callable[[ScoredContact], tuple[str, str]]
#: The preview's evidence lanes, left to right. ``HEARD`` and ``PKTS`` lead, in the Contacts
#: list's own order and drawn in its own grammar: the reader has just come from that list,
#: and these are the two lanes they were already reading. ``MSGS`` and ``HOPS`` follow — the
#: two the sweep adds, and the two nearly always empty in a list of candidates (a contact you
#: have messaged is rarely weak enough to be swept), so a terminal too narrow for the whole
#: row cuts the least informative end first and ``←→`` brings it back.
_EVIDENCE: tuple[_Evidence, ...] = (
_Evidence("HEARD", _heard_cell),
_Evidence("PKTS", lambda scored: _count_cell(scored.signals.packets)),
_Evidence("MSGS", lambda scored: _count_cell(scored.signals.dm_total)),
_Evidence("HOPS", _hops_cell),
)
def _lane_widths(victims: list[ScoredContact]) -> tuple[int, ...]:
"""Each evidence lane's width: its header word, or the widest value under it.
Measured against the list rather than fixed, so a lane of single digits is one cell of
numbers instead of five of air — and remeasured whenever a spared row leaves, which can
only tighten it.
"""
return tuple(
max([cell_len(lane.label), *(cell_len(lane.read(v)[0]) for v in victims)])
for lane in _EVIDENCE
)
def _preview_header(widths: tuple[int, ...], width: int) -> str:
"""The preview's pinned column header: the name lane, then one word per evidence lane.
Each word is right-aligned inside its own lane, over values right-aligned too, so a
header sits where its digits will land rather than off to the left of them — how the
Contacts list heads the same two lanes.
One word per lane is the whole point of the layout. A single ``WHY IT RANKS LOW`` used to
head three lanes at once, and could not name any of them: a lane held a contact's *n*-th
weakest reason, so a message count, an age 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, which is what makes them comparable down the list — and what lets a reader see that
the contact they were unsure about is the one that has never sent a packet.
"""
lanes = [Lane("NAME", _NAME_W)]
last = len(_EVIDENCE) - 1
for index, (lane, lane_w) in enumerate(zip(_EVIDENCE, widths, strict=True)):
lanes.append(Lane(f"{lane.label:>{lane_w}}", 0 if index == last else lane_w + _GAP))
# Past the pointer and the type mark, so ``NAME`` sits over the name, not the glyph.
return column_header(lanes, width, indent=2 + _MARK_W)
def _victim_row(scored: ScoredContact, widths: tuple[int, ...]) -> Text:
"""One preview line: the contact's type mark and name, then its evidence, lane by lane.
The row leads the way the Contacts list's does — the shared type mark in its own colour
(``▲`` repeater, ``■`` room, ``◉`` sensor, ``●`` plain node), then the name — so the
contact reads as the same row here as on the list the reader came from, and a repeater
about to be archived says so before its name is read.
The name keeps its key-derived colour like everywhere else in the app — this is a list of
nodes, and a reader picking one out of thirty rows should not have to read it letter by
letter because the screen it is on happens to be about archiving things. It goes through
:func:`~meshterm.ui.menus.fit_cells`, so an over-long name ends on an ellipsis that says
it was shortened, the lane is measured in display cells, and a name carrying an emoji is
cut between glyphs rather than through one — a name is whatever a stranger's radio
advertised, and it is the one value on this screen that can be anything at all.
Every other lane is right-aligned into its measured width and carries its own style: the
heard age its recency heat, a tally muted, an absent one a faint ``—``. The row is built
on an unstyled :class:`~rich.text.Text` rather than under the name's own style, because
Rich merges a base style into every span — a faint ``—`` under a name-hue base would come
out faint *in that node's colour*.
"""
contact = scored.contact
name = contact.name or contact.key_prefix or "?"
mark, mark_style = NODE_GLYPHS.get(contact.node_type, DEFAULT_GLYPH)
row = Text()
row.append(mark, style=mark_style)
row.append(" " * (_MARK_W - cell_len(mark)))
row.append(
fit_cells(name, _NAME_W - _GAP) + " " * _GAP,
style=name_style(name, contact.public_key or contact.key_prefix),
)
last = len(_EVIDENCE) - 1
for index, (lane, lane_w) in enumerate(zip(_EVIDENCE, widths, strict=True)):
value, style = lane.read(scored)
row.append(fit_cells(value, lane_w, align="right"), style=style)
if index != last:
row.append(" " * _GAP)
return row
#: The ladder's two count lanes, headed by these words. Each lane is exactly as wide as its
#: word, and the count right-aligns in it, so a number ends under its header's last letter.
_KEEPS = "KEEPS"
_ARCHIVES = "ARCHIVES"
def _ladder_header(label_w: int, width: int) -> str:
"""The target ladder's pinned column header, over the rung lane and its two counts."""
return column_header(
[
Lane("PCTL", _PCTL_W),
Lane("NAME", _NAME_W),
Lane(("WHY IT RANKS LOW", "WHY")),
Lane("TARGET", label_w + 2),
Lane(_KEEPS, len(_KEEPS) + 2),
Lane(_ARCHIVES),
],
width,
)
def _victim_row(scored: ScoredContact) -> Text:
"""One preview line: percentile, the contact's name in its own hue, then why it ranks low.
def _rung_row(label: str, kept: int, archived: int, label_w: int) -> Text:
"""One rung: its label, then how many contacts it keeps and how many it archives.
The name keeps its key-derived colour like everywhere else in the app — this is a list
of nodes, and a reader picking one out of thirty rows should not have to read it letter
by letter because the screen it is on happens to be about deleting things.
**Keeps leads**, not archives. The device's contact table is the scarce thing and the
whole reason to sweep at all, so the useful number is the one that has to fit in it —
how many are archived is the arithmetic left over, and it follows. A percentile cutoff
led here for one round and was the wrong unit twice over: it answered a question nobody
asked at this step, and at 53 columns it did not fit anyway.
Both are columns rather than a phrase (``keeps 45 · archives 12``), so the ladder reads
down as a table: every count sits right-aligned under its own header, and comparing two
rungs is a glance down a lane rather than finding the number inside each sentence. A
rung that archives nothing shows its ``0`` muted, so the rungs that do something stand
out from the ones that don't.
Args:
label: The rung's own label.
kept: Contacts left on the device, protected ones included.
archived: Contacts the rung would archive.
label_w: The rung lane's width in cells — the widest label across both sections.
"""
contact = scored.contact
row = _pctl_cell(scored.percentile)
name = contact.name or contact.key_prefix or "?"
# Through ``fit_cells``, so an over-long name ends on an ellipsis that says it was
# shortened — and so the lane is measured in display cells, which is the only measure
# that keeps the column straight once a name carries a wide glyph.
row.append(
fit_cells(name, _NAME_W - 2) + " ",
style=name_style(name, contact.public_key or contact.key_prefix),
)
row.append(" · ".join(scored.reasons), style="muted")
row = Text(label)
row.append(" " * (label_w - cell_len(label) + 2))
row.append(f"{kept:>{len(_KEEPS)}} ")
row.append(f"{archived:>{len(_ARCHIVES)}}", style=None if archived else "muted")
return row
def _rung_desc(kept: int, archived: int) -> str:
"""A rung's description lane, in the number the reader is actually choosing.
**How many contacts you keep**, not how many go. The device's contact table is the
scarce thing and the whole reason to sweep at all, so the useful number is the one that
has to fit in it — how many are archived is the arithmetic left over, and it follows in
the muted half rather than leading. A percentile cutoff led this lane for one round and
was the wrong unit twice over: it answered a question nobody asked at this step, and at
53 columns it did not fit anyway.
"""
if not archived:
return f"keeps all {kept}"
return f"keeps {kept} · archives {archived}"
async def _rank(ctx: AppContext, contacts: list[Contact]) -> list[ScoredContact]:
async def _rank(ctx: AppContext, contacts: list[Contact], self_key: str) -> list[ScoredContact]:
"""Gather every signal these contacts have and rank them, strongest first.
Five grouped scans of the history (:meth:`
~meshterm.persistence.repository.Repository.contact_signals`), one pass over the channel
transcript for name attribution, and the two stores that carry the explicit protections
— the Watchtower's stars and the remembered admin logins. Our own position comes from
the device's self-info, and is simply absent on a device that advertises none, which the
distance term reads as unknown for everybody.
transcript for name attribution, and the three stores that carry the explicit protections
— the contacts you locked, the Watchtower's stars and the remembered admin logins. Our
own position comes from the device's self-info, and is simply absent on a device that
advertises none, which the distance term reads as unknown for everybody.
Args:
ctx: Shared application context.
contacts: The device's contacts (our own node is not among them).
self_key: The device's own public key (hex), which scopes the locks.
Returns:
The full ranking, strongest first.
@@ -210,6 +342,8 @@ async def _rank(ctx: AppContext, contacts: list[Contact]) -> list[ScoredContact]
if folded:
seen[folded] = seen.get(folded, 0) + 1
store = getattr(ctx, "contact_store", None)
locked = store.locked_keys(self_key) if store is not None and self_key else frozenset()
watch = getattr(ctx, "watch_store", None)
admin = getattr(ctx, "admin_store", None)
for contact, node in zip(contacts, nodes, strict=True):
@@ -224,6 +358,7 @@ async def _rank(ctx: AppContext, contacts: list[Contact]) -> list[ScoredContact]
channel_attributed=unique,
watched=bool(watch is not None and watch.is_watched(node)),
has_admin=bool(admin is not None and admin.get(contact)),
locked=(contact.public_key or "").lower().removeprefix("0x") in locked,
)
self_lat = self_lon = None
@@ -235,12 +370,12 @@ async def _rank(ctx: AppContext, contacts: list[Contact]) -> list[ScoredContact]
if lat and lon:
self_lat, self_lon = float(lat), float(lon)
except Exception as exc: # noqa: BLE001 - an unplaced device just loses one weak term
ctx.log.debug("purge: no self position (%s); distance term stays unknown", exc)
ctx.log.debug("archive: no self position (%s); distance term stays unknown", exc)
return rank_contacts(contacts, signals, self_lat=self_lat, self_lon=self_lon)
async def purge_contacts(ctx: AppContext, self_key: str) -> int:
async def archive_contacts(ctx: AppContext, self_key: str) -> int:
"""Run the whole sweep — rank, pick a target, preview, confirm, archive. Returns the count.
Runs over the pushed Contacts list as its backdrop, so every step floats and Esc walks
@@ -267,7 +402,7 @@ async def purge_contacts(ctx: AppContext, self_key: str) -> int:
async with ctx.ui.busy_overlay("Rating contacts"):
contacts = await ctx.devstate.contacts()
ranked = await _rank(ctx, contacts)
ranked = await _rank(ctx, contacts, self_key)
sweepable = [scored for scored in ranked if not scored.protected]
if not sweepable:
@@ -340,26 +475,37 @@ def _target_screen(ranked: list[ScoredContact], sweepable: list[ScoredContact]):
from .tui import SelectScreen
protected = len(ranked) - len(sweepable)
sections: list[tuple[str, list[tuple[str, tuple]]]] = [
(
"By standing",
[(f"Keep the strongest {share}%", ("keep", share)) for share in KEEP_RUNGS],
),
(
"Long silent",
[(label, ("age", secs)) for label, secs in AGE_RUNGS]
+ [("Never heard at all", ("age", _NEVER))],
),
]
# One rung lane across both sections, so the counts stand in two columns down the whole
# ladder rather than restarting under each heading.
label_w = max(cell_len(label) for _, rungs in sections for label, _ in rungs)
def rung(label: str, value: tuple) -> tuple:
victims = victims_for(ranked, sweepable, value)
# What remains on the device: every protected contact, plus the sweepable ones this
# rung doesn't take. Counted from the ranking rather than from the rung's own
# percentage, because a protection is never a victim and the two would disagree.
return (label, _rung_desc(len(ranked) - len(victims), len(victims)), value)
items: list = [section_heading("By standing")]
items += menu_rows(
[rung(f"Keep the strongest {share}%", ("keep", share)) for share in KEEP_RUNGS]
)
items.append(section_heading("Long silent"))
items += menu_rows(
[rung(label, ("age", secs)) for label, secs in AGE_RUNGS]
+ [rung("Never heard at all", ("age", _NEVER))]
)
# Pinned like the editors' header (menus.lane_header), so a scrolled ladder keeps its
# column words overhead along with the section it is in.
items: list = [Separator(lambda w: _ladder_header(label_w, w), pinned=True)]
for heading, rungs in sections:
items.append(section_heading(heading))
for label, value in rungs:
victims = victims_for(ranked, sweepable, value)
# What remains on the device: every protected contact, plus the sweepable ones
# this rung doesn't take. Counted from the ranking rather than from the rung's
# own percentage, because a protection is never a victim and the two would
# disagree.
kept = len(ranked) - len(victims)
items.append(Choice(title=_rung_row(label, kept, len(victims), label_w), value=value))
held = f" · {protected} protected" if protected else ""
return SelectScreen(
f"Purge contacts — {len(ranked)} known{held}",
f"Archive contacts — {len(ranked)} known{held}",
items,
prompt=(
"Archives the weakest off the device, keeping them here. Ranked on messages, "
@@ -374,14 +520,24 @@ def _preview_items(victims: list[ScoredContact]) -> list:
"""The preview's rows: a pinned column header, one line per victim, then Apply/Back.
Each contact row is ``deletable``, so ``Delete`` lifts it out of the sweep, and pins its
percentile lane out of the ``←→`` scroll (``hscroll_from``): the number is the reader's
place in a list ordered by it, and sliding it away to read a long reasons lane would
cost the row its bearing to gain nothing — the percentile is the part that already fits.
type mark and **name** out of the ``←→`` scroll (``hscroll_from``): the evidence lanes
are what a narrow terminal cuts, and sliding the name away to read them would cost the
row the one thing that says which contact is being read.
The lanes are measured here, across every victim, which is also why a spared row rebuilds
the rows through this function rather than dropping one: the widths it leaves behind may
have been set by the value that just left.
"""
widths = _lane_widths(victims)
return [
Separator(_preview_header, pinned=True),
Separator(lambda w: _preview_header(widths, w), pinned=True),
*(
Choice(title=_victim_row(v), value=v, deletable=True, hscroll_from=_PCTL_W)
Choice(
title=_victim_row(v, widths),
value=v,
deletable=True,
hscroll_from=_MARK_W + _NAME_W,
)
for v in victims
),
# The same shape ``exit_rows`` draws, in this flow's own words: Apply has no key of
@@ -402,7 +558,7 @@ def _preview_screen(victims: list[ScoredContact]):
from .tui import SelectScreen
return SelectScreen(
f"Purge contacts — {_count_desc(len(victims))} to archive",
f"Archive contacts — {_count_desc(len(victims))} to archive",
_preview_items(victims),
prompt="Archived contacts leave the device but stay here, with their history.",
# Neither the scroll nor the keep atom is written into the base hint: the select
@@ -468,7 +624,7 @@ async def _preview(ctx: AppContext, victims: list[ScoredContact]) -> bool:
# The title counts the victims, so it is content too and moves with them.
screen.replace_items(
_preview_items(victims),
title=f"Purge contacts — {_count_desc(len(victims))} to archive",
title=f"Archive contacts — {_count_desc(len(victims))} to archive",
)
continue
# Any other row is a contact: Enter opens its node detail page, so a reader who
@@ -506,8 +662,8 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
f"Archive {_count_desc(len(victims))}? They come off this device's contact list, "
"freeing space for new ones. MeshTerm keeps them — with their keys, reception "
"history and messages — under Archived contacts, and any of them can be restored.",
[("Cancel", False), ("Purge", True)],
title="Purge contacts",
[("Cancel", False), ("Archive", True)],
title="Archive contacts",
default=1,
danger=True,
):
@@ -518,7 +674,7 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
store = ctx.contact_store
stamp = int(time.time())
archived = failed = 0
with ctx.ui.progress("Purge contacts") as progress:
with ctx.ui.progress("Archive contacts") as progress:
task = progress.add_task("Archiving", total=len(victims))
for scored in victims:
contact = scored.contact
@@ -527,10 +683,10 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
except ContactNotOnDeviceError:
# Already absent from the radio: the sweep's device-side work is done, and
# archiving it here is what makes the row go away. Not a failure.
ctx.log.debug("purge: %s was not on the device; archiving ours", contact.name)
ctx.log.debug("archive: %s was not on the device; archiving ours", contact.name)
except Exception as exc: # noqa: BLE001 - one bad removal mustn't abort the sweep
failed += 1
ctx.log.debug("purge: could not remove %s: %s", contact.name, exc)
ctx.log.debug("archive: could not remove %s: %s", contact.name, exc)
continue
finally:
progress.advance(task)
@@ -547,5 +703,5 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
)
else:
outcome = Text("no contacts could be archived", style="err")
await ctx.ui.session.message_dialog(outcome, title="Purge contacts")
await ctx.ui.session.message_dialog(outcome, title="Archive contacts")
return archived
+3 -2
View File
@@ -240,7 +240,7 @@ _VT_SLOTS: tuple[tuple[int, str, str], ...] = (
#: The custom 16-slot remap P3 originally shipped (tailwind-family RGBs programmed via
#: ``setvtrgb``) — **archived, not installed**: JP chose the standard palette but asked
#: to keep this in case he changes his mind. Reinstall with
#: ``MESHTERM_CUSTOM_PALETTE=1 sh scripts/calculinux-console-font.sh`` (whose opt-in
#: ``MESHTERM_CUSTOM_PALETTE=1 sh scripts/picocalc/calculinux-console-font-6x12.sh`` (whose opt-in
#: block a test keeps byte-identical to :func:`vtrgb_lines`); MESH_THEME_16 would then
#: want re-tuning against it (see git history at c6485c6 for the matching theme).
_VT_SLOTS_CUSTOM: tuple[tuple[int, str, str], ...] = (
@@ -660,7 +660,8 @@ _GLYPH_MAP: dict[str, str] = {
# Channel openness (widgets.channel_glyph)
"#": "#", # name-derived channel
"🌐": "@", # well-known public channel
"🔒": "⚿", # private channel (the padlock mark)
"🔒": "⚿", # private channel, locked contact (the padlock mark)
"🔓": "⚿", # unlock — the same padlock; the row's word says which way it turns
# Concept icons (menu/list rows)
"📡": "☼", # advert tool — same concept as the advert class
"🕒": "◷", # clock/sync (the clock-face mark)
+317
View File
@@ -0,0 +1,317 @@
# SPDX-License-Identifier: Apache-2.0
"""Pin a written row's columns to the terminal's own grid, so no glyph can shift it.
:mod:`~meshterm.ui.tui.emoji_width` answers *how many cells does this glyph take*. This
module answers the question that actually decides whether a row lines up: **which column does
the next glyph land in?**
A row reaches the terminal as one run of characters, and the terminal advances its own cursor
by its own idea of each glyph's width. Where that idea differs from ours by a cell,
*everything after that glyph* is drawn a column out — the lanes to its right shift, the values
under a heading stop being under it, and the panel's right border notches in one short of the
frame. One emoji in one node's name, and the row it sits on is the only row in the list that
does not line up.
**That disagreement cannot be measured away**, which is the part worth being plain about.
Whether a font draws a given emoji in one cell or two is the font's business rather than the
codepoint's; there is no table that is right on every terminal; and no escape sequence reports
a renderer's width back to the program, because on a split terminal the component that tracks
the cursor and the component that paints the glyph are different programs that disagree with
each other (a cursor probe reads the PTY, not the painter). Curating exceptions one confirmed
glyph at a time — which is what the app used to do — lines up the rows somebody has already
looked at, and leaves every name that arrives off the air, written by a stranger and carrying
whatever they felt like typing, exactly as broken as it was. A contact list is the worst case
in the app for precisely that reason: its content is the one thing on screen that nobody can
put on a list in advance.
So this module stops asking the terminal to agree. After any glyph whose drawn width is not
certain, the row carries an **absolute column address** — ``CSI n G``, naming the one-based
column the app measured that glyph into — so the terminal's answer to *where am I* is replaced
with ours before the next glyph is drawn. The cursor is re-pinned to the app's own grid at
every point where a disagreement could have started, and a mismeasured glyph can no longer
move anything but itself:
* Drawn **narrower** than we reserved, it leaves a blank cell after it. The cells a glyph is
reserved are erased in its own colours before it is drawn (``CSI n X``), so that cell is
blank in the right background: never a character left over from the last frame, and never a
hole in the fill of a chip.
* Drawn **wider**, its overhang is written over by whatever the next column holds.
Both are one cell of cosmetic damage inside the glyph's own lane, where a shift was a whole row
of it, and the second never happens in practice: :mod:`~meshterm.ui.tui.emoji_width` reserves
every glyph that may be drawn as an emoji two cells, the most a single glyph draws. Pinning holds
the lanes after a glyph wherever the terminal puts it; the reservation is what makes the text
beside it line up from row to row as well, since a pin can only land where the glyph was measured.
**What is certain.** Pinning after a glyph that did not need it is harmless (it addresses the
column the cursor is already in) but it is not free: five bytes on the wire and a cluster walk
in Python, and MeshTerm draws some non-ASCII glyphs by the thousand — a map is a canvas of
braille, every frame is box drawing, a path ribbon is half blocks and chevrons. So
:data:`_TEXT_GLYPHS` lists the ranges this app draws in bulk whose one-cell *text* width both
authorities already agree on, and :func:`snap_row` pins after everything else. A row holding
nothing outside that list is handed back untouched, which is nearly every row of nearly every
screen; the walk is paid only where an emoji actually is.
Getting an entry in that list wrong costs exactly what today costs anyway — one glyph's row —
so the list is a performance claim and never the thing correctness rests on. That is the whole
point of the inversion: the exception sets this replaced had to be *complete* to keep the app
aligned, and this one only has to be *cheap*.
**Two writers, two spellings of the same pin.** A frame reaches the terminal one of two ways,
and each knows something different about where its cursor is:
* :mod:`~meshterm.ui.tui.fastrender` writes a plain full-screen frame itself, each changed row
as one run from column 0. It knows every column absolutely, so :func:`snap_row` spells the
pin as an absolute address, ``CSI n G``.
* Anything prompt_toolkit lays out — a floating dialog, and the backdrop it repaints — goes out
through its differential renderer, which never knows a column absolutely: it steps a cursor
of its own by relative moves and adds each character's measured width as it writes. So
:class:`PinnedOutput` spells the pin *relative to the glyph itself* — save the cursor, draw
the glyph, restore, step forward exactly the width the renderer is about to add. The
terminal's cursor then lands where the renderer's arithmetic says it is, after every glyph,
and a relative move computed from that arithmetic is a correct move. Nothing is tracked, so
nothing can drift out of step with the renderer's own bookkeeping.
A platform that never draws a glyph outside its own font has nothing to pin, and pins nothing:
the PicoCalc folds every emoji away before it reaches the console, and every glyph it does draw
is one :mod:`~meshterm.ui.fontset` has verified on the device.
The escape hatch is ``MESHTERM_COLUMN_SNAP=0``: this is terminal geometry, read once at session
build, not a choice anybody makes about how the app behaves.
"""
from __future__ import annotations
import os
import re
from collections import OrderedDict
from typing import Any
from prompt_toolkit.utils import get_cwidth
from rich.cells import cell_len
from ...platforms import get_platform
from .emoji_width import clusters
#: The glyph ranges MeshTerm draws in bulk whose single-cell width both width authorities
#: already agree on, so a row made only of them needs no pinning at all. Each entry is an
#: inclusive ``(first, last)`` pair; a single glyph names itself twice.
#:
#: These are *text* glyphs — outside Emoji_Presentation, carrying no variation selector — which
#: is the class the emoji-width module's hardest-won lesson is about: ``U+1F6E3`` and
#: ``U+1F578`` measure one cell by both authorities because the font draws them as one-cell
#: text, and forcing either to two pulled a row's border in. Everything else non-ASCII — every
#: pictograph, every flag, every joined sequence, every fullwidth character, and anything nobody
#: has ever looked at — falls outside this list and gets pinned.
_TEXT_GLYPHS: tuple[tuple[str, str], ...] = (
("\x00", "\x7f"), # ASCII: every letter, digit and escape byte in a composed row
(" ", "ſ"), # Latin-1 + Latin Extended-A: an accented name, °, ·, ±
("‐", "‧"), # General Punctuation: – — ‘ ’ “ ” • …
("←", "↓"), # ← ↑ → ↓, the arrow atoms every footer hint leads with
("─", "╿"), # Box Drawing: every panel border and every rule
("▀", "▟"), # Block Elements: the path line's half blocks, the splash art
("■", "■"), # ■ room server
("▲", "▲"), # ▲ repeater
("▸", "▸"), # ▸ a row's own marker
("◉", "◉"), # ◉ sensor
("○", "●"), # ○ unknown through ● node, and the rings between them
("★", "★"), # ★ you
("✓", "✓"), # ✓ ok
("✗", "✗"), # ✗ err
("❯", "❯"), # ❯ the cursor
("⠀", "⣿"), # Braille: every chart, and the whole map canvas
("", ""), # the powerline chevrons a path ribbon's seams are drawn from
)
#: Matches the first glyph in a row that is *not* one of :data:`_TEXT_GLYPHS` — the probe that
#: decides whether a row needs walking at all. A negated class of the ranges above, so the test
#: is one C-speed scan of the row instead of a Python loop over its cells.
_UNPINNED = re.compile("[^" + "".join(f"{low}-{high}" for low, high in _TEXT_GLYPHS) + "]")
#: One escape sequence: a CSI (which is everything a composed row actually carries — the
#: theme's SGR colour runs), an OSC string, or a bare two-character escape. Matched so it can
#: be copied through *without* advancing the column: a style change occupies no cell, and
#: counting one would pin every glyph after it to the wrong column.
_ESCAPE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])")
#: Pinned rows, keyed by the row as composed. Scrolling a list rewrites the same few rows over
#: and over — the highlighted one, and the one the highlight just left — so the walk is paid
#: once per distinct row rather than once per paint.
_CACHE: OrderedDict[str, str] = OrderedDict()
#: Rows the cache holds before evicting the least recently used.
_CACHE_MAX = 1024
#: DECSC and DECRC: save the cursor, and return to it. What :class:`PinnedOutput` brackets a
#: glyph with, so the step after it is measured from where the glyph *started* rather than from
#: wherever the terminal's own width table left the cursor. Both also save and restore the
#: current colour, which is harmless here: nothing changes it between the two.
_SAVE = "\x1b7"
_RESTORE = "\x1b8"
def _erase(width: int) -> str:
"""ECH: blank ``width`` cells from the cursor in the current colours, without moving it.
Written in front of a glyph reserved more than one cell, so every cell of its reservation is
painted by this frame whatever the font does with the glyph: the background a chip or a
highlighted row filled it with, rather than whatever the terminal last held there.
"""
return f"\x1b[{width}X"
def enabled() -> bool:
"""Whether written glyphs are pinned to the columns the app measured them into.
On wherever the platform draws emoji, with ``MESHTERM_COLUMN_SNAP=0`` to turn it off for one
run — the escape hatch a terminal that mishandles the pins would need, and the switch that
shows what the alignment looks like without them. Off on a platform that draws no emoji at
all, where every glyph is one the platform's own font has already verified. Read at session
build, like its neighbours.
"""
return os.environ.get("MESHTERM_COLUMN_SNAP") != "0" and get_platform().emoji
def _pinned(cluster: str) -> bool:
"""Whether ``cluster`` needs an absolute column address written after it.
True for anything the app cannot be sure the terminal draws at the width it measured: every
multi-codepoint glyph (a flag, a joined sequence, an emoji carrying a variation selector or
a skin tone — the forms :func:`~meshterm.ui.tui.emoji_width.clusters` gathers), and every
lone codepoint outside :data:`_TEXT_GLYPHS`.
"""
return len(cluster) > 1 or bool(_UNPINNED.match(cluster))
def snap_row(row: str) -> str:
"""``row`` with an absolute column address after every glyph that could be mismeasured.
The row is taken to start in **column 0**, which is what
:mod:`~meshterm.ui.tui.fastrender` guarantees: it addresses the row, then writes it whole.
A row holding only :data:`_TEXT_GLYPHS` comes back unchanged and untouched — one scan, no
walk, no allocation — because that is nearly every row the app draws.
Args:
row: One composed terminal row: text with the theme's escape sequences in it, already
sliced to the terminal's width.
Returns:
The row, with ``CSI n G`` inserted wherever the cursor has to be re-pinned.
"""
if _UNPINNED.search(row) is None:
return row
hit = _CACHE.get(row)
if hit is not None:
_CACHE.move_to_end(row)
return hit
out = _snapped(row)
_CACHE[row] = out
if len(_CACHE) > _CACHE_MAX:
_CACHE.popitem(last=False)
return out
def _snapped(row: str) -> str:
"""Walk ``row`` glyph by glyph, tracking the column, and pin where :func:`_pinned` says.
The address is written **lazily** — held over until something drawable actually follows it —
so a row that ends on an emoji (a chat line, very often) pays nothing for an address nothing
would have used, and a style change between two glyphs stays where the theme put it.
"""
parts: list[str] = []
column = 0
owed = -1 # the column a held-over address names, or -1 when none is owed
position = 0
for escape in _ESCAPE.finditer(row):
column, owed = _pin_text(row[position : escape.start()], parts, column, owed)
parts.append(escape.group())
position = escape.end()
column, owed = _pin_text(row[position:], parts, column, owed)
return "".join(parts)
def _pin_text(text: str, parts: list[str], column: int, owed: int) -> tuple[int, int]:
"""Append one escape-free stretch of ``text`` to ``parts``, pinned glyph by glyph.
Args:
text: The stretch to append, with no escape sequence in it.
parts: The row being built, appended to in place.
column: The column this stretch starts in.
owed: The column a held-over address names, or ``-1`` for none.
Returns:
The column the stretch ends in, and the address it ends owing.
"""
for cluster in clusters(text):
if owed >= 0:
parts.append(f"\x1b[{owed + 1}G")
owed = -1
width = cell_len(cluster)
pinned = _pinned(cluster)
if pinned and width > 1:
parts.append(_erase(width))
parts.append(cluster)
column += width
if pinned:
owed = column
return column, owed
def _lone_pinned_glyph(data: str) -> bool:
"""Whether ``data`` is exactly one glyph, and one :func:`_pinned` says needs pinning.
What prompt_toolkit's renderer writes is one screen cell's content at a time — a character,
or a whole sequence it was handed as one (see
:class:`~meshterm.ui.tui.emoji_width.ClusterTextControl`) — so that is the only shape pinned.
Anything longer is not a cell, and passes through as it came.
"""
if len(data) == 1:
return bool(_UNPINNED.match(data))
for cluster in clusters(data):
return cluster == data
return False
class PinnedOutput:
"""A prompt_toolkit ``Output`` proxy that pins every uncertain glyph it is asked to write.
prompt_toolkit's renderer writes a screen cell with ``write`` and then adds that cell's
measured width to a cursor it keeps for itself; every later move on the row is computed
from that cursor, relatively. Where the terminal draws the glyph at another width, the two
cursors part company and every move after it lands a column out. This proxy closes the gap
at the one moment it opens: a glyph :func:`_pinned` cannot vouch for is written between a
cursor save and a restore, and followed by a forward step of exactly the width the renderer
is about to add — so the terminal's cursor is back in step before the renderer's next move.
Everything else — ``write_raw``, the cursor moves, the size, the attributes — forwards to the
wrapped output untouched, and so does ``write`` for anything that is not a lone uncertain
glyph, which is almost every call it gets.
"""
def __init__(self, inner: Any) -> None:
"""Wrap ``inner``, the concrete prompt_toolkit output for the real terminal."""
self._inner = inner
def write(self, data: str) -> None:
"""Write ``data``, pinning the cursor after it when it is a glyph worth pinning."""
inner = self._inner
if data.isascii() or not _lone_pinned_glyph(data):
inner.write(data)
return
width = get_cwidth(data)
# The renderer skips the cells after a wide glyph as the glyph's own and never writes
# them, so erasing them first is what keeps a glyph drawn narrower from leaving the
# last frame's character in the cell it did not cover.
inner.write_raw(_SAVE + _erase(width) if width > 1 else _SAVE)
inner.write(data)
# A forward step of zero is read as a step of one, so a glyph that measures nothing
# (a stranded joiner) just returns to where it started.
inner.write_raw(f"{_RESTORE}\x1b[{width}C" if width else _RESTORE)
def __getattr__(self, name: str) -> Any:
"""Forward every other attribute and method straight to the wrapped output."""
return getattr(self._inner, name)
__all__ = ["PinnedOutput", "enabled", "snap_row"]
File diff suppressed because it is too large. Load diff
+27 -8
View File
@@ -23,14 +23,20 @@ would mean re-implementing that placement. Anything else falls straight through
stock renderer, which stays the authority: :meth:`FastRenderer.render` is an accelerator
for the common case, never a second implementation of the whole layout engine.
Two details make the row diff safe rather than merely fast:
Three details make the row diff safe rather than merely fast:
* **A row is rewritten whole, from column 0.** The terminal's own cursor advance re-lays
the line, so a glyph it draws narrower than we measured cannot leave the rest of the row
shifted — the same guarantee :meth:`~meshterm.ui.tui.session.TuiSession._emit` relies on,
reached here for free rather than by a scrub pass.
* **Each row is prefixed with a reset and followed by an erase-to-end-of-line**, so no
style leaks in from the row above and a row that got shorter leaves no tail behind.
* **A row is rewritten whole, from column 0.** Stepping down to a row returns the terminal
to a true column 0 whatever happened on the row above, so a glyph drawn at a width we did
not measure can never throw off the rows below it — the same guarantee
:meth:`~meshterm.ui.tui.session.TuiSession._emit` relies on, reached here for free rather
than by a scrub pass.
* **Every column of that row is pinned to the grid the app measured**
(:func:`~meshterm.ui.tui.colsnap.snap_row`), which is what keeps the drift from mattering
*within* the row either. This is the only place in the app where a row's bytes are still
ours on their way to the terminal, so it is the only place that correction can be made.
* **Each row is cleared before it is drawn, never after**, so no style leaks in from the row
above, a row that got shorter leaves no tail behind, and a row that fills the terminal
exactly keeps its last cell.
"""
from __future__ import annotations
@@ -40,6 +46,13 @@ from collections.abc import Callable
from prompt_toolkit.renderer import Renderer
from . import colsnap
def _as_is(row: str) -> str:
"""The unpinned write path: a row goes out exactly as it was composed."""
return row
def enabled() -> bool:
"""Whether the direct row writer is active.
@@ -77,6 +90,10 @@ class FastRenderer(Renderer):
"""
super().__init__(*args, **kwargs)
self._frame_source = frame_source
#: How a row is pinned to the app's column grid on its way out — resolved once here,
#: because the escape hatch is a property of the terminal this session is talking to
#: and not of any one paint. Identity when it is off, so the write path never branches.
self._pin: Callable[[str], str] = colsnap.snap_row if colsnap.enabled() else _as_is
self._prev_rows: list[str] | None = None
self._prev_size: tuple[int, int] | None = None
#: Paints answered here vs handed to the stock renderer — read by the bench.
@@ -157,7 +174,9 @@ class FastRenderer(Renderer):
# missing on the PicoCalc's 53-column console). Clearing first also drops the
# tail of a row that got shorter, which is what the erase was for.
write("\x1b[0m\x1b[K")
write(row)
# Pinned, not merely written: an emoji in a node's name is drawn at whatever width
# this font gives it, and the lanes after it belong to the app's grid either way.
write(self._pin(row))
last = i
# Park the cursor out of the text. The app hides it, but a terminal that ignores
# that should not leave it blinking in the middle of a row.
+13 -4
View File
@@ -39,17 +39,26 @@ _PIN_SETTLE_PASSES = 3
_BANNER_CROP_ROWS = 9
def _window_start(scroll: int, total: int, viewport: int, pinned: int) -> int:
def _window_start(
scroll: int, total: int, viewport: int, pinned: int, cursor: int | None = None
) -> int:
"""The body line the visible window starts at, given ``pinned`` reserved top rows.
Normally the scroll offset itself. Scrolled fully to the bottom, the reserved rows must
not cost the *last* body lines (a chat's input row would vanish exactly when the
transcript fills the screen), so the window slides down by as many rows as are pinned —
the lines it drops are at the top, right under the pins, where they are stale.
Except the ``cursor``'s, which is never stale: the slide stops at the highlighted line.
A reader walking ↑ back up a list that is still scrolled to its bottom reaches the
window's top line with the scroll yet to move, and a slide past that line hid the very
row they had just stepped onto. Where the two cannot share the window the highlight
keeps its row and the last line waits below the ``↓`` marker.
"""
cap = max(1, viewport - pinned)
if scroll >= total - viewport and total > cap:
return min(scroll + pinned, total - cap)
start = min(scroll + pinned, total - cap)
return start if cursor is None else max(scroll, min(start, cursor))
return scroll
@@ -102,14 +111,14 @@ def _visible_slice(screen: Screen, lines: list[str], viewport: int) -> tuple[lis
screen.scroll = scroll
if pinned:
start = _window_start(scroll, total, viewport, len(pinned))
start = _window_start(scroll, total, viewport, len(pinned), cursor)
if start != scroll:
# The window slid down off the scroll offset, so the pins must describe the row
# it now *starts* at — otherwise a section heading among the dropped lines would
# simply vanish instead of being pinned. Re-ask, then re-settle the start against
# however many rows that reserves (never unpinning: the slide depends on it).
pinned = screen.sticky_rows(start) or pinned
start = _window_start(scroll, total, viewport, len(pinned))
start = _window_start(scroll, total, viewport, len(pinned), cursor)
cap = max(1, viewport - len(pinned))
visible = pinned + lines[start : start + cap]
more_below = start + cap < total
+6 -9
View File
@@ -711,14 +711,12 @@ class SelectScreen(Screen):
# One entry per body line: a finished string, or a callable that draws it when the
# frame asks. Positions are exact either way, which is all the layout below reads.
lines: list[str | Callable[[], str]] = []
# A prompt (when set) sits above the list, offsetting every row below it; the cursor
# line and sticky-header indices below are shifted by exactly this many lines.
prefix = 0
# A prompt (when set) sits above the list, offsetting every row below it. Every index
# recorded below — the cursor line, the sticky headers — is read off ``len(lines)``,
# which already counts the prompt's lines, so none of them adds the offset again.
if self._prompt:
plines = render_lines(Text(self._prompt), width)
lines.extend(plines)
lines.extend(render_lines(Text(self._prompt), width))
lines.append("")
prefix = len(plines) + 1
# Record each section heading (a Separator that says it is one) as a sticky block, so
# one that scrolls off is re-pinned to the top rows by the base Screen.sticky_block —
# and a pinned separator (a column header) as the whole-list header pinned above it.
@@ -780,9 +778,8 @@ class SelectScreen(Screen):
lines.append(self._detail_drawer(detail, width))
if not choices:
lines.append(render_to_ansi(Text("no matches", style="muted"), width))
# Remember where the highlighted row landed so the session can keep it in view,
# shifted past any prompt lines drawn above the list.
self._cursor = None if cursor_at is None else cursor_at + prefix
# Remember where the highlighted row landed so the session can keep it in view.
self._cursor = cursor_at
return LazyLines(lines)
def _row_drawer(self, item: Choice, is_sel: bool, width: int) -> Callable[[], str]:
+22 -7
View File
@@ -34,7 +34,7 @@ from rich.text import Text
from ...core import win32dll
from ...platforms import get_platform
from ...services import modifier_watch
from . import fastrender, fkeys, frame
from . import colsnap, fastrender, fkeys, frame
from .emoji_width import ClusterTextControl
from .overlay import BusyOverlay
from .progress import TuiProgress
@@ -1665,18 +1665,33 @@ class TuiSession:
return app
def _resolve_output(self) -> Any:
"""The output the app renders to — optionally widened to reclaim the last column.
"""The output the app renders to: the real terminal, pinned and optionally widened.
Only the *real* terminal (``self._output is None``, so prompt_toolkit would build its
own output) is wrapped, and only when :func:`_reclaim_last_column` says on: a test
that supplies its own output keeps the exact size it set, so headless rendering stays
deterministic. See :class:`_WidthExtendedOutput` for what the wrap does.
own output) is wrapped: a test that supplies its own output keeps the exact size and
the exact bytes it set, so headless rendering stays deterministic. Two wraps, each
where its own gate says so, and either may be the only one:
* :class:`~meshterm.ui.tui.colsnap.PinnedOutput`, where
:func:`~meshterm.ui.tui.colsnap.enabled` — every glyph prompt_toolkit's renderer
writes lands in the column that renderer measured it into.
* :class:`_WidthExtendedOutput`, where :func:`_reclaim_last_column` — outermost, since
the size it reports is what both renderers and the compositor lay out against.
With neither, this is ``None`` and prompt_toolkit builds its own output as before.
"""
if self._output is not None or not _reclaim_last_column():
pin = colsnap.enabled()
reclaim = _reclaim_last_column()
if self._output is not None or not (pin or reclaim):
return self._output
from prompt_toolkit.output.defaults import create_output
return _WidthExtendedOutput(create_output())
output: Any = create_output()
if pin:
output = colsnap.PinnedOutput(output)
if reclaim:
output = _WidthExtendedOutput(output)
return output
# --- rendering -----------------------------------------------------------
+37 -26
View File
@@ -1,30 +1,41 @@
# scripts
# scripts/
Device-side helpers that put MeshTerm on real hardware: bringing up a ClockworkPi PicoCalc
(Luckfox Lyra, Calculinux) and building its console font, giving that handheld a real
MeshCore radio over UART, and fronting an SPI-attached LoRa chip as if it were a companion
device. They run **on the device**, most of them as root, and they are meant to be read
before they are run.
Repo-side tools. None of these ship in the wheel.
**[`docs/hardware.md`](../docs/hardware.md) is the manual** — what each target is, what the
scripts assume about the machine, why each step is the way it is, and what has only ever
been proven on one bench. Start there and pick your target; the table below is the index.
| Script | What it is for |
|---|---|
| `basemap-doctor.py` | Why a user's map has no basemap — certificates, DNS, proxy, or colour depth |
| `picocalc/` | Calculinux device setup: console font, palette, Wi-Fi, radio bridge |
| `uconsole/` | The uConsole SPI bridge service |
| File | Runs on | What it does |
| --- | --- | --- |
| [`calculinux-setup.sh`](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. |
| [`calculinux-console-font.sh`](calculinux-console-font.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. |
| [`calculinux-console-font-6x8.sh`](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`](LICENSE.GPL-2.0)); 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. |
| [`calculinux-wifi-set.sh`](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. |
| [`calculinux-radio-bridge.sh`](calculinux-radio-bridge.sh) | the Lyra (root) | **Unfinished, unsupported.** An early UART-to-TCP pump, superseded by `xiao-radio/lyra-setup.sh`; its header describes routing it does not contain. See the manual before reading it. |
| [`meshterm-spi-bridge`](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. |
| [`xiao-radio/`](xiao-radio/) | see below | Everything for the XIAO nRF52840 + Wio-SX1262 radio wired to the Lyra's UART1. |
| [`xiao-radio/build-firmware.sh`](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`](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`](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`](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`](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. |
## basemap-doctor.py
The PicoCalc scripts and the SPI bridge belong to different machines and share nothing but
MeshTerm. On the Lyra, only one program may hold `/dev/ttyS1` — MeshTerm directly, or a pump
in front of it, never both.
The map is the only networked part of MeshTerm, and every fetch failure on that path is
logged at DEBUG while `log_level` defaults to WARNING — so a reader whose tiles never
arrive sees a blank ground and no explanation. This prints the explanation and the fix.
Send a user this, to run **on the machine with the problem**:
```bash
curl -fsSL https://raw.githubusercontent.com/jpmartineau/MeshTerm/main/scripts/basemap-doctor.py -o /tmp/basemap-doctor.py
python3 /tmp/basemap-doctor.py
```
Any `python3` will do: if that one has no MeshTerm, the script reads the interpreter out
of the installed `meshterm` command's shebang and hands itself over, so the answer always
comes from **the app's own Python** — a Mac routinely has three and only that one has the
trust store that matters. Save it to a file as above rather than piping into `python3 -`:
with no file on disk there is nothing to hand over.
Have them paste the whole output back. It names the interpreter, the certificate store,
the tile source, the cached-tile count, `COLORTERM`, and then one verdict:
- **curl reached it, Python did not** — the Python's certificate store is empty. A
python.org install on macOS ships that way until `Install Certificates.command` is run.
- **neither reached it** — the network path: a firewall, a proxy, a DNS filter, or an
outage. Nothing to fix in MeshTerm.
- **both reached it** — tiles are fine and the complaint is colour: an unset `COLORTERM`
means 256 colours, where the basemap's greens and blues collide. Terminal.app cannot do
24-bit colour at all.
Exit status is 0 when tiles arrive and 1 when they do not, so it can be scripted.
+427
View File
@@ -0,0 +1,427 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Diagnose why MeshTerm's map has no basemap, on the machine that has the problem.
The map is the only networked part of MeshTerm: it fetches OpenStreetMap vector tiles over
HTTPS from the source named by the ``basemap_tilejson_url`` preference. Every failure on
that path — a missing certificate store, DNS, a timeout, a middlebox — is swallowed and
logged at DEBUG (:mod:`meshterm.services.basemap`), and ``log_level`` defaults to WARNING,
so a reader whose tiles never arrive sees a blank ground and no explanation anywhere.
This script is that explanation. It is standalone and stdlib-only so it can be pasted onto
any machine running any version of MeshTerm, and it prints one verdict with the fix.
It runs under MeshTerm's *own* interpreter, which is the one thing that matters here: a Mac
routinely has three Pythons, only one of them is the app's, and only that one's certificate
store is the one the map actually uses. Started under any other, it finds the installed
``meshterm`` command, reads the interpreter out of its shebang and hands itself over — so
the user needs no idea which Python they installed into. Save it to a file rather than
piping it into ``python3 -``: with no file on disk there is nothing to hand over.
The decisive test is an A/B. ``curl`` verifies against the operating system's trust store
and Python verifies against its own, so curl succeeding where Python fails means the
certificates are at fault and nothing about the network is — the one comparison that
separates the two causes that look identical from inside the app.
"""
from __future__ import annotations
import importlib.util
import json
import os
import platform
import shutil
import socket
import ssl
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import NamedTuple
#: Where tiles come from unless the preference says otherwise. Kept in step with
#: ``basemap.DEFAULT_TILEJSON_URL`` by hand: this script may run against an install whose
#: ``meshterm`` it cannot import, so it cannot read the constant it is mirroring.
FALLBACK_TILEJSON_URL = "https://tiles.openfreemap.org/planet"
#: Per-request timeout, matching ``BasemapSource``'s own so that a link which is merely
#: slow fails here for the same reason it fails in the app rather than for a stricter one.
TIMEOUT = 12.0
#: What the app sends, so a source that filters by agent treats this run the same way.
USER_AGENT = "MeshTerm-basemap-doctor (+https://github.com/jpmartineau/MeshTerm)"
#: A tile that exists: zoom 9 over central Poland. The TileJSON resolving is only half the
#: path — the tiles themselves are often a different host — so the check fetches real
#: geometry before it reports success.
SAMPLE_TILE = (9, 285, 167)
def heading(text: str) -> None:
"""Print a section heading."""
print(f"\n=== {text} ===")
def line(label: str, value: object) -> None:
"""Print one aligned ``label: value`` fact."""
print(f" {label:<20} {value}")
def find_meshterm() -> tuple[str | None, str | None, str | None]:
"""Report MeshTerm's version, tile URL and config dir, if this interpreter has them.
Returns:
``(version, tilejson_url, config_dir)``, each ``None`` where it could not be read.
"""
try:
import meshterm
from meshterm.core.config import default_config_dir
from meshterm.core.preferences import Preferences
except Exception: # noqa: BLE001 - any import failure means "not this interpreter"
return None, None, None
config_dir = default_config_dir()
url = None
try:
url = Preferences.load(config_dir / "preferences.toml").basemap_tilejson_url
except Exception: # noqa: BLE001 - an unreadable preferences file is itself a finding
pass
return getattr(meshterm, "__version__", "?"), url, str(config_dir)
#: Set on the re-executed child, so a second interpreter that still cannot import MeshTerm
#: gets on with the diagnosis instead of handing itself on forever.
REEXEC_ENV = "MESHTERM_DOCTOR_REEXEC"
def meshterm_interpreter() -> str | None:
"""The interpreter the installed ``meshterm`` command runs under, if it can be read.
A console script on Unix is a text file whose shebang names the exact interpreter the
app was installed into — and that interpreter's certificate store is the one the map
actually uses, which no other Python on the machine can speak for. On Windows the
console script is a binary launcher with no shebang, so this finds nothing and the
diagnosis proceeds with a banner saying which interpreter it did use.
"""
script = shutil.which("meshterm")
if script is None:
return None
try:
with open(script, "rb") as fh:
first = fh.readline(512)
except OSError:
return None
if not first.startswith(b"#!"):
return None
# A shebang may carry arguments (``#!/usr/bin/env python3``); the interpreter is the
# first word, and an ``env`` line names no absolute path worth re-execing.
interpreter = first[2:].decode("utf-8", "replace").strip().split(" ")[0]
return interpreter if interpreter and Path(interpreter).is_file() else None
def reexec_under_meshterm() -> None:
"""Hand this script to MeshTerm's own interpreter when it is not the one running it.
The user should not have to know which of their three Pythons the app was installed
into — that detail is exactly what the failure is made of.
"""
if os.environ.get(REEXEC_ENV):
return
try:
if importlib.util.find_spec("meshterm") is not None:
return
except (ImportError, ValueError): # a broken or shadowed package: keep looking
pass
target = meshterm_interpreter()
here = Path(globals().get("__file__", "")).resolve() if globals().get("__file__") else None
if target is None or here is None or not here.is_file():
return
if Path(target).resolve() == Path(sys.executable).resolve():
return
print(f" (handing over to MeshTerm's own interpreter: {target})")
# The child inherits this stdout, so the parent's buffer has to go out first or the
# banner lands after the report it introduces. Block buffering makes that certain the
# moment the output is piped into a file or a paste, which is how it is meant to
# travel. (``os.execve`` would avoid the extra process, but on Windows it is not a
# real exec: it spawns and exits, and segfaults outright under a redirect.)
sys.stdout.flush()
child = subprocess.run([target, str(here), *sys.argv[1:]], env={**os.environ, REEXEC_ENV: "1"})
sys.exit(child.returncode)
def exists_marker(path: str) -> str:
"""Return a marker saying whether ``path`` is actually on disk."""
return "" if Path(path).exists() else " <- MISSING"
def describe_env() -> tuple[str | None, str | None]:
"""Print interpreter, platform and MeshTerm facts; return the tile URL and config dir."""
heading("This machine")
line("Python", sys.version.split()[0])
line("Interpreter", sys.executable)
line("OS", f"{platform.system()} {platform.release()} ({platform.machine()})")
line("TERM", os.environ.get("TERM", "(unset)"))
# Not a tile question, but it is the other thing that makes a map look wrong and it
# costs nothing to answer in the same round trip: no COLORTERM means 256 colours, and
# the basemap's greens and blues are what collide when they are quantized.
# Windows is exempt: prompt_toolkit's Windows output reports 24-bit outright, so an
# unset COLORTERM there says nothing. Everywhere else it is the whole of the evidence.
unset = "(unset)" if os.name == "nt" else "(unset) <- only 256 colours"
line("COLORTERM", os.environ.get("COLORTERM") or unset)
version, url, config_dir = find_meshterm()
if version is None:
line("MeshTerm", "NOT importable here <- probably the wrong interpreter")
else:
line("MeshTerm", version)
line("Config dir", config_dir)
return url, config_dir
def describe_trust() -> None:
"""Print the certificate store this Python would verify a tile server against."""
heading("Certificate store")
line("OpenSSL", ssl.OPENSSL_VERSION)
paths = ssl.get_default_verify_paths()
for label, value in (("CA file", paths.cafile), ("CA path", paths.capath)):
line(label, f"{value}{exists_marker(value)}" if value else "(none)")
try:
import certifi
line("certifi", certifi.where())
except ImportError:
line("certifi", "not installed")
for var in (
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
):
if os.environ.get(var):
line(var, os.environ[var])
loaded = len(ssl.create_default_context().get_ca_certs())
line("Certs loaded", loaded if loaded else "0 <- nothing to verify against")
#: ``curl`` exit codes that mean TLS, as opposed to never getting that far. The whole
#: value of asking curl is that it verifies against the *operating system's* trust store,
#: so "curl got a certificate it disliked" and "curl could not reach the host" are
#: opposite answers and must not be collapsed into "curl failed".
CURL_TLS_CODES = frozenset({35, 51, 58, 59, 60, 66, 77, 83, 90, 91})
class Curl(NamedTuple):
"""What the system ``curl`` made of the same URL.
Attributes:
verdict: ``ok`` (the server answered, whatever its status), ``tls`` (a certificate
or handshake failure), ``unreachable`` (never got that far), or ``absent``
(no ``curl`` on this machine to ask).
detail: The status or error, for the transcript.
"""
verdict: str
detail: str
def curl_says(url: str) -> Curl:
"""Fetch ``url`` with the system ``curl``, which verifies against the OS trust store."""
curl = shutil.which("curl")
if curl is None:
return Curl("absent", "no curl on this machine to compare against")
argv = [
curl,
"-sS",
"-o",
os.devnull,
"-w",
"%{http_code}",
"--max-time",
str(int(TIMEOUT)),
url,
]
try:
proc = subprocess.run(argv, capture_output=True, text=True, timeout=TIMEOUT + 5)
except (subprocess.TimeoutExpired, OSError):
return Curl("unreachable", "timed out")
if proc.returncode == 0:
# Any HTTP status at all means the transport worked, which is the only thing this
# comparison is asking about - a 301 or a 404 still proves TLS and the route.
return Curl("ok", f"HTTP {proc.stdout.strip()}")
kind = "tls" if proc.returncode in CURL_TLS_CODES else "unreachable"
return Curl(kind, f"exit {proc.returncode}: {proc.stderr.strip() or 'no detail'}")
def fetch(url: str) -> tuple[bool, object]:
"""GET ``url`` exactly the way the app does. Returns ``(ok, body-or-exception)``."""
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
return True, resp.read()
except Exception as exc: # noqa: BLE001 - classifying the failure is the whole job
return False, exc
#: What to do about a certificate store this Python cannot verify with. The macOS case is
#: first because it is the one that reaches a user who did nothing wrong: a python.org
#: install ships with an empty store until its own installer command is run.
CERT_FIX = (
"Give this Python a certificate store. On macOS a python.org install ships\n"
" without one until you run its own command:\n"
" open /Applications/Python*/Install\\ Certificates.command\n"
" Or point it at certifi's, in the environment MeshTerm runs in:\n"
" pip install certifi\n"
' export SSL_CERT_FILE="$(python3 -m certifi)" # then relaunch MeshTerm'
)
#: What to do when TLS is being terminated in the middle rather than at the tile server.
INTERCEPT_FIX = (
"Trust the interceptor's root certificate in this Python (SSL_CERT_FILE can\n"
" point at a bundle that includes it), or exempt the tile host from HTTPS\n"
" inspection. MeshTerm has no way to accept a certificate it cannot verify."
)
def classify(exc: BaseException) -> tuple[str, str, str]:
"""Turn a fetch exception into ``(kind, what happened, what to do about it)``.
The *kind* is what the verdict reasons with: a certificate failure and a timeout point
at opposite halves of the world, and the ``curl`` comparison means something different
for each.
"""
reason = getattr(exc, "reason", exc)
if isinstance(exc, urllib.error.HTTPError):
return (
"http",
f"the server answered HTTP {exc.code}",
"The network is fine. A 4xx means the tile URL is wrong (check the "
"'Map tiles' preference); a 5xx is the tile server itself.",
)
if isinstance(reason, ssl.SSLCertVerificationError):
return ("cert", "this Python could not verify the tile server's certificate", CERT_FIX)
if isinstance(reason, ssl.SSLError):
return ("tls", "the TLS handshake failed", INTERCEPT_FIX)
if isinstance(reason, socket.gaierror):
return (
"dns",
"the tile host does not resolve",
"DNS. Check the machine is online, and that no DNS filter or ad-blocker "
"is swallowing the tile host.",
)
if isinstance(reason, TimeoutError):
return (
"timeout",
"the connection timed out",
"The host is unreachable, or something is dropping the traffic silently: "
"a firewall, or a link too slow for a 12-second timeout.",
)
if isinstance(reason, ConnectionResetError):
return (
"reset",
"the connection was reset",
"Something in the path is cutting the connection - a firewall or a "
"middlebox rather than the tile server.",
)
return ("other", f"of {type(reason).__name__}: {reason}", "")
def verdict(kind: str, cause: str, fix: str, curl: Curl) -> None:
"""Print one coherent story from what Python saw and what ``curl`` saw.
The two readings have to be reconciled rather than printed side by side: a certificate
Python rejects *and curl accepts* is MeshTerm's own trust store, while one they both
reject is the network re-signing traffic or a genuinely bad server - the same Python
exception, opposite causes, opposite fixes.
"""
print(f" The map cannot fetch tiles because {cause}.")
if kind in ("cert", "tls"):
if curl.verdict == "ok":
print(" The system curl reached that very URL, verifying against the OS trust")
print(" store, so the network, the ISP and the tile server are all innocent:")
print(" the fault is this Python's own certificate store.")
print(f"\n Fix: {CERT_FIX}")
return
if curl.verdict == "tls":
print(" The system curl refuses the same certificate, so this is not just")
print(" MeshTerm's trust store: either something on this network is")
print(" intercepting TLS and re-signing it, or the certificate really is bad.")
print(f"\n Fix: {INTERCEPT_FIX}")
return
if curl.verdict == "unreachable":
print(" The system curl could not reach the host at all, so start with the")
print(" network - a firewall, a proxy or a DNS filter - before the certificates.")
return
elif curl.verdict == "ok":
print(" The system curl reached that very URL, so the route exists and this")
print(" Python is going somewhere else - check the proxy variables above.")
elif curl.verdict in ("tls", "unreachable"):
print(" The system curl could not reach it either, so this is the network path")
print(" rather than MeshTerm - a firewall, a proxy, a DNS filter or an outage.")
if fix:
print(f"\n Fix: {fix}")
def count_cached(config_dir: str | None) -> None:
"""Print how many tiles are already on disk, which the map can draw with no network."""
if not config_dir:
return
tiles = Path(config_dir) / "tilecache" / "tiles"
if not tiles.exists():
line("Tile cache", "empty - no tile has ever arrived")
return
line("Tile cache", f"{sum(1 for _ in tiles.rglob('*.pbf'))} tiles at {tiles.parent}")
def main() -> int:
"""Run every check and print one verdict. Returns a process exit status."""
print("MeshTerm basemap doctor")
reexec_under_meshterm()
url, config_dir = describe_env()
tilejson_url = url or FALLBACK_TILEJSON_URL
describe_trust()
heading("Reaching the tile source")
line("Tile source", tilejson_url + ("" if url else " (default)"))
count_cached(config_dir)
curl = curl_says(tilejson_url)
line("System curl", curl.detail)
ok, result = fetch(tilejson_url)
line("This Python", "OK" if ok else f"FAILED - {type(result).__name__}")
tile_ok = False
if ok:
try:
template = json.loads(result)["tiles"][0]
z, x, y = SAMPLE_TILE
tile_url = template.replace("{z}", str(z)).replace("{x}", str(x)).replace("{y}", str(y))
tile_ok, tile_result = fetch(tile_url)
line("One real tile", f"OK, {len(tile_result)} bytes" if tile_ok else "FAILED")
if not tile_ok:
result = tile_result
except (ValueError, KeyError, IndexError) as exc:
line("TileJSON", f"unreadable: {exc}")
heading("Verdict")
if ok and tile_ok:
print(" Tiles reach this machine, so the basemap is NOT a network problem.")
print(" If the map still looks wrong it is the terminal: COLORTERM unset means")
print(" 256 colours, and the basemap's greens and blues collide once they are")
print(" quantized. Apple's Terminal.app cannot do 24-bit colour at all - try")
print(" iTerm2, Ghostty or kitty.")
return 0
failure = result if isinstance(result, BaseException) else RuntimeError("no answer")
verdict(*classify(failure), curl)
prefs = Path(config_dir) / "preferences.toml" if config_dir else Path("~/.meshterm")
print("\n Meanwhile the map still works - it plots nodes without a basemap.")
print(" Per-request detail goes to the log if you set log_level = DEBUG in")
print(f" {prefs} and reopen the map.")
return 1
if __name__ == "__main__":
sys.exit(main())
File renamed without changes.
@@ -1,6 +1,6 @@
#!/bin/sh
# SPDX-License-Identifier: Apache-2.0
# calculinux-console-font.sh -- build, install, and persist the "meshterm" console fonts
# calculinux-console-font-6x12.sh -- build, install, and persist the "meshterm" console fonts
# and the 16-slot palette.
#
# MeshTerm runs on a Luckfox Lyra inside a ClockworkPi PicoCalc under Calculinux, drawing
@@ -42,7 +42,7 @@
# Everything else is pure geometry, generated on-device; only the stock Terminus font and
# python3 are required. Run as root on the Lyra (serial console is fine):
#
# sh calculinux-console-font.sh
# sh calculinux-console-font-6x12.sh
#
# Idempotent -- safe to re-run after a MeshTerm update or a font tweak. Also manages the
# 16-slot palette: a default run restores the stock VT palette (theme._VT_SLOTS) and
@@ -258,7 +258,7 @@ PYEOF
# --- the 6x8 companion, out of its own (GPL-2.0) file -----------------------------------
# Kept separate because its base bitmap is the kernel's font_6x8 and the produced PSF is
# therefore GPL-2.0 -- see scripts/calculinux-console-font-6x8.sh and scripts/LICENSE.GPL-2.0.
# therefore GPL-2.0 -- see scripts/picocalc/calculinux-console-font-6x8.sh and scripts/picocalc/LICENSE.GPL-2.0.
# It re-uses the shared generator block above rather than carrying a second copy of the
# donor/alias/keeper tables, so a run here still produces both fonts, as it always did.
# A failure there is not a failure here: the 6x12 default is the font the device boots in.
@@ -267,7 +267,7 @@ if [ "${SKIP_6X8:-0}" = 1 ]; then
elif [ -f "$BUILD8" ]; then
sh "$BUILD8" || echo "warning: the 6x8 build failed; $OUT is installed either way" >&2
else
echo "note: $BUILD8 not found -- only $OUT was built (copy the whole scripts/ dir)" >&2
echo "note: $BUILD8 not found -- only $OUT was built (copy the whole scripts/picocalc/ dir)" >&2
fi
# --- apply live: setfont re-renders the whole console immediately -----------------------
@@ -294,7 +294,7 @@ fi
# (theme._VT_SLOTS). The custom tailwind-family remap originally shipped is ARCHIVED,
# one environment variable away for anyone who wants it back:
#
# MESHTERM_CUSTOM_PALETTE=1 sh calculinux-console-font.sh
# MESHTERM_CUSTOM_PALETTE=1 sh calculinux-console-font-6x12.sh
#
# The opt-in block's values match theme.vtrgb_lines() / theme._VT_SLOTS_CUSTOM exactly (a
# test keeps them in sync). A DEFAULT run removes any previously-installed remap and
@@ -6,7 +6,7 @@
# LICENCE -- READ THIS FIRST. The base bitmap embedded below is the Linux kernel's own
# font_6x8 (lib/fonts/font_6x8.c), which is GPL-2.0. This script is therefore GPL-2.0-only
# and so is the PSF it produces; the full licence text sits beside it as
# scripts/LICENSE.GPL-2.0. The rest of MeshTerm is Apache-2.0 and stays that way: the
# scripts/picocalc/LICENSE.GPL-2.0. The rest of MeshTerm is Apache-2.0 and stays that way: the
# produced meshterm8.psf.gz is a DATA FILE the Linux kernel console reads with setfont, on
# the device, at run time. MeshTerm never links it, never bundles it, and never
# redistributes it -- it is built on the machine it runs on, from this script, and the
@@ -15,7 +15,7 @@
#
# WHERE THE SHARED PARTS LIVE. The donor, alias and keeper tables, the braille generator
# and the PSF2 writer are NOT duplicated here. They are read out of
# calculinux-console-font.sh (Apache-2.0), between its "shared generator (BEGIN)" and
# calculinux-console-font-6x12.sh (Apache-2.0), between its "shared generator (BEGIN)" and
# "(END)" marker lines, and exec'd -- so the two fonts can never drift, and a repo test
# that parses those tables has exactly one file to parse. That file must sit beside this
# one; point SHARED at it if it does not. The shared generator is additionally offered
@@ -28,7 +28,7 @@
# named in Cyrillic draws as tofu in this font. The keeper list drops the Cyrillic canary
# accordingly, and only pi is checked.
#
# Run as root on the Lyra, either on its own or through calculinux-console-font.sh, which
# Run as root on the Lyra, either on its own or through calculinux-console-font-6x12.sh, which
# calls it:
#
# sh calculinux-console-font-6x8.sh
@@ -43,7 +43,7 @@ FONT_NAME=meshterm
CONSOLEFONTS=/usr/share/consolefonts
OUT8="$CONSOLEFONTS/${FONT_NAME}8.psf.gz"
HERE=$(dirname "$0")
SHARED=${SHARED:-"$HERE/calculinux-console-font.sh"} # Apache-2.0; holds the shared tables
SHARED=${SHARED:-"$HERE/calculinux-console-font-6x12.sh"} # Apache-2.0; holds the shared tables
# --- preflight: fail early with a plain reason, never half-apply -----------------------
[ "$(id -u)" = 0 ] || { echo "error: run as root (writes $CONSOLEFONTS)" >&2; exit 1; }
@@ -57,7 +57,7 @@ import os
import re
# --- the shared generator, borrowed rather than copied ---------------------------------
# Everything between the two marker lines in calculinux-console-font.sh: the braille
# Everything between the two marker lines in calculinux-console-font-6x12.sh: the braille
# generator, art(), DONORS, ALIASES, KEEP_COMMON, BANDS8 and build(). One copy of those
# tables, in the Apache-2.0 file, both fonts built from it.
SHARED = os.environ["SHARED"]
@@ -34,7 +34,7 @@
# 7. venv + install A venv + `pip install -e .`. pip's C builds hit ENOSPC because /tmp
# is a tiny RAM tmpfs, so TMPDIR is redirected to $HOME/tmp on /data.
# 8. PATH Put the venv's `meshterm` on that user's login PATH via ~/.profile.
# 9. console font Hand off to calculinux-console-font.sh (braille + node glyphs +
# 9. console font Hand off to calculinux-console-font-6x12.sh (braille + node glyphs +
# rounded frame corners + the list cursor the bare console can't draw).
#
# Two prerequisites this script cannot safely embed and will check for / guide you through:
@@ -348,10 +348,10 @@ fi
# --- 8. console font -------------------------------------------------------------------
log "9/9 console font"
if [ -f "$SCRIPT_DIR/calculinux-console-font.sh" ]; then
sh "$SCRIPT_DIR/calculinux-console-font.sh"
if [ -f "$SCRIPT_DIR/calculinux-console-font-6x12.sh" ]; then
sh "$SCRIPT_DIR/calculinux-console-font-6x12.sh"
else
info "calculinux-console-font.sh not alongside this script -- skipping (run it separately)"
info "calculinux-console-font-6x12.sh not alongside this script -- skipping (run it separately)"
fi
# --- done ------------------------------------------------------------------------------
File renamed without changes.
@@ -4,10 +4,10 @@ A Seeed XIAO nRF52840 + Wio-SX1262 wired to the Luckfox Lyra's UART1, running Me
companion firmware on `Serial1`, so MeshTerm on the PicoCalc opens `/dev/ttyS1` like any
other serial companion. No BLE, no USB host, no SD-slot sacrifice.
**The manual is [`docs/hardware.md`](../../docs/hardware.md#a-real-radio-for-the-picocalc-xiao-nrf52840-over-uart)**
— parts, the firmware patch and why it is still needed, flashing a bootloader that offers no
drive, what the register poke does, troubleshooting, and what has only been proven on one
bench. This page is the bench card.
**The step-by-step guide is [`docs/picocalc.md`](../../../docs/picocalc.md), Phase 3** — parts,
the firmware build and the patch explained, flashing, wiring, the Lyra setup, and what has
only been proven on one bench. What the register poke does is in `uart1-mux.py`'s own
header. This page is the bench card.
**Licensing.** The firmware is MeshCore, under the MIT License. The `meshcore-uart1.patch`
modifies MeshCore source and `build-firmware.sh` produces a MeshCore binary; the licence is
@@ -17,13 +17,13 @@ Three steps on two machines:
```bash
# 1. dev machine: build (needs git + PlatformIO)
cd scripts/xiao-radio && sh build-firmware.sh
cd scripts/picocalc/xiao-radio && sh build-firmware.sh
# 2. dev machine: flash, with the XIAO's own USB-C plugged in
python flash.py # double-tap reset if the touch doesn't take
# 3. the Lyra, as root
sh scripts/xiao-radio/lyra-setup.sh # MT_USER=meshterm by default
sh scripts/picocalc/xiao-radio/lyra-setup.sh # MT_USER=meshterm by default
# then, as that user
meshterm
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+24 -1
View File
@@ -23,6 +23,7 @@ no radio, no real address, and no real pairing code is involved.
from __future__ import annotations
import asyncio
import sys
import pytest
@@ -200,8 +201,12 @@ def test_a_good_connect_hands_the_client_over_still_open() -> None:
assert client.cx.link_open is True
def test_the_connection_is_built_from_this_device_s_own_endpoint() -> None:
def test_the_connection_is_built_from_this_device_s_own_endpoint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Address, discovered ``BLEDevice`` and PIN all reach the connection we construct."""
# Pinned off macOS, where the PIN is deliberately withheld (see the test below).
monkeypatch.setattr(sys, "platform", "win32")
_FakeMeshCore.reset("ok")
sentinel = object()
dev = MeshCoreDevice(
@@ -221,6 +226,24 @@ def test_the_connection_is_built_from_this_device_s_own_endpoint() -> None:
assert (client.default_timeout, client.auto_reconnect) == (7.5, False)
def test_the_pin_is_withheld_on_macos(monkeypatch: pytest.MonkeyPatch) -> None:
"""No PIN is handed to macOS: supplying one is what *breaks* the connection there.
``BLEConnection.connect`` responds to a PIN by calling bleak's ``pair()``, and
CoreBluetooth has no pairing API — the macOS backend raises outright, whereupon the
library disconnects and re-raises. Pairing there is the OS's to run, prompted by the
unbonded subscribe, so saying nothing is what lets a protected companion bond at all.
"""
monkeypatch.setattr(sys, "platform", "darwin")
_FakeMeshCore.reset("ok")
dev = MeshCoreDevice(transport="ble", address=_ADDR, pin="000000")
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
assert _FakeTransport.last.pin is None
assert dev._pin == "000000" # still remembered — it is the platform that can't use it
def test_a_cancelled_handshake_still_closes_the_link() -> None:
"""A probe's ``wait_for`` expiring mid-handshake must not leak what it cancelled.
+160
View File
@@ -0,0 +1,160 @@
# SPDX-License-Identifier: Apache-2.0
"""Tests for the clock set: the shared device step, and the on-connect service around it.
One function sets the device clock for both the Device config action and the automatic
service; the service is off by default, acts once per connection in the background, and
says what it did in the log rather than on screen.
"""
from __future__ import annotations
import asyncio
import io
import logging
import time
from pathlib import Path
import pytest
from rich.console import Console
from meshterm.context import AppContext
from meshterm.core.admin_store import AdminStore
from meshterm.core.config import Settings
from meshterm.core.device_store import DeviceStore
from meshterm.core.preferences import get_spec
from meshterm.persistence.repository import Repository
from meshterm.services.clock_sync import ClockSync, set_clock
@pytest.fixture()
def ctx(tmp_path: Path) -> AppContext:
"""A mock-backed application context, preferences at their defaults."""
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "clock.db")
context = AppContext(
console=Console(file=io.StringIO()),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "devices.json"),
admin_store=AdminStore(tmp_path / "admin.json"),
mock=True,
)
yield context
context.repo.close()
@pytest.fixture()
def log_lines() -> list[str]:
"""Every record the app logger emits during the test (it does not propagate to root)."""
lines: list[str] = []
class _Collect(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
lines.append(record.getMessage())
handler = _Collect(level=logging.DEBUG)
logger = logging.getLogger("meshterm")
previous = logger.level
logger.setLevel(logging.DEBUG)
logger.addHandler(handler)
yield lines
logger.removeHandler(handler)
logger.setLevel(previous)
async def _settle() -> None:
"""Let a task spawned by the service run to completion."""
for _ in range(5):
await asyncio.sleep(0)
def test_the_preference_exists_and_is_off_by_default() -> None:
"""Writing to a radio nobody asked to be written to is the owner's call."""
spec = get_spec("set_clock_on_connect")
assert spec.value_type == "bool"
assert spec.default is False
assert spec.group == "Device"
async def test_set_clock_writes_the_host_time_and_reports_the_drift(ctx: AppContext) -> None:
"""The shared step corrects a clock an hour slow and says by how much."""
device = await ctx.device()
await device.set_time(int(time.time()) - 3600)
done = await set_clock(device)
assert abs(done.epoch - int(time.time())) <= 2
assert done.drift_s is not None and -3602 <= done.drift_s <= -3598
assert abs(await device.get_time() - int(time.time())) <= 2
async def test_the_service_does_nothing_while_the_preference_is_off(
ctx: AppContext, log_lines: list[str]
) -> None:
"""Off by default: a connection is noted and the clock is left alone."""
device = await ctx.device()
await device.set_time(int(time.time()) - 3600)
service = ClockSync(ctx)
await service.start()
service.on_connected(device)
await _settle()
assert abs(await device.get_time() - (int(time.time()) - 3600)) <= 2
assert not any("clock sync" in line for line in log_lines)
async def test_the_service_sets_the_clock_once_per_connection_and_logs_it(
ctx: AppContext, log_lines: list[str]
) -> None:
"""On: the first settled connection is set in the background; the same one is not set twice."""
ctx.preferences.set("set_clock_on_connect", True)
device = await ctx.device()
await device.set_time(int(time.time()) - 3600)
service = ClockSync(ctx)
await service.start() # a device is already connected: that counts as the first one
await _settle()
assert abs(await device.get_time() - int(time.time())) <= 2
said = [line for line in log_lines if line.startswith("clock sync: device clock set to")]
assert len(said) == 1
assert "-36" in said[0] # "(was -3600 s off)", give or take the seconds the test took
await device.set_time(int(time.time()) - 3600)
service.on_connected(device)
await _settle()
assert abs(await device.get_time() - (int(time.time()) - 3600)) <= 2 # untouched
assert len([line for line in log_lines if "device clock set to" in line]) == 1
await service.aclose()
async def test_a_service_never_started_ignores_connections(
ctx: AppContext, log_lines: list[str]
) -> None:
"""A scripted run never starts the service, so its connections never write the clock."""
ctx.preferences.set("set_clock_on_connect", True)
device = await ctx.device()
await device.set_time(int(time.time()) - 3600)
ctx.clock_sync.on_connected(device) # what the connect path does
await _settle()
assert abs(await device.get_time() - (int(time.time()) - 3600)) <= 2
assert not any("clock sync" in line for line in log_lines)
async def test_a_write_that_fails_is_logged_not_raised(
ctx: AppContext, log_lines: list[str]
) -> None:
"""A firmware that refuses the write costs a warning line, never the session."""
ctx.preferences.set("set_clock_on_connect", True)
device = await ctx.device()
async def _refuse(epoch: int) -> None:
raise RuntimeError("no clock here")
device.set_time = _refuse # type: ignore[method-assign]
service = ClockSync(ctx)
await service.start()
await _settle()
assert any("could not set the device clock: no clock here" in line for line in log_lines)
+555
View File
@@ -0,0 +1,555 @@
# SPDX-License-Identifier: Apache-2.0
"""Column pinning: a row lands on the app's grid however wide the terminal draws its glyphs.
The reasoning lives in :mod:`meshterm.ui.tui.colsnap`. What these tests pin is the claim that
makes it worth having — that alignment no longer depends on the two width tables being *right*
about a glyph, only on the row saying where its columns are.
So the fixture here is a terminal that **disagrees on purpose** (:class:`_Terminal`, handed the
glyphs it draws at a width of its own). That is not a contrived case: it is the situation the
emoji-width module documents as unprobeable and unfixable-in-general, and the one a node name
off the air walks into every time somebody puts an emoji in it. Every assertion below is about
where a glyph *landed*, never about what was measured.
"""
from __future__ import annotations
import re
from rich.cells import cell_len
from meshterm.ui.tui import colsnap
from meshterm.ui.tui.emoji_width import clusters
_WAVE = "👋" # two cells by both stock tables; one cell on the reference terminal's font
_ROAD = "\U0001f6e3" # 🛣 one cell by both tables — the glyph a font may still draw wide
_CA = "🇨🇦" # a flag: one glyph out of two Regional Indicators
_FAMILY = "\U0001f468‍\U0001f469‍\U0001f467" # 👨‍👩‍👧 one glyph out of three joined people
_ROW = "│ ❯ Lakeside ● ▲ ★ ─── 5m │" # chrome only: nothing here needs an address
class _Terminal:
"""A terminal that lays out what it is written using a width table of its own.
Honours exactly one control sequence — ``CSI n G``, the absolute column address — and
ignores every other one, which is how a real terminal treats the theme's colour runs for
the purposes of column arithmetic.
Args:
width: The terminal's width in cells.
draws: Glyphs this terminal draws at a width the app did not measure, as
``{glyph: cells}``. Everything absent is drawn at the measured width, so a test
names its disagreement and nothing else.
"""
_CSI = re.compile(r"\x1b\[([0-9;]*)([A-Za-z])")
def __init__(self, width: int, *, draws: dict[str, int] | None = None) -> None:
"""Start with a blank row and the cursor in column 0."""
self.width = width
self.draws = draws or {}
self.cells: list[str] = [" "] * width
self.column = 0
def write(self, data: str) -> None:
"""Draw ``data``, obeying any column address in it."""
position = 0
for match in self._CSI.finditer(data):
self._draw(data[position : match.start()])
if match.group(2) == "G":
self.column = int(match.group(1) or "1") - 1
position = match.end()
self._draw(data[position:])
def _draw(self, text: str) -> None:
"""Place each glyph of one escape-free stretch, advancing by this terminal's widths."""
for cluster in clusters(text):
size = self.draws.get(cluster, cell_len(cluster))
if 0 <= self.column < self.width:
self.cells[self.column] = cluster
for overhang in range(1, size):
if self.column + overhang < self.width:
self.cells[self.column + overhang] = ""
self.column += size
def at(self, column: int) -> str:
"""The glyph drawn in ``column``."""
return self.cells[column]
def test_a_row_of_chrome_is_handed_back_untouched() -> None:
"""The common row costs one scan and no allocation — the same object comes back.
Borders, block elements, braille and the node marks are what the app draws by the
thousand, and pinning after each of them would be five bytes and a cluster walk to
address the column the cursor is already in.
"""
assert colsnap.snap_row(_ROW) is _ROW
assert colsnap.snap_row("plain ascii row") is "plain ascii row" # noqa: F632 - identity
assert colsnap.snap_row("⠁⠂⠃⡿ braille chart ▁▃▅█ ╭─╮") is not None
def test_an_emoji_is_followed_by_the_column_it_was_measured_into() -> None:
"""The whole mechanism: the glyph, then the one-based column the next glyph belongs in."""
assert cell_len(_WAVE) == 2, "the stock table measures the wave at two cells"
# "a" fills column 0, the wave is measured into columns 1-2, so "b" belongs in column 3.
assert colsnap.snap_row(f"a{_WAVE}b") == f"a\x1b[2X{_WAVE}\x1b[4Gb"
def test_nothing_is_pinned_after_the_last_glyph_on_the_row() -> None:
"""An address is held over until something drawable follows it, and dropped if none does.
A chat line ending on an emoji is the common case, and there is nothing after it whose
column could be wrong.
"""
assert colsnap.snap_row(f"hi {_WAVE}") == f"hi \x1b[2X{_WAVE}"
def test_a_style_change_occupies_no_column_and_stays_where_the_theme_put_it() -> None:
"""A colour run is copied through without advancing the column it would have shifted."""
pinned = colsnap.snap_row(f"\x1b[31m{_WAVE}\x1b[0m|")
assert pinned == f"\x1b[31m\x1b[2X{_WAVE}\x1b[0m\x1b[3G|"
def test_a_flag_and_a_joined_sequence_are_pinned_once_as_whole_glyphs() -> None:
"""One address per glyph, not per codepoint — the cut that halves a flag is never made."""
for glyph in (_CA, _FAMILY):
width = cell_len(glyph)
pinned = colsnap.snap_row(f"{glyph}|")
assert pinned == f"\x1b[{width}X{glyph}\x1b[{width + 1}G|"
assert pinned.count("G") == 1
def test_a_bare_text_emoji_is_pinned_even_though_both_tables_agree_about_it() -> None:
"""Agreement is not certainty: what a font draws is still the font's business.
``🛣`` is the glyph whose measurement must not be *forced* to two (that pulled the Trophy
case's border in). Pinning is the other answer to the same doubt: leave the measurement
alone and say where the next column is, so the row holds either way.
"""
assert cell_len(_ROAD) == 1
assert colsnap.snap_row(f"{_ROAD}|") == f"{_ROAD}\x1b[2G|"
def test_the_row_frames_flush_on_a_terminal_that_draws_the_glyph_narrow() -> None:
"""The notch, and its absence: a border lands in its measured column once pinned."""
row = f"│ {_WAVE} Lakeside │"
edge = cell_len(row) - 1
loose = _Terminal(cell_len(row), draws={_WAVE: 1})
loose.write(row)
assert loose.at(edge) != "│", "unpinned, the narrow glyph pulls the border a column in"
assert loose.column == cell_len(row) - 1
pinned = _Terminal(cell_len(row), draws={_WAVE: 1})
pinned.write(colsnap.snap_row(row))
assert pinned.at(edge) == "│"
assert pinned.column == cell_len(row)
def test_the_row_frames_flush_on_a_terminal_that_draws_the_glyph_wide() -> None:
"""The mirror case: the overhang is written over, and the row still ends where it should.
One cell of the glyph is lost to its neighbour, which is the trade — a cell of cosmetic
damage inside the glyph's own lane, in place of every lane after it moving.
"""
row = f"│ {_ROAD} Lakeside │"
edge = cell_len(row) - 1
loose = _Terminal(cell_len(row), draws={_ROAD: 2})
loose.write(row)
assert loose.at(edge) != "│"
pinned = _Terminal(cell_len(row), draws={_ROAD: 2})
pinned.write(colsnap.snap_row(row))
assert pinned.at(edge) == "│"
def test_every_lane_of_a_archive_preview_row_lands_where_the_screen_measured_it() -> None:
"""The screen this started on: a swept contact whose name carries an emoji.
The name lane is already fitted in display cells (:func:`~meshterm.ui.menus.fit_cells`),
which is the padding that cannot help — it is computed with the same measurement the
terminal disagrees with. What holds the evidence lanes under their headers is the pinning.
"""
from meshterm.core.contact_score import ContactSignals, ScoredContact
from meshterm.core.models import Contact
from meshterm.ui.sweep_screen import _NAME_W, _lane_widths, _victim_row
from meshterm.ui.tui.render import render_to_ansi
key = "ab" * 32
victim = ScoredContact(
contact=Contact(name=f"{_WAVE} Lakeside", public_key=key, key_prefix=key[:12]),
signals=ContactSignals(node=key[:12], packets=12, hops=2.0),
score=1.0,
percentile=3,
)
row = _victim_row(victim, _lane_widths([victim]))
measured = cell_len(row.plain)
ansi = render_to_ansi(row, 72, no_wrap=True)
loose = _Terminal(80, draws={_WAVE: 1})
loose.write(ansi)
assert loose.column != measured, "unpinned, one emoji shifts every lane after it"
pinned = _Terminal(80, draws={_WAVE: 1})
pinned.write(colsnap.snap_row(ansi))
assert pinned.column == measured
# Not just the right edge: *every* glyph of the row is in the column the screen's own
# cell arithmetic put it in, which is what keeps a lane under the header naming it.
column = 0
for cluster in clusters(row.plain):
if cluster != " ":
assert pinned.at(column) == cluster, f"column {column} holds the wrong glyph"
column += cell_len(cluster)
assert column == measured
assert _NAME_W == 22 # the name lane the evidence starts after, gap included
def test_pinning_is_on_with_an_escape_hatch(monkeypatch) -> None: # noqa: ANN001
"""On by default; only an explicit 0 writes rows the way they were composed."""
monkeypatch.delenv("MESHTERM_COLUMN_SNAP", raising=False)
assert colsnap.enabled()
monkeypatch.setenv("MESHTERM_COLUMN_SNAP", "0")
assert not colsnap.enabled()
def test_the_row_writer_pins_what_it_writes(monkeypatch) -> None: # noqa: ANN001
"""The wiring: a composed frame reaches the terminal carrying its column addresses."""
from prompt_toolkit.data_structures import Size
from prompt_toolkit.styles import Style
from meshterm.ui.tui.fastrender import FastRenderer
class _Capturing:
"""Just enough output for one paint: what was written, and how big the screen is."""
def __init__(self) -> None:
self.written: list[str] = []
def write_raw(self, data: str) -> None:
self.written.append(data)
def get_size(self) -> Size:
return Size(rows=26, columns=72)
def __getattr__(self, name: str): # noqa: ANN001, ANN204 - the rest is inert here
return lambda *args, **kwargs: None
frame = f"contact {_WAVE} 5m\nsecond row\n"
out = _Capturing()
renderer = FastRenderer(Style([]), out, full_screen=True, frame_source=lambda: frame)
renderer.render(None, None)
assert "\x1b[11G" in "".join(out.written) # the wave measured into columns 8-9
monkeypatch.setenv("MESHTERM_COLUMN_SNAP", "0")
off = _Capturing()
plain = FastRenderer(Style([]), off, full_screen=True, frame_source=lambda: frame)
plain.render(None, None)
assert "\x1b[11G" not in "".join(off.written)
def test_a_platform_that_draws_no_emoji_pins_nothing() -> None:
"""The PicoCalc draws only glyphs its own font has verified, so there is nothing to pin."""
from meshterm.platforms import PICOCALC, set_platform
assert colsnap.enabled()
set_platform(PICOCALC)
assert not colsnap.enabled()
# --- prompt_toolkit's renderer: the dialog path -------------------------------------------------
class _Tty:
"""A two-dimensional terminal, for the renderer that moves its cursor relatively.
Honours what prompt_toolkit's differential renderer and :class:`colsnap.PinnedOutput` send:
carriage return, newline, backspace, cursor save and restore, and the CSI cursor moves.
Colour and mode sequences are ignored, which is all a terminal does with them as far as
column arithmetic goes. Glyph widths come from prompt_toolkit's own measurement unless
``draws`` names a glyph this terminal disagrees about.
"""
_SEQUENCE = re.compile(r"\x1b(?:\[([0-9;?]*)([A-Za-z@`])|([78]))")
def __init__(self, cols: int, rows: int, *, draws: dict[str, int] | None = None) -> None:
"""Start blank, with the cursor home."""
self.cols, self.rows = cols, rows
self.draws = draws or {}
self.grid = [[" "] * cols for _ in range(rows)]
self.x = self.y = 0
self._saved = (0, 0)
def feed(self, data: str) -> None:
"""Interpret ``data`` as the terminal would."""
position = 0
for match in self._SEQUENCE.finditer(data):
self._text(data[position : match.start()])
position = match.end()
if match.group(3) == "7":
self._saved = (self.x, self.y)
elif match.group(3) == "8":
self.x, self.y = self._saved
else:
self._csi(match.group(1), match.group(2))
self._text(data[position:])
def _csi(self, params: str, final: str) -> None:
"""One control sequence: the cursor moves, the erases, and nothing else."""
if params.startswith("?"):
return
numbers = [int(part) if part else 0 for part in params.split(";")] if params else []
count = numbers[0] if numbers and numbers[0] else 1
if final == "A":
self.y = max(0, self.y - count)
elif final == "B":
self.y = min(self.rows - 1, self.y + count)
elif final == "C":
self.x = min(self.cols - 1, self.x + count)
elif final == "D":
self.x = max(0, self.x - count)
elif final == "G":
self.x = count - 1
elif final in "Hf":
row = numbers[0] if numbers and numbers[0] else 1
column = numbers[1] if len(numbers) > 1 and numbers[1] else 1
self.y, self.x = row - 1, column - 1
elif final == "X":
for column in range(self.x, min(self.cols, self.x + count)):
self.grid[self.y][column] = " "
elif final == "K":
self.grid[self.y][self.x :] = [" "] * (self.cols - self.x)
elif final == "J":
self.grid[self.y][self.x :] = [" "] * (self.cols - self.x)
for row in range(self.y + 1, self.rows):
self.grid[row] = [" "] * self.cols
def _text(self, text: str) -> None:
"""Place each glyph at the cursor, advancing by this terminal's idea of its width."""
from prompt_toolkit.utils import get_cwidth
for cluster in clusters(text):
if cluster == "\r":
self.x = 0
elif cluster == "\n":
self.y = min(self.rows - 1, self.y + 1)
elif cluster == "\b":
self.x = max(0, self.x - 1)
else:
size = self.draws.get(cluster, get_cwidth(cluster))
if self.x < self.cols:
self.grid[self.y][self.x] = cluster
for overhang in range(1, size):
if self.x + overhang < self.cols:
self.grid[self.y][self.x + overhang] = ""
self.x += size
def glyph_at(self, row: int, column: int) -> str:
"""The glyph drawn at ``(row, column)``."""
return self.grid[row][column]
def _screen(rows: list[str], cols: int): # noqa: ANN202 - a prompt_toolkit Screen
"""Lay ``rows`` out exactly as prompt_toolkit lays out a window's text."""
from prompt_toolkit.layout import Window
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.layout.mouse_handlers import MouseHandlers
from prompt_toolkit.layout.screen import Screen, WritePosition
screen = Screen()
window = Window(FormattedTextControl("\n".join(rows)), wrap_lines=False)
window.write_to_screen(
screen, MouseHandlers(), WritePosition(0, 0, cols, len(rows)), "", False, None
)
return screen
def _paint(output, screen, previous, position, cols: int, rows: int): # noqa: ANN001, ANN202
"""One pass of prompt_toolkit's own differential writer over ``screen``."""
from types import SimpleNamespace
from prompt_toolkit.data_structures import Size
from prompt_toolkit.output.color_depth import ColorDepth
from prompt_toolkit.renderer import (
_output_screen_diff,
_StyleStringHasStyleCache,
_StyleStringToAttrsCache,
)
from prompt_toolkit.styles import DummyStyleTransformation, Style
attrs = _StyleStringToAttrsCache(Style([]).get_attrs_for_style_str, DummyStyleTransformation())
position, _style = _output_screen_diff(
SimpleNamespace(layout=SimpleNamespace(current_window=None)),
output,
screen,
position,
ColorDepth.DEPTH_24_BIT,
previous,
None,
False,
True,
attrs,
_StyleStringHasStyleCache(attrs),
Size(rows=rows, columns=cols),
cols if previous is not None else 0,
)
return position
def _vt100(cols: int, rows: int): # noqa: ANN202 - a Vt100_Output writing into memory
"""A real VT100 output, so every cursor move is spelled the way the terminal receives it."""
import io
from prompt_toolkit.data_structures import Size
from prompt_toolkit.output.vt100 import Vt100_Output
return Vt100_Output(io.StringIO(), lambda: Size(rows=rows, columns=cols), term="xterm")
def _drained(output) -> str: # noqa: ANN001
"""Everything written to ``output`` since the last drain."""
vt100 = getattr(output, "_inner", output)
data = "".join(vt100._buffer)
vt100._buffer.clear()
return data
def _assert_landed(tty: _Tty, row: int, text: str) -> None:
"""Every visible glyph of ``text`` is in the column prompt_toolkit measured it into."""
from prompt_toolkit.utils import get_cwidth
column = 0
for cluster in clusters(text):
if cluster != " ":
assert tty.glyph_at(row, column) == cluster, f"column {column} of row {row}"
column += get_cwidth(cluster)
def test_a_dialog_row_frames_flush_on_a_terminal_that_draws_the_glyph_narrow() -> None:
"""prompt_toolkit's renderer, unpinned and pinned, on a terminal that disagrees about 👋.
The renderer writes the whole row in one run after its first move, adding two cells for the
wave to a cursor of its own while the terminal adds one. Unpinned, the rest of the row is
drawn a column early. Pinned, every glyph is where the renderer measured it.
"""
cols, rows = 24, 2
text = [f"│ {_WAVE} Lakeside 5m │", "│ Plain row 5m │"]
screen = _screen(text, cols)
loose, loose_tty = _vt100(cols, rows), _Tty(cols, rows, draws={_WAVE: 1})
_paint(loose, screen, None, _point(), cols, rows)
loose_tty.feed(_drained(loose))
assert loose_tty.glyph_at(0, cell_len(text[0]) - 1) != "│", "unpinned, the border moves"
pinned, pinned_tty = (
colsnap.PinnedOutput(_vt100(cols, rows)),
_Tty(cols, rows, draws={_WAVE: 1}),
)
_paint(pinned, screen, None, _point(), cols, rows)
pinned_tty.feed(_drained(pinned))
for row, line in enumerate(text):
_assert_landed(pinned_tty, row, line)
def test_a_repaint_after_the_glyph_lands_where_the_renderer_measured_it() -> None:
"""The move that follows a pinned glyph is computed relatively, and still lands.
A second paint changes the glyph *and* a value later on the same row, with unchanged cells
between them, so the renderer writes the new glyph and then jumps forward by the distance its
own arithmetic says. That jump is only right if the terminal's cursor was put back in step
after the glyph — which is the whole claim of the relative pin.
"""
cols, rows = 24, 1
first = [f"│ {_WAVE} Lakeside 5m │"]
second = [f"│ {_ROCKET} Lakeside 7m │"]
before, after = _screen(first, cols), _screen(second, cols)
for pin, lands in ((False, False), (True, True)):
output = _vt100(cols, rows)
if pin:
output = colsnap.PinnedOutput(output)
tty = _Tty(cols, rows, draws={_WAVE: 1, _ROCKET: 1})
position = _paint(output, before, None, _point(), cols, rows)
tty.feed(_drained(output))
_paint(output, after, before, position, cols, rows)
tty.feed(_drained(output))
seven = cell_len(second[0].split("7m")[0])
assert (tty.glyph_at(0, seven) == "7") is lands
def test_a_glyph_drawn_narrow_leaves_no_character_from_the_last_frame_beside_it() -> None:
"""The cell a narrow-drawn glyph does not cover is blank, not what the last frame put there.
prompt_toolkit treats the cell after a wide glyph as part of it and never writes it, so a
repaint that puts a two-cell glyph where two letters were leaves the second letter standing
on a terminal that draws the glyph in one cell. The pin erases its reservation first.
"""
cols, rows = 16, 1
before, after = _screen(["│ ab Lakeside │"], cols), _screen([f"│ {_WAVE} Lakeside │"], cols)
for pin, stale in ((False, True), (True, False)):
output = _vt100(cols, rows)
if pin:
output = colsnap.PinnedOutput(output)
tty = _Tty(cols, rows, draws={_WAVE: 1})
position = _paint(output, before, None, _point(), cols, rows)
tty.feed(_drained(output))
_paint(output, after, before, position, cols, rows)
tty.feed(_drained(output))
assert tty.glyph_at(0, 2) == _WAVE
assert (tty.glyph_at(0, 3) == "b") is stale
def test_the_pinned_output_brackets_only_a_lone_uncertain_glyph() -> None:
"""What reaches the terminal for each kind of write prompt_toolkit's renderer makes."""
class _Recorder:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
def write(self, data: str) -> None:
self.calls.append(("write", data))
def write_raw(self, data: str) -> None:
self.calls.append(("raw", data))
recorder = _Recorder()
output = colsnap.PinnedOutput(recorder)
for data in ("a", "\r\n", "│", "⠿", "é", "ab"):
output.write(data)
assert recorder.calls == [
("write", "a"),
("write", "\r\n"),
("write", "│"),
("write", "⠿"),
("write", "é"),
("write", "ab"),
], "a cell of text passes straight through"
for glyph in (_WAVE, _FAMILY, _CA):
recorder.calls.clear()
output.write(glyph)
from prompt_toolkit.utils import get_cwidth
step = get_cwidth(glyph)
assert recorder.calls == [
("raw", f"\x1b7\x1b[{step}X"),
("write", glyph),
("raw", f"\x1b8\x1b[{step}C"),
]
recorder.calls.clear()
output.write(f"{_WAVE}{_WAVE}") # two glyphs is not a cell, and is not pinned
assert recorder.calls == [("write", f"{_WAVE}{_WAVE}")]
assert output.calls is recorder.calls # everything else forwards
_ROCKET = "\U0001f680" # 🚀 a second emoji the disagreeing terminal draws in one cell
def _point(): # noqa: ANN202 - a prompt_toolkit Point at the origin
"""The cursor at home, where a first paint starts."""
from prompt_toolkit.data_structures import Point
return Point(x=0, y=0)
+10 -12
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
from meshterm.core.contact_score import (
PROTECT_ADMIN,
PROTECT_LOCKED,
PROTECT_MESSAGED,
PROTECT_UNOBSERVED,
PROTECT_WATCHED,
@@ -58,7 +59,7 @@ def test_an_unknown_signal_scores_neutral_not_zero() -> None:
location at all, plenty of nodes are never overheard as a relayed packet so carry no hop
count, and a channel poster whose name matches no contact can't be attributed. If any of
those resolved to zero, every unmeasurable contact would sink together and the sweep
would be purging by how much metadata a node happens to broadcast.
would be archiving by how much metadata a node happens to broadcast.
"""
# Two contacts identical in every measured way; one is simply unplaced and unrouted.
ranked = _by_name(
@@ -145,11 +146,12 @@ def test_a_newcomer_is_given_a_fortnight_to_earn_its_keep() -> None:
def test_protections_outrank_the_arithmetic() -> None:
"""A star, a sent message, an admin login or an unheard contact is never a candidate.
"""A lock, a star, a sent message, an admin login or an unheard contact is never a candidate.
A heuristic must not overrule an explicit choice, and it must not punish a contact for
MeshTerm's own ignorance.
"""
assert protection_for(ContactSignals(node="a" * 12, **_BASE, locked=True)) == PROTECT_LOCKED
assert protection_for(ContactSignals(node="a" * 12, **_BASE, watched=True)) == PROTECT_WATCHED
assert protection_for(ContactSignals(node="a" * 12, **_BASE, dm_outbound=1)) == PROTECT_MESSAGED
assert protection_for(ContactSignals(node="a" * 12, **_BASE, has_admin=True)) == PROTECT_ADMIN
@@ -306,16 +308,6 @@ def test_a_contact_with_no_signals_at_all_is_protected() -> None:
assert ranked[0].protection == PROTECT_UNOBSERVED
def test_reasons_name_the_weakest_lanes_in_plain_words() -> None:
"""Each preview row explains itself without the reader having to know the formula."""
ranked = _rank(("Quiet", {"packets": 3, "heard_age_days": 240.0, "dm_total": 0}))
reasons = ranked[0].reasons
assert "never messaged" in reasons
assert "3 pkts" in reasons
# The age reads in the fewest words that are still true — months, here.
assert any(r.endswith("mo") for r in reasons)
def test_a_stronger_contact_never_scores_below_a_weaker_one_on_every_axis() -> None:
"""Sanity: dominance is preserved — better everywhere means ranked above."""
ranked = _by_name(
@@ -335,3 +327,9 @@ def test_a_stronger_contact_never_scores_below_a_weaker_one_on_every_axis() -> N
)
assert ranked["Better"].score > ranked["Worse"].score
assert ranked["Better"].percentile > ranked["Worse"].percentile
def test_a_lock_explains_a_contact_before_any_other_claim_on_it() -> None:
"""A lock says nothing but "keep this one", so it is the reason the preview gives."""
both = ContactSignals(node="a" * 12, **_BASE, locked=True, watched=True, dm_outbound=3)
assert protection_for(both) == PROTECT_LOCKED
+42 -1
View File
@@ -238,7 +238,7 @@ async def test_devstate_merge_keeps_a_repeater_a_repeater(ctx) -> None:
def test_an_archived_contact_is_remembered_but_kept_out_of_the_merge(tmp_path) -> None: # noqa: ANN001
"""Archiving frees a device slot without losing the contact — and without undoing itself.
The sweep's whole shape (see :mod:`meshterm.ui.purge_screen`): the contact is deleted
The sweep's whole shape (see :mod:`meshterm.ui.sweep_screen`): the contact is deleted
from the *device*, which is the scarce resource, and kept here in full. The subtlety is
the merge — this store's normal job is to union remembered contacts back into any list
the device isn't reporting, which would put an archived contact straight back and make
@@ -312,3 +312,44 @@ def test_a_keyless_contact_cannot_be_archived(tmp_path) -> None: # noqa: ANN001
store = ContactStore(tmp_path / "contacts.json")
store.archive(PUB_A, Contact(name="Ghost"), when=1_700_000_000)
assert store.archived(PUB_A) == []
def test_a_lock_is_ours_and_survives_every_fresh_read_of_the_contact(tmp_path) -> None: # noqa: ANN001
"""The device knows nothing of a lock, so reading the device must never clear one."""
from meshterm.core.models import Contact
store = ContactStore(tmp_path / "contacts.json")
alice = Contact(name="Alice", public_key="aa" * 32, key_prefix="aa" * 6)
bob = Contact(name="Bob", public_key="bb" * 32, key_prefix="bb" * 6)
# Locking a contact the store never saw upserts it, as archiving does.
store.set_locked(PUB_A, alice, True)
assert store.is_locked(PUB_A, "aa" * 32)
assert store.locked_keys(PUB_A) == frozenset({"aa" * 32})
# A fresh read — a rename included — keeps the lock and changes the rest.
store.remember_all(PUB_A, [Contact(name="Alice II", public_key="aa" * 32), bob])
assert store.is_locked(PUB_A, "aa" * 32)
assert not store.is_locked(PUB_A, "bb" * 32)
assert [c.name for c in store.contacts(PUB_A)] == ["Alice II", "Bob"]
# Persisted, and scoped to the device it was set on.
reloaded = ContactStore(tmp_path / "contacts.json")
assert reloaded.locked_keys(PUB_A) == frozenset({"aa" * 32})
assert reloaded.locked_keys("ff" * 32) == frozenset()
reloaded.set_locked(PUB_A, alice, False)
assert ContactStore(tmp_path / "contacts.json").locked_keys(PUB_A) == frozenset()
def test_an_archived_or_keyless_contact_takes_no_lock(tmp_path) -> None: # noqa: ANN001
"""The lock keeps a live contact off the archive path; there is nothing else to lock."""
from meshterm.core.models import Contact
store = ContactStore(tmp_path / "contacts.json")
alice = Contact(name="Alice", public_key="aa" * 32, key_prefix="aa" * 6)
store.archive(PUB_A, alice, when=1_700_000_000)
store.set_locked(PUB_A, alice, True)
assert store.locked_keys(PUB_A) == frozenset()
store.set_locked(PUB_A, Contact(name="Ghost"), True)
assert store.locked_keys(PUB_A) == frozenset()
+244 -39
View File
@@ -311,7 +311,7 @@ def test_contacts_screen_lists_contacts_in_the_shared_lanes() -> None:
import re
from meshterm.core.models import Contact, utcnow
from meshterm.ui.contacts_screen import _PURGE, YOU, ContactsScreen
from meshterm.ui.contacts_screen import _ARCHIVE, YOU, ContactsScreen
from meshterm.ui.tui.screen import CANCEL
contacts = [
@@ -333,8 +333,8 @@ def test_contacts_screen_lists_contacts_in_the_shared_lanes() -> None:
assert f"{7:>5}" in body # Alice's overheard packets, right-aligned in its lane
assert "never" in body # Bob has no last_seen
# Each contact row carries the Contact itself, so Enter hands the whole record on; the
# tail closes the list with the purge action (there is no exit row — Esc leaves).
assert choices[-1].value == _PURGE
# tail closes the list with the archive action (there is no exit row — Esc leaves).
assert choices[-1].value == _ARCHIVE
assert all(isinstance(c.value, Contact) for c in choices[1:-1])
# Plain arrows move the highlight without touching the sort.
@@ -354,7 +354,7 @@ def test_contacts_screen_lists_contacts_in_the_shared_lanes() -> None:
assert resolved == [highlighted, CANCEL]
def test_purge_age_rungs_split_stale_from_never_heard() -> None:
def test_archive_age_rungs_split_stale_from_never_heard() -> None:
"""An age rung takes only contacts once heard and since gone quiet; never-heard has its own.
The ladder's second section is the plain predictable operation a ranking cannot express,
@@ -364,7 +364,7 @@ def test_purge_age_rungs_split_stale_from_never_heard() -> None:
"""
from meshterm.core.contact_score import ContactSignals, ScoredContact
from meshterm.core.models import Contact
from meshterm.ui.purge_screen import _DAY, _NEVER, victims_for
from meshterm.ui.sweep_screen import _DAY, _NEVER, victims_for
def scored(name: str, days, score: float) -> ScoredContact: # noqa: ANN001
contact = Contact(name=name, public_key=f"{ord(name[0]):02x}" * 32)
@@ -396,7 +396,7 @@ def test_purge_age_rungs_split_stale_from_never_heard() -> None:
assert names(("age", 7 * _DAY)) == ["Ancient", "Old"]
async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> None:
async def test_archive_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> None:
"""The whole sweep, end to end: ladder → preview → amber confirm → bar → outcome dialog.
Two things the busy overlay could not do (JP, 2026-08-10). It only ever said "working",
@@ -414,8 +414,8 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
from meshterm.core.contact_score import ContactSignals
from meshterm.core.models import Contact, utcnow
from meshterm.ui.purge_screen import purge_contacts
from meshterm.ui.surface import TuiUi
from meshterm.ui.sweep_screen import archive_contacts
from meshterm.ui.tui.progress import ProgressScreen
from meshterm.ui.tui.session import TuiSession
@@ -459,10 +459,12 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
ui.dialog = _dialog # type: ignore[method-assign]
ctx = SimpleNamespace(
ui=ui,
log=logging.getLogger("test.purge"),
log=logging.getLogger("test.archive"),
repo=_Repo(),
contact_store=SimpleNamespace(
archive=lambda dev, contact, when: archived.append(contact.name)
locked_keys=lambda *a: frozenset(),
is_locked=lambda *a: False,
archive=lambda dev, contact, when: archived.append(contact.name),
),
watch_store=None,
admin_store=None,
@@ -474,15 +476,16 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
device=lambda: _device(_Device()),
)
task = asyncio.ensure_future(purge_contacts(ctx, "cc" * 32))
task = asyncio.ensure_future(archive_contacts(ctx, "cc" * 32))
# Walk past the five standing rungs into the second section and take "not heard in 1
# year", which catches all three — the standing rungs keep a share, so none of them
# ever takes a whole table.
ladder = await _step_until_screen(session, lambda s: s.title.startswith("Purge contacts"))
ladder = await _step_until_screen(session, lambda s: s.title.startswith("Archive contacts"))
body = _screen_text(ladder)
# A rung is described by what it *keeps* — the number that has to fit the device.
assert "keeps" in body
# A rung is counted in columns, and what it *keeps* — the number that has to fit the
# device — leads what it archives.
assert body.index("KEEPS") < body.index("ARCHIVES")
for _ in range(9):
ladder.handle("down")
ladder.handle("enter")
@@ -490,7 +493,10 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
# The preview lists exactly who would go, and commits on its Apply row (the default).
preview = await _step_until_screen(session, lambda s: "to archive" in getattr(s, "title", ""))
body = _screen_text(preview)
assert "PCTL" in body # the standing lane is a percentile, never a raw score
# The evidence, one lane per kind — never a score, and no longer a percentile either:
# the row shows what was measured, and the list's order is the ranking.
lanes = ["NAME", "HEARD", "PKTS", "MSGS", "HOPS"]
assert [body.index(word) for word in lanes] == sorted(body.index(word) for word in lanes)
assert all(v.name in body for v in victims)
preview.handle("enter")
@@ -499,12 +505,12 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
bar = await _step_until_screen(session, lambda s: isinstance(s, ProgressScreen))
assert asked and asked[0]["danger"] is True
assert "destructive" not in asked[0]
assert [label for label, _v in asked[0]["buttons"]] == ["Cancel", "Purge"]
assert [label for label, _v in asked[0]["buttons"]] == ["Cancel", "Archive"]
assert "restored" in asked[0]["prompt"]
# The sweep runs under the progress dialog, which is the frontmost screen for its whole
# lifetime — so the list underneath cannot be walked while it works.
assert bar.title == "Purge contacts"
assert bar.title == "Archive contacts"
assert bar.render_body(60) # a real bar, with a real total to count down
bar.handle("down") # every key is swallowed; nothing underneath moves
@@ -520,6 +526,55 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
assert sorted(archived) == ["Old0", "Old1", "Old2"]
def test_a_preview_row_leads_with_the_contacts_type_mark() -> None:
"""Each contact the sweep would take leads with its type mark, as the Contacts list's do.
The mark is a lane of its own ahead of the name, in the type's colour rather than the
name's. The header moves over with it — ``NAME`` over the name, every evidence word still
over its values — and ``←→`` keeps the mark pinned beside the name it belongs to.
"""
from meshterm.core.contact_score import ContactSignals, ScoredContact
from meshterm.core.models import NODE_TYPE_REPEATER, Contact
from meshterm.ui.sweep_screen import (
_MARK_W,
_NAME_W,
_lane_widths,
_preview_header,
_preview_items,
_victim_row,
)
from meshterm.ui.theme import name_style
def scored(name: str, key: str, node_type: int | None) -> ScoredContact:
return ScoredContact(
contact=Contact(name=name, public_key=key, key_prefix=key[:12], node_type=node_type),
signals=ContactSignals(node=key[:12], packets=3),
score=1.0,
percentile=1,
)
victims = [scored("Hilltop", "3d" * 32, NODE_TYPE_REPEATER), scored("Walker", "5e" * 32, None)]
widths = _lane_widths(victims)
hilltop, walker = (_victim_row(v, widths) for v in victims)
assert hilltop.plain.startswith("▲ Hilltop")
assert walker.plain.startswith("● Walker") # never advertised a type: the plain node
def styles_at(row, index: int) -> list[str]: # noqa: ANN001
return [str(span.style) for span in row.spans if span.start <= index < span.end]
assert styles_at(hilltop, 0) == ["type.repeater"]
assert styles_at(hilltop, hilltop.plain.index("Hilltop")) == [name_style("Hilltop", "3d" * 32)]
# The header is drawn from the pointer column's edge and a row from just past it.
header = _preview_header(widths, 72)
assert header.index("NAME") == 2 + hilltop.plain.index("Hilltop")
assert header.index("PKTS") + len("PKTS") - 1 == 2 + hilltop.plain.index("3")
items = _preview_items(victims)
rows = [c for c in items if isinstance(getattr(c, "value", None), ScoredContact)]
assert len(rows) == 2 and all(c.hscroll_from == _MARK_W + _NAME_W for c in rows)
async def test_sparing_a_row_shrinks_the_sweep_without_leaving_the_preview() -> None:
"""Delete on a preview row lifts that contact out of the sweep, in place.
@@ -534,8 +589,8 @@ async def test_sparing_a_row_shrinks_the_sweep_without_leaving_the_preview() ->
from meshterm.core.contact_score import ContactSignals, ScoredContact
from meshterm.core.models import Contact
from meshterm.ui.purge_screen import _preview
from meshterm.ui.surface import TuiUi
from meshterm.ui.sweep_screen import _preview
from meshterm.ui.tui.session import TuiSession
def scored(name: str, pct: int) -> ScoredContact:
@@ -597,8 +652,8 @@ async def test_sparing_every_row_leaves_the_sweep_with_nothing_to_do() -> None:
from meshterm.core.contact_score import ContactSignals, ScoredContact
from meshterm.core.models import Contact
from meshterm.ui.purge_screen import _preview
from meshterm.ui.surface import TuiUi
from meshterm.ui.sweep_screen import _preview
from meshterm.ui.tui.session import TuiSession
key = "aa" * 32
@@ -618,6 +673,50 @@ async def test_sparing_every_row_leaves_the_sweep_with_nothing_to_do() -> None:
assert victims == []
@pytest.mark.parametrize("platform_name", ["regular", "picocalc"])
def test_the_archive_preview_keeps_its_cursor_in_its_box_walking_both_ways(
platform_name: str,
) -> None:
"""The preview's highlight stays inside its box however far the reader walks, and back.
A long sweep scrolls, and it is a list with a prompt over a pinned column header. Both
once let the cursor leave the window on the way back up: the prompt was counted twice in
the line the frame keeps in view, so the real row rode above the box's top edge, and at
the bottom the header's slide dropped the row the reader had just stepped onto. Walked
through the real dialog compositor on each platform, at its own readable width.
"""
from rich.text import Text
from meshterm.core.contact_score import ContactSignals, ScoredContact
from meshterm.core.models import Contact
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.sweep_screen import _preview_screen
from meshterm.ui.tui.frame import compose_dialog
platform = {"regular": REGULAR, "picocalc": PICOCALC}[platform_name]
set_platform(platform)
def scored(index: int) -> ScoredContact:
key = f"{index:02x}" * 32
return ScoredContact(
contact=Contact(name=f"Faraway {index}", public_key=key, key_prefix=key[:12]),
signals=ContactSignals(node=key[:12], heard_age_days=40.0 + index, packets=index),
score=float(index),
percentile=index,
)
screen = _preview_screen([scored(i) for i in range(40)])
cols, rows = platform.readable_cols, 24
# The cursor opens on Apply, at the bottom. Walk to the first contact, down to Back,
# and up again: every step is a paint the reader sees.
walk = [None] + ["up"] * 45 + ["down"] * 45 + ["up"] * 45
for step, key in enumerate(walk):
if key is not None:
screen.handle(key)
box = Text.from_ansi(compose_dialog(screen, cols, rows)).plain
assert "❯" in box, f"step {step} ({key}): the highlight left the box\n{box}"
async def test_deleting_one_contact_drops_it_from_the_device_and_the_store() -> None:
"""The single-contact delete: a red confirm, then both halves of the union forgotten.
@@ -654,7 +753,11 @@ async def test_deleting_one_contact_drops_it_from_the_device_and_the_store() ->
ctx = SimpleNamespace(
ui=ui,
log=logging.getLogger("test.remove"),
contact_store=SimpleNamespace(forget=lambda dev, key: forgotten.append((dev, key))),
contact_store=SimpleNamespace(
locked_keys=lambda *a: frozenset(),
is_locked=lambda *a: False,
forget=lambda dev, key: forgotten.append((dev, key)),
),
devstate=SimpleNamespace(invalidate_contacts=lambda: invalidated.append(True)),
device=lambda: _device(_Device()),
)
@@ -700,7 +803,11 @@ async def test_cancelling_the_remove_confirm_touches_nothing() -> None:
ctx = SimpleNamespace(
ui=ui,
log=logging.getLogger("test.remove"),
contact_store=SimpleNamespace(forget=lambda *a: touched.append("forgot")),
contact_store=SimpleNamespace(
locked_keys=lambda *a: frozenset(),
is_locked=lambda *a: False,
forget=lambda *a: touched.append("forgot"),
),
devstate=SimpleNamespace(invalidate_contacts=lambda: touched.append("invalidated")),
device=lambda: _device(_Device()),
)
@@ -745,7 +852,11 @@ async def test_a_refused_removal_is_shown_and_the_contact_stays() -> None:
ctx = SimpleNamespace(
ui=ui,
log=logging.getLogger("test.remove"),
contact_store=SimpleNamespace(forget=lambda *a: forgotten.append(a)),
contact_store=SimpleNamespace(
locked_keys=lambda *a: frozenset(),
is_locked=lambda *a: False,
forget=lambda *a: forgotten.append(a),
),
devstate=SimpleNamespace(invalidate_contacts=lambda: forgotten.append("cache")),
device=lambda: _device(_Device()),
)
@@ -798,7 +909,11 @@ async def test_a_contact_the_device_never_held_is_still_removed_here() -> None:
ctx = SimpleNamespace(
ui=ui,
log=logging.getLogger("test.remove"),
contact_store=SimpleNamespace(forget=lambda dev, key: forgotten.append((dev, key))),
contact_store=SimpleNamespace(
locked_keys=lambda *a: frozenset(),
is_locked=lambda *a: False,
forget=lambda dev, key: forgotten.append((dev, key)),
),
devstate=SimpleNamespace(invalidate_contacts=lambda: invalidated.append(True)),
device=lambda: _device(_Device()),
)
@@ -915,19 +1030,19 @@ async def test_the_list_rebuilds_when_the_detail_page_deletes_the_contact(
await task
def test_contacts_screen_tail_offers_purge_only_when_populated() -> None:
"""A populated list closes with the purge action; an empty one has no tail at all."""
def test_contacts_screen_tail_offers_archive_only_when_populated() -> None:
"""A populated list closes with the archive action; an empty one has no tail at all."""
from meshterm.core.models import Contact
from meshterm.ui.contacts_screen import _PURGE, ContactsScreen
from meshterm.ui.contacts_screen import _ARCHIVE, ContactsScreen
populated = ContactsScreen(
"Us", "cc" * 32, [Contact(name="Alice", public_key="aa" * 32)], 1, {}, _contacts_sort()
)
assert populated._choices()[-1].value == _PURGE
assert populated._choices()[-1].value == _ARCHIVE
empty = ContactsScreen("Us", "cc" * 32, [], 1, {}, _contacts_sort())
values = [c.value for c in empty._choices()]
assert _PURGE not in values # nothing to purge — no action row
assert _ARCHIVE not in values # nothing to archive — no action row
def test_the_archived_row_appears_only_when_something_is_archived() -> None:
@@ -938,7 +1053,7 @@ def test_the_archived_row_appears_only_when_something_is_archived() -> None:
own screen is what keeps archiving from being something you lose track of.
"""
from meshterm.core.models import Contact
from meshterm.ui.contacts_screen import _ARCHIVED, _PURGE, ContactsScreen
from meshterm.ui.contacts_screen import _ARCHIVE, _ARCHIVED, ContactsScreen
contacts = [Contact(name="Alice", public_key="aa" * 32)]
@@ -947,16 +1062,16 @@ def test_the_archived_row_appears_only_when_something_is_archived() -> None:
some = ContactsScreen("Us", "cc" * 32, contacts, 1, {}, _contacts_sort(), archived=12)
values = [c.value for c in some._choices()]
# Under the purge, because it is where the purge's output went.
assert values[-2:] == [_PURGE, _ARCHIVED]
# Under the archive action, because it is where the sweep's output went.
assert values[-2:] == [_ARCHIVE, _ARCHIVED]
row = next(c for c in some._choices() if c.value == _ARCHIVED)
assert "View archived contacts" in row.label and "12" in row.label
# It stands on its own for a device whose whole table has been swept: there is nothing
# left to purge, but there is very much something to go and look at.
# left to archive, but there is very much something to go and look at.
swept = ContactsScreen("Us", "cc" * 32, [], 1, {}, _contacts_sort(), archived=3)
values = [c.value for c in swept._choices()]
assert _PURGE not in values and values[-1] == _ARCHIVED
assert _ARCHIVE not in values and values[-1] == _ARCHIVED
async def test_archiving_one_contact_takes_it_off_the_device_and_keeps_it() -> None:
@@ -969,12 +1084,15 @@ async def test_archiving_one_contact_takes_it_off_the_device_and_keeps_it() -> N
import logging
from types import SimpleNamespace
from meshterm.core.models import Contact
from meshterm.core.models import NODE_TYPE_REPEATER, Contact
from meshterm.ui.node_detail_screen import _archive_contact
from meshterm.ui.surface import TuiUi
from meshterm.ui.theme import name_style
from meshterm.ui.tui.session import TuiSession
hub = Contact(name="Hub", public_key="3d" * 32, key_prefix="3d" * 6)
hub = Contact(
name="Hub", public_key="3d" * 32, key_prefix="3d" * 6, node_type=NODE_TYPE_REPEATER
)
removed: list[str] = []
archived: list[tuple[str, str]] = []
invalidated: list[bool] = []
@@ -995,7 +1113,9 @@ async def test_archiving_one_contact_takes_it_off_the_device_and_keeps_it() -> N
ui=ui,
log=logging.getLogger("test.archive"),
contact_store=SimpleNamespace(
archive=lambda dev, contact, when: archived.append((dev, contact.public_key))
locked_keys=lambda *a: frozenset(),
is_locked=lambda *a: False,
archive=lambda dev, contact, when: archived.append((dev, contact.public_key)),
),
devstate=SimpleNamespace(invalidate_contacts=lambda: invalidated.append(True)),
device=lambda: _device(_Device()),
@@ -1005,7 +1125,19 @@ async def test_archiving_one_contact_takes_it_off_the_device_and_keeps_it() -> N
assert asked[0]["danger"] is True and "destructive" not in asked[0]
assert [label for label, _v in asked[0]["buttons"]] == ["Cancel", "Archive"]
assert asked[0]["default"] == 1
assert "restore it at any time" in asked[0]["prompt"]
prompt = asked[0]["prompt"]
assert "restore it at any time" in prompt
# The contact is named as the Contacts list names it — its type mark to the left of the
# name, each in its own colour — while the sentence around them keeps the amber.
assert prompt.plain.startswith("Archive ▲ Hub? ")
def styles_at(index: int) -> list[str]:
return [str(span.style) for span in prompt.spans if span.start <= index < span.end]
assert styles_at(prompt.plain.index("▲")) == ["type.repeater"]
assert styles_at(prompt.plain.index("Hub")) == [name_style("Hub", "3d" * 32)]
assert styles_at(0) == styles_at(prompt.plain.index("restore")) == ["warn"]
assert not prompt.style, "a base style would merge the amber's bold into the mark"
# Off the radio, kept here, and the cached contact list dropped so the list re-reads.
assert removed == ["Hub"]
assert archived == [("cc" * 32, "3d" * 32)]
@@ -1034,7 +1166,11 @@ async def test_restoring_writes_the_contact_back_before_clearing_the_mark() -> N
ctx = SimpleNamespace(
ui=TuiUi(session),
log=logging.getLogger("test.restore"),
contact_store=SimpleNamespace(restore=lambda dev, key: restored.append(key)),
contact_store=SimpleNamespace(
locked_keys=lambda *a: frozenset(),
is_locked=lambda *a: False,
restore=lambda dev, key: restored.append(key),
),
devstate=SimpleNamespace(invalidate_contacts=lambda: None),
device=lambda: _device(_Refuses()),
)
@@ -1375,7 +1511,7 @@ async def _channels(device: MockDevice) -> list[dict]:
async def test_the_preview_opens_node_pages_with_no_management_verbs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Enter on a purge candidate opens its page to *look* at, not to act on.
"""Enter on a archive candidate opens its page to *look* at, not to act on.
The rule (JP, 2026-09-01): management verbs belong to the list a contact actually lives
in, never to a page opened out of a list that is about to act on it wholesale. A preview
@@ -1388,8 +1524,8 @@ async def test_the_preview_opens_node_pages_with_no_management_verbs(
import meshterm.ui.node_detail_screen as nd
from meshterm.core.contact_score import ContactSignals, ScoredContact
from meshterm.core.models import Contact
from meshterm.ui.purge_screen import _preview
from meshterm.ui.surface import TuiUi
from meshterm.ui.sweep_screen import _preview
from meshterm.ui.tui.session import TuiSession
opened: list[dict] = []
@@ -1421,3 +1557,72 @@ async def test_the_preview_opens_node_pages_with_no_management_verbs(
assert session.top is screen
screen.handle("escape")
assert await task is False
def test_a_locked_contact_leads_its_row_with_a_padlock_left_of_its_type_glyph() -> None:
"""Locked rows open on the padlock; the rest keep the column blank, so glyphs line up."""
from meshterm.core.models import Contact
from meshterm.ui.contacts_screen import ContactsScreen
contacts = [
Contact(name="Al", public_key="aa" * 32),
Contact(name="Bartholomew", public_key="bb" * 32),
]
screen = ContactsScreen(
"Us", "dd" * 32, contacts, 1, {}, _contacts_sort(), locked=frozenset({"aa" * 32})
)
screen.note_viewport(30)
screen.render_body(72)
lines = {
name: choice.title.plain
for choice in screen._choices()
for name in ("Al", "Bartholomew")
if getattr(choice.value, "name", None) == name
}
assert lines["Al"].startswith("\U0001f512 ● Al ")
assert lines["Bartholomew"].startswith(" ● Bartholomew")
# Unlocking redraws in place, and a list with no locks spends no cells on the column.
screen.refresh_locks(frozenset())
screen.render_body(72)
al = next(c for c in screen._choices() if getattr(c.value, "name", None) == "Al")
assert al.title.plain.startswith("● Al ")
async def test_a_locked_contact_is_never_offered_to_the_sweep() -> None:
"""The ranking reads the store's locks, so a locked contact is protected, not a victim."""
import logging
from types import SimpleNamespace
from meshterm.core.contact_score import PROTECT_LOCKED, ContactSignals
from meshterm.core.models import Contact
from meshterm.ui.sweep_screen import _rank
contacts = [
Contact(name="Kept", public_key="aa" * 32, key_prefix="aa" * 6),
Contact(name="Loose", public_key="bb" * 32, key_prefix="bb" * 6),
]
class _Repo:
def contact_signals(self, nodes): # noqa: ANN001, ANN202
return {
n: ContactSignals(node=n, heard_age_days=400.0, known_days=500.0) for n in nodes
}
def channel_post_counts(self): # noqa: ANN202
return {}
async def _self_info(): # noqa: ANN202
return {}
ctx = SimpleNamespace(
repo=_Repo(),
log=logging.getLogger("test.lock"),
contact_store=SimpleNamespace(locked_keys=lambda dev: frozenset({"aa" * 32})),
watch_store=None,
admin_store=None,
devstate=SimpleNamespace(self_info=_self_info),
)
ranked = {s.contact.name: s for s in await _rank(ctx, contacts, "cc" * 32)}
assert ranked["Kept"].protection == PROTECT_LOCKED
assert not ranked["Loose"].protected
+1 -1
View File
@@ -187,7 +187,7 @@ def test_a_direct_message_counts_as_hearing_its_sender() -> None:
A direct message never touches the firmware's ``last_advert`` and is stored as a message
rather than an observation, so a node we actively chat with could read days stale (and be
swept by the purge ladder's quiet rungs) while talking to us. The peer prefix the wire
swept by the archive ladder's quiet rungs) while talking to us. The peer prefix the wire
addressed need not match the contact table's width, so either may be the shorter.
"""
dev = FakeDevice()
+54
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import asyncio
import io
import sys
from pathlib import Path
from types import SimpleNamespace
@@ -194,6 +195,10 @@ def _fake_ble_stack(monkeypatch: pytest.MonkeyPatch, *outcomes):
async def test_create_ble_translates_auth_error_to_pin_guidance(monkeypatch) -> None:
"""A raw GATT auth rejection becomes a DeviceAuthenticationError that names the PIN fix."""
# Pinned off macOS: there the same rejection starts an OS-run pairing and is retried
# rather than reported, and the advice is the system dialog rather than --ble-pin.
# Without this the test would assert Windows/Linux wording on the macOS CI runner.
monkeypatch.setattr(sys, "platform", "win32")
# No PIN supplied -> tell the user to pass one. The subclass lets the interactive picker
# catch "needs a PIN" specifically, while the CLI still catches it as DeviceCommandError.
fake = _fake_ble_stack(monkeypatch, BleakGATTProtocolError("Insufficient Authentication"))
@@ -302,6 +307,8 @@ async def test_create_ble_gives_up_after_the_retry(monkeypatch) -> None:
async def test_create_ble_never_retries_an_auth_rejection(monkeypatch) -> None:
"""A PIN/bond rejection is translated on the first attempt — never looped by the retry."""
# Off macOS, where a rejection is the *start* of an OS-run pairing and is waited on.
monkeypatch.setattr(sys, "platform", "win32")
fake = _fake_ble_stack(monkeypatch, BleakGATTProtocolError("Insufficient Authentication"))
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
await _disable_windows_pairing(dev)
@@ -310,6 +317,53 @@ async def test_create_ble_never_retries_an_auth_rejection(monkeypatch) -> None:
assert len(fake.built) == 1
async def test_create_ble_waits_for_macos_to_finish_pairing(monkeypatch) -> None:
"""On macOS the first auth rejection is the pairing *starting*, so the connect waits.
Touching the companion's authenticated characteristic is the only way to make
CoreBluetooth pair at all, so the rejection and the Passkey dialog are the same event.
Giving up there reported an error for a pairing that was succeeding, and the device
connected only when the user selected it a second time.
"""
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(connection, "_BLE_MACOS_PAIRING_DELAY_S", 0)
# Refused twice while the dialog is up, then the bond lands and the subscribe works.
fake = _fake_ble_stack(
monkeypatch,
BleakGATTProtocolError("Insufficient Authentication"),
BleakGATTProtocolError("Insufficient Authentication"),
"handshake-ok",
)
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
await _disable_windows_pairing(dev)
result = await dev._create_ble(fake)
assert result is fake.built[2] # the attempt made after the bond is what the caller gets
assert len(fake.built) == 3
assert fake.built[0].disconnect_calls == 1 # each refused link was released, not stranded
assert fake.built[1].disconnect_calls == 1
assert fake.built[2].disconnect_calls == 0 # the live one is handed over open
async def test_create_ble_gives_up_when_macos_pairing_is_dismissed(monkeypatch) -> None:
"""A dialog nobody answers still fails in the end, and says where the code is asked for."""
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(connection, "_BLE_MACOS_PAIRING_DELAY_S", 0)
refusals = [BleakGATTProtocolError("Insufficient Authentication")] * (
connection._BLE_MACOS_PAIRING_ATTEMPTS + 1
)
fake = _fake_ble_stack(monkeypatch, *refusals)
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
await _disable_windows_pairing(dev)
with pytest.raises(connection.DeviceAuthenticationError) as excinfo:
await dev._create_ble(fake)
assert len(fake.built) == connection._BLE_MACOS_PAIRING_ATTEMPTS + 1 # bounded, not endless
# --ble-pin cannot help on macOS: the OS collects the code, so don't send the reader there.
assert "--ble-pin" not in str(excinfo.value)
assert "System Settings" in str(excinfo.value)
async def test_connect_reports_an_unanswered_handshake_and_closes_it(monkeypatch) -> None:
"""Transport up but no identity reply: a clean "not a companion", link still released."""
fake = _fake_ble_stack(monkeypatch, None)
+89 -3
View File
@@ -263,6 +263,50 @@ async def test_discover_ble_survives_no_adapter(monkeypatch: pytest.MonkeyPatch)
monkeypatch.setattr(bleak, "BleakScanner", _BoomScanner, raising=False)
assert await discovery.discover_ble_devices(timeout=0.0) == []
# A hiccup is not something the reader can act on, so it leaves no reason behind.
assert discovery.ble_unavailable_reason() is None
async def test_discover_ble_records_a_refusal_the_user_can_fix(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A denied scan keeps bleak's sentence instead of reading as "nothing nearby".
On macOS this is the routine case, not an exotic one: Bluetooth is granted to the
terminal running MeshTerm, so an ungranted terminal — and every SSH session, which
macOS refuses to even prompt in — scans successfully and hears nothing at all.
"""
import bleak
import meshterm.core.discovery as discovery
not_available = pytest.importorskip("bleak.exc").BleakBluetoothNotAvailableError
class _DeniedScanner:
@staticmethod
async def discover(timeout: float, return_adv: bool):
raise not_available("Bluetooth access is denied by the user", 2)
monkeypatch.setattr(bleak, "BleakScanner", _DeniedScanner, raising=False)
assert await discovery.discover_ble_devices(timeout=0.0) == []
reason = discovery.ble_unavailable_reason()
assert reason is not None and "denied" in reason
# And it describes the latest attempt, never a stale one.
monkeypatch.setattr(bleak, "BleakScanner", _empty_scanner(), raising=False)
assert await discovery.discover_ble_devices(timeout=0.0) == []
assert discovery.ble_unavailable_reason() is None
def _empty_scanner():
"""A scanner that works and simply hears nothing — the "not a fault" case."""
class _Quiet:
@staticmethod
async def discover(timeout: float, return_adv: bool):
return {}
return _Quiet
# -- device store --------------------------------------------------------------
@@ -496,15 +540,57 @@ def test_resolve_bad_tcp_endpoint_raises() -> None:
def test_resolve_ambiguous_raises(tmp_path: Path) -> None:
"""Multiple devices with no usable default raise a guidance error."""
"""Two plausible companions with no usable default raise a guidance error."""
devices = [
DiscoveredDevice("COM3", serial_number="SN1"),
DiscoveredDevice("COM4", serial_number="SN2"),
DiscoveredDevice("COM3", vid=0x303A, pid=0x1001, serial_number="SN1"),
DiscoveredDevice("COM4", vid=0x10C4, pid=0xEA60, serial_number="SN2"),
]
with pytest.raises(DeviceSelectionError, match="Multiple companion devices"):
resolve_device(devices, None)
def test_resolve_ignores_implausible_ports_when_one_board_is_present() -> None:
"""One real board among ports that look like nothing still resolves to the board.
This is the macOS case: every Mac permanently presents two virtual ``/dev/cu.*``
ports with no USB VID/PID, so counting them meant a Mac with a single companion
attached saw three devices and refused to choose — auto-detection could never fire
on that platform.
"""
board = DiscoveredDevice("/dev/cu.usbmodem1101", vid=0x303A, pid=0x1001)
devices = [
board,
DiscoveredDevice("/dev/cu.Bluetooth-Incoming-Port"),
DiscoveredDevice("/dev/cu.debug-console"),
]
res = resolve_device(devices, None)
assert res.target == board.target and res.source == "only"
def test_resolve_lone_unrecognized_device_still_resolves() -> None:
"""A single adapter we can't place still connects — an unlisted VID is usually real."""
lone = DiscoveredDevice("COM9", vid=0x1234, pid=0x0001)
assert not lone.is_likely_lora
res = resolve_device([lone], None)
assert res.target == "COM9" and res.source == "only"
def test_resolve_several_implausible_ports_does_not_call_them_companions() -> None:
"""Ports that look like nothing are not reported as "multiple companion devices".
A bare Mac with no companion attached reaches here with its two virtual ports, and
being told it has several companions — and asked to pick one of them — is the least
true answer available.
"""
devices = [
DiscoveredDevice("/dev/cu.Bluetooth-Incoming-Port"),
DiscoveredDevice("/dev/cu.debug-console"),
]
with pytest.raises(DeviceSelectionError, match="No companion devices detected among") as err:
resolve_device(devices, None)
assert "Multiple companion devices" not in str(err.value)
def test_resolve_no_devices_raises() -> None:
"""No devices at all raises a clear error mentioning --mock."""
with pytest.raises(DeviceSelectionError, match="No companion devices"):
+337 -267
View File
@@ -1,240 +1,322 @@
# SPDX-License-Identifier: Apache-2.0
"""Terminal-aligned emoji width: VS16 sequences and curated lone-codepoint emoji.
"""Reserve-two emoji widths: one rule in every width authority, and every glyph kept whole.
The border-alignment story lives in :mod:`meshterm.ui.tui.emoji_width`. These tests pin the
two things that make a chat row with ``👋`` frame flush: Rich must *measure* the glyph as one
cell (so it pads the row to the right width) and prompt_toolkit must *count* it as one (so it
places the panel's right border where the terminal actually draws it). An emoji the terminal
draws two wide — a menu icon like ``📡``, never on the allowlist — must stay two in both.
The measuring story lives in :mod:`meshterm.ui.tui.emoji_width`, and the placement half in
:mod:`meshterm.ui.tui.colsnap`. What these tests pin:
The factory helpers (``_make_cell_len``, ``_make_pt_cache``) are exercised in isolation so no
process-global width table is touched; the two :func:`calibrate` tests that do patch the real
tables snapshot and restore them in a ``finally`` so nothing leaks into a later test.
* every glyph that may be drawn as an emoji is reserved two cells, in Rich and in prompt_toolkit;
* the app's own marks, Rich's single-cell ranges and ordinary text keep their stock widths;
* Rich's three measurements and prompt_toolkit's agree about every string, which is what keeps
Rich's segment splitter from walking past a cut it can never meet;
* the app's source draws no text-default emoji its vocabulary does not name;
* a glyph built from several codepoints is split, cut and laid out as the one glyph it is.
:func:`~meshterm.ui.tui.emoji_width.install` patches process-global tables, so every test that
needs it goes through the ``installed`` fixture, which puts every table and cache back afterwards.
"""
from __future__ import annotations
import ast
import pathlib
import threading
from collections.abc import Iterator
import prompt_toolkit.utils as ptu
import pytest
import rich.cells as cells
import rich.segment as segment
from prompt_toolkit.utils import get_cwidth
from meshterm.ui.tui import emoji_width as ew
# One confirmed narrow glyph, plus one the same terminal draws two wide, used throughout.
_WAVE = "👋" # the confirmed lone-codepoint emoji this terminal draws in one cell
_DISH = "📡" # a menu icon the terminal draws two wide — must never be narrowed
_CA = "🇨🇦" # a flag: two Regional Indicators; prompt_toolkit miscounts it as four cells
_CN = "🇨🇳" # a second flag sharing the "C" indicator — the whole category must be handled
_PLANE = "🛩️" # a VS16 sequence this terminal draws two wide despite the narrow-VS16 verdict
_PLANE_BASE = "\U0001f6e9" # its base codepoint (no selector) — what the wide set is keyed on
_ROAD = "\U0001f6e3" # 🛣 a bare text-default emoji: one cell, and both authorities agree
_WEB = "\U0001f578" # 🕸 its untouched sibling one Trophy board down — the control
_SHRUG = "\U0001f937‍♂️" # 🤷‍♂️ a ZWJ sequence: base, joiner, male sign, selector — one glyph
_FAMILY = "\U0001f468‍\U0001f469‍\U0001f467" # 👨‍👩‍👧 three joined people, still one glyph
_ZWJ = "‍" # the joiner itself: the tell that its neighbours are a single glyph
_ZWJ = "\u200d" # the joiner: the tell that its neighbours are a single glyph
_VS16 = "\ufe0f" # the request for emoji presentation
_WAVE = "\U0001f44b" # 👋 emoji presentation by default: two cells by the stock tables already
_DISH = "\U0001f4e1" # 📡 a menu icon, the same
_CA = "\U0001f1e8\U0001f1e6" # 🇨🇦 a flag: two Regional Indicators, one glyph
_CN = "\U0001f1e8\U0001f1f3" # 🇨🇳 a second flag sharing the "C" indicator
_SUN = "☀" # ☀ a text-default emoji, alone
_PLANE = "\U0001f6e9" # 🛩 another, which a font draws either way
_HEART = "❤" # ❤ another
_ROAD = "\U0001f6e3" # 🛣 text-default too, but one of the app's own marks
_WEB = "\U0001f578" # 🕸 the same
_BIN = "\U0001f5d1" # 🗑 the same
_WARN = "⚠" # ⚠ the same
_SHRUG = f"\U0001f937{_ZWJ}♂{_VS16}" # the shrug: base, joiner, sign, selector
_FAMILY = f"\U0001f468{_ZWJ}\U0001f469{_ZWJ}\U0001f467" # the family: three joined people
_THUMB = "\U0001f44d\U0001f3fd" # a thumb and its skin tone, no joiner: still one glyph
_TECHIE = f"\U0001f468\U0001f3fb{_ZWJ}\U0001f4bb" # the technologist: toned, then joined
_KEYCAP = f"1{_VS16}\u20e3" # a keycap: a digit, a selector and the enclosing mark
_FLAG = "\U0001f3f4" # a black flag, the base of a pirate flag
_STRANDED = f"{_FLAG}{_ZWJ}" # a pirate flag cut after its joiner by a name's byte limit
#: Strings every authority has to agree about: emoji of every shape, the app's own marks, and the
#: scripts and marks that must come through the rule untouched.
_SAMPLES = (
"plain ascii",
f"Bob {_FAMILY} x",
f"{_CA}{_CN}",
f"{_SUN}{_VS16}{_SHRUG}",
f"{_SUN} bare, {_PLANE} bare, {_HEART} bare",
f"a{_ZWJ}b",
f"{_STRANDED} x",
f"{_KEYCAP}!",
f"│ {_WARN}{_VS16} warn │ {_WARN} mark │",
f"Tech {_TECHIE} x {_THUMB}{_THUMB}",
"漢字 names",
"ष\u094d\u200dक", # a Devanagari conjunct: a joiner between letters, not pictographs
f"{_CA[0]} a lone indicator",
"e\u0301 a combining accent",
f"{_ROAD}{_WEB}{_BIN} ↔ ↕",
)
def test_narrow_lone_set_defaults_extends_and_disables(monkeypatch) -> None:
"""The allowlist seeds to the confirmed glyph; the env var overrides it outright."""
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
assert _WAVE in ew._narrow_lone_set()
@pytest.fixture
def installed() -> Iterator[None]:
"""Install the reserve-two rule for one test, and put every table and cache back after."""
from meshterm.ui.tui import colsnap
from meshterm.ui.tui.render import _ANSI_CACHE
monkeypatch.setenv("MESHTERM_NARROW_EMOJI", "👋🤙")
assert {"👋", "🤙"} <= ew._narrow_lone_set()
# Empty string is the off switch — a way back to Rich/pt defaults with no code change.
monkeypatch.setenv("MESHTERM_NARROW_EMOJI", "")
assert ew._narrow_lone_set() == frozenset()
def test_wide_base_set_defaults_extends_and_disables(monkeypatch) -> None:
"""The wide set seeds to the confirmed bases; the env var overrides it outright."""
monkeypatch.delenv("MESHTERM_WIDE_EMOJI", raising=False)
# One source only: a VS16 sequence the narrow verdict gets wrong. A *bare* codepoint both
# authorities already measure at one is not a candidate — see test_bare_text_default_emoji.
assert _PLANE_BASE in ew._wide_base_set()
assert not {_ROAD, _WEB} & ew._wide_base_set()
# ✈ (U+2708) is a second VS16 airplane base; both list cleanly.
monkeypatch.setenv("MESHTERM_WIDE_EMOJI", "\U0001f6e9✈")
assert {"\U0001f6e9", "✈"} <= ew._wide_base_set()
# Pasting the whole rendered glyph keeps the base but strips the zero-width selector, so
# the selector is never itself counted as a wide cell.
monkeypatch.setenv("MESHTERM_WIDE_EMOJI", _PLANE)
wide = ew._wide_base_set()
assert _PLANE_BASE in wide and "️" not in wide
# Empty string trusts the narrow-VS16 verdict for every sequence.
monkeypatch.setenv("MESHTERM_WIDE_EMOJI", "")
assert ew._wide_base_set() == frozenset()
def test_rich_cell_len_narrows_only_allowlisted_lone_emoji() -> None:
"""Rich's replacement measures a listed lone emoji as one, an unlisted one still as two."""
cell_len = ew._make_cell_len(frozenset(_WAVE), frozenset(_PLANE_BASE))
assert cell_len(_WAVE) == 1
assert cell_len(_DISH) == 2 # unlisted: the terminal draws it wide, so leave it wide
assert cell_len("Bob 👋 hi") == 8 # the narrowed glyph flows through a whole line
# The pre-existing VS16 handling still applies: a narrow base is measured alone, its
# variation selector skipped, so "☀️" stays one cell rather than being promoted to two.
assert cell_len("☀️") == 1
# A wide-VS16 exception is forced back to two, selector still skipped, so the airplane
# frames flush instead of collapsing to one and smearing the row.
assert cell_len(_PLANE) == 2
assert cell_len("hi 🛩️") == len("hi ") + 2
# A ZWJ sequence is one glyph, measured as its base: the codepoints the joiner folds in
# cost nothing, however many of them there are. Rich's stock loop already does this, and
# the selector-skipping replacement must not lose it — counting the male sign as a cell
# of its own is what pulls the row's right border a column in.
assert cell_len(_SHRUG) == 2
assert cell_len(_FAMILY) == 2
assert cell_len(f"Bob {_FAMILY} hi") == len("Bob hi") + 2
def test_pt_cache_narrows_only_allowlisted_lone_emoji() -> None:
"""prompt_toolkit's cache — the authority that places the border — matches Rich."""
cache = ew._make_pt_cache(frozenset(_WAVE), frozenset(_PLANE_BASE))
assert cache[_WAVE] == 1
assert cache[_DISH] == 2
# The base cache sums a multi-char string per character through the cache, so the
# one-cell wave is inherited by any line that contains it.
assert cache["Bob 👋"] == 5
# The wide-VS16 airplane is the reverse: its base is forced to two, the selector stays
# zero, so the whole glyph (and any line holding it) keeps the terminal's two cells.
assert cache[_PLANE] == 2
assert cache["🛩️ hi"] == 2 + len(" hi")
# The sequences prompt_toolkit added up part by part: three cells for the shrug and six
# for the family, each of them one two-cell glyph, each pulling a border in by the excess.
assert cache[_SHRUG] == 2
assert cache[_FAMILY] == 2
assert cache[f"Bob {_FAMILY} hi"] == len("Bob hi") + 2
# A lone joiner is still zero, and a codepoint a sequence joins keeps its own width when
# it stands alone — ``↕`` is the reorder icon, not part of anything.
assert cache[_ZWJ] == 0
assert cache["↕"] == 1
def test_bare_text_default_emoji_are_left_exactly_as_measured() -> None:
"""An emoji outside Emoji_Presentation, drawn *bare*, is one cell — and stays one.
``🛣`` and ``🕸`` have East-Asian width Neutral, so Rich and wcwidth both measure them at
one; with no variation selector asking for emoji presentation, the font draws a one-cell
text glyph, which is exactly what they said. Both authorities already agreeing is not a
bug to correct: listing ``🛣`` in the wide set made the Trophy case's Longest-distance
heading reserve a cell the terminal never drew and pulled that row's border a column in,
while ``🕸`` — same class, one board down, never listed — framed flush throughout.
"""
cell_len = ew._make_cell_len(frozenset(_WAVE), ew._wide_base_set())
cache = ew._make_pt_cache(frozenset(_WAVE), ew._wide_base_set())
for bare in (_ROAD, _WEB):
assert cell_len(bare) == 1 and cache[bare] == 1
# A heading built around one costs its own cells and no more, so the row frames flush.
caption = cell_len(f"── {_ROAD} Longest distance ──")
assert caption == len("── ") + 1 + len(" Longest distance ──")
# The Trophy case's *visible* gap after the road is not this module's business and must
# not become it: the heading pads the mark out to the widest of the seven disciplines
# (records_screen.discipline_label), which costs a real cell the terminal advances over
# — where widening the glyph here would claim one it does not.
def test_flags_measure_two_cells_in_both_authorities() -> None:
"""A country flag measures two cells in both width authorities.
A flag is a Regional Indicator pair: prompt_toolkit's wcwidth calls it four cells,
while Rich and the terminal draw it as one two-cell glyph. Narrowing the
indicators as a category makes every flag sum to two in both — no per-country
allowlist entry, and flags sharing an indicator (🇨🇦 / 🇨🇳) are fixed at once.
"""
# Only the lone-emoji allowlist is passed; flags are handled by category, not by listing.
cell_len = ew._make_cell_len(frozenset(_WAVE), frozenset())
cache = ew._make_pt_cache(frozenset(_WAVE), frozenset())
for flag in (_CA, _CN):
assert cell_len(flag) == 2
assert cache[flag] == 2 # was 4 unpatched: two indicators at wcwidth 2 each
# A flag rides a chat line without dragging the border: "eh? 🇨🇦" measures its plain
# cells plus the flag's two.
assert cell_len("eh 🇨🇦") == len("eh ") + 2
def _snapshot() -> tuple:
"""Capture the mutable width state :func:`calibrate` patches, to restore afterwards."""
return (cells._cell_len, ptu._CHAR_SIZES_CACHE, ew._CALIBRATED, ew._CLUSTERS)
def _restore(snap: tuple) -> None:
"""Put the width authorities (and the once-only flag) back, clearing Rich's memo cache."""
cells._cell_len, ptu._CHAR_SIZES_CACHE, ew._CALIBRATED, ew._CLUSTERS = snap
cells.cached_cell_len.cache_clear()
def test_calibrate_width1_narrows_the_wave_in_both_authorities(monkeypatch) -> None:
"""A renderer that draws emoji narrow gets both Rich and pt aligned to the terminal."""
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
monkeypatch.delenv("MESHTERM_WIDE_EMOJI", raising=False)
from prompt_toolkit.utils import get_cwidth
snap = _snapshot()
saved = (
cells._cell_len,
cells.get_character_cell_size,
cells.split_graphemes,
segment.get_character_cell_size,
ptu._CHAR_SIZES_CACHE,
ew._INSTALLED,
ew._CLUSTERS,
)
ew._INSTALLED = False
ew.install()
try:
ew._CALIBRATED = False
ew.calibrate(force_width=1)
assert cells.cell_len(_WAVE) == 1
assert cells.cell_len(_DISH) == 2 # unlisted icon stays wide
assert cells.cell_len(_CA) == 2 # flag handled by category, no allowlist entry
assert cells.cell_len(_PLANE) == 2 # wide-VS16 exception carved back out of the narrowing
assert cells.cell_len(_ROAD) == 1 # bare text-default emoji: left exactly as measured
assert cells.cell_len(_WEB) == 1
assert get_cwidth(_WAVE) == 1 # pt now places the border a cell earlier
assert get_cwidth(_DISH) == 2
assert get_cwidth(_CA) == 2 # was 4 unpatched
assert get_cwidth(_PLANE) == 2 # was 1 unpatched: the airplane smeared a cell short
assert get_cwidth(_ROAD) == 1 and get_cwidth(_WEB) == 1 # untouched, in both authorities
assert cells.cell_len(_SHRUG) == 2 and get_cwidth(_SHRUG) == 2 # pt counted three
assert cells.cell_len(_FAMILY) == 2 and get_cwidth(_FAMILY) == 2 # pt counted six
yield
finally:
_restore(snap)
(
cells._cell_len,
cells.get_character_cell_size,
cells.split_graphemes,
segment.get_character_cell_size,
ptu._CHAR_SIZES_CACHE,
ew._INSTALLED,
ew._CLUSTERS,
) = saved
cells.cached_cell_len.cache_clear()
_ANSI_CACHE.clear()
colsnap._CACHE.clear()
def test_calibrate_width2_narrows_nothing_but_still_joins_clusters(monkeypatch) -> None:
"""A width-2 terminal narrows nothing, but still gets the cluster join.
def test_whatever_may_be_drawn_as_an_emoji_is_reserved_two_cells(installed) -> None: # noqa: ANN001
"""Every shape of emoji measures two in both authorities, whatever the stock tables said.
Narrowing would itself break the border there — but a joined sequence is
over-measured at *every* width, so that one correction still lands.
prompt_toolkit sums a ZWJ sequence's codepoints wherever it runs, which no terminal draws:
the family is one two-cell glyph on the widest terminal as surely as on the narrowest. So
the cluster rule is installed on its own here, and nothing else is — the curated sets and
the flag category stay unconfirmed on this renderer, and Rich is not touched at all.
The stock answers were all over the place — prompt_toolkit gave a flag four, a sun asking
for emoji presentation one, a toned thumb four and a family six — and no answer below two
is safe for a glyph a font may draw in two: the pin after it would overwrite its right half.
"""
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
from prompt_toolkit.utils import get_cwidth
snap = _snapshot()
try:
ew._CALIBRATED = False
ew.calibrate(force_width=2)
assert cells.cell_len(_WAVE) == 2
assert get_cwidth(_WAVE) == 2
# Rich untouched: it still promotes a VS16 sequence to the two cells this terminal draws.
assert cells.cell_len(_PLANE) == 2 and cells.cell_len("☀️") == 2
# Neither curated set nor the flag category applies here — pt keeps its stock answers.
assert get_cwidth("☀️") == 1
assert get_cwidth(_CA) == 4
# The one correction that holds at either width.
assert get_cwidth(_SHRUG) == 2 # was 3
assert get_cwidth(_FAMILY) == 2 # was 6
finally:
_restore(snap)
for glyph in (
_WAVE,
_DISH,
_SUN,
f"{_SUN}{_VS16}",
_PLANE,
f"{_PLANE}{_VS16}",
_HEART,
_CA,
_CA[0],
_SHRUG,
_FAMILY,
_THUMB,
_TECHIE,
_KEYCAP,
f"{_WARN}{_VS16}", # a mark asking for emoji presentation is an emoji like any other
):
assert cells.cell_len(glyph) == 2, f"Rich: {glyph!r}"
assert get_cwidth(glyph) == 2, f"prompt_toolkit: {glyph!r}"
def test_join_zwj_clusters_merges_only_joined_runs() -> None:
"""The fragment merge gathers a whole sequence and leaves everything else alone.
def test_text_the_app_draws_keeps_its_stock_width(installed) -> None: # noqa: ANN001
"""The rule reserves nothing it has no reason to: text, chrome and the app's own marks.
prompt_toolkit's ANSI text arrives one codepoint per fragment, so a sequence is a run:
a base, an optional selector, then *joiner + component + optional selector* groups.
The marks are text-default emoji too, but they are the app's own vocabulary, drawn in one
cell on the terminals it is used on, so they keep the one cell every screen was laid out
with. Rich's single-cell ranges are measured by a fast path no patch reaches, so everything
has to agree with it there.
"""
for text, width in (
("a", 1),
("#", 1),
("1", 1),
("é", 1),
("漢", 2),
("─", 1),
("⠿", 1),
("©", 1),
("▶", 1),
(_WARN, 1),
(_ROAD, 1),
(_WEB, 1),
(_BIN, 1),
("↕", 1),
("↔", 1),
("↩", 1),
("⌨", 1),
("⚙", 1),
("a\U0001f3fd", 1), # a stray skin tone on a letter leaves it a letter
):
assert cells.cell_len(text) == width, f"Rich: {text!r}"
assert get_cwidth(text) == width, f"prompt_toolkit: {text!r}"
def test_every_authority_measures_every_string_the_same(installed) -> None: # noqa: ANN001
"""Rich's whole-string width, its grapheme spans and prompt_toolkit's cache all agree.
And every lone character measures the same in all of them. These are the four places a width
is read, and a row is only laid out once if they give it one answer.
"""
for text in _SAMPLES:
spans, total = cells.split_graphemes(text)
assert cells.cell_len(text) == total == get_cwidth(text), text
assert total == sum(width for _start, _end, width in spans), text
assert spans[0][0] == 0 and spans[-1][1] == len(text), f"spans cover {text!r}"
for char in text:
size = cells.get_character_cell_size(char)
assert size == segment.get_character_cell_size(char) == get_cwidth(char), repr(char)
def test_no_prefix_outgrows_the_character_that_ends_it(installed) -> None: # noqa: ANN001
"""The invariant Rich's segment splitter terminates on, then the splitter itself.
``Segment.split_cells`` steps a string a codepoint at a time until a whole-string width meets
the cut, stepping over a two-cell character only where that character measures two on its
own. A prefix that grew by two at a character measuring one would be a cut the walk steps past
in both directions forever — which is why the character width is patched alongside the string
width, and why this checks the invariant before trusting the splitter with it.
"""
for text in _SAMPLES:
for index, char in enumerate(text):
grew = cells.cell_len(text[: index + 1]) - cells.cell_len(text[:index])
assert 0 <= grew <= 2, f"{text!r} at {index}"
if grew == 2:
assert cells.get_character_cell_size(char) == 2, f"{text!r} at {index}"
def split_everywhere() -> None:
for text in _SAMPLES:
for cut in range(cells.cell_len(text) + 1):
segment.Segment(text).split_cells(cut)
worker = threading.Thread(target=split_everywhere, daemon=True)
worker.start()
worker.join(timeout=20)
assert not worker.is_alive(), "a segment split never met its cut"
def test_a_stranded_joiner_leaves_the_next_character_its_cell(installed) -> None: # noqa: ANN001
"""A joiner with no pictograph after it joins nothing, in both authorities.
A MeshCore name is cut at a byte limit, so a name ending on a pirate flag arrives as the
black flag and a bare joiner, followed by the lane's padding. Rich's stock loop folded the
padding into the flag, so the name lane measured a cell short of what the terminal drew.
"""
padded = f"{_STRANDED} x" # the flag's two cells, two of padding, then the next lane
assert cells.cell_len(padded) == 5 and get_cwidth(padded) == 5
# A real sequence still joins: the shrug's male sign is a pictograph, not a padding space.
assert cells.cell_len(f"{_SHRUG} x") == 4 and get_cwidth(f"{_SHRUG} x") == 4
def test_install_happens_once(installed) -> None: # noqa: ANN001
"""A second call is a no-op: the tables patched by the first stay exactly as they are."""
patched = (cells._cell_len, ptu._CHAR_SIZES_CACHE)
ew.install()
assert (cells._cell_len, ptu._CHAR_SIZES_CACHE) == patched
def test_the_app_draws_no_text_default_emoji_its_vocabulary_does_not_name() -> None:
"""Every text-default emoji the package draws is one of its own marks, and every mark is used.
This is what keeps :data:`~meshterm.ui.tui.emoji_width._APP_TEXT_MARKS` a closed vocabulary
rather than a list kept by care: a new one-cell mark added to a screen without being named
there fails here, instead of quietly gaining a blank cell beside it. Prose is skipped
(docstrings and bare strings are read, not drawn), and so is the module that defines the list.
"""
from rich._unicode_data import load
text_default = {
char
for char in load("auto").narrow_to_wide
if not char.isascii() and char not in cells._SINGLE_CELLS
}
# Inputs to the PicoCalc's substitution table, which maps them to glyphs its font has. They
# are never drawn on a terminal that shows emoji, so they are not marks.
fold_inputs = {"↪", "✔", "✖"}
package = pathlib.Path(ew.__file__).parents[2]
drawn: set[str] = set()
for path in package.rglob("*.py"):
if path.name == "emoji_width.py":
continue
tree = ast.parse(path.read_text(encoding="utf-8"))
prose = {
id(node.value)
for node in ast.walk(tree)
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)
}
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
if id(node) not in prose:
drawn |= set(node.value) & text_default
assert drawn - fold_inputs <= ew._APP_TEXT_MARKS, (
f"name these in _APP_TEXT_MARKS: {sorted(drawn - fold_inputs - ew._APP_TEXT_MARKS)}"
)
assert ew._APP_TEXT_MARKS <= drawn, (
f"no longer drawn anywhere: {sorted(ew._APP_TEXT_MARKS - drawn)}"
)
def test_clusters_split_between_glyphs_and_never_inside_one() -> None:
"""The unit a lane may cut on and a width is measured by: one entry per glyph drawn.
A joined run, a flag's indicator *pair*, a toned base and a keycap each come back whole,
because each is one glyph — while a joiner that joins nothing stands on its own (see
:func:`~meshterm.ui.tui.emoji_width._joins`), and so do letters around a joiner.
"""
text = f"a{_FAMILY}{_CA}{_THUMB}{_KEYCAP}b"
assert list(ew.clusters(text)) == ["a", _FAMILY, _CA, _THUMB, _KEYCAP, "b"]
# Two flags in a row pair up one at a time rather than running together into four.
assert list(ew.clusters(_CA + _CN)) == [_CA, _CN]
# A name cut at its byte limit just past a joiner: the joiner is not part of the flag.
assert list(ew.clusters(_STRANDED)) == [_FLAG, _ZWJ]
# A joiner between letters joins nothing a font draws as one emoji.
assert list(ew.clusters(f"a{_ZWJ}b")) == ["a", _ZWJ, "b"]
def test_cut_cells_keeps_every_glyph_whole_and_strands_no_joiner() -> None:
"""A lane's truncation lands between glyphs, so nothing is measured that is not drawn."""
assert ew.cut_cells(f"Bob {_FAMILY}", 6) == f"Bob {_FAMILY}" # it fits, so it is kept
assert ew.cut_cells(f"Bob {_FAMILY}", 5) == "Bob " # it doesn't: dropped whole
assert ew.cut_cells(_CA, 1) == "" # half a flag is a letter, not half a glyph
assert ew.cut_cells(_STRANDED, 4) == _FLAG # the joiner does not trail the cut
assert ew.cut_cells("plain name", 5) == "plain"
def test_drawable_folds_only_what_no_terminal_can_draw() -> None:
"""A newline, an escape or a bidi override in an advert name is folded to a space.
None of them is a width question — they are text that must not reach the terminal at all.
The joiner is format-class too and is the one kept, because it holds an emoji together.
"""
assert ew.drawable("line\nbreak") == "line break"
assert ew.drawable("esc\x1b[31mape") == "esc [31mape"
assert ew.drawable("flip\u202eme") == "flip me"
assert ew.drawable(f"Bob {_FAMILY}{_THUMB}") == f"Bob {_FAMILY}{_THUMB}"
def test_join_clusters_merges_every_multi_codepoint_glyph() -> None:
"""The fragment merge gathers each glyph built from several codepoints into one fragment.
prompt_toolkit's ANSI text arrives one codepoint per fragment, so a glyph is a run to be
gathered — and every such run is merged now, not only the joined ones, because a mark
measured one cell and followed by a selector must not be folded into a one-cell slot.
"""
def line(text: str) -> list:
@@ -243,41 +325,41 @@ def test_join_zwj_clusters_merges_only_joined_runs() -> None:
def texts(fragments: list) -> list:
return [text for _style, text in fragments]
# A line with no joiner is handed straight back — the same object, not a copy.
plain = line("hi 📡")
assert ew._join_zwj_clusters(plain) is plain
# A line with nothing to merge is handed straight back — the same object, not a copy.
plain = line(f"hi {_DISH} {_WARN}")
assert ew._join_clusters(plain) is plain
# The shrug's four fragments become one; the bars around it are untouched.
assert texts(ew._join_zwj_clusters(line(f"|{_SHRUG}|"))) == ["|", _SHRUG, "|"]
# Three joined people, five fragments, still one glyph.
assert texts(ew._join_zwj_clusters(line(_FAMILY))) == [_FAMILY]
# A selector on the *base*, before the joiner, belongs to the run: ❤️‍🔥.
burning = "❤️‍\U0001f525"
assert texts(ew._join_zwj_clusters(line(burning))) == [burning]
# A bare VS16 pair is not a sequence — prompt_toolkit already folds it into the cell
# before it, so those fragments are left exactly as they came.
assert texts(ew._join_zwj_clusters(line(f"☀️{_SHRUG}"))) == ["☀", "️", _SHRUG]
# A trailing joiner with nothing to join is not a sequence either.
assert texts(ew._join_zwj_clusters(line(f"a{_ZWJ}"))) == ["a", _ZWJ]
assert texts(ew._join_clusters(line(f"|{_SHRUG}|"))) == ["|", _SHRUG, "|"]
assert texts(ew._join_clusters(line(_FAMILY))) == [_FAMILY]
burning = f"❤{_VS16}{_ZWJ}\U0001f525" # ❤\ufe0f\u200d🔥 a selector on the base, then a join
assert texts(ew._join_clusters(line(burning))) == [burning]
# A selector pair, a toned emoji and a keycap are one glyph each, merged like the rest.
assert texts(ew._join_clusters(line(f"{_SUN}{_VS16}{_SHRUG}"))) == [f"{_SUN}{_VS16}", _SHRUG]
assert texts(ew._join_clusters(line(f"{_WARN}{_VS16}|"))) == [f"{_WARN}{_VS16}", "|"]
assert texts(ew._join_clusters(line(f"{_THUMB}{_KEYCAP}"))) == [_THUMB, _KEYCAP]
# A flag's two indicators are one glyph, written as one: a cursor pin between the halves
# would leave a terminal drawing two letters instead of a flag.
assert texts(ew._join_clusters(line(f"|{_CA}|"))) == ["|", _CA, "|"]
assert texts(ew._join_clusters(line(f"{_CA}{_CN}"))) == [_CA, _CN]
# A lone indicator is no pair, and a trailing or stranded joiner joins nothing.
assert texts(ew._join_clusters(line(f"{_CA[0]} x"))) == [_CA[0], " ", "x"]
assert texts(ew._join_clusters(line(f"a{_ZWJ}"))) == ["a", _ZWJ]
assert texts(ew._join_clusters(line(f"{_STRANDED} x"))) == [_FLAG, _ZWJ, " ", "x"]
# A merged fragment iterates as the whole sequence, which is what makes prompt_toolkit
# build one Char of it instead of one per codepoint.
(merged,) = texts(ew._join_zwj_clusters(line(_SHRUG)))
# A merged fragment iterates as the whole glyph, which is what makes prompt_toolkit build
# one Char of it instead of one per codepoint.
(merged,) = texts(ew._join_clusters(line(_SHRUG)))
assert list(merged) == [_SHRUG] and merged == _SHRUG
def test_cluster_control_lays_a_sequence_into_a_single_screen_cell(monkeypatch) -> None:
"""The delivery half: one ``Char`` per glyph, with every codepoint still written out.
def test_cluster_control_lays_each_glyph_into_a_single_screen_cell(installed) -> None: # noqa: ANN001
"""The delivery half: one two-cell ``Char`` per glyph, every codepoint still written out.
Measuring the sequence right is not enough on its own — prompt_toolkit lays out one
codepoint at a time, so an uncorrected screen puts the male sign in a cell of its own and
everything after it a column late. The control merges the run, so the window makes a
single two-cell ``Char`` of it and the text after lands where the terminal draws it. The
codepoints themselves are untouched: all four still reach the screen, in order, so the
terminal composes the same glyph.
Measuring a glyph right is not enough on its own — prompt_toolkit lays out one codepoint at a
time, so an unmerged shrug puts its male sign in a cell of its own, and a warning mark with a
selector becomes a two-cell glyph in a one-cell slot. Merged, each is one ``Char`` two cells
wide, and the text after lands where the pins will put it.
"""
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
monkeypatch.delenv("MESHTERM_WIDE_EMOJI", raising=False)
from prompt_toolkit.application import Application
from prompt_toolkit.application.current import set_app
from prompt_toolkit.formatted_text import ANSI
@@ -287,30 +369,18 @@ def test_cluster_control_lays_a_sequence_into_a_single_screen_cell(monkeypatch)
from prompt_toolkit.layout.screen import Screen, WritePosition
from prompt_toolkit.output import DummyOutput
text = f"|{_SHRUG}|{_FAMILY}|end"
warn = f"{_WARN}{_VS16}"
text = f"|{_SHRUG}|{_FAMILY}|{warn}|end"
window = Window(ew.ClusterTextControl(lambda: ANSI(text)), always_hide_cursor=True)
app = Application(layout=Layout(window), input=DummyInput(), output=DummyOutput())
with set_app(app):
screen = Screen(default_char=None, initial_width=40, initial_height=1)
window.write_to_screen(screen, MouseHandlers(), WritePosition(0, 0, 40, 1), "", False, None)
row = screen.data_buffer[0]
def paint() -> list:
window = Window(ew.ClusterTextControl(lambda: ANSI(text)), always_hide_cursor=True)
app = Application(layout=Layout(window), input=DummyInput(), output=DummyOutput())
with set_app(app):
screen = Screen(default_char=None, initial_width=40, initial_height=1)
window.write_to_screen(
screen, MouseHandlers(), WritePosition(0, 0, 40, 1), "", False, None
)
return screen.data_buffer[0]
snap = _snapshot()
try:
ew._CALIBRATED = False
ew.calibrate(force_width=1)
row = paint()
# Each sequence is one cell-occupying Char, two cells wide, exactly as drawn.
assert (row[1].char, row[1].width) == (_SHRUG, 2)
assert (row[4].char, row[4].width) == (_FAMILY, 2)
# So the text after them starts at column 6 rather than 13 — seven cells of border
# drift removed (one for the shrug, four for the family, two from the second bar).
assert "".join(row[x].char for x in range(6, 10)) == "|end"
# Nothing was dropped on the way: the row still spells the source exactly.
assert "".join(row[x].char for x in range(40)).rstrip() == text
finally:
_restore(snap)
assert (row[1].char, row[1].width) == (_SHRUG, 2)
assert (row[4].char, row[4].width) == (_FAMILY, 2)
assert (row[7].char, row[7].width) == (warn, 2)
assert "".join(row[x].char for x in range(9, 13)) == "|end"
# Nothing was dropped on the way: the row still spells the source exactly.
assert "".join(row[x].char for x in range(40)).rstrip() == text
+41 -16
View File
@@ -40,7 +40,9 @@ from meshterm.core.advert_store import AdvertPolicy
from meshterm.core.channels import DEFAULT_PUBLIC_SECRET, derive_secret
from meshterm.core.courier_store import CourierStore
from meshterm.core.models import (
NODE_TYPE_CHAT,
NODE_TYPE_REPEATER,
NODE_TYPE_SENSOR,
ChatMessage,
Contact,
Conversation,
@@ -186,11 +188,21 @@ def _contacts(cols: int, rows: int) -> Screen:
Contact(name="A Rather Long Repeater Name For Width", public_key=_HUB_KEY),
]
# A non-zero archived tally, so the tail draws both maintenance rows — its populated
# state, and the widest the tail ever gets.
return ContactsScreen("Homestead", "cc" * 32, contacts, 1, {"aa" * 6: 7}, sort, archived=12)
# state, and the widest the tail ever gets — and a locked contact, so its padlock is held
# to both platforms' glyph contracts.
return ContactsScreen(
"Homestead",
"cc" * 32,
contacts,
1,
{"aa" * 6: 7},
sort,
archived=12,
locked=frozenset({"aa" * 32}),
)
def _purge_ranked(): # noqa: ANN201
def _archive_ranked(): # noqa: ANN201
"""A ranked contact table for the sweep's screens — scored by the real function.
Deliberately *not* hand-stamped percentiles. The whole point of the gallery is that a
@@ -223,30 +235,41 @@ def _purge_ranked(): # noqa: ANN201
("Lakeside", "3d" * 32, dict(heard_age_days=30.0, packets=22, known_days=250.0, hops=1.0)),
("sensor-2", "7c" * 32, dict(heard_age_days=210.0, packets=2, known_days=260.0)),
]
contacts = [Contact(name=n, public_key=k, key_prefix=k[:12]) for n, k, _ in specs]
# Typed as their names say, so the preview draws a repeater's ``▲`` and a sensor's ``◉``
# beside the plain ``●`` of ``hop-9``, which never advertised a type.
types = {
"Alice": NODE_TYPE_CHAT,
"A Rather Long Repeater Name For Width": NODE_TYPE_REPEATER,
"Lakeside": NODE_TYPE_REPEATER,
"sensor-2": NODE_TYPE_SENSOR,
}
contacts = [
Contact(name=n, public_key=k, key_prefix=k[:12], node_type=types.get(n))
for n, k, _ in specs
]
signals = {k[:12]: ContactSignals(node=k[:12], **sig) for _, k, sig in specs}
return rank_contacts(contacts, signals)
def _purge_victims(): # noqa: ANN201
def _archive_victims(): # noqa: ANN201
"""The sweep's victim list: the weakest half of the ranking, weakest first."""
from meshterm.core.contact_score import sweep_candidates
ranked = _purge_ranked()
ranked = _archive_ranked()
return sweep_candidates(ranked, keep=2)
def _purge_ladder(cols: int, rows: int) -> Screen:
from meshterm.ui.purge_screen import _target_screen
def _archive_ladder(cols: int, rows: int) -> Screen:
from meshterm.ui.sweep_screen import _target_screen
ranked = _purge_ranked()
ranked = _archive_ranked()
return _target_screen(ranked, [r for r in ranked if not r.protected])
def _purge_preview(cols: int, rows: int) -> Screen:
from meshterm.ui.purge_screen import _preview_screen
def _archive_preview(cols: int, rows: int) -> Screen:
from meshterm.ui.sweep_screen import _preview_screen
return _preview_screen(_purge_victims())
return _preview_screen(_archive_victims())
def _archived(cols: int, rows: int) -> Screen:
@@ -972,8 +995,8 @@ def _share_qr(cols: int, rows: int) -> Screen:
_ENTRIES: list[_Entry] = [
_Entry("dashboard", _dashboard),
_Entry("contacts", _contacts),
_Entry("purge_ladder", _purge_ladder),
_Entry("purge_preview", _purge_preview),
_Entry("archive_ladder", _archive_ladder),
_Entry("archive_preview", _archive_preview),
_Entry("archived", _archived),
_Entry("node_detail", _node_detail),
_Entry("node_detail_map", _node_detail_map),
@@ -1180,8 +1203,10 @@ def test_gallery_map_shows_the_basemap_credit_where_its_frame_puts_it(
if platform.frame_border:
assert "OpenStreetMap" not in body, "the credit reached the drawing on a bordered frame"
rule = next(ln for ln in reversed(lines) if "└" in ln)
assert rule.endswith(f" {expected} ─┘"), rule
# Rounded corners, or square where Rich judges the console legacy Windows (a
# captured pytest run there) — either is the frame's own bottom rule.
rule = next(ln for ln in reversed(lines) if "╰" in ln or "└" in ln)
assert rule[-1] in "╯┘" and rule[:-1].endswith(f" {expected} ─"), rule
else:
assert body.splitlines()[-1].endswith(expected)
assert any(expected in ln for ln in lines)
+92 -5
View File
@@ -767,7 +767,7 @@ def test_basemap_sends_its_user_agent_on_every_fetch(tmp_path: Path) -> None:
src = _offline_source(tmp_path / "cache")
seen: list[urllib.request.Request] = []
def _urlopen(req: urllib.request.Request, timeout: float | None = None):
def _urlopen(req: urllib.request.Request, timeout: float | None = None, context=None):
seen.append(req)
raise urllib.error.URLError("no network in tests")
@@ -782,6 +782,84 @@ def test_basemap_sends_its_user_agent_on_every_fetch(tmp_path: Path) -> None:
assert seen[0].get_header("User-agent") == basemap_mod._USER_AGENT
def test_basemap_keeps_the_default_tls_context_when_it_works() -> None:
"""A working platform trust store is left alone — urllib's default is passed through."""
from meshterm.services import basemap as basemap_mod
basemap_mod._tls_context.cache_clear()
try:
assert basemap_mod._tls_context() is None
finally:
basemap_mod._tls_context.cache_clear()
def test_basemap_finds_a_ca_bundle_when_the_default_store_is_empty(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""An empty default store falls back to the OS CA bundle instead of failing every fetch.
THE macOS bug: a frozen build asks OpenSSL for a trust store at a filesystem path baked
into the interpreter's own build, which need not exist on a machine that never
installed that Python — which is every machine a PyInstaller bundle lands on. Fetches
then failed with ``CERTIFICATE_VERIFY_FAILED``, and because a refused fetch is
indistinguishable from empty terrain the map drew no ground under the nodes and said
nothing. Windows hid it by reading the OS certificate store in its default context.
"""
import ssl
from meshterm.services import basemap as basemap_mod
# The file only has to exist: loading is recorded below rather than really parsed,
# so the test pins *which path is chosen* without depending on a PEM being around.
bundle = tmp_path / "cert.pem"
bundle.write_text("# stand-in for the OS bundle\n", encoding="utf-8")
loaded: list[str] = []
class _EmptyStore(ssl.SSLContext):
def get_ca_certs(self, binary_form: bool = False): # type: ignore[override]
return []
def load_verify_locations(self, cafile=None, capath=None, cadata=None): # type: ignore[override]
loaded.append(cafile or "")
monkeypatch.setattr(
ssl, "create_default_context", lambda *a, **k: _EmptyStore(ssl.PROTOCOL_TLS_CLIENT)
)
monkeypatch.setattr(basemap_mod, "_CA_BUNDLES", (str(tmp_path / "nope.pem"), str(bundle)))
basemap_mod._tls_context.cache_clear()
try:
ctx = basemap_mod._tls_context()
assert ctx is not None, "no context built despite a readable bundle"
assert loaded == [str(bundle)] # skipped the missing path, used the present one
assert ctx.verify_mode == ssl.CERT_REQUIRED # never verified away to make it work
finally:
basemap_mod._tls_context.cache_clear()
def test_basemap_never_disables_verification(monkeypatch: pytest.MonkeyPatch) -> None:
"""With no bundle anywhere, the fetch still verifies — it fails rather than trusts."""
import ssl
from meshterm.services import basemap as basemap_mod
class _EmptyStore(ssl.SSLContext):
def get_ca_certs(self, binary_form: bool = False): # type: ignore[override]
return []
monkeypatch.setattr(
ssl, "create_default_context", lambda *a, **k: _EmptyStore(ssl.PROTOCOL_TLS_CLIENT)
)
monkeypatch.setattr(basemap_mod, "_CA_BUNDLES", ("/nonexistent/ca.pem",))
basemap_mod._tls_context.cache_clear()
try:
assert basemap_mod._tls_context() is None # urllib's default, still verifying
finally:
basemap_mod._tls_context.cache_clear()
def test_basemap_user_agent_needs_no_installed_distribution() -> None:
"""Importing the module must not depend on package metadata being on disk.
@@ -1127,7 +1205,7 @@ def test_basemap_http_get_separates_an_answer_from_silence(tmp_path: Path) -> No
return False
def _fake(result):
def _urlopen(_req, timeout=None): # noqa: ANN001
def _urlopen(_req, timeout=None, context=None): # noqa: ANN001
if isinstance(result, Exception):
raise result
return result
@@ -2589,6 +2667,12 @@ def _last_row(screen: MapScreen, cols: int = 80) -> str:
return _plain(screen.render_body(cols)).splitlines()[-1]
#: A panel's bottom corners, in both shapes Rich draws them: rounded wherever the console
#: can take them, square where it judges the console a legacy Windows one — which a
#: captured pytest run on Windows is. Asserting one shape passes on one OS only.
_BOTTOM_CORNERS = ("╰╯", "└┘")
def _bottom_rule(screen: MapScreen, cols: int = 80, rows: int = 24) -> str:
"""The composed frame's bottom border rule — the line the caption is set into."""
from rich.text import Text
@@ -2598,7 +2682,9 @@ def _bottom_rule(screen: MapScreen, cols: int = 80, rows: int = 24) -> str:
screen.note_viewport(max(1, rows - 4))
composed = frame.compose_base(Text(""), screen, screen.footer_hint, cols, rows)
return next(
ln for ln in reversed([Text.from_ansi(x).plain for x in composed.split("\n")]) if "└" in ln
ln
for ln in reversed([Text.from_ansi(x).plain for x in composed.split("\n")])
if any(pair[0] in ln for pair in _BOTTOM_CORNERS)
)
@@ -2651,9 +2737,10 @@ def test_a_bordered_frame_sets_the_credit_in_its_bottom_rule_and_spares_the_draw
set_platform(REGULAR)
screen = _credited_map(72)
# Right-justified with exactly one rule cell before the corner, as a title sits in the
# top rule: "└──… © OpenMapTiles · Data from OpenStreetMap ─┘".
# top rule: "╰──… © OpenMapTiles · Data from OpenStreetMap ─╯".
rule = _bottom_rule(screen, 72)
assert rule.startswith("└") and rule.endswith(f" {attribution.CREDIT_FULL} ─┘")
assert rule[0] + rule[-1] in _BOTTOM_CORNERS, rule
assert rule[:-1].endswith(f" {attribution.CREDIT_FULL} ─"), rule
assert cell_len(rule) == 72
# And nothing of it reached the drawing: the map's own rows are pure picture.
assert "OpenStreetMap" not in _plain(screen.render_body(72))
+37 -6
View File
@@ -143,6 +143,37 @@ def test_fit_cells_measures_display_cells_not_characters() -> None:
assert fit_cells("ab", 5, align="right") == " ab"
def test_fit_cells_keeps_a_broken_name_inside_its_lane() -> None:
"""A name is written by a stranger's radio, and no part of one may skew the lane.
Two failures, one lane. A cut through the middle of an emoji sequence leaves a stranded
joiner, which folds the ellipsis appended after it into the glyph before it — the lane
then measures a cell short of what the terminal draws, and every column right of the
name starts late. And a name carrying a newline or an escape ends the row mid-lane
whatever it measures. Both are settled inside ``fit_cells`` (see
:mod:`meshterm.ui.tui.emoji_width`), so every column in the app is covered by the one
helper they all fit their labels through.
"""
from rich.cells import cell_len
family = "\U0001f468‍\U0001f469‍\U0001f467" # one glyph, three joined people
flag = "\U0001f1e8\U0001f1e6" # one glyph, two Regional Indicators
for name in (f"Bob {family} Family", f"{flag} Canada Hub", "line\nbreak\x1b[31m"):
for width in range(4, 24):
assert cell_len(fit_cells(name, width)) == width, (name, width)
# The cut falls between glyphs: half a family is not a glyph, and half a flag is a letter.
assert family in fit_cells(f"Bob {family}", 8) # it fits: the glyph is kept whole
assert "‍" not in fit_cells(f"Bob {family}", 5) # it doesn't: no joiner left behind
assert fit_cells(f"{flag} Hub", 4).startswith(flag) # the pair survives, or neither does
# What the terminal cannot draw never reaches it; an ordinary name is left alone.
assert "\n" not in fit_cells("line\nbreak", 12)
assert "\x1b" not in fit_cells("esc\x1b[31mape", 12)
assert fit_cells("plain", 12).strip() == "plain"
def test_main_menu_sections_answer_the_menus_own_question() -> None:
"""Each section is a *doing*, in workflow order, and holds only what shares it.
@@ -220,12 +251,12 @@ def test_marked_label_lines_up_a_mixed_list_when_told_its_lane() -> None:
lane = icon_lane((_NARROW, _WIDE))
rows = [
marked_label(_NARROW, "Purge contacts…", "err", lane=lane),
marked_label(_NARROW, "Archive contacts…", "err", lane=lane),
marked_label(_WIDE, "View archived contacts", "", lane=lane),
]
starts = {
cell_len(row.plain[: row.plain.index(word)])
for row, word in zip(rows, ("Purge", "View"), strict=True)
for row, word in zip(rows, ("Archive", "View"), strict=True)
}
assert starts == {lane + 1}
@@ -256,10 +287,10 @@ def test_align_icons_starts_every_word_in_the_same_cell() -> None:
"""
plain_row = "Reorder"
labels = align_icons(
[f"{_NARROW} Purge contacts…", Text.assemble((_WIDE, "ok"), " View archived"), plain_row]
[f"{_NARROW} Archive contacts…", Text.assemble((_WIDE, "ok"), " View archived"), plain_row]
)
assert [_plain(label) for label in labels] == [
f"{_NARROW} Purge contacts…",
f"{_NARROW} Archive contacts…",
f"{_WIDE} View archived",
"Reorder",
]
@@ -306,8 +337,8 @@ def test_align_icons_pads_nothing_where_the_platform_draws_no_icons() -> None:
set_platform(PICOCALC)
try:
labels = align_icons([f"{_NARROW} Purge contacts…", f"{_WIDE} View archived"])
assert [_plain(label) for label in labels] == ["Purge contacts…", "View archived"]
labels = align_icons([f"{_NARROW} Archive contacts…", f"{_WIDE} View archived"])
assert [_plain(label) for label in labels] == ["Archive contacts…", "View archived"]
finally:
set_platform(REGULAR)
+27
View File
@@ -1236,3 +1236,30 @@ def test_node_detail_single_tab_hides_the_switch_hint() -> None:
assert "↑↓ move" in screen.footer_hint and screen.footer_hint.endswith("Esc back")
# Nothing to switch to, so the lane leaves the slot empty rather than dimming it.
assert screen.fkey_lane[2] is None
def test_toggling_the_lock_rewrites_the_rows_and_keeps_the_cursor_on_it() -> None:
"""Lock becomes Unlock on the same row, and Archive is withdrawn while the lock holds."""
tm = _Action("timemachine", "⏳", "", "Time machine — 42 receptions")
delete = _Action("remove", "🗑", "err", "Delete contact…")
screen = _screen(
info_actions=[
tm,
_Action("lock", "🔒", "", "Lock contact"),
_Action("archive", "💾", "", "Archive contact"),
delete,
]
)
resolved: list = []
screen.resolve = lambda value: resolved.append(value) # type: ignore[method-assign]
screen.note_viewport(30)
screen.render_body(72)
screen.handle("down")
screen.handle("enter")
assert resolved == ["lock"]
screen.replace_info_actions([tm, _Action("unlock", "🔓", "", "Unlock contact"), delete])
screen.render_body(72)
screen.handle("enter")
assert resolved == ["lock", "unlock"]
assert [a.key for a in screen._info_actions] == ["timemachine", "unlock", "remove"]
+68
View File
@@ -0,0 +1,68 @@
# SPDX-License-Identifier: Apache-2.0
"""``ui/oklab.py`` — the perceptual colour space the seam and its shade are measured in."""
from __future__ import annotations
import pytest
from meshterm.ui import oklab
from meshterm.ui.theme import _node_style_spectrum
def _fill(key: str) -> str:
return _node_style_spectrum(key).split()[-1]
def test_every_node_fill_round_trips_through_oklab_exactly() -> None:
"""Hex → Lab → hex is lossless on all 256 spectrum fills and the fixed greys."""
for byte in range(256):
colour = _fill(f"{byte:02x}")
assert oklab.to_hex(oklab.from_hex(colour)) == colour
for grey in ("#000000", "#ffffff", "#64748b", "#334155", "#3f3f46"):
assert oklab.to_hex(oklab.from_hex(grey)) == grey
def test_lightness_runs_black_to_white_and_distance_is_a_metric() -> None:
"""``L`` spans 0–1, black to white is distance 1, and distance is symmetric."""
black, white = oklab.from_hex("#000000"), oklab.from_hex("#ffffff")
assert black[0] == pytest.approx(0.0, abs=1e-6) and white[0] == pytest.approx(1.0, abs=1e-3)
assert oklab.distance("#000000", "#ffffff") == pytest.approx(1.0, abs=1e-3)
assert oklab.distance("#f25555", "#f25555") == 0.0
assert oklab.distance("#f25555", "#5557f2") == oklab.distance("#5557f2", "#f25555")
def test_the_wheel_is_not_uniform_to_the_eye() -> None:
"""The reason this module exists: one hue step is a whisper in green, a shout in cyan."""
green = oklab.distance(_fill("4c"), _fill("5c"))
cyan = oklab.distance(_fill("80"), _fill("90"))
assert green < 0.03 < 0.15 < cyan
def test_shaded_moves_lightness_alone_away_from_the_nearer_end() -> None:
"""A light colour comes back darker, a dark one lighter, chroma untouched."""
light, dark = "#55f255", "#334155"
for colour, sign in ((light, -1), (dark, +1)):
before, after = oklab.from_hex(colour), oklab.from_hex(oklab.shaded(colour, 0.1))
assert (after[0] - before[0]) * sign == pytest.approx(0.1, abs=0.01)
assert after[1] == pytest.approx(before[1], abs=0.02) # in-gamut: chroma kept
assert after[2] == pytest.approx(before[2], abs=0.02)
def test_shaded_floor_clears_a_second_colour_too() -> None:
"""With a floor, the shift starts from whichever lightness is further along."""
a, b = _fill("4c"), _fill("5c") # b is the darker of two near greens
plain = oklab.shaded(a, 0.15)
floored = oklab.shaded(a, 0.15, floor=oklab.from_hex(b)[0])
assert oklab.from_hex(floored)[0] <= oklab.from_hex(plain)[0]
assert oklab.from_hex(b)[0] - oklab.from_hex(floored)[0] == pytest.approx(0.15, abs=0.01)
slate, lighter_slate = "#334155", "#3f3f46" # dark: the floor pushes *up* instead
floored = oklab.shaded(slate, 0.15, floor=oklab.from_hex(lighter_slate)[0])
assert oklab.from_hex(floored)[0] >= oklab.from_hex(oklab.shaded(slate, 0.15))[0]
def test_from_hex_rejects_anything_but_six_digits() -> None:
"""Six hex digits, ``#`` and case optional; anything else is a ``ValueError``."""
with pytest.raises(ValueError):
oklab.from_hex("#fff")
assert oklab.from_hex("F25555") == oklab.from_hex("#f25555")
+65 -18
View File
@@ -14,7 +14,9 @@ import random
import pytest
import meshterm.ui.pathline as pathline
from meshterm.ui import oklab
from meshterm.ui.pathline import (
_DIM_BG,
_SELF_INK,
_YOU_BG,
CRACK_HEAD,
@@ -26,10 +28,14 @@ from meshterm.ui.pathline import (
POWERLINE_ROUND_CLOSE,
POWERLINE_ROUND_OPEN,
POWERLINE_SEP,
POWERLINE_THIN,
SEAM_BLUR,
SEAM_SHADE,
SELF_GLYPH,
WRAP_OFFSET,
PathHop,
PathLine,
_seam_ink,
_style_hex,
cut_mark,
cut_to,
@@ -147,7 +153,7 @@ def test_auto_mode_follows_the_terminal_verdict(monkeypatch: pytest.MonkeyPatch)
"""``auto`` renders chips exactly when the terminal can draw them."""
hops = [PathHop("a"), PathHop("b")]
monkeypatch.setattr(pathline, "powerline_enabled", lambda: True)
assert POWERLINE_SEP in PathLine(hops).text().plain
assert POWERLINE_THIN in PathLine(hops).text().plain # two keyless greys: the thin seam
monkeypatch.setattr(pathline, "powerline_enabled", lambda: False)
assert PathLine(hops).text().plain == "a → b"
@@ -639,31 +645,72 @@ def test_a_wrapped_route_middle_pays_for_both_its_chevrons() -> None:
assert not folded[-1].plain.strip().startswith("→") # a continuation opens on its hop
def test_a_seam_between_two_of_one_colour_falls_back_to_the_page() -> None:
"""Where two chips land on the same fill, the seam falls back to the bare page.
def _seams(*hops: PathHop) -> list[tuple[str, str]]:
"""Every seam cell of a chip line as ``(glyph, style)``, in order."""
text = PathLine(list(hops), mode="powerline").text()
return [
(text.plain[s.start], str(s.style))
for s in text.spans
if text.plain[s.start] in (POWERLINE_SEP, POWERLINE_THIN)
]
The interlock can't draw a chevron in its own background, so the point drops its
background and the page cuts the wedge instead.
def test_a_seam_between_two_of_one_colour_is_the_thin_chevron_shaded() -> None:
"""Where two chips land on the same fill, the seam is the thin chevron in that fill, shaded.
The interlock can't draw a solid point in its own background, so the join is drawn
*on* the shared fill instead — powerline's own mark for a join inside one colour —
and the ribbon runs on unbroken. Its colour is the previous chip's own, a lightness
step darker, so nothing on the line is a colour that isn't a chip's; a dark fill (the
faded slate) steps lighter instead.
Not a rare accident, either: a mirrored return leg is a run of identically faded hops
by construction, and a stretch of keyless hops shares one grey. Either way it stays a
single cell — the fallback trades the blend for the separation, not for width.
single cell — the fallback trades the interlock for the outline, not for width.
"""
hue = _style_hex(node_style("aa"))
same = PathLine([PathHop("A", key="aa"), PathHop("B", key="aa")], mode="powerline")
text = same.text()
seam = next(s for s in text.spans if text.plain[s.start] == POWERLINE_SEP)
assert str(seam.style) == hue # foreground only: the page shows through the wedge
glyph, style = _seams(PathHop("A", key="aa"), PathHop("B", key="aa"))[0]
assert glyph == POWERLINE_THIN and style == f"{_seam_ink(hue, hue)} on {hue}"
shade = _seam_ink(hue, hue)
assert oklab.from_hex(shade)[0] < oklab.from_hex(hue)[0] # darker…
assert oklab.distance(shade, hue) == pytest.approx(SEAM_SHADE, abs=0.01) # …by the step
assert oklab.distance(shade, hue) < oklab.distance(hue, _style_hex(node_style("77")))
apart = PathLine([PathHop("A", key="aa"), PathHop("B", key="77")], mode="powerline")
text = apart.text()
seams = [str(s.style) for s in text.spans if text.plain[s.start] == POWERLINE_SEP]
assert seams[0] == f"{hue} on {_style_hex(node_style('77'))}" # blended, one cell
assert _seams(PathHop("A", key="aa"), PathHop("B", key="77"))[0] == (
POWERLINE_SEP,
f"{hue} on {_style_hex(node_style('77'))}",
) # blended, one cell
dimmed = PathLine([PathHop("A", dim=True), PathHop("B", dim=True)], mode="powerline")
text = dimmed.text()
gaps = [str(s.style) for s in text.spans if text.plain[s.start] == POWERLINE_SEP]
assert " on " not in gaps[0] # a dimmed return leg reads as hops, not one bar
glyph, style = _seams(PathHop("A", dim=True), PathHop("B", dim=True))[0]
assert glyph == POWERLINE_THIN and style.endswith(f" on {_DIM_BG}")
lighter = style.split()[0]
assert oklab.from_hex(lighter)[0] > oklab.from_hex(_DIM_BG)[0] # a dark fill steps up
def test_a_seam_blurs_by_perceived_distance_not_by_hue_gap() -> None:
"""Two fills under ``SEAM_BLUR`` apart to the eye blur like an exact match.
The eye, not the wheel: sixteen first-byte steps across the greens are one colour to
a reader and sixteen across the reds are two, so the same hue gap blurs on one side
of the wheel and interlocks on the other. The greys follow the same metric with no
exemption: the keyless grey and the faded slate sit well apart and keep their seam.
"""
green = _seams(PathHop("A", key="4c"), PathHop("B", key="5c"))[0]
red = _seams(PathHop("A", key="00"), PathHop("B", key="10"))[0]
assert green[0] == POWERLINE_THIN and red[0] == POWERLINE_SEP
assert oklab.distance(_style_hex(node_style("4c")), _style_hex(node_style("5c"))) < SEAM_BLUR
assert oklab.distance(_style_hex(node_style("00")), _style_hex(node_style("10"))) > SEAM_BLUR
# The shade is taken from the chip behind but clears the chip ahead as well.
before, after = _style_hex(node_style("4c")), _style_hex(node_style("5c"))
assert green[1] == f"{_seam_ink(before, after)} on {after}"
assert oklab.distance(_seam_ink(before, after), after) >= SEAM_SHADE - 1e-6
wrapped = _seams(PathHop("A", key="fc"), PathHop("B", key="02"))[0]
assert wrapped[0] == POWERLINE_THIN # neighbours across the wheel's seam, too
glyph, style = _seams(PathHop("A"), PathHop("B", dim=True))[0]
assert glyph == POWERLINE_SEP and " on " in style # two greys, still interlocked
def test_bare_self_stands_us_on_a_star_and_fades_both_ends() -> None:
+5 -5
View File
@@ -377,14 +377,14 @@ def test_host_battery_reads_the_sysfs_supply(tmp_path, monkeypatch) -> None:
assert service.reading() is None # unreadable supply = absent, header draws nothing
def test_dialog_gate_shrinks_on_picocalc() -> None:
"""A dialog is allowed fewer cells on the PicoCalc, the screen being narrower."""
from meshterm.ui.surface import _dialog_max_cells
def test_dialog_wrap_shrinks_on_picocalc() -> None:
"""A dialog message wraps to fewer cells on the PicoCalc, the screen being narrower."""
from meshterm.ui.surface import _dialog_wrap_cells
set_platform(PICOCALC)
assert _dialog_max_cells() == 43
assert _dialog_wrap_cells() == 37 # 53 readable - 4 margin - 12 chrome
set_platform(REGULAR)
assert _dialog_max_cells() == 76
assert _dialog_wrap_cells() == 54 # 72 readable - 6 margin - 12 chrome
# --- the dialog border's hint, against the lane one row below it ------------------------
+204
View File
@@ -23,12 +23,16 @@ from meshterm.core.connection import MockDevice
from meshterm.core.device_store import DeviceStore
from meshterm.core.models import Contact, utcnow
from meshterm.core.remote_config import (
DISCOVERED_HELP,
REPEATER_SETTINGS,
SettingCommand,
composite_reads,
discovered_setting,
get_setting,
known_commands,
normalize_value,
parse_reply_value,
parse_setting_command,
range_hint,
read_plan,
reply_is_error,
@@ -780,3 +784,203 @@ def test_admin_actions_start_every_label_in_the_same_cell() -> None:
starts[label] = cell_len(plain[: plain.index(label)])
assert set(starts) == set(labels)
assert len(set(starts.values())) == 1, f"a label starts a column early: {starts}"
# --- settings discovered on the command line -------------------------------------------
def test_only_get_and_set_are_read_as_settings() -> None:
"""The CLI's own grammar is the filter; every top-level verb falls out of it.
Nothing is excluded by name: ``reboot``, ``advert`` and ``powersaving`` are simply not
``get <key>`` or ``set <key> <value>``, and ``setperm`` only starts with the letters.
"""
assert parse_setting_command("get radio.rxps") == SettingCommand("get", "radio.rxps")
assert parse_setting_command(" set reboot.interval 24 ") == SettingCommand(
"set", "reboot.interval", "24"
)
assert parse_setting_command("set owner.info a b c") == SettingCommand(
"set", "owner.info", "a b c"
)
for command in (
"reboot",
"advert",
"clock sync",
"powersaving on",
"setperm ab12 rw",
"start ota",
"get", # no key
"set tx", # no value
"get ../etc", # not a key's spelling
):
assert parse_setting_command(command) is None, command
def test_a_write_the_node_accepts_is_proof_the_key_is_there(tui_ctx) -> None:
"""``set`` teaches a row: the firmware matches its keys exactly and refuses the rest.
``handleSetCmd`` compares ``"radio "`` *with* the trailing space, so a longer key can
never land in a shorter one's branch, and an unknown one answers ``unknown config:``.
"""
repeater_admin.learn_from_cli(tui_ctx, NODE, "set radio.rxps balanced", "OK")
cached = tui_ctx.remote_store.settings(NODE)["radio.rxps"]
assert (cached.value, cached.discovered) == ("balanced", True)
repeater_admin.learn_from_cli(tui_ctx, NODE, "set nonesuch 1", "unknown config: nonesuch")
assert "nonesuch" not in tui_ctx.remote_store.settings(NODE)
def test_a_read_answered_out_of_a_shorter_keys_branch_is_no_discovery(tui_ctx) -> None:
"""``get`` matches on a bare prefix, so a plausible reply can belong to another key.
Stock firmware answers ``get radio.rxps`` from the ``get radio`` branch. With no typing
to reject it — a discovered key has none — the guard is that the reply is exactly what
the shadowing key last said.
"""
for key, value in (("freq", "910.525"), ("bw", "62.5"), ("sf", "7"), ("cr", "5")):
tui_ctx.remote_store.remember_setting(NODE, key, value)
repeater_admin.learn_from_cli(tui_ctx, NODE, "get radio.rxps", "> 910.525,62.5,7,5")
assert "radio.rxps" not in tui_ctx.remote_store.settings(NODE)
repeater_admin.learn_from_cli(tui_ctx, NODE, "get radio.rxps", "> desired=off level=2")
assert tui_ctx.remote_store.settings(NODE)["radio.rxps"].value == "desired=off level=2"
def test_a_read_nothing_shadows_is_taken_at_its_word(tui_ctx) -> None:
"""No catalog key prefixes ``reboot.interval``, so its reply can only be its own."""
repeater_admin.learn_from_cli(tui_ctx, NODE, "get reboot.interval", "> 24")
assert tui_ctx.remote_store.settings(NODE)["reboot.interval"].value == "24"
repeater_admin.learn_from_cli(tui_ctx, NODE, "get nonesuch", "??: nonesuch")
assert "nonesuch" not in tui_ctx.remote_store.settings(NODE)
def test_an_unread_shadow_leaves_the_question_unanswerable(tui_ctx) -> None:
"""A shadowed key whose shadow was never read is not discovered: we cannot tell."""
repeater_admin.learn_from_cli(tui_ctx, NODE, "get radio.rxps", "> 910.525,62.5,7,5")
assert "radio.rxps" not in tui_ctx.remote_store.settings(NODE)
def test_the_private_key_is_never_written_to_disk(tui_ctx, tmp_path: Path) -> None:
"""``get prv.key`` prints in the transcript and goes no further."""
secret = "ab" * 32
repeater_admin.learn_from_cli(tui_ctx, NODE, "get prv.key", "> " + secret)
assert "prv.key" not in tui_ctx.remote_store.settings(NODE)
written = [p for p in tmp_path.rglob("*.json") if secret in p.read_text(encoding="utf-8")]
assert written == []
def test_a_catalog_key_read_on_the_command_line_refreshes_its_row(tui_ctx) -> None:
"""The cache is the page, so the command line fills it exactly as a read does.
``get tx`` used to leave the TX power row showing what it said last week, and
``get radio`` answered four rows that none of them heard about.
"""
repeater_admin.learn_from_cli(tui_ctx, NODE, "get tx", "> 20")
repeater_admin.learn_from_cli(tui_ctx, NODE, "get radio", "> 869.525,250,10,6")
repeater_admin.learn_from_cli(tui_ctx, NODE, "set txdelay 1.5", "OK")
cache = tui_ctx.remote_store.settings(NODE)
assert cache["tx"].value == "20"
assert (cache["freq"].value, cache["sf"].value) == ("869.525", "10")
assert cache["txdelay"].value == "1.5"
assert not any(cached.discovered for cached in cache.values())
def test_a_write_stales_the_other_spelling_from_the_command_line_too(tui_ctx) -> None:
"""``set dutycycle`` at the prompt drops the cached ``af``, as an Apply would."""
tui_ctx.remote_store.remember_setting(NODE, "af", "1.00")
repeater_admin.learn_from_cli(tui_ctx, NODE, "set dutycycle 40", "OK - 40.0%")
cache = tui_ctx.remote_store.settings(NODE)
assert cache["dutycycle"].value == "40.0" and "af" not in cache
def test_no_reply_can_remove_a_discovered_row(tui_ctx) -> None:
"""A read may only ever take a discovered row to ``n/a``; removing it is a decision.
Its key is recorded nowhere but here, so a reply that misreads — a truncated line, a
node answering mid-reboot, a frame correlated to the wrong command — must not be able
to delete the row. A catalog row costs nothing when that happens, the catalog still
naming the key; this one would cost the reader something only they can restore.
"""
spec = discovered_setting("radio.rxps")
tui_ctx.remote_store.remember_discovered(NODE, "radio.rxps", "off")
repeater_admin.remember_reply(tui_ctx, NODE, [spec], "> balanced")
cached = tui_ctx.remote_store.settings(NODE)["radio.rxps"]
assert (cached.value, cached.discovered) == ("balanced", True)
repeater_admin.remember_reply(tui_ctx, NODE, [spec], "??: radio.rxps")
cache = tui_ctx.remote_store.settings(NODE)
assert cache["radio.rxps"].discovered and not cache["radio.rxps"].supported
assert repeater_admin._value_text(spec, cache, {}).plain == "n/a"
assert "radio.rxps" in repeater_admin._extra_keys(cache), "still the reader's to forget"
def test_discovered_rows_draw_last_in_their_own_section_and_are_deletable() -> None:
"""A node's own settings sit under ``Extra`` — and only they answer Del."""
from meshterm.ui.tui import Choice
plain = {"txdelay": CachedValue("0.5", utcnow())}
catalog_rows = [
item
for item in repeater_admin._menu_items(NODE, plain, {})[1]
if isinstance(item, Choice) and item.deletable
]
assert catalog_rows == [], "no catalog row may be forgotten"
cache = dict(plain, **{"radio.rxps": CachedValue("balanced", utcnow(), discovered=True)})
items = repeater_admin._menu_items(NODE, cache, {})[1]
deletable = [i for i in items if isinstance(i, Choice) and i.deletable]
assert [i.value for i in deletable] == ["radio.rxps"]
rows = [i for i in items if isinstance(i, Choice) and isinstance(i.value, str)]
settings_rows = [r for r in rows if repeater_admin.get_setting(r.value) or r.deletable]
assert settings_rows[-1].value == "radio.rxps", "the node's own settings come last"
assert DISCOVERED_HELP in settings_rows[-1].title.plain
def test_a_staged_discovered_value_is_written_with_its_own_set() -> None:
"""A key the catalog hasn't got is still written the one way anything can write it."""
writes = write_plan({"radio.rxps": "balanced", "txdelay": "1.5"}, {})
assert [w.command for w in writes] == ["set txdelay 1.5", "set radio.rxps balanced"]
def test_a_long_discovered_value_cannot_widen_the_whole_page() -> None:
"""The value lane is one width for every row, so a node's own setting may not set it.
Some builds answer ``get radio.rxps`` with a whole diagnostic line. Unbounded, that one
row would push every catalog setting's description right and off a 72-column screen —
reshaping a page the reader opened to read the catalog.
"""
from rich.cells import cell_len
from meshterm.ui.tui import Choice
def widest(cache) -> int:
items = repeater_admin._menu_items(NODE, cache, {})[1]
return max(
cell_len(i.title.plain)
for i in items
if isinstance(i, Choice) and hasattr(i.title, "plain")
)
catalog = {
"tx": CachedValue("20", utcnow()),
"owner.info": CachedValue("Jean-Pierre, Montreal", utcnow()),
}
chatty = dict(
catalog,
**{
"radio.rxps": CachedValue(
"desired=off effective=off supported=yes level=0 preamble=16 rx=0 sleep=0",
utcnow(),
discovered=True,
)
},
)
assert widest(chatty) == widest(catalog)
fitted = repeater_admin._extra_value("a value far too long for its lane", 14)
assert cell_len(fitted) == 14
assert repeater_admin._extra_value(" balanced\n", 14) == "balanced"
+31 -1
View File
@@ -52,6 +52,24 @@ def test_session_wraps_the_real_terminal_on_regular(monkeypatch) -> None:
assert out.get_size() == Size(rows=30, columns=101)
def test_session_pins_the_real_terminal_inside_the_width_extension(monkeypatch) -> None:
"""Both wraps on REGULAR, reclaim outermost: its size is what everything lays out against."""
from meshterm.ui.tui.colsnap import PinnedOutput
fake = SimpleNamespace(get_size=lambda: Size(rows=30, columns=100))
monkeypatch.setattr("prompt_toolkit.output.defaults.create_output", lambda: fake)
monkeypatch.delenv("MESHTERM_FULL_WIDTH", raising=False)
monkeypatch.delenv("MESHTERM_COLUMN_SNAP", raising=False)
out = TuiSession()._resolve_output()
assert isinstance(out, _WidthExtendedOutput)
assert isinstance(out._inner, PinnedOutput) and out._inner._inner is fake
# Pinning alone, where the column is not reclaimed.
monkeypatch.setenv("MESHTERM_FULL_WIDTH", "0")
pinned = TuiSession()._resolve_output()
assert isinstance(pinned, PinnedOutput) and pinned._inner is fake
def test_session_leaves_the_bare_terminal_on_picocalc(monkeypatch) -> None:
"""PICOCALC's exact-width console → no reclaim: a phantom column would tear the frame."""
monkeypatch.delenv("MESHTERM_FULL_WIDTH", raising=False)
@@ -69,7 +87,19 @@ def test_env_override_forces_reclaim_on_despite_picocalc(monkeypatch) -> None:
def test_env_override_forces_reclaim_off_despite_regular(monkeypatch) -> None:
"""``MESHTERM_FULL_WIDTH=0`` wins over REGULAR's on-by-default."""
"""``MESHTERM_FULL_WIDTH=0`` wins over REGULAR's on-by-default.
The terminal is still wrapped for pinning, which has a gate of its own; with that turned
off too there is nothing left to wrap, and prompt_toolkit builds its own output.
"""
fake = SimpleNamespace(get_size=lambda: Size(rows=30, columns=100))
monkeypatch.setattr("prompt_toolkit.output.defaults.create_output", lambda: fake)
monkeypatch.setenv("MESHTERM_FULL_WIDTH", "0")
monkeypatch.delenv("MESHTERM_COLUMN_SNAP", raising=False)
set_platform(REGULAR)
out = TuiSession()._resolve_output()
assert not isinstance(out, _WidthExtendedOutput)
assert out.get_size() == Size(rows=30, columns=100) # no column reclaimed
monkeypatch.setenv("MESHTERM_COLUMN_SNAP", "0")
assert TuiSession()._resolve_output() is None
+128 -48
View File
@@ -6,6 +6,7 @@ These run without hardware against the :class:`MockDevice` simulator.
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
@@ -699,19 +700,23 @@ class _Event:
def _mc_with(contacts: dict, path_hash_mode: int = 2):
"""Build a minimal mock ``MeshCore`` client exposing the trace commands used."""
"""Build a minimal mock ``MeshCore`` client exposing the trace commands used.
class _Commands:
async def get_contacts(self) -> _Event:
return _Event(contacts)
The contacts arrive the way the radio sends them — one record frame each, then the
closing frame holding the whole table (see :class:`_FakeMeshCore`).
"""
from meshcore import EventType
async def get_path_hash_mode(self) -> int:
return path_hash_mode
mc = _FakeMeshCore(
[(EventType.NEXT_CONTACT, info) for info in contacts.values()]
+ [(EventType.CONTACTS, contacts)]
)
class _MC:
commands = _Commands()
async def get_path_hash_mode() -> int:
return path_hash_mode
return _MC()
mc.commands.get_path_hash_mode = get_path_hash_mode
return mc
async def test_contact_with_future_advert_stamp_reads_as_never_heard() -> None:
@@ -831,59 +836,134 @@ async def test_unknown_contact_resolves_to_none() -> None:
assert resolved is None
async def test_contacts_payload_retries_transient_error() -> None:
"""A transient 'no event received' on contacts retrieval is retried, not fatal."""
class _FakeSubscription:
"""One :meth:`_FakeMeshCore.subscribe` registration, undone by ``unsubscribe``."""
def __init__(self, bus: dict, event_type, callback) -> None: # noqa: ANN001
"""Register ``callback`` for ``event_type`` on ``bus``."""
self._bus = bus
self._key = event_type
self._callback = callback
bus.setdefault(event_type, []).append(callback)
def unsubscribe(self) -> None:
"""Drop the registration, as the library's subscription does."""
self._bus.get(self._key, []).remove(self._callback)
class _FakeMeshCore:
"""A companion that answers a contacts request with a scripted run of frames.
Each script entry is one ``(EventType, payload)`` frame dispatched to whoever
subscribed to it, so a test can stream records, close the dump, refuse it outright,
or simply say nothing at all.
"""
def __init__(self, *scripts) -> None:
"""Take one frame script per contacts request, answered in order."""
from types import SimpleNamespace
self._bus: dict = {}
self._scripts = list(scripts)
self.requests = 0
self.commands = SimpleNamespace(get_contacts_async=self._request)
def subscribe(self, event_type, callback, attribute_filters=None): # noqa: ANN001, ANN201
"""Register a listener, returning something that can unsubscribe it."""
return _FakeSubscription(self._bus, event_type, callback)
async def _request(self, lastmod: int = 0) -> None:
"""Answer the next scripted run of frames."""
from meshcore.events import Event
self.requests += 1
script = self._scripts.pop(0) if self._scripts else []
for event_type, payload in script:
for callback in list(self._bus.get(event_type, [])):
callback(Event(event_type, payload))
async def test_contacts_payload_retries_a_refused_read() -> None:
"""A companion that refuses the dump outright is retried, not surfaced."""
from meshcore import EventType
from meshterm.core.connection import MeshCoreDevice
calls = {"n": 0}
class _Err:
payload = {"reason": "no event received during contacts retrieval"}
def is_error(self) -> bool:
return True
class _Ok:
payload = {"Repeater": {"adv_name": "Repeater", "public_key": "aabbcc" + "00" * 29}}
def is_error(self) -> bool:
return False
class _Commands:
async def get_contacts(self): # noqa: ANN202
calls["n"] += 1
return _Err() if calls["n"] < 3 else _Ok()
class _MC:
commands = _Commands()
record = {"adv_name": "Repeater", "public_key": "aabbcc" + "00" * 29}
mc = _FakeMeshCore(
[(EventType.ERROR, {"reason": "ERR_CODE_BAD_STATE"})],
[(EventType.ERROR, {"reason": "ERR_CODE_BAD_STATE"})],
[(EventType.NEXT_CONTACT, record), (EventType.CONTACTS, {"Repeater": record})],
)
device = MeshCoreDevice(port="COM-test")
payload = await device._contacts_payload(_MC(), retries=3, delay=0)
payload = await device._contacts_payload(mc, retries=3, delay=0, idle=0.05)
assert "Repeater" in payload
assert calls["n"] == 3 # failed twice, succeeded on the third attempt
assert mc.requests == 3 # refused twice, served on the third attempt
async def test_contacts_payload_waits_out_a_slow_dump() -> None:
"""A table that streams for longer than any fixed deadline still completes.
The idle clock restarts on every record, so the only thing that ends a read is the
radio going quiet — not how long the whole dump took.
"""
from meshcore import EventType
from meshcore.events import Event
from meshterm.core.connection import MeshCoreDevice
class _SlowMeshCore(_FakeMeshCore):
async def _request(self, lastmod: int = 0) -> None:
self.requests += 1
table = {}
for index in range(8):
await asyncio.sleep(0.04) # each gap is inside the idle window
record = {"adv_name": f"Node{index}", "public_key": f"{index:02x}" + "00" * 31}
table[record["adv_name"]] = record
for callback in list(self._bus.get(EventType.NEXT_CONTACT, [])):
callback(Event(EventType.NEXT_CONTACT, record))
for callback in list(self._bus.get(EventType.CONTACTS, [])):
callback(Event(EventType.CONTACTS, table))
mc = _SlowMeshCore()
device = MeshCoreDevice(port="COM-test")
payload = await device._contacts_payload(mc, retries=0, delay=0, idle=0.1)
assert len(payload) == 8 # 0.32 s of streaming under a 0.1 s idle window
async def test_contacts_payload_ignores_an_error_meant_for_another_command() -> None:
"""An uncorrelated ERROR mid-dump belongs to something else and never ends the read."""
from meshcore import EventType
from meshterm.core.connection import MeshCoreDevice
record = {"adv_name": "Repeater", "public_key": "aabbcc" + "00" * 29}
mc = _FakeMeshCore(
[
(EventType.NEXT_CONTACT, record),
(EventType.ERROR, {"reason": "ERR_CODE_BAD_STATE"}), # a battery poll's, not ours
(EventType.CONTACTS, {"Repeater": record}),
]
)
device = MeshCoreDevice(port="COM-test")
payload = await device._contacts_payload(mc, retries=0, delay=0, idle=0.05)
assert "Repeater" in payload
assert mc.requests == 1
async def test_contacts_payload_raises_clean_error_after_retries() -> None:
"""Persistent contacts-retrieval failure raises a clean, actionable error."""
"""A companion that says nothing at all raises a clean, actionable error."""
from meshterm.core.connection import DeviceCommandError, MeshCoreDevice
class _Err:
payload = {"reason": "no event received during contacts retrieval"}
def is_error(self) -> bool:
return True
class _Commands:
async def get_contacts(self): # noqa: ANN202
return _Err()
class _MC:
commands = _Commands()
mc = _FakeMeshCore() # every request answered with silence
device = MeshCoreDevice(port="COM-test")
with pytest.raises(DeviceCommandError) as exc:
await device._contacts_payload(_MC(), retries=2, delay=0)
await device._contacts_payload(mc, retries=2, delay=0, idle=0.05)
assert "no event received" in str(exc.value)
assert mc.requests == 3
async def test_latest_trace_returns_previous_run(tmp_path: Path) -> None:
+3 -3
View File
@@ -17,7 +17,7 @@ def test_spdx_headers():
python_files = []
# Collect all Python files from the required directories
for directory in ["meshterm", "tests", "packaging", "scripts/xiao-radio"]:
for directory in ["meshterm", "tests", "packaging", "scripts/picocalc/xiao-radio"]:
dir_path = repo_root / directory
if dir_path.exists():
if directory == "packaging":
@@ -27,8 +27,8 @@ def test_spdx_headers():
else:
python_files.extend(dir_path.rglob("*.py"))
# Also check the extensionless scripts/meshterm-spi-bridge script
spi_bridge = repo_root / "scripts" / "meshterm-spi-bridge"
# Also check the extensionless scripts/uconsole/meshterm-spi-bridge script
spi_bridge = repo_root / "scripts" / "uconsole" / "meshterm-spi-bridge"
if spi_bridge.exists():
python_files.append(spi_bridge)
+20 -4
View File
@@ -15,7 +15,7 @@ from typing import Any
from rich.table import Table
from rich.text import Text
from meshterm.ui.surface import _DIALOG_MAX_CELLS, TuiUi, _collapse_to_message
from meshterm.ui.surface import _DIALOG_MAX_WRAPPED, TuiUi, _collapse_to_message
from meshterm.ui.tui.session import TuiSession, _message_border
# -- the collapse gate ------------------------------------------------------------
@@ -43,9 +43,25 @@ def test_collapse_rejects_tall_output() -> None:
assert _collapse_to_message([Text(f"line {i}") for i in range(4)]) is None
def test_collapse_rejects_wide_lines() -> None:
"""A line too wide for the popup (a long path, say) falls back to the window."""
assert _collapse_to_message([Text("x" * (_DIALOG_MAX_CELLS + 1))]) is None
def test_collapse_wraps_a_wide_line_instead_of_rejecting_it() -> None:
"""A long one-line outcome stays a popup; the box wraps it rather than passing it on.
This is what keeps a failure — "✗ Chat failed: could not read contacts …" — an
acknowledgement to dismiss instead of a full-frame reading surface.
"""
sentence = "could not read contacts from the radio. " * 3
message = _collapse_to_message([Text(sentence)])
assert message is not None
lines = message.plain.splitlines()
assert len(lines) > 1 # it was re-flowed
assert max(len(line) for line in lines) <= 54 # the regular platform's wrap width
assert " ".join(line.strip() for line in lines) == sentence.strip()
def test_collapse_rejects_a_line_long_enough_to_be_a_page() -> None:
"""Wrapping has a ceiling: past it the output is reading, and the window takes it."""
assert _collapse_to_message([Text("word " * 120)]) is None
assert _DIALOG_MAX_WRAPPED == 8
def test_collapse_rejects_non_text_renderables() -> None:
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more