48 Commits
Author SHA1 Message Date
Jean-Pierre Martineau ce4ff0f57e MeshTerm 0.3.5
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
GitHub release / put the version on meshterm.net (push) Canceled after 0s
2026-09-14 01:50:09 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 e9261569a2 The PayPal donation link is PayPal's own donate address, not a shortener
The Support page's text and QR code and the repository's funding file all pointed at a
tinyurl redirect, which a reader could not audit; they now carry the hosted-button address
itself. The page still renders on both platforms.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:08:34 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 bca1c83dcd MeshTerm 0.3.4
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
The macOS installers of 0.3.3 never built: the notices step refused pyobjc-core and
pyobjc-framework-libdispatch, whose wheels ship no licence text. Their MIT text is vendored
now, and a test checks every vendored path on every platform.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+4 -1
View File
@@ -4,7 +4,10 @@ Given an emoji on the command line, report its codepoints and how the three widt
authorities measure it (wcwidth, Rich, prompt_toolkit), then classify it and print the
exact action the `add-narrow-emoji` skill should take. Run from the repo root:
d:\\vibe\\MeshTerm\\.venv\\Scripts\\python.exe .claude\\skills\\add-narrow-emoji\\classify.py 👋
$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 /
+23 -1
View File
@@ -96,6 +96,24 @@ version is written**. `[project] version` in `pyproject.toml` is `dynamic` and h
reads the attribute, so the number in a built wheel cannot drift from the number in the
source. Do not add a second one.
### Then re-point the README's download commands
`installers.yml` names each asset `meshterm-${version}-<label><ext>`, so **the file a reader
downloads is named after the release** — and the README tells them what to type at it. Those
lines are prose, not code, so nothing rewrites them for you:
```
grep -n '<the previous version>' README.md
```
Every hit is a download or run command (`.\meshterm-X.Y.Z-windows-x64.exe`,
`chmod +x meshterm-X.Y.Z-*`, the `xattr` quarantine note, the `MESHTERM_HOME` trial runs).
Replace the old number with the one just bumped to, and re-read each line: they must name
the file the workflow is about to publish, or the first thing a new user does fails.
Also check `docs/cli.md` for a sample document carrying a `"version"` field, which is the
live `{version}` placeholder and must show the new number too.
## 5. Commit, tag, push
```
@@ -121,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"
+1
View File
@@ -9,6 +9,7 @@
## Before you mark it ready
- [ ] I've read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md) and agree its licensing terms apply to this contribution
- [ ] `python -m pytest -q` passes, including the dual-platform gallery gate
- [ ] `ruff check .` and `ruff format --check .` are clean
- [ ] New screens, rows and hints go through the helpers in `ui/menus.py`, and follow the
+6 -8
View File
@@ -18,7 +18,7 @@ permissions:
contents: read
# A second push while the first is still running makes the first answer obsolete.
# Cancel it — on a private repo those are metered minutes spent on a stale commit.
# Cancel it — a run on a stale commit is a queue slot and a wait spent on nothing.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
@@ -54,13 +54,11 @@ jobs:
# and one red leg cancelling its siblings hides the rest until the next push.
fail-fast: false
matrix:
# Shaped for a *private* repo, where minutes are metered and billed by a
# multiplier: Linux 1x, Windows 2x, macOS 10x. So Linux carries the full
# Python range and the costly runners cover the ends of it, which is where a
# version-dependent break actually shows up.
#
# Once the repo is public, standard runners are unmetered: widen this to every
# OS times every version and stop thinking about it.
# Linux carries the full Python range and the slower-to-schedule runners
# cover the ends of it, which is where a version-dependent break actually
# shows up. It was shaped this way when the repository was private and
# minutes were metered; on a public one they are not, so this can widen to
# every OS times every version whenever the wait for a full run is worth it.
include:
- { os: ubuntu-latest, python: "3.10" }
- { os: ubuntu-latest, python: "3.11" }
+60 -3
View File
@@ -1,8 +1,8 @@
# Puts the downloadable builds on the repository's Releases page.
#
# Nothing here leaves GitHub. While the repository is private the release is private too,
# visible and downloadable only by people who can see the repo. This is *not* the PyPI
# publish — that is `release.yml`, it is a separate act, and it does not run from here.
# Nothing here leaves GitHub: a release is exactly as visible as the repository it sits
# on. This is *not* the PyPI publish — that is `release.yml`, it is a separate act, and it
# does not run from here.
#
# Triggered by pushing a version tag, which is the moment a release exists in git anyway:
#
@@ -11,6 +11,11 @@
#
# The binaries are built by `installers.yml`, called rather than copied, so the files
# people download are made exactly the way the ones CI checks on every push are.
#
# Once the release exists, `website` puts it on meshterm.net: it rebuilds that site from
# the tagged tree — the landing and About pages carry the version — and pushes the result
# to jpmartineau/meshterm.net, which GitHub Pages serves. It writes with a deploy key
# scoped to that one repository (secret SITE_DEPLOY_KEY); that repo's README makes one.
name: GitHub release
on:
@@ -106,3 +111,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
+38 -7
View File
@@ -14,12 +14,12 @@ on:
# Callable, so the release workflow builds these binaries rather than keeping a second
# copy of the matrix that would drift from this one.
workflow_call:
# Only when the packaging itself changes. Three operating systems is not something to
# spend on every commit while the repository is private and minutes are metered.
# Only when the packaging itself changes. A five-platform build takes the better part
# of half an hour, and a commit that touched nothing it freezes has nothing to learn
# from one; the release build is what proves the binaries.
push:
# `main` only. Without this, every Dependabot branch that touches this file — which
# is every action bump — starts a five-platform build, and three of those legs are
# billed at 2x and 10x.
# is every action bump — starts a five-platform build of its own.
branches: [main]
paths:
- "packaging/**"
@@ -50,9 +50,9 @@ jobs:
# fail, it queues forever and holds the whole run open behind it.)
- { os: macos-15-intel, label: macos-x64, ext: "" }
- { os: ubuntu-22.04, label: linux-x64, ext: "" }
# For the uConsole and other 64-bit ARM handhelds. ARM runners are free on
# public repositories; on a private one this leg may sit unclaimed until the
# repo is public. Drop the line if it blocks you.
# For the uConsole and other 64-bit ARM handhelds. The ARM runners are the
# public-repository ones; a private fork of this file may find the leg sits
# unclaimed, and can drop the line.
- { os: ubuntu-22.04-arm, label: linux-arm64, ext: "" }
steps:
- uses: actions/checkout@v4
@@ -69,6 +69,37 @@ jobs:
working-directory: packaging
run: pyinstaller --clean --noconfirm meshterm.spec
# `packaging/meshterm.spec` regenerates `THIRD-PARTY-NOTICES.txt` fresh on every
# leg's own build (see `packaging/notices.py`) and bundles it, along with LICENSE
# and NOTICE, inside that leg's own installer -- so each downloaded binary always
# carries an accurate copy of what it actually contains. This step is a separate
# convenience: the same three files land on the release page itself, next to the
# downloads, so nobody has to extract an installer just to read a license. One
# copy serves every platform there (LICENSE/NOTICE never vary, and the runtime
# dependency closures differ only in a handful of platform-specific packages), so
# it rides on this leg alone rather than being uploaded -- and attached to the
# release -- five identical-in-substance times over. linux-x64 was picked
# arbitrarily among the five; nothing here is Linux-specific.
- name: Upload the licences for the release page
if: matrix.label == 'linux-x64'
shell: bash
run: |
# `build/<specname>/` is PyInstaller's own workpath layout (see the spec's
# comment on `workpath`), not something worth this step knowing by heart
# twice -- copy it flat, next to LICENSE and NOTICE, so the artifact (and,
# via `find`, the release assets built from it) is three plain files.
cp packaging/build/meshterm/THIRD-PARTY-NOTICES.txt .
- uses: actions/upload-artifact@v4
if: matrix.label == 'linux-x64'
with:
name: meshterm-licenses
path: |
LICENSE
NOTICE
THIRD-PARTY-NOTICES.txt
if-no-files-found: error
- name: Check it actually runs
shell: bash
run: |
+1
View File
@@ -12,6 +12,7 @@ dist/
# planning that names hardware and unshipped ideas.
!.claude/
.claude/settings.local.json
.claude/scheduled_tasks.lock
.claude/plans/
.claude/housekeeping.md
# ...and except the GitHub configuration, which only works where GitHub looks for
+165 -1
View File
@@ -17,6 +17,167 @@ allowed to change behaviour, not just add to it.
## [Unreleased]
## [0.3.5] — 2026-09-14
### Added
- A release now rebuilds [meshterm.net](https://meshterm.net) from the tag, so the site
carries the version it links to.
### Changed
- **The purge preview counts in columns.** The target ladder used to describe each rung in a
phrase — "keeps 45 · archives 12" — so comparing two rungs meant finding the numbers inside
two sentences. Keeps and archives are right-aligned columns now under a pinned
`TARGET / KEEPS / ARCHIVES` header, one lane measured across both sections so the numbers
run straight down the ladder; a rung that archives nothing shows a muted 0. The reasons a
contact ranks low sit in three lanes of their own under one header, instead of running on
from wherever the previous reason ended.
- The PayPal link on the Support page, and in the repository's funding file, is PayPal's own
donate address rather than a shortener redirect, so a reader can see where it goes.
### Fixed
- **A name with a skin tone or a cut-off joiner no longer pulls its row out of line.** Two
real contact names showed it: one carries a skin-tone modifier, which one width table
counted as two cells and the terminal draws as none, so the row reserved four cells for a
two-cell glyph; the other is a flag sequence cut at the name's byte limit just past its
joiner, which swallowed the padding space after it and left the name a cell short. A
modifier now measures zero, and a joiner joins only a pictograph. The purge preview, the
Contacts list and the chat picker all pad names through that one measurement.
## [0.3.4] — 2026-09-13
### Fixed
- **The macOS installers build again.** 0.3.3's release never published: both macOS legs
stopped at the new third-party notices step, which refuses to bundle a dependency whose
wheel carries no licence text — and on macOS bleak's Bluetooth backend pulls in two such
wheels, `pyobjc-core` and `pyobjc-framework-libdispatch`. Their MIT text is vendored
beside pyserial's and pywinrt's now, verified against the wheels themselves, and a test
checks every vendored path on every platform so a gap that only one runner can hit is
caught here first. The 0.3.3 tag stands with no release behind it; this is the same
code with its installers.
## [0.3.3] — 2026-09-13
### Added
- **`meshterm --version` answers**, and under `--mock` the `devices` command lists the
simulator as the one device and scans nothing — it used to walk the serial ports and
switch the Bluetooth radio on, on the first command a stranger without a radio would try.
- **[`docs/hardware.md`](docs/hardware.md)** is the one manual for putting MeshTerm on a
PicoCalc or a uConsole: the Calculinux bring-up, the console font, the XIAO UART radio from
parts to a verified link, the SPI bridge, and a closing list of what has only ever been
proven on one bench. The two script READMEs are short indexes pointing at it.
- **The standalone installers carry their own licences now.** Each one-file build
bundles `LICENSE`, `NOTICE` and a generated `THIRD-PARTY-NOTICES.txt` — every
MIT/BSD/PSF dependency's own licence text, gathered at build time from what that
build actually installed — at the root of the archive, and the same three files ride
beside the downloads on the GitHub release page.
- **The map credits OpenStreetMap in its own bottom-right corner.** The full-screen map is
arrived at with `© OpenMapTiles · Data from OpenStreetMap` there — the corner a paper map
puts it in — and the first pan, zoom, reframe or find keystroke collapses it to
`© OpenStreetMap`, which is a complete credit in its own right, so nothing is ever taken
back. It costs no chrome at all: no title atom, no footer character, no body row. On a
desktop terminal it is set into the panel's bottom border rule, right-justified, the way
a title sits in the top one, so the drawing keeps every cell it had; on the PicoCalc,
whose frame has no bottom rule to set anything into, it rides the right end of the map's
own last row, where a pan can move the ground out from under it. The node page's location
preview carries the short form throughout, being a picture rather than a place. The
licence and its URL live where they can be read — the About page, which also now names
**OpenMapTiles**, as OpenFreeMap asks and neither the page nor the README did.
- **Every Python file names its licence with an SPDX identifier** on its first line (second, after a shebang), and `tests/test_spdx.py` keeps a new file from landing without one. Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all.
- **The XIAO firmware patch carries MeshCore's MIT notice beside it** as `scripts/xiao-radio/LICENSE.MeshCore`, and the console-font scripts state that the shared generator block is offered under GPL-2.0-only as well as Apache-2.0, which is what lets the GPL-2.0-only 6×8 script run it.
- **The SPI radio bridge runs on `openhop_core`.** The library the uConsole's software
node runs on was renamed from `pymc_core` in 2026, and the current `meshcore-uconsole`
package depends on the new name. `meshterm-spi-bridge` now drives either runtime,
preferring the newer, and two of its three compatibility shims stand down under it —
openhop_core accepts long ACK payloads and pushes completed trace replies itself; only
the raw RX-log push is still the bridge's. On a bookworm uConsole, where the trixie
package cannot run, the bridge takes a venv of its own with `openhop-core[hardware]`
and still uses the GUI's identity, so it stays the same node. Verified on the device:
radio up, 159 contacts restored, `info` and `contacts` answered over TCP.
- **The PicoCalc picks its console font, and MeshTerm hands the console back.** A *Console
font* row under Display on the Preferences page offers `6x12 (53x26)` — the font the
device boots in — and `6x8 (53x40)`, fourteen more rows of the same glyph inventory in a
shorter cell. Picking one is the preview: the console loads it there and then, and the
page repaints at the new height (the kernel raises SIGWINCH on a font change), so the
choice is looked at before it is kept. Apply keeps it; leaving and discarding puts the
saved font back. The 6×8 base has no Cyrillic, so a node named in Cyrillic draws as
boxes on it.
A virtual terminal has one font for the whole console, so switching MeshTerm's switches
the shell's. It saves whatever font was loaded before the first paint and reloads it on
the way out — from the session's teardown and from an `atexit` hook, so quitting, an
unwind and a crash all leave the console as they found it. Off the handheld, off a real
VT, or with no `setfont`, none of it happens and the log says which. The row is drawn on
the PicoCalc only, while the value round-trips through `preferences.toml` everywhere.
The 6×8 font's build moved into `scripts/calculinux-console-font-6x8.sh`, on its own
because its base bitmap is the Linux kernel's `font_6x8` and therefore GPL-2.0 — marked
as such, with the licence beside it, and reading the donor, alias and keeper tables out
of the 6×12 script rather than keeping a second copy of them. Running the 6×12 script
still builds both.
### Changed
- **Preferences are kept in `preferences.toml`.** The same file as before — only what you
have changed, grouped and commented the way the Preferences page reads — in the format
`config.toml` and the device-config backups already use, so every file you edit by hand
speaks one syntax and MeshTerm no longer needs PyYAML. A `preferences.yaml` from an
earlier build is not read: set those preferences again, or copy the lines across as
`key = value` with text in quotes. A line TOML refuses costs only that line, and a word
left unquoted (`log_level = DEBUG`) is still understood.
- **A trophy case record is a page, not a card floating over the list.** It fills the frame
like the browser under it and takes the node page's shape: a header line that reads as a
sentence — the discipline's mark, its standing and its score — over three tabs. *Info* is
the record's stats, identity first, with the page's actions at its foot; *Route* is the walk
two ways, the route graph and the route line, where Enter traces the path on show; *Area*
is the ground the walk covered, drawn across the whole stage with every pin labelled by its
hash byte so a pin and the graph node under it match by eye. Tab and Shift+Tab switch, F3 on
the PicoCalc. The trophy case itself fills the frame whichever way it is opened, from the
menu or from a trace.
- **A QR code is white on black, and when it is the answer it is the whole screen.** A camera
reads contrast, and light-on-dark is what a scanner expects of a screen, so every code
MeshTerm draws is pure white on pure black on both themes. A channel's share link and a
contact card are no longer a titled popup with an instruction over the code: they are a
bare frame with nothing on it but the code and the URL it encodes, and Esc leaves.
- **A question is a popup, and it is gone before the page it asked for opens.** The trace
target, the repeater admin's node and the TX optimize link used to stay pushed as full
screens under the page they led to, so Esc out of a trace or an admin page landed back on
the list it was picked from — two places where there is one. Each now floats over the main
menu, is popped before its page opens, and Esc from the page lands on the menu. The TX
optimize link is one box that turns its page (`… · step 1 of 2`): Esc on the second step
turns back with the picked node still highlighted. A refused admin login re-asks with the
same node highlighted.
- The three delay settings — TX delay, direct TX delay and RX delay — show, stage and send at
one decimal on both the repeater admin and Device config pages; Device config's RX delay
had no rounding at all.
- The source distribution ships the package, its tests and the manual, not
the repository: the Claude skills, the GitHub config, the PyInstaller spec and the device
scripts are out of it.
### Fixed
- **A share code's bottom-left finder was one cell skewed**, so a camera could not lock on.
Rich's centre justify strips a row's trailing spaces before padding it, so a code row whose
right edge was light modules landed a column off its neighbours. A code is now moved across
a line one way only, the same indent in front of every row.
## [0.3.2] — 2026-09-11
### Added
@@ -579,7 +740,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.2...HEAD
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.5...HEAD
[0.3.5]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.5
[0.3.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.4
[0.3.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.3
[0.3.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.2
[0.3.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.1
[0.3.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.0
+56 -6
View File
@@ -16,6 +16,26 @@ Commit directly on `main` — do not create topic branches unless explicitly ask
is a solo repo; branches just create divergence to merge back later). Commit freely, but
never `git push` unless explicitly asked in that moment.
## Licensing — what every file and every build carries
- **Every Python file opens with `# SPDX-License-Identifier: Apache-2.0`** (line 2 after a
shebang). `tests/test_spdx.py` is the gate. Anything under another licence lives beside
its own text and says so in its header — the GPL-2.0-only console-font script, the
MeshCore MIT notice beside the XIAO patch — and never enters the distributed package.
- **The map credits OpenStreetMap in the map's own bottom-right corner**
(`ui/attribution.py`): the full OpenFreeMap line on arrival, `© OpenStreetMap` after the
first keystroke, `muted`, on both platforms and in the same words. *Which* corner is the
frame's, not the drawing's, wherever the frame has one — **regular** sets the credit into
the panel's **bottom border rule**, right-justified, one rule cell before the corner, the
way a title sits in the top rule (`Screen.bottom_caption` → `frame._panel_box`), so the
map's cells are untouched; **picocalc**, whose frame has no bottom rule at all, stamps it
over the right end of the drawing's last row instead, overprinting what it covers. The
minimap always stamps. The split is bound once via `platforms.on_platform`, never asked
per frame. Never a title atom, never a footer character, never a key. The licence and URL
live on the About page.
- The name and the wordmark are trademarks reserved in `NOTICE`, which Apache §4(d) makes
every fork carry; `NOTICE` holds only what must travel with a redistribution.
## UX standards
These are binding. Every new screen, dialog, row, or hint follows them; when you touch an
@@ -28,7 +48,7 @@ them instead of hand-rolling:
the language below).
- `ui/widgets.py` — `highlighted_hash` (THE key widget — shows a key, lights its hash),
`format_ago` (prose ages),
`_format_age` (column ages), `channel_glyph`, `_NODE_GLYPHS`, heard-age heat colouring.
`format_age` (column ages), `channel_glyph`, `NODE_GLYPHS`, heard-age heat colouring.
- `ui/theme.py` — `name_style`/`node_style` (per-node hues, hash-derived), `snr_style`,
the `you` white.
@@ -58,14 +78,14 @@ sortable list you pick from is `contactlist.py` (`ContactListScreen`/`ContactRow
Setting vs preference — the other boundary, three-way and never blurred: a **setting** is
the *radio's* (`core/device_config.py`, read live from the companion, edited on Device
config); a **preference** is *MeshTerm's own behaviour* (`core/preferences.py`, defaults in
code, overrides in `preferences.yaml`, edited on the Preferences page — `ctx.preferences`,
code, overrides in `preferences.toml`, edited on the Preferences page — `ctx.preferences`,
or `preferences.current()` where there is no context to reach through); and **config** is
machine setup — paths and device profiles (`core/config.py`, `config.toml`, a text editor
only). A behaviour value belongs in the preference registry, not as a module constant and
not in `config.toml`: a code constant a reader can't reach is a preference nobody has.
Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now ago"),
`_format_age` for aligned columns ("now", "5m").
`format_age` for aligned columns ("now", "5m").
### Navigation — the stack
@@ -85,11 +105,26 @@ deliberately, one at a time, and say why in the code.
refreshes in place with `SelectScreen.replace_items`, which follows the highlighted row
by value and keeps the filter. Rebuilding the screen is for when the rows it was holding
a place in are genuinely gone (a purge, a delete) — and then say so.
- **A popup is not a place.** The stack is for *places* — full-frame screens the reader
is *in* (a menu, a map, a list that is the tool's own page, an editor). A popup
*informs, confirms, or asks*, and then it is gone: it is never kept pushed as a hub
under the screen it was gathering for, so Esc from that screen lands where the tool
was launched from, not on a picker. The test is whether the reader is in it or being
asked something — a `SelectScreen` can be either (Channels is a place; "node to
manage" is a question). A question **draws as a box** even when it is the only frame
on the stack (the main menu is popped while a tool runs, and a lone floating screen
would otherwise be painted full-frame): `session.select(floating=True)`,
`session.text(floating=True)`, `session.stay(screen, dialog=True)` — all through
`TuiSession._floated`, which puts a blank base under it.
- **An entry chain is a stack too.** A flow that asks two or more things in a row runs
through `menus.run_steps`: each step gets the answers so far (to label itself, and to
offer its own previous answer as its default) and returns `None` to step back, so Esc
undoes one step instead of the whole flow. A trailing Cancel/verb confirm is still a
decision, not a step — its Cancel abandons.
decision, not a step — its Cancel abandons. Where the steps are *lists to pick from*,
the chain is **one popup that turns its page** — `menus.run_wizard` over `WizardPage`s
(`SelectScreen.turn_page`; title `Feature — subject · step 1 of 2`), never two popups
stacked. Esc on a later page turns back one, with that page's previous answer
highlighted; the box is popped before whatever the answers were for opens.
- **Esc peels before it leaves.** A screen carrying a find-as-you-type filter treats the
typed query as the most recent thing the reader entered: the first Esc clears it and the
screen stays, the second leaves. One rule on all four (`SelectScreen` and everything
@@ -157,6 +192,21 @@ deliberately, one at a time, and say why in the code.
the query in its footer hint draws the body line only where the footer isn't drawn
(`Platform.footer_fkeys`), never both: the query must be visible on every platform, and
twice on none.
- **A QR code is white on black, and when it is the answer it is the whole screen.**
Modules are pure white ink on a pure black field whatever theme the terminal runs
(`qr._STYLE`, both ends named) — a camera reads contrast, light-on-dark is what a
scanner expects of a screen — and never black on white. A share screen
(`qr.share_screen` → `qr.QrScreen`: the channel share, the contact card, the CLI's
share commands in the menu) is a **bare frame**: `Screen.bare` / `frame.compose_bare`
draw the body alone on blank rows — no header, no footer, no title bar, no box, no
hint, no F-key lane, no instruction — with nothing on it but the code and the URL it
encodes, sat a little above centre; Esc leaves, as everywhere. The code **fits itself
to the frame every paint**, width and rows both: a lighter error level, then a
narrower quiet zone, before it would ever be cut (a contact card is 57 cells and 29
rows at the standard fit; the PicoCalc is 53 across and a regular terminal 24 tall),
and when code and URL can't share the frame the code is whole at the top and the URL
a page down. The one exception is a ` ```qr ` fence in a written page, where the code
is an illustration drawn in the prose where the fence is.
### Written pages
@@ -399,7 +449,7 @@ splitting safe has gone, because splitting is the machine face's job now.
quoting stays and always will (`script._escaped`): a node broadcasts its own name and a
stranger fills in a message body, and neither may end the record it sits in.
- **A time is an age** — `now`, `5m`, `3h`, `never` (`script.age`, delegating to
`widgets._format_age` so the two faces cannot drift). An absolute instant survives where
`widgets.format_age` so the two faces cannot drift). An absolute instant survives where
the instant *is* the fact (`script.stamp`, `fields.instant`): the device clock, an
appointment set with `--at`, a live capture's own `TIME`, every column under `--absolute`.
`--absolute` is an override of the `cli_time_format` **preference**, not a switch beside
@@ -506,7 +556,7 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
base style into every span, so a silent one changes colour inside a selected row. Same
trap outside the theme: `MapCanvas` drops emphasis entirely where bold is brightness,
since its colours arrive quantized and it can't know which bank they landed in, and the
fold's quantizer (`theme._nearest_slot_sgr`) states intent for it — a dim slot leaves as
fold's quantizer (`theme._nearest_slot_params`) states intent for it — a dim slot leaves as
`22;3N`, never a bare `3N`, because `9N` is *how* the console spells bright and adjacent
art spans (the wordmark's bevels, a raster's neighbouring cells) reset nothing between
them, so a bare one inherits the intensity bit and lands a bank too high mid-row.
+8
View File
@@ -25,6 +25,14 @@ project and is not authorized to do so.
--------------------------------------------------------------------------------
Device script: scripts/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
does not apply to it. Nothing GPL-2.0 reaches the MeshTerm package: that script
is excluded from the distribution, runs on the device, and produces a PSF data
file the Linux console driver loads, which MeshTerm neither links nor bundles.
Bundled font: meshterm/assets/fonts/CascadiaMonoPL.ttf is Cascadia Mono PL,
Copyright (c) 2019 - Present, Microsoft Corporation, with Reserved Font Name
Cascadia Code. It is redistributed here unmodified under the SIL Open Font
+42 -20
View File
@@ -26,11 +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 welcome here. Drop PNGs into docs/ and reference them, e.g.:
![Dashboard](docs/dashboard.png)
![Mesh map](docs/map.png)
-->
<p align="center">
<img src="docs/screenshots/dashboard.png" alt="The dashboard: a packets-per-minute timeline over the last two hours, and every packet class counted" width="49%">
<img src="docs/screenshots/map.png" alt="The map: repeaters named over an OpenStreetMap basemap of the Lachine Canal" width="49%">
</p>
<p align="center">
<img src="docs/screenshots/trace.png" alt="A trace: the route it walked drawn as chips, then the SNR of every hop" width="49%">
<img src="docs/screenshots/path.png" alt="Message paths: every route a channel message was heard over, drawn on the ground" width="49%">
</p>
<p align="center">
<img src="docs/screenshots/picocalc.png" alt="MeshTerm on the PicoCalc, a 53-column console with a radio wired inside" width="38%">
</p>
<p align="center"><sub>The dashboard, the map, a trace, the paths a message took — and the whole thing on a PicoCalc.</sub></p>
---
@@ -68,14 +75,14 @@ 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.2.8-windows-x64.exe
.\meshterm-0.3.5-windows-x64.exe
```
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
```bash
chmod +x meshterm-0.2.8-*
./meshterm-0.2.8-*
chmod +x meshterm-0.3.5-*
./meshterm-0.3.5-*
```
### A word about terminals, on Windows
@@ -113,7 +120,7 @@ becomes just `meshterm` from anywhere.
> project.
>
> - **macOS**: *"cannot be opened because the developer cannot be verified"*. Clear it with
> `xattr -d com.apple.quarantine meshterm-0.2.8-*` and run it again.
> `xattr -d com.apple.quarantine meshterm-0.3.5-*` and run it again.
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
>
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
@@ -123,10 +130,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.2.8-* # macOS, Linux
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.3.5-* # macOS, Linux
> ```
> ```powershell
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.8-windows-x64.exe
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.5-windows-x64.exe
> ```
>
> Same variable if you run two radios and want them kept apart.
@@ -182,8 +189,9 @@ meshterm --mock --platform picocalc
meshterm specimen
```
Handheld bring-up (fonts, palette, boot services) lives in
`scripts/calculinux-setup.sh` + `scripts/calculinux-console-font.sh`.
Putting MeshTerm on 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**.
## Features
@@ -352,7 +360,7 @@ $ meshterm preferences set history_days 90
$ meshterm preferences reset --yes # back to the built-in defaults
```
They are kept in `~/.meshterm/preferences.yaml`, which lists only what you have changed;
They are kept in `~/.meshterm/preferences.toml`, which lists only what you have changed;
delete a line and the default takes over again.
**Config** is where things live and which device to talk to. MeshTerm runs with none of it
@@ -469,17 +477,25 @@ MeshTerm stands on the [MeshCore](https://meshcore.io/) project — its firmware
### Map data
The street map is rendered from **© OpenStreetMap contributors** data, served as vector
tiles by [OpenFreeMap](https://openfreemap.org/). OpenStreetMap data is available under the
[Open Database License (ODbL)](https://www.openstreetmap.org/copyright). Tiles are decoded
with a small built-in reader for the [Mapbox Vector Tile](https://github.com/mapbox/vector-tile-spec)
format.
tiles by [OpenFreeMap](https://openfreemap.org/) on the unmodified
**© OpenMapTiles** [schema](https://openmaptiles.org/). OpenStreetMap data is available
under the [Open Database License (ODbL)](https://www.openstreetmap.org/copyright). Tiles
are decoded with a small built-in reader for the
[Mapbox Vector Tile](https://github.com/mapbox/vector-tile-spec) format.
Both map surfaces carry the credit themselves. The full-screen map opens with
`© OpenMapTiles · Data from OpenStreetMap` in its bottom-right corner and collapses it to
`© OpenStreetMap` once you pan, zoom or type — set into the panel's bottom border rule on
a desktop terminal, so the drawing itself keeps every cell, and on the map's own last row
on the PicoCalc, whose frame has no bottom rule. The node page's location preview shows the
short form throughout. The About page inside the app spells out the licence URL.
### Bundled font
MeshTerm ships **Cascadia Mono PL**, © 2019–present Microsoft Corporation, redistributed
unmodified under the [SIL Open Font License 1.1](meshterm/assets/fonts/CascadiaMono-OFL.txt).
It's offered to Windows users whose console can't draw the charts — see
[A word about fonts](#a-word-about-fonts). Microsoft doesn't endorse MeshTerm; the font is
[A word about terminals, on Windows](#a-word-about-terminals-on-windows). Microsoft doesn't endorse MeshTerm; the font is
simply the right tool, being one of the very few monospace faces that carries the braille
block the timelines are drawn from.
@@ -497,8 +513,9 @@ respective project for the authoritative terms):
| [Typer](https://typer.tiangolo.com/) | Scripted CLI (subcommands mirror the menu) | MIT |
| [Rich](https://github.com/Textualize/rich) | Console rendering | MIT |
| [prompt_toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) | Full-screen interactive TUI | BSD-3-Clause |
| [markdown-it-py](https://github.com/executablebooks/markdown-it-py) | CommonMark parser behind the written About pages | MIT |
| [Segno](https://github.com/heuer/segno) | Pure-Python QR codes (channel share links) | BSD-3-Clause |
| [tomli](https://github.com/hukkin/tomli) / [tomli-w](https://github.com/hukkin/tomli-w) | TOML config read/write | MIT |
| [tomli](https://github.com/hukkin/tomli) / [tomli-w](https://github.com/hukkin/tomli-w) | TOML config, preferences and device-config backups | MIT |
## License
@@ -517,3 +534,8 @@ is Microsoft's Cascadia Mono PL, redistributed unmodified under the SIL Open Fon
1.1, which travels with it as
[CascadiaMono-OFL.txt](meshterm/assets/fonts/CascadiaMono-OFL.txt) and stays its only
license.
Each standalone build carries `LICENSE`, `NOTICE` and a generated `THIRD-PARTY-NOTICES.txt`
(every dependency's own license text) inside the archive, and the same three files sit
beside the downloads on the [releases page](https://github.com/jpmartineau/MeshTerm/releases)
so you don't have to extract one just to read them.
+1 -1
View File
@@ -3,7 +3,7 @@
#
# How MeshTerm itself behaves — transmit cooldown, message retries, how much history it
# keeps, whether it connects at launch — is *not* here. Those are preferences: they live
# in ~/.meshterm/preferences.yaml, and are edited on the Preferences page (or with
# in ~/.meshterm/preferences.toml, and are edited on the Preferences page (or with
# `meshterm preferences set <key> <value>`). Every one has a built-in default, so that
# file only ever lists what you have changed.
#
+18 -13
View File
@@ -51,6 +51,7 @@ With no command, MeshTerm launches the interactive menu instead.
| Option | What it does |
| --- | --- |
| `--version` | Print `meshterm <version>` and exit `0`, before anything else is opened. |
| `-p`, `--profile NAME` | Use a named device profile from `config.toml`. |
| `--port PORT` | Serial port to connect to (`COM5`, `/dev/ttyACM0`), overriding the profile. |
| `--ble ADDRESS` | Bluetooth address of a companion; selects the BLE transport. |
@@ -70,8 +71,8 @@ for. A bare `--` stops the lifting, the standard way to say the rest is data, an
`--help` is never lifted: `meshterm contacts --help` stays the *contacts* help.
Commands that need a radio open one, do their work, and close it. Commands that only read
stored history (`records`) or MeshTerm's own state (`preferences`, the written pages) need
no device at all and work with nothing attached.
stored history (`records`) or MeshTerm's own state (`preferences`, `platform`, `specimen`,
the written pages) need no device at all and work with nothing attached.
> **One transmission per invocation.** A trace transmits exactly once — repeaters
> penalise, and can blacklist, nodes that burst traffic. To sample more, run the command
@@ -97,7 +98,7 @@ of two directories from the same shell.
| --- | --- |
| `meshterm.db` | The history: every packet overheard, every trace walked, every message. **The one file `--db` moves.** |
| `config.toml` | Machine setup — device profiles, where the database lives. Yours to write; MeshTerm only reads it. |
| `preferences.yaml` | Your overrides of MeshTerm's own behaviour. Lists only what you changed. |
| `preferences.toml` | Your overrides of MeshTerm's own behaviour. Lists only what you changed. |
| `courier.json` | The outbox — queued messages, waiting and finished. |
| `admin.json` | Remembered repeater-admin passwords. |
| `devices.json` | The remembered default companion, and every device proven to speak the protocol. |
@@ -583,11 +584,14 @@ repeat](#the-shapes-that-repeat), the table names the shape rather than restatin
#### `meshterm devices`
List every attached serial device and in-range Bluetooth companion. Opens no radio — it
only enumerates what is attached or advertising.
only enumerates what is attached or advertising. Under `--mock` it enumerates nothing at
all: the simulator is the one device, listed with `--mock` as its target, and neither the
serial ports nor the Bluetooth radio are touched — a session that promised no real
hardware keeps the promise here too.
| Option | What it does |
| --- | --- |
| `--ble` / `--no-ble` | Include a Bluetooth LE scan. On by default; skipping it saves a few seconds. |
| `--ble` / `--no-ble` | Include a Bluetooth LE scan. On by default; skipping it saves a few seconds. Moot under `--mock`. |
```console
$ meshterm devices --no-ble
@@ -984,7 +988,7 @@ The live transcript is a menu screen; from the command line, pick a subcommand.
| `send TEXT --channel N` | Broadcast on a channel slot. |
| `history [--to NAME \| --channel N] [--limit N]` | Print a conversation's stored transcript. Default limit 50. |
| `list` | Every channel and contact with its unread count and last message. |
| `listen [-s SECONDS] [--debug]` | Tail inbound messages live. |
| `listen [-s SECONDS] [--debug]` | Tail inbound messages live. `-s 0` (the default) runs until Ctrl-C. |
Exactly one of `--to` and `--channel` is required on `send` and `history`.
@@ -1201,7 +1205,7 @@ Walk the path to a target once and report what came back.
| Option | What it does |
| --- | --- |
| `-t`, `--target NAME` | **Required.** Target node name or key prefix. |
| `-p`, `--path SPEC` | Force a route: comma-separated contact names and/or hex hashes, mixed freely (`3d,f2,3d`). Omit it and the device routes. |
| `--path SPEC` | Force a route: comma-separated contact names and/or hex hashes, mixed freely (`3d,f2,3d`). Omit it and the device routes. |
`--target` is required, so `meshterm trace --path …` alone is a usage error — the
target-free form is [`trace-path`](#meshterm-trace-path).
@@ -1238,7 +1242,7 @@ whichever way you choose. It only has to end within earshot of this node.
| Option | What it does |
| --- | --- |
| `-p`, `--path SPEC` | **Required.** The whole walk, comma-separated. |
| `--path SPEC` | **Required.** The whole walk, comma-separated. |
```console
$ meshterm trace-path --path "a1,d4,a1"
@@ -1288,7 +1292,7 @@ gets through reliably.
| Option | What it does |
| --- | --- |
| `-p`, `--path SPEC` | **Required.** Forced path ending at the target (`Repeater,Target`, or `3d,f2`). The hop before the target is the node being tuned. |
| `--path SPEC` | **Required.** Forced path ending at the target (`Repeater,Target`, or `3d,f2`). The hop before the target is the node being tuned. |
| `-n`, `--samples N` | Traces per TX level. Default 3. |
| `--step N` | Coarse sweep step. Default 3. |
| `--min N` / `--max N` | Bound the sweep (else the `tx_opt_min` / `tx_opt_max` preferences). |
@@ -1388,7 +1392,8 @@ CLI grammar, and a document that pretended to would be inventing structure. `rep
#### `meshterm preferences`
How MeshTerm behaves, as opposed to how the radio is configured. Reads nothing from the
companion, transmits nothing, and works with no device attached.
companion, transmits nothing, and works with no device attached. With no subcommand it
runs `show`.
| Subcommand | What it does |
| --- | --- |
@@ -1415,7 +1420,7 @@ opening the menu to find out what one does. Values print in the form `preference
accepts back — no unit suffix on a number, `on`/`off` for a boolean. Where `VALUE` differs
from `DEFAULT`, this install has an override.
Overrides live in `preferences.yaml` in [MeshTerm's state
Overrides live in `preferences.toml` in [MeshTerm's state
directory](#where-meshterm-keeps-its-state), which lists only what you have changed; delete
a line and the default takes over again.
@@ -1433,7 +1438,7 @@ wanted it would be rendering a settings screen. A boolean prints `on`/`off` plai
a mapping; every other `.value | tostring` goes straight back in.
`set` and `reset --yes` answer `{"changes":…,"applied":[…],"path":…}`, `path` being the
`preferences.yaml` that was written.
`preferences.toml` that was written.
#### `meshterm about`, `about-author`, `discord`, `support`
@@ -1455,7 +1460,7 @@ each link; the scripted face prints the link alone.
**`--json`:**
```json
{"page":"discord","title":"Join Discord","version":"0.2.8","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
{"page":"discord","title":"Join Discord","version":"0.3.5","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 |
+679
View File
@@ -0,0 +1,679 @@
# 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.
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.
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.
| 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) |
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.
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 regular and picocalc platforms
MeshTerm resolves one frozen platform spec 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.
```bash
# on the Lyra, as root, with scripts/ copied over
sh calculinux-setup.sh
passwd meshterm # the script deliberately sets no password
```
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.
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

+41
View File
@@ -74,6 +74,47 @@ and `prompt_params` returns only the marker for the interactive path. Trace's pi
re-reads its `TRACED` lane after each walk (`ContactListScreen.update_rows`), because that
is the column the list is sorted by and a returning trace changes it.
*Revised 2026-09-12 — TX optimize.* Keeping both pickers pushed under the sweep was the
wrong reading of the rule for a **popup**: a popup asks, confirms or informs, and then it is
gone — it is not a place to walk back to, and two of them stacked read as two places. The
two picks are now one floating list that turns its page (`menus.run_wizard`, `… · step 1
of 2`): Esc on the second step turns back to the first with the picked node still
highlighted, Esc on the first leaves, and the box is popped before the sweep opens — so Esc
from the sweep lands on the main menu, the sweep being the whole visit. It also *draws* as
a popup: the menu is popped while a tool runs, and a floating screen that is the only frame
on the stack used to be painted as the full-frame background (`_base_index`), so the first
of the two pickers never looked like a question at all. `stay(dialog=True)` now puts the
blank base under a popup's visit that `_run_dialog_screen` puts under a one-shot. Trace's
picker is a full-screen contact list (a place), and stays as it is.
*Same day — Repeater admin.* The same shape, one popup instead of two: the node picker was
a hub under the password prompt and the admin page. It is now a floating one-shot
(`session.select(floating=True)`, `admin_picker.pick_admin_node`), gone once answered; the
admin page opens over the main menu and Esc from it lands there. A refused login re-asks
the question with the same node highlighted. `admin_node_visit` is deleted — nothing keeps
a popup pushed any more.
*Same day — the base under a popup.* The blank `ScrollScreen` that `_run_dialog_screen`
pushed on an empty stack showed nothing behind the box. A dialog is smaller than the frame,
so what shows around it is the page it was reached from: `TuiSession._floated` now pushes
the **root** (the main menu, popped while a tool runs but still declared for ^W), and the
reconnect dialog draws over it too. A blank base survives only for a session with no root.
*Same day — the trophy case is a place.* A bare `SelectScreen` floats by default, and the
browser was full-frame from the menu only by the compositor's fallback; opened from a
trace it came up as a box. It declares `floating = False` now, like the contact list.
*Same day — Trace target.* The third of the three pickers finding H kept pushed, and the
one the previous note said would stay: JP's call is that it is a question too. The contact
list floats over the menu as a one-shot (`session.run_dialog`, public now), the trace screen
opens over the menu, Esc from it lands there; tracing another node is opening the tool
again. The typed fallback floats the same way. `ContactListScreen` stays a full-screen place
everywhere else.
*Same day — a record is a page.* `RecordDialog` floated a 62-column card over the trophy
case; the reader is *in* a record (reading, scrolling, arming a trace), so it is
`RecordScreen`, full-frame, Esc back onto the browser.
**I. An entry chain of prompts was not a stack.** Five flows asked two or more things in a
row, and Esc on any of them abandoned the whole flow rather than stepping back one — a
mistyped 32-hex channel key cost the name typed before it. `menus.run_steps` now runs a
+2 -1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""MeshTerm is a full-featured TUI-based MeshCore client for your terminal.
Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Linux.
@@ -5,7 +6,7 @@ Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Lin
from datetime import datetime
__version__ = "0.3.2"
__version__ = "0.3.5"
__author__ = "Jean-Pierre Martineau"
#: The year MeshTerm was first published; the copyright span starts here.
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Enable ``python -m meshterm``."""
from .cli import main
+7 -4
View File
@@ -58,12 +58,15 @@ author — a fork that keeps soliciting through it, without pointing it at
itself, isn't affiliated with MeshTerm.
Built on **MeshCore** *https://meshcore.io/* — its firmware and companion
library — with Rich, prompt_toolkit, Typer, bleak, pyserial, pycryptodome
and Segno, each under its own terms.
library — with Rich, prompt_toolkit, Typer, bleak, pyserial, pycryptodome,
markdown-it-py, tomli/tomli-w and Segno, each under its own terms.
Map data **© OpenStreetMap contributors**, served as vector tiles by
**OpenFreeMap** *https://openfreemap.org/*, used under the
**ODbL** *https://www.openstreetmap.org/copyright*.
**OpenFreeMap** *https://openfreemap.org/* on the **© OpenMapTiles** schema
*https://openmaptiles.org/*, used under the **ODbL**
*https://www.openstreetmap.org/copyright*. That last address is where the map's
own corner credit points — nothing on a console is clickable, so the link is
spelled out here instead.
---
+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
+40 -2
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Typer entry point.
Global options are parsed once in the callback, which builds the shared
@@ -17,6 +18,7 @@ import typer
from rich.console import Console
from typer.core import TyperGroup
from . import __version__
from .context import AppContext
from .core import exitcodes, win32dll
from .core.admin_store import AdminStore
@@ -25,7 +27,7 @@ from .core.connection import DeviceCommandError, is_connection_lost
from .core.device_config import DeviceConfigError
from .core.device_store import DeviceStore
from .core.instancelock import InstanceBusy, hold_instance_lock
from .core.preferences import PreferenceError, Preferences
from .core.preferences import PREFERENCES_FILENAME, PreferenceError, Preferences
from .core.selection import DeviceSelectionError
from .persistence.logging import configure_logging, get_logger, level_from_name, log_path
from .persistence.repository import Repository
@@ -126,6 +128,21 @@ app = typer.Typer(
epilog=EXIT_STATUS_EPILOG,
)
def _print_version(value: bool) -> None:
"""Print the version and stop, the moment ``--version`` is seen.
Eager, so it answers before the callback opens a database, resolves a platform or looks
for a radio — the one question that is true of the program rather than of a run. Bare
``meshterm <version>``: the caller asked for a version, and a script reading it back has
one field to take. Exits ``0``, because knowing the version is a success.
"""
if not value:
return
script.console().print(f"meshterm {__version__}", highlight=False)
raise typer.Exit(exitcodes.OK)
# The context built by the callback and consumed by subcommands within one process.
_state: AppContext | None = None
@@ -137,6 +154,13 @@ _platform_resolution: Resolution | None = None
@app.callback(invoke_without_command=True)
def main_callback(
ctx: typer.Context,
version: bool = typer.Option(
False,
"--version",
help="Print the version and exit",
callback=_print_version,
is_eager=True,
),
profile: str | None = typer.Option(None, "--profile", "-p", help="Device profile"),
port: str | None = typer.Option(None, "--port", help="Serial port override"),
ble: str | None = typer.Option(
@@ -167,6 +191,7 @@ def main_callback(
Args:
ctx: The Click/Typer context.
version: Print the version and exit (handled eagerly by :func:`_print_version`).
profile: Named device profile to use.
port: Explicit serial port, overriding the profile.
ble: Explicit Bluetooth address, selecting the BLE transport.
@@ -216,7 +241,7 @@ def main_callback(
console = script.console() if scripted else make_console()
# Loaded before logging is configured, because how much goes in the file is one of
# them — and handed to the context afterwards so the file is not read twice.
prefs = Preferences.load(settings.config_dir / "preferences.yaml")
prefs = Preferences.load(settings.config_dir / PREFERENCES_FILENAME)
if absolute:
# An override of the preference rather than a second switch beside it: *how
# MeshTerm behaves* is a preference by this project's own rule, and a flag that
@@ -284,6 +309,17 @@ def main_callback(
if _offer_a_console_that_can_draw_meshterm(console, prefs):
return
from .services import consolefont as vt_console_font
# The PicoCalc's whole console shares one font, so asking for the 6x8 build changes
# what the *shell* is drawn in too. Save the reader's font before the first paint
# and put it back on the way out — from the `finally` here, and from an `atexit`
# remember() arms, since ^Q and an unhandled error leave by other doors. This is
# the menu's boundary only: a scripted run prints into a console it was invited
# into and never touches its font.
saved_font = vt_console_font.remember()
vt_console_font.apply(prefs.get("console_font"))
# The interactive session is the one that holds the stores in memory and rewrites
# them whole, so it is the one that claims the directory. One-shot subcommands are
# brief and mostly read; blocking `meshterm contacts` because a menu is open in
@@ -309,6 +345,8 @@ def main_callback(
get_logger().exception("the interactive session raised an unhandled error")
_report_log_location(console, settings.config_dir)
raise
finally:
vt_console_font.restore(saved_font)
def _offer_a_console_that_can_draw_meshterm(console: Console, prefs: Preferences) -> bool:
+4 -3
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The application context: a small dependency container passed to every tool.
``AppContext`` owns the shared, expensive singletons (console, settings, repository,
@@ -22,7 +23,7 @@ from .core.courier_store import CourierStore
from .core.device_store import DeviceStore
from .core.discovery import DiscoveredDevice, discover_devices
from .core.mute_store import MuteStore
from .core.preferences import Preferences
from .core.preferences import PREFERENCES_FILENAME, Preferences
from .core.preferences import install as install_preferences
from .core.remote_store import RemoteStore
from .core.selection import resolve_device
@@ -55,7 +56,7 @@ class AppContext:
talk to (config directory, database, named profiles). *How MeshTerm behaves*
is ``preferences``, not this.
preferences: MeshTerm's own preferences, backed by
``<config_dir>/preferences.yaml`` and edited on the Preferences page. Defaults
``<config_dir>/preferences.toml`` and edited on the Preferences page. Defaults
to a set loaded from that file when not injected, and is installed as the
process-wide :func:`~meshterm.core.preferences.current` set so the render
layer — which has no context to reach through — reads the same values.
@@ -168,7 +169,7 @@ class AppContext:
stores built without a context still have to read them.
"""
if self.preferences is None:
self.preferences = Preferences.load(self.settings.config_dir / "preferences.yaml")
self.preferences = Preferences.load(self.settings.config_dir / PREFERENCES_FILENAME)
install_preferences(self.preferences)
if self.advert_store is None:
self.advert_store = AdvertStore(self.settings.config_dir / "adverts.json")
+1
View File
@@ -1 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
"""Core domain layer: models, configuration, and the device connection abstraction."""
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for remembered remote-node admin passwords.
The remote-admin optimizer logs in to a repeater to tune its TX power; storing the
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for the background-advert schedule.
MeshTerm announces the connected node on a cadence — a zero-hop (direct) advert for the
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""THE way MeshTerm replaces a file it owns: all of the new contents, or none of them.
Every store under ``core/`` keeps its state in one file that it rewrites whole. Writing
+166
View File
@@ -0,0 +1,166 @@
# SPDX-License-Identifier: Apache-2.0
"""Reading the device's channel slots back off the radio.
One configured channel slot (:class:`ChannelSlot`) and the two reads that discover them.
Every derived property on the slot delegates to :mod:`meshterm.core.channels`, which owns the
key/identity maths; this module is only the device-facing half — the scan, and what it means
when the scan stops.
It lives in :mod:`meshterm.core` rather than beside the channel manager screen because the
screen is not the only reader: the device-state session cache
(:class:`~meshterm.services.device_state.DeviceState`) warms the probe, the ``channels`` and
``chat`` tools read slots for their CLI faces, and a service must never have to reach up into
``ui/`` for a device read. It is a separate module from :mod:`meshterm.core.channels` because
it needs :class:`~meshterm.core.connection.Device`, and ``connection`` already imports
``channels`` — keeping the maths free of the transport is what keeps that direction one-way.
"""
from __future__ import annotations
from dataclasses import dataclass
from .channels import (
CHANNEL_SLOT_EMPTY_RUN,
CHANNEL_SLOT_PROBE_CAP,
channel_hash,
channel_identity,
full_channel_hash,
is_name_derived,
is_public_channel,
)
from .connection import Device, DeviceCommandError, is_connection_lost
from .models import Conversation
@dataclass(slots=True)
class ChannelSlot:
"""One configured channel slot read back from the device.
Attributes:
idx: The 0-based slot index.
name: The channel's name.
secret: The channel's 16-byte shared secret.
"""
idx: int
name: str
secret: bytes
@property
def is_name_derived(self) -> bool:
"""Whether this channel's key is reproducible from its name (so it needn't be stored)."""
return is_name_derived(self.name, self.secret)
@property
def is_public(self) -> bool:
"""Whether this channel is public (shared meshwide) rather than a private one.
Covers both name-derived channels and the firmware's fixed-key default ``Public``.
"""
return is_public_channel(self.name, self.secret)
@property
def hash(self) -> str:
"""The channel's two-character hash fingerprint (the leading byte MeshCore shows)."""
return channel_hash(self.secret)
@property
def full_hash(self) -> str:
"""The channel's complete ``sha256(secret)`` digest; its first byte is :attr:`hash`."""
return full_channel_hash(self.secret)
@property
def identity(self) -> str:
"""The channel's slot-independent identity, used to key its chat history."""
return channel_identity(self.name, self.secret)
@property
def conversation(self) -> Conversation:
"""A :class:`~meshterm.core.models.Conversation` for opening this channel in chat."""
return Conversation(
label=self.name,
is_channel=True,
channel_idx=self.idx,
channel_id=self.identity,
secret=self.secret,
)
async def read_channel_slots(device: Device) -> list[ChannelSlot]:
"""Probe the channel slots and return the configured ones, in index order.
The scan stops as soon as the firmware rejects a slot index, so it reads exactly the
slots a well-behaved device has regardless of its capacity. Firmware that never rejects
an out-of-range index (it answers every slot with an empty payload instead of raising)
would otherwise walk all :data:`CHANNEL_SLOT_PROBE_CAP` slots on every read; a run of
:data:`CHANNEL_SLOT_EMPTY_RUN` consecutive empty slots ends the scan on such firmware.
That is safe because the manager packs channels from slot 0 up, so an unbroken empty run
that long means every configured channel has already been seen (see the constant's note).
Args:
device: The connected device to query.
Returns:
One :class:`ChannelSlot` per configured slot (an empty slot is skipped).
"""
slots, _complete = await probe_channel_slots(device)
return slots
async def probe_channel_slots(device: Device) -> tuple[list[ChannelSlot], bool]:
"""The probe behind :func:`read_channel_slots`, reporting whether it *finished*.
The scan stops for two very different reasons and the plain list cannot tell them
apart. Off the end of the configured slots (a rejected index, or a long enough run of
empty ones) the answer is complete and worth keeping. A read that simply *failed* —
a timeout, a link hiccup, and then every subsequent read failing too because the
firmware's reply no longer matches the slot asked for — ends the scan early with
whatever it happened to have, which is not the device's layout and must not be cached
or acted on as if it were: an empty one reads as "no channels configured", and a
truncated one makes the next free slot look free when a channel is sitting in it.
The two endings raise different things, and that is what separates them: the firmware
*answering* "no such slot" surfaces as a plain rejection, while a link that stopped
answering surfaces as a timeout, a :class:`~meshterm.core.connection.DeviceCommandError`
(whose whole subject is a companion that did not reply in time), or one of the dropped-
link signatures :func:`~meshterm.core.connection.is_connection_lost` knows. Only the
first ending is a layout. A read that fails for some fourth reason is treated as a
rejection, which is the safe way round: the list is used but the cache re-probes.
Args:
device: The connected device to query.
Returns:
The slots read, and whether the scan ran to a clean end.
"""
slots: list[ChannelSlot] = []
empty_run = 0
for idx in range(CHANNEL_SLOT_PROBE_CAP):
try:
payload = await device.get_channel(idx)
except Exception as exc: # noqa: BLE001 - a rejected index, or a read that failed
return slots, not _read_failed(exc)
if payload and payload.get("channel_name"):
empty_run = 0
slots.append(
ChannelSlot(
idx=idx,
name=str(payload["channel_name"]),
secret=bytes(payload.get("channel_secret") or b"\x00" * 16),
)
)
else:
empty_run += 1
if empty_run >= CHANNEL_SLOT_EMPTY_RUN:
break # off the end of a never-rejecting firmware; nothing more to find
return slots, True
def _read_failed(exc: BaseException) -> bool:
"""Whether ``exc`` means the *read* failed, rather than the firmware refusing a slot.
A refusal is the probe's ordinary ending and leaves a complete list behind it. A
failure leaves a short one that looks exactly the same — which is the whole reason
this distinction has to be drawn somewhere (see :func:`probe_channel_slots`).
"""
return isinstance(exc, (TimeoutError, DeviceCommandError)) or is_connection_lost(exc)
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for channels created through MeshTerm, so they survive a device that forgets.
Most companions keep their own channel table in firmware, so MeshTerm reads channels live from
+9 -1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Pure channel logic: secret derivation, MeshCore share-URL round-tripping, and decrypt.
A MeshCore channel is a name plus a 16-byte shared secret. *Public* channels (whose name
@@ -35,6 +36,13 @@ from urllib.parse import parse_qs, quote, urlsplit
#: be followed by ``": "`` — conservative enough to leave ``http://…`` and ``note:x`` alone.
SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,19}):[ \t]+(.*)$", re.DOTALL)
#: Matches an ``@[Name]`` mention token, as the reply flow primes into the compose line. A
#: transcript renders each as a bare ``@Name`` coloured in that sender's hue instead of
#: showing the literal brackets, and the CLI's history face lifts the same names out — so the
#: token lives here with the rest of the message-body parsing rather than in either renderer.
#: Name is 1–20 non-``]`` characters.
MENTION = re.compile(r"@\[([^\]]{1,20})\]")
def split_channel_sender(text: str) -> tuple[str | None, str]:
"""Split a channel message into ``(sender_name, body)`` when it carries a name prefix.
@@ -74,7 +82,7 @@ MAX_CHANNELS = 8
CHANNEL_SLOT_PROBE_CAP = 64
#: A run of this many consecutive *empty* slots ends a *configured-channel* scan (see
#: :func:`meshterm.ui.channels.read_channel_slots`) on firmware that never rejects an
#: :func:`meshterm.core.channel_probe.read_channel_slots`) on firmware that never rejects an
#: out-of-range index — some builds answer every index with an empty payload instead of
#: raising, so the scan would otherwise walk all :data:`CHANNEL_SLOT_PROBE_CAP` slots on
#: every read. The channel manager packs channels from slot 0 up within the stock
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Machine setup and named device profiles.
Settings are read from a TOML file (default ``~/.meshterm/config.toml``) and may
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""TOML backup and restore for device configuration.
A backup captures every registry setting's current value plus experimental custom
+4 -8
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Device connection abstraction.
Services and tools depend only on the :class:`Device` interface, never on the
@@ -41,6 +42,7 @@ from .models import (
advert_time,
utcnow,
)
from .tracing import path_hash_flags, trace_timeout
if TYPE_CHECKING:
from .discovery import DiscoveredDevice
@@ -704,7 +706,7 @@ class Device(ABC):
timeout: Seconds to wait for the trace reply. ``None`` (the default) sizes the
wait to the route: a trace has to travel the whole path out and back, so a
long walk is given proportionally longer to come home
(:func:`~meshterm.services.trace_runner.trace_timeout`).
(:func:`~meshterm.core.tracing.trace_timeout`).
Returns:
A :class:`TraceResult`; ``success`` is ``False`` on timeout.
@@ -1924,8 +1926,6 @@ class MeshCoreDevice(Device):
async def admin_login(self, node: Contact, password: str) -> LoginResult: # noqa: D102
from meshcore import EventType
from ..services.trace_runner import trace_timeout
mc = self._require()
pub = self._node_pubkey(node)
loop = asyncio.get_running_loop()
@@ -2159,11 +2159,9 @@ class MeshCoreDevice(Device):
transmit_gate.mark()
from meshcore import EventType # local import keeps mock path dependency-free
from ..services.trace_runner import path_hash_flags, trace_timeout
mc = self._require()
tag = random.randint(0, 0xFFFFFFFF)
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
started = loop.time()
# A trace packet has no destination field — it walks an explicit path of
@@ -2286,8 +2284,6 @@ class MeshCoreDevice(Device):
``(path_bytes, flags)`` to walk, or ``None`` only when the contact is
unknown or carries no public key to address.
"""
from ..services.trace_runner import path_hash_flags
payload = await self._contacts_payload(mc)
needle = target.casefold()
for name, info in payload.items():
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
r"""Installing and selecting a console font on Windows, for one user, without admin.
The classic Windows console does no font fallback: a character its configured font lacks
+3 -2
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Rating contacts by how much they are worth a slot in the device's contact table.
A companion's contact table is finite, and a mesh fills it with whatever adverts happen to
@@ -529,7 +530,7 @@ def rank_contacts(
gathered: list[ContactSignals] = []
for contact in contacts:
node = _node_id(contact)
node = node_id(contact)
found = signals.get(node) or ContactSignals(node=node)
gathered.append(replace(found, distance_km=_self_distance(contact, self_lat, self_lon)))
@@ -607,7 +608,7 @@ def sweep_candidates(ranked: Sequence[ScoredContact], keep: int) -> list[ScoredC
return list(reversed(victims))
def _node_id(contact: Contact) -> str:
def node_id(contact: Contact) -> str:
"""The 12-hex canonical id a contact's history is keyed by (see ``observations.node``)."""
ident = contact.public_key or contact.key_prefix or ""
return ident.lower().removeprefix("0x")[:12]
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for contacts heard through MeshTerm, so they survive a device that forgets them.
Most companions keep their contact table in firmware, so MeshTerm reads contacts live from the
+3 -1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for the Courier: the store-and-forward outbox.
The Courier (see :mod:`meshterm.services.courier`) queues direct messages for contacts
@@ -28,7 +29,8 @@ DELIVERED = "delivered"
GAVE_UP = "gave-up"
#: How many finished (delivered / given-up) entries are kept for the screen's history —
#: the code's default behind the ``courier_history_kept`` preference.
#: *the* default behind the ``courier_history_kept`` preference, which the registry names
#: rather than re-types (:data:`meshterm.core.preferences.PREFERENCES`).
DONE_CAP = 100
+10
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Declarative registry of every settable device configuration value.
Each :class:`SettingSpec` describes one tunable setting: how to read its current value
@@ -53,6 +54,9 @@ class SettingSpec:
the connected hardware actually supports.
max_length: For ``str`` values, the longest accepted string (protocol field
widths, e.g. the flood scope's 31-byte name slot).
decimals: For ``float`` values, the fixed decimal places a value is shown with and
rounded to on parse, so what is applied is what the row showed; ``None`` keeps
the value as given.
validate: A last rule for a typed, in-range value, given the snapshot when there
is one: it returns the complaint, or ``None``. For what a bound can't state —
the PIN's "zero or six digits", relaying only on an allowed frequency.
@@ -73,6 +77,7 @@ class SettingSpec:
strict_choices: bool = True
max_key: str | None = None
max_length: int | None = None
decimals: int | None = None
validate: Callable[[Any, dict | None], str | None] | None = None
@@ -137,6 +142,8 @@ def _parse_typed(spec: SettingSpec, raw: Any, snapshot: dict | None) -> Any:
try:
if spec.value_type == "float":
value: Any = float(text)
if spec.decimals is not None:
value = round(value, spec.decimals)
else: # int or enum
value = int(text, 0) if isinstance(raw, str) else int(raw)
except (ValueError, TypeError) as exc:
@@ -199,6 +206,8 @@ def format_value(spec: SettingSpec, value: Any) -> str:
return "true" if value else "false"
if spec.value_type == "enum" and spec.choices is not None and value in spec.choices:
return f"{value} ({spec.choices[value]})"
if spec.value_type == "float" and spec.decimals is not None:
return f"{float(value):.{spec.decimals}f}"
return str(value)
@@ -694,6 +703,7 @@ DEVICE_SETTINGS: list[SettingSpec] = [
"float",
minimum=0.0,
maximum=20.0,
decimals=1,
getter=_get("rx_delay"),
apply=_tuning_apply("rx_delay"),
),
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for confirmed companion devices.
Every device that has ever spoken the MeshCore protocol to us successfully is remembered
+9
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Companion-device discovery across transports (serial + Bluetooth LE + TCP).
Discovery enumerates the companion kinds that can be *found* automatically:
@@ -35,6 +36,10 @@ _log = logging.getLogger(__name__)
TRANSPORT_SERIAL = "serial"
TRANSPORT_BLE = "ble"
TRANSPORT_TCP = "tcp"
#: The built-in simulator, as a device inventory reports it under ``--mock``. Never
#: discovered — there is nothing to find — but a session that promised no real hardware
#: still has exactly one device, and this is what it is called.
TRANSPORT_MOCK = "mock"
#: Default TCP port assumed when a network companion is named as a bare host with no port.
#: 5000 is the conventional MeshCore companion-over-TCP / ``meshcored`` listen port.
@@ -144,6 +149,8 @@ class DiscoveredDevice:
"""
if self.is_tcp:
return f"{self.host}:{self.tcp_port}"
if self.transport == TRANSPORT_MOCK:
return "--mock" # the flag is the target: it is how this device is selected
return self.address or self.port if self.is_ble else self.port
@property
@@ -159,6 +166,8 @@ class DiscoveredDevice:
"""
if self.is_tcp:
return f"tcp:{self.host}:{self.tcp_port}"
if self.transport == TRANSPORT_MOCK:
return "mock:simulator"
if self.is_ble:
return f"ble:{(self.address or self.name or '').lower()}"
if self.serial_number:
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Typed events carried by the always-on mesh event hub.
These are the domain-level events that flow from the connected companion device out to
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The exit statuses ``meshterm`` returns, and what each one means.
A scripted run's *return value is its report*: the CLI says what happened in plain
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""What an overheard frame is *addressed to* — the packet body nobody else parses.
The companion's RX packet log hands every overheard frame to the meshcore library, which
+7 -2
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Pure geographic helpers for the terminal map: Web Mercator, tiles, and viewports.
This module is deliberately I/O-free and rendering-free so it can be unit-tested without a
@@ -33,7 +34,9 @@ TILE_PX = 256
#: :mod:`meshterm.ui.map_screen` for it dragged the whole map stack (and, through the
#: basemap's tile fetcher, ``urllib.request`` → ``http.client`` → ``ssl``) into every run,
#: including runs that never open a map. This module is pure arithmetic and already on the
#: boot path, so the constant is free here.
#: boot path, so the constant is free here. It is also what the ``map_view_fraction``
#: preference defaults to — the registry names this constant rather than re-typing the
#: number, so the code default and the preference default are one value.
DEFAULT_VIEW_FRACTION = 0.5
@@ -87,7 +90,9 @@ def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a))
# Clamped because rounding can push ``a`` a hair past 1 for near-antipodal points, and
# ``asin`` of that raises a domain error rather than returning half the planet.
return 2 * EARTH_RADIUS_KM * math.asin(min(1.0, math.sqrt(a)))
def _central_points(
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""One interactive MeshTerm per data directory, and a clear answer when you want two.
Every copy of MeshTerm on a machine reads and writes the same ``~/.meshterm`` — a
+49
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Domain models shared across services, tools, persistence, and visualizations.
These are plain dataclasses with no I/O dependencies so they can be constructed by the
@@ -8,10 +9,25 @@ from __future__ import annotations
import statistics
from collections import Counter
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
#: Maps a hop's raw key-prefix hash to a display label (a contact name when known, or the
#: hash itself when not). ``None`` passes through, being our own device. THE alias for the
#: name side of a hop, shared by everything that builds one
#: (:func:`~meshterm.services.trace_runner.make_node_resolver`) and everything that takes
#: one (every screen that draws a route).
NodeResolver = Callable[[str | None], str | None]
#: Maps a display name back to the node's key — as full as we hold one: a public key, a key
#: prefix, or a stored node id — or ``None`` for a name no known node carries. The colour
#: side of :data:`NodeResolver`: where that turns hex into names, this turns a bare name back
#: into the key its palette hue derives from
#: (:func:`~meshterm.services.trace_runner.make_name_key_resolver`).
NameKeyResolver = Callable[[str], str | None]
#: Label used for our own (local) device when framing a trace path's endpoints.
LOCAL_DEVICE_LABEL = "us"
@@ -177,6 +193,39 @@ class Contact:
return self.node_type == NODE_TYPE_REPEATER
@dataclass(slots=True)
class MapMarker:
"""One mesh node to overlay on the map.
Data, not drawing: a located node with the little the map needs to place and label it.
It lives here rather than beside the renderer because it is assembled well below the UI
— :func:`~meshterm.services.markers.gather_markers` builds the list from contacts and
observations, and three separate surfaces draw it.
Attributes:
label: Node name shown beside the marker.
lat: Latitude in decimal degrees.
lon: Longitude in decimal degrees.
is_repeater: Whether the node is a repeater (prioritised marker).
is_self: Whether this is our own node (highlighted).
detail: Extra text for the CLI legend (e.g. ``"18 pkts · +6.0 dB"``).
key: The node's key hex (as full as the caller holds), seeding the label's
key-derived hue; ``None`` leaves the label the muted no-key grey.
"""
label: str
lat: float
lon: float
is_repeater: bool = False
is_self: bool = False
detail: str = ""
key: str | None = None
def _rank(self) -> int:
"""Draw order: self on top of repeaters on top of leaf nodes."""
return 2 if self.is_self else (1 if self.is_repeater else 0)
@dataclass(slots=True)
class NeighbourInfo:
"""One entry of a remote repeater's neighbour table, fetched over the mesh.
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for muted channel notifications.
Some channels carry traffic you want *recorded* but not *notified* about — an automated
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""A tiny, dependency-free Mapbox Vector Tile (MVT) decoder.
Vector tiles are Protocol-Buffer messages (spec: github.com/mapbox/vector-tile-spec). Rather
+132 -38
View File
@@ -1,4 +1,5 @@
"""Declarative registry of MeshTerm's own preferences, and the YAML file they live in.
# SPDX-License-Identifier: Apache-2.0
"""Declarative registry of MeshTerm's own preferences, and the TOML file they live in.
The distinction this module draws is between the three kinds of "setting" the app already
had and never named apart:
@@ -22,9 +23,11 @@ away from that default, so the file is a short list of your disagreements with t
built-in behaviour rather than a snapshot that silently pins every value forever. Delete a
key (or use *Reset to defaults*) and the code's default takes over again.
The file is ``<config_dir>/preferences.yaml``, written grouped and commented so it reads
the way the page does. Reads are tolerant: an unknown key, a malformed value, or an
unparseable file costs the override, never the session.
The file is ``<config_dir>/preferences.toml``, written flat, grouped and commented so it
reads the way the page does — TOML, like ``config.toml`` and the device-config backups,
so every file a person edits by hand speaks one syntax. Reads are tolerant: an unknown
key, a malformed value, or a line TOML refuses costs that one override, never the rest of
the file and never the session.
Two access paths, deliberately:
@@ -37,13 +40,21 @@ Two access paths, deliberately:
from __future__ import annotations
import sys
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .atomicwrite import write_atomically
from .watch_store import DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
from .courier_store import DONE_CAP
from .geo import DEFAULT_VIEW_FRACTION
from .watch_store import ALERT_CAP, DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
if sys.version_info >= (3, 11):
import tomllib
else: # pragma: no cover - exercised only on 3.10
import tomli as tomllib
#: Display groups, in the order the page and the file present them. The order is the
#: order a session happens in — what MeshTerm puts on the air, how loud, what it watches
@@ -75,7 +86,7 @@ class PrefSpec:
Attributes:
key: Canonical key — the attribute name on :class:`Preferences`, and the key in
the YAML file.
the TOML file.
label: Human-friendly name, as the page's SETTING lane shows it.
help: One-line description, as the page's DESCRIPTION lane shows it.
group: One of :data:`GROUPS`.
@@ -89,6 +100,14 @@ class PrefSpec:
relaunch: Whether the new value only takes hold next launch. Woven into the
description rather than shown as its own mark — it is a caveat about one
preference, not a status the reader scans a column for.
platforms: Which :class:`~meshterm.platforms.Platform` names the preference is
*offered* on, or ``None`` (the default) for all of them. A preference about
hardware only one flavour has — the PicoCalc's console font — would be a row
answering a question the desktop cannot ask, so the page leaves it out there.
Only the page: the key stays in the registry on every platform, so a file
written on the handheld still parses, keeps its value and saves back out on a
desktop rather than being silently dropped by a machine that merely cannot
act on it.
"""
key: str
@@ -102,12 +121,24 @@ class PrefSpec:
maximum: float | None = None
unit: str = ""
relaunch: bool = False
platforms: frozenset[str] | None = None
@property
def description(self) -> str:
"""The DESCRIPTION lane's text: the help, plus the relaunch caveat where it applies."""
return f"{self.help} (next launch)" if self.relaunch else self.help
def offered_on(self, platform: str) -> bool:
"""Whether the editor draws a row for this preference on ``platform``.
Args:
platform: A :attr:`~meshterm.platforms.Platform.name`.
Returns:
``True`` unless :attr:`platforms` names a set this platform is not in.
"""
return self.platforms is None or platform in self.platforms
#: The two languages the plain CLI can print a time in. Named for what the reader sees
#: rather than for the mechanism: an *age* is how long ago, a *timestamp* is when.
@@ -140,12 +171,6 @@ _COLOR_DEPTH_CHOICES: dict[str, str] = {
"16": "16",
}
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
#: Preferences page, a key in the YAML file, a ``preferences get``/``set`` CLI face, and a
#: default — nothing else has to follow.
#: What the log file keeps. The plain level names, which are what every other tool calls
#: these — someone being talked through a problem is being told "set it to debug", not
#: "set it to everything".
#: What MeshTerm may offer when it lands in a console that cannot draw it. Only ever
#: consulted on the classic Windows console, the one host with no font fallback of its own.
_CONSOLE_SETUP_CHOICES: dict[str, str] = {
@@ -153,6 +178,19 @@ _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.
#: Plain ASCII ``x`` and not ``×``: the multiplication sign is not one of the 512 glyphs
#: the console font carries, so it would draw as a tofu box on the one platform this
#: preference is offered on. The value maps to a file in
#: :data:`meshterm.services.consolefont.FONT_FILES`, which is where a filename belongs —
#: a path on one device is not something a reader picks.
_CONSOLE_FONT_CHOICES: dict[str, str] = {"6x12": "6x12 (53x26)", "6x8": "6x8 (53x40)"}
#: What the log file keeps. The plain level names, which are what every other tool calls
#: these — someone being talked through a problem is being told "set it to debug", not
#: "set it to everything".
_LOG_LEVEL_CHOICES: dict[str, str] = {
"ERROR": "Error",
"WARNING": "Warning",
@@ -161,6 +199,11 @@ _LOG_LEVEL_CHOICES: dict[str, str] = {
}
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
#: Preferences page, a key in the TOML file, a ``preferences get``/``set`` CLI face, and a
#: default — nothing else has to follow. A default that a module already states as its
#: code-level behaviour is named, never re-typed: one source, so changing it cannot leave
#: the constant and the registry disagreeing about what MeshTerm does.
PREFERENCES: tuple[PrefSpec, ...] = (
# --- Sending -----------------------------------------------------------------
PrefSpec(
@@ -245,7 +288,7 @@ PREFERENCES: tuple[PrefSpec, ...] = (
help="How many past alerts to keep",
group="Watchtower",
value_type="int",
default=200,
default=ALERT_CAP,
minimum=10,
maximum=5000,
),
@@ -256,7 +299,7 @@ PREFERENCES: tuple[PrefSpec, ...] = (
help="Share of nodes to fit on screen; 1 shows them all",
group="Map",
value_type="float",
default=0.5,
default=DEFAULT_VIEW_FRACTION,
minimum=0.1,
maximum=1.0,
),
@@ -297,7 +340,7 @@ PREFERENCES: tuple[PrefSpec, ...] = (
help="Finished outbox messages to keep",
group="History",
value_type="int",
default=100,
default=DONE_CAP,
minimum=10,
maximum=5000,
),
@@ -361,6 +404,20 @@ PREFERENCES: tuple[PrefSpec, ...] = (
default="auto",
choices=_CONSOLE_SETUP_CHOICES,
),
PrefSpec(
key="console_font",
label="Console font",
help="Bigger text, or more rows on the handheld's panel",
group="Display",
value_type="enum",
# The 6x12 Terminus derivative, because it is the font the device boots with and
# the one every screen's row budget is designed to (Platform.readable_rows is 26).
# The 6x8 build is the same glyph inventory in a shorter cell — fourteen more rows
# for anyone who would rather see more of a list than read it comfortably.
default="6x12",
choices=_CONSOLE_FONT_CHOICES,
platforms=frozenset({"picocalc"}),
),
# --- Diagnostics ---------------------------------------------------------------
PrefSpec(
key="log_level",
@@ -398,13 +455,21 @@ def get_spec(key: str) -> PrefSpec:
raise PreferenceError(f"unknown preference: {key!r}") from None
def by_group() -> list[tuple[str, list[PrefSpec]]]:
def by_group(platform: str | None = None) -> list[tuple[str, list[PrefSpec]]]:
"""Every preference grouped for display, in :data:`GROUPS` order.
Args:
platform: A :attr:`~meshterm.platforms.Platform.name` to draw the page for, which
drops the preferences that platform is not offered (see
:meth:`PrefSpec.offered_on`). ``None`` — the default, and what the file writer
uses — keeps every preference, so an override made on one flavour survives
being loaded and saved on another.
Returns:
``(group, specs)`` pairs; a group holding no preferences is omitted.
"""
grouped = [(g, [s for s in PREFERENCES if s.group == g]) for g in GROUPS]
kept = [s for s in PREFERENCES if platform is None or s.offered_on(platform)]
grouped = [(g, [s for s in kept if s.group == g]) for g in GROUPS]
return [(g, specs) for g, specs in grouped if specs]
@@ -444,15 +509,15 @@ def parse_value(spec: PrefSpec, raw: Any) -> Any:
if raw in choices and not isinstance(raw, bool):
return raw
if isinstance(raw, bool):
# YAML 1.1 reads a bare ``yes``/``no`` (and ``on``/``off``) as a *boolean*, so a
# hand-edited file hands us ``True`` where the choice is spelled "yes". We quote
# ours on the way out, but someone typing the obvious thing should still be
# understood: map the boolean back to whichever spelling this spec offers.
# A hand edit that answers a yes/no choice with a TOML boolean
# (``full_width = true``) hands us ``True`` where the choice is spelled "yes".
# We quote ours on the way out, but someone typing the obvious thing should
# still be understood: map the boolean back to whichever spelling this offers.
for choice in choices:
if str(choice).lower() in (_TRUE if raw else _FALSE):
return choice
# Text arriving from the CLI or a hand-edited file: match a choice by its own
# spelling, so `preferences set watch_silence_hours 6` and a YAML `6` land alike.
# spelling, so `preferences set watch_silence_hours 6` and a TOML `6` land alike.
for choice in choices:
if text.lower() == str(choice).lower():
return choice
@@ -508,6 +573,9 @@ def range_hint(spec: PrefSpec) -> str:
# --- the store ----------------------------------------------------------------
#: The file's name inside the config directory — one spelling for every place that opens it.
PREFERENCES_FILENAME = "preferences.toml"
#: The file's opening comment. It says the one thing a reader hand-editing the file needs
#: to know — that absence means "use the default", so deleting a line is how a preference
#: is undone — and points at the two neighbouring kinds of setting so nobody looks for a
@@ -520,11 +588,36 @@ _HEADER = """\
# Preferences page) hands the value back to the code. `meshterm preferences show` prints
# every preference, what it is set to, and what it defaults to.
#
# One `key = value` per line: text in quotes ("DEBUG"), numbers and true/false bare.
#
# Device profiles, the database location, and the rest of the machine setup stay in
# config.toml; the radio's own settings live on the radio, under Device config.
"""
def _salvage(text: str) -> dict[str, Any]:
"""Read a file TOML refused as a document, one ``key = value`` line at a time.
MeshTerm writes the file flat — no tables, one entry to a line — so a line is a whole
entry and can be judged on its own. A line TOML accepts keeps its typed value. One it
refuses keeps its right-hand side as text, which is exactly what :func:`parse_value`
takes from the command line, so the likeliest hand edit of all — a word left unquoted,
``log_level = DEBUG`` — is still understood. Whatever the spec then refuses is dropped
by :meth:`Preferences.update`, the same as any other bad value.
"""
data: dict[str, Any] = {}
for line in text.splitlines():
entry = line.strip()
if not entry or entry.startswith(("#", "[")) or "=" not in entry:
continue
try:
data.update(tomllib.loads(entry))
except tomllib.TOMLDecodeError:
key, _, value = entry.partition("=")
data[key.strip()] = value.split("#", 1)[0].strip()
return data
class Preferences:
"""The effective preferences: the built-in defaults, with the file's overrides on top.
@@ -554,21 +647,27 @@ class Preferences:
@classmethod
def load(cls, path: Path) -> Preferences:
"""Read ``path``, returning all-defaults for a missing, empty, or corrupt file.
"""Read ``path``, returning all-defaults for a missing, empty, or unreadable file.
TOML refuses a whole document over one bad line, and a hand edit should cost the
line it is on, not the file: a document TOML refuses is read again a line at a
time (:func:`_salvage`), keeping every entry that still makes sense.
Args:
path: The YAML file to read.
path: The TOML file to read.
Returns:
The loaded preferences, bound to ``path`` for a later :meth:`save`.
"""
import yaml
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, ValueError, yaml.YAMLError):
text = path.read_text(encoding="utf-8")
except (OSError, ValueError):
return cls(path)
return cls(path, data if isinstance(data, Mapping) else None)
try:
data: dict[str, Any] = tomllib.loads(text)
except tomllib.TOMLDecodeError:
data = _salvage(text)
return cls(path, data)
@property
def path(self) -> Path | None:
@@ -656,14 +755,14 @@ class Preferences:
self._values.clear()
return count
def as_yaml(self) -> str:
def as_toml(self) -> str:
"""Render the current overrides as the file's text: header, then grouped entries.
Each group holding an override gets its own comment heading, and each entry its
help line and its default above it, so the file reads the way the page does
rather than as a bare map someone has to look up elsewhere.
"""
import yaml
import tomli_w
lines = [_HEADER]
for group, specs in by_group():
@@ -672,12 +771,7 @@ class Preferences:
continue
lines.append(f"# --- {group} ---")
for spec in present:
entry = yaml.safe_dump(
{spec.key: self._values[spec.key]},
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
).rstrip("\n")
entry = tomli_w.dumps({spec.key: self._values[spec.key]}).rstrip("\n")
lines.append(f"# {spec.help}")
lines.append(f"# default: {format_value(spec, spec.default)}")
lines.append(entry)
@@ -695,7 +789,7 @@ class Preferences:
"""
if self._path is None:
raise RuntimeError("these preferences have no file to save to")
write_atomically(self._path, self.as_yaml())
write_atomically(self._path, self.as_toml())
#: The set installed for this process. Starts as a defaults-only, file-less instance so
+5 -4
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The remote-node settings catalog: what a repeater can be asked and told over the mesh.
Repeaters and room servers are administered through their text CLI — ``get``/``set``
@@ -328,7 +329,7 @@ REPEATER_SETTINGS: tuple[RemoteSetting, ...] = (
label="TX delay",
category="Repeating",
kind="float",
decimals=2,
decimals=1,
minimum=0,
maximum=2,
help="Delay factor before rebroadcasting a flood packet",
@@ -338,7 +339,7 @@ REPEATER_SETTINGS: tuple[RemoteSetting, ...] = (
label="Direct TX delay",
category="Repeating",
kind="float",
decimals=2,
decimals=1,
minimum=0,
maximum=2,
help="Delay factor before forwarding a directed packet",
@@ -348,7 +349,7 @@ REPEATER_SETTINGS: tuple[RemoteSetting, ...] = (
label="RX delay",
category="Repeating",
kind="float",
decimals=2,
decimals=1,
minimum=0,
maximum=20,
help="Base delay before acting on a received packet",
@@ -748,7 +749,7 @@ def _format_number(spec: RemoteSetting, value: float) -> str:
def normalize_value(spec: RemoteSetting, raw: str) -> str:
"""A validated typed value in the form the cache stores and the firmware is sent.
TX delay entered as ``0.5`` becomes ``0.50``: rounded to the setting's decimals, and
TX delay entered as ``0.33`` becomes ``0.3``: rounded to the setting's decimals, and
spelled exactly as a read of the same value would be, so staging what the node already
holds unstages rather than queueing a no-op ``set``.
"""
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for remote-node admin state: last-read settings and CLI history.
Reading a repeater's configuration costs one paced mesh round trip per value, so the
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Non-interactive device selection: turn discovery + memory into a chosen port.
This is the logic used on the scripted CLI path (and as the fallback when the interactive
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for device settings changed through MeshTerm, so they survive a forgetful device.
Most companions keep their configuration in firmware, so MeshTerm reads settings live from the
+82
View File
@@ -0,0 +1,82 @@
# SPDX-License-Identifier: Apache-2.0
"""The arithmetic a trace is sized and flagged by.
Two pure functions and the four constants behind them: how a per-hop path-hash width is
encoded into the trace ``flags`` byte, and how long to wait for a trace's reply given the
route it has to walk. Both are protocol arithmetic with no device, no persistence and no
rendering, and :class:`~meshterm.core.connection.MeshCoreDevice` needs them on the send path —
they live here so ``core`` never has to reach up into ``services`` for them. The trace
*runner* (:mod:`meshterm.services.trace_runner`) re-exports both under their own names, which
is where every other caller still reads them from.
"""
from __future__ import annotations
def path_hash_flags(width_bytes: int) -> int | None:
"""Return the trace ``flags`` value that encodes a per-hop path-hash width.
The trace subsystem encodes the hash size as ``1 << (flags & 3)`` on both the
send and receive sides (``send_trace`` and the ``TRACE_DATA`` reader), so only
widths of 1, 2, 4, or 8 bytes are representable. This is independent of, and
differs from, the ``mode = size - 1`` encoding used for *contact routing*
(``out_path_hash_mode``).
Args:
width_bytes: Path-hash width in bytes.
Returns:
The flags value (the exponent ``s``), or ``None`` if the width is not a
representable power of two.
"""
for s in range(4):
if (1 << s) == width_bytes:
return s
return None
#: Fixed overhead in a trace's reply-wait budget, in seconds: our own transmit, the far
#: endpoint's turnaround, and our receive-side decode — the cost that doesn't grow with
#: the route.
TRACE_TIMEOUT_BASE_S = 4.0
#: Per-hop allowance added to the budget, in seconds. Each entry in the walked path is one
#: relay transmission, and this covers its packet airtime, the repeater's processing, and
#: the randomised transmit backoff MeshCore adds so relays don't collide.
TRACE_TIMEOUT_PER_HOP_S = 1.6
#: Upper bound on the reply-wait, in seconds, so a route that never comes home still
#: surrenders the trace (and the session) in bounded time rather than scaling without end.
TRACE_TIMEOUT_CEILING_S = 30.0
#: Fallback budget, in seconds, when the hop count can't be known ahead of the send — a
#: path-less flood to a contact we hold no route for. This is the historical flat value,
#: kept for exactly the case where we can't size the walk.
TRACE_TIMEOUT_FLOOD_S = 10.0
def trace_timeout(hop_count: int) -> float:
"""Return the reply-wait budget, in seconds, for a trace walking ``hop_count`` hops.
A trace is a *single* packet that has to travel the whole path — out to the far hop
and back over the mirrored return leg — before its reply reaches us, so the wait has
to grow with the route. A flat budget sized for a neighbour cuts a long walk off
mid-flight: the packet completes the circuit on the mesh, but our wait has already
expired, so we log a phantom "no reply" for a route that actually worked. The budget
is a fixed base (:data:`TRACE_TIMEOUT_BASE_S`) plus a per-hop allowance
(:data:`TRACE_TIMEOUT_PER_HOP_S`) for each relay transmission, capped at
:data:`TRACE_TIMEOUT_CEILING_S`.
``hop_count`` is the number of hops in the *transmitted* path, which already includes
the mirrored return leg (see
:meth:`~meshterm.core.connection.MeshCoreDevice._trace_path_to_contact`), so it maps
one-to-one onto relay transmissions and must not be doubled here.
Args:
hop_count: Hops in the walked path (return leg included). ``0`` (or less) means the
count is unknown — a path-less flood — and yields :data:`TRACE_TIMEOUT_FLOOD_S`.
Returns:
The number of seconds to wait for the trace reply.
"""
if hop_count <= 0:
return TRACE_TIMEOUT_FLOOD_S
budget = TRACE_TIMEOUT_BASE_S + TRACE_TIMEOUT_PER_HOP_S * hop_count
return min(budget, TRACE_TIMEOUT_CEILING_S)
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The shared transmit clock: how long ago we last put something on the air.
MeshTerm has always paced its own bursts — a trace waits between samples, a TX sweep
+4 -2
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for the Watchtower: watched nodes, their rules, and the alert log.
The Watchtower (see :mod:`meshterm.services.watchtower`) is a passive sentinel: the user
@@ -37,8 +38,9 @@ DEFAULT_SILENCE_HOURS = 12
#: The stored value meaning "this rule is off".
OFF = 0
#: How many alerts the log retains (oldest dropped first) — the code's default behind
#: the ``watch_alerts_kept`` preference.
#: How many alerts the log retains (oldest dropped first) — *the* default behind the
#: ``watch_alerts_kept`` preference, which the registry names rather than re-types
#: (:data:`meshterm.core.preferences.PREFERENCES`), so the two can never disagree.
ALERT_CAP = 200
#: Minimum seconds between disk flushes for the packet-driven last-heard updates.
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Private handles on the Windows DLLs, because ``ctypes.windll`` is shared with everyone.
``ctypes.windll.kernel32`` is a cache. Every caller in the process gets the *same*
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Reopening the session in Windows Terminal, which is where MeshTerm looks like itself.
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether any
+1
View File
@@ -1 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence layer: SQLite schema, repository, and structured logging."""
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""SQLite database bootstrap and schema.
The schema is intentionally generic: a central ``runs`` table records every tool
+21 -3
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Logging setup: a plain text file you can open, and a console that stays out of the way.
Two sinks. The console handler is Rich-formatted and shows what a person watching the
@@ -61,7 +62,7 @@ def level_from_name(name: str, *, fallback: int = logging.WARNING) -> int:
Args:
name: A level name, in any case.
fallback: Used when the name is unrecognised — a hand-edited ``preferences.yaml``
fallback: Used when the name is unrecognised — a hand-edited ``preferences.toml``
with a typo in it should lose some log detail, not stop the app from starting.
Returns:
@@ -97,7 +98,7 @@ def configure_logging(
"""
logger = logging.getLogger(_LOGGER_NAME)
logger.setLevel(logging.DEBUG)
logger.handlers.clear()
_drop_handlers(logger)
logger.propagate = False
if not quiet:
@@ -161,12 +162,29 @@ def _quiet_library_console(file_handler: logging.Handler) -> None:
for name in _LIBRARY_LOGGERS:
lib = logging.getLogger(name)
lib.handlers.clear()
_drop_handlers(lib)
lib.setLevel(logging.INFO)
lib.propagate = False
lib.addHandler(file_handler)
def _drop_handlers(logger: logging.Logger) -> None:
"""Detach ``logger``'s handlers, closing each on the way out.
Clearing the list alone drops the *reference* and leaves the file open: every
reconfigure — a reconnect in the app, each in-process CLI invocation under test — leaked
another descriptor on ``meshterm.log``, which on Windows is also a lock on a file the
rotation then wants to rename. A handler that refuses to close is dropped anyway: the
point of this call is that the old handlers stop receiving records.
"""
for handler in list(logger.handlers):
try:
handler.close()
except Exception: # noqa: BLE001 - a handler we are discarding cannot fail the app
pass
logger.handlers.clear()
def get_logger(name: str | None = None) -> logging.Logger:
"""Return a child of the application logger.
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Repository: the single gateway between the app and the database.
Tools and services never issue SQL directly; they call typed methods here. This keeps
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The platform seam: a frozen spec every consumer binds to once, at boot.
MeshTerm runs two flavours from one codebase — the regular desktop terminal and the
+5 -3
View File
@@ -1,6 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
"""Algorithm layer: pure logic for tracing, TX search, and path exploration.
Services depend on the :class:`~meshterm.core.connection.Device` interface and domain
models only. They contain no UI or persistence code, so they are straightforward to unit
test against the simulator.
Services may depend on :mod:`meshterm.core` (the :class:`~meshterm.core.connection.Device`
interface and the domain models), on the persistence layer's *types*, and on the app
context; never on ``ui/`` or ``tools/``. Nothing here renders, so every service is
straightforward to unit test against the simulator.
"""
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The background-advert scheduler: keep this node announced without anyone asking.
A quiet session-long task that sends a zero-hop (direct) advert and a flood advert on
+9
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Fetch and cache OpenStreetMap vector tiles for the terminal map.
This is the only networked part of the map. It resolves a tile source's (versioned) tile-URL
@@ -69,6 +70,14 @@ DEFAULT_TILEJSON_URL = "https://tiles.openfreemap.org/planet"
#: the tile path is no place to discover either.
_USER_AGENT = f"MeshTerm/{__version__} (+https://github.com/jpmartineau/MeshTerm; mesh node map)"
#: How long a tile the source gave no answer about is left alone before it is asked for
#: again. Long enough that a genuinely offline map isn't retrying every visible tile on a
#: loop, short enough that a Wi-Fi blip costs a few seconds of missing streets rather than
#: the rest of the session. It lives here, beside the source that failed to answer, because
#: both surfaces that draw tiles wait out the same cooldown (see
#: :meth:`meshterm.ui.map_screen.MapScreen._load` and the minimap's).
TILE_RETRY_SECONDS = 20.0
#: Fallback max tile zoom if the TileJSON doesn't declare one (OpenFreeMap serves 14).
_DEFAULT_MAX_ZOOM = 14
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The battery poller: keep the status bar's fuel gauge current, quietly.
A session-long task that reads the connected companion's battery voltage on a slow
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The chat service: message persistence, unread tracking, and the send path.
Like the passive monitor, this service is not a listener in its own right — the always-on
+220
View File
@@ -0,0 +1,220 @@
# SPDX-License-Identifier: Apache-2.0
"""The PicoCalc's console font: remember what was there, switch, put it back.
The handheld draws to a Linux virtual terminal, and a VT loads exactly one font for the
whole console — there is no per-application font the way a desktop terminal emulator has
one. So MeshTerm asking for its 6x8 build (53x40, fourteen more rows) means changing the
font the *shell* is using, and leaving it changed would be MeshTerm walking off with the
reader's console. Hence the shape of this module: **remember** the font the shell had,
**apply** the one the preference names, and **restore** on every way out.
``setfont -O`` writes the current font out with its unicode table, and feeding that file
straight back to ``setfont`` reproduces it exactly — verified on the device. No ``sudo``
is involved: the VT is the process's controlling terminal and is owned by the logged-in
user, which is the whole reason this can be a preference rather than a setup step.
``-C`` is deliberately never passed, so every call targets that controlling VT and not
whichever tty number happens to be first.
Everything here is cosmetic and everything here fails quietly. A missing ``setfont``, a
read-only state directory, an ssh session that is not a VT at all, a non-zero exit — each
is one debug line and a no-op return, because a font is never a reason for the app not to
start. :func:`applies` is the single gate all three verbs ask first, so "this is not that
machine" is answered in one place.
Distinct from :mod:`meshterm.core.consolefont`, which installs a *TrueType* face for the
classic Windows console. Same words, opposite platforms: that one is a Win32 font
resource, this one is a PSF bitmap the Linux kernel's console driver reads.
"""
from __future__ import annotations
import atexit
import os
import re
import shutil
import subprocess
from pathlib import Path
from ..core.config import default_config_dir
from ..persistence.logging import get_logger
from ..platforms import get_platform
#: Where ``scripts/calculinux-console-font.sh`` and its 6x8 companion install the fonts.
FONT_DIR = Path("/usr/share/consolefonts")
#: One preference value -> the file the build script writes for it. This map lives beside
#: the code that runs ``setfont`` rather than in the preference registry: a reader picks a
#: cell size and a row count, not a path on a filesystem only one machine has.
FONT_FILES: dict[str, str] = {"6x12": "meshterm.psf.gz", "6x8": "meshterm8.psf.gz"}
#: The preference this module acts on, named once so the caller and the registry agree.
PREFERENCE_KEY = "console_font"
#: What :func:`remember` writes under the MeshTerm home. A plain ``.psf``: ``setfont -O``
#: writes an uncompressed PSF, and the file is transient anyway (:func:`restore` deletes
#: it), so it is never gzipped and never versioned.
SAVED_NAME = "console-font-before.psf"
#: A Linux virtual terminal, which is the only kind of console ``setfont`` can talk to.
#: ``/dev/pts/3`` (an ssh session or a terminal emulator on the same device) is not one,
#: and ``setfont`` against it would either fail or change a console nobody is looking at.
_VT_NAME = re.compile(r"^/dev/tty[0-9]+$")
#: Seconds before a ``setfont`` call is given up on. It is a sub-millisecond ioctl in
#: practice; the timeout is here so a wedged binary cannot hold the app's startup.
_TIMEOUT_S = 5.0
def _controlling_vt() -> str | None:
"""The name of stdin's terminal when it is a Linux VT, else ``None``.
``os.ttyname`` does not exist on Windows and raises on a redirected stdin, which are
both simply "not a VT" as far as this module is concerned.
"""
try:
name = os.ttyname(0)
except (AttributeError, OSError, ValueError):
return None
return name if _VT_NAME.match(name) else None
def applies() -> bool:
"""Whether switching the console font is a thing this session can do at all.
Three conditions, each a different reason to leave the console alone: the PicoCalc is
the only platform whose fonts this module knows; stdin must be the Linux VT whose font
would change (an ssh session's pty has no font of its own); and ``setfont`` must
exist, which on the device means the ``kbd`` package the font build already needs.
Returns:
``True`` when all three hold. Otherwise ``False``, with one debug line naming
which one did not — the log is where a font that "did nothing" gets explained.
"""
platform = get_platform()
if platform.name != "picocalc":
get_logger().debug("console font: leaving it alone — platform is %s", platform.name)
return False
if _controlling_vt() is None:
get_logger().debug("console font: leaving it alone — stdin is not a Linux VT")
return False
if shutil.which("setfont") is None:
get_logger().debug("console font: leaving it alone — no setfont on PATH")
return False
return True
def remember() -> Path | None:
"""Save the console's current font, and arm its restore.
The saved file is the *shell's* font — whatever was loaded before MeshTerm started,
which is usually the 6x12 default but is whatever the reader last chose — so the
restore puts back what they had rather than what we assume they had.
The restore is registered with :mod:`atexit` here as well as being run from the
session's ``finally``, because the ways out of a full-screen app are many (^Q, an
unhandled exception, the menu's own unwind) and a console left in the wrong font is
only noticed after the app is gone. Restoring twice is harmless: the first one deletes
the file and the second finds nothing to do.
Returns:
The path the font was written to, to hand back to :func:`restore`, or ``None``
when there was nothing to remember (see :func:`applies`) or the write failed.
"""
if not applies():
return None
path = default_config_dir() / SAVED_NAME
try:
path.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
get_logger().debug("console font: cannot make %s: %s", path.parent, exc)
return None
if not _setfont("-O", str(path)):
return None
atexit.register(restore, path)
return path
def apply(choice: str) -> bool:
"""Load the font one preference value names.
Args:
choice: A :data:`FONT_FILES` key — the ``console_font`` preference's value.
Returns:
Whether ``setfont`` ran and succeeded. ``False`` is the ordinary answer on every
machine that is not the handheld, and never an error the caller has to handle.
"""
if not applies():
return False
name = FONT_FILES.get(str(choice))
if name is None:
get_logger().debug("console font: no file for %r; leaving the console alone", choice)
return False
# ``as_posix``: this is a path on the handheld's filesystem whatever machine the
# string is built on, and a developer's Windows box would otherwise spell it with
# backslashes on its way into a test.
return _setfont((FONT_DIR / name).as_posix())
def restore(saved: Path | None) -> None:
"""Put back the font :func:`remember` saved, then drop the file.
Idempotent by construction — the file is deleted whether or not loading it worked, so
a second call (the ``atexit`` one, after the session's ``finally`` already ran) finds
nothing and does nothing.
Args:
saved: What :func:`remember` returned. ``None`` means there was nothing to do.
"""
if saved is None:
return
try:
present = saved.is_file()
except OSError: # pragma: no cover - a stat that fails is a file we can't use
present = False
if present and shutil.which("setfont") is not None:
_setfont(str(saved))
try:
saved.unlink(missing_ok=True)
except OSError as exc: # pragma: no cover - a file we wrote should be ours to remove
get_logger().debug("console font: cannot remove %s: %s", saved, exc)
def _setfont(*args: str) -> bool:
"""Run ``setfont`` with ``args``, logging anything it has to say about failing.
Never ``-C``: with no tty named, ``setfont`` acts on its controlling terminal, which
is the console the reader is actually looking at.
Returns:
``True`` only on a clean exit.
"""
argv = ["setfont", *args]
try:
done = subprocess.run( # noqa: S603 - a fixed binary name with our own arguments
argv, capture_output=True, text=True, timeout=_TIMEOUT_S, check=False
)
except (OSError, subprocess.SubprocessError) as exc:
get_logger().debug("console font: %s failed: %s", " ".join(argv), exc)
return False
if done.returncode != 0:
get_logger().debug(
"console font: %s exited %d: %s",
" ".join(argv),
done.returncode,
(done.stderr or "").strip() or "(no output)",
)
return False
return True
__all__ = [
"FONT_DIR",
"FONT_FILES",
"PREFERENCE_KEY",
"SAVED_NAME",
"applies",
"apply",
"remember",
"restore",
]
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The Courier: store-and-forward delivery for the outbox.
A session-long background loop over the :class:`~meshterm.core.courier_store.
+32 -10
View File
@@ -1,11 +1,13 @@
# SPDX-License-Identifier: Apache-2.0
"""The session's device-state cache: read the radio once, reuse it everywhere.
Opening a screen used to re-read the same stable facts from the companion every time —
the contacts table, the node's own self-info, the path-hash routing width, the configured
channel slots, the channel-slot capacity. Over Bluetooth each of those is a full
request→reply round-trip, and the channel-slot reads dominate: ``get_contacts`` on a busy
node (hundreds of contacts), the slot probe (:func:`~meshterm.ui.channels.read_channel_slots`)
and the capacity probe (:meth:`~meshterm.core.connection.Device.channel_capacity`) each walk
node (hundreds of contacts), the slot probe
(:func:`~meshterm.core.channel_probe.read_channel_slots`) and the capacity probe
(:meth:`~meshterm.core.connection.Device.channel_capacity`) each walk
the slot table one index at a time and are measured in *seconds* on firmware that never
rejects an out-of-range index. Firing them on every navigation is what made moving between
screens feel like it stalled.
@@ -43,10 +45,11 @@ from dataclasses import replace
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from ..core.channel_probe import ChannelSlot, probe_channel_slots
if TYPE_CHECKING:
from ..context import AppContext
from ..core.models import Contact
from ..ui.channels import ChannelSlot
#: How long a cached contacts list is served before a read triggers a background refresh
#: (seconds). Contacts only grow as the mesh advertises, and the list is a naming/addressing
@@ -286,26 +289,45 @@ class DeviceState:
self._path_hash_mode = int(await device.get_path_hash_mode())
return self._path_hash_mode
async def routing_prefix_bytes(self) -> int:
"""The device's path-hash width in bytes, or 0 when unknowable.
What a surface that *highlights* hashes actually wants: how many leading bytes of a
key the mesh routes on, which is what :func:`~meshterm.ui.widgets.highlighted_hash`
lights. Best-effort, because every caller reads stored history and must work with no
radio at all — an unreachable device (or firmware that doesn't report the mode) just
leaves every hash un-highlighted rather than failing the screen.
Returns:
The hash width in bytes (1–4), or 0 when the mode could not be read.
"""
try:
if not (self._ctx.is_connected or self._ctx.settings.connect_on_start):
return 0
mode = await self.path_hash_mode()
except Exception: # noqa: BLE001 - optional read; absence just skips highlighting
return 0
return (mode + 1) if isinstance(mode, int) and 0 <= mode <= 3 else 0
# -- channel slots (held until the channel editor invalidates them) --------
async def channel_slots(self) -> list[ChannelSlot]:
"""Return the configured channel slots, cached until a channel edit invalidates them.
The underlying probe (:func:`~meshterm.ui.channels.read_channel_slots`) walks every
slot index on the firmware, which is one of the slowest reads on a screen open — so it
The underlying probe (:func:`~meshterm.core.channel_probe.read_channel_slots`) walks
every slot index on the firmware, which is one of the slowest reads on a screen open — so it
is well worth reading once. It is best-effort itself (an unsupported firmware yields an
empty list rather than raising), but a probe that ended early because a *read failed*
is answered and then dropped rather than cached — see :func:`~meshterm.ui.channels.
probe_channel_slots` for why a short list is not a layout.
is answered and then dropped rather than cached — see
:func:`~meshterm.core.channel_probe.probe_channel_slots` for why a short list is not a
layout.
Returns:
One :class:`~meshterm.ui.channels.ChannelSlot` per configured slot, in index order.
One :class:`~meshterm.core.channel_probe.ChannelSlot` per slot, in index order.
"""
if self._channels is None:
async with self._channels_lock:
if self._channels is None:
from ..ui.channels import probe_channel_slots
device = await self._ctx.device()
slots, complete = await probe_channel_slots(device)
if complete:
+2 -1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The always-on mesh event hub.
The hub is the single seam between the raw companion-device event stream and everything
@@ -205,7 +206,7 @@ class EventHub:
Returns:
The matching :class:`MeshEvent`, or ``None`` if ``timeout`` elapsed first.
"""
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
future: asyncio.Future = loop.create_future()
def handler(event: MeshEvent) -> None:
+137
View File
@@ -0,0 +1,137 @@
# SPDX-License-Identifier: Apache-2.0
"""Collecting the mesh's located nodes into map markers.
One list, three surfaces: the ``map`` tool's full-screen map, the node page's preview minimap,
and the editors' pick-a-location map all plot the same mesh, so they all come through
:func:`gather_markers`. It sits in ``services/`` because it is an assembly job over the
device's contacts and the observation history — the layer below anything that draws — and
because two screens and one tool need it, none of which may import from the others.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from ..core.geo import usable_fix
from ..core.models import NODE_TYPE_REPEATER, MapMarker
if TYPE_CHECKING: # pragma: no cover - typing only
from ..context import AppContext
async def gather_markers(ctx: AppContext, *, wait: bool = True) -> list[MapMarker]:
"""Collect every located node to plot: the device's contacts, plus our own node.
The companion's **contact list** is the authoritative source for a node's name,
type (repeater vs. leaf), and advertised position — the passive-monitor
observations only carry a location for the rare node that broadcasts one in an
advert. So contacts drive the markers, and each contact is enriched with signal
detail from the observations when we've overheard it directly.
Args:
ctx: Shared application context.
wait: Whether to wait on the radio for contacts and our own position the session
has not cached yet. ``False`` builds the markers from what is already in hand —
the cache and our own history — and never makes a round trip, for a surface
whose nodes are only context (the location picker): a companion refusing the
contacts read can otherwise hold it closed for twenty-odd seconds.
"""
observed = {n.node: n for n in ctx.repo.heard_nodes() if n.node}
markers: list[MapMarker] = []
seen: set[str] = set()
contacts = await _contacts(ctx) if wait else (ctx.devstate.peek_contacts() or [])
for contact in contacts:
if not contact.has_location or not usable_fix(contact.lat, contact.lon):
continue
key = contact.key_prefix or (contact.public_key or "")[:12]
if key:
seen.add(key)
markers.append(
MapMarker(
label=contact.name or key or "?",
lat=float(contact.lat),
lon=float(contact.lon),
is_repeater=contact.is_repeater,
detail=_signal_detail(observed.get(key)),
key=contact.public_key or contact.key_prefix or None,
)
)
# A node we overheard advertising a location but that isn't in our contacts.
for node in observed.values():
if not node.has_location or node.node in seen:
continue
if not usable_fix(node.lat, node.lon):
continue
markers.append(
MapMarker(
label=node.name or node.node or "?",
lat=float(node.lat),
lon=float(node.lon),
is_repeater=node.is_repeater,
detail=_signal_detail(node),
key=node.public_key or node.node or None,
)
)
if wait:
self_marker = await _self_marker(ctx)
else:
info = ctx.devstate.peek_self_info()
self_marker = _marker_for_self(info) if info is not None else None
if self_marker is not None:
markers.append(self_marker)
return markers
async def _contacts(ctx: AppContext) -> list:
"""Fetch the device's contacts, best-effort (an unreachable radio yields none)."""
try:
return await ctx.devstate.contacts()
except Exception: # noqa: BLE001 - the map still works from observations alone
return []
async def _self_marker(ctx: AppContext) -> MapMarker | None:
"""Build a marker for our own node from the device, if its location is known."""
try:
info = await ctx.devstate.self_info()
except Exception: # noqa: BLE001 - the map is useful without our own position
return None
return _marker_for_self(info)
def _marker_for_self(info: dict) -> MapMarker | None:
"""Our own node's marker from a self-info payload, or ``None`` where it has no fix."""
lat, lon = _as_float(info.get("adv_lat")), _as_float(info.get("adv_lon"))
if lat is None or lon is None or not usable_fix(lat, lon):
return None # a device with no fix reports 0/0 (or nonsense out-of-range)
return MapMarker(
label=str(info.get("name") or "this node"),
lat=lat,
lon=lon,
is_repeater=info.get("adv_type") == NODE_TYPE_REPEATER,
is_self=True,
key=str(info.get("public_key") or "") or None,
)
def _signal_detail(node: object) -> str:
"""A compact 'N pkts · +x.x dB' reception note for a heard node, or '' if unheard."""
if node is None:
return ""
detail = f"{node.count} pkts" # type: ignore[attr-defined]
if node.median_snr is not None: # type: ignore[attr-defined]
detail += f" · {node.median_snr:+.1f} dB" # type: ignore[attr-defined]
return detail
def _as_float(value: object) -> float | None:
"""Best-effort float conversion, returning ``None`` on missing/garbage values."""
if value is None:
return None
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Recover the paths a chat message rode in on, from the stored packet log.
The radio's RX log records every frame it decodes — including the relay path each one
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The optional Shift-state watcher behind the PicoCalc F-key lane's live flip.
The PicoCalc keyboard's MCU translates Shift+F1..F5 into plain F6–F10 keycodes, so the
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The passive-monitor history logger.
Passive monitoring records every advert/telemetry frame the companion overhears to the
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Multi-path probing: measure candidate routes to a target and rank what actually works.
The topology graph (:mod:`~meshterm.services.topology`) proposes routes from *received*
+3 -2
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Walk records: how a completed trace is measured and scored for the trophy case.
A *walk* is one trace whose route starts and ends at our node — we transmit the first
@@ -280,7 +281,7 @@ CATEGORIES: tuple[Category, ...] = (
CATEGORY_BY_ID: dict[str, Category] = {c.id: c for c in CATEGORIES}
def _local_xy(origin: tuple[float, float], point: tuple[float, float]) -> tuple[float, float]:
def local_xy(origin: tuple[float, float], point: tuple[float, float]) -> tuple[float, float]:
"""Project a lat/lon onto a local plane around ``origin``, in km.
An equirectangular approximation — exact enough for mesh-sized areas (a few tens
@@ -353,7 +354,7 @@ def compute_walk_stats(
if self_pos is not None:
placed = [p for p in points[:-1] if p is not None] # circuit closes itself
if len(placed) >= 3:
xy = [_local_xy(self_pos, p) for p in placed]
xy = [local_xy(self_pos, p) for p in placed]
twice = sum(
xy[i][0] * xy[(i + 1) % len(xy)][1] - xy[(i + 1) % len(xy)][0] * xy[i][1]
for i in range(len(xy))
+9 -3
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The observed mesh topology: a link graph distilled from every path we ever received.
MeshCore never tells us the network's shape outright, but almost everything we *receive*
@@ -85,8 +86,13 @@ _HEX_DIGITS = frozenset("0123456789abcdef")
_MISS = object()
def _is_hex(value: str) -> bool:
"""Whether ``value`` is non-empty, even-length hex (a plausible path hash)."""
def is_path_hash(value: str) -> bool:
"""Whether ``value`` could be a path hash: non-empty hex, a whole number of bytes.
THE test, shared by everything that has to tell a hop id from a name or a graph
sentinel. Even length because a hash is bytes, never a half one — which is also what
keeps a one-character endpoint marker out.
"""
return bool(value) and len(value) % 2 == 0 and all(c in _HEX_DIGITS for c in value)
@@ -340,7 +346,7 @@ class MeshTopology:
if cached is not _MISS:
return cached
needle = hop.lower().removeprefix("0x")
if not _is_hex(needle):
if not is_path_hash(needle):
result: str | None = None
else:
matches = {
+13 -82
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Repeated-trace execution with duty-cycle pacing, plus trace parsing helpers.
:func:`run_traces` is the measurement primitive the TX optimizer and the path probe
@@ -14,15 +15,21 @@ import asyncio
from collections.abc import Awaitable, Callable
from ..core.connection import Device
from ..core.models import Contact, TraceResult
from ..core.models import Contact, NameKeyResolver, NodeResolver, TraceResult
# Trace arithmetic lives in ``core`` because the device layer sizes its own sends with it;
# re-exported here so the runner's long-standing public names keep working.
from ..core.tracing import ( # noqa: F401 - re-exported for this module's callers
TRACE_TIMEOUT_BASE_S,
TRACE_TIMEOUT_CEILING_S,
TRACE_TIMEOUT_FLOOD_S,
TRACE_TIMEOUT_PER_HOP_S,
path_hash_flags,
trace_timeout,
)
ProgressCallback = Callable[[int, int, TraceResult], None]
#: Maps a trace hop's key-prefix hash to a display label (a contact name, or the
#: hash itself when unknown). ``None`` passes through (our own device).
NodeResolver = Callable[[str | None], str | None]
_HEX_DIGITS = frozenset("0123456789abcdef")
@@ -163,13 +170,6 @@ def make_key_resolver(contacts: list[Contact] | None) -> NodeResolver:
return resolve
#: Maps a display name to the node's key (as full as we hold one — a public key, a key
#: prefix, or a stored node id), or ``None`` for a name no known node carries. The
#: colour side of :func:`make_node_resolver`: where that resolver turns hex into names,
#: this one turns a bare name back into the key its palette hue derives from.
NameKeyResolver = Callable[[str], str | None]
def make_name_key_resolver(
contacts: list[Contact] | None,
stored_names: dict[str, str] | None = None,
@@ -269,75 +269,6 @@ def parse_trace_path(spec: str, contacts: list[Contact] | None = None) -> str:
return ",".join(hops)
def path_hash_flags(width_bytes: int) -> int | None:
"""Return the trace ``flags`` value that encodes a per-hop path-hash width.
The trace subsystem encodes the hash size as ``1 << (flags & 3)`` on both the
send and receive sides (``send_trace`` and the ``TRACE_DATA`` reader), so only
widths of 1, 2, 4, or 8 bytes are representable. This is independent of, and
differs from, the ``mode = size - 1`` encoding used for *contact routing*
(``out_path_hash_mode``).
Args:
width_bytes: Path-hash width in bytes.
Returns:
The flags value (the exponent ``s``), or ``None`` if the width is not a
representable power of two.
"""
for s in range(4):
if (1 << s) == width_bytes:
return s
return None
#: Fixed overhead in a trace's reply-wait budget, in seconds: our own transmit, the far
#: endpoint's turnaround, and our receive-side decode — the cost that doesn't grow with
#: the route.
TRACE_TIMEOUT_BASE_S = 4.0
#: Per-hop allowance added to the budget, in seconds. Each entry in the walked path is one
#: relay transmission, and this covers its packet airtime, the repeater's processing, and
#: the randomised transmit backoff MeshCore adds so relays don't collide.
TRACE_TIMEOUT_PER_HOP_S = 1.6
#: Upper bound on the reply-wait, in seconds, so a route that never comes home still
#: surrenders the trace (and the session) in bounded time rather than scaling without end.
TRACE_TIMEOUT_CEILING_S = 30.0
#: Fallback budget, in seconds, when the hop count can't be known ahead of the send — a
#: path-less flood to a contact we hold no route for. This is the historical flat value,
#: kept for exactly the case where we can't size the walk.
TRACE_TIMEOUT_FLOOD_S = 10.0
def trace_timeout(hop_count: int) -> float:
"""Return the reply-wait budget, in seconds, for a trace walking ``hop_count`` hops.
A trace is a *single* packet that has to travel the whole path — out to the far hop
and back over the mirrored return leg — before its reply reaches us, so the wait has
to grow with the route. A flat budget sized for a neighbour cuts a long walk off
mid-flight: the packet completes the circuit on the mesh, but our wait has already
expired, so we log a phantom "no reply" for a route that actually worked. The budget
is a fixed base (:data:`TRACE_TIMEOUT_BASE_S`) plus a per-hop allowance
(:data:`TRACE_TIMEOUT_PER_HOP_S`) for each relay transmission, capped at
:data:`TRACE_TIMEOUT_CEILING_S`.
``hop_count`` is the number of hops in the *transmitted* path, which already includes
the mirrored return leg (see
:meth:`~meshterm.core.connection.MeshCoreDevice._trace_path_to_contact`), so it maps
one-to-one onto relay transmissions and must not be doubled here.
Args:
hop_count: Hops in the walked path (return leg included). ``0`` (or less) means the
count is unknown — a path-less flood — and yields :data:`TRACE_TIMEOUT_FLOOD_S`.
Returns:
The number of seconds to wait for the trace reply.
"""
if hop_count <= 0:
return TRACE_TIMEOUT_FLOOD_S
budget = TRACE_TIMEOUT_BASE_S + TRACE_TIMEOUT_PER_HOP_S * hop_count
return min(budget, TRACE_TIMEOUT_CEILING_S)
async def run_traces(
device: Device,
target: str,
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Remote-admin TX-power optimization.
This tunes the transmit power of a *remote* node we have admin rights on — the node
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The Watchtower: passive rules over what the hub already hears, raising alerts.
A sentinel, not a prober — it transmits nothing and asks the radio for nothing. It rides
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""Pluggable tools (menu options / CLI subcommands).
Importing this package eagerly imports every tool module so that each one's ``@register``
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The About MeshTerm tools: the four written pages in the menu's last section.
*About MeshTerm*, *About the author*, *Join Discord*, *Support MeshTerm* — in that
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``advert`` tool: announce this node to the mesh, straight from the main menu.
Opens the Send advert flow (see :func:`meshterm.ui.config_editor.send_advert`) — a
+3 -1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The tool plugin contract and registry.
A *tool* is one menu option / CLI subcommand. Subclass :class:`Tool`, decorate it with
@@ -109,7 +110,8 @@ class Tool(ABC):
"""Interactively gather parameters for the menu.
The default implementation requires no parameters. Tools override this to ask
the user via ``questionary``.
the user through :attr:`AppContext.ui` — a dialog or a select list in the menu, and
nothing at all on the CLI, where the arguments came off the command line.
Args:
ctx: Shared application context.
+14 -13
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``channels`` tool: create, join, and manage mesh channels.
Interactively it opens a full-screen channel manager (see :mod:`meshterm.ui.channels`) — a
@@ -15,8 +16,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
import typer
from rich.console import Group
from rich.text import Text
from ..context import AppContext
from ..core import exitcodes
@@ -86,7 +85,7 @@ class ChannelsTool(Tool):
async def _cli_list(self, ctx: AppContext) -> ToolResult:
"""List the configured channel slots."""
from ..ui.channels import read_channel_slots
from ..core.channel_probe import read_channel_slots
device = await ctx.device()
slots = await read_channel_slots(device)
@@ -109,7 +108,7 @@ class ChannelsTool(Tool):
else:
secret = normalize_secret(params["secret"]) if params.get("secret") else random_secret()
await write_channel(ctx, device, idx, name, secret)
self._show_qr(ctx, share_url(name, secret))
await self._show_qr(ctx, name, share_url(name, secret))
return ToolResult(
summary={"index": idx, "name": name},
report=(_written(idx, name, secret),),
@@ -152,7 +151,7 @@ class ChannelsTool(Tool):
the secret and the share URL leave the app at all — every other channel document
carries the identity and stops there.
"""
from ..ui.channels import read_channel_slots
from ..core.channel_probe import read_channel_slots
device = await ctx.device()
idx = int(params["index"])
@@ -163,7 +162,7 @@ class ChannelsTool(Tool):
report=(_written(idx, None, None, shown=False),),
exit_code=exitcodes.NO_RESULT,
)
self._show_qr(ctx, share_url(slot.name, slot.secret))
await self._show_qr(ctx, slot.name, share_url(slot.name, slot.secret))
return ToolResult(
summary={"index": idx, "shared": True},
report=(_written(idx, slot.name, slot.secret),),
@@ -177,7 +176,8 @@ class ChannelsTool(Tool):
command (a private channel's key is lost with the slot unless it's saved
elsewhere), mirroring the interactive flow's danger confirmation.
"""
from ..ui.channels import read_channel_slots, write_channel
from ..core.channel_probe import read_channel_slots
from ..ui.channels import write_channel
device = await ctx.device()
idx = int(params["index"])
@@ -195,18 +195,19 @@ class ChannelsTool(Tool):
)
@staticmethod
def _show_qr(ctx: AppContext, url: str) -> None:
async def _show_qr(ctx: AppContext, name: str, url: str) -> None:
"""Draw a channel's share link as a QR code — in the menu, and only there.
The QR is for a phone pointed at the screen. Piped into a file it is a block of
block characters wrapped around the one thing that is actually the answer, so a
scripted run states the link and nothing else.
The QR is for a phone pointed at the screen, and there it takes the whole screen
(:func:`~meshterm.ui.qr.share_screen`). Piped into a file it is a block of block
characters wrapped around the one thing that is actually the answer, so a scripted
run states the link and nothing else.
"""
from ..ui.qr import qr_text
from ..ui.qr import share_screen
from ..ui.surface import TuiUi
if isinstance(ctx.ui, TuiUi):
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
await share_screen(ctx, name=name, url=url)
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``channels`` subcommand group.
+11 -9
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``chat`` tool: channel and direct messaging over the mesh.
Interactively it opens a conversation picker and then a live, full-screen chat (see
@@ -27,9 +28,11 @@ from ..core import exitcodes
from ..core.channels import (
CHANNEL_SLOT_PROBE_CAP,
DEFAULT_PUBLIC_SECRET,
MENTION,
channel_hash,
channel_identity,
is_public_channel,
split_channel_sender,
)
from ..core.connection import Device
from ..core.events import EventKind, MeshEvent
@@ -43,16 +46,15 @@ from ..core.models import (
)
from ..services.trace_runner import NameKeyResolver, make_name_key_resolver
from ..ui import renderers
from ..ui.chat import _MENTION, _split_channel_sender
from ..ui.fields import ChannelRef, NodeRef
from ..ui.menus import Lane, column_header, fit_cells, section_heading
from ..ui.theme import name_style
from ..ui.tui import Choice, DeleteRequest, Separator
from ..ui.widgets import _NODE_GLYPHS, _age_seconds, _format_age, channel_glyph
from ..ui.widgets import NODE_GLYPHS, age_seconds, channel_glyph, format_age
from .base import Tool, ToolResult, register
if TYPE_CHECKING:
from ..ui.channels import ChannelSlot
from ..core.channel_probe import ChannelSlot
from ..ui.report import Facts, Listing
#: How many recent messages ``chat history`` prints by default.
@@ -498,7 +500,7 @@ def _channels_from_slots(slots: list[ChannelSlot]) -> list[Conversation]:
The picker reads its channels through the session cache
(:meth:`~meshterm.services.device_state.DeviceState.channel_slots`) so it reuses the one
slow slot probe instead of re-walking every slot on each Chat open; this maps that cached
:class:`~meshterm.ui.channels.ChannelSlot` list onto the picker's
:class:`~meshterm.core.channel_probe.ChannelSlot` list onto the picker's
:class:`~meshterm.core.models.Conversation` rows. Channel 0 (the default public channel) is
synthesised when the firmware reports no slot for it, so there is always somewhere to chat —
the same guarantee :func:`_read_channels` (the CLI path) makes.
@@ -833,7 +835,7 @@ def _title(
#: The standard companion pink — the shared plain-node ``●`` colour — the contact dot's
#: hue: the shape (filled/hollow) marks history, the colour marks "a companion", and the
#: name beside it carries the person's own key-derived hue.
_COMPANION_DOT_STYLE = _NODE_GLYPHS[NODE_TYPE_CHAT][1]
_COMPANION_DOT_STYLE = NODE_GLYPHS[NODE_TYPE_CHAT][1]
def _append_marker(
@@ -879,7 +881,7 @@ def _preview_text(last: ChatMessage, key_of: NameKeyResolver) -> Text:
text.append("you: ", style="accent")
_append_body(text, body_raw, key_of)
elif last.is_channel:
name, body = _split_channel_sender(body_raw)
name, body = split_channel_sender(body_raw)
if name is not None:
text.append(name, style=name_style(name, key_of(name)))
text.append(": ", style="muted")
@@ -898,7 +900,7 @@ def _append_body(text: Text, body: str, key_of: NameKeyResolver) -> None:
name resolves to no node we know.
"""
pos = 0
for match in _MENTION.finditer(body):
for match in MENTION.finditer(body):
if match.start() > pos:
text.append(body[pos : match.start()], style="muted")
name = match.group(1)
@@ -909,7 +911,7 @@ def _append_body(text: Text, body: str, key_of: NameKeyResolver) -> None:
def _ago(when: Any) -> str:
"""The column age for a message time, through THE grammar (``_format_age``).
"""The column age for a message time, through THE grammar (``format_age``).
One deliberate difference from the widget: a missing or naive timestamp (a stray one
from the wire) reads as a *blank* lane rather than ``never`` — the picker wants an
@@ -917,7 +919,7 @@ def _ago(when: Any) -> str:
"""
if getattr(when, "tzinfo", None) is None:
return ""
return _format_age(_age_seconds(when))
return format_age(age_seconds(when))
def _channel_ref(idx: int | None, label: str, secret: bytes | None) -> ChannelRef:
+8 -9
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``config`` tool: every device setting, and the operations on the box itself.
Interactively it opens the Device config page (see :mod:`meshterm.ui.config_editor`) —
@@ -303,7 +304,7 @@ async def apply_ops(
"the device reported no public key — the advert cadence was not saved"
)
elif kind == "share":
report.append(_share_contact(ctx, snapshot))
report.append(await _share_contact(ctx, snapshot))
elif kind == "sync_clock":
import time as _time
from datetime import datetime
@@ -479,20 +480,18 @@ def _script_value(spec: SettingSpec, value: Any) -> str:
return str(value)
def _share_contact(ctx: AppContext, snapshot: dict) -> Facts:
"""State this node's contact card — with the QR code drawn in the menu.
async def _share_contact(ctx: AppContext, snapshot: dict) -> Facts:
"""State this node's contact card — with the QR code drawn in the menu, full-frame.
The QR is for a phone pointed at the screen. Redirected into a file it is a block of
The QR is for a phone pointed at the screen, and there it takes the whole screen
(:func:`~meshterm.ui.qr.share_screen`). Redirected into a file it is a block of
block characters around the one thing that is the answer, so a scripted run states the
link by itself. There is no machine face for the code either, and never will be: it is
a second rendering of ``url``.
"""
from rich.console import Group
from rich.text import Text
from ..ui import fields
from ..ui.config_editor import contact_share_url
from ..ui.qr import qr_text
from ..ui.qr import share_screen
from ..ui.report import BARE, Facts
from ..ui.surface import TuiUi
@@ -503,7 +502,7 @@ def _share_contact(ctx: AppContext, snapshot: dict) -> Facts:
adv_type = int(snapshot.get("adv_type") or 1)
url = contact_share_url(name, public_key, adv_type)
if isinstance(ctx.ui, TuiUi):
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
await share_screen(ctx, name=name, url=url)
return Facts(
key="share",
fields=(
+3 -2
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``contacts`` tool: list the contacts this node knows about.
The menu opens the sortable Contacts screen, which leads with our own node; the CLI
@@ -138,7 +139,7 @@ def _listing(contacts: list[Contact], counts: dict[str, int], prefix_bytes: int)
"""
from ..ui import fields
from ..ui.report import Listing
from ..ui.widgets import _contact_pkts
from ..ui.widgets import contact_packets
rows = []
for contact in contacts:
@@ -152,7 +153,7 @@ def _listing(contacts: list[Contact], counts: dict[str, int], prefix_bytes: int)
type=NODE_TYPE_LABELS.get(contact.node_type),
),
"heard_at": contact.last_seen,
"packets": _contact_pkts(contact, counts),
"packets": contact_packets(contact, counts),
"position": (
fields.Position(contact.lat, contact.lon) if contact.has_location else None
),
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``courier`` tool: store-and-forward messaging for contacts that aren't there yet.
Interactively it opens the outbox screen (see :mod:`meshterm.ui.courier_screen`):
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``dashboard`` tool: the live mesh overview, at the top of the Watch section.
Opens the full-screen dashboard (see :mod:`meshterm.ui.dashboard_screen`): the two-hour
+18 -3
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``devices`` tool: enumerate attached serial and in-range Bluetooth companions.
Discovery never opens the radio — it only lists what is attached or advertising. This is a
@@ -17,7 +18,7 @@ import typer
from ..context import AppContext
from ..core import exitcodes
from ..core.discovery import discover_all, discover_devices
from ..core.discovery import TRANSPORT_MOCK, DiscoveredDevice, discover_all, discover_devices
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
@@ -51,8 +52,22 @@ class DevicesTool(Tool):
Returns:
A :class:`ToolResult` summarizing how many devices were found.
"""
scan_ble = params.get("ble", True)
devices = await discover_all(ble=scan_ble) if scan_ble else discover_devices()
if ctx.mock:
# `--mock` promises a session with no real hardware, and a scan is the one
# thing this tool does — it walked the serial ports and switched the Bluetooth
# radio on regardless, which is a promise broken on the first command a
# stranger without a radio would try. So under --mock the simulator *is* the
# inventory: one row, and nothing on the machine touched.
devices = [
DiscoveredDevice(
transport=TRANSPORT_MOCK,
description="the built-in simulator (nothing transmits)",
name="simulator",
)
]
else:
scan_ble = params.get("ble", True)
devices = await discover_all(ble=scan_ble) if scan_ble else discover_devices()
known = ctx.device_store.load_all()
remembered = ctx.device_store.load()
active = ctx.selected_device
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``info`` tool: show the connected companion device's identity and radio config."""
from __future__ import annotations
+1
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``livefeed`` tool: every packet as it arrives, right under the dashboard.
Opens the full-screen live feed (see :mod:`meshterm.ui.livefeed_screen`): the
+3 -128
View File
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``map`` tool: plot the mesh's located nodes on an OpenStreetMap terminal map.
Nodes that share their location in adverts are collected from the passive-monitor history
@@ -20,8 +21,7 @@ from typing import TYPE_CHECKING, Any
import typer
from ..context import AppContext
from ..core.geo import usable_fix
from ..core.models import NODE_TYPE_REPEATER
from ..services.markers import gather_markers
from .base import Tool, ToolResult, register
@@ -44,7 +44,7 @@ def _fraction(ctx: AppContext, params: dict[str, Any]) -> float:
if TYPE_CHECKING:
from ..ui.map_render import MapMarker
from ..core.models import MapMarker
@register
@@ -115,128 +115,3 @@ class MapTool(Tool):
Args:
app: The Typer application (untouched).
"""
async def gather_markers(ctx: AppContext, *, wait: bool = True) -> list[MapMarker]:
"""Collect every located node to plot: the device's contacts, plus our own node.
The companion's **contact list** is the authoritative source for a node's name,
type (repeater vs. leaf), and advertised position — the passive-monitor
observations only carry a location for the rare node that broadcasts one in an
advert. So contacts drive the markers, and each contact is enriched with signal
detail from the observations when we've overheard it directly.
Shared by the ``map`` tool, the node page's preview and the editors' pick-a-location
map, so all of them show the same mesh.
Args:
ctx: Shared application context.
wait: Whether to wait on the radio for contacts and our own position the session
has not cached yet. ``False`` builds the markers from what is already in hand —
the cache and our own history — and never makes a round trip, for a surface
whose nodes are only context (the location picker): a companion refusing the
contacts read can otherwise hold it closed for twenty-odd seconds.
"""
from ..ui.map_render import MapMarker
observed = {n.node: n for n in ctx.repo.heard_nodes() if n.node}
markers: list[MapMarker] = []
seen: set[str] = set()
contacts = await _contacts(ctx) if wait else (ctx.devstate.peek_contacts() or [])
for contact in contacts:
if not contact.has_location or not usable_fix(contact.lat, contact.lon):
continue
key = contact.key_prefix or (contact.public_key or "")[:12]
if key:
seen.add(key)
markers.append(
MapMarker(
label=contact.name or key or "?",
lat=float(contact.lat),
lon=float(contact.lon),
is_repeater=contact.is_repeater,
detail=_signal_detail(observed.get(key)),
key=contact.public_key or contact.key_prefix or None,
)
)
# A node we overheard advertising a location but that isn't in our contacts.
for node in observed.values():
if not node.has_location or node.node in seen:
continue
if not usable_fix(node.lat, node.lon):
continue
markers.append(
MapMarker(
label=node.name or node.node or "?",
lat=float(node.lat),
lon=float(node.lon),
is_repeater=node.is_repeater,
detail=_signal_detail(node),
key=node.public_key or node.node or None,
)
)
if wait:
self_marker = await _self_marker(ctx)
else:
info = ctx.devstate.peek_self_info()
self_marker = _marker_for_self(info) if info is not None else None
if self_marker is not None:
markers.append(self_marker)
return markers
async def _contacts(ctx: AppContext) -> list:
"""Fetch the device's contacts, best-effort (an unreachable radio yields none)."""
try:
return await ctx.devstate.contacts()
except Exception: # noqa: BLE001 - the map still works from observations alone
return []
async def _self_marker(ctx: AppContext) -> MapMarker | None:
"""Build a marker for our own node from the device, if its location is known."""
try:
info = await ctx.devstate.self_info()
except Exception: # noqa: BLE001 - the map is useful without our own position
return None
return _marker_for_self(info)
def _marker_for_self(info: dict) -> MapMarker | None:
"""Our own node's marker from a self-info payload, or ``None`` where it has no fix."""
from ..ui.map_render import MapMarker
lat, lon = _as_float(info.get("adv_lat")), _as_float(info.get("adv_lon"))
if lat is None or lon is None or not usable_fix(lat, lon):
return None # a device with no fix reports 0/0 (or nonsense out-of-range)
return MapMarker(
label=str(info.get("name") or "this node"),
lat=lat,
lon=lon,
is_repeater=info.get("adv_type") == NODE_TYPE_REPEATER,
is_self=True,
key=str(info.get("public_key") or "") or None,
)
def _signal_detail(node: object) -> str:
"""A compact 'N pkts · +x.x dB' reception note for a heard node, or '' if unheard."""
if node is None:
return ""
detail = f"{node.count} pkts" # type: ignore[attr-defined]
if node.median_snr is not None: # type: ignore[attr-defined]
detail += f" · {node.median_snr:+.1f} dB" # type: ignore[attr-defined]
return detail
def _as_float(value: object) -> float | None:
"""Best-effort float conversion, returning ``None`` on missing/garbage values."""
if value is None:
return None
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff. Show more