113 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5.5 6049e989fc MeshTerm 0.10.2
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
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 02:09:04 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 c1d0772baf Lint and format the last few commits
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 02:09:04 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 2d6b8ada6e The main menu's quit hint reads ^Q quit?, since it asks first
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:52:49 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 dd64ea8e87 The PicoCalc's main menu shows its way out, with Quit? on F3
With Esc inert at the main menu, the PicoCalc had nowhere to say how to
leave. Its title bar now ends on ^Q quit there (Esc clear while a filter is
typed), and the menu's F-key lane puts Quit? on F3, which asks first, with
Quit! behind it on F8 to leave without asking. Follows up issue #22.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:46:06 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7071214660 ^Q asks before it quits, and Esc at the main menu stays put
Esc-mashing back to the menu used to land on the quit confirm, and ^Q, one
key from ^W, quit with no question at all. Now Esc at the main menu only
clears a typed filter, and ^Q floats the same quit confirm the Quit row
opens, from anywhere and without stopping what runs underneath; a second
^Q while it asks leaves at once. The main menu's hint names ^Q quit, the
only place the chord is shown. Option D from issue #22.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:31:03 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 0e9f41fb2a A reboot no longer waits for an acknowledgement the board never sends
Over Bluetooth every command is a write-with-response, and a companion that
restarts on the reboot command can drop the link before acknowledging the
write. The write then hung until the link supervision timeout, holding the
reboot dialog back by a second or more. The reboot now returns once the
write has had a quarter second to fail, and leaves the rest to the teardown
that follows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:20:05 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 cdea534fa0 A reboot sent from Device config raises the reconnect dialog at once
The reboot flow used to send the command and then wait for a liveness poll
to see the link drop. A quick reboot behind a port that never goes away
(a USB-UART bridge) was never seen, so the app went on sending commands to
a board mid-restart. Sending the reboot now declares the link down itself:
the session watcher wakes on the announcement, the Device rebooting dialog
takes over, and the first reconnect attempt waits a moment for the board to
actually begin restarting.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:09:32 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 3f73716ff5 The Channels list leads with each channel's slot number
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:38:23 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 854b7fd765 A T-Deck on MeshOS or wadamesh pairs over Bluetooth like any companion
Those firmwares guard only the UART write characteristic, so the
refusal arrives on the handshake's first write, which meshcore's send
swallows. The empty handshake read as "not a MeshCore companion", and
the PIN pairing, the stale-bond repair and the macOS Passkey wait never
ran. The write is now wrapped to keep its refusal, and an empty
handshake behind one raises it, so both firmware shapes take the same
pairing path.

bleak no longer gets the PIN on Windows either: its WinRT pair() is
Just Works only, and after a mistyped PIN it left Windows holding an
unauthenticated bond that the next, correct attempt reused and was
refused over.

Verified against a T-Deck on MeshOS 1.3.0: a planted Just Works bond
heals in one run, and an unbonded connect without a PIN now says the
device needs one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:26:14 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 307e767570 The Time Machine's whole-mesh page cycles through each scope, as the dashboard does
s (F2 on the PicoCalc) steps all → unscoped → each region heard in the window
A–Z → unknown scope, beside w's window cycle. A narrowed page charts that
scope's floods per day (per hour at 24 h), their time-of-day rhythm, and its
own ledger; nodes per day and arrivals leave, being read off adverts, which
carry no scope. A window's stored floods are read once (Repository.flood_frames)
and resolved against the known region names for the page's life.

The node and own-node pages offer no cycle: a node's page is its adverts and
telemetry, and what we send is traces, which go direct.

The ring's order, words and chip move to ui/scopering.py, shared with the
dashboard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 02:35:31 -04:00
Jean-Pierre Martineau ca194fbd30 Updated splash screen (ANSI art, CP437) 2026-09-28 02:25:24 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 247c9bf8c7 Splash letters said white after a reset and drew grey
An art editor spells white as a reset then a bare bold (ESC[0m ESC[1m): the
console's default grey, brightened. The loader forgot the colour on a reset,
so it dropped the bold with nothing restated and the run fell to the
terminal's own default grey. After a reset the grey is now the colour in
force, and a bold lifts it to bright white like any other.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 02:20:36 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 1596f121a3 The dashboard cycles through each scope heard, as the Time Machine does windows
s (F3 on the PicoCalc) steps all → unscoped → each region A–Z → unknown
scope → all, and the whole screen follows: the activity chart, the pulse
rate, the traffic tallies and the SNR/RSSI medians narrow to that scope's
floods, while the radio's own numbers stay whole. A narrowed view reads the
screen's 2-hour window, since naming a frame's region is a per-frame check
the database can't run; the headings say so, and a direct frame, which has
no scope, counts only under all. The title names the view, falling back to
"Dashboard · scope …" where a long region name won't fit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 00:28:40 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 97d4d231c7 The dashboard's traffic counts every frame once, under the live feed's names
Traffic listed what the device reported (advert, telemetry, message, ack)
beside the frames those reports arrived in, so one advert was counted twice
and its frame row had to be renamed "raw advert" to tell the two apart. It
now counts frames heard on the air only, named by the same function as the
live feed's class lane (payload_marks), and every bar takes the colour the
feed draws a frame's class in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 00:22:51 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 3fccfd7971 The Contacts title counts the archived too, abbreviating where it's narrow
"Contacts · 198 known + 123 archived", falling back to "Contacts · 198 + 123"
where the frame's rule can't hold the words. The fallback is a new
Screen.short_title the frame picks through fitted_title, on both the bordered
panel and the PicoCalc title bar.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 23:41:53 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 53de3c9fb4 Donation links as clean as they can be, on GitHub and on the Support page
Patreon becomes GitHub's built-in `patreon:` entry, so the sidebar shows
patreon.com/MeshTerm with its icon like Buy Me a Coffee, and the Support
page carries the same short URL (a smaller QR code too). PayPal stays the
hosted donate button, only without its scheme in FUNDING.yml: a paypal.me
link would let a donor pay as goods and services and claim a refund for an
item that never arrived, which the donate button's donation label avoids.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:21:36 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 00ea4d5e14 The release skill ends with a Discord announcement for JP to post
A short, plain copy for #announcements, shown last in a code block so it's
easy to copy. JP posts it himself; the skill never does. Also puts back the
notes-repair sentence that had come loose after a code block.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:09:48 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 a9e650c07d MeshTerm 0.10.1
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
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:05:41 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b09d97f8a3 The release skill always asks which bump, and asks twice for a major
"Bump minor" meant "a small bump" and nearly shipped 0.11.0. Every release
now starts with a three-way choice showing the number each one produces,
patch recommended, and a major needs a second, separate yes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:05:41 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 0cd40bbd6c Fix three small CLI rough edges: records --width, the simulator's label, two help pages
records --width now refuses anything but 1, 2, or 4 with a usage error, the
way --category already refused an unknown discipline. A width that doesn't
exist used to come back as exit 5, indistinguishable from an empty database,
and --width 0 was falsy and so listed every width. (#15)

A device with no port is labelled by its name alone. The simulator fell
through to the serial branch and came out as "… (nothing transmits) ()". (#14)

platform and specimen pass their own one-line help, so Typer stops printing
their docstrings — notes for whoever reads cli.py, Sphinx roles and source
paths included — and their summaries fit in meshterm --help. A new test runs
--help on every registered command and refuses those markers anywhere. (#16)

Built from the issues alone; the pull requests filed against them were
closed unreviewed and their code was never read.

Closes #14, closes #15, closes #16.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 10:59:17 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 1949d5b1a3 The uConsole guide says the bridge move-over is for setups from 0.9.0 and earlier
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 09:47:16 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 700cbb57b7 Contributing: say hello on Discord first, disclose AI use, and no bots
Three pull requests against toy issues arrived from agents that farm open
issues for contribution stats. None had said a word to the project, and
none disclosed that AI wrote them.

CONTRIBUTING.md now says, in plain words, how MeshTerm was made and who
it wants: people who use it, AI-assisted or not. Before writing code, a
contributor says hello in the new #contributing channel on Discord and
waits for a yes; the pull request carries their Discord username and a
link to that yes. AI use has to be disclosed in the commits and the pull
request; undisclosed or unattended agent output is closed without review.

The PR template, the README's two notes on pull requests, and the docs
index all said small fixes could skip the conversation, and now match.
The release skill's commit carries the AI trailer its seven release
commits went out without.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 09:45:20 -04:00
Jean-Pierre Martineau f02b794861 The release skill never recommends more than a patch bump 2026-09-25 22:16:41 -04:00
Jean-Pierre Martineau 1c355182e5 MeshTerm 0.10.0
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-25 21:46:38 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b7a1ad47c2 The Oxford comma, always, the one-line description included
Every list of three or more takes the serial comma: the one-liner in
pyproject.toml, meshterm/__init__.py, and --help ("USB, Bluetooth, or TCP",
"Windows, macOS, and Linux"), the README, the changelog, the manuals, the
issue and PR templates, and six strings the app shows (the region-name
rule, a repeater setting's help, the diagnostics tool's description, and
the archive confirms). The Windows-console notice is rewrapped so its
comma keeps it inside 72 columns.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 21:28:00 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 5d0bf52717 The docs say what to do when a companion won't connect
A new page, docs/connecting.md, collects every connection failure MeshTerm
reports, with its cause and remedy: who pairs a Bluetooth companion on each
system (MeshTerm on Windows and Linux, macOS's own dialog there, where
--ble-pin does nothing), the terminal's Bluetooth permission on macOS and
its refusal over SSH, reading a screenless companion's PIN over USB,
forgetting a pairing on each system, and the dialout group and ModemManager
for USB on Linux. The README, the docs index, hardware.md, and the CLI
manual's failure section link to it, and --ble-pin says it has no effect
on macOS. The CLI manual's no-radio example shows today's message.

The changelog covers the Linux Bluetooth pairing and link fixes and the
per-step failure messages. configuration.md shows db_path, and a few lists
gain their Oxford comma, WiFi becomes Wi-Fi, and "never-before-seen"
becomes "heard for the first time".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 20:52:08 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 79137598cf Clock sync explains a radio whose clock is ahead instead of calling it malformed
MeshCore firmware sets its clock only forward: CMD_SET_DEVICE_TIME with an earlier time is answered ERR_CODE_ILLEGAL_ARG, which read as 'the device rejected the request as malformed'. Since 2026-09-24 the companion's clock has been ahead of this computer's (itself within 0.7 s of NTP), so every connect's sync was refused. set_clock now reads the radio's clock first: within a minute ahead is in sync and nothing is written; further ahead raises ClockAheadError, which says by how much and that a reboot resets it. The device layer also maps the firmware's refusal to that error, and the simulator's clock now only moves forward, like the firmware's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:10:09 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 bd0c311dbc Bluetooth on Linux stops stranding its own link, and pairs with the PIN first time
Measured against a T1000-E from a uConsole (BlueZ 5.66, btmon on the side).

A connect that BlueZ had to retry stranded its link. bleak retries a link
attempt that failed to synchronise (le-connection-abort-by-local, 3-7 per
connect on a Pi radio) inside connect(), and each one fired meshcore's
disconnect handler, which resets BLEConnection.client to None. bleak's
next retry then connected a client nobody held, meshcore gave up as "not
established", and the live link kept the companion from advertising, so
the retry scanned for 30 s and reported it out of range. Three runs out of
three. While connect() runs, a disconnect is now only recorded, and the
teardown keeps the last live client if meshcore let go of it anyway.

A companion given a PIN after it was paired unauthenticated answers the
subscribe with ATT 0x0E, Unlikely Error (bond Authenticated=0). That is now
read as a pairing refusal, so the stale-bond message and the PIN repair
reach it; with the PIN it re-paired and connected.

BlueZ's Pair left its link up (the companion then stopped advertising to
the connect), and failed at once on the same link flake bleak retries. It
now puts the link down after pairing and retries a flake a bounded 4 times.

And BlueZ starts a pairing of its own when the subscribe is refused, asking
the default agent; with none (any headless machine) it waited on a yes/no
until the companion's 30 s SMP timeout. For the length of a connect
MeshTerm is now the default agent for that one device, KeyboardOnly: with a
PIN it answers, without one it refuses at once, and it never approves a
pairing without the passkey, which is how a bond ends up weak.

Unpaired, no PIN: 33 s -> 4 s to "requires a PIN". Unpaired with the PIN:
paired and connected in 11 s. Paired: connects in 7 s, where it failed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 04:37:51 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 bd222105ae Two tests stop racing CI: the chat waits for its send, the BlueZ fixture leaves os alone
The BlueZ fixture patched os.getpid to return a string, and bluez.os is the
global os module, so Python 3.14's event loop (which opens a pidfd for its own
pid at startup) failed before any test ran. The agent path is now derived from
the real pid instead.

The end-to-end chat test piped Esc beside Enter, so a slow runner left the
screen and cancelled the spawned send before the row was written. It now sends
Esc only once the message is recorded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 04:16:26 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 491325f995 Linux pairs a Bluetooth companion with its PIN, and a serial port says why it won't open
On Linux the PIN never reached the radio: bleak's BlueZ pair() calls
Device1.Pair and nothing else, BlueZ asks a pairing agent for the passkey,
and bleak registers none. Without a desktop agent BlueZ falls back to Just
Works, the firmware refuses a bond without MITM, and the picker looped on
"That PIN was rejected" for a correct PIN. MeshTerm now registers its own
Agent1 for the length of one pairing (core/bluez.py) and calls Pair from
the same bus connection, so BlueZ routes the passkey request to it and
never to the desktop's agent. The Windows repair contract carries over:
an existing bond is reused, force removes it and pairs afresh, and the
outcome is recorded so a refusal names it. Unpair & quit and the stale-
bond message work on Linux too, and the remedies there name Linux and
`bluetoothctl remove` instead of Windows' Settings. bleak no longer gets
the PIN on Linux, where it could only start a second pairing.

A serial port that refuses to open was reported as "didn't answer as a
MeshCore device", with nothing logged. Permission denied now names the
dialout group, a held port names the other program (and ModemManager on
Linux, also named when the port opens but never answers), and a vanished
port says so. A failed startup probe is logged at warning.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 03:58:26 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 ed2ad3b7ab A Bluetooth connect that fails says which step failed
One "requires a PIN" sentence covered every GATT refusal, and a link that
never opened read as "didn't answer as a MeshCore device". A Heltec V4 user
whose Windows bond had gone stale was told to pair a device Settings showed
as paired. Each failure point now has its own message and remedy: link never
opened, stale Windows bond, wrong PIN, device refused to pair (with the
WinRT status), paired but still refused, a BLE probe timeout by stage, and
link up but no identity reply. The PIN dialog shows the diagnosis instead of
calling every refusal a rejected PIN, and each case is logged at warning so
a log sent in from another machine names it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 03:43:15 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 40183e89ca The live feed's SNR and RSSI labels start where their lanes do
Both labels were right-aligned over their numbers, so each ended
mid-value with the unit trailing past it; every other label on the row
starts at its lane's left edge. RSSI also borrowed the SNR's five-cell
field for a reading never wider than four (-120), which set the two
readings three or four cells apart instead of the row's usual two.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 03:40:33 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 6b694e3bd2 Packet classes take shorter names, and the class column narrows to 9
dir messg, chan text, chan data and anon req replace direct message, channel text, channel data and anon request, so the longest class (telemetry, chan text, multipart) is 9 cells and the live feed's class lane gives 5 of its 14 back to the row. The dashboard's traffic tallies and the viewer's headline read the same names from the same table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 03:07:11 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 d019c840a8 The live feed never collapses a packet's class name, and the PicoCalc drops its icon
The class name was dropped below the width the whole row needed, leaving only its icon. It is now drawn at every width, like the scope; a row wider than the screen is read by scrolling the highlighted one with ←→. The class icon beside the name is a family cue once the name is always there, so it follows Platform.menu_icons: on the PicoCalc, where it could only be a one-glyph stand-in, the lane is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 02:38:41 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 01a8f711d6 The session flood scope is framed by MeshTerm, so an older meshcore 2.3 can send scoped
reset_flood_scope and force_unscoped arrived late in meshcore 2.3.x. The uConsole's 2.3.7 satisfies meshcore>=2.3 without them, so every scoped channel send would have crashed on restoring the scope. set_flood_scope now sends the firmware's command 54 itself ([54][0][key], [54][0], [54][1]), as the default scope's command 63 already was. Every other library call MeshTerm makes exists in 2.3.7.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 02:06:44 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b0d89a5ece A received channel message's card names the region it was flooded into
The radio hands a message over as text, not as the frame it rode in, so a 💬 row opened from the live feed had no scope to show. Its card now finds the message's flooded copies in the packet log, the way the message paths dialog does, and reads the scope off them into a scope row. The copies found are kept per row; the region is resolved on every paint, so a name learned while the card is open names it. Direct messages get no row: their copies are encrypted end to end, and a routed one has no scope.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:42:45 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 fa9ef16f2b CLAUDE.md states the live-then-save editor and the one way to send a channel message
A device that holds each edit live until its own save gets the live-then-save shape (the region editor), never a stage; and every channel message goes through send_channel_in_scope under the transmit lock, since a direct send skips the scope window silently.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:38:29 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b89cd266cc The region editor says when a repeater has no home region
The firmware parks its home mark on the wildcard when no region is home, so *^ means no home region set. The wildcard row's note now reads 'unscoped · no home' then, and 'unscoped floods' once a region is home.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:38:00 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 79fe79a857 ^R resends the picked message unscoped, not only the newest
With a message picked, ^R (the Resend chip on the PicoCalc) offers that one: a scoped message of ours is resent unscoped, and a pick that isn't one offers nothing rather than reaching past it to another. With nothing picked it is still the newest. The pick clears once the resend lands at the tail, and the picked-row hint names ^R when it would act.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:37:20 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 bdf166ce6d The live feed's SCOPE column never collapses
It came and went with the terminal's width: hidden between 70 and 83 columns, where the class label won, and always on the PicoCalc. It is now drawn at every width, and the class label is what gives way on a narrow screen, the icon still saying what the packet is. Beside the subject, the lane is on screen even at 53 columns; there it is the readings after it that scroll off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:24:49 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 fcd70c2e2e The packet viewer states a flood's scope on a row of its own
The region rode the route row as a trailing atom (flood · scope harbour), easy to miss on a card opened from the feed's SCOPE lane to see it. Route now says how the packet was routed (flood, direct) and a labelled scope row underneath says where: the region, 'unknown region · code 3fa1', or 'unscoped'. A direct packet has no scope row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:14:51 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7f59bfa5a0 A channel's region is a bare title atom, and a channel with no scope answers -
The chat title reads "#devtest · onqc" rather than "#devtest · scope onqc". `channels scope N` on a channel with no scope prints - (null in --json) and exits 0: sending under the device default is an answer. Only an empty slot still exits 5.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:40:47 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 f0718ce7c1 The Channels list reads LAST before MSGS, and SCOPE only when a channel has one
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:40:47 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7b531fa83a The live feed's scope sits beside the subject, a dash when unscoped
SCOPE moves ahead of the SNR and RSSI readings, since where a flood was allowed to go is a fact about the packet like its subject; the readings are about how it reached us. A plain flood draws a muted dash instead of the word, so the lane is quiet until a row is scoped, and the lane narrows to 10 cells. The Channels list's column test rides along.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:40:47 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 2976bf9e08 Scope means only what MeshCore means by it
The menu's This node / Other nodes / This app sections were called scopes; they now name whose a setting is, and scope is left to a flood's region alone. CLAUDE.md's lexicon says so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:40:46 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 326f6f5479 Another command's refusal no longer ends the channel list early
The meshcore library does not serialize commands, and every command
waiting for "OK or ERROR" takes the first ERROR to arrive. At connect the
channel prewarm runs beside the clock sync, which this companion refuses
as malformed; a slot read handed that refusal took it for "no such slot",
ended the probe there, and cached a list missing every channel after it
for the rest of the session.

Only the firmware's NOT_FOUND (or a code-less refusal from old firmware)
now ends the list. Any other refusal is asked again once, and a second
one, or no answer at all, is a failed read the probe reports as
unfinished and does not cache.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:48:08 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 470959aeb1 The Channels list has a SCOPE column between the channel and its unread
The region each channel's messages are flooded into, blank for one that
sends under the device default. The lane is only as wide as the longest
scope, and shares a budget with the name lane so that at 72 columns it
never cuts the activity sparkline: with a name at its ceiling it gets
eight cells, what the row had spare. On the PicoCalc the row was already
cut inside the activity lane, so there it costs the LAST ages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:38:55 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 62119e6e28 Channel scopes, scoped packets and the default-scope fix are in the manual and changelog
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:37:38 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 169c3272c7 The wildcard scope reaches the command, and an empty slot can't be scoped
On Windows, Click expands any argument holding `*` into the files it
matches, quoted or not, so `chat send --scope '*'` arrived as the working
directory's file names. The entry point turns the expansion off; nothing
MeshTerm takes is a glob.

`channels scope N REGION` on an empty slot did nothing and exited 5, which
tells a script the scope was set and read back empty. A write there is
now a usage error; a read still exits 5, as `share` does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:37:38 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 acd14b3adf An unnamed scope reads as one fact, not two title atoms
"scoped · 3fa1" put a `·` inside the reading, so in the message paths
title it chained as two status atoms. It is now "unknown scope 3fa1",
and "? 3fa1" in the live feed's SCOPE lane, whose heading already says
what the column holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:10:38 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 48444b7d54 The regions command and page are in the manual and the changelog
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:07:40 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7c61c78696 A repeater's regions can be asked from its node page and edited on its admin page
The node page of a repeater shows the regions it was last heard to relay
floods for, whether it relays unscoped floods too, and when it said so, and
asks it once on request. The region store keeps that unscoped flag and the
answer's time beside the regions.

Repeater admin gains a Regions page: the region tree read over the remote
CLI (core/region_admin.py parses the dump, drops a line the 160-byte reply
cut in half, and recovers the rest from the flat lists), flood allowed or
denied per region and for the wildcard, home, default scope, add, remove
and save. Each edit is one command applied at once; the title counts what
a reboot would undo. meshterm regions NODE asks from the command line.

The simulator transcribes the firmware's region CLI, reply cap included.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:07:40 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7cdc6d9ba3 A channel can be given a region its messages are flooded into
The firmware has no per-channel scope, so a channel send is now a window
on the companion's session scope: set it, send, restore it, under a
transmit lock every flood-capable send also takes (DMs, adverts, admin
commands, logins, regions/neighbour requests), so nothing interleaves.
Ack waits stay outside the lock. A scope the radio can't take stops the
message (FloodScopeError naming the firmware it needs) rather than sending
it unscoped or under the default; * checks the firmware version, since
before 1.16 the override would quietly fall back to the default scope.
A failed restore is repaired by the next transmission.

Channel scopes are set from the channel page's Send scope picker (known
regions with their carriers, or a typed name) and from the CLI (channels
scope, chat send --scope). The chat's title carries the scope as a status
atom, ^R/Resend sends the newest scoped message again unscoped after an
amber confirm, and the scope each sent message went under is recorded
(messages.scope, schema v17) so the paths dialog can say what it went
under and when no known repeater carries that region. Device config now
teaches the region store the default scope it reads or sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:02:34 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 2785380d6b Packets say which region a flood was scoped to
The packet viewer's route row reads "flood · scope harbour" (or
"scoped · 3fa1" for a region nobody here has named, or "unscoped")
instead of the library's bare "tc flood"; a direct frame keeps its
route word and states no scope. The live feed gains a SCOPE lane where
the width holds it, 72 columns included (the subject lends it one
cell); the PicoCalc's row already scrolls at 53 and leaves the scope to
the viewer. The message paths dialog states a message's scope once, as
a title atom, read off its arrivals' frames. monitor streams a SCOPE
lane before NAME, and the scope object (or null) under --json.

The viewer and the feed take the region store's scope_of by injection,
falling back to reading the frame alone, and the viewer's card cache
keys on the scope so a region learned while it is open renames it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:50:46 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 c3c0ccf9f9 A scoped flood can be traced back to the region it was sent into
The groundwork for region scopes. core/regions.py holds the firmware's
maths (key = SHA-256("#"+name)[:16] over UTF-8, code = HMAC over type byte
+ payload with the reserved values stepped off) and the Scope reading of a
received frame. core/region_store.py keeps the regions known by name,
which repeaters carry them, and each channel's send scope, and memoizes
resolution. Observations gain transport_code and scope_body (schema v16)
so a scoped frame resolves against a name learned after it was heard.

Device gains set_flood_scope (the session override, * forcing unscoped)
and request_regions (the anonymous REGIONS request). The default scope
is now framed by MeshTerm: the library stored it with a # the firmware
and apps leave off, and padded by characters rather than bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:36:51 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 49603f2b8b Why an SPI radio is added in config.toml and not on the device screen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:13:18 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 430f5b2daa --spi picks the one radio there is, and refuses to guess between two
--spi used to take whichever SPI profile config.toml listed first, a
coin toss that transmits once two radios are configured. It now counts
the radios actually attached: exactly one is used whether or not a
profile names it, which keeps the common single-radio case to a bare
--spi. Two attached, or two SPI profiles with nothing attached to count,
is a selection error (exit 3) that lists them and asks for -p, the same
way several serial devices with no default already are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:12:35 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 f50db48074 meshterm devices lists the radio on the SPI bus
The device screen listed an SPI radio but the CLI inventory didn't, so
the one command for "what is attached?" was silent about it. The listing
moves into spiradio.spi_radios, which both now use, so the two can never
disagree; the row is marked active under --spi or an SPI profile.
resolve_spi becomes public for that.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:59:05 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 2d5e8bb67d Adding a radio on the SPI bus has a walkthrough of its own
configuration.md now takes a non-AIO board from its meshtasticd config
to a working profile: the key-by-key mapping, a whole example, how to
check it, and what the profile deliberately doesn't hold (radio
settings, the preamble, identity). It warns that a key left out keeps
the AIO's value, unlike meshtasticd where it means off. --spi joins the
option lists in cli.md, the README, and the cookbook, radio/ joins the
state-files table, and config.example.toml carries a full wiring example.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 17:44:44 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 dee4674942 The SPI radio listens for the preamble the mesh actually sends
MeshCore transmits a 32-symbol preamble up to SF8 (16 above), but the
node and the bridge configured the SX1262 for the library's fixed 12.
The chip waits for the sync word only about as long as the preamble it
expects, so it locked onto each packet, gave up, and locked on again
further along the same preamble - decoding a packet only when it
happened to catch the tail. On the uConsole that meant 0 of 5 detected
packets decoded after a send; with 32 symbols, 4 of 4.

The node now derives the preamble from the spreading factor as MeshCore
does, and re-sends it to the listening chip when Device config changes
the SF. preamble_length leaves the SPI wiring table (it was protocol,
never wiring) and is retired; the bridge defaults to the same rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 03:12:52 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 9538bfedce MeshTerm drives an SPI radio itself, without the bridge service
A LoRa chip on the host's own SPI bus (the uConsole AIO) is now a
transport like serial, BLE, and TCP: --spi, an SPI profile, or the
picker's row for /dev/spidev1.0. MeshTerm starts a software node as a
child process under whichever Python has openhop-core, talks to it over
a loopback port, and ends it on disconnect; the node also exits with its
parent, so the pins are free whenever MeshTerm is not running.

The node keeps identity, prefs, channels, and contacts under
~/.meshterm/radio/<spidev>/, and on first use copies the bridge's
identity, contacts, and radio settings so it stays the same node.
Board wiring lives in a profile's spi table; radio settings are the
node's own, edited on Device config. Closes #21.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:48:32 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 a93c331226 The SPI bridge remembers its channels across restarts
The radio library keeps its channel table in memory only, so every bridge
restart came up with no channels while MeshTerm's own mute settings
survived. Snapshot the table to channels.json after every set or clear,
the way contacts already were, and replay it at startup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:22:23 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 85fef1f430 The advert test's random source takes its draws as one sequence
Python 3.10's Random.__new__ refuses more than one positional argument, so
_Rng(*draws) broke CI on 3.10 whenever a test fed it two draws.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 23:15:32 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 5021ab8de5 The weekly advert says where its week stands
A muted line under the Weekly advert row on the Preferences page, set in
the value lane: "next advert in 4d" while the week runs, "due — waiting
for a packet" and then "due — after 30 s of quiet" once it is due, "no
device connected", and "the week starts on Apply" while it is only staged
on. The line is a width-aware separator that reads the scheduler's new
status() on every paint, from state the loop already keeps, so it follows
the wait live without touching the file. It is drawn, even blank, for as
long as the preference is on, so the rows below never shift; every message
fits the PicoCalc's 53 columns, and a gallery specimen holds it there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:29:20 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 6b13755c2b Every store writes only the fields its records know
The admin and remote stores round-tripped the raw JSON they read, so any
field a past version wrote survived forever. Both now read into typed
records (AdminCredential; _RemoteNode holding CachedValue settings) and
write out of them, keeping the file's existing spelling. The settings store
keeps only keys device_config still registers: it offers remembered values
back to the radio, so an unregistered key must not reach a restore.

Every persisted record now declares a RETIRED set (all empty: the history
holds no removed fields), device_config gains its own RETIRED registry, and
test_retired pins each store's records so the sweep cannot go vacuous.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:07:48 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 6a8efa2566 A retired name is never reused, and files keep only what the code knows
A value can outlive its key in a file nobody saved since; if the name came
back with a new meaning, an old value that passes the new spec would load
cleanly and mean something else. So every name that leaves is recorded as
retired: preference keys in preferences.RETIRED (the two old advert cadence
keys), stored fields in their record dataclass's own RETIRED set. A test
walks meshterm.core for such records and fails any live name found retired.

Loading preferences refuses retired keys and records every drop (retired,
unknown, invalid); report_dropped logs them once logging exists, INFO for a
retired key and WARNING naming a typo. adverts.json now reads into typed
records and writes out of them, so unknown fields die at the next write.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:00:13 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 a7b664cdfb One flood advert a week replaces the background advert cadences
A MeshCore companion never advertises by itself, so the hourly zero-hop and
daily flood adverts MeshTerm sent by default were the only automatic adverts
on the air, and more than a companion needs. The Background adverts rows leave
Device config and `config advert-cadence` goes with them.

In their place, two preferences under Sending: weekly_flood_advert (off by
default) and advert_quiet_s (5/15/30/60 s, default 30). Each device keeps its
own real-time week, running from the latest of the switch-on, its last flood
advert (automatic or by hand), and its first connection; a due advert survives
a restart. Before sending, each connection must hear a packet, then the air
must stay quiet (nothing heard, nothing of ours sent) for the spell plus a
random 0-5 s, drawn afresh at every break so two waiting radios part.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 20:54:23 -04:00
Jean-Pierre MartineauandClaude Opus 5 42a97f4c5f The demo plays on the repository page
The top of the README showed a still that linked to meshterm.net, because
GitHub strips <video> from markdown and only plays a file uploaded to
GitHub itself. The recording is now uploaded, so the README carries it on a
line of its own and GitHub puts an inline player there. A logged-out
visitor gets the player too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:04:16 -04:00
Jean-Pierre MartineauandClaude Opus 5 278e9f6091 Every release page ends by asking for a star
One line after the changelog text, with the reason: GitHub ranks projects
by stars in its search and topic pages, so a star helps other MeshCore users
find MeshTerm. It lives in the workflow's template rather than the changelog,
so every release carries it and the changelog stays a record of changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:27:35 -04:00
Jean-Pierre MartineauandClaude Opus 5 2bb543d29b The trailer check reads "Fixes #12" and trusts only a 404
It only recognised trailers written with a colon, so "Fixes #12", which
GitHub closes on just the same, went unchecked, as did fix, fixed, close,
closed, resolve and resolved. The colon is now optional and every closing
keyword counts.

It also treated any failed lookup as a missing issue, so a rate limit or an
outage would have left a public comment on a commit saying a real issue did
not exist. Now only a 404 does that; anything else is a warning in the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:10:27 -04:00
Jean-Pierre MartineauandClaude Opus 5 2be9fba424 The Python 3.10 tests stay on Ubuntu 24.04
Python 3.10 reaches end of life in October 2026, and the newer image that
ubuntu-latest moves to may not offer it. The leg would then fail on setup,
and the badge on the public README would turn red with nothing broken.
Pinning it keeps 3.10 tested for as long as MeshTerm says it supports it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:10:27 -04:00
Jean-Pierre MartineauandClaude Opus 5 2a73706cab 0.9.0 is rebuilt from today's main, so it carries today's fixes
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
GitHub release / put the version on meshterm.net (push) Canceled after 0s
JP's call: rebuild 0.9.0 rather than wait for 0.9.1. It has never been public, so the
release is recut from main: Python 3.14 inside the downloads, the corrected written
pages inside the app, companion and room server as the only names for those types, and
the legend that fits a PicoCalc.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  A full-featured TUI MeshCore client for your terminal

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes: #13

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

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

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

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

Fixes: #11

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Being a written page buys the two things a long block most needs and that
a hand-built screen had neither of. Its ## headings are landmarks, so each
pins to the top row while its own section scrolls under it and ^PgUp/^PgDn
step section by section; and the PicoCalc lane picks up Sect up / Sect
down for the same reason a grouped list does. AboutPage already does all
of it, so the page is that page with one verb added.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 12:30:13 -04:00
Jean-Pierre Martineau aed1d91484 MeshTerm 0.3.7
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
GitHub release / put the version on meshterm.net (push) Canceled after 0s
2026-09-19 02:37:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 2e40603a64 Satisfy the lint gates the macOS work walked past
Import order, a docstring reworded rather than have "macOS" capitalised
into something that is not the product's name, two over-long test lines,
and _tls_context moved below the logger it uses so the module keeps its
shape: docstring, imports, constants, logger, then code.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:02:10 -04:00
161 changed files with 21695 additions and 3528 deletions

No files matched your search

+7 -24
View File
@@ -1,31 +1,14 @@
{
"permissions": {
"allow": [
"Bash(python -)",
"Bash(python -m pytest *)",
"Bash(.venv/Scripts/python.exe -m pytest *)",
"Bash(./.venv/Scripts/python.exe -m pytest *)",
"Bash(python -m ruff check *)",
"Bash(python -m ruff format *)",
"Bash(ruff check *)",
"Bash(ruff format *)",
"PowerShell(python -m pytest *)",
"PowerShell(.venv\\Scripts\\python.exe -m pytest *)",
"PowerShell(.\\.venv\\Scripts\\python.exe -m pytest *)",
"Bash(git add *)",
"Bash(git commit *)",
"PowerShell(git add *)",
"PowerShell(git commit *)",
"Bash(PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -c ' *)",
"Bash(python -m pytest tests/test_tui.py -q -k \"progress\")",
"Bash(python -m pytest tests/test_tui.py -q -k \"overlay or progress\")",
"PowerShell([console]::beep\\(880,150\\); Start-Sleep -Milliseconds 60; [console]::beep\\(1175,200\\))",
"Bash(PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -m pytest tests/test_tui.py -q -k \"busy_overlay or scanner or dim_color or overlay_fade\")",
"Bash(PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -m pytest -q)",
"Bash(python -c ' *)",
"Bash(grep -v \"^$\")",
"Bash(PYTHONIOENCODING=utf-8 PYTHONUTF8=1 python -c ' *)",
"Bash(python -m pytest tests/test_channels.py -q)",
"Bash(git commit -m ' *)",
"Bash(.venv/Scripts/python.exe -m meshterm --mock trace --target Alice --profile yagi)",
"Bash(echo \"=== EXIT: $? ===\")"
],
"defaultMode": "dontAsk"
"PowerShell(ruff check *)",
"PowerShell(ruff format *)"
]
}
}
+85 -22
View File
@@ -1,6 +1,6 @@
---
name: release
description: Cut a MeshTerm release — pick the version, write the changelog entry from the commits since the last tag, bump `__version__`, commit, tag, and push so the GitHub release builds. Use when JP says to release, cut a release, ship a version, tag a version, or bump the version, and when asked what would go into the next release.
description: Cut a MeshTerm release — pick the version, write the changelog entry from the commits since the last tag, bump `__version__`, commit, tag, push so the GitHub release builds, and hand JP a Discord announcement to post. Use when JP says to release, cut a release, ship a version, tag a version, or bump the version, and when asked what would go into the next release.
---
# Cutting a MeshTerm release
@@ -48,8 +48,27 @@ A release does not go out on a red suite. If something fails, stop and say so.
## 2. Decide the version
Patch unless JP says otherwise. Say the number you arrived at before you write it anywhere,
so a wrong assumption costs a sentence instead of a tag.
**Always ask, as a multiple choice**, whenever JP asks for a release — even when his
request names a bump, because words like "minor" have meant "a small one" before (he
asked to "bump minor" and meant the patch). One AskUserQuestion, three choices, each
showing the actual number it produces:
- **Patch — X.Y.(Z+1)** (Recommended)
- **Minor — X.(Y+1).0**
- **Major — (X+1).0.0**
**Major asks twice.** If he picks it, ask a second, separate question before writing the
number anywhere — "Release (X+1).0.0? A major version is a promise about compatibility
that can't be taken back." — with *Yes, (X+1).0.0* and *No, go back to patch* as its
choices. Only a second yes makes it major.
Then say the number you arrived at before you write it anywhere, so a wrong assumption
costs a sentence instead of a tag.
**Don't recommend a bigger bump**, however many features or changed defaults the release
carries — not even on the strength of the changelog's SemVer note. 0.10.0 went out that way
on a recommendation, and JP would have preferred 0.9.1. A minor or major bump happens only
when he names it.
## 3. Write the entry
@@ -96,31 +115,36 @@ version is written**. `[project] version` in `pyproject.toml` is `dynamic` and h
reads the attribute, so the number in a built wheel cannot drift from the number in the
source. Do not add a second one.
### Then re-point the README's download commands
### The README needs nothing — deliberately
`installers.yml` names each asset `meshterm-${version}-<label><ext>`, so **the file a reader
downloads is named after the release** — and the README tells them what to type at it. Those
lines are prose, not code, so nothing rewrites them for you:
It used to. `installers.yml` names each asset `meshterm-${version}-<label><ext>`, so every
download command in the README named a version, and every release meant grepping the old
number out of five of them. That step failed quietly when it was missed: a README naming
last release's file still reads perfectly well, and only breaks for the one person who
types it.
```
grep -n '<the previous version>' README.md
```
`github-release.yml` now attaches a **version-less copy of every binary** beside the
stamped one, so `releases/latest/download/meshterm-macos-arm64` is a URL that never moves —
which is what the README and `docs/uconsole.md` use. Nothing in either names a version.
Leave them alone, and if you find yourself adding a version to an install command, add an
alias to the gather step instead.
Every hit is a download or run command (`.\meshterm-X.Y.Z-windows-x64.exe`,
`chmod +x meshterm-X.Y.Z-*`, the `xattr` quarantine note, the `MESHTERM_HOME` trial runs).
Replace the old number with the one just bumped to, and re-read each line: they must name
the file the workflow is about to publish, or the first thing a new user does fails.
Also check `docs/cli.md` for a sample document carrying a `"version"` field, which is the
live `{version}` placeholder and must show the new number too.
What *does* still carry the number: `docs/cli.md` has a sample document with a `"version"`
field, which is the live `{version}` placeholder and must show the new one.
## 5. Commit, tag, push
```
git commit -am "MeshTerm X.Y.Z"
git commit -a -m "MeshTerm X.Y.Z" -m "Co-Authored-By: <the model running this skill> <noreply@anthropic.com>"
git tag -a vX.Y.Z -m "MeshTerm X.Y.Z"
```
The trailer is not optional: the changelog entry in this commit is prose you wrote, and
CONTRIBUTING.md asks every contributor to say when an AI tool wrote part of a change —
the project's own commits say it first. Name the model actually running, not a
hardcoded one. Seven release commits (0.3.1 through 0.10.0) went out without it because
this line used to omit it.
Commit on `main` — never a topic branch. Then **confirm with JP before pushing**, because
the push is the act that builds and publishes the release:
@@ -143,21 +167,60 @@ Five platform builds, the release job, and `website` — which rebuilds meshterm
tag so the site shows the new version — so it takes a few minutes. If only `website` fails,
the release itself is fine (it runs after publishing); the usual cause is the
`SITE_DEPLOY_KEY` secret, and once that is fixed `gh run rerun <run-id> --failed` finishes
it. **Check the notes
actually came out of the changelog** — a green run is not proof, because the fallback is a
successful step:
it.
The notes open with an install block the workflow writes from the tag — the same commands
as the README, but pinned to *this* release's own assets — and the changelog follows under
`## What changed`. **Check that second half actually came out of the changelog**, because a
green run is not proof: the fallback is a successful step.
```
gh release view vX.Y.Z --json body --jq .body | head -3
gh release view vX.Y.Z --json body --jq .body | sed -n '/## What changed/,$p' | head -5
```
If that reads "No changelog entry for X.Y.Z.", the extraction failed rather than the
changelog being empty. Fix the workflow, then repair the published notes in place with
`gh release edit vX.Y.Z --notes-file` — the binaries are fine and the tag does not move.
Fourteen assets is the right count — five version-stamped binaries, five version-less
copies of them, `SHA256SUMS`, and the three licence files:
```
gh release view vX.Y.Z --json assets --jq '.assets | length'
```
If a *build* fails, the tag is already public: fix forward with a new patch version rather
than deleting and re-pushing a tag people may have fetched.
## 7. The Discord announcement — for JP to post
Once the release has landed and checked out, end by showing JP a short announcement for
the Discord server's `#announcements` channel, in a code block he can copy as-is. **He
posts it himself** — never post it, never hand it to a bot. Write it for the people on
the server, in plain words (they use MeshTerm; they don't read the diff):
- one bolded opening line naming the version and what it is, in a few words;
- two to five short bullets of what changed *for them*, from the changelog entry —
the headline items only, not every line;
- the release link, and one line on how to get it (the download page, or the README's
install line for someone who installed that way);
- Discord markdown only (`**bold**`, `- ` bullets, bare links); no headings, no tables,
no emoji beyond one at the start if it fits. Well under Discord's 2000 characters —
it should fit on a phone screen.
```
**MeshTerm 0.10.1 is out**: three small command-line fixes.
- `records --width` now tells you when a width doesn't exist, instead of looking empty
- The simulator's row in `devices` lost a stray `()`
- `platform --help` and `specimen --help` read like the rest
Download: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.1
```
Say who it was written for, then show it last in the reply, so it's the easiest thing to
copy.
## At 0.9.0 the changelog resets
The first public release collapses everything below it into a single entry reading "first
+7 -2
View File
@@ -1,6 +1,11 @@
# Kept in step with meshterm/assets/pages/support.md deliberately. If the app offers a
# way to chip in and this file doesn't, the two have drifted and one of them is lying.
buy_me_a_coffee: johnputer
patreon: MeshTerm
# PayPal is not one of GitHub's built-in platforms, so it is a custom link, written
# without a scheme so the sidebar shows it as cleanly as it can. It stays the donate
# button, not a paypal.me link: a payment through the button is recorded as a donation,
# which PayPal's purchase protection does not cover, so a donor cannot claim a refund
# for an item that never arrived.
custom:
- "https://www.patreon.com/c/MeshTerm"
- "https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6"
- "paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6"
+66 -106
View File
@@ -7,12 +7,22 @@ body:
value: |
Hopefully you never need this form.
If you do: how much of this you fill in really does decide what happens next. A
report I can act on gets worked on. A vague one usually doesn't, because I can't
do anything with it. If it isn't worth describing properly, it's probably not
worth fixing.
If you do: start by running this, and attach the file it writes.
Most of these take ten seconds each.
```
meshterm diagnostics --out bug.md
```
It answers which MeshTerm, on which machine, in which terminal, talking to which
radio over which transport and firmware — most of what I would otherwise ask one
question at a time, and the details people most often end up guessing at. Nothing
in it is private: no keys, no pairing PINs, no positions, no contacts. That is a
tested property, not a promise.
The **Diagnostics** page in the menu saves the same file (with `s`, or F3 on the
PicoCalc), next to the log.
The rest of this form asks only for what that file cannot know.
- type: textarea
id: problem
@@ -49,118 +59,62 @@ body:
validations:
required: true
- type: textarea
id: workaround
attributes:
label: Is there a way around it
description: Anything that avoids it, or gets you unstuck. Say so even if it's ugly.
- type: input
id: version
attributes:
label: MeshTerm version
description: It's on the splash screen when you start, and on the About page.
placeholder: "0.2.0"
validations:
required: true
- type: input
id: hardware
attributes:
label: What are you running MeshTerm on
description: >
Desktop, laptop, Mac, or a handheld — and if it's a handheld, which one.
placeholder: "ClockworkPi uConsole (CM4), or: desktop PC"
validations:
required: true
- type: input
id: os
attributes:
label: Which OS, exact version
placeholder: "Windows 11 24H2 / macOS 15.3 / Debian 12"
validations:
required: true
- type: input
id: terminal
attributes:
label: Which terminal, and how big
description: >
PowerShell, Windows Terminal, iTerm2, the PicoCalc console, ssh from something.
Size in columns x rows if you know it — a lot of layout bugs are really terminal
bugs.
placeholder: "Windows Terminal, 120x40"
validations:
required: true
- type: input
id: companion
attributes:
label: Which companion device
description: >
Be precise. There are a lot of these and they don't behave the same. "A Xiao"
doesn't narrow it down — "Seeed XIAO nRF52840 + Wio-SX1262" does. Leave blank if
no radio was involved.
placeholder: "Heltec V3 / RAK4631 / XIAO nRF52840 + Wio-SX1262"
- type: input
id: firmware
attributes:
label: Which firmware version is on it
placeholder: "MeshCore 1.15.0"
- type: dropdown
id: transport
attributes:
label: How is it connected
options:
- USB / serial
- Bluetooth
- TCP
- SPI-to-TCP bridge
- Something else (say so below)
- No radio — simulator / --mock
validations:
required: true
- type: dropdown
id: flavour
attributes:
label: Regular or PicoCalc
description: >
MeshTerm draws itself differently on a PicoCalc — narrower, fewer colours, a
different font. A bug in one often doesn't exist in the other, so this saves a
round of guessing.
options:
- Regular (desktop, laptop, ssh)
- PicoCalc handheld
- Not sure
validations:
required: true
- type: dropdown
id: mock
attributes:
label: Does it still happen with `meshterm --mock`
description: >
`--mock` runs the whole app against a fake radio. If the bug survives that, it's
in MeshTerm and I can chase it without hardware. If it doesn't, it's in the link
or the radio, which is a different hunt. Skip it if the bug is about connecting
in the first place.
`--mock` runs the whole app against a fake radio. If the bug survives that, it is
in MeshTerm and I can chase it without owning your hardware. If it doesn't, it is
in the link or the radio, which is a different hunt. This one question decides
which of those two jobs this is, so it is worth the minute it takes.
options:
- "Yes, still happens with --mock"
- "No, --mock is fine"
- "Haven't tried"
- "Doesn't apply"
- "Doesn't apply — the problem is connecting"
validations:
required: true
- type: textarea
id: diagnostics
attributes:
label: The diagnostics block
description: >
Attach `bug.md` by dragging it in, or paste its contents here. If you genuinely
cannot produce one, because MeshTerm will not start at all, write down why instead,
along with your version, and I will work with that.
validations:
required: true
- type: input
id: handheld
attributes:
label: Which handheld, if it is a handheld
description: >
Only if you are running on something the diagnostics file cannot name. It knows a
PicoCalc from a regular terminal, but a uConsole and a laptop look much the same to
it. Leave blank on a desktop, laptop, or Mac.
placeholder: "ClockworkPi uConsole (CM4)"
- type: input
id: radio
attributes:
label: Which radio and firmware, if MeshTerm could not reach it
description: >
The diagnostics file reads the model and firmware off the radio itself, so when the
problem *is* connecting, it has nothing to read. That is the one case where I need
you to tell me. Be precise — "a Xiao" covers several boards that do not behave
alike.
placeholder: "Seeed XIAO nRF52840 + Wio-SX1262, MeshCore 1.15.0"
- type: textarea
id: error
attributes:
label: The exact error, if there was one
description: >
Copy it as text rather than photographing the screen — text can be searched, and
a photo of a terminal is hard to read. Paste the whole traceback if it crashed.
Copy it as text rather than photographing the screen — text can be searched, and a
photo of a terminal is hard to read. Paste the whole traceback if it crashed.
render: text
- type: textarea
@@ -169,10 +123,16 @@ body:
label: The log file
description: >
MeshTerm keeps one at `~/.meshterm/meshterm.log` (or wherever `MESHTERM_HOME`
points). Attach it, or paste the last twenty lines. If the problem is hard to
catch, set **Log detail** to `DEBUG` on the Preferences page, do it once more,
and send that instead — it records a great deal more.
render: text
points). Attach the file, or paste the last twenty lines inside a ``` fence. If the
problem is hard to catch, set **Log detail** to Debug on the Preferences page,
restart MeshTerm, do it once more, and send that instead. It records a great deal
more.
- type: textarea
id: workaround
attributes:
label: Is there a way around it
description: Anything that avoids it, or gets you unstuck. Say so even if it's ugly.
- type: textarea
id: extra
+5 -3
View File
@@ -4,10 +4,12 @@ contact_links:
url: https://discord.gg/AZwe5Uvb3S
about: >
Setup trouble, "is it supposed to do this", or anything you want a second opinion
on. The support forum is the place. Someone running your radio has probably hit it.
on. The #support forum is the place. Someone running your radio has probably hit it.
- name: No GitHub account? Report it on Discord instead
- name: "No GitHub account? Report it in the #bugs forum instead"
url: https://discord.gg/AZwe5Uvb3S
about: >
Drop reproducible bugs in #bugs and they'll get moved here. Being told beats
One post per bug, tagged, with the diagnostics file attached. Real ones get opened
here and your post is renamed with the issue number, so you can follow it without an
account — and something comes back to tell you when it ships. Being told beats
filing it tidily.
+17 -4
View File
@@ -2,16 +2,29 @@
<!-- The why matters more than the what — the diff already says the what. -->
## Related issue
## Discord
<!-- CONTRIBUTING asks for an issue first on anything non-trivial, so a large PR with no
issue behind it will get a conversation before it gets a review. Link it here. -->
<!-- Your Discord username on the MeshTerm server, and a link to the message in
#contributing where you got a yes. A pull request without them is closed without
a review. -->
Username:
Link to the yes:
## AI use
<!-- Required. Did an AI tool write any part of this change? If yes: which tool, what it
did, and what you checked yourself. Each commit it helped with also needs a line at
the end naming it, like `Co-Authored-By:` or `Assisted-by:`. If you used AI and don't
say so, this pull request is treated as a bot's and closed. -->
## Before you mark it ready
- [ ] I've read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md) and agree its licensing terms apply to this contribution
- [ ] I've put my Discord username and a link to the yes above
- [ ] If I used AI, I said so above and in the commits it helped with
- [ ] `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
- [ ] New screens, rows, and hints go through the helpers in `ui/menus.py`, and follow the
lexicon and UX standards in `CLAUDE.md`
- [ ] Anything user-visible has a line in `CHANGELOG.md` under `[Unreleased]`
+11 -6
View File
@@ -3,7 +3,7 @@
# Split in two on purpose. Style is a property of the source, not of the machine, so
# `lint` runs once and its failure is unambiguous. Behaviour is a property of the
# machine, so `test` runs everywhere we claim to work — the description promises
# Windows, macOS and Linux, and until this file existed that promise had only ever been
# Windows, macOS, and Linux, and until this file existed that promise had only ever been
# checked on one of them.
name: CI
@@ -33,7 +33,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.10"
python-version: "3.14"
cache: pip
cache-dependency-path: pyproject.toml
@@ -59,14 +59,19 @@ jobs:
# 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.
#
# 3.10 stays on Ubuntu 24.04. It reaches end of life in October 2026, and a
# newer image that ubuntu-latest moves to may not offer it at all. That would
# turn the badge red with nothing broken. Drop this leg when the floor rises.
include:
- { os: ubuntu-latest, python: "3.10" }
- { os: ubuntu-24.04, python: "3.10" }
- { os: ubuntu-latest, python: "3.11" }
- { os: ubuntu-latest, python: "3.12" }
- { os: ubuntu-latest, python: "3.13" }
- { os: ubuntu-latest, python: "3.14" }
- { os: windows-latest, python: "3.10" }
- { os: windows-latest, python: "3.13" }
- { os: macos-latest, python: "3.13" }
- { os: windows-latest, python: "3.14" }
- { os: macos-latest, python: "3.14" }
steps:
- uses: actions/checkout@v4
@@ -97,7 +102,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.14"
- run: pip install build twine
+118 -7
View File
@@ -55,6 +55,10 @@ jobs:
# Every platform's artifact, flattened into one directory of files to attach.
- uses: actions/download-artifact@v4
with:
# The build uploads one artifact nobody should ever download: the unsigned
# Windows binary it hands to SignPath. Everything meant for the release page is
# named `meshterm-…`, so the pattern is the filter.
pattern: meshterm-*
path: artifacts
merge-multiple: true
@@ -62,14 +66,45 @@ jobs:
id: gather
shell: bash
run: |
tag="${{ inputs.tag || github.ref_name }}"
version="${tag#v}"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
mkdir -p release
find artifacts -type f -exec mv {} release/ \;
ls -lh release/
# One combined checksum file is easier to verify against than five loose ones.
(cd release && cat *.sha256 > SHA256SUMS && rm -f *.sha256)
tag="${{ inputs.tag || github.ref_name }}"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
cd release
# A second, version-less copy of every binary. GitHub's evergreen download URL,
# `releases/latest/download/<name>`, resolves against an exact asset name and
# nothing else — so a link that always fetches the newest build needs a name
# that never moves. That is what lets the README, the website and anyone's
# install script say `meshterm-macos-arm64` once instead of naming a version
# that goes stale the moment the next release lands. The version-stamped file
# stays beside it: it is the one you want on disk when you keep several builds
# around, and it is what the release-notes install block points at, since that
# page is about this release rather than the latest one.
made=0
for versioned in "meshterm-$version"-*; do
case "$versioned" in *.sha256) continue ;; esac
[ -e "$versioned" ] || continue
stable="meshterm-${versioned#meshterm-$version-}"
cp "$versioned" "$stable"
sha256sum "$stable" > "$stable.sha256"
made=$((made + 1))
done
# Nothing matched means the tag and `meshterm.__version__` disagree, which is a
# broken release either way: the notes below are about to name files by version
# that the build did not produce. Fail here, where it is one line to read.
if [ "$made" -eq 0 ]; then
echo "::error::No binary named meshterm-$version-*; $tag and meshterm.__version__ disagree."
exit 1
fi
echo "$made version-less aliases made"
# One combined checksum file is easier to verify against than ten loose ones.
cat *.sha256 > SHA256SUMS && rm -f *.sha256
- name: Take the release notes from the changelog
id: notes
@@ -92,6 +127,7 @@ jobs:
awk -v v="$VERSION" '
$0 ~ "^## \\[" v "\\]" {found=1; next}
found && /^## \[/ {exit}
found && /^\[[^]]+\]: / {next}
found {print}
' CHANGELOG.md > notes.md
if ! grep -q '[^[:space:]]' notes.md; then
@@ -99,6 +135,81 @@ jobs:
fi
echo "--- notes ---"; cat notes.md
# Most people who open a release page came to install the thing, so the commands go
# above the changelog rather than in a link to a README that names some other
# version. Written here rather than kept as prose anywhere means they can only ever
# name *this* release: the tag interpolates, so the URLs are the assets attached to
# the very page they are printed on, and there is no example version to go stale.
#
# The heredoc is quoted, so the markdown's own backticks stay backticks instead of
# becoming command substitution, and the two placeholders are filled by `sed`
# afterwards. Neither a tag nor a version can contain a slash, so the substitution
# needs no escaping.
- name: Open the notes with this release's install commands
shell: bash
env:
TAG: ${{ steps.gather.outputs.tag }}
VERSION: ${{ steps.gather.outputs.version }}
run: |
cat > install.md.in <<'INSTALL_MD'
## Install
One file, no Python needed.
**macOS** — paste these into Terminal. Use `curl`, **not your browser**: a browser
flags the download and macOS then refuses to open it at all, and on Sequoia and
later right-click → Open no longer gets you past it. `curl` flags nothing, so it
simply runs.
```bash
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/download/@TAG@/meshterm-@VERSION@-macos-arm64
chmod +x meshterm
./meshterm
```
That is the build for Apple silicon Macs (M1 and later). On an Intel Mac, swap
`arm64` for `x64`.
**Linux** — the same three lines with `linux-x64`, or `linux-arm64` on a uConsole
or another 64-bit ARM handheld.
```bash
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/download/@TAG@/meshterm-@VERSION@-linux-x64
chmod +x meshterm
./meshterm
```
**Windows** — download [meshterm-@VERSION@-windows-x64.exe](https://github.com/jpmartineau/MeshTerm/releases/download/@TAG@/meshterm-@VERSION@-windows-x64.exe), then run it from Windows
Terminal or PowerShell rather than by double-clicking, so you can read anything it
says on the way up.
```powershell
.\meshterm-@VERSION@-windows-x64.exe
```
Every binary below is listed in `SHA256SUMS`. The version-less copies —
`meshterm-macos-arm64` and its four siblings — are the same binaries under a name
that never changes, for anything that wants whichever release is newest without
being told its number. [Installing with pip or pipx, and the rest of the
detail](https://github.com/jpmartineau/MeshTerm#install).
---
## What changed
INSTALL_MD
sed -e "s/@TAG@/$TAG/g" -e "s/@VERSION@/$VERSION/g" install.md.in > install.md
# One ask, at the end, with the reason. The install commands keep the top of
# the page, and the changelog stays a record of what changed.
cat > star.md <<'STAR_MD'
---
If MeshTerm is useful to you, please give [the repository](https://github.com/jpmartineau/MeshTerm) a ⭐. It helps other MeshCore users find it, because GitHub ranks projects by stars in its search and topic pages.
STAR_MD
cat install.md notes.md star.md > body.md
echo "--- body ---"; cat body.md
- name: Create it
env:
GH_TOKEN: ${{ github.token }}
@@ -107,7 +218,7 @@ jobs:
run: |
gh release create "$TAG" release/* \
--title "MeshTerm ${TAG#v}" \
--notes-file notes.md \
--notes-file body.md \
--verify-tag \
$PRERELEASE
echo "https://github.com/${{ github.repository }}/releases/tag/$TAG"
@@ -142,7 +253,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.14"
- name: Rebuild the site from this release
run: |
+48 -3
View File
@@ -59,9 +59,10 @@ jobs:
- uses: actions/setup-python@v5
with:
# Oldest supported version on purpose: a binary built against an older glibc
# and an older Python runs on more machines than one built against the newest.
python-version: "3.10"
# The Python the project builds with, bundled into every binary. How widely a
# Linux binary runs is set by the runner's glibc, which is why the Linux legs
# stay on ubuntu-22.04 rather than ubuntu-latest.
python-version: "3.14"
- run: pip install . pyinstaller
@@ -118,6 +119,50 @@ jobs:
"$GITHUB_WORKSPACE/$bin" contacts --help > /dev/null
echo "runs, finds its assets, and its tools registered"
# The Windows binary is code-signed, so Windows has a publisher to recognise
# instead of an unknown file it has never seen before — SmartScreen's warning eases
# as that publisher's downloads accumulate, and antivirus engines, which flag
# unsigned PyInstaller one-file builds on sight, are markedly less trigger-happy.
# The certificate belongs to SignPath Foundation, who issue it free to open-source
# projects and sign on their own infrastructure; nothing secret exists on this
# runner, and there is no key here to leak. Its condition is that the artifact
# provably came from this repository's CI, which is why the binary is handed over
# as a GitHub artifact rather than uploaded: SignPath reads the workflow run behind
# that artifact id and refuses anything it cannot trace to an approved branch.
#
# Signed between the build's own smoke test and the naming step, so what gets
# verified is the build and what gets checksummed is the signed file — a checksum
# taken before signing would describe a binary nobody ever downloads.
#
# Both legs are skipped wholesale until the account exists (the variable is unset
# until then), so this file builds unsigned Windows binaries in the meantime rather
# than failing, and a fork with no SignPath project of its own is unaffected.
- name: Hand the Windows binary to SignPath
id: unsigned
if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != ''
uses: actions/upload-artifact@v4
with:
# Deliberately not `meshterm-*`: the release workflow attaches every artifact
# matching that prefix, and this one is the unsigned draft.
name: unsigned-${{ matrix.label }}
path: packaging/dist/meshterm.exe
if-no-files-found: error
- name: Sign it
if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != ''
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
project-slug: meshterm
signing-policy-slug: release-signing
artifact-configuration-slug: exe
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
# The signed file comes back where the unsigned one was built, under the same
# name, so every step after this one is untouched by any of it.
wait-for-completion: true
output-artifact-directory: packaging/dist
- name: Name it after the version and the platform
shell: bash
run: |
+91
View File
@@ -0,0 +1,91 @@
name: Issue trailers
# A commit that names an issue is the only thing connecting a fix to the person
# who reported it. GitHub resolves that link silently: `Fixes: #42` against an
# issue that does not exist is not an error, it is nothing at all -- the issue
# stays open, the reporter is never told, and the commit looks like it did the
# job. A typo costs a whole loop and announces itself nowhere.
#
# So this checks the trailers that ARE there rather than nagging about the ones
# that are not. Most commits here are internal work that legitimately closes
# nothing, and a workflow that complains about all of them gets muted in a week.
#
# Cross-repo trailers (`Fixes: owner/repo#12`) are reported but not verified:
# GITHUB_TOKEN only reaches this repository.
on:
push:
branches: [main]
permissions:
contents: write # to leave a comment on the offending commit
issues: read
jobs:
check:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps:
- name: Verify every issue named in a trailer exists
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
COMMITS: ${{ toJSON(github.event.commits) }}
run: |
set -euo pipefail
echo "$COMMITS" > /tmp/commits.json
count=$(jq 'length' /tmp/commits.json)
echo "checking $count commit(s)"
problems=0
for i in $(seq 0 $((count - 1))); do
sha=$(jq -r ".[$i].id" /tmp/commits.json)
msg=$(jq -r ".[$i].message" /tmp/commits.json)
short="${sha:0:8}"
# Trailers only: a line that starts with the keyword. A bare "#12"
# in prose is a reference someone wrote on purpose, not a claim
# that this commit closes anything. The colon is optional, because
# GitHub closes on "Fixes #12" just as it does on "Fixes: #12", and
# the keywords are every form GitHub accepts, plus refs and see.
refs=$(printf '%s\n' "$msg" \
| grep -oiE '^(fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved|refs|see)(:[[:space:]]*|[[:space:]]+)([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)?#[0-9]+' \
|| true)
[ -z "$refs" ] && continue
while IFS= read -r ref; do
[ -z "$ref" ] && continue
target=$(printf '%s' "$ref" | sed -E 's/^[A-Za-z]+:?[[:space:]]*//')
num="${target##*#}"
if [ "$target" != "#${num}" ]; then
repo="${target%%#*}"
echo " $short -> $target (other repository, not verified here)"
continue
fi
if state=$(gh api "repos/$REPO/issues/$num" --jq .state 2>/tmp/gh.err); then
echo " $short -> #$num ($state)"
elif ! grep -q 'HTTP 404' /tmp/gh.err; then
# Only a 404 means the issue is missing. Anything else (a rate
# limit, an outage) says nothing about the trailer, and a public
# comment calling a real issue missing would be worse than silence.
echo "::warning::could not check #$num for commit $short: $(head -c 200 /tmp/gh.err)"
else
echo "::error::commit $short names #$num, which does not exist in $REPO"
problems=$((problems + 1))
gh api "repos/$REPO/commits/$sha/comments" \
-f body="This commit's trailer names **#$num**, which does not exist in \`$REPO\`.
GitHub resolves a closing keyword silently, so nothing failed and nothing was linked — the issue this was meant to close is still open, and whoever reported it has not been told. Worth a follow-up commit with the right number, or a note on the issue itself." \
>/dev/null
fi
done <<< "$refs"
done
if [ "$problems" -gt 0 ]; then
echo "::warning::$problems trailer(s) pointed at issues that do not exist"
fi
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.14"
- run: pip install build twine
+231 -830
View File
File diff suppressed because it is too large. Load diff
+58 -12
View File
@@ -1,6 +1,6 @@
# MeshTerm — working notes for Claude
MeshTerm is a full-screen terminal companion for MeshCore LoRa mesh devices. The UI is a
MeshTerm is a full-featured TUI MeshCore client for your terminal. The UI is a
prompt_toolkit session rendering Rich content (`meshterm/ui/`), tools register into a
menu (`meshterm/tools/`), services run in the background (`meshterm/services/`), and
everything heard is recorded to SQLite (`meshterm/persistence/`).
@@ -56,7 +56,7 @@ them instead of hand-rolling:
| Term | Meaning |
|---|---|
| node | any device on the mesh — a radio broadcasting packets; roles: client, repeater, room server. The umbrella term |
| node | any device on the mesh — a radio broadcasting packets; roles: companion, repeater, room server, sensor. The umbrella term |
| contact | a node your device knows: **discovered** (heard broadcasting, not yet added) or **added** (in the contact list, messageable). Every contact is a node; not every node is a contact |
| heard | received from ("last heard", "first heard") — never "seen" in UX text |
| key | the full fixed-length value — a node's public key, a channel secret |
@@ -64,11 +64,14 @@ them instead of hand-rolling:
| path | an ordered hop spec you compose or force (`a1,3d,…`) |
| route | the concrete node sequence a trace walked or will walk |
| via | prefix for a packet/message's relay chain |
| region | a named area a repeater relays floods for (`region put yul`); shown bare, never `#yul` — the `#` belongs to key derivation only |
| scope | the region a flood is limited to — a packet's, a channel's send scope, the device's default scope. A direct packet has none. MeshCore's sense and the only one: the menu's This node / Other nodes / This app sections name *whose* a setting is, never a "scope" |
| unscoped | a plain flood, relayed by any repeater that allows the wildcard `*` |
| Back | leave the current screen/list — Esc's word, and a row's only where leaving is a *choice* (see below) |
| Quit | leave the app (main menu, device splash) — nowhere else |
Node vs contact — the boundary: **node** is the hardware/participant sense — the map,
mesh walk, heard-nodes, relay hops, graph vertices, and node *types* (client/repeater/room/
mesh walk, heard-nodes, relay hops, graph vertices, and node *types* (companion/repeater/room server/
sensor) all speak "node". **Contact** is the saved-identity sense — the Contacts screen,
the courier recipient, anything you *address*. The reception/persistence layer
(`observations.node`, `HeardNode`, `heard_nodes()`, `trace_hops.node`) stays "node"; the
@@ -84,6 +87,19 @@ machine setup — paths and device profiles (`core/config.py`, `config.toml`, a
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.
What MeshTerm writes, it writes from what it knows: a store reads its file into typed
records and writes back out of *them*, never the raw document it read, so a field no
record knows (retired, or invented by a hand edit) is gone at the next write — every
`*_store.py` and `preferences.toml` work this way, and the settings store keeps only
keys `device_config` still registers, since it offers them back to the radio. And
**a name that leaves is retired for good**, never reused with another meaning, because a
value can outlive its key in a file nobody saved since, and an old `30` that passes a new
spec means something else undetectably. A preference key goes in `preferences.RETIRED`
(loading refuses it; `report_dropped` logs what the next save will drop); a stored field
goes in its record dataclass's own `RETIRED` set — the record is the path, not the leaf.
`tests/test_retired.py` fails any live name found in either. Units live in the name
(`_s`, `_hours`, `_db`), so a changed unit is a new name by construction.
Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now ago"),
`format_age` for aligned columns ("now", "5m").
@@ -132,11 +148,21 @@ deliberately, one at a time, and say why in the code.
for as long as it is true. Chat is the same shape with a different peelable thing — its
first Esc unpicks a selected message. It used to be a 2–2 split, which put opposite
outcomes behind the same keystroke on the same affordance.
- **^W** unwinds every frame back to the main menu, **^Q** quits from anywhere. Both are
answered in `TuiSession._dispatch` and neither is ever advertised — a global verb has no
screen to belong to, and the F-key lane has only three free slots per screen. They are
stated once, on the About page. `test_navigation` walks every string literal in the
package to keep them out of the UI. ^W arms **every frame on the stack**, not just the
- **^W** unwinds every frame back to the main menu, **^Q** asks to quit from anywhere: it
floats the same confirm the menu's Quit row opens, detached over whatever is up so
nothing under it stops, and a second ^Q while it asks leaves at once
(`TuiSession.request_quit`; ^Q sits one key from ^W). Both are answered in
`TuiSession._dispatch`, and neither is advertised — a global verb has no screen to
belong to, and the F-key lane has only three free slots per screen — except `^Q quit?`
on the main menu's own hint, where Esc is inert and the reader looks for the way out.
On picocalc that atom rides the title bar's tail (`frame._way_out`), and the menu's lane
puts the way out on F3: `Quit?` asks, its Shift half F8 `Quit!` leaves without asking
(`quit_now`, the one door that skips the confirm, and only on the menu).
`test_navigation` walks every string literal in the package to keep them out of the rest
of the UI.
- **Esc at the main menu does nothing** but peel a typed filter: the menu is the bottom of
the stack, so Esc-mashing up to it settles there. Leaving is the Quit row or ^Q, both
behind the confirm. ^W arms **every frame on the stack**, not just the
top (`request_pop_all`), stopping at anything modal: a screen opened from a key handler
— the packet viewer, the chat's delivery paths, a trace path flow — runs in a task no
navigation frame awaits, so an unwind sent only to the top died there while the hub
@@ -166,6 +192,18 @@ deliberately, one at a time, and say why in the code.
shape in `ReorderScreen`. A **Quit** row is likewise kept (main menu, device splash):
it *initiates* the app's terminal action behind a confirm, and on the splash it is the
only statement that the app can be left at all.
- **An editor has one of two shapes, chosen by who holds the change.** Where MeshTerm
holds it until told (Device config, Preferences, Repeater admin's settings), edits are
*staged* and nothing reaches the device until Apply — the `exit_rows` shape above.
Where the **device** holds each edit live in RAM until its own save command (a
repeater's region table: every edit takes effect at once, and a reboot undoes whatever
`region save` did not persist), edits apply immediately and the page is *live, then
save*: the title counts unsaved edits (`· 2 unsaved`), the page ends on the same
shape reworded — `✓ Save n changes to the …` over `✗ Back — a reboot undoes them` —
and Esc with unsaved edits asks first (amber). Never stage what the device already
holds live: the edits depend on each other there (a region must exist before it can be
allowed), and a stage would have to re-implement the firmware's rules to order them.
`ui/region_editor.py` is the reference.
- **A cursor clamps at both ends — nothing in the app rolls over.** ↓ off the last row
and ↑ off the first stay put, in a select list, a hand-drawn action list, a reorder
list, a dialog's button row, everywhere. The window follows the highlight, so a roll
@@ -237,7 +275,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
- ≤72 cells. Sentence shape: navigation keys, then action keys, **Esc last**.
- Esc verb by surface: `Esc back` leaves a screen · `Esc close` dismisses a read-only
floating view · `Esc cancel` abandons a prompt/dialog · `Esc keep` leaves a value
picker unchanged · `Esc quit` only at the main menu · `Esc bye` only on the device
picker unchanged · no Esc atom at the main menu, which ends on `^Q quit?` instead ·
`Esc bye` only on the device
splash, which is the door rather than a screen — nothing has been started there to
quit out of. While a find-as-you-type filter is standing the verb becomes `Esc clear`,
because that is what the press does then.
@@ -296,8 +335,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
- Status marks (single-width, themed): `✓` ok · `✗` err · `⚠`/`?` warn · `●` unread/
unacked (err) · `○` acked/empty (muted). Never `✔`, `✖`, `✅`, `❌` as status.
- Node types (shared with the map): `★` you (yellow) · `●` node · `▲` repeater ·
`■` room · `◉` sensor · `○` unknown.
- Node types (shared with the map): `★` you (yellow) · `●` companion · `▲` repeater ·
`■` room server · `◉` sensor · `○` unknown.
- Path cuts: a path line drawn as **chips** that gets cut — a lane that ran out, a row
scrolled past its edge — breaks the chip off on a half block in that chip's own fill
(`▐` ends a line, `▌` opens one; `pathline.cut_mark`/`cut_to`), so half the cell is
@@ -338,7 +377,7 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
⚙ parameter · `#` count · ▶ run · ⚡ explore/probe · ★ best/winner · ⭐ watch ·
📤 send now · 📨 courier/queue · 💬 chat · 🔔 notify · 🔕 mute (notifications off) ·
📱 QR · 🔗 link · ↻ re-read · ↕ reorder · ⇄ reverse (flip a path's direction) ·
🏆 trophy case/record · ⌨ command line · 📖 read/about · 💰 support/donate ·
🔖 region/scope · 🏆 trophy case/record · ⌨ command line · 📖 read/about · 💰 support/donate ·
🚪 quit. Packet-class icons (feed/viewer
lane): 📢 advert · 📊 telemetry · 📦 packet · 💬 message · ✅ ack. Raw payload
classes (`PAYLOAD_ICONS`): 📻 channel text · 💽 channel data · 📩 direct message
@@ -398,6 +437,13 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
- Dialogs anchor slightly above true centre, sized for their populated state.
- Radio traffic: single transmissions or a user-chosen sample count with cooldown pacing
— never bursts.
- **A channel message goes out through `Device.send_channel_in_scope`**, never
`send_channel_message` directly. The firmware has no per-channel scope, only one
session scope that every flood uses, so a channel's scope is a window: set it, send,
restore, under the transmit lock (`Device.transmitting()`) so no other flood slips out
inside it. A direct send skips that window without an error and the message floods
everywhere; a refused scope raises `FloodScopeError` and sends nothing, never a quiet
unscoped send. Any new command that can transmit a flood holds `transmitting()` too.
### The command line — one answer, two faces
+67 -4
View File
@@ -25,8 +25,66 @@ this repository.
seen, etc.), UX standards for screens/dialogs/menus, and where the reusable building
blocks live in code (`ui/menus.py`, `ui/markdown.py`, `ui/widgets.py`, `ui/theme.py`).
New code should read like it always belonged here.
- For anything bigger than a small fix, open an issue first to talk it through before
writing a lot of code — saves both of us a rewrite.
- Tell us before you start working on something (see below). For anything bigger than a
small fix, expect to talk it through first. That saves everyone a rewrite.
## Who can contribute
If you use MeshTerm, you're welcome here. Maybe something broke for you, or you wished
it could do one more thing, or a screen looked wrong on your radio. Those are exactly
the changes we want.
### How MeshTerm was made
Some people in the MeshCore community don't trust code written by AI. After some of
what has happened lately, that's easy to understand, and it deserves a straight answer.
Jean-Pierre Martineau, who started MeshTerm, sees it differently. AI was a big help on
this project. Honestly, MeshTerm couldn't have been built without it. But the choices
were his. He decided what MeshTerm should be, what it should do, and how it should
look. He uses it every day. It's handmade, with AI's help.
The project wants to stay that way. That's what the rules below are for.
### Come say hello first
Using AI tools to help you work is fine. What matters is that a real person who cares
about MeshTerm is behind the change.
The MeshTerm community lives on [Discord](https://discord.gg/AZwe5Uvb3S), and everyone
is welcome to join. But please join before you change things. Before you start writing
code, say hello in the **#contributing** channel and tell us what you'd like to work on.
Then wait until we say yes. This goes for small fixes too.
When you open your pull request, include two things:
- **Your Discord username**, the one you use on the MeshTerm server.
- **A link to the message where we said yes.** In Discord, right-click the message and
choose "Copy Message Link."
If you haven't joined the Discord, or nobody said yes to your idea, your pull request
will be closed without a review.
### No bots
Some accounts use AI to find open issues, write a fix, and open a pull request, all on
their own. There's no person behind it who read the change or can answer questions
about it. People call this "AI slop." We close those pull requests without a review,
even if the code looks fine. The same goes for issues and bug reports written that way.
### Tell us if you used AI
This part is required. If an AI tool wrote any part of your change, say so in two
places:
- **In each commit it helped with.** Add a line at the end of the commit message naming
the tool, like `Co-Authored-By: Claude <noreply@anthropic.com>` or
`Assisted-by: GitHub Copilot`.
- **In your pull request.** Write a sentence about what the tool did and what you
checked yourself.
If you used AI and didn't say so, we'll treat your pull request as a bot's. Saying so
costs you nothing. Either way, you're the author, so be ready to explain your change.
## Making a change
@@ -61,7 +119,12 @@ this repository.
Open a GitHub issue. For bugs, include what you expected, what happened, and enough to
reproduce it — device/platform, MeshCore firmware version if relevant, and steps.
The [bug form](https://github.com/jpmartineau/MeshTerm/issues/new?template=bug.yml) asks
for all of this. Attach the file that `meshterm diagnostics --out bug.md` writes. No
GitHub account? Post it in the #bugs forum on [Discord](https://discord.gg/AZwe5Uvb3S)
instead.
---
Questions about any of this, licensing included, are welcome as a GitHub issue or
discussion before you put work in.
Questions about any of this, licensing included, are welcome as a GitHub issue before you
put work in.
+147 -305
View File
@@ -1,14 +1,14 @@
# MeshTerm
**A full-screen terminal companion for [MeshCore](https://meshcore.io/) LoRa mesh
devices** — tune your radio, chat across the mesh, watch the network live, and keep a
longitudinal record of everything you overhear, all from a connected serial, Bluetooth, or
network (TCP) companion.
**A full-featured TUI [MeshCore](https://meshcore.io/) client for your terminal** — tune
your radio, chat across the mesh, watch the network live, and keep a longitudinal record
of everything you overhear, all from a connected serial, Bluetooth, or network (TCP)
companion.
MeshTerm is interactive by default: a modern, keyboard-driven TUI built on
[Rich](https://github.com/Textualize/rich) and
[prompt_toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit). It is also
fully scriptable — **every screen has a mirror `meshterm` subcommand** — so the same
fully scriptable — **most screens have a mirror `meshterm` subcommand** — so the same
capabilities drive both an evening of exploring the mesh and a cron job. Every packet the
radio overhears is recorded to a local SQLite database, so the longer you run it, the more
your mesh's history is worth.
@@ -23,8 +23,13 @@ your mesh's history is worth.
<a href="https://discord.gg/AZwe5Uvb3S"><img alt="Discord" src="https://img.shields.io/badge/chat-Discord-5865F2"></a>
</p>
> MeshTerm is a side project, not a product. Bug reports are very welcome — but please
> open an issue before writing a pull request. [More on how it's run](#how-this-project-is-run).
> MeshTerm is a side project, run by one person. Bug reports are very welcome. Want to
> change something? Say hello on [Discord](https://discord.gg/AZwe5Uvb3S) first.
> [More on how it's run](#how-this-project-is-run).
https://github.com/user-attachments/assets/f91a6695-14d5-4ae0-8fdc-e65492d0955d
<p align="center"><sub>Under two minutes on a real mesh.</sub></p>
<p align="center">
<img src="docs/screenshots/dashboard.png" alt="The dashboard: a packets-per-minute timeline over the last two hours, and every packet class counted" width="49%">
@@ -53,10 +58,12 @@ your mesh's history is worth.
sweep, and a walkable graph of how the mesh actually hangs together.
- **It talks.** Live channel and direct messaging, store-and-forward courier delivery for
contacts that aren't there yet, and shareable channels as QR codes and `meshcore://` links.
- **It connects however your companion does.** Serial (USB), Bluetooth LE, or TCP (a WiFi
- **It connects however your companion does.** Serial (USB), Bluetooth LE, or TCP (a Wi-Fi
board or a network proxy) — the discoverable transports are auto-found and picked
interactively, a network device is named by hand, and every kind reconnects live when a link
drops. No radio? A built-in simulator covers development.
drops. It pairs a PIN-protected Bluetooth companion itself on Windows and Linux, and when a
connection fails it says which step failed and what to do. No radio? A built-in simulator
covers development.
- **It works offline.** The street map caches OpenStreetMap tiles to disk and falls back to
a blank grid when there's no network — it never needs the internet to run.
@@ -64,76 +71,92 @@ your mesh's history is worth.
### Download a build
**[⬇ Latest release](https://github.com/jpmartineau/MeshTerm/releases/latest)** — one file,
no Python needed. Windows, macOS (Intel and Apple silicon), and Linux (x64 and ARM64, so
the uConsole is covered).
One file, no Python needed — Windows, macOS (Intel and Apple silicon), and Linux (x64 and
ARM64, so the uConsole is covered). Every command below fetches the
**[⬇ latest release](https://github.com/jpmartineau/MeshTerm/releases/latest)**, whichever
one that is, so none of them goes stale.
MeshTerm is a terminal program, so **open a terminal and run it from there.** You can
double-click it and it will work, but you'll get whatever console your system picks, and
if anything goes wrong at startup the window closes before you can read why.
**Windows** — open Windows Terminal or PowerShell, `cd` to your downloads, then:
#### macOS — download it with `curl`
```powershell
.\meshterm-0.3.6-windows-x64.exe
```
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
**Don't use your browser.** A browser flags the file as downloaded, and macOS then refuses
to open it at all — on Sequoia and later, right-click → Open no longer gets you past that
either. `curl` flags nothing, so what it hands you simply runs. Open Terminal and paste:
```bash
chmod +x meshterm-0.3.6-*
./meshterm-0.3.6-*
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-macos-arm64
chmod +x meshterm
./meshterm
```
### A word about terminals, on Windows
That's the build for Apple silicon Macs (M1 and later). On an **Intel** Mac, swap `arm64`
for `x64`. And if you already downloaded it with a browser, you don't have to start over:
`xattr -d com.apple.quarantine meshterm` clears the flag.
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether you
see them is up to your *terminal*, not really your font: a modern terminal, asked for a
character its font doesn't have, quietly borrows it from another font on the machine.
That's why the app looks right in Windows Terminal, in VS Code's terminal, and on macOS
and Linux — usually with a font that contains almost none of it.
#### Linux — the same three lines
`linux-x64` on a PC, `linux-arm64` on a uConsole or another 64-bit ARM handheld.
```bash
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-linux-x64
chmod +x meshterm
./meshterm
```
#### Windows
Download
**[meshterm-windows-x64.exe](https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-windows-x64.exe)**,
then open Windows Terminal or PowerShell, `cd` to your downloads, and run it:
```powershell
.\meshterm-windows-x64.exe
```
Windows says *"Windows protected your PC"* the first time. Click **More info → Run anyway**.
Whichever you took, you now have a single file: `meshterm` on macOS and Linux,
`meshterm-windows-x64.exe` on Windows. Put it somewhere on your `PATH` (renamed to
`meshterm.exe` on Windows) and it's just `meshterm` from anywhere.
> **These builds aren't code-signed**, which is why macOS and Windows both push back the
> first time; signing costs real money on both platforms and this is a free side project.
> Every release ships a `SHA256SUMS` file if you'd rather check what you got, and
> [installing with pip or pipx](#or-install-with-pip) sidesteps the whole business, since
> nothing arrives as a downloaded binary.
>
> Each release page also carries these commands written out for **that exact version**,
> next to its own downloads.
### If your terminal draws empty boxes
MeshTerm is drawn with emoji icons, braille charts, and powerline path chips, and whether
you see them is up to your *terminal* rather than your font. Windows Terminal, VS Code's
terminal, and anything modern on macOS and Linux all draw them correctly.
The classic Windows console — the black `cmd.exe` window you get from a double-click —
doesn't borrow. It draws what its one font holds and empty boxes for everything else, and
no font fixes the icons there: the only two fonts on a Windows machine with emoji in them
are proportional, and a console won't take a proportional font.
So when MeshTerm lands in that console it just **moves to Windows Terminal** — it says
so, and opens there. Nothing is installed and nothing is changed; it's one window instead
of another, and it's the whole app exactly as the screenshots show it. Windows Terminal is
already on every Windows 11 machine and is a free install on Windows 10.
Where there's no Windows Terminal to move to, MeshTerm offers the next best thing instead
— the charts and marks, without the icons. It ships
[Cascadia Mono PL](https://github.com/microsoft/cascadia-code), Microsoft's own console
font and one of the very few monospace faces that carries braille at all, and will install
it just for you: no administrator rights, nothing downloaded. If you already have Cascadia
it simply switches to it.
does not. So when MeshTerm lands there it **moves to Windows Terminal**, says so, and
opens there; nothing is installed and nothing is changed. Where there's no Windows
Terminal to move to, it offers the charts and marks without the icons instead, and will
install Microsoft's [Cascadia Mono PL](https://github.com/microsoft/cascadia-code) just
for you — no administrator rights, nothing downloaded.
Preferences → Display → Console setup turns all of this off if you'd rather stay put.
Rename it to something you don't mind typing and put it somewhere on your `PATH`, and it
becomes just `meshterm` from anywhere.
> **Your system will complain the first time, and it's right to.** These builds aren't
> code-signed — signing costs real money on both platforms and this is a free side
> project.
>
> - **macOS**: *"cannot be opened because the developer cannot be verified"*. Clear it with
> `xattr -d com.apple.quarantine meshterm-0.3.6-*` and run it again.
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
>
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
[Terminals, icons, and the Windows console](docs/terminals.md) explains why any of it is
necessary.
> **Trying a build without touching your real data.** MeshTerm keeps everything in
> `~/.meshterm` — your history database, contacts, channel keys — and *every* copy of
> MeshTerm uses that same folder. Set `MESHTERM_HOME` to try one in isolation:
>
> ```bash
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.3.6-* # macOS, Linux
> MESHTERM_HOME=~/meshterm-test ./meshterm # macOS, Linux
> ```
> ```powershell
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.6-windows-x64.exe
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-windows-x64.exe
> ```
>
> Same variable if you run two radios and want them kept apart.
@@ -169,12 +192,18 @@ Setting up for development instead? That's in [CONTRIBUTING.md](CONTRIBUTING.md)
## Documentation
**[The documentation index](docs/README.md)** lists everything, including the two code
surveys kept as a historical record. The short version:
| Read | For |
| --- | --- |
| **[The command line](docs/cli.md)** | Every subcommand and option, what each prints on the plain and JSON faces, the exit statuses, recipes. |
| **[MeshTerm on the PicoCalc](docs/picocalc.md)** | Building the handheld from a stock PicoCalc: the shopping list, swapping in the Lyra, installing Calculinux and MeshTerm, the soldered LoRa radio. |
| **[MeshTerm on the uConsole](docs/uconsole.md)** | A uConsole with the AIO LoRa board: installing the bridge that fronts its SPI radio as a companion, and connecting to it. |
| [MeshTerm on hardware](docs/hardware.md) | Which of the two manuals is yours, and the two platform flavours MeshTerm draws in. |
| **[What MeshTerm does](docs/features.md)** | Every screen the menu offers, and the command that does the same job without it. |
| [The CLI cookbook](docs/cookbook.md) | Common one-liners, by the thing you're trying to do. |
| [Configuring MeshTerm](docs/configuration.md) | Preferences, device profiles, and everything kept under `~/.meshterm`. |
| [When a companion won't connect](docs/connecting.md) | Bluetooth pairing on each system, finding the PIN, USB permissions, and every connection error, with what to do about it. |
| [MeshTerm on hardware](docs/hardware.md) | Which handheld manual is yours — the [PicoCalc](docs/picocalc.md) build, or the [uConsole](docs/uconsole.md), whose LoRa chip MeshTerm can drive directly. |
| [How the code is laid out](docs/architecture.md) | The layering, and where a new feature goes. |
| [CONTRIBUTING.md](CONTRIBUTING.md) | The development setup and the house rules. |
| [CHANGELOG.md](CHANGELOG.md) | What changed, newest first. |
@@ -184,12 +213,15 @@ Setting up for development instead? That's in [CONTRIBUTING.md](CONTRIBUTING.md)
# Interactive full-screen menu (auto-discovers serial + Bluetooth companions)
meshterm
# Connect over Bluetooth (address from `meshterm devices`)
meshterm --ble AA:BB:CC:DD:EE:FF info
# Connect over Bluetooth (address from `meshterm devices`); add --ble-pin if it has a PIN
meshterm --ble AA:BB:CC:DD:EE:FF --ble-pin 123456 info
# Connect over the network to a TCP companion (host[:port], port defaults to 5000)
meshterm --tcp 192.168.1.50 info
# Drive a LoRa radio on this machine's own SPI bus (the uConsole AIO; Linux only)
meshterm --spi info
# No radio attached? Use the built-in simulator for development.
meshterm --mock
@@ -203,237 +235,56 @@ meshterm specimen
Putting MeshTerm on a handheld has its own manuals. **[MeshTerm on the
PicoCalc](docs/picocalc.md)** takes a stock PicoCalc from the shopping list to a Linux
handheld with a LoRa radio soldered inside; **[MeshTerm on the uConsole](docs/uconsole.md)**
connects a uConsole's SPI LoRa board through a small bridge. The device-side scripts both
manuals run are in [`scripts/`](scripts/); not sure which manual is yours?
drives a uConsole's SPI LoRa board directly with `--spi`, or through a small bridge if you
want the node on the mesh all the time. The device-side scripts both manuals run are in
[`scripts/`](scripts/); not sure which manual is yours?
[`docs/hardware.md`](docs/hardware.md) says.
## Features
A companion that won't connect? **[When a companion won't
connect](docs/connecting.md)** covers Bluetooth pairing on Windows, Linux, and macOS, the
`dialout` group and ModemManager for USB on Linux, and every error message MeshTerm gives.
MeshTerm's menu answers one question — *what would you like to do?* — so its five
sections are named for the doing, not the subject. Every interactive screen is listed
here with its scripted equivalent, where one exists.
## What it does
### 💬 Message — the people on the other end
The menu asks one question — *what would you like to do?* — and answers it in two halves.
Three sections name a doing: **Message** is chat, channels, the courier outbox, and
contacts; **Watch** is the dashboard, the live feed, the watchtower, and the time machine;
**Explore** is the map, the mesh walk, tracing, and the trophy case. Three name whose it is:
**This node** is the radio in your hand, **Other nodes** is someone else's over the mesh,
and **This app** is MeshTerm itself — preferences, diagnostics, and the written pages.
| Feature | What it does | Scripted |
| --- | --- | --- |
| **💬 Chat** | Live full-screen channel and direct messaging — a scrolling transcript with a pinned input line where sent and received messages stream together. Every message is logged; unread counts show in the menu header. | `meshterm chat send / history / list` |
| **📻 Channels** | Create, join, reorder, mute, and share mesh channels — with QR codes and `meshcore://` share links. | `meshterm channels list / add / join / import / share / clear` |
| **📨 Courier** | Store-and-forward outbox for contacts that aren't reachable yet. Queue a message; it goes out (with ack tracking and polite exponential backoff) the moment the contact is next heard, or at a scheduled time. | `meshterm courier queue / list / send / cancel / clear` |
| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. The scripted listing is the contacts alone (this node is `meshterm info`'s answer, in far more detail). | `meshterm contacts` |
Nearly every one of them has a mirror `meshterm` subcommand — the same registry builds
the menu and the CLI, so they can't drift apart. The live screens, such as the map and
the dashboard, are only in the menu.
### 📊 Watch — what the mesh is doing, and what it did
**[What MeshTerm does](docs/features.md)** is the full catalogue: every screen, what it
does, and its scripted equivalent where one exists.
| Feature | What it does | Scripted |
| --- | --- | --- |
| **📊 Dashboard** | The live mesh overview: a two-hour all-packet activity chart with a pulse line, session traffic tallies by packet class, and window RF health beside the radio's own live numbers. Repaints every second. | — |
| **📰 Live feed** | Every packet as it arrives, newest first: time, what the frame *is* (its parsed payload class), and what it is *about* — the node for an advert, the channel for a channel text, sender → recipient for a direct message or a request, the tag for a trace — with reception quality beside it. Enter opens any row in the packet viewer, route graph and all. | — |
| **🚨 Watchtower** | A passive sentinel over the nodes you star: silence alarms when a watched node goes quiet, SNR-sag warnings when reception degrades, recovery notes when it returns, and a heads-up when a never-before-seen node appears. Runs in the background all session; unacked alerts show as a header badge. | — |
| **⏳ Time machine** | Everything the recorder ever heard, as braille charts: reception volume, the median-SNR band, hour-of-day rhythm, packets and nodes per day, and first-ever arrivals — over a switchable 24h / 7d / 30d / all-time window, per node or mesh-wide. | — |
| **🎧 Monitor** | Passive recording is always on in the app. On the CLI, capture a bounded foreground window, tailing each overheard packet and summarising when it ends. It transmits nothing — it only listens. | `meshterm monitor --seconds 60` |
## Scripting it
### 🧭 Explore — where the nodes are and how they reach each other
| Feature | What it does | Scripted |
| --- | --- | --- |
| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | — (a map is a picture; `meshterm contacts` lists the same nodes) |
| **🌐 Mesh walk** | The mesh's *observed shape*, walked one node at a time: an evidence graph built from trace walks, firmware routes, overheard relay chains, and repeater neighbour tables. SNR-coloured braille edges, quality bars, Enter to walk, ⌫ to backtrack, type to find any node. The map answers *where*; the walk answers *how it hangs together*. | — |
| **🎯 Trace target** | A live trace screen: pick a target and watch each trace stream in hop by hop, with running per-hop medians and reliability. Compose or force a route through specific repeaters. A trace transmits **exactly once** (repeaters can blacklist nodes that burst) — sample more by running it again. | `meshterm trace --target …` |
| **👣 Trace path** | The other half of tracing: compose the whole circuit by hand — out and back whichever way you choose — and walk it. The path composer suggests each next hop from the links actually observed, strongest first, and can fetch a repeater's neighbour table over the mesh when you hold its admin password. | `meshterm trace-path --path …` |
| **🏆 Trophy case** | Every trace that comes home is scored, on seven boards: longest distance, farthest node, longest single leg, most nodes (with and without revisits), weakest surviving link, and biggest enclosed loop. Records are kept per hash width, and a record walk must be a *trail* — no link crossed twice the same way. | `meshterm records` |
### 🔧 This node — the radio in your hand
| Feature | What it does | Scripted |
| --- | --- | --- |
| **📋 Device info** | The connected companion's identity and full radio configuration at a glance. | `meshterm info` |
| **🔧 Device config** | Every setting the companion firmware exposes — name, radio, client repeat, auto-add, telemetry, custom variables — *staged* for review and applied in place, plus the operations on the box itself: clock sync, TOML backup/restore, the identity key, reboot, and factory reset, each acting the moment it's confirmed, with destructive ops gated. Laid out like Repeater admin, so the radio in your hand and one over the mesh are configured the same way. | `meshterm config` / `config set <key> <value>` / `config backup` / `reboot` / … |
| **📡 Send advert** | Announce this node to the mesh — a zero-hop or flood advertisement, or share this node's contact card as a QR code. | `meshterm config advert` / `config share` |
| **🔌 Device discovery** | Enumerate serial *and* Bluetooth LE companions, pick one interactively (or auto-select the only one present), add a network (TCP) companion by host:port, and remember the last good default. A dropped link is detected live and offers to reconnect. | `meshterm devices` |
### 🗼 Other nodes — someone else's radio, over the mesh
| Feature | What it does | Scripted |
| --- | --- | --- |
| **🗼 Repeater admin** | Set up remote repeaters and room servers over the mesh: log in (remembered or prompted password), then a config-style editor speaking the node's text CLI — including repeater-only knobs (TX delay, airtime factor, advert intervals) — plus one-shot actions and a readline remote command line. | `meshterm repeater-admin <node> <command…>` |
| **📶 TX optimize** | Sweep a remote node's transmit power live — coarse, then refine, then verify — watch each level land, and decide whether to apply the winner. | `meshterm tx-optimize --path … [--apply]` |
## CLI cookbook
Nearly every menu option is also a subcommand — ideal for scripting, cron, and bots. The
live pictures stay in the menu, where their meaning is (the map, dashboard, live feed,
watchtower and mesh walk); everything else has a command, and every command speaks
`--json`. **[`docs/cli.md`](docs/cli.md) is the full manual**: every command and option,
what each one prints on both faces, and the exit statuses. A taste:
Nearly every command speaks `--json`, and the global options — `--profile/-p`, `--port`,
`--ble`, `--ble-pin`, `--tcp`, `--spi`, `--mock`, `--db`, `--json`, `--absolute`, `--quiet/-q`,
`--platform` — may be typed before or after the subcommand.
```bash
# Trace — a trace transmits exactly once; run it again to sample more.
# Every repeater's public key, for a script
meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
# A trace transmits exactly once; run it again to sample more
meshterm trace --target Alice --profile yagi
# Force a route through specific repeaters (MeshCore-app style): comma-separated
# contact names and/or hex key prefixes, mixed freely. Blank lets the device route.
meshterm trace --target Alice --path "3d,f2,3d"
meshterm trace --target Alice --path "3d,Bravo-Repeater,f2"
# Walk a composed circuit with no target at all — out and back your own way
meshterm trace-path --path "3d,f2,3d"
# Sweep and apply a remote node's TX power
meshterm tx-optimize --path "Bravo-Repeater,Alice" --samples 6 --step 3 --apply
# Messaging: the live transcript is a menu screen; the CLI takes a subcommand
meshterm chat send --to Alice "on my way" # direct message
meshterm chat send --channel 0 "net in 5" # channel broadcast
meshterm chat history --to Alice
meshterm chat list
# Store-and-forward: queue for a contact that's offline right now
# Messaging, and store-and-forward for a contact who isn't there yet
meshterm chat send --channel 0 "net in 5"
meshterm courier queue Alice "ping me when you're back" --at 18:30
meshterm courier list # the outbox — waiting and finished
meshterm courier send 3 # force one delivery attempt now
# Remote repeater admin (one transmission per invocation)
meshterm repeater-admin Bravo-Repeater "get name"
# Device configuration: view, set, back up, restore
meshterm config # show all current settings, one `key value` per line
meshterm config get name # just the value, ready for $(...)
meshterm config set radio_sf 9 # change one setting
meshterm config backup node.toml # archive every setting to TOML
meshterm config restore node.toml --dry-run
meshterm config advert-cadence 2 # auto-advert to neighbours every 2 h (0 = off)
meshterm config advert-cadence 24 --flood # flood the wider mesh daily
# Passive capture window (records to history; transmits nothing)
# The radio's own settings, and a passive capture window
meshterm config set radio_sf 9
meshterm monitor --seconds 60
```
Global options — `--profile/-p`, `--port`, `--ble`, `--ble-pin`, `--tcp`, `--mock`,
`--db`, `--json`, `--absolute`, `--quiet/-q` — may be typed **before or after** the
subcommand: `meshterm contacts --json` and `meshterm --json contacts` are the same run.
### Two output faces
*(The short version — [`docs/cli.md`](docs/cli.md) has the whole of it.)*
The **plain face** is for a person at a prompt. It prints like a standard Unix utility —
no colour, no borders, one record per line, nothing wrapped — and it is allowed to be
comfortable about it: listings are `ps`-style aligned records with **bare names**
(alignment is the delimiter), times are **relative ages** (`now`, `5m`, `never`), and a
route is drawn with arrows — `MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4)`.
`--absolute` swaps every age back for an ISO-8601 instant. A *path*, the spec `--path`
takes back, stays comma-separated hex. `-` is the one token for absent. Errors,
acknowledgements and progress all go to stderr, so a redirect catches only the answer.
The **JSON face** is the machine contract, and `--json` works on **every** command:
```console
$ meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
b2c3d4e500000000000000000000000000000000000000000000000000000000
a1b2c3d400000000000000000000000000000000000000000000000000000000
```
No envelope — an array for a listing, an object for a set of facts. One compact line;
`monitor` and `chat listen` stream one document per record. Values are typed, absent is
`null` and never an omitted key, and timestamps are always UTC to the second regardless of
`--absolute`. `--json` changes the rendering, never the report: same records, same exit
status.
Anything structural should go through `--json` and `jq`. The plain face is for looking at.
### Exit status
`0` success · `1` failure · `2` usage error · `3` no device found (nothing was
transmitted) · `4` the device was reached but the operation failed · `5` nothing to
report. The table with its full wording is printed under `meshterm --help` and explained
in [`docs/cli.md`](docs/cli.md#exit-status).
`5` is the one worth knowing about: it lets a script tell "found nothing" from "worked"
without counting output lines.
```bash
if meshterm contacts > contacts.txt; then
echo "$(wc -l < contacts.txt) contacts"
elif [ $? -eq 5 ]; then
echo "no contacts yet"
fi
```
## Configuration
Two files, two jobs — and you need neither to run.
**Preferences** are how MeshTerm behaves: the pause it leaves between transmissions, how
hard it retries a message, how far back it keeps history, how a map frames itself. Every one has a built-in default, so change them only where you disagree — from
the **Preferences** page in the menu (grouped, staged, saved by one action at the bottom),
or from a shell:
```console
$ meshterm preferences show # every preference, its value, its default
$ meshterm preferences set history_days 90
$ meshterm preferences reset --yes # back to the built-in defaults
```
They are kept in `~/.meshterm/preferences.toml`, which lists only what you have changed;
delete a line and the default takes over again.
**Config** is where things live and which device to talk to. MeshTerm runs with none of it
— it discovers attached serial devices and nearby Bluetooth companions, lets you pick one,
and remembers the last good default. A network (TCP) companion isn't discoverable, so reach
it with `--tcp host:port`, a TCP profile, or the picker's "add a network device" prompt.
Write a config file only to give your hardware stable aliases.
Copy [`config.example.toml`](config.example.toml) to `~/.meshterm/config.toml`:
```toml
default_profile = "s3"
connect_on_start = true # false opens the radio link lazily instead of at launch
[profiles.s3]
port = "COM5"
baudrate = 115200
default_tx_power = 20
description = "XIAO ESP32-S3 + Wio SX1262 serial companion"
# A Bluetooth LE companion: give it an `address` instead of a `port`.
[profiles.handheld]
address = "AA:BB:CC:DD:EE:FF"
# ble_pin = "123456" # only if your device requires a pairing PIN
description = "Pocket handheld over Bluetooth"
# A network (TCP) companion: give it a `host` (and optional `tcp_port`, default 5000).
[profiles.wifi]
host = "192.168.1.50"
description = "Basestation over WiFi"
```
Both live in `~/.meshterm`, along with everything else MeshTerm remembers: the SQLite
history, the outbox, the contact and channel caches, stored admin passwords, and the log.
**`$MESHTERM_HOME` moves the whole directory**, which is the way to run a second radio —
or a `--mock` session — without touching the one you use every day. `--db` moves the
database alone; the rest stays where it was. [`docs/cli.md`](docs/cli.md#where-meshterm-keeps-its-state)
lists every file.
Timestamps are stored as UTC and rendered in your local time. History older than the
`history_days` preference is pruned once at session start.
## Architecture
MeshTerm is layered so a new feature is one file in `meshterm/tools/`:
```
cli.py Typer app; no subcommand -> interactive menu
context.py AppContext (console, config, repository, device) dependency container
core/ Domain: models, preferences + device profiles, connection abstraction (+ mock)
tools/ Pluggable "menu options"; each self-registers and gets logging for free
services/ Background algorithms (monitor, trace, tx search, courier, watchtower) — no UI
persistence/ SQLite schema, repository, structured logging
ui/ Rich theme/widgets + the full-screen TUI (screens, dialogs, map, chat)
```
Adding a feature means subclassing `Tool`, decorating it with `@register`, and
implementing `run()`. The same registry builds both the CLI and the menu, and the base
class wraps every execution in a logged `runs` row automatically.
**[The command line](docs/cli.md)** is the manual — every command and option, what each
prints on both faces, and the exit statuses.
**[The CLI cookbook](docs/cookbook.md)** has more one-liners like these.
## How this project is run
@@ -441,18 +292,21 @@ MeshTerm is one person working evenings and weekends. I'd rather tell you that u
than have you guess from how long things take.
**Issues are welcome — all of them.** Bugs, questions, "is this supposed to do that". The
[bug form](.github/ISSUE_TEMPLATE/bug.yml) asks for a fair bit, and that's on purpose: how
well a problem is described really does decide whether I can do anything with it. If I can
reproduce it, I'll usually chase it. If I can't, I'm mostly guessing.
[bug form](https://github.com/jpmartineau/MeshTerm/issues/new?template=bug.yml) asks for a
fair bit, and that's on purpose: how well a problem is described really does decide whether
I can do anything with it. If I can reproduce it, I'll usually chase it. If I can't, I'm
mostly guessing.
**Ask before you write a pull request.** Open an issue first and wait for a yes. I'm not
being precious — MeshTerm has firm house rules about how screens get built (they're in
[CLAUDE.md](CLAUDE.md)), and I'd hate for you to spend a weekend on something I then ask
you to rewrite. A quick conversation first saves us both.
**Ask before you write a pull request.** Join the [Discord](https://discord.gg/AZwe5Uvb3S)
and say hello in `#contributing`. Tell us what you'd like to change. Then wait for a yes.
That goes for small fixes too. I'm not being precious. MeshTerm has firm house rules
about how screens get built (they're in [CLAUDE.md](CLAUDE.md)), and I'd hate for you to
spend a weekend on something I then ask you to rewrite. A quick conversation first saves
us both. AI tools are fine, as long as you say you used them.
[CONTRIBUTING.md](CONTRIBUTING.md) has the details.
**I can't promise timelines.** Some things get fixed the same night. Some sit for a month
because life happened. If your issue goes quiet, a nudge is completely fine — it's not
rude, it's helpful.
because life happened. If your issue goes quiet, please give it a nudge. That helps me.
**Where to report things.** Either works:
@@ -470,19 +324,6 @@ Also here: [CONTRIBUTING.md](CONTRIBUTING.md) ·
[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
[CHANGELOG.md](CHANGELOG.md)
## Development
```bash
pip install -e ".[dev]"
python -m pytest -q # tests
ruff check . # lint
ruff format . # format
```
Screens are designed to stay readable at **72 columns**.
Want to send a pull request? See [CONTRIBUTING.md](CONTRIBUTING.md) first.
## Acknowledgements & attribution
MeshTerm stands on the [MeshCore](https://meshcore.io/) project — its firmware and the
@@ -499,7 +340,7 @@ are decoded with a small built-in reader for the
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
`© 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.
@@ -509,7 +350,7 @@ short form throughout. The About page inside the app spells out the licence URL.
MeshTerm ships **Cascadia Mono PL**, © 2019–present Microsoft Corporation, redistributed
unmodified under the [SIL Open Font License 1.1](meshterm/assets/fonts/CascadiaMono-OFL.txt).
It's offered to Windows users whose console can't draw the charts — see
[A word about terminals, on Windows](#a-word-about-terminals-on-windows). Microsoft doesn't endorse MeshTerm; the font is
[Terminals, icons, and the Windows console](docs/terminals.md). Microsoft doesn't endorse MeshTerm; the font is
simply the right tool, being one of the very few monospace faces that carries the braille
block the timelines are drawn from.
@@ -520,7 +361,7 @@ respective project for the authoritative terms):
| Library | Role | License |
| --- | --- | --- |
| [meshcore](https://pypi.org/project/meshcore/) | Companion-device protocol (serial / BLE) | MIT |
| [meshcore](https://pypi.org/project/meshcore/) | Companion-device protocol (serial / BLE / TCP) | MIT |
| [pycryptodome](https://www.pycryptodome.org/) | AES/HMAC for decrypting overheard channel packets | BSD-2-Clause / Public Domain |
| [pyserial](https://github.com/pyserial/pyserial) | Serial-port enumeration and I/O | BSD-3-Clause |
| [bleak](https://github.com/hbldh/bleak) | Bluetooth LE scanning + connection | MIT |
@@ -529,7 +370,7 @@ respective project for the authoritative terms):
| [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, preferences and device-config backups | MIT |
| [tomli](https://github.com/hukkin/tomli) / [tomli-w](https://github.com/hukkin/tomli-w) | TOML config, preferences, and device-config backups | MIT |
## License
@@ -549,7 +390,8 @@ is Microsoft's Cascadia Mono PL, redistributed unmodified under the SIL Open Fon
[CascadiaMono-OFL.txt](meshterm/assets/fonts/CascadiaMono-OFL.txt) and stays its only
license.
Each standalone build carries `LICENSE`, `NOTICE` and a generated `THIRD-PARTY-NOTICES.txt`
(every dependency's own license text) inside the archive, and the same three files sit
beside the downloads on the [releases page](https://github.com/jpmartineau/MeshTerm/releases)
so you don't have to extract one just to read them.
Each standalone build is a single file with `LICENSE`, `NOTICE`, and a generated
`THIRD-PARTY-NOTICES.txt` (every dependency's own license text) bundled inside it. The same
three files are also attached to each release on the
[releases page](https://github.com/jpmartineau/MeshTerm/releases), so you can read them
without running anything.
+30 -6
View File
@@ -2,10 +2,9 @@
# Copy to ~/.meshterm/config.toml (or pass --db / --port on the CLI).
#
# How MeshTerm itself behaves — transmit cooldown, message retries, how much history it
# keeps, whether it connects at launch — is *not* here. Those are preferences: they live
# in ~/.meshterm/preferences.toml, and are edited on the Preferences page (or with
# `meshterm preferences set <key> <value>`). Every one has a built-in default, so that
# file only ever lists what you have changed.
# keeps — is *not* here. Those are preferences: they live in ~/.meshterm/preferences.toml,
# and are edited on the Preferences page (or with `meshterm preferences set <key> <value>`).
# Every one has a built-in default, so that file only ever lists what you have changed.
#
# Profiles are optional. With no profile, no --port/--ble/--tcp, MeshTerm discovers attached
# serial devices and nearby Bluetooth companions, lets you pick one (interactively) or uses the
@@ -14,7 +13,10 @@
# the picker's "add a network device" prompt. Define profiles below only to give your hardware
# stable aliases (e.g. --profile yagi, --profile handheld for Bluetooth, --profile wifi for TCP).
default_profile = "s3"
# Uncomment and name one of your own profiles below to make it the default. Left
# commented, MeshTerm finds the device itself, as described above.
# default_profile = "s3"
# Open the radio link as soon as the menu appears. False opens it lazily instead — only
# once monitoring is turned on or a tool first needs the radio.
connect_on_start = true
@@ -24,7 +26,6 @@ connect_on_start = true
[profiles.s3]
port = "COM5"
baudrate = 115200
default_tx_power = 20
description = "XIAO ESP32-S3 + Wio SX1262 serial companion"
[profiles.yagi]
@@ -57,3 +58,26 @@ host = "192.168.1.50"
# tcp_port = 5000
# transport = "tcp"
description = "Basestation over WiFi"
# A radio on this machine's own SPI bus: MeshTerm runs its node while it is connected.
# The pins default to the uConsole AIO v1's, which needs nothing more than this (and is
# listed on the device screen anyway, whenever /dev/spidev1.0 exists).
[profiles.aio]
transport = "spi"
# [profiles.aio.spi]
# en_pins = [27] # the AIO v2 powers its radio from pin 27
# Any other board states its whole wiring — every pin and both switches, because a key
# left out keeps the AIO's value. Read them off the board's meshtasticd config; the
# values here are an illustration only. docs/configuration.md has the key-by-key mapping.
# [profiles.hat.spi]
# bus_id = 0 # /dev/spidev0.0
# cs_id = 0
# reset_pin = 18
# busy_pin = 20
# irq_pin = 16
# txen_pin = 6
# rxen_pin = -1
# use_dio2_rf = false
# use_dio3_tcxo = false
# python = "/opt/radio/bin/python" # only to pick the Python that has openhop-core
+51
View File
@@ -0,0 +1,51 @@
# MeshTerm documentation
Everything that didn't belong on the front page. The [README](../README.md) is the front
door — what MeshTerm is, how to install it, and enough to get a first session running.
This folder is the rest.
## Using MeshTerm
| Read | For |
| --- | --- |
| **[What MeshTerm does](features.md)** | Every screen the menu offers, grouped the way the menu groups them, with the command that does the same job without it. |
| **[The command line](cli.md)** | The manual. Every subcommand and option, what each prints on the plain and JSON faces, the exit statuses, and recipes built on `jq`. |
| [The CLI cookbook](cookbook.md) | Common one-liners by task, for when you want the shape of a command rather than its specification. |
| [Configuring MeshTerm](configuration.md) | Preferences versus config, device profiles, and everything kept under `~/.meshterm`. |
| [When a companion won't connect](connecting.md) | Bluetooth pairing on each system, finding the PIN, and every connection error MeshTerm reports, with what to do about it. |
| [Terminals, icons, and the Windows console](terminals.md) | Why the icons and charts draw on some terminals and not others, and what MeshTerm does about it on Windows. |
## Putting it on hardware
Only if you want MeshTerm running on a handheld's own screen. A MeshCore companion on
USB, Bluetooth, or Wi-Fi needs none of this.
| Read | For |
| --- | --- |
| **[MeshTerm on hardware](hardware.md)** | Start here — which of the two manuals below is yours, and what the device-side scripts do. |
| [MeshTerm on the PicoCalc](picocalc.md) | A stock ClockworkPi PicoCalc to a Linux handheld with a LoRa radio soldered inside: the shopping list, the Lyra swap, Calculinux, and the radio. |
| [MeshTerm on the uConsole](uconsole.md) | A uConsole with the hackergadgets AIO LoRa board, whose SX1262 sits on the host's own SPI bus — MeshTerm drives it directly with `--spi`, or a small bridge fronts it as a companion for an always-on node. |
## Reading the code
| Read | For |
| --- | --- |
| [How the code is laid out](architecture.md) | The layering, and where a new feature goes. |
| [CLAUDE.md](../CLAUDE.md) | The actual style guide — terminology, UX standards, and the reusable building blocks. Written for AI assistants, and the house rules for everyone. |
| [CONTRIBUTING.md](../CONTRIBUTING.md) | How to propose a change, the rules on AI use, the dev install, the three checks a change must pass, and what happens to your contribution's licensing. |
## Historical record
A survey of the code, kept because a project that writes down its own problems is easier
to trust than one that doesn't — but it is a **snapshot of a date, not a to-do list**.
Some of what it describes has been fixed since, and it does not mark which.
| Read | For |
| --- | --- |
| [UI consistency survey](survey-ui-consistency.md) | Everything else in the CLAUDE.md UX standard — six sweeps found nothing, which is the useful part. |
## Also here
`screenshots/` holds the captures the README shows and the ones
[meshterm.net](https://meshterm.net) builds its landing page from, so a file in there is
referenced from more than one place.
+23
View File
@@ -0,0 +1,23 @@
# How the code is laid out
A map of the package for anyone reading it for the first time. The house rules that
govern what goes where are in [CLAUDE.md](../CLAUDE.md), which is the real style guide;
[CONTRIBUTING.md](../CONTRIBUTING.md) has the dev install and the gates a change has to
pass.
MeshTerm is layered so a new feature is one file in `meshterm/tools/`:
```
cli.py Typer app; no subcommand -> interactive menu
context.py AppContext (console, config, repository, device) dependency container
core/ Domain: models, preferences + device profiles, connection abstraction (+ mock)
tools/ Pluggable "menu options"; each self-registers and gets logging for free
services/ Background algorithms (monitor, trace, tx search, courier, watchtower) — no UI
persistence/ SQLite schema, repository, structured logging
ui/ Rich theme/widgets + the full-screen TUI (screens, dialogs, map, chat)
```
Adding a feature means subclassing `Tool`, decorating it with `@register`, and
implementing `run()`. The same registry builds both the CLI and the menu, and the base
class wraps every execution in a logged `runs` row automatically.
+327 -99
View File
@@ -13,13 +13,11 @@ It prints two ways, and the difference is who is reading.
`2026-09-08T04:26:00-04:00`, and a route is drawn with arrows.
- The **JSON face** (`--json`) is for a program, often on another machine and often later.
It is the contract: typed values, absent spelled `null`, timestamps in UTC, keys that do
not move. It works on **every** command — it used to reach two out of twenty.
not move. It works on **every** command.
The CLI used to make one promise to both of them — that it was written to be read by
`awk` — and every rule followed from that. That promise now belongs to `--json`, which
keeps it properly, and the plain face is released from it. If you are splitting output on
whitespace to feed a script, stop: `--json` exists so you do not have to, and the plain
face is no longer holding still for you.
The promise to be read by a program belongs to `--json`. The plain face makes no such
promise. If you are splitting output on whitespace to feed a script, stop: `--json` exists
so you do not have to, and the plain face may change between releases.
- [Running it](#running-it)
- [Where MeshTerm keeps its state](#where-meshterm-keeps-its-state)
@@ -54,11 +52,12 @@ With no command, MeshTerm launches the interactive menu instead.
| `--version` | Print `meshterm <version>` and exit `0`, before anything else is opened. |
| `-p`, `--profile NAME` | Use a named device profile from `config.toml`. |
| `--port PORT` | Serial port to connect to (`COM5`, `/dev/ttyACM0`), overriding the profile. |
| `--ble ADDRESS` | Bluetooth address of a companion; selects the BLE transport. |
| `--ble-pin PIN` | Pairing PIN, if the Bluetooth companion asks for one. |
| `--ble ADDRESS` | Bluetooth address of a companion; selects the Bluetooth transport. |
| `--ble-pin PIN` | Pairing PIN, if the Bluetooth companion asks for one. MeshTerm pairs with it itself on Windows and Linux; on macOS the system asks for the code in its own dialog, and this option has no effect. See [When a companion won't connect](connecting.md#bluetooth). |
| `--tcp HOST[:PORT]` | Network address of a TCP companion; selects the TCP transport. Default port 5000. |
| `--mock` | Use the built-in simulator instead of real hardware. Nothing transmits. |
| `--db PATH` | Use this SQLite database instead of `~/.meshterm/meshterm.db`. **It moves the database and nothing else** — see [Where MeshTerm keeps its state](#where-meshterm-keeps-its-state). |
| `--spi` | The LoRa radio on this machine's own SPI bus; selects the SPI transport. MeshTerm runs the node for the length of the command. With one radio attached, that's the one, profile or not; with none attached, the one SPI profile, else the uConsole AIO's wiring. With two, it refuses (exit `3`) and lists them — pick one with `-p`. See [Adding a radio on the SPI bus](configuration.md#adding-a-radio-on-the-spi-bus). Linux only. |
| `--mock` | Use the built-in simulator instead of real hardware. Nothing transmits, and the run records to `meshterm-mock.db` rather than your real history. |
| `--db PATH` | Use this SQLite database instead of `~/.meshterm/meshterm.db` (or `meshterm-mock.db`, under `--mock`). **It moves the database and nothing else** — see [Where MeshTerm keeps its state](#where-meshterm-keeps-its-state). |
| `--json` | Print the answer as JSON instead of aligned text. |
| `--absolute` | Print times as ISO-8601 instants rather than relative ages, for this run. |
| `-q`, `--quiet` | Suppress console logging entirely (the log file still records). |
@@ -97,31 +96,36 @@ of two directories from the same shell.
| File | What it holds |
| --- | --- |
| `meshterm.db` | The history: every packet overheard, every trace walked, every message. **The one file `--db` moves.** |
| `meshterm-mock.db` | The same, for the simulator: where a `--mock` run records when it doesn't name a database itself. Absent until you run one. |
| `config.toml` | Machine setup — device profiles, where the database lives. Yours to write; MeshTerm only reads it. |
| `preferences.toml` | Your overrides of MeshTerm's own behaviour. Lists only what you changed. |
| `courier.json` | The outbox — queued messages, waiting and finished. |
| `admin.json` | Remembered repeater-admin passwords. |
| `devices.json` | The remembered default companion, and every device proven to speak the protocol. |
| `contacts.json`, `channels.json`, `settings.json` | Per-device caches of what the radio last told us, so a screen opens without a round-trip. |
| `adverts.json` | Per-device background-advert schedules (`config advert-cadence` writes here). |
| `adverts.json` | The weekly flood advert's clock: when it was switched on, and when each device's week began. |
| `mutes.json`, `watchtower.json`, `remote.json` | Muted channels; watched nodes and their alerts; per-node remote-admin cache and CLI history. |
| `regions.json` | The regions known by name — and which repeaters carry each — plus every channel's send scope and each repeater's last answer to "which regions do you carry?". What a scoped packet's region is named against. |
| `radio/<spidev>/` | An SPI radio's node, one folder per device node (`radio/spidev1.0/`): its identity key, settings, channels, and contacts, and its log, `node.log`. What firmware would keep in flash. |
| `tilecache/` | Downloaded basemap tiles. |
| `meshterm.log` | The log file. |
| `.lock` | The single-instance lock. |
**`--db` alone does not isolate a run.** It moves the database, which is most of the
weight but none of the identity: the contact and channel caches, the outbox, the stored
admin passwords and the remembered device all live beside it in the config directory and
admin passwords, and the remembered device all live beside it in the config directory and
would still be the ones you use every day. To run against a scratch state — a test, a
demo, a second radio — set `MESHTERM_HOME`, and set `--db` inside it if you want the
database somewhere else again.
database somewhere else again. The same holds for the database `--mock` picks for itself:
it keeps the simulator's invented mesh out of your history, not out of the caches.
---
## About the examples
**Every sample below was captured from a real run against the built-in simulator**
(`--mock`), in a throwaway `MESHTERM_HOME`. None of it is typed out by hand. The simulator
(`--mock`), in a throwaway `MESHTERM_HOME`, except the `diagnostics` sample, which says so
where it appears. None of it is typed out by hand. The simulator
answers as a companion would — it has four contacts (`Alice`, `Yagi-Repeater`,
`Local-Repeater`, `Observer-Bot`), it adverts, it replies to traces — so the shapes are
real even where the numbers are invented.
@@ -140,11 +144,13 @@ Three things the simulator does that a radio does not, all visible in the sample
*forced* path (`--path "a1,d4"`) resolves properly. The forced form is what the samples
use, and what to build a golden file from.
> **`--mock` records into your real history unless you tell it not to.** The simulator is
> a fake radio, not a fake MeshTerm: what it adverts is written to the database and the
> caches like anything else, and invented nodes then turn up in the mesh walk, the
> dashboard and the map. Give it its own home — `MESHTERM_HOME=... meshterm --mock …` —
> or its own database with `--db`, and it leaves your mesh alone.
> **`--mock` records into its own history, not yours — but it still writes the caches.**
> The simulator is a fake radio, not a fake MeshTerm: what it adverts is written down like
> anything else. The database it writes is `meshterm-mock.db`, so its invented nodes stay
> out of your mesh walk, dashboard, and map; naming a database with `--db` overrides that
> choice. The contact and channel caches, the outbox, and the remembered devices are still
> the ones you use every day, so a demo that should touch nothing at all gets a home of
> its own: `MESHTERM_HOME=... meshterm --mock …`.
---
@@ -172,11 +178,10 @@ Yagi-Repeater repeater 7m - a1 45.50190,-73.56740 a1b2c3d40000000
Alice node 7m - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000
```
Names used to be quoted, so that `awk` could split on the quote and get one field out of a
name holding a space. That bought one reader a delimiter and cost every other reader a
line full of punctuation. The **escaping under the quoting stays and always will**: a node
broadcasts its own name and a stranger fills in a message body, so neither may hold a raw
control character or end the record it sits in.
Names are not quoted. Quoting would give a script a delimiter and give every person a
line full of punctuation. Names **are escaped**: a node broadcasts its own name and a
stranger fills in a message body, so neither may hold a raw control character or end the
record it sits in.
**A time is an age**, because "recently?" is the question a listing is opened to answer:
`now`, `5m`, `3h`, `never`. An absolute instant survives where the instant *is* the fact —
@@ -186,11 +191,11 @@ the device clock, an appointment set with `--at`, a live capture's own `TIME` co
```console
$ meshterm --absolute contacts
NAME TYPE HEARD PKTS HASH LOCATION KEY
Observer-Bot node 2026-09-08T04:30:50-04:00 - c3 45.48800,-73.58100 c3d4e5f600000000000000000000000000000000000000000000000000000000
Local-Repeater repeater 2026-09-08T04:30:49-04:00 - b2 45.47680,-73.59900 b2c3d4e500000000000000000000000000000000000000000000000000000000
Yagi-Repeater repeater 2026-09-08T04:30:49-04:00 - a1 45.50190,-73.56740 a1b2c3d400000000000000000000000000000000000000000000000000000000
Alice node 2026-09-08T04:30:48-04:00 - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000
NAME TYPE HEARD PKTS HASH LOCATION KEY
Observer-Bot companion 2026-09-08T04:30:50-04:00 - c3 45.48800,-73.58100 c3d4e5f600000000000000000000000000000000000000000000000000000000
Local-Repeater repeater 2026-09-08T04:30:49-04:00 - b2 45.47680,-73.59900 b2c3d4e500000000000000000000000000000000000000000000000000000000
Yagi-Repeater repeater 2026-09-08T04:30:49-04:00 - a1 45.50190,-73.56740 a1b2c3d400000000000000000000000000000000000000000000000000000000
Alice companion 2026-09-08T04:30:48-04:00 - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000
```
The flag is an **override of a preference**, not a switch beside one: `cli_time_format` is
@@ -256,12 +261,12 @@ last so it can push nothing.
**No wrapping, with one exception.** A record is a line; wrapping would put half a
record's fields under the wrong headings, so nothing folds and a long line runs off the
right. The exception is the four written pages (`about`, `about-author`, `discord`,
`support`), which now wrap at 72 cells. The no-wrap rule exists to protect a record's
`support`), which wrap at 72 cells. The no-wrap rule exists to protect a record's
fields; a paragraph has no fields, and unwrapped it is a 600-cell line no terminal can
read.
**stdout is the answer; everything else is stderr** — errors (`meshterm: what went
wrong`), progress bars, log records, and now the two things that used to be thrown away:
wrong`), progress bars, log records, acknowledgements, and closing messages:
```console
$ meshterm config set radio_sf 9
@@ -271,10 +276,9 @@ $ meshterm config set radio_sf 9 2>&1
```
An **acknowledgement** ("✓ device clock set") and a command's closing **message** ("0
records stored") are reassurance a person needs and a script does not. They used to be
dropped on the CLI entirely. Sending them to stderr keeps the promise the dropping was
made to keep — `meshterm contacts > contacts.txt` still puts contacts in the file and
nothing else — while giving the person at the prompt back their tick and their count.
records stored") are reassurance a person needs and a script does not. Sending them to
stderr means `meshterm contacts > contacts.txt` puts contacts in the file and nothing
else, while the person at the prompt still sees their tick and their count.
---
@@ -373,7 +377,7 @@ present.
| `name` | string \| null | The name the node advertises. Never quoted, never truncated. |
| `key` | string \| null | The full public key, lowercase hex, 64 characters. `null` where only a hash was ever heard. |
| `hash` | string \| null | The short derived id, at the width the surrounding surface addressed the node by. Never `key` cut short. |
| `type` | string \| null | `"node"` \| `"repeater"` \| `"room"` \| `"sensor"` \| `null`. |
| `type` | string \| null | `"companion"` \| `"repeater"` \| `"room server"` \| `"sensor"` \| `null`. |
| `self` | boolean | `true` for our own node. What the menu's star says and the plain face deliberately does not. |
Reception facts — when heard, how strong, where — belong to the **row**, not to the node:
@@ -424,7 +428,7 @@ The return value is half the report, on both faces.
| Code | Meaning |
| --- | --- |
| `0` | Success. |
| `1` | Failure with no more specific code — an unreadable file, an unhandled fault. |
| `1` | Failure with no more specific code — an unreadable file, an unknown setting or preference key (`config set`, `preferences set`), an unhandled fault. |
| `2` | Usage error: an unknown flag, a missing argument, a value the parser rejected, a confirmation not given. |
| `3` | No companion device could be selected: none attached, the named one could not be opened, or the choice was ambiguous. **Nothing was transmitted.** |
| `4` | A device was reached but the operation failed — a command error, a timeout, a link lost mid-run. Retrying is reasonable. |
@@ -432,9 +436,8 @@ The return value is half the report, on both faces.
The table is printed under `meshterm --help` as well.
Two boundaries are worth stating because both used to sit in the wrong place. **Nothing
transmitted is not a device failure**: a value the tool refuses before it opens the radio
is a usage error, so a missing `--yes`, a bad `--sort` or `--category`, an unknown
Two boundaries need stating. **Nothing transmitted is not a device failure**: a value
the tool refuses before it opens the radio is a usage error, so a missing `--yes`, a bad `--sort` or `--category`, an unknown
`--profile`, an outbox id that does not exist, and `tx-optimize` with no password
available are all `2`. And **a named connection that will not open is `3`, not `4`**: a
`--port` that is not there means no device was selected, and nothing went out.
@@ -479,11 +482,16 @@ consumer that cannot would have to tell an error document from a result.
```console
$ meshterm --port NOSUCHPORT99 info
meshterm: could not open serial port NOSUCHPORT99: could not open port 'NOSUCHPORT99': FileNotFoundError(2, 'The system cannot find the file specified.', None, 2)
meshterm: could not open serial port NOSUCHPORT99: NOSUCHPORT99 isn't there — the device was unplugged, or came back under another name.
$ echo $?
3
```
The sentence names the cause and the remedy: a port that is missing, in use by another
program, or not yours to open, and for Bluetooth a link that never opened, a pairing that is
out of date, or a PIN that was wrong. [When a companion won't connect](connecting.md) lists
every one of them.
With nothing named and more than one candidate attached, the same `3` arrives as a list
and the way out of it:
@@ -583,8 +591,11 @@ 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. Under `--mock` it enumerates nothing at
List every attached serial device, every radio on this machine's SPI bus, and every
in-range Bluetooth companion. Opens no radio — it only enumerates what is attached or
advertising. An SPI radio is listed when its `/dev/spidev*` node exists, exactly as the
device screen lists it: one per SPI profile, plus the uConsole AIO's `/dev/spidev1.0` when
no profile covers it. 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.
@@ -602,7 +613,8 @@ COM26 serial USB Serial Device (COM26) Espressif maybe
COM1 serial Communications Port (COM1) (Standard port types) no - no
```
`TARGET` is what `--port` and `--ble` take, verbatim. `MESHCORE` is three-valued and stays
`TARGET` is what `--port` and `--ble` take, verbatim; an SPI radio's is its device node,
reached with `--spi` or its profile's `-p`. `MESHCORE` is three-valued and stays
that way: `yes` only once a connection has proved the device speaks the protocol, `maybe`
for a USB vendor ID that suggests a LoRa board or a bridge chip, `no` for anything else —
a vendor ID is a hint, and the column would be lying if it rounded one up.
@@ -617,11 +629,11 @@ table has no room for.
| Field | Type | Meaning |
| --- | --- | --- |
| `target` | string | What `--port` / `--ble` take, verbatim. |
| `transport` | string | `"serial"` \| `"ble"` \| `"tcp"`. |
| `port` | string \| null | The serial port, for a serial device. |
| `address` | string \| null | The Bluetooth address, for a BLE device. |
| `transport` | string | `"serial"` \| `"ble"` \| `"tcp"`, or `"mock"` under `--mock`. |
| `port` | string \| null | The serial port, for a serial device. `""` on the `--mock` row. |
| `address` | string \| null | The Bluetooth address, for a Bluetooth device. |
| `label` | string | The OS's name for the device, or the confirmed node name where we have one. |
| `hardware` | string \| null | The confirmed hardware model, else the USB vendor label. |
| `hardware` | string \| null | The confirmed hardware model, else the USB vendor label. `""` on the `--mock` row. |
| `meshcore` | string | `"yes"` \| `"maybe"` \| `"no"`. Not a boolean — rounding a hint up to `true` would be a lie. |
| `confidence` | string | `"board"` \| `"bridge"` \| `"unknown"` — what a `"maybe"` was derived from. |
| `serial_number` | string \| null | |
@@ -630,6 +642,9 @@ table has no room for.
| `remembered` | boolean | Whether it is the remembered default. |
| `active` | boolean | Whether this invocation is (or would be) using it. |
Under `--mock` there is no scan. The list is one row for the simulator: `target`
`"--mock"`, `transport` `"mock"`, `stable_id` `"mock:simulator"`.
Returns `5` when nothing is found.
---
@@ -682,10 +697,11 @@ always present:
{"name":"MockCompanion","public_key":"0000000000000000000000000000000000000000000000000000000000000000","role":"companion","model":"MeshCore Simulator","firmware":"mock mock","battery_v":4.1,"storage_used_kb":128,"storage_total_kb":1024,"clock_at":"2026-09-08T08:24:42Z","clock_drift_s":-125,"uptime_s":93784,"noise_floor_dbm":-110,"last_rssi_dbm":-62,"last_snr_db":9.5,"tx_air_s":42,"rx_air_s":360,"packets_sent":210,"packets_received":1234,"receive_errors":3}
```
`role` is `"companion"` \| `"repeater"` \| `"room server"` \| `"sensor"` \| `null`. This is
the one place the two faces disagree about absence on purpose: plain omits a key the
firmware never answered for, JSON writes `null`, because a variable key set costs every
consumer a lookup guard on every field.
`role` is `"companion"` \| `"repeater"` \| `"room server"` \| `"sensor"` \| `null`, or
`"type N"` for an advert type MeshTerm does not know. This is the one place the two faces
disagree about absence on purpose: plain omits a key the firmware never answered for, JSON
writes `null`, because a variable key set costs every consumer a lookup guard on every
field.
`info` does not embed a `node` object — it is our own node in far more detail than the
shape carries, and `name` / `public_key` here are its whole identity. What the radio is
@@ -705,7 +721,6 @@ View and change every device setting. With no subcommand it runs `show`.
| `custom KEY VALUE` | Set an experimental custom variable. |
| `channel INDEX NAME [--secret HEX]` | Configure a channel slot (see also the `channels` group). |
| `advert [--flood]` | Broadcast an advertisement. Zero-hop unless `--flood`. |
| `advert-cadence HOURS [--flood]` | How often to auto-advertise in the background. `0` disables. |
| `share` | Print this node's contact card as a `meshcore://` URI. |
| `sync-clock` | Set the device clock from this computer. |
| `export-key [--out PATH]` | Export the private key. **Sensitive.** |
@@ -724,10 +739,12 @@ radio_bw 62.5
radio_sf 8
radio_cr 5
tx_power 20
client_repeat false
airtime_factor 0.0
rx_delay 0.0
manual_add_contacts false
autoadd_config 0
autoadd_max_hops 0
flood_scope ""
adv_loc_policy 0
multi_acks 0
@@ -783,12 +800,10 @@ $ meshterm config channel 2 brigade --json
{"changes":1,"channel":{"slot":2,"name":"brigade","public":true,"hash":"b5"}}
$ meshterm config advert --json
{"sent":true,"flood":false}
$ meshterm config advert-cadence 6 --json
{"changes":1,"flood":false,"hours":6}
$ meshterm config sync-clock --json
{"changes":1,"set_at":"2026-09-08T08:29:27Z","drift_s":-125}
$ meshterm config backup node.toml --json
{"path":"D:\\vibe\\MeshTerm\\node.toml","settings":20,"channels":0,"custom":0}
{"path":"node.toml","settings":22,"channels":0,"custom":0}
$ meshterm config restore node.toml --dry-run --json
{"dry_run":true,"changes":0}
$ meshterm config export-key --json
@@ -801,8 +816,8 @@ $ meshterm config share --json
`previous` is `null` where the snapshot held no prior value; `changes` is `0` and
`applied` is `[]` where the value already was what was asked for, and the exit stays `0`
because the command did what it was asked. `backup`'s `path` is the file actually written
(the plain face prints it bare, so a caller can keep it). `export-key` carries a private
because the command did what it was asked. `backup`'s `path` is the file written, as you
named it (the plain face prints it bare, so a caller can keep it). `export-key` carries a private
key, exactly as the plain face does — that is what the command was asked for, and no extra
gate is introduced here. `factory-reset --yes` answers `{"factory_reset":true}`.
@@ -823,20 +838,20 @@ The contacts your device knows.
```console
$ meshterm contacts --sort name
NAME TYPE HEARD PKTS HASH LOCATION KEY
Alice node 7m - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000
Local-Repeater repeater 7m - b2 45.47680,-73.59900 b2c3d4e500000000000000000000000000000000000000000000000000000000
Observer-Bot node 7m - c3 45.48800,-73.58100 c3d4e5f600000000000000000000000000000000000000000000000000000000
Yagi-Repeater repeater 7m - a1 45.50190,-73.56740 a1b2c3d400000000000000000000000000000000000000000000000000000000
NAME TYPE HEARD PKTS HASH LOCATION KEY
Alice companion 7m - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000
Local-Repeater repeater 7m - b2 45.47680,-73.59900 b2c3d4e500000000000000000000000000000000000000000000000000000000
Observer-Bot companion 7m - c3 45.48800,-73.58100 c3d4e5f600000000000000000000000000000000000000000000000000000000
Yagi-Repeater repeater 7m - a1 45.50190,-73.56740 a1b2c3d400000000000000000000000000000000000000000000000000000000
```
`TYPE` is the node's advertised role in words — `node`, `repeater`, `room`, `sensor`, or
`unknown`. `HEARD` is a relative age, because "recently?" is what this listing is opened to
answer; `--absolute` turns it into an instant. `PKTS` is how many packets passive
`TYPE` is the node's advertised role in words — `companion`, `repeater`, `room server`,
`sensor`, or `unknown`. `HEARD` is a relative age, because "recently?" is what this listing
is opened to answer; `--absolute` turns it into an instant. `PKTS` is how many packets passive
monitoring has overheard.
**`HASH` is the token `--path` and `--to` take.** It used to have to be sliced out of `KEY`
by hand, and getting the width wrong meant addressing a different node. It is exactly the
**`HASH` is the token `--path` and `--to` take**, so it never has to be sliced out of `KEY`
by hand, where getting the width wrong would address a different node. It is exactly the
device's path-hash width. **`LOCATION`** is a fact the model always carried and no column
had room for. `KEY` stays full and last, so it runs off the right harmlessly and is never
elided: a truncated key is not something a caller can hand back.
@@ -847,7 +862,7 @@ far more detail than a row could hold.
**`--json`** is an array of rows, each embedding the `node` shape.
```json
{"node":{"name":"Observer-Bot","key":"c3d4e5f600000000000000000000000000000000000000000000000000000000","hash":"c3","type":"node","self":false},"heard_at":"2026-09-08T08:30:50Z","packets":null,"position":{"lat":45.488,"lon":-73.581}}
{"node":{"name":"Observer-Bot","key":"c3d4e5f600000000000000000000000000000000000000000000000000000000","hash":"c3","type":"companion","self":false},"heard_at":"2026-09-08T08:30:50Z","packets":null,"position":{"lat":45.488,"lon":-73.581}}
```
| Field | Type | Meaning |
@@ -871,12 +886,11 @@ the live view.
```console
$ meshterm monitor --seconds 2
TIME NODE SNR_DB RSSI_DBM LOCATION NAME
2026-09-08T04:26:00-04:00 a1b2c3d4 +7.0 -101 45.50190,-73.56740 Yagi-Repeater
2026-09-08T04:26:00-04:00 b2c3d4e5 +9.5 -98 45.47680,-73.59900 Local-Repeater
2026-09-08T04:26:00-04:00 c3d4e5f6 +4.9 -93 - Observer-Bot
2026-09-08T04:26:00-04:00 d4e5f6a7 +7.4 -86 - Alice
TIME NODE SNR_DB RSSI_DBM LOCATION SCOPE NAME
2026-09-08T04:26:00-04:00 a1b2c3d4 +7.0 -101 45.50190,-73.56740 - Yagi-Repeater
2026-09-08T04:26:00-04:00 b2c3d4e5 +9.5 -98 45.47680,-73.59900 - Local-Repeater
2026-09-08T04:26:00-04:00 c3d4e5f6 +4.9 -93 - - Observer-Bot
2026-09-08T04:26:00-04:00 d4e5f6a7 +7.4 -86 - - Alice
NODE NAME PKTS MEDIAN_SNR_DB BEST_SNR_DB RSSI_DBM HEARD LOCATION
d4e5f6a7 Alice 149 +6.9 +14.1 -93 now -
c3d4e5f6 Observer-Bot 148 +5.7 +13.0 -108 now -
@@ -884,9 +898,10 @@ b2c3d4e5 Local-Repeater 132 +6.7 +14.9 -102 now 45.
a1b2c3d4 Yagi-Repeater 132 +6.0 +13.6 -90 now 45.50190,-73.56740
```
Two blocks, separated by a blank line. The **stream** is a header and then a record per
packet as it arrives; its lanes are pinned in advance, because a capture cannot measure a
column it has not seen yet, and `TIME` is an absolute instant rather than an age — on a
Two blocks, one straight after the other: the summary starts at its own `NODE` header
line, with no blank line before it. The sample shows only the first four stream records.
The **stream** is a header and then a record per packet as it arrives; its lanes are
pinned in advance, because a capture cannot measure a column it has not seen yet, and `TIME` is an absolute instant rather than an age — on a
live tail every row would otherwise read `now`. The **summary** is the aggregate the
stream cannot give: a count, a median, a best per node, most recently heard first.
@@ -894,7 +909,7 @@ stream cannot give: a count, a median, a best per node, most recently heard firs
same shape, and **no summary document at the end**:
```json
{"observed_at":"2026-09-08T08:30:40Z","node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"kind":"advert","snr_db":7.0,"rssi_dbm":-101.3,"position":{"lat":45.5019,"lon":-73.5674},"path":null}
{"observed_at":"2026-09-08T08:30:40Z","node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"kind":"advert","snr_db":7.0,"rssi_dbm":-101.3,"position":{"lat":45.5019,"lon":-73.5674},"scope":null,"path":null}
```
| Field | Type | Meaning |
@@ -905,6 +920,7 @@ same shape, and **no summary document at the end**:
| `snr_db` | number \| null | |
| `rssi_dbm` | number \| null | |
| `position` | object \| null | The `position` shape. |
| `scope` | object \| null | The region a flood was sent into: `{"state", "region", "code"}`. `state` is `"scoped"` (`region` names it), `"unknown"` (scoped, but no region known here reproduces `code`) or `"unscoped"` (a plain flood). `code` is the frame's transport code as lowercase hex, kept even when unresolved so two packets can be seen to share a region. `null` for a direct packet, or a class whose route was never reported — a repeater never region-filters those. The plain `SCOPE` lane prints the region, `unknown`, `unscoped` or `-`. |
| `path` | array of strings \| null | The relay chain the packet arrived by, one lowercase-hex hash per hop, in propagation order. `[]` is a direct reception; `null` means the packet class carries no path at all. This is the one place `path` is an array — it is a *received* relay chain rather than a composed spec. |
**This is the one place a whole block appears on one face and not the other, and it is
@@ -968,7 +984,7 @@ prints, honestly.
| `lower_bound` | boolean | **JSON only.** `true` where the score is a floor — the plain face's `>=` prefix, extracted, so a consumer comparing scores need not string-match a qualifier off the front of its own data. |
| `recorded_at` | timestamp | |
| `version` | string | The MeshTerm version that discovered it. |
| `path` | string | **JSON only.** The transmitted spec, comma-separated hex — what a caller re-walks with. It has no column because a route line already runs off the right. |
| `path` | string \| null | **JSON only.** The transmitted spec, comma-separated hex — what a caller re-walks with. It has no column because a route line already runs off the right. `null` where the record stored no spec. |
| `route` | array | The `route` shape, hop-aligned with `path`. |
Returns `5` and `[]` when no records match.
@@ -985,7 +1001,7 @@ The live transcript is a menu screen; from the command line, pick a subcommand.
| Subcommand | What it does |
| --- | --- |
| `send TEXT --to NAME` | Send a direct message. |
| `send TEXT --channel N` | Broadcast on a channel slot. |
| `send TEXT --channel N [--scope REGION]` | Broadcast on a channel slot, under the channel's scope — or under `REGION` for this one message, `*` for unscoped. |
| `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. `-s 0` (the default) runs until Ctrl-C. |
@@ -1044,13 +1060,30 @@ for working out whether messages are being pulled from the companion at all.
```console
$ meshterm chat send "see you there" --to Alice --json
{"kind":"direct","node":{"name":"Alice","key":"d4e5f6a700000000000000000000000000000000000000000000000000000000","hash":"d4e5f6a7","type":"node","self":false},"channel":null,"sent":true,"acked":true}
{"kind":"direct","node":{"name":"Alice","key":"d4e5f6a700000000000000000000000000000000000000000000000000000000","hash":"d4e5f6a7","type":"companion","self":false},"channel":null,"sent":true,"acked":true}
$ meshterm chat send "net in 5" --channel 0 --json
{"kind":"channel","node":null,"channel":{"slot":0,"name":"#0","public":true,"hash":null},"sent":true,"acked":null}
```
`kind` is `"direct"` \| `"channel"` and says which of `node` / `channel` is populated.
A channel send also carries `scope`, in the same shape `monitor` gives a received packet's:
`{"state":"scoped","region":"harbour","code":null}` for a region, `"unscoped"` for a plain
flood, and `null` where it isn't known (a direct message, or a channel with no scope of its
own whose device default couldn't be read). `code` is `null` on a send: the code is a hash
of the packet, and the radio builds the packet.
`--scope` applies to a channel send only. A direct message has no scope of its own to give
— the radio floods it under the device default like anything else — so `--scope` with
`--to` is a usage error (exit `2`), as is a name the firmware would refuse. A radio that
can't send under the scope (firmware older than 1.10, or `*` before 1.16 with a default
scope set) refuses before anything is transmitted, and that is a failure (exit `1`),
never a quiet unscoped send. Quote the `*` so the shell leaves it alone:
```console
$ meshterm chat send "all clear" --channel 0 --scope '*' --json
{"kind":"channel","node":null,"channel":{"slot":0,"name":"#0","public":true,"hash":null},"sent":true,"acked":null,"scope":{"state":"unscoped","region":null,"code":null}}
```
`acked` is `null` on a channel, not `false`: there is no acknowledgement to have, and that
is exactly what one absence token is for — it is also why the plain face prints nothing
there, having no way to say it.
@@ -1095,8 +1128,9 @@ Join on `hash`.
| `import INDEX URL` | Import a `meshcore://channel/add` link. |
| `share INDEX` | Print a slot's share link. |
| `clear INDEX --yes` | Clear a slot, removing the channel from the device. |
| `scope INDEX [REGION] [--clear]` | Show or set the region the channel's messages are flooded into. |
`list` prints `SLOT NAME TYPE HASH`, one record per configured slot, and returns `5` when
`list` prints `SLOT NAME TYPE HASH SCOPE`, one record per configured slot, and returns `5` when
the device has none. (The simulator these examples run against has none, so there is no
captured listing to show; each `--mock` invocation opens a fresh radio, and a slot written
by one run is not there for the next.)
@@ -1115,9 +1149,19 @@ that is actually the answer.)
`clear` is gated behind `--yes`, because a private channel's key is lost with the slot
unless it is saved elsewhere.
`scope` is MeshTerm's to keep, not the radio's: the firmware has no per-channel scope, so
MeshTerm sets the companion's scope around each message it sends on the channel. Setting
one therefore writes nothing to the device, and it follows the channel to any slot it moves
to. With no `REGION`, `scope` prints the channel's region alone, the way `config get` prints
one value — and a channel with none prints `-` (`null` in `--json`) and exits `0`: it sends
under the device default, which is an answer. Setting (`scope 1 harbour`) or clearing
(`scope 1 --clear`) prints nothing plain. Reading an empty slot exits
`5`, like `share`; writing to one is a usage error (exit `2`), since there is no channel to
scope. `SCOPE` in `list` is `-` for a channel that sends under the device default.
**`--json`.** `list` is an array whose rows are **flat** — this is the one listing whose
record *is* the shared shape rather than carrying one, so a row is
`{"slot":0,"name":"Public","type":"public","hash":"11"}`, with `type` the plain column's
`{"slot":0,"name":"Public","type":"public","hash":"11","scope":null}`, with `type` the plain column's
word rather than the `channel` shape's `public` boolean. Nesting a `channel` key under
every row would make `jq '.[].channel.name'` out of a listing whose every column is
already the channel.
@@ -1329,7 +1373,7 @@ single transmission.
objects, which matters most here: the two nodes are told apart by nothing else on the line.
```json
{"tuning_node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"target":{"name":"Alice","key":"d4e5f6a700000000000000000000000000000000000000000000000000000000","hash":"d4e5f6a7","type":"node","self":false},"path":"a1b2c3d4,d4e5f6a7","optimal_tx_dbm":19,"target_snr_db":8.7,"reliability":1.0,"previous_tx_dbm":20,"applied":true,"levels":[{"tx_dbm":18,"target_snr_db":6.6,"successes":2,"samples":2},{"tx_dbm":19,"target_snr_db":8.7,"successes":4,"samples":4},{"tx_dbm":20,"target_snr_db":9.1,"successes":2,"samples":2},{"tx_dbm":21,"target_snr_db":7.6,"successes":2,"samples":2},{"tx_dbm":24,"target_snr_db":4.0,"successes":2,"samples":2},{"tx_dbm":27,"target_snr_db":-9.9,"successes":2,"samples":2},{"tx_dbm":28,"target_snr_db":-15.1,"successes":1,"samples":2}]}
{"tuning_node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"target":{"name":"Alice","key":"d4e5f6a700000000000000000000000000000000000000000000000000000000","hash":"d4e5f6a7","type":"companion","self":false},"path":"a1b2c3d4,d4e5f6a7","optimal_tx_dbm":19,"target_snr_db":8.7,"reliability":1.0,"previous_tx_dbm":20,"applied":true,"levels":[{"tx_dbm":18,"target_snr_db":6.6,"successes":2,"samples":2},{"tx_dbm":19,"target_snr_db":8.7,"successes":4,"samples":4},{"tx_dbm":20,"target_snr_db":9.1,"successes":2,"samples":2},{"tx_dbm":21,"target_snr_db":7.6,"successes":2,"samples":2},{"tx_dbm":24,"target_snr_db":4.0,"successes":2,"samples":2},{"tx_dbm":27,"target_snr_db":-9.9,"successes":2,"samples":2},{"tx_dbm":28,"target_snr_db":-15.1,"successes":1,"samples":2}]}
```
`reliability` is a fraction in `[0, 1]`, not a formatted `"1.00"`. `levels` is ordered by
@@ -1385,6 +1429,43 @@ node sent multiple lines. It is **not** parsed: MeshTerm does not know the remot
CLI grammar, and a document that pretended to would be inventing structure. `reply` is
`null` on the exit-`5` no-answer path.
#### `meshterm regions`
Ask a repeater which regions it relays floods for (firmware 1.12 or newer).
```
meshterm regions NODE
```
| Argument / option | What it does |
| --- | --- |
| `NODE` | The repeater's contact name. |
```console
$ meshterm regions Yagi-Repeater
node Yagi-Repeater
unscoped yes
regions lakeside, lakeside-north, harbour
```
One anonymous request — no login — and one transmission. A repeater answers it only when it
arrives direct: from a neighbour, or over a route the companion has learned. It also
rate-limits the question, so nothing here retries it. `unscoped` says whether it relays plain
floods too; `regions` lists the regions it relays *scoped* floods for. The answer is
remembered, so the node page and the packet views know it afterwards.
A repeater that answered and named nothing at all relays no floods, and returns `5`. One that
never answered is a device error (`4`). A name no contact matches, and a contact known to be
something other than a repeater, are usage errors (`2`): nothing was sent.
**`--json`:**
```json
{"node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"unscoped":true,"regions":["lakeside","lakeside-north","harbour"]}
```
`regions` never holds the wildcard `*` — that is `unscoped`, since it names no region.
---
### MeshTerm itself
@@ -1405,20 +1486,35 @@ runs `show`.
```console
$ meshterm preferences show
PREFERENCE VALUE DEFAULT DESCRIPTION
set_clock_on_connect off off Set the device clock from this computer when it connects
trace_cooldown_s 5 5 Wait this long before transmitting again
flood_advert_cooldown_s 60 60 Extra wait before another mesh-wide advert
weekly_flood_advert off off Flood an advert after a week without one
advert_quiet_s 30 30 Silence to wait for before the weekly advert
direct_message_soft_retries 0 0 Times to resend a message that gets no reply
tx_opt_min 18 18 Weakest power it will try
tx_opt_max 28 28 Strongest power it will try
tx_snr_tolerance_db 1 1 Signals this close are a tie, so less power wins
watch_silence_hours 12 12 Warn when a watched node goes quiet this long
watch_alerts_kept 200 200 How many past alerts to keep
map_view_fraction 0.5 0.5 Share of nodes to fit on screen; 1 shows them all
basemap_tilejson_url https://tiles.openfreemap.org/planet https://tiles.openfreemap.org/planet Where map images come from (next launch)
history_days 365 365 Older ones are deleted; 0 keeps everything
chat_history_limit 200 200 Old messages to load when you open a chat
courier_history_kept 100 100 Finished outbox messages to keep
cli_time_format relative relative Whether the command line prints ages or timestamps
fast_render on on Faster screen updates; turn off if it looks wrong (next launch)
full_width auto auto Use the extra column some terminals hide
color_depth auto auto How many colours to send this terminal (next launch)
console_setup auto auto Move to a better terminal when this console can't draw MeshTerm
console_font 6x12 6x12 Bigger text, or more rows on the handheld's panel
log_level WARNING WARNING How much MeshTerm writes to its log file (next launch)
```
**`DESCRIPTION` is back**, because a key's name is not its meaning and the alternative was
opening the menu to find out what one does. Values print in the form `preferences set`
accepts back — no unit suffix on a number, `on`/`off` for a boolean. Where `VALUE` differs
from `DEFAULT`, this install has an override.
**`DESCRIPTION`** says what each preference does, because a key's name is not its
meaning. Values print in the form `preferences set` accepts back — no unit suffix on a
number, `on`/`off` for a boolean. Where `VALUE` differs from `DEFAULT`, this install has an
override.
Overrides live in `preferences.toml` in [MeshTerm's state
directory](#where-meshterm-keeps-its-state), which lists only what you have changed; delete
@@ -1460,7 +1556,7 @@ each link; the scripted face prints the link alone.
**`--json`:**
```json
{"page":"discord","title":"Join Discord","version":"0.3.6","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
{"page":"discord","title":"Join Discord","version":"0.10.2","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
```
| Field | Type | Meaning |
@@ -1476,9 +1572,139 @@ bothers to draw a QR of. `text` is the page's plain rendering, not a fold of the
into nested JSON — a structured markdown tree would be a second rendering rather than data,
and nothing would consume it.
#### `meshterm diagnostics`
Everything a bug report opens with, in one block: which MeshTerm, on which host, in which
terminal, talking to which radio, over how much stored history. It is the answer to the
three or four questions that otherwise get asked one at a time in an issue thread, and the
person who hit the bug is usually the one least able to answer them — half of it is
resolved at boot and shown on no screen.
This sample is the one exception to [About the examples](#about-the-examples): it is an
illustrative report from a real radio over Bluetooth, because the simulator's report says
little. The field names and layout are what the command prints; the values are examples.
```console
$ meshterm diagnostics
meshterm 0.9.0
install frozen
os Windows 11
os_build 10.0.26100
arch AMD64
python 3.13.1
terminal Windows Terminal
term -
colorterm truecolor
terminal_size 120x30
over_ssh no
platform regular
icons yes
icons_source windows-terminal
font Cascadia Mono PL (windows-terminal)
powerline full (font:windows-terminal)
connected yes
transport ble
port -
device_role companion
device_model Heltec V3
firmware v1.7.1 1c3f9a2
radio_freq_mhz 906.8750
radio_bw_khz 250.00
radio_sf 10
radio_cr 5
tx_power_dbm 20
error -
config_dir C:\Users\you\.meshterm
db_size_kb 41280
log_level DEBUG
log_size_kb 96
runs_failed 3
first_heard 2026-01-14T19:42:10-05:00
last_heard 2026-09-21T11:03:00-04:00
TABLE ROWS
app_state 4
discovered_paths 1287
messages 3316
neighbour_reports 214
observations 418203
path_candidates 96
runs 912
schema_meta 1
trace_hops 233
traces 47
tx_samples 68
PREFERENCE VALUE
log_level DEBUG
history_days 730
```
The seven groups are the page's seven sections — build, host, terminal, radio, storage,
stored rows, changed preferences. The command line shows them as blank-line-separated
records, because that face is a stream to grep; the page and the saved file show them as
markdown headings, because that one is a document to read.
**The radio is asked but never required.** A report about a companion that will not connect
is exactly the report most worth filing, so a failure to reach it becomes `connected no`
with the radio's own words in `error`, and every other fact still arrives.
**Only overridden preferences are listed.** The defaults are in the source and identical for
everyone; the two the reporter changed are the two that can explain anything.
**The mesh is described in aggregate and only in aggregate** — a row count per table, and
the span of time the observations cover. The counts come off the schema rather than a list
written down somewhere, so a table added later starts being reported the day it lands.
**Nothing in the block is private.** No pairing PIN, no admin password, no channel secret,
no private key, no position, and no contact's name or key. That is a property of the
feature and not a habit: the block is designed to be pasted in public by somebody who has
not read it, and `tests/test_diagnostics.py` plants real secrets in every store that holds
one and fails if any of them — or a field merely *named* like one — reaches either face.
Under `--json`, the five fact blocks merge into one flat object and the two listings keep
a key of their own:
```json
{"meshterm":"0.9.0","install":"frozen","os":"Windows 11","…":"…","tables":[{"table":"app_state","rows":4},"…"],"preferences":[{"preference":"log_level","value":"DEBUG"},{"preference":"history_days","value":"730"}]}
```
**`--out PATH` writes it to a file instead of printing it**, and the answer becomes the
path — the same trade `config export-key --out` makes, since a caller who asked for a file
wants to be told where it is rather than handed the contents they just redirected into it.
```console
$ meshterm diagnostics --out meshterm-diagnostics.md
✓ diagnostics written — attach it to the report.
meshterm-diagnostics.md
```
**The file is markdown**, whichever face the run was printing, because an issue tracker
renders markdown as written — no fence, no apology. Every value is a code span, so a
Windows path's backslashes and a firmware error's asterisks arrive as themselves rather
than as markdown. Redirection still works and is not replaced; `--out` is what gives you
the document rather than the record stream.
In the menu this is the **Diagnostics** page under *This app*, and it is one of the app's
**written pages** — the same markdown renderer behind the About pages, drawing the same
document the file holds. That buys what a long block most needs: its `##` headings are
landmarks, so each pins to the top row while its own section scrolls under it and
`^PgUp`/`^PgDn` step section by section.
`s` saves, advertised in the footer hint on a desktop and on the lane's free **F3** slot on
the PicoCalc — never in the page's body, which belongs to the report. It writes
`meshterm-diagnostics.md` into the config directory beside the log, and answers in a popup
naming the path. A write that fails says so in the same popup, in red, and leaves the page
readable.
#### `meshterm platform`
A diagnostic for which UI flavour a given invocation resolves to, and why.
The narrow sibling of `diagnostics`: which UI flavour a given invocation resolves to, and
why, without asking the radio anything.
```console
$ meshterm platform
@@ -1598,18 +1824,20 @@ for i in $(seq 5); do
done
```
**Let MeshTerm advertise, rather than cron.** Background adverts are MeshTerm's own job,
not the firmware's, and the cadence is a policy you set once:
**Let MeshTerm advertise, rather than cron.** A companion never advertises by itself, so
MeshTerm can do it for you — once a week, and only when the air is quiet:
```bash
meshterm config advert-cadence 3 # zero-hop, to neighbours, every 3 h
meshterm config advert-cadence 6 --flood # mesh-wide, every 6 h
meshterm preferences set weekly_flood_advert on # starts the week; sends nothing now
meshterm preferences set advert_quiet_s 60 # wait for a minute of silence first
```
A running MeshTerm session sends them on that schedule. **Do not put `config advert
--flood` in a tight cron slot**: the cadence picker's floor for a flood is 3 h, and a
`*/30` line would be six times tighter than the app will ever let a person choose. A
repeater that decides you are bursting can blacklist you.
A running MeshTerm session then floods one advert from a device once it has gone a full
week without one — a flood advert you send by hand restarts that device's week — and only
after it has heard the mesh and then heard nothing for the quiet spell plus a random 0–5 s.
**Do not put `config advert --flood` in a cron slot instead**: every repeater in range
rebroadcasts a flood advert, and a repeater that decides you are bursting can blacklist
you.
**Cron-friendly quiet.** `--quiet` suppresses console logging entirely, leaving only the
command's own output and any error line. Cron's `PATH` is nearly empty, so give it the
+171
View File
@@ -0,0 +1,171 @@
# Configuring MeshTerm
Preferences, device profiles, and where everything is kept. You need none of it to run
MeshTerm: it discovers a companion, lets you pick one, and remembers the choice.
Two files, two jobs — and you need neither to run.
**Preferences** are how MeshTerm behaves: the pause it leaves between transmissions, how
hard it retries a message, how far back it keeps history, how a map frames itself. Every one has a built-in default, so change them only where you disagree — from
the **Preferences** page in the menu (grouped, staged, saved by one action at the bottom),
or from a shell:
```console
$ meshterm preferences show # every preference, its value, its default
$ meshterm preferences set history_days 90
$ meshterm preferences reset --yes # back to the built-in defaults
```
They are kept in `~/.meshterm/preferences.toml`, which lists only what you have changed;
delete a line and the default takes over again.
**Config** is where things live and which device to talk to. MeshTerm runs with none of it
— it discovers attached serial devices and nearby Bluetooth companions, lets you pick one,
and remembers the last good default. A network (TCP) companion isn't discoverable, so reach
it with `--tcp host:port`, a TCP profile, or the picker's "add a network device" prompt.
A LoRa radio on this machine's own SPI bus is listed on the picker when its device node
exists, or reached with `--spi`; see [the uConsole manual](uconsole.md).
Write a config file only to give your hardware stable aliases.
Copy [`config.example.toml`](../config.example.toml) to `~/.meshterm/config.toml`:
```toml
# default_profile = "s3" # uncomment and name your own profile to make it the default
connect_on_start = true # false opens the radio link lazily instead of at launch
# db_path = "C:/Users/you/meshterm/meshterm.db" # the history lives elsewhere
[profiles.s3]
port = "COM5"
baudrate = 115200
description = "XIAO ESP32-S3 + Wio SX1262 serial companion"
# A Bluetooth LE companion: give it an `address` instead of a `port`.
[profiles.handheld]
address = "AA:BB:CC:DD:EE:FF"
# ble_pin = "123456" # only if your device requires a pairing PIN (not used on macOS)
description = "Pocket handheld over Bluetooth"
# A network (TCP) companion: give it a `host` (and optional `tcp_port`, default 5000).
[profiles.wifi]
host = "192.168.1.50"
description = "Basestation over Wi-Fi"
# A radio on this machine's own SPI bus (the uConsole AIO): MeshTerm runs its node.
# The pins default to the AIO v1's; a `spi` table states only what differs.
[profiles.aio]
transport = "spi"
# [profiles.aio.spi]
# en_pins = [27] # the AIO v2 powers its radio from pin 27
```
Both live in `~/.meshterm`, along with everything else MeshTerm remembers: the SQLite
history, the outbox, the contact and channel caches, stored admin passwords, and the log.
**`$MESHTERM_HOME` moves the whole directory**, which is the way to run a second radio —
or a `--mock` session — without touching the one you use every day. `--db` moves the
database alone; the rest stays where it was. A `--mock` run that names neither writes to
`meshterm-mock.db` beside the real history, so the simulator's invented nodes never enter
it. [`docs/cli.md`](cli.md#where-meshterm-keeps-its-state)
lists every file.
Timestamps are stored as UTC and rendered in your local time. Heard packets older than the
`history_days` preference are deleted when monitoring starts: once in each menu session,
and at the start of each `meshterm monitor` run. Nothing else is pruned.
## Adding a radio on the SPI bus
Some boards put the LoRa chip straight on the computer's SPI bus, with no firmware in front
of it: the uConsole's AIO board, and LoRa HATs for the Raspberry Pi. MeshTerm drives these
itself — it runs the node while it is connected and lets go of the radio when it quits.
It needs Linux and the radio library; [the uConsole manual](uconsole.md#step-2--install-the-radio-library)
shows how to install that.
**An AIO v1 needs no configuration.** Its wiring is MeshTerm's default, so whenever
`/dev/spidev1.0` exists the device screen lists it as **SPI radio**, and `meshterm --spi`
reaches it. Any other board is added with a profile that states how it is wired.
**Why a profile, and not a prompt on the device screen.** A USB companion describes itself:
plug it in and there is nothing to ask. An SPI radio says nothing about how it is wired, and
the wiring is a dozen facts — the bus, the chip select, the reset, busy, and interrupt pins,
the antenna switch, the TCXO — any one of which, wrong, leaves a radio that comes up deaf
rather than failing. Those are facts to look up once and write down, not to type into a
dialog. And the radio doesn't come and go the way a cable does: it is soldered, stacked on
a header, or built into the case, so its description belongs somewhere as lasting as the
hardware is. A file you write once, and can read back when something is off, is that place.
### 1. Find your board's wiring
The easiest place to find it is the board's `meshtasticd` configuration, which most vendors
publish (usually a `config.yaml` or a `config.d/` fragment with a `Lora:` section). Its keys
map one to one:
| `meshtasticd` (`Lora:`) | MeshTerm (`[profiles.<name>.spi]`) |
| --- | --- |
| `spidev: spidev0.0` | `bus_id = 0` and `cs_id = 0` — the two numbers of `spidevB.C` |
| `CS: 21` | `cs_pin = 21` (only when the board drives chip select from a GPIO) |
| `Reset`, `Busy`, `IRQ` | `reset_pin`, `busy_pin`, `irq_pin` |
| `TXen`, `RXen` | `txen_pin`, `rxen_pin` |
| `DIO2_AS_RF_SWITCH: true` | `use_dio2_rf = true` |
| `DIO3_TCXO_VOLTAGE: true` | `use_dio3_tcxo = true` (1.8 V) |
| `gpiochip: 4` | `gpio_chip = 4` |
`Module` must be `sx1262`; that is the chip MeshTerm's node drives. `gpio_chip` is `0` on
a Compute Module 4 but differs on a Raspberry Pi 5 and a CM5 — `gpiodetect`, or
`ls /dev/gpiochip*`, shows which chip carries the 40-pin header.
**State every pin and both switches.** Where a `meshtasticd` file leaves a key out, it means
"none" or "off". Where the `spi` table leaves a key out, it means *the AIO's value* — IRQ on
26, DIO2 and DIO3 on — which is wrong for almost any other board, and a radio wired wrong
comes up deaf rather than failing.
### 2. Write the profile
In `~/.meshterm/config.toml`, give the radio a name and its wiring. The values below are an
illustration, not any particular board's — take yours from step 1:
```toml
[profiles.hat]
description = "SX1262 HAT on the Pi header"
[profiles.hat.spi]
bus_id = 0 # /dev/spidev0.0
cs_id = 0
reset_pin = 18
busy_pin = 20
irq_pin = 16
txen_pin = 6 # this HAT switches its antenna from a GPIO…
rxen_pin = -1
use_dio2_rf = false # …so DIO2 doesn't
use_dio3_tcxo = false # no TCXO on this board
```
A `spi` table is enough to make it an SPI profile; `transport = "spi"` says so outright and
is what an AIO profile with no table uses. Every key, with the AIO defaults, is in
[the uConsole manual's wiring table](uconsole.md#board-wiring). A key MeshTerm doesn't know,
or a value of the wrong type, stops it at startup with the profile named — a misspelt pin
is never quietly ignored.
### 3. Check it
```bash
meshterm -p hat info
```
The first run starts the node, which mints the radio's identity. If something is wrong, the
error says what to fix — a missing `/dev/spidev*` (enable SPI and reboot), a permission
problem (join the `spi` and `gpio` groups), a missing radio library, or another program
already holding the radio. A node that starts but never answers leaves its log in
`~/.meshterm/radio/spidev0.0/node.log`. Once it answers, the device screen lists the radio
under the profile's name.
### What the profile doesn't hold
- **Radio settings.** Frequency, bandwidth, spreading factor, coding rate, and TX power are
the node's own settings, saved by the node and changed on **Device config** like any
companion's. A new node starts on MeshCore's **US/Canada preset** (910.525 MHz, 62.5 kHz,
SF7, CR 4/5, 22 dBm): anywhere else, change it on Device config before you send anything.
- **The preamble.** The node follows MeshCore's own rule — 32 symbols up to SF8, 16 above —
and a receiver that expects anything else misses most of the mesh. `preamble_length` was
once a wiring key and is refused now; delete the line if an old profile has it.
- **Identity.** The node's key, contacts, and channels live in
`~/.meshterm/radio/<spidev>/`, filed by the device node rather than the profile, so
renaming a profile, or reaching the same radio from the device screen, keeps the same node.
+128
View File
@@ -0,0 +1,128 @@
# When a companion won't connect
MeshTerm talks to a MeshCore companion over USB, Bluetooth, or TCP, or runs the node itself
on a radio on the SPI bus. When a connection fails, MeshTerm says which step failed and what
to do about it, in the device picker and on the command line alike. This page collects those
messages, so you can look one up before you meet it, or search for the one you have.
Every failure is also written to the log file, `~/.meshterm/meshterm.log` (see
[Where MeshTerm keeps its state](cli.md#where-meshterm-keeps-its-state)). Attach it to a
bug report. For more detail, set the **Log detail** preference to `DEBUG` (`meshterm
preferences set log_level DEBUG`) and run it again.
- [Bluetooth](#bluetooth)
- [Who does the pairing](#who-does-the-pairing)
- [Finding the PIN](#finding-the-pin)
- [What each message means](#what-each-bluetooth-message-means)
- [Forgetting a pairing](#forgetting-a-pairing)
- [USB](#usb)
- [TCP](#tcp)
- [A radio on the SPI bus](#a-radio-on-the-spi-bus)
---
## Bluetooth
**A companion takes one Bluetooth connection at a time.** If your phone's MeshCore app is
connected to it, MeshTerm cannot connect until the phone lets go, and the companion stops
advertising while it is connected, so MeshTerm cannot even see it. Disconnect the phone
first, or turn its Bluetooth off.
### Who does the pairing
A PIN-protected companion must be *paired* before it will talk: the computer and the
companion exchange keys once, using the six-digit PIN, and remember them. Who runs that
exchange depends on the system.
| System | Who pairs | What you do |
| --- | --- | --- |
| Windows | MeshTerm | Give the PIN when MeshTerm asks for it (the picker's PIN dialog, or `--ble-pin` on the command line). You do not need to pair in Settings first. |
| Linux | MeshTerm | The same. MeshTerm answers the pairing itself, so it works over SSH and on machines with no desktop, and you do not need `bluetoothctl` or the desktop's Bluetooth settings. |
| macOS | macOS | macOS shows its own dialog asking for the code; type the PIN there. `--ble-pin` has no effect on macOS. |
Once paired, the system remembers it, and later connections need no PIN.
**On macOS, the terminal needs Bluetooth permission.** macOS grants Bluetooth to the app
MeshTerm runs in (Terminal, iTerm2, Ghostty), not to MeshTerm itself. If no pairing dialog
appears, or no Bluetooth devices are listed, check System Settings > Privacy & Security >
Bluetooth. **Over SSH, macOS denies Bluetooth outright** and never asks, so run MeshTerm in
a terminal on the Mac itself.
### Finding the PIN
The PIN is shown on the companion's screen, or in the MeshCore app's settings for it. A
companion with no screen (a T1000-E, for instance) can tell you over USB instead:
```console
$ meshterm --port /dev/ttyACM0 config get device_pin
123456
```
`meshterm config set device_pin <PIN>` changes it, and so does the **Device PIN** row on
the menu's Device config page. A changed PIN makes every existing pairing with that
companion out of date. See [a stale pairing](#what-each-bluetooth-message-means) below.
### What each Bluetooth message means
In the device picker, a pairing problem opens the PIN dialog with a one-line reason under
the question. On the command line, it is the error message. Both come from the same
diagnosis.
| The message starts with… | What happened | What to do |
| --- | --- | --- |
| **couldn't open a Bluetooth link** | The companion was never reached. | Move closer, check it is powered on, and make sure no phone or other computer is connected to it. |
| **requires a Bluetooth pairing PIN** | The companion is PIN-protected and not paired with this computer. | Give the PIN: the picker asks for it; on the command line, add `--ble-pin <PIN>`. |
| **Windows has … paired** / **Linux has … paired, but the device refused that pairing** | Your computer remembers a pairing that the companion no longer accepts. It was reflashed, reset, or given a new PIN, or it was paired *without* a PIN back when it did not have one. | Give the PIN, and MeshTerm removes the old pairing and pairs again. Or [forget the pairing](#forgetting-a-pairing) yourself and reconnect. |
| **rejected the Bluetooth PIN** | The PIN was wrong. | Check the code on the device or in the MeshCore app, and try again. |
| **refused to pair** | The companion turned the pairing down. It is usually connected to a phone, or still holds an old pairing for this computer. | Disconnect it from anything else, or restart it, and try again. |
| **Windows couldn't pair** / **Linux couldn't pair** | The pairing failed for another reason, named in brackets. | Restart the companion, [forget the pairing](#forgetting-a-pairing), and try again. |
| **Windows couldn't reach** / **Linux couldn't reach … to pair it** | The system could not find the companion when it came to pair. | As for a link that never opened: range, power, or another connection. |
| **paired with …, but the device still refuses the connection** | Pairing succeeded, yet the companion still refuses. It is holding an old pairing for this computer. | Restart the companion, [forget the pairing](#forgetting-a-pairing), and try again. |
| **was not paired. macOS asks for the pairing code in its own dialog** | macOS had not finished pairing. | Type the PIN into the macOS dialog when it appears. If none appears, check the terminal's Bluetooth permission (above). |
| **connecting … took longer than** | The link, the pairing, or the service discovery stalled. | Move closer, restart the companion, and try again. |
| **connected …, but it didn't answer the identity query** | The link came up and the companion went quiet. | It may be busy with another app. Restart it and try again. |
| **connected …, but it never answered as a MeshCore companion** | Something answered, but not as a companion. | It may be running repeater or room server firmware, which has no companion interface. |
### Forgetting a pairing
MeshTerm fixes a stale pairing itself when you give it the PIN. To remove one by hand:
- **Windows:** Settings > Bluetooth & devices, open the companion, and choose *Remove device*.
- **Linux:** `bluetoothctl remove AA:BB:CC:DD:EE:FF`, with your companion's address.
- **macOS:** System Settings > Bluetooth, the companion's ⓘ button, then *Forget This Device*.
On Windows and Linux, MeshTerm's quit dialog also offers **Unpair & quit** while a paired
Bluetooth companion is connected. It forgets the pairing on the way out, so the next
connection asks for the PIN again.
---
## USB
| The message starts with… | What happened | What to do |
| --- | --- | --- |
| **no permission to open** | Your user may not open the port. On Linux, serial ports belong to a group, `dialout` on Ubuntu and Debian. | `sudo usermod -aG dialout $USER`, then log out and back in. The new group applies only to a new login. |
| **… is in use by another program** | Something else has the port open: the MeshCore app, a firmware flasher, or a serial monitor. On Windows this is reported as *Access is denied*. | Close the other program. On Linux, ModemManager probes new USB serial devices and can hold one: `sudo systemctl stop ModemManager`, and try again. |
| **… isn't there** | The port does not exist. The device was unplugged, or came back under another name (`/dev/ttyACM0` becoming `/dev/ttyACM1`, `COM5` becoming `COM6`). | `meshterm devices` lists what is attached now. |
| **no response from a MeshCore companion on …** | The port opened, but nothing answered as a companion. | It may not be a MeshCore companion (repeater firmware, or another device), or it is powered off or busy. On Linux, ModemManager may be probing it: `sudo systemctl stop ModemManager`, and try again. |
To stop ModemManager for good rather than for one boot, `sudo systemctl disable --now
ModemManager`, if nothing else on the machine needs it. Nothing does unless you have a
cellular modem.
---
## TCP
**no response from a MeshCore companion at HOST:PORT** means nothing answered there. Check
the host and the port (MeshTerm's default is `5000`), that the companion is powered on and
reachable on your network, and that no other client is already connected to it. Like
Bluetooth, a network companion serves one client at a time.
---
## A radio on the SPI bus
A radio MeshTerm drives itself (`--spi`) is not a companion. It has no pairing and no port,
and its failures are about the node MeshTerm starts. Its own guides cover them:
[the uConsole](uconsole.md) and [adding a radio on the SPI bus](configuration.md#adding-a-radio-on-the-spi-bus).
+108
View File
@@ -0,0 +1,108 @@
# The CLI cookbook
Common one-liners, by the thing you are trying to do. [The command line](cli.md) is the
full manual — every command, every option, both output faces, the exit statuses — and its
own [Recipes](cli.md#recipes) section goes further than this one, into `jq` pipelines and
shell loops.
Nearly every menu option is also a subcommand — ideal for scripting, cron, and bots. The
live pictures stay in the menu, where their meaning is (the map, dashboard, live feed,
watchtower, and mesh walk); everything else has a command, and every command speaks
`--json`. **[`docs/cli.md`](cli.md) is the full manual**: every command and option,
what each one prints on both faces, and the exit statuses. A taste:
```bash
# Trace — a trace transmits exactly once; run it again to sample more.
meshterm trace --target Alice --profile yagi
# Force a route through specific repeaters (MeshCore-app style): comma-separated
# contact names and/or hex key prefixes, mixed freely. Blank lets the device route.
meshterm trace --target Alice --path "3d,f2,3d"
meshterm trace --target Alice --path "3d,Bravo-Repeater,f2"
# Walk a composed circuit with no target at all — out and back your own way
meshterm trace-path --path "3d,f2,3d"
# Sweep and apply a remote node's TX power
meshterm tx-optimize --path "Bravo-Repeater,Alice" --samples 6 --step 3 --apply
# Messaging: the live transcript is a menu screen; the CLI takes a subcommand
meshterm chat send --to Alice "on my way" # direct message
meshterm chat send --channel 0 "net in 5" # channel broadcast
meshterm chat history --to Alice
meshterm chat list
# Store-and-forward: queue for a contact that's offline right now
meshterm courier queue Alice "ping me when you're back" --at 18:30
meshterm courier list # the outbox — waiting and finished
meshterm courier send 3 # force one delivery attempt now
# Remote repeater admin (one transmission per invocation). --password is needed
# until an interactive login has stored one.
meshterm repeater-admin Bravo-Repeater "get name" --password YOUR-ADMIN-PASSWORD
# Device configuration: view, set, back up, restore
meshterm config # show all current settings, one `key value` per line
meshterm config get name # just the value, ready for $(...)
meshterm config set radio_sf 9 # change one setting
meshterm config backup node.toml # archive every setting to TOML
meshterm config restore node.toml --dry-run
meshterm preferences set weekly_flood_advert on # flood an advert once a week, when quiet
# Passive capture window (records to history; transmits nothing)
meshterm monitor --seconds 60
```
Global options — `--profile/-p`, `--port`, `--ble`, `--ble-pin`, `--tcp`, `--spi`, `--mock`,
`--db`, `--json`, `--absolute`, `--quiet/-q`, `--platform` — may be typed **before or
after** the subcommand: `meshterm contacts --json` and `meshterm --json contacts` are the
same run.
## Two output faces
*(The short version — [`docs/cli.md`](cli.md) has the whole of it.)*
The **plain face** is for a person at a prompt. It prints like a standard Unix utility —
no colour, no borders, one record per line, nothing wrapped except the four written
pages (`about`, `about-author`, `discord`, `support`), which wrap at 72 cells — and it is
allowed to be comfortable about it: listings are `ps`-style aligned records with **bare names**
(alignment is the delimiter), times are **relative ages** (`now`, `5m`, `never`), and a
route is drawn with arrows — `MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4)`.
`--absolute` swaps every age back for an ISO-8601 instant. A *path*, the spec `--path`
takes back, stays comma-separated hex. `-` is the one token for absent. Errors,
acknowledgements, and progress all go to stderr, so a redirect catches only the answer.
The **JSON face** is the machine contract, and `--json` works on **every** command:
```console
$ meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
b2c3d4e500000000000000000000000000000000000000000000000000000000
a1b2c3d400000000000000000000000000000000000000000000000000000000
```
No envelope — an array for a listing, an object for a set of facts. One compact line;
`monitor` and `chat listen` stream one document per record. Values are typed, absent is
`null` and never an omitted key, and timestamps are always UTC to the second regardless of
`--absolute`. `--json` changes the rendering, never the report: same records, same exit
status.
Anything structural should go through `--json` and `jq`. The plain face is for looking at.
## Exit status
`0` success · `1` failure · `2` usage error · `3` no device found (nothing was
transmitted) · `4` the device was reached but the operation failed · `5` nothing to
report. The table with its full wording is printed under `meshterm --help` and explained
in [`docs/cli.md`](cli.md#exit-status).
`5` is the one worth knowing about: it lets a script tell "found nothing" from "worked"
without counting output lines.
```bash
if meshterm contacts > contacts.txt; then
echo "$(($(wc -l < contacts.txt) - 1)) contacts" # minus the header
elif [ $? -eq 5 ]; then
echo "no contacts yet"
fi
```
+72
View File
@@ -0,0 +1,72 @@
# What MeshTerm does
Every screen the menu offers, and the command that does the same job without it. This is
the catalogue; [the command line](cli.md) is the manual for the scripted half of it.
MeshTerm's menu asks one question — *what would you like to do?* — and its six sections
answer it in two halves. The first three name a **doing**: Message, Watch, Explore. The
last three name **whose** a setting is: This node, the radio in your hand;
Other nodes, someone else's over the mesh; and This app, the program in front of you.
Every interactive screen is listed here with its scripted equivalent, where one exists.
## 💬 Message — the people on the other end
| Feature | What it does | Scripted |
| --- | --- | --- |
| **💬 Chat** | Live full-screen channel and direct messaging — a scrolling transcript with a pinned input line where sent and received messages stream together. Every message is logged; unread counts show in the menu header. | `meshterm chat send / history / list` |
| **📻 Channels** | Create, join, reorder, mute, and share mesh channels — with QR codes and `meshcore://` share links. Give a channel a **send scope** and its messages are flooded into one region, relayed only by the repeaters that carry it. | `meshterm channels list / add / join / import / share / clear / scope` |
| **📨 Courier** | Store-and-forward outbox for contacts that aren't reachable yet. Queue a message; it goes out (with ack tracking and polite exponential backoff) the moment the contact is next heard, or at a scheduled time. | `meshterm courier queue / list / send / cancel / clear` |
| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. The scripted listing is the contacts alone (this node is `meshterm info`'s answer, in far more detail). | `meshterm contacts` |
## 📊 Watch — what the mesh is doing, and what it did
| Feature | What it does | Scripted |
| --- | --- | --- |
| **📊 Dashboard** | The live mesh overview: a two-hour all-packet activity chart with a pulse line, session traffic tallies by packet class, and window RF health beside the radio's own live numbers. Repaints every second. | — |
| **📰 Live feed** | Every packet as it arrives, newest first: time, what the frame *is* (its parsed payload class), and what it is *about* — the node for an advert, the channel for a channel text, sender → recipient for a direct message or a request, the tag for a trace — with reception quality beside it, and the region a scoped flood was sent into. Enter opens any row in the packet viewer, route graph and all. | — |
| **🚨 Watchtower** | A passive sentinel over the nodes you star: silence alarms when a watched node goes quiet, SNR-sag warnings when reception degrades, recovery notes when it returns, and a heads-up when a node is heard for the first time. Runs in the background all session; unacked alerts show as a header badge. | — |
| **⏳ Time machine** | Everything the recorder ever heard, as braille charts: reception volume, the median-SNR band, hour-of-day rhythm, packets and nodes per day, and first-ever arrivals — over a switchable 24h / 7d / 30d / all-time window (on the PicoCalc the windows stop at 30d), per node or mesh-wide. | — |
| **🎧 Monitor** | Passive recording is always on in the app. On the CLI, capture a bounded foreground window, tailing each overheard packet and summarising when it ends. It transmits nothing — it only listens. | `meshterm monitor --seconds 60` |
## 🧭 Explore — where the nodes are and how they reach each other
| Feature | What it does | Scripted |
| --- | --- | --- |
| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | — (a map is a picture; `meshterm contacts` lists the same nodes) |
| **🌐 Mesh walk** | The mesh's *observed shape*, walked one node at a time: an evidence graph built from trace walks, firmware routes, overheard relay chains, and repeater neighbour tables. SNR-coloured braille edges, quality bars, Enter to walk, ⌫ to backtrack, type to find any node. The map answers *where*; the walk answers *how it hangs together*. | — |
| **🎯 Trace target** | A live trace screen: pick a target and watch each trace stream in hop by hop, with running per-hop medians and reliability. Compose or force a route through specific repeaters. A trace transmits **exactly once** (repeaters can blacklist nodes that burst) — sample more by running it again. | `meshterm trace --target …` |
| **👣 Trace path** | The other half of tracing: compose the whole circuit by hand — out and back whichever way you choose — and walk it. The path composer suggests each next hop from the links actually observed, strongest first, and can fetch a repeater's neighbour table over the mesh when you hold its admin password. | `meshterm trace-path --path …` |
| **🏆 Trophy case** | Every trace that comes home is scored, on seven boards: longest distance, farthest node, longest single leg, most nodes (with and without revisits), weakest surviving link, and biggest enclosed loop. Records are kept per hash width, and a record walk must be a *trail* — no link crossed twice the same way. | `meshterm records` |
## 🔧 This node — the radio in your hand
| Feature | What it does | Scripted |
| --- | --- | --- |
| **📋 Device info** | The connected companion's identity and full radio configuration at a glance. | `meshterm info` |
| **🔧 Device config** | Every setting the companion firmware exposes — name, radio, client repeat, auto-add, telemetry, custom variables — *staged* for review and applied in place, plus the operations on the box itself: clock sync, TOML backup/restore, the identity key, reboot, and factory reset, each acting the moment it's confirmed, with destructive ops gated. Laid out like Repeater admin, so the radio in your hand and one over the mesh are configured the same way. | `meshterm config` / `config set <key> <value>` / `config backup` / `config reboot` / … |
| **📡 Send advert** | Announce this node to the mesh — a zero-hop or flood advertisement, or share this node's contact card as a QR code. | `meshterm config advert` / `config share` |
| **🔌 Devices** | Not a menu screen. The companion is picked on the startup splash, which enumerates serial *and* Bluetooth LE companions (or auto-selects the only one present), adds a network (TCP) companion by host:port, and remembers the last good default. A dropped link is detected live and offers to reconnect. `meshterm devices` lists what discovery finds. | `meshterm devices` |
## 🗼 Other nodes — someone else's radio, over the mesh
| Feature | What it does | Scripted |
| --- | --- | --- |
| **🗼 Repeater admin** | Set up remote repeaters and room servers over the mesh: log in (remembered or prompted password), then a config-style editor speaking the node's text CLI — including repeater-only knobs (TX delay, airtime factor, advert intervals) — plus one-shot actions and a readline remote command line. A **Regions** page edits the repeater's region tree: which regions it relays floods for, whether it relays unscoped floods, its home and default scope. | `meshterm repeater-admin <node> <command…>` |
| **🔖 Regions** | Not a menu screen. A repeater's node page lists the regions it carries and asks it once, without logging in (it answers only a neighbour, or over a known route); the packet viewer, message paths and live feed name the region a flood was scoped to. | `meshterm regions <node>` |
| **📶 TX optimize** | Sweep a remote node's transmit power live — coarse, then refine, then verify — watch each level land, and decide whether to apply the winner. The scripted form applies the winner unless told not to. | `meshterm tx-optimize --path … [--no-apply]` |
## ⚙ This app — the program in front of you
The last of the three, and the one nothing on the mesh can answer. Preferences leads it because
it is the only row here that *changes* MeshTerm rather than describing it; the written
pages close it.
| Feature | What it does | Scripted |
| --- | --- | --- |
| **⚙ Preferences** | How MeshTerm behaves, in eight groups: Device, Sending, TX optimize, Watchtower, Map, History, Display, and Diagnostics. Grouped and staged, saved by one action at the bottom, and written to `preferences.toml` as only the values you changed. [Configuring MeshTerm](configuration.md) has the detail. | `meshterm preferences show / set / reset` |
| **🩺 Diagnostics** | Everything a bug report opens with, in one block: which build this is and how it was installed, the OS, the terminal and its size, the boot-time verdicts that show on no screen (icons, powerline, platform flavour, and why), the radio and its link, and how much history is stored. Nothing private — no keys, passwords, positions, or contact names; the mesh is described as per-table counts and the span they cover. `s` saves it beside the log for attaching. | `meshterm diagnostics [--out PATH]` |
| **📖 About MeshTerm** | What MeshTerm is, where it came from, and the terms it ships under — including the map and font credits and the licence URL. | `meshterm about` |
| **👤 About the author** | The person behind MeshTerm, on the mesh and off it. | `meshterm about-author` |
| **🔗 Join Discord** | The community server: one invite link, and a QR of it for a phone to read. | `meshterm discord` |
| **💰 Support MeshTerm** | What keeps MeshTerm going, and the ways — paid and unpaid — to help it along. | `meshterm support` |
+10 -8
View File
@@ -1,7 +1,7 @@
# MeshTerm on hardware
MeshTerm talks to a **node** — a MeshCore radio that speaks the companion protocol over
serial, Bluetooth LE or TCP. Plug a companion board into USB, or pair one over Bluetooth,
serial, Bluetooth LE, or TCP. Plug a companion board into USB, or pair one over Bluetooth,
and none of this page applies: MeshTerm finds it, and the [README](../README.md) and the
[command line manual](cli.md) are all you need.
@@ -14,8 +14,8 @@ its own step-by-step manual, and this page only tells you which one is yours.
| --- | --- |
| a ClockworkPi **PicoCalc**, stock or already running Calculinux on a Luckfox Lyra, and I want MeshTerm on its own screen | **[MeshTerm on the PicoCalc](picocalc.md)** — from the shopping list to a working machine |
| …and I want a **radio** inside it | the same manual, [Phase 3](picocalc.md#phase-3--add-the-radio) — a XIAO nRF52840 + Wio-SX1262 soldered to the Lyra's UART |
| a ClockworkPi **uConsole** with the hackergadgets AIO board — an SX1262 on the host's own SPI bus | **[MeshTerm on the uConsole](uconsole.md)** — the bridge that fronts that chip as a companion |
| a MeshCore companion on USB, BLE or Wi-Fi | nothing here — the [README](../README.md) |
| a ClockworkPi **uConsole** with the hackergadgets AIO board — an SX1262 on the host's own SPI bus | **[MeshTerm on the uConsole](uconsole.md)** — MeshTerm drives it directly with `--spi`, or a bridge service fronts it as a companion if you want it always on |
| a MeshCore companion on USB, Bluetooth, or Wi-Fi | nothing here — the [README](../README.md), and [When a companion won't connect](connecting.md) if it doesn't |
The two targets have nothing in common but MeshTerm itself. The device-side scripts each
manual runs live in [`scripts/`](../scripts/), one folder per handheld; the
@@ -28,7 +28,9 @@ script before you run it; most run as root and change the machine they run on.
[`picocalc/`](../scripts/picocalc/) for the PicoCalc — bringing up Calculinux on the Lyra,
building its console font, and (under [`xiao-radio/`](../scripts/picocalc/xiao-radio/))
giving it a MeshCore radio over UART — and [`uconsole/`](../scripts/uconsole/) for the
bridge that fronts the uConsole's SPI-attached LoRa chip as a companion. The manual is the
always-on bridge that fronts the uConsole's SPI-attached LoRa chip as a companion (MeshTerm
can also drive that chip directly, with no script at all — the uConsole manual covers
both). The manual is the
walkthrough; each script's own header says what it assumes about the machine and why each
step is the way it is. On the Lyra, only one program may hold `/dev/ttyS1` — MeshTerm
directly, or a pump in front of it, never both.
@@ -36,17 +38,17 @@ directly, or a pump in front of it, never both.
| File | Runs on | What it does |
| --- | --- | --- |
| [`picocalc/calculinux-setup.sh`](../scripts/picocalc/calculinux-setup.sh) | the Lyra (root) | One-time bring-up: python packages, the Wi-Fi boot-scan kick, a boot clock sync (there is no RTC), the deploy user, the checkout, the venv and `pip install -e .`, the login PATH, and the console font. Idempotent. |
| [`picocalc/calculinux-console-font-6x12.sh`](../scripts/picocalc/calculinux-console-font-6x12.sh) | the Lyra (root) | Just the console font: builds, installs and persists the 512-glyph **meshterm** PSF (braille, node marks, rounded frame corners, the list cursor) and restores the stock 16-slot palette. Calls the 6×8 build below when it sits alongside. Run it alone after a MeshTerm update. |
| [`picocalc/calculinux-console-font-6x12.sh`](../scripts/picocalc/calculinux-console-font-6x12.sh) | the Lyra (root) | Just the console font: builds, installs and persists the 512-glyph **meshterm** PSF (braille, node marks, rounded frame corners, the list cursor) and restores the stock 16-slot palette. Calls the 6×8 build below when it sits alongside. Run it alone after a MeshTerm update, from the pulled checkout (`/home/meshterm/MeshTerm/scripts/picocalc/`) rather than the copy made at install time. |
| [`picocalc/calculinux-console-font-6x8.sh`](../scripts/picocalc/calculinux-console-font-6x8.sh) | the Lyra (root) | The shorter-cell companion font, **meshterm8** (6×8 → 53×40), for anyone who wants more rows than the 6×12 default gives. Its base bitmap is the kernel's `font_6x8`, so this one file and the PSF it builds are **GPL-2.0-only** ([`LICENSE.GPL-2.0`](../scripts/picocalc/LICENSE.GPL-2.0) sits beside it); it reads the donor/alias/keeper tables out of the script above rather than keeping a second copy. Pick between the two fonts from MeshTerm's Preferences page. |
| [`picocalc/calculinux-wifi-set.sh`](../scripts/picocalc/calculinux-wifi-set.sh) | the Lyra (sudo) | Interactive Wi-Fi join: prompts for an SSID and passphrase, writes iwd's credentials, connects now. This is how a network *becomes* known to the boot kick. |
| [`picocalc/calculinux-radio-bridge.sh`](../scripts/picocalc/calculinux-radio-bridge.sh) | the Lyra (root) | **Unfinished, unsupported.** An early UART-to-TCP pump, superseded by `xiao-radio/lyra-setup.sh`; its header describes routing it does not contain. See the manual before reading it. |
| [`picocalc/calculinux-radio-bridge.sh`](../scripts/picocalc/calculinux-radio-bridge.sh) | the Lyra (root) | **Unfinished, unsupported.** An early UART-to-TCP pump: it serves `/dev/ttyS1` on TCP port 5000 and writes a `radio` TCP profile. It does not route UART1 to the header pads. Superseded by `xiao-radio/lyra-setup.sh`, which does the routing and points MeshTerm at the serial port directly. |
| [`picocalc/xiao-radio/`](../scripts/picocalc/xiao-radio/) | see below | Everything for the XIAO nRF52840 + Wio-SX1262 radio wired to the Lyra's UART1; its [bench card](../scripts/picocalc/xiao-radio/README.md) has the wiring table. The firmware is MeshCore, MIT-licensed — [`LICENSE.MeshCore`](../scripts/picocalc/xiao-radio/LICENSE.MeshCore) sits beside the patch. |
| [`xiao-radio/build-firmware.sh`](../scripts/picocalc/xiao-radio/build-firmware.sh) | dev machine | Clones and patches MeshCore, builds the `Xiao_nrf52_companion_radio_serial` environment, emits the `.uf2`. |
| [`xiao-radio/flash.py`](../scripts/picocalc/xiao-radio/flash.py) | dev machine | Flashes the `.uf2` to the XIAO over its UF2 bootloader, refusing the wrong board or SoftDevice; prints the serial-DFU command when the bootloader offers no drive. |
| [`xiao-radio/meshcore-uart1.patch`](../scripts/picocalc/xiao-radio/meshcore-uart1.patch) | — | The two firmware hunks: the companion on nRF52 `Serial1`, and I²C moved off the UART pads. Not upstream yet. |
| [`xiao-radio/lyra-setup.sh`](../scripts/picocalc/xiao-radio/lyra-setup.sh) | the Lyra (root) | Boot-persistent UART1 → GP4/GP5 mux service, `dialout` membership, and a default MeshTerm serial profile for `/dev/ttyS1`. |
| [`xiao-radio/uart1-mux.py`](../scripts/picocalc/xiao-radio/uart1-mux.py) | the Lyra (root) | The matrix-IO register poke that routes UART1 to the header pads, run once per boot by the service above. |
| [`uconsole/meshterm-spi-bridge`](../scripts/uconsole/meshterm-spi-bridge) | the radio host (your user) | Runs a mesh node in software over an SPI-attached SX1262 (uConsole AIO, Waveshare HATs) and serves the companion protocol on `127.0.0.1:5000`. Setup menu, preflight, optional `systemd --user` service. Runs on `openhop_core` (preferred) or its predecessor `pymc_core`; see the manual for the venv it wants on a bookworm uConsole. |
| [`uconsole/meshterm-spi-bridge`](../scripts/uconsole/meshterm-spi-bridge) | the radio host (your user) | The **always-on** option: runs a mesh node in software over an SPI-attached SX1262 (uConsole AIO, Waveshare HATs) and serves the companion protocol on `127.0.0.1:5000`, so the node stays on the mesh while MeshTerm is closed. Setup menu, preflight, optional `systemd --user` service. Runs on `openhop_core` (preferred) or its predecessor `pymc_core`; see the manual for the venv it wants on a bookworm uConsole. Most people should reach for `meshterm --spi` instead — no script, no service, MeshTerm drives the chip itself while it runs. |
## The regular and picocalc platforms
@@ -54,7 +56,7 @@ MeshTerm resolves one frozen **platform** at boot and draws everything through i
**regular** platform is a desktop or ssh terminal: 72 readable columns, truecolour, emoji,
a footer hint line. The **picocalc** platform is a framebuffer console: 53 columns, no
emoji, an F-key lane instead of a hint line, and sixteen colour slots rather than a colour
space — nothing loses its colour there, the node hue and the recency heat gradient
space. Nothing loses its colour there: the node hue and the recency heat gradient
quantise to those slots instead. It is detected from the device tree on the Lyra, and
`--platform picocalc` or `MESHTERM_PLATFORM=picocalc` forces it anywhere, which is how you
preview the handheld layout on a desktop:
+88 -62
View File
@@ -25,8 +25,8 @@ a radio in the meantime.
> errors, and parts of it will go out of date as boards, images and firmware change. It is
> up to you to check every fact in it against your own hardware and the current sources
> before you act on it, and every decision along the way is yours. The author accepts no
> responsibility for any damage, loss or injury that results from following this guide,
> and nothing in it is a warranty that your parts, your tools or your hands will behave the
> responsibility for any damage, loss, or injury that results from following this guide,
> and nothing in it is a warranty that your parts, your tools, or your hands will behave the
> way the author's did. If you are not comfortable with any step, stop and get help from
> someone who is.
@@ -65,9 +65,9 @@ a radio in the meantime.
| Item | Product / what to search for | Approx. price | Link | Note |
| --- | --- | --- | --- | --- |
| PicoCalc kit | ClockworkPi PicoCalc | $89 | [clockworkpi.com/product-page/picocalc](https://www.clockworkpi.com/product-page/picocalc) | Comes with a Raspberry Pi Pico 1H, screen, keyboard, shell, hex key and a 32 GB SD card. **18650 batteries are not included.** |
| Luckfox Lyra | "Luckfox Lyra 128MB, pre-soldered header, no NAND" | $20–25 | [luckfox.com/Luckfox-Lyra](https://www.luckfox.com/Luckfox-Lyra) | Buy the **plain 128 MB RAM, Pico-form-factor** variant, **with the header pre-soldered**. See the note below — this is the one part of the list where the wrong SKU won't work. |
| Luckfox Lyra | "Luckfox Lyra 128MB, pre-soldered header, no NAND" | $20–25 | [luckfox.com/Luckfox-Lyra](https://www.luckfox.com/Luckfox-Lyra) | Buy the **plain 128 MB RAM, Pico-form-factor** variant, **with the header pre-soldered**. Other RAM sizes have incompatible pinouts, so this is the one part of the list where the wrong SKU won't work. |
| microSD card | 16 GB+, Class 10 or better | $6–10 | — | The PicoCalc's stock 32 GB card works fine — it's more than the 8 GB minimum. You can reuse it instead of buying a new one. |
| Two 18650 batteries | e.g. Samsung 30Q, Molicel P26A, Sony VTC6 (examples, not endorsements) | $10–20 | — | **Unprotected**, flat-top or button-top, Ø18 × 65–69 mm. See [the battery note](#a-note-on-batteries) below. |
| An 18650 battery | e.g. Samsung 30Q, Molicel P26A, Sony VTC6 (examples, not endorsements) | $5–10 | — | **Unprotected**, flat-top or button-top, Ø18 × 65–69 mm. One is enough to run; a second is optional (below). See [the battery note](#a-note-on-batteries) below. |
| USB Wi-Fi dongle | TP-Link TL-WN725N (an RTL8188EU nano dongle), or search the **chipset name**: RTL8192CU, R8712U, RTL8188EU | $5–10 | [amazon.ca/dp/B008IFXQFU](https://www.amazon.ca/dp/B008IFXQFU) | The TL-WN725N is the dongle this guide was built with. Must run at **3.3 V** — see [Step 3](#step-3--attach-the-wi-fi-dongle). Only needed for setup and updates; MeshTerm itself runs offline. |
| MX1.25 4-pin pigtail | "MX1.25 4P cable with leads" (a pack of pre-crimped pigtails, 10–15 cm) | $2–4 | — | One end of the DIY USB lead in [Step 3](#step-3--attach-the-wi-fi-dongle). Pre-crimped, because MX1.25 contacts need a crimper you don't otherwise own. |
| USB-A female socket | "USB 2.0 type A female socket, solder / through-hole" | $2–3 | — | The other end of the DIY lead. A ready-made [MX1.25 4P to USB-A cable](https://spotpear.com/shop/Luckfox-Lyra-MX1.25-4P-To-USB-A-Cable.html) (about $1, 30 cm) works too, but it is a lot of cable to fold into the shell. |
@@ -101,7 +101,7 @@ a radio in the meantime.
| Foam tape | To secure the XIAO board inside the shell once it's wired in. |
Approximate total: **$130–150** for the machine and radio together, before batteries — most
of that is the two boards ($89 + ~$22) and the radio kit ($13.49).
of that is the PicoCalc kit ($89), the Lyra (~$22) and the radio kit ($13.49).
**What you do NOT need:**
@@ -213,8 +213,8 @@ machine it describes. Read this section before you open the shell.
Follow ClockworkPi's own
[assembly guidelines](https://github.com/clockworkpi/PicoCalc/blob/master/Clockwork_PicoCalc_Assembly_Guidelines.pdf)
(a PDF in the [PicoCalc repository](https://github.com/clockworkpi/PicoCalc), which also
has a wiki) to put the kit together: mainboard, screen, keyboard, speakers and shell. Insert your two
18650 batteries, watching the `+`/`-` marks in the battery compartment.
has a wiki) to put the kit together: mainboard, screen, keyboard, speakers, and shell. Insert your 18650
battery (or two), watching the `+`/`-` marks in the battery compartment.
⚠ Two of the warnings in [Three ways to break it](#three-ways-to-break-it) apply here: tape
the screen's edges with Kapton before you close the shell and check it sits square, and
@@ -223,7 +223,7 @@ there is no reason to make the screws tight now.
Power it on once with the **stock Pico still in place**. You should see the stock BASIC
firmware boot on the screen, and the keyboard should respond. This confirms the screen,
keyboard and battery are all good before you start swapping boards.
keyboard, and battery are all good before you start swapping boards.
Power it back off.
@@ -311,14 +311,17 @@ Don't power the device on yet — Wi-Fi is set up later, once Calculinux is inst
### Step 4 — Write the image to the microSD
On your PC, download the latest `calculinux-image-luckfox-lyra.rootfs.wic.gz` from the
[Calculinux releases page](https://github.com/Calculinux/meta-calculinux/releases).
On your PC, download `calculinux-image-luckfox-lyra.rootfs-<timestamp>.wic.gz` from the
newest release on the
[Calculinux releases page](https://github.com/Calculinux/meta-calculinux/releases). Every
Calculinux release is marked **Pre-release**, so there is no "Latest" badge to follow; take
the newest pre-release.
Decompress it:
```bash
# on the PC
gunzip calculinux-image-luckfox-lyra.rootfs.wic.gz
gunzip calculinux-image-luckfox-lyra.rootfs-*.wic.gz
```
(The Calculinux installation page mentions `unxz`, but the file is a `.gz`, so use
@@ -331,11 +334,11 @@ Write the resulting `.wic` file to your microSD card. Three options:
- **`dd`**, on Linux/macOS. Check the device name first — writing to the wrong device
destroys its contents:
```bash
# on the PC
lsblk # find your SD card's device, e.g. /dev/sdb
sudo dd if=calculinux-image-luckfox-lyra.rootfs.wic of=/dev/sdX bs=4M status=progress conv=fsync
```
```bash
# on the PC
lsblk # find your SD card's device, e.g. /dev/sdb
sudo dd if=calculinux-image-luckfox-lyra.rootfs-*.wic of=/dev/sdX bs=4M status=progress conv=fsync
```
- **Rufus**, on Windows — select the device and the image, choose the **MBR** partition
scheme, and start.
@@ -345,6 +348,9 @@ This erases everything already on the card. Once it finishes, eject it and put i
from its own slot; the front slot is only reachable once MeshTerm (or anything else) is
already running on Linux.
Leave the front slot **empty** for the first boot, as Calculinux's own instructions warn: a
second card there with duplicate partition labels leaves the system read-only.
### Step 5 — First boot
With the card in the Lyra, power the PicoCalc on. Give it 30–60 seconds — the first boot is
@@ -417,30 +423,29 @@ the network, so this is a one-time step; to change networks later, run `uwific`
### Step 8 — Copy the setup scripts to the device
You need this repository's `scripts/` directory on the Lyra — its `picocalc/` folder is the
part the Lyra runs. Three ways to get it there, in order of ease:
part the Lyra runs. Two ways to get it there, once Wi-Fi is up:
- **`scp` from your PC**, once Wi-Fi is up. Find the Lyra's IP address with `ip addr show
wlan0` on the device, then from your PC:
- **`scp` from your PC**. Find the Lyra's IP address with `ip addr show wlan0` on the
device, then from your PC:
```bash
# on the PC, from your MeshTerm checkout
scp -r scripts root@<lyra-ip>:/root/
```
```bash
# on the PC, from your MeshTerm checkout
scp -r scripts root@<lyra-ip>:/root/
```
If root is refused over SSH, copy as the `pico` user (password `calc`) instead, then
move the directory as root on the device:
```bash
# on the PC
scp -r scripts pico@<lyra-ip>:/home/pico/
# on the PicoCalc, as root
mv /home/pico/scripts /root/
```
```bash
# on the PC
scp -r scripts pico@<lyra-ip>:/home/pico/
# on the PicoCalc, as root
mv /home/pico/scripts /root/
```
- **The microSD card.** Pull the card, copy `scripts/` onto it from your PC's card reader,
put it back.
- **`git clone` on the device.** Only once `git` is installed, and the setup script is what
installs it — so this is a route for later updates, not the first copy.
- **`git clone` on the device.** `git` ships in the Calculinux base image, so as root you
can run `git clone https://github.com/jpmartineau/MeshTerm` and use the `scripts/`
folder inside the clone.
`scp` is the recommended route.
@@ -452,30 +457,40 @@ cd scripts/picocalc
sh calculinux-setup.sh
```
It's idempotent — safe to re-run — and it checks before it acts. Two things it can't figure
out on its own, both covered by environment variables read at the top of the script:
It's idempotent — safe to re-run — and it checks before it acts. It reads these
environment variables at the top of the script:
| Knob | Default | Effect |
| --- | --- | --- |
| `DEPLOY_USER` | `meshterm` | the login MeshTerm runs under |
| `TIMEZONE` | `America/Toronto` | any IANA zone |
| `TIMEZONE` | `America/Toronto` | any IANA zone. Set it if you are not on Eastern time. |
| `REPO_URL` | the project's HTTPS URL | cloned when there's no deploy key |
| `REPO_SSH` | the project's SSH URL | cloned when there is one |
| `KEY_PATH` | `/home/$DEPLOY_USER/.ssh/id_ed25519` | a read-only GitHub deploy key, if you want SSH instead of HTTPS |
| `WIFI_SSID`, `WIFI_PSK` | unset | set both and the script writes `iwd`'s credentials for that network, as an alternative to Step 7 |
You don't need to set any of these for a normal install — the defaults clone MeshTerm's
public repository over plain HTTPS, which needs no credential at all.
The defaults clone MeshTerm's public repository over plain HTTPS, which needs no credential
at all. The one you are likely to need is `TIMEZONE`, if you live outside Eastern time:
```bash
# on the PicoCalc, as root
TIMEZONE=America/Vancouver sh calculinux-setup.sh
```
The script installs only the Americas time-zone data, so a zone elsewhere may not take. If
it cannot set the zone, it warns and the clock stays on UTC.
What it does, one line per phase:
- **opkg packages** — installs `python3-modules`, `python3-pip`, `git`, `kbd`, because
Calculinux ships a stripped Python missing `pip`, `venv` and several stdlib modules.
- **opkg packages** — installs `python3-modules` and `python3-pip` (and `git` and `kbd`,
if the image lacks them), because Calculinux ships a stripped Python missing `pip`,
`venv` and several stdlib modules.
- **Wi-Fi kick** — installs a boot-time service that re-scans until the dongle joins the
network `iwd` already knows, because on a cold boot the dongle's firmware finishes
loading after `iwd`'s first scan and the link would otherwise never come up.
- **Time sync** — installs a boot-time service that sets the clock over the network, since
this board has no battery-backed real-time clock.
- **Timezone** — sets `$TIMEZONE` (Eastern by default).
- **Timezone** — sets `$TIMEZONE` (`America/Toronto` by default).
- **Deploy user** — creates the `meshterm` login and puts it in the groups it needs.
- **Clone** — checks out MeshTerm to `/home/meshterm/MeshTerm`.
- **venv and install** — builds a Python virtual environment and `pip install -e .`s
@@ -493,9 +508,10 @@ password yet, so set one first:
passwd meshterm
```
> **With no Wi-Fi dongle attached, the next boot takes about six minutes** to get past the
> Wi-Fi kick, which keeps trying before it gives up. Nothing is broken. If the device is
> going to live offline, see [Day-to-day](#day-to-day) for how to switch the kick off.
> **With no Wi-Fi dongle attached, the next boot can take up to about eleven minutes.**
> The Wi-Fi kick keeps trying for about six before it gives up, and the time sync then
> waits up to five more for a network route. Nothing is broken. If the device is going to
> live offline, see [Day-to-day](#day-to-day) for how to switch both off.
### Step 10 — Log in as meshterm
@@ -521,7 +537,7 @@ Then look at the whole visual language on one card:
meshterm specimen
```
There are two console fonts available — a 6×12 one (53×26 rows) and a 6×8 one (53×40 rows,
There are two console fonts available — a 6×12 one (53 × 26) and a 6×8 one (53 × 40,
more rows but no Cyrillic glyphs). Try both from *Preferences → Display → Console font* —
picking one previews it immediately, and the screen repaints at the new row count so you can
see the choice before you keep it.
@@ -740,7 +756,7 @@ those. Keep the iron on each pin only as long as it takes, and don't press down
board. Attach the LoRa antenna to the Wio-SX1262's u.FL connector before you
power anything back on — running the radio without an antenna attached can damage it.
Wrap the XIAO + Wio-SX1262 stack in Kapton tape so no pad can touch the Lyra, the
mainboard or a battery, then tuck it into the shell wherever it fits without straining the
mainboard, or a battery, then tuck it into the shell wherever it fits without straining the
wires; a strip of foam tape holds it in place against the inside of the case.
### Step 15 — Set up the Lyra
@@ -776,8 +792,9 @@ default_tx_power = 22
description = "XIAO nRF52840 + SX1262 via Lyra UART1 on GP4/GP5"
```
If a `config.toml` already exists, it prints those lines instead of touching your file —
paste them in yourself.
If a `config.toml` already exists, it leaves your file untouched and prints only the
`default_profile` line, the `[profiles.picocalc]` heading and the `port`. Copy the whole
block above into your file yourself.
### Step 16 — Run it
@@ -788,7 +805,8 @@ Log out and back in as `meshterm` (so the `dialout` group membership takes effec
meshterm
```
Because `default_profile` is set, a bare `meshterm` connects straight to the radio. You
A bare `meshterm` opens on the **Select a companion device** splash, with the `picocalc`
profile listed as a row; pick it. To skip the splash, run `meshterm -p picocalc`. You
should see your node come up under whatever name the firmware advertises — both the device
page and the dashboard header show it.
@@ -815,39 +833,47 @@ through the Lyra's own USB-C at the back — see
sudo poweroff
```
**Updating MeshTerm.** Either re-run the setup script from root (it pulls the repository,
reinstalls, and rebuilds the console font):
**Updating MeshTerm.** The copy of `scripts/` you made in Step 8 goes stale as soon as
the checkout moves on, so run the scripts inside the checkout instead. Either pull and
re-run the setup script from root (it reinstalls and rebuilds the console font):
```bash
# on the PicoCalc, as root
sh calculinux-setup.sh
su - meshterm -c 'git -C ~/MeshTerm pull'
sh /home/meshterm/MeshTerm/scripts/picocalc/calculinux-setup.sh
```
or, as the `meshterm` user, do just the update:
```bash
# on the PicoCalc, as meshterm
cd ~/MeshTerm && git pull && .venv/bin/pip install -e .
cd ~/MeshTerm && git pull && TMPDIR=$HOME/tmp .venv/bin/pip install -e .
```
If an update adds new glyphs to the console font, rebuild it on its own afterwards:
```bash
# on the PicoCalc, as root
sh calculinux-console-font-6x12.sh
sh /home/meshterm/MeshTerm/scripts/picocalc/calculinux-console-font-6x12.sh
```
**Changing Wi-Fi.** Run `uwific` as root, as in [Step 7](#step-7--join-wi-fi). The boot-time
kick picks up the new network on its own; it only needs `iwd` to know it.
**The six-minute boot without a dongle.** If the device is going to run permanently offline,
the Wi-Fi kick is wasted time on every boot. Disable it:
**The slow boot without a dongle.** If the device is going to run permanently offline,
the Wi-Fi kick and the time sync are wasted time on every boot. Disabling the kick is not
enough, because the time sync pulls it back in, so mask it. Then disable the time sync,
which would otherwise still wait up to five minutes for a network:
```bash
# on the PicoCalc, as root
systemctl disable --now wifi-kick.service
systemctl mask --now wifi-kick.service
systemctl disable --now time-sync.service
```
To undo it later, `systemctl unmask wifi-kick.service` and
`systemctl enable time-sync.service`.
**The console font restores itself.** MeshTerm saves whatever font the console was using
before it starts, and puts it back when it exits — on a normal quit and on a crash alike.
You don't need to do anything to keep the shell's own font intact.
@@ -876,10 +902,10 @@ You don't need to do anything to keep the shell's own font intact.
This whole path — from a stock PicoCalc kit through a running radio — has been exercised on
one maintainer's bench: one Luckfox Lyra, one PicoCalc, one XIAO + Wio-SX1262 pair. It
works. Some of it is also checked in code: a test parses the font script and pins it to
MeshTerm's glyph inventory, the profile keys the scripts write are tested, the pinned
MeshCore commit exists, the patch's two hunks are still needed on `dev`, and the nRF52840
UF2 family id and both XIAO bootloader ids match Adafruit's board files.
works. One part is also checked in code: a test (`tests/test_theme16.py`) parses the font
script and pins it to MeshTerm's glyph inventory. The pinned MeshCore commit, the need for
the patch's two hunks on `dev`, and the nRF52840 UF2 family id and XIAO bootloader ids
against Adafruit's board files were checked by hand when this was written.
The rest is one bench's findings, and a second device may disagree:
@@ -888,7 +914,7 @@ The rest is one bench's findings, and a second device may disagree:
on one board, not read from a reference manual.
- **That the Calculinux kernel permits those `/dev/mem` writes**, and that the mux oneshot
really runs at every boot.
- **The header pin numbers** 6, 7, 8 and 36 for GP4, GP5, GND and 3V3. The Pico-side
- **The header pin numbers** 6, 7, 8, and 36 for GP4, GP5, GND, and 3V3. The Pico-side
numbering is right; the carrier's wiring is a bench observation.
- **The MX1.25 socket's pin order.** Taken from the wire colours of Luckfox's own cable,
which is why [Step 3](#step-3--attach-the-wi-fi-dongle) has you check pin 1 with a meter.
-485
View File
@@ -1,485 +0,0 @@
# Navigation survey — every exception to "Esc pops the stack"
> **A historical record.** This was a snapshot of the code on the date below, kept
> because a project that writes down its own problems is easier to trust than one
> that doesn't. Some of what's here has since been fixed and some hasn't; nothing in
> it is a to-do list. Read it as "what this looked like then", not "what is wrong now".
Written 2026-08-22 against `main` @ `4b29248`. A catalogue, not a change: nothing here had
been fixed at the time of writing. Each finding says where it is, what it does, and what
makes it a question.
**Resolved since:** findings **E** and **§10** — 2026-08-29. Exit rows were retired
app-wide: `back_rows()` is gone, `exit_rows()` draws nothing while clean, and every
caller's three-way Back test collapsed to `is CANCEL` / `is None`. What is kept is stated
in CLAUDE.md.
**Resolved 2026-08-30 — A, B, C, and the shortcut that made B possible.** Navigation is
a strict stack now; the rules are in CLAUDE.md under *Navigation — the stack*.
- **A1/A2/A3.** A2 is the rule, and it grew a second half. A hub stays pushed for the
whole visit (`session.stay` / `Visit.result`), so the *object* — not a `default=`
restore — carries the cursor, the sort, the scroll and the filter. Where the rows are
genuinely data (the editors, the outbox, the watchtower) they are swapped in place with
`SelectScreen.replace_items`, which follows the highlighted row by value and clamps to
its position when that row is gone. A1's vanishing-row problem is answered by that
clamp, everywhere, rather than by watchtower's one hand-written case. A screen is
rebuilt only when the rows it was holding a place in no longer exist — a purge, a
removal, a record count that moved — and each such site says so.
- **B1/B2/B3.** They no longer flatten. Both hand-built escapes existed because a deep
stack had no way out; **^W** is the way out, so trace ↔ trophy case and node detail's
peers all nest, and Esc from any of them is one pop back onto the screen that opened
them. The trace screen takes an `open_trophy_case` callback instead of resolving a
sentinel its opener had to interpret, which is also what lets the two open each other.
- **C.** All five backdrop re-pushes are gone, and so are the six hand-rolled
push/await/pop loops the survey paired them with — the same need, spelled one way.
Repeater admin's was the worst of them: a second, identical `SelectScreen` drawn as a
static stand-in for the real one. `admin_node_visit` keeps the real list up instead.
- **F.** Still the one place the stack is cleared rather than unwound, and it now also
disarms a pending ^W: a disconnect has already abandoned the flow that unwind was for.
**Resolved 2026-08-30 (second pass) — D, plus H and I, found by re-running the survey.**
D is closed by peeling *everywhere*: the select list and the path composer now clear a
standing filter on the first Esc and leave on the second, as the map and the walk always
did, and the footer swaps its trailing verb to `Esc clear` while that is true. Two
exceptions the first survey missed are closed with it — see **H** and **I** below.
**Still open: G** (`popup` declared by hand). It is not navigation-visible: one tool
floats over the menu, the rest replace it, and the result presenter separately infers the
same thing from content.
The point of the survey is that MeshTerm's navigation is *almost* uniform. The exceptions
are few enough to list exhaustively, which means they can be decided one at a time rather
than discovered one at a time.
---
## 0. Exceptions H and I — found 2026-08-30
Two more shapes, neither in the original catalogue, both closed the day they were found.
**H. A picker gathered in `prompt_params` pops before the screen it feeds.** A tool's
`prompt_params` is a *one-shot*: whatever it opens resolves and pops before `run` is
called. Three tools picked something there and then opened a screen, so the screen sat
directly on the main menu and Esc from it skipped the list it had just been chosen from.
| Site | What Esc did | Now |
|---|---|---|
| Trace target | sweep → **main menu**, skipping the target list | picker stays; Esc lands on the row it was launched from |
| TX optimize | sweep → **main menu**; and the target list → **main menu**, not the node list | both pickers stay, nested; Esc walks the flow back one list at a time |
| Chat | landed on a picker, but a rebuilt one (filter lost, cursor by `default=`) | one picker for the visit, rows swapped in place |
The fix is A2's rule applied one level up: the loop that owns the picker moves into `run`,
and `prompt_params` returns only the marker for the interactive path. Trace's picker also
re-reads its `TRACED` lane after each walk (`ContactListScreen.update_rows`), because that
is the column the list is sorted by and a returning trace changes it.
*Revised 2026-09-12 — TX optimize.* Keeping both pickers pushed under the sweep was the
wrong reading of the rule for a **popup**: a popup asks, confirms or informs, and then it is
gone — it is not a place to walk back to, and two of them stacked read as two places. The
two picks are now one floating list that turns its page (`menus.run_wizard`, `… · step 1
of 2`): Esc on the second step turns back to the first with the picked node still
highlighted, Esc on the first leaves, and the box is popped before the sweep opens — so Esc
from the sweep lands on the main menu, the sweep being the whole visit. It also *draws* as
a popup: the menu is popped while a tool runs, and a floating screen that is the only frame
on the stack used to be painted as the full-frame background (`_base_index`), so the first
of the two pickers never looked like a question at all. `stay(dialog=True)` now puts the
blank base under a popup's visit that `_run_dialog_screen` puts under a one-shot. Trace's
picker is a full-screen contact list (a place), and stays as it is.
*Same day — Repeater admin.* The same shape, one popup instead of two: the node picker was
a hub under the password prompt and the admin page. It is now a floating one-shot
(`session.select(floating=True)`, `admin_picker.pick_admin_node`), gone once answered; the
admin page opens over the main menu and Esc from it lands there. A refused login re-asks
the question with the same node highlighted. `admin_node_visit` is deleted — nothing keeps
a popup pushed any more.
*Same day — the base under a popup.* The blank `ScrollScreen` that `_run_dialog_screen`
pushed on an empty stack showed nothing behind the box. A dialog is smaller than the frame,
so what shows around it is the page it was reached from: `TuiSession._floated` now pushes
the **root** (the main menu, popped while a tool runs but still declared for ^W), and the
reconnect dialog draws over it too. A blank base survives only for a session with no root.
*Same day — the trophy case is a place.* A bare `SelectScreen` floats by default, and the
browser was full-frame from the menu only by the compositor's fallback; opened from a
trace it came up as a box. It declares `floating = False` now, like the contact list.
*Same day — Trace target.* The third of the three pickers finding H kept pushed, and the
one the previous note said would stay: JP's call is that it is a question too. The contact
list floats over the menu as a one-shot (`session.run_dialog`, public now), the trace screen
opens over the menu, Esc from it lands there; tracing another node is opening the tool
again. The typed fallback floats the same way. `ContactListScreen` stays a full-screen place
everywhere else.
*Same day — a record is a page.* `RecordDialog` floated a 62-column card over the trophy
case; the reader is *in* a record (reading, scrolling, arming a trace), so it is
`RecordScreen`, full-frame, Esc back onto the browser.
**I. An entry chain of prompts was not a stack.** Five flows asked two or more things in a
row, and Esc on any of them abandoned the whole flow rather than stepping back one — a
mistyped 32-hex channel key cost the name typed before it. `menus.run_steps` now runs a
chain as a stack; a trailing Cancel/verb confirm is deliberately left as a decision rather
than a step. Sites: `channels._join_with_key`, `channels._edit`,
`config_editor._stage_custom_var`, `courier._queue_flow`, `courier._pick_schedule`.
Also closed in the same pass: the channel detail was the last screen rebuilt per round
(A1's `default=cursor`, now `stay` + `replace_items`), which left `_menu_round` with no
session path at all and shrank it to the sessionless select-and-dispatch it always was
underneath — the last of §5's hand-rolled loops.
---
## 1. The machinery, in one screenful
Three primitives, all in [`ui/tui/`](../meshterm/ui/tui/):
| Call | What it does |
|---|---|
| `session.push(screen)` | screen becomes the top of the stack and is drawn |
| `session.pop(screen)` | that screen leaves the stack |
| `screen.resolve(value)` | the screen's future completes; the awaiting caller decides what happens next |
`session.run_screen(screen)` is push + await + pop as one call — the normal way to show a
screen. `Screen.handle("escape")` resolves with the `CANCEL` sentinel
([`screen.py:243`](../meshterm/ui/tui/screen.py#L243)); every screen inherits that unless it
overrides `handle`.
So the app has no "navigator". A screen never decides where the user goes — it resolves a
value and its *caller* decides. Every exception below is a caller deciding something other
than "pop and carry on".
---
## 2. What "normal" looks like
The reference flow is the main menu ([`menu.py:466-559`](../meshterm/ui/menu.py#L466)):
1. build the row list
2. `push` the menu
3. await a choice
4. `pop` the menu in a `finally`
5. run whatever was chosen
6. loop — rebuilding the menu, **re-highlighting the row just used** (`default=last_selection`)
Point 6 is the part worth naming: *returning from a thing lands you on the thing you
returned from.* It is the behaviour every list should have, and most do.
---
## 3. Exception A — cursor position on re-entry
**The single largest inconsistency in the app.** Three groups, three behaviours:
### A1. Restores the cursor — the intended behaviour (7 sites)
| Screen | Where | How |
|---|---|---|
| Main menu | [`menu.py:502`](../meshterm/ui/menu.py#L502) | `default=last_selection` |
| Channels (manager) | [`channels.py:214`](../meshterm/ui/channels.py#L214) | `default=highlight` |
| Channels (one channel) | [`channels.py:728`](../meshterm/ui/channels.py#L728) | `default=cursor` |
| Device config editor | [`config_editor.py:161`](../meshterm/ui/config_editor.py#L161) | `default=cursor` |
| Device actions | [`config_editor.py:688`](../meshterm/ui/config_editor.py#L688) | `default=cursor` |
| Repeater admin | [`repeater_admin.py:172`](../meshterm/ui/repeater_admin.py#L172) | `default=cursor` |
| Courier outbox | [`courier_screen.py:120`](../meshterm/ui/courier_screen.py#L120) | `default=cursor` |
| Watchtower | [`watchtower_screen.py:137`](../meshterm/ui/watchtower_screen.py#L137) | `cursor = choice` |
Watchtower has the most careful version of it — it *drops* the restore when the chosen row
is about to vanish ([`watchtower_screen.py:145-146`](../meshterm/ui/watchtower_screen.py#L145)):
```python
elif choice == _CLEAR:
store.clear_acked()
cursor = None # the row itself disappears
```
That refinement exists in exactly one place. Anywhere else a row can disappear, the restore
either silently fails to match (harmless — falls back to the top) or lands on whatever row
slid into the vacated position (not harmless).
### A2. Reuses the screen object, so it keeps everything (4 sites)
Contacts states the principle outright
([`contacts_screen.py:200-201`](../meshterm/ui/contacts_screen.py#L200)):
> One screen for the whole visit: re-running it keeps the highlight (and any sort or
> filter) on the row the user just opened a detail for, rather than snapping to the top.
Also chat, live feed, dashboard. Strictly better than A1 — it preserves sort and filter too,
not just the cursor — and it costs less code.
Contacts also shows the honest exception: after a purge changes the data, it deliberately
*does* rebuild ([`contacts_screen.py:217-219`](../meshterm/ui/contacts_screen.py#L217)), and
says why.
### A3. Rebuilds and loses your place (3 sites) — **the actual finding**
| Screen | Where | What is lost |
|---|---|---|
| Trophy case | [`records_screen.py:851`](../meshterm/ui/records_screen.py#L851) | cursor — a fresh `SelectScreen` per round, no `default=` |
| Time Machine picker | [`timemachine_screen.py:1323`](../meshterm/ui/timemachine_screen.py#L1323) | cursor (sort *is* carried) |
| Node detail | [`node_detail_screen.py:1360`](../meshterm/ui/node_detail_screen.py#L1360) | cursor **and the open tab** |
Node detail is the sharpest case. `NodeDetailScreen.__init__` sets `self._tab_index = 0`
([`node_detail_screen.py:446`](../meshterm/ui/node_detail_screen.py#L446)) and the opener
constructs a new one every iteration. So: open a contact → Tab to **Routes** → pick a route
→ Enter to trace it → come back → **you are on Info, at the top**. The route you were
working through is two keypresses away again, every time.
All three are a one-line fix in the A2 direction (hoist the construction out of the loop).
> **Question for JP:** is A2 the rule? If so these three are bugs. If a *deliberate* reset
> is wanted anywhere, Contacts' purge branch is the model for how to say so.
---
## 4. Exception B — hand-offs that flatten the stack instead of nesting
Two places let one screen hand off to a *peer* screen, and both deliberately unwind first so
the user does not end up deep in a stack they have to climb.
**B1. Trace → Trophy case.** A trace that sets a record raises a dialog offering the trophy
case. Choosing it resolves the sentinel `OPEN_TROPHY_CASE`
([`trace_screen.py:128`](../meshterm/ui/trace_screen.py#L128)), the trace screen comes down,
and only then does the caller open records
([`trace_screen.py:2193-2199`](../meshterm/ui/trace_screen.py#L2193)):
> the trace screen (and every prompt over it) is already down, so the trophy case opens
> over a clean stack and Esc from it unwinds straight to the main menu — never back into
> this session.
**B2. Trophy case → Trace.** The mirror image, and the comment is even more explicit
([`records_screen.py:905-909`](../meshterm/ui/records_screen.py#L905)):
> the browser does not reopen behind it: when the trace screen closes, this whole flow
> returns, landing the user on the main menu instead of a trophy-case → trace →
> trophy-case stack that takes many Escs to climb out of.
These two are the same rule discovered twice: **a hand-off between peers flattens; only a
sub-view nests.** It is not written down anywhere.
**B3. Node detail → Trace / Map / Time Machine / Share** ([`node_detail_screen.py:1372-1397`](../meshterm/ui/node_detail_screen.py#L1372))
follows the same rule *without saying so*, and that is where §3's A3 comes from. Because
`run_screen` pops on resolve, node detail is already down when the peer opens — so the peer
gets a clean stack, exactly as B1 and B2 arrange by hand. But unlike B1/B2, node detail then
**comes back**, by constructing a fresh screen at the top of the loop.
So the three sites agree on flattening and differ on what happens afterwards:
| | flattens before the peer | returns afterwards | keeps its state |
|---|---|---|---|
| B1 Trace → Trophy case | yes, deliberately | no — lands on the menu | n/a |
| B2 Trophy case → Trace | yes, deliberately | no — lands on the menu | n/a |
| B3 Node detail → peers | yes, as a side effect of `run_screen` | **yes** | **no** |
B3 is the only one that re-enters, and re-entering through a constructor is what costs the
tab and the cursor. That makes A3-on-node-detail a *consequence* of this shape, not an
independent bug: fixing it means either hoisting the screen out of the loop (keep the object,
keep the state) or nesting properly (keep it pushed while the peer runs).
> **Question for JP:** "flatten before a peer opens" looks like the settled rule — three for
> three. What is unsettled is whether a hub screen should *nest* instead, so it never has to
> be rebuilt. Node detail is the only hub, so this is really one decision about one screen.
---
## 5. Exception C — the backdrop re-push
`run_screen` pops the screen when it resolves. But a dialog raised straight afterwards needs
something behind it, or it draws over a blank frame. So five sites pop, then immediately
re-push the same screen as a static backdrop:
| Site | Screen re-pushed as backdrop |
|---|---|
| [`records_screen.py:867`](../meshterm/ui/records_screen.py#L867) | trophy case, behind the discipline picker / delete confirms |
| [`contacts_screen.py:210`](../meshterm/ui/contacts_screen.py#L210) | contacts, behind the purge picker |
| [`repeater_admin.py:108`](../meshterm/ui/repeater_admin.py#L108) | node picker, behind the login prompt |
| [`menu.py:910-911`](../meshterm/ui/menu.py#L910) | base screen, behind the disconnect dialog |
| [`tx_screen.py:885`](../meshterm/ui/tx_screen.py#L885) | flight screen |
Records has the fullest explanation ([`records_screen.py:862-866`](../meshterm/ui/records_screen.py#L862)).
This is a **workaround for an API shape**, repeated five times. The screens that avoid it
(channels, config editor, repeater admin's own menu, courier, watchtower, main menu) all
drive `push`/`await future`/`pop` by hand instead of calling `run_screen`, precisely so the
screen stays up while sub-prompts float.
> **Question for JP:** would a `session.run_screen(screen, keep_pushed=True)` — or a
> `session.backdrop(screen)` context manager — retire both the five re-pushes and the six
> hand-rolled push/await/pop loops? They are the same need spelled two ways.
---
## 6. Exception D — what Esc peels before it leaves
Four screens carry a live find-as-you-type filter and draw it through the one
`render.query_line`. **Esc means two different things across them:**
| Screen | Esc with a filter active | ⌫ with a filter active |
|---|---|---|
| Map | clears the filter, stays ([`map_screen.py:920-925`](../meshterm/ui/map_screen.py#L920)) | — |
| Mesh walk | clears the filter, stays ([`walk_screen.py:430-436`](../meshterm/ui/walk_screen.py#L430)) | deletes a char, then pops the trail ([`walk_screen.py:449-456`](../meshterm/ui/walk_screen.py#L449)) |
| **Select list** | **leaves the screen, filter and all** ([`select.py:773-774`](../meshterm/ui/tui/select.py#L773)) | deletes a char |
| **Path composer** | **leaves the screen, filter and all** ([`path_composer.py:717-718`](../meshterm/ui/path_composer.py#L717)) | deletes a char, then pops a hop |
So on the map, typing `hub` then Esc shows you the map again. On a contact list, typing
`hub` then Esc drops you back to the menu. Same affordance, same glyphs, opposite outcome —
and the select list is by far the most-used of the four.
Chat is a fifth member of the family with a different peelable thing
([`chat.py:624-629`](../meshterm/ui/chat.py#L624)):
```python
if self._selected is not None:
self._clear_selection() # first Esc unpicks; next leaves the chat
```
so chat peels, and its comment reads as though peeling were the house rule.
**Score: 3 screens peel, 2 do not.** Worth noting the peel is *not* free — on a screen where
Esc peels, leaving a filtered list takes two Escs, which is the cost JP is weighing on exit
rows elsewhere.
> **Question for JP:** peel everywhere, or nowhere? The current split is not defensible as
> a distinction between spatial and list screens, because the path composer is a list and
> does not peel, while chat is not a filter and does.
---
## 7. Exception E — six spellings of "the user pressed Back"
`back_rows(value)` takes the value its Back row resolves with. Call sites, verbatim:
| Value passed | Sites |
|---|---|
| *(nothing — `None`)* | [`admin_picker.py:49`](../meshterm/ui/admin_picker.py#L49), [`:77`](../meshterm/ui/admin_picker.py#L77), [`config_editor.py:617`](../meshterm/ui/config_editor.py#L617), [`:790`](../meshterm/ui/config_editor.py#L790), [`:1025`](../meshterm/ui/config_editor.py#L1025), [`courier_screen.py:242`](../meshterm/ui/courier_screen.py#L242), [`watchtower_screen.py:225`](../meshterm/ui/watchtower_screen.py#L225), [`:362`](../meshterm/ui/watchtower_screen.py#L362) |
| explicit `None` | [`tx_optimize.py:439`](../meshterm/tools/tx_optimize.py#L439), [`contacts_screen.py:263`](../meshterm/ui/contacts_screen.py#L263) |
| `"__back__"` | [`tools/chat.py:187`](../meshterm/tools/chat.py#L187), [`records_screen.py:790`](../meshterm/ui/records_screen.py#L790) |
| `_BACK` | [`channels.py:619`](../meshterm/ui/channels.py#L619), [`:683`](../meshterm/ui/channels.py#L683), [`contacts_screen.py:152`](../meshterm/ui/contacts_screen.py#L152) |
| `_CANCEL` | [`config_editor.py:736`](../meshterm/ui/config_editor.py#L736) |
| a tuple shaped like the screen's other values | [`records_screen.py:850`](../meshterm/ui/records_screen.py#L850) `("back", None, 0, None)`, [`trace_screen.py:2012`](../meshterm/ui/trace_screen.py#L2012) `("back", None)` |
The cost lands on the callers, which have to test every way at once:
```python
# contacts_screen.py:204
if chosen is CANCEL or chosen is None or chosen == _BACK:
# records_screen.py:858
if picked is CANCEL or picked is None or picked[0] == "back":
```
Note `config_editor` names its Back sentinel `_CANCEL` — the one word CLAUDE.md reserves for
dialogs, used here for a row that says "Back".
> **Suggestion:** `back_rows()` could default its value to `CANCEL` itself, so a Back row and
> Esc resolve *identically* and every caller's three-way test collapses to `is CANCEL`. The
> tuple-shaped ones exist only because their screens type their values as tuples; those could
> take `CANCEL` too.
**Resolved 2026-08-29** — by removal rather than by defaulting: with no Back row there is
no Back value, and every caller now tests `is CANCEL` / `is None` alone. All six spellings
are gone, `channels._BACK` and `contacts_screen._BACK` with them.
This tied directly into the exit-row question — see §10.
---
## 8. Exception F — the stack reset
One site clears the whole stack: [`menu.py:824`](../meshterm/ui/menu.py#L824), when the device
link drops mid-session. The watcher cancels the menu worker, `session.reset()` drops whatever
screens it left behind, and the reconnect dialog opens over a clean frame.
This is the only place the stack is cleared rather than unwound. It relies on every screen
being pop-safe in a `finally`, which is documented at
[`session.py:388-395`](../meshterm/ui/tui/session.py#L388). Correct as written — noted here
because it is the one code path where a screen can vanish without its caller resolving it,
and anything holding state across a `run_screen` boundary would silently lose it.
---
## 9. Exception G — one tool floats, twenty-two replace
`Tool.popup` ([`tools/base.py:67`](../meshterm/tools/base.py#L67)) decides whether the menu
stays pushed while the tool runs. **Exactly one tool sets it:** `advert`
([`tools/advert.py:33`](../meshterm/tools/advert.py#L33)).
Every other tool pops the menu, runs full-screen, and the menu is rebuilt afterwards. Which
is right for a screen — but several tools are *also* dialog-sized in practice, and the result
presenter already makes exactly this call at the other end
([`surface.py:603-610`](../meshterm/ui/surface.py#L603)): a short text-only result floats as
a dialog, anything bigger opens a window.
So the app decides "float or replace" twice, by two different mechanisms, one hand-declared
per tool and one measured from the content.
> **Question for JP:** should `popup` be inferred the way the result presentation is, or is
> one declared flag on one tool the whole of the need?
---
## 10. Where the exit-row question actually lands
> **Resolved 2026-08-29.** Acted on in full, along the middle position this section
> proposed — wider, in the end, than the 19 `back_rows()` sites: the four hand-drawn Back
> rows went too (`ReorderScreen`'s clean-state row, the live feed's pinned foot row, node
> detail's tail action, and the `trace`/`tx` screens' action-list rows). Kept: the
> `exit_rows` discard pair, `ReorderScreen`'s dirty-state pair, the two **Quit** rows, and
> the new-record dialog's `Close` button — each for the reason named below. The gallery
> now asserts the *absence* of an exit row on every screen, on both platforms. What
> follows is the survey as written.
JP's note — *"remove the cancel and close actions at the bottom of pages; Esc is quicker and
available on all platforms; keep it where there is a confirm or cancel situation"* — was
acted on for the one unambiguous case (the path composer's `Cancel` row, which literally ran
`super().handle("escape")`; removed in `4b29248`). The rest of the territory:
**Already clean.** The three About pages have no exit row at all — prose, Esc, done. Titles
carry no emoji. Footer hints are 100% compliant on width, Esc-last, and verb choice (56
literal hints audited).
**Rows that are exactly Esc, still standing:**
- `back_rows()` — the app-wide **Back** row, 19 sites. Two of them carry the comment
`# a visible exit beside Esc` ([`courier_screen.py:242`](../meshterm/ui/courier_screen.py#L242),
[`watchtower_screen.py:225`](../meshterm/ui/watchtower_screen.py#L225)) — i.e. the row exists
*because* it duplicates Esc, which is precisely the premise now in question.
- `ReorderScreen`'s Back row ([`select.py:931`](../meshterm/ui/tui/select.py#L931)) —
`super().handle("escape") # Back resolves CANCEL, same as Esc`.
- Live feed's Back row, reached by scrolling past the oldest packet
([`livefeed_screen.py:41-44`](../meshterm/ui/livefeed_screen.py#L41)).
Removing **Back** app-wide is a bigger decision than the path composer's Cancel, because:
- it is written into CLAUDE.md as a binding standard ("the only exit word on rows");
- `exit_rows()` builds on it — an editor with staged changes shows `✓ Apply n staged changes`
over `✗ Back — discard staged changes`, where Back is *not* redundant: it names a
consequence Esc does not;
- on PicoCalc, Esc is a real key, so the platform argument does not add anything there.
**A middle position worth considering:** keep Back exactly where it says something Esc does
not (the `exit_rows` discard case), drop it where it says nothing (the plain `back_rows`
case). That is roughly 3 sites kept, 16 dropped — and §7's suggestion (Back resolves `CANCEL`)
would make the drop mechanical rather than a per-caller edit.
**Buttons that are exactly Esc:** only one — the `Close` button on the new-record dialog
([`trace_screen.py:639`](../meshterm/ui/trace_screen.py#L639)). Left alone deliberately: it
is the *default*, so Enter dismisses safely, and this dialog appears unbidden after a trace.
Dropping it would leave `Trophy case` as the default and make a stray Enter navigate away.
Every other `Cancel` in the app is one half of a real two-way choice.
---
## 11. Summary — the shortlist
| # | Finding | Sites | Weight | Status |
|---|---|---|---|---|
| H | A picker gathered in `prompt_params` pops before the screen it feeds | 3 | **high** — Esc skipped a whole list | **done** 08-30 |
| I | An entry chain of prompts abandons instead of stepping back | 5 | medium — loses typing, never surprises | **done** 08-30 |
| A3 | Screen rebuilt per round, losing cursor (and node detail's tab) | 3 | **high** — felt on every visit | **done** 08-30 |
| D | Esc peels the filter on 3 screens, leaves on 2 | 5 | **high** — same keys, opposite result | **done** 08-30 |
| E | Six spellings of the Back value; callers test three at once | 19 | medium — invisible to users, costly in code | done 08-29 |
| C | Pop-then-re-push backdrop workaround | 5 | medium — an API gap, repeated | **done** 08-30 |
| B | Peer hand-offs all flatten, but the rule is unwritten and node detail pays for it | 3 | medium | **done** 08-30 |
| G | `popup` declared once by hand while the result presenter infers it | 1 | low | open |
| A1 | Cursor restore doesn't handle a vanishing row (except watchtower) | 7 | low | **done** 08-30 |
| F | `session.reset()` on disconnect — correct, noted for completeness | 1 | none | kept |
+2 -3
View File
@@ -5,9 +5,8 @@
> that doesn't. Some of what's here has since been fixed and some hasn't; nothing in
> it is a to-do list. Read it as "what this looked like then", not "what is wrong now".
Written 2026-08-22 against `main` @ `4b29248`. Companion to
[`survey-navigation.md`](survey-navigation.md), which covers navigation exceptions; this one
covers everything else in the CLAUDE.md UX standard. A catalogue, not a change.
Written 2026-08-22 against `main` @ `4b29248`. It covers the CLAUDE.md UX standard apart
from navigation. A catalogue, not a change.
**The short version: the standard is very well kept.** Six sweeps found nothing at all;
what remains is nine small, specific things. That is the useful result — it means the list
+37
View File
@@ -0,0 +1,37 @@
# Terminals, icons, and the Windows console
Why MeshTerm looks right in some terminals and draws empty boxes in others, what it does
about it on Windows, and how to turn all of that off.
MeshTerm is drawn with emoji icons, braille charts, and powerline path chips. Whether you
see them is up to your *terminal*, not really your font: a modern terminal, asked for a
character its font doesn't have, quietly borrows it from another font on the machine.
That's why the app looks right in Windows Terminal, in VS Code's terminal, and on macOS
and Linux — usually with a font that contains almost none of it. The exception is macOS's
Terminal app, where the charts may draw misaligned: no Mac monospace font has braille, so
it is borrowed from a proportional one at the wrong width.
The classic Windows console — the black `cmd.exe` window a double-click opens on
Windows 10 — doesn't borrow. (Windows 11, 22H2 and later, opens Windows Terminal by
default instead.) It draws what its one font holds and empty boxes for everything else, and
no font fixes the icons there: the only two fonts on a Windows machine with emoji in them
are proportional, and a console won't take a proportional font.
So when MeshTerm lands in that console it just **moves to Windows Terminal** — it says
so, and opens there. Nothing is installed and nothing is changed; it's one window instead
of another, and it's the whole app exactly as the screenshots show it. Windows Terminal is
already on every Windows 11 machine and is a free install on Windows 10.
Where there's no Windows Terminal to move to, and the console's font can't already draw
the charts, MeshTerm offers the next best thing instead — the charts and marks, without
the icons. It ships
[Cascadia Mono PL](https://github.com/microsoft/cascadia-code), Microsoft's own console
font and one of the very few monospace faces that carries braille at all, and will install
it just for you: no administrator rights, nothing downloaded. If you already have a font
that can draw the charts (Cascadia, or another such as Iosevka), it offers to switch to
that one instead. Either way it asks first (`[y/N]`), and the answer defaults to no.
Preferences → Display → Console setup turns all of this off if you'd rather stay put.
Where MeshTerm keeps its state, and how to point a trial run somewhere else, is in
[Configuring MeshTerm](configuration.md).
+374 -148
View File
@@ -2,40 +2,41 @@
A ClockworkPi uConsole with the hackergadgets AIO LoRa board has no companion
microcontroller and no firmware to flash — the LoRa chip sits straight on the host's SPI
bus. So a small bridge runs the mesh node in software on the uConsole itself and puts the
standard MeshCore companion protocol in front of it on a local TCP port. MeshTerm then
connects to that port like it would any network companion. By the end of this guide you'll
have the bridge running as a background service and MeshTerm talking to it.
bus. MeshTerm can drive it directly: it starts a mesh node as a child process the moment
you connect, talks to it over the standard companion protocol on a loopback port, and ends
it when you disconnect or quit. No separate service, no TCP port to remember — just
`meshterm --spi`.
> ⚠ **Read this first.** This guide edits your uConsole's boot configuration, adds your
> user to hardware groups, installs a service under your account, and then keys a LoRa
> transmitter. It assumes you are comfortable at a Linux shell and know which frequency
> plan and power limits apply where you live — what the radio does on air is your
> responsibility, not the bridge's. There is a real risk of leaving the system, the radio
> or the mesh around you in a worse state if a step goes wrong. You do this at your own
> risk.
If you'd rather the node stayed on the mesh while MeshTerm is closed, a small bridge can
run it as a background service instead, fronting the same chip as a network companion.
That's [further down](#always-on-the-bridge) — most people want the section above it.
> ⚠ **Read this first.** Either route edits your uConsole's boot configuration, adds your
> user to hardware groups, and then keys a LoRa transmitter. It assumes you are comfortable
> at a Linux shell and know which frequency plan and power limits apply where you live —
> what the radio does on air is your responsibility, not MeshTerm's. There is a real risk
> of leaving the system, the radio, or the mesh around you in a worse state if a step goes
> wrong. You do this at your own risk.
>
> **Read the whole manual through once before you start.** Which route you take in
> Step 2 depends on your Debian release, and whether the GUI is running changes what the
> preflight and the service will do — both are easier to get right knowing the later steps.
> **Read the whole manual through once before you start.** Which install route you take
> depends on how you're running MeshTerm, and that's easier to get right knowing the later
> steps.
>
> This is a guide, not a prescription. Despite the author's best efforts it may contain
> errors, and parts of it will go out of date as packages, runtimes and boards change. It
> errors, and parts of it will go out of date as packages, runtimes, and boards change. It
> is up to you to check every fact in it against your own hardware and the current sources
> before you act on it, and every decision along the way is yours. The author accepts no
> responsibility for any damage, loss or injury that results from following this guide.
> responsibility for any damage, loss, or injury that results from following this guide.
- [What you need](#what-you-need)
- [Step 1 — Prepare the system](#step-1--prepare-the-system)
- [Step 2 — Install the radio runtime](#step-2--install-the-radio-runtime)
- [Step 3 — Get the bridge](#step-3--get-the-bridge)
- [Step 4 — Run the preflight](#step-4--run-the-preflight)
- [Step 5 — Run it once in the foreground](#step-5--run-it-once-in-the-foreground)
- [Step 6 — Install it as a service](#step-6--install-it-as-a-service)
- [Step 7 — Install MeshTerm and connect](#step-7--install-meshterm-and-connect)
- [Updating and uninstalling](#updating-and-uninstalling)
- [Hardware knobs](#hardware-knobs)
- [Step 2 — Install the radio library](#step-2--install-the-radio-library)
- [Step 3 — Install MeshTerm](#step-3--install-meshterm)
- [Step 4 — Connect](#step-4--connect)
- [Moving over from the bridge (0.9.0 and earlier)](#moving-over-from-the-bridge-090-and-earlier)
- [Board wiring](#board-wiring)
- [Troubleshooting](#troubleshooting)
- [Always on: the bridge](#always-on-the-bridge)
- [What this has been tested on](#what-this-has-been-tested-on)
---
@@ -47,56 +48,31 @@ have the bridge running as a background service and MeshTerm talking to it.
| Part | Notes |
| --- | --- |
| ClockworkPi **uConsole** (CM4 module) | Any uConsole with a Compute Module 4. |
| hackergadgets **AIO** expansion board | The board this guide is written for and the one we recommend: an SX1262 on SPI bus 1, plus GPS, an RTL-SDR and a USB hub you don't need here. The bridge's defaults are its wiring. |
| hackergadgets **AIO** expansion board | The board this guide is written for and the one we recommend: an SX1262 on SPI bus 1, plus GPS, an RTL-SDR and a USB hub you don't need here. MeshTerm's SPI defaults are its wiring (AIO v1; the v2 needs one extra setting — see [Board wiring](#board-wiring)). |
| An antenna for your region's LoRa band | Required. |
Installing the AIO board into the uConsole is out of scope here — follow
[hackergadgets' own setup guide](https://hackergadgets.com/pages/hackergadgets-uconsole-rtl-sdr-lora-gps-rtc-usb-hub-all-in-one-extension-board-setup-guide)
for that part.
The bridge assumes the AIO's wiring by default:
| Setting | Default |
| --- | --- |
| SPI bus | 1 |
| Reset pin | 25 |
| Busy pin | 24 |
| IRQ pin | 26 |
| TX-enable pin | none |
| DIO2 (RF switch) | on |
| DIO3 (TCXO) | on |
| Frequency | 910.525 MHz |
| TX power | 22 dBm |
| Spreading factor | 7 |
| Bandwidth | 62.5 kHz |
| Coding rate | 4/5 |
Every one of these is overridable with a `MESHCORE_*` environment variable if your board or
region differs — see [Hardware knobs](#hardware-knobs).
**Software:**
- Raspberry Pi OS / Debian **bookworm** or **trixie** on the uConsole, 64-bit.
- Python 3 (the system one is fine — the bridge finds whichever interpreter has the radio
library, and re-execs itself under it).
- Raspberry Pi OS / Debian **bookworm** or **trixie** on the uConsole, 64-bit. This is
Linux-only — driving a radio on the host's own SPI bus is not something Windows or macOS
can do.
- A `/dev/spidev*` node. This needs `dtoverlay=spi1-1cs` in `/boot/firmware/config.txt`.
- `/dev/gpiochip0`, and membership of the `spi` and `gpio` groups so you can read and write
both without root.
- Optionally, the [`meshcore-uconsole`](https://github.com/cwill747/meshcore-uconsole)
package (it brings the `meshcore-console` GUI and a radio runtime). Whether you can use it depends on your Debian release — see
[Step 2](#step-2--install-the-radio-runtime).
- The radio library, **`openhop-core[hardware]` 1.1.3 or newer** — [Step
2](#step-2--install-the-radio-library) gets you there. It doesn't have to be in
MeshTerm's own Python; MeshTerm looks in several places for it.
- MeshTerm itself, on the uConsole — [Step 3](#step-3--install-meshterm).
The radio library that actually drives the chip was renamed in 2026: `pymc_core` became
`openhop_core`. PyPI's `pymc-core` stopped at 1.0.12; `openhop-core` carries on from 1.1.x.
The bridge supports both and prefers the newer one when it finds both installed.
**MeshTerm itself** — either:
- the Linux ARM64 one-file release from
[the latest release page](https://github.com/jpmartineau/MeshTerm/releases/latest), or
- an install from the repository with `pipx` (Python 3.10 or newer). Plain `pip install`
into the system Python is refused on bookworm and trixie; `pipx` (`sudo apt install pipx`)
gives MeshTerm a virtual environment of its own.
A node's own radio settings — frequency, bandwidth, spreading factor, coding rate, TX
power — are not part of this setup. They're the node's, saved by the node, and you change
them the same way you would on any companion: MeshTerm's Device config page. A brand-new
node starts on MeshCore's US/Canada preset (910.525 MHz, 62.5 kHz, SF 7, CR 4/5, 22 dBm)
until you change it.
---
@@ -132,30 +108,263 @@ and `spi` and `gpio` in your `groups` output.
---
## Step 2 — Install the radio runtime
## Step 2 — Install the radio library
MeshTerm needs `openhop-core[hardware]` 1.1.3 or newer importable by *some* Python on the
machine — not necessarily MeshTerm's own. It looks, in order: a `python` named in the
profile's `spi` table; MeshTerm's own interpreter; the bridge's venv at
`~/.local/share/meshterm-spi-bridge/venv/bin/python`; `/opt/venvs/meshcore-uconsole/bin/python`;
then `python3` on your `PATH`. Whichever route you take below, that's the interpreter it
lands in.
- **Installed MeshTerm with `pipx`?** Inject it into MeshTerm's own environment:
```bash
pipx inject mesh-term 'openhop-core[hardware]'
```
- **Installed with `pip`?** Pull in the `spi` extra when you install MeshTerm (see
[Step 3](#step-3--install-meshterm)):
```bash
pip install 'mesh-term[spi]'
```
- **Running the one-file Linux ARM64 build?** It's a single frozen binary and can't carry
the library, so give it a venv of its own — the same one the bridge guide uses, so it's
shared if you ever run both:
```bash
python3 -m venv ~/.local/share/meshterm-spi-bridge/venv
~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]"
```
MeshTerm finds it there automatically; no configuration needed.
The older `pymc_core` runtime (the library's name before 2026) is not supported for a
direct connection — only the standalone bridge still carries the compatibility shims it
needs.
---
## Step 3 — Install MeshTerm
Same as any other machine — the simplest way is the Linux ARM64 one-file release:
```bash
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-linux-arm64
chmod +x meshterm
sudo mv meshterm /usr/local/bin/meshterm
```
Or install it from the repository with `pipx`:
```bash
sudo apt install pipx
pipx install git+https://github.com/jpmartineau/MeshTerm
pipx ensurepath
pipx inject mesh-term 'openhop-core[hardware]'
```
Or with `pip`, pulling in the radio library as its own extra in one line:
```bash
pip install 'mesh-term[spi]'
```
---
## Step 4 — Connect
```bash
meshterm --spi
```
`--spi` finds the AIO's radio at `/dev/spidev1.0` and starts a node on it. The startup
device picker also lists it — a row reading **SPI radio (/dev/spidev1.0)** with a 📍 icon —
whenever that device node exists, so `meshterm` with no flags gets you there too.
You're connected when **Device info** or the dashboard shows a node name instead of a
connection error. MeshTerm reports the device model as **MeshTerm SPI node**, and the
header's connection label reads **SPI**.
**What happens underneath.** MeshTerm starts the node when it connects and ends it when
you disconnect or quit — including a crash: the node exits with MeshTerm, and the kernel
frees the GPIO lines and SPI handle the moment its process does. The trade-off is the one
this implies: **the node is off the mesh while MeshTerm is closed.** If you want it on all
the time, that's what [the bridge](#always-on-the-bridge) is for.
**Where its state lives.** Each radio keeps its identity, preferences, channels, and
contacts under `~/.meshterm/radio/spidev1.0/` — `identity.key`, `prefs.json`,
`channels.json`, `contacts.json` — plus a running log, `node.log` (the previous run's is
kept as `node.log.1`). All of it survives a restart.
**A profile, if you want a name for it:**
```toml
[profiles.aio]
transport = "spi"
```
```bash
meshterm -p aio
```
The AIO's wiring is the default, so this profile needs nothing beyond `transport = "spi"`.
[Board wiring](#board-wiring) is for when yours differs.
---
## Moving over from the bridge (0.9.0 and earlier)
> **Only if you set up the bridge with MeshTerm 0.9.0 or earlier.** Before 0.10.0 the bridge
> was the only way to use the uConsole's radio, so an older setup is almost certainly
> running one. On a fresh install of 0.10.0 or newer there is nothing to move over — skip
> ahead to [Board wiring](#board-wiring).
Upgraded from 0.9.0 or earlier with the bridge still installed? The first time you connect
with `--spi` (or a profile, or the picker row), MeshTerm carries the bridge's node over —
**copying**, never moving, so the bridge still works afterwards if you go back to it:
- the identity key — from the `meshcore-console` GUI's copy first
(`~/.local/share/meshcore-uconsole/identity.key`), the way the bridge itself looks for
it, else the bridge's own (`~/.local/share/meshterm-spi-bridge/identity.key`);
- the bridge's `contacts.json`;
- the name `uConsole`;
- the bridge's radio settings — frequency, bandwidth, spreading factor, coding rate, TX
power — read out of the bridge service's `MESHCORE_*` environment.
This only happens once, while `~/.meshterm/radio/spidev1.0/` has no `identity.key` yet — so
your node keeps its public key, and with it its place in everyone else's contact list.
**Wiring is not carried over.** If your board needed `MESHCORE_EN_PINS` or another wiring
variable for the bridge, put the equivalent in the profile's `spi` table (see
[Board wiring](#board-wiring)) — the direct connection reads wiring from there, never from
the bridge's environment.
**Stop the bridge before switching over**, since only one program may hold the radio at a
time:
```bash
systemctl --user stop meshterm-spi-bridge
systemctl --user disable meshterm-spi-bridge # keep it stopped across reboots
```
If MeshTerm sees the bridge service running, it refuses to start the node and tells you the
same two commands.
---
## Board wiring
The defaults match the hackergadgets uConsole AIO **v1**, which needs no `spi` table at
all. State only what differs, under `[profiles.<name>.spi]`. For a board that isn't an
AIO — a LoRa HAT on a Raspberry Pi, say — [Adding a radio on the SPI
bus](configuration.md#adding-a-radio-on-the-spi-bus) walks through a whole profile, including
how to read the pins off the board's `meshtasticd` configuration:
```toml
[profiles.aio2]
transport = "spi"
[profiles.aio2.spi]
en_pins = [27] # the AIO v2 needs this
```
| Setting | Default | What it is |
| --- | --- | --- |
| `bus_id`, `cs_id` | `1`, `0` | Which SPI bus and chip-select the radio is on (`/dev/spidev<bus_id>.<cs_id>`). |
| `cs_pin` | `-1` | A GPIO driven as chip select by hand, instead of the bus's own. |
| `gpio_chip` | `0` | Which `/dev/gpiochip<n>` the pins below are on. |
| `use_gpiod_backend` | `false` | Drive the pins through `gpiod` instead of `python-periphery`. |
| `reset_pin`, `busy_pin`, `irq_pin` | `25`, `24`, `26` | The chip's reset, busy and interrupt (DIO1) lines. |
| `txen_pin`, `rxen_pin` | `-1`, `-1` | An external RF switch's transmit/receive-enable lines, if the board has one. |
| `en_pins` | `[]` | Power-enable lines to raise before the chip is touched. The **AIO v2 needs `[27]`.** |
| `use_dio2_rf`, `use_dio3_tcxo` | `true`, `true` | Whether DIO2 drives the RF switch and DIO3 a TCXO (both on for the AIO). |
| `is_waveshare` | `false` | The Waveshare HAT's wiring quirks, which the radio library knows about. |
| `python` | `""` (empty) | An interpreter to run the node under, when the one MeshTerm would find on its own isn't the one you want. Empty lets MeshTerm look. |
An unknown key, or one of the wrong type, stops MeshTerm at startup with the profile
named — a misspelt pin is never silently ignored.
Radio settings — frequency, bandwidth, spreading factor, coding rate, TX power — are
**not** in this table. They're the node's own, on Device config, as noted in
[What you need](#what-you-need).
---
## Troubleshooting
| Symptom | Fix |
| --- | --- |
| The radio library isn't found | Follow [Step 2](#step-2--install-the-radio-library) for however you installed MeshTerm, or set `python` in the profile's `spi` table to an interpreter that has `openhop-core[hardware]`. |
| No `/dev/spidev1.0` | Add `dtoverlay=spi1-1cs` to `/boot/firmware/config.txt` and reboot. |
| Permission denied opening the SPI or GPIO device | `sudo usermod -aG spi,gpio $USER`, then log out and back in. |
| "the meshterm-spi-bridge service has the radio" | Stop it: `systemctl --user stop meshterm-spi-bridge` (and `disable` it to keep it stopped). |
| "meshcore-console has the radio open" | Close the GUI and connect again. |
| Some other program has the radio | `sudo lsof /dev/gpiochip0` shows which; only one program may hold the radio at a time. |
| The node starts but never answers | Check its log: `~/.meshterm/radio/spidev1.0/node.log`. |
| An AIO v2 radio never comes up | It needs `en_pins = [27]` in the profile's `spi` table — see [Board wiring](#board-wiring). |
---
## Always on: the bridge
Most people should use the direct connection above. Use the bridge instead if you want the
node reachable on the mesh **while MeshTerm is closed** — it runs as a `systemd --user`
service, on its own, independent of whether MeshTerm is even installed. The trade-off runs
the other way from the direct connection: once the bridge is running, it holds the radio's
pins for good, so no other LoRa program — including a direct `meshterm --spi` — can use
them until you stop it.
The bridge puts the standard MeshCore companion protocol in front of the same SPI-attached
chip, on a local TCP port. MeshTerm then connects to that port like it would any network
companion.
- [Bridge step 1 — Install the radio runtime](#bridge-step-1--install-the-radio-runtime)
- [Bridge step 2 — Get the bridge](#bridge-step-2--get-the-bridge)
- [Bridge step 3 — Run the preflight](#bridge-step-3--run-the-preflight)
- [Bridge step 4 — Run it once in the foreground](#bridge-step-4--run-it-once-in-the-foreground)
- [Bridge step 5 — Install it as a service](#bridge-step-5--install-it-as-a-service)
- [Bridge step 6 — Connect MeshTerm to it](#bridge-step-6--connect-meshterm-to-it)
- [Updating and uninstalling the bridge](#updating-and-uninstalling-the-bridge)
- [Bridge hardware knobs](#bridge-hardware-knobs)
- [Bridge troubleshooting](#bridge-troubleshooting)
The bridge's own wiring and radio defaults are the same AIO v1 numbers as the direct
connection's — see the table in [Board wiring](#board-wiring) — just set through
`MESHCORE_*` environment variables instead of a profile's `spi` table.
The radio library that actually drives the chip was renamed in 2026: `pymc_core` became
`openhop_core`. PyPI's `pymc-core` stopped at 1.0.12; `openhop-core` carries on from 1.1.x.
The bridge supports both and prefers the newer one when it finds both installed.
### Bridge step 1 — Install the radio runtime
Which route you take depends on your Debian release:
- **trixie**, with `meshcore-uconsole` installed from the vendor's package: you already
have a working runtime (Python 3.13-based). Skip to [Step 3](#step-3--get-the-bridge).
- **bookworm**: the `meshcore-uconsole` 1.12.0+ package is built for Python 3.13 and will
**not run** — its virtualenv points at an interpreter bookworm doesn't have. Don't take
the upgrade if apt offers it. Instead give the bridge a runtime of its own:
- **trixie**, with the `meshcore-uconsole` package (by cwill747) installed: you already
have a working runtime (Python 3.13-based). Skip to
[step 2](#bridge-step-2--get-the-bridge).
- **bookworm**: the trixie `.deb` of `meshcore-uconsole` 1.12.0 is built on Python 3.13
and needs glibc 2.38, so it will not install on bookworm, and the APT repository offers
nothing for bookworm. Upstream's GitHub Releases also carry a separate bookworm `.deb`
built on Python 3.11; it has not been tested with the bridge. The documented route is to
give the bridge a runtime of its own:
```bash
python3 -m venv ~/.local/share/meshterm-spi-bridge/venv
~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]"
```
```bash
python3 -m venv ~/.local/share/meshterm-spi-bridge/venv
~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]"
```
This is also the route to take if you'd rather not install the `meshcore-uconsole` package
at all.
You'll confirm which runtime the bridge actually found in [Step 4](#step-4--run-the-preflight) —
its first check line names the interpreter and the runtime.
You'll confirm which runtime the bridge actually found in
[step 3](#bridge-step-3--run-the-preflight) — its first check line names the interpreter
and the runtime.
---
## Step 3 — Get the bridge
### Bridge step 2 — Get the bridge
`scripts/uconsole/meshterm-spi-bridge` is a single, self-contained Python file — no install step of
its own. Either clone the MeshTerm repo:
@@ -178,9 +387,7 @@ Make it executable:
chmod +x meshterm-spi-bridge
```
---
## Step 4 — Run the preflight
### Bridge step 3 — Run the preflight
```bash
./meshterm-spi-bridge --check
@@ -191,23 +398,24 @@ what to do if it fails:
| Check | Meaning if it fails | Fix |
| --- | --- | --- |
| `node runtime` | Neither `openhop_core` nor `pymc_core` is importable by any interpreter the bridge tried. | Build the venv from [Step 2](#step-2--install-the-radio-runtime), or set `MESHTERM_PYMC_PYTHON` to a python that has one. |
| `node runtime` | Neither `openhop_core` nor `pymc_core` is importable by any interpreter the bridge tried. | Build the venv from [step 1](#bridge-step-1--install-the-radio-runtime), or set `MESHTERM_PYMC_PYTHON` to a python that has one. |
| `SPI device` | No `/dev/spidev*` node exists, or it exists but you can't read/write it. | No node at all: add `dtoverlay=spi1-1cs` to `/boot/firmware/config.txt` and reboot. Node present but no permission: `sudo usermod -aG spi $USER`, then log out and back in. |
| `GPIO chip` | `/dev/gpiochip0` is missing or not read/write for you. | `sudo usermod -aG gpio $USER`, then log out and back in. |
| `bridge service` / `radio in use` / `port 5000 busy` / `radio is free` | Tells you whether something already holds the radio: the bridge's own boot service, a running `meshcore-console`, or something else already listening on the TCP port. | Only one program may use the radio at a time — see [Step 7](#step-7--install-meshterm-and-connect). If `meshcore-console` is running, close it before running the bridge. |
| `bridge service` / `radio in use` / `port 5000 busy` / `radio is free` | Tells you whether something already holds the radio: the bridge's own boot service, a running `meshcore-console`, or something else already listening on the TCP port. | Only one program may use the radio at a time — see [step 6](#bridge-step-6--connect-meshterm-to-it). If `meshcore-console` is running, close it before running the bridge. |
If the first three checks all pass, you're ready to run it.
---
## Step 5 — Run it once in the foreground
### Bridge step 4 — Run it once in the foreground
```bash
./meshterm-spi-bridge --run
```
This runs the preflight again, prints it, then starts the bridge in this terminal. A
healthy startup logs something like:
This runs the preflight again and prints it. Before it starts the bridge, it asks whether
to add a `bridge` profile to your MeshTerm config
(`` Add a 'bridge' profile to /home/you/.meshterm/config.toml so you can just run `meshterm`? [y/N] ``).
Either answer is fine; step 5 asks again if you say no. Then it starts the bridge in this
terminal. A healthy startup logs something like:
```
node runtime openhop_core 1.1.3 (/home/you/.local/share/meshterm-spi-bridge/venv/bin/python)
@@ -221,7 +429,8 @@ READY — connect with: meshterm --tcp 127.0.0.1:5000
The `radio config` line lists every setting as `key=value`, so the one above is
shortened; yours is longer.
Leave it running, and in another terminal on the same machine try:
You can test the bridge this way: leave it running, and in another terminal on the same
machine run:
```bash
meshterm --tcp 127.0.0.1:5000
@@ -229,9 +438,7 @@ meshterm --tcp 127.0.0.1:5000
Back in the bridge's terminal, press **Ctrl+C** to stop it.
---
## Step 6 — Install it as a service
### Bridge step 5 — Install it as a service
Run the bridge with no flags to get the interactive menu:
@@ -254,7 +461,7 @@ sudo loginctl enable-linger $USER
```
It also offers to add a `[profiles.bridge]` block to your MeshTerm `config.toml` — say yes,
it's the profile you'll use in [Step 7](#step-7--install-meshterm-and-connect).
it's the profile you'll use next.
Check on it any time:
@@ -265,26 +472,14 @@ journalctl --user -u meshterm-spi-bridge.service -f
Menu option **4) Show status** prints the same information from inside the script.
---
### Bridge step 6 — Connect MeshTerm to it
## Step 7 — Install MeshTerm and connect
Install MeshTerm on the uConsole the same way as [Step 3](#step-3--install-meshterm)
above — the bridge doesn't need the `spi` extra or `openhop-core[hardware]` in MeshTerm's
own Python, since the bridge is the one running the node.
Install MeshTerm on the uConsole. The simplest way is the Linux ARM64 one-file release:
```bash
chmod +x meshterm-*-linux-arm64
sudo mv meshterm-*-linux-arm64 /usr/local/bin/meshterm
```
Or install it from the repository with `pipx`:
```bash
sudo apt install pipx
pipx install git+https://github.com/jpmartineau/MeshTerm
pipx ensurepath
```
If you accepted the profile in Step 6, your `config.toml` now has:
If you accepted the profile in step 4 or 5, `~/.meshterm/config.toml` now has this block
(the bridge always writes to that path, even if you set `MESHTERM_HOME`):
```toml
[profiles.bridge]
@@ -305,71 +500,96 @@ or, without a profile:
meshterm --tcp 127.0.0.1:5000
```
You're connected when the device page or dashboard shows a node name (`uConsole` by
You're connected when **Device info** or the dashboard shows a node name (`uConsole` by
default) instead of a connection error. MeshTerm reports the device model as
`pyMC-spi-bridge` — that's how you tell a bridged node from a real companion.
`pyMC-spi-bridge` — that's how you tell a bridged node from a real companion or a direct
SPI connection.
**Identity.** If the `meshcore-console` GUI's package is present, the bridge loads *that*
package's identity key, so MeshTerm and the GUI are the same node — same contacts, same
public key. On a library-only install, the bridge mints and keeps its own identity key
under `~/.local/share/meshterm-spi-bridge/identity.key`.
package's identity key, so MeshTerm and the GUI share the same public key. Contacts and
radio settings are the bridge's own: it keeps contacts in
`~/.local/share/meshterm-spi-bridge/contacts.json`, its channels in
`~/.local/share/meshterm-spi-bridge/channels.json` (both survive a restart), and builds its
radio settings from the `MESHCORE_*` variables and its defaults, never from the GUI's saved
settings. Set the `MESHCORE_*` variables to match what the GUI uses. On a library-only
install, the bridge mints and keeps its own identity key under
`~/.local/share/meshterm-spi-bridge/identity.key`.
**Only one program may use the radio at a time.** Never run the bridge and the
`meshcore-console` GUI at the same time — whichever started second will fail to open the
radio.
radio. The same goes for a direct `meshterm --spi` connection while the bridge is running.
---
## Updating and uninstalling
### Updating and uninstalling the bridge
To update, pull or re-download `meshterm-spi-bridge` and reinstall the service (menu option
2 again — it overwrites the copy in `~/.local/bin/`).
2 again — it overwrites the copy in `~/.local/bin/`). The running service keeps the old
code until you restart it:
```bash
systemctl --user restart meshterm-spi-bridge.service
```
Option 2 also rewrites the unit file, so any `Environment=` lines you added to it by hand
are lost. Put them in a drop-in instead, as [Bridge hardware knobs](#bridge-hardware-knobs)
shows.
To remove the bridge, run the menu and choose **3) Uninstall the service**. This stops and
removes the `systemd --user` unit and the installed script copy. It does **not** touch
`meshcore-console`, the radio library, your node identity, or your saved contacts — those
stay put. If you'd added `[profiles.bridge]` to your MeshTerm config, it tells you to remove
that block by hand if you no longer want it.
`meshcore-console`, the radio library, your node identity, or your saved contacts and
channels — those stay put. If you'd added `[profiles.bridge]` to your MeshTerm config, it
tells you to remove that block by hand if you no longer want it.
---
### Bridge hardware knobs
## Hardware knobs
The pin defaults match the hackergadgets uConsole AIO v1, which needs none of these. The
**AIO v2 needs `MESHCORE_EN_PINS=27`**, whether or not the `meshcore-console` package is
installed. The radio settings default to the US/Canada preset. For a foreground run, export
what differs before launching the bridge. For the service, add a drop-in, which survives a
reinstall:
The defaults match the hackergadgets uConsole AIO, so a stock AIO needs none of these. They
exist for a revision or a board that differs: export what differs before launching the
bridge, or put the lines in the service's environment.
```bash
systemctl --user edit meshterm-spi-bridge.service
```
and put the variables under a `[Service]` heading, one `Environment=` line each:
```ini
[Service]
Environment=MESHCORE_EN_PINS=27
```
Then `systemctl --user restart meshterm-spi-bridge.service`.
| Variable | What it sets |
| --- | --- |
| `MESHCORE_BUS_ID`, `MESHCORE_CS_ID`, `MESHCORE_CS_PIN` | which SPI bus, device and chip-select pin the radio is on |
| `MESHCORE_BUS_ID`, `MESHCORE_CS_ID`, `MESHCORE_CS_PIN` | which SPI bus, device, and chip-select pin the radio is on |
| `MESHCORE_RESET_PIN`, `MESHCORE_BUSY_PIN`, `MESHCORE_IRQ_PIN` | the three control lines |
| `MESHCORE_FREQUENCY`, `MESHCORE_TX_POWER` | frequency in Hz, power in dBm |
| `MESHCORE_SPREADING_FACTOR`, `MESHCORE_BANDWIDTH`, `MESHCORE_CODING_RATE` | the modem preset — all three must match the mesh you are joining |
| `MESHCORE_SPREADING_FACTOR`, `MESHCORE_BANDWIDTH`, `MESHCORE_CODING_RATE` | the modem preset — all three must match the mesh you are joining. Whole numbers only: bandwidth in Hz (`62500`), coding rate as the denominator (`5` for 4/5). A decimal or `4/5` stops the bridge at startup. |
| `MESHCORE_TXEN_PIN`, `MESHCORE_RXEN_PIN`, `MESHCORE_EN_PINS` | the RF-switch and power-enable lines a board may need (`-1` for none; `EN_PINS` is a comma list — the AIO v2 wants `27`) |
| `MESHCORE_USE_DIO2_RF`, `MESHCORE_USE_DIO3_TCXO`, `MESHCORE_IS_WAVESHARE` | whether DIO2 drives the RF switch and DIO3 the TCXO (both on for the AIO), and a flag for a different vendor's wiring the runtime knows about |
| `MESHCORE_GPIO_CHIP`, `MESHCORE_USE_GPIOD_BACKEND`, `MESHCORE_PREAMBLE_LENGTH` | which gpiochip, whether to drive it through `gpiod`, and the LoRa preamble |
| `MESHCORE_GPIO_CHIP`, `MESHCORE_USE_GPIOD_BACKEND`, `MESHCORE_PREAMBLE_LENGTH` | which gpiochip, whether to drive it through `gpiod`, and the LoRa preamble (leave it unset: the bridge follows MeshCore, 32 symbols up to SF8 and 16 above, and a shorter one leaves the radio deaf to most of the mesh) |
| `MESHTERM_PYMC_PYTHON` | force a specific interpreter instead of letting the bridge discover one |
The bridge passes a knob only when the runtime's radio constructor accepts it, so the same
environment works under `pymc_core` 1.0.x, which lacks the newer ones. Where the
`meshcore-console` package is installed, the bridge builds the radio through *its* hardware
configuration, which reads the same names plus a few more of its own — see that package's
documentation. On a library-only install the table above is the whole of it, so an AIO v2
without that package present may not power its radio.
`meshcore-console` package is installed, the bridge builds the radio through that
package's environment reader, which reads the same names plus a few more of its own (see
that package's documentation) and ignores the GUI's saved presets. On a library-only
install the table above is the whole of it. Either way, an AIO v2 without
`MESHCORE_EN_PINS=27` will not power its radio.
---
## Troubleshooting
### Bridge troubleshooting
| Symptom | Check |
| --- | --- |
| `node runtime` fails in the preflight | Did you build the venv in [Step 2](#step-2--install-the-radio-runtime)? Is `MESHTERM_PYMC_PYTHON` pointing at the right interpreter, if set? |
| `node runtime` fails in the preflight | Did you build the venv in [step 1](#bridge-step-1--install-the-radio-runtime)? Is `MESHTERM_PYMC_PYTHON` pointing at the right interpreter, if set? |
| `SPI device` shows "no /dev/spidev* found" | Is `dtoverlay=spi1-1cs` in `/boot/firmware/config.txt`? Did you reboot after adding it? |
| `SPI device` or `GPIO chip` shows a permission error | Are you in the `spi` and `gpio` groups (`groups`)? Did you log out and back in after `usermod`? |
| Preflight warns "radio in use — meshcore-console is running" | Close the GUI before running or installing the bridge. |
| Preflight warns "port 5000 busy" | Something else is already listening there — check `systemctl --user status meshterm-spi-bridge.service` for an already-running instance. |
| Bridge starts but `radio.begin()` fails after 4 attempts | GPIO lines may still be held by a session that just closed — wait a few seconds and try again. |
| MeshTerm connects but direct messages to an old contact say "not found" | Restart the bridge — contacts are restored from its snapshot file at startup; if the snapshot is missing, the contact needs to be heard again. |
| Preflight warns `radio in use meshcore-console is running:` | Close the GUI before running or installing the bridge. |
| Preflight warns `port 5000 busy something is already on 127.0.0.1:5000` | Something other than the bridge service is listening there (an active service is reported as `bridge service running` instead). Usually it is a foreground `--run` in another terminal; stop that one. |
| Bridge exits with `bridge failed: GPIO pins still busy after retries` | The bridge retries `radio.begin()` four times only when the GPIO lines are busy. They may still be held by a session that just closed — wait a few seconds and try again. |
| MeshTerm connects but direct messages to an old contact say "not found" | Contacts are restored from the bridge's snapshot file at startup. If the contact is not in it, wait until the contact is heard again. |
| Reboot, factory reset, or setting a device PIN silently does nothing | Expected — the radio library has no handler for these three, whatever runtime you're on. |
| Traces or the live feed don't show up | These rely on compatibility shims the bridge installs (or, under `openhop_core`, on the library's own equivalents). Check the bridge's log for lines starting `compat:` to confirm they're wired. |
@@ -377,14 +597,20 @@ without that package present may not power its radio.
## What this has been tested on
This has been verified on a bookworm uConsole with `openhop-core` 1.1.3: the radio comes up,
contacts are restored across a restart, and `info` and `contacts` answer correctly over the
TCP connection. The older `pymc-core` 1.0.x path, with all three of the bridge's
compatibility shims active, has been exercised in full, including traces and the live feed.
Every interface the bridge calls in both runtimes is checked by MeshTerm's test suite.
**The direct connection** has been tested against the real radio library
(`openhop-core`) with a simulated radio; it has **not yet been verified on uConsole
hardware**.
**The bridge** has been verified on a bookworm uConsole with `openhop-core` 1.1.3: the
radio comes up, contacts are restored across a restart, and `info` and `contacts` answer
correctly over the TCP connection. The older `pymc-core` 1.0.x path, with all three of the
bridge's compatibility shims active, has been exercised in full, including traces and the
live feed. MeshTerm's test suite does not cover the bridge.
What has not been confirmed:
- **The direct connection on real hardware** — everything above has only run against a
simulated radio under the real library.
- **The bridge under `openhop_core` beyond `info` and `contacts`.** Startup, identity,
contact restore and those two reads were exercised; a trace, a message and the live feed
under the new runtime were not, though the library's own trace push is what the bridge
+3 -3
View File
@@ -1,12 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
"""MeshTerm is a full-featured TUI-based MeshCore client for your terminal.
"""MeshTerm is a full-featured TUI MeshCore client for your terminal.
Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Linux.
Connects to a companion over USB, Bluetooth, or TCP. For Windows, macOS, and Linux.
"""
from datetime import datetime
__version__ = "0.3.6"
__version__ = "0.10.2"
__author__ = "Jean-Pierre Martineau"
#: The year MeshTerm was first published; the copyright span starts here.
+16 -16
View File
@@ -4,11 +4,11 @@
## What it is
**MeshTerm** is a full-featured **MeshCore** client that lives in your terminal. Plug in a companion device over USB, pair one over Bluetooth, or reach one over TCP. Windows, macOS, and Linux. Free and open-source.
**MeshTerm** is a full-featured TUI **MeshCore** client for your terminal. Plug in a companion device over USB, pair one over Bluetooth, or reach one over TCP. Windows, macOS, and Linux. Free and open-source.
Nearly everything MeshTerm can do thru its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The live pictures stay in the TUI, where they belong — the map, the dashboard, the live feed, the watchtower, the mesh walk — and everything else has a command. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks! Every command prints for a person by default, and speaks JSON with `--json` when a program is reading!
Nearly everything MeshTerm can do through its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The live pictures stay in the TUI, where they belong — the map, the dashboard, the live feed, the watchtower, the mesh walk — and everything else has a command. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks! Every command prints for a person by default, and speaks JSON with `--json` when a program is reading!
Everything the radio overhears is recorded to a local database from the moment it's turned on — adverts, telemetry, signal quality, positions, etc. Features like maps, dashboard, and route graphs read back from it, so the longer it runs, the more it know about the mesh!
While MeshTerm is running and connected to your radio, everything the radio overhears is recorded to a local database — adverts, telemetry, signal quality, positions, etc. Features like maps, dashboard, and route graphs read back from it, so the longer it runs, the more it knows about the mesh!
## Where it came from
@@ -18,29 +18,29 @@ I wanted something I could run from my computer. I wanted it to run on Windows,
## Platform-specific developments
In addition to generic Terminal setup, I wanted it to run on two devices I had lying around on my nerdcave.
In addition to a generic terminal setup, I wanted it to run on two devices I had lying around in my nerdcave.
### Clockwork uConsole
MeshTerm is compatible with the **HackerGadgets AIO board v1**. The **AIO** board has just a raw LoRa radio, meaning it can't run MeshCore firmware. MeshTerm includes a script to run the mesh node in software, and expose it using a TCP port which MeshTerm can connect to.
### ClockworkPi uConsole
MeshTerm is compatible with the **HackerGadgets AIO board (v1 or v2)**. The **AIO** board has just a raw LoRa radio, meaning it can't run MeshCore firmware. MeshTerm can run the mesh node in software itself, straight over the board's SPI bus, for as long as MeshTerm is running — no separate service needed. If you'd rather the node stayed on the mesh while MeshTerm is closed, an optional bridge service can run it all the time instead.
MeshTerm feels right at home on the **uConsole**. Running MeshTerm on it makes it feel like a piece of gear straight out of a William Gibson novel.
### Clockwork PicoCalc
### ClockworkPi PicoCalc
Getting MeshTerm to run on the **PicoCalc** is a bit more involved, but totally worth it:
- Swap out the **Raspberry Pi Pico** for a **Luckfox Lyra** to run **Calculinux**.
- Add a compatible 3.3v USB wifi dongle (e.g.: **TP-Link TL-WN725N N150 Nano**) to the **Lyra**'s internal USB port.
- Add a LoRa device. Here's a few options:
- Add a compatible 3.3 V USB Wi-Fi dongle (e.g. **TP-Link TL-WN725N N150 Nano**) to the **Lyra**'s internal USB port.
- Add a LoRa device. Here are two options:
- Connect a companion via USB-C;
- add **Seeed Studio XIAO nRF52840 + Wio-SX1262** internally, soldered to GPIO connectors' legs.
- In order to use the XIAO nRF52840 via GPIO, we do need to use a modified firmware. A **PR** *https://github.com/meshcore-dev/MeshCore/pull/3191* has been submitted to MeshCore, but it might take a while for it to go through.
- In order to use the XIAO nRF52840 via GPIO, a modified firmware is needed. A **PR** *https://github.com/meshcore-dev/MeshCore/pull/3191* has been submitted to MeshCore, but it might take a while for it to go through.
The UX is a bit different on the the PicoCalc's 16-color 53-column screen layout, but it's been tailored to make good use of the available space and the PicoCalc's F-keys. The result is, in my humble opinion, the best MeshCore experience you can get on a portable device. With two 18650 batteries, it'll run all day.
The UX is a bit different on the PicoCalc's 16-colour 53-column screen layout, but it's been tailored to make good use of the available space and the PicoCalc's F-keys. The result is, in my humble opinion, the best MeshCore experience you can get on a portable device. It runs on one 18650 battery, and a second one roughly doubles the runtime.
## What the future holds
MeshTerm will evolve over time, it is a work in progress.
MeshTerm will evolve over time. It is a work in progress.
I've build MeshTerm for myself — I find that's when I produce my best work — but I sincerely hope it will be appreciated by others.
I've built MeshTerm for myself — I find that's when I produce my best work — but I sincerely hope it will be appreciated by others.
If you'd like a specific feature, pitch your idea on the Discord!
@@ -49,9 +49,9 @@ If you'd like a specific feature, pitch your idea on the Discord!
MeshTerm ships under the **Apache License, Version 2.0** — free to use,
change, and redistribute, commercially or otherwise, as long as the
LICENSE and NOTICE files travel with it. The full terms:
**apache.org/licenses/LICENSE-2.0** *https://www.apache.org/licenses/LICENSE-2.0*.
*https://www.apache.org/licenses/LICENSE-2.0*.
The licence covers the code, not the name: **MeshTerm and its logo are
The licence does not cover the name: **MeshTerm and its logo are
trademarks the author keeps**, so a fork goes out under a name of its own.
The donate link in this app supports this project and its original
author — a fork that keeps soliciting through it, without pointing it at
@@ -59,7 +59,7 @@ 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,
markdown-it-py, tomli/tomli-w and Segno, each under its own terms.
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/* on the **© OpenMapTiles** schema
+4 -4
View File
@@ -1,7 +1,7 @@
## Who am I IRL
I'm Jean-Pierre Martineau. I live in Montréal, Québec, Canada.
I'm Jean-Pierre Martineau. I live in Montréal, Québec, Canada.
Data expert by day and tinkerer by night, I've spend my career designing solutions and writing code for various industries, from video-games to public transit. I've always had an unquenchable curiosity that drove me to pursue personal projects during my free time. MeshCore has been the focal point of my nights and weekends as of lately.
Data expert by day and tinkerer by night, I've spent my career designing solutions and writing code for various industries, from video games to public transit. I've always had an unquenchable curiosity that drove me to pursue personal projects during my free time. MeshCore has been the focal point of my nights and weekends lately.
## Who am I on the mesh
@@ -9,11 +9,11 @@ I operate **YUL-Cartierville** and **YUL-Upper-Salaberry** repeaters in Montréa
## On the subject of AI assisted development
As a computer scientist, I find AI to be a powerful amplifier, not a substitute—of one's capabilities. For me, it's been a vector of productivity, allowing me to grasp something which would have been out of reach. I can spend my time ideating and let the machine take care of the nitty-gritty. Don't be fooled, sculpting MeshTerm into its current form still took a *lot* of work. Every nook and cranny of the app has been designed, tested, and iterated by me. Hundreds of hours of my own time, augmented by what I learned throughout my sofware developer career.
As a computer scientist, I find AI to be a powerful amplifier of one's capabilities. For me, it's been a vector of productivity, allowing me to grasp something which would have been out of reach. I can spend my time ideating and let the machine take care of the nitty-gritty. Don't be fooled: sculpting MeshTerm into its current form still took a *lot* of work. Every nook and cranny of the app has been designed, tested, and iterated by me. It took hundreds of hours of my own time, augmented by what I learned throughout my software developer career.
MeshTerm would *not* exist without the use of AI tools.
One detail: the about pages were hand-written by me, and the ANSI art splash screen logo was done by hand — no AI — using **Moebius**.
One detail: these pages were hand-written by me, and the ANSI art splash screen logo was done by hand — no AI — using **Moebius**.
## How to reach me
+8 -8
View File
@@ -1,14 +1,14 @@
## Why it needs support
**MeshTerm** is free.
**MeshTerm** is free and open source, and it will stay that way.
The project is a labour of love. I've poured hundreds of hours of my free time into it. Evenings, late nights, weekends, even some of my vacation time!
As I mentionned in the about pages, this project relies on AI tools to accelerate development. Without them, MeshTerm would absolutely not exist. Subscriptions to these AI tools are a recurring expense!
As I mentioned on the Author page, this project relies on AI tools to accelerate development. Without them, MeshTerm would absolutely not exist. Subscriptions to these AI tools are a recurring expense!
Routers, companion devices, handhelds, antennas, and batteries aren't super expensive individually, but it all adds up quickly!
Repeaters, companion devices, handhelds, antennas, and batteries aren't super expensive individually, but it all adds up quickly!
If you want pitch in and contribute financially, you're more that welcome to do so. Any help will be greatly appreciated and put to good use.
If you want to pitch in and contribute financially, you're more than welcome to do so. Any help will be greatly appreciated and put to good use.
## Chip in
@@ -20,7 +20,7 @@ If you want pitch in and contribute financially, you're more that welcome to do
https://buymeacoffee.com/johnputer
```
### Donate via Paypal
### Donate via PayPal
- *https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6*
@@ -30,17 +30,17 @@ https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6
### Become a patron
- *https://www.patreon.com/c/MeshTerm*
- *https://patreon.com/MeshTerm*
```qr
https://www.patreon.com/c/MeshTerm
https://patreon.com/MeshTerm
```
## Other ways to help
- Spread the word and let others know how awesome **MeshTerm** is!
- Join the Discord community!
- Give this project a Star **⭐** on **GitHub**!
- Give this project a Star **⭐** on **GitHub**!
- Open an issue on **GitHub** when you encounter a bug or problem!
*https://github.com/jpmartineau/MeshTerm*
Binary file not shown.
Binary file not shown.
+63 -17
View File
@@ -22,12 +22,17 @@ from . import __version__
from .context import AppContext
from .core import exitcodes, win32dll
from .core.admin_store import AdminStore
from .core.config import Settings
from .core.config import MOCK_DB_FILENAME, Settings
from .core.connection import DeviceCommandError, is_connection_lost
from .core.device_config import DeviceConfigError
from .core.device_store import DeviceStore
from .core.instancelock import InstanceBusy, hold_instance_lock
from .core.preferences import PREFERENCES_FILENAME, PreferenceError, Preferences
from .core.preferences import (
PREFERENCES_FILENAME,
PreferenceError,
Preferences,
report_dropped,
)
from .core.selection import DeviceSelectionError
from .persistence.logging import configure_logging, get_logger, level_from_name, log_path
from .persistence.repository import Repository
@@ -121,9 +126,9 @@ app = typer.Typer(
# The one description, same as the package summary and the PyPI page. See
# `meshterm.__doc__` — if this drifts from that, one of them is lying.
help=(
"MeshTerm is a full-featured TUI-based MeshCore client for your terminal. "
"Supports USB, Bluetooth and TCP companion connection. "
"For Windows, macOS and Linux."
"MeshTerm is a full-featured TUI MeshCore client for your terminal. "
"Connects to a companion over USB, Bluetooth, or TCP. "
"For Windows, macOS, and Linux."
),
epilog=EXIT_STATUS_EPILOG,
)
@@ -174,8 +179,15 @@ def main_callback(
"--tcp",
help="Network address host[:port] of a TCP companion (selects the TCP transport)",
),
spi: bool = typer.Option(
False,
"--spi",
help="Run a node on the LoRa radio on this machine's SPI bus (selects the SPI transport)",
),
mock: bool = typer.Option(False, "--mock", help="Use the built-in simulator"),
db_path: Path | None = typer.Option(None, "--db", help="SQLite database path"),
db_path: Path | None = typer.Option(
None, "--db", help="SQLite database path (--mock records to meshterm-mock.db)"
),
json_output: bool = typer.Option(False, "--json", help="Machine-readable output"),
absolute: bool = typer.Option(
False, "--absolute", help="Print absolute timestamps instead of relative ages"
@@ -197,8 +209,10 @@ def main_callback(
ble: Explicit Bluetooth address, selecting the BLE transport.
ble_pin: Optional BLE pairing PIN for the Bluetooth companion.
tcp: Explicit network address ``host[:port]``, selecting the TCP transport.
spi: Select the radio on this machine's SPI bus, whose node MeshTerm runs itself.
mock: Whether to use the simulator instead of real hardware.
db_path: Override the database location.
db_path: Override the database location. Without it, a ``--mock`` run records to
the simulator's own database rather than the real history.
json_output: Print the answer as JSON instead of aligned text.
absolute: Print times as ISO-8601 instants rather than relative ages, for this run.
quiet: Suppress console logging (file logging continues).
@@ -221,17 +235,35 @@ def main_callback(
active = without_emoji(active)
set_platform(active)
if mock and (port or ble or tcp):
named_radios = [
flag
for flag, given in (("--port", port), ("--ble", ble), ("--tcp", tcp), ("--spi", spi))
if given
]
if spi and len(named_radios) > 1:
# The SPI radio is on this machine and every other flag names a radio that isn't.
raise typer.BadParameter(f"--spi and {named_radios[0]} name different devices; pass one")
if mock and named_radios:
# One flag says "pretend", the other names one exact radio, and `--mock` used to
# win silently — so a scheduled `--port COM7 --mock info` reported on a simulator
# while reading as though it had reached the radio. Two contradictory claims about
# which device to talk to is a bad command line, not a precedence question.
named = "--port" if port else ("--ble" if ble else "--tcp")
raise typer.BadParameter(f"--mock and {named} name different devices; pass one")
raise typer.BadParameter(f"--mock and {named_radios[0]} name different devices; pass one")
settings = Settings.load()
if db_path is not None:
settings.db_path = db_path
elif mock:
# The simulator is a fake radio, not a fake MeshTerm: everything it adverts was
# written to the real history like anything a companion said, and its four
# invented contacts then turned up in the mesh walk, the dashboard and the map of
# the mesh you actually run. A pretend radio gets a pretend history —
# ``<config_dir>/meshterm-mock.db``, created on first use — while ``--db`` still
# wins, because naming a database is a deliberate claim about where a run records.
# ``$MESHTERM_HOME`` remains the only thing that isolates a run whole: the contact
# and channel caches, the outbox and the remembered devices live beside the
# database either way (see docs/cli.md).
settings.db_path = settings.config_dir / MOCK_DB_FILENAME
# Two front ends, two consoles, and which one this process gets is settled here — a
# named subcommand is a scripted run, so it prints on the plain, colourless,
@@ -261,6 +293,9 @@ def main_callback(
file_level=level_from_name(prefs.log_level),
quiet=quiet or json_output,
)
# What the file held that no preference now takes — reported only now, since the file
# was read before there was a log to say so in.
report_dropped(prefs, get_logger())
if profile is not None and settings.resolve_profile(profile) is None:
# Falling through to ordinary discovery would pick whichever radio is attached, so
@@ -282,13 +317,14 @@ def main_callback(
port_override=port,
ble_override=ble,
tcp_override=tcp,
spi_override=spi,
ble_pin=ble_pin,
output=OutputFormat.JSON if json_output else OutputFormat.PLAIN,
# Whether a tool may stop and ask, which is a different question from what its
# output looks like. A pipe on stdin means nobody is there to answer.
interactive=_stdin_is_a_person(),
explicit_selection=(
profile is not None or port is not None or ble is not None or tcp is not None
profile is not None or port is not None or ble is not None or tcp is not None or spi
),
)
_state = app_ctx
@@ -557,9 +593,9 @@ def _report_no_windows_console(console: Console) -> None:
console.print()
console.print("That usually means one of two things:")
console.print()
console.print(" • You are in Git Bash, MSYS or Cygwin. Those are not Windows consoles.")
console.print(" Open [accent]Windows Terminal[/accent] or [accent]PowerShell[/accent]")
console.print(" and run it from there.")
console.print(" • You are in Git Bash, MSYS, or Cygwin, which are not Windows")
console.print(" consoles. Open [accent]Windows Terminal[/accent] or")
console.print(" [accent]PowerShell[/accent] and run it from there.")
console.print(" • The output is piped or redirected somewhere. The full-screen menu")
console.print(" needs the terminal itself.")
console.print()
@@ -567,7 +603,10 @@ def _report_no_windows_console(console: Console) -> None:
console.print("try [accent]meshterm --help[/accent].")
@app.command(name="platform")
@app.command(
name="platform",
help="Show which platform MeshTerm runs as, and why",
)
def platform_command() -> None:
"""Print the resolved platform and every input the resolution looked at.
@@ -614,7 +653,10 @@ def platform_command() -> None:
)
@app.command(name="specimen")
@app.command(
name="specimen",
help="Print every mark, icon, and colour MeshTerm draws",
)
def specimen_command() -> None:
"""Print the visual-language specimen for the active platform.
@@ -853,7 +895,11 @@ def main() -> None:
run from a shell leaves its output on the screen anyway.
"""
try:
app()
# No wildcard expansion on Windows. Click expands an argument holding `*` into
# the files it matches — even one the shell passed through quoted, which it can't
# tell apart — so `chat send --scope '*'` arrived as the working directory's file
# names. No MeshTerm argument is a file glob, and `*` is the wildcard region.
app(windows_expand_args=False)
except SystemExit as exit_request:
if exit_request.code and _owns_its_console():
_wait_before_the_window_closes()
+151 -11
View File
@@ -8,6 +8,7 @@ never open a serial port.
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
@@ -16,15 +17,16 @@ from rich.console import Console
from .core.admin_store import AdminStore
from .core.advert_store import AdvertStore
from .core.channel_store import ChannelStore
from .core.config import DeviceProfile, Settings
from .core.config import DeviceProfile, Settings, SpiWiring
from .core.connection import Device, make_device
from .core.contact_store import ContactStore
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_FILENAME, Preferences
from .core.preferences import PREFERENCES_FILENAME, Preferences, report_dropped
from .core.preferences import install as install_preferences
from .core.region_store import RegionStore
from .core.remote_store import RemoteStore
from .core.selection import resolve_device
from .core.settings_store import SettingsStore
@@ -76,6 +78,9 @@ class AppContext:
mute_store: Store for muted channel notifications — the channels whose new
messages don't raise the unread badge (defaults to
``<config_dir>/mutes.json`` when not injected).
region_store: Store for the regions known by name and each channel's send scope —
what a scoped flood is resolved against (defaults to
``<config_dir>/regions.json`` when not injected).
channel_store: Store for channels created through MeshTerm, replayed into a device
that forgot them (a firmware-less radio bridge). Keyed by device public key;
defaults to ``<config_dir>/channels.json`` when not injected.
@@ -121,6 +126,7 @@ class AppContext:
watch_store: WatchStore | None = None
courier_store: CourierStore | None = None
mute_store: MuteStore | None = None
region_store: RegionStore | None = None
channel_store: ChannelStore | None = None
settings_store: SettingsStore | None = None
contact_store: ContactStore | None = None
@@ -129,6 +135,7 @@ class AppContext:
port_override: str | None = None
ble_override: str | None = None
tcp_override: str | None = None
spi_override: bool = False
ble_pin: str | None = None
output: OutputFormat = OutputFormat.PLAIN
interactive: bool = True
@@ -145,11 +152,15 @@ class AppContext:
#: the older handle no longer names it. Typed ``object`` so ``bleak`` stays optional.
_ble_handle: object | None = field(default=None, init=False, repr=False)
unpair_on_exit: bool = field(default=False, init=False, repr=False)
#: Set by the config editor just before it sends a reboot command, so the session's
#: disconnect watcher can label the ensuing (expected) link drop as a reboot in
#: progress rather than a surprise unplug. Cleared by the reconnect dialog that
#: consumes it (see :func:`meshterm.ui.menu._handle_disconnect`).
#: Set by :meth:`announce_reboot` once a reboot command has gone out, so the reconnect
#: dialog presents the drop as a reboot in progress rather than a surprise unplug.
#: Cleared by :meth:`take_reboot` in the dialog that consumes it (see
#: :func:`meshterm.ui.menu._handle_disconnect`).
reboot_in_progress: bool = field(default=False, init=False, repr=False)
#: Set alongside :attr:`reboot_in_progress`: wakes the session's disconnect watcher at
#: once, without waiting for a liveness poll to *observe* the drop — which it may never
#: do, since a board behind a USB-UART bridge keeps its port through a reboot.
_link_going_down: asyncio.Event = field(default_factory=asyncio.Event, init=False, repr=False)
_resume_intent: tuple[bool, bool, bool] | None = field(default=None, init=False, repr=False)
_events: EventHub | None = field(default=None, init=False, repr=False)
_monitor: MonitorService | None = field(default=None, init=False, repr=False)
@@ -172,6 +183,7 @@ class AppContext:
"""
if self.preferences is None:
self.preferences = Preferences.load(self.settings.config_dir / PREFERENCES_FILENAME)
report_dropped(self.preferences, self.log)
install_preferences(self.preferences)
if self.advert_store is None:
self.advert_store = AdvertStore(self.settings.config_dir / "adverts.json")
@@ -183,6 +195,8 @@ class AppContext:
self.courier_store = CourierStore(self.settings.config_dir / "courier.json")
if self.mute_store is None:
self.mute_store = MuteStore(self.settings.config_dir / "mutes.json")
if self.region_store is None:
self.region_store = RegionStore(self.settings.config_dir / "regions.json")
if self.channel_store is None:
self.channel_store = ChannelStore(self.settings.config_dir / "channels.json")
if self.settings_store is None:
@@ -209,7 +223,7 @@ class AppContext:
serial port). Falls back to an explicit ``--port`` override when a connection hasn't
recorded one yet.
"""
if self.mock or self.active_transport in ("ble", "tcp"):
if self.mock or self.active_transport in ("ble", "tcp", "spi"):
return None
return self._active_port or self.port_override
@@ -241,15 +255,17 @@ class AppContext:
def active_transport(self) -> str | None:
"""Which transport the current or selected connection uses.
One of ``"serial"``, ``"ble"``, ``"tcp"``, or ``None`` for the simulator. For a
real device it reflects the open connection when one exists, otherwise the
One of ``"serial"``, ``"ble"``, ``"tcp"``, ``"spi"``, or ``None`` for the simulator.
For a real device it reflects the open connection when one exists, otherwise the
transport implied by the pending selection (an explicit ``--tcp`` selects TCP,
``--ble`` selects BLE), defaulting to serial.
``--ble`` selects BLE, ``--spi`` the SPI radio), defaulting to serial.
"""
if self.mock:
return None
if self._active_transport is not None:
return self._active_transport
if self.spi_override or (self.profile is not None and self.profile.is_spi):
return "spi"
if self.tcp_override:
return "tcp"
return "ble" if self.ble_override else "serial"
@@ -271,6 +287,34 @@ class AppContext:
except Exception: # noqa: BLE001 - a liveness-check failure must not fake a disconnect
return True
def announce_reboot(self) -> None:
"""Declare the link down because we just told the companion to reboot.
Called *after* the reboot command was sent (never before: the watcher it wakes
cancels the screen that sent it, and a cancel mid-write could leave the command half
out). From then on the device is not ours to talk to until it comes back, so the
session's disconnect watcher (:meth:`link_going_down`) fires at once and the
reconnect dialog takes over — rather than hoping a liveness poll catches the drop,
which misses a quick reboot entirely where the serial port never goes away.
"""
self.reboot_in_progress = True
self._link_going_down.set()
async def link_going_down(self) -> None:
"""Resolve once :meth:`announce_reboot` has declared the link down."""
await self._link_going_down.wait()
def take_reboot(self) -> bool:
"""Consume a pending reboot announcement, returning whether there was one.
Clears both halves, so a later, genuine disconnect goes back to the generic
wording and the next watcher waits for a real drop again.
"""
pending = self.reboot_in_progress
self.reboot_in_progress = False
self._link_going_down.clear()
return pending
@property
def ui(self) -> Ui:
"""The active UI surface, defaulting to the plain console surface for the CLI.
@@ -519,6 +563,27 @@ class AppContext:
await self._open("the simulator")
return self._device
# A radio on the host's own SPI bus (``--spi``, an SPI profile, or the remembered
# default) has nothing to find: MeshTerm starts the node that answers on it.
spi = self.resolve_spi()
if spi is not None:
from .core.spiradio import state_dir
self._device = make_device(
mock=False,
port=None,
transport="spi",
spi=spi,
state=state_dir(self.settings.config_dir, spi),
)
self._active_transport = "spi"
self._active_endpoint = spi.spidev
self._active_port = None
self._active_address = None
await self._open(f"SPI radio {spi.spidev}")
await self._settle_connection()
return self._device
# A network endpoint (an explicit ``--tcp``, a TCP profile, a device picked at startup,
# or the remembered TCP default) is opened directly by host:port — TCP companions
# aren't discoverable, so this remembered/explicit endpoint is the only way to reach one.
@@ -614,8 +679,10 @@ class AppContext:
explicit_other = (
bool(self.port_override)
or bool(self.ble_override)
or self.spi_override
or (
self.profile is not None and (bool(self.profile.port) or bool(self.profile.address))
self.profile is not None
and (bool(self.profile.port) or bool(self.profile.address) or self.profile.is_spi)
)
)
if explicit_other:
@@ -630,6 +697,79 @@ class AppContext:
return host, port
return None, None
def resolve_spi(self) -> SpiWiring | None:
"""The wiring of the SPI radio to open, or ``None`` when another transport is meant.
In priority order: an SPI profile, the radio picked on the startup splash, ``--spi``
(the one radio attached — see :meth:`_spi_for_flag`), then the remembered default when it
was an SPI radio and nothing else was named. Anything else named explicitly — a port,
an address, a host, another kind of profile — means this session is not about the SPI
radio.
"""
if self.profile is not None and self.profile.is_spi:
return self.profile.spi or SpiWiring()
chosen = self.selected_device
if chosen is not None and chosen.is_spi:
return chosen.spi or self.spi_wiring_for(chosen.port) # type: ignore[return-value]
if self.spi_override:
return self._spi_for_flag()
if self.port_override or self.ble_override or self.tcp_override or self.profile:
return None
remembered = self.device_store.load()
if remembered is None or remembered.transport != "spi" or not remembered.target:
return None
self.selected_device = None # remembered, not freshly discovered this session
return self.spi_wiring_for(remembered.target)
def _spi_for_flag(self) -> SpiWiring:
"""The radio a bare ``--spi`` means: the one there is, and never a guess between two.
Nearly everyone with a radio on the SPI bus has exactly one, and for them ``--spi``
needs nothing more — attached radios are counted first, so a single one is used
whether or not a profile names it. Only when two are attached, or (with none
attached to count) two SPI profiles are configured, is the flag ambiguous; it used
to take whichever profile ``config.toml`` listed first, which is a coin toss that
transmits. With none attached and one profile or none, the choice is made from the
configuration, so the connect attempt can say what is missing.
Raises:
DeviceSelectionError: When more than one radio could be meant.
"""
from .core.selection import DeviceSelectionError
from .core.spiradio import spi_radios
profiles = self.settings.profiles
attached = spi_radios(profiles)
if len(attached) == 1:
return attached[0].spi # type: ignore[return-value]
configured = [p for p in profiles.values() if p.is_spi]
if not attached and len(configured) <= 1:
return (configured[0].spi or SpiWiring()) if configured else SpiWiring()
if attached:
rows = [f" {d.name or '(no profile)':<16} {d.port}" for d in attached]
what = "Several radios are attached to the SPI bus"
else:
rows = [f" {p.name:<16} {(p.spi or SpiWiring()).spidev}" for p in configured]
what = "Several SPI profiles are configured"
raise DeviceSelectionError(
f"{what}, and --spi doesn't say which.\n"
+ "\n".join(rows)
+ "\nChoose one with -p <PROFILE>. A radio with no profile needs one first — see "
"'Adding a radio on the SPI bus' in docs/configuration.md."
)
def spi_wiring_for(self, spidev: str) -> SpiWiring:
"""The wiring for the radio on ``spidev``: a profile's that names it, else the AIO's.
A remembered or listed SPI radio is known by its device node alone, so its pins come
from whichever SPI profile is on that node; with none, the defaults are the only
wiring there is (and the right one for the board they describe).
"""
for profile in self.settings.profiles.values():
if profile.is_spi and (profile.spi or SpiWiring()).spidev == spidev:
return profile.spi or SpiWiring()
return SpiWiring()
def _resolve_ble_endpoint(self) -> tuple[str | None, str | None]:
"""Return the ``(address, pin)`` to open over Bluetooth, or ``(None, None)`` for serial.
+48 -18
View File
@@ -7,6 +7,10 @@ password means the user isn't prompted on every run. Like the remembered device
directory (``<config_dir>/admin.json``) rather than the per-invocation SQLite database,
because admin credentials are global machine state.
The file is read into :class:`AdminCredential` records and written back out of them,
never round-tripped as the raw JSON it was read as, so a field no record knows is gone
after the next write (see :data:`AdminCredential.RETIRED`).
Passwords are stored in plaintext, so the file is written with owner-only permissions
where the platform supports it. This is a local operator tool tuning their own mesh, not
a multi-user secret store; treat the file accordingly.
@@ -17,6 +21,7 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import ClassVar
from .atomicwrite import write_atomically
from .models import Contact, LoginResult, utcnow
@@ -43,7 +48,8 @@ class AdminCredential:
"""A remembered admin password for one remote node.
Attributes:
key: The :func:`admin_key` the password is stored under.
key: The :func:`admin_key` the password is stored under (the record's key in the
file, not one of its fields).
password: The node's admin password (plaintext).
label: A friendly node name for display.
last_used: ISO-8601 timestamp of the last successful login.
@@ -54,6 +60,26 @@ class AdminCredential:
label: str
last_used: str
#: Fields this record once held and must never hold again under another meaning (see
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
RETIRED: ClassVar[frozenset[str]] = frozenset()
@classmethod
def read(cls, key: str, raw: object) -> AdminCredential | None:
"""Build a record from one file entry, or ``None`` when it holds no password."""
if not isinstance(raw, dict) or raw.get("password") is None:
return None
return cls(
key=key,
password=str(raw["password"]),
label=str(raw.get("label") or ""),
last_used=str(raw.get("last_used") or ""),
)
def to_json(self) -> dict:
"""The record as written: its own fields and nothing it happened to be read with."""
return {"password": self.password, "label": self.label, "last_used": self.last_used}
class AdminStore:
"""Reads and writes remembered remote-node admin passwords."""
@@ -66,13 +92,20 @@ class AdminStore:
"""
self._path = path
def _load_all(self) -> dict[str, dict]:
"""Return the raw key -> record mapping, or empty on missing/corrupt file."""
def _load(self) -> dict[str, AdminCredential]:
"""Read every credential, keyed by :func:`admin_key`; empty on a missing/corrupt file."""
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
if not isinstance(data, dict):
return {}
records = {}
for key, raw in data.items():
record = AdminCredential.read(str(key), raw)
if record is not None:
records[record.key] = record
return records
def get(self, node: Contact) -> str | None:
"""Return the remembered password for ``node``, or ``None`` if not stored.
@@ -83,11 +116,8 @@ class AdminStore:
Returns:
The stored password, or ``None``.
"""
record = self._load_all().get(admin_key(node))
if isinstance(record, dict):
password = record.get("password")
return str(password) if password is not None else None
return None
record = self._load().get(admin_key(node))
return record.password if record is not None else None
def remember(self, node: Contact, password: str) -> None:
"""Store (or update) the admin password for ``node``.
@@ -96,12 +126,11 @@ class AdminStore:
node: The contact the password belongs to.
password: The admin password to remember.
"""
records = self._load_all()
records[admin_key(node)] = {
"password": password,
"label": node.name,
"last_used": utcnow().isoformat(),
}
records = self._load()
key = admin_key(node)
records[key] = AdminCredential(
key=key, password=password, label=node.name, last_used=utcnow().isoformat()
)
self._write(records)
def record(self, node: Contact, password: str, outcome: LoginResult) -> None:
@@ -133,10 +162,11 @@ class AdminStore:
Args:
node: The contact to forget.
"""
records = self._load_all()
records = self._load()
if records.pop(admin_key(node), None) is not None:
self._write(records)
def _write(self, records: dict[str, dict]) -> None:
def _write(self, records: dict[str, AdminCredential]) -> None:
"""Persist ``records`` atomically with owner-only permissions where supported."""
write_atomically(self._path, json.dumps(records, indent=2), owner_only=True)
data = {key: record.to_json() for key, record in records.items()}
write_atomically(self._path, json.dumps(data, indent=2), owner_only=True)
+177 -130
View File
@@ -1,105 +1,133 @@
# SPDX-License-Identifier: Apache-2.0
"""Persistence for the background-advert schedule.
"""Persistence for the weekly flood advert: when each device's week began.
MeshTerm announces the connected node on a cadence — a zero-hop (direct) advert for the
immediate neighbourhood and a flood advert for the wider mesh — so the node stays fresh in
other nodes' contact lists without anyone thinking about it. This store remembers, per
device (keyed by its public key), the chosen cadence for each advert type and when one was
last sent, so the countdown survives restarts and *every* advert counts: a manual send from
the advert menu resets the same clock the scheduler reads (see
:class:`~meshterm.services.advert_scheduler.AdvertScheduler`).
A MeshCore companion never advertises by itself — no timer, not even at boot; only an app
asking (``CMD_SEND_SELF_ADVERT``) or a press on the device's own menu puts one on the air.
So a node that nobody remembers to advertise slowly drops out of everyone else's contact
list. The ``weekly_flood_advert`` preference is MeshTerm's answer, and deliberately a
frugal one: at most one flood advert a week from any one device, and only once that device
has gone a full week without one (see
:class:`~meshterm.services.advert_scheduler.AdvertScheduler`, which sends it).
This store remembers what that rule needs, and nothing else:
* **when the preference was turned on** — turning it on starts the week rather than
sending, so a week has to pass before the first automatic advert from any device;
* **per device** (keyed by its public key), **when its week began** — the last flood
advert that went out from it, whoever asked for it, or failing that the first time
MeshTerm connected to it.
A device's advert is due one week after the *latest* of those instants. The clock is real
time rather than run time, so a week with the app closed counts, and a due advert that the
app did not live to send is still due the next time that device connects.
Like the remembered devices (:mod:`meshterm.core.device_store`), this is global machine
state in a small JSON file (``<config_dir>/adverts.json``) rather than the per-invocation
SQLite database.
SQLite database, read into typed records and written back out of them (see
:class:`AdvertStore`). A file in an older shape (the per-device cadences this replaced)
reads as empty: every device simply starts its week again.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import ClassVar
from .atomicwrite import write_atomically
from .models import utcnow
#: The direct (zero-hop) cadence choices offered in the editor, in hours.
DIRECT_CADENCE_HOURS = (1, 2, 3, 4)
#: How long a device must go without a flood advert before the weekly one is due.
WEEK = timedelta(days=7)
#: The flood cadence choices offered in the editor, in hours (3 h .. 1 week).
FLOOD_CADENCE_HOURS = (3, 6, 12, 24, 48, 168)
#: The quiet spells the ``advert_quiet_s`` preference offers, in seconds: how long the air
#: must go without a packet, heard or sent, before the due advert goes out.
QUIET_CHOICES_S = (5, 15, 30, 60)
#: Defaults applied when a device has no stored policy: announce to neighbours hourly,
#: flood the wider mesh daily.
DEFAULT_DIRECT_HOURS = 1
DEFAULT_FLOOD_HOURS = 24
#: The stored cadence meaning "this advert type is off" (0 hours).
OFF = 0
#: The quiet spell waited for by default, in seconds.
DEFAULT_QUIET_S = 30
@dataclass(slots=True)
class AdvertPolicy:
"""One device's background-advert schedule and its last-sent marks.
class _Device:
"""One device's record: when its week began.
Attributes:
direct_hours: Hours between zero-hop adverts (:data:`OFF` disables them).
flood_hours: Hours between flood adverts (:data:`OFF` disables them).
last_direct: When a zero-hop advert (scheduled *or* manual) last went out.
last_flood: When a flood advert last went out.
since: The last flood advert from it, or its first connection, whichever is
later — the instant its week counts from.
"""
direct_hours: int = DEFAULT_DIRECT_HOURS
flood_hours: int = DEFAULT_FLOOD_HOURS
last_direct: datetime | None = None
last_flood: datetime | None = None
since: datetime | None = None
def cadence(self, flood: bool) -> int:
"""The cadence in hours for one advert type (:data:`OFF` when disabled)."""
return self.flood_hours if flood else self.direct_hours
#: Fields this record once held and must never hold again under another meaning (see
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
RETIRED: ClassVar[frozenset[str]] = frozenset(
{
# The per-device cadences the weekly flood advert replaced.
"direct_hours",
"flood_hours",
"last_direct",
"last_flood",
}
)
def last_sent(self, flood: bool) -> datetime | None:
"""When an advert of one type last went out, or ``None`` if never recorded."""
return self.last_flood if flood else self.last_direct
@classmethod
def read(cls, raw: object) -> _Device:
"""Build a record from the file's JSON, taking only the fields it knows."""
record = raw if isinstance(raw, dict) else {}
return cls(since=_as_time(record.get("since")))
def due(self, flood: bool, now: datetime | None = None) -> bool:
"""Whether an advert of one type is due at ``now``.
def to_json(self) -> dict:
"""The record as written: its own fields and nothing it happened to be read with."""
return {"since": _stamp(self.since)}
Never-sent is *not* due: the scheduler arms the clock by recording "now" on its
first look (see :meth:`AdvertStore.arm`), so a fresh install waits a full cadence
instead of transmitting the moment it starts — adverts are routine, not urgent,
and the quiet default is the polite one on a shared mesh.
Args:
flood: ``True`` for the flood clock, ``False`` for the zero-hop one.
now: The current time (defaults to :func:`~meshterm.core.models.utcnow`).
@dataclass(slots=True)
class _Document:
"""The whole file: the switch-on instant, and every device's record by public key.
Returns:
``True`` when the type is enabled, armed, and its cadence has elapsed.
Attributes:
enabled_since: When the weekly advert was last switched on; ``None`` while off.
devices: Each device's record, keyed by its normalized public key.
"""
enabled_since: datetime | None = None
devices: dict[str, _Device] = field(default_factory=dict)
#: Top-level fields this document once held; none yet (see :attr:`_Device.RETIRED`).
RETIRED: ClassVar[frozenset[str]] = frozenset()
@classmethod
def read(cls, raw: object) -> _Document:
"""Build the document from the file's JSON, taking only the fields it knows.
A file without a ``devices`` map is from before this shape — the per-device
cadences — and reads as empty rather than being mined for look-alike fields.
"""
hours = self.cadence(flood)
last = self.last_sent(flood)
if hours == OFF or last is None:
return False
return ((now or utcnow()) - last).total_seconds() >= hours * 3600
if not isinstance(raw, dict) or not isinstance(raw.get("devices"), dict):
return cls()
return cls(
enabled_since=_as_time(raw.get("enabled_since")),
devices={str(k): _Device.read(v) for k, v in raw["devices"].items()},
)
def cadence_label(hours: int) -> str:
"""Render a cadence for display: ``"off"``, ``"every hour"``, ``"every 2 h"``, ``"weekly"``."""
if hours == OFF:
return "off"
if hours == 1:
return "every hour"
if hours == 24:
return "daily"
if hours == 168:
return "weekly"
return f"every {hours} h"
def to_json(self) -> dict:
"""The document as written: known fields only, so a stale one dies at the next save."""
return {
"enabled_since": _stamp(self.enabled_since),
"devices": {key: device.to_json() for key, device in self.devices.items()},
}
class AdvertStore:
"""Reads and writes per-device background-advert schedules."""
"""Reads and writes the weekly-advert clock: the switch-on instant, and each device's.
The file is read into typed records and written back out of them — never round-tripped
as the raw JSON it was read as — so a field no record knows (a retired one, one a hand
edit invented) is gone after the next write.
"""
def __init__(self, path: Path) -> None:
"""Open the store against a JSON file location.
@@ -114,94 +142,113 @@ class AdvertStore:
"""Normalize a device public key into the storage key."""
return public_key.lower().removeprefix("0x")
def _load_all(self) -> dict[str, dict]:
"""Return the raw key -> record mapping, or empty on missing/corrupt file."""
def _load(self) -> _Document:
"""Read the file's document; an empty one when it is missing, corrupt or outdated."""
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
raw = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
return _Document()
return _Document.read(raw)
def load(self, public_key: str) -> AdvertPolicy:
"""Return the stored policy for a device, or the on-by-default policy.
def _write(self, document: _Document) -> None:
"""Persist ``document`` atomically (a crash mid-write keeps the previous file)."""
write_atomically(self._path, json.dumps(document.to_json(), indent=2))
# -- the switch ---------------------------------------------------------------------
def enabled_since(self) -> datetime | None:
"""When the weekly advert was last turned on, or ``None`` while it is off."""
return self._load().enabled_since
def set_enabled(self, on: bool, when: datetime | None = None) -> None:
"""Record the preference being turned on (starting the week) or off.
Turning it on always restarts the clock, even when it was already recorded as on:
the call means "it was switched on just now", and switching on never sends.
Args:
public_key: The device's public key (hex).
Returns:
The device's :class:`AdvertPolicy`; defaults apply for anything unset.
on: Whether the weekly advert is now on.
when: The switch-on instant (defaults to now); ignored when turning off.
"""
record = self._load_all().get(self._key(public_key))
if not isinstance(record, dict):
return AdvertPolicy()
return AdvertPolicy(
direct_hours=_as_hours(record.get("direct_hours"), DEFAULT_DIRECT_HOURS),
flood_hours=_as_hours(record.get("flood_hours"), DEFAULT_FLOOD_HOURS),
last_direct=_as_time(record.get("last_direct")),
last_flood=_as_time(record.get("last_flood")),
)
document = self._load()
if on:
document.enabled_since = when or utcnow()
elif document.enabled_since is not None:
document.enabled_since = None
else:
return # already off; nothing to write
self._write(document)
def set_cadence(self, public_key: str, *, flood: bool, hours: int) -> None:
"""Store one advert type's cadence for a device.
def sync_enabled(self, on: bool) -> None:
"""Bring the recorded switch into line with the preference, where they disagree.
Args:
public_key: The device's public key (hex).
flood: ``True`` to set the flood cadence, ``False`` for the zero-hop one.
hours: The new cadence in hours (:data:`OFF` disables the type).
The Preferences page and ``preferences set`` record the switch as it happens (see
:meth:`set_enabled`); this catches the one path neither sees, a hand edit of
``preferences.toml``, so a preference found on with no switch-on instant starts its
week now instead of never.
"""
self._update(public_key, {"flood_hours" if flood else "direct_hours": int(hours)})
recorded = self.enabled_since() is not None
if on != recorded:
self.set_enabled(on)
def mark_sent(self, public_key: str, *, flood: bool, when: datetime | None = None) -> None:
"""Record that an advert went out, resetting that type's countdown.
# -- the devices ----------------------------------------------------------------------
Called for scheduled *and* manual sends alike, so the next background advert
counts from the most recent announcement of that type, whoever triggered it.
def week_began(self, public_key: str) -> datetime | None:
"""When a device's own week began, or ``None`` if MeshTerm has never marked it."""
device = self._load().devices.get(self._key(public_key))
return device.since if device is not None else None
def arm(self, public_key: str, when: datetime | None = None) -> None:
"""Start a device's week on its first connection, without sending anything.
Only writes for a device with no mark yet, so it is safe to call on every connect.
Args:
public_key: The device's public key (hex).
when: The week's start (defaults to now).
"""
if self.week_began(public_key) is None:
self.mark_flood(public_key, when=when)
def mark_flood(self, public_key: str, when: datetime | None = None) -> None:
"""Record a flood advert from a device, restarting its week.
Called for the weekly advert and for a flood advert sent by hand alike, so the
next automatic one is never less than a week after the last of either.
Args:
public_key: The device's public key (hex).
flood: Which advert type went out.
when: The send time (defaults to now).
"""
stamp = (when or utcnow()).isoformat()
self._update(public_key, {"last_flood" if flood else "last_direct": stamp})
document = self._load()
document.devices[self._key(public_key)] = _Device(since=when or utcnow())
self._write(document)
def arm(self, public_key: str, *, flood: bool, when: datetime | None = None) -> None:
"""Start a never-sent advert type's countdown from ``when`` without sending.
# -- the verdict ----------------------------------------------------------------------
Only writes when no last-sent mark exists, so it is safe to call on every
scheduler pass.
def due_at(self, public_key: str) -> datetime | None:
"""When a device's weekly advert falls due, or ``None`` while it cannot.
``None`` while the weekly advert is off, and for a device not yet armed.
Args:
public_key: The device's public key (hex).
flood: Which advert type to arm.
when: The countdown start (defaults to now).
"""
if self.load(public_key).last_sent(flood) is None:
self.mark_sent(public_key, flood=flood, when=when)
document = self._load()
device = document.devices.get(self._key(public_key))
if document.enabled_since is None or device is None or device.since is None:
return None
return max(document.enabled_since, device.since) + WEEK
def _update(self, public_key: str, fields: dict) -> None:
"""Merge ``fields`` into a device's record and persist the store."""
records = self._load_all()
key = self._key(public_key)
record = records.get(key)
if not isinstance(record, dict):
record = {}
record.update(fields)
records[key] = record
self._write(records)
def _write(self, records: dict[str, dict]) -> None:
"""Persist ``records`` atomically (crash mid-write keeps the previous file)."""
write_atomically(self._path, json.dumps(records, indent=2))
def due(self, public_key: str, now: datetime | None = None) -> bool:
"""Whether a device's weekly advert is due at ``now`` (defaults to now)."""
at = self.due_at(public_key)
return at is not None and (now or utcnow()) >= at
def _as_hours(value: object, default: int) -> int:
"""Coerce a stored cadence to a non-negative int, falling back to ``default``."""
try:
hours = int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
return hours if hours >= 0 else default
def _stamp(when: datetime | None) -> str | None:
"""A timestamp as the file stores it: ISO-8601, or ``null`` for absent."""
return when.isoformat() if when is not None else None
def _as_time(value: object) -> datetime | None:
+504
View File
@@ -0,0 +1,504 @@
# SPDX-License-Identifier: Apache-2.0
"""Pair a Bluetooth companion with its PIN through BlueZ, the Linux Bluetooth stack.
A MeshCore companion puts its UART characteristic behind an *authenticated* bond (ENC+MITM),
which on a device with a fixed PIN means Passkey Entry: the computer has to type the six
digits in. On Linux, bleak's ``pair()`` cannot do that. It calls BlueZ's ``Device1.Pair`` and
nothing else, and BlueZ gets a passkey only by asking a **pairing agent** — an object some
program registers on the system bus to answer ``RequestPasskey``. On a desktop that agent is
the Settings panel, in a terminal it is ``bluetoothctl``, over SSH it is usually nobody. With
no agent to ask, BlueZ falls back to "Just Works", the firmware refuses a bond without MITM,
and the PIN MeshTerm was handed never reached the radio at all. That is the Linux twin of the
Windows gap :meth:`~meshterm.core.connection.MeshCoreDevice._pair_ble_windows` closes.
So MeshTerm registers its own agent for the length of one pairing, answering with the PIN it
was given, and calls ``Pair`` from the *same* bus connection. BlueZ routes a pairing's agent
requests to the agent of whoever called ``Pair`` (the system default agent is only the
fallback), so this never takes over the desktop's agent and never answers for any other
device's pairing.
The agent answers raw D-Bus messages (:class:`PinAgent`) rather than going through
``dbus_fast``'s annotated ``ServiceInterface``: that one reads D-Bus signatures out of
annotations like ``device: "o"``, which a linter takes for an undefined name and postponed
annotations turn into a quoted string. A message handler has neither problem and can be
exercised without a bus.
``dbus_fast`` is bleak's own dependency on Linux, so nothing new is installed; it is imported
lazily, and every entry point here is best-effort — a missing system bus or BlueZ reports an
``"error"`` outcome instead of raising.
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
_log = logging.getLogger(__name__)
_BLUEZ = "org.bluez"
_AGENT_IFACE = "org.bluez.Agent1"
_AGENT_MANAGER_IFACE = "org.bluez.AgentManager1"
_ADAPTER_IFACE = "org.bluez.Adapter1"
_DEVICE_IFACE = "org.bluez.Device1"
_PROPERTIES_IFACE = "org.freedesktop.DBus.Properties"
_OBJECT_MANAGER_IFACE = "org.freedesktop.DBus.ObjectManager"
_REJECTED = "org.bluez.Error.Rejected"
_ALREADY_EXISTS = "org.bluez.Error.AlreadyExists"
_AGENTS = "/org/bluez" # where AgentManager1 lives
#: The agent's declared input/output capability. The companion is the side with the PIN
#: (``DisplayOnly``), so we are the keyboard: ``KeyboardOnly`` makes the pairing Passkey
#: Entry with us typing, which is the one method a fixed PIN can satisfy.
AGENT_CAPABILITY = "KeyboardOnly"
#: How long to run discovery for a device BlueZ has forgotten (seconds). BlueZ drops an
#: unpaired device it has stopped hearing after about half a minute, so one the picker
#: listed a while ago may need hearing again before it can be paired.
DISCOVER_S = 6.0
#: Bound on the ``Pair`` call itself (seconds). A healthy passkey pairing takes one to three
#: seconds; this only has to outlast a slow radio, and stays well inside the probe's window.
PAIR_TIMEOUT_S = 15.0
#: Attempts at ``Pair`` when the link under it fails to come up, and the pause between them.
#: A Raspberry Pi class radio drops three to seven link attempts per connect
#: (``le-connection-abort-by-local``, HCI 0x3e). bleak retries those inside its own
#: ``connect()``; BlueZ's ``Pair`` does not, and answers ConnectionAttemptFailed at once.
#: Measured on a uConsole: the first Pair failed that way, the connect then went ahead
#: unpaired, and the protected subscribe cost 32 s before the repair paired it after all.
PAIR_ATTEMPTS = 4
PAIR_RETRY_DELAY_S = 0.5
_LINK_FLAKES = ("org.bluez.Error.ConnectionAttemptFailed",)
def _link_flake(reply: Any) -> bool:
"""Whether a ``Pair`` error is the link failing to come up — worth another try."""
detail = reply.body[0] if reply.body and isinstance(reply.body[0], str) else ""
return reply.error_name in _LINK_FLAKES or "le-connection-abort" in detail
_MAC = re.compile(r"^[0-9A-Fa-f]{2}([:-][0-9A-Fa-f]{2}){5}$")
def is_mac(address: str) -> bool:
"""Whether ``address`` is a colon- or dash-separated 48-bit Bluetooth address."""
return bool(_MAC.match(address or ""))
def _normal(address: str) -> str:
"""An address as BlueZ spells it: upper case, colon-separated."""
return address.replace("-", ":").upper()
def status_name(error_name: str, body: list[Any] | None = None) -> str:
"""Turn a BlueZ error into the lowercase words a message can quote.
``org.bluez.Error.AuthenticationFailed`` → ``"authentication failed"``. The bare
``org.bluez.Error.Failed`` says nothing on its own, so its text rides along
(``"failed: le-connection-abort-by-local"``).
Args:
error_name: The D-Bus error name from the reply.
body: The error reply's body, whose first item is BlueZ's detail string.
Returns:
The status, as :data:`~meshterm.core.connection._PAIRING_REFUSED` and its sibling
sets spell them.
"""
leaf = error_name.rsplit(".", 1)[-1]
words = re.sub(r"(?<!^)(?=[A-Z])", " ", leaf).lower()
detail = body[0] if body and isinstance(body[0], str) else ""
if words == "failed" and detail:
return f"failed: {detail}"
return words
class PinAgent:
"""A BlueZ ``Agent1`` that answers one device's pairing with one PIN — or refuses it.
Installed as a message handler on the bus that calls ``Pair`` (see the module docstring
for why that bus), or made BlueZ's default agent for the length of a connect (see
:func:`answering`). It answers only calls addressed to its own object path, and only for
the device it was built for; anything else is ``Rejected``, so a pairing we did not start
can never be approved by us. Without a PIN it refuses every request at once, which is
the point of it there: the alternative is BlueZ waiting on an agent that doesn't exist.
It never approves a pairing *without* the passkey (``RequestAuthorization``, the "Just
Works" yes/no). That is how a companion ends up with an unauthenticated bond — encryption
works, the UART characteristic still refuses — which is the stale-bond failure itself.
Attributes:
path: The object path the agent is registered at.
asked: Whether BlueZ actually asked for the PIN — a pairing that failed without
asking was refused before the passkey stage, which is a different story.
"""
def __init__(self, path: str, pin: str | None, device_path: str) -> None:
"""Build the agent.
Args:
path: The object path to answer at.
pin: The pairing PIN (digits), or ``None`` to refuse every request.
device_path: The one device it may answer for: its BlueZ object path, or the
path's ``/dev_…`` tail when the adapter isn't known yet (see
:func:`device_tail`).
"""
self.path = path
self._pin = pin
self._device = device_path
self.asked = False
def handle(self, msg: Any) -> Any:
"""Answer one incoming message, or return ``None`` to leave it to other handlers.
Args:
msg: A ``dbus_fast.Message``.
Returns:
A reply ``Message``, or ``None`` when the message is not ours.
"""
from dbus_fast import Message, MessageType
if (
msg.message_type != MessageType.METHOD_CALL
or msg.path != self.path
or msg.interface != _AGENT_IFACE
):
return None
member = msg.member
if member in ("Release", "Cancel"):
return Message.new_method_return(msg)
device = msg.body[0] if msg.body else ""
if not str(device).endswith(self._device):
return Message.new_error(msg, _REJECTED, "not the device MeshTerm is pairing")
if member in ("RequestPasskey", "RequestPinCode", "RequestConfirmation") and not self._pin:
self.asked = True
return Message.new_error(msg, _REJECTED, "no PIN was given for this device")
if member == "RequestPasskey":
self.asked = True
return Message.new_method_return(msg, "u", [int(self._pin)])
if member == "RequestPinCode":
self.asked = True
return Message.new_method_return(msg, "s", [self._pin])
if member == "RequestConfirmation":
# Numeric Comparison: approve only the number our PIN names. A companion with a
# fixed PIN never asks for this; one that does and shows another number is not
# the pairing we were told to make.
self.asked = True
if int(msg.body[1]) == int(self._pin):
return Message.new_method_return(msg)
return Message.new_error(msg, _REJECTED, "passkey does not match the PIN")
if member in ("DisplayPasskey", "DisplayPinCode"):
return Message.new_method_return(msg)
return Message.new_error(msg, _REJECTED, f"{member} is not something MeshTerm answers")
def device_tail(address: str) -> str:
"""The ``/dev_AA_BB_…`` tail of the object path BlueZ gives the device at ``address``."""
return "/dev_" + _normal(address).replace(":", "_")
@asynccontextmanager
async def answering(address: str, pin: str | None) -> AsyncIterator[None]:
"""Be BlueZ's default pairing agent for one device while a connect runs.
BlueZ does not wait to be asked to pair. When the companion refuses the UART subscribe
with *Insufficient Authentication*, BlueZ raises the link's security by itself and starts
SMP pairing, which needs an agent — and the one it asks is the **default** agent, not
ours (that routing is only for a ``Pair`` we call). With none registered, as on any
headless machine, it offers the companion ``DisplayYesNo``, lands on "Just Works", asks a
yes/no nobody is there to answer, and the companion hangs up on its 30 s SMP timeout.
Measured on a uConsole with btmon: 3.3 s to the question, 33.5 s to the hang-up, and only
then "requires a PIN". On a desktop the question goes to the desktop's dialog instead,
and answering it makes the unauthenticated bond that later refuses the subscribe.
So for the length of the connect MeshTerm is the default agent, for this device only,
declared ``KeyboardOnly`` so the pairing BlueZ starts is Passkey Entry: with a PIN it types
it in and the connect pairs on the spot; without one it refuses at once and the refusal
arrives in seconds. Another device's pairing in that window is refused, not left hanging.
BlueZ keeps default agents as a stack, so unregistering hands the role back to whoever
held it. Best-effort: with no system bus it simply does nothing.
Args:
address: The companion's Bluetooth address.
pin: Its PIN, or ``None`` to refuse.
Yields:
Nothing; the agent is live for the body of the ``async with``.
"""
bus = None
agent = PinAgent(f"/net/meshterm/connect{os.getpid()}", pin, device_tail(address))
registered = False
try:
bus = await _system_bus()
bus.add_message_handler(agent.handle)
reply = await _call(
bus, _AGENTS, _AGENT_MANAGER_IFACE, "RegisterAgent", "os",
[agent.path, AGENT_CAPABILITY],
) # fmt: skip
registered = not _is_error(reply)
if registered:
await _call(
bus, _AGENTS, _AGENT_MANAGER_IFACE, "RequestDefaultAgent", "o", [agent.path]
)
except Exception as exc: # noqa: BLE001 - no bus, no BlueZ: connect without an agent
_log.debug("couldn't stand in as the BlueZ agent: %s", exc)
try:
yield
finally:
if bus is not None:
try:
if registered:
await _call(
bus, _AGENTS, _AGENT_MANAGER_IFACE, "UnregisterAgent", "o", [agent.path]
)
except Exception as exc: # noqa: BLE001 - the bus closing drops it anyway
_log.debug("unregistering the BlueZ agent failed: %s", exc)
bus.remove_message_handler(agent.handle)
bus.disconnect()
async def _call(bus: Any, path: str, interface: str, member: str, signature: str = "", body=None):
"""Call a BlueZ method and return the raw reply (errors are left for the caller)."""
from dbus_fast import Message
return await bus.call(
Message(
destination=_BLUEZ,
path=path,
interface=interface,
member=member,
signature=signature,
body=body or [],
)
)
def _is_error(reply: Any) -> bool:
from dbus_fast import MessageType
return reply.message_type == MessageType.ERROR
async def _objects(bus: Any) -> dict[str, dict[str, dict[str, Any]]]:
"""Every BlueZ object, from ``GetManagedObjects`` (empty when BlueZ didn't answer)."""
reply = await _call(bus, "/", _OBJECT_MANAGER_IFACE, "GetManagedObjects")
if _is_error(reply) or not reply.body:
return {}
return reply.body[0]
def _value(props: dict[str, Any], name: str) -> Any:
"""A property's plain value, unwrapping the ``Variant`` BlueZ sends."""
raw = props.get(name)
return getattr(raw, "value", raw)
def _device_in(objects: dict[str, dict[str, dict[str, Any]]], address: str) -> str | None:
"""The object path of the device at ``address``, if BlueZ knows it."""
want = _normal(address)
for path, interfaces in objects.items():
props = interfaces.get(_DEVICE_IFACE)
if props is not None and str(_value(props, "Address") or "").upper() == want:
return path
return None
def _adapter_in(objects: dict[str, dict[str, dict[str, Any]]]) -> str | None:
"""The first Bluetooth adapter's object path."""
return next((path for path, ifaces in objects.items() if _ADAPTER_IFACE in ifaces), None)
async def _find_device(bus: Any, address: str, discover_s: float) -> tuple[str | None, bool]:
"""Find the device's object path, listening for it briefly if BlueZ has forgotten it.
Returns:
``(path, paired)`` — ``path`` is ``None`` when it could not be heard.
"""
objects = await _objects(bus)
path = _device_in(objects, address)
if path is None:
adapter = _adapter_in(objects)
if adapter is None:
return None, False
# A refusal here is fine: bleak or the desktop may already be discovering, which
# is all we need.
await _call(bus, adapter, _ADAPTER_IFACE, "StartDiscovery")
try:
deadline = asyncio.get_running_loop().time() + discover_s
while path is None and asyncio.get_running_loop().time() < deadline:
await asyncio.sleep(0.5)
objects = await _objects(bus)
path = _device_in(objects, address)
finally:
await _call(bus, adapter, _ADAPTER_IFACE, "StopDiscovery")
if path is None:
return None, False
return path, bool(_value(objects[path][_DEVICE_IFACE], "Paired"))
def _adapter_of(objects: dict[str, dict[str, dict[str, Any]]], path: str) -> str | None:
"""The adapter a device hangs off — its own ``Adapter`` property, else the first one."""
own = _value(objects.get(path, {}).get(_DEVICE_IFACE, {}), "Adapter")
return str(own) if own else _adapter_in(objects)
async def _remove(bus: Any, path: str) -> Any:
"""Forget a device's bond (``Adapter1.RemoveDevice``), which also forgets the device.
Returns:
The raw reply, or ``None`` when there was no adapter to ask.
"""
adapter = _adapter_of(await _objects(bus), path)
if adapter is None:
return None
return await _call(bus, adapter, _ADAPTER_IFACE, "RemoveDevice", "o", [path])
async def _system_bus() -> Any:
from dbus_fast import BusType
from dbus_fast.aio import MessageBus
return await MessageBus(bus_type=BusType.SYSTEM).connect()
async def pair(
address: str,
pin: str,
*,
force: bool,
discover_s: float = DISCOVER_S,
pair_timeout_s: float = PAIR_TIMEOUT_S,
) -> tuple[str, str]:
"""Pair the companion at ``address`` using ``pin``, through our own BlueZ agent.
Mirrors the Windows ceremony's contract: an existing bond is trusted unless ``force``,
which tears it down and pairs afresh — the heal for a bond the device has since lost.
Args:
address: The companion's Bluetooth address.
pin: Its pairing PIN (six digits).
force: Whether to remove an existing bond first.
discover_s: How long to listen for a device BlueZ has forgotten.
pair_timeout_s: Bound on the ``Pair`` call.
Returns:
``(outcome, status)`` in :class:`~meshterm.core.connection._BlePairing`'s vocabulary:
``"reused"``, ``"paired"``, ``"absent"``, ``"failed"`` with the BlueZ status, or
``"error"`` with what went wrong.
"""
if not pin.isdigit():
return "failed", "authentication failed" # a passkey is a number; this can't be it
try:
bus = await _system_bus()
except Exception as exc: # noqa: BLE001 - no system bus / no dbus_fast: nothing to pair with
return "error", f"couldn't reach BlueZ: {exc}"
try:
path, paired = await _find_device(bus, address, discover_s)
if path is None:
return "absent", ""
if paired:
if not force:
return "reused", ""
await _remove(bus, path)
path, _ = await _find_device(bus, address, discover_s)
if path is None:
return "absent", ""
agent = PinAgent(f"/net/meshterm/agent{os.getpid()}", pin, path)
paired_link: str | None = None # set once Pair has been asked to open a link
bus.add_message_handler(agent.handle)
try:
reply = await _call(
bus, _AGENTS, _AGENT_MANAGER_IFACE, "RegisterAgent", "os",
[agent.path, AGENT_CAPABILITY],
) # fmt: skip
if _is_error(reply):
return "error", status_name(reply.error_name, reply.body)
paired_link = path
for attempt in range(PAIR_ATTEMPTS):
if attempt:
await asyncio.sleep(PAIR_RETRY_DELAY_S)
try:
reply = await asyncio.wait_for(
_call(bus, path, _DEVICE_IFACE, "Pair"), pair_timeout_s
)
except asyncio.TimeoutError:
await _call(bus, path, _DEVICE_IFACE, "CancelPairing")
return "failed", "authentication timeout"
if not (_is_error(reply) and _link_flake(reply)):
break
_log.debug("BlueZ Pair lost its link (attempt %d/%d)", attempt + 1, PAIR_ATTEMPTS)
if _is_error(reply) and reply.error_name != _ALREADY_EXISTS:
status = status_name(reply.error_name, reply.body)
if not agent.asked and status == "authentication failed":
# Refused before the passkey stage: the PIN was never in question.
status = "authentication rejected"
return "failed", status
# Trusted lets later connections through without an agent to authorize them.
from dbus_fast import Variant
await _call(
bus, path, _PROPERTIES_IFACE, "Set", "ssv",
[_DEVICE_IFACE, "Trusted", Variant("b", True)],
) # fmt: skip
return "paired", ""
finally:
if paired_link is not None:
# ``Pair`` opens a link to pair over and BlueZ leaves it up. A companion that
# is connected stops advertising, and the connect that follows looks the
# device up by scanning for it, so it never finds it — measured on a
# uConsole: paired, then "couldn't open a link" after two 30 s tries. Put
# the link down so the connect meets an advertising device, as it would
# have without us.
await _call(bus, paired_link, _DEVICE_IFACE, "Disconnect")
await _call(bus, _AGENTS, _AGENT_MANAGER_IFACE, "UnregisterAgent", "o", [agent.path])
bus.remove_message_handler(agent.handle)
except Exception as exc: # noqa: BLE001 - best-effort: the connect reports the refusal
return "error", str(exc)
finally:
bus.disconnect()
async def is_paired(address: str) -> bool:
"""Whether BlueZ holds a bond for ``address``. ``False`` on any failure to ask."""
try:
bus = await _system_bus()
except Exception as exc: # noqa: BLE001 - no bus: nothing is known to be paired
_log.debug("BlueZ pairing query unavailable: %s", exc)
return False
try:
objects = await _objects(bus)
path = _device_in(objects, address)
return path is not None and bool(_value(objects[path][_DEVICE_IFACE], "Paired"))
except Exception as exc: # noqa: BLE001 - a status hiccup is not a bond
_log.debug("BlueZ pairing query failed for %s: %s", address, exc)
return False
finally:
bus.disconnect()
async def unpair(address: str) -> bool:
"""Forget BlueZ's bond for ``address``. ``True`` only if a bond was removed."""
try:
bus = await _system_bus()
except Exception as exc: # noqa: BLE001 - no bus: nothing to remove
_log.debug("BlueZ unpair unavailable: %s", exc)
return False
try:
objects = await _objects(bus)
path = _device_in(objects, address)
if path is None or not _value(objects[path][_DEVICE_IFACE], "Paired"):
return False
reply = await _remove(bus, path)
return reply is not None and not _is_error(reply)
except Exception as exc: # noqa: BLE001 - best-effort, like the Windows twin
_log.debug("BlueZ unpair failed for %s: %s", address, exc)
return False
finally:
bus.disconnect()
+5 -1
View File
@@ -29,7 +29,7 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, ClassVar
from .atomicwrite import write_atomically
from .channels import (
@@ -52,6 +52,10 @@ class RememberedChannel:
name: str
secret: bytes
#: Fields this record once held and must never hold again under another meaning (see
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
RETIRED: ClassVar[frozenset[str]] = frozenset()
@property
def identity(self) -> str:
"""The channel's slot-independent intrinsic identity (see :func:`channel_identity`)."""
+133 -3
View File
@@ -20,7 +20,7 @@ import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from typing import Any, ClassVar
if sys.version_info >= (3, 11):
import tomllib
@@ -34,6 +34,17 @@ else: # pragma: no cover - exercised only on 3.10
#: can move it between cases.
CONFIG_DIR_ENV = "MESHTERM_HOME"
#: The history's filename inside the config directory, when ``config.toml`` and ``--db``
#: both leave it alone.
DB_FILENAME = "meshterm.db"
#: The simulator's history, kept beside the real one and used by ``--mock`` runs that did
#: not name a database themselves. ``--mock`` is a fake radio, not a fake MeshTerm: what
#: the simulator adverts is recorded like anything a real companion said, so without a
#: database of its own its four invented contacts turn up in the mesh walk, the dashboard
#: and the map of the mesh you actually run.
MOCK_DB_FILENAME = "meshterm-mock.db"
def default_config_dir() -> Path:
"""Return the directory MeshTerm uses for config and data.
@@ -56,6 +67,104 @@ def default_config_dir() -> Path:
return Path.home() / ".meshterm"
@dataclass(frozen=True, slots=True)
class SpiWiring:
"""How a LoRa chip on the host's SPI bus is wired, for a ``transport = "spi"`` profile.
The defaults are the hackergadgets uConsole AIO v1's wiring, so that board needs no
``[profiles.<name>.spi]`` table at all; any other board states only what differs. These
are facts about the *board*, which is why they live in ``config.toml`` with the profile
and not among the radio's settings: the frequency, bandwidth and power are the node's
own, saved by the node and edited on Device config like any companion's.
Attributes:
bus_id: SPI bus (``/dev/spidev<bus_id>.<cs_id>``).
cs_id: SPI chip-select device on that bus.
cs_pin: A GPIO driven as chip select by hand, or ``-1`` for the bus's own.
gpio_chip: Which ``/dev/gpiochip<n>`` the pins below are on.
use_gpiod_backend: Drive the pins through ``gpiod`` instead of ``python-periphery``.
reset_pin: The chip's reset line.
busy_pin: The chip's busy line.
irq_pin: The chip's interrupt line (DIO1).
txen_pin: A transmit-enable line for an external RF switch, or ``-1``.
rxen_pin: A receive-enable line for an external RF switch, or ``-1``.
en_pins: Power-enable lines raised before the chip is touched (the AIO v2 wants
``[27]``); empty for none.
use_dio2_rf: Whether DIO2 drives the RF switch.
use_dio3_tcxo: Whether DIO3 powers a TCXO.
is_waveshare: The Waveshare HAT's wiring quirks, which the radio library knows.
python: An interpreter to run the node under, when the one MeshTerm would find
is not the one you want. Empty to let MeshTerm look.
"""
bus_id: int = 1
cs_id: int = 0
cs_pin: int = -1
gpio_chip: int = 0
use_gpiod_backend: bool = False
reset_pin: int = 25
busy_pin: int = 24
irq_pin: int = 26
txen_pin: int = -1
rxen_pin: int = -1
en_pins: tuple[int, ...] = ()
use_dio2_rf: bool = True
use_dio3_tcxo: bool = True
is_waveshare: bool = False
python: str = ""
#: Keys this table once took and must never take again under another meaning (see
#: :data:`meshterm.core.preferences.RETIRED`). ``preamble_length`` was never wiring: it is
#: protocol, and a fixed 12 against a mesh sending 32 is what left the node deaf to
#: most traffic; the node now derives it from the spreading factor as MeshCore does.
RETIRED: ClassVar[frozenset[str]] = frozenset({"preamble_length"})
@classmethod
def from_toml(cls, table: dict[str, Any]) -> SpiWiring:
"""Build the wiring from a ``[profiles.<name>.spi]`` table, defaults for the rest.
A key this dataclass doesn't know is refused rather than ignored: a misspelt
``irq_pn = 22`` would otherwise leave the default in force and a deaf radio with
nothing to say why.
Raises:
ValueError: On an unknown key or a value of the wrong type.
"""
known = {f.name: f for f in cls.__dataclass_fields__.values()}
retired = sorted(set(table) & cls.RETIRED)
if retired:
raise ValueError(
f"{', '.join(retired)} is no longer a wiring key — the node sets the "
"preamble from the spreading factor, as MeshCore does; delete the line"
)
unknown = sorted(set(table) - set(known))
if unknown:
raise ValueError(f"unknown SPI wiring key(s): {', '.join(unknown)}")
values: dict[str, Any] = {}
for key, raw in table.items():
default = getattr(cls(), key)
if isinstance(default, bool):
ok = isinstance(raw, bool)
elif isinstance(default, int):
ok = isinstance(raw, int) and not isinstance(raw, bool)
elif isinstance(default, tuple):
ok = isinstance(raw, list) and all(
isinstance(p, int) and not isinstance(p, bool) for p in raw
)
raw = tuple(raw) if ok else raw
else:
ok = isinstance(raw, str)
if not ok:
raise ValueError(f"SPI wiring {key} = {raw!r} is not a {type(default).__name__}")
values[key] = raw
return cls(**values)
@property
def spidev(self) -> str:
"""The SPI device node this wiring opens."""
return f"/dev/spidev{self.bus_id}.{self.cs_id}"
@dataclass(slots=True)
class DeviceProfile:
"""Connection defaults for one physical companion device.
@@ -77,6 +186,8 @@ class DeviceProfile:
host: Hostname or IP of a network companion (e.g. ``"192.168.1.50"``); TCP profiles.
tcp_port: TCP port the network companion listens on; TCP profiles (defaults to
:data:`~meshterm.core.discovery.DEFAULT_TCP_PORT` when a host is given without one).
spi: How the radio is wired, for ``transport = "spi"`` — a LoRa chip on the host's
own SPI bus, run by a node MeshTerm starts itself (:mod:`meshterm.core.spiradio`).
"""
name: str
@@ -89,6 +200,12 @@ class DeviceProfile:
ble_pin: str | None = None
host: str | None = None
tcp_port: int | None = None
spi: SpiWiring | None = None
@property
def is_spi(self) -> bool:
"""Whether this profile addresses a radio on the host's own SPI bus."""
return self.transport == "spi"
@property
def is_ble(self) -> bool:
@@ -138,7 +255,7 @@ class Settings:
def __post_init__(self) -> None:
"""Derive dependent paths that were not explicitly provided."""
if self.db_path is None:
self.db_path = self.config_dir / "meshterm.db"
self.db_path = self.config_dir / DB_FILENAME
def resolve_profile(self, name: str | None) -> DeviceProfile | None:
"""Look up a profile by name, falling back to the default profile.
@@ -180,9 +297,21 @@ class Settings:
# keeps port-only profiles working untouched while a bare ``host``/``address`` is
# enough to declare a network/Bluetooth one.
transport = pdata.get("transport") or (
"tcp" if pdata.get("host") else "ble" if pdata.get("address") else "serial"
"spi"
if "spi" in pdata
else "tcp"
if pdata.get("host")
else "ble"
if pdata.get("address")
else "serial"
)
tcp_port = pdata.get("tcp_port")
spi = None
if transport == "spi":
try:
spi = SpiWiring.from_toml(pdata.get("spi") or {})
except ValueError as exc:
raise ValueError(f"[profiles.{pname}.spi]: {exc}") from exc
profiles[pname] = DeviceProfile(
name=pname,
port=pdata.get("port"),
@@ -194,6 +323,7 @@ class Settings:
ble_pin=pdata.get("ble_pin"),
host=pdata.get("host"),
tcp_port=int(tcp_port) if tcp_port is not None else None,
spi=spi,
)
return cls(
File diff suppressed because it is too large. Load diff
+5
View File
@@ -56,6 +56,7 @@ from __future__ import annotations
import json
from dataclasses import dataclass, replace
from pathlib import Path
from typing import ClassVar
from .atomicwrite import write_atomically
from .models import Contact, advert_time
@@ -118,6 +119,10 @@ class RememberedContact:
archived_at: int | None = None
locked: bool = False
#: Fields this record once held and must never hold again under another meaning (see
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
RETIRED: ClassVar[frozenset[str]] = frozenset()
@property
def archived(self) -> bool:
"""Whether this contact has been swept off the device but kept here."""
+5
View File
@@ -19,6 +19,7 @@ import json
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import ClassVar
from .atomicwrite import write_atomically
from .models import utcnow
@@ -66,6 +67,10 @@ class QueuedMessage:
status: str = QUEUED
finished: datetime | None = None
#: Fields this record once held and must never hold again under another meaning (see
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
RETIRED: ClassVar[frozenset[str]] = frozenset()
class CourierStore:
"""Reads and writes the outbox, memory-first (loaded once, persisted on change)."""
+30 -1
View File
@@ -18,6 +18,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from . import regions
from .connection import Device, repeat_freq_allowed
# Display categories, in the order the editor and `config show` present them.
@@ -377,6 +378,21 @@ async def _client_repeat_apply(device: Device, value: Any, snapshot: dict) -> No
snapshot["repeat"] = bool(value)
def _flood_scope_valid(value: Any, snapshot: dict | None) -> str | None:
"""Refuse a default scope name the firmware would refuse (see :func:`regions.validate`).
``max_length`` counts characters; the firmware counts UTF-8 bytes, and a repeater could
never list back a name with a space or comma in it. Empty is valid — it clears.
"""
if not str(value or "").strip():
return None
try:
regions.validate(str(value))
except regions.RegionNameError as exc:
return str(exc)
return None
async def _autoadd_hops_apply(device: Device, value: Any, snapshot: dict) -> None:
"""Set the auto-add hop limit, restating the bitmask it travels behind."""
flags = snapshot.get("autoadd_config")
@@ -744,10 +760,11 @@ DEVICE_SETTINGS: list[SettingSpec] = [
SettingSpec(
"flood_scope",
"Flood scope",
"Keep traffic to one group; empty reaches everyone",
"Keep floods in one region; empty reaches everyone",
"Behavior",
"str",
max_length=30,
validate=_flood_scope_valid,
getter=_get("flood_scope"),
apply=lambda d, v, s: d.set_default_flood_scope(v),
),
@@ -795,6 +812,18 @@ DEVICE_SETTINGS: list[SettingSpec] = [
_BY_KEY: dict[str, SettingSpec] = {s.key: s for s in DEVICE_SETTINGS}
#: Every key that was once a device setting and no longer is — none yet. A retired key is
#: never reused, for the reason :data:`meshterm.core.preferences.RETIRED` gives, and with
#: more at stake here: the settings store remembers values to *restore onto the radio*, so a
#: key that came back meaning something else would offer to write the old value's number
#: into the new setting. ``tests/test_retired.py`` fails a registry that takes one back.
RETIRED: frozenset[str] = frozenset()
def is_setting_key(key: str) -> bool:
"""Whether ``key`` names a device setting in the registry (and not a retired one)."""
return key in _BY_KEY
def get_spec(key: str) -> SettingSpec:
"""Return the spec for ``key``.
+5
View File
@@ -28,6 +28,7 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import ClassVar
from .atomicwrite import write_atomically
from .discovery import (
@@ -75,6 +76,10 @@ class RememberedDevice:
host: str = ""
tcp_port: int = 0
#: Fields this record once held and must never hold again under another meaning (see
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
RETIRED: ClassVar[frozenset[str]] = frozenset()
@property
def is_ble(self) -> bool:
"""Whether this remembered device was reached over Bluetooth LE."""
+86 -4
View File
@@ -36,6 +36,9 @@ _log = logging.getLogger(__name__)
TRANSPORT_SERIAL = "serial"
TRANSPORT_BLE = "ble"
TRANSPORT_TCP = "tcp"
#: A LoRa chip on the host's own SPI bus, run by a node MeshTerm starts itself
#: (:mod:`meshterm.core.spiradio`). Found by its ``/dev/spidev*`` node, like a serial port.
TRANSPORT_SPI = "spi"
#: 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.
@@ -53,6 +56,32 @@ BLE_NAME_PREFIX = "MeshCore"
#: advertisement, short enough not to stall the startup splash noticeably.
BLE_SCAN_TIMEOUT_S = 5.0
try: # bleak >= 3 only; the floor in pyproject is 0.22, so this must not be assumed
from bleak.exc import BleakBluetoothNotAvailableError as _BleakNotAvailable
_BLE_NOT_AVAILABLE: tuple[type[BaseException], ...] = (_BleakNotAvailable,)
except Exception: # noqa: BLE001 - older bleak, or bleak absent entirely
# An empty tuple in ``except`` matches nothing, so older installs fall straight through
# to the broad handler and behave exactly as they did before.
_BLE_NOT_AVAILABLE = ()
#: Why the last BLE scan could not run, when the cause is something the reader can act on
#: (Bluetooth off; on macOS, permission not granted to the terminal running us). ``None``
#: when the scan worked — including when it simply heard nothing, which is not a fault.
_ble_unavailable: str | None = None
def ble_unavailable_reason() -> str | None:
"""Why the last BLE scan was refused, if it was, as a sentence fit to show the reader.
Returns:
The reason the most recent :func:`discover_ble_devices` could not scan, or ``None``
if it ran — whether or not it found anything. Reset at the start of every scan, so
it always describes the latest attempt rather than a stale one.
"""
return _ble_unavailable
#: Default seconds to watch for one *known* companion's advertisement while waiting for it to
#: come back (see :func:`find_ble_device`). Deliberately longer than the startup scan: nothing
#: is waiting on it, the watch ends the instant the device is heard, and a companion that just
@@ -101,7 +130,7 @@ class DiscoveredDevice:
serial_number: USB serial number, if exposed by the device.
manufacturer: USB manufacturer string, if available.
product: USB product string, if available.
transport: ``"serial"``, ``"ble"``, or ``"tcp"`` — the connection layer for this device.
transport: ``"serial"``, ``"ble"``, ``"tcp"``, or ``"spi"`` — the connection layer.
address: Bluetooth address for a BLE device (e.g. ``AA:BB:CC:DD:EE:FF``).
name: Advertised BLE local name (e.g. ``"MeshCore-Basestation"``); BLE only.
ble_device: The live ``bleak.BLEDevice`` the scan produced; BLE only, and only for
@@ -113,6 +142,10 @@ class DiscoveredDevice:
persisted.
host: Hostname or IP of a TCP companion (e.g. ``"192.168.1.50"``); TCP only.
tcp_port: TCP port the companion listens on (e.g. ``5000``); TCP only.
spi: The :class:`~meshterm.core.config.SpiWiring` of a radio on the host's SPI bus;
SPI only. Its ``port`` is the SPI device node (``/dev/spidev1.0``), which is what
such a radio is named and remembered by. Typed ``object`` to keep this module
free of the config import.
"""
port: str = ""
@@ -129,6 +162,12 @@ class DiscoveredDevice:
ble_device: object | None = None
host: str | None = None
tcp_port: int | None = None
spi: object | None = None
@property
def is_spi(self) -> bool:
"""Whether this device is a radio on the host's own SPI bus, run by MeshTerm's node."""
return self.transport == TRANSPORT_SPI
@property
def is_ble(self) -> bool:
@@ -168,6 +207,8 @@ class DiscoveredDevice:
return f"tcp:{self.host}:{self.tcp_port}"
if self.transport == TRANSPORT_MOCK:
return "mock:simulator"
if self.is_spi:
return f"spi:{self.port}"
if self.is_ble:
return f"ble:{(self.address or self.name or '').lower()}"
if self.serial_number:
@@ -186,8 +227,8 @@ class DiscoveredDevice:
``"bridge"`` for a generic USB-UART chip (a weak hint — could be anything), or
``"unknown"`` for an unrecognized adapter.
"""
if self.is_ble or self.is_tcp:
return "board" # a MeshCore BLE advert / a user-named TCP endpoint is confident
if self.is_ble or self.is_tcp or self.is_spi:
return "board" # a MeshCore advert / a named endpoint / a wired radio is confident
if self.vid in NATIVE_LORA_VIDS:
return "board"
if self.vid in UART_BRIDGE_VIDS:
@@ -210,7 +251,7 @@ class DiscoveredDevice:
its USB vendor ID to a friendly name, falling back to the driver's manufacturer
string.
"""
if self.is_ble or self.is_tcp:
if self.is_ble or self.is_tcp or self.is_spi:
return self.manufacturer or ""
if self.vid in KNOWN_LORA_VIDS:
return KNOWN_LORA_VIDS[self.vid]
@@ -229,7 +270,11 @@ class DiscoveredDevice:
if self.is_ble:
name = self.name or self.product or self.description or "Bluetooth device"
return f"{name} (BLE)"
if self.is_spi:
return f"{self.name or 'SPI radio'} ({self.port})"
name = self.product or self.description or self.vendor_label or "Serial device"
if not self.port: # the simulator has nothing to point at, so no "()"
return name
suffix = f"({self.port})"
if name.endswith(suffix): # avoid "… (COM11) (COM11)"
name = name[: -len(suffix)].rstrip()
@@ -393,6 +438,9 @@ async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[Disc
Returns:
Discovered BLE companions (deduplicated by address), sorted by name.
"""
global _ble_unavailable
_ble_unavailable = None
try:
from bleak import BleakScanner
except ImportError: # bleak not installed → BLE simply unavailable
@@ -400,6 +448,16 @@ async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[Disc
try:
found = await BleakScanner.discover(timeout=timeout, return_adv=True)
except _BLE_NOT_AVAILABLE as exc:
# A *state the reader can fix* — Bluetooth switched off, or (on macOS, routinely)
# the terminal running us has not been granted Bluetooth. That is not the "no
# adapter / driver hiccup" the broad catch below is for, and returning [] silently
# made it indistinguishable from "nothing is nearby": the one message that would
# have told the user what to do was thrown away at debug level, under a log that
# defaults to WARNING. Keep the sentence bleak wrote — it already names the remedy.
_ble_unavailable = str(getattr(exc, "args", [None])[0] or exc)
_log.warning("BLE unavailable: %s", _ble_unavailable)
return []
except Exception as exc: # noqa: BLE001 - no adapter / OS Bluetooth off / driver hiccup
_log.debug("BLE scan unavailable: %s", exc)
return []
@@ -490,3 +548,27 @@ async def discover_all(
# (USB serial number vs BLE address), so no cross-transport dedup is needed here.
ble_devices = await discover_ble_devices(ble_timeout)
return serial + ble_devices
def spi_device(wiring: object, *, name: str = "") -> DiscoveredDevice:
"""Build the :class:`DiscoveredDevice` for a radio on the host's own SPI bus.
Such a radio is "found" the way a serial port is — its ``/dev/spidev*`` node exists —
but nothing answers on it until MeshTerm starts a node, so it is listed from its wiring
(a profile's, or the AIO's defaults) rather than probed.
Args:
wiring: The radio's :class:`~meshterm.core.config.SpiWiring`.
name: Optional friendly name for display (a profile's description, a node name).
Returns:
An SPI :class:`DiscoveredDevice` ready to select or connect.
"""
return DiscoveredDevice(
transport=TRANSPORT_SPI,
port=wiring.spidev, # type: ignore[attr-defined]
spi=wiring,
name=name or None,
description=name or "SPI radio",
product=name or None,
)
+238
View File
@@ -0,0 +1,238 @@
# SPDX-License-Identifier: Apache-2.0
"""What MeshTerm is running on, read once so a bug report does not have to be interviewed.
Every value here answers a question that otherwise costs a round trip with whoever
reported the problem: which build of MeshTerm, on which OS, in which terminal, how wide,
with which colour depth. None of it is about the mesh and none of it is private — this
module deliberately knows nothing about contacts, keys, positions or messages, so the
block it feeds can be pasted in public without anyone having to read it first.
Two probes are **ladders over environment variables** rather than protocol queries, which
is a concession worth naming: a terminal has no portable way to be asked who it is. The
one sequence that comes close (``CSI > q``) needs a live tty, a reply that may never
arrive, and a timeout budget on a path that must never hang. So each terminal is
identified by the variable it sets about *itself* — which is the same ladder
:func:`~meshterm.ui.termfont.detect_terminal_font` already climbs for the font — and
anything unrecognised falls back to ``TERM`` rather than to a guess.
"""
from __future__ import annotations
import os
import platform
import shutil
import sys
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
#: The first Windows 11 build. Windows 11 still reports its release as ``"10"`` through
#: every documented API, so the build number is the only thing that separates the two.
_WINDOWS_11_BUILD = 22000
#: Terminals that announce themselves, by the variable each one sets. Ordered most to
#: least specific: an emulator hosting another (VS Code's terminal, a multiplexer) sets
#: its own marker *and* inherits the outer one, so the inner identity has to win.
#:
#: ``TERM_PROGRAM`` is checked separately, between these and the ``TERM`` fallback,
#: because its *value* is the name — it identifies terminals this table never has to list.
_TERMINAL_VARS: tuple[tuple[str, str], ...] = (
("WT_SESSION", "Windows Terminal"),
("ConEmuPID", "ConEmu"),
("TERMINAL_EMULATOR", ""), # JetBrains IDEs put the name in the value
("KITTY_WINDOW_ID", "kitty"),
("ALACRITTY_WINDOW_ID", "Alacritty"),
("KONSOLE_VERSION", "Konsole"),
("GNOME_TERMINAL_SCREEN", "GNOME Terminal"),
("VTE_VERSION", "VTE-based"),
)
#: What ``TERM_PROGRAM`` calls terminals whose own name is not what a person calls them.
#: Only the ones that genuinely differ; every other value passes through as it is, which
#: is what keeps this from growing into a catalogue of every terminal there is.
_TERM_PROGRAM_NAMES: dict[str, str] = {
"Apple_Terminal": "Terminal.app",
"vscode": "VS Code",
}
@dataclass(frozen=True, slots=True)
class Host:
"""The machine and the Python running MeshTerm.
Attributes:
install: How this copy was installed — ``"frozen"`` (a one-file build, which has
its own bootloader, its own relaunch rules and no source tree to read),
``"source"`` (a checkout, so a commit can be asked for), or ``"package"``
(a wheel from an index). The three fail in different ways and the reporter
usually does not know which one they have.
os: The flavour and its version as a person writes it — ``Windows 11``,
``macOS 15.6``, ``Debian GNU/Linux 12 (bookworm)``.
os_build: The exact build under that name: a Windows build number, or the kernel
release everywhere else. What separates two hosts that call themselves the
same thing.
arch: The processor architecture, which is what tells an arm64 build from an
x86_64 one when a binary misbehaves.
python: The interpreter version, three parts.
"""
install: str
os: str
os_build: str
arch: str
python: str
@dataclass(frozen=True, slots=True)
class Terminal:
"""The terminal MeshTerm is drawing into, and what it can draw.
Attributes:
program: The emulator's name, or ``TERM``'s value where nothing identified
itself, or ``None`` when even that is unset.
term: The ``TERM`` value, verbatim. Kept beside ``program`` rather than folded
into it: a terminal lying about ``TERM`` is itself a common cause of a
rendering report.
colorterm: The ``COLORTERM`` value, which is how a terminal claims truecolor and
the first thing to check when a screen arrives in the wrong colours.
cols: The terminal's width in cells.
rows: The terminal's height in cells.
over_ssh: Whether this is an ssh session. It changes what is knowable — the font
lives on the far client — and it is why several other verdicts here read
``unknown`` rather than wrong.
"""
program: str | None
term: str | None
colorterm: str | None
cols: int
rows: int
over_ssh: bool
def install_kind() -> str:
"""How this copy of MeshTerm got here: ``frozen``, ``source`` or ``package``.
A frozen build is unambiguous — the bootloader sets the flag. Past that, the question
is whether there is a repository above the package: a checkout can be asked for its
commit and can carry uncommitted work, and a wheel can do neither.
Returns:
One of ``"frozen"``, ``"source"`` or ``"package"``.
"""
if getattr(sys, "frozen", False):
return "frozen"
# meshterm/core/hostinfo.py -> meshterm/core -> meshterm -> the tree that holds it
if (Path(__file__).resolve().parents[2] / ".git").exists():
return "source"
return "package"
def host() -> Host:
"""Read the machine's own facts.
Returns:
The :class:`Host` block.
"""
name, build = _os_name_and_build()
return Host(
install=install_kind(),
os=name,
os_build=build,
arch=platform.machine() or "unknown",
python=platform.python_version(),
)
def terminal(environ: Mapping[str, str] | None = None) -> Terminal:
"""Read the terminal's own facts.
Args:
environ: The environment to inspect (defaults to ``os.environ``), injectable so
the ladder can be tested without a terminal of the right kind to hand.
Returns:
The :class:`Terminal` block.
"""
env = os.environ if environ is None else environ
size = shutil.get_terminal_size()
return Terminal(
program=terminal_program(env),
term=env.get("TERM") or None,
colorterm=env.get("COLORTERM") or None,
cols=size.columns,
rows=size.lines,
over_ssh=bool(env.get("SSH_TTY") or env.get("SSH_CONNECTION")),
)
def terminal_program(environ: Mapping[str, str] | None = None) -> str | None:
"""Name the terminal emulator from what it says about itself.
The ladder: the terminals in :data:`_TERMINAL_VARS` that set a marker variable, then
``TERM_PROGRAM`` (whose *value* is the name, so it covers terminals this module has
never heard of), then ``TERM`` as the last honest thing there is to say.
Args:
environ: The environment to inspect (defaults to ``os.environ``).
Returns:
The terminal's name, or ``None`` when nothing in the environment names one.
"""
env = os.environ if environ is None else environ
for var, name in _TERMINAL_VARS:
value = env.get(var)
if value:
return name or value
program = env.get("TERM_PROGRAM")
if program:
return _TERM_PROGRAM_NAMES.get(program, program)
return env.get("TERM") or None
def _os_name_and_build() -> tuple[str, str]:
"""The OS as a person writes it, and the exact build under that name."""
system = platform.system()
if system == "Windows":
return _windows()
if system == "Darwin":
version = platform.mac_ver()[0]
return (f"macOS {version}" if version else "macOS"), platform.release()
if system == "Linux":
return (_pretty_name() or "Linux"), platform.release()
return (system or "unknown"), platform.release()
def _windows() -> tuple[str, str]:
"""Windows by the name it is sold under, and its build number.
``platform.win32_ver`` reports release ``"10"`` on Windows 11 as well — the rename
never reached the version APIs — so the build number is what decides which one this
is. The build is also the useful half of the pair: it is what a fix is shipped
against.
"""
release, version, _service_pack, _kind = platform.win32_ver()
try:
build = int(version.rsplit(".", 1)[-1])
except ValueError:
build = 0
name = "Windows 11" if build >= _WINDOWS_11_BUILD else f"Windows {release or '?'}"
return name, version or platform.version()
def _pretty_name() -> str | None:
"""The distribution's own name for itself, from ``/etc/os-release``.
The one place a Linux flavour is stated by the system rather than inferred, and the
reason a report can say *Calculinux* or *Raspberry Pi OS* instead of ``Linux``.
A host without the file (or without permission to read it) simply has no answer.
"""
try:
text = Path("/etc/os-release").read_text(encoding="utf-8")
except OSError:
return None
for line in text.splitlines():
key, _, value = line.partition("=")
if key == "PRETTY_NAME":
return value.strip().strip('"') or None
return None
+413
View File
@@ -0,0 +1,413 @@
# SPDX-License-Identifier: Apache-2.0
"""Reopening the session in a Terminal window that looks like MeshTerm.
macOS Terminal is not the classic Windows console's problem over again. It falls back
generously — ask it for a glyph its font lacks and it finds one somewhere — so nothing here
is ever a box. The trouble is *what* it finds: no Mac monospace face carries the Braille
Patterns block at all (SF Mono, Menlo, Monaco and Courier all measure zero), so the block
the charts and the map's terrain are drawn from is borrowed from a **proportional** face
and arrives at the wrong width. The screen is not empty, it is crooked, which is worse —
nothing announces itself as broken and the reader has nothing to compare it against.
The fix is a font, and a font means a Terminal profile that names it. Which raises the
question this module exists to answer carefully: **whose terminal is it?**
MeshTerm's palette is the 16-colour VT set (:data:`~meshterm.ui.theme._VT_SLOTS`) — a
deliberately crude, deliberately CGA-ish thing that the app wears on purpose. Nobody should
have to live in it to read their mail. So this module never touches the reader's default
profile and never edits their preferences: it *adds* a profile named MeshTerm, and opens
MeshTerm's own window in it. Their Terminal keeps whatever they chose, down to its
translucency; ours is opaque and crude in a window of its own, and quitting leaves nothing
behind.
That also sidesteps a trap worth recording, because it costs an evening to find. Terminal
rewrites ``~/Library/Preferences/com.apple.Terminal.plist`` **from memory when it quits**,
so anything written into that file while Terminal is running is silently discarded later —
and MeshTerm is always running inside it. Nothing here writes that file. A ``.terminal``
file is handed to Terminal and *Terminal* does the importing, which is the one path that
works from inside a live session.
The profile is generated rather than shipped, from the same theme constant the app draws
itself with, so the window can never disagree with the screen it is holding.
"""
from __future__ import annotations
import os
import plistlib
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
from .relaunch import REOPENED_ENV, own_command, wanted_size
#: The name the profile is installed under, and the name the reader sees in Terminal's
#: settings list. Stable: renaming it would strand the copy already on their machine.
PROFILE_NAME = "MeshTerm"
#: The bundled face, by its PostScript name — which is what an ``NSFont`` archive holds,
#: and not the family name Font Book shows. Installed by :func:`install_font` from the copy
#: in the package; see :mod:`meshterm.core.consolefont` for the Windows half of the same
#: idea.
FONT_FACE = "CascadiaMonoPL-Regular"
#: Points. Terminal's own default is 11; 13 is a size the braille charts read cleanly at on
#: a Retina panel without the map losing rows.
FONT_SIZE = 13.0
#: Where a user-installed font goes on macOS. No administrator rights, no installer, and
#: the reader can undo it by dragging the file out of Font Book.
USER_FONT_DIR = Path("~/Library/Fonts").expanduser()
#: Terminal's key for each palette slot, in :data:`~meshterm.ui.theme._VT_SLOTS` order —
#: the standard ANSI 16, dim bank then bright bank.
_ANSI_KEYS: tuple[str, ...] = (
"ANSIBlackColor",
"ANSIRedColor",
"ANSIGreenColor",
"ANSIYellowColor",
"ANSIBlueColor",
"ANSIMagentaColor",
"ANSICyanColor",
"ANSIWhiteColor",
"ANSIBrightBlackColor",
"ANSIBrightRedColor",
"ANSIBrightGreenColor",
"ANSIBrightYellowColor",
"ANSIBrightBlueColor",
"ANSIBrightMagentaColor",
"ANSIBrightCyanColor",
"ANSIBrightWhiteColor",
)
def _archive(objects: list[object]) -> bytes:
"""One ``NSKeyedArchiver`` document, as Terminal stores every colour and font.
Terminal keeps these as nested binary plists inside the profile — an archived
``NSColor`` or ``NSFont`` rather than a number and a string, because the profile is
written by AppKit rather than for us. The shape is small and fixed, so it is built
here rather than reached for through a dependency.
Args:
objects: The ``$objects`` array, with ``$null`` already at index 0.
Returns:
The encoded archive. Binary, because :class:`plistlib.UID` has no XML spelling.
"""
return plistlib.dumps(
{
"$archiver": "NSKeyedArchiver",
"$objects": objects,
"$top": {"root": plistlib.UID(1)},
"$version": 100000,
},
fmt=plistlib.FMT_BINARY,
)
def ns_color(rgb: str) -> bytes:
"""An archived ``NSColor`` for a ``#rrggbb`` string.
Args:
rgb: The colour, as the theme spells it.
Returns:
The archive Terminal expects in a colour key.
"""
channels = (int(rgb[index : index + 2], 16) / 255 for index in (1, 3, 5))
# Calibrated RGB, space-separated to six places and NUL-terminated: AppKit's own
# spelling, matched exactly because Terminal parses it rather than tolerating it.
packed = " ".join(f"{value:.6f}" for value in channels).encode("ascii") + b"\x00"
return _archive(
[
"$null",
{"$class": plistlib.UID(2), "NSColorSpace": 1, "NSRGB": packed},
{"$classes": ["NSColor", "NSObject"], "$classname": "NSColor"},
]
)
def ns_font(face: str = FONT_FACE, size: float = FONT_SIZE) -> bytes:
"""An archived ``NSFont``.
Args:
face: The PostScript name of the face.
size: The size in points.
Returns:
The archive Terminal expects in the ``Font`` key.
"""
return _archive(
[
"$null",
{
"$class": plistlib.UID(3),
"NSName": plistlib.UID(2),
"NSSize": float(size),
"NSfFlags": 16,
},
face,
{"$classes": ["NSFont", "NSObject"], "$classname": "NSFont"},
]
)
def launch_command() -> str:
"""The shell line that starts this session again, for the profile to run.
Terminal starts the command from a **fresh login shell**, which is why this is a
string rather than an argv and why it carries what it needs explicitly. Nothing of the
current process's environment survives the hop — good news for the ``_PYI*`` variables
a frozen build must not pass on (see
:func:`~meshterm.core.relaunch.child_environment`, which exists for the Windows side
where they *do* survive), and bad news for a ``MESHTERM_HOME`` the reader set for this
invocation alone, which the README itself suggests doing. So every ``MESHTERM_*``
variable currently set is carried across, and the reopened-session marker with them.
The variables ride on ``/usr/bin/env`` rather than on a ``VAR=value`` prefix, and that
is not a stylistic choice. Terminal's own handling of the string is not something to
lean on: measured on macOS 26, the same command line runs under ``RunCommandAsShell``
*false* and fails under *true*, where Terminal takes the whole thing as the name of a
program and puts this on the screen::
Command not found: MESHTERM_REOPENED=1
Could not create a new process and open a pseudo-tty
``env`` is a real executable, so it is a valid argv *and* a valid shell line, and the
window cannot land on that message however Terminal decides to read it. ``env`` execs
the program in its own place, so nothing lingers behind it either — the window holds
MeshTerm and nothing else, and closing MeshTerm does not drop to a prompt.
Returns:
A command line Terminal can run with or without a shell.
"""
carried = {name: value for name, value in os.environ.items() if name.startswith("MESHTERM_")}
carried[REOPENED_ENV] = "1"
assignments = " ".join(
f"{name}={shlex.quote(value)}" for name, value in sorted(carried.items())
)
program = " ".join(shlex.quote(part) for part in own_command())
return f"/usr/bin/env {assignments} {program}"
def build_profile(command: str | None = None) -> dict[str, object]:
"""The MeshTerm profile, as Terminal's preferences hold it.
Every colour comes from :data:`~meshterm.ui.theme._VT_SLOTS`, so the window and the
app it is holding cannot disagree — change the theme and this follows on the next
write. The window size is
:func:`~meshterm.core.relaunch.wanted_size`'s, the same one Windows Terminal is asked
for.
Args:
command: A shell line for the window to run on open, or ``None`` for a profile
that just sits in the list waiting to be chosen.
Returns:
The profile dictionary, ready to write as a ``.terminal`` file.
"""
from ..ui.theme import _VT_SLOTS
cols, rows = wanted_size()
profile: dict[str, object] = {
"name": PROFILE_NAME,
"type": "Window Settings",
"ProfileCurrentVersion": 2.07,
"Font": ns_font(),
"FontAntialias": True,
"FontWidthSpacing": 1.0,
# Opaque on purpose. A braille raster over a translucent ground is unreadable, and
# a translucent profile is a perfectly reasonable thing for the reader to have --
# which is the whole argument for MeshTerm having a window of its own.
"BackgroundColor": ns_color("#000000"),
"TextColor": ns_color("#aaaaaa"),
"TextBoldColor": ns_color("#ffffff"),
"SelectionColor": ns_color("#555555"),
"CursorColor": ns_color("#55ffff"),
"CursorType": 0,
"BlinkText": False,
# Bold is bold, not a jump to the bright bank. The theme states brightness as a
# colour (see the wordmark's own note); letting Terminal promote it as well would
# land a bank too high.
"UseBrightBold": False,
"columnCount": cols,
"rowCount": rows,
"ScrollbackLines": 10000,
"ShouldLimitScrollback": 0,
"ShowActiveProcessInTitle": True,
"ShowDimensionsInTitle": False,
"ShowWindowSettingsNameInTitle": False,
# Close the window when MeshTerm exits cleanly, and keep it when it does not --
# a crash the reader never sees is a bug report nobody can write.
"shellExitAction": 1,
}
for key, (_slot, _label, rgb) in zip(_ANSI_KEYS, _VT_SLOTS, strict=True):
profile[key] = ns_color(rgb)
if command is not None:
profile["CommandString"] = command
# False, measured. Under *true* Terminal takes the whole string as a program name
# and the window says "Command not found: ..."; under false it runs. The opposite
# of what the key's name suggests, which is why it is written down.
profile["RunCommandAsShell"] = False
return profile
def launcher_path() -> Path:
"""The little script the profile runs, beside the profile itself."""
from .config import default_config_dir
return default_config_dir() / "launch.sh"
def write_launcher() -> Path:
"""Write the launcher, and return where it landed.
This exists for one reason, and it is the finding that shaped this module. Terminal
names an imported settings set **after the file** — ``MeshTerm.terminal`` becomes the
profile "MeshTerm" — and re-importing the *same bytes* reuses that profile, while
importing **different** bytes under the same filename creates "MeshTerm 1", then
"MeshTerm 2". Measured, by opening one file four times: three identical imports left
one profile, and the fourth, one word different, left two.
So the command cannot live in the profile. It carries ``sys.argv`` and the reader's
``MESHTERM_*`` variables, which differ between ``meshterm`` and ``meshterm --mock``,
and a profile per command line would fill the reader's settings list with junk that
only they can remove.
Moving the variable part behind a fixed path fixes it exactly: the profile says
``~/.meshterm/launch.sh`` and never changes, and this file underneath it says whatever
this launch happens to need.
Returns:
The path written, executable.
"""
path = launcher_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"#!/bin/sh\n"
"# Written by MeshTerm each time it reopens itself. Safe to delete.\n"
f"exec {launch_command()}\n",
encoding="utf-8",
)
path.chmod(0o755)
return path
def profile_path() -> Path:
"""Where the generated ``.terminal`` file is kept.
In MeshTerm's own directory rather than the Desktop: it is ours, it is the one place
that already survives an upgrade, and it moves with ``MESHTERM_HOME``.
"""
from .config import default_config_dir
return default_config_dir() / f"{PROFILE_NAME}.terminal"
def write_profile(*, command: str | None = None) -> Path:
"""Write the profile out, and return where it landed.
Args:
command: A shell line for the window to run, or ``None`` for a bare profile.
Returns:
The path written.
"""
path = profile_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(plistlib.dumps(build_profile(command), fmt=plistlib.FMT_XML))
return path
def font_installed() -> bool:
"""Whether the bundled face is already in the reader's font directory."""
from .consolefont import BUNDLED_FONT
return (USER_FONT_DIR / BUNDLED_FONT.name).is_file()
def install_font() -> bool:
"""Copy the bundled face into the reader's own font directory.
The one thing here that writes outside MeshTerm's own folder, which is why it is the
one thing worth asking about: a single file, in the reader's own Library, no
administrator rights, no installer, removable by dragging it out of Font Book. It
changes nothing about how their Terminal looks — it only makes the face available for
a profile to name.
Safe to repeat: an existing copy is left alone.
Returns:
Whether the font is installed and usable, including when it already was.
"""
from .consolefont import BUNDLED_FONT
if sys.platform != "darwin" or not BUNDLED_FONT.is_file():
return False
target = USER_FONT_DIR / BUNDLED_FONT.name
if target.is_file():
return True
try:
USER_FONT_DIR.mkdir(parents=True, exist_ok=True)
shutil.copy2(BUNDLED_FONT, target)
except OSError:
return False
return True
def in_apple_terminal() -> bool:
"""Whether this session is drawing into macOS Terminal.
iTerm2, Ghostty, kitty, WezTerm and VS Code all set ``TERM_PROGRAM`` to their own
name, and several of them have both truecolor and a font the reader chose deliberately
— none of them wants anything this module offers.
"""
return sys.platform == "darwin" and os.environ.get("TERM_PROGRAM") == "Apple_Terminal"
def available() -> bool:
"""Whether reopening in MeshTerm's own window is a thing worth doing here.
``False`` in a session that was itself reopened, which is what keeps a relaunch from
chaining into another one: the profile's own command carries the marker, because
Terminal starts it from a fresh shell that inherits nothing from us.
"""
return in_apple_terminal() and not os.environ.get(REOPENED_ENV)
def reopen() -> bool:
"""Open MeshTerm in a window using its own profile, and report whether it started.
``open`` hands the file to Terminal, and **Terminal** imports the profile and opens the
window — the only route that works from inside a live session, since Terminal discards
external edits to its preferences when it quits.
The new window is not a child in any meaningful sense: the caller exits immediately
afterwards and the two never talk.
Two files, not one: the launcher carries what varies and the profile points at it, so
the profile's bytes are the same on every launch and Terminal reuses the one settings
set instead of adding a numbered copy. :func:`write_launcher` has the measurement.
Returns:
Whether ``open`` was launched. ``False`` leaves the caller exactly where it was,
so a failure here is a detour and never a dead end.
"""
if not available():
return False
try:
launcher = write_launcher()
path = write_profile(command=str(launcher))
except OSError:
return False
try:
subprocess.Popen( # noqa: S603 - the argv is ours, not the reader's
["/usr/bin/open", str(path)],
close_fds=True,
)
except OSError:
return False
return True
+17 -2
View File
@@ -48,14 +48,24 @@ NODE_TYPE_ROOM = 3
NODE_TYPE_SENSOR = 4
#: Human-readable label for each advert type, for legends and summaries.
#: The one name for each advert type, everywhere MeshTerm prints one and in every
#: `--json` answer that carries a role or a type. A type-1 node is a companion: the radio
#: someone talks through, the same kind as the one in your hand.
NODE_TYPE_LABELS = {
NODE_TYPE_CHAT: "node",
NODE_TYPE_CHAT: "companion",
NODE_TYPE_REPEATER: "repeater",
NODE_TYPE_ROOM: "room",
NODE_TYPE_ROOM: "room server",
NODE_TYPE_SENSOR: "sensor",
}
def node_type_label(node_type: int | None) -> str | None:
"""The name for an advert type, ``type N`` for one MeshTerm does not know, None for none."""
if node_type is None:
return None
return NODE_TYPE_LABELS.get(int(node_type), f"type {node_type}")
class LoginResult(Enum):
"""How an admin login ended — and, crucially, whether the node was there to end it.
@@ -395,6 +405,10 @@ class ChatMessage:
created_at: When the message was sent or received.
row_id: The ``messages`` table primary key once persisted, used to update an
outbound message's delivery state in place on retry; ``None`` until stored.
scope: What an outbound channel message was flooded under: the bare region name,
:data:`~meshterm.core.regions.WILDCARD` (``*``) for unscoped, or ``None`` where
it isn't known — every inbound or direct message, and anything sent before the
scope was recorded.
"""
text: str
@@ -408,6 +422,7 @@ class ChatMessage:
acked: bool | None = None
created_at: datetime = field(default_factory=utcnow)
row_id: int | None = None
scope: str | None = None
@property
def key(self) -> str:
+102 -1
View File
@@ -40,12 +40,14 @@ Two access paths, deliberately:
from __future__ import annotations
import logging
import sys
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .advert_store import DEFAULT_QUIET_S, QUIET_CHOICES_S
from .atomicwrite import write_atomically
from .courier_store import DONE_CAP
from .geo import DEFAULT_VIEW_FRACTION
@@ -151,6 +153,10 @@ _SILENCE_CHOICES: dict[int, str] = {
OFF: "off",
}
#: How long the air must stay quiet before the weekly flood advert goes out, drawn from the
#: scheduler's own choices so the two never drift.
_QUIET_CHOICES: dict[int, str] = {s: f"{s} s" for s in QUIET_CHOICES_S}
#: How the terminal-width reclaim can be asked for: follow the platform's own verdict, or
#: overrule it in either direction. The row's description asks a yes/no question ("use the
#: column ... ?"), so the values answer it in those words rather than naming the mechanism
@@ -241,6 +247,26 @@ PREFERENCES: tuple[PrefSpec, ...] = (
maximum=3600.0,
unit="s",
),
PrefSpec(
key="weekly_flood_advert",
label="Weekly advert",
help="Flood an advert after a week without one",
group="Sending",
value_type="bool",
# Off: a companion never advertises on its own, so this is the only advert MeshTerm
# would ever send unasked, and every repeater in range rebroadcasts it. Turning it
# on starts the week rather than sending — see meshterm.services.advert_scheduler.
default=False,
),
PrefSpec(
key="advert_quiet_s",
label="Advert quiet",
help="Silence to wait for before the weekly advert",
group="Sending",
value_type="enum",
default=DEFAULT_QUIET_S,
choices=_QUIET_CHOICES,
),
PrefSpec(
key="direct_message_soft_retries",
label="Message retries",
@@ -449,6 +475,28 @@ PREFERENCES: tuple[PrefSpec, ...] = (
_BY_KEY: dict[str, PrefSpec] = {spec.key: spec for spec in PREFERENCES}
#: Every key that was once a preference and no longer is. A retired key is never reused:
#: a value can outlive its key in a file nobody saved since, and if the name came back
#: with a new meaning — seconds become minutes, a range moves — an old ``30`` that still
#: passes the new spec would load cleanly and mean something else, which no validation
#: can catch. So a preference that returns changed returns under a new name (a unit in the
#: key makes that the natural move: ``_s`` to ``_min``), and ``tests/test_retired.py``
#: fails any registry that takes one of these back. Loading refuses them outright.
RETIRED: frozenset[str] = frozenset(
{
# Per-device background advert cadences, moved to Device config and since replaced
# by the weekly flood advert (``weekly_flood_advert``).
"advert_direct_hours",
"advert_flood_hours",
}
)
#: The reason :attr:`Preferences.dropped` gives for a key found in :data:`RETIRED`.
DROPPED_RETIRED = "retired"
#: The reason :attr:`Preferences.dropped` gives for a key the registry has never held.
DROPPED_UNKNOWN = "unknown"
def get_spec(key: str) -> PrefSpec:
"""Look up one preference's spec.
@@ -465,6 +513,8 @@ def get_spec(key: str) -> PrefSpec:
try:
return _BY_KEY[key]
except KeyError:
if key in RETIRED:
raise PreferenceError(f"{key!r} is no longer a preference") from None
raise PreferenceError(f"unknown preference: {key!r}") from None
@@ -655,8 +705,9 @@ class Preferences:
"""
self._path = path
self._values: dict[str, Any] = {}
self._dropped: dict[str, str] = {}
if values:
self.update(values)
self._absorb(values)
@classmethod
def load(cls, path: Path) -> Preferences:
@@ -669,6 +720,12 @@ class Preferences:
Args:
path: The TOML file to read.
Nothing the file holds that the registry cannot take survives the next
:meth:`save`, which writes only the overrides in force: a retired key, a key
never registered (a typo, most likely), and a value its spec refuses are all
left out, and :attr:`dropped` says which and why, for the caller to log once
logging is configured (see :func:`report_dropped`).
Returns:
The loaded preferences, bound to ``path`` for a later :meth:`save`.
"""
@@ -682,6 +739,27 @@ class Preferences:
data = _salvage(text)
return cls(path, data)
def _absorb(self, values: Mapping[str, Any]) -> None:
"""Take in a file's entries, recording in :attr:`dropped` each one refused."""
for raw_key, value in values.items():
key = str(raw_key)
if key in RETIRED:
self._dropped[key] = DROPPED_RETIRED
continue
try:
self.set(key, value)
except PreferenceError as exc:
self._dropped[key] = str(exc) if key in _BY_KEY else DROPPED_UNKNOWN
@property
def dropped(self) -> dict[str, str]:
"""What loading refused, key by key (a copy).
The reason is :data:`DROPPED_RETIRED`, :data:`DROPPED_UNKNOWN`, or — for a key
that is registered but whose value its spec refused — the refusal itself.
"""
return dict(self._dropped)
@property
def path(self) -> Path | None:
"""Where :meth:`save` writes, or ``None`` for an in-memory set."""
@@ -811,6 +889,27 @@ class Preferences:
_current: Preferences = Preferences()
def report_dropped(preferences: Preferences, log: logging.Logger) -> None:
"""Log what loading the file refused: the entries its next save will leave out.
A retired key is expected — it was written by an older MeshTerm — so it is noted at
INFO. Anything else was probably typed by hand and is not doing what its author
meant, so it is a WARNING, and the line names it so the typo can be found.
Args:
preferences: A set built by :meth:`Preferences.load`.
log: Where to report.
"""
name = preferences.path.name if preferences.path else PREFERENCES_FILENAME
for key, why in preferences.dropped.items():
if why == DROPPED_RETIRED:
log.info("%s: ignoring retired preference %r; the next save drops it", name, key)
elif why == DROPPED_UNKNOWN:
log.warning("%s: ignoring unknown preference %r; the next save drops it", name, key)
else:
log.warning("%s: ignoring %s; the next save drops it", name, why)
def current() -> Preferences:
"""The preferences in force for this process.
@@ -832,6 +931,7 @@ def install(preferences: Preferences) -> None:
__all__ = [
"GROUPS",
"PREFERENCES",
"RETIRED",
"PrefSpec",
"PreferenceError",
"Preferences",
@@ -842,4 +942,5 @@ __all__ = [
"install",
"parse_value",
"range_hint",
"report_dropped",
]
+552
View File
@@ -0,0 +1,552 @@
# SPDX-License-Identifier: Apache-2.0
"""The software MeshCore node MeshTerm runs on a radio wired straight to the host.
A board like the uConsole's AIO puts an SX1262 on the host's SPI bus with no
microcontroller and no firmware in front of it, so there is no companion to talk to until
something runs one. This file is that something: MeshTerm starts it as a **child process**
when it connects (:mod:`meshterm.core.spiradio` is the parent's half), talks to it over the
standard companion protocol on a loopback port like any network companion, and ends it when
it disconnects — so the radio's pins are held exactly as long as MeshTerm is using them.
**Self-contained on purpose.** Nothing here imports MeshTerm. The radio library
(``openhop_core``) is an optional install that often lives in a different interpreter than
MeshTerm's — the one-file build cannot import it at all, and on a uConsole it usually sits
in its own venv — so the parent runs *this file* by path under whichever Python has the
library. Standard library plus ``openhop_core``, and nothing else, is the contract that
makes that work; ``tests/test_radionode.py`` holds it.
**What firmware would remember, this remembers.** The library keeps preferences, channels
and contacts in memory only, which is why a node run this way used to come up with no
channels after every restart. Everything a firmware companion keeps in flash is kept in the
state directory the parent names instead:
================= =============================================================
``identity.key`` the node's private seed — its identity on the mesh
``prefs.json`` name, radio settings, TX power, position, the other prefs
``channels.json`` the channel table, slot by slot
``contacts.json`` the contact list
================= =============================================================
**Talking to the parent.** The parent reads exactly one JSON line from this process's
stdout: ``{"event": "ready", "port": …, "public_key": …}`` once the frame server is
listening, or ``{"event": "error", "kind": …, "message": …}`` if the radio could not be
opened. The library prints its own diagnostics to stdout, so the real stdout is set aside
for that one line and everything else is sent to stderr, which the parent keeps as the
node's log.
**Letting go of the radio.** GPIO lines requested through the character device and an open
``spidev`` are the kernel's to release, and it releases them when the process ends however
it ends. The work is making sure the process *does* end with its parent: it exits when its
stdin reaches end-of-file (the parent closing it, or the parent dying and the kernel closing
it), and on Linux it also asks for ``SIGTERM`` the moment its parent goes.
"""
from __future__ import annotations
import argparse
import asyncio
import dataclasses
import json
import logging
import os
import signal
import sys
import threading
from pathlib import Path
from typing import Any
#: The radio library this node runs on. ``pymc_core`` (its name before 2026) is not
#: supported here: it needs three shims that ``openhop_core`` made unnecessary, and the
#: standalone bridge still carries them for anyone on it.
RUNTIME = "openhop_core"
#: How many times ``radio.begin()`` is tried when a GPIO line is busy, and the pause before
#: each retry. A node that just exited released its lines as it went, so the retries are for
#: a line some *other* program is letting go of — not for waiting out a program that holds it.
BEGIN_ATTEMPTS = 3
BEGIN_BACKOFF_S = 1.5
#: The model string the frame server reports, which MeshTerm shows as the device model.
DEVICE_MODEL = "MeshTerm SPI node"
log = logging.getLogger("radionode")
# --- errors the parent is told about -------------------------------------------------------
class NodeError(Exception):
"""A failure to bring the node up, with the ``kind`` the parent words its message by.
Kinds: ``runtime`` (the radio library is missing or too old), ``no-spi`` / ``no-gpio``
(the device node doesn't exist), ``permission`` (it exists but this user can't open it),
``busy`` (another program holds the radio's pins), and ``failed`` (anything else).
"""
def __init__(self, kind: str, message: str) -> None:
"""Carry ``message`` as the error text and ``kind`` as its classification."""
super().__init__(message)
self.kind = kind
class _Recent(logging.Handler):
"""Keeps the library's recent error lines, to tell *why* ``radio.begin()`` gave up.
The GPIO manager answers a busy or forbidden pin by logging the reason and calling
``sys.exit``, so the reason exists only as a log line; this is where it is read back.
"""
def __init__(self) -> None:
super().__init__(level=logging.WARNING)
self.lines: list[str] = []
def emit(self, record: logging.LogRecord) -> None:
self.lines.append(record.getMessage())
del self.lines[:-20]
def classify_begin_failure(lines: list[str]) -> str:
"""The error kind a failed ``radio.begin()`` amounts to, from the library's log lines."""
text = " ".join(lines).lower()
if "already in use" in text or "resource busy" in text:
return "busy"
if "permission denied" in text:
return "permission"
return "failed"
# --- the state directory -------------------------------------------------------------------
def _write_json(path: Path, value: Any) -> None:
"""Replace ``path`` with ``value`` as JSON in one rename (a crash keeps the old file)."""
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
tmp.write_text(json.dumps(value, indent=2), encoding="utf-8")
tmp.replace(path)
def _read_json(path: Path) -> Any:
"""``path`` parsed as JSON, or ``None`` when it is missing or unreadable."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
def load_identity_seed(state: Path, mint) -> bytes: # noqa: ANN001 - () -> bytes
"""The node's private seed from ``identity.key``, minting and saving one if there is none.
Args:
state: The node's state directory.
mint: Makes a fresh seed (the library's key generator) when none is saved yet.
"""
path = state / "identity.key"
try:
seed = path.read_bytes()
except OSError:
seed = b""
if seed:
return seed
seed = bytes(mint())
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
tmp.write_bytes(seed)
try:
os.chmod(tmp, 0o600)
except OSError:
pass
tmp.replace(path)
log.info("minted a new node identity at %s", path)
return seed
def prefs_to_json(prefs: Any) -> dict:
"""A ``NodePrefs`` as JSON-safe fields (``bytes`` as hex), for ``prefs.json``."""
out = {}
for field in dataclasses.fields(prefs):
value = getattr(prefs, field.name)
out[field.name] = value.hex() if isinstance(value, (bytes, bytearray)) else value
return out
def apply_saved_prefs(prefs: Any, saved: Any) -> None:
"""Load ``prefs.json`` into a ``NodePrefs`` in place, field by field.
Only fields the library's ``NodePrefs`` still declares are taken, each coerced to the
type its default has, so a field the library dropped is ignored and a hand-edited value
of the wrong type is skipped rather than carried into the radio.
"""
if not isinstance(saved, dict):
return
for field in dataclasses.fields(prefs):
if field.name not in saved:
continue
current = getattr(prefs, field.name)
raw = saved[field.name]
try:
if isinstance(current, (bytes, bytearray)):
value: Any = bytes.fromhex(str(raw))
elif isinstance(current, bool):
value = bool(raw)
elif isinstance(current, int):
value = int(raw)
elif isinstance(current, float):
value = float(raw)
else:
value = str(raw)
except (TypeError, ValueError):
log.warning("prefs.json: ignoring %s=%r", field.name, raw)
continue
setattr(prefs, field.name, value)
def channels_to_json(store: Any) -> list[dict]:
"""The channel table as ``[{"idx", "name", "secret"}]`` in slot order."""
out = []
for idx in range(store.max_channels):
channel = store.get(idx)
if channel is not None and channel.name:
out.append({"idx": idx, "name": channel.name, "secret": bytes(channel.secret).hex()})
return out
def saved_channels(saved: Any) -> list[tuple[int, str, bytes]]:
"""``channels.json`` as ``(idx, name, secret)`` triples, skipping malformed entries."""
out = []
for entry in saved if isinstance(saved, list) else []:
try:
out.append((int(entry["idx"]), str(entry["name"]), bytes.fromhex(entry["secret"])))
except (KeyError, TypeError, ValueError):
continue
return [c for c in out if c[1]]
# --- the node ------------------------------------------------------------------------------
def preamble_for_sf(spreading_factor: int) -> int:
"""The LoRa preamble, in symbols, MeshCore uses at a spreading factor: 32 up to SF8, else 16.
This is MeshCore's own rule (``RadioLibWrapper::preambleLengthForSF``), and the receiver
has to follow it, not only the transmitter. The SX1262 waits for the sync word only about
as long as the preamble it was told to expect, so a node listening for 12 symbols against
a mesh sending 32 locks on, gives up, and locks on again further along the same preamble
— and decodes a packet only when it happens to lock on near the end. That was the whole
of "the uConsole misses replies other radios hear": the preamble was a fixed 12, the
library's default, and at SF7 the mesh sends 32.
"""
return 32 if spreading_factor <= 8 else 16
def radio_kwargs(signature_params, wiring: dict, prefs: Any) -> dict: # noqa: ANN001
"""The ``SX1262Radio`` constructor arguments: the board's wiring plus the saved radio.
Passes only what this library version's constructor accepts, so a knob it lacks is
dropped instead of refusing the call. ``None`` means "not set" and is never passed.
"""
wanted = dict(wiring)
wanted.update(
preamble_length=preamble_for_sf(prefs.spreading_factor),
frequency=prefs.frequency_hz,
bandwidth=prefs.bandwidth_hz,
spreading_factor=prefs.spreading_factor,
coding_rate=prefs.coding_rate,
tx_power=prefs.tx_power_dbm,
)
return {k: v for k, v in wanted.items() if k in signature_params and v is not None}
def _check_device(path: str, missing_kind: str, what: str) -> None:
"""Refuse early, with the right kind, when a device node is absent or unopenable."""
if not os.path.exists(path):
raise NodeError(missing_kind, f"{path} does not exist — is the {what} enabled?")
if not os.access(path, os.R_OK | os.W_OK):
raise NodeError("permission", f"no read/write permission on {path}")
def _persist_contacts(store: Any, path: Path) -> None:
"""Snapshot the contact list after every change, the way the firmware writes flash."""
def save() -> None:
try:
_write_json(path, store.to_dicts())
except Exception as exc: # noqa: BLE001 - a failed snapshot must not stop the node
log.warning("could not save contacts: %s", exc)
for name in ("add", "add_or_overwrite", "update", "remove", "clear"):
original = getattr(store, name, None)
if original is None:
continue
def wrapped(*args, _original=original, **kwargs): # noqa: ANN002, ANN003, ANN202
result = _original(*args, **kwargs)
save()
return result
setattr(store, name, wrapped)
store.save_snapshot = save
async def run_node(config: dict, report) -> int: # noqa: ANN001 - (dict) -> None
"""Bring the node up, report ready, and serve until told to stop.
Args:
config: ``state_dir``, ``wiring`` (the board's pins and switches) and ``seed``
(name and radio settings for a node with no ``prefs.json`` yet).
report: Sends the one status line to the parent.
Returns:
The process exit status.
"""
import importlib
import inspect
try:
rt = importlib.import_module(RUNTIME)
companion_mod = importlib.import_module(f"{RUNTIME}.companion")
models = importlib.import_module(f"{RUNTIME}.companion.models")
identity_mod = importlib.import_module(f"{RUNTIME}.protocol.identity")
sx1262 = importlib.import_module(f"{RUNTIME}.hardware.sx1262_wrapper")
except ImportError as exc:
raise NodeError("runtime", f"{RUNTIME} is not importable here ({exc})") from exc
log.info("node runtime %s %s (%s)", RUNTIME, getattr(rt, "__version__", "?"), sys.executable)
state = Path(config["state_dir"])
state.mkdir(parents=True, exist_ok=True)
wiring = dict(config.get("wiring") or {})
seed = dict(config.get("seed") or {})
bus, cs = int(wiring.get("bus_id", 0)), int(wiring.get("cs_id", 0))
_check_device(f"/dev/spidev{bus}.{cs}", "no-spi", "SPI overlay")
_check_device(f"/dev/gpiochip{int(wiring.get('gpio_chip', 0))}", "no-gpio", "GPIO chip")
# The saved preferences decide the radio the chip is brought up on; the seed only fills
# in a node that has never saved any.
prefs = models.NodePrefs(
node_name=seed.get("node_name", "MeshTerm"),
tx_power_dbm=seed.get("tx_power_dbm", 22),
frequency_hz=seed.get("frequency_hz", 910_525_000),
bandwidth_hz=seed.get("bandwidth_hz", 62_500),
spreading_factor=seed.get("spreading_factor", 7),
coding_rate=seed.get("coding_rate", 5),
)
apply_saved_prefs(prefs, _read_json(state / "prefs.json"))
SX1262Radio = sx1262.SX1262Radio
kwargs = radio_kwargs(inspect.signature(SX1262Radio.__init__).parameters, wiring, prefs)
log.info("radio config %s", " ".join(f"{k}={v}" for k, v in kwargs.items()))
radio = SX1262Radio(**kwargs)
recent = _Recent()
logging.getLogger().addHandler(recent)
for attempt in range(BEGIN_ATTEMPTS):
recent.lines.clear()
try:
if radio.begin():
break
kind = classify_begin_failure(recent.lines)
except SystemExit: # the GPIO manager's answer to a busy or forbidden pin
kind = classify_begin_failure(recent.lines)
except Exception as exc: # noqa: BLE001 - reported to the parent, not raised
recent.lines.append(str(exc))
kind = classify_begin_failure(recent.lines)
try:
radio.cleanup()
except Exception: # noqa: BLE001, S110 - best-effort release before retrying
pass
if kind != "busy" or attempt == BEGIN_ATTEMPTS - 1:
detail = recent.lines[-1] if recent.lines else "radio.begin() failed"
raise NodeError(kind, detail)
await asyncio.sleep(BEGIN_BACKOFF_S * (attempt + 1))
logging.getLogger().removeHandler(recent)
log.info("radio ready")
try:
seed_bytes = load_identity_seed(
state, lambda: identity_mod.LocalIdentity().get_signing_key_bytes()
)
identity = identity_mod.LocalIdentity(seed_bytes)
node = _persistent_companion(companion_mod.CompanionRadio, state)(
radio,
identity,
node_name=prefs.node_name,
radio_config={
"frequency": prefs.frequency_hz,
"bandwidth": prefs.bandwidth_hz,
"spreading_factor": prefs.spreading_factor,
"coding_rate": prefs.coding_rate,
"tx_power": prefs.tx_power_dbm,
},
)
apply_saved_prefs(node.prefs, prefs_to_json(prefs))
for idx, name, secret in saved_channels(_read_json(state / "channels.json")):
node.set_channel(idx, name, secret)
contacts = _read_json(state / "contacts.json")
if isinstance(contacts, list):
node.contacts.load_from_dicts(contacts)
log.info("restored %d contact(s)", node.contacts.get_count())
_persist_contacts(node.contacts, state / "contacts.json")
node.add_push_callback(
"channel_updated",
lambda _idx, _channel: _write_json(
state / "channels.json", channels_to_json(node.channels)
),
)
await node.start()
public_key = node.get_public_key()
server = companion_mod.CompanionFrameServer(
bridge=node,
companion_hash=f"{public_key[0]:02x}",
port=0,
bind_address="127.0.0.1",
local_hash=public_key[0],
device_model=DEVICE_MODEL,
client_idle_timeout_sec=None,
)
await server.start()
# The one push the library's frame server doesn't subscribe to: every overheard frame
# with its SNR/RSSI, which MeshTerm's live feed is built from.
node.add_push_callback("rx_log_data", server.push_rx_raw)
port = server._server.sockets[0].getsockname()[1]
except Exception:
radio.cleanup()
raise
log.info("node %s… listening on 127.0.0.1:%d", public_key.hex()[:16], port)
report({"event": "ready", "port": port, "public_key": public_key.hex()})
try:
await _until_told_to_stop()
finally:
log.info("shutting down")
for step in (server.stop, node.stop):
try:
await step()
except Exception as exc: # noqa: BLE001 - keep going: the radio still needs freeing
log.warning("shutdown step failed: %s", exc)
node.contacts.save_snapshot()
radio.cleanup()
return 0
def apply_preamble(radio: Any, symbols: int) -> None:
"""Give a running radio a new preamble length, for both what it sends and what it hears.
The driver reads ``preamble_length`` afresh for every transmission, but reception keeps
the packet parameters it was last given, so those are re-sent from standby and the chip
put back to listening.
"""
if getattr(radio, "preamble_length", symbols) == symbols:
return
radio.preamble_length = symbols
lora = getattr(radio, "lora", None)
if lora is None:
return
try:
lora.setStandby(lora.STANDBY_RC)
lora.setPacketParamsLoRa(symbols, lora.HEADER_EXPLICIT, 64, lora.CRC_ON, lora.IQ_STANDARD)
lora.request(lora.RX_CONTINUOUS)
log.info("preamble set to %d symbols", symbols)
except Exception as exc: # noqa: BLE001 - the next transmission sets it regardless
log.warning("could not apply the new preamble while listening: %s", exc)
def _persistent_companion(base: type, state: Path) -> type:
"""``CompanionRadio`` with its preferences written to ``prefs.json`` on every change.
``_save_prefs`` is the library's own hook for exactly this ("subclasses that need
persistence … should override this method"), called after every preference setter.
"""
class PersistentCompanion(base): # type: ignore[misc, valid-type]
def set_radio_params(self, freq_hz: int, bw_hz: int, sf: int, cr: int) -> bool:
"""Retune, and bring the preamble along when the spreading factor moves it.
The library retunes the modulation but leaves the packet parameters alone, and
the preamble is one of them (see :func:`preamble_for_sf`).
"""
ok = super().set_radio_params(freq_hz, bw_hz, sf, cr)
if ok:
apply_preamble(self._radio, preamble_for_sf(sf))
return ok
def _save_prefs(self) -> None:
try:
_write_json(state / "prefs.json", prefs_to_json(self.prefs))
except Exception as exc: # noqa: BLE001 - a failed save must not fail the setter
log.warning("could not save prefs: %s", exc)
return PersistentCompanion
async def _until_told_to_stop() -> None:
"""Wait for stdin to close or a SIGTERM/SIGINT — the parent's two ways of saying stop."""
loop = asyncio.get_running_loop()
stop = asyncio.Event()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, stop.set)
except (NotImplementedError, RuntimeError):
pass
def watch_stdin() -> None:
try:
while sys.stdin.buffer.read(4096):
pass
except (OSError, ValueError):
pass
loop.call_soon_threadsafe(stop.set)
threading.Thread(target=watch_stdin, name="parent-watch", daemon=True).start()
await stop.wait()
def _die_with_parent() -> None:
"""On Linux, have the kernel send SIGTERM when the parent exits (best-effort)."""
if not sys.platform.startswith("linux"):
return
try:
import ctypes
PR_SET_PDEATHSIG = 1
ctypes.CDLL("libc.so.6", use_errno=True).prctl(PR_SET_PDEATHSIG, signal.SIGTERM)
except (OSError, AttributeError):
pass
def main(argv: list[str] | None = None) -> int:
"""Entry point: ``python radionode.py --config '<json>'``."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--config", required=True, help="the node's configuration, as JSON")
args = parser.parse_args(argv)
# One line on the real stdout is the parent's; everything else goes to stderr.
status = os.fdopen(os.dup(1), "w", encoding="utf-8")
os.dup2(2, 1)
sys.stdout = sys.stderr
def report(message: dict) -> None:
status.write(json.dumps(message) + "\n")
status.flush()
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
_die_with_parent()
try:
return asyncio.run(run_node(json.loads(args.config), report))
except NodeError as exc:
log.error("%s: %s", exc.kind, exc)
report({"event": "error", "kind": exc.kind, "message": str(exc)})
return 1
except Exception as exc: # noqa: BLE001 - the parent gets a sentence, the log the trace
log.exception("node failed")
report({"event": "error", "kind": "failed", "message": str(exc)})
return 1
if __name__ == "__main__":
raise SystemExit(main())
+520
View File
@@ -0,0 +1,520 @@
# SPDX-License-Identifier: Apache-2.0
"""A repeater's region table as its CLI shows it: the parser, the commands, the edits.
A MeshCore repeater keeps a small table of regions (at most 32) in a tree under the
wildcard ``*``, and relays a *scoped* flood only when the flood's region is one it lists as
flood-allowed (see :mod:`~meshterm.core.regions` for the maths that ties a flood to its
region). The tree is **organisational only**: a repeater relays exactly the regions it
lists as allowed, so allowing ``lakeside`` says nothing about ``lakeside-north`` beneath it.
The wildcard's own flag is the unscoped case — whether it relays plain floods at all.
The admin reads and edits that table over the remote CLI (firmware ``CommonCLI::
handleRegionCmd``, ``RegionMap``), and this module is everything about it that is not a
screen:
* **The dump** (:func:`parse_region_dump`). ``region`` answers with the tree, one region per
line, each line indented one space per level below the wildcard, the name followed by
``^`` on the home region and `` F`` where floods are allowed::
*^ F
lakeside F
lakeside-north F
lakeside-south
harbour F
* **The cut.** Every CLI reply is written into a 160-byte buffer (``exportTo(reply, 160)``),
and the dump simply stops where the buffer ends — mid-line, mid-name, with nothing to say
it did. A table of 32 regions with real names does not fit, so a dump near the cap is read
as *possibly cut* (:data:`REPLY_CAP_BYTES`, :func:`parse_region_dump`), its unfinished last
line is dropped rather than shown as a region with half a name, and the reader is told.
The flat lists (``region list allowed`` / ``region list denied``) are how the rest is
recovered (:func:`parse_name_list`, :func:`RegionTable.with_lists`) — they carry no parent,
so those regions are shown *beyond the cut*, placed nowhere. The lists have a cap of their
own and skip a name that would not fit rather than cutting it, so a list close enough to
the cap to have skipped one is flagged too (:func:`list_may_be_incomplete`). Nothing here
ever claims to hold the whole table when it cannot know.
* **The commands** (:func:`allow_command` and siblings). Every edit is one remote command,
and each is spelled here exactly once. ``allowf``/``denyf``/``home`` match a name by
*prefix* on the firmware, preferring an exact match — MeshTerm only ever sends names the
repeater itself listed, so the exact match always wins.
* **The edits** (:meth:`RegionTable.after`). What a command the repeater accepted does to the
table, so the editor redraws from the reply rather than spending another round trip on a
re-read. ``region put`` of a name that already exists *moves* it on the firmware, which is
why the editor refuses a name it can see is taken.
* **Persistence.** Every edit lives in the repeater's RAM until ``region save``; a reboot
undoes whatever was not saved. The one exception is ``region default`` (firmware 1.15+),
which saves the whole table itself — its reply (``default scope is now …``) is how an edit
says it took the unsaved ones with it (:func:`saves_table`).
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass, replace
from .regions import MAX_NAME_BYTES, WILDCARD, RegionNameError, validate
#: The firmware's CLI reply buffer, in bytes. ``BufStream`` stops one short of it (the NUL),
#: so a dump that ran out of room arrives 159 bytes long.
REPLY_CAP_BYTES = 160
#: How close to the cap a dump must come to be read as possibly cut. A cut can land inside
#: a multi-byte UTF-8 name, which the decode on the way here may shorten by a few bytes, and
#: the transport may strip a trailing newline — so the test leaves room for both rather
#: than asking for exactly 159.
_CUT_SLACK = 4
#: The command that dumps the tree.
DUMP_COMMAND = "region"
#: The command that asks which region is the default scope (firmware 1.15+).
DEFAULT_QUERY = "region default"
#: The two flat lists — every flood-allowed and every flood-denied name, ``*`` included.
LIST_ALLOWED = "region list allowed"
LIST_DENIED = "region list denied"
#: The command that writes the table to flash. Nothing else survives a reboot.
SAVE_COMMAND = "region save"
#: The token ``region default`` takes to clear the default scope.
NULL_DEFAULT = "<null>"
#: What an empty flat list reads as on the repeater.
_EMPTY_LIST = "-none-"
# -- the table -----------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class RegionRow:
"""One region in a repeater's table.
Attributes:
name: The bare region name (``*`` for the wildcard).
parent: The parent's name — ``*`` for a top-level region, ``None`` for the wildcard
itself and for a region recovered from a flat list, whose place is unknown.
depth: Levels below the wildcard (0 for the wildcard, 1 for a top-level region).
flood: Whether the repeater relays floods scoped to it (``F``) — for the wildcard,
whether it relays unscoped floods.
home: Whether it is the repeater's home region (``^``).
placed: ``False`` for a region known only from a flat list: it is in the table,
but the dump was cut before it, so its parent and depth are not known.
"""
name: str
parent: str | None
depth: int
flood: bool
home: bool = False
placed: bool = True
@property
def wildcard(self) -> bool:
"""Whether this row is the wildcard — the unscoped case, not a region."""
return self.name == WILDCARD
@dataclass(frozen=True, slots=True)
class RegionTable:
"""What MeshTerm knows of one repeater's region table.
Attributes:
rows: The wildcard first (when the dump reached it), then every region in dump order
(depth-first, as the firmware prints the tree), then any recovered from the flat
lists after a cut (``placed=False``).
cut: Whether the dump may have been cut at the reply cap — rows after ``cut_after``
may exist that the tree does not show.
cut_after: The last region the dump showed whole (``None`` when it showed none).
lists_read: Whether the flat lists were read to recover what the cut hid.
lists_partial: Whether either flat list came close enough to its own cap that a
name may have been skipped — the recovered set may still be short.
default: The default-scope region, ``None`` where there is none, and absent
knowledge is :attr:`default_known` ``False`` (older firmware has no such query).
default_known: Whether ``default`` was actually read.
"""
rows: tuple[RegionRow, ...] = ()
cut: bool = False
cut_after: str | None = None
lists_read: bool = False
lists_partial: bool = False
default: str | None = None
default_known: bool = False
# -- reading it ---------------------------------------------------------------
def get(self, name: str) -> RegionRow | None:
"""The row for ``name``, or ``None`` when the table (as known) has no such region."""
return next((row for row in self.rows if row.name == name), None)
@property
def wildcard(self) -> RegionRow | None:
"""The wildcard's row, or ``None`` when the dump never reached it."""
return self.get(WILDCARD)
def regions(self) -> list[RegionRow]:
"""Every region row, the wildcard left out."""
return [row for row in self.rows if not row.wildcard]
def children(self, name: str) -> list[RegionRow]:
"""The regions directly under ``name`` (placed rows only — nothing else has a parent)."""
return [row for row in self.rows if row.parent == name and not row.wildcard]
@property
def home(self) -> str | None:
"""The home region's name (``*`` when none was set), or ``None`` when not seen."""
return next((row.name for row in self.rows if row.home), None)
@property
def complete(self) -> bool:
"""Whether every region the repeater holds is (to the best of our reading) here.
A clean dump is complete. A cut one is complete again once both flat lists were read
and neither came near its own cap — the tree is still partly unplaced, but no name
is missing.
"""
return not self.cut or (self.lists_read and not self.lists_partial)
def carried(self) -> list[str]:
"""What the repeater relays floods for, as the regions request would list it.
``*`` first when it relays unscoped floods, then every flood-allowed region — the
exact shape :meth:`~meshterm.core.region_store.RegionStore.learn_carried` takes.
"""
wild = self.wildcard
head = [WILDCARD] if wild is not None and wild.flood else []
return head + [row.name for row in self.regions() if row.flood]
# -- editing it ---------------------------------------------------------------
def after(self, command: str, reply: str) -> RegionTable:
"""The table as it stands once the repeater accepted ``command``.
Only the verbs this module spells are understood; anything else (or a refused
reply — see :func:`region_refused`) returns the table unchanged, so a caller may
fold every accepted reply through here without first asking what it was.
Args:
command: The command sent, as built by this module.
reply: The repeater's reply to it.
Returns:
The updated table (a new object; tables are immutable).
"""
if region_refused(reply):
return self
parts = command.split()
if len(parts) < 2 or parts[0] != "region":
return self
verb, args = parts[1], parts[2:]
if verb in ("allowf", "denyf") and args:
return self._with(args[0], flood=verb == "allowf")
if verb == "home" and args:
if self.get(args[0]) is None:
return self
rows = tuple(replace(row, home=row.name == args[0]) for row in self.rows)
return replace(self, rows=rows)
if verb == "default" and args:
if args[0] == NULL_DEFAULT:
return replace(self, default=None, default_known=True)
# The firmware forces flood on for the default region, and creates it at the top
# level if it did not exist.
table = self if self.get(args[0]) else self._put(args[0], WILDCARD, flood=True)
return replace(table._with(args[0], flood=True), default=args[0], default_known=True)
if verb == "put" and args:
parent = args[1] if len(args) > 1 else WILDCARD
# 1.15+ answers "OK - (flood allowed)"; older firmware created it denied.
return self._put(args[0], parent, flood="flood allowed" in reply.lower())
if verb == "remove" and args:
if self.children(args[0]):
return self # the firmware refuses this; a reply claiming otherwise is noise
rows = tuple(row for row in self.rows if row.name != args[0])
default = None if self.default == args[0] else self.default
return replace(self, rows=rows, default=default)
return self
def _with(self, name: str, **changes: object) -> RegionTable:
"""The table with one row's fields changed (unchanged when the row is not here)."""
rows = tuple(replace(row, **changes) if row.name == name else row for row in self.rows)
return replace(self, rows=rows)
def _put(self, name: str, parent: str, *, flood: bool) -> RegionTable:
"""The table with a new region placed as the last child of ``parent``.
Dump order is depth-first, so the new row goes after the parent's last descendant —
exactly where the next ``region`` dump would print it.
"""
if self.get(name) is not None:
return self
above = self.get(parent)
depth = (above.depth + 1) if above is not None and above.placed else 1
row = RegionRow(name=name, parent=parent, depth=depth, flood=flood)
rows = list(self.rows)
at = len(rows)
if above is not None and above.placed:
at = rows.index(above) + 1
while at < len(rows) and rows[at].placed and rows[at].depth > above.depth:
at += 1
else:
# An unplaced parent has no subtree to append to: keep placed rows together and
# put the new one ahead of the unplaced tail.
at = next((i for i, r in enumerate(rows) if not r.placed), len(rows))
rows.insert(at, row)
return replace(self, rows=tuple(rows))
def with_lists(self, allowed: str | None, denied: str | None) -> RegionTable:
"""Fold the two flat lists into a cut table, recovering the regions the cut hid.
A name in a list that the tree did not show joins it unplaced, flooded as its list
says. Names the tree already has keep their tree row — the tree has the parent the
list lacks — and the wildcard's flag is taken from the lists when the tree never
reached it (it always does, being the first line, unless the reply was empty).
Args:
allowed: The reply to :data:`LIST_ALLOWED` (``None`` when it never came).
denied: The reply to :data:`LIST_DENIED` (``None`` when it never came).
Returns:
The table with the recovered rows appended; ``lists_read`` is set only when both
lists answered, and ``lists_partial`` when either may have skipped a name.
"""
rows = list(self.rows)
partial = False
for reply, flood in ((allowed, True), (denied, False)):
if reply is None or region_refused(reply):
continue
partial = partial or list_may_be_incomplete(reply)
for name in parse_name_list(reply):
if any(row.name == name for row in rows):
continue
if name == WILDCARD:
rows.insert(0, RegionRow(name=WILDCARD, parent=None, depth=0, flood=flood))
else:
rows.append(
RegionRow(name=name, parent=None, depth=1, flood=flood, placed=False)
)
both = (
allowed is not None
and denied is not None
and not region_refused(allowed)
and not region_refused(denied)
)
return replace(self, rows=tuple(rows), lists_read=both, lists_partial=partial)
def with_default(self, reply: str | None) -> RegionTable:
"""The table with the default scope read from a :data:`DEFAULT_QUERY` reply.
Unchanged (``default_known`` left ``False``) when there was no reply or the firmware
does not know the query — pre-1.15 answers it with its catch-all ``Err - ??``.
"""
parsed = parse_default_reply(reply)
if parsed is None:
return self
return replace(self, default=parsed or None, default_known=True)
# -- parsing ---------------------------------------------------------------------------
def dump_may_be_cut(text: str) -> bool:
"""Whether a reply came close enough to the 160-byte cap to have been cut short."""
return len(text.encode("utf-8")) >= REPLY_CAP_BYTES - 1 - _CUT_SLACK
def parse_region_dump(text: str | None) -> RegionTable:
"""Parse a ``region`` dump into a table, dropping a line the reply cap cut in half.
Each non-blank line is one region: its leading spaces are its depth, then the name, an
optional ``^`` (home), and an optional ``F`` (flood allowed) after a space. A line's
parent is the nearest line above it one level up; depth 0 is the wildcard. A line that
does not fit the grammar, or whose indentation skips a level, is not guessed at — it
ends the tree there and the table is marked cut, since a dump that stops making sense
has stopped being the dump.
Args:
text: The reply text (``None`` or empty reads as an empty, uncut table).
Returns:
The table. When the reply came within reach of the cap its last line is dropped
unless the reply ended on a newline (so the line is known whole), ``cut`` is set,
and ``cut_after`` names the last region shown.
"""
if not text:
return RegionTable()
raw = text.replace("\r", "").replace("\x00", "")
cut = dump_may_be_cut(raw)
lines = raw.split("\n")
if cut and not raw.endswith("\n") and lines:
lines = lines[:-1] # the line the buffer ran out inside — a name may be half a name
rows: list[RegionRow] = []
stack: list[str] = [] # the open ancestor at each depth
for line in lines:
if not line.strip():
continue
depth = len(line) - len(line.lstrip(" "))
row = _parse_line(line.strip(), depth, stack)
if row is None:
cut = True
break
del stack[depth:]
stack.append(row.name)
rows.append(row)
cut_after = next((row.name for row in reversed(rows)), None) if cut else None
return RegionTable(rows=tuple(rows), cut=cut, cut_after=cut_after)
def _parse_line(body: str, depth: int, stack: list[str]) -> RegionRow | None:
"""One dump line, or ``None`` when it breaks the grammar (see :func:`parse_region_dump`)."""
tokens = body.split()
if not tokens or len(tokens) > 2 or (len(tokens) == 2 and tokens[1] != "F"):
return None
name = tokens[0]
home = name.endswith("^")
name = name.removesuffix("^")
if not name or depth > len(stack):
return None
if depth == 0 and name != WILDCARD:
return None
if depth > 0 and name == WILDCARD:
return None
parent = stack[depth - 1] if depth > 0 else None
return RegionRow(name=name, parent=parent, depth=depth, flood=len(tokens) == 2, home=home)
def parse_name_list(text: str | None) -> list[str]:
"""The names in a ``region list allowed|denied`` reply (``-none-`` reads as empty)."""
if not text or text.strip() == _EMPTY_LIST:
return []
names: list[str] = []
for part in text.replace("\x00", "").split(","):
name = part.strip().removeprefix("#")
if name and name not in names:
names.append(name)
return names
def list_may_be_incomplete(text: str) -> bool:
"""Whether a flat list came near enough its cap that a name may have been skipped.
The firmware appends a name only while ``length + len(name) + 2 < 160`` and skips one
that would not fit — then carries on with the next, so a later short name can still get
in. A skip therefore needs the list to have reached at least ``158 - len(name)`` bytes
for some name of at most :data:`~meshterm.core.regions.MAX_NAME_BYTES`, and a list
shorter than that skipped nothing.
"""
return len(text.encode("utf-8")) >= REPLY_CAP_BYTES - 2 - MAX_NAME_BYTES
def parse_default_reply(text: str | None) -> str | None:
"""The default scope named by a ``region default`` reply.
Returns:
The region name, ``""`` for none (``<null>``), or ``None`` when the reply is
missing, refused, or not a default-scope answer at all.
"""
if not text or region_refused(text):
return None
marker = "default scope is"
lowered = text.lower()
if marker not in lowered:
return None
rest = text[lowered.index(marker) + len(marker) :].strip()
rest = rest.removeprefix("now").strip()
name = rest.split()[0] if rest.split() else ""
return "" if name in ("", NULL_DEFAULT) else name.removeprefix("#")
def region_refused(reply: str | None) -> bool:
"""Whether a reply to a region command is the repeater refusing it.
The region verbs answer ``Err - …`` (``unknown region``, ``not empty``, ``unable to
put``, ``save failed``, and ``??`` for a verb the firmware lacks), which the settings
catalog's own error test does not recognise. Older firmware with no ``region`` command
at all answers its generic unknown-command text.
"""
if reply is None:
return True
lowered = reply.strip().lower()
return lowered.startswith(("err", "??", "unknown command", "error"))
def saves_table(command: str, reply: str | None) -> bool:
"""Whether an accepted command wrote the whole table to flash on its own.
``region save`` does, by definition; ``region default`` does too on firmware 1.15+,
which is the firmware that answers it with ``default scope is now …``.
"""
if reply is None or region_refused(reply):
return False
if command == SAVE_COMMAND:
return True
return command.startswith("region default ") and "default scope is now" in reply.lower()
# -- the commands ------------------------------------------------------------------
def allow_command(name: str) -> str:
"""Relay floods scoped to ``name`` (``*``: relay unscoped floods)."""
return f"region allowf {name}"
def deny_command(name: str) -> str:
"""Stop relaying floods scoped to ``name`` (``*``: stop relaying unscoped floods)."""
return f"region denyf {name}"
def home_command(name: str) -> str:
"""Make ``name`` the repeater's home region (``*`` clears it back to none)."""
return f"region home {name}"
def default_command(name: str | None) -> str:
"""Make ``name`` the default scope for the repeater's own floods, or clear it (``None``)."""
return f"region default {name or NULL_DEFAULT}"
def put_command(name: str, parent: str = WILDCARD) -> str:
"""Add ``name`` under ``parent`` (top level for ``*``). New regions are flood-allowed."""
return f"region put {name}" if parent == WILDCARD else f"region put {name} {parent}"
def remove_command(name: str) -> str:
"""Remove ``name`` — refused by the firmware while it still has sub-regions."""
return f"region remove {name}"
def _is_name_char(ch: str) -> bool:
"""Whether the firmware's ``RegionMap::is_name_char`` accepts ``ch`` in a region name."""
return ch in "-$#" or ch.isdigit() or ord(ch) >= ord("A")
def validate_new_name(name: str, taken: Iterable[str] = ()) -> str:
"""Check a name for ``region put``, the firmware's own rules and one of MeshTerm's.
On top of :func:`~meshterm.core.regions.validate` (length, no wildcard, no private
``$`` names), the firmware refuses a name with any punctuation but ``-`` — its
``is_name_char`` — and ``region put`` of a name that exists *moves* that region rather
than adding one, so a name already in the table is refused here instead.
Args:
name: The name as typed.
taken: Names already in the table.
Returns:
The bare, valid name.
Raises:
RegionNameError: With the reason, in words a reader can act on.
"""
bare = validate(name)
bad = sorted({ch for ch in bare if not _is_name_char(ch)})
if bad:
raise RegionNameError(
f"a repeater refuses {' '.join(bad)} in a region name — letters, digits, and - only"
)
if bare in set(taken):
raise RegionNameError(f"{bare} is already a region here")
return bare
+244
View File
@@ -0,0 +1,244 @@
# SPDX-License-Identifier: Apache-2.0
"""A repeater's region table and region CLI, as the simulator speaks them.
The :class:`~meshterm.core.connection.MockDevice` answers ``region …`` commands and the
anonymous regions request from one of these per simulated repeater, so the node page, the
region editor and the ``regions`` command are all drivable under ``--mock``.
It is a transcription of the firmware rather than an idea of it — ``RegionMap`` and
``CommonCLI::handleRegionCmd`` as of MeshCore 1.15/1.16 — because the parser it feeds
(:mod:`~meshterm.core.region_admin`) is meant for the real thing, and a simulator that
answered more kindly than a repeater would let a parser bug through. So it keeps the
firmware's awkward edges on purpose: the 160-byte reply buffer that cuts a long dump
mid-name, the flat lists that *skip* a name that would not fit, prefix matching on
``allowf``/``denyf``/``home``/``get``, ``put`` of an existing name moving it, ``remove``
refusing a region with children, new regions flood-allowed, ``default`` creating a missing
region and saving the table, and every other edit living in RAM until ``save``
(:meth:`SimulatedRegionMap.reboot` puts the saved table back).
"""
from __future__ import annotations
import copy
from dataclasses import dataclass
from .region_admin import REPLY_CAP_BYTES
from .regions import WILDCARD
#: The firmware's table size (``MAX_REGION_ENTRIES``).
MAX_REGIONS = 32
@dataclass(slots=True)
class _Entry:
"""One ``RegionEntry``: an id, its parent's id, the deny-flood flag, and the name."""
id: int
parent: int
deny_flood: bool
name: str
class SimulatedRegionMap:
"""One simulated repeater's region table and the CLI verbs that edit it."""
def __init__(self, tree: list[tuple[str, str, bool]] | None = None) -> None:
"""Build a table, already saved, from ``(name, parent, flood)`` triples in order.
Args:
tree: The regions, each after its parent (``*`` for top level). ``None`` is an
empty table: a stock repeater relaying unscoped floods and nothing else.
"""
self._wild_deny = False
self._entries: list[_Entry] = []
self._next_id = 1
self._home = 0
self._default = 0
for name, parent, flood in tree or ():
parent_id = 0 if parent == WILDCARD else self._by_name(parent).id # type: ignore[union-attr]
entry = self._put(name, parent_id)
assert entry is not None
entry.deny_flood = not flood
self._saved = self._snapshot()
# -- the RegionMap ------------------------------------------------------------
def _snapshot(self) -> tuple:
return copy.deepcopy(
(self._wild_deny, self._entries, self._next_id, self._home, self._default)
)
def reboot(self) -> None:
"""Drop every unsaved edit, as a power cycle does."""
(self._wild_deny, self._entries, self._next_id, self._home, self._default) = copy.deepcopy(
self._saved
)
def _by_name(self, name: str) -> _Entry | None:
"""``findByName``: an exact match (``*`` is the wildcard, reported as ``None`` here)."""
name = name.removeprefix("#")
return next((e for e in self._entries if e.name.removeprefix("#") == name), None)
def _by_prefix(self, prefix: str) -> _Entry | str | None:
"""``findByNamePrefix``: exact wins, else the *last* prefix match; ``*`` → ``"*"``."""
if prefix == WILDCARD:
return WILDCARD
prefix = prefix.removeprefix("#")
partial = None
for entry in self._entries:
bare = entry.name.removeprefix("#")
if bare == prefix:
return entry
if bare.startswith(prefix):
partial = entry
return partial
def _put(self, name: str, parent_id: int) -> _Entry | None:
"""``putRegion``: refuse bad characters, move an existing name, else append."""
if not all(ch in "-$#" or ch.isdigit() or ord(ch) >= ord("A") for ch in name):
return None
entry = self._by_name(name)
if entry is not None:
if entry.id == parent_id:
return None
entry.parent = parent_id
return entry
if len(self._entries) >= MAX_REGIONS:
return None
entry = _Entry(id=self._next_id, parent=parent_id, deny_flood=True, name=name)
self._next_id += 1
self._entries.append(entry)
return entry
def _dump(self) -> str:
"""``exportTo(reply, 160)``: the tree, cut where the buffer runs out."""
out: list[str] = []
def visit(indent: int, entry_id: int, name: str, deny: bool) -> None:
home = "^" if entry_id == self._home else ""
out.append(
" " * indent + f"{name.removeprefix('#')}{home}" + ("" if deny else " F") + "\n"
)
for child in self._entries:
if child.parent == entry_id:
visit(indent + 1, child.id, child.name, child.deny_flood)
visit(0, 0, WILDCARD, self._wild_deny)
return _capped("".join(out))
def _names(self, *, allowed: bool) -> str:
"""``exportNamesTo(reply, 160, …)``: comma-joined, skipping what will not fit."""
parts: list[str] = []
length = 0
if self._wild_deny != allowed:
parts.append(WILDCARD)
length = 2
for entry in self._entries:
if entry.deny_flood == allowed:
continue
bare = entry.name.removeprefix("#")
size = len(bare.encode("utf-8"))
if length + size + 2 < REPLY_CAP_BYTES:
parts.append(bare)
length += size + 1
return ",".join(parts)
def allowed_names(self) -> str:
"""What the anonymous regions request answers with (``*`` first when unscoped)."""
return self._names(allowed=True)
# -- the CLI ------------------------------------------------------------------
def command(self, text: str) -> str:
"""Answer one ``region …`` command as ``handleRegionCmd`` does."""
parts = text.split()
n = len(parts)
if n == 1:
return self._dump()
verb = parts[1]
if verb == "save":
self._saved = self._snapshot()
return "OK"
if verb in ("allowf", "denyf") and n >= 3:
found = self._by_prefix(parts[2])
if found is None:
return "Err - unknown region"
if found == WILDCARD:
self._wild_deny = verb == "denyf"
else:
found.deny_flood = verb == "denyf" # type: ignore[union-attr]
return "OK"
if verb == "get" and n >= 3:
found = self._by_prefix(parts[2])
if found is None:
return "Err - unknown region"
if found == WILDCARD:
return f" * {'' if self._wild_deny else 'F'}"
parent = next((e for e in self._entries if e.id == found.parent), None) # type: ignore[union-attr]
flag = "" if found.deny_flood else "F" # type: ignore[union-attr]
if parent is not None:
return f" {found.name} ({parent.name}) {flag}" # type: ignore[union-attr]
return f" {found.name} {flag}" # type: ignore[union-attr]
if verb == "home":
if n == 2:
home = next((e.name for e in self._entries if e.id == self._home), WILDCARD)
return f" home is {home}"
found = self._by_prefix(parts[2])
if found is None:
return "Err - unknown region"
self._home = 0 if found == WILDCARD else found.id # type: ignore[union-attr]
return f" home is now {WILDCARD if found == WILDCARD else found.name}" # type: ignore[union-attr]
if verb == "default":
if n == 2:
current = next((e.name for e in self._entries if e.id == self._default), None)
return f" default scope is {current or '<null>'}"
if parts[2] == "<null>":
self._default = 0
self._saved = self._snapshot()
return " default scope is now <null>"
found = self._by_prefix(parts[2])
if found == WILDCARD:
self._wild_deny = False
self._default = 0
self._saved = self._snapshot()
return " default scope is now *"
if found is None:
found = self._put(parts[2], 0)
if found is None:
return "Err - region table full"
found.deny_flood = False # type: ignore[union-attr]
self._default = found.id # type: ignore[union-attr]
self._saved = self._snapshot()
return f" default scope is now {found.name}" # type: ignore[union-attr]
if verb == "put" and n >= 3:
if n >= 4:
parent = self._by_prefix(parts[3])
if parent is None:
return "Err - unknown parent"
parent_id = 0 if parent == WILDCARD else parent.id # type: ignore[union-attr]
else:
parent_id = 0
entry = self._put(parts[2], parent_id)
if entry is None:
return "Err - unable to put"
entry.deny_flood = False
return "OK - (flood allowed)"
if verb == "remove" and n >= 3:
entry = self._by_name(parts[2])
if entry is None:
return "Err - not found"
if any(e.parent == entry.id for e in self._entries):
return "Err - not empty"
self._entries.remove(entry)
return "OK"
if verb == "list" and n >= 3:
if parts[2] not in ("allowed", "denied"):
return "Err - use 'allowed' or 'denied'"
return self._names(allowed=parts[2] == "allowed") or "-none-"
return "Err - ??"
def _capped(text: str) -> str:
"""``BufStream``: bytes past the 159th are dropped, whatever they were in the middle of."""
raw = text.encode("utf-8")[: REPLY_CAP_BYTES - 1]
return raw.decode("utf-8", errors="ignore")
+417
View File
@@ -0,0 +1,417 @@
# SPDX-License-Identifier: Apache-2.0
"""The regions MeshTerm knows by name, and the scope each channel is sent under.
A scoped flood carries no region name — only a transport code keyed on one (see
:mod:`~meshterm.core.regions`) — so *which* region a packet was flooded into can only be
answered by trying the names already known. This store is that list. A name arrives from
wherever MeshTerm meets one:
* ``default`` — the companion's own default flood scope, read or set on Device config;
* ``channel`` — a scope given to a channel, so its messages stay in its region;
* ``repeater`` — a repeater's answer to the regions request, or its ``region`` listing,
which also records *which* repeaters carry it (the node page lists them, and a scoped
send that nothing relayed can say whether anything in earshot was ever heard to carry
its region) — and, beside the regions, each repeater's last whole answer: whether it
relays unscoped floods too, and when it said so (:class:`CarriedAnswer`);
* ``typed`` — a name the reader entered themselves.
The store also holds **channel scopes**: the region a channel's messages are sent under.
The firmware has no per-channel scope (``// TODO: have per-channel send_scope``), so a
client that wants one sets the companion's session scope just before each channel send,
which is what every official app does. The scope is keyed by the channel's intrinsic
identity (:func:`~meshterm.core.channels.channel_identity`), exactly like a mute, so it
follows the channel across slots and two devices sharing the channel share its scope.
Resolution is memoized here too (:meth:`RegionStore.scope_of`): a packet list repaints
often and resolving a scoped frame costs an HMAC per known name, so an answer is kept per
``(body, code)`` until the set of names changes.
Like the other operator state (mutes, watched nodes, admin passwords) this is global
machine state in a small JSON file (``<config_dir>/regions.json``), read once and written
atomically on each real change. It writes from its typed records, never from the document
it read, so a field no record knows is gone at the next save.
"""
from __future__ import annotations
import json
from collections.abc import Iterable
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from pathlib import Path
from typing import ClassVar
from .atomicwrite import write_atomically
from .regions import (
WILDCARD,
RegionNameError,
Scope,
frame_scope,
normalize,
raw_scope_body,
validate,
)
#: Where a region name was learned — the closed set :attr:`KnownRegion.sources` draws from.
SOURCES = ("default", "channel", "repeater", "typed")
#: Resolutions kept before the memo is cleared wholesale (a long capture meets many bodies;
#: a repaint meets the same few hundred again and again).
_MEMO_CAP = 4096
@dataclass(frozen=True)
class KnownRegion:
"""One region MeshTerm knows by name.
Attributes:
name: The bare region name (no ``#``).
sources: Where it was learned, members of :data:`SOURCES`, in first-learned order.
repeaters: The 12-hex node ids of repeaters that were heard to carry it.
learned_at: When it was first learned (UTC).
"""
name: str
sources: tuple[str, ...] = ()
repeaters: tuple[str, ...] = ()
learned_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
#: Fields this record once held and must never hold again under another meaning (see
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
RETIRED: ClassVar[frozenset[str]] = frozenset()
@dataclass(frozen=True)
class CarriedAnswer:
"""The last whole answer one repeater gave about what it carries.
The regions themselves live on :class:`KnownRegion` (``repeaters``), where resolution
wants them; this keeps what does not belong to any one region — whether the repeater
also relays *unscoped* floods (the ``*`` a region list leads with, which names no
region), and when it said so. A repeater with no record here was never asked, which is
a different thing from a repeater that answered with nothing.
Attributes:
node: The repeater's 12-hex node id.
unscoped: Whether its answer included the wildcard.
answered_at: When it answered (UTC).
"""
node: str
unscoped: bool
answered_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
#: Fields this record once held and must never hold again under another meaning.
RETIRED: ClassVar[frozenset[str]] = frozenset()
class RegionStore:
"""Reads and writes the known regions and the channel scopes, memory-first.
Interact through :meth:`names`/:meth:`regions` (what is known), :meth:`learn` and
:meth:`forget` (changing it), :meth:`channel_scope`/:meth:`set_channel_scope` (a
channel's send scope), :meth:`carriers` (which repeaters carry a region), and
:meth:`scope_of` (what a received frame's scope is). :attr:`revision` bumps on every
change, for a screen that memoizes on it.
"""
def __init__(self, path: Path) -> None:
"""Open the store against a JSON file location.
Args:
path: Path to the JSON state file (created lazily on the first change).
"""
self._path = path
self._regions: dict[str, KnownRegion] | None = None
self._channels: dict[str, str] = {}
self._answers: dict[str, CarriedAnswer] = {}
self._memo: dict[tuple[bytes, int], str | None] = {}
self.revision = 0
# -- loading ---------------------------------------------------------------------
@property
def _state(self) -> dict[str, KnownRegion]:
"""The name -> record map, loaded from disk on first access."""
if self._regions is None:
self._regions, self._channels, self._answers = self._load()
return self._regions
def _load(
self,
) -> tuple[dict[str, KnownRegion], dict[str, str], dict[str, CarriedAnswer]]:
"""Parse the file, or start empty on a missing or corrupt one."""
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}, {}, {}
if not isinstance(data, dict):
return {}, {}, {}
regions: dict[str, KnownRegion] = {}
for entry in data.get("regions") or []:
region = _region_from_json(entry)
if region is not None:
regions[region.name] = region
channels: dict[str, str] = {}
for identity, name in (data.get("channels") or {}).items():
try:
channels[str(identity)] = validate(str(name))
except RegionNameError:
continue
answers: dict[str, CarriedAnswer] = {}
for entry in data.get("answers") or []:
answer = _answer_from_json(entry)
if answer is not None:
answers[answer.node] = answer
return regions, channels, answers
# -- what is known ---------------------------------------------------------------
def regions(self) -> list[KnownRegion]:
"""Every known region, in the order names are tried when resolving a frame.
Names the reader chose (a default scope, a channel scope, a typed name) lead, then
names only a repeater has mentioned — the ones this station most likely sends
under are the ones its own frames will carry. Ties keep the order learned.
"""
chosen = [r for r in self._state.values() if set(r.sources) - {"repeater"}]
heard = [r for r in self._state.values() if not set(r.sources) - {"repeater"}]
return chosen + heard
def names(self) -> list[str]:
"""The known region names, in resolution order (see :meth:`regions`)."""
return [r.name for r in self.regions()]
def get(self, name: str) -> KnownRegion | None:
"""The record for one region name, or ``None`` if it is not known."""
return self._state.get(normalize(name))
def carriers(self, name: str) -> tuple[str, ...]:
"""The node ids of the repeaters heard to carry a region (empty when none)."""
region = self.get(name)
return region.repeaters if region else ()
def carried_by(self, node: str) -> list[str]:
"""The regions a repeater was heard to carry, by its node id (12-hex prefix)."""
node = node.lower()[:12]
return [r.name for r in self.regions() if node in r.repeaters]
def answer_of(self, node: str) -> CarriedAnswer | None:
"""The last whole answer a repeater gave (unscoped too, and when), or ``None``.
``None`` means it was never asked — or never answered — which the node page states
as such, rather than reading an empty :meth:`carried_by` as "carries nothing".
"""
self._state # noqa: B018 - load on first access
return self._answers.get(node.lower()[:12])
# -- changing it -----------------------------------------------------------------
def learn(self, name: str, source: str, *, repeater: str | None = None) -> str | None:
"""Record a region name and where it came from; persist only a real change.
Args:
name: The name, bare or ``#``-prefixed. The wildcard and names the firmware
would refuse are ignored (a repeater listing ``*`` teaches no region).
source: One of :data:`SOURCES`.
repeater: For ``source="repeater"``, the node id of the repeater that carries it.
Returns:
The bare name recorded, or ``None`` when nothing was learnable.
"""
if source not in SOURCES:
raise ValueError(f"unknown region source {source!r}")
try:
bare = validate(name)
except RegionNameError:
return None
current = self._state.get(bare) or KnownRegion(name=bare)
sources = current.sources if source in current.sources else current.sources + (source,)
repeaters = current.repeaters
if repeater:
node = repeater.lower()[:12]
if node not in repeaters:
repeaters = repeaters + (node,)
updated = replace(current, sources=sources, repeaters=repeaters)
if self._state.get(bare) != updated:
self._state[bare] = updated
self._changed()
return bare
def learn_carried(self, repeater: str, names: Iterable[str]) -> list[str]:
"""Record what one repeater says it carries, replacing what it said before.
A repeater's list is its whole answer, so a region it no longer names loses that
repeater (and, if nothing else taught it, the region itself). Whether it listed the
wildcard — relays unscoped floods — and when it answered are kept beside the
regions (:meth:`answer_of`).
Args:
repeater: The repeater's node id (12-hex prefix or full key).
names: The regions it listed (the wildcard is recorded as the unscoped flag).
Returns:
The bare region names learned, in the order given.
"""
node = repeater.lower()[:12]
given = [normalize(x) for x in names]
self._state # noqa: B018 - load on first access
self._answers[node] = CarriedAnswer(node=node, unscoped=WILDCARD in given)
self._changed()
wanted = [n for n in given if n and n != WILDCARD]
learned: list[str] = []
for name in wanted:
bare = self.learn(name, "repeater", repeater=node)
if bare:
learned.append(bare)
for region in list(self._state.values()):
if node in region.repeaters and region.name not in learned:
repeaters = tuple(r for r in region.repeaters if r != node)
sources = region.sources
if not repeaters:
sources = tuple(s for s in sources if s != "repeater")
if not sources:
del self._state[region.name]
else:
self._state[region.name] = replace(region, sources=sources, repeaters=repeaters)
self._changed()
return learned
def forget(self, name: str) -> None:
"""Drop a region name entirely, and any channel scope that used it."""
bare = normalize(name)
if bare not in self._state and bare not in self._channels.values():
return
self._state.pop(bare, None)
self._channels = {k: v for k, v in self._channels.items() if v != bare}
self._changed()
# -- channel scopes ----------------------------------------------------------------
def channel_scope(self, channel_id: str | None) -> str | None:
"""The region a channel's messages are sent under, or ``None`` for the default."""
self._state # noqa: B018 - load on first access
return self._channels.get(channel_id) if channel_id else None
def channel_scopes(self) -> dict[str, str]:
"""Every channel scope, channel identity -> region (a copy)."""
self._state # noqa: B018 - load on first access
return dict(self._channels)
def set_channel_scope(self, channel_id: str, name: str | None) -> None:
"""Give a channel a send scope, or clear it back to the device's default.
Args:
channel_id: The channel's intrinsic identity.
name: The region, or ``None``/empty to clear.
Raises:
RegionNameError: If ``name`` is a name the firmware would refuse.
"""
self._state # noqa: B018 - load on first access
if not name or not normalize(name):
if self._channels.pop(channel_id, None) is not None:
self._changed()
return
bare = validate(name)
self.learn(bare, "channel")
if self._channels.get(channel_id) != bare:
self._channels[channel_id] = bare
self._changed()
# -- resolution ------------------------------------------------------------------
def scope_of(self, raw: dict | None) -> Scope | None:
"""The scope of a received frame against every known name (memoized).
Args:
raw: The frame's raw payload (live, or restored from history).
Returns:
As :func:`~meshterm.core.regions.frame_scope`: ``None`` where the frame has no
scope to state (direct, or a route type never kept).
"""
if not isinstance(raw, dict):
return None
route = str(raw.get("route_typename") or "").upper()
if route != "TC_FLOOD":
return frame_scope(raw, ())
body = raw_scope_body(raw)
scope = frame_scope(raw, ()) # parses the code; resolution happens below
if scope is None or scope.code is None or body is None:
return scope
memo_key = (body, int(scope.code, 16))
if memo_key not in self._memo:
if len(self._memo) >= _MEMO_CAP:
self._memo.clear()
self._memo[memo_key] = (frame_scope(raw, self.names()) or scope).region
region = self._memo[memo_key]
return Scope("scoped", region, scope.code) if region else scope
# -- persistence -----------------------------------------------------------------
def _changed(self) -> None:
"""Bump the revision, drop the memo, and persist."""
self.revision += 1
self._memo.clear()
self._save()
def _save(self) -> None:
"""Persist the whole store atomically (a crash mid-write keeps the old file)."""
data = {
"regions": [
{
"name": r.name,
"sources": list(r.sources),
"repeaters": list(r.repeaters),
"learned_at": r.learned_at.astimezone(timezone.utc).isoformat(),
}
for r in self._state.values()
],
"channels": dict(sorted(self._channels.items())),
"answers": [
{
"node": a.node,
"unscoped": a.unscoped,
"answered_at": a.answered_at.astimezone(timezone.utc).isoformat(),
}
for a in self._answers.values()
],
}
write_atomically(self._path, json.dumps(data, indent=2, ensure_ascii=False))
def _answer_from_json(entry: object) -> CarriedAnswer | None:
"""Parse one stored repeater answer, or ``None`` if it is malformed."""
if not isinstance(entry, dict) or not entry.get("node"):
return None
try:
answered_at = datetime.fromisoformat(str(entry["answered_at"]))
except (KeyError, ValueError):
return None
if answered_at.tzinfo is None:
answered_at = answered_at.replace(tzinfo=timezone.utc)
return CarriedAnswer(
node=str(entry["node"]).lower()[:12],
unscoped=bool(entry.get("unscoped")),
answered_at=answered_at,
)
def _region_from_json(entry: object) -> KnownRegion | None:
"""Parse one stored region, or ``None`` if it is malformed."""
if not isinstance(entry, dict):
return None
try:
name = validate(str(entry["name"]))
except (KeyError, RegionNameError):
return None
sources = tuple(s for s in entry.get("sources") or () if s in SOURCES)
repeaters = tuple(str(r).lower()[:12] for r in entry.get("repeaters") or () if r)
try:
learned_at = datetime.fromisoformat(str(entry["learned_at"]))
except (KeyError, ValueError):
learned_at = datetime.now(timezone.utc)
if learned_at.tzinfo is None:
learned_at = learned_at.replace(tzinfo=timezone.utc)
return KnownRegion(name=name, sources=sources, repeaters=repeaters, learned_at=learned_at)
+305
View File
@@ -0,0 +1,305 @@
# SPDX-License-Identifier: Apache-2.0
"""Region scopes: the maths that ties a flood to the region it was sent into.
MeshCore repeaters can be told which *regions* they carry (``region put yul``,
``region allowf yul``), and a flood sent *scoped* to a region is relayed only by the
repeaters that carry it. The scope travels in the packet as a **transport code**: a
``TRANSPORT_FLOOD`` frame (route type 0 — the library's ``TC_FLOOD``) carries two
little-endian ``uint16`` codes between its header and its path, and the first is
HMAC-SHA256(region key, payload_type_byte || payload)[0:2]
with ``0x0000`` and ``0xFFFF`` reserved (they become ``0x0001`` and ``0xFFFE``). The
second code is reserved and always zero today. A region's key is the first 16 bytes of
``SHA-256("#" + name)``, the name taken as its UTF-8 bytes — the firmware stores names
bare and puts the ``#`` in front when it derives the key (``RegionMap::getTransportKeysFor``,
``TransportKeyStore``), and so does every companion app.
Two consequences shape everything built on this module:
* **A code names no region.** It is a keyed hash of the packet's own payload, so the one
way to say which region a scoped packet belongs to is to try the region names already
known and see whose key reproduces it (:func:`resolve`). A packet scoped to a region
nobody here has named reads as *scoped, region unknown* — honestly, and for good.
* **The code survives the trip.** Neither the header nor the path is in the hash, so every
relay's copy carries the sender's code unchanged: one message has one scope however many
ways it arrived.
Direct-routed packets are never region-filtered by a repeater, so a scope matters to a
flood alone; a ``TC_DIRECT`` frame's codes are the ``{0, 0}`` "to nowhere" pair a contact
share uses, not a region.
The vocabulary, fixed here because the UI speaks it everywhere: a **region** is a named
area a repeater carries; a flood's **scope** is the region it is limited to; a plain flood
is **unscoped**, which a repeater's region list spells as the wildcard ``*``.
"""
from __future__ import annotations
import hashlib
import hmac
from collections.abc import Iterable
from dataclasses import dataclass
#: The wildcard — "every region", i.e. unscoped — as repeaters list it and as the send
#: scope's explicit-unscoped override takes it.
WILDCARD = "*"
#: The longest region name, in UTF-8 bytes. The firmware's name slot is 31 bytes with the
#: terminating NUL (``RegionEntry.name[31]``, the default-scope frame's 31-byte field), and
#: ``CMD_SET_DEFAULT_FLOOD_SCOPE`` refuses anything outside 1–30.
MAX_NAME_BYTES = 30
#: Width of a region's transport key, in bytes.
KEY_BYTES = 16
#: The route types that carry transport codes, as the meshcore library names them
#: (``ROUTE_TYPENAMES``): a scoped flood and its direct sibling.
SCOPED_ROUTES = frozenset({"TC_FLOOD", "TC_DIRECT"})
#: The route types a repeater filters by region at all — floods, scoped or not. A direct
#: packet is never region-filtered, so it has no scope to show.
FLOOD_ROUTES = frozenset({"FLOOD", "TC_FLOOD"})
class RegionNameError(ValueError):
"""A region name the firmware would refuse, with the reason in words a reader can act on."""
def normalize(name: str) -> str:
"""A region name as the firmware stores and shows it: trimmed, with no leading ``#``.
Apps and older firmware wrote names as ``#yul``; since firmware 1.12 they are stored
bare and the ``#`` belongs only to key derivation. Case is kept — the hash is over the
exact bytes, so ``YUL`` and ``yul`` are two different regions.
Args:
name: The name as typed, listed by a repeater, or read back from a device.
Returns:
The bare name (possibly empty).
"""
return name.strip().removeprefix("#").strip()
def validate(name: str) -> str:
"""Normalize a region name and refuse one the firmware would refuse.
Args:
name: The name as typed.
Returns:
The bare, valid name.
Raises:
RegionNameError: If the name is empty, too long, private, or the wildcard.
"""
bare = normalize(name)
if not bare:
raise RegionNameError("a region needs a name")
if bare == WILDCARD:
raise RegionNameError("* is every region — leave the scope empty for unscoped")
if bare.startswith("$"):
# Private regions derive their keys from a hardware keystore the firmware has not
# built yet (``RegionMap::loadKeysFor`` is a TODO), so no repeater can match one.
raise RegionNameError("$ regions are private and no firmware can match one yet")
if len(bare.encode("utf-8")) > MAX_NAME_BYTES:
raise RegionNameError(f"a region name is at most {MAX_NAME_BYTES} bytes")
if any(ch.isspace() or ch == "," for ch in bare):
# A repeater lists its regions comma-separated and parses its CLI on whitespace, so
# a name holding either could be stored but never listed or addressed again.
raise RegionNameError("a region name has no spaces or commas")
return bare
def region_key(name: str) -> bytes:
"""The 16-byte transport key of a region, as the firmware derives it.
Args:
name: The region name, with or without its leading ``#``.
Returns:
``SHA-256("#" + bare name)[:16]``, the name taken as UTF-8.
"""
return hashlib.sha256(("#" + normalize(name)).encode("utf-8")).digest()[:KEY_BYTES]
def transport_code(key: bytes, body: bytes) -> int:
"""The transport code a packet carries when scoped under ``key``.
Args:
key: The region's 16-byte transport key (:func:`region_key`).
body: The HMAC input: the payload type byte followed by the payload
(:func:`scope_body`).
Returns:
The 16-bit code, with the two reserved values stepped off.
"""
code = int.from_bytes(hmac.new(key, body, hashlib.sha256).digest()[:2], "little")
if code == 0x0000:
return 0x0001
if code == 0xFFFF:
return 0xFFFE
return code
def scope_body(payload_type: int, payload: bytes) -> bytes:
"""The bytes a transport code is computed over: the payload type, then the payload.
Args:
payload_type: The header's 4-bit payload type (``GRP_TXT`` is 5, ``ADVERT`` 4, …).
payload: The packet's payload — everything after the path.
Returns:
``bytes([payload_type]) + payload``.
"""
return bytes([payload_type & 0x0F]) + bytes(payload)
def parse_codes(hex4: str | None) -> tuple[int, int] | None:
"""Split a frame's 4-byte transport-codes field into its two little-endian codes.
Args:
hex4: The field as the meshcore library reports it (``transport_code``, 8 hex
digits), or ``None`` where the frame carried none.
Returns:
``(code0, code1)``, or ``None`` for an absent or malformed field.
"""
if not hex4:
return None
try:
raw = bytes.fromhex(str(hex4))
except ValueError:
return None
if len(raw) != 4:
return None
return int.from_bytes(raw[0:2], "little"), int.from_bytes(raw[2:4], "little")
@dataclass(frozen=True, slots=True)
class Scope:
"""What a received frame says about the region it was flooded into.
Attributes:
state: ``"unscoped"`` — a plain flood, every repeater may relay it;
``"scoped"`` — a scoped flood whose region is :attr:`region`; ``"unknown"`` — a
scoped flood whose code matches no region known here.
region: The bare region name for a resolved scope, else ``None``.
code: The frame's first transport code (lowercase 4-hex), for a scoped frame.
"""
state: str
region: str | None = None
code: str | None = None
@property
def scoped(self) -> bool:
"""Whether the flood was scoped at all (resolved or not)."""
return self.state != "unscoped"
#: The one unscoped answer, shared (a Scope is immutable).
UNSCOPED = Scope("unscoped")
def resolve(names: Iterable[str], body: bytes, code: int) -> str | None:
"""The first known region whose key reproduces ``code`` over ``body``.
Two distinct regions can collide on a 16-bit code for one payload (one chance in
65 534 per pair), and the firmware has the same ambiguity — a repeater that carries
both relays the packet under whichever it lists first. The first match in ``names``
order is the answer here too; callers order names by how likely they are.
Args:
names: Candidate region names (bare or ``#``-prefixed).
body: The HMAC input (:func:`scope_body`).
code: The frame's first transport code.
Returns:
The matching bare name, or ``None``.
"""
for name in names:
bare = normalize(name)
if bare and bare != WILDCARD and transport_code(region_key(bare), body) == code:
return bare
return None
def frame_scope(raw: dict, names: Iterable[str]) -> Scope | None:
"""The scope of a received frame's raw payload, or ``None`` where it has none.
Reads the route type and transport codes the meshcore library parses out of an
RX-log frame (or that the repository restores for a replayed one), and the HMAC input
— built live from ``payload_type``/``pkt_payload``, or restored as ``scope_body``.
Args:
raw: The frame's raw payload mapping.
names: Region names to resolve a scoped frame against.
Returns:
:data:`UNSCOPED` for a plain flood, a scoped :class:`Scope` for a transport flood,
and ``None`` for a direct-routed frame or one whose route type was never kept —
a repeater never region-filters those, so there is no scope to state.
"""
route = str(raw.get("route_typename") or "").upper()
if route == "FLOOD":
return UNSCOPED
if route != "TC_FLOOD":
return None
codes = parse_codes(raw.get("transport_code"))
if codes is None:
return Scope("unknown")
code0 = codes[0]
body = raw_scope_body(raw)
region = resolve(names, body, code0) if body is not None else None
return Scope("scoped" if region else "unknown", region, f"{code0:04x}")
def raw_scope_body(raw: dict) -> bytes | None:
"""The HMAC input for a frame's raw payload, live or restored from history.
Args:
raw: The frame's raw payload mapping.
Returns:
The body bytes, or ``None`` where neither the live fields nor a stored body exist.
"""
stored = raw.get("scope_body")
if stored:
try:
return bytes.fromhex(str(stored))
except ValueError:
return None
payload_type = raw.get("payload_type")
payload = raw.get("pkt_payload")
if payload_type is None or payload is None or int(payload_type) < 0:
return None
if isinstance(payload, str):
try:
payload = bytes.fromhex(payload)
except ValueError:
return None
return scope_body(int(payload_type), bytes(payload))
def parse_region_list(text: str | None) -> list[str]:
"""The regions a repeater's ``REGIONS`` anonymous-request reply names.
The reply is a comma-separated list of the regions the repeater relays floods for,
``*`` first when it relays unscoped floods too (firmware ``RegionMap::exportNamesTo``).
Args:
text: The reply's text (the meshcore library strips the clock ahead of it).
Returns:
The bare names in the order given, the wildcard kept as ``*``, blanks dropped.
"""
if not text:
return []
names: list[str] = []
for part in str(text).replace("\x00", "").split(","):
bare = normalize(part)
if bare and bare not in names:
names.append(bare)
return names
+104
View File
@@ -0,0 +1,104 @@
# SPDX-License-Identifier: Apache-2.0
"""What every relaunch of MeshTerm needs, whichever terminal it is relaunching into.
MeshTerm reopens itself when the terminal it was started from cannot draw it, and there
are two such terminals for two unrelated reasons. The classic Windows console has no font
fallback at all, so an icon it lacks is a box and no font choice fixes it
(:mod:`meshterm.core.winterminal`). macOS Terminal falls back generously — too generously:
no Mac monospace face carries the Braille Patterns block, so the charts are borrowed from a
*proportional* face and land at the wrong width (:mod:`meshterm.core.macterminal`). One
host shows nothing, the other shows something crooked.
The destinations could hardly be less alike. The act is the same one: work out the command
that would start this session again, hand it an environment a fresh launch can survive, and
let the caller exit. So it is written once, here, and each platform module supplies only
the part that is genuinely its own — which terminal, and how to ask it.
"""
from __future__ import annotations
import os
import sys
#: Set in the reopened session, so a relaunch can never chain into another one. Each
#: platform's own gate would usually refuse a second pass anyway — Windows Terminal is not
#: a classic console; a window opened from MeshTerm's profile is already in it — but this
#: is the belt to those braces, and it costs one environment variable.
REOPENED_ENV = "MESHTERM_REOPENED"
def own_command() -> list[str]:
"""The command that would start this same session again.
A frozen build is its own executable and takes the arguments as they were given. A
development or ``pip`` install is reached through its interpreter, because the console
script's own path is not something the receiving terminal can be relied on to resolve.
Returns:
The program and its arguments, ready to hand to a terminal.
"""
if getattr(sys, "frozen", False):
return [sys.executable, *sys.argv[1:]]
return [sys.executable, "-m", "meshterm", *sys.argv[1:]]
def child_environment() -> dict[str, str]:
"""The environment for a fresh launch of MeshTerm, minus this bundle's bookkeeping.
A PyInstaller one-file build runs in two stages: the executable unpacks itself into a
temporary directory and then runs itself again as a child, the two halves coordinating
through ``_PYI*`` environment variables. Those must not reach a *new* launch of the
executable. The bootloader would find them, conclude it is the second stage of a launch
it never made, check that its parent is the same program — find the terminal instead —
and abort::
[PYI-33368:ERROR] Security validation failure: parent process has different
executable!
Which is the whole session gone, in a brand new window holding a message nobody can act
on, while the window that offered the move has already closed. Removing them makes the
new process a first stage, which is what it actually is.
Returns:
The environment to start the new process with.
"""
environment = dict(os.environ)
environment[REOPENED_ENV] = "1"
if not getattr(sys, "frozen", False):
return environment
for key in [key for key in environment if key.startswith("_PYI")]:
del environment[key]
environment.pop("_MEIPASS2", None) # the name older bootloaders used
# The bootloader points the loader at the unpacked bundle and parks the caller's own
# value beside it. The new process unpacks its own copy, so it wants the original.
for name in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"):
original = environment.pop(f"{name}_ORIG", None)
if original is not None:
environment[name] = original
else:
environment.pop(name, None)
return environment
def wanted_size() -> tuple[int, int]:
"""The window to ask for, as ``(cols, rows)``.
Asked for, because a window MeshTerm opens should be one MeshTerm fits in. A session
that lands in whatever size the reader's shell happens to launch at can arrive too
small for the app, and the failure is quiet and cosmetic: the startup wordmark is 71
columns, so at 70 it silently swaps to the narrow one drawn for the PicoCalc, and a
short window truncates every description onto the pager.
The numbers come from the platform's own stated minimum plus a margin, so they follow
it rather than repeating it. A terminal is free to clamp to what the display can show,
so this is a request and not a demand — on a small screen the app degrades exactly as
it would have anyway.
Returns:
The columns and rows to ask the terminal for.
"""
from ..platforms import get_platform
platform = get_platform()
return platform.readable_cols + 8, platform.readable_rows + 6
+152 -2
View File
@@ -28,6 +28,13 @@ three of its shapes only make sense against that source:
``af`` both read and write ``airtime_factor``, so writing one stales the other
(:attr:`RemoteSetting.overlaps`).
The catalog is not the whole page, though: it is what *any* MeshCore build might expose,
and a node can have settings beyond it (a third-party firmware's own knobs). Those are never
listed here and never probed — they are learned one node at a time from commands the reader
actually ran (:func:`parse_setting_command`, :func:`discovered_setting`), which is why
:attr:`RemoteSetting.discovered` exists and why :func:`learnable_from_get` has to care that
``get`` matches keys by prefix and ``set`` does not.
Replies are parsed *loosely* on purpose: repeater firmware answers tersely and has changed
its phrasing across versions (``"> 20"``, ``"tx: 20"``, ``"OK - repeat is now ON"``), so
values are extracted rather than pattern-matched. Firmware without a given key answers
@@ -45,12 +52,13 @@ from dataclasses import dataclass
#: Reply text (lowercased) that reads as the firmware refusing/unknowing a command. ``??:``
#: is how ``handleGetCmd`` answers a key it doesn't know; ``unknown config:`` is its ``set``
#: twin.
#: twin. ``err - `` is the ``region`` verbs' own refusal (``Err - not empty``).
_ERRORISH = (
"??:",
"unknown",
"error",
"err:",
"err - ",
"invalid",
"denied",
"bad ",
@@ -118,6 +126,9 @@ class RemoteSetting:
part: This setting's position within its composite's comma-joined value.
overlaps: Keys that are another spelling of the same firmware value, whose cached
reading a write to this one makes stale.
discovered: Whether this setting is not in the catalog at all, but was learned from
a command the reader ran on *one node's* command line (see
:func:`discovered_setting`). Never true of a catalog entry.
"""
key: str
@@ -139,6 +150,7 @@ class RemoteSetting:
composite: str = ""
part: int = 0
overlaps: tuple[str, ...] = ()
discovered: bool = False
@property
def get_command(self) -> str:
@@ -531,7 +543,7 @@ REPEATER_SETTINGS: tuple[RemoteSetting, ...] = (
label="Bridge type",
category="Bridge",
writable=False,
help="The bridge this firmware was built with: none, rs232 or espnow",
help="The bridge this firmware was built with: none, rs232, or espnow",
),
RemoteSetting(
key="bridge.enabled",
@@ -616,12 +628,17 @@ KNOWN_COMMANDS: tuple[str, ...] = (
"password ",
"poweroff",
"reboot",
# ``region load`` is left out on purpose: it switches the CLI into a line-by-line
# reading mode ended by a blank line, which a remote command line cannot send.
"region",
"region allowf ",
"region def ",
"region default",
"region denyf ",
"region get ",
"region home",
"region list allowed",
"region list denied",
"region put ",
"region remove ",
"region save",
@@ -671,6 +688,134 @@ def composite_members(composite: str) -> list[RemoteSetting]:
return sorted((s for s in REPEATER_SETTINGS if s.composite == composite), key=lambda s: s.part)
#: The category a discovered setting sorts under, and the heading its section takes.
DISCOVERED_CATEGORY = "Extra"
#: The description lane's text for a discovered setting. The catalog has no explanation to
#: give — all it knows is that this node answered the key — so the row says exactly that, and
#: says it short: the lane is shared with descriptions written to fit a 72-column screen.
DISCOVERED_HELP = "Found on this node"
#: Keys whose value is never written to disk, however it was asked for. ``prv.key`` is the
#: node's own identity: the firmware answers it on the serial console only (``sender_timestamp
#: == 0`` in ``handleGetCmd``), it is a fact rather than a setting, and a reply that reached
#: us anyway must not be the first place a repeater's private key lands in a file. The
#: command line still runs the command and still prints the answer; only the remembering
#: stops here.
NEVER_STORED = frozenset({"prv.key"})
@dataclass(frozen=True, slots=True)
class SettingCommand:
"""A remote-CLI command recognised as a read or a write of one named setting.
Attributes:
verb: ``"get"`` or ``"set"``.
key: The setting's key, as the reader spelled it.
value: What a ``set`` writes; ``""`` for a ``get``.
"""
verb: str
key: str
value: str = ""
def parse_setting_command(command: str) -> SettingCommand | None:
"""Read one typed CLI command as a setting read/write, or ``None`` if it is neither.
The grammar is the filter, and deliberately so: ``get <key>`` and ``set <key> <value>``
are the firmware's own way of saying *this is a setting*, so recognising them costs no
list of what to exclude. Every top-level verb — ``reboot``, ``advert``, ``clock sync``,
``start ota``, ``powersaving``, ``setperm`` — simply isn't this shape and falls out.
A key is held to the spelling the firmware's own keys use, so a mistyped line can't
invent one.
"""
parts = command.strip().split()
if len(parts) < 2 or parts[0] not in ("get", "set"):
return None
key = parts[1]
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9._]*", key):
return None
if parts[0] == "get":
return SettingCommand("get", key) if len(parts) == 2 else None
value = command.strip()[len("set") :].strip()[len(key) :].strip()
return SettingCommand("set", key, value) if value else None
def get_match_keys() -> list[str]:
"""Every spelling a firmware ``get`` matches on, longest first.
A composite's members share their composite's key (``get radio``); verb-shaped settings
aren't ``get`` keys at all.
"""
keys = {s.composite or s.key for s in REPEATER_SETTINGS if s.readable and not s.verb}
return sorted(keys, key=len, reverse=True)
def shadowing_key(key: str) -> str | None:
"""The catalog ``get`` key that would answer a ``get key`` on firmware lacking ``key``.
``handleGetCmd`` compares with ``memcmp(config, "radio", 5)`` — no trailing space — so a
key it doesn't know falls into the branch of any *shorter* key that prefixes it, and
answers with that one's value. ``get radio.rxps`` on stock firmware comes back
``> 910.525,62.5,7,5``: not an error, and not a value of the key that was asked for.
``handleSetCmd`` matches ``"radio "`` *with* the space, so a write has no such shadow.
"""
return next((k for k in get_match_keys() if key != k and key.startswith(k)), None)
def learnable_from_get(key: str, reply: str, known: Mapping[str, str]) -> bool:
"""Whether a ``get key`` reply really came from ``key`` and not from a shorter sibling.
Only asked of a key the catalog doesn't have, where there is no typing to reject a
wrong-shaped answer with. The reply is trusted when nothing shadows the key
(:func:`shadowing_key`), or when it cannot be the shadow's answer — it doesn't parse as
the shadow's type, or it parses to something other than what the shadow last said. A
shadow whose own value was never read leaves the question unanswerable, and an
unanswerable question is not a discovery.
Args:
key: The key the reader asked for.
reply: What the node answered.
known: The node's last-read values, keyed as the cache keys them.
"""
shadow = shadowing_key(key)
if shadow is None:
return True
members = composite_members(shadow)
if not members:
spec = get_setting(shadow)
members = [spec] if spec is not None else []
if not members: # pragma: no cover - every get key is a catalog key
return True
parsed = {m.key: parse_reply_value(m, reply) for m in members}
if any(value is None for value in parsed.values()):
return True # the reply isn't even shaped like the shadow's answer
if any(known.get(k) is None for k in parsed):
return False # it could be the shadow's answer, and there is nothing to compare
return any(known[k] != value for k, value in parsed.items())
def discovered_setting(key: str) -> RemoteSetting:
"""A catalog entry for a key the catalog hasn't got, which one node said it has.
Everything downstream of the catalog — the read plan, the reply parser, the editor's
lanes, staging, :func:`write_plan` — speaks :class:`RemoteSetting`, so a discovered key
becomes one rather than growing a second path beside it. What it carries is only what
was actually learned: the key as the node spells it, and a string value. No range, no
words, no options — the firmware never said, and guessing is how a row would start
lying about a node whose build we have never seen.
"""
return RemoteSetting(
key=key,
label=key,
help=DISCOVERED_HELP,
category=DISCOVERED_CATEGORY,
kind="str",
discovered=True,
)
def reply_is_error(reply: str) -> bool:
"""Whether a reply text reads as the firmware refusing the command.
@@ -884,6 +1029,11 @@ def write_plan(pending: Mapping[str, str], known: Mapping[str, str]) -> list[Wri
else:
joined = ",".join(str(values[m.key]) for m in members)
writes.append(Write(f"set {spec.composite} {joined}", dict(values))) # type: ignore[arg-type]
# A staged key the catalog hasn't got is a discovered one (nothing else can stage a
# row), and it is written the only way anything knows how: its own plain `set`.
for key, value in pending.items():
if get_setting(key) is None:
writes.append(Write(f"set {key} {value}", {key: value}))
return writes
+139 -48
View File
@@ -8,14 +8,19 @@ here, per node (keyed like the admin passwords, by public key), in a small JSON
the config directory (``<config_dir>/remote.json``) alongside each node's remote
command-line history — global machine state, like the other JSON stores, deliberately
outside the per-invocation SQLite database.
The file is read into typed records (:class:`_RemoteNode`, each holding its
:class:`CachedValue` settings) and written back out of them, never round-tripped as the
raw JSON it was read as, so a field no record knows is gone after the next write.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from dataclasses import dataclass, field, replace
from datetime import datetime
from pathlib import Path
from typing import ClassVar
from .admin_store import admin_key
from .atomicwrite import write_atomically
@@ -35,11 +40,93 @@ class CachedValue:
supported: ``False`` when the node answered the read with an error — its firmware
has no such setting (a board without a front-end module, a build without a
bridge). Kept apart from *never read*, which is no entry at all.
discovered: ``True`` for a setting the catalog hasn't got, learned from a command
the reader ran on *this node's* command line. Such a row reads ``n/a`` like any
other when the node stops answering it, and is removed only by hand: its key is
recorded nowhere else, so a misread reply must not be able to delete it.
"""
value: str
read_at: datetime | None
supported: bool = True
discovered: bool = False
#: Fields this record once held and must never hold again under another meaning (see
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
RETIRED: ClassVar[frozenset[str]] = frozenset()
@classmethod
def read(cls, raw: object) -> CachedValue | None:
"""Build a value from one file entry, or ``None`` when it is malformed.
A supported entry must carry its value; an unsupported one carries none.
"""
if not isinstance(raw, dict):
return None
supported = not raw.get("unsupported", False)
if supported and "value" not in raw:
return None
return cls(
value=str(raw.get("value", "")),
read_at=_as_time(raw.get("read_at")),
supported=supported,
discovered=bool(raw.get("discovered", False)),
)
def to_json(self) -> dict:
"""The entry as written, in the file's long-standing spelling.
A supported value carries ``value``; an unsupported one says ``unsupported`` and
carries no value; ``discovered`` appears only when true.
"""
entry: dict = {"value": self.value} if self.supported else {"unsupported": True}
entry["read_at"] = self.read_at.isoformat() if self.read_at is not None else None
if self.discovered:
entry["discovered"] = True
return entry
@dataclass(slots=True)
class _RemoteNode:
"""Everything remembered about one remote node.
Attributes:
settings: Its cached settings, keyed by CLI parameter name.
history: Its remote command-line history, oldest first.
"""
settings: dict[str, CachedValue] = field(default_factory=dict)
history: list[str] = field(default_factory=list)
#: Fields this record once held; none yet (see :attr:`CachedValue.RETIRED`).
RETIRED: ClassVar[frozenset[str]] = frozenset()
@classmethod
def read(cls, raw: object) -> _RemoteNode:
"""Build a node's record from its file entry, taking only the fields it knows."""
record = raw if isinstance(raw, dict) else {}
settings: dict[str, CachedValue] = {}
raw_settings = record.get("settings")
if isinstance(raw_settings, dict):
for key, entry in raw_settings.items():
value = CachedValue.read(entry)
if value is not None:
settings[str(key)] = value
raw_history = record.get("history")
history = [str(c) for c in raw_history] if isinstance(raw_history, list) else []
return cls(settings=settings, history=history)
@property
def empty(self) -> bool:
"""Whether there is nothing left to remember about this node."""
return not self.settings and not self.history
def to_json(self) -> dict:
"""The record as written: its own fields and nothing it happened to be read with."""
return {
"settings": {key: value.to_json() for key, value in self.settings.items()},
"history": list(self.history),
}
class RemoteStore:
@@ -53,86 +140,90 @@ class RemoteStore:
"""
self._path = path
def _load_all(self) -> dict[str, dict]:
"""Return the raw key -> record mapping, or empty on missing/corrupt file."""
def _load(self) -> dict[str, _RemoteNode]:
"""Read every node's record, keyed by :func:`admin_key`; empty on trouble."""
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
if not isinstance(data, dict):
return {}
return {str(key): _RemoteNode.read(raw) for key, raw in data.items()}
def _node(self, node: Contact) -> _RemoteNode:
"""One node's record, or an empty one when nothing is remembered."""
return self._load().get(admin_key(node)) or _RemoteNode()
# -- the settings cache -------------------------------------------------------
def settings(self, node: Contact) -> dict[str, CachedValue]:
"""Every remembered setting for ``node``, keyed by CLI parameter name."""
record = self._load_all().get(admin_key(node))
raw = record.get("settings") if isinstance(record, dict) else None
out: dict[str, CachedValue] = {}
if not isinstance(raw, dict):
return out
for key, entry in raw.items():
if not isinstance(entry, dict):
continue
supported = not entry.get("unsupported", False)
if supported and "value" not in entry:
continue
read_at: datetime | None = None
stamp = entry.get("read_at")
if isinstance(stamp, str):
try:
read_at = datetime.fromisoformat(stamp)
except ValueError:
read_at = None
out[key] = CachedValue(
value=str(entry.get("value", "")), read_at=read_at, supported=supported
)
return out
return dict(self._node(node).settings)
def remember_setting(self, node: Contact, key: str, value: str) -> None:
"""Cache one setting's value for ``node``, stamped now."""
self._put(node, key, {"value": value, "read_at": utcnow().isoformat()})
self._put(node, key, CachedValue(value=value, read_at=utcnow()))
def remember_discovered(self, node: Contact, key: str, value: str) -> None:
"""Cache a setting the catalog hasn't got, which ``node`` has just proved it has."""
self._put(node, key, CachedValue(value=value, read_at=utcnow(), discovered=True))
def remember_unsupported(self, node: Contact, key: str) -> None:
"""Record that ``node`` answered a read of ``key`` with an error, stamped now."""
self._put(node, key, {"unsupported": True, "read_at": utcnow().isoformat()})
self._put(node, key, CachedValue(value="", read_at=utcnow(), supported=False))
def forget_setting(self, node: Contact, key: str) -> None:
"""Drop one cached setting, so the row reads as never read."""
records = self._load_all()
settings = records.get(admin_key(node), {}).get("settings")
if isinstance(settings, dict) and settings.pop(key, None) is not None:
records = self._load()
record = records.get(admin_key(node))
if record is not None and record.settings.pop(key, None) is not None:
self._write(records)
def _put(self, node: Contact, key: str, entry: dict) -> None:
"""Store one setting's cache entry for ``node``."""
records = self._load_all()
record = records.setdefault(admin_key(node), {})
settings = record.setdefault("settings", {})
settings[key] = entry
def _put(self, node: Contact, key: str, value: CachedValue) -> None:
"""Store one setting's cache entry for ``node``, keeping it discovered if it was.
A discovered row is refreshed by the same reads and writes as any other — through
:meth:`remember_setting` — and that must not quietly demote it to a catalog row it
has no entry for, which would leave a row nothing draws.
"""
records = self._load()
record = records.setdefault(admin_key(node), _RemoteNode())
previous = record.settings.get(key)
if previous is not None and previous.discovered:
value = replace(value, discovered=True)
record.settings[key] = value
self._write(records)
# -- the command-line history ---------------------------------------------------
def history(self, node: Contact) -> list[str]:
"""The node's remote CLI history, oldest first."""
record = self._load_all().get(admin_key(node))
raw = record.get("history") if isinstance(record, dict) else None
return [str(c) for c in raw] if isinstance(raw, list) else []
return list(self._node(node).history)
def append_history(self, node: Contact, command: str) -> None:
"""Append one sent command to the node's history (dropping an adjacent dupe)."""
command = command.strip()
if not command:
return
records = self._load_all()
record = records.setdefault(admin_key(node), {})
history = record.setdefault("history", [])
if history and history[-1] == command:
records = self._load()
record = records.setdefault(admin_key(node), _RemoteNode())
if record.history and record.history[-1] == command:
return # re-running the last command shouldn't stutter the recall
history.append(command)
del history[:-HISTORY_CAP]
record.history.append(command)
del record.history[:-HISTORY_CAP]
self._write(records)
def _write(self, records: dict[str, dict]) -> None:
def _write(self, records: dict[str, _RemoteNode]) -> None:
"""Persist ``records`` atomically (crash mid-write keeps the previous file)."""
write_atomically(self._path, json.dumps(records, indent=2))
data = {key: record.to_json() for key, record in records.items() if not record.empty}
write_atomically(self._path, json.dumps(data, indent=2))
def _as_time(value: object) -> datetime | None:
"""Parse a stored ISO-8601 timestamp, or ``None`` if absent or corrupt."""
if not isinstance(value, str):
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
+39 -4
View File
@@ -112,9 +112,17 @@ def resolve_device(
3. ``explicit_port`` (an explicit ``--port``).
4. A TCP profile's ``host:port``, or a serial profile's ``port``.
5. The remembered "last known good" device, if it is currently attached/in range.
6. The single attached device, if exactly one is present.
6. The single *likely-LoRa* device, if exactly one is present — a port whose USB
vendor marks it a board or bridge, or a BLE/TCP endpoint. Ports that look like
nothing in particular are ignored at this step, because a platform may present
some unconditionally (every Mac carries two) and they would otherwise make the
count ambiguous forever.
7. The single attached device, if exactly one is present and *none* looked likely —
so an unrecognized-but-real adapter still resolves.
Otherwise a :class:`DeviceSelectionError` is raised listing the candidates.
Otherwise a :class:`DeviceSelectionError` is raised listing the candidates: the
likely ones where there are any, else everything attached under a message that does
not claim they are companions.
Args:
devices: Currently discovered devices (serial and/or BLE).
@@ -155,7 +163,23 @@ def resolve_device(
if match is not None:
return Resolution(match.target, match, "remembered", match.transport)
if len(devices) == 1:
# Prefer the devices that actually look like companions. macOS is why this matters:
# every Mac permanently presents /dev/cu.Bluetooth-Incoming-Port and
# /dev/cu.debug-console, which carry no USB VID/PID and so score "unknown". Counting
# them put `len(devices) == 1` out of reach there — a Mac with exactly one real board
# attached saw three devices and refused to choose, so auto-detection could never fire
# on that platform and every Mac user met "Multiple companion devices detected" on a
# first run. Which device is plausible is already known, and already printed in the
# listing below as "[likely LoRa]"; this decides with it instead of only saying it.
likely = [d for d in devices if d.is_likely_lora]
if len(likely) == 1:
return Resolution(likely[0].target, likely[0], "only", likely[0].transport)
# Nothing recognizable: fall back to the whole list rather than narrowing to nothing,
# so a lone *unrecognized* adapter — a UART bridge carrying a VID we don't list, which
# is a real board more often than not — still connects exactly as it always has.
if not likely and len(devices) == 1:
return Resolution(devices[0].target, devices[0], "only", devices[0].transport)
if not devices:
@@ -164,9 +188,20 @@ def resolve_device(
"or pass --port / --ble explicitly, or use --mock for the simulator."
)
if not likely:
# Several ports attached, none of them plausible. "Multiple companion devices" is
# simply false here: on a bare Mac it names two virtual ports that are not
# companions at all, and then tells the reader to choose one of them.
raise DeviceSelectionError(
"No companion devices detected among the attached ports.\n"
f"{_format_device_list(devices)}\n"
"Connect a companion device (USB or Bluetooth), or pass --port / --ble "
"explicitly if one of these is your companion, or use --mock for the simulator."
)
raise DeviceSelectionError(
"Multiple companion devices detected and no default to fall back on.\n"
f"{_format_device_list(devices)}\n"
f"{_format_device_list(likely)}\n"
"Choose one with --port <PORT> or --ble <ADDRESS> (or run 'meshterm devices' to "
"inspect them). The chosen device is remembered as the default after it connects."
)
+14 -3
View File
@@ -34,7 +34,13 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any
from .atomicwrite import write_atomically
from .device_config import DeviceConfigError, format_value, get_spec, parse_value
from .device_config import (
DeviceConfigError,
format_value,
get_spec,
is_setting_key,
parse_value,
)
if TYPE_CHECKING:
from .connection import Device
@@ -86,9 +92,14 @@ class SettingsStore:
for pubkey, entries in (data.get("devices") or {}).items():
if not isinstance(entries, dict):
continue
# Keep only plain scalar values; a container or null is a malformed entry.
# Keep only plain scalar values of settings the registry knows: a container or
# null is a malformed entry, and a key that is no longer a setting (or a typo)
# is not something to offer to write back onto the radio. Whatever is left out
# here is gone from the file at its next save.
values = {
str(key): value for key, value in entries.items() if isinstance(value, _SCALARS)
str(key): value
for key, value in entries.items()
if isinstance(value, _SCALARS) and is_setting_key(str(key))
}
if values:
devices[_norm(str(pubkey))] = values
+632
View File
@@ -0,0 +1,632 @@
# SPDX-License-Identifier: Apache-2.0
"""A LoRa radio on the host's own SPI bus, driven by a node MeshTerm runs only while it runs.
Some boards — the uConsole's AIO is the one this was built for — put the radio chip straight
on the host's SPI bus, with no microcontroller and so no companion firmware to talk to. A
software node has to run somewhere. The standalone ``meshterm-spi-bridge`` service runs one
all the time, which keeps the node on the mesh while MeshTerm is closed but holds the radio's
pins for good, so no other LoRa program can use them until the service is stopped.
This is the other shape: **MeshTerm runs the node itself, for exactly as long as it is
connected.** :class:`SpiRadioDevice` starts the node (:mod:`meshterm.core.radionode`) as a
child process when it connects, talks to it over the ordinary companion protocol on a
loopback port — so every feature works exactly as it does against a network companion — and
ends it when it disconnects. The pins go back when MeshTerm quits, and also when it crashes:
the node exits with its parent (see :mod:`~meshterm.core.radionode`), and the kernel frees a
process's GPIO lines and SPI handle when it exits.
**Why a child process and not a library call.** The radio library (``openhop-core``) is an
optional install, deliberately not something every desktop MeshTerm carries, and the
one-file build can't import it at all. So the node runs under whichever Python *has* the
library (:func:`find_runtime`): MeshTerm's own when it was installed with the ``spi``
extra, the bridge's venv, or the ``meshcore-uconsole`` package's. A separate process also
keeps the node's radio timing off the event loop that draws the screen.
**One radio, one identity.** A node's state — its identity key, preferences, channels and
contacts — lives under ``<config_dir>/radio/<spidev>/``, keyed by the SPI device rather than
by a profile name, so reaching the same radio from the picker one day and from a profile the
next is still the same node. The first time a radio is used, the bridge's identity and
contacts are copied in (:func:`import_bridge_state`) so an existing node keeps its key, and
with it its place in everyone else's contact list.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import shutil
import subprocess
import sys
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
from .config import DeviceProfile, SpiWiring
from .connection import DeviceCommandError, MeshCoreDevice
from .discovery import DiscoveredDevice, spi_device
_log = logging.getLogger(__name__)
#: The radio library the node needs, as ``pip`` spells it — what every "install it" hint says.
RUNTIME_PACKAGE = "openhop-core[hardware]"
#: How long a starting node gets to report ready. It imports the radio library, brings the
#: chip up (retrying a busy pin a few times) and starts listening; on a PicoCalc-class board
#: the import alone is several seconds.
READY_TIMEOUT_S = 60.0
#: How long a node gets to shut down on its own after its stdin closes, before it is sent
#: SIGTERM, and then again before SIGKILL. Its own shutdown saves contacts and frees the
#: radio; the signals are for a node that has stopped answering.
STOP_GRACE_S = 8.0
TERM_GRACE_S = 3.0
#: What the node is seeded with the first time a radio is used and it has no preferences of
#: its own: MeshCore's US/Canada preset at the AIO's 22 dBm. Afterwards the node's saved
#: settings rule, edited on Device config like any companion's.
DEFAULT_SEED: dict[str, object] = {
"frequency_hz": 910_525_000,
"bandwidth_hz": 62_500,
"spreading_factor": 7,
"coding_rate": 5,
"tx_power_dbm": 22,
}
#: The standalone bridge's service name and the directory it keeps its state in.
BRIDGE_SERVICE = "meshterm-spi-bridge.service"
BRIDGE_NAME = "meshterm-spi-bridge"
#: ``MESHCORE_*`` variables the bridge reads, mapped to the node preference each seeds.
_BRIDGE_RADIO_ENV = {
"MESHCORE_FREQUENCY": "frequency_hz",
"MESHCORE_BANDWIDTH": "bandwidth_hz",
"MESHCORE_SPREADING_FACTOR": "spreading_factor",
"MESHCORE_CODING_RATE": "coding_rate",
"MESHCORE_TX_POWER": "tx_power_dbm",
}
class SpiRadioError(DeviceCommandError):
"""The node could not be started, worded for the person who has to fix it.
A :class:`~meshterm.core.connection.DeviceCommandError`, because that is what the
startup picker and the connect path pass through as an *actionable* failure — shown
with its remedy — rather than folding it into "didn't answer".
Attributes:
kind: What went wrong — ``unsupported``, ``runtime``, ``no-spi``, ``no-gpio``,
``permission``, ``busy``, or ``failed`` (see :func:`explain`).
"""
def __init__(self, kind: str, message: str) -> None:
"""Carry ``message`` as the error text and ``kind`` as its classification."""
super().__init__(message)
self.kind = kind
# --- where things are ----------------------------------------------------------------------
def node_script() -> Path:
"""The node's source file, which is run by path under another interpreter.
It sits beside this module in a checkout and in an install, and the one-file build
ships it as a data file at the same relative place (``packaging/meshterm.spec``).
"""
return Path(__file__).with_name("radionode.py")
def state_dir(config_dir: Path, wiring: SpiWiring) -> Path:
"""Where the node on ``wiring``'s SPI device keeps its identity, settings and lists."""
return config_dir / "radio" / Path(wiring.spidev).name
def _on_linux() -> bool:
"""Whether this is Linux, the one place a radio on the SPI bus can be driven from."""
return sys.platform.startswith("linux")
def _xdg_data_home() -> Path:
"""``$XDG_DATA_HOME``, or ``~/.local/share`` where it is unset."""
raw = os.environ.get("XDG_DATA_HOME", "").strip()
return Path(raw) if raw else Path.home() / ".local" / "share"
def spi_present(wiring: SpiWiring) -> bool:
"""Whether ``wiring``'s SPI device node exists on this machine (Linux only)."""
return _on_linux() and os.path.exists(wiring.spidev)
def spi_radios(
profiles: Mapping[str, DeviceProfile] | None, listed: Iterable[DiscoveredDevice] = ()
) -> list[DiscoveredDevice]:
"""The radios on this machine's own SPI bus, as devices — THE one answer to "which are here".
One per ``transport = "spi"`` profile whose ``/dev/spidev*`` node exists, named by the
profile; and, where no profile covers it, one for the uConsole AIO's device node when it
exists, wired with the defaults. Nothing is probed — a radio on the bus has no node
running until one is chosen — so a radio is listed exactly when there is a device node
to open. The device screen and ``meshterm devices`` both list from here, so the two can
never disagree about what is attached.
Args:
profiles: The configured profiles, or ``None`` when none are loaded.
listed: Devices already gathered; a radio among them is not listed twice.
Returns:
One SPI :class:`~meshterm.core.discovery.DiscoveredDevice` per radio not in ``listed``.
"""
seen = {d.stable_id for d in listed}
wirings = [(p.spi or SpiWiring(), p.name) for p in (profiles or {}).values() if p.is_spi]
covered = {wiring.spidev for wiring, _name in wirings}
if SpiWiring().spidev not in covered:
wirings.append((SpiWiring(), ""))
radios: list[DiscoveredDevice] = []
for wiring, name in wirings:
if not spi_present(wiring):
continue
device = spi_device(wiring, name=name)
if device.stable_id in seen:
continue
seen.add(device.stable_id)
radios.append(device)
return radios
# --- finding a Python with the radio library -----------------------------------------------
#: Each interpreter probed this session: path -> whether it can import the radio library.
_probed: dict[str, bool] = {}
def _runtime_candidates(wiring: SpiWiring) -> list[str]:
"""Interpreters worth probing for the radio library, most specific first."""
candidates: list[str] = []
if wiring.python:
candidates.append(os.path.expanduser(wiring.python))
if not getattr(sys, "frozen", False):
candidates.append(sys.executable) # MeshTerm's own, installed with the ``spi`` extra
data = _xdg_data_home()
candidates += [
str(data / BRIDGE_NAME / "venv" / "bin" / "python"),
"/opt/venvs/meshcore-uconsole/bin/python",
]
system = shutil.which("python3")
if system:
candidates.append(system)
seen: set[str] = set()
return [c for c in candidates if c and not (c in seen or seen.add(c))]
def _can_import_runtime(python: str) -> bool:
"""Whether ``python`` can import ``openhop_core`` (one short subprocess, then cached)."""
if python in _probed:
return _probed[python]
ok = False
if os.path.exists(python):
try:
result = subprocess.run(
[python, "-c", "import openhop_core"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=child_env(),
timeout=30,
check=False,
)
ok = result.returncode == 0
except (OSError, subprocess.SubprocessError):
ok = False
_probed[python] = ok
return ok
def find_runtime(wiring: SpiWiring) -> str:
"""An interpreter that can run the node, or raise :class:`SpiRadioError` (``runtime``).
A ``python`` named in the wiring is the only candidate when it is set — naming one is a
decision, and quietly falling back past it would hide that it doesn't work.
"""
candidates = _runtime_candidates(wiring)
if wiring.python:
candidates = candidates[:1]
for python in candidates:
if _can_import_runtime(python):
return python
if wiring.python:
raise SpiRadioError("runtime", f"{wiring.python} cannot import openhop_core")
raise SpiRadioError("runtime", "no Python on this machine has the radio library")
def child_env() -> dict[str, str]:
"""The environment for a program MeshTerm starts, minus what a frozen build injected.
The one-file build's bootloader leaves its own variables behind (``_PYI_*``,
``_MEIPASS2``) and points ``LD_LIBRARY_PATH`` at its unpacked libraries; another Python
started with those loads MeshTerm's bundled copies of libssl and friends instead of its
own. PyInstaller keeps the original under ``LD_LIBRARY_PATH_ORIG``, which is put back.
"""
env = {k: v for k, v in os.environ.items() if not (k.startswith("_PYI") or k == "_MEIPASS2")}
if getattr(sys, "frozen", False):
original = env.pop("LD_LIBRARY_PATH_ORIG", None)
if original is not None:
env["LD_LIBRARY_PATH"] = original
else:
env.pop("LD_LIBRARY_PATH", None)
env["PYTHONUNBUFFERED"] = "1"
return env
# --- who else has the radio ----------------------------------------------------------------
def radio_holder() -> str | None:
"""Name the program already holding the radio, when it is one we know how to stop.
Asked before starting the node, because the node's own answer to a held pin is a bare
"busy" after several seconds of retries, and the two usual holders each have a one-line
fix worth saying instead.
"""
if not _on_linux():
return None
systemctl = shutil.which("systemctl")
if systemctl:
try:
active = subprocess.run(
[systemctl, "--user", "is-active", "--quiet", BRIDGE_SERVICE],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
check=False,
)
if active.returncode == 0:
return "bridge"
except (OSError, subprocess.SubprocessError):
pass
for cmdline in Path("/proc").glob("[0-9]*/cmdline"):
try:
argv = cmdline.read_bytes().split(b"\0")
except OSError:
continue
if any(os.path.basename(a) == b"meshcore-console" for a in argv[:3]):
return "console"
return None
def explain(kind: str, detail: str, wiring: SpiWiring, holder: str | None = None) -> str:
"""The sentence a failed start is reported with: what is wrong and what to do about it."""
if kind == "busy" or holder:
if holder == "bridge":
return (
f"the {BRIDGE_NAME} service has the radio — stop it with "
f"`systemctl --user stop {BRIDGE_NAME}` (and `disable` it to keep it stopped)"
)
if holder == "console":
return "meshcore-console has the radio open — close it and connect again"
return (
"another program has the radio's pins "
f"(`sudo lsof /dev/gpiochip{wiring.gpio_chip}` shows which)"
)
if kind == "runtime":
return (
f"{detail}. Install it with `pipx inject mesh-term '{RUNTIME_PACKAGE}'`, or into a "
"venv of its own and name that venv's python as `python` in the profile's spi table"
)
if kind == "no-spi":
return (
f"{wiring.spidev} does not exist — enable SPI (on a uConsole, add "
"`dtoverlay=spi1-1cs` to /boot/firmware/config.txt) and reboot"
)
if kind == "no-gpio":
return f"/dev/gpiochip{wiring.gpio_chip} does not exist — check `gpio_chip` in the profile"
if kind == "permission":
return (
f"{detail} — add yourself to the spi and gpio groups "
"(`sudo usermod -aG spi,gpio $USER`) and log in again"
)
if kind == "unsupported":
return detail
return f"the radio node failed: {detail}"
# --- first use: carrying over the bridge's node --------------------------------------------
def _bridge_radio_seed() -> dict[str, int]:
"""The radio settings the bridge service runs with, from its unit's environment.
The bridge takes its frequency, preset and power from ``MESHCORE_*`` variables, usually
set in a drop-in; a node that inherits the bridge's identity should come up on the same
channel of air, not on the default preset. Empty when there is no such service.
"""
systemctl = shutil.which("systemctl")
if not systemctl:
return {}
try:
shown = subprocess.run(
[systemctl, "--user", "show", "--property=Environment", BRIDGE_SERVICE],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
encoding="utf-8",
timeout=5,
check=False,
)
except (OSError, subprocess.SubprocessError):
return {}
seed: dict[str, int] = {}
for word in shown.stdout.removeprefix("Environment=").split():
name, _, value = word.partition("=")
if name in _BRIDGE_RADIO_ENV:
try:
seed[_BRIDGE_RADIO_ENV[name]] = int(value)
except ValueError:
continue
return seed
def import_bridge_state(state: Path) -> list[str]:
"""Copy the bridge's node into a radio's state directory the first time it is used.
Runs only while the state directory has no identity yet, and copies rather than moves, so
the bridge still works for anyone who goes back to it. The identity is looked for where
the bridge itself looks, in its order: the ``meshcore-uconsole`` GUI's key first (the
bridge shares it), then the bridge's own. Contacts come from the bridge's snapshot, whose
records are the radio library's own contact dicts, so they load unchanged. Radio settings
are seeded from the service's environment, and the node keeps the bridge's name.
Args:
state: The radio's state directory.
Returns:
What was carried over, for the log — empty when nothing was.
"""
if (state / "identity.key").exists():
return []
data = _xdg_data_home()
bridge = data / BRIDGE_NAME
carried: list[str] = []
for key in (data / "meshcore-uconsole" / "identity.key", bridge / "identity.key"):
if key.is_file():
state.mkdir(parents=True, exist_ok=True)
shutil.copyfile(key, state / "identity.key")
os.chmod(state / "identity.key", 0o600)
carried.append(f"identity from {key}")
break
if not carried:
return []
contacts = bridge / "contacts.json"
if contacts.is_file() and not (state / "contacts.json").exists():
shutil.copyfile(contacts, state / "contacts.json")
carried.append(f"contacts from {contacts}")
if not (state / "prefs.json").exists():
prefs: dict[str, object] = {"node_name": "uConsole", **_bridge_radio_seed()}
(state / "prefs.json").write_text(json.dumps(prefs, indent=2), encoding="utf-8")
carried.append("the bridge's name and radio settings")
return carried
# --- the node process ----------------------------------------------------------------------
@dataclass
class NodeProcess:
"""One running node: the child process and the loopback port it serves on.
Attributes:
process: The child.
port: The loopback TCP port its frame server listens on.
public_key: The node's public key, hex, as it reported it.
log_path: Where its stderr goes.
"""
process: asyncio.subprocess.Process
port: int
public_key: str
log_path: Path
@property
def alive(self) -> bool:
"""Whether the child is still running."""
return self.process.returncode is None
async def stop(self) -> None:
"""End the node and wait for it: close its stdin, then SIGTERM, then SIGKILL.
Closing stdin is the polite request — the node saves its contacts and frees the radio
on the way out. The signals are for a node that has stopped listening, and each wait
is bounded so a wedged child can never hold up a quit.
"""
proc = self.process
if proc.returncode is not None:
return
if proc.stdin is not None:
proc.stdin.close()
for grace, escalate in ((STOP_GRACE_S, proc.terminate), (TERM_GRACE_S, proc.kill)):
try:
await asyncio.wait_for(proc.wait(), timeout=grace)
return
except asyncio.TimeoutError:
try:
escalate()
except ProcessLookupError:
return
await proc.wait()
def _rotate(log_path: Path) -> None:
"""Keep the previous run's node log beside this one's, and no more."""
if log_path.exists():
try:
log_path.replace(log_path.with_suffix(".log.1"))
except OSError:
pass
async def start_node(wiring: SpiWiring, state: Path, *, node_name: str) -> NodeProcess:
"""Start the node on ``wiring``'s radio and wait until it is listening.
Args:
wiring: How the radio is wired.
state: The radio's state directory.
node_name: The name a brand-new node advertises until it is renamed.
Returns:
The running node.
Raises:
SpiRadioError: If it can't be started, with the reason already worded.
"""
if not _on_linux():
raise SpiRadioError("unsupported", "a radio on the SPI bus needs Linux")
# Each of these runs a short subprocess or two; none of them belongs on the event loop
# that is drawing the "connecting" card.
holder = await asyncio.to_thread(radio_holder)
if holder:
raise SpiRadioError("busy", explain("busy", "", wiring, holder))
try:
python = await asyncio.to_thread(find_runtime, wiring)
except SpiRadioError as exc:
raise SpiRadioError(exc.kind, explain(exc.kind, str(exc), wiring)) from None
state.mkdir(parents=True, exist_ok=True)
for line in await asyncio.to_thread(import_bridge_state, state):
_log.info("spi radio: carried over %s", line)
config = {
"state_dir": str(state),
"wiring": {
"bus_id": wiring.bus_id,
"cs_id": wiring.cs_id,
"cs_pin": wiring.cs_pin,
"gpio_chip": wiring.gpio_chip,
"use_gpiod_backend": wiring.use_gpiod_backend,
"reset_pin": wiring.reset_pin,
"busy_pin": wiring.busy_pin,
"irq_pin": wiring.irq_pin,
"txen_pin": wiring.txen_pin,
"rxen_pin": wiring.rxen_pin,
"en_pins": list(wiring.en_pins) or None,
"use_dio2_rf": wiring.use_dio2_rf,
"use_dio3_tcxo": wiring.use_dio3_tcxo,
"is_waveshare": wiring.is_waveshare,
},
"seed": {"node_name": node_name, **DEFAULT_SEED},
}
log_path = state / "node.log"
_rotate(log_path)
_log.info("spi radio: starting the node under %s (log %s)", python, log_path)
with log_path.open("wb") as log_file:
proc = await asyncio.create_subprocess_exec(
python,
str(node_script()),
"--config",
json.dumps(config),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=log_file,
env=child_env(),
start_new_session=True, # ^C in the terminal is MeshTerm's to handle, not the node's
)
node = NodeProcess(process=proc, port=0, public_key="", log_path=log_path)
try:
line = await asyncio.wait_for(proc.stdout.readline(), timeout=READY_TIMEOUT_S)
except asyncio.CancelledError:
proc.kill() # abandoned mid-start (a probe's own deadline): nothing may keep the radio
raise
except asyncio.TimeoutError:
await node.stop()
detail = f"it did not start within {READY_TIMEOUT_S:.0f}s (see {log_path})"
raise SpiRadioError("failed", explain("failed", detail, wiring)) from None
try:
status = json.loads(line) if line else {}
except ValueError:
status = {}
if status.get("event") != "ready":
await node.stop()
kind = str(status.get("kind") or "failed")
detail = str(status.get("message") or f"it exited without a word (see {log_path})")
holder = await asyncio.to_thread(radio_holder) if kind == "busy" else None
raise SpiRadioError(kind, explain(kind, detail, wiring, holder))
node.port = int(status["port"])
node.public_key = str(status.get("public_key") or "")
_log.info("spi radio: node %s… ready on 127.0.0.1:%d", node.public_key[:16], node.port)
return node
# --- the device ----------------------------------------------------------------------------
class SpiRadioDevice(MeshCoreDevice):
"""A companion connection to a node MeshTerm starts on the host's own radio.
Underneath it is exactly a network companion on ``127.0.0.1`` — every command, event
and trace goes through :class:`MeshCoreDevice`'s TCP path unchanged — with the node's
lifetime tied to the connection's: :meth:`connect` starts it, :meth:`disconnect` ends
it, and a reconnect (which disconnects and builds a fresh device) starts a fresh one.
"""
def __init__(self, wiring: SpiWiring, state: Path, *, node_name: str = "MeshTerm") -> None:
"""Prepare the device; nothing is started until :meth:`connect`.
Args:
wiring: How the radio is wired.
state: The radio's state directory (see :func:`state_dir`).
node_name: The name a brand-new node advertises until it is renamed.
"""
super().__init__(transport="tcp", host="127.0.0.1", tcp_port=0)
self._wiring = wiring
self._state = state
self._node_name = node_name
self._node: NodeProcess | None = None
@property
def transport(self) -> str:
"""``"spi"`` — the loopback socket underneath is an implementation detail."""
return "spi"
@property
def endpoint(self) -> str:
"""The SPI device the radio is on, which is how the radio is named and remembered."""
return self._wiring.spidev
@property
def wiring(self) -> SpiWiring:
"""How the radio this device drives is wired."""
return self._wiring
async def connect(self) -> None: # noqa: D102 - inherited docstring
if self._mc is not None:
return
if self._node is None or not self._node.alive:
self._node = await start_node(self._wiring, self._state, node_name=self._node_name)
self._tcp_port = self._node.port
try:
await super().connect()
except BaseException:
await self._stop_node()
raise
async def link_present(self) -> bool: # noqa: D102 - inherited docstring
if self._node is not None and not self._node.alive:
return False # the node died, so the radio is gone whatever the socket says
return await super().link_present()
async def disconnect(self) -> None: # noqa: D102 - inherited docstring
try:
await super().disconnect()
finally:
await self._stop_node()
async def _stop_node(self) -> None:
"""End the node, if one is running, and forget it."""
node, self._node = self._node, None
if node is not None:
await node.stop()
def _no_response_message(self) -> str:
"""The node started but never answered as a companion: point at its log."""
where = self._node.log_path if self._node is not None else self._state / "node.log"
return f"the radio node on {self._wiring.spidev} did not answer; its log is {where}"
+9
View File
@@ -63,6 +63,15 @@ class TransmitGate:
if flood_advert:
self._last_flood_advert = now
@property
def last_sent(self) -> float | None:
"""When anything last went out, on :func:`time.monotonic`'s clock; ``None`` if never.
For a listener that treats our own transmissions as breaking the air's silence
(the weekly advert's wait for a quiet spell) rather than as a cooldown to wait out.
"""
return self._last_sent
def remaining(self, *, flood_advert: bool = False) -> float:
"""Seconds a caller should wait before transmitting; ``0.0`` when it may go now.
+81
View File
@@ -0,0 +1,81 @@
# SPDX-License-Identifier: Apache-2.0
"""The transmit lock: what keeps a channel's scope from leaking onto someone else's flood.
The firmware has no per-channel scope. What it has is a *session* scope — one setting on
the companion that every flood it sends goes out under, channel messages, flood DMs,
adverts and requests alike (see :meth:`~meshterm.core.connection.Device.set_flood_scope`).
A channel scope is therefore three commands, not one: set the session scope, send the
channel message, set it back. Anything else the app transmits *between* the first and the
third goes out under that channel's region too — a courier retry, a DM typed in another
screen, the weekly advert — and would be relayed only by the repeaters carrying a region
its sender never chose.
So every transmission MeshTerm asks of the companion holds this lock for the moment it
takes to hand the frame over, and a scoped channel send holds it across all three commands.
Nothing can be interleaved into the window, and the window is as short as three round
trips; an acknowledgement *wait* is not a transmission and is done outside the lock, so a
DM waiting eight seconds for its ack never holds a channel message up behind it.
The lock is **re-entrant per task**: the scoped send takes it, then calls the ordinary
channel send, which takes it again on its own account — and must not wait for itself.
One leak is out of any client's reach: a DM *received* inside the window is acknowledged
by the firmware on its own, and that ack is a flood under whatever the session scope is
at that instant. The window being three round trips long is the whole of the mitigation,
and it is what the official apps live with too.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
class TransmitLock:
"""A task-re-entrant :class:`asyncio.Lock` for "something is going on the air now".
Interact through :meth:`held`, an async context manager. A task already holding the
lock enters again at once; any other task waits for the holder to leave.
"""
def __init__(self) -> None:
"""Start unheld."""
self._lock = asyncio.Lock()
self._owner: asyncio.Task | None = None
self._depth = 0
@property
def locked(self) -> bool:
"""Whether any task holds the lock right now."""
return self._lock.locked()
def held_by_me(self) -> bool:
"""Whether the calling task is the one holding the lock."""
try:
task = asyncio.current_task()
except RuntimeError: # no running loop: nobody here holds anything
return False
return task is not None and self._owner is task
@asynccontextmanager
async def held(self) -> AsyncIterator[None]:
"""Hold the lock for the body; re-enter at once if this task already holds it."""
if self.held_by_me():
self._depth += 1
try:
yield
finally:
self._depth -= 1
return
async with self._lock:
self._owner = asyncio.current_task()
self._depth = 1
try:
yield
finally:
self._owner = None
self._depth = 0
__all__ = ["TransmitLock"]
+13
View File
@@ -23,6 +23,7 @@ import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import ClassVar
from .models import utcnow
@@ -73,6 +74,10 @@ class WatchedNode:
silent_since: datetime | None = None
node_type: int | None = None
#: Fields this record once held and must never hold again under another meaning (see
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
RETIRED: ClassVar[frozenset[str]] = frozenset()
@dataclass(slots=True)
class Alert:
@@ -94,6 +99,10 @@ class Alert:
message: str
acked: bool = False
#: Fields this record once held and must never hold again under another meaning (see
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
RETIRED: ClassVar[frozenset[str]] = frozenset()
@dataclass(slots=True)
class _State:
@@ -105,6 +114,10 @@ class _State:
new_node_alerts: bool = True
next_id: int = 1
#: Fields this record once held and must never hold again under another meaning (see
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
RETIRED: ClassVar[frozenset[str]] = frozenset()
class WatchStore:
"""Reads and writes the Watchtower's state, memory-first."""
+17 -74
View File
@@ -30,10 +30,15 @@ import shutil
import subprocess
import sys
#: Set in the reopened session, so a relaunch can never chain into another one. Windows
#: Terminal is not a classic console and so would not qualify anyway — this is the belt to
#: that braces, and it costs one environment variable.
REOPENED_ENV = "MESHTERM_REOPENED"
from .relaunch import REOPENED_ENV, child_environment, own_command, wanted_size
__all__ = [
"REOPENED_ENV",
"available",
"child_environment",
"own_command",
"reopen",
]
def available() -> str | None:
@@ -47,60 +52,6 @@ def available() -> str | None:
return shutil.which("wt.exe")
def own_command() -> list[str]:
"""The command that would start this same session again.
A frozen build is its own executable and takes the arguments as they were given. A
development or ``pip`` install is reached through its interpreter, because the console
script's own path is not something ``wt`` can be relied on to resolve.
Returns:
The program and its arguments, ready to hand to Windows Terminal.
"""
if getattr(sys, "frozen", False):
return [sys.executable, *sys.argv[1:]]
return [sys.executable, "-m", "meshterm", *sys.argv[1:]]
def child_environment() -> dict[str, str]:
"""The environment for a fresh launch of MeshTerm, minus this bundle's bookkeeping.
A PyInstaller one-file build runs in two stages: the executable unpacks itself into a
temporary directory and then runs itself again as a child, the two halves coordinating
through ``_PYI*`` environment variables. Those must not reach a *new* launch of the
executable. The bootloader would find them, conclude it is the second stage of a launch
it never made, check that its parent is the same program — find Windows Terminal
instead — and abort::
[PYI-33368:ERROR] Security validation failure: parent process has different
executable!
Which is the whole session gone, in a brand new window holding a message nobody can act
on, while the window that offered the move has already closed. Removing them makes the
new process a first stage, which is what it actually is.
Returns:
The environment to start the new process with.
"""
environment = dict(os.environ)
environment[REOPENED_ENV] = "1"
if not getattr(sys, "frozen", False):
return environment
for key in [key for key in environment if key.startswith("_PYI")]:
del environment[key]
environment.pop("_MEIPASS2", None) # the name older bootloaders used
# The bootloader points the loader at the unpacked bundle and parks the caller's own
# value beside it. The new process unpacks its own copy, so it wants the original.
for name in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"):
original = environment.pop(f"{name}_ORIG", None)
if original is not None:
environment[name] = original
else:
environment.pop(name, None)
return environment
def reopen() -> bool:
"""Start this session again in Windows Terminal, in the same directory.
@@ -148,20 +99,12 @@ def reopen() -> bool:
def _wanted_size() -> str:
"""The window to ask Windows Terminal for, as ``cols,rows``.
Asked for, because a window MeshTerm opens should be one MeshTerm fits in. Without
``-w new`` the session lands as a *tab* in whatever window happens to be open and
inherits its size; without ``--size`` a new window takes the reader's global launch
size, which is a setting about their shell and not about this app. Either way the app
can arrive in a window too small for it, and the failure is quiet and cosmetic: the
startup wordmark is 71 columns, so at 70 it silently swaps to the narrow one drawn for
the PicoCalc, and a short window truncates every description onto the pager.
The numbers come from the platform's own stated minimum plus a margin, so they follow
it rather than repeating it. Windows Terminal clamps to what the display can show, so
this is a request and not a demand — on a small screen the app degrades exactly as it
would have anyway.
Asked for, because without ``-w new`` the session lands as a *tab* in whatever window
happens to be open and inherits its size, and without ``--size`` a new window takes the
reader's global launch size, which is a setting about their shell and not about this
app. The dimensions themselves are
:func:`~meshterm.core.relaunch.wanted_size`'s, shared with every other terminal
MeshTerm reopens itself in; only the spelling is Windows Terminal's.
"""
from ..platforms import get_platform
platform = get_platform()
return f"{platform.readable_cols + 8},{platform.readable_rows + 6}"
cols, rows = wanted_size()
return f"{cols},{rows}"
+34 -3
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import sqlite3
from pathlib import Path
SCHEMA_VERSION = 15
SCHEMA_VERSION = 17
_SCHEMA = """
CREATE TABLE IF NOT EXISTS schema_meta (
@@ -140,7 +140,10 @@ CREATE TABLE IF NOT EXISTS observations (
src TEXT, -- …the sender's key hash, or its whole key (anon request)
tag TEXT, -- …the frame's own token: an ack's checksum, a trace's tag
trace_snrs TEXT, -- TRACE rows: comma-separated per-hop SNR readings (dB)
route TEXT -- 'packet' rows: FLOOD/DIRECT/… — what `path` MEANS here
route TEXT, -- 'packet' rows: FLOOD/DIRECT/… — what `path` MEANS here
transport_code TEXT, -- scoped (TC_*) rows: the 4-byte transport-codes field (hex)
scope_body TEXT -- TC_FLOOD rows: payload type byte + payload (hex), the
-- HMAC input a region name is resolved against
);
-- One chat message, sent or received, on a channel or with a contact. Unlike the other
@@ -158,7 +161,9 @@ CREATE TABLE IF NOT EXISTS messages (
text TEXT NOT NULL,
snr REAL,
acked INTEGER,
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
scope TEXT -- outbound floods: the region it was sent under, '*' for
-- unscoped, NULL where it was never known
);
CREATE INDEX IF NOT EXISTS idx_traces_run ON traces(run_id);
@@ -332,3 +337,29 @@ def _migrate(conn: sqlite3.Connection) -> None:
# than assumed flooded; the raw headers they arrived in were never persisted, so this
# cannot be filled in from history.
conn.execute("ALTER TABLE observations ADD COLUMN route TEXT")
if "transport_code" not in observation_cols:
# v15 -> v16: a scoped flood's region. A TC_FLOOD frame carries a transport code —
# an HMAC of its own payload keyed on the region it was sent into — and nothing
# else names the region, so which one it was can only be answered by trying the
# region names known (see meshterm.core.regions). Names arrive late: a repeater
# tells us what it carries long after we overheard traffic scoped to it. So the
# code is kept with the exact bytes it was computed over, and a row heard today
# resolves against a name learned next week. The body is kept for TC_FLOOD rows
# alone — a plain flood has no code, a direct frame is never region-filtered — so
# the price is paid only by the traffic it explains. Older rows carry NULL: the
# raw frames they arrived in were never persisted, so their scope is lost.
conn.execute("ALTER TABLE observations ADD COLUMN transport_code TEXT")
conn.execute("ALTER TABLE observations ADD COLUMN scope_body TEXT")
if "scope" not in message_cols:
# v16 -> v17: the scope a message we sent went out under. A channel can be given a
# region its messages are flooded into (a scope is a *setting on the companion*,
# made just before the send — see Device.send_channel_in_scope), and the scope can
# change between one message and the next, or be dropped for one resend. So the
# transcript cannot work out afterwards what a message went out under from what the
# channel's scope is *now*; it is kept with the message, the moment it is sent.
# Its first reader is the message paths dialog: a scoped message nothing relayed is
# most often one no repeater in earshot carries the region of, and saying so needs
# the region. Stored as the bare name, '*' for a flood sent unscoped, and NULL for
# anything else — inbound messages, direct messages, and every row sent before this
# column existed, whose scope was never known and cannot be recovered.
conn.execute("ALTER TABLE messages ADD COLUMN scope TEXT")
+127 -5
View File
@@ -29,6 +29,7 @@ from ..core.models import (
TxLevelResult,
utcnow,
)
from ..core.regions import SCOPED_ROUTES, raw_scope_body
from . import db
if TYPE_CHECKING:
@@ -96,6 +97,8 @@ def _packet_raw(row: sqlite3.Row) -> dict | None:
tag = _row_value(row, "tag")
trace_snrs = _row_value(row, "trace_snrs")
route = _row_value(row, "route")
transport_code = _row_value(row, "transport_code")
scope_body = _row_value(row, "scope_body")
if not any((typename, chan_hash, cipher_mac, dest, src, tag, trace_snrs, route)):
return None
raw: dict = {}
@@ -121,6 +124,12 @@ def _packet_raw(row: sqlite3.Row) -> dict | None:
raw["trace_snrs"] = [float(v) for v in str(trace_snrs).split(",") if v]
if route:
raw["route_typename"] = route
if transport_code:
# A scoped frame's code and the bytes it was computed over, so its region resolves
# against names learned after it was heard (see meshterm.core.regions).
raw["transport_code"] = transport_code
if scope_body:
raw["scope_body"] = scope_body
return raw
@@ -1024,12 +1033,18 @@ class Repository:
trace_snrs = ",".join(f"{v:g}" for v in readings) if readings else None
# What the frame's `path` means — see the v15 migration. Only a packet row has one.
route = raw.get("route_typename") if obs.kind == "packet" else None
# A scoped frame's transport code, and — for a scoped flood — the bytes it was
# computed over, so its region can be resolved against a name learned later (see
# the v16 migration). Nothing is kept for a frame that carries no code.
transport_code = raw.get("transport_code") if route in SCOPED_ROUTES else None
body = raw_scope_body(raw) if route == "TC_FLOOD" and transport_code else None
scope_body = body.hex() if body is not None else None
self._conn.execute(
"INSERT INTO observations "
"(run_id, node, public_key, name, kind, node_type, snr, rssi, lat, lon, path, "
"observed_at, chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, "
"trace_snrs, route) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"trace_snrs, route, transport_code, scope_body) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
run_id,
obs.node,
@@ -1052,6 +1067,8 @@ class Repository:
tag,
trace_snrs,
route,
transport_code,
scope_body,
),
)
self._conn.commit()
@@ -1068,6 +1085,69 @@ class Repository:
row = self._conn.execute("SELECT COUNT(*) AS n FROM observations").fetchone()
return int(row["n"]) if row else 0
def table_counts(self) -> dict[str, int]:
"""Return a row count for every table in the database, by name.
Read off ``sqlite_master`` rather than a list written out here, which is the whole
point: a table added to the schema starts being reported the day it lands, and a
table removed stops, with nothing to keep in step. The one thing this must never
become is a curated set of the counts somebody thought were interesting — the
shape of a database is the aggregate, and the interesting one is always the table
nobody expected to be full.
Every value is a *count*. Nothing here reads a row, so the result says how much
history a report's author has without saying a word about who they talk to.
Returns:
Table name to row count, alphabetically, SQLite's own internal tables
excluded (they describe the file format, not this app's data).
"""
names = [
str(row["name"])
for row in self._conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name NOT LIKE 'sqlite_%' ORDER BY name"
).fetchall()
]
counts: dict[str, int] = {}
for name in names:
# The names come from sqlite_master, so they are this database's own tables
# and not caller input; quoted anyway, because an identifier cannot be bound.
row = self._conn.execute(f'SELECT COUNT(*) AS n FROM "{name}"').fetchone()
counts[name] = int(row["n"]) if row else 0
return counts
def failed_run_count(self) -> int:
"""Return how many recorded tool runs ended in an error.
The single most useful number a reporter can hand over: it says whether this
install has been failing quietly for weeks or broke for the first time today,
and it costs one query rather than an interview.
Returns:
The number of ``runs`` rows whose status is ``error``.
"""
row = self._conn.execute("SELECT COUNT(*) AS n FROM runs WHERE status = 'error'").fetchone()
return int(row["n"]) if row else 0
def observation_span(self) -> tuple[datetime | None, datetime | None]:
"""Return the first and last times anything was overheard.
How much mesh this database has actually seen. A report whose span is an hour and
a report whose span is a year describe different installs, and the difference
explains a class of "it works here" before anyone else has to ask for it.
Returns:
``(first, last)`` as aware UTC times, or ``(None, None)`` when nothing has
ever been heard.
"""
row = self._conn.execute(
"SELECT MIN(observed_at) AS first, MAX(observed_at) AS last FROM observations"
).fetchone()
if row is None or row["first"] is None:
return None, None
return _as_when(row["first"]), _as_when(row["last"])
def recent_observations(self, *, since: datetime, limit: int = 4000) -> list[Observation]:
"""Return raw stored observations in a recent window, oldest first.
@@ -1087,7 +1167,8 @@ class Repository:
"""
rows = self._conn.execute(
"SELECT node, name, kind, node_type, snr, rssi, lat, lon, path, observed_at, "
"chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, trace_snrs, route "
"chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, trace_snrs, route, "
"transport_code, scope_body "
"FROM observations WHERE observed_at >= ? ORDER BY observed_at DESC LIMIT ?",
(since.isoformat(), limit),
).fetchall()
@@ -1113,7 +1194,8 @@ class Repository:
"""
rows = self._conn.execute(
"SELECT node, name, kind, node_type, snr, rssi, lat, lon, path, observed_at, "
"chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, trace_snrs, route "
"chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, trace_snrs, route, "
"transport_code, scope_body "
"FROM observations WHERE kind = 'packet' AND observed_at >= ? "
"AND observed_at <= ? ORDER BY observed_at ASC LIMIT ?",
(start.isoformat(), end.isoformat(), limit),
@@ -1319,6 +1401,44 @@ class Repository:
continue # a malformed stray timestamp simply isn't counted
return counts
def flood_frames(self, *, since: datetime | None = None) -> list[tuple[datetime, dict]]:
"""Every stored flood frame, oldest first, with what its scope is read from.
The Time Machine's scope views: a scoped frame's region is named by recomputing its
transport code under each known region's key, which no SQL can do, so the frames
come back as the raw mapping :meth:`~meshterm.core.region_store.RegionStore.scope_of`
reads (route, transport code, the bytes the code was computed over) for the caller
to resolve. Only floods: a direct frame has no scope, and a frame stored before its
route was kept cannot say which it was.
Args:
since: Only frames at or after this time, if given.
Returns:
``(observed_at, raw)`` per flood frame, oldest first.
"""
sql = (
"SELECT observed_at, route, transport_code, scope_body FROM observations "
"WHERE kind = 'packet' AND upper(route) IN ('FLOOD', 'TC_FLOOD')"
)
params: list[Any] = []
if since is not None:
sql += " AND observed_at >= ?"
params.append(since.isoformat())
sql += " ORDER BY observed_at"
frames: list[tuple[datetime, dict]] = []
for row in self._conn.execute(sql, params):
when = _as_when(row["observed_at"])
if when is None:
continue # a malformed stray simply isn't counted
raw = {"route_typename": row["route"]}
if row["transport_code"]:
raw["transport_code"] = row["transport_code"]
if row["scope_body"]:
raw["scope_body"] = row["scope_body"]
frames.append((when, raw))
return frames
def self_transmissions(self, *, since: datetime | None = None) -> list[datetime]:
"""Timestamps of everything this station put on the air, oldest first.
@@ -1839,7 +1959,7 @@ class Repository:
cur = self._conn.execute(
"INSERT INTO messages "
"(run_id, outbound, is_channel, channel_id, channel_idx, peer, peer_name, text, "
"snr, acked, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"snr, acked, created_at, scope) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
run_id,
int(msg.outbound),
@@ -1852,6 +1972,7 @@ class Repository:
msg.snr,
None if msg.acked is None else int(msg.acked),
msg.created_at.isoformat(),
msg.scope,
),
)
self._conn.commit()
@@ -2145,4 +2266,5 @@ class Repository:
acked=None if row["acked"] is None else bool(row["acked"]),
created_at=datetime.fromisoformat(row["created_at"]),
row_id=row["id"],
scope=row["scope"] if "scope" in row.keys() else None,
)
+215 -63
View File
@@ -1,58 +1,143 @@
# SPDX-License-Identifier: Apache-2.0
"""The background-advert scheduler: keep this node announced without anyone asking.
"""The weekly flood advert: keep this node on the mesh's contact lists, once a week at most.
A quiet session-long task that sends a zero-hop (direct) advert and a flood advert on
their configured cadences (see :class:`~meshterm.core.advert_store.AdvertStore`; the
cadence rows live in the device-configuration editor). The countdown for each type runs
from the *last* advert of that type — scheduled or manual — so using the Send advert menu
pushes the next background send out by a full cadence rather than doubling up.
A MeshCore companion never advertises by itself — unlike a repeater it has no advert
timer, and it does not even announce itself at boot — so a node whose owner never presses
*Send advert* gradually falls out of everyone else's contact list. When the
``weekly_flood_advert`` preference is on (it is off by default), this session-long task
sends one flood advert from the connected device once that device has gone a full week
without one. The week is kept per device, in real time, by
:class:`~meshterm.core.advert_store.AdvertStore`:
Deliberately transmission-shy, in keeping with the app's single-transmission rule:
* it runs from the **latest** of three instants — the preference being turned on, the last
flood advert from that device (automatic *or* sent by hand), and the first time MeshTerm
connected to it — so switching the preference on never sends, and a manual flood advert
pushes the next automatic one a whole week out;
* a due advert the app did not live to send stays due, and goes out on the next run that
connects that device.
* At most **one** advert goes out per pass, even when both types are due (the other
follows a pass later), so the scheduler can never burst.
* A device with no recorded last-send is *armed* — the countdown starts now — instead of
advertised immediately, so a fresh install stays silent for its first full cadence.
Being due is not enough to transmit. A flood advert is rebroadcast by every repeater in
range, so it waits for the air to be **quiet**:
The scheduler holds no device subscription; each pass just checks whether a device is
connected and skips quietly otherwise. That makes it indifferent to reconnects — it keeps
ticking across a link drop and resumes sending once the session's reconnect flow has done
its job.
* **online first** — each connection (a launch, and every reconnect after a link drop)
must *hear* at least one packet before any silence counts, because a link that hears
nothing proves nothing about the mesh;
* **then a quiet spell** — no packet heard *and* nothing of ours sent for the
``advert_quiet_s`` preference (30 s by default) plus a random 0–5 s. The random part is
drawn afresh every time the silence breaks: two devices waiting through the same packets
would otherwise wait the same time after each one and collide every time, where a fresh
draw per break parts them. A busy mesh can hold the advert back indefinitely; it waits.
Every packet the event hub delivers counts as heard — they are all receptions — and our
own transmissions are read off the shared transmit clock
(:mod:`meshterm.core.transmit_gate`), which every send marks.
The scheduler holds no device subscription of its own and never opens the radio: each
tick checks for a connected device and skips quietly without one, so it keeps ticking
across a link drop and resumes once the session's reconnect flow has done its job. The
interactive session starts it; scripted CLI runs never do, so a one-shot command cannot
fire a background transmission.
"""
from __future__ import annotations
import asyncio
import random
import time
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING
from ..core import transmit_gate
if TYPE_CHECKING:
from ..context import AppContext
from ..core.connection import Device
from ..core.events import MeshEvent
#: Seconds between scheduler passes. Cadences are hours, so a coarse tick is plenty; it
#: also spaces consecutive sends (when both types come due together) a comfortable gap
#: apart on the shared mesh.
POLL_S = 30.0
#: Seconds between ticks. The quiet spell is measured to about this precision, which is
#: plenty against a 5–60 s spell drawn with seconds of randomness on top.
TICK_S = 1.0
#: The widest random extension of the quiet spell, in seconds (drawn uniformly from 0).
JITTER_S = 5.0
#: Seconds between re-reads of the advert store while an advert is not yet due. The week
#: is days long, so a coarse check costs nothing in accuracy and spares the file a read on
#: every tick.
DUE_CHECK_S = 30.0
#: :attr:`AdvertStatus.state` while no device is connected: no week to count, nothing to send.
OFFLINE = "offline"
#: :attr:`AdvertStatus.state` while the connected device's week is still running.
COUNTING = "counting"
#: :attr:`AdvertStatus.state` once due, while this connection has yet to hear a packet.
LISTENING = "listening"
#: :attr:`AdvertStatus.state` once due and heard, while the air has yet to go quiet.
QUIET = "quiet"
@dataclass(frozen=True, slots=True)
class AdvertStatus:
"""Where the weekly advert stands, for a screen to say so.
Attributes:
state: :data:`OFFLINE`, :data:`COUNTING`, :data:`LISTENING`, or :data:`QUIET`.
due_at: When the connected device's advert falls due, while :data:`COUNTING`.
"""
state: str
due_at: datetime | None = None
class AdvertScheduler:
"""Owns the background-advert loop for an interactive session.
"""Owns the weekly-advert loop for an interactive session.
Interact through the async lifecycle methods (:meth:`start`, :meth:`stop`,
:meth:`aclose`); everything else happens on the loop's own schedule.
"""
def __init__(self, ctx: AppContext) -> None:
def __init__(
self,
ctx: AppContext,
*,
rng: random.Random | None = None,
clock: Callable[[], float] = time.monotonic,
) -> None:
"""Initialize an idle scheduler bound to an application context.
Args:
ctx: The shared application context, read each pass for the connected
device and the advert store. Nothing is touched until :meth:`start`.
ctx: The shared application context, read each tick for the connected device,
the preferences and the advert store. Nothing is touched until
:meth:`start`.
rng: The source of the quiet spell's random extension (tests pin it).
clock: A monotonic clock in seconds — the transmit clock's own, which the
silence is compared against (tests substitute one they can advance).
"""
self._ctx = ctx
self._rng = rng or random.Random()
self._clock = clock
self._task: asyncio.Task | None = None
# The connected device's public key, learned once per connection. Keyed by the
# device object's identity so a reconnect (a fresh Device) re-probes it.
self._key_for: tuple[int, str] | None = None
self._unsubscribe: Callable[[], None] | None = None
# The connection the state below belongs to: the Device object itself, held rather
# than its id() so a freed one's address can never pass for a fresh link. A
# reconnect is a new Device, so it starts over — key, due check, proof of life.
self._device: Device | None = None
self._key = ""
self._due = False
self._due_at: datetime | None = None
self._checked_at: float | None = None
# Which connection last heard a packet, and when anything was last heard.
self._heard_on: Device | None = None
self._last_heard: float | None = None
# The silence break the current random extension was drawn for, and the draw.
self._break_at: float | None = None
self._jitter = 0.0
@property
def active(self) -> bool:
@@ -60,14 +145,18 @@ class AdvertScheduler:
return self._task is not None and not self._task.done()
async def start(self) -> None:
"""Start the scheduler loop. Idempotent; never touches the radio itself."""
"""Start listening and ticking. Idempotent; never touches the radio itself."""
if self.active:
return
self._unsubscribe = self._ctx.events.subscribe(self._on_event)
self._task = asyncio.ensure_future(self._run())
self._ctx.log.info("advert scheduler started")
async def stop(self) -> None:
"""Stop the scheduler loop. Idempotent."""
"""Stop the loop and the listening. Idempotent."""
if self._unsubscribe is not None:
self._unsubscribe()
self._unsubscribe = None
task, self._task = self._task, None
if task is not None:
task.cancel()
@@ -81,10 +170,43 @@ class AdvertScheduler:
"""Stop the loop at session end (an alias for :meth:`stop`)."""
await self.stop()
def status(self) -> AdvertStatus | None:
"""Where the weekly advert stands right now, or ``None`` when there is nothing to say.
``None`` while the scheduler is not running, while the preference is off, and in
the second between a connection and the first look at it. Read from what the loop
already keeps, so a screen can ask on every paint without touching the file; a
:meth:`recheck` pending leaves the last reading standing until the tick replaces it.
"""
ctx = self._ctx
if not self.active or not ctx.preferences.weekly_flood_advert:
return None
device = ctx._device
if device is None:
return AdvertStatus(OFFLINE)
if device is not self._device:
return None
if not self._due:
return AdvertStatus(COUNTING, self._due_at) if self._due_at is not None else None
return AdvertStatus(LISTENING if self._heard_on is not device else QUIET)
def recheck(self) -> None:
"""Re-read the preference and the store on the next tick, not the next due check.
For a screen about to report :meth:`status`, which would otherwise show what the
loop last read up to :data:`DUE_CHECK_S` ago — before a switch just applied.
"""
self._checked_at = None
def _on_event(self, _event: MeshEvent) -> None:
"""Note a packet heard: it breaks the silence, and proves this connection is live."""
self._last_heard = self._clock()
self._heard_on = self._ctx._device
async def _run(self) -> None:
"""Tick forever: sleep, then make one best-effort pass."""
while True:
await asyncio.sleep(POLL_S)
await asyncio.sleep(TICK_S)
try:
await self._pass()
except asyncio.CancelledError:
@@ -93,44 +215,74 @@ class AdvertScheduler:
self._ctx.log.debug("advert scheduler: pass failed: %s", exc)
async def _pass(self) -> None:
"""One pass: arm never-sent clocks, and send the most overdue advert type if due."""
"""One tick: keep the clock current, and send the weekly advert once due and quiet."""
ctx = self._ctx
if not ctx.is_connected:
device = ctx._device
if device is None:
self._device = None
return
key = await self._public_key()
if not key:
return
store = ctx.advert_store
# Arm fresh clocks so "never sent" becomes "counting from now" (no-op otherwise).
store.arm(key, flood=False)
store.arm(key, flood=True)
policy = store.load(key)
# One send per pass, direct first (the cheaper, zero-hop announcement); a flood
# due at the same moment follows on the next pass, POLL_S later.
for flood in (False, True):
if policy.due(flood):
device = await ctx.device()
await device.send_advert(flood)
store.mark_sent(key, flood=flood)
kind = "flood" if flood else "zero-hop"
ctx.log.info("advert scheduler: sent scheduled %s advert", kind)
if device is not self._device:
self._device, self._key, self._due, self._checked_at = device, "", False, None
self._due_at = None
if not self._key:
self._key = await self._public_key()
if not self._key:
return
ctx.advert_store.arm(self._key) # a device never seen before starts its week
now = self._clock()
if self._checked_at is None or now - self._checked_at >= DUE_CHECK_S:
self._checked_at = now
self._check_due()
if not self._due or self._heard_on is not device or not self._quiet(now):
return
if transmit_gate.remaining(flood_advert=True) > 0:
return # a flood advert of ours went out moments ago; the next tick re-judges
# The file, not the cached verdict: a flood advert sent by hand since the last
# check restarted the week, and the preference may have been switched off.
self._check_due()
if not self._due:
return
try:
await device.send_advert(True)
except Exception:
self._due = False # judged again at the next due check, not on every tick
raise
ctx.advert_store.mark_flood(self._key)
self._due = False
self._checked_at = None # the next tick reads the new week in
ctx.log.info("advert scheduler: sent the weekly flood advert")
def _check_due(self) -> None:
"""Re-read the preference and the store, and settle whether the advert is due."""
ctx = self._ctx
on = bool(ctx.preferences.weekly_flood_advert)
ctx.advert_store.sync_enabled(on)
self._due_at = ctx.advert_store.due_at(self._key) if on else None
due = on and ctx.advert_store.due(self._key)
if due and not self._due:
ctx.log.info(
"advert scheduler: weekly flood advert due; waiting for %s s of quiet",
ctx.preferences.advert_quiet_s,
)
self._due = due
def _quiet(self, now: float) -> bool:
"""Whether the air has been quiet long enough, drawing a fresh extension per break."""
sent = transmit_gate.current().last_sent
breaks = [t for t in (self._last_heard, sent) if t is not None]
if not breaks:
return False
break_at = max(breaks)
if break_at != self._break_at:
self._break_at = break_at
self._jitter = self._rng.uniform(0.0, JITTER_S)
return now - break_at >= float(self._ctx.preferences.advert_quiet_s) + self._jitter
async def _public_key(self) -> str:
"""The connected device's public key, probed once per connection (best-effort)."""
device = self._ctx._device
if device is None:
return ""
ident = id(device)
if self._key_for is not None and self._key_for[0] == ident:
return self._key_for[1]
"""The connected device's public key, from the session cache (best-effort)."""
try:
info = await device.get_self_info()
except Exception: # noqa: BLE001 - identity probe is best-effort; retry next pass
info = await self._ctx.devstate.self_info()
except Exception: # noqa: BLE001 - identity probe is best-effort; retry next tick
return ""
key = str(info.get("public_key") or "")
if key:
self._key_for = (ident, key)
return key
return str(info.get("public_key") or "")
+56 -1
View File
@@ -44,12 +44,14 @@ from __future__ import annotations
import json
import logging
import os
import ssl
import threading
import time
import urllib.error
import urllib.request
from collections import OrderedDict
from collections.abc import Container
from functools import lru_cache
from pathlib import Path
from typing import NamedTuple
@@ -109,6 +111,59 @@ _RESOLVE_RETRY_SECONDS = 30.0
_log = logging.getLogger(__name__)
#: Where the operating system keeps its CA bundle, most specific first. Only consulted when
#: the default context came up empty (see :func:`_tls_context`) — macOS and Alpine put it at
#: ``/etc/ssl/cert.pem``, Debian and Ubuntu at ``ca-certificates.crt``, Fedora and RHEL under
#: ``/etc/pki``, openSUSE at ``ca-bundle.pem``. Files rather than a bundled copy on purpose:
#: these are the trust decisions the *machine's administrator* has made, kept current by the
#: OS, and shipping our own would quietly freeze a snapshot of them into every release.
_CA_BUNDLES = (
"/etc/ssl/cert.pem",
"/etc/ssl/certs/ca-certificates.crt",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/ca-bundle.pem",
)
@lru_cache(maxsize=1)
def _tls_context() -> ssl.SSLContext | None:
"""The TLS context for tile fetches, with a CA bundle found if the default has none.
A frozen build can have no trust store to consult. Python's default context asks
OpenSSL for one, and on macOS and Linux that is a *filesystem path baked into the
interpreter's own build* — which need not exist on a machine that never installed that
Python, which is every machine a PyInstaller bundle lands on. Every tile fetch then
fails with ``CERTIFICATE_VERIFY_FAILED``; and because a refused fetch is
indistinguishable from empty terrain, the map drew no ground under the nodes and said
nothing about why. Windows is the exception that hid it for so long: its default
context reads the OS certificate store, so the binary there always worked.
So: keep the default when it actually loaded certificates, and otherwise point it at
the bundle the OS ships (:data:`_CA_BUNDLES`). Verification is never weakened — a map
tile is not worth teaching the app to skip certificate checks, and an unverified
fetcher here would be one import away from being reused somewhere it matters.
Returns:
A context to pass to ``urlopen``, or ``None`` to accept urllib's default — which
is right whenever that default already works, and is never worse than the failure
it replaces.
"""
try:
context = ssl.create_default_context()
if context.get_ca_certs():
return None # the platform store answered; leave urllib exactly as it was
for path in _CA_BUNDLES:
if os.path.isfile(path):
context.load_verify_locations(cafile=path)
_log.debug("no default CA store; verifying against %s", path)
return context
_log.debug("no CA bundle found in %s — TLS will likely fail", ", ".join(_CA_BUNDLES))
return None
except Exception as exc: # noqa: BLE001 - a broken store must not take the map with it
_log.debug("could not build a TLS context, using urllib's default: %s", exc)
return None
class _Response(NamedTuple):
"""One HTTP GET's outcome: whether the server answered, and what it said.
@@ -478,7 +533,7 @@ class BasemapSource:
"""
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
with urllib.request.urlopen(req, timeout=self._timeout, context=_tls_context()) as resp:
body = resp.read()
declared = (resp.headers.get("Content-Length") or "").strip()
# A flaky link can end a read early without raising. A body short of its
+65 -6
View File
@@ -30,6 +30,7 @@ from ..core.models import (
is_direct_messageable,
utcnow,
)
from ..core.regions import WILDCARD, normalize
if TYPE_CHECKING:
from ..context import AppContext
@@ -509,28 +510,86 @@ class ChatService:
self._ctx.repo.update_chat_ack(message.row_id, message.acked)
return message
async def send_channel(self, index: int, text: str, *, label: str | None = None) -> ChatMessage:
"""Broadcast a message on a channel and record it in history.
def channel_scope(self, channel_id: str | None) -> str | None:
"""The region a channel's messages are sent under, or ``None`` for the device default.
Read from :class:`~meshterm.core.region_store.RegionStore` by the channel's intrinsic
identity, so the scope follows the channel across slots. A context with no store (a
test's, or a surface that never built one) has no channel scopes at all.
"""
store = getattr(self._ctx, "region_store", None)
return store.channel_scope(channel_id) if store is not None else None
async def send_channel(
self,
index: int,
text: str,
*,
label: str | None = None,
scope: str | None = None,
) -> ChatMessage:
"""Broadcast a message on a channel, under the channel's scope, and record it.
The channel's identity is resolved *before* the send, because it is what names the
scope the message goes out under (:meth:`channel_scope`). The send itself is
:meth:`~meshterm.core.connection.Device.send_channel_in_scope` — set the session
scope, send, restore, with no other flood let in between — and a scope the radio
can't take stops the message rather than sending it some other way.
Args:
index: The channel slot to transmit on.
text: The message body.
label: A display label for the channel (e.g. ``#general``), stored for the
transcript.
scope: Overrides the channel's own scope for this one message: a region name,
or :data:`~meshterm.core.regions.WILDCARD` (``*``) to send it unscoped —
the resend for a scoped message nothing relayed. ``None`` uses the
channel's scope, or the device default when the channel has none.
Returns:
The recorded outbound :class:`ChatMessage`.
The recorded outbound :class:`ChatMessage`, its :attr:`~ChatMessage.scope` the
one it went out under.
Raises:
~meshterm.core.connection.FloodScopeError: If the radio can't send under the
scope; nothing was sent and nothing is recorded.
"""
device = await self._ctx.device()
await device.send_channel_message(index, text)
channel_id = await self.channel_id_for(index)
asked = scope if scope is not None else self.channel_scope(channel_id)
await device.send_channel_in_scope(index, text, asked)
chat = ChatMessage(
text=text,
outbound=True,
is_channel=True,
channel_id=await self.channel_id_for(index),
channel_id=channel_id,
channel_idx=index,
peer_name=label,
created_at=utcnow(),
scope=await self._sent_under(asked),
)
self._ctx.repo.record_chat_message(chat, run_id=self._run_id)
chat.row_id = self._ctx.repo.record_chat_message(chat, run_id=self._run_id)
return chat
async def _sent_under(self, asked: str | None) -> str | None:
"""What a channel send asked to go out under, as the transcript records it.
A scope that was asked for is what it went under — the send refuses rather than
substitute. A plain send went under the device's default scope, which is read (once
a session, through the devstate cache): its name when one is set, ``*`` when none
is — a plain flood with no default *is* unscoped. A default that can't be read
(firmware before 1.15 has none to read; a link blip) records ``None``: unknown is
the honest answer, and the transcript never claims a scope it didn't see.
"""
bare = normalize(asked) if asked else ""
if bare:
return bare
devstate = getattr(self._ctx, "devstate", None)
if devstate is None:
return None
try:
default = await devstate.default_scope()
except Exception as exc: # noqa: BLE001 - unknown, not a failed send
self._ctx.log.debug("chat: default scope unreadable: %s", exc)
return None
return normalize(default) or WILDCARD
+41 -3
View File
@@ -11,6 +11,13 @@ and acknowledges on screen; :class:`ClockSync` calls the same function on its ow
per connection, when the ``set_clock_on_connect`` preference asks for it (off by default —
writing to a radio that nobody asked to be written to is a choice the owner makes).
**Forward only.** MeshCore firmware never sets its clock back: a time earlier than the one
it holds is refused (:class:`~meshterm.core.connection.ClockAheadError`). So a radio whose
clock has run ahead of this computer's — a GPS fix, another app, drift — is read first and
not written at all: a few seconds ahead is simply in sync, and more is stated plainly with
what resets it, rather than retried every connect into a refusal the firmware's error code
calls "malformed".
The automatic set is a background step, not a startup step: the connect path hands the
device over and carries on, and the write happens in a task of its own so a slow or silent
firmware never holds the menu. It reports in the **log**, never on screen.
@@ -30,23 +37,35 @@ from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING
from ..core.connection import ClockAheadError
if TYPE_CHECKING:
from ..context import AppContext
from ..core.connection import Device
#: How far ahead a radio's clock may run and still count as in sync. Two clocks a few
#: seconds apart — a GPS-disciplined radio beside a PC that is itself a second or so off
#: true time — are not a problem to report, and the firmware couldn't close the gap anyway.
IN_SYNC_AHEAD_S = 60
@dataclass(frozen=True, slots=True)
class ClockSet:
"""What one clock set did.
Attributes:
epoch: The UNIX time written to the device.
epoch: The UNIX time written to the device — or, where nothing was written, the
time the device already held.
drift_s: How far the device's clock was from ours *before* the set (device minus
host, seconds), or ``None`` where the firmware would not report its clock.
written: Whether the clock was written. ``False`` when it was already a few
seconds ahead (within :data:`IN_SYNC_AHEAD_S`), which firmware would refuse to
set back and which needs no setting.
"""
epoch: int
drift_s: int | None
written: bool = True
@property
def set_at(self) -> datetime:
@@ -72,13 +91,23 @@ async def set_clock(device: Device) -> ClockSet:
The instant written and the drift it corrected.
Raises:
Whatever the device raises when the write itself fails.
ClockAheadError: If the device's clock is ahead of this computer's by more than
:data:`IN_SYNC_AHEAD_S` (read first) or the firmware refused the write as one
that would set it back. Within the margin nothing is written, and the result
says so (``written`` false).
Whatever the device raises when the write itself fails otherwise.
"""
try:
before = await device.get_time()
except Exception: # noqa: BLE001 - optional read; the drift is a nicety
before = None
epoch = int(time.time())
if before is not None and before > epoch:
# Firmware only moves its clock forward, so a write here could only be refused.
# Close enough is in sync; further ahead is a fact to state, not a write to retry.
if before - epoch > IN_SYNC_AHEAD_S:
raise ClockAheadError(before - epoch)
return ClockSet(epoch=before, drift_s=before - epoch, written=False)
await device.set_time(epoch)
return ClockSet(epoch=epoch, drift_s=None if before is None else before - epoch)
@@ -161,10 +190,19 @@ class ClockSync:
done = await set_clock(device)
except asyncio.CancelledError:
raise
except ClockAheadError as exc:
log.warning("clock sync: left the device clock alone: %s", exc)
return
except Exception as exc: # noqa: BLE001 - best-effort; the link is not ours to break
log.warning("clock sync: could not set the device clock: %s", exc)
return
if done.drift_s is None:
if not done.written:
log.info(
"clock sync: device clock already in sync (%+d s, ahead of ours; firmware "
"never sets it back)",
done.drift_s,
)
elif done.drift_s is None:
log.info("clock sync: device clock set to %s (drift unknown)", done.stamp)
else:
log.info("clock sync: device clock set to %s (was %+d s off)", done.stamp, done.drift_s)
+35
View File
@@ -99,6 +99,9 @@ class DeviceState:
self._channels: list[ChannelSlot] | None = None
self._channels_epoch = 0
self._channel_capacity: int | None = None
#: The companion's default flood scope — ``""`` for none — once read (see
#: :meth:`default_scope`); ``None`` while unread.
self._default_scope: str | None = None
# One lock per slow fetch so overlapping first-access callers (two screens opened in
# quick succession) collapse onto a single round-trip instead of each firing their own.
self._contacts_lock = asyncio.Lock()
@@ -309,6 +312,36 @@ class DeviceState:
return 0
return (mode + 1) if isinstance(mode, int) and 0 <= mode <= 3 else 0
# -- the default flood scope (held until a config write invalidates it) ------
async def default_scope(self) -> str:
"""The companion's persisted default flood scope, ``""`` for none, read once.
What a plain channel send goes out under, so it is what a channel with no scope of
its own records its messages as sent under. One round trip, then held until the
config editor writes a setting (:meth:`invalidate_config`) or the link is rebuilt.
Returns:
The bare region name, or ``""`` when no default scope is set.
Raises:
Exception: Like :meth:`~meshterm.core.connection.Device.get_default_flood_scope`
— firmware older than 1.15 has no default scope to read. Not cached, so the
next call asks again.
"""
if self._default_scope is None:
device = await self._ctx.device()
self._default_scope = str(await device.get_default_flood_scope() or "")
return self._default_scope
def note_default_scope(self, name: str | None) -> None:
"""Record a default scope just read or written elsewhere (Device config).
Args:
name: The bare name, ``""`` for none, or ``None`` to forget it and re-read.
"""
self._default_scope = None if name is None else str(name)
# -- channel slots (held until the channel editor invalidates them) --------
async def channel_slots(self) -> list[ChannelSlot]:
@@ -446,6 +479,7 @@ class DeviceState:
"""
self.invalidate_self_info()
self.invalidate_path_hash_mode()
self._default_scope = None
def reset(self) -> None:
"""Clear the whole cache and cancel any in-flight background refresh.
@@ -457,6 +491,7 @@ class DeviceState:
self.invalidate_self_info()
self.invalidate_path_hash_mode()
self.invalidate_channels()
self._default_scope = None
# Capacity is a hardware constant (not touched by channel edits, so it has no per-write
# invalidator), but a reconnect may be to a different device — drop it too.
self._channel_capacity = None
+48 -2
View File
@@ -36,8 +36,8 @@ Nothing here transmits; it is a read-model over the repository.
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, replace
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
@@ -46,6 +46,7 @@ from ..core.frames import ENDPOINT_HASH_BYTES
from ..core.models import ChatMessage, Observation
if TYPE_CHECKING:
from ..core.regions import Scope
from ..persistence.repository import Repository
#: How far around a channel message the log is searched. Wide, because an inbound
@@ -104,6 +105,9 @@ class Arrival:
repeater in earshot, so one message routinely leaves a dozen frames on two
paths; listing them individually said "twelve arrivals" where the truth was
"two paths, six times each".
frame: The logged frame's raw payload — what :func:`message_scope` reads the
message's scope from. Not part of an arrival's identity (two copies on one path
are one row whatever else their frames carry), so it takes no part in equality.
"""
when: datetime
@@ -112,6 +116,7 @@ class Arrival:
resend: int = 0
routed: bool | None = None
copies: int = 1
frame: dict | None = field(default=None, compare=False, repr=False)
@property
def route_known(self) -> bool:
@@ -196,6 +201,7 @@ def channel_arrivals(
snr=frame.snr,
resend=decrypted.attempt,
routed=_frame_routed(raw),
frame=raw,
)
)
return arrivals
@@ -337,6 +343,7 @@ def direct_arrivals(
hops=_frame_hops(frame),
snr=frame.snr,
routed=_frame_routed(raw),
frame=raw,
)
mac = str(raw.get("cipher_mac") or "").lower()
if mac:
@@ -386,6 +393,45 @@ def collapse(arrivals: Sequence[Arrival]) -> list[Arrival]:
return list(folded.values())
def message_scope(
arrivals: Sequence[Arrival], scope_of: Callable[[dict | None], Scope | None]
) -> Scope | None:
"""The one scope a message was flooded under, read off its arrivals' frames.
A transport code is a keyed hash of the payload alone — neither the header nor the
path is in it (see :mod:`~meshterm.core.regions`) — so every relay's copy carries the
sender's code unchanged, and one message has **one** scope however many ways it
arrived. That is why the dialog states it once rather than per arrival row.
Which frame answers still matters, because not every copy is a flood: a direct-routed
copy has no scope at all. So the first scoped reading wins, a *named* one over one no
known region reproduces (they are the same code, but a name is the better answer when
any copy yields one); failing that, any plain-flood copy makes the message
``unscoped``; and a message heard only direct-routed has no scope to state.
Args:
arrivals: The message's arrivals, as the matchers (or :func:`collapse`) return
them — each carrying its logged frame.
scope_of: Reads a frame's scope (``ctx.region_store.scope_of``).
Returns:
The message's scope, or ``None`` where no copy was a flood.
"""
unscoped: Scope | None = None
unnamed: Scope | None = None
for arrival in arrivals:
scope = scope_of(arrival.frame)
if scope is None:
continue
if not scope.scoped:
unscoped = unscoped or scope
elif scope.region:
return scope
else:
unnamed = unnamed or scope
return unnamed or unscoped
def distinct_paths(arrivals: list[Arrival]) -> int:
"""How many distinct relay paths a set of arrivals covers."""
return len({a.hops for a in arrivals})
+1 -1
View File
@@ -4,7 +4,7 @@
*About MeshTerm*, *About the author*, *Join Discord*, *Support MeshTerm* — in that
order, which is the order a stranger asks the questions in: what is this, who made it,
where is everyone, how do I help. They share the *This app* section with Preferences,
which leads it: the section is the app's own scope, and the one row that *changes*
which leads it: the section is the app's own, and the one row that *changes*
MeshTerm sits above the four that describe it.
Each is a page rather than a feature: it reads nothing, transmits nothing, and needs no
device, so it opens straight to its screen (see :mod:`meshterm.ui.about`) instead of
+2 -2
View File
@@ -202,10 +202,10 @@ _REGISTRY: dict[str, Tool] = {}
#: everything that shares it. Message leads (the everyday features, plus the book you
#: pick a recipient from), then Watch (what the mesh is doing, what it did, what to be
#: told about), Explore (where the nodes are, how they reach each other, and the boards
#: that score the walking), then the two scopes a setting can belong to — This node, the
#: that score the walking), then the two owners a setting can belong to — This node, the
#: radio in your hand, and Other nodes, someone else's over the mesh.
#:
#: This app closes the list as the third and last *scope*, after the radio in your hand and
#: This app closes the list as the third and last *owner*, after the radio in your hand and
#: someone else's over the mesh: the program in front of you. It holds the one thing that
#: changes how MeshTerm behaves (Preferences) and the four pages that say what MeshTerm is
#: — questions none of the doings above can hold, and which nothing on the mesh can answer.
+121 -4
View File
@@ -4,8 +4,8 @@
Interactively it opens a full-screen channel manager (see :mod:`meshterm.ui.channels`) — a
first-class, phone-app-style experience for creating private channels, adding public ``#``
channels, joining with a key, importing a scanned ``meshcore://`` link, and sharing any
channel as a QR code. On the CLI it exposes ``list``, ``add``, ``join``, ``import``, and
``share`` subcommands for scripted use.
channel as a QR code. On the CLI it exposes ``list``, ``add``, ``join``, ``import``,
``share``, ``clear`` and ``scope`` subcommands for scripted use.
Channels are also *listed* by the ``chat`` tool for picking a conversation; this tool owns
everything to do with configuring the slots themselves.
@@ -81,6 +81,8 @@ class ChannelsTool(Tool):
return await self._cli_share(ctx, params)
if action == "clear":
return await self._cli_clear(ctx, params)
if action == "scope":
return await self._cli_scope(ctx, params)
return await self._cli_list(ctx)
async def _cli_list(self, ctx: AppContext) -> ToolResult:
@@ -91,7 +93,7 @@ class ChannelsTool(Tool):
slots = await read_channel_slots(device)
return ToolResult(
summary={"channels": len(slots)},
report=(_channel_listing(slots),),
report=(_channel_listing(slots, ctx.region_store),),
exit_code=exitcodes.OK if slots else exitcodes.NO_RESULT,
)
@@ -194,6 +196,52 @@ class ChannelsTool(Tool):
report=(_cleared(idx, True),),
)
async def _cli_scope(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Read, set or clear the region a channel's messages are sent under.
The scriptable face of the detail page's *Send scope…*. The scope is MeshTerm's to
keep (the firmware has none per channel; the send sets the companion's session
scope around each message), so setting one writes nothing to the radio — the slot
is read only to learn which channel is in it, since the scope is keyed by the
channel's identity and follows it to any slot.
With no region, the plain face prints the scope alone, the way ``config get`` prints
one value — and a channel with none prints ``-`` and exits 0: "no scope, so the
device default" is an answer about a channel that is there, not an empty result. A
set or a clear prints nothing, and the document always carries the channel and its
scope (``null`` for none). Only an empty slot, with no channel to answer about,
exits 5.
"""
from ..core.channel_probe import read_channel_slots
device = await ctx.device()
idx = int(params["index"])
slot = next((s for s in await read_channel_slots(device) if s.idx == idx), None)
region = params.get("region")
if slot is None:
if region is not None or params.get("clear"):
# A write names a channel that isn't there: a bad argument, not an empty
# answer — exit 5 would tell a script the scope was set and read back empty.
import typer
raise typer.BadParameter(f"slot {idx} is empty; there is no channel to scope")
return ToolResult(
summary={"index": idx, "scope": None},
report=(_scoped(idx, None, None, None, changed=False),),
exit_code=exitcodes.NO_RESULT,
)
store = ctx.region_store
changed = bool(params.get("clear")) or region is not None
if params.get("clear"):
store.set_channel_scope(slot.identity, None)
elif region is not None:
store.set_channel_scope(slot.identity, region)
scope = store.channel_scope(slot.identity)
return ToolResult(
summary={"index": idx, "scope": scope},
report=(_scoped(idx, slot.name, slot.secret, scope, changed=changed),),
)
@staticmethod
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.
@@ -274,19 +322,48 @@ class ChannelsTool(Tool):
)
run_tool_command(self, {"cli_action": "clear", "index": index})
@channels_app.command("scope", help="Show or set the region a channel sends into")
def _scope_cmd(
index: int = typer.Argument(..., help="Channel slot index"),
region: str | None = typer.Argument(
None, help="Region to send this channel's messages into (omit to show it)"
),
clear: bool = typer.Option(
False, "--clear", help="Drop the channel's scope (send under the default)"
),
) -> None:
from ..core.regions import RegionNameError, validate
if clear and region is not None:
raise typer.BadParameter("Pass a region or --clear, not both.")
if region is not None:
try:
region = validate(region)
except RegionNameError as exc:
raise typer.BadParameter(str(exc)) from exc
run_tool_command(
self, {"cli_action": "scope", "index": index, "region": region, "clear": clear}
)
app.add_typer(channels_app, name=self.name)
def _channel_listing(slots: list) -> Listing:
def _channel_listing(slots: list, store: object | None = None) -> Listing:
"""The configured slots, one record each.
The one listing whose record *is* a shared shape rather than carrying one: a row that
nested its only field under a ``channel`` key would make ``jq '.[].channel.name'``
out of a listing whose every column is already the channel.
``SCOPE`` is the region the channel's messages are sent under (``-`` and ``null`` for
the device default), read from the region store the send path reads.
"""
from ..ui import fields
from ..ui.report import Listing
def scope_of(slot) -> str | None: # noqa: ANN001 - a ChannelSlot
return store.channel_scope(slot.identity) if store is not None else None
return Listing(
key="channels",
columns=(
@@ -294,6 +371,7 @@ def _channel_listing(slots: list) -> Listing:
fields.name("name", "NAME"),
fields.word("type", "TYPE"),
fields.hexid("hash", "HASH"),
fields.name("scope", "SCOPE"),
),
rows=[
{
@@ -301,12 +379,51 @@ def _channel_listing(slots: list) -> Listing:
"name": slot.name,
"type": "public" if slot.is_public else "private",
"hash": slot.hash,
"scope": scope_of(slot),
}
for slot in slots
],
)
def _scoped(
idx: int, name: str | None, secret: bytes | None, scope: str | None, *, changed: bool
) -> Facts:
"""What ``channels scope`` read or set: the channel and the region it sends into.
Args:
idx: The slot index.
name: The channel name, or ``None`` for a slot that turned out to be empty.
secret: The 16-byte key, or ``None`` for an empty slot.
scope: The channel's scope now, or ``None`` for the device default.
changed: Whether this run set or cleared it — then the plain face is silent, the
way every write's is; a read prints the scope alone.
Returns:
The facts block.
"""
from ..ui import fields, script
from ..ui.fields import ChannelRef, Rendered
from ..ui.report import BARE, SILENT, Facts
channel = None
if name is not None and secret is not None:
public = is_public_channel(name, secret)
channel = ChannelRef(slot=idx, name=name, public=public, hash=channel_hash(secret))
# A channel with no scope answers `-` plain (and `null` in the document): "the device
# default" is an answer about a channel that is there, where the bare form's usual
# silence for an absent value would leave only the exit status to say it. An empty
# slot has no channel to answer about, so it keeps that silence (and its exit 5).
value = Rendered(scope, scope or script.NONE) if channel is not None else None
return Facts(
key="channel",
fields=(fields.channel("channel"), fields.rendered("scope", "scope")),
values={"channel": channel, "scope": value},
shape=SILENT if changed else BARE,
bare="scope",
)
def _written(idx: int, name: str | None, secret: bytes | None, *, shown: bool = True) -> Facts:
"""What a slot now holds, after ``add``, ``join``, ``import`` or ``share``.
+60 -4
View File
@@ -286,15 +286,22 @@ class ChatTool(Tool):
message prints its ``acked`` state, which is the one thing the send does not
already tell the caller: the radio accepted it either way, and whether the peer
answered is a separate fact.
A channel message goes out under the channel's send scope, or under ``scope`` for
this one message (a region name, or ``*`` for unscoped); the document says which it
went under. A radio that can't send under the scope refuses before anything is
transmitted, and that is a failure (exit 1), never a quiet unscoped send.
"""
device = await ctx.device()
text = str(params["text"])
channel = params.get("channel")
if channel is not None:
await ctx.chat.send_channel(int(channel), text, label=f"#{channel}")
message = await ctx.chat.send_channel(
int(channel), text, label=f"#{channel}", scope=params.get("scope") or None
)
return ToolResult(
summary={"channel": channel, "sent": True},
report=(_sent(channel=int(channel)),),
summary={"channel": channel, "sent": True, "scope": message.scope},
report=(_sent(channel=int(channel), scope=message.scope),),
)
contact = _resolve_contact(await device.get_contacts(), str(params["to"]))
@@ -433,11 +440,19 @@ class ChatTool(Tool):
text: str = typer.Argument(..., help="The message body"),
to: str | None = typer.Option(None, "--to", help="Contact name or key prefix"),
channel: int | None = typer.Option(None, "--channel", help="Channel slot index"),
scope: str | None = typer.Option(
None,
"--scope",
help="Send under this region instead of the channel's scope (* = unscoped)",
),
) -> None:
if (to is None) == (channel is None):
raise typer.BadParameter("Pass exactly one of --to / --channel.")
if scope is not None:
scope = _cli_scope(scope, channel)
run_tool_command(
self, {"cli_action": "send", "to": to, "channel": channel, "text": text}
self,
{"cli_action": "send", "to": to, "channel": channel, "text": text, "scope": scope},
)
@chat_app.command("history", help="Show a conversation's stored history")
@@ -476,6 +491,28 @@ class ChatTool(Tool):
# -- helpers ------------------------------------------------------------------
def _cli_scope(scope: str, channel: int | None) -> str:
"""Check ``--scope`` before anything is sent: a region the firmware could hold, or ``*``.
A usage error (exit 2), not a device failure: nothing has been transmitted yet. A
direct message has no scope to give it here — the firmware floods a DM under the
session scope like anything else, but only a channel send sets one for itself.
Raises:
typer.BadParameter: For a direct send, or a name the firmware would refuse.
"""
from ..core.regions import WILDCARD, RegionNameError, normalize, validate
if channel is None:
raise typer.BadParameter("--scope applies to a channel send (--channel).")
if normalize(scope) == WILDCARD:
return WILDCARD
try:
return validate(scope)
except RegionNameError as exc:
raise typer.BadParameter(str(exc)) from exc
def _enable_receive_debug() -> None:
"""Route the message-pull and library debug logs to the console for diagnosis.
@@ -1092,6 +1129,7 @@ def _sent(
contact: object | None = None,
channel: int | None = None,
acked: bool | None = None,
scope: str | None = None,
) -> Facts:
"""What one ``chat send`` did.
@@ -1100,10 +1138,24 @@ def _sent(
answered is a separate fact. A channel broadcast prints nothing, because there *is* no
acknowledgement on a channel — and the document says that in the one way the plain
face never could, with ``null`` rather than ``false``.
The document also carries the ``scope`` a channel message went out under, in the same
shape a received frame's scope takes (:func:`~meshterm.ui.fields.scope`): the region,
``unscoped``, or ``null`` where it isn't known (a direct message, or a default scope
that couldn't be read).
"""
from dataclasses import replace
from ..core.regions import UNSCOPED, WILDCARD, Scope
from ..ui import fields
from ..ui.report import PAIRS, SILENT, Facts
sent_scope = None
if scope == WILDCARD:
sent_scope = UNSCOPED
elif scope:
sent_scope = Scope("scoped", scope)
node = (
NodeRef(
name=contact.name,
@@ -1125,6 +1177,9 @@ def _sent(
fields.channel("channel", lanes=()),
fields.hidden("sent"),
fields.flag("acked", "acked"),
# Document only: a direct message has no scope of its own to print, and a
# channel send prints nothing at all.
replace(fields.scope("scope", "scope"), lanes=()),
),
values={
"kind": "channel" if channel is not None else "direct",
@@ -1134,6 +1189,7 @@ def _sent(
),
"sent": True,
"acked": acked,
"scope": sent_scope,
},
shape=SILENT if channel is not None else PAIRS,
)
+51 -39
View File
@@ -78,6 +78,7 @@ class ConfigTool(Tool):
device = await ctx.device()
ops: list[tuple] = list(params.get("ops") or [])
snapshot = await build_snapshot(device)
learn_default_scope(ctx, snapshot)
changes, artifacts, report = await apply_ops(ctx, device, snapshot, ops)
plural = "" if changes == 1 else "s"
@@ -159,20 +160,6 @@ class ConfigTool(Tool):
def _share_cmd() -> None:
run_tool_command(self, {"ops": [("share",)]})
@config_app.command(
"advert-cadence",
help="Set how often this node auto-advertises in the background (0 = off)",
)
def _advert_cadence_cmd(
hours: int = typer.Argument(
..., min=0, help="Cadence in hours between background adverts (0 disables)"
),
flood: bool = typer.Option(
False, "--flood", help="Set the flood cadence (else the zero-hop / direct one)"
),
) -> None:
run_tool_command(self, {"ops": [("advert_cadence", flood, hours)]})
@config_app.command("export-key", help="Export the private key (sensitive)")
def _export_key_cmd(
out: Path | None = typer.Option(None, "--out", help="Write to file instead of stdout"),
@@ -278,44 +265,33 @@ async def apply_ops(
elif kind == "advert":
flood = len(op) > 1 and bool(op[1])
await device.send_advert(flood)
# A manual advert resets the background scheduler's countdown for its type,
# so the next scheduled send counts from this one (see AdvertScheduler).
# A flood advert, however it was asked for, restarts this device's week: the
# weekly one only ever goes out a full week after the last (see
# AdvertScheduler). A zero-hop reaches only the neighbours, and resets nothing.
public_key = str(snapshot.get("public_key") or "")
if public_key:
ctx.advert_store.mark_sent(public_key, flood=flood)
if flood and public_key:
ctx.advert_store.mark_flood(public_key)
kind_label = "flood" if flood else "zero-hop"
ctx.ui.ack(f"[ok]✓[/ok] {kind_label} advertisement sent")
report.append(_acted("advert", sent=True, flood=flood))
elif kind == "advert_cadence":
from ..core.advert_store import cadence_label
flood, hours = bool(op[1]), int(op[2])
public_key = str(snapshot.get("public_key") or "")
if public_key:
ctx.advert_store.set_cadence(public_key, flood=flood, hours=hours)
kind_label = "flood" if flood else "zero-hop"
ctx.ui.ack(
f"[ok]✓[/ok] background {kind_label} advert: "
f"[brand]{cadence_label(hours)}[/brand]"
)
changes += 1
report.append(_acted("cadence", changes=1, flood=flood, hours=hours))
else: # pragma: no cover - SELF_INFO always carries the key on real firmware
raise DeviceCommandError(
"the device reported no public key — the advert cadence was not saved"
)
elif kind == "share":
report.append(await _share_contact(ctx, snapshot))
elif kind == "sync_clock":
# The same step the on-connect service runs in the background
# (:mod:`meshterm.services.clock_sync`); here it is acknowledged and reported.
done = await set_clock(device)
ctx.ui.ack(f"[ok]✓[/ok] device clock set to [brand]{done.stamp}[/brand]")
changes += 1
if done.written:
ctx.ui.ack(f"[ok]✓[/ok] device clock set to [brand]{done.stamp}[/brand]")
else:
ctx.ui.ack(
f"[ok]✓[/ok] device clock already in sync "
f"([muted]{done.drift_s:+d} s, ahead of this computer's[/muted])"
)
changes += int(done.written)
report.append(
_acted(
"clock",
changes=1,
changes=int(done.written),
set_at=done.set_at,
# What the clock was off by *before* the set: the fact worth logging,
# and the one this command destroys by succeeding.
@@ -326,6 +302,10 @@ async def apply_ops(
await device.reboot()
ctx.ui.ack("[warn]device rebooting[/warn]")
report.append(_acted("reboot", rebooted=True))
if ctx.active_transport is not None:
# Run from the menu's command line, this hands off to the reconnect dialog
# as the config editor's reboot does; a scripted run has no watcher to wake.
ctx.announce_reboot()
elif kind == "export_key":
report.append(await _export_key(ctx, device, op[1] if len(op) > 1 else None, artifacts))
elif kind == "import_key":
@@ -379,10 +359,42 @@ async def _apply_setting(
# meshterm.core.settings_store). Provenance-gated: only values changed through MeshTerm.
if ctx.settings_store is not None:
ctx.settings_store.remember(str(snapshot.get("public_key") or ""), key, value)
if key == "flood_scope":
learn_default_scope(ctx, snapshot)
ctx.ui.ack(f"[ok]✓[/ok] [brand]{key}[/brand] = {format_value(spec, value)}")
return {"key": key, "previous": previous, "value": value}
def learn_default_scope(ctx: AppContext, snapshot: dict) -> None:
"""Take in the default flood scope a config read or write has just seen.
Two things want it. The region store learns the name (source ``default``): it is the
region this station's own plain floods carry, so it is the first name any of them will
be resolved against. And the session cache holds it (``devstate``), since a channel with
no scope of its own records its messages as sent under the default, and this saves that
record a round trip.
A snapshot whose read of the default failed (firmware before 1.15) carries no
``flood_scope`` key at all, and teaches nothing.
Args:
ctx: Shared application context.
snapshot: A :func:`~meshterm.core.device_config.build_snapshot` result, or one an
apply has just updated.
"""
if "flood_scope" not in snapshot:
return
from ..core.regions import normalize
name = normalize(str(snapshot.get("flood_scope") or ""))
devstate = getattr(ctx, "devstate", None)
if devstate is not None:
devstate.note_default_scope(name)
store = getattr(ctx, "region_store", None)
if store is not None and name:
store.learn(name, "default")
async def _show(ctx: AppContext, device: Device, snapshot: dict) -> Listing | None:
"""Print every current setting, plus any custom variables.
+43 -6
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``devices`` tool: enumerate attached serial and in-range Bluetooth companions.
"""The ``devices`` tool: enumerate attached serial, SPI, and in-range Bluetooth companions.
Discovery never opens the radio — it only lists what is attached or advertising. This is a
CLI-only, read-only inventory (``menu_visible = False``): by the time the interactive menu is
@@ -18,7 +18,15 @@ import typer
from ..context import AppContext
from ..core import exitcodes
from ..core.discovery import TRANSPORT_MOCK, DiscoveredDevice, discover_all, discover_devices
from ..core.discovery import (
TRANSPORT_MOCK,
DiscoveredDevice,
ble_unavailable_reason,
discover_all,
discover_devices,
)
from ..core.selection import DeviceSelectionError
from ..core.spiradio import spi_radios
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
@@ -33,11 +41,11 @@ _MAYBE: dict[str, str] = {"board": "maybe", "bridge": "maybe", "unknown": "no"}
@register
class DevicesTool(Tool):
"""List attached serial + in-range Bluetooth companions, flag likely LoRa, mark the default."""
"""List attached serial, SPI, and in-range Bluetooth companions; mark likely and active."""
name = "devices"
title = "Devices"
help = "List attached serial and Bluetooth devices, flagging likely companions"
help = "List attached serial, SPI, and Bluetooth devices, flagging likely companions"
category = "This node"
order = 5
menu_visible = False # CLI-only: a startup diagnostic with no place in a connected session
@@ -68,10 +76,39 @@ class DevicesTool(Tool):
else:
scan_ble = params.get("ble", True)
devices = await discover_all(ble=scan_ble) if scan_ble else discover_devices()
# A radio on the SPI bus is attached the way a serial port is, but nothing
# enumerates it: it is listed from its device node, as the device screen lists it.
devices += spi_radios(ctx.settings.profiles, devices)
known = ctx.device_store.load_all()
remembered = ctx.device_store.load()
active = ctx.selected_device
active_target = ctx.ble_override or ctx.port_override or (active.target if active else None)
try:
spi = (
ctx.resolve_spi()
if ctx.spi_override or (ctx.profile is not None and ctx.profile.is_spi)
else None
)
except DeviceSelectionError:
spi = None # an ambiguous --spi: the listing is how the reader finds out which
active_target = (
ctx.ble_override
or ctx.port_override
or (spi.spidev if spi is not None else None)
or (active.target if active else None)
)
# A refused Bluetooth scan is not the same as a quiet one, and the difference is
# invisible in the listing — both simply lack BLE rows. Say so whether or not
# anything else was found: a serial board being present does not make a companion
# the reader expected over Bluetooth any less missing.
blocked = ble_unavailable_reason()
if blocked:
from ..ui import script
script.stderr_console().print(
f"meshterm: Bluetooth not scanned — {blocked}", style="warn", highlight=False
)
if not devices:
# Not an error: the scan ran and found nothing. Said on stderr so a caller
@@ -107,7 +144,7 @@ class DevicesTool(Tool):
# The listing used to close with a line explaining its own markers and how to
# act on a row. That is help, and this is where help goes.
epilog="Select a device with --port TARGET or --ble TARGET, using the "
"TARGET column verbatim.",
"TARGET column verbatim; an SPI radio with --spi, or -p and its profile.",
)
def _devices(
ble: bool = typer.Option(
+530
View File
@@ -0,0 +1,530 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``diagnostics`` tool: everything a bug report needs, in one block worth pasting.
A report that opens with "it crashed" costs two or three round trips before anyone knows
which MeshTerm, which OS, which terminal, which radio — and the person who hit the bug is
usually the one least able to answer, because half of it is resolved at boot and never
shown. This states all of it at once.
**What it deliberately does not say.** The block is designed to be pasted in public by
somebody who has not read it, so nothing that identifies a person or unlocks anything may
be in it: no pairing PIN, no admin password, no channel secret, no private key, no
position, and no contact's name or key. The mesh is described in **aggregate** — how many
rows of each kind, and the span of time they cover — which is the half that explains a bug
(a database with four observations and one with four hundred thousand fail differently)
without naming anyone the reporter talks to. :func:`~meshterm.core.hostinfo.host` and
:meth:`~meshterm.persistence.repository.Repository.table_counts` hold that line on their
own side, so it cannot be crossed here by accident.
**One report, three faces**, which is the whole reason this is a report and not a
``print``: the menu draws it as a written page (see :mod:`meshterm.ui.diagnostics`) and
saves it as markdown, ``meshterm diagnostics`` prints the same facts as aligned records for
anyone who would rather redirect than read, and ``--json`` hands them to whatever files the
issue. The document face is the one that leaves this machine, so it is the one written in
the format an issue tracker already renders.
The device is asked but never required. A report about a radio that will not connect is
exactly the report that most needs to be filed, so a failure to reach it becomes a fact in
the block (``connected``/``error``) rather than an error that replaces it.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from ..context import AppContext
from ..core.models import node_type_label
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Report
#: What the saved file is called, in the config directory beside the log it sits next to
#: in a bug report. Named for the app rather than just ``diagnostics.md`` because the one
#: thing that happens to this file is being moved somewhere else and attached, and by then
#: the directory it came from is no longer saying what it is.
DIAGNOSTICS_FILENAME = "meshterm-diagnostics.md"
@register
class DiagnosticsTool(Tool):
"""What MeshTerm, this machine, this terminal and this radio actually are."""
name = "diagnostics"
title = "Diagnostics"
icon = "🩺"
help = "Version, host, terminal, and device facts for a bug report"
category = "This app"
order = 7 # after Preferences (the row that changes MeshTerm), before the pages
def register_cli(self, app: Any) -> None:
"""Register the command, with the one option that turns the answer into a file.
``--out`` is spelled the way ``config export-key --out`` already spells it, rather
than inventing a second word for writing an answer to a path. Plain redirection
still works and is not replaced by it — what the option adds is a file whose
columns are laid out at full width instead of at whatever the terminal happened to
be, which is the difference between a readable attachment and a folded one.
"""
import typer
from ..cli import run_tool_command
@app.command(name=self.name, help=self.help)
def _command(
out: Path | None = typer.Option(
None, "--out", help="Write the block to this file instead of printing it"
),
) -> None:
run_tool_command(self, {"out": out})
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
"""Open the page in the menu; returning ``None`` completes with no run row.
The same shape the About pages use — and the same screen, since the page *is* one:
the menu's whole interaction is the reading of it, and a run row recording that
somebody looked at their own version number would be a line in the log for every
glance. Saving from the page is likewise not a run.
Args:
ctx: Shared application context.
Returns:
Always ``None`` in the menu; the CLI never reaches this.
"""
from ..ui.diagnostics import open_diagnostics_page
report = await build_report(ctx)
await open_diagnostics_page(ctx, markdown_source(report), save_path=default_path(ctx))
return None
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""State the diagnostics, or write them to a file — only reachable from the CLI.
With ``--out`` the answer *becomes the path*, the same trade ``config export-key``
makes: a caller who asked for a file wants to be told where it is, not handed the
contents they just redirected into it.
Args:
ctx: Shared application context.
params: ``out`` — a destination path, or ``None`` to print the block.
Returns:
A :class:`ToolResult` carrying the block, or the path it was written to.
"""
report = await build_report(ctx)
out = params.get("out")
if out is None:
return ToolResult(summary={"tool": self.name}, report=report)
written = write_markdown(markdown_source(report), out)
ctx.ui.ack("[ok]✓[/ok] diagnostics written — attach it to the report.")
return ToolResult(
summary={"tool": self.name, "path": str(written)},
artifacts=[str(written)],
report=(_written_to(written),),
)
async def build_report(ctx: AppContext) -> Report:
"""Gather every diagnostic fact, as the report both faces render.
Seven blocks in the order a reader needs them: what this program is, what it is running
on, what it is drawing into, what radio it found, how much history it has, what that
history is made of, and what has been changed from the defaults. Each carries a
``caption``, which is what the markdown face turns into the page's ``##`` landmarks.
The five :class:`~meshterm.ui.report.Facts` blocks merge into one flat JSON object and
the two :class:`~meshterm.ui.report.Listing` blocks keep a key of their own — which is
also why the table counts are a listing, since a table named after a fact would
otherwise be able to collide with it.
Args:
ctx: Shared application context.
Returns:
The seven-block report.
"""
return (
_app_facts(),
_host_facts(),
_terminal_facts(),
await _device_facts(ctx),
_mesh_facts(ctx),
_table_listing(ctx),
_preference_listing(ctx),
)
def markdown_source(report: Report, *, when: datetime | None = None) -> str:
"""The whole block as a markdown document — the page's source, and the file's contents.
Markdown because both places this goes already speak it: the menu draws it through
:func:`~meshterm.ui.markdown.render_markdown`, the same renderer behind the About
pages, so the page gets real ``##`` landmarks that pin as it scrolls and that its
section jumps step by; and an issue tracker renders it as written, with no fence and
no apology. The command line keeps its aligned records — that face is a stream a
person greps, and headings are furniture on it.
Args:
report: The blocks to write out.
when: The moment to stamp in the colophon (defaults to now, local). Injectable so
the same report can be rendered twice and compared.
Returns:
Markdown source, ready for :func:`~meshterm.ui.markdown.render_markdown` or a file.
"""
from .. import __version__
from ..ui.renderers import markdown_blocks
stamp = (when or datetime.now().astimezone()).strftime("%Y-%m-%d %H:%M")
return (
# The `#` title carries what the frame around it cannot — which is the same reason
# `about.md` has one and the other three written pages do not. The screen is already
# headed "Diagnostics"; repeating that would spend the page's first line saying
# nothing, while the version belongs at the top of a file somebody will open later.
# Emphasis makes it the muted qualifier beside the name.
f"# MeshTerm diagnostics *v{__version__}*\n\n"
# The standfirst sits flush and muted under the title, and says the one thing a
# reader needs before reading any of it: this is safe to hand over.
"Everything a bug report opens with. Nothing here is private.\n\n"
f"{markdown_blocks(report)}\n\n"
"---\n\n"
f"Taken {stamp}.\n"
)
def default_path(ctx: AppContext) -> Path:
"""Where the page saves when nobody named a place: beside the log, in the config dir.
The config directory rather than the working directory, which in the menu is wherever
the reader happened to launch from and is not somewhere they can be told to look. It
is also where the log already lives, so "send me both" names one folder.
"""
return ctx.settings.config_dir / DIAGNOSTICS_FILENAME
def write_markdown(source: str, path: Path | str) -> Path:
"""Write a markdown document to ``path``, creating its directory.
**Always markdown**, whatever face the run was printing. This file exists to be
attached to an issue and read there, and an issue tracker renders markdown; anyone who
wants the machine face can redirect ``--json`` and already has a better name for that
file than this one.
Args:
source: The document, from :func:`markdown_source`.
path: The destination.
Returns:
The path written.
Raises:
OSError: If the directory cannot be made or the file cannot be written.
"""
written = Path(path)
written.parent.mkdir(parents=True, exist_ok=True)
written.write_text(source, encoding="utf-8")
return written
def _written_to(written: Path) -> Any:
"""The answer a ``--out`` run gives: the path, alone on the plain face."""
from ..ui import fields
from ..ui.report import BARE, Facts
return Facts(
key="saved",
fields=(fields.path("path", "path"),),
values={"path": written},
shape=BARE,
bare="path",
)
def _app_facts() -> Any:
"""What this program is: version, how it was installed, and where it keeps things."""
from .. import __version__
from ..core import hostinfo
from ..ui import fields
from ..ui.report import Facts
return Facts(
key="meshterm",
caption="Build",
fields=(
fields.word("meshterm", "meshterm"),
fields.word("install", "install"),
),
values={"meshterm": __version__, "install": hostinfo.install_kind()},
)
def _host_facts() -> Any:
"""The machine underneath: which OS, which build, which processor, which Python."""
from ..core import hostinfo
from ..ui import fields
from ..ui.report import Facts
machine = hostinfo.host()
return Facts(
key="host",
caption="Host",
fields=(
fields.word("os", "os"),
fields.word("os_build", "os_build"),
fields.word("arch", "arch"),
fields.word("python", "python"),
),
values={
"os": machine.os,
"os_build": machine.os_build,
"arch": machine.arch,
"python": machine.python,
},
)
def _terminal_facts() -> Any:
"""The terminal, and every verdict about it resolved once at boot.
Its own section rather than more host facts, because these are what a "it looks wrong"
report is actually about, and a reader skimming for them should not have to pass the
processor architecture on the way. Whether icons draw, whether the path widget's
separators draw, which platform flavour resolved and *why* — each is decided from the
environment before the first frame and none of it is visible on any screen.
"""
from ..core import hostinfo
from ..platforms import get_platform
from ..ui import fields
from ..ui.report import Facts
from ..ui.termfont import detect_terminal_font, emoji_support, powerline_support
term = hostinfo.terminal()
emoji = emoji_support()
powerline = powerline_support()
font = detect_terminal_font()
return Facts(
key="terminal",
caption="Terminal",
fields=(
fields.word("terminal", "terminal"),
fields.word("term", "term"),
fields.word("colorterm", "colorterm"),
fields.word("terminal_size", "terminal_size"),
fields.flag("over_ssh", "over_ssh"),
fields.word("platform", "platform"),
fields.flag("icons", "icons"),
fields.word("icons_source", "icons_source"),
fields.word("font", "font"),
fields.word("powerline", "powerline"),
),
values={
"terminal": term.program,
"term": term.term,
"colorterm": term.colorterm,
"terminal_size": f"{term.cols}x{term.rows}",
"over_ssh": term.over_ssh,
"platform": get_platform().name,
"icons": emoji.supported,
"icons_source": emoji.source,
# The face, and which terminal it was read from — an unidentified terminal
# (or an ssh session, where the font lives on the far client) has no answer,
# and saying so is worth more than naming a font nobody here can see.
"font": f"{font.face} ({font.source})" if font else None,
# Level and source together: `none (font:windows-terminal)` is a measurement
# and `unknown (ssh)` is an admission, and they lead somewhere different.
"powerline": f"{powerline.level} ({powerline.source})",
},
)
async def _device_facts(ctx: AppContext) -> Any:
"""The radio: what it is, how it is attached, and what its link is set to.
Best-effort by design. Every value here is ``None`` on a machine whose radio is
unplugged, asleep, or refusing to pair — and ``connected: false`` with the failure's
own words in ``error`` is a better first line for that bug report than a command that
refused to produce one.
"""
from ..ui import fields
from ..ui.report import Facts
values: dict[str, Any] = dict.fromkeys(
(
"connected",
"transport",
"port",
"device_role",
"device_model",
"firmware",
"radio_freq_mhz",
"radio_bw_khz",
"radio_sf",
"radio_cr",
"tx_power_dbm",
"error",
)
)
values["connected"] = False
try:
device = await ctx.device()
snapshot = await _snapshot(ctx, device)
info = await _device_info(device)
adv_type = snapshot.get("adv_type")
values.update(
connected=True,
transport="mock" if ctx.mock else (ctx.active_transport or "serial"),
port=ctx.active_port,
device_role=node_type_label(adv_type),
device_model=info.get("model") or None,
firmware=" ".join(str(info[k]) for k in ("ver", "fw_build") if info.get(k)) or None,
radio_freq_mhz=snapshot.get("radio_freq"),
radio_bw_khz=snapshot.get("radio_bw"),
radio_sf=snapshot.get("radio_sf"),
radio_cr=snapshot.get("radio_cr"),
tx_power_dbm=snapshot.get("tx_power"),
)
except Exception as exc: # noqa: BLE001 - any failure to reach the radio is the fact
values["error"] = str(exc) or exc.__class__.__name__
return Facts(
key="device",
caption="Radio",
fields=(
fields.flag("connected", "connected"),
fields.word("transport", "transport"),
fields.word("port", "port"),
fields.word("device_role", "device_role"),
fields.word("device_model", "device_model"),
fields.word("firmware", "firmware"),
fields.decimal("radio_freq_mhz", "radio_freq_mhz", ".4f"),
fields.decimal("radio_bw_khz", "radio_bw_khz", ".2f"),
fields.integer("radio_sf", "radio_sf"),
fields.integer("radio_cr", "radio_cr"),
fields.integer("tx_power_dbm", "tx_power_dbm"),
fields.free("error", "error"),
),
values=values,
)
def _mesh_facts(ctx: AppContext) -> Any:
"""How much history this install holds, and where it holds it.
Counts and a span, never a row. The span is the fact that sizes every other number
here: a thousand observations over two years and a thousand in an afternoon describe
two different installs, and only one of them has a radio worth suspecting.
"""
from ..core.preferences import current
from ..persistence.logging import log_path
from ..ui import fields
from ..ui.report import Facts
preferences = ctx.preferences or current()
db_path = ctx.settings.db_path
first, last = ctx.repo.observation_span()
return Facts(
key="mesh",
caption="Storage",
fields=(
fields.path("config_dir", "config_dir"),
fields.integer("db_size_kb", "db_size_kb"),
fields.word("log_level", "log_level"),
fields.integer("log_size_kb", "log_size_kb"),
fields.integer("runs_failed", "runs_failed"),
fields.instant("first_heard", "first_heard"),
fields.instant("last_heard", "last_heard"),
),
values={
"config_dir": ctx.settings.config_dir,
"db_size_kb": _size_kb(db_path),
"log_level": preferences.log_level,
# How much the log holds, so a maintainer asking for it knows what they are
# asking for — and so a file still at zero says the level never let it write.
"log_size_kb": _size_kb(log_path(ctx.settings.config_dir)),
"runs_failed": ctx.repo.failed_run_count(),
"first_heard": first,
"last_heard": last,
},
)
def _table_listing(ctx: AppContext) -> Any:
"""Every table in the database and how many rows it holds.
A listing rather than more facts, for two reasons. The keys are discovered from the
schema rather than declared, so a table named after a fact could otherwise collide
with it in the merged JSON object; and this genuinely is a set of records — one per
table — which is the shape a reader and a parser both already know.
"""
from ..ui import fields
from ..ui.report import Column, Listing
counts = ctx.repo.table_counts()
return Listing(
key="tables",
caption="Stored rows",
columns=(fields.word("table", "TABLE"), Column(key="rows", lanes=(_rows_lane(),))),
rows=[{"table": name, "rows": n} for name, n in counts.items()],
)
def _preference_listing(ctx: AppContext) -> Any:
"""The preferences that differ from their defaults — the whole list, and only those.
An override is the only part of the preference set that can explain anything: the
defaults are in the source, the same for everyone, and dumping all forty would bury
the two the reporter actually changed.
"""
from ..core.preferences import current
from ..ui import fields
from ..ui.report import Listing
preferences = ctx.preferences or current()
return Listing(
key="preferences",
caption="Changed preferences",
columns=(fields.word("preference", "PREFERENCE"), fields.free("value", "VALUE")),
rows=[
{"preference": key, "value": str(value)}
for key, value in preferences.overrides().items()
],
)
def _rows_lane() -> Any:
"""The row-count lane: right-aligned, because a column of magnitudes only compares aligned."""
from ..ui.report import Lane
return Lane(header="ROWS", render=lambda v: str(v if v is not None else 0), align="right")
def _size_kb(path: Any) -> int | None:
"""A file's size in whole kilobytes, or ``None`` when it is not there to measure."""
if path is None:
return None
try:
return round(path.stat().st_size / 1024)
except OSError:
return None
async def _snapshot(ctx: AppContext, device: Any) -> dict:
"""The device's settings snapshot, or an empty one when the read fails."""
from ..ui.config_editor import cached_snapshot
try:
return await cached_snapshot(ctx, device)
except Exception: # noqa: BLE001 - a settings read that fails leaves the radio facts blank
return {}
async def _device_info(device: Any) -> dict:
"""The device's self-description, or an empty one when the firmware does not answer."""
try:
return await device.get_device_info() or {}
except Exception: # noqa: BLE001 - firmware predating the query contributes nothing
return {}
+3 -5
View File
@@ -14,14 +14,12 @@ from rich.text import Text
from ..context import AppContext
from ..core.connection import Device
from ..core.models import node_type_label
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Column, Facts
#: MeshCore advert-type byte -> human-readable node role.
_ROLES = {1: "companion", 2: "repeater", 3: "room server", 4: "sensor"}
@register
class InfoTool(Tool):
@@ -141,7 +139,7 @@ async def _status_panel(device: Device, snapshot: dict) -> Panel:
adv_type = snapshot.get("adv_type")
if adv_type is not None:
role = _ROLES.get(int(adv_type), f"type {adv_type}")
role = node_type_label(adv_type)
rows.append(("role", Text(role, style="brand")))
if info.get("model"):
@@ -239,7 +237,7 @@ async def status_facts(device: Device, snapshot: dict) -> Facts:
# `config show`'s answer, not this one.
"name": snapshot.get("name"),
"public_key": str(snapshot.get("public_key") or "").lower() or None,
"role": _ROLES.get(int(adv_type), f"type {adv_type}") if adv_type is not None else None,
"role": node_type_label(adv_type),
"model": info.get("model"),
"firmware": _firmware(info) or None,
"battery_v": round(int(level) / 1000, 2) if level else None,
+11 -2
View File
@@ -112,6 +112,9 @@ class MonitorTool(Tool):
"snr_db": obs.snr,
"rssi_dbm": obs.rssi,
"position": _position(obs.lat, obs.lon),
# The region a flood was sent into, resolved against the names known
# right now; a direct frame (or a class with no route) has none.
"scope": ctx.region_store.scope_of(obs.raw),
"path": _relays(obs.path),
}
)
@@ -188,7 +191,9 @@ def _live_packets() -> Listing:
"""The shape the live capture streams: one record per packet, as it lands.
``TIME`` is absolute where a listing's times are ages, and for the reason the whole
rule turns on: every row of a live tail would read ``now``. The **name goes last**, so
rule turns on: every row of a live tail would read ``now``. ``SCOPE`` says which region
a flood was sent into (:func:`~meshterm.ui.fields.scope`), ``null`` in the document for
a frame that has none. The **name goes last**, so
the one field with no width cannot push a lane — which is what finally let the two
streams have columns at all, and with them the quoting could go.
"""
@@ -217,9 +222,13 @@ def _live_packets() -> Listing:
pin(fields.snr("snr_db", "SNR_DB"), 6),
pin(fields.decimal("rssi_dbm", "RSSI_DBM", ".0f"), 8),
pin(fields.position(), 19),
# The flood's scope — a region name, ``unknown`` or ``unscoped``, ``-`` for a
# frame with none. Pinned to hold the two words whole; a long region name
# overruns and pushes the row right, which is why NAME still goes last.
pin(fields.scope(), 10),
fields.hidden("path"),
),
order=("TIME", "NODE", "SNR_DB", "RSSI_DBM", "LOCATION", "NAME"),
order=("TIME", "NODE", "SNR_DB", "RSSI_DBM", "LOCATION", "SCOPE", "NAME"),
)
+6 -1
View File
@@ -12,7 +12,7 @@ staging eleven changes or from a single ``preferences set``.
This is deliberately the *app's* tool, not the radio's. It reads nothing from the
companion, transmits nothing, and works with no device attached at all — which is why it
leads the **This app** section (the third scope, after this node and other nodes) rather
leads the **This app** section (the third owner, after this node and other nodes) rather
than sitting beside Device config.
"""
@@ -127,6 +127,11 @@ class PreferencesTool(Tool):
if changes:
prefs.save()
if any(c["key"] == "weekly_flood_advert" for c in applied):
# Switching the weekly advert on starts its week rather than sending, so the
# instant is recorded as it happens — from the page and a shell alike — and
# not whenever the scheduler next looks (see meshterm.core.advert_store).
ctx.advert_store.set_enabled(bool(prefs.weekly_flood_advert))
if not scripted and any(c["key"] == consolefont.PREFERENCE_KEY for c in applied):
# The one preference whose effect is outside the app: the console's font is
# the console's, so it takes hold now rather than next launch. The kernel
+10 -1
View File
@@ -26,6 +26,9 @@ from ..services.records import CATEGORIES, CATEGORY_BY_ID, Category
from ..ui.script import NONE
from .base import Tool, ToolResult, register
#: The hash widths a record can be set at: MeshCore's path hashes are 1, 2 or 4 bytes.
WIDTHS = (1, 2, 4)
if TYPE_CHECKING: # pragma: no cover - typing only
from ..persistence.repository import DiscoveredPath
from ..ui.fields import NodeRef
@@ -183,7 +186,13 @@ class TrophyCaseTool(Tool):
f"--category must be one of: {choices} (got {category!r})"
)
tool_params["category"] = category
if width:
if width is not None:
# The same closed set as --category, for the same reason: `--width 3`
# found nothing and exited 5, indistinguishable from an empty database,
# and `--width 0` was falsy and so quietly listed every width.
if width not in WIDTHS:
choices = ", ".join(map(str, WIDTHS))
raise typer.BadParameter(f"--width must be one of: {choices} (got {width})")
tool_params["width"] = width
run_tool_command(self, tool_params)
+132
View File
@@ -0,0 +1,132 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``regions`` command: ask a repeater which regions it relays floods for.
``meshterm regions <repeater>`` sends the anonymous regions request (firmware 1.12+, no
login) and prints the answer — the regions it relays scoped floods for, and whether it also
relays unscoped ones. One request, one transmission, per invocation: the repeater
rate-limits the question, and nothing here retries it.
It is a command-line face only. In the menu the same question lives on a repeater's node
page (*Ask which regions it carries*), beside the answer it last gave, so there is no menu
row for it here.
The answer is learned into the region store either way, exactly as the node page learns it,
so a scoped flood heard afterwards can be traced back to a region this repeater named.
Exit status, per the CLI's rules: ``0`` when it answered with anything (unscoped floods
alone are an answer), ``5`` when it answered and named nothing at all — it relays no floods
— ``2`` for a contact that does not exist or is known not to be a repeater (nothing was
sent), and ``4`` when it never answered, which is what a repeater out of direct reach does:
the request is only answered when it arrives from a neighbour or over a known route.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import typer
from ..context import AppContext
from ..core import exitcodes
from ..core.models import NODE_TYPE_LABELS, NODE_TYPE_REPEATER, Contact
from ..core.regions import WILDCARD
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Facts
@register
class RegionsTool(Tool):
"""Ask one repeater which regions it carries (command line only)."""
name = "regions"
title = "Regions"
icon = "🔖"
help = "Ask a repeater which regions it relays floods for"
category = "Other nodes"
order = 20
menu_visible = False # the menu asks from the repeater's node page
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Send the regions request to one repeater and report its answer.
Args:
ctx: Shared application context.
params: ``node``, the repeater's contact name.
Returns:
The answer as a report; ``NO_RESULT`` when it named nothing.
Raises:
typer.BadParameter: For an unknown contact, or one known not to be a repeater —
both decided before anything is sent.
DeviceCommandError: When the repeater never answered.
"""
device = await ctx.device()
contacts = await device.get_contacts()
name = str(params["node"])
node = next((c for c in contacts if c.name == name), None)
if node is None:
raise typer.BadParameter(f"unknown contact: {name!r}")
if node.node_type is not None and node.node_type != NODE_TYPE_REPEATER:
role = NODE_TYPE_LABELS.get(node.node_type, "not a repeater")
raise typer.BadParameter(
f"{name!r} is a {role}; only a repeater answers the regions request"
)
names = await device.request_regions(node)
node_id = (node.public_key or node.key_prefix or "").lower()[:12]
if node_id and ctx.region_store is not None:
ctx.region_store.learn_carried(node_id, names)
regions = [n for n in names if n != WILDCARD]
unscoped = WILDCARD in names
return ToolResult(
report=(_answer(node, unscoped, regions),),
summary={"node": name, "unscoped": unscoped, "regions": regions},
exit_code=exitcodes.OK if names else exitcodes.NO_RESULT,
)
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``regions`` subcommand.
Args:
app: The Typer application.
"""
from ..cli import run_tool_command
@app.command(name=self.name, help=self.help)
def _regions(
node: str = typer.Argument(..., help="The repeater's contact name"),
) -> None:
run_tool_command(self, {"node": node})
def _answer(node: Contact, unscoped: bool, regions: list[str]) -> Facts:
"""The repeater's answer, stated once for both faces.
Three facts: who answered, whether it relays unscoped floods, and the regions it relays
scoped floods for — the wildcard kept out of that list, since it names no region (see
:func:`~meshterm.ui.fields.regions`).
"""
from ..ui import fields
from ..ui.fields import NodeRef
from ..ui.report import Facts
return Facts(
key="regions",
fields=(
fields.node("node", lanes=(("name", "node"),)),
fields.flag("unscoped", "unscoped"),
fields.regions("regions", "regions"),
),
values={
"node": NodeRef(
name=node.name,
key=(node.public_key or "").lower() or None,
hash=(node.key_prefix or "").lower() or None,
type=NODE_TYPE_LABELS.get(node.node_type),
),
"unscoped": unscoped,
"regions": regions,
},
)
+204 -31
View File
@@ -16,11 +16,19 @@ recorded to history. The mute lives in :class:`~meshterm.core.mute_store.MuteSto
the channel's intrinsic identity so it follows the channel across slot moves, and the list
row shows a ``🔕`` in its (then always-empty) unread lane to mark it.
A channel's **send scope** is the other: the region its messages are flooded into, so only
the repeaters carrying that region relay them. The firmware has no such thing per channel —
the chat sets the companion's session scope around each send instead (see
:meth:`~meshterm.core.connection.Device.send_channel_in_scope`) — so it is kept by MeshTerm,
in :class:`~meshterm.core.region_store.RegionStore`, keyed the same way a mute is. The
detail page's *Send scope…* row picks it from the regions already known here, or takes a
typed name.
The list is laid out like the config editor: fixed, column-aligned lanes under one header
line — the openness glyph and name, unread badge, total messages, last-message age, and a
braille sparkline of the trailing two hours' traffic — so a glance shows not just *which*
channels exist but which ones are alive. Openness beyond the glyph, and the hash, live in
the detail views (the title line and Show key), keeping the list lean enough that the
line — the openness glyph and name, send scope, unread badge, total messages, last-message
age, and a braille sparkline of the trailing two hours' traffic — so a glance shows not
just *which* channels exist but which ones are alive. Openness beyond the glyph, and the
hash, live in the detail views (the title line and Show key), keeping the list lean enough that the
activity lane survives a 72-column terminal. The message statistics come from
:meth:`~meshterm.persistence.repository.Repository.channel_stats` (read through a small
TTL cache) and the unread counts from the live chat service, and each row is a callable
@@ -55,6 +63,8 @@ from ..core.channels import (
share_url,
)
from ..core.connection import Device
from ..core.regions import RegionNameError
from ..core.regions import validate as validate_region
from ..persistence.repository import ACTIVITY_DRAWN_BUCKETS
from ..platforms import get_platform
from .braillechart import activity_peak, activity_sparkline
@@ -93,7 +103,15 @@ _QR = "qr"
_KEY = "key"
_CHAT = "chat"
_MUTE = "mute"
_SCOPE = "scope"
_EDIT = "edit"
# Send-scope picker sentinels: clear the channel's scope, or type a region by name.
_NO_SCOPE = "__no_scope__"
_TYPE_REGION = "__type_region__"
#: The scope picker's footer: a value picker over a list that can grow, so it filters.
_SCOPE_HINT = "↑↓ move · type to filter · Enter set · Esc keep"
_CLEAR = "clear"
@@ -450,6 +468,21 @@ class _LiveStats:
#: Widest the name lane grows (longer names are ellipsized so the lanes stay put).
_NAME_WIDTH_MAX = 18
#: Cells the name and send-scope lanes share. The scope lane is drawn only while at least
#: one channel on the list has a scope — a column of blanks says nothing — and is then only
#: as wide as the longest scope (never narrower than its ``SCOPE`` label), taking what the
#: names leave of this budget: with a name at :data:`_NAME_WIDTH_MAX` it gets eight cells —
#: what the 72-column row had spare before the lane existed, less the last cell an
#: ellipsizing row keeps back — so the activity sparkline is never cut to make room; shorter
#: names give a long region name more of its own. A 30-byte region name is ellipsized rather
#: than let widen the row. On the PicoCalc's 53 the row was already cut inside the activity
#: lane, so a scope lane there costs the MSGS count behind it; the detail page states it.
#: The leading SLOT lane is paid for out of this budget too (see :func:`_menu_items`), for
#: the same reason: the sparkline keeps its cells.
_NAME_SCOPE_BUDGET = _NAME_WIDTH_MAX + 8
#: Narrowest the right-aligned slot-index lane is drawn (two digits: MeshCore's default
#: table holds more than ten slots); a wider table widens it to its highest index.
_SLOT_WIDTH_MIN = 2
#: Width of the unread-badge lane (fits ``● 999``), matching the conversation picker's.
_BADGE_WIDTH = 5
#: Width of the right-aligned total-messages lane.
@@ -479,17 +512,23 @@ def _activity_sparkline(histogram: tuple[int, ...], peak: float) -> Text:
# --- menus -------------------------------------------------------------------
def _lanes_header(name_w: int, width: int) -> str:
def _lanes_header(slot_w: int, name_w: int, scope_w: int, width: int) -> str:
"""Column headers over the channel list's fixed lanes (see :func:`_slot_text`).
The indent covers the select screen's pointer column (2 cells, drawn on choice rows but
not separators) plus the glyph lane, *measured* rather than assumed: a channel's glyph
is two cells on the desktop and one on the console, so a hard-coded indent put every
label a column off there. ``UNREAD`` borrows its lane's trailing gap — the badge lane
not separators). ``SLOT`` leads — the index every ``channels`` command and the chat's
slot references take — and its lane also spans the unlabelled glyph lane after it,
*measured* rather than assumed: a channel's glyph is two cells on the desktop and one on
the console, so a hard-coded width put every later label a column off there. ``UNREAD``
borrows its lane's trailing gap — the badge lane
itself is one cell too narrow for the word — which still leaves a space before the
message count. (No TYPE or HASH lane: the glyph already carries the openness and the
hash lives in Show key, which buys the activity sparkline its room on a 72-column
terminal.)
terminal.) ``SCOPE`` sits between the name and the badge: it says where the channel's
messages go, which is the channel's own fact, before the lanes that count its traffic —
and only while some channel has one (``scope_w`` is 0 otherwise). ``LAST`` comes before
``MSGS``: how recently a channel spoke is the question a glance down the list asks, and
the total is the detail behind it.
Resolved against the render width, because the header row is pinned and must stay one
row: at 53 columns the full line ran to 54 and wrapped, costing a content row out of
@@ -498,22 +537,23 @@ def _lanes_header(name_w: int, width: int) -> str:
"""
return column_header(
[
Lane("SLOT", slot_w + 2 + cell_len(channel_glyph("Public", None)) + 1),
Lane("CHANNEL", name_w + 2),
*([Lane("SCOPE", scope_w + 2)] if scope_w else []),
Lane("UNREAD", _BADGE_WIDTH + 2),
# Right-aligned, because the values under them are: a count and an age are
# Right-aligned, because the values under them are: an age and a count are
# padded to the right edge of their lane, so a left-aligned label would sit
# off the digits it names.
Lane(f"{'MSGS':>{_COUNT_WIDTH}}", _COUNT_WIDTH + 2),
Lane(f"{'LAST':>{_AGE_WIDTH}}", _AGE_WIDTH + 2),
Lane(f"{'MSGS':>{_COUNT_WIDTH}}", _COUNT_WIDTH + 2),
Lane(("ACTIVITY", "ACT")),
],
width,
indent=2 + cell_len(channel_glyph("Public", None)) + 1,
)
def _slot_row(
ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: int
ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, slot_w: int, name_w: int, scope_w: int
) -> Callable[[], Text]:
"""Return a list-row title *callable* the select screen re-renders on each repaint.
@@ -521,29 +561,41 @@ def _slot_row(
:class:`_LiveStats`), so a message arriving while the list sits open updates the row on
the next repaint — exactly the conversation picker's behavior.
"""
return lambda: _slot_text(ctx, slot, stats, name_w)
return lambda: _slot_text(ctx, slot, stats, slot_w, name_w, scope_w)
def _slot_text(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: int) -> Text:
def _slot_text(
ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, slot_w: int, name_w: int, scope_w: int
) -> Text:
"""Build one channel's list row as fixed-width, colour-coded lanes.
Alignment carries the readability — glyph, name, unread badge, total messages,
last-message age, and the activity sparkline each sit in their own lane under the
:func:`_lanes_header` line. Colour stays light and purposeful: the name is the row's
focus in the base colour, the descriptive lanes are muted, the unread ``●`` badge is
red with its count in warn (the conversation picker's language), and the sparkline
Alignment carries the readability — slot index (right-aligned, muted), glyph, name,
send scope, unread badge, last-message age, total messages, and the activity
sparkline each sit in their own lane under the :func:`_lanes_header` line. Colour
stays light and purposeful: the name is the row's focus in the base colour, the
descriptive lanes are muted, the unread ``●`` badge is red with its count in warn
(the conversation picker's language), and the sparkline
draws in the ok green over a faint flatline. A muted channel shows a muted ``🔕`` in
the unread lane instead of a count — muting zeros its unread and stops it accruing, so
that lane is always free to carry the state. The row is always a Rich
:class:`~rich.text.Text` so those spans survive under the select screen's row highlight.
that lane is always free to carry the state. The scope is the region name in the
``scope`` style, and blank for a channel that sends under the device default — the
ordinary case, which a word in every row would only make harder to see past; with
``scope_w`` 0 (no channel has one) the lane is not drawn at all. The row is always a
Rich :class:`~rich.text.Text` so those spans survive under the select screen's row
highlight.
"""
st = stats.get(slot.identity)
muted = _is_muted(ctx, slot)
unread = ctx.chat.unread(slot.conversation.key)
text = Text(no_wrap=True, overflow="ellipsis")
text.append(f"{slot.idx:>{slot_w}}", style="muted")
text.append(" ")
text.append(f"{channel_glyph(slot.name, slot.secret)} ") # # / 🌐 / 🔒 (2 cells) + gap
text.append(fit_cells(slot.name, name_w))
text.append(" ")
if scope_w:
text.append(fit_cells(_channel_scope(ctx, slot) or "", scope_w), style="scope")
text.append(" ")
if muted:
mark = glyph("🔕") # two cells on the desktop, one on the console
text.append(mark, style="muted")
@@ -554,20 +606,20 @@ def _slot_text(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: in
else:
text.append(" " * _BADGE_WIDTH)
text.append(" ")
age = format_age(age_seconds(st.last_at)) if st is not None and st.last_at else ""
text.append(f"{age:>{_AGE_WIDTH}}", style="muted")
text.append(" ")
total = st.total if st is not None else 0
if total:
# Clamped so a pathological backlog can't push the row out of its lanes.
text.append(f"{min(total, 99999):>{_COUNT_WIDTH}}")
else:
# ``○`` is the app's empty mark. ``·`` here was three things at once: the separator
# that chains status atoms, the picker's unknown-sender stand-in, and — two cells
# away in this very row — what the console folds ``🔕`` to, so a muted channel with
# no messages drew the same glyph twice meaning different things.
# that chains status atoms, the picker's unknown-sender stand-in, and — in this very
# row — what the console folds ``🔕`` to, so a muted channel with no messages drew
# the same glyph twice meaning different things.
text.append(f"{'○':>{_COUNT_WIDTH}}", style="muted")
text.append(" ")
age = format_age(age_seconds(st.last_at)) if st is not None and st.last_at else ""
text.append(f"{age:>{_AGE_WIDTH}}", style="muted")
text.append(" ")
# The shared peak across all channels, so every row's sparkline uses one scale.
text.append_text(_activity_sparkline(st.histogram if st is not None else (), stats.peak()))
return text
@@ -585,12 +637,23 @@ def _menu_items(
"""
items: list = []
if slots:
slot_w = max(_SLOT_WIDTH_MIN, *(len(str(s.idx)) for s in slots))
name_w = min(_NAME_WIDTH_MAX, max(len("CHANNEL"), *(len(s.name) for s in slots)))
scopes = [cell_len(_channel_scope(ctx, s) or "") for s in slots]
# No lane at all while no channel has a scope (see :data:`_NAME_SCOPE_BUDGET`); the
# slot lane and its gap come out of the same budget, so the sparkline keeps its room.
# A drawn scope lane is never narrower than its label — a long name gives up cells
# before the lane would crowd ``SCOPE`` against ``UNREAD``.
budget = _NAME_SCOPE_BUDGET - (slot_w + 2)
scope_w = max(len("SCOPE"), min(budget - name_w, max(*scopes))) if any(scopes) else 0
name_w = min(name_w, budget - scope_w)
# The lane names are this block's only landmark (its section carries no ── heading ──),
# so they pin overhead while the slots scroll and give way to Organize/Add a channel.
items.append(Separator(lambda w: _lanes_header(name_w, w), heading=True))
items.append(Separator(lambda w: _lanes_header(slot_w, name_w, scope_w, w), heading=True))
for slot in slots:
items.append(Choice(title=_slot_row(ctx, slot, stats, name_w), value=slot.idx))
items.append(
Choice(title=_slot_row(ctx, slot, stats, slot_w, name_w, scope_w), value=slot.idx)
)
else:
items.append(Separator(" no channels yet — add one below"))
@@ -668,6 +731,11 @@ def _detail_summary(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats) -> st
unread = ctx.chat.unread(slot.conversation.key)
kind = "public" if slot.is_public else "private"
parts = [kind, f"hash {slot.hash}", f"slot {slot.idx}"]
# The scope rides with the channel's identity rather than its traffic: it decides who
# can hear the next message, so it is kept ahead of the counts when the line sheds.
scope = _channel_scope(ctx, slot)
if scope:
parts.append(f"scope {scope}")
if st is None or not st.total:
parts.append("no messages yet")
else:
@@ -698,7 +766,7 @@ def _detail_items(ctx: AppContext, slot: ChannelSlot) -> list:
# ✎ and 🗑 are one cell where 📱 🔑 💬 🔔 🔕 are two, so the column is measured once and
# every mark padded out to it — otherwise the edit and clear rows start their labels a
# column left of the rows above them.
lane = icon_lane(("📱", "🔑", "💬", "🔔", "🔕", "✎", "🗑"))
lane = icon_lane(("📱", "🔑", "💬", "🔔", "🔕", "🔖", "✎", "🗑"))
chat_label = marked_label("💬", "Open in chat", "", lane=lane)
if unread:
chat_label.append(" ●", style="err")
@@ -729,6 +797,7 @@ def _detail_items(ctx: AppContext, slot: ChannelSlot) -> list:
),
(chat_label, "Read and send messages on this channel", _CHAT),
mute_row,
_scope_row(ctx, slot, lane),
(
marked_label("✎", "Rename / change key…", "", lane=lane),
"Edit the name or paste a different key",
@@ -794,6 +863,10 @@ async def _channel_detail(
# A preference, not a slot-config change: toggle in place and loop back to the
# detail (whose rows re-render to the new state) without counting a channel change.
_toggle_mute(ctx, slot)
elif choice == _SCOPE:
# MeshTerm's to keep, like the mute: nothing is written to the radio until a
# message is sent, so it is not a channel change either.
await _pick_scope(ctx, slot)
elif choice == _EDIT and await _edit(ctx, device, slot):
changes += 1
if not await reread(): # pragma: no cover - the slot we just wrote is there
@@ -829,6 +902,106 @@ async def _channel_detail(
)
# --- send scope ----------------------------------------------------------------
def _channel_scope(ctx: AppContext, slot: ChannelSlot) -> str | None:
"""The region this channel's messages are sent under, or ``None`` for the device default."""
store = getattr(ctx, "region_store", None)
return store.channel_scope(slot.identity) if store is not None else None
def _scope_row(ctx: AppContext, slot: ChannelSlot, lane: int) -> tuple:
"""The detail page's *Send scope…* row, its description saying what is set now."""
scope = _channel_scope(ctx, slot)
description = f"Messages flood in {scope} only" if scope else "Keep messages in one region"
return (marked_label("🔖", "Send scope…", "", lane=lane), description, _SCOPE)
def _scope_items(ctx: AppContext, current: str | None) -> list:
"""The scope picker's rows: no scope, every region known here, then typing one in.
A region reads in the ``scope`` style (a region is not a node, so no node hue), with
how many repeaters were heard to carry it — the thing that decides whether a message
scoped to it goes anywhere at all. The one in force is marked ``current``.
"""
store = getattr(ctx, "region_store", None)
none_row = Text("No scope — the device default")
if not current:
none_row.append(" · current", style="muted")
items: list = [Choice(title=none_row, value=_NO_SCOPE)]
for name in store.names() if store is not None else []:
row = Text(name, style="scope")
carriers = len(store.carriers(name))
if carriers:
row.append(f" · {carriers} repeater{'s' if carriers != 1 else ''}", style="muted")
if name == current:
row.append(" · current", style="muted")
items.append(Choice(title=row, value=name))
items.append(Choice(title=Text("Type a region name…"), value=_TYPE_REGION))
return items
async def _pick_scope(ctx: AppContext, slot: ChannelSlot) -> bool:
"""Pick the region a channel's messages are sent under; return whether it changed.
A value picker floated over the detail page, opening on the scope in force. Typing a
new name is a second step stacked on the first (the ``run_steps`` shape): Esc on the
name field comes back to the list rather than abandoning the pick. A typed name is
learned (source ``typed``) as well as set, so the next channel can pick it from the
list.
Args:
ctx: Shared application context.
slot: The channel whose scope is being set.
Returns:
``True`` when the channel's scope changed.
"""
store = getattr(ctx, "region_store", None)
if store is None: # pragma: no cover - every real context builds one
return False
current = store.channel_scope(slot.identity)
session = getattr(ctx.ui, "session", None)
while True:
items = _scope_items(ctx, current)
title = f"Send scope — {slot.name}"
prompt = "The region this channel's messages flood into:"
default = current or _NO_SCOPE
if session is not None:
picked = await session.select(
title, items, prompt=prompt, default=default, footer_hint=_SCOPE_HINT
)
else:
picked = await ctx.ui.select(title, items, prompt=prompt, default=default)
if picked is None:
return False
if picked == _TYPE_REGION:
typed = await ctx.ui.text(
"Send scope",
prompt="Region name, as its repeaters list it:",
default=current or "",
validate=_valid_region,
)
if typed is None:
continue # Esc on the name steps back to the list
picked = store.learn(typed, "typed") or typed
wanted = None if picked == _NO_SCOPE else str(picked)
if wanted == current:
return False
store.set_channel_scope(slot.identity, wanted)
return True
def _valid_region(text: str) -> bool | str:
"""Require a region name the firmware could hold (see :func:`regions.validate`)."""
try:
validate_region(text)
except RegionNameError as exc:
return str(exc)
return True
# --- create / join flows -----------------------------------------------------
+194 -9
View File
@@ -27,6 +27,7 @@ from ..core.channels import MENTION, split_channel_sender
from ..core.connection import ContactNotOnDeviceError
from ..core.events import EventKind, MeshEvent
from ..core.models import ChatMessage, Contact, Conversation, Message, utcnow
from ..core.regions import WILDCARD
from .theme import name_style, snr_style
from .tui.prompt import (
CHANNEL_BYTE_LIMIT,
@@ -63,6 +64,11 @@ class ChatScreen(Screen):
opens the picked message's paths in either kind — a path belongs to one message, so
with nothing picked there is nothing to show. Esc leaves the chat once nothing is
picked.
A channel with a send scope names its region in the title (``#ops · yul``), and ^R there
resends a message *unscoped* — the picked one, or with nothing picked the newest — the
way out for a scoped message no repeater in earshot carries — after an amber confirm,
since an unscoped flood reaches every repeater the scope was keeping it from.
"""
floating = False
@@ -85,8 +91,11 @@ class ChatScreen(Screen):
The F3 pair follows the lane's two claims. *Retry* is absent in a channel — a
channel message is never acknowledged, so there is no such thing to retry there —
and merely dim in a direct chat with nothing outstanding. *Paths* needs a picked
message to have paths of, and both nav slots need a transcript to walk.
and merely dim in a direct chat with nothing outstanding. A channel's Shift slot is
*Resend* instead — the picked message (or the newest) again, unscoped (see
:meth:`_retry_target`) — present where a resend is wired and dim until that message
is one of ours that went out under a region. *Paths* needs a picked message to have
paths of, and both nav slots need a transcript to walk.
F1/F2 carry the **day** jump, the transcript's own section step (its dividers are
days) — the same claim a grouped select list makes with ``Sect ↑``/``Sect ↓``, on
@@ -111,7 +120,12 @@ class ChatScreen(Screen):
lane[1] = FPair("Day ↓", "ctrl_pagedown", enabled=days)
lane[3] = FPair("Page ↓", "pagedown", "Latest", "ctrl_end", enabled=live, opp_enabled=live)
lane[4] = FPair("Page ↑", "pageup", "Oldest", "ctrl_home", enabled=live, opp_enabled=live)
retry = ("Retry", "retry") if not self._is_channel else ("", "")
if not self._is_channel:
retry = ("Retry", "retry")
elif self._resend_unscoped is not None:
retry = ("Resend", "retry")
else:
retry = ("", "")
lane[2] = FPair(
"Paths",
"paths",
@@ -132,6 +146,8 @@ class ChatScreen(Screen):
resend: Callable[[ChatMessage], Awaitable[ChatMessage]] | None = None,
paths: Callable[[ChatMessage], Awaitable[None]] | None = None,
key_of: Callable[[str], str | None] | None = None,
scope: str | None = None,
resend_unscoped: Callable[[ChatMessage], Awaitable[ChatMessage | None]] | None = None,
) -> None:
"""Build the chat screen.
@@ -152,9 +168,17 @@ class ChatScreen(Screen):
:func:`~meshterm.services.trace_runner.make_name_key_resolver`), the seed
of the sender's hue; ``None`` (or a name it can't place) leaves senders
muted — colour is reserved for keyed identities.
scope: The channel's send scope, stated in the title as a bare ``·`` atom
(``#ops · yul`` — a channel's title has no other atom a region name could be
mistaken for); ``None`` for a channel sending under the device
default, and always for a direct chat.
resend_unscoped: Async callable that sends a channel message's text again,
unscoped, and returns the recorded message (channels only; ``None`` leaves
^R inert there).
"""
super().__init__()
self.title = conversation.label
self._scope = scope if conversation.is_channel else None
self.title = f"{conversation.label} · {self._scope}" if self._scope else conversation.label
self._is_channel = conversation.is_channel
self._key_of: Callable[[str], str | None] = key_of or (lambda name: None)
# A direct thread's one remote sender is the peer; its key colours the header
@@ -168,6 +192,7 @@ class ChatScreen(Screen):
self._messages = list(messages)
self._send = send
self._resend = resend
self._resend_unscoped = resend_unscoped if conversation.is_channel else None
self._paths = paths
self._names = names
self._session = session
@@ -181,6 +206,7 @@ class ChatScreen(Screen):
self._stick = True # keep the newest message in view until the user scrolls up
self._paths_open = False # one paths dialog at a time
self._paste_open = False # one paste-confirm dialog at a time
self._resend_open = False # one resend-unscoped confirm at a time
# The pick: index of the highlighted message (or None when the compose line is
# focused), plus the body line it rendered on so the frame keeps it in view.
self._selected: int | None = None
@@ -211,10 +237,17 @@ class ChatScreen(Screen):
:meth:`_retry_target`) — the same rules the F-key lane dims its slots by.
"""
if self._selected is not None:
if self._is_channel and self._retry_target() is not None:
# A picked scoped message of ours can be resent unscoped, and that key has
# to be named; the reply's "(@mention)" and ^End give way to keep the line
# inside 72 (Esc still cancels the pick, and Enter still says what it does).
return "Enter reply · ^P paths · ^R resend unscoped · ↑↓ pick · Esc cancel"
if self._is_channel:
return "Enter reply (@mention) · ^P paths · ↑↓ pick · ^End/Esc cancel"
return "Enter paths · ↑↓ pick · ^End/Esc cancel"
if self._retry_target() is not None:
if self._is_channel:
return "Enter send · ↑ pick a message · ^R resend unscoped · Esc back"
return "Enter send · ↑ pick a message · ^R retry failed · Esc back"
return "Enter send · ↑ pick a message · Esc back"
@@ -496,6 +529,7 @@ class ChatScreen(Screen):
if message.is_channel:
if message.acked is False:
text.append(" ⚠ no ack", style="warn")
text.append_text(self._scope_note(message))
else:
text.append(" ")
text.append_text(self._delivery_glyph(message.acked))
@@ -503,6 +537,20 @@ class ChatScreen(Screen):
text.append(f" {message.snr:+.0f} dB", style=snr_style(message.snr))
return text
def _scope_note(self, message: ChatMessage) -> Text:
"""A muted tail on a sent message that went out under a scope other than the title's.
Only in a channel whose title names a scope, and only for the exceptions to it — an
unscoped resend, or a message sent before the scope was set or changed. Every other
line would repeat the title; and in a channel with no scope of its own, the default
its messages went under is the device's business, not news on every line.
"""
if not self._scope or not message.scope or message.scope == self._scope:
return Text()
if message.scope == WILDCARD:
return Text(" · unscoped", style="muted")
return Text(f" · scope {message.scope}", style="muted")
def _render_mentions(self, body: str, *, selected: bool) -> Text:
"""Render body text, rewriting each ``@[Name]`` token to a ``@Name`` in its hue.
@@ -625,7 +673,9 @@ class ChatScreen(Screen):
elif action == "paths":
self._open_paths(self._selected)
elif action == "retry":
if not self._is_channel:
if self._is_channel:
self._begin_resend_unscoped()
else:
self._retry()
elif action == "escape":
if self._selected is not None:
@@ -905,10 +955,31 @@ class ChatScreen(Screen):
def _retry_target(self) -> ChatMessage | None:
"""The message ^R would re-send, or ``None`` when there is nothing to retry.
The newest outbound direct message that went out and was never acknowledged —
and only while a retry could actually start: no send already in flight, a resend
path wired (channels have none, since a channel message is never acked).
In a direct chat, the newest outbound message that went out and was never
acknowledged. In a channel — where nothing is ever acknowledged — a message *we*
sent under a region, which ^R sends again unscoped: the **picked** one when a
message is picked (the reader pointed at it), else the newest we sent. With a pick,
only the pick: a picked message that isn't a scoped one of ours offers nothing,
rather than ^R quietly reaching past it to another. With none, only the newest —
once its unscoped copy has gone that copy is the newest, and nothing is left to
offer. Either way only while a resend could actually start — no send already in
flight, and a resend path wired.
"""
if self._is_channel:
if self._sending or self._resend_unscoped is None:
return None
if self._selected is not None:
candidate: ChatMessage | None = self._messages[self._selected]
else:
candidate = next((m for m in reversed(self._messages) if m.outbound), None)
if (
candidate is None
or not candidate.outbound
or not candidate.scope
or candidate.scope == WILDCARD
):
return None
return candidate
if self._sending or self._resend is None:
return None
return next(
@@ -928,6 +999,61 @@ class ChatScreen(Screen):
self._session.invalidate()
self._spawn(self._resend_message(target))
def _begin_resend_unscoped(self) -> None:
"""Confirm, then send a scoped channel message again unscoped (^R).
Amber (``danger``): nothing is lost, but an unscoped flood is relayed by every
repeater that allows one — the whole mesh the scope was keeping the message out of —
so it is a choice made on purpose, with the region it is leaving named. The resend
is a new message on the air (a channel message has no identity to re-deliver), so
it appends as one; a radio that can't send unscoped refuses before anything goes
out, and the status line says why.
"""
target = self._retry_target()
if target is None or self._resend_open:
return
self._resend_open = True
preview = target.text if len(target.text) <= 32 else target.text[:31] + "…"
async def run() -> None:
try:
confirmed = await self._session.button_dialog(
Text(
f"Send “{preview}” again, unscoped? Every repeater that relays "
f"unscoped floods will carry it, not only those in {target.scope}.",
style="warn",
),
[("Cancel", False), ("Resend", True)],
title="Resend unscoped",
default=1,
border_style="warn",
footer_hint="←→ choose · Enter select · Esc cancel",
)
finally:
self._resend_open = False
if not confirmed or self._sending or self._resend_unscoped is None:
self._session.invalidate()
return
self._sending = True
self._status = "resending unscoped…"
# The resend lands at the tail as a new message; the pick that chose it is done.
self._clear_selection()
self._stick = True
self._session.invalidate()
try:
message = await self._resend_unscoped(target)
if message is not None:
self._messages.append(message)
self._status = ""
except Exception as exc: # noqa: BLE001 - report inline, keep the chat alive
self._status = f"resend failed: {exc}"
finally:
self._sending = False
self._stick = True
self._session.invalidate()
self._spawn(run())
async def _resend_message(self, message: ChatMessage) -> None:
"""Drive a retry to completion, refreshing the message's delivery state in place."""
assert self._resend is not None
@@ -1010,7 +1136,18 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
return await _with_restore(ctx, lambda: ctx.chat.send_direct(conversation.contact, text))
resend: Callable[[ChatMessage], Awaitable[ChatMessage]] | None = None
if not conversation.is_channel:
resend_unscoped: Callable[[ChatMessage], Awaitable[ChatMessage | None]] | None = None
scope: str | None = None
if conversation.is_channel:
scope = ctx.chat.channel_scope(conversation.channel_id)
async def resend_unscoped(message: ChatMessage) -> ChatMessage | None:
assert conversation.channel_idx is not None
return await ctx.chat.send_channel(
conversation.channel_idx, message.text, label=conversation.label, scope=WILDCARD
)
else:
async def resend(message: ChatMessage) -> ChatMessage:
assert conversation.contact is not None
@@ -1028,6 +1165,8 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
resend=resend,
paths=paths,
key_of=key_of,
scope=scope,
resend_unscoped=resend_unscoped,
)
ctx.chat.set_active(conversation.key)
@@ -1152,6 +1291,49 @@ def _belongs(message: Message, conversation: Conversation) -> bool:
# --- message paths (the ^P view) -----------------------------------------------------
def _sent_scope_line(ctx: AppContext, message: ChatMessage, *, relayed: bool) -> Text | None:
"""What a message we sent went out under, for the paths dialog — and why it may be lost.
Built from the scope recorded on the message at send time (:attr:`ChatMessage.scope`),
never from the channel's scope now, which may have moved on since. When a scoped
message has no relayed copy on record *and* no repeater was ever heard here to carry
its region (:meth:`~meshterm.core.region_store.RegionStore.carriers`), the likeliest
reason is stated plainly: a scoped flood is relayed only by repeaters carrying its
region, and nothing known in earshot does.
Args:
ctx: Shared application context (for the region store).
message: The message whose paths are open.
relayed: Whether any copy of it was heard coming back.
Returns:
The line, or ``None`` for a message with no recorded scope (inbound, direct, or
sent before scopes were recorded).
"""
from ..core.regions import UNSCOPED, Scope
from .widgets import scope_text
if not message.outbound or not message.scope:
return None
line = Text("sent ", style="muted")
if message.scope == WILDCARD:
line.append_text(scope_text(UNSCOPED))
return line
line.append("under ", style="muted")
line.append_text(scope_text(Scope("scoped", message.scope)))
store = getattr(ctx, "region_store", None)
carriers = len(store.carriers(message.scope)) if store is not None else 0
if not relayed and not carriers:
line.append(" — no repeater known here carries it", style="warn")
elif carriers:
line.append(
f" · {carriers} known repeater{'s' if carriers != 1 else ''} carr"
f"{'y' if carriers != 1 else 'ies'} it",
style="muted",
)
return line
async def _make_paths_presenter(
ctx: AppContext,
conversation: Conversation,
@@ -1180,6 +1362,7 @@ async def _make_paths_presenter(
collapse,
direct_arrivals,
distinct_paths,
message_scope,
)
from .message_paths_screen import MessagePathsScreen
@@ -1289,6 +1472,8 @@ async def _make_paths_presenter(
destination=destination or None,
type_of=type_of,
key_of=key_of,
scope=message_scope(arrivals, ctx.region_store.scope_of),
sent_scope=_sent_scope_line(ctx, message, relayed=bool(arrivals)),
)
)
+41 -139
View File
@@ -9,10 +9,10 @@ radio in your hand reads exactly like configuring one over the mesh.
* **Settings** — every value the companion firmware lets an app read and write (see
:data:`~meshterm.core.device_config.DEVICE_SETTINGS`), each custom variable the device
reports, and MeshTerm's own background-advert cadences. Editing a row *stages* the new
value and nothing touches the radio until *Apply*, which sends the staged values one at a
time and stays on the page: one the device refuses stays staged with its reason, as on
the remote page. Backing out with changes staged asks before discarding them.
reports. Editing a row *stages* the new value and nothing touches the radio until
*Apply*, which sends the staged values one at a time and stays on the page: one the
device refuses stays staged with its reason, as on the remote page. Backing out with
changes staged asks before discarding them.
* **Actions** — the operations on the box itself rather than on a value: re-read, clock
sync, backup/restore, the identity key, reboot, and factory reset. These *run
immediately*, each behind its own confirmation (destructive ones behind typing a word),
@@ -42,13 +42,6 @@ from rich.markup import escape
from rich.table import Table
from rich.text import Text
from ..core.advert_store import (
DIRECT_CADENCE_HOURS,
FLOOD_CADENCE_HOURS,
OFF,
AdvertPolicy,
cadence_label,
)
from ..core.device_config import (
DEVICE_SETTINGS,
DeviceConfigError,
@@ -89,8 +82,6 @@ _PRESETS = "__presets__"
_CUSTOM = "__custom__"
#: Prefix of a custom variable's row value; the variable's name follows it.
_CUSTOM_VAR = "__custom_var__:"
_ADVERT_DIRECT = "__advert_direct__"
_ADVERT_FLOOD = "__advert_flood__"
_READ = "__read__"
_SYNC_CLOCK = "__sync_clock__"
_REBOOT = "__reboot__"
@@ -106,13 +97,11 @@ _OTHER = "__other__"
#: The setting keys the map pick sets together (each also has its own row, for a typed value).
_COORD_KEYS = ("adv_lat", "adv_lon")
#: How long the reboot flow waits to *observe* the link actually dropping before handing
#: off to the session's reconnect dialog (seconds). A companion normally vanishes from the
#: bus well within this; on timeout we hand off anyway.
_REBOOT_DROP_TIMEOUT_S = 10.0
#: Poll cadence while waiting for the rebooting companion's link to drop (seconds).
_REBOOT_DROP_POLL_S = 0.25
#: How long the reboot flow holds the actions screen after announcing the reboot, waiting
#: for the session's disconnect watcher to cancel it and raise the reconnect dialog
#: (seconds). The watcher wakes on the announcement itself, so this only runs out where
#: no watcher is running.
_REBOOT_HANDOFF_TIMEOUT_S = 5.0
async def cached_snapshot(ctx: AppContext, device: Device) -> dict:
@@ -132,7 +121,11 @@ async def cached_snapshot(ctx: AppContext, device: Device) -> dict:
path_hash_mode = await ctx.devstate.path_hash_mode()
except Exception: # noqa: BLE001 - fall back to the raw reads below
pass
return await build_snapshot(device, self_info=self_info, path_hash_mode=path_hash_mode)
from ..tools.config import learn_default_scope
snapshot = await build_snapshot(device, self_info=self_info, path_hash_mode=path_hash_mode)
learn_default_scope(ctx, snapshot)
return snapshot
async def edit_config(ctx: AppContext) -> dict[str, Any] | None:
@@ -158,15 +151,11 @@ async def edit_config(ctx: AppContext) -> dict[str, Any] | None:
device = await ctx.device()
snapshot = await cached_snapshot(ctx, device)
custom = await device.get_custom_vars()
# The background-advert cadences are app-side settings (MeshTerm sends the adverts,
# not the firmware), but they're staged and applied exactly like device values so
# the page stays one coherent surface.
policy = ctx.advert_store.load(str(snapshot.get("public_key") or ""))
session = getattr(ctx.ui, "session", None)
if session is None: # pragma: no cover - guarded by the menu-only caller
raise RuntimeError("the config editor is only available in the menu")
pending: dict[str, Any] = {} # setting key (or cadence sentinel) -> staged new value
pending: dict[str, Any] = {} # setting key -> staged new value
custom_pending: dict[str, str] = {} # custom variable name -> staged new value
applied = 0
@@ -176,15 +165,14 @@ async def edit_config(ctx: AppContext) -> dict[str, Any] | None:
def summary() -> dict[str, Any] | None:
return {"applied": applied} if applied else None
# The rows read ``snapshot``, ``custom`` and ``policy`` through this closure, so a
# re-read that rebinds them is exactly what the next refresh draws.
# The rows read ``snapshot`` and ``custom`` through this closure, so a re-read that
# rebinds them is exactly what the next refresh draws.
menu = _ConfigMenu(
session,
lambda reveal: _menu_items(
snapshot,
pending,
staged(),
policy,
reveal,
custom=custom,
custom_pending=custom_pending,
@@ -205,7 +193,6 @@ async def edit_config(ctx: AppContext) -> dict[str, Any] | None:
if choice == _APPLY:
applied += await _apply(ctx, device, snapshot, pending, custom_pending)
snapshot, custom = await _reread(ctx, device)
policy = ctx.advert_store.load(str(snapshot.get("public_key") or ""))
elif choice == _READ:
snapshot, custom = await _reread(ctx, device)
_unstage_held(snapshot, custom, pending, custom_pending)
@@ -236,8 +223,6 @@ async def edit_config(ctx: AppContext) -> dict[str, Any] | None:
pending.clear()
custom_pending.clear()
snapshot, custom = await _reread(ctx, device)
elif choice in (_ADVERT_DIRECT, _ADVERT_FLOOD):
await _stage_advert_cadence(ctx, choice == _ADVERT_FLOOD, policy, pending)
elif choice == _LOCATION:
await _stage_location(ctx, snapshot, pending)
elif choice == _PRESETS:
@@ -578,7 +563,6 @@ def _menu_items(
snapshot: dict,
pending: dict,
staged: int,
policy: AdvertPolicy,
reveal_pin: bool = False,
*,
custom: dict[str, str] | None = None,
@@ -588,9 +572,9 @@ def _menu_items(
The settings sit in three aligned lanes — setting, current value (and any staged new
value), description — under one header line, grouped by category; the device's custom
variables and MeshTerm's own advert cadences follow in the same lanes; and the
**Actions** close the list. The same shape as the repeater-admin page (see
:func:`meshterm.ui.repeater_admin._menu_items`), so the two read as one editor.
variables follow in the same lanes; and the **Actions** close the list. The same shape
as the repeater-admin page (see :func:`meshterm.ui.repeater_admin._menu_items`), so
the two read as one editor.
``reveal_pin`` is :class:`_ConfigMenu`'s toggle, passed straight through to the PIN's
value lane. ``custom`` holds the variables the device reports and ``custom_pending``
@@ -638,29 +622,6 @@ def _menu_items(
var_rows.append(("Set by name…", Text(), "Set a raw firmware variable by name", _CUSTOM))
sections.append(("Custom variables", var_rows))
# App-side rows: the background-advert cadences MeshTerm itself runs (see the
# advert scheduler). They stage and apply like device settings, so they sit in the
# same lanes under their own heading.
sections.append(
(
"Background adverts",
[
(
"Direct advert",
_cadence_value(policy, False, pending),
"Scheduled zero-hop announce; any manual send resets it",
_ADVERT_DIRECT,
),
(
"Flood advert",
_cadence_value(policy, True, pending),
"Scheduled mesh-wide announce via repeaters",
_ADVERT_FLOOD,
),
],
)
)
label_w = max(cell_len(label) for _, rows in sections for label, _, _, _ in rows)
value_w = max(cell_len(value.plain) for _, rows in sections for _, value, _, _ in rows)
@@ -715,15 +676,6 @@ def _menu_items(
return title, items
def _cadence_value(policy: AdvertPolicy, flood: bool, pending: dict) -> Text:
"""One background-advert row's VALUE lane: ``current [→ staged]``."""
key = _ADVERT_FLOOD if flood else _ADVERT_DIRECT
value = Text(cadence_label(policy.cadence(flood)))
if key in pending:
value.append(f" → {cadence_label(pending[key])}", style="warn")
return value
# --- staging individual changes ----------------------------------------------
@@ -820,44 +772,6 @@ async def _prompt_value(ctx: AppContext, spec: SettingSpec, current: Any, snapsh
return None if raw is None else parse_value(spec, raw, snapshot)
async def _stage_advert_cadence(
ctx: AppContext, flood: bool, policy: AdvertPolicy, pending: dict[str, Any]
) -> None:
"""Pick one background-advert type's cadence and stage it.
Staged under the type's sentinel key; Apply turns it into an ``advert_cadence`` op
(see :func:`~meshterm.tools.config.apply_ops`). Picking the value already in force
un-stages the row, matching :func:`_stage_setting`.
"""
key = _ADVERT_FLOOD if flood else _ADVERT_DIRECT
in_force = policy.cadence(flood)
current = pending.get(key, in_force)
hours_choices = FLOOD_CADENCE_HOURS if flood else DIRECT_CADENCE_HOURS
items: list = [
Choice(
title=cadence_label(hours).capitalize() + (" (current)" if hours == current else ""),
value=hours,
)
for hours in (*hours_choices, OFF)
]
if flood:
prompt = "How often repeaters rebroadcast this node across the mesh:"
else:
prompt = "How often this node announces itself to neighbours in range:"
selected = await ctx.ui.select(
"Flood advert" if flood else "Direct advert",
items,
prompt=prompt,
default=current,
)
if selected is None:
return
if selected == in_force:
pending.pop(key, None) # set back to the value in force — nothing to change
else:
pending[key] = selected
async def _stage_location(ctx: AppContext, snapshot: dict, pending: dict[str, Any]) -> None:
"""Pick the advertised location on the map and stage both coordinates it sets.
@@ -1042,27 +956,18 @@ def _staged_ops(pending: dict[str, Any], custom_pending: dict[str, str]) -> list
"""The staged values as ``(stage key, op)`` pairs, in the order they should be sent.
Settings go in the registry's order rather than the order they were staged in, so a
retuned frequency lands before relaying is switched on for it; the advert cadences and
the custom variables follow.
retuned frequency lands before relaying is switched on for it; the custom variables
follow.
"""
order = {spec.key: i for i, spec in enumerate(DEVICE_SETTINGS)}
ops = [
(key, ("set", key, pending[key]))
for key in sorted((k for k in pending if k in order), key=order.__getitem__)
]
ops += [
(key, ("advert_cadence", key == _ADVERT_FLOOD, pending[key]))
for key in (_ADVERT_DIRECT, _ADVERT_FLOOD)
if key in pending
]
ops += [(name, ("set_custom", name, value)) for name, value in custom_pending.items()]
return ops
#: What the result window calls a staged value whose stage key is a sentinel.
_STAGE_NAMES = {_ADVERT_DIRECT: "direct advert", _ADVERT_FLOOD: "flood advert"}
async def _apply(
ctx: AppContext,
device: Device,
@@ -1087,7 +992,7 @@ async def _apply(
from ..tools.config import apply_ops
batch = _staged_ops(pending, custom_pending)
names = [_STAGE_NAMES.get(key, key) for key, _op in batch]
names = [key for key, _op in batch]
run_id = ctx.repo.start_run("config", {"mode": "apply", "settings": names}, ctx.profile_name)
accepted = 0
for (key, op), name in zip(batch, names, strict=True):
@@ -1121,10 +1026,13 @@ async def _reread(ctx: AppContext, device: Device) -> tuple[dict, dict[str, str]
Raw rather than through the session cache, which is exactly what just went stale — and
the cache is dropped on the way, so the next screen that trusts it reads the truth too.
"""
from ..tools.config import learn_default_scope
ctx.devstate.invalidate_config()
async with ctx.ui.busy_overlay():
snapshot = await build_snapshot(device)
custom = await device.get_custom_vars()
learn_default_scope(ctx, snapshot)
return snapshot, custom
@@ -1132,7 +1040,7 @@ def _unstage_held(
snapshot: dict, custom: dict[str, str], pending: dict[str, Any], custom_pending: dict[str, str]
) -> None:
"""Unstage every value the device turns out to hold already — a re-read can settle one."""
for key in [k for k in pending if k not in _STAGE_NAMES]:
for key in list(pending):
if get_spec(key).getter(snapshot) == pending[key]:
del pending[key]
for name in [n for n, value in custom_pending.items() if custom.get(n) == value]:
@@ -1272,10 +1180,11 @@ async def _show_contact_card(ctx: AppContext, snapshot: dict) -> None:
async def _reboot(ctx: AppContext, device: Device, snapshot: dict) -> bool:
"""Confirm and reboot the device, handing off to the session's reconnect dialog.
After the command is sent, we wait to actually observe the link dropping — flagging
:attr:`~meshterm.context.AppContext.reboot_in_progress` so the session-wide
disconnect watcher labels the ensuing dialog as a reboot, waits for the companion to
come back, and reconnects — exactly the unplugged-device flow.
Once the command is sent the link is declared down
(:meth:`~meshterm.context.AppContext.announce_reboot`) rather than waited on, so the
session-wide disconnect watcher raises its dialog at once, labelled as a reboot, waits
for the companion to come back, and reconnects — exactly the unplugged-device flow,
and nothing else is sent to the board while it restarts.
Returns:
``True`` if the reboot was sent and the actions screen should close; ``False``
@@ -1298,22 +1207,15 @@ async def _reboot(ctx: AppContext, device: Device, snapshot: dict) -> bool:
await ctx.ui.present(title="Reboot")
return False
# Flag the drop as expected *before* sending, so however quickly the watcher fires,
# the reconnect dialog already knows to present it as a reboot.
ctx.reboot_in_progress = True
try:
await device.reboot()
except Exception:
ctx.reboot_in_progress = False
raise
# Hold here until the link is actually observed down (or a generous timeout), so the
# actions screen doesn't flash back to the menu for the second or two before the
# watcher notices. The watcher may cancel us mid-wait when it fires — that's the handoff.
deadline = asyncio.get_running_loop().time() + _REBOOT_DROP_TIMEOUT_S
while asyncio.get_running_loop().time() < deadline:
if not await ctx.link_alive():
break
await asyncio.sleep(_REBOOT_DROP_POLL_S)
await device.reboot()
# The command is out, so the link is as good as gone: declare it, rather than wait for
# a liveness poll to notice — a quick reboot behind a port that never disappears is
# never noticed at all, and the next command would go to a board mid-restart.
ctx.announce_reboot()
# The session's watcher now cancels us and raises the reconnect dialog; hold here until
# it does, so the actions screen doesn't flash back to the menu in between. The timeout
# only matters where no watcher is running to take the handoff.
await asyncio.sleep(_REBOOT_HANDOFF_TIMEOUT_S)
return True
+14 -4
View File
@@ -98,9 +98,10 @@ class ContactsScreen(ContactListScreen):
span :data:`~meshterm.ui.contactlist.SORT_COLUMNS` so the hash sort is
reachable.
archived: How many contacts are archived off this device. Non-zero adds the
``View archived contacts`` row and shows the count on it, so the tally is
visible without opening the list; zero draws no row, since a screen should
not offer a way into an empty list.
``View archived contacts`` row and shows the count on it and in the title
(``Contacts · 198 known + 123 archived``), so the tally is visible without
opening the list; zero draws no row and leaves the title at ``… known``,
since a screen should not offer a way into an empty list.
locked: The full keys (lowercase hex) of the contacts locked against archiving;
each of those rows closes its name lane on a padlock.
"""
@@ -132,13 +133,22 @@ class ContactsScreen(ContactListScreen):
label = marked_label("📂", "View archived contacts", "", lane=lane)
label.append(f" · {archived}", style="muted")
tail.append(Choice(title=label, value=_ARCHIVED))
# The title counts both halves of what this device's contacts amount to: those on it
# and those a sweep moved off it. Where the frame is too narrow for the words, the
# figures stay and the words go (``Contacts · 198 + 123``).
known = len(contacts)
title = f"Contacts · {known} known"
if archived:
title += f" + {archived} archived"
super().__init__(
f"Contacts · {len(contacts)} known",
title,
rows=rows,
prefix_bytes=prefix_bytes,
sort=sort,
tail=tail,
)
if archived:
self.short_title = f"Contacts · {known} + {archived}"
def _contact_rows_for(self, locked: frozenset[str]) -> list[ContactRow]:
"""Our own node, then every contact, each marked locked or not by ``locked``."""
+177 -59
View File
@@ -12,15 +12,21 @@ stacks three reads of the mesh, coarsest first:
drawn from the same :meth:`~meshterm.services.monitor_service.MonitorService`
buckets. A pulse line beneath it reads the rate, who's been heard, and the busiest
node of the window.
* **Traffic** — the session's tallies by packet class, each with its icon and a
proportional bar, from the monitor's kind counters — the raw ``packet`` bucket
broken out by payload class (channel text, trace, path, …), so every known frame
class shows once heard.
* **Traffic** — the frames heard on the air, tallied by class (chan text, trace,
path, …) under the names and icons the live feed gives them, each with a
proportional bar. Every frame counts once: what the device *reported* about a frame
(its advert event, a decoded message) is not counted a second time beside it.
* **RF health** — the trailing window's reception quality: median SNR (on the trace
tool's quality bar) and RSSI from stored observations, plus the radio's own live
numbers — noise floor, last RSSI/SNR, airtime, battery — polled from the device the
way Device info reads them.
**Scope** narrows the whole screen to one region's floods: ``s`` (the PicoCalc's F3 chip)
cycles every view — all, unscoped, each region heard, unknown scopes — as the Time
Machine's ``w`` cycles its windows. A narrowed view reads the screen's own trailing
window (stored + live, 2 h), since a region is named per frame by a cryptographic check
the database cannot run; the radio's own numbers stay whole, being the device's.
The per-packet stream that used to close this screen is now its own tool — the Live
feed (see :mod:`meshterm.ui.livefeed_screen`), right under the dashboard in the menu —
so this screen is pure overview: it scrolls as one body under the arrow/page keys.
@@ -33,9 +39,11 @@ tears them all down when the screen resolves. Esc backs out directly.
from __future__ import annotations
import asyncio
import time
from collections import Counter, deque
from collections.abc import Callable
from statistics import median
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, NamedTuple
from rich.console import Group, RenderableType
from rich.table import Table
@@ -43,16 +51,13 @@ from rich.text import Text
from ..core.events import MeshEvent
from ..core.models import NODE_TYPE_REPEATER, Observation, utcnow
from ..core.regions import Scope
from ..persistence.repository import OBSERVATION_WINDOW
from ..services.monitor_service import ACTIVITY_BUCKET_S, ACTIVITY_BUCKETS
from .braillechart import axis_chart, axis_chrome, axis_label_w, meter, timeline_rows
from .packet_viewer import (
_PAYLOAD_GLOSS,
DEFAULT_ICON,
KIND_ICONS,
KIND_STYLES,
PAYLOAD_ICONS,
)
from .theme import glyph, snr_style
from .packet_viewer import KIND_STYLES, payload_marks
from .scopering import ScopeKey, ring, scope_atom, scope_chip, scope_key, step
from .theme import snr_style
from .trace_screen import snr_bar
from .tui.render import render_lines
from .tui.screen import Screen
@@ -69,13 +74,10 @@ _STATS_POLL_S = 10.0
#: How many braille rows tall the activity chart draws (each row is four dot rows).
_CHART_ROWS = 3
#: The order the traffic panel lists its buckets in (heard ones not listed sort last,
#: alphabetically): the decoded families first, then the raw ``packet:<TYPENAME>``
#: classes expanding in the old lone-``packet`` slot — chat-ish frames before
#: protocol-ish ones — with the class-less ``packet`` closing the raw block.
#: The order the traffic panel lists its classes in (heard ones not listed sort last,
#: alphabetically): chat-ish frames before protocol-ish ones, the class-less ``packet``
#: closing the list.
_TRAFFIC_ORDER = (
"advert",
"telemetry",
"packet:GRP_TXT",
"packet:GRP_DATA",
"packet:TEXT_MSG",
@@ -89,31 +91,27 @@ _TRAFFIC_ORDER = (
"packet:MULTIPART",
"packet:CONTROL",
"packet",
"message",
"ack",
)
#: Raw payload glosses that would read identically to a decoded family's row, retold
#: so two meters never share a label (an overheard advert frame vs. the advert event).
_RAW_GLOSS_CLASH = {"advert": "raw advert", "ack": "raw ack"}
def _is_frame_bucket(bucket: str) -> bool:
"""Whether a tally bucket counts frames heard on the air (``packet[:<TYPENAME>]``).
The monitor also tallies what the *device* reported — ``advert``, ``telemetry``,
``message``, ``ack`` — but each of those is a frame already counted under its own
class (the advert event *and* its RX-logged ``ADVERT`` frame), so the traffic panel
counts frames only: every packet once, under the name the live feed gives it.
"""
return bucket == "packet" or bucket.startswith("packet:")
def _traffic_chrome(bucket: str) -> tuple[str, str, str]:
"""One traffic bucket's ``(icon, label, meter style)``.
def _traffic_chrome(bucket: str) -> tuple[str, str]:
"""One traffic bucket's ``(icon, label)`` — the live feed's, from the same function.
A decoded family keeps its kind icon, name, and colour; a ``packet:<TYPENAME>``
bucket takes the payload class's icon and gloss over the calm muted ``packet``
meter — colour keeps marking the decoded families, raw overheard stays quiet.
Icons are mapped to single glyphs on PICOCALC via :func:`~meshterm.ui.theme.glyph`.
"""
if bucket.startswith("packet:"):
typename = bucket.split(":", 1)[1]
gloss = _PAYLOAD_GLOSS.get(typename, typename.lower())
label = _RAW_GLOSS_CLASH.get(gloss, gloss)
emoji = PAYLOAD_ICONS.get(typename, DEFAULT_ICON)
return glyph(emoji), label, KIND_STYLES["packet"]
emoji = KIND_ICONS.get(bucket, DEFAULT_ICON)
return glyph(emoji), bucket, KIND_STYLES.get(bucket, "brand")
typename = bucket.split(":", 1)[1] if ":" in bucket else None
return payload_marks(typename)
#: How many character cells a traffic lane's meter spans (48 half-step levels).
@@ -124,6 +122,26 @@ _TRAFFIC_METER_CELLS = 24
_GRID_LABEL_W = 9
#: The screen's title, before any scope atom.
_TITLE = "Dashboard — mesh overview"
class _ScopeDigest(NamedTuple):
"""One scope view's numbers over the window, in the shapes the sections draw.
Attributes:
histogram: Frames per minute, newest first, the monitor's bucket shape.
counts: Frames by traffic bucket (``packet:<TYPENAME>``).
snrs: Every SNR reading among them.
rssis: Every RSSI reading among them.
"""
histogram: tuple[int, ...]
counts: dict[str, int]
snrs: list[float]
rssis: list[float]
def _span_label(minutes: int) -> str:
"""A compact duration — ``45 min`` under two hours, else ``2.5 h`` / ``3 h``."""
if minutes < 120:
@@ -147,21 +165,28 @@ class DashboardScreen(Screen):
:attr:`~meshterm.ui.tui.screen.Screen.content_overflows`); otherwise Esc is the only
key that acts.
"""
if self.content_overflows:
return "↑↓ PgUp/PgDn scroll · Esc back"
return "Esc back"
atoms = ["↑↓ PgUp/PgDn scroll"] if self.content_overflows else []
if len(self._ring()) > 1:
atoms.append("s scope")
return " · ".join([*atoms, "Esc back"])
@property
def fkey_lane(self):
"""The shared lane, dimmed on the same gate the hint above uses.
"""The shared lane, dimmed on the same gate the hint above uses, plus the scope cycle.
The PicoCalc draws no hint line at all — the lane *is* the footer — so the rule
the hint follows has to hold there too: an overview that fits whole has nothing
for Top/End or the paging pair to move.
for Top/End or the paging pair to move. The scope cycle takes F3, as the Time
Machine's window cycle does, its chip naming the view a press goes *to* (the title
already says where you are); with no flood heard to narrow to, the slot stays empty.
"""
from .tui.fkeys import default_lane
from .tui.fkeys import FPair, default_lane
return default_lane(nav=self.content_overflows)
lane = list(default_lane(nav=self.content_overflows))
views = self._ring()
if len(views) > 1:
lane[2] = FPair(scope_chip(step(views, self._scope)), "scope")
return lane
def __init__(
self,
@@ -172,6 +197,7 @@ class DashboardScreen(Screen):
activity: Any,
activity_flags: Any,
kind_counts: Any,
scope_of: Callable[[dict | None], Scope | None] | None = None,
) -> None:
"""Create the dashboard over its data feeds.
@@ -183,9 +209,15 @@ class DashboardScreen(Screen):
activity_flags: Zero-arg callable returning the histogram's per-bucket
this-session flags (seeded history draws grey, live traffic green).
kind_counts: Zero-arg callable returning the monitor's kind tallies.
scope_of: Names a frame's scope from its raw payload
(:meth:`~meshterm.core.region_store.RegionStore.scope_of`); ``None`` offers
no scope views.
"""
super().__init__()
self.title = "Dashboard — mesh overview"
self._scope_of = scope_of
#: The scope view on screen; ``None`` is every packet heard.
self._scope: ScopeKey | None = None
self._set_title()
self._session = session
self._resolve = resolve
self._activity = activity
@@ -209,6 +241,10 @@ class DashboardScreen(Screen):
self._win_counts: Counter = Counter()
self._win_snrs: list[float] = []
self._win_rssis: list[float] = []
#: The window's frames that have a scope, each with the view it counts in.
self._win_scoped: list[tuple[ScopeKey, Observation]] = []
#: The narrowed view's numbers and what they were computed for — see :meth:`_scoped`.
self._scoped_memo: tuple[tuple, _ScopeDigest] | None = None
#: The last activity chart and the inputs it was drawn from — see
#: :meth:`_activity_section`.
self._chart_memo: tuple[tuple, list[RenderableType]] | None = None
@@ -247,9 +283,63 @@ class DashboardScreen(Screen):
self.scroll_to_top()
elif action in ("end", "ctrl_end"):
self.scroll_to_bottom()
elif action == "scope" or (action == "text" and data.lower() == "s"):
self._cycle_scope()
elif action == "escape":
self.resolve(None)
# --- scope -----------------------------------------------------------------------
def _ring(self) -> list[ScopeKey | None]:
"""Every view on offer: all, then each scope heard in the window."""
self._digest_window()
return ring((key for key, _ in self._win_scoped), self._scope)
def _cycle_scope(self) -> None:
"""Step to the next view (see :func:`~meshterm.ui.scopering.step`)."""
views = self._ring()
if len(views) < 2:
return
self._scope = step(views, self._scope)
self._set_title()
self.scroll_to_top()
self._session.invalidate()
def _set_title(self) -> None:
"""Title the screen for its view; a narrowed one says which, tightened when narrow."""
if self._scope is None:
self.title, self.short_title = _TITLE, ""
return
atom = scope_atom(self._scope)
self.title = f"{_TITLE} · {atom}"
self.short_title = f"Dashboard · {atom}"
def _scoped(self) -> _ScopeDigest:
"""The narrowed view's numbers, recomputed only when the window, view or minute moves."""
minute = int(time.time() // ACTIVITY_BUCKET_S)
memo_key = (self._window_rev, self._scope, minute)
if self._scoped_memo is not None and self._scoped_memo[0] == memo_key:
return self._scoped_memo[1]
histogram = [0] * ACTIVITY_BUCKETS
counts: Counter = Counter()
snrs: list[float] = []
rssis: list[float] = []
for key, o in self._win_scoped:
if key != self._scope:
continue
age = minute - int(o.observed_at.timestamp() // ACTIVITY_BUCKET_S)
if 0 <= age < ACTIVITY_BUCKETS:
histogram[age] += 1
typename = (o.raw or {}).get("payload_typename")
counts[f"packet:{typename}" if typename else "packet"] += 1
if o.snr is not None:
snrs.append(o.snr)
if o.rssi is not None:
rssis.append(o.rssi)
digest = _ScopeDigest(tuple(histogram), dict(counts), snrs, rssis)
self._scoped_memo = (memo_key, digest)
return digest
# --- rendering ---------------------------------------------------------------------
def render_body(self, width: int) -> list[str]:
@@ -290,10 +380,15 @@ class DashboardScreen(Screen):
counts: Counter = Counter()
snrs: list[float] = []
rssis: list[float] = []
scoped: list[tuple[ScopeKey, Observation]] = []
for o in self._window:
if o.node and o.node_type == NODE_TYPE_REPEATER:
repeaters.add(o.node)
if o.kind == "packet":
# The region store memoizes by frame, so a frame is checked once per visit.
key = scope_key(self._scope_of(o.raw)) if self._scope_of else None
if key is not None:
scoped.append((key, o))
continue
if o.node:
nodes.add(o.node)
@@ -307,6 +402,7 @@ class DashboardScreen(Screen):
self._win_counts = counts
self._win_snrs = snrs
self._win_rssis = rssis
self._win_scoped = scoped
# -- activity --
@@ -320,7 +416,8 @@ class DashboardScreen(Screen):
terminal simply shows more history. Time runs oldest→now left to right,
every MeshTerm timeline's direction.
"""
histogram = list(self._activity()) # newest first, one count per minute
# Newest first, one count per minute.
histogram = list(self._scoped().histogram if self._scope else self._activity())
# Size the label lane from the whole histogram's peak (not just the visible
# slice) so the gutters never shift as a burst scrolls out of view.
label_w = axis_label_w(max(histogram, default=0), _CHART_ROWS)
@@ -330,7 +427,14 @@ class DashboardScreen(Screen):
peak = max(shown)
heading = Text("Activity", style="accent")
heading.append(" · every packet heard · one minute per dot column", style="muted")
# A narrowed view spends the aside on what it counts, which the reader has to be
# told; the minute per column is on the axis beneath either way.
what = (
"floods in this scope · last 2 h"
if self._scope
else "every packet heard · one minute per dot column"
)
heading.append(f" · {what}", style="muted")
# Buckets seeded from a previous session's stored history draw grey; only
# what this session heard itself pulses green.
flags = (tuple(self._activity_flags()) + (True,) * minutes)[:minutes]
@@ -402,6 +506,8 @@ class DashboardScreen(Screen):
line = Text()
line.append(f"{recent:.1f} pkt/min", style="brand")
line.append(f" (15 m) · {overall:.1f} ({span})", style="muted")
if self._scope:
return line # who was heard is read off adverts, which carry no scope
line.append(" · ", style="muted")
line.append(str(len(nodes)))
suffix = f" node{'s' if len(nodes) != 1 else ''}"
@@ -417,7 +523,7 @@ class DashboardScreen(Screen):
if len(line.plain) + _GRID_LABEL_W > width:
line = compose(heard=False)
rows = [("pulse", line)]
busiest = self._busiest()
busiest = None if self._scope else self._busiest()
if busiest is not None:
name, count = busiest
value = Text(name, style="brand")
@@ -439,26 +545,34 @@ class DashboardScreen(Screen):
# -- traffic --
def _traffic_section(self) -> list[RenderableType]:
"""Tallies by packet class, each with an icon and a proportional braille meter.
"""Frames heard on the air, by class, each with an icon and a proportional meter.
Every known class shows once heard — the raw ``packet`` bucket is broken out
by payload class (channel text, trace, path, …) instead of lumping every
overheard frame together; classes never heard stay hidden.
Every class shows once heard, named as the live feed names it; classes never
heard stay hidden. Every bar is the one colour the feed draws a frame's class in
(``packet``'s): the icon and the label say which class a row is, so colour has
nothing left to say here.
"""
heading = Text("Traffic", style="accent")
heading.append(" · by packet class · stored history + live", style="muted")
counts = self._kind_counts()
if self._scope:
heading.append(" · by packet class · floods in the last 2 h", style="muted")
counts = self._scoped().counts
empty = "nothing heard in this scope in the last 2 h"
else:
heading.append(" · by packet class · stored history + live", style="muted")
counts = {k: n for k, n in self._kind_counts().items() if _is_frame_bucket(k)}
empty = "nothing heard yet"
if not counts:
return [heading, Text("nothing heard yet", style="muted")]
return [heading, Text(empty, style="muted")]
order = {k: i for i, k in enumerate(_TRAFFIC_ORDER)}
peak = max(counts.values())
chrome = {bucket: _traffic_chrome(bucket) for bucket in counts}
label_w = max(len(label) for _, label, _ in chrome.values())
label_w = max(len(label) for _, label in chrome.values())
count_w = len(str(peak))
style = KIND_STYLES["packet"]
rows: list[RenderableType] = [heading]
for bucket in sorted(counts, key=lambda k: (order.get(k, len(order)), k)):
count = counts[bucket]
icon, label, style = chrome[bucket]
icon, label = chrome[bucket]
row = Text(f"{icon} ")
row.append(f"{label.ljust(label_w)} ", style="muted")
row.append(f"{count:>{count_w}} ")
@@ -480,8 +594,11 @@ class DashboardScreen(Screen):
heading.append(" · reception over 2 h · radio live", style="muted")
rows: list[tuple[str, Text]] = []
snrs = self._win_snrs # the one-pass window digest (see _digest_window)
rssis = self._win_rssis
# The one-pass window digest (see _digest_window), or the narrowed view's frames.
if self._scope:
snrs, rssis = self._scoped().snrs, self._scoped().rssis
else:
snrs, rssis = self._win_snrs, self._win_rssis
if snrs:
med = median(snrs)
line = Text(f"{med:+.1f} dB median ", style=snr_style(med))
@@ -570,6 +687,7 @@ async def open_dashboard(ctx: AppContext) -> None:
activity=ctx.monitor.activity_histogram,
activity_flags=ctx.monitor.activity_session_flags,
kind_counts=ctx.monitor.kind_counts,
scope_of=ctx.region_store.scope_of if ctx.region_store is not None else None,
)
unsubscribe = ctx.events.subscribe(screen.on_event)
+191 -36
View File
@@ -28,6 +28,7 @@ is connected to again.
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Mapping
from typing import TYPE_CHECKING
@@ -40,10 +41,13 @@ from ..core.device_store import DeviceStore, RememberedDevice
from ..core.discovery import (
DEFAULT_TCP_PORT,
DiscoveredDevice,
discover_ble_devices,
discover_devices,
parse_tcp_endpoint,
serial_device,
tcp_device,
)
from ..core.spiradio import spi_radios
from ..platforms import get_platform
from .logo import load_logo
from .menus import Lane, align_icons, column_header, fit_cells
@@ -87,6 +91,10 @@ _SERIAL_ICON = "🔌"
#: rather than a wired or Bluetooth link. Two cells like the serial plug, so it aligns the same.
_TCP_ICON = "🌐"
#: TYPE-column glyph for a radio on the host's own SPI bus — a pin, because the radio is
#: right here, on this machine. Two cells, aligning with the plug and the globe.
_SPI_ICON = "📍"
#: Half-block glyphs that taper the Bluetooth badge: ``▐`` fills a cell's right half (so it
#: hugs the rune's left edge) and ``▌`` its left half (hugging the right edge). Drawn in the
#: badge's blue over the terminal background, they widen the blue by half a cell on each side.
@@ -107,6 +115,8 @@ def _type_cell(device: DiscoveredDevice) -> Text:
"""
if device.is_tcp:
return Text(" " + _TCP_ICON)
if device.is_spi:
return Text(" " + _SPI_ICON)
if not device.is_ble:
return Text(" " + _SERIAL_ICON)
cell = Text()
@@ -127,6 +137,8 @@ def _hardware_name(device: DiscoveredDevice) -> str:
return device.name or device.product or device.description or "Network device"
if device.is_ble:
return device.name or device.product or device.description or "Bluetooth device"
if device.is_spi:
return device.name or "SPI radio"
name = device.product or device.description or device.vendor_label or "Serial device"
suffix = f"({device.port})"
if name.endswith(suffix):
@@ -153,6 +165,36 @@ def _where(device: DiscoveredDevice) -> str:
return device.target
def _fit_target(text: str, width: int) -> str:
"""Fit a connection target into ``width`` cells, keeping its **end**.
Deliberately not :func:`~meshterm.ui.menus.fit_cells`, which keeps the head: what tells
two targets apart is their *tail* — ``usbmodem1101`` from ``usbmodem1102``,
``ttyUSB0`` from ``ttyUSB1`` — while the head they share (``/dev/cu.``) is boilerplate.
Cutting the front is the cut that leaves the column able to do its one job in a picker,
and cutting the back would render two different ports identically. A CoreBluetooth UUID
is equally identifiable from either end, so the rule that is right for ports costs the
platform that needed the cap nothing.
Args:
text: The target to fit.
width: The cells available. Text already inside it is returned unchanged, so the
platforms whose targets already fit the lane are untouched.
Returns:
The target, or its tail behind a leading ``…``, measuring at most ``width`` cells.
"""
if cell_len(text) <= width:
return text
keep = max(0, width - 1) # the leading ellipsis costs a cell
tail = ""
for ch in reversed(text):
if cell_len(tail) + cell_len(ch) > keep:
break
tail = ch + tail
return "…" + tail
def _hardware_label(device: DiscoveredDevice, registry: dict[str, RememberedDevice]) -> str:
"""The HARDWARE column text: the remembered firmware model, else the USB vendor.
@@ -209,7 +251,6 @@ async def _smoke_test(
unrecoverable failure, or a cancelled PIN prompt).
"""
pin: str | None = None
pin_error = "" # empty on the first ask; set once a PIN has been rejected
while True:
# BLE connect and service discovery take a few seconds, so the spinner matters most here.
try:
@@ -219,19 +260,21 @@ async def _smoke_test(
title="Checking companion",
banner=load_logo(),
)
except DeviceAuthenticationError:
except DeviceAuthenticationError as exc:
# The device answered the scan but won't connect until it's bonded (or the last PIN
# was wrong). Collect one in the popup and loop to retry; Esc returns to the list.
# The error says *which* refusal it was — a stale bond, a wrong PIN, a device that
# turned the pairing down — and the dialog carries that line under its question,
# rather than calling every refusal a rejected PIN.
entered = await ui.prompt_pin_startup(
name,
error=pin_error,
error=exc.hint,
help_text="The 6-digit code shown on the device or in the MeshCore app",
banner=load_logo(),
)
if entered is None:
return None # the user gave up → back to the device list
pin = entered
pin_error = "That PIN was rejected — check the code and try again."
continue
except DeviceCommandError as exc:
# A different actionable failure (not a PIN): show its remedy verbatim, then back to
@@ -248,6 +291,11 @@ async def _smoke_test(
"Check the host and port — it may be unreachable, powered off, already "
"connected elsewhere, or busy."
)
elif chosen.is_spi:
reason = (
"Its node started but never answered — its log is node.log in "
"MeshTerm's radio folder."
)
elif chosen.is_ble:
reason = (
"It may be out of range, powered off, already connected elsewhere, or busy."
@@ -268,6 +316,27 @@ async def _smoke_test(
return info
#: How often the splash re-enumerates serial ports. `comports()` is a SetupAPI/sysfs
#: walk rather than a free read, so it runs off the event loop -- but it is cheap enough
#: to repeat at this rate without anyone noticing, and it covers the common case: a USB
#: radio plugged in after the splash appeared.
_POLL_S = 2.0
#: Bluetooth is not cheap in the same way. Each scan is a bounded listen, so keeping one
#: running means keeping the radio listening for as long as this screen is open -- and
#: this screen is the one a reader leaves up while they go and find a cable, on a handheld,
#: on a battery. So it gets a duty cycle rather than a loop: a window this often, and only
#: while it is still plausible somebody is waiting on one.
_BLE_EVERY_S = 15.0
_BLE_WINDOW_S = 4.0
#: How many windows to run before giving up on finding a companion nobody has turned on.
#: Once something *is* listed the reader has what they came for; while the list is still
#: empty the count is ignored, because an empty splash is exactly where somebody is
#: waiting and the cost of another listen is the cost of being useful.
_BLE_WINDOWS = 8
async def prompt_device(
ui: Ui,
devices: list[DiscoveredDevice],
@@ -297,11 +366,24 @@ async def prompt_device(
leave the picker without selecting one — by pressing Esc or choosing the Quit row —
which the caller treats as a request to exit.
"""
scanned = list(devices)
# Serial and Bluetooth are kept apart because they are refreshed on different clocks:
# a poll replaces everything attached, a Bluetooth window replaces everything in range,
# and one must not wipe the other's findings.
wired = [d for d in devices if not d.is_ble]
wireless = [d for d in devices if d.is_ble]
#: The row the *next* redraw should open on, when the pass just finished moved the list
#: under the reader (see :func:`_after_hiding`). Cleared as soon as it is spent.
focus: DiscoveredDevice | None = None
while True:
def assemble() -> tuple[
list[DiscoveredDevice], list, RememberedDevice | None, dict[str, RememberedDevice]
]:
"""The list as it stands this instant: what is there, less what is hidden.
Built in one place because two callers need it now -- the loop below, and the
rescan that redraws the rows under the reader while the splash is open.
"""
scanned = wired + wireless
remembered = store.load()
# The full registry (not just the single last device) so *every* confirmed companion
# can be named, highlighted, and sorted to the top — keyed by stable_id. Reloaded each
@@ -318,12 +400,50 @@ async def prompt_device(
# produced by the scan, so fold those in too — after the scanned set, so a port
# pyserial *does* enumerate keeps its richer scanned row rather than the bare profile.
listed += _profile_serial_devices(listed, profiles)
# A radio on the SPI bus answers nothing until MeshTerm starts its node, so it is
# listed from the device node's presence, the way a serial port is.
listed += spi_radios(profiles, listed)
# Devices the user has told this splash to stop showing (h). Read each pass, so a
# row hidden a moment ago is gone the next time the list is drawn — which is the
# only feedback hiding needs.
hidden = store.hidden_ids()
# In display order, so a shortcut acting on a row can say what the row *after* it is.
ordered = _order([d for d in listed if d.stable_id not in hidden], registry)
order = _order([d for d in listed if d.stable_id not in hidden], registry)
items = _build_items(order, remembered, registry, hidden=len(hidden))
return order, items, remembered, registry
async def keep_looking(redraw: Callable[[list], None]) -> None:
"""Re-enumerate while the splash is up, and redraw when the answer changes.
The screen used to scan once on the way in and never again, so a radio plugged in
ten seconds late was invisible until MeshTerm was restarted -- and the screen that
said so offered only a network address typed by hand, or quitting. Fetching the
cable is the obvious thing to do and it was the one thing that did not work.
Only a change redraws. A poll that finds the same ports is silence, which matters
because the reader may be part-way through arrowing down the list.
"""
windows = 0
waited = 0.0
while True:
await asyncio.sleep(_POLL_S)
before = {d.stable_id for d in wired + wireless}
# ``comports()`` blocks; off the event loop so the splash stays live.
wired[:] = await asyncio.to_thread(discover_devices)
waited += _POLL_S
if waited >= _BLE_EVERY_S and (windows < _BLE_WINDOWS or not wired + wireless):
waited = 0.0
windows += 1
wireless[:] = await discover_ble_devices(_BLE_WINDOW_S)
if {d.stable_id for d in wired + wireless} != before:
_, rows, _, _ = assemble()
redraw(rows)
while True:
ordered, items, remembered, registry = assemble()
# Preselect the remembered "last known good" device when it is currently attached/in
# range — unless the last pass asked for a particular row, which a hide does so the
# highlight lands where the vanished row was rather than jumping back to the default.
@@ -332,16 +452,17 @@ async def prompt_device(
default = focus if focus in ordered else default
focus = None
# Build the rows once so the same list can be redrawn as the backdrop behind a
# removal confirm (so it floats over the picker rather than replacing it).
items = _build_items(ordered, remembered, registry, hidden=len(hidden))
chosen = await ui.select_startup(
"Select a companion device",
items,
default=default,
banner=load_logo(),
keys=_SHORTCUTS,
key_hint=_shortcut_hint(len(hidden)),
key_hint=_shortcut_hint(len(store.hidden_ids())),
live=keep_looking,
# Said outright because the rows no longer imply it: scrolling is switched on by
# a row pinning a head block, and none of these do any more.
hscroll=True,
)
# Esc (``None``) and the Quit row both mean "leave the picker" — surface that to the
# caller as ``None`` so it can exit the program instead of continuing device-less.
@@ -670,34 +791,66 @@ async def _remove_network_device(
#: Below this a model string says nothing at all, and the lane is better spent on the name.
_HARDWARE_MIN = 10
#: The narrowest the PORT / ADDRESS lane is worth shrinking to before DEVICE has to give.
#: This is the lane that adapts: a target is how you tell two similar rows apart, and ten
#: cells still does that (the tail of a UUID, the end of a port path), while a *name* is
#: what the reader actually came to read. macOS is why any of this is needed —
#: CoreBluetooth reports no MAC at all but a per-machine 36-cell UUID, and even its ports
#: run long (every Mac carries a 31-cell ``/dev/cu.Bluetooth-Incoming-Port``) — where a MAC
#: is 17 and ``/dev/ttyUSB0`` is 12, so no other platform ever presses on the row at all.
_ADDRESS_MIN = 10
def _name_width(
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice], other_lanes: int
) -> int:
"""The DEVICE lane's width: the longest name, capped so HARDWARE keeps a readable column.
A USB adapter's own product string runs to forty-odd characters ("CP2102 USB to UART
Bridge Controller"), and the lane used to size itself to whichever of those was longest.
On a 53-column console that pushed every other lane off the right-hand edge — the row
ended mid-port, and the HARDWARE column that says what the thing *is* was not on screen
at all, nor reachable by scrolling, because the pinned head was already wider than the
box. So the name is the lane that gives: it ellipsizes, and the columns after it survive.
def _lane_widths(
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice]
) -> tuple[int, int]:
"""The DEVICE and PORT / ADDRESS widths: the name in full, the address taking the rest.
The row has a fixed budget, and something has to give when the two lanes together
outrun it. **The address gives first.** A name is what the reader came to read and what
they recognise their own radio by; an address is a disambiguator, and a disambiguator
does its whole job from its last ten cells (:func:`_fit_target` keeps the tail for
exactly this reason). So DEVICE is sized to the longest name it has, and ADDRESS takes
whatever is left over, down to :data:`_ADDRESS_MIN`.
Only once the address is down to that floor does the name start ellipsizing, and it
still must: a USB adapter's own product string runs to forty-odd characters ("CP2102
USB to UART Bridge Controller"), and a lane sized to *that* pushed everything after it
off the right-hand edge — the row ended mid-port and the HARDWARE column that says what
the thing actually *is* was not on screen, nor reachable by scrolling, because the
pinned head was already wider than the box. HARDWARE keeps :data:`_HARDWARE_MIN` out of
the budget so it always has a readable stub; it is the row's scrolling tail, so beyond
that stub it is free to run long.
Args:
devices: The devices being listed.
registry: Confirmed companions, for their remembered names.
other_lanes: Cells the PORT and TYPE lanes take between them.
Returns:
The name lane's width in cells.
``(name_w, port_w)`` in cells.
"""
platform = get_platform()
# What the splash's box gives a row: the terminal, less the gutter it floats over, less
# its own border and padding, less the pointer and star columns the row leads with.
content = platform.readable_cols - platform.dialog_margin - 4 - 4
fixed = other_lanes + 6 # the three two-cell gaps between the four lanes
longest = max(cell_len(_display_name(d, registry)) for d in devices)
return max(len("DEVICE"), min(longest, content - fixed - _HARDWARE_MIN))
# ...less the three two-cell gaps, the TYPE badge, and HARDWARE's readable stub.
budget = content - 6 - len("TYPE") - _HARDWARE_MIN
name_natural = max(cell_len(_display_name(d, registry)) for d in devices)
port_natural = max(cell_len(_where(d)) for d in devices)
# Never reserve more room for the address than it actually wants: a lone "COM3" should
# hand its slack to the name rather than sit in a ten-cell lane holding four cells.
floor = min(port_natural, _ADDRESS_MIN)
name_w = max(len("DEVICE"), min(name_natural, budget - floor))
port_w = max(floor, min(port_natural, budget - name_w))
if name_w < name_natural and port_w < port_natural:
# The name is being ellipsized whatever happens, so spending two more of its cells
# to keep the address whole trades one cut for none rather than adding a second.
port_w = min(port_natural, budget - len("DEVICE"))
name_w = max(len("DEVICE"), budget - port_w)
return name_w, port_w
def _order(
@@ -765,12 +918,13 @@ def _build_items(
port_label = (
"PORT / ADDRESS" if has_serial and has_address else "ADDRESS" if has_address else "PORT"
)
port_w = max(cell_len(_where(d)) for d in devices)
port_w = max(port_w, len(port_label))
# The TYPE column holds a small transport badge (at most 3 cells); its heading is wider,
# so the four-cell "TYPE" label sets the column width and every badge pads out to it.
type_w = len("TYPE")
name_w = _name_width(devices, registry, port_w + type_w)
# DEVICE in full, ADDRESS taking what is left (see _lane_widths). The heading does not
# set a floor here the way it used to: the lane may end up narrower than "PORT / ADDRESS"
# spells, and column_header already carries the short form to fall back on.
name_w, port_w = _lane_widths(devices, registry)
hardware_w = max(cell_len(_hardware_label(d, registry)) for d in devices)
hardware_w = max(hardware_w, len("HARDWARE"))
@@ -806,7 +960,7 @@ def _build_items(
style="device.known" if is_known else "",
)
row.append(" ")
row.append(_pad(_where(device), port_w), style="muted")
row.append(_pad(_fit_target(_where(device), port_w), port_w), style="muted")
row.append(" ")
# The TYPE badge marks the transport: a plug emoji for serial, or the Bluetooth rune
# on its blue badge for BLE. It's built with its own colours, then the column is
@@ -816,11 +970,12 @@ def _build_items(
row.append_text(cell)
row.append(" " * max(0, type_w - cell.cell_len))
row.append(" ")
# Everything up to here is the row's fixed lanes — the reader's place in the list —
# so it is pinned and only what follows slides under ←→ (see Choice.hscroll_from).
# What follows is the hardware model and its tag, which is the part that runs long:
# a firmware model string is as long as its vendor felt like making it.
head = row.cell_len
# Nothing is pinned here, so ←→ slide the whole row. The Trophy case pins its
# rank/date/score lanes because those always fit and only the walk overflows, which
# makes them the reader's place in a long list. This list inverts that: on a narrow
# terminal it is the device name and the address that get cut, so pinning them would
# pin the truncation in place and leave the one thing ←→ could not reach being the
# text somebody most wants to finish reading.
row.append(_pad(_hardware_label(device, registry), hardware_w), style="muted")
# Only devices we've actually confirmed are billed as MeshCore companions; a USB
# vendor ID (or a BLE advert) is a sort hint, not a claim. A bare serial bridge earns
@@ -840,7 +995,7 @@ def _build_items(
# Only a network device opts into Delete-to-remove: it's listed solely from its
# remembered endpoint, so forgetting it is the only way it leaves the picker. A scanned
# serial/BLE device would just reappear, so Delete stays inert on those rows.
items.append(Choice(title=row, value=device, deletable=device.is_tcp, hscroll_from=head))
items.append(Choice(title=row, value=device, deletable=device.is_tcp))
items.extend(_action_rows())
return items
+170
View File
@@ -0,0 +1,170 @@
# SPDX-License-Identifier: Apache-2.0
"""The Diagnostics page: the bug-report block, drawn as one of the app's written pages.
It is prose-shaped rather than screen-shaped — a title, a standfirst, sections, and a list
under each — so it is markdown, and it goes through the renderer the About pages already
use (:mod:`meshterm.ui.markdown`). That is not a presentational choice made twice: the same
document is what gets **saved**, and an issue tracker renders markdown as written. One
source, one look, on screen and in the issue.
Being a written page buys the two things a long block most needs and neither of which a
hand-built screen had: its ``##`` headings are landmarks, so each pins to the top row while
its own section scrolls under it and ``^PgUp``/``^PgDn`` step section by section; and the
PicoCalc's F-key lane picks up ``Sect ↑``/``Sect ↓`` for the same reason a grouped list
does. :class:`~meshterm.ui.about.AboutPage` already does all of it, so this is that page
with one verb added.
The verb is **save** (:data:`SAVE_KEY`), advertised where the platform advertises verbs —
in the footer hint on a desktop, on the free F3 slot of the lane on the console — and never
in the page's own body, which belongs to the report. It answers in a popup, because the
answer is a path the reader may need to read carefully and because a write can fail; the
page underneath is unchanged and still there when the popup goes.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from rich.text import Text
from .about import AboutPage
from .markdown import render_markdown
if TYPE_CHECKING: # pragma: no cover - typing only
from ..context import AppContext
from .tui.session import TuiSession
#: The key that writes the page to a file. A bare letter, which a read-only page can
#: afford because it has no filter and no field for one to mean anything else in.
SAVE_KEY = "s"
#: The action both the key and the lane's chip dispatch, so the chip is not a second
#: implementation of the key.
SAVE_ACTION = "save"
class DiagnosticsPage(AboutPage):
"""The written diagnostics page, plus the one verb that writes it to a file.
Everything about how it *reads* is :class:`~meshterm.ui.about.AboutPage`: the pager,
the sticky ``##`` headings, the section jumps, the lane that dims when there is nothing
to page. What is added is the save, on the one slot the About pages leave free.
"""
def __init__(self, source: str, *, session: TuiSession, save_path: Path) -> None:
"""Show ``source`` as the page, saving to ``save_path`` on demand.
Args:
source: The markdown document, from
:func:`~meshterm.tools.diagnostics.markdown_source`.
session: The session, for the popup the save answers in.
save_path: Where :data:`SAVE_KEY` writes.
"""
super().__init__("Diagnostics", render_markdown(source))
self._source = source
self._session = session
self._save_path = save_path
self._saving = False
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The read-only page's keys, with the save between the pager and Esc.
Navigation first, then the action, then Esc — the standard sentence shape. The
pager atom comes and goes with :attr:`~Screen.content_overflows`, as it does on
every read-only page, so the line never names a key that would do nothing.
"""
scroll = "↑↓ PgUp/PgDn scroll · " if self.content_overflows else ""
return f"{scroll}{SAVE_KEY} save · Esc back"
@property
def fkey_lane(self):
"""The About page's lane, with ``Save`` on the slot it leaves free.
F1/F2 are the section pair and F4/F5 the pager, so F3 is the one free primary slot
— and on this platform the lane is the *only* place a bare letter can advertise
itself at all, which is what makes the chip load-bearing rather than decorative.
"""
from .tui.fkeys import FPair
lane = list(super().fkey_lane)
lane[2] = FPair("Save", SAVE_ACTION)
return lane
def handle(self, action: str, data: str = "") -> None:
"""Save on the key or the chip; scroll, jump or leave on everything else."""
if action == SAVE_ACTION or (action == "text" and data.lower() == SAVE_KEY):
self._begin_save()
return
super().handle(action, data)
def _begin_save(self) -> None:
"""Write the file and report it in a popup, off the key handler.
``run_detached`` because a screen's ``handle`` is synchronous and the popup is a
screen: the flow can only be *started* here, and the task it starts sits outside
the navigation chain (see :meth:`~meshterm.ui.tui.session.TuiSession.run_detached`).
One save at a time, so a held key cannot stack popups over each other.
"""
if self._saving:
return
self._saving = True
async def run() -> None:
try:
await self._session.message_dialog(self._write(), title="Diagnostics")
finally:
self._saving = False
self._session.run_detached(run())
def _write(self) -> Text:
"""Write the document, and say what happened as the popup's message.
A :class:`~rich.text.Text` with real spans rather than a markup string: the dialog
draws the message as given and reads its *spans* to pick a border tone (see
``session._message_border``), so markup left as text would both print its own tags
and leave a failure framed in the success colour.
Only the **mark** is styled — ``✓`` or ``✗``, the app's own ok/err pair — which is
enough for the border to read the tone and leaves the path itself in body ink,
where it stays legible. That is what the mark is for.
"""
from ..tools.diagnostics import write_markdown
message = Text()
try:
written = write_markdown(self._source, self._save_path)
except OSError as exc:
# A read-only home, a full disk, a path that is not there. The page itself is
# untouched and still readable, which is the fallback that matters.
message.append("✗ ", style="err")
message.append(f"could not save to\n{self._save_path}\n\n")
message.append(exc.strerror or str(exc), style="muted")
return message
message.append("✓ ", style="ok")
message.append(f"saved to\n{written}")
return message
async def open_diagnostics_page(ctx: AppContext, source: str, *, save_path: Path) -> None:
"""Open the Diagnostics page full-screen and hold it until the reader backs out.
Args:
ctx: The shared application context (must be running the interactive TUI).
source: The markdown document to show and to save.
save_path: Where the page's save key writes.
Raises:
RuntimeError: If called outside the interactive menu (no full-screen session).
"""
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
raise RuntimeError("the Diagnostics page is only available in the menu")
session = ctx.ui.session
await session.run_screen(DiagnosticsPage(source, session=session, save_path=save_path))
__all__ = ["SAVE_ACTION", "SAVE_KEY", "DiagnosticsPage", "open_diagnostics_page"]
+80 -1
View File
@@ -32,11 +32,14 @@ from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from typing import TYPE_CHECKING, Any
from . import script
from .report import Column, Lane, normalise
if TYPE_CHECKING:
from ..core.regions import Scope
# -- the shared value shapes -----------------------------------------------------------
@@ -240,6 +243,55 @@ def channel(
)
def scope_json(scope: Scope | None) -> dict[str, Any] | None:
"""A flood's scope as its machine object, or ``null`` for a frame that has none.
``state`` is ``scoped`` (``region`` names it), ``unknown`` (scoped, no known region
reproduces ``code``) or ``unscoped``; ``code`` is the frame's first transport code as
lowercase hex, kept even when unresolved so two frames can be seen to share a region.
A direct frame is ``null`` — never region-filtered, so it has no scope to report.
"""
if scope is None:
return None
return {"state": scope.state, "region": scope.region, "code": scope.code}
def _scope_cell(scope: Scope | None) -> str:
"""A scope's plain word: the region, ``unknown`` for an unresolved one, or ``unscoped``."""
if scope is None:
return script.NONE
if scope.state == "scoped" and scope.region:
return script.name(scope.region)
return "unknown" if scope.scoped else "unscoped"
def scope(key: str = "scope", header: str = "SCOPE") -> Column:
"""A flood's scope — the region a repeater relays it in — as one lane and one object.
The row holds a :class:`~meshterm.core.regions.Scope` (or ``None`` for a direct frame).
The plain lane is a word, never the code: a four-digit hash in a column of region
names reads as a region nobody has, and the document carries the code for whoever
needs it.
"""
return Column(key=key, lanes=(Lane(header=header, render=_scope_cell),), json=scope_json)
def regions(key: str = "regions", header: str = "REGIONS") -> Column:
"""A set of region names — what a repeater relays: joined plain, an array in the document.
The row holds a sequence of bare names, never the wildcard: *unscoped* is its own
concept (a :func:`flag` beside this column), because ``*`` names no region and a
consumer reading this array for names would otherwise have to know to skip it. An empty
sequence is a real answer — *no regions* — so it reads as the word ``none`` plain and
stays ``[]`` in the document; ``None`` is *not known*, the absent token and ``null``.
"""
return Column(
key=key,
lanes=(Lane(header=header, render=_regions_cell),),
json=lambda names: None if names is None else [str(n) for n in names],
)
def when(key: str, header: str, *, absent: str = script.NONE) -> Column:
"""A time the reader reads as an age: ``5m``, ``3h``, ``never``.
@@ -332,6 +384,21 @@ def name(key: str, header: str) -> Column:
return Column(key=key, lanes=(Lane(header=header, render=script.name),))
def path(key: str, header: str) -> Column:
"""A place on this filesystem: a config directory, a database, a file just written.
Its own constructor rather than :func:`word` because the *typed* value is a
:class:`~pathlib.Path` and nothing else in the lane vocabulary turns one into text —
``word`` hands it straight to Rich, which cannot render it. The machine face was
already right: :func:`~meshterm.ui.report.normalise` has always spelled a Path as its
string, so this closes the plain half of a shape that was only ever half-declared.
Printed as the host writes it, separators and all, since the one thing a reader does
with a path is paste it back into their own shell.
"""
return Column(key=key, lanes=(Lane(header=header, render=_path_cell),))
def free(key: str, header: str) -> Column:
"""Free text a stranger filled in: escaped so it can never end its own record.
@@ -438,11 +505,23 @@ def hidden(key: str) -> Column:
return Column(key=key)
def _regions_cell(names: Sequence[str] | None) -> str:
"""A region set's plain cell: the names comma-joined, ``none`` when empty, else absent."""
if names is None:
return script.NONE
return ", ".join(script.name(n) for n in names) if names else "none"
def _rendered_cell(value: Rendered | None) -> str:
"""One setting's cell: the text its own spec produced, or the absent token."""
return script.NONE if value is None else value.text
def _path_cell(value: Any) -> str:
"""A path as its own text, or the absent token where there is none."""
return script.NONE if value is None else str(value)
def _position_cell(where: Position | None) -> str:
"""One position's cell: the pair, or the absent token."""
return script.NONE if where is None else script.location(where.lat, where.lon)
+216 -62
View File
@@ -3,7 +3,7 @@
The interactive face of the ``livefeed`` tool — the dashboard's old feed panel,
promoted to a first-class screen. One always-repainting list streams the latest
packets, newest first: time, class (icon + label, icon alone on a narrow terminal),
packets, newest first: time, class (its name, beside an icon where the platform draws one),
subject, SNR/RSSI, and a message's conversation. What a row deliberately does *not* carry
is the relay path a frame rode in on: a route is a shape, not a lane, and squeezing one
into the cells left at the right edge only ever produced a stub — so the route belongs
@@ -11,6 +11,12 @@ to the packet viewer, which has the room to draw it as a wrapped path line over
graph. The feed's job is to say what arrived and how well it was heard; Enter says how
it got here.
A **scope** lane sits between the subject and the readings, at every width: the region a
flood was sent into (``harbour``), ``? 3fa1`` for a region nobody here has named, a muted
``-`` for a plain flood, and nothing for a direct frame, which no repeater region-filters.
It never collapses; the class label beside the icon is what gives way on a narrow terminal
(see :func:`_lanes_for`).
The subject lane is contextual: it holds whatever the *class* of packet is about (see
:meth:`LiveFeedScreen._feed_subject`). An advert or a telemetry frame is about the node
that sent it, so the lane names the node — but a channel text is about its channel, a
@@ -54,7 +60,7 @@ import asyncio
from collections import deque
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, NamedTuple
from rich.text import Text
@@ -62,22 +68,27 @@ from ..core.channels import identify_channel, split_channel_sender
from ..core.events import EventKind, MeshEvent
from ..core.frames import CHANNEL_CLASSES, ENDPOINT_HASH_BYTES
from ..core.models import Observation, utcnow
from ..platforms import Platform, on_platform
from .menus import fit_cells
from .packet_viewer import (
KIND_STYLES,
MessageScopeOf,
PacketEntry,
PacketViewer,
ScopeOf,
class_marks,
node_label,
unnamed_scope,
)
from .pathline import SELF_GLYPH, PathHop, PathLine
from .theme import name_style, snr_style
from .tui.render import crop_cells, render_to_ansi
from .tui.screen import ListWindow, Screen
from .widgets import NameKeyResolver, TypeOf
from .widgets import NameKeyResolver, TypeOf, scope_text
if TYPE_CHECKING:
from ..context import AppContext
from ..core.regions import Scope
#: Seconds between full repaints while the feed is open (keeps age-sensitive chrome
#: honest even between hub events).
@@ -116,8 +127,10 @@ _FEED_SEED_FLOOR = datetime.min.replace(tzinfo=timezone.utc)
_FEED_SUBJECT_WIDTH = 18
#: The class lane's fixed width — wide enough for the longest class the app files a
#: packet under (``direct message``), so every label reads whole and the lanes hold.
_FEED_CLASS_WIDTH = 14
#: packet under (``telemetry``, ``chan text``, ``multipart``), so every label reads whole.
#: The names are kept short on purpose (``dir messg``, ``anon req`` — JP, 2026-09-25): the
#: lane never collapses, so every cell it spends is one the row's readings scroll off for.
_FEED_CLASS_WIDTH = 9
# The remaining lanes, as the row and its column header both measure them — one set of
# numbers so a header label can never drift off the values it names, and so a lane a
@@ -126,31 +139,77 @@ _FEED_CLASS_WIDTH = 14
_LANE_GAP = 2
#: ``HH:MM:SS`` plus the gap that follows it.
_TIME_LANE = 8 + _LANE_GAP
#: The class icon and its trailing space (all the class lane keeps on a narrow terminal).
#: The class icon and its trailing space — or nothing, where the platform draws no icons
#: (bound by :func:`_bind_class_icon`).
_ICON_LANE = 3
#: Cells a reception reading right-aligns its number in — the ``%+5.1f``/``%5.0f`` field
#: both lanes are built on, and the slot their column headers sit over.
_READING_W = 5
#: ``+13.2 dB`` — the number field, then its unit.
_SNR_LANE = _READING_W + len(" dB")
#: `` -61 dBm`` — the lane gap, the number field, then its unit.
_RSSI_LANE = _LANE_GAP + _READING_W + len(" dBm")
#: Terminal width below which the feed drops the textual class label and keeps only the
#: two-cell icon. It is exactly the width the full row occupies — with no route to make
#: room for, every lane fits a 72-column screen, so the label (the row's most informative
#: field now that it names the real class) is only ever dropped when it truly can't fit.
_FEED_LABEL_MIN_WIDTH = (
2
+ _TIME_LANE
+ _ICON_LANE
+ _FEED_CLASS_WIDTH
+ _LANE_GAP
+ _FEED_SUBJECT_WIDTH
+ _LANE_GAP
+ _SNR_LANE
+ _RSSI_LANE
)
@on_platform
def _bind_class_icon(platform: Platform) -> None:
"""Bind the class icon lane to the platform (runs now and on every switch).
The class *name* is always drawn (it never collapses — JP, 2026-09-25), so the icon
beside it is a family cue rather than the information, which is exactly what
:attr:`~meshterm.platforms.Platform.menu_icons` governs: on the PicoCalc, where a class
icon can only be a one-glyph stand-in, the lane is dropped and its three cells go to
the row.
"""
global _ICON_LANE
_ICON_LANE = 3 if platform.menu_icons else 0
#: Cells an SNR right-aligns its number in: ``+13.2``, ``-20.5`` — sign, two digits, tenth.
_SNR_W = 5
#: Cells an RSSI right-aligns its number in: ``-120`` at its weakest, whole dBm. Sized to
#: the reading rather than borrowed from the SNR's field, whose extra cell used to sit
#: between the two readings as a gap twice the width of every other gap on the row.
_RSSI_W = 4
#: ``+13.2 dB`` — the number field, then its unit.
_SNR_LANE = _SNR_W + len(" dB")
#: `` -105 dBm`` — the lane gap, the number field, then its unit.
_RSSI_LANE = _LANE_GAP + _RSSI_W + len(" dBm")
#: The scope lane's width. Room for an unnamed scope's ``? 3fa1`` and a short region name
#: whole; a longer name ellipsizes — its whole name is on the viewer's ``scope`` row, one
#: keypress away, like everything else a lane cuts.
_FEED_SCOPE_WIDTH = 10
#: The scope lane with the gap that sets it off from the readings after it.
_SCOPE_LANE = _FEED_SCOPE_WIDTH + _LANE_GAP
def _feed_scope(scope: Scope | None) -> Text:
"""One row's scope cell: the region, ``? 3fa1`` unnamed, a muted dash for unscoped.
Blank for a direct frame (or a row that is no frame at all), which has no scope: a
dash there would claim a plain flood it never was.
"""
if scope is not None and not scope.scoped:
return Text("-", style="muted")
return scope_text(scope, bare=True)
class _Lanes(NamedTuple):
"""The lane widths one paint lays its rows out in.
Attributes:
subject: The subject lane's width (:data:`_FEED_SUBJECT_WIDTH`).
"""
subject: int
def _lanes_for(width: int) -> _Lanes:
"""The lanes a row carries at ``width`` — all of them, at every width.
No lane collapses (JP, 2026-09-25): the class name and the scope were each dropped on
a narrow terminal in turn, and each time the lane that went was one a reader came to
the feed for. A row wider than the screen is read the app-wide way instead — the
highlighted row slides sideways under ``←→`` — which on a 72-column terminal reaches
the readings at the row's end, and on the PicoCalc's 53 everything past the subject.
"""
return _Lanes(_FEED_SUBJECT_WIDTH)
#: Cells one ←/→ press shifts the highlighted row by — the app-wide select list's own
#: step (:attr:`~meshterm.ui.tui.select.SelectScreen._HSCROLL_STEP`), so a row here
@@ -236,6 +295,8 @@ class LiveFeedScreen(Screen):
channel_names: Mapping[int, str] | None = None,
type_of: TypeOf | None = None,
key_of: NameKeyResolver | None = None,
scope_of: ScopeOf | None = None,
message_scope: MessageScopeOf | None = None,
) -> None:
"""Create the feed over its data feeds.
@@ -259,6 +320,14 @@ class LiveFeedScreen(Screen):
:func:`~meshterm.services.trace_runner.make_name_key_resolver`), so a
channel sender the contacts or the recorder know takes its key-derived
hue; an unresolvable name stays muted.
scope_of: Reads a flood's scope off its raw frame against the regions known by
name (``ctx.region_store.scope_of``) — for the scope lane, and handed to
each opened viewer for its ``scope`` row. ``None`` still tells a scoped
flood from a plain one, showing a scoped one's code where a name would go
(:func:`~meshterm.ui.packet_viewer.unnamed_scope`).
message_scope: Reads a decoded channel message's scope off its copies in the
packet log (:func:`message_scope_reader`), handed to each opened viewer so
a message's card has a ``scope`` row too. ``None``: no row for messages.
"""
super().__init__()
self.title = "Live feed"
@@ -270,6 +339,11 @@ class LiveFeedScreen(Screen):
self._channel_names: Mapping[int, str] = channel_names or {}
self._type_of = type_of
self._key_of: NameKeyResolver = key_of or (lambda name: None)
self._scope_of: ScopeOf = scope_of or unnamed_scope
self._message_scope = message_scope
#: The lanes the last paint carried (see :func:`_lanes_for`) — read by the subject
#: builders, whose crowded-lane fallbacks measure against the lane actually drawn.
self._lanes = _lanes_for(0)
#: The feed: latest events of every class as data, newest first — rendered
#: fresh each paint (rows adapt to width) and handed whole to the viewer.
self._feed: deque[PacketEntry] = deque(maxlen=_FEED_CAP)
@@ -476,6 +550,8 @@ class LiveFeedScreen(Screen):
channels=self._channels,
type_of=self._type_of,
key_of=self._key_of,
scope_of=self._scope_of,
message_scope=self._message_scope,
# The live feed itself (newest first), so the viewer keeps up with packets
# that arrive while it is open instead of freezing at this snapshot.
source=lambda: list(self._feed),
@@ -497,17 +573,17 @@ class LiveFeedScreen(Screen):
if self._selected is None:
self._hmax = 0 # nothing highlighted scrolls, so nothing advertises ←→
self._pinned = False # a feed with no rows has nothing to pin to
show_label = width >= _FEED_LABEL_MIN_WIDTH
lanes = self._lanes = _lanes_for(width)
lines: list[str] = []
if self._feed: # a header over nothing is noise; the empty note speaks for itself
lines.append(render_to_ansi(self._column_header(show_label), width, no_wrap=True))
lines.append(render_to_ansi(self._column_header(lanes), width, no_wrap=True))
win = max(1, self._scroll_viewport - len(lines))
lines.extend(self._feed_lines(width, win, show_label))
lines.extend(self._feed_lines(width, win, lanes))
self._scroll_total = max(1, len(lines))
return lines
@staticmethod
def _column_header(show_label: bool) -> Text:
def _column_header(lanes: _Lanes) -> Text:
"""The lane names, in the app's uppercase muted column-header voice.
Laid out lane for lane against :meth:`_feed_row`, the pointer column included, so
@@ -525,22 +601,17 @@ class LiveFeedScreen(Screen):
"""
header = Text(" ", style="muted") # the pointer lane
header.append(fit_cells("TIME", _TIME_LANE))
header.append(
fit_cells("CLASS", _ICON_LANE + _FEED_CLASS_WIDTH + _LANE_GAP)
if show_label
else " " * _ICON_LANE
)
header.append(fit_cells("SUBJECT", _FEED_SUBJECT_WIDTH + _LANE_GAP))
# The two readings right-align their number, so their labels do too — each sits
# over the digits it names rather than over the sign column ahead of them.
header.append(fit_cells("SNR", _READING_W, align="right"))
header.append(" " * (_SNR_LANE - _READING_W))
header.append(" " * _LANE_GAP)
header.append(fit_cells("RSSI", _READING_W, align="right"))
header.append(" " * (_RSSI_LANE - _LANE_GAP - _READING_W))
header.append(fit_cells("CLASS", _ICON_LANE + _FEED_CLASS_WIDTH + _LANE_GAP))
header.append(fit_cells("SUBJECT", lanes.subject + _LANE_GAP))
header.append(fit_cells("SCOPE", _SCOPE_LANE))
# Each reading's label starts where its lane does, as every other label on the row
# does. Right-aligned over the number instead, it ended mid-value with the unit
# trailing past it, and read as floating between two columns rather than naming one.
header.append(fit_cells("SNR", _SNR_LANE + _LANE_GAP))
header.append(fit_cells("RSSI", _RSSI_LANE - _LANE_GAP))
return header
def _feed_lines(self, width: int, win: int, show_label: bool) -> list[str]:
def _feed_lines(self, width: int, win: int, lanes: _Lanes) -> list[str]:
"""The feed's windowed rows: newest first, ``↑/↓ n more`` at the edges."""
if not self._feed:
return [render_to_ansi(Text("nothing heard yet", style="muted"), width)]
@@ -550,22 +621,22 @@ class LiveFeedScreen(Screen):
if top > 0:
out.append(render_to_ansi(ListWindow.marker(top, "above"), width))
for i in range(top, top + count):
row = self._feed_row(entries[i], i == self._selected, show_label, width)
row = self._feed_row(entries[i], i == self._selected, lanes, width)
out.append(render_to_ansi(row, width, no_wrap=True))
below = len(entries) - top - count
if below > 0:
out.append(render_to_ansi(ListWindow.marker(below, "below"), width))
return out
def _feed_row(self, entry: PacketEntry, selected: bool, show_label: bool, width: int) -> Text:
def _feed_row(self, entry: PacketEntry, selected: bool, lanes: _Lanes, width: int) -> Text:
"""Lay one feed row out in fixed lanes: time, class, subject, reception, detail.
The class lane says what the packet *is*, straight from
:func:`~meshterm.ui.packet_viewer.class_marks` — so a raw frame reads
``📻 channel text``, the class the viewer's card headlines, rather than the
``📦 packet`` event family it merely arrived in. Its icon always shows; the
textual label beside it is dropped wholesale on a narrow terminal
(``show_label``), keeping the lanes aligned either way. The subject lane beside it
``📦 packet`` event family it merely arrived in. The name always shows; the icon
beside it only where the platform draws icons (:func:`_bind_class_icon`). The
subject lane beside it
then answers what the packet is *about*, in whatever terms its class deals in
(:meth:`_feed_subject`). No relay path rides here — the whole row fits a
72-column screen precisely because it doesn't try to, and the route is drawn
@@ -599,25 +670,33 @@ class LiveFeedScreen(Screen):
style="muted",
)
icon, class_label = class_marks(entry)
body.append(fit_cells(icon, _ICON_LANE))
if show_label:
body.append(
fit_cells(class_label, _FEED_CLASS_WIDTH),
style=KIND_STYLES.get(entry.kind, "brand"),
)
body.append(" " * _LANE_GAP) # the lane's gutter — the longest class fills it
if _ICON_LANE:
body.append(fit_cells(icon, _ICON_LANE))
body.append(
fit_cells(class_label, _FEED_CLASS_WIDTH),
style=KIND_STYLES.get(entry.kind, "brand"),
)
body.append(" " * _LANE_GAP) # the lane's gutter — the longest class fills it
subject = self._feed_subject(entry)
# The lane fits like every other: cells, not characters, ellipsis on overflow —
# but as a Text, so a subject built of several styled pieces keeps them.
subject.truncate(_FEED_SUBJECT_WIDTH, overflow="ellipsis", pad=True)
subject.truncate(lanes.subject, overflow="ellipsis", pad=True)
body.append_text(subject)
body.append(" " * _LANE_GAP)
# Beside the subject, ahead of the readings: where a flood was allowed to go is a
# fact about the packet, as its subject is, and the readings are about how it
# reached us. A plain flood — most rows — is a muted dash rather than the word, so
# the lane is quiet until a row is actually scoped.
scope = _feed_scope(self._entry_scope(entry))
scope.truncate(_FEED_SCOPE_WIDTH, overflow="ellipsis", pad=True)
body.append_text(scope)
body.append(" " * _LANE_GAP)
body.append(
f"{entry.snr:+{_READING_W}.1f} dB" if entry.snr is not None else " " * _SNR_LANE,
f"{entry.snr:+{_SNR_W}.1f} dB" if entry.snr is not None else " " * _SNR_LANE,
style=snr_style(entry.snr) if entry.snr is not None else "muted",
)
body.append(
f"{'':{_LANE_GAP}}{entry.rssi:{_READING_W}.0f} dBm"
f"{'':{_LANE_GAP}}{entry.rssi:{_RSSI_W}.0f} dBm"
if entry.rssi is not None
else " " * _RSSI_LANE,
style="muted",
@@ -759,7 +838,7 @@ class LiveFeedScreen(Screen):
if not src:
return self._token("to", dest)
named = PathLine([self._hop(src), self._hop(dest)], mode="plain").text()
if named.cell_len <= _FEED_SUBJECT_WIDTH:
if named.cell_len <= self._lanes.subject:
return named
# Too wide: the sender drops to its hash, so the addressee keeps its name whole (or
# as much of it as the lane holds) instead of being the half that gets amputated.
@@ -799,6 +878,20 @@ class LiveFeedScreen(Screen):
text.append(value, style="muted")
return text
def _entry_scope(self, entry: PacketEntry) -> Scope | None:
"""The scope of a row's frame, or ``None`` where it has none to state.
Only a raw ``packet`` frame carries the route type and transport codes a scope is
read from, and only a flood has one; the lane is left blank for everything else —
a direct frame is never region-filtered, so a word there would claim a meaning it
lacks. The lane is drawn ``bare``: its heading already says ``SCOPE``, so a known
region reads as its name alone, while ``unscoped`` and ``unknown scope 3fa1`` stay the
words they are everywhere (:func:`~meshterm.ui.widgets.scope_text`).
"""
if entry.kind != "packet" or not isinstance(entry.raw, dict):
return None
return self._scope_of(entry.raw)
def _feed_note(self, entry: PacketEntry) -> Text | None:
"""The row's trailing detail — a message's conversation, and nothing else.
@@ -850,6 +943,7 @@ async def open_livefeed(ctx: AppContext) -> None:
self_name: str | None = None
channels: list[tuple[str, bytes]] = []
channel_names: dict[int, str] = {}
slots_by_idx: dict[int, tuple[str, bytes]] = {}
try:
if ctx.is_connected or ctx.settings.connect_on_start:
# Through the session cache: contacts and the channel-slot probe are the two
@@ -860,6 +954,7 @@ async def open_livefeed(ctx: AppContext) -> None:
slots = await ctx.devstate.channel_slots()
channels = [(s.name, s.secret) for s in slots]
channel_names = {s.idx: s.name for s in slots}
slots_by_idx = {s.idx: (s.name, s.secret) for s in slots}
except Exception: # noqa: BLE001 - the feed renders fine without contact names
contacts = []
# Contacts first, every name the recorder ever overheard as the fallback — the
@@ -883,6 +978,8 @@ async def open_livefeed(ctx: AppContext) -> None:
channel_names=channel_names,
type_of=type_of,
key_of=key_of,
scope_of=ctx.region_store.scope_of,
message_scope=message_scope_reader(ctx, slots_by_idx),
)
unsubscribe = ctx.events.subscribe(screen.on_event)
@@ -905,3 +1002,60 @@ async def open_livefeed(ctx: AppContext) -> None:
pass
except Exception: # noqa: BLE001 - teardown must never surface a tick hiccup
pass
def message_scope_reader(
ctx: AppContext, slots_by_idx: Mapping[int, tuple[str, bytes]]
) -> MessageScopeOf:
"""A reader for a decoded channel message's scope, off its copies in the packet log.
The radio hands a received message over as text, not as the frame it arrived in, so a
``message`` row carries no transport code. Its flooded copies are in the packet log,
though, and the message paths dialog already finds them by decrypting and matching
the text (:func:`~meshterm.services.message_paths.channel_arrivals`); every copy
carries the sender's one code, so their scope is the message's
(:func:`~meshterm.services.message_paths.message_scope`).
The copies found are kept per entry — a card repaints every second and the lookup is a
windowed query plus a decrypt per frame — but only once some were found: a message
can be told to the feed a beat before its frame is written to the log, and a miss
cached then would be a miss for good. The region is resolved on every call, against
the store's memo, so a name learned while the card is open still names it.
A direct message has no reader: its copies are encrypted end to end and a routed one
has no scope anyway.
Args:
ctx: The application context (its repository and region store).
slots_by_idx: The device's channels by slot, as ``(name, secret)``.
Returns:
The reader, answering ``None`` for anything it can't place.
"""
from ..core.models import ChatMessage
from ..services.message_paths import channel_arrivals, message_scope
found: dict[int, list] = {}
def read(entry: PacketEntry) -> Scope | None:
if entry.kind != "message" or entry.channel is None:
return None
channel = slots_by_idx.get(entry.channel)
if channel is None or not entry.text:
return None
arrivals = found.get(id(entry))
if arrivals is None:
message = ChatMessage(
text=entry.text, outbound=False, is_channel=True, created_at=entry.when
)
try:
arrivals = channel_arrivals(
ctx.repo, message, channel_name=channel[0], secret=channel[1]
)
except Exception: # noqa: BLE001 - a card without a scope row, never a crash
return None
if arrivals:
found[id(entry)] = arrivals
return message_scope(arrivals, ctx.region_store.scope_of)
return read
+68 -15
View File
@@ -64,6 +64,10 @@ _SGR = re.compile(r"\x1b\[[0-9;]*m")
#: lands a bank too high — the theme's own styles state their intent for the same reason.
_DIM_FG = re.compile(r"3[0-7]")
#: The foreground a DOS console stands on after a reset: grey, slot 7 — which bold lifts
#: to white.
_DEFAULT_FG = 37
#: Parameters that settle the intensity question themselves, so a span carrying one needs
#: no help: a reset, bold, faint, or an explicit return to normal.
_SAYS_INTENSITY = frozenset({"", "0", "1", "2", "22"})
@@ -158,16 +162,29 @@ def _rewrap(line: str, width: int) -> list[str]:
def _state_intensity(row: str) -> str:
"""Rewrite the row's colour changes so every one says whether it is bright.
"""Rewrite the row's colour changes so brightness is stated as a colour, not as bold.
An art editor spells brightness the DOS way — ``1m`` lifts the bank and each span after
it inherits that — which reads correctly only while the escapes stay one unbroken
stream. They don't here: a row is handed to Rich on its own, and the console draws bold
*as* the bright bank, so a span that never mentions intensity is read against whatever
happened to be set. Saying it outright changes no colour; it just stops one drifting.
An art editor spells brightness the DOS way — ``1m`` lifts the foreground into the
bright bank, and each span after it inherits that. Two things then have to be settled
before the row can be drawn anywhere else.
The first is that the inheritance does not survive: a row is handed to Rich on its own,
so a span that never mentions intensity is read against whatever the *previous row* left
set. That is resolved by working the state out here and saying it outright.
The second is that **bold is not brightness everywhere**. It is on the PicoCalc console
and on a DOS one; it is not on macOS Terminal, where bold asks for a heavier face and
leaves the colour alone. There ``1;30`` is not dark grey but plain black — and this art
leans on it, seventy-nine spans of the wide mark being bold-black, much of that on a
black ground. The mark came out muted, and the ``░▒▓`` dithers that fade one colour into
another faded toward the wrong end. So brightness is emitted as the colour it means:
``9N`` (the aixterm bright bank), which needs no bold and is also how the PicoCalc's own
console spells bright. Nothing about *which* colour is being asked for changes; only the
spelling, from one a terminal may read as a font weight to one that can only be a colour.
"""
out: list[str] = []
bright = False
standing: str | None = None # the dim-bank foreground currently in force
at = 0
while at < len(row):
found = _SGR.match(row, at)
@@ -177,16 +194,52 @@ def _state_intensity(row: str) -> str:
continue
params = found.group()[2:-1]
parts = params.split(";") if params else [""]
for part in parts: # what this sequence leaves the intensity set to
if part in ("", "0", "22"):
bright = False
elif part == "1":
bright = True
dim = any(_DIM_FG.fullmatch(p) for p in parts)
if dim and not any(p in _SAYS_INTENSITY for p in parts):
out.append("\x1b[" + ";".join(["1" if bright else "22"] + parts) + "m")
# What this sequence leaves the intensity set to, for the spans that follow it.
says = [p for p in parts if p in ("", "0", "1", "22")]
for part in says:
bright = part == "1"
if part in ("", "0"):
standing = None # a reset takes the foreground with it
# Whether *this* sequence's own foreground is bright: what it says, else what stands.
here = bright
fg = next((p for p in parts if _DIM_FG.fullmatch(p)), None)
if fg is None:
# A bare "1" has nothing left to say once brightness travels as a colour, and
# dropping it keeps bold off glyphs a terminal may redraw at another weight.
kept = [p for p in parts if p != "1"]
# But the brightness it was announcing applies to the colour already in force,
# not only to the next one named. `ESC[1m` is how the art says "everything after
# this is bright", and with the "1" gone that run would carry on in the dim bank
# -- two spans meant to read dark then light both coming out dark. So the
# standing foreground is restated in whichever bank now applies.
if standing is not None and says:
if here:
kept.append(str(int(standing) + 60))
else:
kept.append(standing)
if "22" not in kept:
kept.insert(0, "22")
elif says and here:
# No colour named since a reset: the one in force is the console's default,
# grey (7), and brightening it gives white. ``ESC[0m`` then ``ESC[1m`` is how
# the art says "white", and dropping the "1" without restating anything left
# those spans in the terminal's own default grey — a white run drawn grey.
kept.append(str(_DEFAULT_FG + 60))
if kept:
out.append("\x1b[" + ";".join(kept) + "m")
else:
out.append(found.group())
standing = fg
rewritten = []
for part in parts:
if part == "1":
continue # brightness is in the colour now
if part == fg:
rewritten.append(str(int(fg) + 60) if here else fg)
else:
rewritten.append(part)
if not here and "22" not in rewritten:
rewritten.insert(0, "22") # say dim outright; never inherit a bank
out.append("\x1b[" + ";".join(rewritten) + "m")
at = found.end()
return "".join(out)
+154 -55
View File
@@ -39,7 +39,6 @@ from .menus import (
from .surface import TuiUi
from .theme import make_console
from .tui import (
CANCEL,
Choice,
PopToMenu,
ReconnectDialog,
@@ -49,6 +48,7 @@ from .tui import (
TuiSession,
)
from .tui.emoji_width import install as install_emoji_widths
from .tui.fkeys import FPair
from .tui.spinner import spinner_interval
from .widgets import battery_cell
@@ -79,6 +79,12 @@ _BLE_PRESENCE_SCAN_S = 8.0
#: stops the loop spinning when scanning fails instantly (no adapter, Bluetooth switched off).
_BLE_RESCAN_PAUSE_S = 1.0
#: After a reboot we sent, how long to leave the companion alone before the first reconnect
#: attempt (seconds). The dialog is up the instant the command is out, which is before the
#: board has necessarily started restarting; a port that stays put through the reboot would
#: otherwise be reopened against the old session, which dies under it a moment later.
_REBOOT_SETTLE_S = 1.5
def _arm_exit_watchdog(seconds: float = _EXIT_WATCHDOG_S) -> None:
"""Guarantee the process terminates even if the exit path wedges.
@@ -363,6 +369,8 @@ def _device_label(ctx: AppContext, cache: dict) -> tuple[str, str]:
where = "BLE"
elif ctx.active_transport == "tcp":
where = ctx.active_endpoint or "TCP"
elif ctx.active_transport == "spi":
where = "SPI"
else:
where = ctx.active_port or (sel.port if sel is not None else "") or ""
cache["key"], cache["value"] = key, (name, where)
@@ -390,6 +398,8 @@ async def run_menu(ctx: AppContext) -> None:
header_cache: dict = {}
session = TuiSession(header=lambda cols: _header(ctx, header_cache, cols))
ctx.ui = TuiUi(session)
# The one quit confirm, asked by the menu's Quit row and by the quit chord from anywhere.
session.set_quit_confirm(lambda: _confirm_quit(ctx, session))
async def main() -> None:
try:
@@ -474,11 +484,95 @@ async def _unpair_on_exit(ctx: AppContext) -> None:
#: can show, the same as any tool's.
_QUIT_ICON = "🚪"
#: The value the Quit row resolves with — Esc's ``CANCEL`` and this both open the quit
#: confirm (see :func:`_menu_round`).
#: The value the Quit row resolves with, which opens the quit confirm (see
#: :func:`_menu_round`). Esc at the menu does not: it is inert there (see :class:`_MainMenu`).
_QUIT_VALUE = "__quit__"
async def _confirm_quit(ctx: AppContext, session: TuiSession) -> bool:
"""Float the quit confirm over whatever is up; return whether to leave.
One dialog for both doors — the menu's Quit row and the quit chord, which asks from
anywhere (see :meth:`~meshterm.ui.tui.session.TuiSession.request_quit`). Cancel (Esc)
sits left of Quit (Enter), and Quit starts highlighted so Enter commits it.
It offers to drop the OS pairing on the way out, but only when there is a live Bluetooth
bond to drop — never on serial, an open (PIN-less) companion, or a platform we can't
unpair. That button sits between Cancel and Quit and is never the default, so it takes a
deliberate choice, not a stray Enter. The unpair itself is deferred to teardown (see
:func:`run_menu`), because the link has to be disconnected first; the remembered-device
record is intentionally kept, so the device just asks for its PIN again.
Args:
ctx: The shared application context (``unpair_on_exit`` is set on *Unpair & quit*).
session: The running TUI session.
Returns:
``True`` if the reader chose Quit or Unpair & quit.
"""
buttons = [("Cancel", "cancel")]
if await _can_unpair(ctx):
buttons.append(("Unpair & quit", "unpair"))
buttons.append(("Quit", "quit"))
choice = await session.button_dialog(
"Are you sure you want to quit?",
buttons,
title="Quit MeshTerm",
default=len(buttons) - 1, # highlight Quit
# The hint stays the dialog default: Enter commits *the highlighted button*,
# which is Quit only until ←→ moves it — and with a third button in the row,
# naming the key that moves it is the point.
prompt_style="warn",
button_style="selected",
button_idle_style="muted",
border_style="warn",
)
if choice == "unpair":
ctx.unpair_on_exit = True
return choice in ("unpair", "quit")
class _MainMenu(SelectScreen):
"""The main menu's list, on which Esc peels a typed filter and otherwise does nothing.
Pressing Esc over and over is the natural climb back to the menu, and when Esc at the
top opened the quit confirm, the presses that landed after the climb opened it too — the
reader asked for the menu and got a question about leaving (issue #22). The menu is the
bottom of the stack, so there is nothing for Esc to go back to; leaving is a choice made
on the Quit row, or with the quit chord, both of which ask first.
The footer therefore ends on the quit chord rather than on Esc, until a filter is
standing and ``Esc clear`` joins it last, because that is what the press does then —
the same atom every filtering list shows.
"""
def handle(self, action: str, data: str = "") -> None:
"""Answer every key as a select list does, except a bare Esc, which is inert."""
if action == "escape" and not self._filter:
return
super().handle(action, data)
@property
def fkey_lane(self):
"""The list's own lane, with the way out on F3: ``Quit?`` asks, ``Quit!`` doesn't.
The PicoCalc has no hint line, so the lane is where the menu's way out has to be
stated. F3 is the list's one free slot (F1/F2 are the section jumps; a menu row is
never deletable). ``Quit?`` opens the same confirm as the Quit row and ^Q; its Shift
half, F8, leaves without asking — a two-key reach on the menu alone, for the reader
who already decided (JP, 2026-09-29).
"""
lane = list(super().fkey_lane)
lane[2] = FPair("Quit?", "quit", "Quit!", "quit_now")
return lane
@property
def footer_hint(self) -> str:
"""The list's own hint, gaining ``Esc clear`` only while a filter is standing."""
base = super().footer_hint
return f"{base} · Esc clear" if self._filter else base
def _menu_lane(tools: Sequence[Any]) -> int:
"""The main menu's icon column in cells: every tool's mark *and* the Quit row's.
@@ -606,18 +700,20 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None:
# Drive the menu list ourselves (rather than via session.select) so it stays on the
# stack while the quit dialog floats over it: the confirm is drawn as a centered box
# on top of the still-visible menu, not as a screen that replaces it.
menu = SelectScreen(
menu = _MainMenu(
"What would you like to do?",
items,
footer_hint="↑↓ move · type to filter · Enter select · Esc quit",
# The one place the quit chord is named: the menu is where a reader looks for the
# way out, and with Esc inert here the footer would otherwise name none. Everywhere
# else it stays unadvertised, like ^W (see session._CTRL_LETTER_CHORDS).
footer_hint="↑↓ move · type to filter · Enter select · ^Q quit?",
)
session.set_root(menu)
loop = asyncio.get_running_loop()
while True:
try:
await _menu_round(ctx, session, menu, tools, loop)
except _Quit as chosen:
ctx.unpair_on_exit = chosen.unpair
except _Quit:
return
except PopToMenu:
# ^W from somewhere deep. Every frame between there and here has already
@@ -627,18 +723,14 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None:
class _Quit(Exception):
"""The user confirmed the quit dialog; ``unpair`` says whether to drop the OS bond too.
"""The user confirmed the quit dialog from the menu's Quit row.
A menu round runs a whole tool inside itself, so "the user chose Quit" cannot be spelled
as a ``return`` — the loop would just show the menu again. It is raised instead, and
caught in the one frame that owns the decision to leave.
caught in the one frame that owns the decision to leave. Whether to drop the OS bond too
is already recorded on the context by the confirm (see :func:`_confirm_quit`).
"""
def __init__(self, *, unpair: bool) -> None:
"""Record whether the OS pairing should be dropped on the way out."""
super().__init__("quit")
self.unpair = unpair
async def _menu_round(
ctx: AppContext,
@@ -669,41 +761,11 @@ async def _menu_round(
ran_over_menu = False
try:
selection = await menu.future
if selection is CANCEL: # Esc at the top level
selection = None
# Both picking "quit" and pressing Esc ask to leave; confirm on a dialog floating
# over the (still-pushed) menu so a stray key doesn't drop the user out. Cancel
# (Esc) sits left of Quit (Enter); Quit starts highlighted so Enter commits it.
if selection in (None, _QUIT_VALUE):
# Offer to drop the OS pairing on the way out, but only when there is a live
# Bluetooth bond to drop — never on serial, an open (PIN-less) companion, or a
# platform we can't unpair. The extra button sits between Cancel and Quit and is
# never the default, so it takes a deliberate choice, not a stray Enter.
unpairable = await _can_unpair(ctx)
buttons = [("Cancel", "cancel")]
if unpairable:
buttons.append(("Unpair & quit", "unpair"))
buttons.append(("Quit", "quit"))
choice = await session.button_dialog(
"Are you sure you want to quit?",
buttons,
title="Quit MeshTerm",
default=len(buttons) - 1, # highlight Quit
# The hint stays the dialog default: Enter commits *the highlighted
# button*, which is Quit only until ←→ moves it — and with a third
# button in the row, naming the key that moves it is the point.
prompt_style="warn",
button_style="selected",
button_idle_style="muted",
border_style="warn",
)
# "Unpair & quit" forgets the OS bond as we leave; the disconnect must happen
# first, so the actual unpair is deferred to teardown (see run_menu). The
# remembered-device record is intentionally kept — the device just asks for its
# PIN again. Cancel (button or Esc → None) ends the round and redraws the menu,
# whose cursor never moved off the row they came from.
if choice in ("unpair", "quit"):
raise _Quit(unpair=choice == "unpair")
# The Quit row asks first, on a dialog floating over the (still-pushed) menu; Cancel
# ends the round and redraws the menu, whose cursor never moved off the row.
if selection == _QUIT_VALUE:
if await session.confirm_quit():
raise _Quit()
return
# A popup tool runs while the menu is still pushed, so its prompts and
# result float over it as modal dialogs instead of replacing the screen.
@@ -730,6 +792,7 @@ async def _startup(ctx: AppContext) -> bool:
if not (ctx.mock or ctx.explicit_selection):
from ..core.connection import probe_device
from ..core.discovery import DiscoveredDevice, discover_all
from ..core.spiradio import state_dir
from .device_picker import prompt_device
from .logo import load_logo
@@ -750,7 +813,12 @@ async def _startup(ctx: AppContext) -> bool:
# ``pin`` is what the picker's PIN dialog collected on a retry; fall back to any
# ``--ble-pin`` supplied on the CLI for the first attempt.
used = pin if pin is not None else ctx.ble_pin
result = await probe_device(device, baudrate=baudrate, pin=used)
spi_state = (
state_dir(ctx.settings.config_dir, device.spi or ctx.spi_wiring_for(device.port))
if device.is_spi
else None
)
result = await probe_device(device, baudrate=baudrate, pin=used, spi_state=spi_state)
if result is None:
return None
connection, info = result
@@ -770,7 +838,14 @@ async def _startup(ctx: AppContext) -> bool:
if chosen is None:
return False # the user quit at the splash — exit without opening the menu
ctx.selected_device = chosen
if chosen.is_tcp:
if chosen.is_spi:
# A reconnect rebuilds the device from the context, so it has to know which radio.
# An SPI profile names its own; a bare row is the AIO's, which ``--spi`` resolves.
ctx.spi_override = True
ctx.tcp_override = None
ctx.ble_override = None
ctx.port_override = None
elif chosen.is_tcp:
ctx.tcp_override = chosen.target
ctx.ble_override = None
ctx.port_override = None
@@ -1029,6 +1104,20 @@ async def _wait_for_disconnect(ctx: AppContext) -> None:
if ctx.mock:
await asyncio.Event().wait() # the simulator is never "unplugged"; wait forever
return
# A reboot we sent is a drop we already know about: race it against the poll so the
# reconnect dialog is up the moment the command is out, not whenever (or if ever) a
# poll happens to land inside the reboot.
announced = asyncio.ensure_future(ctx.link_going_down())
polled = asyncio.ensure_future(_poll_for_disconnect(ctx))
try:
await asyncio.wait({announced, polled}, return_when=asyncio.FIRST_COMPLETED)
finally:
await _cancel_and_wait(announced)
await _cancel_and_wait(polled)
async def _poll_for_disconnect(ctx: AppContext) -> None:
"""Resolve once the liveness check sees the link gone, debounced against a blip."""
while True:
await asyncio.sleep(_LIVENESS_POLL_S)
if not ctx.is_connected:
@@ -1061,8 +1150,8 @@ async def _handle_disconnect(ctx: AppContext, session: TuiSession) -> bool:
# A drop the config editor announced (it just sent a reboot command) is expected, so
# the dialog says what is actually happening instead of implying an unplug. The flag
# is consumed here so a later, genuine disconnect goes back to the generic wording.
if ctx.reboot_in_progress:
ctx.reboot_in_progress = False
rebooting = ctx.take_reboot()
if rebooting:
dialog = ReconnectDialog(
"Rebooting — waiting for the device to come back…",
title="Device rebooting",
@@ -1081,7 +1170,9 @@ async def _handle_disconnect(ctx: AppContext, session: TuiSession) -> bool:
session.push(base)
session.push(dialog)
animator = asyncio.ensure_future(_animate_dialog(session, dialog))
reconnector = asyncio.ensure_future(_auto_reconnect(ctx, dialog))
reconnector = asyncio.ensure_future(
_auto_reconnect(ctx, dialog, settle_s=_REBOOT_SETTLE_S if rebooting else 0.0)
)
try:
result = await dialog.future
finally:
@@ -1100,7 +1191,9 @@ async def _animate_dialog(session: TuiSession, dialog: ReconnectDialog) -> None:
session.invalidate()
async def _auto_reconnect(ctx: AppContext, dialog: ReconnectDialog) -> None:
async def _auto_reconnect(
ctx: AppContext, dialog: ReconnectDialog, *, settle_s: float = 0.0
) -> None:
"""Poll for the device to return, reconnect when it does, then dismiss ``dialog``.
The dead link is released up front (:meth:`~meshterm.context.AppContext.release_link`) so
@@ -1126,6 +1219,10 @@ async def _auto_reconnect(ctx: AppContext, dialog: ReconnectDialog) -> None:
Args:
ctx: The shared application context.
dialog: The reconnect dialog to dismiss once the link is back.
settle_s: How long to leave the device alone after releasing the link, before the
first attempt. Nonzero only for a reboot we sent: the watcher fires the instant
the command is out, and a serial port that never went away would otherwise be
reopened against a board that has not yet begun to restart.
"""
from ..core.connection import serial_port_present
from ..core.discovery import find_ble_device
@@ -1134,6 +1231,8 @@ async def _auto_reconnect(ctx: AppContext, dialog: ReconnectDialog) -> None:
# still nominally connected to does not advertise, so the scan below would wait out a
# peripheral that is powered on and silent purely because we never let go of it.
await ctx.release_link()
if settle_s:
await asyncio.sleep(settle_s)
while True:
ble_device: object | None = None
+46 -4
View File
@@ -35,12 +35,19 @@ detail read together:
under a cracked chip at whichever edge continues, and snaps back the moment the
selection moves on. A row you are *not* on is cut the same way — it just cannot slide.
The message's **scope** — the region it was flooded into — rides the title as a status
atom (``Message paths · scope harbour``), stated once for the message rather than per
arrival: every copy carries its sender's transport code unchanged (see
:func:`~meshterm.services.message_paths.message_scope`).
Nothing here transmits; like the service beneath it, this is a read-model over what
the radio already heard.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from rich.text import Text
from ..core.models import ChatMessage
@@ -58,7 +65,7 @@ from .pathline import (
path_line,
)
from .theme import snr_style
from .tui.render import crop_cells, render_to_ansi
from .tui.render import crop_cells, render_lines, render_to_ansi
from .tui.screen import ListWindow, Screen
from .widgets import (
NameKeyResolver,
@@ -67,8 +74,12 @@ from .widgets import (
node_type_legend,
revisit_note,
route_graph_style,
scope_text,
)
if TYPE_CHECKING:
from ..core.regions import Scope
#: Cells one ←/→ press shifts the selected row by.
_HSTEP = 4
@@ -108,6 +119,8 @@ class MessagePathsScreen(Screen):
destination: str | None = None,
type_of: TypeOf | None = None,
key_of: NameKeyResolver | None = None,
scope: Scope | None = None,
sent_scope: Text | None = None,
) -> None:
"""Build the dialog over one message's matched arrivals.
@@ -135,9 +148,15 @@ class MessagePathsScreen(Screen):
key_of: Maps the origin's display name back to its node's key, so the
graph's left endpoint takes its key-derived hue; ``None`` (or an
unresolvable name) leaves it muted.
scope: The region the message was flooded into, read off its arrivals'
frames (:func:`~meshterm.services.message_paths.message_scope`); ``None``
where no copy was a flood, and the title then states nothing.
sent_scope: For a message we sent, the line saying what it was flooded under
(``sent under scope yul``, and why nothing relayed it where that is
known), drawn under the summary; ``None`` draws nothing.
"""
super().__init__()
self.title = "Message paths"
self.title = self.titled(scope)
self._message = message
self._arrivals = arrivals
self._matched = matched
@@ -149,6 +168,7 @@ class MessagePathsScreen(Screen):
self._destination = destination
self._type_of = type_of
self._key_of = key_of
self._sent_scope = sent_scope
self._index = 0
#: Cells the selected row is shifted left by (reset whenever ↑↓ move), and
#: how far it *can* shift, measured against the width of the last render.
@@ -161,6 +181,23 @@ class MessagePathsScreen(Screen):
self._list = ListWindow()
self._list_hidden = False
@staticmethod
def titled(scope: Scope | None) -> str:
"""The dialog's title: its name, then the message's scope as a status atom.
Every copy of one message carries its sender's transport code (the code is over the
payload, which no relay touches), so the scope is a fact about the *message*, not
about any one arrival — stated once, up here, rather than repeated down a list whose
rows differ only by path. It rides the title because the title is where a screen's
status atoms chain (``Message paths · scope harbour``) and it costs the body no
line: on the PicoCalc's 26 rows a line spent here is a lane of the fan or a row of
the list. The words are :func:`~meshterm.ui.widgets.scope_text`'s, plain — a title
carries no styling — so ``unscoped`` and ``unknown scope 3fa1`` read here exactly as they
do on the packet viewer's ``route`` row.
"""
atom = scope_text(scope).plain
return f"Message paths · {atom}" if atom else "Message paths"
# --- input ---------------------------------------------------------------
@property
@@ -255,6 +292,9 @@ class MessagePathsScreen(Screen):
render_to_ansi(Text(f"“{quoted}”"), width, no_wrap=True),
render_to_ansi(stamp, width, no_wrap=True),
]
if self._sent_scope is not None:
# Wrapped rather than cut: its tail is the reason a message went nowhere.
lines.extend(render_lines(self._sent_scope, width))
self._cursor = None
self._list_hidden = False
if not self._arrivals:
@@ -265,7 +305,9 @@ class MessagePathsScreen(Screen):
if self._matched
else "No direct-message frames logged in the window."
)
lines.append(render_to_ansi(Text(note, style="muted"), width))
# One entry per drawn row: a wrapped render_to_ansi is one string holding a
# newline, which the frame counts as a single line and measures as one.
lines.extend(render_lines(Text(note, style="muted"), width))
self._scroll_total = len(lines)
return lines
@@ -293,7 +335,7 @@ class MessagePathsScreen(Screen):
style="faint",
)
lines.append(render_to_ansi(caption, width, no_wrap=True))
lines.append(render_to_ansi(node_type_legend(), width, no_wrap=True))
lines.extend(render_lines(node_type_legend(width=width), width, no_wrap=True))
lines.extend(revisit)
lines.append("")
+131 -7
View File
@@ -15,7 +15,9 @@ the node itself, in full:
* **Info** — the node's vitals as labelled rows (its key with the routing hash lit, when
it was first and last heard, how many packets we've overheard, its reception SNR and
last RSSI, and where it sits). The key is a single lane, never wrapped: a full public
last RSSI, where it sits, and — for a repeater — the regions it was last heard to relay
floods for, whether it relays unscoped floods too, and when it said so; see
:func:`regions_value`). The key is a single lane, never wrapped: a full public
key outruns the row on any terminal we target, so ``←→`` scroll it a whole four bytes
at a time under faint ``…`` edge marks, exactly as a long pathline scrolls on the
Routes tab. Then — when the node has advertised a location — a
@@ -24,7 +26,10 @@ the node itself, in full:
map opens centred here with its find filter seeded to this node, so it lights among
the rest), ``Time machine``, ``Share contact`` — a popup contact card (QR code +
``meshcore://`` link, see :func:`~meshterm.ui.config_editor.show_contact_card`),
offered whenever the node's full key is known — and, last, ``Remove contact``: the
offered whenever the node's full key is known — ``Ask which regions it carries`` on a
repeater (one anonymous request, answered only when it arrives direct; the answer is
learned into the region store and the row redraws in place) — and, last,
``Remove contact``: the
single-contact counterpart to the Contacts list's bulk archive (see
:mod:`~meshterm.ui.contacts_screen`), dropping *this* node from the device's contact
table behind a red confirm. It is the one thing on the page that changes anything, so
@@ -68,7 +73,8 @@ The screen is a pure read-and-route view: it renders already-resolved display da
resolves an action token (the Trace token carrying the selected route's spec via
:meth:`NodeDetailScreen.selected_spec`); :func:`open_node_detail` owns the data-gathering
and runs the sub-flows each action opens, then re-shows the page — the same loop the Time
Machine and Contacts list use. Nothing here transmits.
Machine and Contacts list use. The screen itself never transmits; the one action that does
is the regions question, asked once, by the opener, when the reader commits its row.
"""
from __future__ import annotations
@@ -85,9 +91,9 @@ from typing import TYPE_CHECKING
from rich.cells import cell_len
from rich.text import Text
from ..core.connection import ContactNotOnDeviceError
from ..core.connection import ContactNotOnDeviceError, DeviceCommandError
from ..core.geo import haversine_km, usable_fix
from ..core.models import NODE_TYPE_LABELS, Contact, utcnow
from ..core.models import NODE_TYPE_LABELS, NODE_TYPE_REPEATER, Contact, utcnow
from ..platforms import Platform, on_platform
from . import menus
from .mapcanvas import RGB
@@ -576,6 +582,18 @@ class NodeDetailScreen(Screen):
trace = [self._trace_action] if self._trace_action else []
return menus.icon_lane(action.glyph for action in (*self._info_actions, *trace))
def replace_info_row(self, label: str, value: Text) -> None:
"""Swap one vital's value in place — the regions row, once the repeater has answered.
The row keeps its place in the block (a label not already there is appended), and
the cursor stays on whatever it was on: the page is the same visit, one fact newer.
"""
for i, (existing, _value) in enumerate(self._info_rows):
if existing == label:
self._info_rows[i] = (label, value)
return
self._info_rows.append((label, value))
def replace_info_actions(self, actions: list[_Action]) -> None:
"""Swap the Info tab's action rows in place, keeping the cursor on the row it was on.
@@ -950,7 +968,7 @@ class NodeDetailScreen(Screen):
while lines and not _SGR.sub("", lines[0]).strip():
lines.pop(0)
if rv.legend:
lines.extend(render_lines(node_type_legend(), width, no_wrap=True))
lines.extend(render_lines(node_type_legend(width=width), width, no_wrap=True))
self._stage_memo = (stage_key, lines)
return lines
@@ -1279,6 +1297,20 @@ async def open_node_detail(
info_rows.append(("signal", signal))
if lat is not None and lon is not None:
info_rows.append(("where", _range_text(lat, lon, self_lat, self_lon)))
# A repeater's regions: what it was last heard to relay, from the region store (its own
# answer to the regions request, or its table read on the admin page). Only a repeater
# answers the request, so only a repeater's page asks it.
region_store = getattr(ctx, "region_store", None)
asks_regions = (
not you
and contact is not None
and node_type == NODE_TYPE_REPEATER
and bool(node_id)
and region_store is not None
)
routed = contact is not None and contact.route_hops is not None
if asks_regions:
info_rows.append(("regions", regions_value(region_store, node_id, routed=routed)))
# -- the location preview (only when the node advertised a fix).
minimap: MiniMap | None = None
@@ -1347,6 +1379,9 @@ async def open_node_detail(
full_key = key.lower().removeprefix("0x")
if len(full_key) == 64 and is_path_hash(full_key):
info_actions.append(_Action("share", "📱", "", "Share contact — QR / link"))
if asks_regions:
# Acts at once — one anonymous request, one transmission — so no trailing "…".
info_actions.append(_Action("regions", "🔖", "", "Ask which regions it carries"))
# -- contact management, the page's last group and the only actions that end the visit.
# Withheld entirely when the caller opened the page to look rather than to act (see
# ``manage``), and never offered for our own node or for a contact carrying no key at
@@ -1450,6 +1485,13 @@ async def open_node_detail(
)
elif action == "share":
await show_contact_card(ctx, label, full_key, adv_type)
elif action == "regions":
assert contact is not None and region_store is not None # only offered so
failed = await _ask_regions(ctx, contact, node_id, label, routed=routed)
screen.replace_info_row(
"regions",
regions_value(region_store, node_id, routed=routed, failed=failed),
)
elif action == "timemachine":
if you:
await open_timemachine_self(ctx)
@@ -1556,7 +1598,7 @@ async def _archive_contact(ctx: AppContext, contact: Contact, self_key: str, lab
)
prompt.append(
"? It comes off this device's contact list, freeing a slot for a new one. MeshTerm "
"keeps it — with its key, reception history and messages — and you can restore it "
"keeps it — with its key, reception history, and messages — and you can restore it "
"at any time.",
style="warn",
)
@@ -1727,6 +1769,88 @@ async def _remove_contact(ctx: AppContext, contact: Contact, self_key: str, labe
return True
#: Why a repeater may not answer the regions request, in the words the page uses for it.
_REGIONS_REACH = "it answers only a neighbour, or over a known route"
def regions_value(store, node_id: str, *, routed: bool, failed: bool = False) -> Text: # noqa: ANN001 - RegionStore
"""A repeater's ``regions`` vital: what it relays, whether unscoped too, and how fresh.
Four states, each in its own words so none reads as another:
* **answered** — the regions it named, then whether it also relays *unscoped* floods
(the ``*`` its answer leads with, which names no region), then when it said so. A
repeater that relays unscoped floods and no region reads ``unscoped floods only``, and
one that named nothing at all reads that it relays no floods;
* **not asked** — nothing on record; a repeater with no known route also says why the
question may go unanswered (the request is only answered when it arrives direct);
* **no answer** — the question was just asked and nothing came back, on a repeater that
never answered before. One that answered earlier keeps its answer, marked stale by its
age: an unanswered repeat says nothing about what it carries.
Args:
store: The region store.
node_id: The repeater's 12-hex id.
routed: Whether the device holds a route to it (a neighbour or a learned path).
failed: Whether the ask this visit went unanswered.
Returns:
The row's value.
"""
answer = store.answer_of(node_id)
if answer is None:
if failed:
text = Text("no answer", style="warn")
text.append(f" — {_REGIONS_REACH}", style="muted")
return text
text = Text("not asked yet", style="muted")
if not routed:
text.append(f" — {_REGIONS_REACH}", style="muted")
return text
names = store.carried_by(node_id)
text = Text()
if names:
text.append(", ".join(names))
text.append(" · " + ("unscoped too" if answer.unscoped else "scoped only"), style="muted")
elif answer.unscoped:
text.append("unscoped floods only")
else:
text.append("none — it relays no floods", style="warn")
text.append(f" · answered {format_ago(age_seconds(answer.answered_at))}", style="muted")
if failed:
text.append(" · no answer now", style="warn")
return text
async def _ask_regions(
ctx: AppContext, contact: Contact, node_id: str, label: str, *, routed: bool
) -> bool:
"""Ask a repeater which regions it carries — once — and learn the answer.
One anonymous request, one transmission, under the busy overlay; the answer replaces
what the store held for this repeater (:meth:`~meshterm.core.region_store.RegionStore.
learn_carried`). An unanswered request is said in a popup with the reason it most
likely went unanswered — the request is dropped unless it arrives direct, and the
repeater rate-limits it — and nothing retries it.
Returns:
``True`` when the repeater did not answer.
"""
device = await ctx.device()
try:
async with ctx.ui.busy_overlay():
names = await device.request_regions(contact)
except DeviceCommandError as exc:
where = "" if routed else " There is no known route to it, so only a neighbour answers."
await ctx.ui.session.message_dialog(
Text(f"{exc}{where}", style="warn"),
title=f"Regions — {label}",
)
return True
ctx.region_store.learn_carried(node_id, names)
return False
def _signal_row(hn) -> Text | None: # noqa: ANN001 - Optional[HeardNode]
"""Median/best reception SNR and last RSSI, or ``None`` when nothing was measured."""
if hn is None:
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more