Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aed1d91484 | ||
|
|
2e40603a64 | ||
|
|
9a58bc04bb | ||
|
|
b1f21664ff | ||
|
|
1933d725b0 | ||
|
|
730230461c | ||
|
|
71ce60713f | ||
|
|
add17b49d6 | ||
|
|
fc4406ba86 | ||
|
|
a7d201f4da | ||
|
|
be7e588c3a | ||
|
|
c7c1770d96 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user