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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+4
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
+44
View File
@@ -118,6 +118,50 @@ jobs:
"$GITHUB_WORKSPACE/$bin" contacts --help > /dev/null
echo "runs, finds its assets, and its tools registered"
# The Windows binary is code-signed, so Windows has a publisher to recognise
# instead of an unknown file it has never seen before — SmartScreen's warning eases
# as that publisher's downloads accumulate, and antivirus engines, which flag
# unsigned PyInstaller one-file builds on sight, are markedly less trigger-happy.
# The certificate belongs to SignPath Foundation, who issue it free to open-source
# projects and sign on their own infrastructure; nothing secret exists on this
# runner, and there is no key here to leak. Its condition is that the artifact
# provably came from this repository's CI, which is why the binary is handed over
# as a GitHub artifact rather than uploaded: SignPath reads the workflow run behind
# that artifact id and refuses anything it cannot trace to an approved branch.
#
# Signed between the build's own smoke test and the naming step, so what gets
# verified is the build and what gets checksummed is the signed file — a checksum
# taken before signing would describe a binary nobody ever downloads.
#
# Both legs are skipped wholesale until the account exists (the variable is unset
# until then), so this file builds unsigned Windows binaries in the meantime rather
# than failing, and a fork with no SignPath project of its own is unaffected.
- name: Hand the Windows binary to SignPath
id: unsigned
if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != ''
uses: actions/upload-artifact@v4
with:
# Deliberately not `meshterm-*`: the release workflow attaches every artifact
# matching that prefix, and this one is the unsigned draft.
name: unsigned-${{ matrix.label }}
path: packaging/dist/meshterm.exe
if-no-files-found: error
- name: Sign it
if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != ''
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
project-slug: meshterm
signing-policy-slug: release-signing
artifact-configuration-slug: exe
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
# The signed file comes back where the unsigned one was built, under the same
# name, so every step after this one is untouched by any of it.
wait-for-completion: true
output-artifact-directory: packaging/dist
- name: Name it after the version and the platform
shell: bash
run: |
+72 -1
View File
@@ -17,6 +17,76 @@ allowed to change behaviour, not just add to it.
## [Unreleased]
## [0.3.7] — 2026-09-19
### Added
- **A setting you use on a node's command line becomes a row on that node's page.**
Third-party builds carry knobs the catalog has never heard of, and MeshTerm cannot know
which firmware a repeater runs without asking — and every question is a paced round trip,
so probing for a fork's keys would spend a sweep on every node to benefit a few. Nothing
is probed speculatively and the catalog does not grow. Instead a `get` or `set` you ran,
which that node answered, earns a row under **Extra** on that node's page: the round trip
was one you were spending anyway, and no other node's page changes. Only the reader
removes a discovered row — a later read that comes back empty takes it to `n/a` rather
than deleting the evidence that it exists.
- **The Windows binary is signed** by SignPath Foundation, who issue certificates free to
open-source projects, so it no longer arrives as an unknown publisher.
### Fixed
- **MeshTerm finds your companion on a Mac.** Every Mac permanently presents two virtual
serial ports — `/dev/cu.Bluetooth-Incoming-Port` and `/dev/cu.debug-console` — that carry
no USB vendor id, and they were counted as candidates. A Mac with exactly one real board
attached therefore saw three devices and refused to choose, so automatic detection could
never fire there and every Mac user met *"Multiple companion devices detected"* on a first
run, naming two ports that are not companions as the ones to pick between. Which device is
plausible was already known, and already printed in that listing; it now decides with it. A
lone unrecognised adapter still connects, and several implausible ports no longer claim to
be companions.
- **A PIN-protected Bluetooth companion connects on the first attempt on macOS**, instead of
reporting an error and working when you selected it again. CoreBluetooth has no pairing
API: a peripheral pairs *implicitly* when something touches a characteristic that requires
encryption, which is exactly what the companion's firmware demands — so the refusal
MeshTerm was treating as a verdict is in fact macOS *starting* to pair, and its Passkey
dialog goes up as the attempt unwinds. The connection now waits for that dialog to be
answered, bounded so a dismissed one still fails. Elsewhere a rejection keeps meaning what
it always did.
- **The map draws its ground again on the macOS and Linux downloads.** A standalone build can
have no certificate store to consult — the default one names a path baked into the
interpreter's own build, which need not exist on a machine that never installed that
Python — so every tile fetch failed TLS verification. Because a refused fetch is
indistinguishable from empty terrain, the map drew nodes over blank ground and said nothing
about why; Windows hid it for so long because it reads the operating system's certificate
store. The fetch now falls back to the CA bundle the OS ships. Verification is never
weakened: with no bundle to be found it still verifies, and still fails.
- **The splash wordmark draws in its own colours on macOS.** The art spells brightness the
way DOS did, with bold lifting a colour into the bright bank — true on a DOS console and on
the PicoCalc, false on macOS Terminal, where bold asks for a heavier typeface and leaves
the colour alone. The mark came out muted and its dithered gradients faded toward the wrong
end. Brightness is now emitted as the colour it means, which cannot be mistaken for a font
weight. Nothing about the art changed, and no cell moved.
- **The device list shows the device.** The startup splash sized its PORT / ADDRESS column to
the longest target and gave the name whatever was left, which held while a Bluetooth
address was a 17-character MAC. macOS reports no MAC at all but a 36-character per-machine
UUID, and that squeezed the DEVICE column down to the width of its own heading. The name is
now sized first and shown in full; the address takes what is left and ellipsizes from the
front, keeping the end that tells two targets apart.
### Changed
- **The macOS download note leads with `curl`.** The flag that makes Gatekeeper refuse an
unsigned binary is set by the *browser* that downloaded it, not by macOS, so fetching it
with `curl` sidesteps the dialog entirely — and for a program whose users are already at a
terminal, that is the normal way to install it. The old advice was also going stale:
Sequoia removed the right-click → Open bypass, and since Catalina a quarantined binary
started from Terminal gets the same block rather than escaping it.
## [0.3.6] — 2026-09-17
### Added
@@ -840,7 +910,8 @@ deliberately not reconstructed here.
- Two `TYPE_CHECKING` imports the test suite referenced but never imported, on paths that
happened never to run.
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.6...HEAD
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.7...HEAD
[0.3.7]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.7
[0.3.6]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.6
[0.3.5]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.5
[0.3.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.4
+21 -7
View File
@@ -75,16 +75,19 @@ if anything goes wrong at startup the window closes before you can read why.
**Windows** — open Windows Terminal or PowerShell, `cd` to your downloads, then:
```powershell
.\meshterm-0.3.6-windows-x64.exe
.\meshterm-0.3.7-windows-x64.exe
```
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
```bash
chmod +x meshterm-0.3.6-*
./meshterm-0.3.6-*
chmod +x meshterm-0.3.7-*
./meshterm-0.3.7-*
```
On macOS, downloading it with `curl` instead of a browser saves you a fight with Gatekeeper
— the note below has the line.
### A word about terminals, on Windows
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether you
@@ -119,8 +122,19 @@ becomes just `meshterm` from anywhere.
> code-signed — signing costs real money on both platforms and this is a free side
> project.
>
> - **macOS**: *"cannot be opened because the developer cannot be verified"*. Clear it with
> `xattr -d com.apple.quarantine meshterm-0.3.6-*` and run it again.
> - **macOS**: *"cannot be opened because the developer cannot be verified"* — or, on Sequoia
> and later, *"Apple could not verify…"*, and right-click → Open no longer gets past it.
> The flag that causes it is set by your **browser**, not by macOS, so the easiest answer
> is to not use one:
>
> ```bash
> curl -L -O https://github.com/jpmartineau/MeshTerm/releases/download/v0.3.7/meshterm-0.3.7-macos-arm64
> chmod +x meshterm-0.3.7-macos-arm64 && ./meshterm-0.3.7-macos-arm64
> ```
>
> `curl` marks nothing, so it simply runs — on an Intel Mac, swap `arm64` for `x64`. If it's
> already downloaded, clear the flag instead: `xattr -d com.apple.quarantine meshterm-0.3.7-*`.
> Installing with pipx (below) avoids all of this too, since nothing is downloaded as a binary.
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
>
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
@@ -130,10 +144,10 @@ becomes just `meshterm` from anywhere.
> MeshTerm uses that same folder. Set `MESHTERM_HOME` to try one in isolation:
>
> ```bash
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.3.6-* # macOS, Linux
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.3.7-* # macOS, Linux
> ```
> ```powershell
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.6-windows-x64.exe
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.7-windows-x64.exe
> ```
>
> Same variable if you run two radios and want them kept apart.
+1 -1
View File
@@ -1460,7 +1460,7 @@ each link; the scripted face prints the link alone.
**`--json`:**
```json
{"page":"discord","title":"Join Discord","version":"0.3.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.3.7","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
```
| Field | Type | Meaning |
+1 -1
View File
@@ -6,7 +6,7 @@ Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Lin
from datetime import datetime
__version__ = "0.3.6"
__version__ = "0.3.7"
__author__ = "Jean-Pierre Martineau"
#: The year MeshTerm was first published; the copyright span starts here.
+81 -1
View File
@@ -145,6 +145,16 @@ _BLE_CONNECT_ATTEMPTS = 2
#: Pause between BLE link-open attempts (seconds), giving the OS radio a beat to settle.
_BLE_CONNECT_RETRY_DELAY_S = 1.0
#: Attempts, and the wait between them, while macOS finishes a pairing it has just begun.
#: An unbonded subscribe to the companion's authenticated characteristic is what *triggers*
#: Passkey Entry there, so the very failure we catch is the signal that the OS has raised
#: its dialog — and the person now has to read a 6-digit code off the device and type it.
#: Giving up at once meant a correct PIN produced an error and only the *second* attempt
#: worked, the first having quietly done the bonding. Sized to cover a human typing a code
#: rather than a radio settling, and bounded so a cancelled dialog still fails in the end.
_BLE_MACOS_PAIRING_ATTEMPTS = 5
_BLE_MACOS_PAIRING_DELAY_S = 6.0
TX_POWER_MIN = 1
TX_POWER_MAX = 22
@@ -1224,8 +1234,55 @@ class MeshCoreDevice(Device):
# Clear it, pair with the PIN, and retry the connect exactly once before giving up.
if allow_repair and await self._pair_ble_windows(force=True):
return await self._open_ble(mesh_core, allow_repair=False)
# On macOS the same rejection means the opposite thing: it is not the end of a
# pairing attempt but the *start* of one, because touching the authenticated
# characteristic is the only way to make CoreBluetooth pair at all. The OS is
# putting its Passkey dialog up as we unwind. Wait for the person to answer it.
if allow_repair and sys.platform == "darwin":
return await self._open_ble_after_macos_pairing(mesh_core, exc)
raise DeviceAuthenticationError(self._ble_auth_message()) from exc
async def _open_ble_after_macos_pairing(self, mesh_core, cause: BaseException): # type: ignore[no-untyped-def]
"""Re-open the link while macOS is running the Passkey dialog it just raised.
CoreBluetooth exposes no pairing API — Apple's model is that a peripheral pairs
*implicitly* when something touches a characteristic that requires encryption. The
companion firmware puts its UART characteristic at ENC+MITM precisely so that
happens (``SECMODE_ENC_WITH_MITM`` on nRF52, ``ESP_GATT_PERM_*_ENC_MITM`` on
ESP32), so the subscribe fails with "Insufficient Authentication" *and* that
failure is what makes macOS ask for the code. The GATT operation is already dead by
then; the bond it started arrives seconds later, once a human has typed six digits.
Failing there reported an error for a pairing that was in fact succeeding, and the
device connected on the next attempt — the first having silently done the work. So
retry rather than give up, for long enough to cover the typing.
Args:
mesh_core: The imported ``meshcore.MeshCore`` class.
cause: The authentication failure that opened the dialog, chained onto the
final error if the pairing never completes.
Returns:
The connected ``MeshCore`` client, or ``None`` if a later attempt opened the
transport but the peripheral never answered the identity handshake.
Raises:
DeviceAuthenticationError: If every attempt was still refused — the dialog was
dismissed, or the code entered was wrong.
"""
for attempt in range(_BLE_MACOS_PAIRING_ATTEMPTS):
await asyncio.sleep(_BLE_MACOS_PAIRING_DELAY_S)
try:
return await self._open_ble(mesh_core, allow_repair=False)
except DeviceAuthenticationError:
_log.debug(
"BLE bond with %s not established yet (attempt %d/%d)",
self._address,
attempt + 1,
_BLE_MACOS_PAIRING_ATTEMPTS,
)
raise DeviceAuthenticationError(self._ble_auth_message()) from cause
async def _create_ble_with_retry(self, mesh_core): # type: ignore[no-untyped-def]
"""Open the owned BLE client, retrying the transport-level failures that are transient.
@@ -1305,7 +1362,20 @@ class MeshCoreDevice(Device):
"""
from meshcore import BLEConnection
connection = BLEConnection(address=self._address, device=self._ble_device, pin=self._pin)
# Withhold the PIN on macOS. Handed one, ``BLEConnection.connect`` calls bleak's
# ``client.pair()``, and CoreBluetooth has no pairing API at all — the macOS
# backend raises ``NotImplementedError`` outright — whereupon the library
# disconnects and re-raises, so supplying a *correct* PIN is what breaks the
# connection. Apple's model is that pairing is the OS's to run, not ours: the
# companion firmware puts its UART characteristic at ENC+MITM
# (``SECMODE_ENC_WITH_MITM`` on nRF52, ``ESP_GATT_PERM_*_ENC_MITM`` on ESP32), so
# the unbonded subscribe below is answered with "Insufficient Authentication",
# and macOS reacts by running Passkey Entry and prompting for the code itself.
# Saying nothing here is therefore what *lets* a PIN-protected companion bond;
# the OS keeps the bond, and later connections need no PIN. Windows and Linux do
# expose explicit pairing, and keep it (see :meth:`_pair_ble_windows`).
pin = None if sys.platform == "darwin" else self._pin
connection = BLEConnection(address=self._address, device=self._ble_device, pin=pin)
mc = mesh_core(
connection,
default_timeout=self._connect_timeout,
@@ -1632,6 +1702,16 @@ class MeshCoreDevice(Device):
before an authenticated characteristic can be subscribed.
"""
where = self._address or "the selected Bluetooth device"
if sys.platform == "darwin":
# macOS collects the code itself, in its own dialog, so --ble-pin is not the
# remedy here and naming it would send the reader somewhere that cannot help.
return (
f"{where} was not paired. macOS asks for the pairing code in its own dialog "
"rather than through MeshTerm — enter the 6-digit code shown on the device "
"(or in the MeshCore app) when it appears, and the bond is remembered for "
"next time. If no dialog appeared, check that Bluetooth is allowed for this "
"terminal in System Settings > Privacy & Security > Bluetooth."
)
if self._pin:
return (
f"{where} rejected the Bluetooth PIN — it needs pairing and the PIN provided "
+39
View File
@@ -53,6 +53,32 @@ BLE_NAME_PREFIX = "MeshCore"
#: advertisement, short enough not to stall the startup splash noticeably.
BLE_SCAN_TIMEOUT_S = 5.0
try: # bleak >= 3 only; the floor in pyproject is 0.22, so this must not be assumed
from bleak.exc import BleakBluetoothNotAvailableError as _BleakNotAvailable
_BLE_NOT_AVAILABLE: tuple[type[BaseException], ...] = (_BleakNotAvailable,)
except Exception: # noqa: BLE001 - older bleak, or bleak absent entirely
# An empty tuple in ``except`` matches nothing, so older installs fall straight through
# to the broad handler and behave exactly as they did before.
_BLE_NOT_AVAILABLE = ()
#: Why the last BLE scan could not run, when the cause is something the reader can act on
#: (Bluetooth off; on macOS, permission not granted to the terminal running us). ``None``
#: when the scan worked — including when it simply heard nothing, which is not a fault.
_ble_unavailable: str | None = None
def ble_unavailable_reason() -> str | None:
"""Why the last BLE scan was refused, if it was, as a sentence fit to show the reader.
Returns:
The reason the most recent :func:`discover_ble_devices` could not scan, or ``None``
if it ran — whether or not it found anything. Reset at the start of every scan, so
it always describes the latest attempt rather than a stale one.
"""
return _ble_unavailable
#: Default seconds to watch for one *known* companion's advertisement while waiting for it to
#: come back (see :func:`find_ble_device`). Deliberately longer than the startup scan: nothing
#: is waiting on it, the watch ends the instant the device is heard, and a companion that just
@@ -393,6 +419,9 @@ async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[Disc
Returns:
Discovered BLE companions (deduplicated by address), sorted by name.
"""
global _ble_unavailable
_ble_unavailable = None
try:
from bleak import BleakScanner
except ImportError: # bleak not installed → BLE simply unavailable
@@ -400,6 +429,16 @@ async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[Disc
try:
found = await BleakScanner.discover(timeout=timeout, return_adv=True)
except _BLE_NOT_AVAILABLE as exc:
# A *state the reader can fix* — Bluetooth switched off, or (on macOS, routinely)
# the terminal running us has not been granted Bluetooth. That is not the "no
# adapter / driver hiccup" the broad catch below is for, and returning [] silently
# made it indistinguishable from "nothing is nearby": the one message that would
# have told the user what to do was thrown away at debug level, under a log that
# defaults to WARNING. Keep the sentence bleak wrote — it already names the remedy.
_ble_unavailable = str(getattr(exc, "args", [None])[0] or exc)
_log.warning("BLE unavailable: %s", _ble_unavailable)
return []
except Exception as exc: # noqa: BLE001 - no adapter / OS Bluetooth off / driver hiccup
_log.debug("BLE scan unavailable: %s", exc)
return []
+144
View File
@@ -28,6 +28,13 @@ three of its shapes only make sense against that source:
``af`` both read and write ``airtime_factor``, so writing one stales the other
(:attr:`RemoteSetting.overlaps`).
The catalog is not the whole page, though: it is what *any* MeshCore build might expose,
and a node can have settings beyond it (a third-party firmware's own knobs). Those are never
listed here and never probed — they are learned one node at a time from commands the reader
actually ran (:func:`parse_setting_command`, :func:`discovered_setting`), which is why
:attr:`RemoteSetting.discovered` exists and why :func:`learnable_from_get` has to care that
``get`` matches keys by prefix and ``set`` does not.
Replies are parsed *loosely* on purpose: repeater firmware answers tersely and has changed
its phrasing across versions (``"> 20"``, ``"tx: 20"``, ``"OK - repeat is now ON"``), so
values are extracted rather than pattern-matched. Firmware without a given key answers
@@ -118,6 +125,9 @@ class RemoteSetting:
part: This setting's position within its composite's comma-joined value.
overlaps: Keys that are another spelling of the same firmware value, whose cached
reading a write to this one makes stale.
discovered: Whether this setting is not in the catalog at all, but was learned from
a command the reader ran on *one node's* command line (see
:func:`discovered_setting`). Never true of a catalog entry.
"""
key: str
@@ -139,6 +149,7 @@ class RemoteSetting:
composite: str = ""
part: int = 0
overlaps: tuple[str, ...] = ()
discovered: bool = False
@property
def get_command(self) -> str:
@@ -671,6 +682,134 @@ def composite_members(composite: str) -> list[RemoteSetting]:
return sorted((s for s in REPEATER_SETTINGS if s.composite == composite), key=lambda s: s.part)
#: The category a discovered setting sorts under, and the heading its section takes.
DISCOVERED_CATEGORY = "Extra"
#: The description lane's text for a discovered setting. The catalog has no explanation to
#: give — all it knows is that this node answered the key — so the row says exactly that, and
#: says it short: the lane is shared with descriptions written to fit a 72-column screen.
DISCOVERED_HELP = "Found on this node"
#: Keys whose value is never written to disk, however it was asked for. ``prv.key`` is the
#: node's own identity: the firmware answers it on the serial console only (``sender_timestamp
#: == 0`` in ``handleGetCmd``), it is a fact rather than a setting, and a reply that reached
#: us anyway must not be the first place a repeater's private key lands in a file. The
#: command line still runs the command and still prints the answer; only the remembering
#: stops here.
NEVER_STORED = frozenset({"prv.key"})
@dataclass(frozen=True, slots=True)
class SettingCommand:
"""A remote-CLI command recognised as a read or a write of one named setting.
Attributes:
verb: ``"get"`` or ``"set"``.
key: The setting's key, as the reader spelled it.
value: What a ``set`` writes; ``""`` for a ``get``.
"""
verb: str
key: str
value: str = ""
def parse_setting_command(command: str) -> SettingCommand | None:
"""Read one typed CLI command as a setting read/write, or ``None`` if it is neither.
The grammar is the filter, and deliberately so: ``get <key>`` and ``set <key> <value>``
are the firmware's own way of saying *this is a setting*, so recognising them costs no
list of what to exclude. Every top-level verb — ``reboot``, ``advert``, ``clock sync``,
``start ota``, ``powersaving``, ``setperm`` — simply isn't this shape and falls out.
A key is held to the spelling the firmware's own keys use, so a mistyped line can't
invent one.
"""
parts = command.strip().split()
if len(parts) < 2 or parts[0] not in ("get", "set"):
return None
key = parts[1]
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9._]*", key):
return None
if parts[0] == "get":
return SettingCommand("get", key) if len(parts) == 2 else None
value = command.strip()[len("set") :].strip()[len(key) :].strip()
return SettingCommand("set", key, value) if value else None
def get_match_keys() -> list[str]:
"""Every spelling a firmware ``get`` matches on, longest first.
A composite's members share their composite's key (``get radio``); verb-shaped settings
aren't ``get`` keys at all.
"""
keys = {s.composite or s.key for s in REPEATER_SETTINGS if s.readable and not s.verb}
return sorted(keys, key=len, reverse=True)
def shadowing_key(key: str) -> str | None:
"""The catalog ``get`` key that would answer a ``get key`` on firmware lacking ``key``.
``handleGetCmd`` compares with ``memcmp(config, "radio", 5)`` — no trailing space — so a
key it doesn't know falls into the branch of any *shorter* key that prefixes it, and
answers with that one's value. ``get radio.rxps`` on stock firmware comes back
``> 910.525,62.5,7,5``: not an error, and not a value of the key that was asked for.
``handleSetCmd`` matches ``"radio "`` *with* the space, so a write has no such shadow.
"""
return next((k for k in get_match_keys() if key != k and key.startswith(k)), None)
def learnable_from_get(key: str, reply: str, known: Mapping[str, str]) -> bool:
"""Whether a ``get key`` reply really came from ``key`` and not from a shorter sibling.
Only asked of a key the catalog doesn't have, where there is no typing to reject a
wrong-shaped answer with. The reply is trusted when nothing shadows the key
(:func:`shadowing_key`), or when it cannot be the shadow's answer — it doesn't parse as
the shadow's type, or it parses to something other than what the shadow last said. A
shadow whose own value was never read leaves the question unanswerable, and an
unanswerable question is not a discovery.
Args:
key: The key the reader asked for.
reply: What the node answered.
known: The node's last-read values, keyed as the cache keys them.
"""
shadow = shadowing_key(key)
if shadow is None:
return True
members = composite_members(shadow)
if not members:
spec = get_setting(shadow)
members = [spec] if spec is not None else []
if not members: # pragma: no cover - every get key is a catalog key
return True
parsed = {m.key: parse_reply_value(m, reply) for m in members}
if any(value is None for value in parsed.values()):
return True # the reply isn't even shaped like the shadow's answer
if any(known.get(k) is None for k in parsed):
return False # it could be the shadow's answer, and there is nothing to compare
return any(known[k] != value for k, value in parsed.items())
def discovered_setting(key: str) -> RemoteSetting:
"""A catalog entry for a key the catalog hasn't got, which one node said it has.
Everything downstream of the catalog — the read plan, the reply parser, the editor's
lanes, staging, :func:`write_plan` — speaks :class:`RemoteSetting`, so a discovered key
becomes one rather than growing a second path beside it. What it carries is only what
was actually learned: the key as the node spells it, and a string value. No range, no
words, no options — the firmware never said, and guessing is how a row would start
lying about a node whose build we have never seen.
"""
return RemoteSetting(
key=key,
label=key,
help=DISCOVERED_HELP,
category=DISCOVERED_CATEGORY,
kind="str",
discovered=True,
)
def reply_is_error(reply: str) -> bool:
"""Whether a reply text reads as the firmware refusing the command.
@@ -884,6 +1023,11 @@ def write_plan(pending: Mapping[str, str], known: Mapping[str, str]) -> list[Wri
else:
joined = ",".join(str(values[m.key]) for m in members)
writes.append(Write(f"set {spec.composite} {joined}", dict(values))) # type: ignore[arg-type]
# A staged key the catalog hasn't got is a discovered one (nothing else can stage a
# row), and it is written the only way anything knows how: its own plain `set`.
for key, value in pending.items():
if get_setting(key) is None:
writes.append(Write(f"set {key} {value}", {key: value}))
return writes
+22 -2
View File
@@ -35,11 +35,16 @@ class CachedValue:
supported: ``False`` when the node answered the read with an error — its firmware
has no such setting (a board without a front-end module, a build without a
bridge). Kept apart from *never read*, which is no entry at all.
discovered: ``True`` for a setting the catalog hasn't got, learned from a command
the reader ran on *this node's* command line. Such a row reads ``n/a`` like any
other when the node stops answering it, and is removed only by hand: its key is
recorded nowhere else, so a misread reply must not be able to delete it.
"""
value: str
read_at: datetime | None
supported: bool = True
discovered: bool = False
class RemoteStore:
@@ -84,7 +89,10 @@ class RemoteStore:
except ValueError:
read_at = None
out[key] = CachedValue(
value=str(entry.get("value", "")), read_at=read_at, supported=supported
value=str(entry.get("value", "")),
read_at=read_at,
supported=supported,
discovered=bool(entry.get("discovered", False)),
)
return out
@@ -92,6 +100,10 @@ class RemoteStore:
"""Cache one setting's value for ``node``, stamped now."""
self._put(node, key, {"value": value, "read_at": utcnow().isoformat()})
def remember_discovered(self, node: Contact, key: str, value: str) -> None:
"""Cache a setting the catalog hasn't got, which ``node`` has just proved it has."""
self._put(node, key, {"value": value, "read_at": utcnow().isoformat(), "discovered": True})
def remember_unsupported(self, node: Contact, key: str) -> None:
"""Record that ``node`` answered a read of ``key`` with an error, stamped now."""
self._put(node, key, {"unsupported": True, "read_at": utcnow().isoformat()})
@@ -104,10 +116,18 @@ class RemoteStore:
self._write(records)
def _put(self, node: Contact, key: str, entry: dict) -> None:
"""Store one setting's cache entry for ``node``."""
"""Store one setting's cache entry for ``node``, keeping it discovered if it was.
A discovered row is refreshed by the same reads and writes as any other — through
:meth:`remember_setting` — and that must not quietly demote it to a catalog row it
has no entry for, which would leave a row nothing draws.
"""
records = self._load_all()
record = records.setdefault(admin_key(node), {})
settings = record.setdefault("settings", {})
previous = settings.get(key)
if isinstance(previous, dict) and previous.get("discovered"):
entry = {**entry, "discovered": True}
settings[key] = entry
self._write(records)
+39 -4
View File
@@ -112,9 +112,17 @@ def resolve_device(
3. ``explicit_port`` (an explicit ``--port``).
4. A TCP profile's ``host:port``, or a serial profile's ``port``.
5. The remembered "last known good" device, if it is currently attached/in range.
6. The single attached device, if exactly one is present.
6. The single *likely-LoRa* device, if exactly one is present — a port whose USB
vendor marks it a board or bridge, or a BLE/TCP endpoint. Ports that look like
nothing in particular are ignored at this step, because a platform may present
some unconditionally (every Mac carries two) and they would otherwise make the
count ambiguous forever.
7. The single attached device, if exactly one is present and *none* looked likely —
so an unrecognized-but-real adapter still resolves.
Otherwise a :class:`DeviceSelectionError` is raised listing the candidates.
Otherwise a :class:`DeviceSelectionError` is raised listing the candidates: the
likely ones where there are any, else everything attached under a message that does
not claim they are companions.
Args:
devices: Currently discovered devices (serial and/or BLE).
@@ -155,7 +163,23 @@ def resolve_device(
if match is not None:
return Resolution(match.target, match, "remembered", match.transport)
if len(devices) == 1:
# Prefer the devices that actually look like companions. macOS is why this matters:
# every Mac permanently presents /dev/cu.Bluetooth-Incoming-Port and
# /dev/cu.debug-console, which carry no USB VID/PID and so score "unknown". Counting
# them put `len(devices) == 1` out of reach there — a Mac with exactly one real board
# attached saw three devices and refused to choose, so auto-detection could never fire
# on that platform and every Mac user met "Multiple companion devices detected" on a
# first run. Which device is plausible is already known, and already printed in the
# listing below as "[likely LoRa]"; this decides with it instead of only saying it.
likely = [d for d in devices if d.is_likely_lora]
if len(likely) == 1:
return Resolution(likely[0].target, likely[0], "only", likely[0].transport)
# Nothing recognizable: fall back to the whole list rather than narrowing to nothing,
# so a lone *unrecognized* adapter — a UART bridge carrying a VID we don't list, which
# is a real board more often than not — still connects exactly as it always has.
if not likely and len(devices) == 1:
return Resolution(devices[0].target, devices[0], "only", devices[0].transport)
if not devices:
@@ -164,9 +188,20 @@ def resolve_device(
"or pass --port / --ble explicitly, or use --mock for the simulator."
)
if not likely:
# Several ports attached, none of them plausible. "Multiple companion devices" is
# simply false here: on a bare Mac it names two virtual ports that are not
# companions at all, and then tells the reader to choose one of them.
raise DeviceSelectionError(
"No companion devices detected among the attached ports.\n"
f"{_format_device_list(devices)}\n"
"Connect a companion device (USB or Bluetooth), or pass --port / --ble "
"explicitly if one of these is your companion, or use --mock for the simulator."
)
raise DeviceSelectionError(
"Multiple companion devices detected and no default to fall back on.\n"
f"{_format_device_list(devices)}\n"
f"{_format_device_list(likely)}\n"
"Choose one with --port <PORT> or --ble <ADDRESS> (or run 'meshterm devices' to "
"inspect them). The chosen device is remembered as the default after it connects."
)
+56 -1
View File
@@ -44,12 +44,14 @@ from __future__ import annotations
import json
import logging
import os
import ssl
import threading
import time
import urllib.error
import urllib.request
from collections import OrderedDict
from collections.abc import Container
from functools import lru_cache
from pathlib import Path
from typing import NamedTuple
@@ -109,6 +111,59 @@ _RESOLVE_RETRY_SECONDS = 30.0
_log = logging.getLogger(__name__)
#: Where the operating system keeps its CA bundle, most specific first. Only consulted when
#: the default context came up empty (see :func:`_tls_context`) — macOS and Alpine put it at
#: ``/etc/ssl/cert.pem``, Debian and Ubuntu at ``ca-certificates.crt``, Fedora and RHEL under
#: ``/etc/pki``, openSUSE at ``ca-bundle.pem``. Files rather than a bundled copy on purpose:
#: these are the trust decisions the *machine's administrator* has made, kept current by the
#: OS, and shipping our own would quietly freeze a snapshot of them into every release.
_CA_BUNDLES = (
"/etc/ssl/cert.pem",
"/etc/ssl/certs/ca-certificates.crt",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/ca-bundle.pem",
)
@lru_cache(maxsize=1)
def _tls_context() -> ssl.SSLContext | None:
"""The TLS context for tile fetches, with a CA bundle found if the default has none.
A frozen build can have no trust store to consult. Python's default context asks
OpenSSL for one, and on macOS and Linux that is a *filesystem path baked into the
interpreter's own build* — which need not exist on a machine that never installed that
Python, which is every machine a PyInstaller bundle lands on. Every tile fetch then
fails with ``CERTIFICATE_VERIFY_FAILED``; and because a refused fetch is
indistinguishable from empty terrain, the map drew no ground under the nodes and said
nothing about why. Windows is the exception that hid it for so long: its default
context reads the OS certificate store, so the binary there always worked.
So: keep the default when it actually loaded certificates, and otherwise point it at
the bundle the OS ships (:data:`_CA_BUNDLES`). Verification is never weakened — a map
tile is not worth teaching the app to skip certificate checks, and an unverified
fetcher here would be one import away from being reused somewhere it matters.
Returns:
A context to pass to ``urlopen``, or ``None`` to accept urllib's default — which
is right whenever that default already works, and is never worse than the failure
it replaces.
"""
try:
context = ssl.create_default_context()
if context.get_ca_certs():
return None # the platform store answered; leave urllib exactly as it was
for path in _CA_BUNDLES:
if os.path.isfile(path):
context.load_verify_locations(cafile=path)
_log.debug("no default CA store; verifying against %s", path)
return context
_log.debug("no CA bundle found in %s — TLS will likely fail", ", ".join(_CA_BUNDLES))
return None
except Exception as exc: # noqa: BLE001 - a broken store must not take the map with it
_log.debug("could not build a TLS context, using urllib's default: %s", exc)
return None
class _Response(NamedTuple):
"""One HTTP GET's outcome: whether the server answered, and what it said.
@@ -478,7 +533,7 @@ class BasemapSource:
"""
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
with urllib.request.urlopen(req, timeout=self._timeout, context=_tls_context()) as resp:
body = resp.read()
declared = (resp.headers.get("Content-Length") or "").strip()
# A flaky link can end a read early without raising. A body short of its
+19 -1
View File
@@ -18,7 +18,13 @@ import typer
from ..context import AppContext
from ..core import exitcodes
from ..core.discovery import TRANSPORT_MOCK, DiscoveredDevice, discover_all, discover_devices
from ..core.discovery import (
TRANSPORT_MOCK,
DiscoveredDevice,
ble_unavailable_reason,
discover_all,
discover_devices,
)
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
@@ -73,6 +79,18 @@ class DevicesTool(Tool):
active = ctx.selected_device
active_target = ctx.ble_override or ctx.port_override or (active.target if active else None)
# A refused Bluetooth scan is not the same as a quiet one, and the difference is
# invisible in the listing — both simply lack BLE rows. Say so whether or not
# anything else was found: a serial board being present does not make a companion
# the reader expected over Bluetooth any less missing.
blocked = ble_unavailable_reason()
if blocked:
from ..ui import script
script.stderr_console().print(
f"meshterm: Bluetooth not scanned — {blocked}", style="warn", highlight=False
)
if not devices:
# Not an error: the scan ran and found nothing. Said on stderr so a caller
# redirecting stdout still hears it, and reported as NO_RESULT so a script can
+82 -19
View File
@@ -153,6 +153,36 @@ def _where(device: DiscoveredDevice) -> str:
return device.target
def _fit_target(text: str, width: int) -> str:
"""Fit a connection target into ``width`` cells, keeping its **end**.
Deliberately not :func:`~meshterm.ui.menus.fit_cells`, which keeps the head: what tells
two targets apart is their *tail* — ``usbmodem1101`` from ``usbmodem1102``,
``ttyUSB0`` from ``ttyUSB1`` — while the head they share (``/dev/cu.``) is boilerplate.
Cutting the front is the cut that leaves the column able to do its one job in a picker,
and cutting the back would render two different ports identically. A CoreBluetooth UUID
is equally identifiable from either end, so the rule that is right for ports costs the
platform that needed the cap nothing.
Args:
text: The target to fit.
width: The cells available. Text already inside it is returned unchanged, so the
platforms whose targets already fit the lane are untouched.
Returns:
The target, or its tail behind a leading ``…``, measuring at most ``width`` cells.
"""
if cell_len(text) <= width:
return text
keep = max(0, width - 1) # the leading ellipsis costs a cell
tail = ""
for ch in reversed(text):
if cell_len(tail) + cell_len(ch) > keep:
break
tail = ch + tail
return "…" + tail
def _hardware_label(device: DiscoveredDevice, registry: dict[str, RememberedDevice]) -> str:
"""The HARDWARE column text: the remembered firmware model, else the USB vendor.
@@ -670,34 +700,66 @@ async def _remove_network_device(
#: Below this a model string says nothing at all, and the lane is better spent on the name.
_HARDWARE_MIN = 10
#: The narrowest the PORT / ADDRESS lane is worth shrinking to before DEVICE has to give.
#: This is the lane that adapts: a target is how you tell two similar rows apart, and ten
#: cells still does that (the tail of a UUID, the end of a port path), while a *name* is
#: what the reader actually came to read. macOS is why any of this is needed —
#: CoreBluetooth reports no MAC at all but a per-machine 36-cell UUID, and even its ports
#: run long (every Mac carries a 31-cell ``/dev/cu.Bluetooth-Incoming-Port``) — where a MAC
#: is 17 and ``/dev/ttyUSB0`` is 12, so no other platform ever presses on the row at all.
_ADDRESS_MIN = 10
def _name_width(
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice], other_lanes: int
) -> int:
"""The DEVICE lane's width: the longest name, capped so HARDWARE keeps a readable column.
A USB adapter's own product string runs to forty-odd characters ("CP2102 USB to UART
Bridge Controller"), and the lane used to size itself to whichever of those was longest.
On a 53-column console that pushed every other lane off the right-hand edge — the row
ended mid-port, and the HARDWARE column that says what the thing *is* was not on screen
at all, nor reachable by scrolling, because the pinned head was already wider than the
box. So the name is the lane that gives: it ellipsizes, and the columns after it survive.
def _lane_widths(
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice]
) -> tuple[int, int]:
"""The DEVICE and PORT / ADDRESS widths: the name in full, the address taking the rest.
The row has a fixed budget, and something has to give when the two lanes together
outrun it. **The address gives first.** A name is what the reader came to read and what
they recognise their own radio by; an address is a disambiguator, and a disambiguator
does its whole job from its last ten cells (:func:`_fit_target` keeps the tail for
exactly this reason). So DEVICE is sized to the longest name it has, and ADDRESS takes
whatever is left over, down to :data:`_ADDRESS_MIN`.
Only once the address is down to that floor does the name start ellipsizing, and it
still must: a USB adapter's own product string runs to forty-odd characters ("CP2102
USB to UART Bridge Controller"), and a lane sized to *that* pushed everything after it
off the right-hand edge — the row ended mid-port and the HARDWARE column that says what
the thing actually *is* was not on screen, nor reachable by scrolling, because the
pinned head was already wider than the box. HARDWARE keeps :data:`_HARDWARE_MIN` out of
the budget so it always has a readable stub; it is the row's scrolling tail, so beyond
that stub it is free to run long.
Args:
devices: The devices being listed.
registry: Confirmed companions, for their remembered names.
other_lanes: Cells the PORT and TYPE lanes take between them.
Returns:
The name lane's width in cells.
``(name_w, port_w)`` in cells.
"""
platform = get_platform()
# What the splash's box gives a row: the terminal, less the gutter it floats over, less
# its own border and padding, less the pointer and star columns the row leads with.
content = platform.readable_cols - platform.dialog_margin - 4 - 4
fixed = other_lanes + 6 # the three two-cell gaps between the four lanes
longest = max(cell_len(_display_name(d, registry)) for d in devices)
return max(len("DEVICE"), min(longest, content - fixed - _HARDWARE_MIN))
# ...less the three two-cell gaps, the TYPE badge, and HARDWARE's readable stub.
budget = content - 6 - len("TYPE") - _HARDWARE_MIN
name_natural = max(cell_len(_display_name(d, registry)) for d in devices)
port_natural = max(cell_len(_where(d)) for d in devices)
# Never reserve more room for the address than it actually wants: a lone "COM3" should
# hand its slack to the name rather than sit in a ten-cell lane holding four cells.
floor = min(port_natural, _ADDRESS_MIN)
name_w = max(len("DEVICE"), min(name_natural, budget - floor))
port_w = max(floor, min(port_natural, budget - name_w))
if name_w < name_natural and port_w < port_natural:
# The name is being ellipsized whatever happens, so spending two more of its cells
# to keep the address whole trades one cut for none rather than adding a second.
port_w = min(port_natural, budget - len("DEVICE"))
name_w = max(len("DEVICE"), budget - port_w)
return name_w, port_w
def _order(
@@ -765,12 +827,13 @@ def _build_items(
port_label = (
"PORT / ADDRESS" if has_serial and has_address else "ADDRESS" if has_address else "PORT"
)
port_w = max(cell_len(_where(d)) for d in devices)
port_w = max(port_w, len(port_label))
# The TYPE column holds a small transport badge (at most 3 cells); its heading is wider,
# so the four-cell "TYPE" label sets the column width and every badge pads out to it.
type_w = len("TYPE")
name_w = _name_width(devices, registry, port_w + type_w)
# DEVICE in full, ADDRESS taking what is left (see _lane_widths). The heading does not
# set a floor here the way it used to: the lane may end up narrower than "PORT / ADDRESS"
# spells, and column_header already carries the short form to fall back on.
name_w, port_w = _lane_widths(devices, registry)
hardware_w = max(cell_len(_hardware_label(d, registry)) for d in devices)
hardware_w = max(hardware_w, len("HARDWARE"))
@@ -806,7 +869,7 @@ def _build_items(
style="device.known" if is_known else "",
)
row.append(" ")
row.append(_pad(_where(device), port_w), style="muted")
row.append(_pad(_fit_target(_where(device), port_w), port_w), style="muted")
row.append(" ")
# The TYPE badge marks the transport: a plug emoji for serial, or the Bluetooth rune
# on its blue badge for BLE. It's built with its own colours, then the column is
+43 -15
View File
@@ -158,13 +158,25 @@ def _rewrap(line: str, width: int) -> list[str]:
def _state_intensity(row: str) -> str:
"""Rewrite the row's colour changes so every one says whether it is bright.
"""Rewrite the row's colour changes so brightness is stated as a colour, not as bold.
An art editor spells brightness the DOS way — ``1m`` lifts the bank and each span after
it inherits that — which reads correctly only while the escapes stay one unbroken
stream. They don't here: a row is handed to Rich on its own, and the console draws bold
*as* the bright bank, so a span that never mentions intensity is read against whatever
happened to be set. Saying it outright changes no colour; it just stops one drifting.
An art editor spells brightness the DOS way — ``1m`` lifts the foreground into the
bright bank, and each span after it inherits that. Two things then have to be settled
before the row can be drawn anywhere else.
The first is that the inheritance does not survive: a row is handed to Rich on its own,
so a span that never mentions intensity is read against whatever the *previous row* left
set. That is resolved by working the state out here and saying it outright.
The second is that **bold is not brightness everywhere**. It is on the PicoCalc console
and on a DOS one; it is not on macOS Terminal, where bold asks for a heavier face and
leaves the colour alone. There ``1;30`` is not dark grey but plain black — and this art
leans on it, seventy-nine spans of the wide mark being bold-black, much of that on a
black ground. The mark came out muted, and the ``░▒▓`` dithers that fade one colour into
another faded toward the wrong end. So brightness is emitted as the colour it means:
``9N`` (the aixterm bright bank), which needs no bold and is also how the PicoCalc's own
console spells bright. Nothing about *which* colour is being asked for changes; only the
spelling, from one a terminal may read as a font weight to one that can only be a colour.
"""
out: list[str] = []
bright = False
@@ -177,16 +189,32 @@ def _state_intensity(row: str) -> str:
continue
params = found.group()[2:-1]
parts = params.split(";") if params else [""]
for part in parts: # what this sequence leaves the intensity set to
if part in ("", "0", "22"):
bright = False
elif part == "1":
bright = True
dim = any(_DIM_FG.fullmatch(p) for p in parts)
if dim and not any(p in _SAYS_INTENSITY for p in parts):
out.append("\x1b[" + ";".join(["1" if bright else "22"] + parts) + "m")
# What this sequence leaves the intensity set to, for the spans that follow it.
says = [p for p in parts if p in ("", "0", "1", "22")]
for part in says:
bright = part == "1"
# Whether *this* sequence's own foreground is bright: what it says, else what stands.
here = bright
fg = next((p for p in parts if _DIM_FG.fullmatch(p)), None)
if fg is None:
# No foreground to restate. A bare "1" has nothing left to do once brightness
# travels as a colour, and dropping it keeps bold off glyphs a terminal may
# redraw at another weight; a reset still has to go through.
kept = [p for p in parts if p != "1"]
if kept:
out.append("\x1b[" + ";".join(kept) + "m")
else:
out.append(found.group())
rewritten = []
for part in parts:
if part == "1":
continue # brightness is in the colour now
if part == fg:
rewritten.append(str(int(fg) + 60) if here else fg)
else:
rewritten.append(part)
if not here and "22" not in rewritten:
rewritten.insert(0, "22") # say dim outright; never inherit a bank
out.append("\x1b[" + ";".join(rewritten) + "m")
at = found.end()
return "".join(out)
+15 -4
View File
@@ -13,8 +13,9 @@ The input line behaves like a shell:
:class:`~meshterm.core.remote_store.RemoteStore`) so last week's incantation is one
keystroke away next session.
* **Tab** completes the current text against the known repeater commands — the fixed
verbs plus every ``get``/``set`` spelling in the settings catalog — and the best
match previews muted, ghost-text style, after the cursor.
verbs, every ``get``/``set`` spelling in the settings catalog, and any key *this node*
taught us here on an earlier visit — and the best match previews muted, ghost-text
style, after the cursor.
* **Enter** sends. The transcript keeps every exchange of the session, newest at the
bottom, and the view follows the prompt as it grows.
@@ -25,7 +26,7 @@ the radio, the history store, and the reply plumbing) and feeds replies back thr
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Iterable
from rich.console import Group
from rich.text import Text
@@ -70,6 +71,7 @@ class RemoteCliScreen(Screen):
history: list[str],
send: Callable[[str], None],
session: object,
extra_keys: Iterable[str] = (),
) -> None:
"""Create the command line over a node's persisted history.
@@ -78,6 +80,9 @@ class RemoteCliScreen(Screen):
history: The node's prior commands, oldest first (recalled with ↑).
send: Commits one command (owner-guarded; no-op while one is flying).
session: The running TUI session (for repaints).
extra_keys: Settings *this node* has that the catalog hasn't, found here on an
earlier visit. They complete like any other key: the prompt is where they
were learned, so it is where they should be one Tab away.
"""
super().__init__()
self.title = f"Command line — {node_label}"
@@ -89,7 +94,13 @@ class RemoteCliScreen(Screen):
self._history = list(history)
self._recall: int | None = None # index into history while ↑/↓ browse it
self._draft = "" # what was typed before recall began, restored on ↓ past the end
self._completions = known_commands()
self._completions = sorted(
{
*known_commands(),
*(f"get {key}" for key in extra_keys),
*(f"set {key} " for key in extra_keys),
}
)
self.busy = False
self._spinner = Spinner()
self._prompt_line = 0 # body index of the input line (pinned into view)
+244 -16
View File
@@ -21,6 +21,16 @@ Beyond the settings, the action rows cover the box itself — advert, clock sync
admin password, reboot, each behind its own floating confirmation — and the **Command
line** opens the readline-style remote CLI (:mod:`meshterm.ui.remote_cli`) for anything
the catalog doesn't spell.
That command line is also where the page *grows*. Third-party builds carry settings the
catalog has never heard of, and MeshTerm can't know which build a node is running without
asking it — every question being one paced round trip, and every guessed row a dead ``n/a``
on everyone else's page. So nothing is probed speculatively: a ``get``/``set`` the reader
ran here, which *this node* answered, earns a row on *this node's* page under **Extra**
(:func:`learn_from_cli`). The round trip was one the reader was spending anyway, the catalog
doesn't grow, and no other node's page changes. A row is only ever removed by hand
(``Del``): a key that stops answering reads ``n/a`` like any other, because a misread reply
would otherwise delete the one thing about this node nobody else can restore.
"""
from __future__ import annotations
@@ -36,12 +46,18 @@ from rich.text import Text
from ..core.models import Contact
from ..core.remote_config import (
DISCOVERED_CATEGORY,
NEVER_STORED,
REPEATER_SETTINGS,
RemoteSetting,
composite_members,
composite_reads,
discovered_setting,
get_setting,
learnable_from_get,
normalize_value,
parse_reply_value,
parse_setting_command,
range_hint,
read_plan,
reply_is_error,
@@ -54,6 +70,7 @@ from .menus import (
PICK_LOCATION_LABEL,
confirm_discard,
exit_rows,
fit_cells,
icon_lane,
lane_header,
lane_row,
@@ -63,7 +80,7 @@ from .menus import (
)
from .trace_screen import TracingDialog
from .tui import Choice, Separator
from .tui.select import SelectScreen, splice_hint
from .tui.select import DeleteRequest, SelectScreen, splice_hint
from .tui.spinner import Spinner, spinner_interval
if TYPE_CHECKING:
@@ -88,6 +105,50 @@ _CANCEL = "__cancel__"
#: The footer atom naming the chord that re-reads the highlighted setting.
READ_ONE_HINT = "^R read"
#: The footer atom naming the key that drops a discovered row (surfaced on those rows only).
FORGET_HINT = "Del forget"
#: The narrowest the value lane may be squeezed to for a discovered setting — a floor, so a
#: page whose catalog rows are all ``?`` still shows something of what the node answered.
_EXTRA_VALUE_MIN = 14
def _extra_value(text: str, width: int) -> str:
"""One discovered value, fitted for the lane: whitespace collapsed, a long reply cut.
A discovered setting's value is whatever the node said, and a build that answers a whole
diagnostic line (``desired=off effective=off supported=yes …``) would otherwise set the
value lane's width — one width, for every row on the page — and push each setting's
description off the edge. The row is cut here instead of the page being reshaped around
it; the command line is where the whole reply is read.
"""
flat = " ".join(text.split())
return fit_cells(flat, width) if cell_len(flat) > width else flat
def _spec_for(key: str, cache: dict) -> RemoteSetting | None:
"""The setting ``key`` names on this node: the catalog's, or its own discovered one."""
spec = get_setting(key)
if spec is not None:
return spec
cached = cache.get(key)
return discovered_setting(key) if cached is not None and cached.discovered else None
def _discovered_specs(cache: dict) -> list[RemoteSetting]:
"""This node's discovered settings, in key order — rows the catalog knows nothing about."""
return [discovered_setting(key) for key, cached in sorted(cache.items()) if cached.discovered]
def _all_specs(cache: dict) -> list[RemoteSetting]:
"""Every setting this node's page draws: the catalog, plus what it taught us itself."""
return [*REPEATER_SETTINGS, *_discovered_specs(cache)]
def _extra_keys(cache: dict) -> frozenset[str]:
"""The keys on this node's page that came from the node rather than from the catalog."""
return frozenset(key for key, cached in cache.items() if cached.discovered)
@dataclass(frozen=True, slots=True)
class ReadOne:
@@ -121,6 +182,9 @@ class AdminMenu(SelectScreen):
floating = False
#: The keys on this node's page that the catalog hasn't got, refreshed with the rows.
extra_keys: frozenset[str] = frozenset()
def __init__(self, title: str, items: list, **kwargs: Any) -> None:
"""Build the page's list, its rows ending at the edge rather than sliding under ←→.
@@ -134,14 +198,28 @@ class AdminMenu(SelectScreen):
kwargs.setdefault("hscroll", False)
super().__init__(title, items, **kwargs)
def _readable_key(self) -> str | None:
"""The highlighted row's setting key, when it is a setting the node can be asked."""
def _row_key(self) -> str | None:
"""The highlighted row's setting key, or ``None`` on an action row."""
current = self._current_choice()
if current is None or not isinstance(current.value, str):
return None
spec = get_setting(current.value)
return current.value
def _readable_key(self) -> str | None:
"""The highlighted row's setting key, when it is a setting the node can be asked."""
key = self._row_key()
if key is None:
return None
if key in self.extra_keys:
return key # a discovered row is a string setting, and always re-readable
spec = get_setting(key)
return spec.key if spec is not None and spec.readable else None
def _forgettable_key(self) -> str | None:
"""The highlighted row's key, when it is a discovered row Del can drop."""
key = self._row_key()
return key if key is not None and key in self.extra_keys else None
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The list's own hint, plus the read atom while a readable setting is highlighted."""
@@ -150,16 +228,26 @@ class AdminMenu(SelectScreen):
@property
def fkey_lane(self):
"""The list's lane with ``Read`` on F3 — dim on a row there is nothing to read.
"""The list's lane with ``Read`` on F3 and ``Forget`` behind it — dim where inert.
F1/F2 are this grouped list's section jumps and F4/F5 the pager; F3 is the slot a
select list spends on its own verb. Dim rather than empty on an action row: reading
is a thing on this screen, just not for the row the cursor is on.
select list spends on its own verb. Read and Forget are the same slot's two halves
because they are the same subject — *this row* — and they are never both live: a
catalog row can be read and not forgotten, and only a discovered row can be dropped.
Dim rather than empty on an action row: both are things on this screen, just not for
the row the cursor is on.
"""
from .tui.fkeys import FPair
lane = list(super().fkey_lane)
lane[2] = FPair("Read", "retry", enabled=self._readable_key() is not None)
lane[2] = FPair(
"Read",
"retry",
opp_label="Forget",
opp_action="delete",
enabled=self._readable_key() is not None,
opp_enabled=self._forgettable_key() is not None,
)
return lane
def handle(self, action: str, data: str = "") -> None:
@@ -292,7 +380,9 @@ async def _admin_session(ctx: AppContext, device: Device, node: Contact) -> dict
title,
items,
footer_hint="↑↓ move · type to filter · Enter select · Esc back",
delete_hint=FORGET_HINT,
)
menu.extra_keys = _extra_keys(cache)
async with session.stay(menu) as visit:
while True:
choice = await visit.result()
@@ -304,13 +394,17 @@ async def _admin_session(ctx: AppContext, device: Device, node: Contact) -> dict
continue
return {"node": node.name, "applied": applied}
if isinstance(choice, ReadOne):
spec = get_setting(choice.key)
spec = _spec_for(choice.key, cache)
if spec is not None:
await read_settings(ctx, device, node, [spec])
elif isinstance(choice, DeleteRequest):
await _forget_discovered(ctx, node, str(choice.value), pending)
elif choice == _APPLY:
applied += await _apply(ctx, device, node, pending)
elif choice == _READ:
await read_settings(ctx, device, node, REPEATER_SETTINGS)
# This node's page, not the catalog: a discovered row is refreshed by the
# same sweep as everything else, and reads n/a if it stops answering.
await read_settings(ctx, device, node, _all_specs(cache))
elif choice == _LOCATION:
await _stage_location(ctx, cache, pending)
elif choice == _CLI:
@@ -349,7 +443,7 @@ async def _admin_session(ctx: AppContext, device: Device, node: Contact) -> dict
danger=True,
)
else: # a setting key
spec = get_setting(str(choice))
spec = _spec_for(str(choice), cache)
if spec is not None and not spec.writable:
# A read-only fact has nothing to stage; asking again is all Enter can do.
await read_settings(ctx, device, node, [spec])
@@ -359,6 +453,7 @@ async def _admin_session(ctx: AppContext, device: Device, node: Contact) -> dict
# the values are the ones the action just produced.
cache = ctx.remote_store.settings(node)
title, items = _menu_items(node, cache, pending)
menu.extra_keys = _extra_keys(cache)
menu.replace_items(items, title=title)
@@ -383,6 +478,25 @@ def _menu_items(node: Contact, cache: dict, pending: dict[str, str]) -> tuple[st
rows.append((spec.label, _value_text(spec, cache, pending), spec.help, spec.key))
sections.append((category, rows))
# What this node taught us itself, last and in its own section — absent entirely on a
# node that has taught us nothing, which is every node until someone asks it something.
# Its values are fitted to the lane the catalog's rows already need: the value lane is
# one width for the whole page, so an unbounded reply here would push every setting's
# description right, off the edge of a 72-column screen, and reshape a page the reader
# opened to read the catalog.
extra = _discovered_specs(cache)
if extra:
budget = max(
_EXTRA_VALUE_MIN,
max((cell_len(value.plain) for _, rows in sections for _, value, _, _ in rows)),
)
sections.append(
(
DISCOVERED_CATEGORY,
[(s.label, _value_text(s, cache, pending, budget), s.help, s.key) for s in extra],
)
)
label_w = max(cell_len(label) for _, rows in sections for label, _, _, _ in rows)
value_w = max(cell_len(value.plain) for _, rows in sections for _, value, _, _ in rows)
@@ -394,7 +508,13 @@ def _menu_items(node: Contact, cache: dict, pending: dict[str, str]) -> tuple[st
items.append(section_heading(category))
for label, value, help_text, key in rows:
items.append(
Choice(title=lane_row(label, value, help_text, label_w, value_w), value=key)
Choice(
title=lane_row(label, value, help_text, label_w, value_w),
value=key,
# Only a discovered row can be dropped: a catalog row that goes away
# would come straight back on the next paint, the catalog still naming it.
deletable=category == DISCOVERED_CATEGORY,
)
)
# ↻ and ⌨ are one cell where 📡 🕒 🔐 🔄 are two, so the column is measured once and
@@ -424,7 +544,9 @@ def _menu_items(node: Contact, cache: dict, pending: dict[str, str]) -> tuple[st
return title, items
def _value_text(spec: RemoteSetting, cache: dict, pending: dict[str, str]) -> Text:
def _value_text(
spec: RemoteSetting, cache: dict, pending: dict[str, str], width: int = _EXTRA_VALUE_MIN
) -> Text:
"""One setting's VALUE lane: what the node last said, and any staged arrow.
Four states, each its own word: the value; ``empty`` for a string the node holds blank;
@@ -441,10 +563,14 @@ def _value_text(spec: RemoteSetting, cache: dict, pending: dict[str, str]) -> Te
value = Text("n/a", style="muted")
elif cached.value == "":
value = Text("empty", style="muted")
elif spec.discovered:
value = Text(_extra_value(cached.value, width))
else:
value = Text(spec.display(cached.value))
if spec.key in pending:
value.append(f" → {spec.display(pending[spec.key])}", style="warn")
staged = pending[spec.key]
shown = _extra_value(staged, width) if spec.discovered else spec.display(staged)
value.append(f" → {shown}", style="warn")
return value
@@ -453,7 +579,7 @@ def _value_text(spec: RemoteSetting, cache: dict, pending: dict[str, str]) -> Te
async def _stage_setting(ctx: AppContext, key: str, cache: dict, pending: dict[str, str]) -> None:
"""Prompt for one setting's new value and stage it (nothing is sent yet)."""
spec = get_setting(key)
spec = _spec_for(key, cache)
if spec is None or not spec.writable: # pragma: no cover - menu offers only real keys
return
cached = cache.get(key)
@@ -508,6 +634,38 @@ async def _stage_setting(ctx: AppContext, key: str, cache: dict, pending: dict[s
pending[key] = value
async def _forget_discovered(
ctx: AppContext, node: Contact, key: str, pending: dict[str, str]
) -> None:
"""Drop one discovered row from this node's page, behind a confirm.
This is the *only* way a discovered row is removed. Letting a read drop one — a key the
node no longer answers is real enough, after a board swap onto a reused identity — would
mean a single misread reply silently deleting the row: a truncated line, a node answering
mid-reboot, a stray frame correlated to the wrong command. A catalog row costs an ``n/a``
when that happens and nothing is lost, because the catalog still names the key; a
discovered row's key is known only here, and the reader may not remember what it was. So
the same ``n/a`` is all a read may do, and removing is a decision.
It is a red confirm like any other single-record delete, though nothing on the node
changes: what goes is what MeshTerm remembers, and asking the key again brings it back.
"""
from .tui import CANCEL
choice = await ctx.ui.dialog(
f"Stop showing {key} on {node.name}? Nothing on the node changes — "
"ask it for the setting again and the row comes back.",
[("Cancel", CANCEL), ("Forget", "forget")],
title="Forget setting",
default=1,
destructive=True,
)
if choice != "forget":
return
pending.pop(key, None)
ctx.remote_store.forget_setting(node, key)
async def _stage_location(ctx: AppContext, cache: dict, pending: dict[str, str]) -> None:
"""Pick the node's advertised location on the map and stage both coordinates it sets.
@@ -598,8 +756,11 @@ async def _apply(ctx: AppContext, device: Device, node: Contact, pending: dict[s
remember_reply(ctx, node, fills, reply)
writes = write_plan(pending, _known_values(ctx, node))
# The catalog plus this node's own discovered rows: a staged key that only this node
# has is still a row with a label to name in the outcome, and a write to count.
specs = _all_specs(ctx.remote_store.settings(node))
for i, write in enumerate(writes, start=1):
staged = [s for s in REPEATER_SETTINGS if s.key in write.values and s.key in pending]
staged = [s for s in specs if s.key in write.values and s.key in pending]
names = ", ".join(f"{s.label} = {s.display(write.values[s.key])}" for s in staged)
if write.missing:
unread = ", ".join(s.label for s in map(get_setting, write.missing) if s)
@@ -675,6 +836,71 @@ def remember_reply(ctx: AppContext, node: Contact, fills: list[RemoteSetting], r
return got
def learn_from_cli(ctx: AppContext, node: Contact, command: str, reply: str) -> None:
"""Fold what one command-line exchange proved about ``node`` into its page.
The command line is the only place a setting outside the catalog can be reached, so it
is also the only place one can be *found*: a ``get``/``set`` this node answered is proof
the key is there, and the row it earns costs no round trip nobody asked for. The catalog
doesn't grow — a key learned here belongs to this node alone (see
:func:`~meshterm.core.remote_config.discovered_setting`), and every other node's page is
what it always was.
Three cases, in the order they are tested:
* A **composite's own key** (``get radio``) fills all four of its fields, exactly as a
read of any one of them does.
* A **catalog key** folds into the cache the way the sweep or an Apply would — which is
also how ``get tx`` at the command line stopped leaving the TX power row stale.
* Anything else is **discovered**, and the two verbs are not equally good evidence.
``handleSetCmd`` matches a key with its trailing space and refuses an unknown one
outright, so an accepted write is proof. ``handleGetCmd`` matches on a bare prefix and
will answer a key it hasn't got out of a shorter key's branch, so a read is proof only
when the reply cannot be that shorter key's
(:func:`~meshterm.core.remote_config.learnable_from_get`).
``prv.key`` is never stored, whichever way it was asked
(:data:`~meshterm.core.remote_config.NEVER_STORED`).
"""
parsed = parse_setting_command(command)
if parsed is None or parsed.key in NEVER_STORED:
return
members = composite_members(parsed.key)
spec = _spec_for(parsed.key, ctx.remote_store.settings(node))
if parsed.verb == "get":
if members:
remember_reply(ctx, node, members, reply)
elif spec is not None:
remember_reply(ctx, node, [spec], reply)
elif not reply_is_error(reply) and learnable_from_get(
parsed.key, reply, _known_values(ctx, node)
):
found = discovered_setting(parsed.key)
value = parse_reply_value(found, reply)
if value is not None:
ctx.remote_store.remember_discovered(node, found.key, value)
return
if reply_is_error(reply):
return # a refused write says nothing about the key, and nothing about its value
if members:
for member in members:
value = parse_reply_value(member, parsed.value)
if value is not None:
ctx.remote_store.remember_setting(node, member.key, value)
return
if spec is not None and not spec.discovered:
value = parse_reply_value(spec, parsed.value)
if value is None:
return # sent in words the catalog can't read back; leave the row as it was
ctx.remote_store.remember_setting(node, spec.key, value)
for other in spec.overlaps:
ctx.remote_store.forget_setting(node, other)
return
ctx.remote_store.remember_discovered(node, parsed.key, parsed.value)
async def read_settings(
ctx: AppContext, device: Device, node: Contact, specs: Iterable[RemoteSetting]
) -> None:
@@ -870,6 +1096,7 @@ async def _command_line(ctx: AppContext, device: Device, node: Contact) -> None:
if reply is None:
screen.failed(f"no reply within {_REPLY_TIMEOUT_S:.0f} s")
else:
learn_from_cli(ctx, node, command, reply)
screen.reply(reply)
screen = RemoteCliScreen(
@@ -877,6 +1104,7 @@ async def _command_line(ctx: AppContext, device: Device, node: Contact) -> None:
history=ctx.remote_store.history(node),
send=send,
session=session,
extra_keys=_extra_keys(ctx.remote_store.settings(node)),
)
async def animate() -> None:
+24 -1
View File
@@ -23,6 +23,7 @@ no radio, no real address, and no real pairing code is involved.
from __future__ import annotations
import asyncio
import sys
import pytest
@@ -200,8 +201,12 @@ def test_a_good_connect_hands_the_client_over_still_open() -> None:
assert client.cx.link_open is True
def test_the_connection_is_built_from_this_device_s_own_endpoint() -> None:
def test_the_connection_is_built_from_this_device_s_own_endpoint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Address, discovered ``BLEDevice`` and PIN all reach the connection we construct."""
# Pinned off macOS, where the PIN is deliberately withheld (see the test below).
monkeypatch.setattr(sys, "platform", "win32")
_FakeMeshCore.reset("ok")
sentinel = object()
dev = MeshCoreDevice(
@@ -221,6 +226,24 @@ def test_the_connection_is_built_from_this_device_s_own_endpoint() -> None:
assert (client.default_timeout, client.auto_reconnect) == (7.5, False)
def test_the_pin_is_withheld_on_macos(monkeypatch: pytest.MonkeyPatch) -> None:
"""No PIN is handed to macOS: supplying one is what *breaks* the connection there.
``BLEConnection.connect`` responds to a PIN by calling bleak's ``pair()``, and
CoreBluetooth has no pairing API — the macOS backend raises outright, whereupon the
library disconnects and re-raises. Pairing there is the OS's to run, prompted by the
unbonded subscribe, so saying nothing is what lets a protected companion bond at all.
"""
monkeypatch.setattr(sys, "platform", "darwin")
_FakeMeshCore.reset("ok")
dev = MeshCoreDevice(transport="ble", address=_ADDR, pin="000000")
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
assert _FakeTransport.last.pin is None
assert dev._pin == "000000" # still remembered — it is the platform that can't use it
def test_a_cancelled_handshake_still_closes_the_link() -> None:
"""A probe's ``wait_for`` expiring mid-handshake must not leak what it cancelled.
+54
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import asyncio
import io
import sys
from pathlib import Path
from types import SimpleNamespace
@@ -194,6 +195,10 @@ def _fake_ble_stack(monkeypatch: pytest.MonkeyPatch, *outcomes):
async def test_create_ble_translates_auth_error_to_pin_guidance(monkeypatch) -> None:
"""A raw GATT auth rejection becomes a DeviceAuthenticationError that names the PIN fix."""
# Pinned off macOS: there the same rejection starts an OS-run pairing and is retried
# rather than reported, and the advice is the system dialog rather than --ble-pin.
# Without this the test would assert Windows/Linux wording on the macOS CI runner.
monkeypatch.setattr(sys, "platform", "win32")
# No PIN supplied -> tell the user to pass one. The subclass lets the interactive picker
# catch "needs a PIN" specifically, while the CLI still catches it as DeviceCommandError.
fake = _fake_ble_stack(monkeypatch, BleakGATTProtocolError("Insufficient Authentication"))
@@ -302,6 +307,8 @@ async def test_create_ble_gives_up_after_the_retry(monkeypatch) -> None:
async def test_create_ble_never_retries_an_auth_rejection(monkeypatch) -> None:
"""A PIN/bond rejection is translated on the first attempt — never looped by the retry."""
# Off macOS, where a rejection is the *start* of an OS-run pairing and is waited on.
monkeypatch.setattr(sys, "platform", "win32")
fake = _fake_ble_stack(monkeypatch, BleakGATTProtocolError("Insufficient Authentication"))
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
await _disable_windows_pairing(dev)
@@ -310,6 +317,53 @@ async def test_create_ble_never_retries_an_auth_rejection(monkeypatch) -> None:
assert len(fake.built) == 1
async def test_create_ble_waits_for_macos_to_finish_pairing(monkeypatch) -> None:
"""On macOS the first auth rejection is the pairing *starting*, so the connect waits.
Touching the companion's authenticated characteristic is the only way to make
CoreBluetooth pair at all, so the rejection and the Passkey dialog are the same event.
Giving up there reported an error for a pairing that was succeeding, and the device
connected only when the user selected it a second time.
"""
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(connection, "_BLE_MACOS_PAIRING_DELAY_S", 0)
# Refused twice while the dialog is up, then the bond lands and the subscribe works.
fake = _fake_ble_stack(
monkeypatch,
BleakGATTProtocolError("Insufficient Authentication"),
BleakGATTProtocolError("Insufficient Authentication"),
"handshake-ok",
)
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
await _disable_windows_pairing(dev)
result = await dev._create_ble(fake)
assert result is fake.built[2] # the attempt made after the bond is what the caller gets
assert len(fake.built) == 3
assert fake.built[0].disconnect_calls == 1 # each refused link was released, not stranded
assert fake.built[1].disconnect_calls == 1
assert fake.built[2].disconnect_calls == 0 # the live one is handed over open
async def test_create_ble_gives_up_when_macos_pairing_is_dismissed(monkeypatch) -> None:
"""A dialog nobody answers still fails in the end, and says where the code is asked for."""
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(connection, "_BLE_MACOS_PAIRING_DELAY_S", 0)
refusals = [BleakGATTProtocolError("Insufficient Authentication")] * (
connection._BLE_MACOS_PAIRING_ATTEMPTS + 1
)
fake = _fake_ble_stack(monkeypatch, *refusals)
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
await _disable_windows_pairing(dev)
with pytest.raises(connection.DeviceAuthenticationError) as excinfo:
await dev._create_ble(fake)
assert len(fake.built) == connection._BLE_MACOS_PAIRING_ATTEMPTS + 1 # bounded, not endless
# --ble-pin cannot help on macOS: the OS collects the code, so don't send the reader there.
assert "--ble-pin" not in str(excinfo.value)
assert "System Settings" in str(excinfo.value)
async def test_connect_reports_an_unanswered_handshake_and_closes_it(monkeypatch) -> None:
"""Transport up but no identity reply: a clean "not a companion", link still released."""
fake = _fake_ble_stack(monkeypatch, None)
+89 -3
View File
@@ -263,6 +263,50 @@ async def test_discover_ble_survives_no_adapter(monkeypatch: pytest.MonkeyPatch)
monkeypatch.setattr(bleak, "BleakScanner", _BoomScanner, raising=False)
assert await discovery.discover_ble_devices(timeout=0.0) == []
# A hiccup is not something the reader can act on, so it leaves no reason behind.
assert discovery.ble_unavailable_reason() is None
async def test_discover_ble_records_a_refusal_the_user_can_fix(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A denied scan keeps bleak's sentence instead of reading as "nothing nearby".
On macOS this is the routine case, not an exotic one: Bluetooth is granted to the
terminal running MeshTerm, so an ungranted terminal — and every SSH session, which
macOS refuses to even prompt in — scans successfully and hears nothing at all.
"""
import bleak
import meshterm.core.discovery as discovery
not_available = pytest.importorskip("bleak.exc").BleakBluetoothNotAvailableError
class _DeniedScanner:
@staticmethod
async def discover(timeout: float, return_adv: bool):
raise not_available("Bluetooth access is denied by the user", 2)
monkeypatch.setattr(bleak, "BleakScanner", _DeniedScanner, raising=False)
assert await discovery.discover_ble_devices(timeout=0.0) == []
reason = discovery.ble_unavailable_reason()
assert reason is not None and "denied" in reason
# And it describes the latest attempt, never a stale one.
monkeypatch.setattr(bleak, "BleakScanner", _empty_scanner(), raising=False)
assert await discovery.discover_ble_devices(timeout=0.0) == []
assert discovery.ble_unavailable_reason() is None
def _empty_scanner():
"""A scanner that works and simply hears nothing — the "not a fault" case."""
class _Quiet:
@staticmethod
async def discover(timeout: float, return_adv: bool):
return {}
return _Quiet
# -- device store --------------------------------------------------------------
@@ -496,15 +540,57 @@ def test_resolve_bad_tcp_endpoint_raises() -> None:
def test_resolve_ambiguous_raises(tmp_path: Path) -> None:
"""Multiple devices with no usable default raise a guidance error."""
"""Two plausible companions with no usable default raise a guidance error."""
devices = [
DiscoveredDevice("COM3", serial_number="SN1"),
DiscoveredDevice("COM4", serial_number="SN2"),
DiscoveredDevice("COM3", vid=0x303A, pid=0x1001, serial_number="SN1"),
DiscoveredDevice("COM4", vid=0x10C4, pid=0xEA60, serial_number="SN2"),
]
with pytest.raises(DeviceSelectionError, match="Multiple companion devices"):
resolve_device(devices, None)
def test_resolve_ignores_implausible_ports_when_one_board_is_present() -> None:
"""One real board among ports that look like nothing still resolves to the board.
This is the macOS case: every Mac permanently presents two virtual ``/dev/cu.*``
ports with no USB VID/PID, so counting them meant a Mac with a single companion
attached saw three devices and refused to choose — auto-detection could never fire
on that platform.
"""
board = DiscoveredDevice("/dev/cu.usbmodem1101", vid=0x303A, pid=0x1001)
devices = [
board,
DiscoveredDevice("/dev/cu.Bluetooth-Incoming-Port"),
DiscoveredDevice("/dev/cu.debug-console"),
]
res = resolve_device(devices, None)
assert res.target == board.target and res.source == "only"
def test_resolve_lone_unrecognized_device_still_resolves() -> None:
"""A single adapter we can't place still connects — an unlisted VID is usually real."""
lone = DiscoveredDevice("COM9", vid=0x1234, pid=0x0001)
assert not lone.is_likely_lora
res = resolve_device([lone], None)
assert res.target == "COM9" and res.source == "only"
def test_resolve_several_implausible_ports_does_not_call_them_companions() -> None:
"""Ports that look like nothing are not reported as "multiple companion devices".
A bare Mac with no companion attached reaches here with its two virtual ports, and
being told it has several companions — and asked to pick one of them — is the least
true answer available.
"""
devices = [
DiscoveredDevice("/dev/cu.Bluetooth-Incoming-Port"),
DiscoveredDevice("/dev/cu.debug-console"),
]
with pytest.raises(DeviceSelectionError, match="No companion devices detected among") as err:
resolve_device(devices, None)
assert "Multiple companion devices" not in str(err.value)
def test_resolve_no_devices_raises() -> None:
"""No devices at all raises a clear error mentioning --mock."""
with pytest.raises(DeviceSelectionError, match="No companion devices"):
+80 -2
View File
@@ -767,7 +767,7 @@ def test_basemap_sends_its_user_agent_on_every_fetch(tmp_path: Path) -> None:
src = _offline_source(tmp_path / "cache")
seen: list[urllib.request.Request] = []
def _urlopen(req: urllib.request.Request, timeout: float | None = None):
def _urlopen(req: urllib.request.Request, timeout: float | None = None, context=None):
seen.append(req)
raise urllib.error.URLError("no network in tests")
@@ -782,6 +782,84 @@ def test_basemap_sends_its_user_agent_on_every_fetch(tmp_path: Path) -> None:
assert seen[0].get_header("User-agent") == basemap_mod._USER_AGENT
def test_basemap_keeps_the_default_tls_context_when_it_works() -> None:
"""A working platform trust store is left alone — urllib's default is passed through."""
from meshterm.services import basemap as basemap_mod
basemap_mod._tls_context.cache_clear()
try:
assert basemap_mod._tls_context() is None
finally:
basemap_mod._tls_context.cache_clear()
def test_basemap_finds_a_ca_bundle_when_the_default_store_is_empty(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""An empty default store falls back to the OS CA bundle instead of failing every fetch.
THE macOS bug: a frozen build asks OpenSSL for a trust store at a filesystem path baked
into the interpreter's own build, which need not exist on a machine that never
installed that Python — which is every machine a PyInstaller bundle lands on. Fetches
then failed with ``CERTIFICATE_VERIFY_FAILED``, and because a refused fetch is
indistinguishable from empty terrain the map drew no ground under the nodes and said
nothing. Windows hid it by reading the OS certificate store in its default context.
"""
import ssl
from meshterm.services import basemap as basemap_mod
# The file only has to exist: loading is recorded below rather than really parsed,
# so the test pins *which path is chosen* without depending on a PEM being around.
bundle = tmp_path / "cert.pem"
bundle.write_text("# stand-in for the OS bundle\n", encoding="utf-8")
loaded: list[str] = []
class _EmptyStore(ssl.SSLContext):
def get_ca_certs(self, binary_form: bool = False): # type: ignore[override]
return []
def load_verify_locations(self, cafile=None, capath=None, cadata=None): # type: ignore[override]
loaded.append(cafile or "")
monkeypatch.setattr(
ssl, "create_default_context", lambda *a, **k: _EmptyStore(ssl.PROTOCOL_TLS_CLIENT)
)
monkeypatch.setattr(basemap_mod, "_CA_BUNDLES", (str(tmp_path / "nope.pem"), str(bundle)))
basemap_mod._tls_context.cache_clear()
try:
ctx = basemap_mod._tls_context()
assert ctx is not None, "no context built despite a readable bundle"
assert loaded == [str(bundle)] # skipped the missing path, used the present one
assert ctx.verify_mode == ssl.CERT_REQUIRED # never verified away to make it work
finally:
basemap_mod._tls_context.cache_clear()
def test_basemap_never_disables_verification(monkeypatch: pytest.MonkeyPatch) -> None:
"""With no bundle anywhere, the fetch still verifies — it fails rather than trusts."""
import ssl
from meshterm.services import basemap as basemap_mod
class _EmptyStore(ssl.SSLContext):
def get_ca_certs(self, binary_form: bool = False): # type: ignore[override]
return []
monkeypatch.setattr(
ssl, "create_default_context", lambda *a, **k: _EmptyStore(ssl.PROTOCOL_TLS_CLIENT)
)
monkeypatch.setattr(basemap_mod, "_CA_BUNDLES", ("/nonexistent/ca.pem",))
basemap_mod._tls_context.cache_clear()
try:
assert basemap_mod._tls_context() is None # urllib's default, still verifying
finally:
basemap_mod._tls_context.cache_clear()
def test_basemap_user_agent_needs_no_installed_distribution() -> None:
"""Importing the module must not depend on package metadata being on disk.
@@ -1127,7 +1205,7 @@ def test_basemap_http_get_separates_an_answer_from_silence(tmp_path: Path) -> No
return False
def _fake(result):
def _urlopen(_req, timeout=None): # noqa: ANN001
def _urlopen(_req, timeout=None, context=None): # noqa: ANN001
if isinstance(result, Exception):
raise result
return result
+204
View File
@@ -23,12 +23,16 @@ from meshterm.core.connection import MockDevice
from meshterm.core.device_store import DeviceStore
from meshterm.core.models import Contact, utcnow
from meshterm.core.remote_config import (
DISCOVERED_HELP,
REPEATER_SETTINGS,
SettingCommand,
composite_reads,
discovered_setting,
get_setting,
known_commands,
normalize_value,
parse_reply_value,
parse_setting_command,
range_hint,
read_plan,
reply_is_error,
@@ -780,3 +784,203 @@ def test_admin_actions_start_every_label_in_the_same_cell() -> None:
starts[label] = cell_len(plain[: plain.index(label)])
assert set(starts) == set(labels)
assert len(set(starts.values())) == 1, f"a label starts a column early: {starts}"
# --- settings discovered on the command line -------------------------------------------
def test_only_get_and_set_are_read_as_settings() -> None:
"""The CLI's own grammar is the filter; every top-level verb falls out of it.
Nothing is excluded by name: ``reboot``, ``advert`` and ``powersaving`` are simply not
``get <key>`` or ``set <key> <value>``, and ``setperm`` only starts with the letters.
"""
assert parse_setting_command("get radio.rxps") == SettingCommand("get", "radio.rxps")
assert parse_setting_command(" set reboot.interval 24 ") == SettingCommand(
"set", "reboot.interval", "24"
)
assert parse_setting_command("set owner.info a b c") == SettingCommand(
"set", "owner.info", "a b c"
)
for command in (
"reboot",
"advert",
"clock sync",
"powersaving on",
"setperm ab12 rw",
"start ota",
"get", # no key
"set tx", # no value
"get ../etc", # not a key's spelling
):
assert parse_setting_command(command) is None, command
def test_a_write_the_node_accepts_is_proof_the_key_is_there(tui_ctx) -> None:
"""``set`` teaches a row: the firmware matches its keys exactly and refuses the rest.
``handleSetCmd`` compares ``"radio "`` *with* the trailing space, so a longer key can
never land in a shorter one's branch, and an unknown one answers ``unknown config:``.
"""
repeater_admin.learn_from_cli(tui_ctx, NODE, "set radio.rxps balanced", "OK")
cached = tui_ctx.remote_store.settings(NODE)["radio.rxps"]
assert (cached.value, cached.discovered) == ("balanced", True)
repeater_admin.learn_from_cli(tui_ctx, NODE, "set nonesuch 1", "unknown config: nonesuch")
assert "nonesuch" not in tui_ctx.remote_store.settings(NODE)
def test_a_read_answered_out_of_a_shorter_keys_branch_is_no_discovery(tui_ctx) -> None:
"""``get`` matches on a bare prefix, so a plausible reply can belong to another key.
Stock firmware answers ``get radio.rxps`` from the ``get radio`` branch. With no typing
to reject it — a discovered key has none — the guard is that the reply is exactly what
the shadowing key last said.
"""
for key, value in (("freq", "910.525"), ("bw", "62.5"), ("sf", "7"), ("cr", "5")):
tui_ctx.remote_store.remember_setting(NODE, key, value)
repeater_admin.learn_from_cli(tui_ctx, NODE, "get radio.rxps", "> 910.525,62.5,7,5")
assert "radio.rxps" not in tui_ctx.remote_store.settings(NODE)
repeater_admin.learn_from_cli(tui_ctx, NODE, "get radio.rxps", "> desired=off level=2")
assert tui_ctx.remote_store.settings(NODE)["radio.rxps"].value == "desired=off level=2"
def test_a_read_nothing_shadows_is_taken_at_its_word(tui_ctx) -> None:
"""No catalog key prefixes ``reboot.interval``, so its reply can only be its own."""
repeater_admin.learn_from_cli(tui_ctx, NODE, "get reboot.interval", "> 24")
assert tui_ctx.remote_store.settings(NODE)["reboot.interval"].value == "24"
repeater_admin.learn_from_cli(tui_ctx, NODE, "get nonesuch", "??: nonesuch")
assert "nonesuch" not in tui_ctx.remote_store.settings(NODE)
def test_an_unread_shadow_leaves_the_question_unanswerable(tui_ctx) -> None:
"""A shadowed key whose shadow was never read is not discovered: we cannot tell."""
repeater_admin.learn_from_cli(tui_ctx, NODE, "get radio.rxps", "> 910.525,62.5,7,5")
assert "radio.rxps" not in tui_ctx.remote_store.settings(NODE)
def test_the_private_key_is_never_written_to_disk(tui_ctx, tmp_path: Path) -> None:
"""``get prv.key`` prints in the transcript and goes no further."""
secret = "ab" * 32
repeater_admin.learn_from_cli(tui_ctx, NODE, "get prv.key", "> " + secret)
assert "prv.key" not in tui_ctx.remote_store.settings(NODE)
written = [p for p in tmp_path.rglob("*.json") if secret in p.read_text(encoding="utf-8")]
assert written == []
def test_a_catalog_key_read_on_the_command_line_refreshes_its_row(tui_ctx) -> None:
"""The cache is the page, so the command line fills it exactly as a read does.
``get tx`` used to leave the TX power row showing what it said last week, and
``get radio`` answered four rows that none of them heard about.
"""
repeater_admin.learn_from_cli(tui_ctx, NODE, "get tx", "> 20")
repeater_admin.learn_from_cli(tui_ctx, NODE, "get radio", "> 869.525,250,10,6")
repeater_admin.learn_from_cli(tui_ctx, NODE, "set txdelay 1.5", "OK")
cache = tui_ctx.remote_store.settings(NODE)
assert cache["tx"].value == "20"
assert (cache["freq"].value, cache["sf"].value) == ("869.525", "10")
assert cache["txdelay"].value == "1.5"
assert not any(cached.discovered for cached in cache.values())
def test_a_write_stales_the_other_spelling_from_the_command_line_too(tui_ctx) -> None:
"""``set dutycycle`` at the prompt drops the cached ``af``, as an Apply would."""
tui_ctx.remote_store.remember_setting(NODE, "af", "1.00")
repeater_admin.learn_from_cli(tui_ctx, NODE, "set dutycycle 40", "OK - 40.0%")
cache = tui_ctx.remote_store.settings(NODE)
assert cache["dutycycle"].value == "40.0" and "af" not in cache
def test_no_reply_can_remove_a_discovered_row(tui_ctx) -> None:
"""A read may only ever take a discovered row to ``n/a``; removing it is a decision.
Its key is recorded nowhere but here, so a reply that misreads — a truncated line, a
node answering mid-reboot, a frame correlated to the wrong command — must not be able
to delete the row. A catalog row costs nothing when that happens, the catalog still
naming the key; this one would cost the reader something only they can restore.
"""
spec = discovered_setting("radio.rxps")
tui_ctx.remote_store.remember_discovered(NODE, "radio.rxps", "off")
repeater_admin.remember_reply(tui_ctx, NODE, [spec], "> balanced")
cached = tui_ctx.remote_store.settings(NODE)["radio.rxps"]
assert (cached.value, cached.discovered) == ("balanced", True)
repeater_admin.remember_reply(tui_ctx, NODE, [spec], "??: radio.rxps")
cache = tui_ctx.remote_store.settings(NODE)
assert cache["radio.rxps"].discovered and not cache["radio.rxps"].supported
assert repeater_admin._value_text(spec, cache, {}).plain == "n/a"
assert "radio.rxps" in repeater_admin._extra_keys(cache), "still the reader's to forget"
def test_discovered_rows_draw_last_in_their_own_section_and_are_deletable() -> None:
"""A node's own settings sit under ``Extra`` — and only they answer Del."""
from meshterm.ui.tui import Choice
plain = {"txdelay": CachedValue("0.5", utcnow())}
catalog_rows = [
item
for item in repeater_admin._menu_items(NODE, plain, {})[1]
if isinstance(item, Choice) and item.deletable
]
assert catalog_rows == [], "no catalog row may be forgotten"
cache = dict(plain, **{"radio.rxps": CachedValue("balanced", utcnow(), discovered=True)})
items = repeater_admin._menu_items(NODE, cache, {})[1]
deletable = [i for i in items if isinstance(i, Choice) and i.deletable]
assert [i.value for i in deletable] == ["radio.rxps"]
rows = [i for i in items if isinstance(i, Choice) and isinstance(i.value, str)]
settings_rows = [r for r in rows if repeater_admin.get_setting(r.value) or r.deletable]
assert settings_rows[-1].value == "radio.rxps", "the node's own settings come last"
assert DISCOVERED_HELP in settings_rows[-1].title.plain
def test_a_staged_discovered_value_is_written_with_its_own_set() -> None:
"""A key the catalog hasn't got is still written the one way anything can write it."""
writes = write_plan({"radio.rxps": "balanced", "txdelay": "1.5"}, {})
assert [w.command for w in writes] == ["set txdelay 1.5", "set radio.rxps balanced"]
def test_a_long_discovered_value_cannot_widen_the_whole_page() -> None:
"""The value lane is one width for every row, so a node's own setting may not set it.
Some builds answer ``get radio.rxps`` with a whole diagnostic line. Unbounded, that one
row would push every catalog setting's description right and off a 72-column screen —
reshaping a page the reader opened to read the catalog.
"""
from rich.cells import cell_len
from meshterm.ui.tui import Choice
def widest(cache) -> int:
items = repeater_admin._menu_items(NODE, cache, {})[1]
return max(
cell_len(i.title.plain)
for i in items
if isinstance(i, Choice) and hasattr(i.title, "plain")
)
catalog = {
"tx": CachedValue("20", utcnow()),
"owner.info": CachedValue("Jean-Pierre, Montreal", utcnow()),
}
chatty = dict(
catalog,
**{
"radio.rxps": CachedValue(
"desired=off effective=off supported=yes level=0 preamble=16 rx=0 sleep=0",
utcnow(),
discovered=True,
)
},
)
assert widest(chatty) == widest(catalog)
fitted = repeater_admin._extra_value("a value far too long for its lane", 14)
assert cell_len(fitted) == 14
assert repeater_admin._extra_value(" balanced\n", 14) == "balanced"
+38
View File
@@ -504,6 +504,44 @@ def test_every_mark_is_as_wide_as_its_name_says() -> None:
assert logo_width(_rows(name)) == declared, f"{name} is not {declared} cells wide"
def test_the_mark_says_bright_as_a_colour_not_as_bold() -> None:
"""No span of the wordmark depends on the terminal reading bold as brightness.
An art editor spells brightness the DOS way, ``1m`` lifting the foreground into the
bright bank. That is true of the PicoCalc console and of a DOS one -- and false on
macOS Terminal, where bold asks for a heavier face and leaves the colour alone. There
``1;30`` is not dark grey but plain black, and the wide mark carries seventy-nine such
spans, much of it on a black ground: the mark drew muted, and the dithers that fade one
colour into another faded toward the wrong end. So the loader emits the bright bank as
the colour it means (``9N``), which cannot be mistaken for a font weight.
"""
import re
from meshterm.ui.logo import _NAMED_WIDTH, _rows, _variants, logo_width
sgr = re.compile(chr(27) + r"\[([0-9;]*)m")
for name in _variants():
rows = _rows(name)
codes = [m.group(1) for row in rows for m in sgr.finditer(row)]
assert codes, f"{name} carries no colour at all"
params = [p for code in codes for p in code.split(";")]
# Nothing asks for bold: brightness travels as 90-97 instead.
assert "1" not in params, f"{name} still leans on bold for brightness"
assert any(p.isdigit() and 90 <= int(p) <= 97 for p in params), (
f"{name} lost its bright spans entirely"
)
# A foreground on the dim bank still says so outright, so it cannot inherit a bank
# from whatever the row above left set (each row reaches Rich on its own).
for code in codes:
parts = code.split(";")
if any(re.fullmatch(r"3[0-7]", p) for p in parts):
assert "22" in parts, f"{name} has a dim foreground that never says so: {code}"
# And the spelling change moves no cells: the art measures exactly as it did.
assert logo_width(rows) == int(_NAMED_WIDTH.search(name).group(1))
def test_the_mark_the_console_picks_stays_inside_its_font() -> None:
"""The wordmark obeys the glyph contract every other screen does.
+55
View File
@@ -1323,6 +1323,61 @@ def test_device_picker_builds_aligned_columns(tmp_path) -> None:
assert device_rows[0].index("COM5") == device_rows[1].index("/dev/ttyUSB0")
def test_device_picker_address_lane_is_the_one_that_adapts() -> None:
"""DEVICE shows its name in full; PORT / ADDRESS takes whatever is left.
A name is what the reader came to read and recognises their own radio by; an address
is a disambiguator, and does that job from its last ten cells. macOS is what forced
the question — CoreBluetooth reports no MAC but a per-machine 36-cell UUID, and even
its ports run long — which sized the lane to 36 and left DEVICE the width of its own
heading.
"""
from rich.cells import cell_len
from meshterm.core.discovery import DiscoveredDevice
from meshterm.ui.device_picker import _ADDRESS_MIN, _fit_target, _lane_widths
name = "MeshCore-Johnputer Wardriver"
uuid = "12345678-1234-1234-1234-123456789ABC"
mac_devices = [
DiscoveredDevice(transport="ble", address="C6:B6:26:BC:7F:09", name=name, product=name),
DiscoveredDevice("COM3", product="Some Adapter", vid=0x1234),
]
uuid_devices = [
DiscoveredDevice(transport="ble", address=uuid, name=name, product=name),
DiscoveredDevice("/dev/cu.Bluetooth-Incoming-Port"),
]
# The name is shown whole on both, and a 36-cell address does not shrink it: the lane
# that gives is the address, and it never drops below the floor that keeps it useful.
for devices in (mac_devices, uuid_devices):
name_w, port_w = _lane_widths(devices, {})
assert name_w == cell_len(name)
assert port_w >= _ADDRESS_MIN
assert _lane_widths(mac_devices, {})[0] == _lane_widths(uuid_devices, {})[0]
# A target that fits is untouched, and a long one keeps its tail -- which is what tells
# two of them apart, and the whole reason the cut takes the front.
_, port_w = _lane_widths(uuid_devices, {})
assert _fit_target("COM3", port_w) == "COM3"
assert _fit_target(uuid, port_w).endswith("9ABC")
assert cell_len(_fit_target(uuid, port_w)) <= port_w
assert _fit_target("/dev/cu.usbmodem1101", 14) != _fit_target("/dev/cu.usbmodem1102", 14)
# Nothing is hoarded: a lone short port hands its slack back to the name.
short = [DiscoveredDevice("COM5", product="Wio SX1262", vid=0x2886)]
assert _lane_widths(short, {})[1] == cell_len("COM5")
# And where the name cannot fit whatever happens, the address is left whole rather
# than ellipsized alongside it -- one cut in the row instead of two.
sprawling = [
DiscoveredDevice("/dev/ttyUSB0", product="CP2102 USB to UART Bridge Controller", vid=0x10C4)
]
name_w, port_w = _lane_widths(sprawling, {})
assert port_w == cell_len("/dev/ttyUSB0")
assert name_w < cell_len("CP2102 USB to UART Bridge Controller")
def test_device_picker_names_and_sorts_known_devices(tmp_path) -> None:
"""A confirmed device shows its node name (in white), sorts to the top, and marks type."""
from meshterm.core.device_store import DeviceStore